diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index d398cfbdeb2..810b93d27c0 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -294,6 +294,7 @@ export interface CompanyPortabilityAdapterOverride { export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest { adapterOverrides?: Record; + secretValues?: Record; } export interface CompanyPortabilityImportResult { diff --git a/packages/shared/src/types/teams-catalog.ts b/packages/shared/src/types/teams-catalog.ts index 51d852b13dd..5ae79708dcf 100644 --- a/packages/shared/src/types/teams-catalog.ts +++ b/packages/shared/src/types/teams-catalog.ts @@ -144,6 +144,7 @@ export interface CatalogTeamImportOptions { export interface CatalogTeamInstallOptions extends CatalogTeamImportOptions { adapterOverrides?: Record; + secretValues?: Record; } export type CatalogTeamSkillPreparationAction = diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 8b369ae819c..d6d81d964e2 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -256,6 +256,7 @@ export const portabilityAdapterOverrideSchema = z.object({ export const companyPortabilityImportSchema = companyPortabilityPreviewSchema.extend({ adapterOverrides: z.record(z.string().min(1), portabilityAdapterOverrideSchema).optional(), + secretValues: z.record(z.string().min(1), z.string()).optional(), }); export type CompanyPortabilityImport = z.infer; diff --git a/packages/shared/src/validators/teams-catalog.ts b/packages/shared/src/validators/teams-catalog.ts index c58ba9fe5a1..a61945cfea6 100644 --- a/packages/shared/src/validators/teams-catalog.ts +++ b/packages/shared/src/validators/teams-catalog.ts @@ -146,6 +146,7 @@ export const catalogTeamPreviewSchema = z.object({ export const catalogTeamInstallSchema = catalogTeamPreviewSchema.extend({ adapterOverrides: z.record(z.string().min(1), portabilityAdapterOverrideSchema).optional(), + secretValues: z.record(z.string().min(1), z.string()).optional(), }); export const catalogTeamSkillPreparationSchema = z.object({ diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 52a3048dfa2..e3648b30988 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -63,6 +63,7 @@ const assetSvc = { }; const secretSvc = { + create: vi.fn(async () => ({ id: "secret-created" })), normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: Record) => env), syncEnvBindingsForTarget: vi.fn(async () => []), @@ -131,6 +132,7 @@ describe("company portability", () => { beforeEach(() => { vi.clearAllMocks(); + secretSvc.create.mockResolvedValue({ id: "secret-created" }); secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config); secretSvc.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); secretSvc.syncEnvBindingsForTarget.mockResolvedValue([]); @@ -1392,6 +1394,113 @@ describe("company portability", () => { ]); }); + it("materializes required agent env inputs from import secretValues as company secrets", async () => { + const portability = companyPortabilityService({} as any); + agentSvc.list.mockResolvedValue([]); + agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ + id: "agent-imported", + name: input.name, + adapterType: input.adapterType, + adapterConfig: input.adapterConfig, + status: input.status, + })); + + await portability.importBundle({ + source: { + type: "inline", + files: { + "COMPANY.md": [ + "---", + "name: Import", + "includes:", + " - agents/coder/AGENTS.md", + "---", + "", + ].join("\n"), + "agents/coder/AGENTS.md": [ + "---", + "name: Coder", + "slug: coder", + "kind: agent", + "---", + "", + "# Coder", + "", + ].join("\n"), + ".paperclip.yaml": [ + "schema: paperclip/v1", + "agents:", + " coder:", + " adapter:", + " type: codex_local", + " config: {}", + " inputs:", + " env:", + " OPENAI_API_KEY:", + " kind: secret", + " requirement: required", + "", + ].join("\n"), + }, + }, + include: { + company: false, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "existing_company", + companyId: "company-1", + }, + collisionStrategy: "rename", + secretValues: { + "agent:coder:OPENAI_API_KEY": "sk-imported", + }, + }, "user-1"); + + expect(secretSvc.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + provider: "local_encrypted", + value: "sk-imported", + description: expect.stringContaining("OPENAI_API_KEY"), + }), + { userId: "user-1", agentId: null }, + ); + expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + env: { + OPENAI_API_KEY: { + type: "secret_ref", + secretId: "secret-created", + version: "latest", + }, + }, + }), + { strictMode: false }, + ); + expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + adapterConfig: expect.objectContaining({ + env: { + OPENAI_API_KEY: { + type: "secret_ref", + secretId: "secret-created", + version: "latest", + }, + }, + }), + })); + expect(secretSvc.syncEnvBindingsForTarget).toHaveBeenCalledWith( + "company-1", + { targetType: "agent", targetId: "agent-imported" }, + expect.objectContaining({ + OPENAI_API_KEY: expect.objectContaining({ secretId: "secret-created" }), + }), + ); + }); + it("exports project env as portable inputs without concrete values", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/__tests__/teams-catalog-routes.test.ts b/server/src/__tests__/teams-catalog-routes.test.ts index 5734160b6b9..54131b7acd5 100644 --- a/server/src/__tests__/teams-catalog-routes.test.ts +++ b/server/src/__tests__/teams-catalog-routes.test.ts @@ -204,7 +204,10 @@ describe("teams catalog routes", () => { const res = await request(app) .post(`/api/companies/${companyId}/teams/catalog/product-engineering/install`) - .send({ collisionStrategy: "rename" }); + .send({ + collisionStrategy: "rename", + secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" }, + }); expect(res.status, JSON.stringify(res.body)).toBe(201); expect(mockTeamsCatalogService.installCatalogTeam).toHaveBeenCalledWith( @@ -212,6 +215,7 @@ describe("teams catalog routes", () => { "product-engineering", expect.objectContaining({ collisionStrategy: "rename", + secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" }, actor: expect.objectContaining({ actorType: "agent", actorId: "agent-1", diff --git a/server/src/__tests__/teams-catalog-service.test.ts b/server/src/__tests__/teams-catalog-service.test.ts index 14cc19da9ec..52ff4d52cc5 100644 --- a/server/src/__tests__/teams-catalog-service.test.ts +++ b/server/src/__tests__/teams-catalog-service.test.ts @@ -112,6 +112,22 @@ describe("teamsCatalogService", () => { ); }); + it("passes install secretValues through to company portability import", async () => { + const svc = teamsCatalogService({} as any); + + await svc.installCatalogTeam("company-1", "core-exec-team", { + secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" }, + }); + + expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith( + expect.objectContaining({ + secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" }, + }), + null, + { mode: "agent_safe", sourceCompanyId: "company-1" }, + ); + }); + it("classifies unresolved and unsafe external skill requirements as blocked", () => { const fakeTeam: CatalogTeam = { id: "paperclipai:optional:test:unsafe", diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 51d7db31d0b..c370bbfa941 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import { execFile } from "node:child_process"; import path from "node:path"; @@ -70,6 +70,7 @@ import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; import { routineService } from "./routines.js"; import { secretService } from "./secrets.js"; +import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; import { PORTABLE_CATALOG_PROVENANCE_STRING_KEYS, readCatalogStringList, @@ -2412,6 +2413,63 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) { return env; } +function envInputScopedKey(input: CompanyPortabilityEnvInput) { + if (input.agentSlug) return `agent:${input.agentSlug}:${input.key}`; + if (input.projectSlug) return `project:${input.projectSlug}:${input.key}`; + return input.key; +} + +function envInputValue(input: CompanyPortabilityEnvInput, values: Record | null | undefined) { + if (!values) return null; + const scopedKey = envInputScopedKey(input); + if (Object.prototype.hasOwnProperty.call(values, scopedKey)) return values[scopedKey]; + if (Object.prototype.hasOwnProperty.call(values, input.key)) return values[input.key]; + return null; +} + +function importSecretLabel(input: CompanyPortabilityEnvInput) { + const scope = input.agentSlug + ? `agent ${input.agentSlug}` + : input.projectSlug + ? `project ${input.projectSlug}` + : "company import"; + return `${scope} ${input.key}`; +} + +function importSecretKey(input: CompanyPortabilityEnvInput, suffix: string) { + const scope = input.agentSlug + ? `agent-${input.agentSlug}` + : input.projectSlug + ? `project-${input.projectSlug}` + : "company"; + return `import-${scope}-${input.key}-${suffix}`; +} + +function writeManifestEnvBinding( + manifest: CompanyPortabilityManifest, + input: CompanyPortabilityEnvInput, + binding: AgentEnvConfig[string], +) { + if (input.agentSlug) { + const agent = manifest.agents.find((entry) => entry.slug === input.agentSlug); + if (!agent) return; + const adapterConfig = isPlainRecord(agent.adapterConfig) ? agent.adapterConfig : {}; + const env = isPlainRecord(adapterConfig.env) ? { ...adapterConfig.env } : {}; + env[input.key] = binding; + agent.adapterConfig = { ...adapterConfig, env }; + return; + } + + if (input.projectSlug) { + const project = manifest.projects.find((entry) => entry.slug === input.projectSlug); + if (!project) return; + project.env = { + ...(project.env ?? {}), + [input.key]: binding, + }; + } +} + function readCompanyApprovalDefault(_frontmatter: Record) { return false; } @@ -2925,6 +2983,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const companySkills = companySkillService(db); const secrets = secretService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + const defaultSecretProvider = getConfiguredSecretProvider(); function assertKnownImportAdapterType(type: string | null | undefined): string { const adapterType = typeof type === "string" ? type.trim() : ""; @@ -2983,6 +3042,56 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { }; } + async function materializeImportEnvInputValues( + companyId: string, + manifest: CompanyPortabilityManifest, + envInputs: CompanyPortabilityEnvInput[], + secretValues: Record | null | undefined, + actorUserId: string | null | undefined, + ) { + if (envInputs.length === 0) return; + const missingRequired = envInputs.filter((input) => { + if (input.requirement !== "required") return false; + const value = envInputValue(input, secretValues); + return value === null || value.trim().length === 0; + }); + if (missingRequired.length > 0) { + throw unprocessable(`Required environment values are missing: ${missingRequired.map(envInputScopedKey).join(", ")}`); + } + + for (const input of envInputs) { + const value = envInputValue(input, secretValues); + if (value === null || value.trim().length === 0) continue; + + if (input.kind === "plain") { + writeManifestEnvBinding(manifest, input, { + type: "plain", + value, + }); + continue; + } + + const suffix = randomUUID().slice(0, 8); + const label = importSecretLabel(input); + const secret = await secrets.create( + companyId, + { + name: `Imported ${label} ${suffix}`, + key: importSecretKey(input, suffix), + provider: defaultSecretProvider, + value, + description: input.description ?? `Imported ${input.key} for ${label}.`, + }, + { userId: actorUserId ?? null, agentId: null }, + ); + writeManifestEnvBinding(manifest, input, { + type: "secret_ref", + secretId: secret.id, + version: "latest", + }); + } + } + function resolveImportedAssigneeAgentId( assigneeSlug: string | null | undefined, importedSlugToAgentId: Map, @@ -4235,6 +4344,33 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (!targetCompany) throw notFound("Target company not found"); + const importedAgentEnvSlugs = new Set( + plan.preview.plan.agentPlans + .filter((entry) => entry.action !== "skip") + .map((entry) => entry.slug), + ); + const importedProjectEnvSlugs = new Set( + plan.preview.plan.projectPlans + .filter((entry) => entry.action !== "skip") + .map((entry) => entry.slug), + ); + const importEnvInputs = (sourceManifest.envInputs ?? []).filter((inputValue) => { + if (inputValue.agentSlug) { + return include.agents && importedAgentEnvSlugs.has(inputValue.agentSlug); + } + if (inputValue.projectSlug) { + return include.projects && importedProjectEnvSlugs.has(inputValue.projectSlug); + } + return true; + }); + await materializeImportEnvInputValues( + targetCompany.id, + sourceManifest, + importEnvInputs, + input.secretValues, + actorUserId, + ); + if (include.company) { const logoPath = sourceManifest.company?.logoPath ?? null; if (!logoPath) { @@ -4412,6 +4548,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null); + await secrets.syncEnvBindingsForTarget?.( + targetCompany.id, + { targetType: "agent", targetId: updated.id }, + isPlainRecord(updated.adapterConfig) ? updated.adapterConfig.env : undefined, + ); importedSlugToAgentId.set(planAgent.slug, updated.id); existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id); resultAgents.push({ @@ -4448,6 +4589,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } agentStatusById.set(created.id, created.status ?? createdStatus); + await secrets.syncEnvBindingsForTarget?.( + targetCompany.id, + { targetType: "agent", targetId: created.id }, + isPlainRecord(created.adapterConfig) ? created.adapterConfig.env : undefined, + ); importedSlugToAgentId.set(planAgent.slug, created.id); existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id); resultAgents.push({ diff --git a/server/src/services/teams-catalog.ts b/server/src/services/teams-catalog.ts index 914131d73b4..8fe53709ace 100644 --- a/server/src/services/teams-catalog.ts +++ b/server/src/services/teams-catalog.ts @@ -61,6 +61,7 @@ export interface CatalogTeamImportOptions { nameOverrides?: Record; selectedFiles?: string[]; adapterOverrides?: CompanyPortabilityImport["adapterOverrides"]; + secretValues?: CompanyPortabilityImport["secretValues"]; sourcePolicy?: CatalogTeamSourcePolicy; actor?: CatalogTeamActorContext | null; } @@ -780,6 +781,7 @@ export function teamsCatalogService(db: Db) { const importInput: CompanyPortabilityImport = { ...buildPortabilityInput(companyId, prepared.source, options), adapterOverrides: options.adapterOverrides, + secretValues: options.secretValues, }; const result = await portability.importBundle( importInput, diff --git a/ui/src/api/teamCatalog.ts b/ui/src/api/teamCatalog.ts index 0b0d312187b..5e4cd8e3f95 100644 --- a/ui/src/api/teamCatalog.ts +++ b/ui/src/api/teamCatalog.ts @@ -20,7 +20,7 @@ export interface TeamCatalogListQuery { * * The preview/install bodies mirror `catalogTeamPreviewSchema` / * `catalogTeamInstallSchema` exactly. Several richer fields the Phase F design - * imagined (per-source policy maps, secret values, skill-plan overrides) are not + * imagined (per-source policy maps, skill-plan overrides) are not * accepted by the shipped strict schema, so the UI derives those affordances * client-side and degrades gracefully — see TeamCatalog.tsx. */ diff --git a/ui/src/pages/TeamCatalog.test.tsx b/ui/src/pages/TeamCatalog.test.tsx index 12b231cf570..d1c3ed167ae 100644 --- a/ui/src/pages/TeamCatalog.test.tsx +++ b/ui/src/pages/TeamCatalog.test.tsx @@ -181,6 +181,12 @@ function makePreview(): CatalogTeamImportPreviewResult { }; } +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + function findButton(label: string): HTMLButtonElement | undefined { return Array.from(document.querySelectorAll("button")).find((b) => (b.textContent ?? "").includes(label), @@ -280,6 +286,60 @@ describe("TeamCatalog install preview path", () => { expect(document.body.textContent).toContain("Team installed"); }); + it("requires and submits Step 4 secret values", async () => { + const preview = makePreview(); + preview.portabilityPreview.envInputs = [ + { + key: "OPENAI_API_KEY", + description: "OpenAI API key", + agentSlug: "ceo", + projectSlug: null, + kind: "secret", + requirement: "required", + defaultValue: null, + portability: "portable", + }, + ]; + mockTeamCatalogApi.preview.mockResolvedValue(preview); + + await renderPage(); + + const installCta = findButton("Install team"); + await act(async () => { + installCta!.click(); + }); + await flushReact(); + + const footerInstall = Array.from(document.querySelectorAll("button")).filter((b) => + (b.textContent ?? "").includes("Install team"), + ).at(-1) as HTMLButtonElement; + expect(footerInstall.disabled).toBe(true); + expect(document.body.textContent).toContain("Required secrets missing: 1"); + + const secretInput = document.querySelector('input[aria-label="OPENAI_API_KEY value"]') as HTMLInputElement; + expect(secretInput).toBeTruthy(); + await act(async () => { + setInputValue(secretInput, "sk-imported"); + }); + await flushReact(); + + expect(footerInstall.disabled).toBe(false); + await act(async () => { + footerInstall.click(); + }); + await flushReact(); + + expect(mockTeamCatalogApi.install).toHaveBeenCalledWith( + "company-1", + "team-no-deps", + expect.objectContaining({ + secretValues: { + "agent:ceo:OPENAI_API_KEY": "sk-imported", + }, + }), + ); + }); + it("requires a target manager before continuing when the team has root agents", async () => { currentRoute = "team-with-root"; mockTeamCatalogApi.catalogList.mockResolvedValue([ diff --git a/ui/src/pages/TeamCatalog.tsx b/ui/src/pages/TeamCatalog.tsx index ab6219eb101..a46cee584b1 100644 --- a/ui/src/pages/TeamCatalog.tsx +++ b/ui/src/pages/TeamCatalog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useMutation, useQuery } from "@tanstack/react-query"; import type { @@ -11,6 +11,8 @@ import type { CatalogTeamSourceRef, CatalogTeamTrustLevel, CatalogTeamCompatibility, + CatalogTeamImportOptions, + CatalogTeamInstallOptions, CatalogTeamInstallResult, CompanyPortabilityAdapterOverride, CompanyPortabilityCollisionStrategy, @@ -83,6 +85,8 @@ import { Cpu, Crown, Download, + Eye, + EyeOff, FileText, Filter, Folder, @@ -452,7 +456,7 @@ function TeamFileTree({ // and group the remaining members — graceful degradation per design §7). // --------------------------------------------------------------------------- -function TeamHierarchyPreview({ team }: { team: CatalogTeam }) { +export function TeamHierarchyPreview({ team }: { team: CatalogTeam }) { const roots = new Set(team.rootAgentSlugs); const members = team.agentSlugs.filter((slug) => !roots.has(slug)); const requiresManager = team.rootAgentSlugs.length > 0; @@ -518,7 +522,7 @@ function MetricTile({ ); } -function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[] }) { +export function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[] }) { if (skills.length === 0) return

No required skills.

; return (
    @@ -547,7 +551,7 @@ function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[] ); } -function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) { +export function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) { if (inputs.length === 0) return null; return (
    @@ -581,7 +585,13 @@ function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) { ); } -function ExternalSourcesList({ sources }: { sources: CatalogTeamSourceRef[] }) { +function envInputFormKey(input: CatalogTeamEnvInputSummary) { + if (input.agentSlug) return `agent:${input.agentSlug}:${input.key}`; + if (input.projectSlug) return `project:${input.projectSlug}:${input.key}`; + return input.key; +} + +export function ExternalSourcesList({ sources }: { sources: CatalogTeamSourceRef[] }) { const external = sources.filter((s) => s.type !== "include"); const [open, setOpen] = useState(false); if (external.length === 0) return null; @@ -802,10 +812,18 @@ const STEP_LABELS: Record = { preview: "Preview", }; -function computeSteps(team: CatalogTeam): WizardStep[] { +// `simplified` is the onboarding seam (design §6): the newly created company is +// treated as a full-company-equivalent target, so the target-manager step is +// dropped and source policy is never surfaced (onboarding only offers +// markdown_only/assets teams). The skill plan collapses to a count and the +// preview is minimal, but the skill step still renders when required skills +// exist so resolution stays honest. +function computeSteps(team: CatalogTeam, simplified = false): WizardStep[] { const steps: WizardStep[] = []; - if (team.rootAgentSlugs.length > 0) steps.push("target_manager"); - if (team.sourceRefs.some((s) => sourceWarningCode(s) !== "ok")) steps.push("source_policy"); + if (!simplified && team.rootAgentSlugs.length > 0) steps.push("target_manager"); + if (!simplified && team.sourceRefs.some((s) => sourceWarningCode(s) !== "ok")) { + steps.push("source_policy"); + } if (team.requiredSkills.length > 0) steps.push("skill_plan"); steps.push("preview"); return steps; @@ -813,6 +831,174 @@ function computeSteps(team: CatalogTeam): WizardStep[] { type ApplyPhase = "form" | "applying" | "done" | "error"; +// --------------------------------------------------------------------------- +// Install hook — the onboarding seam (design §6 + §12.5). +// +// `useInstallTeamCatalogEntry` owns the preview/install engine: option building, +// the two API mutations, and the resolved result/phase state. The installer +// dialog drives it with operator-entered form state; a future onboarding step +// can drive the same hook with `{ simplified: true }` and default form state to +// run the collapsed, no-target-manager flow without any UI rework. +// --------------------------------------------------------------------------- + +export interface TeamInstallFormState { + targetManagerAgentId: string | null; + fullCompany: boolean; + allowExternalSources: boolean; + allowUnpinnedOptionalSources: boolean; + allowLocalPathSources: boolean; + collisionStrategy: CompanyPortabilityCollisionStrategy; + /** slug -> renamed entity name */ + nameOverrides: Record; + /** slug -> adapterType override */ + adapterOverrides: Record; + /** scoped env input key -> operator-entered value */ + secretValues: Record; +} + +export const EMPTY_INSTALL_FORM: TeamInstallFormState = { + targetManagerAgentId: null, + fullCompany: false, + allowExternalSources: false, + allowUnpinnedOptionalSources: false, + allowLocalPathSources: false, + collisionStrategy: "rename", + nameOverrides: {}, + adapterOverrides: {}, + secretValues: {}, +}; + +export interface UseInstallTeamCatalogEntryOptions { + companyId: string; + team: CatalogTeam; + /** + * Run the simplified onboarding flow: no target-manager step, source policy + * never surfaced, collapsed preview. The company is treated as a + * full-company-equivalent target (`targetManagerAgentId: null`). + */ + simplified?: boolean; + onInstalled?: (result: CatalogTeamInstallResult) => void; +} + +export interface UseInstallTeamCatalogEntryResult { + simplified: boolean; + steps: WizardStep[]; + phase: ApplyPhase; + setPhase: (phase: ApplyPhase) => void; + previewResult: CatalogTeamImportPreviewResult | null; + previewError: string | null; + isPreviewing: boolean; + installResult: CatalogTeamInstallResult | null; + applyError: string | null; + runPreview: (form: TeamInstallFormState) => void; + runInstall: (form: TeamInstallFormState) => void; + buildPreviewOptions: (form: TeamInstallFormState) => CatalogTeamImportOptions; + buildInstallOptions: (form: TeamInstallFormState) => CatalogTeamInstallOptions; + reset: () => void; +} + +export function useInstallTeamCatalogEntry({ + companyId, + team, + simplified = false, + onInstalled, +}: UseInstallTeamCatalogEntryOptions): UseInstallTeamCatalogEntryResult { + const [phase, setPhase] = useState("form"); + const [previewResult, setPreviewResult] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [installResult, setInstallResult] = useState(null); + const [applyError, setApplyError] = useState(null); + + const steps = useMemo(() => computeSteps(team, simplified), [team, simplified]); + + // Preview body — the preview schema is strict and does NOT accept adapterOverrides. + const buildPreviewOptions = useCallback( + (form: TeamInstallFormState): CatalogTeamImportOptions => ({ + targetManagerAgentId: simplified || form.fullCompany ? null : form.targetManagerAgentId, + collisionStrategy: form.collisionStrategy, + nameOverrides: + Object.keys(form.nameOverrides).length > 0 ? form.nameOverrides : undefined, + sourcePolicy: { + allowExternalSources: form.allowExternalSources, + allowUnpinnedOptionalSources: form.allowUnpinnedOptionalSources, + allowLocalPathSources: form.allowLocalPathSources, + }, + }), + [simplified], + ); + + // Install body extends the preview body with adapterOverrides. + const buildInstallOptions = useCallback( + (form: TeamInstallFormState): CatalogTeamInstallOptions => { + const overrides: Record = {}; + for (const [slug, adapterType] of Object.entries(form.adapterOverrides)) { + if (adapterType) overrides[slug] = { adapterType }; + } + return { + ...buildPreviewOptions(form), + adapterOverrides: Object.keys(overrides).length > 0 ? overrides : undefined, + secretValues: Object.keys(form.secretValues).length > 0 ? form.secretValues : undefined, + }; + }, + [buildPreviewOptions], + ); + + const previewMutation = useMutation({ + mutationFn: (form: TeamInstallFormState) => + teamCatalogApi.preview(companyId, team.id, buildPreviewOptions(form)), + onSuccess: (result) => { + setPreviewResult(result); + setPreviewError(null); + }, + onError: (error) => { + setPreviewError(error instanceof Error ? error.message : "Failed to load install preview."); + }, + }); + + const installMutation = useMutation({ + mutationFn: (form: TeamInstallFormState) => + teamCatalogApi.install(companyId, team.id, buildInstallOptions(form)), + onMutate: () => { + setPhase("applying"); + setApplyError(null); + }, + onSuccess: (result) => { + setInstallResult(result); + setPhase("done"); + onInstalled?.(result); + }, + onError: (error) => { + setPhase("error"); + setApplyError(error instanceof Error ? error.message : "Install failed."); + }, + }); + + const reset = useCallback(() => { + setPhase("form"); + setPreviewResult(null); + setPreviewError(null); + setInstallResult(null); + setApplyError(null); + }, []); + + return { + simplified, + steps, + phase, + setPhase, + previewResult, + previewError, + isPreviewing: previewMutation.isPending, + installResult, + applyError, + runPreview: previewMutation.mutate, + runInstall: installMutation.mutate, + buildPreviewOptions, + buildInstallOptions, + reset, + }; +} + function TeamInstallerDialog({ team, companyId, @@ -847,6 +1033,8 @@ function TeamInstallerDialog({ const [nameOverrides, setNameOverrides] = useState>({}); // slug -> adapterType override (the install schema accepts adapterOverrides). const [adapterOverrides, setAdapterOverrides] = useState>({}); + const [secretValues, setSecretValues] = useState>({}); + const [visibleSecretKeys, setVisibleSecretKeys] = useState>({}); const [confirmScripts, setConfirmScripts] = useState(false); const [previewResult, setPreviewResult] = useState(null); @@ -867,6 +1055,8 @@ function TeamInstallerDialog({ setCollisionStrategy("rename"); setNameOverrides({}); setAdapterOverrides({}); + setSecretValues({}); + setVisibleSecretKeys({}); setConfirmScripts(false); setPreviewResult(null); setPreviewError(null); @@ -895,9 +1085,13 @@ function TeamInstallerDialog({ for (const [slug, adapterType] of Object.entries(adapterOverrides)) { if (adapterType) overrides[slug] = { adapterType }; } + const enteredSecretValues = Object.fromEntries( + Object.entries(secretValues).filter(([, value]) => value.trim().length > 0), + ); return { ...buildPreviewOptions(), adapterOverrides: Object.keys(overrides).length > 0 ? overrides : undefined, + secretValues: Object.keys(enteredSecretValues).length > 0 ? enteredSecretValues : undefined, }; }; @@ -955,6 +1149,10 @@ function TeamInstallerDialog({ const hasErrors = (previewResult?.errors.length ?? 0) > 0; const blockedCount = previewResult?.errors.length ?? 0; + const missingRequiredSecretInputs = (previewResult?.portabilityPreview.envInputs ?? []) + .filter((input) => input.requirement === "required" && (secretValues[envInputFormKey(input)] ?? "").trim().length === 0); + const missingRequiredSecretCount = missingRequiredSecretInputs.length; + const installBlocked = hasErrors || missingRequiredSecretCount > 0; const needsScriptsConfirm = team.trustLevel === "scripts_executables"; function goNext() { @@ -1047,6 +1245,10 @@ function TeamInstallerDialog({ onRename={(slug, name) => setNameOverrides((cur) => ({ ...cur, [slug]: name }))} adapterOverrides={adapterOverrides} onAdapterChange={(slug, adapterType) => setAdapterOverrides((cur) => ({ ...cur, [slug]: adapterType }))} + secretValues={secretValues} + visibleSecretKeys={visibleSecretKeys} + onSecretChange={(key, value) => setSecretValues((cur) => ({ ...cur, [key]: value }))} + onToggleSecretVisibility={(key) => setVisibleSecretKeys((cur) => ({ ...cur, [key]: !cur[key] }))} onRetry={() => previewMutation.mutate()} /> )} @@ -1088,14 +1290,19 @@ function TeamInstallerDialog({ Install blocked: {blockedCount} error{blockedCount === 1 ? "" : "s"} )} + {currentStep === "preview" && !hasErrors && missingRequiredSecretCount > 0 && ( + + Required secrets missing: {missingRequiredSecretCount} + + )} {currentStep === "preview" ? ( needsScriptsConfirm && confirmScripts ? ( - ) : ( - @@ -1456,6 +1663,10 @@ export function StepPreview({ onRename, adapterOverrides, onAdapterChange, + secretValues = {}, + visibleSecretKeys = {}, + onSecretChange = () => {}, + onToggleSecretVisibility = () => {}, onRetry, }: { team: CatalogTeam; @@ -1468,6 +1679,10 @@ export function StepPreview({ onRename: (slug: string, name: string) => void; adapterOverrides: Record; onAdapterChange: (slug: string, adapterType: string) => void; + secretValues?: Record; + visibleSecretKeys?: Record; + onSecretChange?: (key: string, value: string) => void; + onToggleSecretVisibility?: (key: string) => void; onRetry: () => void; }) { if (loading && !result) { @@ -1616,33 +1831,58 @@ export function StepPreview({ )} - {/* Env inputs (read-only — install schema does not yet accept secretValues) */} + {/* Env inputs */} {envInputs.length > 0 && ( - {envInputs.map((input) => ( -
  • - - {input.key} - {input.description && {input.description}} - {input.requirement === "required" && ( - required - )} - - {input.kind} - -
  • - ))} -
  • - - - The teams-catalog install API does not yet accept secret values, so these are listed - read-only and must be set on each imported agent after install. Inline entry is tracked - in the Phase E follow-up (see comment). - -
  • + {envInputs.map((input) => { + const formKey = envInputFormKey(input); + const visible = Boolean(visibleSecretKeys[formKey]); + const missingRequired = input.requirement === "required" && (secretValues[formKey] ?? "").trim().length === 0; + return ( +
  • +
    + + {input.key} + {input.description && {input.description}} + {input.requirement === "required" && ( + required + )} + + {input.kind} + +
    +
    + onSecretChange(formKey, event.target.value)} + placeholder={input.requirement === "required" ? "Required" : "Optional"} + aria-label={`${input.key} value`} + aria-invalid={missingRequired || undefined} + className={cn("h-8 min-w-0", missingRequired && "border-rose-500/60 focus-visible:ring-rose-500/30")} + /> + + + + + {visible ? "Hide value" : "Show value"} + +
    +
  • + ); + })}
    )} @@ -1800,6 +2040,65 @@ export function TeamRow({ ); } +// --------------------------------------------------------------------------- +// TeamCard — square tile for the onboarding "Pick a starter team" grid +// (design §6 + §12.5). Rendered in a 3-col grid of `defaultInstall` bundled +// teams. Selection is owned by the parent (the onboarding step) so the same +// tile works for the future live flow without rework. +// --------------------------------------------------------------------------- + +export function TeamCard({ + team, + selected = false, + onSelect, +}: { + team: CatalogTeam; + selected?: boolean; + onSelect?: () => void; +}) { + return ( + + ); +} + type KindFilter = "all" | "bundled" | "optional"; type RiskFilter = "any" | "safe" | "has_warnings" | "blocked";