diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 457e1998..356c620d 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -13,6 +13,7 @@ export interface CompanyPortabilityEnvInput { key: string; description: string | null; agentSlug: string | null; + projectSlug: string | null; kind: "secret" | "plain"; requirement: "required" | "optional"; defaultValue: string | null; diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 97004b35..952c9cce 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -15,6 +15,7 @@ export const portabilityEnvInputSchema = z.object({ key: z.string().min(1), description: z.string().nullable(), agentSlug: z.string().min(1).nullable(), + projectSlug: z.string().min(1).nullable(), kind: z.enum(["secret", "plain"]), requirement: z.enum(["required", "optional"]), defaultValue: z.string().nullable(), diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index d14ae8c0..b8bf822d 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1149,6 +1149,7 @@ describe("company portability", () => { key: "ANTHROPIC_API_KEY", description: "Provide ANTHROPIC_API_KEY for agent claudecoder", agentSlug: "claudecoder", + projectSlug: null, kind: "secret", requirement: "optional", defaultValue: "", @@ -1158,6 +1159,7 @@ describe("company portability", () => { key: "GH_TOKEN", description: "Provide GH_TOKEN for agent claudecoder", agentSlug: "claudecoder", + projectSlug: null, kind: "secret", requirement: "optional", defaultValue: "", @@ -1166,6 +1168,128 @@ describe("company portability", () => { ]); }); + it("exports project env as portable inputs without concrete values", async () => { + const portability = companyPortabilityService({} as any); + + projectSvc.list.mockResolvedValue([ + { + id: "project-1", + name: "Launch", + urlKey: "launch", + description: "Ship it", + leadAgentId: "agent-1", + targetDate: null, + color: null, + status: "planned", + env: { + OPENAI_API_KEY: { + type: "plain", + value: "sk-project-secret", + }, + DOCS_MODE: { + type: "plain", + value: "strict", + }, + GITHUB_TOKEN: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + version: "latest", + }, + }, + executionWorkspacePolicy: null, + workspaces: [], + metadata: null, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: false, + agents: false, + projects: true, + issues: false, + }, + }); + + const extension = asTextFile(exported.files[".paperclip.yaml"]); + expect(extension).toContain("OPENAI_API_KEY:"); + expect(extension).toContain("DOCS_MODE:"); + expect(extension).toContain("GITHUB_TOKEN:"); + expect(extension).not.toContain("sk-project-secret"); + expect(extension).not.toContain('type: "secret_ref"'); + expect(extension).not.toContain("11111111-1111-1111-1111-111111111111"); + expect(extension).toContain('default: "strict"'); + expect(extension).toContain('kind: "secret"'); + expect(extension).toContain('kind: "plain"'); + }); + + it("reads project env inputs back from .paperclip.yaml during preview import", async () => { + const portability = companyPortabilityService({} as any); + + projectSvc.list.mockResolvedValue([ + { + id: "project-1", + name: "Launch", + urlKey: "launch", + description: "Ship it", + leadAgentId: "agent-1", + targetDate: null, + color: null, + status: "planned", + env: { + OPENAI_API_KEY: { + type: "plain", + value: "sk-project-secret", + }, + }, + executionWorkspacePolicy: null, + workspaces: [], + metadata: null, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: false, + agents: false, + projects: true, + issues: false, + }, + }); + + const preview = await portability.previewImport({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: false, + agents: false, + projects: true, + issues: false, + }, + target: { + mode: "new_company", + newCompanyName: "Imported Paperclip", + }, + agents: "all", + collisionStrategy: "rename", + }); + + expect(preview.errors).toEqual([]); + expect(preview.envInputs).toContainEqual({ + key: "OPENAI_API_KEY", + description: "Optional default for OPENAI_API_KEY on project launch", + agentSlug: null, + projectSlug: "launch", + kind: "secret", + requirement: "optional", + defaultValue: "", + portability: "portable", + }); + }); + it("exports routines as recurring task packages with Paperclip routine extensions", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index b133411b..1c0fc90e 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -394,6 +394,83 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null { return parsed.success ? parsed.data : null; } +function extractPortableScopedEnvInputs( + scope: { + label: string; + warningPrefix: string; + agentSlug: string | null; + projectSlug: string | null; + }, + envValue: unknown, + warnings: string[], +): CompanyPortabilityEnvInput[] { + if (!isPlainRecord(envValue)) return []; + const env = envValue as Record; + const inputs: CompanyPortabilityEnvInput[] = []; + + for (const [key, binding] of Object.entries(env)) { + if (key.toUpperCase() === "PATH") { + warnings.push(`${scope.warningPrefix} PATH override was omitted from export because it is system-dependent.`); + continue; + } + + if (isPlainRecord(binding) && binding.type === "secret_ref") { + inputs.push({ + key, + description: `Provide ${key} for ${scope.label}`, + agentSlug: scope.agentSlug, + projectSlug: scope.projectSlug, + kind: "secret", + requirement: "optional", + defaultValue: "", + portability: "portable", + }); + continue; + } + + if (isPlainRecord(binding) && binding.type === "plain") { + const defaultValue = asString(binding.value); + const isSensitive = isSensitiveEnvKey(key); + const portability = defaultValue && isAbsoluteCommand(defaultValue) + ? "system_dependent" + : "portable"; + if (portability === "system_dependent") { + warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`); + } + inputs.push({ + key, + description: `Optional default for ${key} on ${scope.label}`, + agentSlug: scope.agentSlug, + projectSlug: scope.projectSlug, + kind: isSensitive ? "secret" : "plain", + requirement: "optional", + defaultValue: isSensitive ? "" : defaultValue ?? "", + portability, + }); + continue; + } + + if (typeof binding === "string") { + const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable"; + if (portability === "system_dependent") { + warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`); + } + inputs.push({ + key, + description: `Optional default for ${key} on ${scope.label}`, + agentSlug: scope.agentSlug, + projectSlug: scope.projectSlug, + kind: isSensitiveEnvKey(key) ? "secret" : "plain", + requirement: "optional", + defaultValue: isSensitiveEnvKey(key) ? "" : binding, + portability, + }); + } + } + + return inputs; +} + type ResolvedSource = { manifest: CompanyPortabilityManifest; files: Record; @@ -1536,68 +1613,33 @@ function extractPortableEnvInputs( envValue: unknown, warnings: string[], ): CompanyPortabilityEnvInput[] { - if (!isPlainRecord(envValue)) return []; - const env = envValue as Record; - const inputs: CompanyPortabilityEnvInput[] = []; + return extractPortableScopedEnvInputs( + { + label: `agent ${agentSlug}`, + warningPrefix: `Agent ${agentSlug}`, + agentSlug, + projectSlug: null, + }, + envValue, + warnings, + ); +} - for (const [key, binding] of Object.entries(env)) { - if (key.toUpperCase() === "PATH") { - warnings.push(`Agent ${agentSlug} PATH override was omitted from export because it is system-dependent.`); - continue; - } - - if (isPlainRecord(binding) && binding.type === "secret_ref") { - inputs.push({ - key, - description: `Provide ${key} for agent ${agentSlug}`, - agentSlug, - kind: "secret", - requirement: "optional", - defaultValue: "", - portability: "portable", - }); - continue; - } - - if (isPlainRecord(binding) && binding.type === "plain") { - const defaultValue = asString(binding.value); - const isSensitive = isSensitiveEnvKey(key); - const portability = defaultValue && isAbsoluteCommand(defaultValue) - ? "system_dependent" - : "portable"; - if (portability === "system_dependent") { - warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`); - } - inputs.push({ - key, - description: `Optional default for ${key} on agent ${agentSlug}`, - agentSlug, - kind: isSensitive ? "secret" : "plain", - requirement: "optional", - defaultValue: isSensitive ? "" : defaultValue ?? "", - portability, - }); - continue; - } - - if (typeof binding === "string") { - const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable"; - if (portability === "system_dependent") { - warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`); - } - inputs.push({ - key, - description: `Optional default for ${key} on agent ${agentSlug}`, - agentSlug, - kind: isSensitiveEnvKey(key) ? "secret" : "plain", - requirement: "optional", - defaultValue: binding, - portability, - }); - } - } - - return inputs; +function extractPortableProjectEnvInputs( + projectSlug: string, + envValue: unknown, + warnings: string[], +): CompanyPortabilityEnvInput[] { + return extractPortableScopedEnvInputs( + { + label: `project ${projectSlug}`, + warningPrefix: `Project ${projectSlug}`, + agentSlug: null, + projectSlug, + }, + envValue, + warnings, + ); } function jsonEqual(left: unknown, right: unknown): boolean { @@ -2183,7 +2225,7 @@ function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) { const seen = new Set(); const out: CompanyPortabilityManifest["envInputs"] = []; for (const value of values) { - const key = `${value.agentSlug ?? ""}:${value.key.toUpperCase()}`; + const key = `${value.agentSlug ?? ""}:${value.projectSlug ?? ""}:${value.key.toUpperCase()}`; if (seen.has(key)) continue; seen.add(key); out.push(value); @@ -2240,6 +2282,31 @@ function readAgentEnvInputs( key, description: asString(record.description) ?? null, agentSlug, + projectSlug: null, + kind: record.kind === "plain" ? "plain" : "secret", + requirement: record.requirement === "required" ? "required" : "optional", + defaultValue: typeof record.default === "string" ? record.default : null, + portability: record.portability === "system_dependent" ? "system_dependent" : "portable", + }]; + }); +} + +function readProjectEnvInputs( + extension: Record, + projectSlug: string, +): CompanyPortabilityManifest["envInputs"] { + const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null; + const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null; + if (!env) return []; + + return Object.entries(env).flatMap(([key, value]) => { + if (!isPlainRecord(value)) return []; + const record = value as EnvInputRecord; + return [{ + key, + description: asString(record.description) ?? null, + agentSlug: null, + projectSlug, kind: record.kind === "plain" ? "plain" : "secret", requirement: record.requirement === "required" ? "required" : "optional", defaultValue: typeof record.default === "string" ? record.default : null, @@ -2546,6 +2613,7 @@ function buildManifestFromPackageFiles( workspaces, metadata: isPlainRecord(extension.metadata) ? extension.metadata : null, }); + manifest.envInputs.push(...readProjectEnvInputs(extension, slug)); if (frontmatter.kind && frontmatter.kind !== "project") { warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`); } @@ -3153,6 +3221,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const project of selectedProjectRows) { const slug = projectSlugById.get(project.id)!; const projectPath = `projects/${slug}/PROJECT.md`; + const envInputsStart = envInputs.length; + const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings); + envInputs.push(...exportedEnvInputs); + const projectEnvInputs = dedupeEnvInputs( + envInputs + .slice(envInputsStart) + .filter((inputValue) => inputValue.projectSlug === slug), + ); const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings); projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById); files[projectPath] = buildMarkdown( @@ -3168,7 +3244,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { targetDate: project.targetDate ?? null, color: project.color ?? null, status: project.status, - env: normalizePortableProjectEnv(project.env) ?? undefined, executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy( slug, project.executionWorkspacePolicy, @@ -3177,6 +3252,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ) ?? undefined, workspaces: portableWorkspaces.extension, }); + if (isPlainRecord(extension) && projectEnvInputs.length > 0) { + extension.inputs = { + env: buildEnvInputMap(projectEnvInputs), + }; + } paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {}; } @@ -3516,7 +3596,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const envInput of manifest.envInputs) { if (envInput.portability === "system_dependent") { - warnings.push(`Environment input ${envInput.key}${envInput.agentSlug ? ` for ${envInput.agentSlug}` : ""} is system-dependent and may need manual adjustment after import.`); + const scope = envInput.agentSlug + ? ` for agent ${envInput.agentSlug}` + : envInput.projectSlug + ? ` for project ${envInput.projectSlug}` + : ""; + warnings.push(`Environment input ${envInput.key}${scope} is system-dependent and may need manual adjustment after import.`); } }