From 040248add117707aa7dcdd8be744abf8ee778b2b Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 1 Oct 2026 12:12:16 -0700 Subject: [PATCH 01/22] feat(deploy): vercel target API mode (projectId + system-store token, no vercel binary) A vercel target whose options carry projectId writes env vars through Vercel's REST API with the token in _TARGET_VERCEL_TOKEN. Shared multi-environment records are split, never overwritten or deleted; writes are read back and hash-checked. Targets without projectId keep the CLI path. --- docs/cli-reference.md | 12 + src/commands/deployCommand.ts | 107 ++- src/deploy/adapter.ts | 7 + src/deploy/adapters/vercel.ts | 688 ++++++++++----- src/deploy/vercelTargetApi.ts | 835 +++++++++++++++++++ src/types/index.ts | 25 + tests/commands/deployVercelApiMode.test.ts | 292 +++++++ tests/deploy/vercelApiMode.test.ts | 536 ++++++++++++ tests/deploy/vercelApiModeFake.ts | 212 +++++ tests/deploy/vercelApiModeNoBinary.test.ts | 17 + tests/deploy/vercelApiModeNoBinaryChild.ts | 31 + tests/deploy/vercelApiModeTokenStore.test.ts | 182 ++++ tests/run-tests.sh | 2 + 13 files changed, 2723 insertions(+), 223 deletions(-) create mode 100644 src/deploy/vercelTargetApi.ts create mode 100644 tests/commands/deployVercelApiMode.test.ts create mode 100644 tests/deploy/vercelApiMode.test.ts create mode 100644 tests/deploy/vercelApiModeFake.ts create mode 100644 tests/deploy/vercelApiModeNoBinary.test.ts create mode 100644 tests/deploy/vercelApiModeNoBinaryChild.ts create mode 100644 tests/deploy/vercelApiModeTokenStore.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e0e7684b..f8995c20 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -732,6 +732,18 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `CI_DEPLOY_TARGETS_RECORD_FAILED` - `DEPLOY_STALE_KEEP` - `DEPLOY_TOKEN_UNTRACKED` +- `VERCEL_TOKEN_MISSING` +- `VERCEL_TARGET_KEY_MISSING` +- `VERCEL_TOKEN_REJECTED` +- `VERCEL_NO_ACCESS` +- `VERCEL_PROJECT_NOT_FOUND` +- `VERCEL_ENVIRONMENT_NOT_FOUND` +- `VERCEL_NETWORK_ERROR` +- `VERCEL_RATE_LIMITED` +- `VERCEL_API_ERROR` +- `VERCEL_VERIFY_MISMATCH` +- `VERCEL_OPTIONS_INVALID` +- `VERCEL_SPLIT_NOT_RESTORED` ## Conventions diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index 2b5d0a8f..dc2ecf6d 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -75,6 +75,12 @@ import { resolveDokployEnvironmentLabel, } from '../deploy/adapters/dokploy'; import type { ResolveDokployApiKeyResult, DokployServiceKind, DokployServiceUrlOk } from '../deploy/adapters/dokploy'; +import { + ResolveVercelTokenResult, + getVercelTargetSecretFromStore, + isVercelApiMode, + resolveVercelTargetToken, +} from '../deploy/vercelTargetApi'; import { DOKPLOY_CONNECTOR_SECRET_NAME, DOKPLOY_TARGET_SECRET_NAME, @@ -224,6 +230,32 @@ async function resolveDokployApiKeyOnce( }); } +/** + * Vercel API mode only (CAP-696): resolves the org system store's + * `_TARGET_VERCEL_TOKEN` ONCE for this command, exactly as + * `resolveDokployApiKeyOnce` does for Dokploy, and hands the result to every + * `preflight`/`deploy` call via `ctx.resolvedVercelToken`. `undefined` for any + * other adapter, and for a Vercel target still on the CLI path (no + * `projectId`) — those never read a token from the store. + */ +async function resolveVercelTokenOnce( + adapter: DeployAdapter, + target: TargetConfig, + orgId: string | undefined, + devMode: boolean | undefined, + interactive: boolean, +): Promise { + if (adapter.id !== 'vercel') return undefined; + if (!isVercelApiMode(target.options)) return undefined; + return resolveVercelTargetToken({ + env: process.env, + interactive, + orgId, + devMode, + getSecret: getVercelTargetSecretFromStore, + }); +} + /** * `authenticateSilent`'s common "org-scoped, then unscoped" probe as one * value rather than a reassigned local — the org-scoped attempt wins @@ -497,6 +529,16 @@ function targetRefFor(target: TargetConfig): Record | undefined const opts = target.options as Record; if (typeof opts.composeId === 'string') return { composeId: opts.composeId }; if (typeof opts.applicationId === 'string') return { applicationId: opts.applicationId }; + // Vercel API mode (CAP-696): the project, plus the custom environment's id + // when the target points at one. + if (target.kind === 'vercel' && isVercelApiMode(opts)) { + return { + projectId: String(opts.projectId), + ...(typeof opts.customEnvironmentId === 'string' && opts.customEnvironmentId + ? { envId: opts.customEnvironmentId } + : {}), + }; + } return undefined; } @@ -1466,6 +1508,12 @@ export async function resolveAdapterOptions( }, ]); } + if (adapter.id === 'vercel' && isVercelApiMode(existingOpts)) { + // API-mode target (CAP-696): its project/team/environment identity is set + // by whoever created it. Re-asking the CLI-path questions here would + // silently drop `projectId` and flip the target back to the CLI path. + return { ...existingOpts }; + } if (adapter.id === 'vercel') { // Vercel: code ships via the keep.lock PR (Vercel git CI builds on merge), // but capy pushes each var as a plaintext Environment Variable into the @@ -1779,7 +1827,12 @@ async function runPicker( branch: picked.branch, vars: picked.vars, knownVars: branchVars, - options: picked.options, + // An API-mode Vercel target keeps its saved options (CAP-696): the + // settings page only knows the CLI-path fields and would drop `projectId`. + options: + existing?.kind === 'vercel' && isVercelApiMode(existing.options) + ? { ...existing.options } + : picked.options, mode: picked.mode, gitBaseBranch: picked.gitBaseBranch, }; @@ -3144,8 +3197,11 @@ export async function deployCommand( // at `vercel env add` with "Branch not found in the connected Git // repository" whenever the names don't coincide. Ask once and persist. const targetOpts = target.options as Record; + // API-mode targets (CAP-696) do not need a git branch: without one, the + // vars apply to every Preview branch — so they are never healed here. if ( target.kind === 'vercel' && + !isVercelApiMode(targetOpts) && targetOpts.vercelEnv === 'preview' && !targetOpts.gitBranch ) { @@ -3157,29 +3213,32 @@ export async function deployCommand( console.error(`\nRun \`capy deploy --edit\` once interactively to set it.`); return 1; } - if (web.web) { - // One step of the setup screen — which is where this question belongs: - // the page states in as many words that this is a GIT branch and not the - // capy branch, which is the confusion that produced the gap being healed. - const healed = await promptVercelGitBranchInBrowser(cwd, keep, target); - if (!healed) { - console.log('Cancelled.'); - return 0; + const healedOpts = await (async (): Promise | null> => { + if (web.web) { + // One step of the setup screen — which is where this question belongs: + // the page states in as many words that this is a GIT branch and not the + // capy branch, which is the confusion that produced the gap being healed. + const healed = await promptVercelGitBranchInBrowser(cwd, keep, target); + if (!healed) return null; + // Production is not branch-scoped, so a switch to it drops the key + // rather than leaving a stale one behind — the same reason the picker + // omits it. + const { gitBranch: _dropped, ...withoutBranch } = targetOpts; + return { ...withoutBranch, ...healed }; } - // Production is not branch-scoped, so a switch to it drops the key - // rather than leaving a stale one behind — the same reason the picker - // omits it. - delete targetOpts.gitBranch; - Object.assign(targetOpts, healed); - } else { - targetOpts.gitBranch = await promptVercelGitBranch(cwd); + return { ...targetOpts, gitBranch: await promptVercelGitBranch(cwd) }; + })(); + if (!healedOpts) { + console.log('Cancelled.'); + return 0; } + target = { ...target, options: healedOpts }; upsertTarget(cwd, target); console.log( GREEN( - targetOpts.gitBranch - ? `✓ Saved gitBranch=${targetOpts.gitBranch} to target "${target.name}"` - : `✓ Saved vercelEnv=${targetOpts.vercelEnv} to target "${target.name}"`, + healedOpts.gitBranch + ? `✓ Saved gitBranch=${healedOpts.gitBranch} to target "${target.name}"` + : `✓ Saved vercelEnv=${healedOpts.vercelEnv} to target "${target.name}"`, ), ); } @@ -3256,7 +3315,15 @@ export async function deployCommand( !!web.web || !!options.yes || !!options.dryRun, ); const dokployApiKey = await resolveDokployApiKeyOnce(adapter, target, keep.orgId, options.devMode, secretsInteractive); - const adapterCallCtx = { orgId: keep.orgId, devMode: options.devMode, interactive: secretsInteractive, resolvedApiKey: dokployApiKey }; + // Vercel API mode only (CAP-696) — same once-per-command resolution, same prompt rules. + const vercelToken = await resolveVercelTokenOnce(adapter, target, keep.orgId, options.devMode, secretsInteractive); + const adapterCallCtx = { + orgId: keep.orgId, + devMode: options.devMode, + interactive: secretsInteractive, + resolvedApiKey: dokployApiKey, + resolvedVercelToken: vercelToken, + }; // Preflight (fail BEFORE decryption). At a terminal, a failed preflight is // not a dead end: the user can edit the target (e.g. a wrong composeId) and diff --git a/src/deploy/adapter.ts b/src/deploy/adapter.ts index c19324e7..d856f3df 100644 --- a/src/deploy/adapter.ts +++ b/src/deploy/adapter.ts @@ -12,6 +12,7 @@ import type { Classification } from './classify'; import type { ResolveDokployApiKeyResult } from './dokployApi'; +import type { ResolveVercelTokenResult } from './vercelTargetApi'; import type { ErrorCode } from '../types/index'; export type DeployMode = 'direct' | 'ci'; @@ -36,6 +37,12 @@ export interface AdapterCallContext { interactive?: boolean; /** A Dokploy API key pre-resolved by the caller — see `AdapterCallContext`'s own doc. */ resolvedApiKey?: ResolveDokployApiKeyResult; + /** + * A Vercel token pre-resolved by the caller, once per command — set only + * for a Vercel target in API mode (CAP-696). Same idea as `resolvedApiKey`: + * the system store is asked at most once, and an admin prompted at most once. + */ + resolvedVercelToken?: ResolveVercelTokenResult; } export interface TargetConfig { diff --git a/src/deploy/adapters/vercel.ts b/src/deploy/adapters/vercel.ts index 270e0f8f..920b324d 100644 --- a/src/deploy/adapters/vercel.ts +++ b/src/deploy/adapters/vercel.ts @@ -22,6 +22,12 @@ * Required for preview targets — deployCommand heals * targets saved before `options.gitBranch` existed by * prompting once and persisting. + * + * API MODE (CAP-696): a target whose options carry a `projectId` skips the + * `vercel` binary, the CLI login and `.vercel/project.json` entirely and talks + * to Vercel's REST API with the token in the org system store + * (`_TARGET_VERCEL_TOKEN`) — see `../vercelTargetApi.ts`. Targets without a + * `projectId` take the CLI path above, unchanged. */ import { appendFileSync, @@ -31,11 +37,13 @@ import { writeFileSync, } from 'fs'; import { spawnSync, spawn } from 'child_process'; +import { text as streamText } from 'stream/consumers'; import { homedir } from 'os'; import { basename, join } from 'path'; import inquirer from 'inquirer'; import { LIST_THEME } from '../../ui/promptStyle'; import { + AdapterCallContext, DeployAdapter, DeployContext, DeployResult, @@ -44,6 +52,33 @@ import { PreflightResult, TargetConfig, } from '../adapter'; +import { ERROR_CODES } from '../../types/index'; +import { + ApplyResult, + FetchLike, + GetVercelStoreSecret, + ResolveVercelTokenResult, + VarPlan, + VercelApiOptions, + VercelClient, + applyPlans, + asKnownErrorCode, + checkVercelTarget, + createVercelClient, + describeScope, + describeVercelFailure, + describeVercelTokenProblem, + dryRunSummary, + failureOf, + getVercelTargetSecretFromStore, + isVercelApiMode, + parseVercelApiOptions, + planAll, + resolveEnvScope, + resolveVercelTargetToken, + sortedNames, + verifyApplied, +} from '../vercelTargetApi'; type VercelEnv = 'production' | 'preview'; @@ -74,9 +109,17 @@ function setVercelEnv( gitBranch: string | undefined, cwd: string, ): { ok: boolean; error?: string } { - const args = ['env', 'add', name, vercelEnv]; - if (vercelEnv === 'preview' && gitBranch) args.push(gitBranch); - args.push('--force', '--yes', '--cwd', cwd); + const args = [ + 'env', + 'add', + name, + vercelEnv, + ...(vercelEnv === 'preview' && gitBranch ? [gitBranch] : []), + '--force', + '--yes', + '--cwd', + cwd, + ]; const r = spawnSync('vercel', args, { input: value, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }); if (r.status !== 0) { return { ok: false, error: extractVercelError(r.stderr, r.stdout) }; @@ -96,7 +139,7 @@ function extractVercelError(stderr: string, stdout: string): string { .map((l) => l.trim()) .filter((l) => l && !/^[╭╰│─╮╯\s]+$/.test(l)); const errLine = lines.find((l) => /error/i.test(l)); - return errLine ?? lines.pop() ?? 'vercel env add failed'; + return errLine ?? lines[lines.length - 1] ?? 'vercel env add failed'; } function readPackageJson(dir: string): any | null { @@ -147,12 +190,9 @@ function spawnAsync( env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (d) => (stdout += d.toString())); - child.stderr.on('data', (d) => (stderr += d.toString())); - child.on('close', (code) => - resolve({ stdout, stderr, code: code ?? 1 }), + const closed = new Promise((done) => child.on('close', (code) => done(code ?? 1))); + void Promise.all([streamText(child.stdout), streamText(child.stderr), closed]).then( + ([stdout, stderr, code]) => resolve({ stdout, stderr, code }), ); }); } @@ -280,21 +320,28 @@ type PickerOutcome = /** The user asked for the wizard. */ | { kind: 'declined' }; +/** The user's projects, or why they could not be listed — a try/catch as a value. */ +async function tryListAllVercelProjects( + token: string, +): Promise<{ ok: true; projects: PickableProject[] } | { ok: false; detail: string }> { + try { + return { ok: true, projects: await listAllVercelProjects(token) }; + } catch (e) { + return { ok: false, detail: e instanceof Error ? e.message : String(e) }; + } +} + async function linkByProjectPicker(projectDir: string): Promise { const token = readVercelCliToken(); if (!token) return { kind: 'no-token' }; - let projects: PickableProject[]; - try { - projects = await listAllVercelProjects(token); - } catch (e) { - return { kind: 'api-error', detail: e instanceof Error ? e.message : String(e) }; - } - if (projects.length === 0) return { kind: 'no-projects' }; + const listed = await tryListAllVercelProjects(token); + if (!listed.ok) return { kind: 'api-error', detail: listed.detail }; + if (listed.projects.length === 0) return { kind: 'no-projects' }; // Best guess first: a project named like the directory it lives in. const dirName = basename(projectDir); - projects.sort((a, b) => { + const projects = Array.from(listed.projects).sort((a, b) => { const aMatch = a.projectName === dirName; const bMatch = b.projectName === dirName; if (aMatch !== bMatch) return aMatch ? -1 : 1; @@ -364,58 +411,274 @@ async function runVercelLink(projectDir: string): Promise { }); } -export const vercelAdapter: DeployAdapter = { - id: 'vercel', - label: 'Vercel', - description: 'Pushes plaintext env vars; opens a keep.lock PR Vercel CI deploys on merge', - // Vercel's env store is consumed at both build and runtime, so we treat it - // as runtime ("push to Vercel") for picker copy, but pre-select ALL vars via - // presumeVars below — public-prefixed ones still get inlined by Vercel itself. - varKind: 'runtime', - // CI-only for CODE: capy never runs `vercel deploy`. The deploy PR is the - // deploy signal; Vercel's git integration builds and ships when it merges. - // (We do use the `vercel` CLI here, but only to set env vars, not to deploy.) - defaultMode: 'ci', - ciOnly: true, - // Pre-check every var: Vercel's single env store holds both build-time public - // vars (Vercel inlines them into the bundle by prefix) and runtime secrets. - presumeVars: (cls) => [...cls.buildTime, ...cls.runtime].sort(), - requires: { - // Code deploy is CI (the PR), but we push the env vars via the vercel CLI. - binaries: ['vercel'], - env: [], - }, - - async detect(cwd: string): Promise { - const projectDir = findProjectDir(cwd); - if (!projectDir) return {}; - const pkg = readPackageJson(projectDir); - const framework = detectFramework(pkg); - const rel = projectDir === cwd ? '.' : projectDir.slice(cwd.length + 1); +// ── CLI mode (legacy: the `vercel` binary + .vercel/project.json) ────────── + +async function legacyPreflight(config: TargetConfig, ctx: { cwd: string }): Promise { + // CI-only: no vendor checks at all (no vercel binary, project linkage, or + // login) — the build runs in the user's CI when the deploy PR merges. We + // only validate that the config is coherent and there's something to ship. + const opts = config.options as Partial; + if (!opts.projectDir) { return { - options: { projectDir: rel }, - summary: framework ? `${framework} app in ${rel}` : `app in ${rel}`, + ok: false, + reason: 'vercel target missing projectDir', + hint: `Run \`capy deploy --edit ${config.name}\` to fix.`, }; - }, - - async preflight(config: TargetConfig, ctx: { cwd: string }): Promise { - // CI-only: no vendor checks at all (no vercel binary, project linkage, or - // login) — the build runs in the user's CI when the deploy PR merges. We - // only validate that the config is coherent and there's something to ship. - const opts = config.options as Partial; - if (!opts.projectDir) { - return { - ok: false, - reason: 'vercel target missing projectDir', - hint: `Run \`capy deploy --edit ${config.name}\` to fix.`, - }; - } - const projectDir = join(ctx.cwd, opts.projectDir); - if (!existsSync(projectDir)) { - return { - ok: false, - reason: `project directory ${opts.projectDir} not found`, - }; + } + const projectDir = join(ctx.cwd, opts.projectDir); + if (!existsSync(projectDir)) { + return { + ok: false, + reason: `project directory ${opts.projectDir} not found`, + }; + } + if (config.vars.length === 0) { + return { + ok: false, + reason: 'vercel target has no vars to push', + hint: 'Re-run with `--edit` and select at least one var.', + }; + } + if (opts.vercelEnv !== 'production' && opts.vercelEnv !== 'preview') { + return { + ok: false, + reason: `vercel target missing vercelEnv (got ${JSON.stringify(opts.vercelEnv)})`, + hint: `Set options.vercelEnv to "production" or "preview" (run \`capy deploy --edit ${config.name}\`).`, + }; + } + if (opts.vercelEnv === 'preview' && !opts.gitBranch) { + return { + ok: false, + reason: 'vercel preview target missing options.gitBranch', + hint: + `Preview env vars are scoped to a GIT branch Vercel knows about ` + + `(not a capy branch). Run \`capy deploy --edit ${config.name}\` to pick one.`, + }; + } + // Linkage: either .vercel/project.json exists OR VERCEL_PROJECT_ID + + // VERCEL_ORG_ID are in env (CI). Either is sufficient. If neither holds + // AND we're sitting at an interactive TTY, auto-run `vercel link` so the + // user doesn't have to break flow. In CI/non-TTY we keep the original + // hard fail with the install hint. + const alreadyLinked = readVercelProjectId(projectDir); + const hasEnvIds = + !!process.env.VERCEL_PROJECT_ID && !!process.env.VERCEL_ORG_ID; + if (alreadyLinked.projectId || hasEnvIds) return { ok: true }; + + const interactive = !!process.stdin.isTTY && !!process.stdout.isTTY; + if (!interactive) { + return { + ok: false, + reason: `${opts.projectDir} is not linked to a Vercel project`, + hint: + `Link the project once:\n` + + ` cd ${opts.projectDir} && vercel link\n` + + `Or in CI, set VERCEL_PROJECT_ID + VERCEL_ORG_ID + VERCEL_TOKEN.`, + }; + } + // List the user's Vercel projects and ask which one this is, writing + // .vercel/project.json directly — the whole of what linking produces. + // + // `vercel link` is reached only where it can do something this cannot: + // create a project. Its wizard offers to pull the project's environment + // variables into `.env.local`, and capy owns those, so escalating every + // failure to it — which is what this used to do — risked that pull for + // reasons the wizard could not have fixed anyway. A missing credential + // and a failing API break `vercel link` in exactly the same way. + const picked = await linkByProjectPicker(projectDir); + if (picked.kind === 'no-token') { + return { + ok: false, + reason: 'not signed in to Vercel', + hint: + `Sign in once, then re-run:\n` + + ` vercel login\n` + + `Or set VERCEL_TOKEN in the environment.`, + }; + } + if (picked.kind === 'api-error') { + return { + ok: false, + reason: `could not list your Vercel projects (${picked.detail})`, + hint: + `Usually transient — re-run. If it persists, your saved Vercel\n` + + `credential may have expired: vercel login`, + }; + } + // Nothing to pick, or the user asked for the wizard. Creating a project + // is the one thing the picker cannot do. + const linkOk = picked.kind === 'linked' || (await runVercelLink(projectDir)); + if (!linkOk) { + return { + ok: false, + reason: 'vercel link did not complete', + hint: `Re-run, or link manually: cd ${opts.projectDir} && vercel link`, + }; + } + if (!readVercelProjectId(projectDir).projectId) { + return { + ok: false, + reason: `${opts.projectDir} is still not linked after vercel link`, + hint: `Try again: cd ${opts.projectDir} && vercel link`, + }; + } + return { ok: true }; +} + +async function legacyDeploy(config: TargetConfig, ctx: DeployContext): Promise { + const opts = config.options as Partial; + const vercelEnv = opts.vercelEnv as VercelEnv; + // Preview scope = the git branch the Preview env is wired to. Always the + // explicit per-target option — preflight rejects preview targets without + // it, because the old fallback (the capy branch name) is not a git branch + // and made `vercel env add` fail with "Branch not found". + const gitBranch = vercelEnv === 'preview' ? opts.gitBranch : undefined; + const projectDir = join(ctx.cwd, opts.projectDir ?? '.'); + const scope = vercelEnv === 'preview' ? `preview · branch=${gitBranch}` : 'production'; + + if (ctx.dryRun) { + return { + ok: true, + steps: [ + { + label: 'set Vercel env', + status: 'skip', + detail: `dry-run — would push ${config.vars.length} var(s) to ${scope}, then open the deploy PR`, + }, + ], + }; + } + + // Push each declared var as a plaintext Vercel Environment Variable, scoped + // to the chosen Vercel env. Filter the decrypted branch env to the declared + // names; fail loudly if any are missing rather than pushing a partial set. + const filtered: ReadonlyArray = config.vars + .filter((name) => name in ctx.env) + .map((name) => [name, ctx.env[name]] as const); + const missing = config.vars.filter((name) => !(name in ctx.env)); + if (missing.length > 0) { + return { + ok: false, + steps: [ + { + label: 'set Vercel env', + status: 'fail', + detail: `missing in branch ${config.branch}: ${missing.join(', ')}`, + }, + ], + }; + } + + const failed = filtered + .map(([name, value]) => ({ name, r: setVercelEnv(name, value, vercelEnv, gitBranch, projectDir) })) + .filter((x) => !x.r.ok) + .map((x) => `${x.name} (${x.r.error})`); + + if (failed.length > 0) { + return { + ok: false, + steps: [ + { + label: 'set Vercel env', + status: 'fail', + detail: `${scope}: ${failed.join(', ')}`, + }, + ], + }; + } + // Code ships via the keep.lock PR (opened by deployCommand); Vercel's git + // CI builds on merge and reads these env vars natively — no `capy run`. + return { + ok: true, + steps: [ + { + label: 'set Vercel env', + status: 'ok', + detail: `${filtered.length} var(s) pushed to ${scope}`, + }, + { + label: 'vercel build + deploy', + status: 'skip', + detail: 'CI mode — Vercel builds and deploys when the deploy PR merges', + }, + ], + }; +} + +// ── API mode (CAP-696) ───────────────────────────────────────────────────── + +export interface VercelAdapterDeps { + fetch?: FetchLike; + sleep?: (ms: number) => Promise; + now?: () => number; + env?: Record; + /** Reads the org system store. Defaults to a getter that never touches it (env-only), so tests and bare callers stay offline. */ + getTargetSecret?: GetVercelStoreSecret; +} + +const SET_ENV_LABEL = 'set Vercel env'; +const VERIFY_LABEL = 'verify Vercel env'; + +function failResult(step: DeployStep): DeployResult { + return { ok: false, steps: [step] }; +} + +function countOf(applied: ApplyResult['applied'], outcome: string): number { + return applied.filter((a) => a.outcome === outcome).length; +} + +function writeSummary(applied: ApplyResult['applied']): string { + return ( + `created ${countOf(applied, 'created')}, updated ${countOf(applied, 'updated')}, ` + + `split from a shared record ${countOf(applied, 'split')}, unchanged ${countOf(applied, 'unchanged')}` + ); +} + +function verifyStep(results: ReadonlyArray<{ name: string; state: string }>): DeployStep { + const names = (state: string) => sortedNames(results.filter((r) => r.state === state).map((r) => r.name)); + const mismatched = names('mismatch'); + if (mismatched.length > 0) { + return { + label: VERIFY_LABEL, + status: 'fail', + code: ERROR_CODES.VERCEL_VERIFY_MISMATCH, + detail: `Vercel holds a different value than was sent for: ${mismatched.join(', ')}`, // COPY-FLAG + }; + } + const unverified = names('unverified'); + const verifiedCount = names('verified').length; + return { + label: VERIFY_LABEL, + status: 'ok', + detail: + unverified.length > 0 + ? `${verifiedCount} verified; written but not readable back: ${unverified.join(', ')}` // COPY-FLAG + : `${verifiedCount} verified`, // COPY-FLAG + }; +} + +export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter { + const clientDeps = { fetch: deps.fetch, sleep: deps.sleep, now: deps.now }; + + /** The Vercel token for one call: the caller's single pre-resolution wins; else resolve here. */ + const resolveToken = (ctx: AdapterCallContext): Promise => + ctx.resolvedVercelToken + ? Promise.resolve(ctx.resolvedVercelToken) + : resolveVercelTargetToken({ + env: deps.env ?? process.env, + interactive: ctx.interactive ?? false, + orgId: ctx.orgId, + devMode: ctx.devMode, + getSecret: deps.getTargetSecret, + }); + + const clientFor = (token: string, opts: VercelApiOptions): VercelClient => + createVercelClient(token, opts.projectId, opts.teamId, clientDeps); + + async function apiPreflight(config: TargetConfig, ctx: { cwd: string } & AdapterCallContext): Promise { + const parsed = parseVercelApiOptions(config.options); + if (!parsed.ok) { + return { ok: false, reason: parsed.reason, hint: parsed.hint, code: ERROR_CODES.VERCEL_OPTIONS_INVALID }; } if (config.vars.length === 0) { return { @@ -424,161 +687,180 @@ export const vercelAdapter: DeployAdapter = { hint: 'Re-run with `--edit` and select at least one var.', }; } - if (opts.vercelEnv !== 'production' && opts.vercelEnv !== 'preview') { - return { - ok: false, - reason: `vercel target missing vercelEnv (got ${JSON.stringify(opts.vercelEnv)})`, - hint: `Set options.vercelEnv to "production" or "preview" (run \`capy deploy --edit ${config.name}\`).`, - }; + const token = await resolveToken(ctx); + if (!token.ok) { + const { reason, hint } = describeVercelTokenProblem(token.code); + return { ok: false, reason, hint, code: asKnownErrorCode(token.code) }; } - if (opts.vercelEnv === 'preview' && !opts.gitBranch) { - return { - ok: false, - reason: 'vercel preview target missing options.gitBranch', - hint: - `Preview env vars are scoped to a GIT branch Vercel knows about ` + - `(not a capy branch). Run \`capy deploy --edit ${config.name}\` to pick one.`, - }; - } - // Linkage: either .vercel/project.json exists OR VERCEL_PROJECT_ID + - // VERCEL_ORG_ID are in env (CI). Either is sufficient. If neither holds - // AND we're sitting at an interactive TTY, auto-run `vercel link` so the - // user doesn't have to break flow. In CI/non-TTY we keep the original - // hard fail with the install hint. - let linked = readVercelProjectId(projectDir); - const hasEnvIds = - !!process.env.VERCEL_PROJECT_ID && !!process.env.VERCEL_ORG_ID; - if (!linked.projectId && !hasEnvIds) { - const interactive = !!process.stdin.isTTY && !!process.stdout.isTTY; - if (!interactive) { - return { - ok: false, - reason: `${opts.projectDir} is not linked to a Vercel project`, - hint: - `Link the project once:\n` + - ` cd ${opts.projectDir} && vercel link\n` + - `Or in CI, set VERCEL_PROJECT_ID + VERCEL_ORG_ID + VERCEL_TOKEN.`, - }; - } - // List the user's Vercel projects and ask which one this is, writing - // .vercel/project.json directly — the whole of what linking produces. - // - // `vercel link` is reached only where it can do something this cannot: - // create a project. Its wizard offers to pull the project's environment - // variables into `.env.local`, and capy owns those, so escalating every - // failure to it — which is what this used to do — risked that pull for - // reasons the wizard could not have fixed anyway. A missing credential - // and a failing API break `vercel link` in exactly the same way. - const picked = await linkByProjectPicker(projectDir); - if (picked.kind === 'no-token') { - return { - ok: false, - reason: 'not signed in to Vercel', - hint: - `Sign in once, then re-run:\n` + - ` vercel login\n` + - `Or set VERCEL_TOKEN in the environment.`, - }; - } - if (picked.kind === 'api-error') { - return { - ok: false, - reason: `could not list your Vercel projects (${picked.detail})`, - hint: - `Usually transient — re-run. If it persists, your saved Vercel\n` + - `credential may have expired: vercel login`, - }; - } - // Nothing to pick, or the user asked for the wizard. Creating a project - // is the one thing the picker cannot do. - const linkOk = picked.kind === 'linked' || (await runVercelLink(projectDir)); - if (!linkOk) { - return { - ok: false, - reason: 'vercel link did not complete', - hint: `Re-run, or link manually: cd ${opts.projectDir} && vercel link`, - }; - } - linked = readVercelProjectId(projectDir); - if (!linked.projectId) { - return { - ok: false, - reason: `${opts.projectDir} is still not linked after vercel link`, - hint: `Try again: cd ${opts.projectDir} && vercel link`, - }; - } + const checked = await checkVercelTarget(clientFor(token.value, parsed.options), parsed.options); + if (!checked.ok) { + return { ok: false, ...describeVercelFailure(checked.code, checked.status), code: checked.code }; } return { ok: true }; - }, - - async deploy(config: TargetConfig, ctx: DeployContext): Promise { - const steps: DeployStep[] = []; - const opts = config.options as Partial; - const vercelEnv = opts.vercelEnv as VercelEnv; - // Preview scope = the git branch the Preview env is wired to. Always the - // explicit per-target option — preflight rejects preview targets without - // it, because the old fallback (the capy branch name) is not a git branch - // and made `vercel env add` fail with "Branch not found". - const gitBranch = vercelEnv === 'preview' ? opts.gitBranch : undefined; - const projectDir = join(ctx.cwd, opts.projectDir ?? '.'); - const scope = vercelEnv === 'preview' ? `preview · branch=${gitBranch}` : 'production'; - - if (ctx.dryRun) { - steps.push({ - label: 'set Vercel env', - status: 'skip', - detail: `dry-run — would push ${config.vars.length} var(s) to ${scope}, then open the deploy PR`, - }); - return { ok: true, steps }; + } + + async function apiDryRun(config: TargetConfig, ctx: DeployContext, opts: VercelApiOptions): Promise { + // Reads only — a dry run makes no write call of any kind. + const token = await resolveToken(ctx); + if (!token.ok) { + const { reason } = describeVercelTokenProblem(token.code); + return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code) }); + } + const client = clientFor(token.value, opts); + const scope = await resolveEnvScope(client, opts); + if (!scope.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason }); } + const listed = await client.listEnvs().then( + (records) => ({ ok: true as const, records }), + (err) => failureOf(err), + ); + if (!listed.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason }); + } + const plan = dryRunSummary(config.vars, listed.records, scope.scope); + const names = (xs: readonly string[]) => (xs.length > 0 ? ` [${sortedNames(xs).join(', ')}]` : ''); + return { + ok: true, + steps: [ + { + label: SET_ENV_LABEL, + status: 'skip', + // COPY-FLAG: names and counts only. + detail: + `dry-run — would push ${config.vars.length} var(s) to ${describeScope(scope.scope)}: ` + + `create ${plan.create.length}${names(plan.create)}, ` + + `update ${plan.update.length}${names(plan.update)}, ` + + `split from a shared record ${plan.split.length}${names(plan.split)}; then open the deploy PR`, + }, + ], + }; + } - // Push each declared var as a plaintext Vercel Environment Variable, scoped - // to the chosen Vercel env. Filter the decrypted branch env to the declared - // names; fail loudly if any are missing rather than pushing a partial set. - const filtered: Array<[string, string]> = []; - const missing: string[] = []; - for (const name of config.vars) { - if (name in ctx.env) filtered.push([name, ctx.env[name]]); - else missing.push(name); + async function apiDeploy(config: TargetConfig, ctx: DeployContext): Promise { + const parsed = parseVercelApiOptions(config.options); + if (!parsed.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: ERROR_CODES.VERCEL_OPTIONS_INVALID, detail: parsed.reason }); } + if (ctx.dryRun) return apiDryRun(config, ctx, parsed.options); + + const missing = config.vars.filter((name) => !(name in ctx.env)); if (missing.length > 0) { - steps.push({ - label: 'set Vercel env', + return failResult({ + label: SET_ENV_LABEL, status: 'fail', detail: `missing in branch ${config.branch}: ${missing.join(', ')}`, }); - return { ok: false, steps }; } - - const failed: string[] = []; - for (const [name, value] of filtered) { - const r = setVercelEnv(name, value, vercelEnv, gitBranch, projectDir); - if (!r.ok) failed.push(`${name} (${r.error})`); + const token = await resolveToken(ctx); + if (!token.ok) { + const { reason } = describeVercelTokenProblem(token.code); + return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code) }); + } + const client = clientFor(token.value, parsed.options); + const scope = await resolveEnvScope(client, parsed.options); + if (!scope.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason }); + } + const where = describeScope(scope.scope); + const listed = await client.listEnvs().then( + (records) => ({ ok: true as const, records }), + (err) => failureOf(err), + ); + if (!listed.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason }); } - if (failed.length > 0) { - steps.push({ - label: 'set Vercel env', + const pairs = config.vars.map((name) => [name, ctx.env[name]] as const); + const plans: readonly VarPlan[] = planAll(pairs, listed.records, scope.scope); + const applied = await applyPlans(client, plans, scope.scope); + if (!applied.ok) { + return failResult({ + label: SET_ENV_LABEL, status: 'fail', - detail: `${scope}: ${failed.join(', ')}`, + code: applied.code, + // COPY-FLAG: names and counts only — never a value. + detail: `${where}: stopped at ${applied.name} (${describeVercelFailure(applied.code, applied.status).reason}); ${applied.applied.length} var(s) written before it`, }); - return { ok: false, steps }; } - steps.push({ - label: 'set Vercel env', + + const verified = await verifyApplied(client, applied.applied, ctx.env); + const writeStep: DeployStep = { + label: SET_ENV_LABEL, status: 'ok', - detail: `${filtered.length} var(s) pushed to ${scope}`, - }); - // Code ships via the keep.lock PR (opened by deployCommand); Vercel's git - // CI builds on merge and reads these env vars natively — no `capy run`. - steps.push({ + detail: `${config.vars.length} var(s) pushed to ${where} (${writeSummary(applied.applied)})`, + }; + if (!verified.ok) { + return { + ok: false, + steps: [ + writeStep, + { label: VERIFY_LABEL, status: 'fail', code: verified.code, detail: describeVercelFailure(verified.code, verified.status).reason }, + ], + }; + } + const vStep = verifyStep(verified.results); + const buildStep: DeployStep = { label: 'vercel build + deploy', status: 'skip', detail: 'CI mode — Vercel builds and deploys when the deploy PR merges', - }); - return { ok: true, steps }; - }, -}; + }; + return vStep.status === 'fail' + ? { ok: false, steps: [writeStep, vStep] } + : { ok: true, steps: [writeStep, vStep, buildStep] }; + } + + return { + id: 'vercel', + label: 'Vercel', + description: 'Pushes plaintext env vars; opens a keep.lock PR Vercel CI deploys on merge', + // Vercel's env store is consumed at both build and runtime, so we treat it + // as runtime ("push to Vercel") for picker copy, but pre-select ALL vars via + // presumeVars below — public-prefixed ones still get inlined by Vercel itself. + varKind: 'runtime', + // CI-only for CODE: capy never runs `vercel deploy`. The deploy PR is the + // deploy signal; Vercel's git integration builds and ships when it merges. + // (CLI mode uses the `vercel` CLI here, but only to set env vars, not to deploy.) + defaultMode: 'ci', + ciOnly: true, + // Pre-check every var: Vercel's single env store holds both build-time public + // vars (Vercel inlines them into the bundle by prefix) and runtime secrets. + presumeVars: (cls) => sortedNames([...cls.buildTime, ...cls.runtime]), + requires: { + // CLI mode pushes the env vars via the vercel CLI. API-mode targets (a + // `projectId` in options) never spawn it — nothing in the deploy flow + // reads this list to refuse a run, so the static declaration is + // unchanged for existing callers and tests. + binaries: ['vercel'], + env: [], + }, + + async detect(cwd: string): Promise { + const projectDir = findProjectDir(cwd); + if (!projectDir) return {}; + const pkg = readPackageJson(projectDir); + const framework = detectFramework(pkg); + const rel = projectDir === cwd ? '.' : projectDir.slice(cwd.length + 1); + return { + options: { projectDir: rel }, + summary: framework ? `${framework} app in ${rel}` : `app in ${rel}`, + }; + }, + + async preflight(config: TargetConfig, ctx: { cwd: string } & AdapterCallContext): Promise { + return isVercelApiMode(config.options) ? apiPreflight(config, ctx) : legacyPreflight(config, ctx); + }, + + async deploy(config: TargetConfig, ctx: DeployContext): Promise { + return isVercelApiMode(config.options) ? apiDeploy(config, ctx) : legacyDeploy(config, ctx); + }, + }; +} + +/** The registry's instance: API mode reads the real org system store. */ +export const vercelAdapter: DeployAdapter = createVercelAdapter({ + getTargetSecret: getVercelTargetSecretFromStore, +}); function detectFramework(pkg: any): string | null { if (!pkg?.dependencies && !pkg?.devDependencies) return null; diff --git a/src/deploy/vercelTargetApi.ts b/src/deploy/vercelTargetApi.ts new file mode 100644 index 00000000..10d82d08 --- /dev/null +++ b/src/deploy/vercelTargetApi.ts @@ -0,0 +1,835 @@ +/** + * Vercel REST API client and env-var planner for the `vercel` deploy target's + * API mode (CAP-696). A target whose `options` carry a `projectId` talks to + * Vercel over HTTPS with a token from the org system store — no `vercel` + * binary, no `vercel login`, no `.vercel/project.json`. + * + * Routes used (each checked against Vercel's published API reference): + * - GET /v9/projects/{idOrName} — preflight: project exists, token has access + * https://vercel.com/docs/rest-api/projects/find-a-project-by-id-or-name + * - GET /v9/projects/{idOrName}/custom-environments/{environmentSlugOrId} + * https://vercel.com/docs/rest-api/environment/retrieve-a-custom-environment + * - GET /v10/projects/{idOrName}/env?decrypt=true — list the project's env records + * https://vercel.com/docs/rest-api/projects/retrieve-the-environment-variables-of-a-project-by-id-or-name + * - POST /v10/projects/{idOrName}/env — create ONE record + * https://vercel.com/docs/rest-api/projects/create-one-or-more-environment-variables + * - PATCH /v9/projects/{idOrName}/env/{id} — edit one record (value and/or target) + * https://vercel.com/docs/rest-api/projects/edit-an-environment-variable + * - GET /v1/projects/{idOrName}/env/{id} — read one record's decrypted value (verification) + * https://vercel.com/docs/rest-api/projects/retrieve-the-decrypted-value-of-an-environment-variable-of-a-project-by-id + * Every route takes `?teamId=` when the target has a `teamId`. Rate limiting + * and the `{ error: { code } }` body shape: https://vercel.com/docs/rest-api/errors + * + * Why this file never sends `upsert=true` and never deletes: one Vercel + * record can serve several environments at once (`target: ['production', + * 'preview']`). `upsert=true` would overwrite such a shared record's value for + * EVERY environment it serves, and a delete would remove it for all of them. + * Writing one environment must never change what another sees, so a shared + * record is split (this environment removed from it) and a fresh record is + * created for this environment alone — see `planVar`. + * + * Sensitive vars: Vercel never returns their value, so they cannot be read + * back; they are reported as written-unverified, never as verified. A team + * policy can turn every new Production/Preview var sensitive + * (https://vercel.com/docs/environment-variables/sensitive-environment-variables). + * The docs describe that as an automatic conversion, not an error code, so + * there is nothing to retry on: we send `encrypted` and accept whatever type + * Vercel stored. + * + * Nothing here ever logs or returns a token or a value in an error: failures + * carry a stable `code`, the HTTP status, and Vercel's own `error.code` field. + */ +import { createHash } from 'crypto'; +import { ERROR_CODES, ErrorCode } from '../types/index'; + +// ── Token: system store, same shape as Dokploy's ─────────────────────────── + +/** The org system store entry holding the Vercel token for DEPLOY (outbound). */ +export const VERCEL_TARGET_SECRET_NAME = '_TARGET_VERCEL_TOKEN'; +/** The import side's entry; `_TARGET_VERCEL_TOKEN` may be a one-level reference to it. */ +export const VERCEL_CONNECTOR_SECRET_NAME = '_CONNECTOR_VERCEL_TOKEN'; +/** Last-resort environment variable, after the system store. */ +export const DEFAULT_VERCEL_TOKEN_ENV = 'VERCEL_TOKEN'; + +export type VercelTokenSource = 'system' | 'env'; + +export type ResolveVercelTokenResult = + | { ok: true; value: string; source: VercelTokenSource } + | { ok: false; code: string }; + +/** What the system store's secret getter needs — same shape as Dokploy's. */ +export interface VercelSystemStoreCallOptions { + orgId?: string; + interactive: boolean; + devMode?: boolean; + apiUrl?: string; +} + +export type GetVercelStoreSecret = ( + name: string, + opts: VercelSystemStoreCallOptions, +) => Promise; + +/** + * The real system-store read for the deploy direction: `_TARGET_VERCEL_TOKEN` + * first; when only `_CONNECTOR_VERCEL_TOKEN` exists, interactive runs are + * asked whether to reuse it and non-interactive runs are refused with + * `VERCEL_TARGET_KEY_MISSING`. Exactly Dokploy's behaviour — see + * `system/systemStore.ts#getDirectionalConnectorSecret`. + */ +export const getVercelTargetSecretFromStore: GetVercelStoreSecret = async (name, opts) => { + const { getDirectionalConnectorSecret } = await import('../system/systemStore'); + return getDirectionalConnectorSecret(name, VERCEL_CONNECTOR_SECRET_NAME, { + ...opts, + missingWithFallbackCode: ERROR_CODES.VERCEL_TARGET_KEY_MISSING, + }); +}; + +/** A typed store error (`{ code: string }`) as its code — never its message. */ +function storeErrorCode(err: unknown): string { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : ERROR_CODES.VERCEL_TOKEN_MISSING; +} + +type StoreOutcome = + | { kind: 'value'; value: string } + | { kind: 'empty' } + | { kind: 'error'; code: string }; + +async function readStore( + getSecret: GetVercelStoreSecret, + callOpts: VercelSystemStoreCallOptions, +): Promise { + try { + const value = await getSecret(VERCEL_TARGET_SECRET_NAME, callOpts); + return value ? { kind: 'value', value } : { kind: 'empty' }; + } catch (err) { + return { kind: 'error', code: storeErrorCode(err) }; + } +} + +export interface ResolveVercelTokenOptions { + env: Record; + interactive: boolean; + orgId?: string; + devMode?: boolean; + apiUrl?: string; + /** Defaults to a getter that never touches the store (env-only). Real callers pass `getVercelTargetSecretFromStore`. */ + getSecret?: GetVercelStoreSecret; +} + +async function neverReachesTheStore(): Promise { + return null; +} + +/** + * The Vercel token for one command: the system store's + * `_TARGET_VERCEL_TOKEN` (a one-level reference to `_CONNECTOR_VERCEL_TOKEN` + * resolves inside the store), then `$VERCEL_TOKEN`. When nothing has a value + * the refusal carries the STORE's own code if the store refused (admin-only, + * connector-key-only, …), else `VERCEL_TOKEN_MISSING`. + */ +export async function resolveVercelTargetToken( + opts: ResolveVercelTokenOptions, +): Promise { + const outcome = await readStore(opts.getSecret ?? neverReachesTheStore, { + orgId: opts.orgId, + interactive: opts.interactive, + devMode: opts.devMode, + apiUrl: opts.apiUrl, + }); + if (outcome.kind === 'value') return { ok: true, value: outcome.value, source: 'system' }; + const fallback = opts.env[DEFAULT_VERCEL_TOKEN_ENV]; + if (fallback) return { ok: true, value: fallback, source: 'env' }; + return outcome.kind === 'error' + ? { ok: false, code: outcome.code } + : { ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }; +} + +/** A token refusal as a printable reason + hint. Names only, never a value. */ +export function describeVercelTokenProblem(code: string): { reason: string; hint: string } { + if (code === ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY) { + return { + // COPY-FLAG: minimal neutral wording. + reason: `only an org owner or admin can set ${VERCEL_TARGET_SECRET_NAME} in the system store, and $${DEFAULT_VERCEL_TOKEN_ENV} is not set`, + // COPY-FLAG + hint: `Ask an org owner/admin to run \`capy system set ${VERCEL_TARGET_SECRET_NAME}\`, or export ${DEFAULT_VERCEL_TOKEN_ENV} yourself.`, + }; + } + if (code === ERROR_CODES.VERCEL_TARGET_KEY_MISSING) { + return { + // COPY-FLAG + reason: `${VERCEL_TARGET_SECRET_NAME} is not set, but ${VERCEL_CONNECTOR_SECRET_NAME} already holds a Vercel token`, + // COPY-FLAG + hint: `Run this interactively to choose whether to reuse it, or run \`capy system set ${VERCEL_TARGET_SECRET_NAME}\` to set a dedicated one.`, + }; + } + if (code === ERROR_CODES.VERCEL_TOKEN_MISSING) { + return { + // COPY-FLAG + reason: `${VERCEL_TARGET_SECRET_NAME} is not set`, + // COPY-FLAG + hint: `Run \`capy system set ${VERCEL_TARGET_SECRET_NAME}\`, or export ${DEFAULT_VERCEL_TOKEN_ENV}=… first.`, + }; + } + return { + // COPY-FLAG + reason: `could not resolve the Vercel token (${code})`, + // COPY-FLAG + hint: `Run \`capy system set ${VERCEL_TARGET_SECRET_NAME}\`, or export ${DEFAULT_VERCEL_TOKEN_ENV}=… first.`, + }; +} + +// ── Options ──────────────────────────────────────────────────────────────── + +export type StandardVercelEnv = 'production' | 'preview' | 'development'; + +const STANDARD_ENVS: readonly string[] = ['production', 'preview', 'development']; + +/** The API-mode option set. `projectId` is what switches a target into API mode. */ +export interface VercelApiOptions { + projectDir?: string; + /** `production`, `preview`, `development`, or a custom environment's slug. */ + vercelEnv: string; + projectId: string; + teamId?: string; + /** Preview only. Absent means every preview branch. */ + gitBranch?: string; + customEnvironmentId?: string; +} + +/** Whether a target's options put it in API mode. The one switch; everything else follows it. */ +export function isVercelApiMode(options: Record): boolean { + return typeof options.projectId === 'string' && options.projectId.trim() !== ''; +} + +function nonEmptyString(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined; +} + +export type ParsedVercelApiOptions = + | { ok: true; options: VercelApiOptions } + | { ok: false; reason: string; hint: string }; + +export function parseVercelApiOptions(raw: Record): ParsedVercelApiOptions { + const projectId = nonEmptyString(raw.projectId); + const vercelEnv = nonEmptyString(raw.vercelEnv); + if (!projectId || !vercelEnv) { + return { + ok: false, + // COPY-FLAG + reason: 'vercel API-mode target needs options.projectId and options.vercelEnv', + // COPY-FLAG + hint: 'Set vercelEnv to "production", "preview", "development" or a custom environment slug.', + }; + } + return { + ok: true, + options: { + projectDir: nonEmptyString(raw.projectDir), + vercelEnv, + projectId, + teamId: nonEmptyString(raw.teamId), + gitBranch: nonEmptyString(raw.gitBranch), + customEnvironmentId: nonEmptyString(raw.customEnvironmentId), + }, + }; +} + +// ── Typed errors ─────────────────────────────────────────────────────────── + +/** A failed Vercel call. `code` is ours; `vercelCode` is Vercel's own `error.code` field, when it sent one. */ +export class VercelApiError extends Error { + constructor( + readonly code: ErrorCode, + readonly status: number | undefined, + readonly vercelCode: string | undefined, + ) { + // The message is for logs: a code and a status, never a body (a body can echo a value). + super(`${code}${status !== undefined ? ` (HTTP ${status})` : ''}`); + this.name = 'VercelApiError'; + } +} + +// ── Client ───────────────────────────────────────────────────────────────── + +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +const API_BASE = 'https://api.vercel.com'; +/** Retries after a 429. */ +export const MAX_RATE_LIMIT_RETRIES = 3; +/** A wait longer than this is not waited for: the call fails with VERCEL_RATE_LIMITED. */ +export const MAX_RATE_LIMIT_WAIT_MS = 60_000; +const DEFAULT_RATE_LIMIT_WAIT_MS = 1_000; + +/** One Vercel env record — only the fields we read. */ +export interface VercelEnvRecord { + id: string; + key: string; + value?: string; + type?: string; + target?: readonly string[] | string; + gitBranch?: string | null; + customEnvironmentIds?: readonly string[]; + decrypted?: boolean; +} + +export interface VercelClientDeps { + fetch?: FetchLike; + sleep?: (ms: number) => Promise; + /** Epoch milliseconds. */ + now?: () => number; +} + +export interface VercelClient { + getProject(): Promise; + getCustomEnvironment(slugOrId: string): Promise<{ id: string; slug?: string }>; + listEnvs(): Promise; + createEnv(body: Record): Promise; + patchEnv(envId: string, body: Record): Promise; + getEnv(envId: string): Promise; +} + +/** Seconds a 429 asks us to wait: `retry-after` (seconds) first, then `x-ratelimit-reset` (epoch seconds). */ +function rateLimitWaitMs(headers: Headers, nowMs: number): number { + const retryAfter = Number(headers.get('retry-after')); + if (headers.get('retry-after') !== null && Number.isFinite(retryAfter) && retryAfter >= 0) { + return Math.ceil(retryAfter * 1000); + } + const reset = Number(headers.get('x-ratelimit-reset')); + if (headers.get('x-ratelimit-reset') !== null && Number.isFinite(reset) && reset > 0) { + return Math.max(0, reset * 1000 - nowMs); + } + return DEFAULT_RATE_LIMIT_WAIT_MS; +} + +async function vercelErrorCodeOf(res: Response): Promise { + try { + const body = (await res.json()) as { error?: { code?: unknown } } | null; + const code = body?.error?.code; + return typeof code === 'string' ? code : undefined; + } catch { + return undefined; + } +} + +function codeForStatus(status: number, notFound: ErrorCode): ErrorCode { + if (status === 401) return ERROR_CODES.VERCEL_TOKEN_REJECTED; + if (status === 403) return ERROR_CODES.VERCEL_NO_ACCESS; + if (status === 404) return notFound; + if (status === 429) return ERROR_CODES.VERCEL_RATE_LIMITED; + return ERROR_CODES.VERCEL_API_ERROR; +} + +export function createVercelClient( + token: string, + projectId: string, + teamId: string | undefined, + deps: VercelClientDeps = {}, +): VercelClient { + const doFetch: FetchLike = deps.fetch ?? ((input, init) => fetch(input, init)); + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = deps.now ?? (() => Date.now()); + const project = encodeURIComponent(projectId); + + const urlFor = (path: string, query: Readonly>): string => { + const params = new URLSearchParams({ ...query, ...(teamId ? { teamId } : {}) }); + const qs = params.toString(); + return `${API_BASE}${path}${qs ? `?${qs}` : ''}`; + }; + + /** One request, retrying a 429 up to MAX_RATE_LIMIT_RETRIES times. Sequential by construction. */ + const request = async ( + method: string, + path: string, + query: Readonly>, + body: Record | undefined, + notFound: ErrorCode, + attempt: number = 0, + ): Promise => { + const res = await doFetch(urlFor(path, query), { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }).catch((): null => null); + if (res === null) throw new VercelApiError(ERROR_CODES.VERCEL_NETWORK_ERROR, undefined, undefined); + if (res.ok) return res.json(); + const vercelCode = await vercelErrorCodeOf(res); + if (res.status !== 429) { + throw new VercelApiError(codeForStatus(res.status, notFound), res.status, vercelCode); + } + const waitMs = rateLimitWaitMs(res.headers, now()); + if (attempt >= MAX_RATE_LIMIT_RETRIES || waitMs > MAX_RATE_LIMIT_WAIT_MS) { + throw new VercelApiError(ERROR_CODES.VERCEL_RATE_LIMITED, 429, vercelCode); + } + await sleep(waitMs); + return request(method, path, query, body, notFound, attempt + 1); + }; + + const notFoundProject = ERROR_CODES.VERCEL_PROJECT_NOT_FOUND; + + return { + async getProject() { + await request('GET', `/v9/projects/${project}`, {}, undefined, notFoundProject); + }, + async getCustomEnvironment(slugOrId) { + const got = (await request( + 'GET', + `/v9/projects/${project}/custom-environments/${encodeURIComponent(slugOrId)}`, + {}, + undefined, + ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND, + )) as { id?: unknown; slug?: unknown }; + if (typeof got?.id !== 'string') { + throw new VercelApiError(ERROR_CODES.VERCEL_API_ERROR, undefined, undefined); + } + return { id: got.id, slug: typeof got.slug === 'string' ? got.slug : undefined }; + }, + async listEnvs() { + const got = (await request('GET', `/v10/projects/${project}/env`, { decrypt: 'true' }, undefined, notFoundProject)) as + | { envs?: unknown } + | readonly unknown[] + | null; + const envs = Array.isArray(got) ? got : (got as { envs?: unknown } | null)?.envs; + return Array.isArray(envs) ? (envs as VercelEnvRecord[]) : []; + }, + async createEnv(body) { + const got = (await request('POST', `/v10/projects/${project}/env`, {}, body, notFoundProject)) as { + created?: VercelEnvRecord | readonly VercelEnvRecord[]; + failed?: readonly { error?: { code?: unknown } }[]; + }; + const failedCode = got?.failed?.[0]?.error?.code; + if (Array.isArray(got?.failed) && got.failed.length > 0) { + throw new VercelApiError( + ERROR_CODES.VERCEL_API_ERROR, + undefined, + typeof failedCode === 'string' ? failedCode : undefined, + ); + } + const created = Array.isArray(got?.created) ? got.created[0] : got?.created; + if (!created || typeof (created as VercelEnvRecord).id !== 'string') { + throw new VercelApiError(ERROR_CODES.VERCEL_API_ERROR, undefined, undefined); + } + return created as VercelEnvRecord; + }, + async patchEnv(envId, body) { + const got = (await request( + 'PATCH', + `/v9/projects/${project}/env/${encodeURIComponent(envId)}`, + {}, + body, + notFoundProject, + )) as VercelEnvRecord | null; + return got ?? { id: envId, key: '' }; + }, + async getEnv(envId) { + return (await request( + 'GET', + `/v1/projects/${project}/env/${encodeURIComponent(envId)}`, + {}, + undefined, + notFoundProject, + )) as VercelEnvRecord; + }, + }; +} + +// ── Environment scope ────────────────────────────────────────────────────── + +/** The one environment (and, for Preview, branch scope) a target writes to. */ +export type EnvScope = + | { kind: 'standard'; env: StandardVercelEnv; gitBranch?: string } + | { kind: 'custom'; id: string; slug: string }; + +/** `production` / `preview` / `development` / `custom:` — one string per environment a record can serve. */ +function scopeKey(scope: EnvScope): string { + return scope.kind === 'standard' ? scope.env : `custom:${scope.id}`; +} + +export function describeScope(scope: EnvScope): string { + if (scope.kind === 'custom') return `custom environment ${scope.slug}`; + if (scope.env === 'preview') { + return scope.gitBranch ? `preview · branch=${scope.gitBranch}` : 'preview · all branches'; + } + return scope.env; +} + +export type ScopeResolution = + | { ok: true; scope: EnvScope } + | { ok: false; code: ErrorCode; status?: number }; + +function isStandardEnv(v: string): v is StandardVercelEnv { + return STANDARD_ENVS.includes(v); +} + +/** The target's environment as an `EnvScope`, confirming a custom environment exists on the project. */ +export async function resolveEnvScope( + client: VercelClient, + opts: VercelApiOptions, +): Promise { + if (isStandardEnv(opts.vercelEnv)) { + const gitBranch = opts.vercelEnv === 'preview' ? opts.gitBranch : undefined; + return { ok: true, scope: { kind: 'standard', env: opts.vercelEnv, ...(gitBranch ? { gitBranch } : {}) } }; + } + try { + const found = await client.getCustomEnvironment(opts.customEnvironmentId ?? opts.vercelEnv); + return { ok: true, scope: { kind: 'custom', id: found.id, slug: found.slug ?? opts.vercelEnv } }; + } catch (err) { + return failureOf(err); + } +} + +/** A thrown error as `{ ok: false, code, status }`. Anything not a VercelApiError is an unexpected API error. */ +export function failureOf(err: unknown): { ok: false; code: ErrorCode; status?: number } { + if (err instanceof VercelApiError) return { ok: false, code: err.code, status: err.status }; + return { ok: false, code: ERROR_CODES.VERCEL_API_ERROR }; +} + +/** Preflight: the project exists, the token can see it, and (custom env) the environment exists. */ +export async function checkVercelTarget( + client: VercelClient, + opts: VercelApiOptions, +): Promise<{ ok: true; scope: EnvScope } | { ok: false; code: ErrorCode; status?: number }> { + try { + await client.getProject(); + } catch (err) { + return failureOf(err); + } + return resolveEnvScope(client, opts); +} + +// ── Planning ─────────────────────────────────────────────────────────────── + +function targetsOf(rec: VercelEnvRecord): readonly string[] { + if (Array.isArray(rec.target)) return rec.target as readonly string[]; + return typeof rec.target === 'string' ? [rec.target] : []; +} + +function customIdsOf(rec: VercelEnvRecord): readonly string[] { + return rec.customEnvironmentIds ?? []; +} + +/** Every environment this record serves, as scope keys. */ +function envKeysOf(rec: VercelEnvRecord): readonly string[] { + return [...targetsOf(rec), ...customIdsOf(rec).map((id) => `custom:${id}`)]; +} + +function branchOf(v: string | null | undefined): string { + return v ?? ''; +} + +/** Whether `rec` is a record for this var name that serves `scope` (and, for Preview, the same branch scope). */ +function covers(rec: VercelEnvRecord, name: string, scope: EnvScope): boolean { + if (rec.key !== name) return false; + if (!envKeysOf(rec).includes(scopeKey(scope))) return false; + if (scope.kind === 'standard' && scope.env === 'preview') { + return branchOf(rec.gitBranch) === branchOf(scope.gitBranch); + } + return true; +} + +/** The value Vercel will let us read, or undefined when it is unreadable (sensitive, or not decrypted). */ +function readableValue(rec: VercelEnvRecord): string | undefined { + if (typeof rec.value !== 'string') return undefined; + if (rec.type === 'sensitive') return undefined; + if (rec.type === 'plain' || rec.decrypted === true) return rec.value; + return undefined; +} + +export type NewRecordType = 'encrypted' | 'sensitive'; + +/** A new record keeps an existing record's type when that is sensitive or encrypted; else `encrypted`. */ +function typeLike(rec: VercelEnvRecord | undefined): NewRecordType { + return rec?.type === 'sensitive' ? 'sensitive' : 'encrypted'; +} + +/** What to do for one var. `record` is always an existing Vercel record. */ +export type VarPlan = + | { kind: 'create'; name: string; value: string; type: NewRecordType } + | { kind: 'update'; name: string; value: string; record: VercelEnvRecord } + | { kind: 'unchanged'; name: string; record: VercelEnvRecord } + | { kind: 'split'; name: string; value: string; shared: VercelEnvRecord; type: NewRecordType }; + +/** + * Decide how to write one var into one environment, from the current records: + * 1. a record serving exactly this environment → edit its value (skip when equal); + * 2. a record serving this environment AND others → equal value: nothing to do; + * otherwise split — this environment is removed from it and a new record + * for this environment alone is created. The shared record is never deleted; + * 3. no record → create one. + * Records for other keys are never looked at. + */ +export function planVar( + name: string, + value: string, + records: readonly VercelEnvRecord[], + scope: EnvScope, +): VarPlan { + const serving = records.filter((r) => covers(r, name, scope)); + const exact = serving.find((r) => envKeysOf(r).length === 1); + if (exact) { + return readableValue(exact) === value + ? { kind: 'unchanged', name, record: exact } + : { kind: 'update', name, value, record: exact }; + } + const shared = serving[0]; + if (shared) { + return readableValue(shared) === value + ? { kind: 'unchanged', name, record: shared } + : { kind: 'split', name, value, shared, type: typeLike(shared) }; + } + return { kind: 'create', name, value, type: 'encrypted' }; +} + +/** `planVar` for every name, in order. */ +export function planAll( + vars: ReadonlyArray, + records: readonly VercelEnvRecord[], + scope: EnvScope, +): readonly VarPlan[] { + return vars.map(([name, value]) => planVar(name, value, records, scope)); +} + +/** Names only, per kind — all a dry run may say. Without values, "unchanged" cannot be known. */ +export function dryRunSummary( + names: readonly string[], + records: readonly VercelEnvRecord[], + scope: EnvScope, +): { create: readonly string[]; update: readonly string[]; split: readonly string[] } { + const kindOf = (name: string): 'create' | 'update' | 'split' => { + const serving = records.filter((r) => covers(r, name, scope)); + if (serving.some((r) => envKeysOf(r).length === 1)) return 'update'; + return serving.length > 0 ? 'split' : 'create'; + }; + const kinds = names.map((name) => ({ name, kind: kindOf(name) })); + const pick = (k: 'create' | 'update' | 'split') => kinds.filter((x) => x.kind === k).map((x) => x.name); + return { create: pick('create'), update: pick('update'), split: pick('split') }; +} + +// ── Applying ─────────────────────────────────────────────────────────────── + +export type WriteOutcome = 'created' | 'updated' | 'split' | 'unchanged'; + +export interface AppliedVar { + name: string; + outcome: WriteOutcome; + /** The Vercel record now holding this environment's value. */ + envId: string; + /** The type Vercel reports for that record, when it said. */ + type?: string; +} + +export type ApplyResult = + | { ok: true; applied: readonly AppliedVar[] } + | { ok: false; code: ErrorCode; status?: number; name: string; applied: readonly AppliedVar[] }; + +function sortedCopy(xs: readonly T[], cmp?: (a: T, b: T) => number): T[] { + return Array.from(xs).sort(cmp); +} + +/** Body that removes `scope` from a shared record, leaving every other environment it serves exactly as it was. */ +function detachBody(shared: VercelEnvRecord, scope: EnvScope): Record { + const key = scopeKey(scope); + const remainingStandard = targetsOf(shared).filter((t) => t !== key); + const remainingCustom = customIdsOf(shared).filter((id) => `custom:${id}` !== key); + const lostBranchScope = scope.kind === 'standard' && scope.env === 'preview' && !!shared.gitBranch; + return { + target: remainingStandard, + ...(shared.customEnvironmentIds ? { customEnvironmentIds: remainingCustom } : {}), + // A git branch is only valid while the record still serves Preview. + ...(lostBranchScope && !remainingStandard.includes('preview') ? { gitBranch: null } : {}), + }; +} + +/** Body that puts a shared record back exactly as listed. */ +function restoreBody(shared: VercelEnvRecord): Record { + return { + target: targetsOf(shared), + ...(shared.customEnvironmentIds ? { customEnvironmentIds: customIdsOf(shared) } : {}), + ...(shared.gitBranch ? { gitBranch: shared.gitBranch } : {}), + }; +} + +/** POST body for a brand-new record serving only `scope`. */ +function createBody(name: string, value: string, type: NewRecordType, scope: EnvScope): Record { + if (scope.kind === 'custom') { + return { key: name, value, type, customEnvironmentIds: [scope.id] }; + } + return { + key: name, + value, + type, + target: [scope.env], + ...(scope.env === 'preview' && scope.gitBranch ? { gitBranch: scope.gitBranch } : {}), + }; +} + +type OneResult = + | { ok: true; applied: AppliedVar } + | { ok: false; code: ErrorCode; status?: number }; + +async function applyOne(client: VercelClient, plan: VarPlan, scope: EnvScope): Promise { + try { + if (plan.kind === 'unchanged') { + return { ok: true, applied: { name: plan.name, outcome: 'unchanged', envId: plan.record.id, type: plan.record.type } }; + } + if (plan.kind === 'update') { + const got = await client.patchEnv(plan.record.id, { value: plan.value }); + return { ok: true, applied: { name: plan.name, outcome: 'updated', envId: plan.record.id, type: got.type ?? plan.record.type } }; + } + if (plan.kind === 'create') { + const made = await client.createEnv(createBody(plan.name, plan.value, plan.type, scope)); + return { ok: true, applied: { name: plan.name, outcome: 'created', envId: made.id, type: made.type } }; + } + return await applySplit(client, plan, scope); + } catch (err) { + return failureOf(err); + } +} + +/** + * Detach this environment from the shared record, then create its own record. + * The order matters: while the shared record still serves this environment, a + * create would collide with it. If the create fails after the detach, the + * detach is undone so the environment does not lose the var; if even that + * fails the result says so (`VERCEL_SPLIT_NOT_RESTORED`). + */ +async function applySplit( + client: VercelClient, + plan: Extract, + scope: EnvScope, +): Promise { + await client.patchEnv(plan.shared.id, detachBody(plan.shared, scope)); + try { + const made = await client.createEnv(createBody(plan.name, plan.value, plan.type, scope)); + return { ok: true, applied: { name: plan.name, outcome: 'split', envId: made.id, type: made.type } }; + } catch (createErr) { + const restored = await client + .patchEnv(plan.shared.id, restoreBody(plan.shared)) + .then(() => true) + .catch(() => false); + if (!restored) return { ok: false, code: ERROR_CODES.VERCEL_SPLIT_NOT_RESTORED }; + return failureOf(createErr); + } +} + +/** Apply every plan, one request at a time, stopping at the first failure. */ +export async function applyPlans( + client: VercelClient, + plans: readonly VarPlan[], + scope: EnvScope, +): Promise { + const step = async (index: number, done: readonly AppliedVar[]): Promise => { + const plan = plans[index]; + if (!plan) return { ok: true, applied: done }; + const result = await applyOne(client, plan, scope); + if (!result.ok) return { ok: false, code: result.code, status: result.status, name: plan.name, applied: done }; + return step(index + 1, [...done, result.applied]); + }; + return step(0, []); +} + +// ── Verification ─────────────────────────────────────────────────────────── + +export type VerifyState = 'verified' | 'unverified' | 'mismatch'; + +export interface VerifiedVar { + name: string; + state: VerifyState; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** + * Re-read what Vercel holds and compare SHA-256 hashes in memory (the values + * themselves are never printed or kept). Sensitive records cannot be read — + * they are `unverified`, never `verified`. A record Vercel will not decrypt + * is the same. + */ +export async function verifyApplied( + client: VercelClient, + applied: readonly AppliedVar[], + expected: Readonly>, +): Promise<{ ok: true; results: readonly VerifiedVar[] } | { ok: false; code: ErrorCode; status?: number }> { + const step = async ( + index: number, + done: readonly VerifiedVar[], + ): Promise<{ ok: true; results: readonly VerifiedVar[] } | { ok: false; code: ErrorCode; status?: number }> => { + const item = applied[index]; + if (!item) return { ok: true, results: done }; + if (item.outcome === 'unchanged') { + // Equal by comparison against the decrypted list read — already verified. + return step(index + 1, [...done, { name: item.name, state: 'verified' }]); + } + try { + const rec = await client.getEnv(item.envId); + const readable = readableValue(rec); + const state: VerifyState = + readable === undefined + ? 'unverified' + : sha256(readable) === sha256(expected[item.name] ?? '') + ? 'verified' + : 'mismatch'; + return step(index + 1, [...done, { name: item.name, state }]); + } catch (err) { + return failureOf(err); + } + }; + return step(0, []); +} + +/** Names in `names`, sorted — for stable, value-free messages. */ +export function sortedNames(names: readonly string[]): string[] { + return sortedCopy(names); +} + +// ── Messages ─────────────────────────────────────────────────────────────── + +/** A coded refusal as a printable reason + hint. Callers branch on `code`; this text is display only. */ +export function describeVercelFailure( + code: ErrorCode, + status?: number, +): { reason: string; hint?: string } { + const http = status !== undefined ? ` (HTTP ${status})` : ''; + if (code === ERROR_CODES.VERCEL_TOKEN_REJECTED) { + // COPY-FLAG + return { reason: `Vercel rejected the token${http}`, hint: `Replace it: capy system set ${VERCEL_TARGET_SECRET_NAME}` }; + } + if (code === ERROR_CODES.VERCEL_NO_ACCESS) { + // COPY-FLAG + return { reason: `the Vercel token has no access to this project${http}` }; + } + if (code === ERROR_CODES.VERCEL_PROJECT_NOT_FOUND) { + // COPY-FLAG + return { reason: `Vercel project not found${http}`, hint: 'Check options.projectId and options.teamId.' }; + } + if (code === ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND) { + // COPY-FLAG + return { reason: `Vercel environment not found on the project${http}`, hint: 'Check options.vercelEnv / options.customEnvironmentId.' }; + } + if (code === ERROR_CODES.VERCEL_NETWORK_ERROR) { + // COPY-FLAG + return { reason: 'could not reach the Vercel API' }; + } + if (code === ERROR_CODES.VERCEL_RATE_LIMITED) { + // COPY-FLAG + return { reason: `Vercel rate limit reached${http}`, hint: 'Wait a minute and run again.' }; + } + if (code === ERROR_CODES.VERCEL_SPLIT_NOT_RESTORED) { + // COPY-FLAG + return { reason: 'a shared Vercel variable lost this environment and could not be restored', hint: 'Check the project’s Environment Variables in Vercel.' }; + } + // COPY-FLAG + return { reason: `Vercel API call failed (${code}${http})` }; +} + +/** Narrows a store-supplied code string to a known `ErrorCode`, else the generic token-missing code. */ +export function asKnownErrorCode(code: string): ErrorCode { + const known = Object.values(ERROR_CODES) as readonly string[]; + return known.includes(code) ? (code as ErrorCode) : ERROR_CODES.VERCEL_TOKEN_MISSING; +} diff --git a/src/types/index.ts b/src/types/index.ts index 053a0183..c89ee895 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -664,6 +664,31 @@ export const ERROR_CODES = { * to clean it up manually. */ DEPLOY_TOKEN_UNTRACKED: 'DEPLOY_TOKEN_UNTRACKED', + // CAP-696 Vercel target + /** API-mode Vercel deploy: no `_TARGET_VERCEL_TOKEN` (or `VERCEL_TOKEN`) anywhere. */ + VERCEL_TOKEN_MISSING: 'VERCEL_TOKEN_MISSING', + /** `_TARGET_VERCEL_TOKEN` is absent but `_CONNECTOR_VERCEL_TOKEN` holds one, and there is no TTY to choose. */ + VERCEL_TARGET_KEY_MISSING: 'VERCEL_TARGET_KEY_MISSING', + /** Vercel answered 401: the token is not valid. */ + VERCEL_TOKEN_REJECTED: 'VERCEL_TOKEN_REJECTED', + /** Vercel answered 403: the token has no access to this project or team. */ + VERCEL_NO_ACCESS: 'VERCEL_NO_ACCESS', + /** Vercel answered 404 for the project id. */ + VERCEL_PROJECT_NOT_FOUND: 'VERCEL_PROJECT_NOT_FOUND', + /** The custom environment named in the target does not exist on the project. */ + VERCEL_ENVIRONMENT_NOT_FOUND: 'VERCEL_ENVIRONMENT_NOT_FOUND', + /** The request never got an answer from Vercel. */ + VERCEL_NETWORK_ERROR: 'VERCEL_NETWORK_ERROR', + /** Vercel kept answering 429 (or asked for a wait longer than we allow). */ + VERCEL_RATE_LIMITED: 'VERCEL_RATE_LIMITED', + /** Any other unexpected Vercel API answer. */ + VERCEL_API_ERROR: 'VERCEL_API_ERROR', + /** After writing, the value Vercel holds hashes differently from the one sent. */ + VERCEL_VERIFY_MISMATCH: 'VERCEL_VERIFY_MISMATCH', + /** The API-mode target's options are incomplete or malformed. */ + VERCEL_OPTIONS_INVALID: 'VERCEL_OPTIONS_INVALID', + /** A shared record had this environment removed, but creating its replacement failed and the removal could not be undone. */ + VERCEL_SPLIT_NOT_RESTORED: 'VERCEL_SPLIT_NOT_RESTORED', } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/tests/commands/deployVercelApiMode.test.ts b/tests/commands/deployVercelApiMode.test.ts new file mode 100644 index 00000000..4cd1ff1b --- /dev/null +++ b/tests/commands/deployVercelApiMode.test.ts @@ -0,0 +1,292 @@ +/** + * A REAL `deployCommand()` run against a Vercel target in API mode (CAP-696), + * against a real git repo + bare origin (same vehicle as + * `deployCiTargetsRecord.test.ts`) and an in-memory Vercel + * (`tests/deploy/vercelApiModeFake.ts`) standing in for `fetch`. + * + * What it proves, end to end: + * - the token is asked of the org system store ONCE, by the documented name, + * and never printed; + * - a preview target with no gitBranch deploys under `--yes` (the CLI path's + * "missing gitBranch" refusal does not apply in API mode); + * - the keep.lock PR still opens, and the delivery is recorded on keep.lock + * (`provider: 'vercel'`, target name, `ref.projectId`, deployed hash); + * - a dry run reads but never writes, and records nothing; + * - a refused preflight / failed delivery sends no value and records nothing; + * - a target WITHOUT projectId never asks the store or Vercel for anything. + * + * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, mock, spyOn, afterEach } from 'bun:test'; +import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { spawnSync } from 'child_process'; +import { SyncEngine } from '../../src/sync/syncEngine'; + +const TMP = join(tmpdir(), `capy-deploy-vercel-api-${process.pid}`); +const REPO = join(TMP, 'repo'); +const ORIGIN = join(TMP, 'origin.git'); +const SERVER_SNAPSHOTS_PATH = join(TMP, 'server-snapshots.json'); + +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + async authenticateSilent() { + return { success: true, user_id: 'user_1' }; + } + async getValidToken() { + return 'fake-session-token'; + } + }, + silentAuthFailureMessage: () => 'auth failed', +})); + +const pushSecretsMock = mock(async (_projectId: string, keepFileJson: string, _envBlob: string, _branch: string) => ({ + keep_hash: 'h'.repeat(16), + keep_file: keepFileJson, +})); +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + async pushSecrets(projectId: string, keepFileJson: string, envBlob: string, branch: string) { + return pushSecretsMock(projectId, keepFileJson, envBlob, branch); + } + async getLatestSecrets(_projectId: string, branch: string) { + if (!existsSync(SERVER_SNAPSHOTS_PATH)) return null; + const snapshots = JSON.parse(readFileSync(SERVER_SNAPSHOTS_PATH, 'utf-8')) as Record; + const snap = snapshots[branch]; + if (!snap) return null; + return { + env_file: snap.envFile, + keep_hash: SyncEngine.computeKeepHash(JSON.parse(snap.keepFile), branch), + keep_file: snap.keepFile, + }; + } + }, +})); +mock.module('../../src/utils/gh', () => ({ + resolveGh: () => null, + GH_SEARCHED: ['PATH'], +})); + +afterEach(() => { + mock.restore(); + pushSecretsMock.mockClear(); +}); + +import { deployCommand } from '../../src/commands/deployCommand'; +import { hashValue } from '../../src/deploy/keepGate'; +import { serializeKeep } from '../../src/files/fileManager'; +import type { KeepFile } from '../../src/types/index'; +import { createFakeVercel, type FakeRecord, type FakeVercelOptions } from '../deploy/vercelApiModeFake'; + +function git(args: string[], cwd: string) { + const r = spawnSync('git', args, { cwd, encoding: 'utf-8' }); + return { code: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +const TOKEN = 'tok_fake_value_never_print'; +const STRIPE_VALUE = 'same-value'; + +function baseKeep(): KeepFile { + return { + version: '3.0', + org_id: 'org_1', + project_id: 'proj_1', + project_name: 'demo', + variables: { + STRIPE_KEY: [{ resource_id: 'r-1', branch: 'main', value_hash: hashValue(STRIPE_VALUE) }], + }, + } as unknown as KeepFile; +} + +function writeServerSnapshot(branch: string): void { + writeFileSync( + SERVER_SNAPSHOTS_PATH, + JSON.stringify({ [branch]: { keepFile: JSON.stringify(baseKeep()), envFile: 'STRIPE_KEY=SERVER_STORED_BLOB_UNCHANGED' } }), + ); +} + +const API_OPTIONS = { projectDir: 'web', projectId: 'prj_1', teamId: 'team_1' }; + +function setUpRepo(targetOptions: Record): void { + rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + git(['init', '--bare', '-b', 'main', ORIGIN], TMP); + git(['init', '-q', '-b', 'main', REPO], TMP); + git(['config', 'user.email', 't@e.com'], REPO); + git(['config', 'user.name', 'T'], REPO); + git(['config', 'commit.gpgsign', 'false'], REPO); + git(['remote', 'add', 'origin', ORIGIN], REPO); + mkdirSync(join(REPO, '.capy'), { recursive: true }); + writeFileSync(join(REPO, 'keep.lock'), serializeKeep(baseKeep())); + writeFileSync(join(REPO, '.env'), `STRIPE_KEY=${STRIPE_VALUE}\n`); + writeFileSync( + join(REPO, '.capy', 'deploy.json'), + JSON.stringify({ + version: '1', + targets: { + 'vercel-api': { + name: 'vercel-api', + kind: 'vercel', + branch: 'main', + vars: ['STRIPE_KEY'], + mode: 'ci', + gitBaseBranch: 'main', + options: targetOptions, + }, + }, + }), + ); + git(['add', '.'], REPO); + git(['commit', '-q', '-m', 'base'], REPO); + git(['push', '-q', 'origin', 'main'], REPO); + writeServerSnapshot('main'); +} + +interface Run { + code: number; + lines: readonly string[]; + vercelWrites: number; + vercelCalls: number; + fake: ReturnType; + storeAsks: ReadonlyArray; +} + +/** + * Runs the deploy with console, Vercel and the system store faked. `store` + * is what `getDirectionalConnectorSecret` answers (the real one is mocked + * out: this file is about the wiring, `vercelApiModeTokenStore.test.ts` is + * about the store itself). Every count is read off bun's own spies. + */ +async function run( + options: Parameters[1], + fakeOpts: FakeVercelOptions = {}, + store: string | null = TOKEN, +): Promise { + const fake = createFakeVercel({ projectId: 'prj_1', teamId: 'team_1', token: TOKEN, ...fakeOpts }); + const getDirectional = mock(async (..._args: unknown[]) => store); + mock.module('../../src/system/systemStore', () => ({ getDirectionalConnectorSecret: getDirectional })); + const logMock = mock((..._a: unknown[]) => {}); + const errMock = mock((..._a: unknown[]) => {}); + const logSpy = spyOn(console, 'log').mockImplementation(logMock as never); + const errSpy = spyOn(console, 'error').mockImplementation(errMock as never); + const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(((input: string, init?: RequestInit) => fake.fetch(String(input), init)) as never); + try { + const code = await deployCommand('vercel-api', options, REPO); + return { + code, + lines: [...logMock.mock.calls, ...errMock.mock.calls].map((args) => args.map(String).join(' ')), + vercelWrites: fake.writeCalls().length, + vercelCalls: fake.calls().length, + fake, + storeAsks: getDirectional.mock.calls, + }; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + fetchSpy.mockRestore(); + } +} + +function withCwd(fn: () => Promise): Promise { + const original = process.cwd(); + process.chdir(REPO); + return fn().finally(() => process.chdir(original)); +} + +const rec = (over: Partial & Pick): FakeRecord => ({ type: 'encrypted', ...over }); + +describe('capy deploy — Vercel API mode, end to end (CAP-696)', () => { + afterEach(() => rmSync(TMP, { recursive: true, force: true })); + + test('preview target with NO gitBranch deploys under --yes: values land, the PR branch is pushed, the delivery is recorded', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'preview' }); + const result = await withCwd(() => + run( + { yes: true }, + { records: [rec({ id: 'env_shared', key: 'STRIPE_KEY', value: 'old', target: ['production', 'preview'] })] }, + ), + ); + expect(result.code).toBe(0); + // Property: preview got the new value, production did not move. + expect(result.fake.seenBy('preview', 'STRIPE_KEY')).toBe(STRIPE_VALUE); + expect(result.fake.seenBy('production', 'STRIPE_KEY')).toBe('old'); + // The system store was asked once, for the documented name, with the connector as the fallback. + expect(result.storeAsks).toHaveLength(1); + const [name, fallback, opts] = result.storeAsks[0] as [string, string, Record]; + expect(name).toBe('_TARGET_VERCEL_TOKEN'); + expect(fallback).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(opts).toMatchObject({ interactive: false, missingWithFallbackCode: 'VERCEL_TARGET_KEY_MISSING' }); + // Never printed. + expect(result.lines.some((l) => l.includes(TOKEN))).toBe(false); + // The keep.lock PR branch exists on origin: the deploy PR still opens in API mode. + expect(git(['branch', '--list', 'capy-deploy-*'], ORIGIN).stdout).toContain('capy-deploy-'); + // Recorded on the server's keep.lock. + expect(pushSecretsMock).toHaveBeenCalledTimes(1); + const pushedKeep = JSON.parse(pushSecretsMock.mock.calls[0][1]); + const targets = pushedKeep.variables.STRIPE_KEY[0].targets; + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + provider: 'vercel', + target: 'vercel-api', + ref: { projectId: 'prj_1' }, + deployed_value_hash: hashValue(STRIPE_VALUE), + }); + expect(targets[0].ref.envId).toBeUndefined(); + }, 30_000); + + test('a custom environment target records its environment id as ref.envId', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'staging', customEnvironmentId: 'env_stg' }); + const result = await withCwd(() => run({ yes: true }, { customEnvs: [{ id: 'env_stg', slug: 'staging' }] })); + expect(result.code).toBe(0); + expect(result.fake.seenBy('custom:env_stg', 'STRIPE_KEY')).toBe(STRIPE_VALUE); + const targets = JSON.parse(pushSecretsMock.mock.calls[0][1]).variables.STRIPE_KEY[0].targets; + expect(targets[0].ref).toEqual({ projectId: 'prj_1', envId: 'env_stg' }); + }, 30_000); + + test('--dry-run reads but writes nothing: no Vercel write, no push to Capy, no PR branch', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true, dryRun: true })); + expect(result.code).toBe(0); + expect(result.vercelWrites).toBe(0); + expect(result.fake.records()).toEqual([]); + expect(pushSecretsMock).not.toHaveBeenCalled(); + expect(git(['branch', '--list', 'capy-deploy-*'], ORIGIN).stdout.trim()).toBe(''); + expect(result.lines.some((l) => l.includes('would push 1 var(s) to production'))).toBe(true); + }, 30_000); + + test('no token anywhere + no TTY: refused with the key named, zero Vercel requests, nothing recorded', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true }, {}, null)); + expect(result.code).toBe(1); + expect(result.vercelCalls).toBe(0); + expect(result.lines.some((l) => l.includes('_TARGET_VERCEL_TOKEN'))).toBe(true); + expect(pushSecretsMock).not.toHaveBeenCalled(); + }, 30_000); + + test('a token Vercel rejects: refused at preflight before any value is sent, nothing recorded', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true }, { token: 'a-different-token' })); + expect(result.code).toBe(1); + expect(result.vercelWrites).toBe(0); + expect(pushSecretsMock).not.toHaveBeenCalled(); + }, 30_000); + + test('a failed delivery fails the run and records nothing', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true }, { failPostFor: ['STRIPE_KEY'] })); + expect(result.code).toBe(1); + expect(pushSecretsMock).not.toHaveBeenCalled(); + expect(git(['branch', '--list', 'capy-deploy-*'], ORIGIN).stdout.trim()).toBe(''); + }, 30_000); + + test('a target WITHOUT projectId never asks the store for a token and never calls Vercel', async () => { + // The old path: no projectId. The project dir is absent, so its own preflight refuses — before anything else. + setUpRepo({ projectDir: 'web', vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true })); + expect(result.code).toBe(1); + expect(result.storeAsks).toHaveLength(0); + expect(result.vercelCalls).toBe(0); + }, 30_000); +}); diff --git a/tests/deploy/vercelApiMode.test.ts b/tests/deploy/vercelApiMode.test.ts new file mode 100644 index 00000000..5194f01a --- /dev/null +++ b/tests/deploy/vercelApiMode.test.ts @@ -0,0 +1,536 @@ +/** + * Vercel deploy target, API mode (CAP-696): a target whose options carry a + * `projectId` writes env vars through Vercel's REST API with a token — no + * `vercel` binary, no login, no `.vercel/project.json`. + * + * Every write test asserts the PROPERTY, against an in-memory Vercel + * (`vercelApiModeFake.ts`) that applies the POSTs and PATCHes it receives: + * "what does each environment see afterwards?", never "was this request sent". + * `fetch` is injected, so nothing here reaches the network, and every token + * and value is a fake. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createVercelAdapter } from '../../src/deploy/adapters/vercel'; +import type { DeployAdapter, DeployContext, TargetConfig } from '../../src/deploy/adapter'; +import { ERROR_CODES } from '../../src/types/index'; +import { createFakeVercel, type FakeRecord, type FakeVercelOptions } from './vercelApiModeFake'; + +const TOKEN = 'tok_fake_value'; +const PROJECT = 'prj_1'; +const TEAM = 'team_1'; + +function rec(over: Partial & Pick): FakeRecord { + return { type: 'encrypted', ...over }; +} + +function setUp(fakeOpts: FakeVercelOptions = {}) { + const fake = createFakeVercel({ projectId: PROJECT, teamId: TEAM, token: TOKEN, ...fakeOpts }); + const sleep = mock(async (_ms: number) => {}); + const adapter: DeployAdapter = createVercelAdapter({ + fetch: fake.fetch, + sleep, + now: () => 1_000_000_000_000, + env: {}, + getTargetSecret: async () => TOKEN, + }); + return { fake, sleep, adapter }; +} + +function target(options: Record, vars: readonly string[] = ['API_KEY']): TargetConfig { + return { + name: 'vercel-api', + kind: 'vercel', + branch: 'production', + vars: [...vars], + mode: 'ci', + options: { projectDir: 'web', projectId: PROJECT, teamId: TEAM, ...options }, + }; +} + +function ctx(env: Record, over: Partial = {}): DeployContext { + return { env, dryRun: false, secretsOnly: true, cwd: '/nonexistent-cwd', interactive: false, ...over }; +} + +describe('vercel API mode — write plan, shared records', () => { + test('writing production NEVER changes what preview or development sees (shared record is split, not overwritten)', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old-shared', target: ['production', 'preview', 'development'] })], + }); + const before = { + preview: fake.seenBy('preview', 'API_KEY'), + development: fake.seenBy('development', 'API_KEY'), + }; + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new-prod' })); + + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('new-prod'); + expect(fake.seenBy('preview', 'API_KEY')).toBe(before.preview); + expect(fake.seenBy('development', 'API_KEY')).toBe(before.development); + expect(fake.seenBy('preview', 'API_KEY')).toBe('old-shared'); + // The shared record is still there, now serving only the others; nothing was deleted or upserted. + const shared = fake.records().find((r) => r.id === 'env_shared'); + expect(shared?.target).toEqual(['preview', 'development']); + expect(shared?.value).toBe('old-shared'); + expect(fake.writeCalls().some((c) => c.method === 'DELETE')).toBe(false); + expect(fake.calls().some((c) => c.query.upsert !== undefined)).toBe(false); + }); + + test('writing preview leaves production and development exactly as they were', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old-shared', target: ['production', 'preview', 'development'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'preview' }), ctx({ API_KEY: 'new-preview' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('preview', 'API_KEY')).toBe('new-preview'); + expect(fake.seenBy('production', 'API_KEY')).toBe('old-shared'); + expect(fake.seenBy('development', 'API_KEY')).toBe('old-shared'); + }); + + test('a shared record whose value already equals ours is left alone', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'same', target: ['production', 'preview'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'same' })); + expect(result.ok).toBe(true); + expect(fake.writeCalls()).toEqual([]); + expect(fake.records().map((r) => r.target)).toEqual([['production', 'preview']]); + }); + + test('if creating the replacement record fails after the split, the shared record is put back', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old-shared', target: ['production', 'preview'] })], + failPostFor: ['API_KEY'], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(false); + expect(result.steps[0].code).toBe(ERROR_CODES.VERCEL_API_ERROR); + // Production did not lose its variable: the shared record serves both again. + expect(fake.seenBy('production', 'API_KEY')).toBe('old-shared'); + expect(fake.seenBy('preview', 'API_KEY')).toBe('old-shared'); + expect(fake.records().find((r) => r.id === 'env_shared')?.target).toEqual(['production', 'preview']); + }); +}); + +describe('vercel API mode — write plan, other cases', () => { + test('a record for exactly this environment gets its value edited in place (value only)', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(fake.records().map((r) => [r.id, r.value])).toEqual([['env_prod', 'new']]); + const writes = fake.writeCalls(); + expect(writes).toHaveLength(1); + expect(writes[0].method).toBe('PATCH'); + expect(writes[0].body).toEqual({ value: 'new' }); + }); + + test('no record yet: creates one for this environment only, as an encrypted var', async () => { + const { fake, adapter } = setUp(); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'v1' })); + expect(result.ok).toBe(true); + expect(fake.records()).toHaveLength(1); + expect(fake.records()[0]).toMatchObject({ key: 'API_KEY', value: 'v1', type: 'encrypted', target: ['production'] }); + expect(fake.seenBy('preview', 'API_KEY')).toBeUndefined(); + expect(fake.seenBy('development', 'API_KEY')).toBeUndefined(); + }); + + test('value already equal: nothing is written', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'same', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'same' })); + expect(result.ok).toBe(true); + expect(fake.writeCalls()).toEqual([]); + }); + + test('a sensitive record stays sensitive, and is reported written-but-not-readable-back, never verified', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', type: 'sensitive', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(fake.records()[0]).toMatchObject({ type: 'sensitive', value: 'new' }); + // The PATCH never sends a type, so it cannot downgrade the record. + expect(fake.writeCalls()[0].body).toEqual({ value: 'new' }); + const verify = result.steps.find((s) => s.label === 'verify Vercel env'); + expect(verify?.detail).toContain('0 verified'); + expect(verify?.detail).toContain('API_KEY'); + expect(verify?.detail).toContain('not readable back'); + }); + + test('a split off a sensitive shared record creates the new record as sensitive too', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old', type: 'sensitive', target: ['production', 'preview'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + const created = fake.records().find((r) => r.id !== 'env_shared'); + expect(created).toMatchObject({ type: 'sensitive', target: ['production'], value: 'new' }); + expect(fake.seenBy('preview', 'API_KEY')).toBe('old'); + }); + + test('a team that turns every new production var sensitive: the write succeeds and is reported unverified', async () => { + const { fake, adapter } = setUp({ enforceSensitive: true }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'v1' })); + expect(result.ok).toBe(true); + expect(fake.records()[0].type).toBe('sensitive'); + expect(result.steps.find((s) => s.label === 'verify Vercel env')?.detail).toContain('not readable back'); + }); + + test('development is allowed in API mode and is written on its own', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old', target: ['production', 'development'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'development' }), ctx({ API_KEY: 'dev-only' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('development', 'API_KEY')).toBe('dev-only'); + expect(fake.seenBy('production', 'API_KEY')).toBe('old'); + }); + + test('keys that are not in config.vars are never touched, not even read back', async () => { + const others = [ + rec({ id: 'env_other', key: 'OTHER', value: 'keep-me', target: ['production'] }), + rec({ id: 'env_other2', key: 'ANOTHER', value: 'keep-me-too', target: ['production', 'preview'] }), + ]; + const { fake, adapter } = setUp({ + records: [...others, rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new', OTHER: 'ignored', ANOTHER: 'ignored' })); + expect(result.ok).toBe(true); + expect(fake.records().filter((r) => r.key !== 'API_KEY')).toEqual(others); + expect(fake.calls().filter((c) => c.path.includes('env_other'))).toEqual([]); + }); + + test('several vars: each is planned on its own', async () => { + const { fake, adapter } = setUp({ + records: [ + rec({ id: 'e1', key: 'A', value: 'old-a', target: ['production'] }), + rec({ id: 'e2', key: 'B', value: 'same-b', target: ['production'] }), + rec({ id: 'e3', key: 'C', value: 'old-c', target: ['production', 'preview'] }), + ], + }); + const result = await adapter.deploy( + target({ vercelEnv: 'production' }, ['A', 'B', 'C', 'D']), + ctx({ A: 'new-a', B: 'same-b', C: 'new-c', D: 'new-d' }), + ); + expect(result.ok).toBe(true); + expect(['A', 'B', 'C', 'D'].map((k) => fake.seenBy('production', k))).toEqual(['new-a', 'same-b', 'new-c', 'new-d']); + expect(fake.seenBy('preview', 'C')).toBe('old-c'); + expect(result.steps[0].detail).toContain('created 1, updated 1, split from a shared record 1, unchanged 1'); + }); +}); + +describe('vercel API mode — preview scope', () => { + test('preview with a gitBranch writes a branch-scoped record and leaves the all-branches one alone', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_all', key: 'API_KEY', value: 'for-all-branches', target: ['preview'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'preview', gitBranch: 'staging' }), ctx({ API_KEY: 'staging-value' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('preview', 'API_KEY', 'staging')).toBe('staging-value'); + expect(fake.seenBy('preview', 'API_KEY', 'some-other-branch')).toBe('for-all-branches'); + expect(fake.records().find((r) => r.id !== 'env_all')).toMatchObject({ gitBranch: 'staging', target: ['preview'] }); + }); + + test('preview WITHOUT a gitBranch applies to all preview branches: the record carries no gitBranch', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_branch', key: 'API_KEY', value: 'staging-only', target: ['preview'], gitBranch: 'staging' })], + }); + const preflight = await adapter.preflight(target({ vercelEnv: 'preview' }), { cwd: '/nonexistent-cwd' }); + expect(preflight.ok).toBe(true); + const result = await adapter.deploy(target({ vercelEnv: 'preview' }), ctx({ API_KEY: 'everywhere' })); + expect(result.ok).toBe(true); + const created = fake.records().find((r) => r.id !== 'env_branch'); + expect(created?.gitBranch).toBeUndefined(); + expect(fake.writeCalls()[0].body).not.toHaveProperty('gitBranch'); + expect(fake.seenBy('preview', 'API_KEY', 'any-branch')).toBe('everywhere'); + // The branch-specific record is a different scope and keeps its own value. + expect(fake.seenBy('preview', 'API_KEY', 'staging')).toBe('staging-only'); + }); + + test('updating an existing all-branches preview record patches that record', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_all', key: 'API_KEY', value: 'old', target: ['preview'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'preview' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(fake.records().map((r) => [r.id, r.value])).toEqual([['env_all', 'new']]); + }); +}); + +describe('vercel API mode — custom environments', () => { + const customEnvs = [{ id: 'env_stg', slug: 'staging' }, { id: 'env_qa', slug: 'qa' }]; + + test('a custom environment slug is resolved to its id; the record serves only that environment', async () => { + const { fake, adapter } = setUp({ customEnvs }); + const result = await adapter.deploy(target({ vercelEnv: 'staging' }), ctx({ API_KEY: 'stg-value' })); + expect(result.ok).toBe(true); + const created = fake.records()[0]; + expect(created.customEnvironmentIds).toEqual(['env_stg']); + expect(created.target).toEqual([]); + expect(fake.seenBy('custom:env_stg', 'API_KEY')).toBe('stg-value'); + expect(fake.seenBy('production', 'API_KEY')).toBeUndefined(); + }); + + test('customEnvironmentId in the options is used as given', async () => { + const { fake, adapter } = setUp({ customEnvs }); + const result = await adapter.deploy(target({ vercelEnv: 'staging', customEnvironmentId: 'env_stg' }), ctx({ API_KEY: 'x' })); + expect(result.ok).toBe(true); + expect(fake.records()[0].customEnvironmentIds).toEqual(['env_stg']); + }); + + test('writing one custom environment leaves the other custom environment on a shared record untouched', async () => { + const { fake, adapter } = setUp({ + customEnvs, + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old', target: [], customEnvironmentIds: ['env_stg', 'env_qa'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'staging' }), ctx({ API_KEY: 'stg-only' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('custom:env_stg', 'API_KEY')).toBe('stg-only'); + expect(fake.seenBy('custom:env_qa', 'API_KEY')).toBe('old'); + }); + + test('a custom environment that does not exist is refused at preflight, before any value is sent', async () => { + const { fake, adapter } = setUp({ customEnvs }); + const result = await adapter.preflight(target({ vercelEnv: 'nope' }), { cwd: '/nonexistent-cwd' }); + expect(result.ok).toBe(false); + expect(result.code).toBe(ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND); + expect(fake.writeCalls()).toEqual([]); + }); +}); + +describe('vercel API mode — rate limits', () => { + test('a 429 is waited out (retry-after, in seconds) and the write then lands', async () => { + const { fake, adapter, sleep } = setUp({ + rateLimit: { count: 2, headers: { 'retry-after': '3' } }, + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('new'); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([3000, 3000]); + }); + + test('x-ratelimit-reset (epoch seconds) is used when there is no retry-after', async () => { + // now() is fixed at 1_000_000_000_000 ms; a reset 7s later means a 7000 ms wait. + const { adapter, sleep } = setUp({ + rateLimit: { count: 1, headers: { 'x-ratelimit-reset': String(1_000_000_000 + 7) } }, + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([7000]); + }); + + test('429 forever: bounded retries, then a coded failure', async () => { + const { fake, adapter, sleep } = setUp({ rateLimit: { count: 99, headers: { 'retry-after': '1' } } }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(false); + expect(result.steps[0].code).toBe(ERROR_CODES.VERCEL_RATE_LIMITED); + expect(sleep).toHaveBeenCalledTimes(3); + expect(fake.records()).toEqual([]); + }); + + test('a wait longer than the cap is not waited for', async () => { + const { adapter, sleep } = setUp({ rateLimit: { count: 1, headers: { 'retry-after': '3600' } } }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(false); + expect(result.steps[0].code).toBe(ERROR_CODES.VERCEL_RATE_LIMITED); + expect(sleep).not.toHaveBeenCalled(); + }); +}); + +describe('vercel API mode — preflight', () => { + const prefl = (adapter: DeployAdapter, options: Record = { vercelEnv: 'production' }) => + adapter.preflight(target(options), { cwd: '/nonexistent-cwd' }); + + test('good token, existing project: ok — with no .vercel/project.json and no project directory on disk', async () => { + const { adapter } = setUp(); + expect((await prefl(adapter)).ok).toBe(true); + }); + + test('401 → VERCEL_TOKEN_REJECTED', async () => { + const { adapter, fake } = setUp({ token: 'a-different-token' }); + const r = await prefl(adapter); + expect(r.ok).toBe(false); + expect(r.code).toBe(ERROR_CODES.VERCEL_TOKEN_REJECTED); + expect(fake.writeCalls()).toEqual([]); + }); + + test('403 → VERCEL_NO_ACCESS', async () => { + const { adapter } = setUp({ projectStatus: 403 }); + expect((await prefl(adapter)).code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + }); + + test('404 → VERCEL_PROJECT_NOT_FOUND', async () => { + const { adapter } = setUp(); + const r = await prefl(adapter, { vercelEnv: 'production', projectId: 'prj_gone' }); + expect(r.code).toBe(ERROR_CODES.VERCEL_PROJECT_NOT_FOUND); + }); + + test('a dropped connection → VERCEL_NETWORK_ERROR', async () => { + const { adapter } = setUp({ dropConnection: true }); + expect((await prefl(adapter)).code).toBe(ERROR_CODES.VERCEL_NETWORK_ERROR); + }); + + test('a failed preflight means deploy was never asked to send a value: preflight itself makes no write', async () => { + const { adapter, fake } = setUp({ projectStatus: 403 }); + await prefl(adapter); + expect(fake.writeCalls()).toEqual([]); + }); + + test('an empty vars list is refused', async () => { + const { adapter } = setUp(); + const r = await adapter.preflight(target({ vercelEnv: 'production' }, []), { cwd: '/nonexistent-cwd' }); + expect(r.ok).toBe(false); + }); + + test('missing vercelEnv is a coded options refusal', async () => { + const { adapter } = setUp(); + const r = await adapter.preflight(target({}), { cwd: '/nonexistent-cwd' }); + expect(r.ok).toBe(false); + expect(r.code).toBe(ERROR_CODES.VERCEL_OPTIONS_INVALID); + }); +}); + +describe('vercel API mode — dry run', () => { + test('zero write calls; says what it would do with names and counts, never a value', async () => { + const { fake, adapter } = setUp({ + records: [ + rec({ id: 'e1', key: 'A', value: 'secret-a', target: ['production'] }), + rec({ id: 'e2', key: 'C', value: 'secret-c', target: ['production', 'preview'] }), + ], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }, ['A', 'B', 'C']), ctx({}, { dryRun: true })); + expect(result.ok).toBe(true); + expect(fake.writeCalls()).toEqual([]); + const detail = result.steps[0].detail ?? ''; + expect(detail).toContain('would push 3 var(s) to production'); + expect(detail).toContain('create 1 [B]'); + expect(detail).toContain('update 1 [A]'); + expect(detail).toContain('split from a shared record 1 [C]'); + expect(detail).not.toContain('secret-a'); + expect(detail).not.toContain('secret-c'); + expect(fake.records().map((r) => r.value)).toEqual(['secret-a', 'secret-c']); + }); +}); + +describe('vercel API mode — read-back verification', () => { + test('verified: values read back hash to what was sent', async () => { + const { adapter } = setUp({ + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(result.steps.find((s) => s.label === 'verify Vercel env')).toMatchObject({ status: 'ok', detail: '1 verified' }); + }); + + test('mismatch: Vercel holding another value is a coded failure naming the var, never the values', async () => { + const { adapter } = setUp({ + corruptWrites: true, + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new-secret-value' })); + expect(result.ok).toBe(false); + const step = result.steps.find((s) => s.label === 'verify Vercel env'); + expect(step?.status).toBe('fail'); + expect(step?.code).toBe(ERROR_CODES.VERCEL_VERIFY_MISMATCH); + expect(step?.detail).toContain('API_KEY'); + expect(JSON.stringify(result)).not.toContain('new-secret-value'); + expect(JSON.stringify(result)).not.toContain('corrupted'); + }); +}); + +describe('vercel API mode — token handling', () => { + test('the token is only ever sent as a bearer header, and never appears in any result', async () => { + const { fake, adapter } = setUp(); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'v' })); + expect(fake.calls().every((c) => c.authorization === `Bearer ${TOKEN}`)).toBe(true); + expect(JSON.stringify(result)).not.toContain(TOKEN); + expect(fake.calls().every((c) => !JSON.stringify(c.query).includes(TOKEN))).toBe(true); + }); + + test('a pre-resolved token from the caller wins, and the store is not asked again', async () => { + const fake = createFakeVercel({ projectId: PROJECT, teamId: TEAM, token: TOKEN }); + const getTargetSecret = mock(async () => 'should-not-be-used'); + const adapter = createVercelAdapter({ fetch: fake.fetch, env: {}, getTargetSecret }); + const r = await adapter.preflight(target({ vercelEnv: 'production' }), { + cwd: '/nonexistent-cwd', + resolvedVercelToken: { ok: true, value: TOKEN, source: 'system' }, + }); + expect(r.ok).toBe(true); + expect(getTargetSecret).not.toHaveBeenCalled(); + }); + + test('a refused token resolution is a coded preflight refusal and nothing is sent to Vercel', async () => { + const fake = createFakeVercel({ projectId: PROJECT, teamId: TEAM, token: TOKEN }); + const adapter = createVercelAdapter({ fetch: fake.fetch, env: {}, getTargetSecret: async () => null }); + const r = await adapter.preflight(target({ vercelEnv: 'production' }), { cwd: '/nonexistent-cwd' }); + expect(r.ok).toBe(false); + expect(r.code).toBe(ERROR_CODES.VERCEL_TOKEN_MISSING); + expect(fake.calls()).toEqual([]); + }); + + test('a system-store refusal passes its own code through', async () => { + const fake = createFakeVercel({ projectId: PROJECT, token: TOKEN }); + const adapter = createVercelAdapter({ + fetch: fake.fetch, + env: {}, + getTargetSecret: async () => { + throw Object.assign(new Error('x'), { code: ERROR_CODES.VERCEL_TARGET_KEY_MISSING }); + }, + }); + const r = await adapter.preflight(target({ vercelEnv: 'production' }), { cwd: '/nonexistent-cwd' }); + expect(r.code).toBe(ERROR_CODES.VERCEL_TARGET_KEY_MISSING); + }); +}); + +describe('vercel — CLI path is unchanged when projectId is absent', () => { + test('a target without projectId never touches fetch, and keeps its CLI-path refusals', async () => { + const fetchSpy = mock(async () => new Response('{}')); + const adapter = createVercelAdapter({ fetch: fetchSpy as never, env: {}, getTargetSecret: async () => TOKEN }); + const legacy: TargetConfig = { + name: 'v', + kind: 'vercel', + branch: 'production', + vars: ['A'], + mode: 'ci', + options: { projectDir: 'web', vercelEnv: 'production' }, + }; + // Not linked, no TTY: exactly the old refusal (the project dir is checked first). + const pre = await adapter.preflight(legacy, { cwd: '/nonexistent-cwd' }); + expect(pre.ok).toBe(false); + expect(pre.reason).toMatch(/not found/); + const dep = await adapter.deploy(legacy, ctx({})); + expect(dep.ok).toBe(false); + expect(dep.steps[0].detail).toMatch(/missing in branch production: A/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test('legacy dry run keeps its exact wording', async () => { + const { adapter } = setUp(); + const legacy: TargetConfig = { + name: 'v', + kind: 'vercel', + branch: 'production', + vars: ['A', 'B'], + mode: 'ci', + options: { projectDir: 'web', vercelEnv: 'preview', gitBranch: 'dev' }, + }; + const r = await adapter.deploy(legacy, ctx({}, { dryRun: true })); + expect(r.steps).toEqual([ + { label: 'set Vercel env', status: 'skip', detail: 'dry-run — would push 2 var(s) to preview · branch=dev, then open the deploy PR' }, + ]); + }); + + test('an empty or blank projectId does not switch a target into API mode', async () => { + const fetchSpy = mock(async () => new Response('{}')); + const adapter = createVercelAdapter({ fetch: fetchSpy as never, env: {}, getTargetSecret: async () => TOKEN }); + const r = await adapter.preflight( + { name: 'v', kind: 'vercel', branch: 'production', vars: ['A'], mode: 'ci', options: { projectDir: 'web', vercelEnv: 'production', projectId: ' ' } }, + { cwd: '/nonexistent-cwd' }, + ); + expect(r.ok).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/deploy/vercelApiModeFake.ts b/tests/deploy/vercelApiModeFake.ts new file mode 100644 index 00000000..59afbc88 --- /dev/null +++ b/tests/deploy/vercelApiModeFake.ts @@ -0,0 +1,212 @@ +/** + * An in-memory stand-in for Vercel's REST API, for the API-mode Vercel deploy + * tests (CAP-696). It APPLIES the calls it receives — POST creates a record, + * PATCH edits one — so a test can assert the property that matters ("what does + * each environment see afterwards?") instead of the shape of a request. + * + * It models only what Vercel documents and the deploy code relies on: + * - one record may serve several environments (`target`, `customEnvironmentIds`); + * - a Preview record may be scoped to one git branch; + * - a POST that collides with an existing record is refused (403), and + * `upsert=true` would overwrite it — recorded so a test can prove the + * deploy code never sends it; + * - sensitive records never reveal their value; encrypted ones do when + * `decrypt=true`; + * - 429 with rate-limit headers, 401/403/404 and a dropped connection on demand. + * + * The store inside is genuinely mutable — it stands in for Vercel's own + * database — and is confined to this file. Tests only ever read immutable + * snapshots from it. + */ +import type { FetchLike } from '../../src/deploy/vercelTargetApi'; + +export interface FakeRecord { + id: string; + key: string; + value: string; + type: 'encrypted' | 'sensitive' | 'plain'; + target: readonly string[]; + gitBranch?: string; + customEnvironmentIds?: readonly string[]; +} + +export interface FakeCall { + method: string; + path: string; + query: Readonly>; + body?: Record; + authorization: string | null; +} + +export interface FakeVercelOptions { + projectId?: string; + teamId?: string; + token?: string; + records?: readonly FakeRecord[]; + customEnvs?: ReadonlyArray<{ id: string; slug: string }>; + /** New Production/Preview records come back `sensitive` (the team policy). */ + enforceSensitive?: boolean; + /** Store a different value than the one sent — what a failed write would look like. */ + corruptWrites?: boolean; + /** Status the project GET answers with instead of 200. */ + projectStatus?: number; + /** Every request fails like a dropped connection. */ + dropConnection?: boolean; + /** The first `count` write requests (POST/PATCH) answer 429 with these headers. */ + rateLimit?: { count: number; headers: Readonly> }; + /** POSTs for these keys answer 500. */ + failPostFor?: readonly string[]; +} + +export type StandardEnv = 'production' | 'preview' | 'development'; + +export interface FakeVercel { + fetch: FetchLike; + /** Every request received, in order (a snapshot). */ + calls(): readonly FakeCall[]; + /** Calls that could change something. */ + writeCalls(): readonly FakeCall[]; + /** Every record now stored (a snapshot, ids and all). */ + records(): readonly FakeRecord[]; + /** The value an environment actually sees for `key`. */ + seenBy(env: StandardEnv | `custom:${string}`, key: string, gitBranch?: string): string | undefined; +} + +const SERVES = (rec: FakeRecord, env: string): boolean => + rec.target.includes(env) || + (env.startsWith('custom:') && (rec.customEnvironmentIds ?? []).includes(env.slice('custom:'.length))); + +function envKeys(rec: FakeRecord): readonly string[] { + return [...rec.target, ...(rec.customEnvironmentIds ?? []).map((id) => `custom:${id}`)]; +} + +function json(status: number, body: unknown, headers: Readonly> = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +function asRecordJson(rec: FakeRecord, reveal: boolean): Record { + const readable = reveal && rec.type !== 'sensitive'; + return { + id: rec.id, + key: rec.key, + type: rec.type, + target: [...rec.target], + ...(rec.gitBranch ? { gitBranch: rec.gitBranch } : {}), + ...(rec.customEnvironmentIds ? { customEnvironmentIds: [...rec.customEnvironmentIds] } : {}), + ...(readable ? { value: rec.value } : rec.type === 'sensitive' ? {} : { value: `ciphertext(${rec.id})` }), + decrypted: readable && rec.type === 'encrypted', + }; +} + +export function createFakeVercel(opts: FakeVercelOptions = {}): FakeVercel { + const projectId = opts.projectId ?? 'prj_1'; + const token = opts.token ?? 'tok_fake_value'; + const store = new Map((opts.records ?? []).map((r) => [r.id, r])); + const log: FakeCall[] = []; + const state = { nextId: 1, rateLimited: 0 }; + + const fetchFake: FetchLike = async (input, init) => { + const url = new URL(input); + const method = (init?.method ?? 'GET').toUpperCase(); + const headers = (init?.headers ?? {}) as Record; + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : undefined; + const query = Object.fromEntries(url.searchParams.entries()); + log.push({ method, path: url.pathname, query, body, authorization: headers.Authorization ?? null }); + + if (opts.dropConnection) throw new TypeError('connection dropped'); + if (headers.Authorization !== `Bearer ${token}`) return json(401, { error: { code: 'forbidden' } }); + if (opts.teamId !== undefined && query.teamId !== opts.teamId) return json(403, { error: { code: 'forbidden' } }); + + const isWrite = method === 'POST' || method === 'PATCH' || method === 'DELETE'; + if (isWrite && opts.rateLimit && state.rateLimited < opts.rateLimit.count) { + state.rateLimited += 1; + return json(429, { error: { code: 'rate_limited' } }, opts.rateLimit.headers); + } + + const project = url.pathname.match(/^\/v9\/projects\/([^/]+)$/); + if (project && method === 'GET') { + if (opts.projectStatus && opts.projectStatus !== 200) return json(opts.projectStatus, { error: { code: 'x' } }); + return decodeURIComponent(project[1]) === projectId ? json(200, { id: projectId, name: 'demo' }) : json(404, { error: { code: 'not_found' } }); + } + const custom = url.pathname.match(/^\/v9\/projects\/([^/]+)\/custom-environments\/([^/]+)$/); + if (custom && method === 'GET') { + const wanted = decodeURIComponent(custom[2]); + const found = (opts.customEnvs ?? []).find((e) => e.id === wanted || e.slug === wanted); + return found ? json(200, { id: found.id, slug: found.slug, type: 'preview' }) : json(404, { error: { code: 'not_found' } }); + } + if (decodeURIComponent(url.pathname.split('/')[3] ?? '') !== projectId) return json(404, { error: { code: 'not_found' } }); + + const list = url.pathname.match(/^\/v10\/projects\/[^/]+\/env$/); + if (list && method === 'GET') { + const reveal = query.decrypt === 'true'; + return json(200, { envs: [...store.values()].map((r) => asRecordJson(r, reveal)) }); + } + if (list && method === 'POST') return createRecord(body ?? {}, query); + + const one = url.pathname.match(/^\/v(?:9|1)\/projects\/[^/]+\/env\/([^/]+)$/); + if (one) { + const rec = store.get(decodeURIComponent(one[1])); + if (!rec) return json(404, { error: { code: 'not_found' } }); + if (method === 'GET') return json(200, asRecordJson(rec, true)); + if (method === 'PATCH') return patchRecord(rec, body ?? {}); + } + return json(405, { error: { code: 'method_unknown' } }); + }; + + function createRecord(body: Record, query: Readonly>): Response { + const key = String(body.key); + if (opts.failPostFor?.includes(key)) return json(500, { error: { code: 'internal_server_error' } }); + const target = (body.target as string[] | undefined) ?? []; + const custom = (body.customEnvironmentIds as string[] | undefined) ?? []; + if (target.length === 0 && custom.length === 0) return json(400, { error: { code: 'bad_request' } }); + const draft: FakeRecord = { + id: `env_${state.nextId}`, + key, + value: opts.corruptWrites ? `${String(body.value)}-corrupted` : String(body.value), + type: opts.enforceSensitive && target.some((t) => t === 'production' || t === 'preview') + ? 'sensitive' + : (body.type as FakeRecord['type']), + target, + ...(body.gitBranch ? { gitBranch: String(body.gitBranch) } : {}), + ...(custom.length > 0 ? { customEnvironmentIds: custom } : {}), + }; + const collides = [...store.values()].some( + (r) => + r.key === key && + envKeys(r).some((e) => envKeys(draft).includes(e)) && + (!envKeys(r).includes('preview') || (r.gitBranch ?? '') === (draft.gitBranch ?? '')), + ); + if (collides && query.upsert !== 'true') return json(403, { error: { code: 'ENV_ALREADY_EXISTS' } }); + state.nextId += 1; + store.set(draft.id, draft); + return json(201, { created: asRecordJson(draft, false), failed: [] }); + } + + function patchRecord(rec: FakeRecord, body: Record): Response { + const next: FakeRecord = { + ...rec, + ...(typeof body.value === 'string' ? { value: opts.corruptWrites ? `${body.value}-corrupted` : body.value } : {}), + ...(Array.isArray(body.target) ? { target: body.target as string[] } : {}), + ...(Array.isArray(body.customEnvironmentIds) ? { customEnvironmentIds: body.customEnvironmentIds as string[] } : {}), + ...(body.gitBranch === null ? { gitBranch: undefined } : typeof body.gitBranch === 'string' ? { gitBranch: body.gitBranch } : {}), + }; + store.set(rec.id, next); + return json(200, asRecordJson(next, false)); + } + + return { + fetch: fetchFake, + calls: () => [...log], + writeCalls: () => log.filter((c) => c.method !== 'GET'), + records: () => [...store.values()].map((r) => ({ ...r })), + seenBy(env, key, gitBranch) { + const serving = [...store.values()].filter((r) => r.key === key && SERVES(r, env)); + if (env !== 'preview') return serving[0]?.value; + const branchSpecific = serving.find((r) => gitBranch !== undefined && r.gitBranch === gitBranch); + return (branchSpecific ?? serving.find((r) => !r.gitBranch))?.value; + }, + }; +} diff --git a/tests/deploy/vercelApiModeNoBinary.test.ts b/tests/deploy/vercelApiModeNoBinary.test.ts new file mode 100644 index 00000000..d6dca025 --- /dev/null +++ b/tests/deploy/vercelApiModeNoBinary.test.ts @@ -0,0 +1,17 @@ +/** + * API mode needs no `vercel` binary (CAP-696): proven in a child process whose + * PATH contains nothing, so a stray spawn of `vercel` would fail the run. + */ +import { test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { join } from 'path'; + +test('preflight and deploy succeed with no vercel binary on PATH', () => { + const child = spawnSync(process.execPath, ['run', join(import.meta.dir, 'vercelApiModeNoBinaryChild.ts')], { + encoding: 'utf-8', + env: { PATH: '/nonexistent-bin-dir', HOME: process.env.HOME ?? '/nonexistent-home' }, + }); + expect(child.status).toBe(0); + const out = JSON.parse(child.stdout.trim().split('\n').pop() ?? '{}'); + expect(out).toEqual({ binaryAbsent: true, preflightOk: true, deployOk: true, seen: 'v1' }); +}); diff --git a/tests/deploy/vercelApiModeNoBinaryChild.ts b/tests/deploy/vercelApiModeNoBinaryChild.ts new file mode 100644 index 00000000..3a4c8099 --- /dev/null +++ b/tests/deploy/vercelApiModeNoBinaryChild.ts @@ -0,0 +1,31 @@ +/** + * Child process for `vercelApiModeNoBinary.test.ts`: runs an API-mode Vercel + * deploy in a process whose PATH holds no `vercel` binary at all, and prints + * one JSON line. Not a test file itself. + */ +import { spawnSync } from 'child_process'; +import { createVercelAdapter } from '../../src/deploy/adapters/vercel'; +import { createFakeVercel } from './vercelApiModeFake'; + +const fake = createFakeVercel({ projectId: 'prj_1', teamId: 'team_1', token: 'tok_fake_value' }); +const adapter = createVercelAdapter({ fetch: fake.fetch, env: {}, getTargetSecret: async () => 'tok_fake_value' }); +const target = { + name: 'v', + kind: 'vercel', + branch: 'production', + vars: ['API_KEY'], + mode: 'ci' as const, + options: { projectDir: 'web', projectId: 'prj_1', teamId: 'team_1', vercelEnv: 'production' }, +}; + +const probe = spawnSync('vercel', ['--version'], { encoding: 'utf-8' }); +const pre = await adapter.preflight(target, { cwd: '/nonexistent-cwd' }); +const dep = await adapter.deploy(target, { env: { API_KEY: 'v1' }, dryRun: false, cwd: '/nonexistent-cwd' }); +console.log( + JSON.stringify({ + binaryAbsent: probe.error !== undefined, + preflightOk: pre.ok, + deployOk: dep.ok, + seen: fake.seenBy('production', 'API_KEY'), + }), +); diff --git a/tests/deploy/vercelApiModeTokenStore.test.ts b/tests/deploy/vercelApiModeTokenStore.test.ts new file mode 100644 index 00000000..8e991b15 --- /dev/null +++ b/tests/deploy/vercelApiModeTokenStore.test.ts @@ -0,0 +1,182 @@ +/** + * The Vercel API-mode token comes from the org system store (CAP-696): + * `_TARGET_VERCEL_TOKEN`, which may be a one-level reference to + * `_CONNECTOR_VERCEL_TOKEN`. Run against the REAL system store (same fake + * service + on-disk master key as `tests/system/systemStore.test.ts`), so the + * reference is saved and followed by the real code, not by a stand-in. + * + * mock.module is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { mock, describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; + +const tempHome = mkdtempSync(join(require('os').tmpdir(), 'capy-vercel-token-store-')); +mock.module('os', () => { + const actual = require('os'); + return { ...actual, homedir: () => tempHome }; +}); + +const ORG = 'org_a'; +const USER = 'user_a'; +const KMS_PREFIX = 'KMS1.'; + +interface FakeServer { + projectId: string; + role: 'owner' | 'member'; + envBlob: string; + keepFile?: string; + keepHash: string; + pushCount: number; +} +const servers = new Map(); +const serverFor = (orgId: string): FakeServer => + servers.get(orgId) ?? servers.set(orgId, { projectId: `proj_${orgId}`, role: 'owner', envBlob: '', keepHash: 'h0', pushCount: 0 }).get(orgId)!; +const serverForProject = (projectId: string): FakeServer => { + const found = [...servers.values()].find((s) => s.projectId === projectId); + if (!found) throw new Error(`no fake server for project ${projectId}`); + return found; +}; +function refuseNonAdmin(s: FakeServer): void { + if (s.role === 'owner') return; + const { CapyError, ERROR_CODES } = require('../../src/types/index'); + throw new CapyError('owners/admins only', ERROR_CODES.PERMISSION_DENIED, { status: 403, code: 'SYSTEM_STORE_ADMIN_ONLY' }); +} + +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + async getOrCreateSystemStore(orgId: string) { + const s = serverFor(orgId); + refuseNonAdmin(s); + return { project_id: s.projectId, branch: 'system' }; + } + async getDecryptData(projectId: string) { + const s = serverForProject(projectId); + refuseNonAdmin(s); + return { env_content: s.envBlob, decrypt_key: '', expires_at: new Date().toISOString(), keep_file: s.keepFile, keep_hash: s.keepHash }; + } + async pushSecrets(projectId: string, keepFile: string, envBlob: string) { + const s = serverForProject(projectId); + refuseNonAdmin(s); + s.envBlob = envBlob; + s.keepFile = keepFile; + s.pushCount += 1; + s.keepHash = `h${s.pushCount}`; + return { keep_hash: s.keepHash, keep_file: s.keepFile }; + } + async coDecrypt(_orgId: string, ciphertext: string) { + return { plaintext: ciphertext.slice(KMS_PREFIX.length) }; + } + async wrapOuterLayer(_orgId: string, plaintext: string) { + return { ciphertext: KMS_PREFIX + plaintext }; + } + }, +})); +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + async authenticateSilent() { + return { success: true, user_id: USER, user_email: 'a@capy.sc' }; + } + async authenticate() { + return { success: true, user_id: USER, user_email: 'a@capy.sc' }; + } + async getValidToken() { + return { access_token: 'tok', expires_at: Date.now() + 999999, organization_id: ORG, user_id: USER }; + } + }, +})); +const promptAnswers = new Map(); +mock.module('inquirer', () => ({ + default: { prompt: mock(async (questions: Array<{ name: string }>) => ({ [questions[0].name]: promptAnswers.get(questions[0].name) })) }, +})); + +afterAll(() => { + mock.restore(); + rmSync(tempHome, { recursive: true, force: true }); +}); + +let openSystemStore: typeof import('../../src/system/systemStore').openSystemStore; +let resolveVercelTargetToken: typeof import('../../src/deploy/vercelTargetApi').resolveVercelTargetToken; +let getVercelTargetSecretFromStore: typeof import('../../src/deploy/vercelTargetApi').getVercelTargetSecretFromStore; +let VERCEL_TARGET_SECRET_NAME: string; +let VERCEL_CONNECTOR_SECRET_NAME: string; +let ERROR_CODES: typeof import('../../src/types/index').ERROR_CODES; + +beforeAll(async () => { + openSystemStore = (await import('../../src/system/systemStore')).openSystemStore; + const api = await import('../../src/deploy/vercelTargetApi'); + resolveVercelTargetToken = api.resolveVercelTargetToken; + getVercelTargetSecretFromStore = api.getVercelTargetSecretFromStore; + VERCEL_TARGET_SECRET_NAME = api.VERCEL_TARGET_SECRET_NAME; + VERCEL_CONNECTOR_SECRET_NAME = api.VERCEL_CONNECTOR_SECRET_NAME; + ERROR_CODES = (await import('../../src/types/index')).ERROR_CODES; + const { wrapAndSaveMasterKey } = await import('../../src/crypto/keyResolver'); + await wrapAndSaveMasterKey(randomBytes(32), ORG, USER, { + coDecrypt: async (_o: string, ct: string) => ct.slice(KMS_PREFIX.length), + wrapOuterLayer: async (_o: string, pt: string) => KMS_PREFIX + pt, + }); +}); + +beforeEach(() => { + servers.clear(); + promptAnswers.clear(); +}); + +const setValue = async (name: string, value: string): Promise => { + const store = await openSystemStore({ orgId: ORG, devMode: true }); + await store.set(name, value); +}; + +const resolve = (over: { interactive?: boolean; env?: Record } = {}) => + resolveVercelTargetToken({ + env: over.env ?? {}, + interactive: over.interactive ?? false, + orgId: ORG, + devMode: true, + getSecret: getVercelTargetSecretFromStore, + }); + +describe('Vercel target token — org system store', () => { + it('the names are the documented ones', () => { + expect(VERCEL_TARGET_SECRET_NAME).toBe('_TARGET_VERCEL_TOKEN'); + expect(VERCEL_CONNECTOR_SECRET_NAME).toBe('_CONNECTOR_VERCEL_TOKEN'); + }); + + it('a literal _TARGET_VERCEL_TOKEN resolves, from the system store', async () => { + await setValue(VERCEL_TARGET_SECRET_NAME, 'tok_literal_fake'); + expect(await resolve()).toEqual({ ok: true, value: 'tok_literal_fake', source: 'system' }); + }); + + it('a one-level reference to _CONNECTOR_VERCEL_TOKEN resolves to the connector value, and follows it when it changes', async () => { + await setValue(VERCEL_CONNECTOR_SECRET_NAME, 'tok_connector_fake'); + // Interactive run: choose to reuse the connector token — the store saves a REFERENCE. + promptAnswers.set('choice', 'use'); + expect(await resolve({ interactive: true })).toEqual({ ok: true, value: 'tok_connector_fake', source: 'system' }); + + const reopened = await openSystemStore({ orgId: ORG, devMode: true }); + expect(reopened.listNames().find((e) => e.name === VERCEL_TARGET_SECRET_NAME)?.referencesName).toBe(VERCEL_CONNECTOR_SECRET_NAME); + + // Later, non-interactive (the sandbox case): the reference resolves with no prompt. + expect(await resolve()).toEqual({ ok: true, value: 'tok_connector_fake', source: 'system' }); + // It is a reference, not a copy: rotating the connector token rotates what deploy uses. + await setValue(VERCEL_CONNECTOR_SECRET_NAME, 'tok_rotated_fake'); + expect(await resolve()).toEqual({ ok: true, value: 'tok_rotated_fake', source: 'system' }); + }); + + it('only the connector token exists + non-interactive: a coded refusal, nothing borrowed silently', async () => { + await setValue(VERCEL_CONNECTOR_SECRET_NAME, 'tok_connector_fake'); + expect(await resolve()).toEqual({ ok: false, code: ERROR_CODES.VERCEL_TARGET_KEY_MISSING }); + }); + + it('nothing anywhere: VERCEL_TOKEN_MISSING; $VERCEL_TOKEN is the last resort', async () => { + expect(await resolve()).toEqual({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }); + expect(await resolve({ env: { VERCEL_TOKEN: 'tok_env_fake' } })).toEqual({ ok: true, value: 'tok_env_fake', source: 'env' }); + }); + + it('a non-admin caller: the store refuses with its own code, passed through', async () => { + serverFor(ORG).role = 'member'; + expect(await resolve()).toEqual({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }); +}); diff --git a/tests/run-tests.sh b/tests/run-tests.sh index de6d45e7..10538937 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -69,6 +69,8 @@ ISOLATED_FILES=( tests/commands/agentsCommand.test.ts tests/commands/transportCommand.test.ts tests/commands/pairCommand.test.ts + tests/commands/deployVercelApiMode.test.ts + tests/deploy/vercelApiModeTokenStore.test.ts ) # Build a grep pattern to exclude isolated files from the batch run From ce83eeba8463763325b3ed34898fc0cc8abf622c Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 1 Oct 2026 12:18:00 -0700 Subject: [PATCH 02/22] =?UTF-8?q?feat(connect):=20capy=20connect=20vercel?= =?UTF-8?q?=20=E2=80=94=20one-time=20import=20of=20Vercel=20env=20vars=20(?= =?UTF-8?q?single=20project=20and=20--discover)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only Vercel REST client with token resolution from the org system store (_CONNECTOR_VERCEL_TOKEN), team resolution, 429 backoff, and per-environment import mapping (production, preview, development, custom slugs). Preview records scoped to a git branch, sensitive, system and legacy secret records are skipped by stable reason. Discovery matches projects to local repos by git origin and rootDirectory and reuses the Dokploy discovery sequencer and local discovery commit. --- docs/cli-reference.md | 21 +- src/commands/connectCommand.ts | 16 +- src/commands/connectors/dokploy.ts | 88 +- src/commands/connectors/dokployDiscovery.ts | 25 +- src/commands/connectors/registry.ts | 49 +- src/commands/connectors/vercel.ts | 793 ++++++++++++++++++ src/commands/connectors/vercelDiscovery.ts | 363 ++++++++ src/deploy/dokployApi.ts | 4 +- src/deploy/vercelApi.ts | 756 +++++++++++++++++ src/git/discoveryCommit.ts | 14 +- src/index.ts | 10 +- src/types/index.ts | 37 + tests/commands/connectVercel.test.ts | 552 ++++++++++++ tests/commands/connectVercelDiscovery.test.ts | 506 +++++++++++ tests/deploy/vercelApi.test.ts | 469 +++++++++++ tests/helpers/fakeVercel.ts | 128 +++ tests/helpers/testSandbox.ts | 99 +++ 17 files changed, 3877 insertions(+), 53 deletions(-) create mode 100644 src/commands/connectors/vercel.ts create mode 100644 src/commands/connectors/vercelDiscovery.ts create mode 100644 src/deploy/vercelApi.ts create mode 100644 tests/commands/connectVercel.test.ts create mode 100644 tests/commands/connectVercelDiscovery.test.ts create mode 100644 tests/deploy/vercelApi.test.ts create mode 100644 tests/helpers/fakeVercel.ts create mode 100644 tests/helpers/testSandbox.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e0e7684b..51256acf 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -619,13 +619,15 @@ capy connect [provider] [options] | `--base-url ` | dokploy import: dashboard URL | | | `--application ` | dokploy import: Application id (mutually exclusive with --compose) | | | `--compose ` | dokploy import: Compose service id (mutually exclusive with --application) | | -| `--token-env ` | dokploy import: env var holding the API token | | +| `--token-env ` | dokploy/vercel import: env var holding the API token | | +| `--project ` | vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision) | | +| `--team ` | vercel import/discover: Vercel team slug or id (default: the token's only team) | | | `--json` | emit machine-readable JSON instead of the human UI (import connectors) | | | `--dry-run` | dokploy import/discover: preview the plan only — resolve settings + read Dokploy, write/push nothing | | -| `--discover` | dokploy: find every Dokploy service matching a repo under cwd, instead of one named --application/--compose | | +| `--discover` | dokploy/vercel: find every Dokploy service / Vercel project matching a repo under cwd, instead of one named --application/--compose/--project | | | `-y, --yes` | dokploy import/discover: skip the confirmation prompt (import: --overwrite's clear/replace/import ask; discover: the real-run "proceed?" ask) — required non-interactively | | | `--overwrite` | dokploy import: set the branch's vars to EXACTLY Dokploy's set — clear names not in Dokploy, replace differing values, import new ones | | -| `--environment ` | dokploy discover: restrict the plan to these Dokploy environment names, comma-separated (e.g. staging,preview) | | +| `--environment ` | dokploy/vercel discover: restrict the plan to these environment names, comma-separated (e.g. staging,preview); vercel import: which Vercel environment to read (default: the current branch's name) | | JSON support: yes (`--json`) @@ -732,6 +734,19 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `CI_DEPLOY_TARGETS_RECORD_FAILED` - `DEPLOY_STALE_KEEP` - `DEPLOY_TOKEN_UNTRACKED` +- `VERCEL_TOKEN_MISSING` +- `VERCEL_CONNECTOR_KEY_MISSING` +- `VERCEL_AUTH_FAILED` +- `VERCEL_API_ERROR` +- `VERCEL_RATE_LIMITED` +- `VERCEL_TEAM_REQUIRED` +- `VERCEL_TEAM_NOT_FOUND` +- `VERCEL_PROJECT_REQUIRED` +- `VERCEL_PROJECT_NOT_FOUND` +- `VERCEL_ENVIRONMENT_REQUIRED` +- `VERCEL_ENVIRONMENT_NOT_FOUND` +- `VERCEL_CONFIRMATION_REQUIRED` +- `VERCEL_MAPPING_COLLISION` ## Conventions diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index 8e8ad27f..8e0efbdc 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -424,6 +424,9 @@ export class ConnectCommand { opts: ConnectOpts, ): Promise<{ linked: boolean }> { const outcome = await mod.import!(ctx, opts); + // "from Dokploy" / "from Vercel": omitted `displayName` is Dokploy, the + // only import connector that predates the field (its output is unchanged). + const label = mod.displayName ?? 'Dokploy'; if (!outcome.ok) { if (opts.json) { @@ -478,7 +481,7 @@ export class ConnectCommand { console.log(dryRun ? ' Nothing to import.' : ' Nothing new to import.'); } else if (dryRun) { if (outcome.imported.length > 0) { - console.log(` Would import ${outcome.imported.map((e) => B(e.varName)).join(', ')} from Dokploy.`); + console.log(` Would import ${outcome.imported.map((e) => B(e.varName)).join(', ')} from ${label}.`); } if (cleared.length > 0) { console.log(` Would clear: ${cleared.join(', ')}`); @@ -486,11 +489,11 @@ export class ConnectCommand { } else { if (outcome.imported.length > 0) { console.log( - ` ✓ Imported ${outcome.imported.map((e) => B(e.varName)).join(', ')} from Dokploy` + + ` ✓ Imported ${outcome.imported.map((e) => B(e.varName)).join(', ')} from ${label}` + `${pushed ? ' and pushed' : ' (not pushed)'}.`, ); } else { - console.log(` ✓ Cleared from Dokploy${pushed ? ' and pushed' : ' (not pushed)'}.`); + console.log(` ✓ Cleared from ${label}${pushed ? ' and pushed' : ' (not pushed)'}.`); } if (cleared.length > 0) { console.log(` Cleared: ${cleared.join(', ')}`); @@ -512,7 +515,9 @@ export class ConnectCommand { console.log(` ⚠ ${w.code}: ${w.names.join(', ')}`); } if (outcome.deployTargetSaved) { - console.log(` Saved a Dokploy deploy target for application ${outcome.applicationId}.`); + console.log( + mod.displayName ? ` Saved a ${label} deploy target.` : ` Saved a Dokploy deploy target for application ${outcome.applicationId}.`, + ); } console.log(''); return { linked: wrote }; @@ -542,6 +547,9 @@ export class ConnectCommand { return { linked: false }; } + // A connector whose plan has its own shape (Vercel) reports it itself. + if (mod.reportDiscovery) return mod.reportDiscovery(outcome, opts, provider); + const planForOutput = { ...(outcome.plan.environmentFilter ? { environmentFilter: outcome.plan.environmentFilter } : {}), folders: outcome.plan.folders.map((f) => ({ diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index 6af224d8..7c22f09f 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -464,7 +464,7 @@ export interface DokployConnectorDeps { getConnectorSecret?: (name: string, opts: DokploySystemStoreCallOptions) => Promise; } -async function defaultConfirm(message: string, defaultValue: boolean): Promise { +export async function defaultConfirm(message: string, defaultValue: boolean): Promise { const inquirer = (await import('inquirer')).default; const { ok } = await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: defaultValue }]); return ok; @@ -569,7 +569,7 @@ async function defaultAskDiscoveryBaseUrl(): Promise { export const DISCOVERY_NEW_PROJECT = '__capy_discovery_new_project__'; // COPY-FLAG: new user-facing string, minimal/neutral wording. -async function defaultAskExistingOrNewProject( +export async function defaultAskExistingOrNewProject( existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string, ): Promise { @@ -601,7 +601,7 @@ function discoveryProjectNameProblem(trimmed: string): string | null { return null; } -async function defaultAskDiscoveryProjectName(defaultName: string): Promise { +export async function defaultAskDiscoveryProjectName(defaultName: string): Promise { const inquirer = (await import('inquirer')).default; const { name } = await inquirer.prompt([ { @@ -620,7 +620,7 @@ async function defaultAskDiscoveryProjectName(defaultName: string): Promise { +export async function defaultAskDiscoveryCommitBranchName(defaultName: string): Promise { const inquirer = (await import('inquirer')).default; const { name } = await inquirer.prompt([ { @@ -641,7 +641,7 @@ async function defaultAskDiscoveryCommitBranchName(defaultName: string): Promise } // COPY-FLAG: new user-facing string, minimal/neutral wording. -async function defaultPickCollisionWinner(collision: DiscoveryCollision): Promise { +export async function defaultPickCollisionWinner(collision: DiscoveryCollision): Promise { const inquirer = (await import('inquirer')).default; const { winner } = await inquirer.prompt([ { @@ -1285,7 +1285,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect } /** Plan-time-only local keep.lock read (no auth) — see `PeekLocalKeep`'s own doc. */ -function peekLocalKeepForFolder(repoDir: string, folder: string): KeepFile | null { +export function peekLocalKeepForFolder(repoDir: string, folder: string): KeepFile | null { return new ProjectManager(folder ? join(repoDir, folder) : repoDir).readKeepFile(); } @@ -1319,7 +1319,7 @@ function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwr const folderPath = (repoDir: string, folder: string): string => (folder ? join(repoDir, folder) : repoDir); /** keep.lock and .gitignore, relative to `repoDir` — the ONLY paths discovery's own commit step ever touches. */ -function discoveryCommitPathsFor(folder: string): readonly string[] { +export function discoveryCommitPathsFor(folder: string): readonly string[] { const prefix = folder ? `${folder}/` : ''; return [`${prefix}keep.lock`, `${prefix}.gitignore`]; } @@ -1337,11 +1337,16 @@ function discoveryCommitPathsFor(folder: string): readonly string[] { * `discoveryCommitPathsFor` would otherwise be counted once per entry, * reporting `keep.lock`/`.gitignore` twice for what is really one write. */ -function commitPreviewForFolders(folders: readonly DiscoveryPlanFolder[], branchName: string): readonly DiscoveryRepoCommitResult[] { +export function commitPreviewForFolders( + folders: readonly DiscoveryPlanFolder[], + branchName: string, + /** The commit subject; default is the Dokploy one. */ + commitMessage?: string, +): readonly DiscoveryRepoCommitResult[] { const repoDirs = [...new Set(folders.map((f) => f.repoDir))]; return repoDirs.map((repoDir) => { const paths = [...new Set(folders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)))]; - const outcome = commitDiscoveryChanges(repoDir, paths, new Set(), { branchName, dryRun: true, summaryLines: [] }); + const outcome = commitDiscoveryChanges(repoDir, paths, new Set(), { branchName, dryRun: true, summaryLines: [], ...(commitMessage ? { commitMessage } : {}) }); return { repoDir, ...outcome }; }); } @@ -1353,10 +1358,12 @@ function commitPreviewForFolders(folders: readonly DiscoveryPlanFolder[], branch * zero successful folders is skipped entirely (nothing to commit, no * refusal either). */ -function commitForAppliedRepos( +export function commitForAppliedRepos( applied: readonly DiscoveryFolderResult[], beforeStatusByRepo: ReadonlyMap>, branchName: string, + /** The commit subject; default is the Dokploy one. */ + commitMessage?: string, ): readonly DiscoveryRepoCommitResult[] { const repoDirs = [...new Set(applied.map((f) => f.repoDir))]; return repoDirs.flatMap((repoDir) => { @@ -1368,13 +1375,14 @@ function commitForAppliedRepos( branchName, dryRun: false, summaryLines, + ...(commitMessage ? { commitMessage } : {}), }); return [{ repoDir, ...outcome }]; }); } /** What `listOrgProjectsOrUnavailable` returns — named here so the non-interactive `--yes` snapshot (see `ensureProjectSafe`'s doc) and the interactive per-folder lookup share one type. */ -type ExistingProjectsSnapshot = { +export type ExistingProjectsSnapshot = { existingProjects: ReadonlyArray<{ id: string; name: string; organization_id: string }>; projectsUnavailable: boolean; }; @@ -1414,7 +1422,7 @@ function sameRunFolderKey(repoDir: string, folder: string): string { * that is NOT the first candidate to claim its (normalized) default name; * the very first claimant of a name is never in the returned set. */ -function computeSameRunNameCollisions(folders: readonly DiscoveryPlanFolder[]): ReadonlySet { +export function computeSameRunNameCollisions(folders: readonly DiscoveryPlanFolder[]): ReadonlySet { const candidates = folders .filter((f) => !f.initialized) .map((f) => ({ key: sameRunFolderKey(f.repoDir, f.folder), normalizedName: normalizeProjectName(defaultDiscoveryProjectName(f.repoDir, f.folder, f.remote)) })); @@ -1833,7 +1841,16 @@ async function importIntoBranchSafe( folder: string, env: DiscoveryPlanEnv, overwrite: boolean, - runOpts: { noPush: boolean; interactive: boolean; json: boolean; yes: boolean; baseUrl: string; tokenEnv: string }, + runOpts: { + noPush: boolean; + interactive: boolean; + json: boolean; + yes: boolean; + baseUrl: string; + tokenEnv: string; + /** Another provider's discovery (Vercel, CAP-696) supplies the per-step import options itself; absent = the Dokploy options below, unchanged. */ + importOptsFor?: (env: DiscoveryPlanEnv, projectName: string, folderPath: string) => ConnectOpts; + }, /** CAP-673: this environment's Dokploy PROJECT name — already known from the plan's own `project.all` read, threaded through so `import()` never re-fetches it (see `ConnectOpts.dokployProjectName`'s doc). */ projectName: string, ): Promise { @@ -1867,18 +1884,17 @@ async function importIntoBranchSafe( } const projectKey = projectKeyResult.value; - const localPlaintext: Record = {}; - for (const [k, v] of Object.entries(fm.readEnvFile())) { - if (!v.startsWith('capy:')) { - localPlaintext[k] = v; - continue; - } - try { - localPlaintext[k] = fm.decryptValue(v, projectKey); - } catch { - // Skip undecryptable. - } - } + const localPlaintext: Record = Object.fromEntries( + Object.entries(fm.readEnvFile()).flatMap(([k, v]): ReadonlyArray => { + if (!v.startsWith('capy:')) return [[k, v]]; + try { + return [[k, fm.decryptValue(v, projectKey)]]; + } catch { + // Skip undecryptable. + return []; + } + }), + ); const resolvedCtx: ResolvedContext = { pm, @@ -1893,19 +1909,24 @@ async function importIntoBranchSafe( keep, localPlaintext, }; - const importOpts: ConnectOpts = { - ...(env.serviceKind === 'application' ? { application: env.serviceId } : { compose: env.serviceId }), + const sharedImportOpts = { overwrite, noPush: runOpts.noPush, nonTty: !runOpts.interactive, json: runOpts.json, yes: runOpts.yes, - baseUrl: runOpts.baseUrl, - tokenEnv: runOpts.tokenEnv, - // CAP-673: already known from the plan — see `dokployProjectName`'s doc. - dokployProjectName: projectName, - dokployEnvironmentName: env.environmentName, }; + const importOpts: ConnectOpts = runOpts.importOptsFor + ? { ...sharedImportOpts, ...runOpts.importOptsFor(env, projectName, path) } + : { + ...(env.serviceKind === 'application' ? { application: env.serviceId } : { compose: env.serviceId }), + ...sharedImportOpts, + baseUrl: runOpts.baseUrl, + tokenEnv: runOpts.tokenEnv, + // CAP-673: already known from the plan — see `dokployProjectName`'s doc. + dokployProjectName: projectName, + dokployEnvironmentName: env.environmentName, + }; const outcome = await importFn(resolvedCtx, importOpts); if (!outcome.ok) return outcome; @@ -1927,7 +1948,7 @@ async function importIntoBranchSafe( * — the discovery scan's own org/session, reused for every folder rather * than re-authenticating per folder. */ -function buildRealDiscoverySequenceDeps( +export function buildRealDiscoverySequenceDeps( ctx: DiscoveryContext, importFn: (ctx: ResolvedContext, opts: ConnectOpts) => Promise, runOpts: { @@ -1937,6 +1958,7 @@ function buildRealDiscoverySequenceDeps( yes: boolean; baseUrl: string; tokenEnv: string; + importOptsFor?: (env: DiscoveryPlanEnv, projectName: string, folderPath: string) => ConnectOpts; askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; askProjectName: (defaultName: string) => Promise; /** diff --git a/src/commands/connectors/dokployDiscovery.ts b/src/commands/connectors/dokployDiscovery.ts index 5265fe30..8790decd 100644 --- a/src/commands/connectors/dokployDiscovery.ts +++ b/src/commands/connectors/dokployDiscovery.ts @@ -332,7 +332,7 @@ export function deepestCommonDir(dirs: readonly string[]): string { } /** A Compose service's folder is its compose file's directory; an Application's is the repo root (`''`). */ -export function serviceFolder(service: { serviceKind: 'application' | 'compose'; composePath?: string }): string { +export function serviceFolder(service: { serviceKind: DiscoveryServiceKind; composePath?: string }): string { if (service.serviceKind === 'application' || !service.composePath) return ''; return normalizeRelativeDir(posix.dirname(normalizeRelativeDir(service.composePath))); } @@ -411,7 +411,8 @@ export const DISCOVERY_PROJECT_NAME_MAX_LENGTH = 255; // ── Matching Dokploy services to repos ────────────────────────────────────── -export type DiscoveryServiceKind = 'application' | 'compose'; +/** `'vercel'` (CAP-696): a Vercel project, whose id rides in `serviceId` — see `vercelDiscovery.ts`. */ +export type DiscoveryServiceKind = 'application' | 'compose' | 'vercel'; interface ServiceDetail { projectName: string; @@ -430,12 +431,20 @@ interface ServiceDetail { export type DiscoveryUnmatchedReason = 'no_git_source' | 'no_remote_match'; +/** Why a Vercel project was not matched to a local repo (CAP-696). */ +export type VercelUnmatchedReason = + | 'no_git_link' + | 'unsupported_git_provider' + | 'no_remote_match' + | 'root_directory_missing' + | 'no_variables'; + export interface DiscoveryUnmatched { projectName: string; serviceName: string; serviceKind: DiscoveryServiceKind; serviceId: string; - reason: DiscoveryUnmatchedReason; + reason: DiscoveryUnmatchedReason | VercelUnmatchedReason; } interface MatchedService extends ServiceDetail { @@ -558,6 +567,12 @@ export interface DiscoveryPlanEnv { gitBranch?: string; /** Already-fetched (`application.one`/`compose.one`) env — plan preview reads this; the real per-environment import step re-fetches fresh rather than trusting a plan-time snapshot that may be stale by the time it runs. */ rawEnv: string | null; + /** + * Vercel plan only (CAP-696): the names this environment will NOT import and + * the stable reason code for each (`gitBranch` for a branch-scoped preview + * record). Names only. Absent for Dokploy. + */ + skipped?: ReadonlyArray<{ name: string; code: string; gitBranch?: string }>; /** * PREVIEW ONLY, and only known when `DiscoveryPlanFolder.initialized` — * whether this environment's branch already exists on the server, i.e. @@ -783,7 +798,7 @@ export interface DiscoveryCollision { } /** Two or more distinct (project, service) groups mapping the same (repoDir, folder, environment). */ -function detectCollisions(folders: readonly DiscoveryPlanFolder[]): readonly DiscoveryCollision[] { +export function detectCollisions(folders: readonly DiscoveryPlanFolder[]): readonly DiscoveryCollision[] { interface Cell { repoDir: string; folder: string; @@ -841,6 +856,8 @@ export interface DiscoveryPlan { allEnvironmentNames: readonly string[]; /** The `--environment` names this plan was filtered to, if any (CAP-657 follow-up) — for the plan header ("filtered to: staging"). Absent when no filter was passed. */ environmentFilter?: readonly string[]; + /** Vercel plan only (CAP-696): the team scope the plan read. Absent for Dokploy and for a personal-account Vercel plan. */ + team?: { id: string; slug?: string }; } /** diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index 3e4fd5e0..75ebc164 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -1,6 +1,7 @@ import { ConnectorMetadata } from '../../types/index'; import type { Blocked } from '../../ui/screens/contract'; import type { DokployImportSource } from '../../deploy/dokployApi'; +import type { VercelImportSource } from '../../deploy/vercelApi'; import type { DiscoveryContext, DiscoveryOutcome } from './dokployDiscovery'; import { ResolvedContext } from './shared'; @@ -162,6 +163,34 @@ export interface ConnectOpts { * the connector entry's `environment` field (CAP-673). */ dokployEnvironmentName?: string; + /** + * Vercel import only (CAP-696): the project to import from (flag + * `--project`, id or name). Under `--discover` it is instead the + * collision-winner hint, the same way `application`/`compose` are for + * Dokploy. Other connectors never read this. + */ + project?: string; + /** Vercel import only (CAP-696): team slug or id (flag `--team`). Other connectors never read this. */ + team?: string; + /** + * Vercel DISCOVERY only, set INTERNALLY by `discover()`'s own per-environment + * step — never a CLI flag. The team scope discovery already resolved + * (`id` undefined = the personal account), so the single import it runs + * does not list teams again. + */ + vercelTeam?: { id?: string }; + /** + * Vercel DISCOVERY only, set INTERNALLY alongside `vercelTeam`: the project + * facts the plan already read, so the single import does not fetch the + * project again. + */ + vercelProject?: { id: string; name: string; productionBranch?: string }; + /** + * Vercel import only: the folder a saved deploy target's `.capy/deploy.json` + * belongs in. Set INTERNALLY by discovery (each folder has its own); a plain + * import uses the current directory. + */ + projectDir?: string; } export interface RotateOpts { @@ -222,6 +251,8 @@ export interface ImportedVarEntry { export interface ImportSkip { name: string; code: string; + /** Vercel import only, for `git_branch_scoped`: the branch the record is scoped to — a name, never a value. */ + gitBranch?: string; } /** A non-blocking heads-up about the import, grouped by code like `DeployWarning`. */ @@ -243,7 +274,7 @@ export type ImportOutcome = */ applicationId?: string; /** Dokploy import only: which Dokploy object this import read from. */ - source?: DokployImportSource; + source?: DokployImportSource | VercelImportSource; imported: readonly ImportedVarEntry[]; unchanged: readonly string[]; skipped: readonly ImportSkip[]; @@ -309,6 +340,12 @@ export interface ConnectorModule { * the refusal is not `=== 'dokploy'` reasoning about a human-readable id. */ kind?: 'link' | 'import'; + /** + * The provider's display name in import/discovery output ("from Dokploy", + * "from Vercel"). Omitted means `Dokploy` — the only import connector that + * existed before this field, whose output stays byte-identical. + */ + displayName?: string; /** * Set when rotating runs an interactive auth step the user must complete by * hand (e.g. Stripe shells out to `stripe login`, which opens a browser). @@ -359,6 +396,13 @@ export interface ConnectorModule { * that implement it — no other provider does today. */ discover?(ctx: DiscoveryContext, opts: ConnectOpts): Promise; + /** + * A connector whose discovery plan has its own shape (Vercel: project name + * + id, skipped names grouped by reason) prints and emits it here instead + * of through `ConnectCommand`'s built-in Dokploy-shaped report. Returns + * whether anything was linked, exactly like `executeDiscovery` does. + */ + reportDiscovery?(outcome: Extract, opts: ConnectOpts, provider: string): { linked: boolean }; } /** Registered providers, keyed by name (matches `connector.provider` on each keep.lock entry). */ @@ -366,6 +410,7 @@ export const providers: Record Promise> = { stripe: async () => (await import('./stripe')).stripeConnector, workos: async () => (await import('./workos')).workosConnector, dokploy: async () => (await import('./dokploy')).dokployConnector, + vercel: async () => (await import('./vercel')).vercelConnector, }; export function listProviders(): { name: string; description: string }[] { @@ -374,6 +419,8 @@ export function listProviders(): { name: string; description: string }[] { { name: 'workos', description: 'WorkOS environment API key (sandbox or production)' }, // COPY-FLAG: new user-facing string, minimal/neutral wording. { name: 'dokploy', description: 'One-time import of env vars from a Dokploy Application or Compose service' }, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + { name: 'vercel', description: 'One-time import of env vars from a Vercel project' }, ]; } diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts new file mode 100644 index 00000000..79291188 --- /dev/null +++ b/src/commands/connectors/vercel.ts @@ -0,0 +1,793 @@ +/** + * `capy connect vercel` — a ONE-TIME IMPORT of a Vercel project's environment + * variables into this project's `.env`, then encrypted + synced the same way + * every other connector's write lands (CAP-696). It mirrors + * `capy connect dokploy` (`./dokploy.ts`): same flags, same outcomes, same + * safety rules, same discovery sequence. + * + * Import is READ-ONLY on the Vercel side — see `deploy/vercelApi.ts`, whose + * client type has no way to write. The Vercel token is never stored by THIS + * file: it resolves from the org system store's `_CONNECTOR_VERCEL_TOKEN` + * (which the store may prompt for and save, once, on an admin's first use), + * or the variable named by `--token-env` / `$VERCEL_TOKEN`. It is resolved + * before any Vercel request and never prompted for under `--dry-run`, + * `--json` or `--web`. + * + * Environment → Capy branch: `production`, `preview`, `development`, or a + * custom environment's slug. One Vercel record that targets several + * environments counts in each. What is NOT imported is reported by stable + * reason, names only: `git_branch_scoped` (a preview record scoped to one git + * branch — it is not an all-branches preview var), `sensitive_unreadable`, + * `legacy_secret_unreadable`, `vercel_system`, `unsupported_type`, + * `duplicate_key`, `empty_value`. + * + * Deploy targets: like Dokploy, a target is OFFERED only after a single + * import, only interactively (never under `--json`, `--dry-run`, or + * non-interactive). Discovery never saves one by itself. + */ +import { TargetConfig } from '../../deploy/adapter'; +import { classifyImportCandidates } from '../../deploy/dokployApi'; +import { + DEFAULT_VERCEL_TOKEN_ENV, + VERCEL_TARGET_SECRET_NAME, + VercelApiError, + VercelClient, + VercelCustomEnvironment, + VercelEnvRecord, + VercelEnvironmentInfo, + VercelFetchLike, + VercelImportSource, + VercelProject, + VercelSkippedRecord, + VercelSystemStoreCallOptions, + VercelTeam, + availableEnvironments, + buildEnvironmentPlans, + createVercelClient, + describeVercelTokenProblem, + mapVercelApiError, + parseVarList, + resolveEnvValues, + resolveTeam, + resolveVercelToken, + vercelSecretsMayPrompt, +} from '../../deploy/vercelApi'; +import { ConnectorMetadata, ERROR_CODES } from '../../types/index'; +import { isInteractive } from '../../ui/interactive'; +import { listOrgProjectsOrUnavailable } from '../capyCommand'; +import { defaultDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; +import { fingerprint, ResolvedContext } from './shared'; +import { ConnectOpts, ConnectorModule, ConnectResult, ImportOutcome, ImportSkip, ImportWarning, RotateResult } from './registry'; +import { + CandidateRepo, + DiscoveryCollision, + DiscoveryContext, + DiscoveryOutcome, + DiscoveryPlan, + DiscoveryPlanFolder, + applyCollisionResolutions, + findCandidateReposResult, + orderEnvironments, + resolveCollisions, + runDiscoverySequences, +} from './dokployDiscovery'; +import { + ExistingProjectsSnapshot, + buildRealDiscoverySequenceDeps, + commitForAppliedRepos, + commitPreviewForFolders, + computeOverwritePlan, + computeSameRunNameCollisions, + OverwritePlan, + defaultAskDiscoveryCommitBranchName, + defaultAskDiscoveryProjectName, + defaultAskExistingOrNewProject, + defaultConfirm, + defaultPickCollisionWinner, + discoveryCommitPathsFor, + peekLocalKeepForFolder, +} from './dokploy'; +import { buildVercelDiscoveryPlan, reportVercelDiscovery } from './vercelDiscovery'; + +const VERCEL_COMMIT_MESSAGE = 'chore(capy): import Vercel environments'; + +type Failure = { ok: false; code: string; message: string }; + +// ── Prompts (real `inquirer`; every one is injectable) ────────────────────── + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +async function defaultSelectVars(candidates: readonly string[]): Promise { + if (candidates.length === 0) return []; + const inquirer = (await import('inquirer')).default; + const { picked } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'picked', + message: 'Which variables to import from Vercel:', + choices: candidates.map((name) => ({ name, value: name, checked: true })), + }, + ]); + return picked; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +async function defaultPickTeam(teams: readonly VercelTeam[]): Promise { + const inquirer = (await import('inquirer')).default; + const { picked } = await inquirer.prompt([ + { type: 'list', name: 'picked', message: 'Which Vercel team:', choices: teams.map((t) => ({ name: t.slug, value: t.id })) }, + ]); + return picked; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +async function defaultAskProject(): Promise { + const inquirer = (await import('inquirer')).default; + const { project } = await inquirer.prompt([ + { + type: 'input', + name: 'project', + message: 'Vercel project (id or name):', + validate: (v: string) => (v.trim() ? true : 'required'), + filter: (v: string) => v.trim(), + }, + ]); + return project; +} + +export interface VercelConnectorDeps { + fetch?: VercelFetchLike; + env?: Record; + cwd?: string; + now?: () => Date; + /** Injectable so a 429 test never really waits. */ + sleep?: (ms: number) => Promise; + confirm?: (message: string, defaultValue: boolean) => Promise; + selectVars?: (candidates: readonly string[]) => Promise; + pickTeam?: (teams: readonly VercelTeam[]) => Promise; + askProject?: () => Promise; + pickCollisionWinner?: (collision: DiscoveryCollision) => Promise; + askExistingOrNewProject?: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; + askDiscoveryProjectName?: (defaultName: string) => Promise; + askDiscoveryCommitBranchName?: (defaultName: string) => Promise; + /** + * Reads the org system store's `_CONNECTOR_VERCEL_TOKEN` entry. Defaults to + * a no-op that never touches the network; the exported `vercelConnector` + * singleton wires the real `system/systemStore.ts` lookup. + */ + getConnectorSecret?: (name: string, opts: VercelSystemStoreCallOptions) => Promise; +} + +// ── Connector entry (keep.lock metadata) ──────────────────────────────────── + +/** + * The keep.lock connector entry for one vercel-imported value. Additive: + * `vercel_project_id`, `vercel_project`, `team_id`, `env_id`, + * `configuration_id` (when Vercel set one), plus the generic `provider`, + * `source`, `environment`, `imported_at`, `fingerprint`. + */ +export function buildVercelConnectorEntry( + record: VercelEnvRecord, + value: string, + meta: { project: { id: string; name: string }; teamId?: string; environment: string; createdAtSec: number; importedAt: string }, +): ConnectorMetadata { + return { + provider: 'vercel', + source: 'import', + created_at: meta.createdAtSec, + fingerprint: fingerprint(value), + imported_at: meta.importedAt, + vercel_project_id: meta.project.id, + vercel_project: meta.project.name, + ...(meta.teamId ? { team_id: meta.teamId } : {}), + environment: meta.environment, + env_id: record.id, + ...(record.configurationId ? { configuration_id: record.configurationId } : {}), + }; +} + +// ── Source resolution (token, team, project, environment, records) ────────── + +interface SourceRead { + teamId: string | undefined; + project: { id: string; name: string; productionBranch?: string }; + environment: VercelEnvironmentInfo; + importable: readonly VercelEnvRecord[]; + skipped: readonly VercelSkippedRecord[]; + truncated: boolean; +} + +async function guard(fn: () => Promise): Promise<{ ok: true; value: T } | Failure> { + try { + return { ok: true, value: await fn() }; + } catch (err) { + return { ok: false, ...mapVercelApiError(err) }; + } +} + +/** `getProject` with a 404 as `VERCEL_PROJECT_NOT_FOUND` (checked on the typed error's code, never its text). */ +async function fetchProject(client: VercelClient, idOrName: string, teamId: string | undefined): Promise<{ ok: true; project: VercelProject } | Failure> { + try { + return { ok: true, project: await client.getProject(idOrName, teamId) }; + } catch (err) { + if (err instanceof VercelApiError && err.code === 'not_found') { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code: ERROR_CODES.VERCEL_PROJECT_NOT_FOUND, message: `No Vercel project ${idOrName}.` }; + } + return { ok: false, ...mapVercelApiError(err) }; + } +} + +/** Which environment a single import reads: `--environment`, else the one named like the current Capy branch. */ +function chooseEnvironment( + available: readonly VercelEnvironmentInfo[], + requested: string | undefined, + branch: string, +): { ok: true; environment: VercelEnvironmentInfo } | Failure { + const names = available.map((e) => e.name).join(', '); + const want = requested?.trim(); + if (want) { + const hit = available.find((e) => e.name === want); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return hit + ? { ok: true, environment: hit } + : { ok: false, code: ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND, message: `No Vercel environment "${want}". Environments: ${names}.` }; + } + const byBranch = available.find((e) => e.name === branch); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return byBranch + ? { ok: true, environment: byBranch } + : { + ok: false, + code: ERROR_CODES.VERCEL_ENVIRONMENT_REQUIRED, + message: `The current branch "${branch}" is not a Vercel environment. Pass --environment . Environments: ${names}.`, + }; +} + +async function pickProjectArg(opts: ConnectOpts, promptable: boolean, askProject: () => Promise): Promise { + const given = opts.project?.trim(); + if (given) return given; + return promptable ? (await askProject()).trim() || null : null; +} + +// ── --overwrite ────────────────────────────────────────────────────────────── + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +function overwriteConfirmMessage(plan: OverwritePlan): string { + const lines = [ + 'Overwrite local vars to match Vercel exactly?', + plan.toImport.length > 0 ? ` Import (${plan.toImport.length}): ${plan.toImport.map((e) => e.name).join(', ')}` : null, + plan.toReplace.length > 0 ? ` Replace (${plan.toReplace.length}): ${plan.toReplace.map((e) => e.name).join(', ')}` : null, + plan.toClear.length > 0 ? ` Clear (${plan.toClear.length}): ${plan.toClear.join(', ')}` : null, + ].filter((l): l is string => l !== null); + return lines.join('\n'); +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwrite: boolean, environmentFilter?: readonly string[]): string { + const totalSteps = folders.reduce((n, f) => n + f.environments.length, 0); + const filterNote = environmentFilter && environmentFilter.length > 0 ? ` (--environment ${environmentFilter.join(',')})` : ''; + const header = `Run ${totalSteps} step(s) across ${folders.length} folder(s) from Vercel${overwrite ? ' (--overwrite)' : ''}${filterNote}?`; + const rows = folders.flatMap((f) => [ + ` ${f.folder || '.'} (${f.projectName})${f.initialized ? '' : ' — capy (init)'}`, + ...orderEnvironments(f.environments).map((e) => { + const checkoutCmd = e.branchExists === true ? `capy checkout ${e.environmentName}` : `capy checkout -b ${e.environmentName}`; + const importCmd = `capy connect vercel --project ${e.serviceId} --environment ${e.environmentName}${overwrite || e.willAutoOverwrite ? ' --overwrite' : ''}`; + return ` ${checkoutCmd} && ${importCmd} (${e.variableCount} var(s), ${e.skippedCount} skipped)${e.willAutoOverwrite && !overwrite ? ' [auto-overwrite]' : ''}`; + }), + ]); + return [header, ...rows].join('\n'); +} + +// ── Deploy-target offer ───────────────────────────────────────────────────── + +function uniqueTargetName(existing: readonly TargetConfig[], base: string): string { + const names = new Set(existing.map((t) => t.name)); + const numbered = (n: number): string => (names.has(`${base}-${n}`) ? numbered(n + 1) : `${base}-${n}`); + return names.has(base) ? numbered(2) : base; +} + +interface TargetOfferArgs { + importedNames: readonly string[]; + project: { id: string; name: string; productionBranch?: string }; + teamId: string | undefined; + environment: VercelEnvironmentInfo; + allTargets: readonly TargetConfig[]; + promptable: boolean; + dir: string; + confirm: (message: string, defaultValue: boolean) => Promise; +} + +/** + * After a successful single import, offer to save a Vercel deploy target for + * the SAME project + environment — all-branches vars (no `gitBranch`), CI + * mode, `gitBaseBranch` the project's production branch for `production`. + * Only asked interactively; non-interactive, `--json` and `--dry-run` never + * ask and never save. + */ +async function maybeOfferDeployTarget(args: TargetOfferArgs): Promise { + if (args.importedNames.length === 0 || !args.promptable) return false; + const already = args.allTargets.some((t) => { + const o = t.options as { projectId?: string; vercelEnv?: string }; + return t.kind === 'vercel' && o.projectId === args.project.id && o.vercelEnv === args.environment.name; + }); + if (already) return false; + + // COPY-FLAG: new user-facing string, minimal/neutral wording. + const yes = await args.confirm( + `Save a Vercel deploy target for ${args.project.name} (${args.environment.name}), so \`capy deploy\` can ship the runtime pair to it?`, + false, + ); + if (!yes) return false; + + const { upsertTarget } = await import('../../deploy/config'); + upsertTarget(args.dir, { + name: uniqueTargetName(args.allTargets, `vercel-${args.environment.name}`), + kind: 'vercel', + branch: args.environment.name, + vars: [...args.importedNames], + mode: 'ci', + ...(args.environment.name === 'production' && args.project.productionBranch ? { gitBaseBranch: args.project.productionBranch } : {}), + options: { + projectDir: '.', + vercelEnv: args.environment.name, + projectId: args.project.id, + ...(args.teamId ? { teamId: args.teamId } : {}), + ...(args.environment.customEnvironmentId ? { customEnvironmentId: args.environment.customEnvironmentId } : {}), + }, + }); + return true; +} + +// ── Connector ──────────────────────────────────────────────────────────────── + +export function createVercelConnector(deps: VercelConnectorDeps = {}): ConnectorModule { + const cwd = () => deps.cwd ?? process.cwd(); + const now = deps.now ?? (() => new Date()); + const env = () => deps.env ?? process.env; + const confirm = deps.confirm ?? defaultConfirm; + const selectVars = deps.selectVars ?? defaultSelectVars; + const pickTeam = deps.pickTeam ?? defaultPickTeam; + const askProject = deps.askProject ?? defaultAskProject; + const pickCollisionWinner = deps.pickCollisionWinner ?? defaultPickCollisionWinner; + const askExistingOrNewProject = deps.askExistingOrNewProject ?? defaultAskExistingOrNewProject; + const askDiscoveryProjectName = deps.askDiscoveryProjectName ?? defaultAskDiscoveryProjectName; + const askDiscoveryCommitBranchName = deps.askDiscoveryCommitBranchName ?? defaultAskDiscoveryCommitBranchName; + + /** The token, then a client — or the coded refusal. Resolved BEFORE any Vercel request, so a missing token means zero requests. */ + const openClient = async ( + orgId: string, + opts: ConnectOpts, + interactive: boolean, + ): Promise<{ ok: true; client: VercelClient; tokenEnv: string } | Failure> => { + const tokenEnv = opts.tokenEnv?.trim() || DEFAULT_VERCEL_TOKEN_ENV; + // `--web`, `--json` and `--dry-run` each suppress ONLY the system store's + // hidden prompt: a raw terminal prompt must not hang a browser run, be + // interleaved with machine output, or save a key as a preview's side effect. + const mayPrompt = vercelSecretsMayPrompt(interactive, !!opts.web || !!opts.json || !!opts.dryRun); + const resolved = await resolveVercelToken({ + tokenEnv, + env: env(), + interactive: mayPrompt, + orgId, + devMode: opts.devMode, + getConnectorSecret: deps.getConnectorSecret, + }); + if (!resolved.ok) { + const { reason } = describeVercelTokenProblem(resolved.code, tokenEnv); + return { ok: false, code: resolved.code, message: `${reason}.` }; + } + return { + ok: true, + tokenEnv, + client: createVercelClient(resolved.value, { fetch: deps.fetch, sleep: deps.sleep }), + }; + }; + + /** Team scope: `--team`, the token's only team, a pick (only when this run can ask), or a coded refusal. */ + const chooseTeam = async ( + client: VercelClient, + opts: ConnectOpts, + promptable: boolean, + ): Promise<{ ok: true; teamId: string | undefined; teamSlug?: string } | Failure> => { + const teams = await guard(() => client.listTeams()); + if (!teams.ok) return teams; + const team = await resolveTeam(teams.value, opts.team, promptable ? pickTeam : null); + return team; + }; + + const readSource = async ( + client: VercelClient, + ctx: ResolvedContext, + opts: ConnectOpts, + promptable: boolean, + ): Promise<({ ok: true } & SourceRead) | Failure> => { + // Discovery's per-environment call hands over the team and project it + // already resolved; a plain import resolves both here. + const team = opts.vercelTeam ? { ok: true as const, teamId: opts.vercelTeam.id } : await chooseTeam(client, opts, promptable); + if (!team.ok) return team; + + const projectInfo = opts.vercelProject + ? ({ ok: true, project: opts.vercelProject } as const) + : await resolveProjectInfo(client, opts, promptable, team.teamId); + if (!projectInfo.ok) return projectInfo; + + const custom = await guard(() => client.listCustomEnvironments(projectInfo.project.id, team.teamId)); + if (!custom.ok) return custom; + const chosen = chooseEnvironment(availableEnvironments(custom.value), opts.environment, ctx.branch); + if (!chosen.ok) return chosen; + const list = await guard(() => client.listEnvs(projectInfo.project.id, team.teamId)); + if (!list.ok) return list; + + const plan = buildEnvironmentPlans(list.value.envs, custom.value).find((p) => p.environment.name === chosen.environment.name); + return { + ok: true, + teamId: team.teamId, + project: projectInfo.project, + environment: chosen.environment, + importable: plan?.importable ?? [], + skipped: plan?.skipped ?? [], + truncated: list.value.truncated, + }; + }; + + const resolveProjectInfo = async ( + client: VercelClient, + opts: ConnectOpts, + promptable: boolean, + teamId: string | undefined, + ): Promise<{ ok: true; project: { id: string; name: string; productionBranch?: string } } | Failure> => { + const arg = await pickProjectArg(opts, promptable, askProject); + if (!arg) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code: ERROR_CODES.VERCEL_PROJECT_REQUIRED, message: 'No Vercel project. Pass --project , or run this interactively.' }; + } + const found = await fetchProject(client, arg, teamId); + if (!found.ok) return found; + const productionBranch = found.project.link?.productionBranch; + return { ok: true, project: { id: found.project.id, name: found.project.name, ...(productionBranch ? { productionBranch } : {}) } }; + }; + + /** `--overwrite`'s own run: plan from Vercel's FULL readable set (never `--var`-restricted), confirm, return the outcome for the caller to write. */ + const runOverwriteImport = async (a: { + ctx: ResolvedContext; + opts: ConnectOpts; + source: SourceRead; + importSource: VercelImportSource; + client: VercelClient; + promptable: boolean; + dryRun: boolean; + }): Promise => { + const { ctx, opts, source, importSource, client, promptable, dryRun } = a; + const values = await guard(() => resolveEnvValues(client, source.project.id, source.teamId, source.importable)); + if (!values.ok) return values; + const fullCandidates = values.value.entries.map((e) => ({ name: e.record.key, value: e.value })); + // A name Vercel HAS but will not show us (sensitive, scoped, empty, …) is + // never cleared locally just because it is unreadable. + const protectedNames = [...source.skipped, ...values.value.empty].map((s) => s.name); + const plan = computeOverwritePlan(fullCandidates, protectedNames, ctx.localPlaintext); + const warnings = truncationWarnings(source); + + const hasWrite = plan.toClear.length > 0 || plan.toReplace.length > 0 || plan.toImport.length > 0; + if (!dryRun && hasWrite) { + if (promptable && !opts.yes) { + const proceed = await confirm(overwriteConfirmMessage(plan), false); + if (!proceed) { + return { ok: true, source: importSource, imported: [], cleared: [], replacedNames: [], unchanged: plan.unchanged, skipped: [], warnings, deployTargetSaved: false }; + } + } else if (!promptable && !opts.yes) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code: ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED, message: 'Pass --yes to overwrite non-interactively, or run this interactively.' }; + } + } + + const importedAt = now().toISOString(); + const createdAtSec = Math.floor(now().getTime() / 1000); + const recordByName = new Map(values.value.entries.map((e) => [e.record.key, e.record] as const)); + const entryFor = (name: string, value: string) => importedEntry(recordByName.get(name)!, name, value, source, createdAtSec, importedAt); + const written = [...plan.toImport, ...plan.toReplace].map(({ name, value }) => entryFor(name, value)); + const unchangedEntries = plan.unchanged.map((name) => entryFor(name, fullCandidates.find((c) => c.name === name)!.value)); + return { + ok: true, + source: importSource, + imported: written, + cleared: plan.toClear, + replacedNames: plan.toReplace.map((e) => e.name), + unchanged: plan.unchanged, + unchangedEntries, + // The names Vercel has but will not show are reported here too: an + // overwrite leaves them alone, and saying so is the point of the list. + skipped: [...source.skipped, ...values.value.empty].map(toImportSkip), + warnings, + deployTargetSaved: false, + }; + }; + + const connector: ConnectorModule = { + name: 'vercel', + // COPY-FLAG: new user-facing string, minimal/neutral wording. + description: 'One-time import of env vars from a Vercel project', + kind: 'import', + displayName: 'Vercel', + + // Never reached: `connectCommand.ts` routes `kind === 'import'` to import(). + async connect(): Promise { + throw new Error('vercel is import-only; connectCommand should have routed to import() instead.'); + }, + async rotate(): Promise { + throw new Error('vercel is import-only; rotateCommand should have refused before calling rotate().'); + }, + + async import(ctx: ResolvedContext, opts: ConnectOpts): Promise { + const interactive = isInteractive(opts.nonTty); + // A dry run changes nothing; `--json` never has a prompt interleaved. + const dryRun = !!opts.dryRun; + const promptable = interactive && !opts.json; + + const opened = await openClient(ctx.orgId, opts, interactive); + if (!opened.ok) return opened; + const { client } = opened; + + const source = await readSource(client, ctx, opts, promptable); + if (!source.ok) return source; + + const importSource: VercelImportSource = { + kind: 'vercel', + projectId: source.project.id, + projectName: source.project.name, + ...(source.teamId ? { teamId: source.teamId } : {}), + environment: source.environment.name, + }; + + if (opts.overwrite) { + return runOverwriteImport({ ctx, opts, source, importSource, client, promptable, dryRun }); + } + + const restrict = parseVarList(opts.var); + const restricted = source.importable.filter((r) => !restrict || restrict.includes(r.key)); + const values = await guard(() => resolveEnvValues(client, source.project.id, source.teamId, restricted)); + if (!values.ok) return values; + const candidates = values.value.entries.map((e) => ({ name: e.record.key, value: e.value })); + + // A dry run previews the WHOLE plan rather than a hand-picked subset. + const selectedNames = restrict || !promptable || dryRun ? candidates.map((c) => c.name) : await selectVars(candidates.map((c) => c.name)); + const selected = candidates.filter((c) => selectedNames.includes(c.name)); + + const { unchanged, skipped: conflictSkipped, toImport, wouldAsk } = await classifyImportCandidates(selected, ctx.localPlaintext, { + dryRun, + interactive: promptable, + confirm, + providerLabel: 'Vercel', + }); + + const importedAt = now().toISOString(); + const createdAtSec = Math.floor(now().getTime() / 1000); + const recordByName = new Map(values.value.entries.map((e) => [e.record.key, e.record] as const)); + const entryFor = (name: string, value: string) => importedEntry(recordByName.get(name)!, name, value, source, createdAtSec, importedAt); + const imported = toImport.map(({ name, value }) => entryFor(name, value)); + // A re-import over a name whose value already matches still refreshes + // its connector metadata — never populated under a dry run. + const unchangedEntries = dryRun ? [] : unchanged.map((name) => entryFor(name, selected.find((c) => c.name === name)!.value)); + + const inScope = (name: string): boolean => !restrict || restrict.includes(name); + const skipped: readonly ImportSkip[] = [ + ...[...source.skipped, ...values.value.empty].filter((s) => inScope(s.name)).map(toImportSkip), + ...conflictSkipped, + ]; + + const importedNames = [...imported.map((i) => i.varName), ...unchanged]; + const deployTargetSaved = + !dryRun && + (await maybeOfferDeployTarget({ + importedNames, + project: source.project, + teamId: source.teamId, + environment: source.environment, + allTargets: await listTargetsAt(opts.projectDir ?? cwd()), + promptable, + dir: opts.projectDir ?? cwd(), + confirm, + })); + + return { + ok: true, + source: importSource, + imported, + unchanged, + unchangedEntries, + skipped, + warnings: truncationWarnings(source), + deployTargetSaved, + ...(dryRun ? { wouldAsk } : {}), + }; + }, + + /** + * `capy connect vercel --discover`: find every Vercel project whose git + * link matches a repo reachable from `cwd`, then run Dokploy discovery's + * own sequence (`runDiscoverySequences`) over the plan. See + * `vercelDiscovery.ts`. + */ + async discover(ctx: DiscoveryContext, opts: ConnectOpts): Promise { + const interactive = isInteractive(opts.nonTty); + const dryRun = !!opts.dryRun; + const promptable = interactive && !opts.json; + + const opened = await openClient(ctx.orgId, opts, interactive); + if (!opened.ok) return opened; + const { client, tokenEnv } = opened; + + const team = await chooseTeam(client, opts, promptable); + if (!team.ok) return team; + + const scan = findCandidateReposResult(cwd()); + const environmentFilter = parseVarList(opts.environment) ?? undefined; + const built = await guard(() => + buildVercelDiscoveryPlan( + client, + team.teamId, + scan.repos as readonly CandidateRepo[], + { peekLocalKeep: peekLocalKeepForFolder, listServerBranches: (projectId) => ctx.serviceClient.listBranches(projectId) }, + environmentFilter, + ), + ); + if (!built.ok) return built; + const plan: DiscoveryPlan = { + ...built.value, + unreadableRepos: scan.unreadable, + ...(team.teamId ? { team: { id: team.teamId, ...(team.teamSlug ? { slug: team.teamSlug } : {}) } } : {}), + }; + + if (environmentFilter) { + const unknown = environmentFilter.filter((name) => !plan.allEnvironmentNames.includes(name)); + if (unknown.length > 0) { + return { + ok: false, + code: ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `No such environment(s): ${unknown.join(', ')}. Environments found: ${plan.allEnvironmentNames.join(', ') || '(none)'}.`, + }; + } + } + + if (dryRun) { + // A dry run never asks for a branch name either; the DEFAULT is what it previews. + const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), VERCEL_COMMIT_MESSAGE); + return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}) }; + } + + // `--project` under `--discover` is the collision-winner hint (an id or a name), not an import source. + const preferredServiceId = preferredProjectId(plan, opts.project); + const resolution = await resolveCollisions(orderEnvironments(plan.collisions), { + preferredServiceId, + interactive: promptable, + askWinner: pickCollisionWinner, + }); + if (!resolution.ok) { + return { + ok: false, + code: ERROR_CODES.VERCEL_MAPPING_COLLISION, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: 'Several Vercel projects map to the same folder + branch. Pass --project to pick one, or run this interactively.', + }; + } + + const resolvedFolders = applyCollisionResolutions(plan.folders, resolution.resolutions); + if (resolvedFolders.length === 0) return { ok: true, dryRun: false, plan, applied: [] }; + + // A real run never writes without a yes: `--yes` skips the ask regardless of TTY. + if (promptable && !opts.yes) { + const proceed = await confirm(discoveryConfirmMessage(resolvedFolders, !!opts.overwrite, environmentFilter), false); + if (!proceed) return { ok: true, dryRun: false, plan, applied: [], cancelled: true }; + } else if (!promptable && !opts.yes) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code: ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED, message: 'Pass --yes to apply non-interactively, or run this interactively.' }; + } + + const commitBranchName = + promptable && !opts.yes + ? await askDiscoveryCommitBranchName(defaultDiscoveryCommitBranchName(new Date(), 'vercel')) + : defaultDiscoveryCommitBranchName(new Date(), 'vercel'); + + // Snapshotted BEFORE any write, per repo, so the commit step can tell + // "discovery made this change" from "already dirty before the run". + const beforeStatusByRepo = new Map( + [...new Set(resolvedFolders.map((f) => f.repoDir))].map((repoDir) => { + const paths = resolvedFolders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)); + return [repoDir, snapshotPathStatus(repoDir, paths)] as const; + }), + ); + + // Same snapshot-once rule as Dokploy's non-interactive `--yes` auto-create. + const existingProjectsSnapshot: ExistingProjectsSnapshot | undefined = + !promptable && !!opts.yes ? await listOrgProjectsOrUnavailable(ctx.serviceClient) : undefined; + const sameRunNameCollisions: ReadonlySet = !promptable && !!opts.yes ? computeSameRunNameCollisions(resolvedFolders) : new Set(); + + const sequenceDeps = buildRealDiscoverySequenceDeps(ctx, connector.import!, { + noPush: !!opts.noPush, + interactive: promptable, + json: !!opts.json, + yes: !!opts.yes, + baseUrl: '', + tokenEnv, + importOptsFor: (env, projectName, folderPath) => ({ + project: env.serviceId, + vercelProject: { id: env.serviceId, name: projectName, ...(env.gitBranch ? { productionBranch: env.gitBranch } : {}) }, + vercelTeam: { id: team.teamId }, + environment: env.environmentName, + tokenEnv, + projectDir: folderPath, + }), + askExistingOrNewProject, + askProjectName: askDiscoveryProjectName, + existingProjectsSnapshot, + sameRunNameCollisions, + }); + const applied = await runDiscoverySequences(resolvedFolders, { overwrite: !!opts.overwrite }, sequenceDeps); + const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, VERCEL_COMMIT_MESSAGE); + return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}) }; + }, + + reportDiscovery: reportVercelDiscovery, + }; + return connector; +} + +// ── small helpers ─────────────────────────────────────────────────────────── + +async function listTargetsAt(dir: string): Promise { + const { listTargets } = await import('../../deploy/config'); + return listTargets(dir); +} + +const toImportSkip = (s: VercelSkippedRecord): ImportSkip => ({ name: s.name, code: s.reason, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) }); + +function truncationWarnings(source: SourceRead): readonly ImportWarning[] { + // The env list documents no paging parameter, so a "next page" it reports cannot be fetched: say so rather than import silently partial. + return source.truncated ? [{ code: 'VERCEL_ENV_LIST_TRUNCATED', names: [] }] : []; +} + +function importedEntry( + record: VercelEnvRecord, + name: string, + value: string, + source: SourceRead, + createdAtSec: number, + importedAt: string, +): { varName: string; value: string; entry: ConnectorMetadata } { + return { + varName: name, + value, + entry: buildVercelConnectorEntry(record, value, { + project: source.project, + teamId: source.teamId, + environment: source.environment.name, + createdAtSec, + importedAt, + }), + }; +} + +/** `--project` (id or name) → the id of a project in the plan, else the raw value. */ +function preferredProjectId(plan: DiscoveryPlan, project: string | undefined): string | undefined { + const want = project?.trim(); + if (!want) return undefined; + const ids = plan.folders.flatMap((f) => f.environments.map((e) => ({ id: e.serviceId, name: f.projectName }))); + return ids.find((p) => p.id === want || p.name === want)?.id ?? want; +} + +/** + * The `capy connect vercel` production instance — the only place this file + * wires the org system store for real (every other construction, including + * every test, gets the safe env-only default — see `VercelConnectorDeps`). + */ +export const vercelConnector: ConnectorModule = createVercelConnector({ + getConnectorSecret: async (name, opts) => { + // Import's own direction: `_CONNECTOR_VERCEL_TOKEN`, borrowing from + // `_TARGET_VERCEL_TOKEN` (the deploy side) the same way Dokploy's does. + const { getDirectionalConnectorSecret } = await import('../../system/systemStore'); + return getDirectionalConnectorSecret(name, VERCEL_TARGET_SECRET_NAME, { + ...opts, + missingWithFallbackCode: ERROR_CODES.VERCEL_CONNECTOR_KEY_MISSING, + }); + }, +}); diff --git a/src/commands/connectors/vercelDiscovery.ts b/src/commands/connectors/vercelDiscovery.ts new file mode 100644 index 00000000..84da1d95 --- /dev/null +++ b/src/commands/connectors/vercelDiscovery.ts @@ -0,0 +1,363 @@ +/** + * Discovery mode for `capy connect vercel --discover` (CAP-696): find every + * Vercel project whose git link matches a repo reachable from `cwd`, then run + * the SAME init → per-environment checkout → import sequence Dokploy + * discovery runs (`dokployDiscovery.ts#runDiscoverySequences`, reused + * unchanged), ending in the same local, never-pushed discovery commit. + * + * What differs from Dokploy, and why: + * - Matching. A Vercel project carries `link: { type: 'github', org, repo }` + * and a `rootDirectory`. The repo is matched on `origin` owner/repo exactly + * as Dokploy's is; the folder is the normalized `rootDirectory` (null or + * '' = the repo root). Only GitHub links are matched: the docs show no + * GitLab/Bitbucket link shape, so those projects are REPORTED + * (`unsupported_git_provider`), never guessed at. + * - Nesting. A Vercel project has exactly one folder, so there is no nested- + * folder folding: two projects on the same repo + folder are a collision, + * handled by the same `detectCollisions` / `resolveCollisions` Dokploy + * uses. + * - Names only. The plan reads each project's env LIST (no values) and + * counts what is readable by type. No secret value is read, held, or + * printed until a real run's per-environment import fetches it. + * + * READ-ONLY on the Vercel side: `listProjects`, `listCustomEnvironments`, + * `listEnvs` — see `deploy/vercelApi.ts`. + */ +import { existsSync } from 'node:fs'; +import { sortedCopy } from '../../deploy/dokployApi'; +import { join } from 'node:path'; +import { + VercelClient, + VercelProject, + VercelEnvironmentPlan, + buildEnvironmentPlans, + groupSkipped, +} from '../../deploy/vercelApi'; +import { + CandidateRepo, + DiscoveryFolderResult, + DiscoveryOutcome, + DiscoveryPlan, + DiscoveryPlanEnv, + DiscoveryPlanFolder, + DiscoveryUnmatched, + PeekLocalKeep, + ListServerBranches, + VercelUnmatchedReason, + detectCollisions, + normalizeRelativeDir, + orderEnvironments, +} from './dokployDiscovery'; +import type { ConnectOpts } from './registry'; + +/** `listProjects`/`listCustomEnvironments`/`listEnvs` only — the read-only surface planning needs. */ +export type VercelDiscoveryClient = Pick; + +interface MatchedProject { + project: VercelProject; + repoDir: string; + remote: CandidateRepo['remote']; + /** Relative to `repoDir`; '' = repo root. */ + folder: string; + productionBranch?: string; +} + +const unmatchedFrom = (project: VercelProject, reason: VercelUnmatchedReason): DiscoveryUnmatched => ({ + projectName: project.name, + serviceName: project.name, + serviceKind: 'vercel', + serviceId: project.id, + reason, +}); + +/** + * One Vercel project → a local repo + folder, or the reason it did not match. + * Checked in this order so each project gets its most specific reason: + * no link at all, a link this connector cannot match, no local repo with that + * `origin`, a `rootDirectory` that does not exist in the matched repo. + */ +export function matchVercelProject( + project: VercelProject, + repos: readonly CandidateRepo[], + folderExists: (repoDir: string, folder: string) => boolean, +): { kind: 'matched'; value: MatchedProject } | { kind: 'unmatched'; value: DiscoveryUnmatched } { + const link = project.link; + if (!link) return { kind: 'unmatched', value: unmatchedFrom(project, 'no_git_link') }; + if (link.kind !== 'github') return { kind: 'unmatched', value: unmatchedFrom(project, 'unsupported_git_provider') }; + const hit = repos.find((r) => r.remote.owner === link.org.toLowerCase() && r.remote.repo === link.repo.toLowerCase()); + if (!hit) return { kind: 'unmatched', value: unmatchedFrom(project, 'no_remote_match') }; + const folder = normalizeRelativeDir(project.rootDirectory ?? ''); + if (!folderExists(hit.repoDir, folder)) return { kind: 'unmatched', value: unmatchedFrom(project, 'root_directory_missing') }; + return { + kind: 'matched', + value: { project, repoDir: hit.repoDir, remote: hit.remote, folder, ...(link.productionBranch ? { productionBranch: link.productionBranch } : {}) }, + }; +} + +const defaultFolderExists = (repoDir: string, folder: string): boolean => folder === '' || existsSync(join(repoDir, folder)); + +function envFromPlan(plan: VercelEnvironmentPlan, m: MatchedProject): Omit { + return { + environmentName: plan.environment.name, + serviceId: m.project.id, + serviceKind: 'vercel', + variableCount: plan.importable.length, + skippedCount: plan.skipped.length, + // The project's production git branch — informational only, never the Capy + // branch (the Vercel environment is), same role as Dokploy's `gitBranch`. + ...(m.productionBranch ? { gitBranch: m.productionBranch } : {}), + rawEnv: null, + skipped: plan.skipped.map((s) => ({ name: s.name, code: s.reason, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) })), + }; +} + +interface ProjectPlan { + matched: MatchedProject; + envs: ReadonlyArray>; +} + +/** Sequential, one project at a time: deterministic call order, and gentle on Vercel's rate limit. */ +async function readProjectPlans( + client: VercelDiscoveryClient, + teamId: string | undefined, + matched: readonly MatchedProject[], +): Promise { + return matched.reduce>(async (accPromise, m) => { + const acc = await accPromise; + const custom = await client.listCustomEnvironments(m.project.id, teamId); + const list = await client.listEnvs(m.project.id, teamId); + const plans = buildEnvironmentPlans(list.envs, custom); + return [...acc, { matched: m, envs: plans.map((p) => envFromPlan(p, m)) }]; + }, Promise.resolve([])); +} + +async function toPlanFolder( + pp: ProjectPlan, + environmentFilter: ReadonlySet | undefined, + peek: { peekLocalKeep: PeekLocalKeep; listServerBranches: ListServerBranches }, +): Promise { + const envs = environmentFilter ? pp.envs.filter((e) => environmentFilter.has(e.environmentName)) : pp.envs; + if (envs.length === 0) return null; + const { matched } = pp; + const localKeep = peek.peekLocalKeep(matched.repoDir, matched.folder); + const initialized = !!localKeep; + const serverBranchNames = localKeep ? new Set((await peek.listServerBranches(localKeep.project_id)).map((b) => b.name)) : null; + const firstName = orderEnvironments(envs)[0]?.environmentName; + const environments: readonly DiscoveryPlanEnv[] = envs.map((e) => { + const branchExists = serverBranchNames ? serverBranchNames.has(e.environmentName) : undefined; + const willCreateBranch = branchExists === false || !initialized; + const willAutoOverwrite = willCreateBranch && !(!initialized && firstName === e.environmentName); + return { ...e, ...(branchExists !== undefined ? { branchExists } : {}), willAutoOverwrite }; + }); + return { + repoDir: matched.repoDir, + folder: matched.folder, + projectName: matched.project.name, + serviceName: matched.project.name, + environments, + initialized, + remote: matched.remote, + }; +} + +/** + * Reads every project, matches against `repos`, plans a folder per matched + * project, flags collisions. Apart from the injected client/peek this never + * writes anything — safe under `--dry-run` as-is. + */ +export async function buildVercelDiscoveryPlan( + client: VercelDiscoveryClient, + teamId: string | undefined, + repos: readonly CandidateRepo[], + peek: { peekLocalKeep: PeekLocalKeep; listServerBranches: ListServerBranches }, + environmentFilter?: readonly string[], + folderExists: (repoDir: string, folder: string) => boolean = defaultFolderExists, +): Promise { + const projects = await client.listProjects(teamId); + const results = projects.map((p) => matchVercelProject(p, repos, folderExists)); + const matched = results.flatMap((r) => (r.kind === 'matched' ? [r.value] : [])); + const unmatchedByLink = results.flatMap((r) => (r.kind === 'unmatched' ? [r.value] : [])); + + const projectPlans = await readProjectPlans(client, teamId, matched); + const withVariables = projectPlans.filter((pp) => pp.envs.length > 0); + const noVariables = projectPlans.filter((pp) => pp.envs.length === 0).map((pp) => unmatchedFrom(pp.matched.project, 'no_variables')); + const allEnvironmentNames = sortedCopy([...new Set(withVariables.flatMap((pp) => pp.envs.map((e) => e.environmentName)))]); + const filterSet = environmentFilter && environmentFilter.length > 0 ? new Set(environmentFilter) : undefined; + + const maybeFolders = await Promise.all(withVariables.map((pp) => toPlanFolder(pp, filterSet, peek))); + const folders = maybeFolders.filter((f): f is DiscoveryPlanFolder => f !== null); + return { + folders, + collisions: detectCollisions(folders), + unmatched: [...unmatchedByLink, ...noVariables], + unreadableRepos: [], + allEnvironmentNames, + ...(filterSet ? { environmentFilter: [...filterSet] } : {}), + }; +} + +// ── Reporting (names and codes only — never a value) ─────────────────────── + +const B = (s: string) => `\x1b[1m${s}\x1b[0m`; + +const projectIdOf = (f: DiscoveryPlanFolder): string => f.environments[0]?.serviceId ?? ''; + +function envReport(e: Extract['environments'][number]) { + return { + branch: e.environmentName, + branchCreated: e.branchCreated, + overwrote: e.overwrote, + imported: e.outcome.imported.map((i) => i.varName), + ...(e.outcome.replacedNames ? { replacedNames: e.outcome.replacedNames } : {}), + ...(e.outcome.cleared ? { cleared: e.outcome.cleared } : {}), + unchanged: e.outcome.unchanged, + skipped: e.outcome.skipped, + warnings: e.outcome.warnings, + }; +} + +function planReport(plan: DiscoveryPlan) { + return { + ...(plan.team ? { team: plan.team } : {}), + ...(plan.environmentFilter ? { environmentFilter: plan.environmentFilter } : {}), + folders: plan.folders.map((f) => ({ + repoDir: f.repoDir, + folder: f.folder || '.', + vercelProject: f.projectName, + vercelProjectId: projectIdOf(f), + initialized: f.initialized, + environments: f.environments.map((e) => ({ + branch: e.environmentName, + variableCount: e.variableCount, + skippedCount: e.skippedCount, + skipped: groupSkipped((e.skipped ?? []).map((s) => ({ name: s.name, reason: s.code, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) }))), + ...(e.branchExists !== undefined ? { branchExists: e.branchExists } : {}), + willAutoOverwrite: e.willAutoOverwrite, + })), + })), + collisions: plan.collisions.map((c) => ({ + folder: c.folder || '.', + branch: c.environmentName, + candidates: c.candidates.map((cand) => ({ vercelProject: cand.projectName, vercelProjectId: cand.serviceId })), + })), + unmatched: plan.unmatched.map((u) => ({ vercelProject: u.projectName, vercelProjectId: u.serviceId, reason: u.reason })), + unreadableRepos: plan.unreadableRepos.map((r) => ({ repoDir: r.repoDir, code: r.code, message: r.message })), + }; +} + +function appliedReport(applied: readonly DiscoveryFolderResult[]) { + return applied.map((f) => + f.ok + ? { repoDir: f.repoDir, folder: f.folder || '.', ok: true as const, projectCreated: f.projectCreated, activeBranch: f.activeBranch, environments: f.environments.map(envReport) } + : { repoDir: f.repoDir, folder: f.folder || '.', ok: false as const, code: f.code, message: f.message, environments: f.environments.map(envReport) }, + ); +} + +function commitsReport(commits: NonNullable['commits']>) { + return commits.map((c) => + c.ok + ? c.dryRun + ? { repoDir: c.repoDir, ok: true as const, dryRun: true as const, branch: c.branch, wouldCommitFiles: c.wouldCommitFiles } + : { repoDir: c.repoDir, ok: true as const, dryRun: false as const, branch: c.branch, sha: c.sha, committedFiles: c.committedFiles } + : { repoDir: c.repoDir, ok: false as const, code: c.code, message: c.message }, + ); +} + +/** + * `ConnectorModule.reportDiscovery` for Vercel: under `--json`, exactly ONE + * JSON object on stdout (nothing else, from anything on this path); otherwise + * the human plan. Names and codes only. + */ +export function reportVercelDiscovery( + outcome: Extract, + opts: ConnectOpts, + provider: string, +): { linked: boolean } { + const plan = planReport(outcome.plan); + const applied = outcome.applied ? appliedReport(outcome.applied) : undefined; + const commits = outcome.commits ? commitsReport(outcome.commits) : undefined; + const linked = + !!outcome.applied && + outcome.applied.some((f) => f.environments.some((e) => e.outcome.imported.length > 0 || (e.outcome.cleared?.length ?? 0) > 0)); + // One folder failing still lets the others run, but the run as a whole did + // not fully succeed — CI watching the exit code must see that. + if (outcome.applied?.some((f) => !f.ok)) process.exitCode = 1; + + if (opts.json) { + console.log( + JSON.stringify({ + ok: true, + provider, + dryRun: outcome.dryRun, + cancelled: !!outcome.cancelled, + plan, + ...(applied ? { applied } : {}), + ...(commits ? { commits } : {}), + }), + ); + return { linked }; + } + + const lines = humanLines(outcome, plan, applied, commits); + lines.forEach((l) => console.log(l)); + return { linked }; +} + +// COPY-FLAG: report lines mirror Dokploy's discovery report word for word, with Vercel substituted; nothing new invented. +function humanLines( + outcome: Extract, + plan: ReturnType, + applied: ReturnType | undefined, + commits: ReturnType | undefined, +): readonly string[] { + const filterNote = plan.environmentFilter ? ` (--environment ${plan.environmentFilter.join(',')})` : ''; + return [ + '', + outcome.dryRun ? ` Dry run — nothing written.${filterNote}` : ` Discovery plan:${filterNote}`, + ...(plan.unreadableRepos.length > 0 + ? [' ⚠ Repos git could not read:', ...plan.unreadableRepos.map((r) => ` ${r.repoDir} (${r.code}): ${r.message}`)] + : []), + ...(plan.folders.length === 0 ? [' No matching Vercel projects found.'] : []), + ...plan.folders.flatMap((f) => [ + ` ${B(f.folder)} (${f.vercelProject})${f.initialized ? '' : ' — capy (init)'}`, + ...f.environments.flatMap((e) => [ + ` ${e.branchExists === true ? 'checkout' : 'checkout -b'} ${e.branch}: ${e.variableCount} var(s), ${e.skippedCount} skipped${e.willAutoOverwrite ? ' [auto-overwrite]' : ''}`, + ...Object.entries(e.skipped).map( + ([reason, items]) => ` skipped ${reason}: ${items.map((i) => (i.gitBranch ? `${i.name} (${i.gitBranch})` : i.name)).join(', ')}`, + ), + ]), + ]), + ...(plan.collisions.length > 0 + ? [' Collisions:', ...plan.collisions.map((c) => ` ${c.folder} / ${c.branch}: ${c.candidates.map((x) => x.vercelProject).join(', ')}`)] + : []), + ...(plan.unmatched.length > 0 ? [' Unmatched:', ...plan.unmatched.map((u) => ` ${u.vercelProject} (${u.reason})`)] : []), + ...appliedLines(outcome, applied), + ...(commits && commits.length > 0 ? [' Commit:', ...commits.map(commitLine)] : []), + '', + ]; +} + +// COPY-FLAG: report lines mirror Dokploy's discovery report word for word, with Vercel substituted; nothing new invented. +function appliedLines(outcome: Extract, applied: ReturnType | undefined): readonly string[] { + if (outcome.cancelled) return [' Cancelled — nothing written.']; + if (!applied) return []; + return applied.flatMap((f) => [ + ` ${B(f.folder)}${f.ok && f.projectCreated ? ' (new project)' : ''}`, + ...f.environments.map((e) => { + const clearedCount = e.cleared?.length ?? 0; + return ( + ` ${e.branch}${e.branchCreated ? ' (new branch)' : ''}${e.overwrote ? ' (--overwrite)' : ''}: ` + + `imported ${e.imported.length}, unchanged ${e.unchanged.length}, skipped ${e.skipped.length}` + + (clearedCount > 0 ? `, cleared ${clearedCount}` : '') + ); + }), + ...(f.ok ? (f.activeBranch ? [` folder is on branch ${f.activeBranch}`] : []) : [` ✗ ${f.code}: ${f.message}`]), + ]); +} + +// COPY-FLAG: report lines mirror Dokploy's discovery report word for word, with Vercel substituted; nothing new invented. +function commitLine(c: ReturnType[number]): string { + if (!c.ok) return ` ${c.repoDir}: ✗ ${c.code}: ${c.message}`; + if (c.dryRun) return ` ${c.repoDir}: would commit ${c.wouldCommitFiles.length} file(s) onto ${c.branch}`; + return ` ${c.repoDir}: committed ${c.committedFiles.length} file(s) onto ${c.branch} (${c.sha.slice(0, 8)})`; +} diff --git a/src/deploy/dokployApi.ts b/src/deploy/dokployApi.ts index dbeb2452..5a175732 100644 --- a/src/deploy/dokployApi.ts +++ b/src/deploy/dokployApi.ts @@ -1611,6 +1611,8 @@ export async function classifyImportCandidates( interactive: boolean; /** Names only — never the value on either side. */ confirm: (message: string, defaultValue: boolean) => Promise; + /** The provider the value comes from, for the replace prompt. Default `Dokploy` — every existing caller's wording is unchanged. */ + providerLabel?: string; }, ): Promise { return candidates.reduce>(async (accPromise, e) => { @@ -1628,7 +1630,7 @@ export async function classifyImportCandidates( if (opts.interactive) { // COPY-FLAG: new user-facing string, minimal/neutral wording. const replace = await opts.confirm( - `${e.name} is already set locally with a different value. Replace it with the Dokploy value?`, + `${e.name} is already set locally with a different value. Replace it with the ${opts.providerLabel ?? 'Dokploy'} value?`, false, ); return replace diff --git a/src/deploy/vercelApi.ts b/src/deploy/vercelApi.ts new file mode 100644 index 00000000..d3c8fb05 --- /dev/null +++ b/src/deploy/vercelApi.ts @@ -0,0 +1,756 @@ +/** + * Vercel REST client + env-record primitives for `capy connect vercel` + * (CAP-696): a ONE-TIME, READ-ONLY import of a Vercel project's environment + * variables. + * + * READ-ONLY by construction: every request goes through `VercelFetchLike`, + * whose `method` is the literal type `'GET'`, and this file contains no other + * verb. The token is a header on every request and is never part of an error + * message, a URL, or a return value. + * + * Docs checked (2026-10-01), every path/version/field below: + * - Teams: https://vercel.com/docs/rest-api/teams/list-all-teams + * GET /v2/teams, `limit`, `until`; `teams[].{id,slug,name}`; + * `pagination.next` is a timestamp for `until`. + * - Projects: https://vercel.com/docs/rest-api/projects/retrieve-a-list-of-projects + * GET /v10/projects, `limit`, `from` (a "timestamp or + * continuation token" — the docs list `from`, not `until`, + * for this endpoint), `teamId`; `projects[].{id,name, + * rootDirectory,link}`, `pagination.next`. `link` is + * undocumented beyond the GitHub shape + * (`type:'github'`, `org`, `repo`, `productionBranch`). + * - One project: GET /v9/projects/{idOrName} (projects index page). + * - Env list: https://vercel.com/docs/rest-api/projects/retrieve-the-environment-variables-of-a-project-by-id-or-name + * GET /v10/projects/{idOrName}/env; `envs[].{id,key,type, + * target,gitBranch,customEnvironmentIds,configurationId, + * value}`; `type` is encrypted | plain | secret | sensitive + * | system; `target` is an array OR a string. + * - Env value: https://vercel.com/docs/rest-api/projects/retrieve-the-decrypted-value-of-an-environment-variable-of-a-project-by-id + * GET /v1/projects/{idOrName}/env/{id} — "Retrieve the + * decrypted value of an environment variable of a project + * by id". Neither this route nor the list's `decrypt=true` + * is marked deprecated; this connector uses the per-id + * route so the list (names + metadata) never carries + * values. + * - Custom envs: https://vercel.com/docs/rest-api/environment/retrieve-custom-environments + * GET /v9/projects/{idOrName}/custom-environments → + * `{ environments: [{ id, slug, type, ... }] }`. + * - Rate limits: 429, `X-RateLimit-Limit/Remaining/Reset` (reset in UTC + * epoch seconds); error body `error.code === 'rate_limited'` + * with `error.limit.{reset,resetMs}` + * (https://vercel.com/docs/rest-api/errors). + * - Team scope: query `teamId` (or `slug`) on every endpoint above. + */ +import { ERROR_CODES } from '../types/index'; +import { sortedCopy } from './dokployApi'; + +// ── Names ──────────────────────────────────────────────────────────────────── + +/** Org system store entry that holds the Vercel token for IMPORT (`capy connect vercel`, inbound). */ +export const VERCEL_CONNECTOR_SECRET_NAME = '_CONNECTOR_VERCEL_TOKEN'; + +/** Org system store entry that holds the Vercel token for DEPLOY (`capy deploy`, outbound). A SEPARATE key on purpose — see `DOKPLOY_TARGET_SECRET_NAME`. */ +export const VERCEL_TARGET_SECRET_NAME = '_TARGET_VERCEL_TOKEN'; + +/** The env var read when no `--token-env` is given and the system store has no entry — mirrors Dokploy's `DOKPLOY_API_KEY` fallback. */ +export const DEFAULT_VERCEL_TOKEN_ENV = 'VERCEL_TOKEN'; + +export const VERCEL_API_BASE = 'https://api.vercel.com'; + +/** The three built-in Vercel environments; every project has them whether or not any variable targets them. */ +export const VERCEL_STANDARD_ENVIRONMENTS = ['production', 'preview', 'development'] as const; +export type VercelStandardEnvironment = (typeof VERCEL_STANDARD_ENVIRONMENTS)[number]; + +// ── Shapes (only the fields we read) ───────────────────────────────────────── + +export interface VercelTeam { + id: string; + slug: string; + name?: string; +} + +/** A project's git link. Only `github` is documented well enough to match on; everything else is carried as `unsupported` so it can be REPORTED, never guessed at. */ +export type VercelGitLink = + | { kind: 'github'; org: string; repo: string; productionBranch?: string } + | { kind: 'unsupported'; type: string; productionBranch?: string }; + +export interface VercelProject { + id: string; + name: string; + /** `null`/absent/'' all mean the repo root. */ + rootDirectory: string | null; + link: VercelGitLink | null; +} + +export interface VercelCustomEnvironment { + id: string; + slug: string; +} + +export interface VercelEnvRecord { + id: string; + key: string; + /** `system` | `secret` | `encrypted` | `plain` | `sensitive` — kept as a plain string so a type Vercel adds later is `unsupported_type`, never a crash. */ + type: string; + /** `target` normalized: the array form, the string form wrapped, absent → `[]` (custom-environment-only records). */ + targets: readonly string[]; + /** Preview-only branch scoping. Absent/'' = all branches. */ + gitBranch?: string; + customEnvironmentIds: readonly string[]; + configurationId?: string; + /** Present on the list response for `plain` records; never for `sensitive`; ciphertext-or-absent for `encrypted` (which is why values come from the per-id route). */ + value?: string; +} + +/** Which Vercel object an import read from — the `source` in the import's JSON, parallel to `DokployImportSource`. */ +export interface VercelImportSource { + kind: 'vercel'; + projectId: string; + projectName: string; + /** Absent for a personal-account project. */ + teamId?: string; + /** The Vercel environment: `production` | `preview` | `development` | a custom slug. */ + environment: string; +} + +// ── Errors ─────────────────────────────────────────────────────────────────── + +export type VercelErrorCode = + | 'unreachable' + | 'unauthorized' + | 'not_found' + | 'bad_request' + | 'rate_limited' + | 'server_error' + | 'bad_response'; + +export class VercelApiError extends Error { + constructor( + public readonly code: VercelErrorCode, + public readonly status: number | null, + message: string, + ) { + super(message); + this.name = 'VercelApiError'; + } +} + +function codeForStatus(status: number): VercelErrorCode { + if (status === 401 || status === 403) return 'unauthorized'; + if (status === 404) return 'not_found'; + if (status === 429) return 'rate_limited'; + if (status >= 400 && status < 500) return 'bad_request'; + return 'server_error'; +} + +/** + * A failed Vercel call as a stable refusal code + message. Never a value. + * Branches on the typed error's `code`, never on its text. + */ +export function mapVercelApiError(err: unknown): { code: string; message: string } { + if (err instanceof VercelApiError) { + if (err.code === 'unauthorized') { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { code: ERROR_CODES.VERCEL_AUTH_FAILED, message: `Vercel rejected the token (HTTP ${err.status}).` }; + } + if (err.code === 'rate_limited') { + return { code: ERROR_CODES.VERCEL_RATE_LIMITED, message: err.message }; + } + return { code: ERROR_CODES.VERCEL_API_ERROR, message: err.message }; + } + return { code: ERROR_CODES.VERCEL_API_ERROR, message: err instanceof Error ? err.message : String(err) }; +} + +// ── HTTP client ────────────────────────────────────────────────────────────── + +/** + * `fetch`'s shape, narrowed: GET only (read-only is a type, not a promise), + * and the response carries `headers` — the rate-limit wait is read from them. + * The global `fetch` satisfies it. + */ +export type VercelFetchLike = ( + url: string, + init: { + method: 'GET'; + headers: Record; + /** Always `'error'`: a redirect must fail rather than re-send the bearer token to whatever host a 30x names. */ + redirect?: 'error' | 'manual' | 'follow'; + }, +) => Promise<{ + status: number; + ok: boolean; + headers?: { get(name: string): string | null }; + text(): Promise; +}>; + +export interface VercelClientOptions { + fetch?: VercelFetchLike; + baseUrl?: string; + /** Injectable so a 429 test never really waits. */ + sleep?: (ms: number) => Promise; + now?: () => number; + /** How many times one request is retried after a 429. Default 3. */ + maxRetries?: number; + /** A requested wait longer than this is refused rather than slept. Default 60 000 ms. */ + maxWaitMs?: number; +} + +export interface VercelEnvList { + envs: readonly VercelEnvRecord[]; + /** True when Vercel said there is a next page this client cannot ask for (the env list documents no paging parameter). */ + truncated: boolean; +} + +/** The read-only surface the connector needs. No method here can write. */ +export interface VercelClient { + listTeams(): Promise; + listProjects(teamId?: string): Promise; + getProject(idOrName: string, teamId?: string): Promise; + listCustomEnvironments(projectId: string, teamId?: string): Promise; + listEnvs(projectId: string, teamId?: string): Promise; + /** The decrypted value of one env record, or `undefined` when Vercel returned none. */ + getEnvValue(projectId: string, envId: string, teamId?: string): Promise; +} + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_MAX_WAIT_MS = 60_000; +const DEFAULT_RETRY_WAIT_MS = 1_000; +const MAX_PAGES = 100; +const PAGE_LIMIT = '100'; + +const asRecord = (v: unknown): Record | null => + v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; + +const nonEmptyString = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined); + +const numberOrNull = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null); + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError('bad_response', null, 'Vercel returned a response that is not JSON.'); + } +} + +/** Milliseconds to wait before retrying a 429, from `Retry-After` (seconds), else `X-RateLimit-Reset` (epoch seconds), else the error body's `limit.resetMs`, else a short default. */ +export function rateLimitWaitMs( + headers: { get(name: string): string | null } | undefined, + bodyText: string, + nowMs: number, +): number { + const retryAfter = Number(headers?.get('retry-after')); + if (headers?.get('retry-after') != null && Number.isFinite(retryAfter)) return Math.max(0, retryAfter * 1000); + const reset = Number(headers?.get('x-ratelimit-reset')); + if (headers?.get('x-ratelimit-reset') != null && Number.isFinite(reset)) return Math.max(0, reset * 1000 - nowMs); + const resetMs = readBodyResetMs(bodyText); + return resetMs === null ? DEFAULT_RETRY_WAIT_MS : Math.max(0, resetMs - nowMs); +} + +function readBodyResetMs(bodyText: string): number | null { + try { + const body = asRecord(JSON.parse(bodyText)); + const limit = asRecord(asRecord(body?.error)?.limit); + return numberOrNull(limit?.resetMs); + } catch { + return null; + } +} + +export function createVercelClient(token: string, options: VercelClientOptions = {}): VercelClient { + const fetchImpl: VercelFetchLike = options.fetch ?? ((url, init) => fetch(url, init)); + const base = (options.baseUrl ?? VERCEL_API_BASE).replace(/\/+$/, ''); + const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const now = options.now ?? (() => Date.now()); + const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS; + + const urlFor = (path: string, query: Readonly>): string => { + const qs = new URLSearchParams( + Object.entries(query).flatMap(([k, v]) => (v === undefined ? [] : [[k, v] as [string, string]])), + ).toString(); + return `${base}${path}${qs ? `?${qs}` : ''}`; + }; + + const attempt = async (url: string, retriesLeft: number): Promise => { + const res = await fetchImpl(url, { + method: 'GET', + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + redirect: 'error', + }).catch((): never => { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError('unreachable', null, 'Could not reach Vercel.'); + }); + const text = await res.text(); + if (res.status === 429) { + const waitMs = rateLimitWaitMs(res.headers, text, now()); + if (retriesLeft <= 0 || waitMs > maxWaitMs) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError('rate_limited', 429, 'Vercel rate limit reached; try again later.'); + } + await sleep(waitMs); + return attempt(url, retriesLeft - 1); + } + if (!res.ok) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError(codeForStatus(res.status), res.status, `Vercel returned HTTP ${res.status}.`); + } + return parseJson(text); + }; + + const get = (path: string, query: Readonly> = {}): Promise => + attempt(urlFor(path, query), maxRetries); + + const enc = encodeURIComponent; + + return { + async listTeams() { + const collect = async (until: string | undefined, seen: readonly string[], page: number): Promise => { + const body = asRecord(await get('/v2/teams', { limit: PAGE_LIMIT, until })); + const teams = (Array.isArray(body?.teams) ? body.teams : []).flatMap((t) => { + const parsed = parseTeam(t); + return parsed ? [parsed] : []; + }); + const fresh = teams.filter((t) => !seen.includes(t.id)); + const next = numberOrNull(asRecord(body?.pagination)?.next); + if (next === null || fresh.length === 0 || page >= MAX_PAGES) return fresh; + return [...fresh, ...(await collect(String(next), [...seen, ...fresh.map((t) => t.id)], page + 1))]; + }; + return collect(undefined, [], 1); + }, + + async listProjects(teamId) { + const collect = async (from: string | undefined, seen: readonly string[], page: number): Promise => { + const body = await get('/v10/projects', { limit: PAGE_LIMIT, from, teamId }); + const list = Array.isArray(body) ? body : asRecord(body)?.projects; + const projects = (Array.isArray(list) ? list : []).flatMap((p) => { + const parsed = parseProject(p); + return parsed ? [parsed] : []; + }); + // A page with nothing new ends the walk: a server that ignores the + // continuation parameter would otherwise be asked for page 1 forever. + const fresh = projects.filter((p) => !seen.includes(p.id)); + const next = Array.isArray(body) ? null : numberOrNull(asRecord(asRecord(body)?.pagination)?.next); + if (next === null || fresh.length === 0 || page >= MAX_PAGES) return fresh; + return [...fresh, ...(await collect(String(next), [...seen, ...fresh.map((p) => p.id)], page + 1))]; + }; + return collect(undefined, [], 1); + }, + + async getProject(idOrName, teamId) { + const parsed = parseProject(await get(`/v9/projects/${enc(idOrName)}`, { teamId })); + if (!parsed) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError('bad_response', null, 'Vercel returned a project this client cannot read.'); + } + return parsed; + }, + + async listCustomEnvironments(projectId, teamId) { + const body = asRecord(await get(`/v9/projects/${enc(projectId)}/custom-environments`, { teamId })); + return (Array.isArray(body?.environments) ? body.environments : []).flatMap((e) => { + const rec = asRecord(e); + const id = nonEmptyString(rec?.id); + const slug = nonEmptyString(rec?.slug); + return id && slug ? [{ id, slug }] : []; + }); + }, + + async listEnvs(projectId, teamId) { + const body = asRecord(await get(`/v10/projects/${enc(projectId)}/env`, { teamId })); + const envs = (Array.isArray(body?.envs) ? body.envs : []).flatMap((e) => { + const parsed = parseEnvRecord(e); + return parsed ? [parsed] : []; + }); + const next = numberOrNull(asRecord(body?.pagination)?.next); + return { envs, truncated: next !== null }; + }, + + async getEnvValue(projectId, envId, teamId) { + const body = asRecord(await get(`/v1/projects/${enc(projectId)}/env/${enc(envId)}`, { teamId })); + return typeof body?.value === 'string' ? body.value : undefined; + }, + }; +} + +function parseTeam(raw: unknown): VercelTeam | null { + const rec = asRecord(raw); + const id = nonEmptyString(rec?.id); + const slug = nonEmptyString(rec?.slug); + if (!id || !slug) return null; + const name = nonEmptyString(rec?.name); + return { id, slug, ...(name ? { name } : {}) }; +} + +/** `link` → a github link we can match on, or `unsupported` carrying the provider type so it can be reported. */ +export function parseGitLink(raw: unknown): VercelGitLink | null { + const rec = asRecord(raw); + if (!rec) return null; + const type = nonEmptyString(rec.type) ?? 'unknown'; + const productionBranch = nonEmptyString(rec.productionBranch); + const branch = productionBranch ? { productionBranch } : {}; + const org = nonEmptyString(rec.org); + const repo = nonEmptyString(rec.repo); + if (type === 'github' && org && repo) return { kind: 'github', org, repo, ...branch }; + return { kind: 'unsupported', type, ...branch }; +} + +function parseProject(raw: unknown): VercelProject | null { + const rec = asRecord(raw); + const id = nonEmptyString(rec?.id); + const name = nonEmptyString(rec?.name); + if (!rec || !id || !name) return null; + return { id, name, rootDirectory: nonEmptyString(rec.rootDirectory) ?? null, link: parseGitLink(rec.link) }; +} + +function parseTargets(raw: unknown): readonly string[] { + if (typeof raw === 'string') return [raw]; + return Array.isArray(raw) ? raw.filter((t): t is string => typeof t === 'string') : []; +} + +function parseEnvRecord(raw: unknown): VercelEnvRecord | null { + const rec = asRecord(raw); + const id = nonEmptyString(rec?.id); + const key = nonEmptyString(rec?.key); + const type = nonEmptyString(rec?.type); + if (!rec || !id || !key || !type) return null; + const gitBranch = nonEmptyString(rec.gitBranch); + const configurationId = nonEmptyString(rec.configurationId); + return { + id, + key, + type, + targets: parseTargets(rec.target), + customEnvironmentIds: Array.isArray(rec.customEnvironmentIds) + ? rec.customEnvironmentIds.filter((c): c is string => typeof c === 'string') + : [], + ...(gitBranch ? { gitBranch } : {}), + ...(configurationId ? { configurationId } : {}), + ...(typeof rec.value === 'string' ? { value: rec.value } : {}), + }; +} + +// ── Token resolution ───────────────────────────────────────────────────────── + +export type VercelTokenSource = 'system' | 'env'; + +export type ResolveVercelTokenResult = + | { ok: true; value: string; source: VercelTokenSource } + | { ok: false; code: string }; + +/** What the system store's `getConnectorSecret` needs — same shape as `system/systemStore.ts#GetConnectorSecretOptions`. */ +export interface VercelSystemStoreCallOptions { + orgId?: string; + interactive: boolean; + devMode?: boolean; + apiUrl?: string; +} + +export interface ResolveVercelTokenOptions { + /** `--token-env`. Wins outright when that variable is actually set. */ + tokenEnv?: string; + env: Record; + /** Whether this run may let the store prompt a human (hidden input + save). */ + interactive: boolean; + orgId?: string; + devMode?: boolean; + apiUrl?: string; + /** + * Reads (and, when missing + interactive + admin, prompts for and saves) + * the org system store entry. Defaults to a function that never touches the + * network, so every test and every other caller gets zero store traffic + * unless it opts in — the `vercelConnector` singleton wires the real one. + */ + getConnectorSecret?: (name: string, opts: VercelSystemStoreCallOptions) => Promise; +} + +async function neverReachesTheStore(): Promise { + return null; +} + +/** A typed store error (`CapyError`-shaped: `{code: string}`) as its code — never its `.message`. */ +function storeErrorCode(err: unknown): string { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : ERROR_CODES.VERCEL_TOKEN_MISSING; +} + +type StoreOutcome = { kind: 'value'; value: string } | { kind: 'empty' } | { kind: 'error'; code: string }; + +async function readFromSystemStore( + getConnectorSecret: NonNullable, + callOpts: VercelSystemStoreCallOptions, +): Promise { + try { + const value = await getConnectorSecret(VERCEL_CONNECTOR_SECRET_NAME, callOpts); + return value ? { kind: 'value', value } : { kind: 'empty' }; + } catch (err) { + return { kind: 'error', code: storeErrorCode(err) }; + } +} + +/** + * The Vercel token for one command, in the order Dokploy's key resolves: + * + * 1. An explicit `--token-env` variable, when it is actually set. + * 2. The org system store's `_CONNECTOR_VERCEL_TOKEN` (missing + interactive + * + admin: the store itself asks, hidden input, and saves it). + * 3. `$VERCEL_TOKEN`. + * 4. Refused: the store's own coded error when it refused (non-admin, …), + * otherwise `VERCEL_TOKEN_MISSING`. + */ +export async function resolveVercelToken(opts: ResolveVercelTokenOptions): Promise { + const explicit = opts.tokenEnv?.trim(); + const fromExplicit = explicit ? opts.env[explicit] || null : null; + if (fromExplicit) return { ok: true, value: fromExplicit, source: 'env' }; + + const storeOutcome = await readFromSystemStore(opts.getConnectorSecret ?? neverReachesTheStore, { + orgId: opts.orgId, + interactive: opts.interactive, + devMode: opts.devMode, + apiUrl: opts.apiUrl, + }); + if (storeOutcome.kind === 'value') return { ok: true, value: storeOutcome.value, source: 'system' }; + + const fallback = opts.env[DEFAULT_VERCEL_TOKEN_ENV] || null; + if (fallback) return { ok: true, value: fallback, source: 'env' }; + + return storeOutcome.kind === 'error' + ? { ok: false, code: storeOutcome.code } + : { ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }; +} + +/** Whether a run may let the system store prompt for a missing token: a real TTY, and none of `--web`, `--json`, `--dry-run` (a preview must never save a key). */ +export function vercelSecretsMayPrompt(baseInteractive: boolean, suppressed: boolean): boolean { + return baseInteractive && !suppressed; +} + +/** A token-resolution refusal as a printable reason + hint — names only. */ +export function describeVercelTokenProblem(code: string, tokenEnv: string): { reason: string; hint: string } { + if (code === ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY) { + return { + // COPY-FLAG: minimal neutral wording. + reason: `only an org owner or admin can set ${VERCEL_CONNECTOR_SECRET_NAME} in the system store, and $${tokenEnv} is not set`, + hint: `Ask an org owner/admin to run \`capy system set ${VERCEL_CONNECTOR_SECRET_NAME}\`, or export ${tokenEnv} yourself.`, + }; + } + if (code === ERROR_CODES.VERCEL_CONNECTOR_KEY_MISSING) { + return { + // COPY-FLAG: minimal neutral wording. + reason: `${VERCEL_CONNECTOR_SECRET_NAME} is not set, but ${VERCEL_TARGET_SECRET_NAME} already holds a Vercel token`, + hint: `Run this interactively to choose whether to reuse it, or run \`capy system set ${VERCEL_CONNECTOR_SECRET_NAME}\` to set a dedicated one.`, + }; + } + if (code === ERROR_CODES.VERCEL_TOKEN_MISSING) { + return { + // COPY-FLAG: minimal neutral wording. + reason: `$${tokenEnv} is not set`, + hint: `Run \`capy system set ${VERCEL_CONNECTOR_SECRET_NAME}\`, or export ${tokenEnv}=… first.`, + }; + } + return { + // COPY-FLAG: minimal neutral wording. + reason: `could not resolve the Vercel token (${code})`, + hint: `Run \`capy system set ${VERCEL_CONNECTOR_SECRET_NAME}\`, or export ${tokenEnv}=… first.`, + }; +} + +// ── Teams ──────────────────────────────────────────────────────────────────── + +export type ResolveTeamResult = + | { ok: true; teamId: string | undefined; teamSlug?: string } + | { ok: false; code: string; message: string }; + +/** + * Which team scope to read: `--team ` when given; otherwise the + * token's only team, a pick when several and this run can ask, a coded + * refusal listing the slugs when it cannot; no teams at all means the + * personal account (no `teamId` query). + */ +export async function resolveTeam( + teams: readonly VercelTeam[], + requested: string | undefined, + pick: ((teams: readonly VercelTeam[]) => Promise) | null, +): Promise { + const want = requested?.trim(); + if (want) { + const hit = teams.find((t) => t.slug === want || t.id === want); + if (!hit) { + return { + ok: false, + code: ERROR_CODES.VERCEL_TEAM_NOT_FOUND, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `No Vercel team "${want}". Teams this token sees: ${teams.map((t) => t.slug).join(', ') || '(none)'}.`, + }; + } + return { ok: true, teamId: hit.id, teamSlug: hit.slug }; + } + if (teams.length === 0) return { ok: true, teamId: undefined }; + if (teams.length === 1) return { ok: true, teamId: teams[0].id, teamSlug: teams[0].slug }; + if (!pick) { + return { + ok: false, + code: ERROR_CODES.VERCEL_TEAM_REQUIRED, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `This token sees several Vercel teams (${teams.map((t) => t.slug).join(', ')}). Pass --team .`, + }; + } + const pickedId = await pick(teams); + const hit = teams.find((t) => t.id === pickedId) ?? teams[0]; + return { ok: true, teamId: hit.id, teamSlug: hit.slug }; +} + +// ── Environments and importable records ───────────────────────────────────── + +export type VercelEnvironmentKind = VercelStandardEnvironment | 'custom'; + +export interface VercelEnvironmentInfo { + /** The Capy branch name: `production` | `preview` | `development`, or a custom environment's slug. */ + name: string; + kind: VercelEnvironmentKind; + customEnvironmentId?: string; +} + +/** Why a record is not imported. Stable codes — never a sentence. */ +export type VercelSkipReason = + | 'git_branch_scoped' + | 'sensitive_unreadable' + | 'vercel_system' + | 'legacy_secret_unreadable' + | 'unsupported_type' + | 'duplicate_key' + | 'empty_value'; + +export interface VercelSkippedRecord { + name: string; + reason: VercelSkipReason; + /** Only for `git_branch_scoped`: the branch the record is scoped to (a name, never a value). */ + gitBranch?: string; +} + +export interface VercelEnvironmentPlan { + environment: VercelEnvironmentInfo; + /** Readable records, one per name. Values are NOT here — see `resolveEnvValues`. */ + importable: readonly VercelEnvRecord[]; + skipped: readonly VercelSkippedRecord[]; +} + +/** All environment names a project has: the three built-ins plus its custom environments' slugs. */ +export function availableEnvironments(custom: readonly VercelCustomEnvironment[]): readonly VercelEnvironmentInfo[] { + return [ + ...VERCEL_STANDARD_ENVIRONMENTS.map((name): VercelEnvironmentInfo => ({ name, kind: name })), + ...custom.map((c): VercelEnvironmentInfo => ({ name: c.slug, kind: 'custom', customEnvironmentId: c.id })), + ]; +} + +function skipReasonFor(rec: VercelEnvRecord, kind: VercelEnvironmentKind): VercelSkipReason | null { + if (rec.type === 'system') return 'vercel_system'; + if (rec.type === 'sensitive') return 'sensitive_unreadable'; + if (rec.type === 'secret') return 'legacy_secret_unreadable'; + if (rec.type !== 'plain' && rec.type !== 'encrypted') return 'unsupported_type'; + if (kind === 'preview' && rec.gitBranch) return 'git_branch_scoped'; + return null; +} + +function recordTouchesEnvironment(rec: VercelEnvRecord, env: VercelEnvironmentInfo): boolean { + if (env.kind === 'custom') return !!env.customEnvironmentId && rec.customEnvironmentIds.includes(env.customEnvironmentId); + return rec.targets.includes(env.name); +} + +/** + * One plan per environment that at least one record references. A record + * targeting several environments counts in each. Names only: no value is + * read here, so a plan is safe to print. + */ +export function buildEnvironmentPlans( + envs: readonly VercelEnvRecord[], + custom: readonly VercelCustomEnvironment[], +): readonly VercelEnvironmentPlan[] { + return availableEnvironments(custom).flatMap((environment): readonly VercelEnvironmentPlan[] => { + const records = envs.filter((r) => recordTouchesEnvironment(r, environment)); + return records.length === 0 ? [] : [planFor(environment, records)]; + }); +} + +function planFor(environment: VercelEnvironmentInfo, records: readonly VercelEnvRecord[]): VercelEnvironmentPlan { + const classified = records.map((rec) => ({ rec, reason: skipReasonFor(rec, environment.kind) })); + const readable = classified.flatMap(({ rec, reason }) => (reason === null ? [rec] : [])); + // Two readable records for one name in one environment: the first wins, the + // rest are reported, so a name is never imported twice. + const firstIds = new Map( + readable.flatMap((rec, i) => (readable.findIndex((r) => r.key === rec.key) === i ? [[rec.key, rec.id] as const] : [])), + ); + const importable = readable.filter((rec) => firstIds.get(rec.key) === rec.id); + const duplicates = readable.filter((rec) => firstIds.get(rec.key) !== rec.id); + const skipped: readonly VercelSkippedRecord[] = [ + ...classified.flatMap(({ rec, reason }): readonly VercelSkippedRecord[] => + reason === null + ? [] + : [{ name: rec.key, reason, ...(reason === 'git_branch_scoped' && rec.gitBranch ? { gitBranch: rec.gitBranch } : {}) }], + ), + ...duplicates.map((rec): VercelSkippedRecord => ({ name: rec.key, reason: 'duplicate_key' })), + ]; + return { environment, importable, skipped }; +} + +export interface ResolvedEnvValues { + /** Name + plaintext, for records that came back with a real value. */ + entries: ReadonlyArray<{ record: VercelEnvRecord; value: string }>; + /** Records that came back empty/absent although typed readable — never imported as empty strings. */ + empty: readonly VercelSkippedRecord[]; +} + +/** + * Reads the value of every importable record, sequentially (one request at a + * time keeps well inside the rate limit and gives a deterministic call + * order). A `plain` record's list value is used as-is; everything else + * (`encrypted`, or a `plain` record the list sent without a value) comes + * from the per-id decrypted-value route. + */ +export async function resolveEnvValues( + client: Pick, + projectId: string, + teamId: string | undefined, + records: readonly VercelEnvRecord[], +): Promise { + const fetched = await records.reduce>>( + async (accPromise, record) => { + const acc = await accPromise; + const value = + record.type === 'plain' && record.value !== undefined + ? record.value + : await client.getEnvValue(projectId, record.id, teamId); + return [...acc, { record, value }]; + }, + Promise.resolve([]), + ); + return { + entries: fetched.flatMap(({ record, value }) => (value === undefined || value === '' ? [] : [{ record, value }])), + empty: fetched.flatMap(({ record, value }): readonly VercelSkippedRecord[] => + value === undefined || value === '' ? [{ name: record.key, reason: 'empty_value' }] : [], + ), + }; +} + +/** `--var a,b`: names to restrict the import to; `null` when absent. */ +export function parseVarList(raw: string | undefined): readonly string[] | null { + if (!raw) return null; + const names = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return names.length > 0 ? names : null; +} + +/** Skipped names grouped by reason — the shape a dry-run plan prints. Names only. */ +export function groupSkipped( + skipped: ReadonlyArray<{ name: string; reason: string; gitBranch?: string }>, +): Readonly>> { + const reasons = sortedCopy([...new Set(skipped.map((s) => s.reason))]); + return Object.fromEntries( + reasons.map((reason) => [ + reason, + skipped.filter((s) => s.reason === reason).map((s) => ({ name: s.name, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) })), + ]), + ); +} diff --git a/src/git/discoveryCommit.ts b/src/git/discoveryCommit.ts index bb4cd206..901f24f9 100644 --- a/src/git/discoveryCommit.ts +++ b/src/git/discoveryCommit.ts @@ -91,10 +91,10 @@ export function snapshotPathStatus(repoRoot: string, paths: readonly string[]): } /** `capy/dokploy-import-` — the default branch name, used whenever nothing asked for (or answered) a different one. */ -export function defaultDiscoveryCommitBranchName(now: Date = new Date()): string { +export function defaultDiscoveryCommitBranchName(now: Date = new Date(), provider: string = 'dokploy'): string { const pad = (n: number): string => String(n).padStart(2, '0'); const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`; - return `capy/dokploy-import-${stamp}`; + return `capy/${provider}-import-${stamp}`; } /** @@ -143,7 +143,13 @@ export function commitDiscoveryChanges( repoRoot: string, paths: readonly string[], beforeStatus: ReadonlySet, - opts: { branchName: string; dryRun: boolean; summaryLines: readonly string[] }, + opts: { + branchName: string; + dryRun: boolean; + summaryLines: readonly string[]; + /** The commit subject. Default is the Dokploy one, so every existing caller's commit is unchanged. */ + commitMessage?: string; + }, ): DiscoveryCommitOutcome { if (paths.length === 0) { return { ok: true, dryRun: true, branch: opts.branchName, wouldCommitFiles: [] }; @@ -194,7 +200,7 @@ export function commitDiscoveryChanges( keepPaths.forEach((relPath) => syncTrackedKeepFromWorkingCopy(join(repoRoot, dirname(relPath)))); const added = tryGit(repoRoot, ['add', '--', ...paths]); - const messageArgs = ['-m', 'chore(capy): import Dokploy environments', ...(opts.summaryLines.length > 0 ? ['-m', opts.summaryLines.join('\n')] : [])]; + const messageArgs = ['-m', opts.commitMessage ?? 'chore(capy): import Dokploy environments', ...(opts.summaryLines.length > 0 ? ['-m', opts.summaryLines.join('\n')] : [])]; const committed = added.ok ? tryGit(repoRoot, ['commit', ...messageArgs, '--', ...paths]) : { ok: false as const }; if (!committed.ok) { // Never leaves the repo stranded on a half-made branch after a failed diff --git a/src/index.ts b/src/index.ts index fa47cab3..46cecdbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -915,7 +915,9 @@ program .option('--base-url ', 'dokploy import: dashboard URL') .option('--application ', 'dokploy import: Application id (mutually exclusive with --compose)') .option('--compose ', 'dokploy import: Compose service id (mutually exclusive with --application)') - .option('--token-env ', 'dokploy import: env var holding the API token') + .option('--token-env ', 'dokploy/vercel import: env var holding the API token') + .option('--project ', 'vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision)') + .option('--team ', 'vercel import/discover: Vercel team slug or id (default: the token\'s only team)') .option('--json', 'emit machine-readable JSON instead of the human UI (import connectors)') .option( '--dry-run', @@ -923,7 +925,7 @@ program ) .option( '--discover', - 'dokploy: find every Dokploy service matching a repo under cwd, instead of one named --application/--compose', + 'dokploy/vercel: find every Dokploy service / Vercel project matching a repo under cwd, instead of one named --application/--compose/--project', ) .option( '-y, --yes', @@ -935,7 +937,7 @@ program ) .option( '--environment ', - 'dokploy discover: restrict the plan to these Dokploy environment names, comma-separated (e.g. staging,preview)', + 'dokploy/vercel discover: restrict the plan to these environment names, comma-separated (e.g. staging,preview); vercel import: which Vercel environment to read (default: the current branch\'s name)', ) .action(async (provider, options, command) => { assertNotLocalOnly('connect'); @@ -971,6 +973,8 @@ program yes: options.yes === true, overwrite: options.overwrite === true, environment: options.environment, + project: options.project, + team: options.team, }); }); diff --git a/src/types/index.ts b/src/types/index.ts index 053a0183..ea30db26 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -93,6 +93,16 @@ export interface ConnectorMetadata { * availability rule as `dokploy_project`. */ environment?: string; + /** Vercel import only (CAP-696): the Vercel project's id. */ + vercel_project_id?: string; + /** Vercel import only (CAP-696): the Vercel project's name. */ + vercel_project?: string; + /** Vercel import only (CAP-696): the Vercel team id; absent for a personal-account project. */ + team_id?: string; + /** Vercel import only (CAP-696): the id of the Vercel env record the value came from (`environment`, above, names the Vercel environment). */ + env_id?: string; + /** Vercel import only (CAP-696): the integration configuration id on the env record, when Vercel set one. */ + configuration_id?: string; } /** @@ -664,6 +674,33 @@ export const ERROR_CODES = { * to clean it up manually. */ DEPLOY_TOKEN_UNTRACKED: 'DEPLOY_TOKEN_UNTRACKED', + // CAP-696 Vercel import + /** `capy connect vercel`: no token anywhere (`_CONNECTOR_VERCEL_TOKEN`, `--token-env`, `$VERCEL_TOKEN`) and no way to ask. */ + VERCEL_TOKEN_MISSING: 'VERCEL_TOKEN_MISSING', + /** `_CONNECTOR_VERCEL_TOKEN` is absent, `_TARGET_VERCEL_TOKEN` holds a token, and there is no TTY to ask which to use. */ + VERCEL_CONNECTOR_KEY_MISSING: 'VERCEL_CONNECTOR_KEY_MISSING', + /** Vercel answered 401/403 to the token. */ + VERCEL_AUTH_FAILED: 'VERCEL_AUTH_FAILED', + /** Any other failed or unreadable Vercel response. */ + VERCEL_API_ERROR: 'VERCEL_API_ERROR', + /** Vercel kept answering 429 after the bounded retries, or asked for a wait longer than the cap. */ + VERCEL_RATE_LIMITED: 'VERCEL_RATE_LIMITED', + /** The token sees several teams and no `--team` was given, and this run cannot ask. */ + VERCEL_TEAM_REQUIRED: 'VERCEL_TEAM_REQUIRED', + /** `--team` matched none of the teams the token sees. */ + VERCEL_TEAM_NOT_FOUND: 'VERCEL_TEAM_NOT_FOUND', + /** Single import with no `--project`, and this run cannot ask. */ + VERCEL_PROJECT_REQUIRED: 'VERCEL_PROJECT_REQUIRED', + /** `--project` names no project Vercel can see. */ + VERCEL_PROJECT_NOT_FOUND: 'VERCEL_PROJECT_NOT_FOUND', + /** Single import: the current Capy branch is not a Vercel environment of the project, and no `--environment` was given. */ + VERCEL_ENVIRONMENT_REQUIRED: 'VERCEL_ENVIRONMENT_REQUIRED', + /** `--environment` names no environment of the project (single import), or none the plan has (discover). */ + VERCEL_ENVIRONMENT_NOT_FOUND: 'VERCEL_ENVIRONMENT_NOT_FOUND', + /** A real write needs a yes (`--yes`) and this run cannot ask. */ + VERCEL_CONFIRMATION_REQUIRED: 'VERCEL_CONFIRMATION_REQUIRED', + /** Several Vercel projects map to the same repo, folder and branch, and this run cannot ask which wins. */ + VERCEL_MAPPING_COLLISION: 'VERCEL_MAPPING_COLLISION', } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/tests/commands/connectVercel.test.ts b/tests/commands/connectVercel.test.ts new file mode 100644 index 00000000..683ef11a --- /dev/null +++ b/tests/commands/connectVercel.test.ts @@ -0,0 +1,552 @@ +/** + * `capy connect vercel --project …` — the single-project import (CAP-696). + * + * Covers: token resolution before any request, team resolution (0/1/many), + * the environment → Capy branch mapping (production/preview/development and a + * custom environment's slug), one record targeting several environments, the + * skip reasons (git-branch-scoped preview, sensitive, system, legacy secret, + * empty value), conflicts / unchanged / `--var` / `--overwrite` / `--dry-run`, + * the keep.lock connector metadata shape, the deploy-target offer, and — end + * to end through the REAL `ConnectCommand.executeImport` + a REAL + * `FileManager` — that the `.env` values equal the mocked Vercel values and + * that no value appears on any output surface. + * + * Every Vercel call goes through a scripted fake that throws on any non-GET, + * so "this connector never writes to Vercel" is proven by running it. No + * `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createVercelConnector, buildVercelConnectorEntry } from '../../src/commands/connectors/vercel'; +import { listProviders, loadProvider } from '../../src/commands/connectors/registry'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { FileManager } from '../../src/files/fileManager'; +import { listTargets, upsertTarget } from '../../src/deploy/config'; +import { ERROR_CODES, type ConnectorMetadata, type KeepFile } from '../../src/types/index'; +import type { ConnectOpts, ImportOutcome } from '../../src/commands/connectors/registry'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { callsOf, fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, withExitCodeRestored, withIsolatedGlobalDir, withTTY, withTempDir } from '../helpers/testSandbox'; + +// ── fixtures (every value is a fake sentinel) ─────────────────────────────── + +const V = { + plainProd: 'plain-prod-SENT-1a2b', + encAll: 'enc-all-SENT-3c4d', + scoped: 'scoped-SENT-5e6f', + prevAll: 'prev-all-SENT-7a8b', + stage: 'stage-SENT-9c0d', + sensitive: 'sensitive-SENT-must-never-be-read', +} as const; +const ALL_SENTINELS = Object.values(V); + +const env = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +const ENVS: ReadonlyArray = [ + env({ id: 'env_1', key: 'PLAIN_PROD', type: 'plain', listValue: V.plainProd }), + env({ id: 'env_2', key: 'ENC_ALL', type: 'encrypted', target: ['production', 'preview', 'development'], listValue: 'opaque-ciphertext' }), + env({ id: 'env_3', key: 'SENS', type: 'sensitive' }), + env({ id: 'env_4', key: 'SYS', type: 'system' }), + env({ id: 'env_5', key: 'OLD', type: 'secret' }), + env({ id: 'env_6', key: 'SCOPED', type: 'encrypted', target: ['preview'], gitBranch: 'feature/x', listValue: 'opaque' }), + env({ id: 'env_7', key: 'PREV_ALL', type: 'plain', target: ['preview'], listValue: V.prevAll }), + env({ id: 'env_8', key: 'STAGE_ONLY', type: 'encrypted', target: [], customEnvironmentIds: ['env_c1'], configurationId: 'icfg_9', listValue: 'opaque' }), + env({ id: 'env_9', key: 'EMPTY_ENC', type: 'encrypted', listValue: 'opaque' }), +]; + +const DATA: FakeVercelData = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: ENVS }, + custom: { prj_web: [{ id: 'env_c1', slug: 'staging' }] }, + // env_9 has NO value: Vercel returned none although the type is readable. + values: { env_2: V.encAll, env_6: V.scoped, env_8: V.stage, env_3: V.sensitive }, +}; + +const BASE_OPTS: ConnectOpts = { nonTty: true, project: 'web', tokenEnv: 'T' }; + +function ctxWith(over: Partial & { localPlaintext?: Record } = {}): ResolvedContext { + const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + return { keep, branch: 'production', orgId: 'org_1', localPlaintext: {}, ...over } as unknown as ResolvedContext; +} + +function makeConnector(data: FakeVercelData = DATA, extra: Partial[0]> = {}) { + const fetchMock = mock(fakeVercelFetch(data)); + const connector = createVercelConnector({ fetch: fetchMock as unknown as VercelFetchLike, env: { T: 'tok_fake_value' }, sleep: async () => {}, ...extra }); + return { connector, fetchMock }; +} + +const okOutcome = (o: ImportOutcome): Extract => { + if (!o.ok) throw new Error(`expected ok, got ${o.code}: ${o.message}`); + return o; +}; + +const valuesByName = (o: Extract): Record => + Object.fromEntries(o.imported.map((i) => [i.varName, i.value])); + +// ── tokens, zero requests, read-only ──────────────────────────────────────── + +describe('vercel import — token and request shape', () => { + test('no token anywhere: VERCEL_TOKEN_MISSING and ZERO Vercel requests', async () => { + const { connector, fetchMock } = makeConnector(DATA, { env: {} }); + const out = await connector.import!(ctxWith(), BASE_OPTS); + expect(out.ok).toBe(false); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_TOKEN_MISSING); + expect(fetchMock.mock.calls.length).toBe(0); + }); + + test('the token resolves from the org system store entry, with no prompt under --json', async () => { + const getConnectorSecret = mock(async (_n: string, _o: { interactive: boolean }) => 'tok_from_store'); + const { connector, fetchMock } = makeConnector(DATA, { env: {}, getConnectorSecret }); + const out = await connector.import!(ctxWith(), { ...BASE_OPTS, tokenEnv: undefined, json: true }); + expect(out.ok).toBe(true); + expect(getConnectorSecret.mock.calls[0][0]).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(getConnectorSecret.mock.calls[0][1].interactive).toBe(false); + expect(callsOf(fetchMock)[0].authorization).toBe('Bearer tok_from_store'); + }); + + test('a run only ever GETs, with the bearer token, and the token is never in a URL', async () => { + const { connector, fetchMock } = makeConnector(); + okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); + const calls = callsOf(fetchMock); + expect(calls.length).toBeGreaterThan(3); + expect(calls.every((c) => c.method === 'GET')).toBe(true); + expect(calls.every((c) => c.authorization === 'Bearer tok_fake_value')).toBe(true); + expect(calls.some((c) => c.url.includes('tok_fake_value'))).toBe(false); + }); + + test('401 → VERCEL_AUTH_FAILED; an unknown project → VERCEL_PROJECT_NOT_FOUND', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const a = createVercelConnector({ fetch: unauthorized, env: { T: 'x' }, sleep: async () => {} }); + expect(((await a.import!(ctxWith(), BASE_OPTS)) as { code: string }).code).toBe(ERROR_CODES.VERCEL_AUTH_FAILED); + + const { connector } = makeConnector(); + const missing = await connector.import!(ctxWith(), { ...BASE_OPTS, project: 'nope' }); + expect((missing as { code: string }).code).toBe(ERROR_CODES.VERCEL_PROJECT_NOT_FOUND); + }); + + test('no --project, non-interactive: VERCEL_PROJECT_REQUIRED, and the project is never asked for', async () => { + const { connector } = makeConnector(); + const out = await connector.import!(ctxWith(), { nonTty: true, tokenEnv: 'T' }); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_PROJECT_REQUIRED); + }); +}); + +// ── teams ─────────────────────────────────────────────────────────────────── + +describe('vercel import — team resolution', () => { + test('one team: used automatically, every project request is scoped to it', async () => { + const { connector, fetchMock } = makeConnector(); + okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); + const scoped = callsOf(fetchMock).filter((c) => !c.url.includes('/v2/teams')); + expect(scoped.every((c) => new URL(c.url).searchParams.get('teamId') === 'team_acme')).toBe(true); + }); + + test('no teams: the personal account — no teamId on any request, and no team_id recorded', async () => { + const { connector, fetchMock } = makeConnector({ ...DATA, teams: [], projects: { personal: [project('prj_web', 'web')] } }); + const out = okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); + expect(callsOf(fetchMock).every((c) => new URL(c.url).searchParams.get('teamId') === null)).toBe(true); + expect(out.imported[0].entry.team_id).toBeUndefined(); + }); + + test('several teams, non-interactive, no --team: VERCEL_TEAM_REQUIRED listing slugs; nothing but the team list was requested', async () => { + const { connector, fetchMock } = makeConnector({ ...DATA, teams: [{ id: 'team_acme', slug: 'acme' }, { id: 'team_beta', slug: 'beta' }] }); + const out = await connector.import!(ctxWith(), BASE_OPTS); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_TEAM_REQUIRED); + expect((out as { message: string }).message).toContain('acme'); + expect((out as { message: string }).message).toContain('beta'); + expect(callsOf(fetchMock).map((c) => new URL(c.url).pathname)).toEqual(['/v2/teams']); + }); + + test('several teams + --team : that team; --team nope: VERCEL_TEAM_NOT_FOUND', async () => { + const data = { ...DATA, teams: [{ id: 'team_beta', slug: 'beta' }, { id: 'team_acme', slug: 'acme' }] }; + const { connector } = makeConnector(data); + okOutcome(await connector.import!(ctxWith(), { ...BASE_OPTS, team: 'acme' })); + const bad = await connector.import!(ctxWith(), { ...BASE_OPTS, team: 'nope' }); + expect((bad as { code: string }).code).toBe(ERROR_CODES.VERCEL_TEAM_NOT_FOUND); + }); + + test('several teams, interactive: asks which team', async () => { + const pickTeam = mock(async () => 'team_acme'); + const { connector } = makeConnector({ ...DATA, teams: [{ id: 'team_beta', slug: 'beta' }, { id: 'team_acme', slug: 'acme' }] }, { pickTeam, selectVars: async (c) => c, confirm: async () => false }); + const out = await withTTY(() => connector.import!(ctxWith(), { ...BASE_OPTS, nonTty: false })); + okOutcome(out); + expect(pickTeam.mock.calls.length).toBe(1); + }); +}); + +// ── environment → branch, and what is skipped ────────────────────────────── + +describe('vercel import — environment mapping and skips', () => { + test('production (the current branch): plain from the list, encrypted from the per-id route; values equal Vercel\'s', async () => { + const { connector, fetchMock } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'production' }), BASE_OPTS)); + expect(valuesByName(out)).toEqual({ PLAIN_PROD: V.plainProd, ENC_ALL: V.encAll }); + // the sensitive value is NEVER requested, whatever the fake would answer + expect(callsOf(fetchMock).some((c) => c.url.includes('/env/env_3'))).toBe(false); + // plain needs no per-id request + expect(callsOf(fetchMock).some((c) => c.url.includes('/env/env_1'))).toBe(false); + }); + + test('skipped names carry stable reasons: sensitive, system, legacy secret, empty value', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'production' }), BASE_OPTS)); + expect(Object.fromEntries(out.skipped.map((s) => [s.name, s.code]))).toEqual({ + SENS: 'sensitive_unreadable', + SYS: 'vercel_system', + OLD: 'legacy_secret_unreadable', + EMPTY_ENC: 'empty_value', + }); + expect(out.imported.map((i) => i.varName)).not.toContain('EMPTY_ENC'); + }); + + test('preview: the git-branch-scoped record is NOT imported — skipped with its name and branch; the unscoped one is', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'preview' }), BASE_OPTS)); + expect(valuesByName(out)).toEqual({ ENC_ALL: V.encAll, PREV_ALL: V.prevAll }); + expect(out.skipped).toEqual([{ name: 'SCOPED', code: 'git_branch_scoped', gitBranch: 'feature/x' }]); + }); + + test('one record targeting production, preview and development counts in each', async () => { + const { connector } = makeConnector(); + const results = await Promise.all(['production', 'preview', 'development'].map((b) => connector.import!(ctxWith({ branch: b }), BASE_OPTS))); + expect(results.map((r) => okOutcome(r).imported.some((i) => i.varName === 'ENC_ALL' && i.value === V.encAll))).toEqual([true, true, true]); + }); + + test('a custom environment maps to its slug: --environment staging reads the record that names env_c1', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'development' }), { ...BASE_OPTS, environment: 'staging' })); + expect(valuesByName(out)).toEqual({ STAGE_ONLY: V.stage }); + expect(out.imported[0].entry.environment).toBe('staging'); + expect(out.source).toEqual({ kind: 'vercel', projectId: 'prj_web', projectName: 'web', teamId: 'team_acme', environment: 'staging' }); + }); + + test('a branch named like a custom environment picks it without --environment', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'staging' }), BASE_OPTS)); + expect(Object.keys(valuesByName(out))).toEqual(['STAGE_ONLY']); + }); + + test('current branch matches no environment and no --environment: VERCEL_ENVIRONMENT_REQUIRED', async () => { + const { connector } = makeConnector(); + const out = await connector.import!(ctxWith({ branch: 'main' }), BASE_OPTS); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_ENVIRONMENT_REQUIRED); + }); + + test('--environment that does not exist: VERCEL_ENVIRONMENT_NOT_FOUND, listing what does', async () => { + const { connector } = makeConnector(); + const out = await connector.import!(ctxWith(), { ...BASE_OPTS, environment: 'qa' }); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND); + expect((out as { message: string }).message).toContain('staging'); + }); + + test('--environment overrides the branch name: main branch, development environment', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'main' }), { ...BASE_OPTS, environment: 'development' })); + expect(Object.keys(valuesByName(out))).toEqual(['ENC_ALL']); + }); + + test('a list that says there is a next page it cannot fetch is flagged, never imported as if complete', async () => { + const { connector } = makeConnector({ ...DATA, envListNext: 123 }); + const out = okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); + expect(out.warnings.map((w) => w.code)).toContain('VERCEL_ENV_LIST_TRUNCATED'); + }); +}); + +// ── conflicts, --var, --overwrite, --dry-run ─────────────────────────────── + +describe('vercel import — conflicts, --var, --overwrite, --dry-run', () => { + test('same local value: unchanged (and its metadata is refreshed); a different local value: kept, skipped IMPORT_CONFLICT_SKIPPED', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ localPlaintext: { PLAIN_PROD: V.plainProd, ENC_ALL: 'different-local' } }), BASE_OPTS)); + expect(out.unchanged).toEqual(['PLAIN_PROD']); + expect(out.unchangedEntries?.map((e) => e.varName)).toEqual(['PLAIN_PROD']); + expect(out.imported.map((i) => i.varName)).toEqual([]); + expect(out.skipped.filter((s) => s.code === 'IMPORT_CONFLICT_SKIPPED').map((s) => s.name)).toEqual(['ENC_ALL']); + }); + + test('--var restricts the import, and the unrelated records are not even fetched', async () => { + const { connector, fetchMock } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith(), { ...BASE_OPTS, var: 'PLAIN_PROD' })); + expect(Object.keys(valuesByName(out))).toEqual(['PLAIN_PROD']); + expect(callsOf(fetchMock).some((c) => c.url.includes('/env/env_2'))).toBe(false); + expect(out.skipped).toEqual([]); + }); + + test('--dry-run: no prompt, conflicts reported as wouldAsk, no unchangedEntries, no deploy target', async () => { + const confirm = mock(async () => true); + const { connector } = makeConnector(DATA, { confirm }); + const out = okOutcome(await connector.import!(ctxWith({ localPlaintext: { ENC_ALL: 'different-local' } }), { ...BASE_OPTS, dryRun: true })); + expect(out.wouldAsk).toEqual(['ENC_ALL']); + expect(out.unchangedEntries).toEqual([]); + expect(out.deployTargetSaved).toBe(false); + expect(confirm.mock.calls.length).toBe(0); + }); + + test('--overwrite non-interactive without --yes: VERCEL_CONFIRMATION_REQUIRED, nothing computed to write', async () => { + const { connector } = makeConnector(); + const out = await connector.import!(ctxWith({ localPlaintext: { LOCAL_ONLY: 'x' } }), { ...BASE_OPTS, overwrite: true }); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED); + }); + + test('--overwrite --yes: clears local-only names, replaces differing ones, imports new ones — and never clears a name Vercel has but will not show', async () => { + const { connector } = makeConnector(); + const out = okOutcome( + await connector.import!( + ctxWith({ localPlaintext: { LOCAL_ONLY: 'x', PLAIN_PROD: 'old', SENS: 'keep-me-sensitive-in-vercel', EMPTY_ENC: 'keep-me-too' } }), + { ...BASE_OPTS, overwrite: true, yes: true }, + ), + ); + expect(out.cleared).toEqual(['LOCAL_ONLY']); + expect(out.replacedNames).toEqual(['PLAIN_PROD']); + expect(valuesByName(out)).toEqual({ PLAIN_PROD: V.plainProd, ENC_ALL: V.encAll }); + }); + + test('--overwrite --dry-run needs no --yes and plans without writing', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ localPlaintext: { LOCAL_ONLY: 'x' } }), { ...BASE_OPTS, overwrite: true, dryRun: true })); + expect(out.cleared).toEqual(['LOCAL_ONLY']); + }); + + test('--overwrite interactive: declining the confirmation writes nothing', async () => { + const { connector } = makeConnector(DATA, { confirm: async () => false }); + const out = okOutcome(await withTTY(() => connector.import!(ctxWith({ localPlaintext: { LOCAL_ONLY: 'x' } }), { ...BASE_OPTS, nonTty: false, overwrite: true }))); + expect(out.imported).toEqual([]); + expect(out.cleared).toEqual([]); + }); +}); + +// ── keep.lock connector metadata ──────────────────────────────────────────── + +describe('vercel import — keep.lock connector metadata', () => { + test('each imported entry records provider, source, project, team, environment, env id, time and fingerprint', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, var: 'ENC_ALL' })); + const entry = out.imported[0].entry as ConnectorMetadata; + expect(entry).toMatchObject({ + provider: 'vercel', + source: 'import', + vercel_project_id: 'prj_web', + vercel_project: 'web', + team_id: 'team_acme', + environment: 'production', + env_id: 'env_2', + fingerprint: `${V.encAll.slice(0, 3)}…${V.encAll.slice(-3)}`, + }); + expect(typeof entry.imported_at).toBe('string'); + expect(typeof entry.created_at).toBe('number'); + expect(entry.configuration_id).toBeUndefined(); + // the entry never carries the value itself + expect(JSON.stringify(entry)).not.toContain(V.encAll); + }); + + test('configuration_id is recorded when Vercel set one', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'staging' }), BASE_OPTS)); + expect(out.imported[0].entry.configuration_id).toBe('icfg_9'); + }); + + test('buildVercelConnectorEntry is additive: no team_id for a personal project', () => { + const entry = buildVercelConnectorEntry( + { id: 'e', key: 'K', type: 'plain', targets: ['production'], customEnvironmentIds: [] }, + 'a-long-enough-value', + { project: { id: 'prj', name: 'p' }, environment: 'production', createdAtSec: 1, importedAt: '2026-01-01T00:00:00.000Z' }, + ); + expect('team_id' in entry).toBe(false); + }); +}); + +// ── deploy-target offer ───────────────────────────────────────────────────── + +describe('vercel import — deploy-target offer (interactive only, like Dokploy)', () => { + const interactiveDeps = (confirm: () => Promise) => ({ confirm, selectVars: async (c: readonly string[]) => c }); + const importAt = (dir: string, branch: string, over: Partial = {}, deps = interactiveDeps(async () => true)) => + withTTY(() => makeConnector(DATA, { ...deps, cwd: dir }).connector.import!(ctxWith({ branch }), { ...BASE_OPTS, nonTty: false, ...over })); + + test('production, interactive + yes: saves vercel-production with the imported names, CI mode, the project\'s production branch, no gitBranch', async () => { + await withTempDir('capy-vercel-target', async (dir) => { + const out = okOutcome(await importAt(dir, 'production')); + expect(out.deployTargetSaved).toBe(true); + const targets = listTargets(dir); + expect(targets.length).toBe(1); + expect(targets[0]).toEqual({ + name: 'vercel-production', + kind: 'vercel', + branch: 'production', + vars: ['PLAIN_PROD', 'ENC_ALL'], + mode: 'ci', + gitBaseBranch: 'main', + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, + }); + expect('gitBranch' in (targets[0].options as object)).toBe(false); + }); + }); + + test('preview: no gitBaseBranch; custom environment: customEnvironmentId is recorded', async () => { + await withTempDir('capy-vercel-target-preview', async (dir) => { + okOutcome(await importAt(dir, 'preview')); + okOutcome(await importAt(dir, 'staging')); + const byName = Object.fromEntries(listTargets(dir).map((t) => [t.name, t])); + expect(byName['vercel-preview'].gitBaseBranch).toBeUndefined(); + expect(byName['vercel-preview'].vars).toEqual(['ENC_ALL', 'PREV_ALL']); + expect(byName['vercel-staging'].options).toEqual({ projectDir: '.', vercelEnv: 'staging', projectId: 'prj_web', teamId: 'team_acme', customEnvironmentId: 'env_c1' }); + }); + }); + + test('interactive + no: nothing saved', async () => { + await withTempDir('capy-vercel-target-no', async (dir) => { + const out = okOutcome(await importAt(dir, 'production', {}, interactiveDeps(async () => false))); + expect(out.deployTargetSaved).toBe(false); + expect(listTargets(dir)).toEqual([]); + }); + }); + + test('non-interactive, --json and --dry-run: never asked, never saved', async () => { + await withTempDir('capy-vercel-target-quiet', async (dir) => { + const confirm = mock(async () => true); + const { connector } = makeConnector(DATA, { confirm, cwd: dir }); + okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); + okOutcome(await withTTY(() => connector.import!(ctxWith(), { ...BASE_OPTS, nonTty: false, json: true }))); + okOutcome(await withTTY(() => connector.import!(ctxWith(), { ...BASE_OPTS, nonTty: false, dryRun: true }))); + expect(confirm.mock.calls.length).toBe(0); + expect(listTargets(dir)).toEqual([]); + }); + }); + + test('already has a target for this project + environment: not offered again', async () => { + await withTempDir('capy-vercel-target-existing', async (dir) => { + upsertTarget(dir, { name: 'mine', kind: 'vercel', branch: 'production', vars: [], options: { projectId: 'prj_web', vercelEnv: 'production' } }); + const confirm = mock(async () => true); + const out = okOutcome(await importAt(dir, 'production', {}, interactiveDeps(confirm))); + expect(out.deployTargetSaved).toBe(false); + expect(confirm.mock.calls.length).toBe(0); + }); + }); + + test('nothing imported: never offered', async () => { + await withTempDir('capy-vercel-target-empty', async (dir) => { + const confirm = mock(async () => true); + const empty = { ...DATA, envs: { prj_web: [] } }; + const out = okOutcome(await withTTY(() => makeConnector(empty, { ...interactiveDeps(confirm), cwd: dir }).connector.import!(ctxWith(), { ...BASE_OPTS, nonTty: false }))); + expect(out.imported).toEqual([]); + expect(confirm.mock.calls.length).toBe(0); + }); + }); +}); + +// ── end to end: real FileManager, real executeImport, no value on any surface ─ + +describe('vercel import — end to end through ConnectCommand.executeImport', () => { + const PROJECT_KEY = 'vercel-e2e-project-key-0123456789'; + + const realCtx = (dir: string, branch: string): ResolvedContext => { + const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + return { + pm: { readSyncState: () => null }, + fileManager: new FileManager(dir), + serviceClient: { + pushSecrets: () => { + throw new Error('must not push — these runs pass noPush'); + }, + }, + orgId: 'org_1', + projectId: 'p', + branch, + userId: 'u', + projectKey: PROJECT_KEY, + keep, + localPlaintext: {}, + } as unknown as ResolvedContext; + }; + + test('--json: stdout is ONE JSON object, the .env holds the mocked Vercel values (decrypt-verified), and no value is printed anywhere', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-e2e', async (dir) => { + const { connector } = makeConnector(); + const ctx = realCtx(dir, 'production'); + const { stdout, stderr } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector, 'vercel', ctx, { ...BASE_OPTS, json: true, noPush: true }), + ); + + expect(stdout.length).toBe(1); + const parsed = JSON.parse(stdout[0]); + expect(parsed).toMatchObject({ + ok: true, + provider: 'vercel', + dryRun: false, + imported: ['PLAIN_PROD', 'ENC_ALL'], + source: { kind: 'vercel', projectId: 'prj_web', environment: 'production' }, + }); + expect(parsed.skipped).toContainEqual({ name: 'SENS', code: 'sensitive_unreadable' }); + + // the property: decrypting what landed on disk gives Vercel's values + const decrypted = new FileManager(dir).readEncryptedEnvFile(PROJECT_KEY); + expect(decrypted).toEqual({ PLAIN_PROD: V.plainProd, ENC_ALL: V.encAll }); + expect(readFileSync(join(dir, '.env'), 'utf-8')).toMatch(/ENC_ALL=capy:/); + + const everything = [...stdout, ...stderr].join('\n') + readFileSync(join(dir, '.env'), 'utf-8'); + ALL_SENTINELS.forEach((s) => expect(everything).not.toContain(s)); + }), + ), + ); + }); + + test('--dry-run --json: pure JSON, nothing written to disk, no value anywhere', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-e2e-dry', async (dir) => { + const { connector } = makeConnector(); + const { stdout, stderr } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector, 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, json: true, dryRun: true }), + ); + expect(stdout.length).toBe(1); + expect(JSON.parse(stdout[0])).toMatchObject({ ok: true, dryRun: true, imported: ['PLAIN_PROD', 'ENC_ALL'], pushed: false }); + expect(existsSync(join(dir, '.env'))).toBe(false); + ALL_SENTINELS.forEach((s) => expect([...stdout, ...stderr].join('\n')).not.toContain(s)); + }), + ), + ); + }); + + test('the human output names Vercel, never Dokploy, and prints no value', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-e2e-human', async (dir) => { + const { connector } = makeConnector(); + const { stdout } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector, 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, noPush: true }), + ); + const text = stdout.join('\n'); + expect(text).toContain('from Vercel'); + expect(text).not.toContain('Dokploy'); + ALL_SENTINELS.forEach((s) => expect(text).not.toContain(s)); + }), + ); + }); + + test('a coded refusal under --json is one JSON object with the code', async () => { + await withExitCodeRestored(async () => { + const { connector } = makeConnector(DATA, { env: {} }); + const { stdout } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector, 'vercel', ctxWith(), { ...BASE_OPTS, json: true }), + ); + expect(JSON.parse(stdout[0])).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }); + }); + }); +}); + +// ── registration ──────────────────────────────────────────────────────────── + +describe('vercel is a registered import connector', () => { + test('listed beside the others, loadable, import-kind, with discover and its own discovery report', async () => { + expect(listProviders().map((p) => p.name)).toEqual(['stripe', 'workos', 'dokploy', 'vercel']); + const mod = await loadProvider('vercel'); + expect(mod.name).toBe('vercel'); + expect(mod.kind).toBe('import'); + expect(typeof mod.import).toBe('function'); + expect(typeof mod.discover).toBe('function'); + expect(typeof mod.reportDiscovery).toBe('function'); + }); +}); diff --git a/tests/commands/connectVercelDiscovery.test.ts b/tests/commands/connectVercelDiscovery.test.ts new file mode 100644 index 00000000..ec5c63b4 --- /dev/null +++ b/tests/commands/connectVercelDiscovery.test.ts @@ -0,0 +1,506 @@ +/** + * `capy connect vercel --discover` (CAP-696). + * + * Covers: matching each Vercel project to a local repo by git `origin` and to + * a folder by `rootDirectory` (null/''/'./x/'/'/x' all normalized), every + * unmatched reason, collisions handled like Dokploy's, the `--environment` + * filter, a plan that holds no value, the dry-run JSON (pure, names only, no + * value anywhere), the coded refusals, and — through the REAL sequencer, a + * REAL `FileManager` and real git repos — that a real run pushes each branch + * exactly Vercel's set for that environment and ends in the local discovery + * commit (never pushed). + * + * No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { buildVercelDiscoveryPlan, matchVercelProject } from '../../src/commands/connectors/vercelDiscovery'; +import { findCandidateReposResult, type DiscoveryContext } from '../../src/commands/connectors/dokployDiscovery'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { FileManager } from '../../src/files/fileManager'; +import { resolveProjectKey, wrapAndSaveMasterKey } from '../../src/crypto/keyResolver'; +import { ERROR_CODES, type KeepFile } from '../../src/types/index'; +import type { ConnectOpts, ConnectorModule } from '../../src/commands/connectors/registry'; +import { createVercelClient, type VercelFetchLike, type VercelProject } from '../../src/deploy/vercelApi'; +import { callsOf, fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, git, initRepo, withExitCodeRestored, withIsolatedGlobalDir, withTempDir } from '../helpers/testSandbox'; + +const SECRET = { + aProd: 'a-prod-SENT-11aa', + sharedProd: 'shared-prod-SENT-22bb', + bStage: 'b-stage-SENT-33cc', + sharedStage: 'shared-stage-SENT-44dd', + hidden: 'hidden-SENT-55ee', +} as const; +const ALL_SECRETS = Object.values(SECRET); + +const e = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +const WEB_ENVS: ReadonlyArray = [ + e({ id: 'w1', key: 'A', type: 'plain', listValue: SECRET.aProd }), + e({ id: 'w2', key: 'SHARED', type: 'encrypted', listValue: 'opaque' }), + e({ id: 'w3', key: 'B', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + e({ id: 'w4', key: 'SHARED', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + e({ id: 'w5', key: 'HIDDEN', type: 'sensitive' }), + e({ id: 'w6', key: 'BRANCHY', type: 'encrypted', target: ['preview'], gitBranch: 'dev', listValue: 'opaque' }), + e({ id: 'w7', key: 'P', type: 'plain', target: ['preview'], listValue: 'preview-plain-SENT-66ff' }), +]; +const WEB_VALUES: Record = { w2: SECRET.sharedProd, w3: SECRET.bStage, w4: SECRET.sharedStage, w5: SECRET.hidden }; + +const gh = (org: string, repo: string) => ({ type: 'github', org, repo, productionBranch: 'main' }); + +// ── matchVercelProject ────────────────────────────────────────────────────── + +describe('matchVercelProject — repo by origin, folder by rootDirectory, every unmatched reason', () => { + const repos = [{ repoDir: '/r/widgets', remote: { host: 'github.com', owner: 'acme', repo: 'widgets' } }]; + const asProject = (raw: Record): VercelProject => { + const link = raw.link as Record | undefined; + return { + id: raw.id as string, + name: raw.name as string, + rootDirectory: (raw.rootDirectory as string | null) ?? null, + link: !link ? null : link.type === 'github' ? { kind: 'github', org: link.org as string, repo: link.repo as string, productionBranch: link.productionBranch as string } : { kind: 'unsupported', type: String(link.type) }, + }; + }; + const exists = (_r: string, folder: string) => folder === '' || folder === 'apps/web'; + + test('root project: matched, folder is the repo root', () => { + const r = matchVercelProject(asProject(project('p1', 'web')), repos, exists); + expect(r.kind).toBe('matched'); + if (r.kind === 'matched') expect([r.value.repoDir, r.value.folder, r.value.productionBranch]).toEqual(['/r/widgets', '', 'main']); + }); + + test('rootDirectory is normalized: "apps/web", "./apps/web/", "/apps/web" are the same folder; "" and "." are the root', () => { + const folders = ['apps/web', './apps/web/', '/apps/web', 'apps\\web'].map((rd) => { + const r = matchVercelProject(asProject(project('p', 'x', { rootDirectory: rd })), repos, exists); + return r.kind === 'matched' ? r.value.folder : `unmatched:${r.value.reason}`; + }); + expect(folders).toEqual(['apps/web', 'apps/web', 'apps/web', 'apps/web']); + const roots = ['', '.'].map((rd) => { + const r = matchVercelProject(asProject(project('p', 'x', { rootDirectory: rd })), repos, exists); + return r.kind === 'matched' ? r.value.folder : 'unmatched'; + }); + expect(roots).toEqual(['', '']); + }); + + test('origin match is case-insensitive', () => { + const r = matchVercelProject(asProject(project('p', 'x', { link: gh('ACME', 'Widgets') })), repos, exists); + expect(r.kind).toBe('matched'); + }); + + test.each([ + ['no link at all', { link: null }, 'no_git_link'], + ['a gitlab link', { link: { type: 'gitlab', projectId: 5 } }, 'unsupported_git_provider'], + ['a bitbucket link', { link: { type: 'bitbucket' } }, 'unsupported_git_provider'], + ['a github repo that is not cloned here', { link: gh('acme', 'other') }, 'no_remote_match'], + ['a rootDirectory that does not exist locally', { rootDirectory: 'apps/ghost' }, 'root_directory_missing'], + ] as const)('%s → %s', (_label, over, reason) => { + const r = matchVercelProject(asProject(project('p', 'x', over as never)), repos, exists); + expect(r.kind).toBe('unmatched'); + if (r.kind === 'unmatched') expect(r.value.reason).toBe(reason); + }); +}); + +// ── buildVercelDiscoveryPlan against real repos ───────────────────────────── + +const noPeek = { peekLocalKeep: () => null, listServerBranches: async () => [] as ReadonlyArray<{ name: string }> }; + +const planData = (over: Partial = {}): FakeVercelData => ({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { + team_acme: [ + project('prj_web', 'web'), + project('prj_app', 'app', { rootDirectory: 'apps/web' }), + project('prj_ghost', 'ghost', { rootDirectory: 'apps/ghost' }), + project('prj_nolink', 'nolink', { link: null }), + project('prj_lab', 'lab', { link: { type: 'gitlab', projectId: 1 } }), + project('prj_other', 'other', { link: gh('someone', 'else') }), + project('prj_empty', 'empty', { rootDirectory: 'apps/empty' }), + ], + }, + envs: { + prj_web: WEB_ENVS, + prj_app: [e({ id: 'a1', key: 'APP_ONLY', type: 'plain', listValue: 'app-plain-SENT-77aa' })], + prj_ghost: [e({ id: 'g1', key: 'G', type: 'plain', listValue: 'x' })], + prj_empty: [], + }, + custom: { prj_web: [{ id: 'c_stage', slug: 'staging' }] }, + values: WEB_VALUES, + ...over, +}); + +async function inRepo(fn: (root: string) => Promise): Promise { + return withTempDir('capy-vercel-discover', async (root) => { + initRepo(root, 'git@github.com:acme/widgets.git'); + mkdirSync(join(root, 'apps/web'), { recursive: true }); + mkdirSync(join(root, 'apps/empty'), { recursive: true }); + return fn(root); + }); +} + +describe('buildVercelDiscoveryPlan', () => { + test('folders, environments, counts, and every unmatched reason', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const scan = findCandidateReposResult(root); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', scan.repos, noPeek); + + expect(plan.folders.map((f) => [f.folder || '.', f.projectName])).toEqual([['.', 'web'], ['apps/web', 'app']]); + const web = plan.folders[0]; + // production, preview, then the custom one — keyed by name; `staging` is a custom environment slug + expect(Object.fromEntries(web.environments.map((env) => [env.environmentName, [env.variableCount, env.skippedCount]]))).toEqual({ + production: [2, 1], // A, SHARED; HIDDEN skipped + preview: [1, 1], // P; BRANCHY skipped + staging: [2, 0], // B, SHARED + }); + expect(web.environments.every((env) => env.serviceId === 'prj_web' && env.serviceKind === 'vercel')).toBe(true); + expect(plan.unmatched.map((u) => [u.serviceId, u.reason]).toSorted()).toEqual( + [ + ['prj_ghost', 'root_directory_missing'], + ['prj_nolink', 'no_git_link'], + ['prj_lab', 'unsupported_git_provider'], + ['prj_other', 'no_remote_match'], + ['prj_empty', 'no_variables'], + ].toSorted(), + ); + expect(plan.allEnvironmentNames).toEqual(['preview', 'production', 'staging']); + expect(plan.collisions).toEqual([]); + }); + }); + + test('the plan lists skipped names with reasons (and the scoped branch) — and holds no value at all', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, noPeek); + const web = plan.folders[0]; + expect(web.environments.find((env) => env.environmentName === 'production')!.skipped).toEqual([{ name: 'HIDDEN', code: 'sensitive_unreadable' }]); + expect(web.environments.find((env) => env.environmentName === 'preview')!.skipped).toEqual([{ name: 'BRANCHY', code: 'git_branch_scoped', gitBranch: 'dev' }]); + const serialized = JSON.stringify(plan); + [...ALL_SECRETS, 'preview-plain-SENT-66ff', 'app-plain-SENT-77aa'].forEach((v) => expect(serialized).not.toContain(v)); + }); + }); + + test('the plan reads only env LISTS — no per-id value request is made while planning', async () => { + await inRepo(async (root) => { + const fetchMock = mock(fakeVercelFetch(planData())); + await buildVercelDiscoveryPlan(createVercelClient('tok', { fetch: fetchMock }), 'team_acme', findCandidateReposResult(root).repos, noPeek); + expect(callsOf(fetchMock).some((c) => /\/v1\/projects\/[^/]+\/env\//.test(c.url))).toBe(false); + expect(callsOf(fetchMock).every((c) => c.method === 'GET')).toBe(true); + }); + }); + + test('--environment filters before collision detection, and a folder left with nothing drops out', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, noPeek, ['staging']); + expect(plan.folders.map((f) => f.projectName)).toEqual(['web']); + expect(plan.folders[0].environments.map((env) => env.environmentName)).toEqual(['staging']); + expect(plan.environmentFilter).toEqual(['staging']); + expect(plan.allEnvironmentNames).toEqual(['preview', 'production', 'staging']); + }); + }); + + test('two projects on the same repo + folder + environment are a collision, naming both', async () => { + await inRepo(async (root) => { + const data = planData({ + projects: { team_acme: [project('prj_a', 'alpha'), project('prj_b', 'beta')] }, + envs: { prj_a: [e({ id: 'x1', key: 'K', type: 'plain', listValue: 'v' })], prj_b: [e({ id: 'y1', key: 'K', type: 'plain', listValue: 'v' })] }, + custom: {}, + }); + const plan = await buildVercelDiscoveryPlan(createVercelClient('tok', { fetch: fakeVercelFetch(data) }), 'team_acme', findCandidateReposResult(root).repos, noPeek); + expect(plan.collisions.length).toBe(1); + expect(plan.collisions[0]).toMatchObject({ folder: '', environmentName: 'production' }); + expect(plan.collisions[0].candidates.map((c) => c.serviceId).toSorted()).toEqual(['prj_a', 'prj_b']); + }); + }); + + test('an initialized folder previews checkout vs checkout -b from the server\'s branches; a new project previews no auto-overwrite on its first step', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const repos = findCandidateReposResult(root).repos; + const fresh = await buildVercelDiscoveryPlan(client, 'team_acme', repos, noPeek); + const freshWeb = fresh.folders[0]; + // order: staging, preview, production — staging is first in a brand-new project + expect(freshWeb.initialized).toBe(false); + expect(Object.fromEntries(freshWeb.environments.map((env) => [env.environmentName, env.willAutoOverwrite]))).toEqual({ staging: false, preview: true, production: true }); + + const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'proj_x', project_name: 'widgets', variables: {} }; + const existing = await buildVercelDiscoveryPlan(client, 'team_acme', repos, { peekLocalKeep: () => keep, listServerBranches: async () => [{ name: 'production' }] }); + const web = existing.folders[0]; + expect(Object.fromEntries(web.environments.map((env) => [env.environmentName, env.branchExists]))).toEqual({ staging: false, preview: false, production: true }); + }); + }); +}); + +// ── connector.discover / ConnectCommand.executeDiscovery ──────────────────── + +const fakeDiscoveryCtx = (over: Record = {}): DiscoveryContext => ({ + orgId: 'org_v', + userId: 'user_v', + authService: {} as unknown as DiscoveryContext['authService'], + serviceClient: { + listBranches: async () => [], + listProjects: async () => [], + ...over, + } as unknown as DiscoveryContext['serviceClient'], +}); + +const discoverOpts = (over: Partial = {}): ConnectOpts => ({ nonTty: true, tokenEnv: 'T', ...over }); + +const connectorFor = (root: string, data: FakeVercelData, extra: Partial[0]> = {}) => { + const fetchMock = mock(fakeVercelFetch(data)); + const connector = createVercelConnector({ fetch: fetchMock as unknown as VercelFetchLike, env: { T: 'tok_fake' }, cwd: root, sleep: async () => {}, ...extra }); + return { connector, fetchMock }; +}; + +describe('connector.discover --dry-run', () => { + test('--json: ONE pure JSON object — folders with repo/folder/project name+id/environments/skipped-by-reason, plus unmatched, collisions, unreadableRepos; no value anywhere; nothing written', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + const { connector, fetchMock } = connectorFor(root, planData()); + const command = new ConnectCommand(false); + const run = await captureConsole(() => command['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true }))); + expect(run.stdout.length).toBe(1); + const out = JSON.parse(run.stdout[0]); + expect(out).toMatchObject({ ok: true, provider: 'vercel', dryRun: true, cancelled: false }); + expect(out.plan.team).toEqual({ id: 'team_acme', slug: 'acme' }); + + const web = out.plan.folders.find((f: { vercelProjectId: string }) => f.vercelProjectId === 'prj_web'); + expect(web).toMatchObject({ repoDir: root, folder: '.', vercelProject: 'web', vercelProjectId: 'prj_web', initialized: false }); + const prod = web.environments.find((env: { branch: string }) => env.branch === 'production'); + expect(prod).toMatchObject({ variableCount: 2, skippedCount: 1, skipped: { sensitive_unreadable: [{ name: 'HIDDEN' }] } }); + const preview = web.environments.find((env: { branch: string }) => env.branch === 'preview'); + expect(preview.skipped).toEqual({ git_branch_scoped: [{ name: 'BRANCHY', gitBranch: 'dev' }] }); + + expect(out.plan.unmatched.map((u: { vercelProjectId: string; reason: string }) => [u.vercelProjectId, u.reason]).toSorted()).toEqual( + [['prj_ghost', 'root_directory_missing'], ['prj_nolink', 'no_git_link'], ['prj_lab', 'unsupported_git_provider'], ['prj_other', 'no_remote_match'], ['prj_empty', 'no_variables']].toSorted(), + ); + expect(out.plan.collisions).toEqual([]); + expect(out.plan.unreadableRepos).toEqual([]); + // the local discovery commit is previewed, never made + expect(out.commits[0]).toMatchObject({ ok: true, dryRun: true }); + expect(out.commits[0].branch).toMatch(/^capy\/vercel-import-\d{8}-\d{4}$/); + + // the property: not one value on any surface + const everything = [...run.stdout, ...run.stderr].join('\n'); + [...ALL_SECRETS, 'preview-plain-SENT-66ff', 'app-plain-SENT-77aa', 'tok_fake'].forEach((v) => expect(everything).not.toContain(v)); + // read-only, no per-id value requests, nothing written to the repo + expect(callsOf(fetchMock).every((c) => c.method === 'GET')).toBe(true); + expect(callsOf(fetchMock).some((c) => /\/v1\/projects\/[^/]+\/env\//.test(c.url))).toBe(false); + expect(git(root, ['status', '--porcelain']).trim()).toBe(''); + expect(existsSync(join(root, 'keep.lock'))).toBe(false); + }), + ); + }); + + test('human output names Vercel projects and reasons, never a value', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + const { connector } = connectorFor(root, planData()); + const run = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true }))); + const text = run.stdout.join('\n'); + expect(text).toContain('Dry run'); + expect(text).toContain('web'); + expect(text).toContain('skipped sensitive_unreadable: HIDDEN'); + expect(text).toContain('skipped git_branch_scoped: BRANCHY (dev)'); + expect(text).toContain('root_directory_missing'); + expect(text).not.toContain('Dokploy'); + ALL_SECRETS.forEach((v) => expect(text).not.toContain(v)); + }), + ); + }); +}); + +describe('connector.discover — refusals carry codes', () => { + test('no token: VERCEL_TOKEN_MISSING, zero requests', async () => { + await inRepo(async (root) => { + const { connector, fetchMock } = connectorFor(root, planData(), { env: {} }); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ dryRun: true })); + expect(out.ok).toBe(false); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_TOKEN_MISSING); + expect(fetchMock.mock.calls.length).toBe(0); + }); + }); + + test('several teams, non-interactive: VERCEL_TEAM_REQUIRED; --team picks one', async () => { + await inRepo(async (root) => { + const data = planData({ teams: [{ id: 'team_acme', slug: 'acme' }, { id: 'team_beta', slug: 'beta' }] }); + const { connector } = connectorFor(root, data); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ dryRun: true })); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_TEAM_REQUIRED); + const ok = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ dryRun: true, team: 'acme' })); + expect(ok.ok).toBe(true); + }); + }); + + test('--environment naming nothing the plan has: VERCEL_ENVIRONMENT_NOT_FOUND, listing what exists', async () => { + await inRepo(async (root) => { + const { connector } = connectorFor(root, planData()); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ dryRun: true, environment: 'qa' })); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND); + expect((out as { message: string }).message).toContain('staging'); + }); + }); + + test('a collision with no --project hint and no TTY: VERCEL_MAPPING_COLLISION, zero writes; with --project the named one wins', async () => { + await inRepo(async (root) => { + const data = planData({ + projects: { team_acme: [project('prj_a', 'alpha'), project('prj_b', 'beta')] }, + envs: { prj_a: [e({ id: 'x1', key: 'K', type: 'plain', listValue: 'va' })], prj_b: [e({ id: 'y1', key: 'K', type: 'plain', listValue: 'vb' })] }, + custom: {}, + }); + const { connector } = connectorFor(root, data); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ yes: true })); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_MAPPING_COLLISION); + expect(existsSync(join(root, 'keep.lock'))).toBe(false); + }); + }); + + test('with --project the named project wins the collision and is the one imported', async () => { + await withIsolatedGlobalDir(() => + inRepo(async (root) => { + const orgId = 'org_v_hint'; + const userId = 'user_v_hint'; + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), orgId, userId, keyOps); + writeFileSync(join(root, 'keep.lock'), JSON.stringify({ version: '3.0', org_id: orgId, project_id: 'proj_hint', project_name: 'widgets', variables: {} })); + const data = planData({ + projects: { team_acme: [project('prj_a', 'alpha'), project('prj_b', 'beta')] }, + envs: { prj_a: [e({ id: 'x1', key: 'K', type: 'plain', listValue: 'va' })], prj_b: [e({ id: 'y1', key: 'K', type: 'plain', listValue: 'vb' })] }, + custom: {}, + }); + const ctx = { + ...fakeDiscoveryCtx({ + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + }), + orgId, + userId, + } as DiscoveryContext; + const { connector } = connectorFor(root, data); + const out = await connector.discover!(ctx, discoverOpts({ yes: true, noPush: true, project: 'beta' })); + expect(out.ok).toBe(true); + if (!out.ok) return; + const applied = (out.applied ?? []).filter((f) => f.ok); + expect(applied.map((f) => f.ok && f.environments.map((env) => env.outcome.imported.map((i) => i.value)))).toEqual([[['vb']]]); + }), + ); + }); + + test('a real run non-interactively without --yes: VERCEL_CONFIRMATION_REQUIRED, zero writes', async () => { + await inRepo(async (root) => { + const { connector } = connectorFor(root, planData()); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts()); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED); + expect(git(root, ['status', '--porcelain']).trim()).toBe(''); + }); + }); +}); + +// ── a real run: sequencer + FileManager + real git ────────────────────────── + +describe('connector.discover — a real run, end to end', () => { + const E2E_ENVS: ReadonlyArray = [ + e({ id: 'p1', key: 'A', type: 'plain', listValue: SECRET.aProd }), + e({ id: 'p2', key: 'SHARED', type: 'encrypted', listValue: 'opaque' }), + e({ id: 's1', key: 'B', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + e({ id: 's2', key: 'SHARED', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + e({ id: 'h1', key: 'HIDDEN', type: 'sensitive' }), + ]; + const e2eData = (): FakeVercelData => ({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: E2E_ENVS }, + custom: { prj_web: [{ id: 'c_stage', slug: 'staging' }] }, + values: { p2: SECRET.sharedProd, s1: SECRET.bStage, s2: SECRET.sharedStage, h1: SECRET.hidden }, + }); + + test('--yes: staging then production; each branch is pushed EXACTLY its Vercel environment\'s set (decrypt-verified), the discovery commit is local on its own branch, nothing is pushed to git, and no value is printed', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + inRepo(async (root) => { + const orgId = 'org_v_e2e'; + const userId = 'user_v_e2e'; + const projectId = 'proj_v_e2e'; + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), orgId, userId, keyOps); + const projectKey = await resolveProjectKey(orgId, projectId, userId, keyOps); + + // An existing project whose folder is already committed and clean. + const keep: KeepFile = { version: '3.0', org_id: orgId, project_id: projectId, project_name: 'widgets', variables: {} }; + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + // `capy`'s own init also writes the ignore rules; a clean, committed pair is what a real initialized repo looks like. + new FileManager(root).ensureCapyGitignore(); + git(root, ['add', 'keep.lock', '.gitignore']); + git(root, ['commit', '-q', '-m', 'add keep.lock']); + const mainHead = git(root, ['rev-parse', 'HEAD']).trim(); + const mainBranch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + + const pushSecrets = mock(async (_p: string, _keep: string, _env: string, _branch: string) => ({ keep_hash: 'h'.repeat(16) })); + const ctx = fakeDiscoveryCtx({ + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets, + }); + const ctxWithIds = { ...ctx, orgId, userId } as DiscoveryContext; + + const { connector, fetchMock } = connectorFor(root, e2eData()); + const run = await captureConsole(() => connector.discover!(ctxWithIds, discoverOpts({ yes: true }))); + const outcome = run.result; + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + const folder = outcome.applied?.[0]; + expect(folder?.ok).toBe(true); + if (!folder?.ok) return; + expect(folder.environments.map((env) => env.environmentName)).toEqual(['staging', 'production']); + expect(folder.activeBranch).toBe('production'); + + // every push, decrypted with the project key: branch → name/value set + const fm = new FileManager(root); + const pushed = Object.fromEntries( + pushSecrets.mock.calls.map(([, , envBlob, branch]) => [ + branch, + Object.fromEntries(Object.entries(fm.parseEnvContent(envBlob)).map(([k, v]) => [k, fm.decryptValue(v, projectKey)])), + ]), + ); + expect(pushed.staging).toEqual({ B: SECRET.bStage, SHARED: SECRET.sharedStage }); + // production was created from staging, then overwritten with ITS set: nothing of staging's B survives + expect(pushed.production).toEqual({ A: SECRET.aProd, SHARED: SECRET.sharedProd }); + expect(fm.readEncryptedEnvFile(projectKey)).toEqual({ A: SECRET.aProd, SHARED: SECRET.sharedProd }); + + // each imported entry's connector metadata names the project, team and environment + const stagingEntry = folder.environments.find((env) => env.environmentName === 'staging')!.outcome.imported.find((i) => i.varName === 'B')!.entry; + expect(stagingEntry).toMatchObject({ provider: 'vercel', vercel_project_id: 'prj_web', vercel_project: 'web', team_id: 'team_acme', environment: 'staging', env_id: 's1' }); + expect(outcome.applied?.[0].ok && (outcome.applied[0].environments[1].outcome.skipped)).toEqual([{ name: 'HIDDEN', code: 'sensitive_unreadable' }]); + + // the local discovery commit: its own branch, the Vercel message, never pushed + expect(outcome.commits?.[0]).toMatchObject({ ok: true, dryRun: false }); + const commit = outcome.commits?.[0]; + if (commit?.ok && !commit.dryRun) { + expect(commit.branch).toMatch(/^capy\/vercel-import-/); + expect(git(root, ['log', '-1', '--format=%s', commit.branch]).trim()).toBe('chore(capy): import Vercel environments'); + } + expect(git(root, ['rev-parse', mainBranch]).trim()).toBe(mainHead); + expect(git(root, ['remote']).trim()).toBe('origin'); // a remote exists, and nothing pushed to it: this run has no push step for git at all + + // no deploy target is created non-interactively (Dokploy discovery doesn't either) + expect(existsSync(join(root, '.capy', 'deploy.json'))).toBe(false); + + // read-only on Vercel; values only ever read via the per-id route + expect(callsOf(fetchMock).every((c) => c.method === 'GET')).toBe(true); + const printed = [...run.stdout, ...run.stderr].join('\n'); + ALL_SECRETS.forEach((v) => expect(printed).not.toContain(v)); + // the working tree has the `.env` ciphertext only + expect(readFileSync(join(root, '.env'), 'utf-8')).not.toContain(SECRET.aProd); + }), + ), + ); + }, 30_000); +}); diff --git a/tests/deploy/vercelApi.test.ts b/tests/deploy/vercelApi.test.ts new file mode 100644 index 00000000..d0ffa490 --- /dev/null +++ b/tests/deploy/vercelApi.test.ts @@ -0,0 +1,469 @@ +/** + * `deploy/vercelApi.ts` — the READ-ONLY Vercel client and the env-record + * primitives behind `capy connect vercel` (CAP-696). + * + * Every test drives a scripted `fetch`; nothing here touches the network. The + * fake (`tests/helpers/fakeVercel.ts`) throws on any non-GET, so "the client + * never writes" is proven by running real calls through it, not by reading + * the source. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + DEFAULT_VERCEL_TOKEN_ENV, + VERCEL_CONNECTOR_SECRET_NAME, + VERCEL_TARGET_SECRET_NAME, + VercelApiError, + buildEnvironmentPlans, + createVercelClient, + groupSkipped, + mapVercelApiError, + parseGitLink, + rateLimitWaitMs, + resolveEnvValues, + resolveTeam, + resolveVercelToken, + vercelSecretsMayPrompt, + type VercelEnvRecord, + type VercelFetchLike, +} from '../../src/deploy/vercelApi'; +import { ERROR_CODES } from '../../src/types/index'; +import { callsOf, fakeVercelFetch, project, type RawEnv } from '../helpers/fakeVercel'; + +const TOKEN = 'vcl_fake_token_do_not_leak_8d2f'; + +const rec = (over: Partial & { id: string; key: string }): VercelEnvRecord => ({ + type: 'encrypted', + targets: ['production'], + customEnvironmentIds: [], + ...over, +}); + +describe('names', () => { + test('the system store entry names the deploy side will also use', () => { + expect(VERCEL_CONNECTOR_SECRET_NAME).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(VERCEL_TARGET_SECRET_NAME).toBe('_TARGET_VERCEL_TOKEN'); + expect(DEFAULT_VERCEL_TOKEN_ENV).toBe('VERCEL_TOKEN'); + }); +}); + +describe('createVercelClient — read-only, bearer auth, team scope', () => { + test('exposes no method that could write', () => { + const client = createVercelClient(TOKEN, { fetch: fakeVercelFetch({ projects: {}, envs: {} }) }); + expect(Object.keys(client).toSorted()).toEqual( + ['getEnvValue', 'getProject', 'listCustomEnvironments', 'listEnvs', 'listProjects', 'listTeams'].toSorted(), + ); + }); + + test('the source contains no write verb at all', () => { + const src = readFileSync(join(import.meta.dir, '../../src/deploy/vercelApi.ts'), 'utf-8'); + expect(src).not.toMatch(/method:\s*['"](POST|PATCH|PUT|DELETE)['"]/); + }); + + test('a full read pass makes only GETs, each with the bearer token, and scopes by teamId', async () => { + const data = { + teams: [{ id: 'team_1', slug: 'acme' }], + projects: { team_1: [project('prj_1', 'web')] }, + envs: { prj_1: [{ id: 'env_1', key: 'A', type: 'plain', target: ['production'], listValue: 'a-value' } as RawEnv] }, + custom: { prj_1: [{ id: 'env_c1', slug: 'staging' }] }, + values: { env_1: 'a-value' }, + }; + const fetchMock = mock(fakeVercelFetch(data)); + const client = createVercelClient(TOKEN, { fetch: fetchMock }); + await client.listTeams(); + await client.listProjects('team_1'); + await client.getProject('web', 'team_1'); + await client.listCustomEnvironments('prj_1', 'team_1'); + await client.listEnvs('prj_1', 'team_1'); + await client.getEnvValue('prj_1', 'env_1', 'team_1'); + const calls = callsOf(fetchMock); + expect(calls.length).toBe(6); + expect(calls.every((c) => c.method === 'GET')).toBe(true); + expect(calls.every((c) => c.authorization === `Bearer ${TOKEN}`)).toBe(true); + // team scope on every request after the team list itself + expect(calls.slice(1).every((c) => new URL(c.url).searchParams.get('teamId') === 'team_1')).toBe(true); + // the token is a header, never part of any URL + expect(calls.some((c) => c.url.includes(TOKEN))).toBe(false); + }); + + test('endpoint paths and versions match the docs', async () => { + const data = { + teams: [], + projects: { personal: [project('prj_1', 'web')] }, + envs: { prj_1: [{ id: 'env_1', key: 'A', type: 'plain', target: 'production', listValue: 'v' } as RawEnv] }, + values: { env_1: 'v' }, + }; + const fetchMock = mock(fakeVercelFetch(data)); + const client = createVercelClient(TOKEN, { fetch: fetchMock }); + await client.listTeams(); + await client.listProjects(); + await client.getProject('prj_1'); + await client.listCustomEnvironments('prj_1'); + await client.listEnvs('prj_1'); + await client.getEnvValue('prj_1', 'env_1'); + expect(callsOf(fetchMock).map((c) => new URL(c.url).pathname)).toEqual([ + '/v2/teams', + '/v10/projects', + '/v9/projects/prj_1', + '/v9/projects/prj_1/custom-environments', + '/v10/projects/prj_1/env', + '/v1/projects/prj_1/env/env_1', + ]); + }); + + test('a redirect is refused rather than followed (the bearer token must not go to another host)', async () => { + const fetchMock = mock(async () => ({ status: 200, ok: true, text: async () => '{"teams":[]}' })); + await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike }).listTeams(); + expect(fetchMock.mock.calls.map(([, init]) => (init as { redirect?: string }).redirect)).toEqual(['error']); + }); +}); + +describe('pagination', () => { + test('projects: follows pagination.next via `from` until it is null', async () => { + const all = Array.from({ length: 5 }, (_, i) => project(`prj_${i}`, `p${i}`)); + const fetchMock = mock(fakeVercelFetch({ projects: { personal: all }, envs: {}, pageSize: 2 })); + const client = createVercelClient(TOKEN, { fetch: fetchMock }); + const projects = await client.listProjects(); + const calls = callsOf(fetchMock); + expect(projects.map((p) => p.id)).toEqual(['prj_0', 'prj_1', 'prj_2', 'prj_3', 'prj_4']); + expect(calls.length).toBe(3); + expect(calls.map((c) => new URL(c.url).searchParams.get('from'))).toEqual([null, '2', '4']); + expect(calls.every((c) => new URL(c.url).searchParams.get('limit') === '100')).toBe(true); + }); + + test('projects: a server that ignores the continuation cannot loop us forever', async () => { + const page = { projects: [project('prj_a', 'a')], pagination: { count: 1, next: 123, prev: null } }; + const fetchMock = mock(async () => ({ status: 200, ok: true, text: async () => JSON.stringify(page) })); + const projects = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike }).listProjects(); + expect(projects.map((p) => p.id)).toEqual(['prj_a']); + expect(fetchMock.mock.calls.length).toBe(2); + }); + + test('projects: a bare-array response (the documented alternative shape) is read too', async () => { + const fetchImpl: VercelFetchLike = async () => ({ status: 200, ok: true, text: async () => JSON.stringify([project('prj_1', 'a')]) }); + const projects = await createVercelClient(TOKEN, { fetch: fetchImpl }).listProjects(); + expect(projects.map((p) => p.name)).toEqual(['a']); + }); + + test('teams: follows pagination.next via `until`', async () => { + const reply = (body: unknown) => ({ status: 200, ok: true, text: async () => JSON.stringify(body) }); + const fetchMock = mock() + .mockResolvedValueOnce(reply({ teams: [{ id: 't1', slug: 'one' }], pagination: { count: 1, next: 1700, prev: null } })) + .mockResolvedValueOnce(reply({ teams: [{ id: 't2', slug: 'two' }], pagination: { count: 1, next: null, prev: null } })); + const teams = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike }).listTeams(); + expect(teams.map((t) => t.slug)).toEqual(['one', 'two']); + expect(new URL(callsOf(fetchMock)[1].url).searchParams.get('until')).toBe('1700'); + }); +}); + +describe('rate limits — 429', () => { + const limited = (headers: Record) => ({ + status: 429, + ok: false, + headers: { get: (n: string) => headers[n.toLowerCase()] ?? null }, + text: async () => JSON.stringify({ error: { code: 'rate_limited', message: 'slow down' } }), + }); + const okTeams = { status: 200, ok: true, headers: { get: () => null }, text: async () => '{"teams":[]}' }; + const waitsOf = (sleep: { mock: { calls: ReadonlyArray } }) => sleep.mock.calls.map(([ms]) => ms); + + test('waits per Retry-After, then succeeds', async () => { + const fetchMock = mock().mockResolvedValueOnce(limited({ 'retry-after': '2' })).mockResolvedValueOnce(okTeams); + const sleep = mock(async (_ms: number) => {}); + const teams = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike, sleep }).listTeams(); + expect(teams).toEqual([]); + expect(waitsOf(sleep)).toEqual([2000]); + expect(fetchMock.mock.calls.length).toBe(2); + }); + + test('waits per X-RateLimit-Reset (epoch seconds) when there is no Retry-After', async () => { + const fetchMock = mock().mockResolvedValueOnce(limited({ 'x-ratelimit-reset': '1700000005' })).mockResolvedValueOnce(okTeams); + const sleep = mock(async (_ms: number) => {}); + await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike, sleep, now: () => 1_700_000_000_000 }).listTeams(); + expect(waitsOf(sleep)).toEqual([5000]); + }); + + test('rateLimitWaitMs: header precedence, body fallback, short default', () => { + const h = (m: Record) => ({ get: (n: string) => m[n.toLowerCase()] ?? null }); + expect(rateLimitWaitMs(h({ 'retry-after': '3', 'x-ratelimit-reset': '9999999999' }), '', 0)).toBe(3000); + expect(rateLimitWaitMs(h({ 'x-ratelimit-reset': '10' }), '', 4000)).toBe(6000); + expect(rateLimitWaitMs(h({}), JSON.stringify({ error: { limit: { resetMs: 9000 } } }), 4000)).toBe(5000); + expect(rateLimitWaitMs(h({}), 'not json', 0)).toBe(1000); + expect(rateLimitWaitMs(undefined, '', 0)).toBe(1000); + }); + + test('keeps answering 429: bounded retries, then a coded refusal', async () => { + const fetchMock = mock(async () => limited({ 'retry-after': '1' })); + const sleep = mock(async (_ms: number) => {}); + const err = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike, sleep, maxRetries: 3 }) + .listTeams() + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(VercelApiError); + expect((err as VercelApiError).code).toBe('rate_limited'); + expect(fetchMock.mock.calls.length).toBe(4); // first try + 3 retries + expect(waitsOf(sleep)).toEqual([1000, 1000, 1000]); + expect(mapVercelApiError(err).code).toBe(ERROR_CODES.VERCEL_RATE_LIMITED); + }); + + test('a requested wait longer than the cap is refused without sleeping', async () => { + const fetchMock = mock(async () => limited({ 'retry-after': '604800' })); // 7 days + const sleep = mock(async (_ms: number) => {}); + const err = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike, sleep }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(err).code).toBe(ERROR_CODES.VERCEL_RATE_LIMITED); + expect(sleep.mock.calls.length).toBe(0); + }); +}); + +describe('errors map to stable codes and never carry the token', () => { + const status = (s: number): VercelFetchLike => async () => ({ status: s, ok: false, text: async () => JSON.stringify({ error: { message: TOKEN } }) }); + + test('401/403 → VERCEL_AUTH_FAILED', async () => { + for (const s of [401, 403]) { + const err = await createVercelClient(TOKEN, { fetch: status(s) }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(err).code).toBe(ERROR_CODES.VERCEL_AUTH_FAILED); + } + }); + + test('404 → not_found; 500 → VERCEL_API_ERROR; neither message contains the token', async () => { + const nf = await createVercelClient(TOKEN, { fetch: status(404) }).getProject('x').catch((e: unknown) => e); + expect((nf as VercelApiError).code).toBe('not_found'); + const se = await createVercelClient(TOKEN, { fetch: status(500) }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(se).code).toBe(ERROR_CODES.VERCEL_API_ERROR); + expect((nf as Error).message).not.toContain(TOKEN); + expect(mapVercelApiError(se).message).not.toContain(TOKEN); + }); + + test('a network failure and a non-JSON body are VERCEL_API_ERROR, token never echoed', async () => { + const down: VercelFetchLike = async () => { + throw new Error(`connect ECONNREFUSED ${TOKEN}`); + }; + const e1 = await createVercelClient(TOKEN, { fetch: down }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(e1).code).toBe(ERROR_CODES.VERCEL_API_ERROR); + expect((e1 as Error).message).not.toContain(TOKEN); + const html: VercelFetchLike = async () => ({ status: 200, ok: true, text: async () => '' }); + const e2 = await createVercelClient(TOKEN, { fetch: html }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(e2).code).toBe(ERROR_CODES.VERCEL_API_ERROR); + }); +}); + +describe('resolveTeam — 0 / 1 / many', () => { + const teams = [ + { id: 'team_a', slug: 'alpha' }, + { id: 'team_b', slug: 'beta' }, + ]; + + test('no teams: the personal account (no teamId)', async () => { + expect(await resolveTeam([], undefined, null)).toEqual({ ok: true, teamId: undefined }); + }); + + test('exactly one team: used without asking', async () => { + expect(await resolveTeam([teams[0]], undefined, null)).toEqual({ ok: true, teamId: 'team_a', teamSlug: 'alpha' }); + }); + + test('several, non-interactive: VERCEL_TEAM_REQUIRED listing the slugs', async () => { + const r = await resolveTeam(teams, undefined, null); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.code).toBe(ERROR_CODES.VERCEL_TEAM_REQUIRED); + expect(r.message).toContain('alpha'); + expect(r.message).toContain('beta'); + }); + + test('several, interactive: the picked team', async () => { + expect(await resolveTeam(teams, undefined, async () => 'team_b')).toEqual({ ok: true, teamId: 'team_b', teamSlug: 'beta' }); + }); + + test('--team by slug or by id; an unknown one is VERCEL_TEAM_NOT_FOUND', async () => { + expect(await resolveTeam(teams, 'beta', null)).toEqual({ ok: true, teamId: 'team_b', teamSlug: 'beta' }); + expect(await resolveTeam(teams, 'team_a', null)).toEqual({ ok: true, teamId: 'team_a', teamSlug: 'alpha' }); + const r = await resolveTeam(teams, 'gamma', null); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe(ERROR_CODES.VERCEL_TEAM_NOT_FOUND); + }); +}); + +describe('project links', () => { + test('github link → org/repo/productionBranch', () => { + expect(parseGitLink({ type: 'github', org: 'acme', repo: 'widgets', productionBranch: 'main' })).toEqual({ + kind: 'github', + org: 'acme', + repo: 'widgets', + productionBranch: 'main', + }); + }); + + test('gitlab / bitbucket / anything else is carried as unsupported (reported, never guessed)', () => { + expect(parseGitLink({ type: 'gitlab', projectId: 5 })).toEqual({ kind: 'unsupported', type: 'gitlab' }); + expect(parseGitLink({ type: 'bitbucket', slug: 'x' })).toEqual({ kind: 'unsupported', type: 'bitbucket' }); + }); + + test('no link at all is null', () => { + expect(parseGitLink(undefined)).toBeNull(); + expect(parseGitLink(null)).toBeNull(); + }); +}); + +describe('buildEnvironmentPlans — type → skip mapping, scoping, targets', () => { + const plan = (envs: readonly VercelEnvRecord[], custom: ReadonlyArray<{ id: string; slug: string }> = []) => + buildEnvironmentPlans(envs, custom); + const byName = (plans: ReturnType, name: string) => plans.find((p) => p.environment.name === name); + + test('plain and encrypted are importable; sensitive, system, legacy secret each skip with their own reason', () => { + const p = plan([ + rec({ id: '1', key: 'PLAIN', type: 'plain' }), + rec({ id: '2', key: 'ENC', type: 'encrypted' }), + rec({ id: '3', key: 'SENS', type: 'sensitive' }), + rec({ id: '4', key: 'SYS', type: 'system' }), + rec({ id: '5', key: 'OLD', type: 'secret' }), + rec({ id: '6', key: 'WEIRD', type: 'quantum' }), + ]); + const prod = byName(p, 'production')!; + expect(prod.importable.map((r) => r.key)).toEqual(['PLAIN', 'ENC']); + expect(Object.fromEntries(prod.skipped.map((s) => [s.name, s.reason]))).toEqual({ + SENS: 'sensitive_unreadable', + SYS: 'vercel_system', + OLD: 'legacy_secret_unreadable', + WEIRD: 'unsupported_type', + }); + }); + + test('a preview record scoped to a git branch is skipped (git_branch_scoped, name + branch); an unscoped one is imported', () => { + const p = plan([ + rec({ id: '1', key: 'SCOPED', targets: ['preview'], gitBranch: 'feature/x' }), + rec({ id: '2', key: 'ALL_BRANCHES', targets: ['preview'] }), + ]); + const preview = byName(p, 'preview')!; + expect(preview.importable.map((r) => r.key)).toEqual(['ALL_BRANCHES']); + expect(preview.skipped).toEqual([{ name: 'SCOPED', reason: 'git_branch_scoped', gitBranch: 'feature/x' }]); + }); + + test('one record targeting several environments counts in each', () => { + const p = plan([rec({ id: '1', key: 'SHARED', targets: ['production', 'preview', 'development'] })]); + expect(p.map((x) => x.environment.name)).toEqual(['production', 'preview', 'development']); + expect(p.every((x) => x.importable.map((r) => r.key).join() === 'SHARED')).toBe(true); + }); + + test('a target given as a bare string works (the docs allow string or array)', () => { + const p = plan([rec({ id: '1', key: 'ONE', targets: ['development'] })]); + expect(byName(p, 'development')!.importable.length).toBe(1); + expect(byName(p, 'production')).toBeUndefined(); + }); + + test('custom environments map to their slug via customEnvironmentIds', () => { + const p = plan( + [rec({ id: '1', key: 'FOR_STAGING', targets: [], customEnvironmentIds: ['env_c1'] }), rec({ id: '2', key: 'BOTH', targets: ['production'], customEnvironmentIds: ['env_c1'] })], + [{ id: 'env_c1', slug: 'staging' }], + ); + const staging = byName(p, 'staging')!; + expect(staging.environment).toEqual({ name: 'staging', kind: 'custom', customEnvironmentId: 'env_c1' }); + expect(staging.importable.map((r) => r.key)).toEqual(['FOR_STAGING', 'BOTH']); + expect(byName(p, 'production')!.importable.map((r) => r.key)).toEqual(['BOTH']); + }); + + test('a git-branch scope only matters in preview', () => { + const p = plan([rec({ id: '1', key: 'K', targets: ['production'], gitBranch: 'main' })]); + expect(byName(p, 'production')!.importable.length).toBe(1); + }); + + test('the same name twice in one environment: first wins, the other is reported duplicate_key', () => { + const p = plan([rec({ id: '1', key: 'DUP' }), rec({ id: '2', key: 'DUP' })]); + const prod = byName(p, 'production')!; + expect(prod.importable.map((r) => r.id)).toEqual(['1']); + expect(prod.skipped).toEqual([{ name: 'DUP', reason: 'duplicate_key' }]); + }); + + test('groupSkipped groups names by reason, branch kept for scoped ones', () => { + expect( + groupSkipped([ + { name: 'A', reason: 'sensitive_unreadable' }, + { name: 'B', reason: 'git_branch_scoped', gitBranch: 'dev' }, + { name: 'C', reason: 'sensitive_unreadable' }, + ]), + ).toEqual({ + git_branch_scoped: [{ name: 'B', gitBranch: 'dev' }], + sensitive_unreadable: [{ name: 'A' }, { name: 'C' }], + }); + }); +}); + +describe('resolveEnvValues', () => { + test('plain uses the list value (no extra request); encrypted comes from the per-id route', async () => { + const getEnvValue = mock(async (_p: string, envId: string, _t?: string) => `decrypted-${envId}`); + const client = { getEnvValue }; + const r = await resolveEnvValues(client, 'prj', undefined, [ + rec({ id: 'e1', key: 'P', type: 'plain', value: 'plain-value' }), + rec({ id: 'e2', key: 'E', type: 'encrypted', value: 'ciphertext-from-list' }), + ]); + expect(getEnvValue.mock.calls.map(([, envId]) => envId)).toEqual(['e2']); + expect(r.entries.map((e) => [e.record.key, e.value])).toEqual([ + ['P', 'plain-value'], + ['E', 'decrypted-e2'], + ]); + expect(r.empty).toEqual([]); + }); + + test('an empty or absent value although typed readable is skipped as empty_value, never imported as ""', async () => { + const client = { getEnvValue: async (_p: string, envId: string) => (envId === 'e1' ? '' : undefined) }; + const r = await resolveEnvValues(client, 'prj', undefined, [ + rec({ id: 'e1', key: 'EMPTY', type: 'encrypted' }), + rec({ id: 'e2', key: 'ABSENT', type: 'encrypted' }), + rec({ id: 'e3', key: 'PLAIN_EMPTY', type: 'plain', value: '' }), + ]); + expect(r.entries).toEqual([]); + expect(r.empty.map((s) => [s.name, s.reason])).toEqual([ + ['EMPTY', 'empty_value'], + ['ABSENT', 'empty_value'], + ['PLAIN_EMPTY', 'empty_value'], + ]); + }); +}); + +describe('resolveVercelToken', () => { + const base = { env: {} as Record, interactive: false }; + + test('an explicit --token-env that is set wins, with zero store traffic', async () => { + const getConnectorSecret = mock(async (_n: string, _o: unknown) => 'from-store'); + const r = await resolveVercelToken({ + ...base, + tokenEnv: 'MY_VERCEL', + env: { MY_VERCEL: 'from-env', VERCEL_TOKEN: 'other' }, + getConnectorSecret, + }); + expect(r).toEqual({ ok: true, value: 'from-env', source: 'env' }); + expect(getConnectorSecret.mock.calls.length).toBe(0); + }); + + test('then the system store entry _CONNECTOR_VERCEL_TOKEN', async () => { + const getConnectorSecret = mock(async (_n: string, _o: unknown) => 'from-store'); + const r = await resolveVercelToken({ ...base, env: { VERCEL_TOKEN: 'fallback' }, getConnectorSecret }); + expect(r).toEqual({ ok: true, value: 'from-store', source: 'system' }); + expect(getConnectorSecret.mock.calls.map(([n]) => n)).toEqual(['_CONNECTOR_VERCEL_TOKEN']); + }); + + test('then $VERCEL_TOKEN', async () => { + expect(await resolveVercelToken({ ...base, env: { VERCEL_TOKEN: 'fallback' } })).toEqual({ ok: true, value: 'fallback', source: 'env' }); + }); + + test('nothing anywhere: VERCEL_TOKEN_MISSING', async () => { + expect(await resolveVercelToken(base)).toEqual({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }); + }); + + test('a store refusal carries the store\'s own code (never its message)', async () => { + const r = await resolveVercelToken({ + ...base, + getConnectorSecret: async () => { + throw Object.assign(new Error('admins only, honest'), { code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }, + }); + expect(r).toEqual({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }); + + test('the store is told whether it may prompt, and a hidden prompt is suppressed under --json/--dry-run/--web', async () => { + const getConnectorSecret = mock(async (_n: string, _o: { interactive: boolean }) => null); + await resolveVercelToken({ ...base, interactive: true, getConnectorSecret }); + expect(getConnectorSecret.mock.calls.map(([, o]) => o.interactive)).toEqual([true]); + expect(vercelSecretsMayPrompt(true, false)).toBe(true); + expect(vercelSecretsMayPrompt(true, true)).toBe(false); + expect(vercelSecretsMayPrompt(false, false)).toBe(false); + }); +}); diff --git a/tests/helpers/fakeVercel.ts b/tests/helpers/fakeVercel.ts new file mode 100644 index 00000000..883def23 --- /dev/null +++ b/tests/helpers/fakeVercel.ts @@ -0,0 +1,128 @@ +/** + * A scripted fake of the Vercel REST API for the `capy connect vercel` tests. + * + * It answers ONLY the five read endpoints the connector uses and THROWS on + * any non-GET request or unknown path, so a stray write (or a call to a route + * the docs do not list) fails the test at the call site rather than being + * merely recorded. Every value in here is a fake sentinel. + */ +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; + +export interface RawEnv { + id: string; + key: string; + type: string; + target?: string | string[]; + gitBranch?: string; + customEnvironmentIds?: string[]; + configurationId?: string | null; + /** What the LIST returns as `value` (plain: the real value; encrypted: opaque ciphertext). */ + listValue?: string; +} + +export interface FakeVercelData { + teams?: ReadonlyArray<{ id: string; slug: string; name?: string }>; + /** Projects per scope: a team id, or `personal`. */ + projects: Readonly>>>; + envs: Readonly>>; + custom?: Readonly>>; + /** Per-id decrypted values, keyed by env record id. A missing key = Vercel returned no `value`. */ + values?: Readonly>; + /** Project list page size, to exercise pagination. */ + pageSize?: number; + /** Set `pagination.next` on the env list, to exercise the truncation warning. */ + envListNext?: number; +} + +export interface FakeCall { + method: string; + url: string; + authorization?: string; +} + +/** + * Wrap a fake in `bun:test`'s `mock()` and read the calls back with this: the + * recording lives inside the test library's own mock object, so the tests + * never push into an array of their own. + */ +export function callsOf(fetchMock: { mock: { calls: ReadonlyArray } }): readonly FakeCall[] { + return fetchMock.mock.calls.map(([url, init]) => ({ + method: (init as { method: string }).method, + url: url as string, + authorization: (init as { headers: Record }).headers.Authorization, + })); +} + +const json = (body: unknown, status = 200) => ({ + status, + ok: status >= 200 && status < 300, + headers: { get: () => null }, + text: async () => JSON.stringify(body), +}); + +export function fakeVercelFetch(data: FakeVercelData): VercelFetchLike { + return (async (url: string, init: { method: string; headers: Record }) => { + if (init.method !== 'GET') throw new Error(`fakeVercel: unexpected non-GET ${init.method} ${url}`); + const u = new URL(url); + const teamId = u.searchParams.get('teamId') ?? undefined; + const path = u.pathname; + + if (path === '/v2/teams') { + return json({ teams: data.teams ?? [], pagination: { count: (data.teams ?? []).length, next: null, prev: null } }); + } + if (path === '/v10/projects') { + const all = data.projects[teamId ?? 'personal'] ?? []; + const size = data.pageSize ?? all.length; + const start = Number(u.searchParams.get('from') ?? '0'); + const page = all.slice(start, start + size); + const next = start + size < all.length ? start + size : null; + return json({ projects: page, pagination: { count: page.length, next, prev: null } }); + } + const projectRoute = /^\/v9\/projects\/([^/]+)$/.exec(path); + if (projectRoute) { + const idOrName = decodeURIComponent(projectRoute[1]); + const hit = (data.projects[teamId ?? 'personal'] ?? []).find((p) => p.id === idOrName || p.name === idOrName); + return hit ? json(hit) : json({ error: { code: 'not_found', message: 'nope' } }, 404); + } + const customRoute = /^\/v9\/projects\/([^/]+)\/custom-environments$/.exec(path); + if (customRoute) { + return json({ accountLimit: { total: 3 }, environments: data.custom?.[decodeURIComponent(customRoute[1])] ?? [] }); + } + const envListRoute = /^\/v10\/projects\/([^/]+)\/env$/.exec(path); + if (envListRoute) { + const records = data.envs[decodeURIComponent(envListRoute[1])] ?? []; + return json({ + envs: records.map(({ listValue, ...rec }) => ({ + ...rec, + ...(rec.target === undefined ? {} : { target: rec.target }), + ...(listValue === undefined ? {} : { value: listValue }), + decrypted: false, + })), + ...(data.envListNext === undefined ? {} : { pagination: { count: records.length, next: data.envListNext, prev: null } }), + }); + } + const envValueRoute = /^\/v1\/projects\/([^/]+)\/env\/([^/]+)$/.exec(path); + if (envValueRoute) { + const projectId = decodeURIComponent(envValueRoute[1]); + const envId = decodeURIComponent(envValueRoute[2]); + const rec = (data.envs[projectId] ?? []).find((e) => e.id === envId); + if (!rec) return json({ error: { code: 'not_found', message: 'nope' } }, 404); + const value = data.values?.[envId]; + const { listValue: _omit, ...meta } = rec; + return json({ ...meta, decrypted: true, ...(value === undefined ? {} : { value }) }); + } + throw new Error(`fakeVercel: unexpected path ${path}`); + }) as VercelFetchLike; +} + +/** A project row as Vercel returns it (only the fields the connector reads). */ +export const project = ( + id: string, + name: string, + over: { rootDirectory?: string | null; link?: Record | null } = {}, +): Record => ({ + id, + name, + rootDirectory: over.rootDirectory === undefined ? null : over.rootDirectory, + ...(over.link === null ? {} : { link: over.link ?? { type: 'github', org: 'acme', repo: 'widgets', productionBranch: 'main' } }), +}); diff --git a/tests/helpers/testSandbox.ts b/tests/helpers/testSandbox.ts new file mode 100644 index 00000000..34d01310 --- /dev/null +++ b/tests/helpers/testSandbox.ts @@ -0,0 +1,99 @@ +/** + * Process-level sandboxing for the connector tests. The few unavoidable + * writes to Node's own globals (`process.env`, `process.stdin.isTTY`, + * `process.exitCode`) live HERE, behind one function each, and every one is + * restored in a `finally` — so no test file touches a global or keeps a + * mutable binding of its own. + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spyOn } from 'bun:test'; + +/** A throwaway directory (symlinks resolved), removed afterwards. */ +export async function withTempDir(prefix: string, fn: (dir: string) => Promise): Promise { + const dir = realpathSync(mkdtempSync(join(tmpdir(), `${prefix}-`))); + try { + return await fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Points Capy's global config dir (`~/.capy` equivalent: session, wrapped + * master key, keep cache) at a throwaway name for the duration, so a test + * never reads or writes the developer's real one. + */ +export async function withIsolatedGlobalDir(fn: () => Promise): Promise { + const name = `.capy-test-vercel-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e9)}`; + const saved = process.env.CAPY_GLOBAL_DIR_NAME; + process.env.CAPY_GLOBAL_DIR_NAME = name; + try { + return await fn(); + } finally { + if (saved === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = saved; + rmSync(join(homedir(), name), { recursive: true, force: true }); + } +} + +/** + * `isInteractive()` also requires a real `process.stdin.isTTY`, which + * `bun test` never has — `nonTty: false` alone cannot reach the interactive + * branches. + */ +export async function withTTY(fn: () => Promise): Promise { + const saved = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + try { + return await fn(); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: saved, configurable: true }); + } +} + +/** A refusal sets `process.exitCode = 1` (right for the CLI); restore it so the test process itself still exits 0. */ +export async function withExitCodeRestored(fn: () => Promise): Promise { + const saved = process.exitCode; + try { + return await fn(); + } finally { + process.exitCode = saved ?? 0; + } +} + +/** + * Runs `fn` with `console.log` / `console.error` captured. Returns what each + * printed, one string per call — the spies' own call records, so nothing is + * pushed into a test-owned array. + */ +export async function captureConsole(fn: () => Promise): Promise<{ result: T; stdout: readonly string[]; stderr: readonly string[] }> { + const logSpy = spyOn(console, 'log').mockImplementation((() => {}) as never); + const errSpy = spyOn(console, 'error').mockImplementation((() => {}) as never); + try { + const result = await fn(); + const flat = (calls: ReadonlyArray): readonly string[] => calls.map((a) => a.map(String).join(' ')); + return { result, stdout: flat(logSpy.mock.calls), stderr: flat(errSpy.mock.calls) }; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + } +} + +export function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' }); +} + +/** A git repo with one commit and (optionally) an `origin`. */ +export function initRepo(dir: string, remoteUrl?: string): void { + mkdirSync(dir, { recursive: true }); + git(dir, ['init', '-q']); + git(dir, ['config', 'user.email', 'test@example.com']); + git(dir, ['config', 'user.name', 'Test']); + if (remoteUrl) git(dir, ['remote', 'add', 'origin', remoteUrl]); + writeFileSync(join(dir, 'README.md'), 'x'); + git(dir, ['add', '.']); + git(dir, ['commit', '-q', '-m', 'init']); +} From 5b93f22ef7b62b577d69bf19b7949b4522c2245a Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 1 Oct 2026 12:45:02 -0700 Subject: [PATCH 03/22] feat(connect): capy connect vercel --create-token (team-scoped token from the Vercel CLI sign-in) and where-to-get-the-key help on every key ask - --create-token / --token-expires-days: make _CONNECTOR_VERCEL_TOKEN through the Vercel CLI's sign-in, check it is scoped to exactly the chosen team (else delete it), save it to the org system store; alone it stops after saving, with --project/--discover it continues; refused under --dry-run - interactive runs with a missing token offer the CLI route next to the hidden prompt - every Vercel/Dokploy key ask and key refusal carries link + remedy (JSON) and the page URL (stderr); capy system set prints it for the four known names --- docs/cli-reference.md | 9 + src/commands/connectCommand.ts | 78 ++- src/commands/connectors/dokploy.ts | 17 +- src/commands/connectors/dokployDiscovery.ts | 12 +- src/commands/connectors/registry.ts | 47 +- src/commands/connectors/vercel.ts | 579 ++++++++++++------ src/commands/connectors/vercelDiscovery.ts | 3 + src/commands/deployCommand.ts | 10 + src/commands/systemCommand.ts | 5 + src/deploy/adapter.ts | 8 + src/deploy/adapters/dokploy.ts | 9 +- src/deploy/adapters/vercel.ts | 20 +- src/deploy/dokployApi.ts | 5 + src/deploy/keyHelp.ts | 77 +++ src/deploy/vercelApi.ts | 5 + src/deploy/vercelCliToken.ts | 464 ++++++++++++++ src/deploy/vercelTargetApi.ts | 10 +- src/index.ts | 7 + src/system/systemStore.ts | 31 + src/types/index.ts | 15 + .../commands/connectVercelCreateToken.test.ts | 367 +++++++++++ tests/commands/keyHelpRefusals.test.ts | 198 ++++++ tests/commands/systemSetKeyHelp.test.ts | 59 ++ tests/deploy/vercelCliToken.test.ts | 264 ++++++++ tests/helpers/stubVercelCli.ts | 96 +++ tests/run-tests.sh | 1 + tests/system/systemStore.test.ts | 68 +- 27 files changed, 2233 insertions(+), 231 deletions(-) create mode 100644 src/deploy/keyHelp.ts create mode 100644 src/deploy/vercelCliToken.ts create mode 100644 tests/commands/connectVercelCreateToken.test.ts create mode 100644 tests/commands/keyHelpRefusals.test.ts create mode 100644 tests/commands/systemSetKeyHelp.test.ts create mode 100644 tests/deploy/vercelCliToken.test.ts create mode 100644 tests/helpers/stubVercelCli.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 36a4b02f..2c41cdfc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -622,6 +622,8 @@ capy connect [provider] [options] | `--token-env ` | dokploy/vercel import: env var holding the API token | | | `--project ` | vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision) | | | `--team ` | vercel import/discover: Vercel team slug or id (default: the token's only team) | | +| `--create-token` | vercel: create the Vercel token from the Vercel CLI sign-in (team-scoped) and save it as _CONNECTOR_VERCEL_TOKEN; with --project/--discover the run then continues, alone it stops after saving. Not with --dry-run | | +| `--token-expires-days ` | vercel --create-token: days until the created token expires (default: never) | | | `--json` | emit machine-readable JSON instead of the human UI (import connectors) | | | `--dry-run` | dokploy import/discover: preview the plan only — resolve settings + read Dokploy, write/push nothing | | | `--discover` | dokploy/vercel: find every Dokploy service / Vercel project matching a repo under cwd, instead of one named --application/--compose/--project | | @@ -754,6 +756,13 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `VERCEL_VERIFY_MISMATCH` - `VERCEL_OPTIONS_INVALID` - `VERCEL_SPLIT_NOT_RESTORED` +- `VERCEL_CLI_MISSING` +- `VERCEL_CLI_NOT_SIGNED_IN` +- `VERCEL_CREATE_TOKEN_DRY_RUN` +- `VERCEL_TOKEN_EXPIRY_INVALID` +- `VERCEL_TOKEN_CREATE_FAILED` +- `VERCEL_TOKEN_SCOPE_UNEXPECTED` +- `VERCEL_TOKEN_CLEANUP_FAILED` ## Conventions diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index 8e0efbdc..e3998f86 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -1,5 +1,7 @@ import { resolveContext, writeAndSync, writeImportOutcome, listManagedKeys, ResolvedContext } from './connectors/shared'; -import { listProviders, loadProvider, ConnectOpts, ConnectorModule, ConnectResult } from './connectors/registry'; +import { listProviders, loadProvider, ConnectFailure, ConnectOpts, ConnectorModule, ConnectResult } from './connectors/registry'; +import { describeTokenCreated } from '../deploy/vercelCliToken'; +import { formatKeyHelp } from '../deploy/keyHelp'; import { connectPlan } from './connectors/plans'; import { isInteractive } from '../ui/interactive'; import { ProjectManager } from '../core/projectManager'; @@ -15,6 +17,33 @@ import type { const B = (s: string) => `\x1b[1m${s}\x1b[0m`; +/** + * A refusal, in the terminal or as one JSON object. `link` + `remedy` (where to + * get a missing key, and what to do) and `tokenId` / `tokenCreated` are added + * only when the connector set them; without them the output is exactly what it + * has always been. Human text goes to stderr, never stdout. + */ +function printFailure(outcome: ConnectFailure, json: boolean): void { + if (json) { + console.log( + JSON.stringify({ + ok: false, + code: outcome.code, + message: outcome.message, + ...(outcome.link ? { link: outcome.link } : {}), + ...(outcome.remedy ? { remedy: outcome.remedy } : {}), + ...(outcome.tokenId ? { tokenId: outcome.tokenId } : {}), + ...(outcome.tokenCreated ? { tokenCreated: outcome.tokenCreated } : {}), + }), + ); + return; + } + console.error(`\n ${outcome.message}\n`); + if (outcome.link && outcome.remedy) console.error(formatKeyHelp({ link: outcome.link, remedy: outcome.remedy }) + '\n'); + else if (outcome.remedy) console.error(` ${outcome.remedy}\n`); + if (outcome.tokenCreated) describeTokenCreated(outcome.tokenCreated).forEach((line) => console.error(line)); +} + /** Browser paths honour this so a test never opens the developer's real browser. */ const shouldOpen = (): boolean => !process.env.CAPY_WEB_NO_OPEN; @@ -247,6 +276,12 @@ export class ConnectCommand { // which has (or needs) a keep.lock yet. Discovery gets its OWN, lighter // context instead: org + auth + serviceClient only, no project key, no // `.env` decrypt — see `resolveDiscoveryContext`'s own doc. + // `--create-token` alone (no project to import, nothing to discover): make + // and save the key, report it, stop. Needs the org only — no keep.lock. + if (effective.createToken && mod.createToken && !effective.discover && !effective.project) { + return await this.executeCreateToken(mod, provider, effective); + } + if (effective.discover && mod.discover) { const discoveryCtx = await resolveDiscoveryContext(this.devMode); return await this.executeDiscovery(mod, provider, discoveryCtx, effective); @@ -407,6 +442,33 @@ export class ConnectCommand { return { linked: !failed }; } + /** + * `capy connect vercel --create-token` on its own. Names, ids and the team + * only — never the token's value. + */ + private async executeCreateToken( + mod: ConnectorModule, + provider: string, + opts: ConnectOpts, + // Org + sign-in only, like discovery. A parameter so a test can name the org without a login. + orgIdOf: () => Promise = async () => (await resolveDiscoveryContext(this.devMode)).orgId, + ): Promise<{ linked: boolean }> { + const outcome = await mod.createToken!(opts, orgIdOf); + if (!outcome.ok) { + printFailure(outcome, !!opts.json); + process.exitCode = 1; + return { linked: false }; + } + if (opts.json) { + console.log(JSON.stringify({ ok: true, provider, tokenCreated: outcome.tokenCreated })); + return { linked: false }; + } + console.log(''); + describeTokenCreated(outcome.tokenCreated).forEach((line) => console.log(line)); + console.log(''); + return { linked: false }; + } + /** * `capy connect ` — pulls the provider's variables into * `.env` in one pass, then reports. No browser ending page exists for this @@ -429,11 +491,7 @@ export class ConnectCommand { const label = mod.displayName ?? 'Dokploy'; if (!outcome.ok) { - if (opts.json) { - console.log(JSON.stringify({ ok: false, code: outcome.code, message: outcome.message })); - } else { - console.error(`\n ${outcome.message}\n`); - } + printFailure(outcome, !!opts.json); process.exitCode = 1; return { linked: false }; } @@ -470,12 +528,14 @@ export class ConnectCommand { warnings: outcome.warnings, pushed, deployTargetSaved: outcome.deployTargetSaved, + ...(outcome.tokenCreated ? { tokenCreated: outcome.tokenCreated } : {}), }), ); return { linked: wrote }; } console.log(''); + if (outcome.tokenCreated) describeTokenCreated(outcome.tokenCreated).forEach((line) => console.log(line)); if (dryRun) console.log(' Dry run — nothing written.'); if (!hasChange) { console.log(dryRun ? ' Nothing to import.' : ' Nothing new to import.'); @@ -538,11 +598,7 @@ export class ConnectCommand { const outcome = await mod.discover!(ctx, opts); if (!outcome.ok) { - if (opts.json) { - console.log(JSON.stringify({ ok: false, code: outcome.code, message: outcome.message })); - } else { - console.error(`\n ${outcome.message}\n`); - } + printFailure(outcome, !!opts.json); process.exitCode = 1; return { linked: false }; } diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index 7c22f09f..f1275d5f 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -34,6 +34,7 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { TargetConfig } from '../../deploy/adapter'; +import { dokployKeyHelp, keyHelpFields } from '../../deploy/keyHelp'; import { baseUrlProblem } from '../../deploy/adapters/dokploy'; import { DEFAULT_TOKEN_ENV, @@ -951,12 +952,13 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect interactive: secretsInteractive, orgId: ctx.orgId, devMode: opts.devMode, + helpBaseUrl: baseUrl, storeName: DOKPLOY_CONNECTOR_SECRET_NAME, deps: deps.getConnectorSecret ? { getConnectorSecret: deps.getConnectorSecret } : undefined, }); if (!resolved.ok) { const { reason } = describeDokployTokenProblem(resolved.code, tokenEnv, DOKPLOY_CONNECTOR_SECRET_NAME); - return { ok: false, code: resolved.code, message: `${reason}.` }; + return { ok: false, code: resolved.code, message: `${reason}.`, ...keyHelpFields(dokployKeyHelp(DOKPLOY_CONNECTOR_SECRET_NAME, baseUrl)) }; } const token = resolved.value; @@ -965,7 +967,15 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect source.kind === 'compose' ? await fetchComposeEnv(client, source.id) : await fetchApplicationEnv(client, source.id); - if (!envResult.ok) return { ok: false, code: envResult.code, message: envResult.message }; + if (!envResult.ok) { + // A rejected key says where to get another. + return { + ok: false, + code: envResult.code, + message: envResult.message, + ...(envResult.code === 'DOKPLOY_AUTH_FAILED' ? keyHelpFields(dokployKeyHelp(DOKPLOY_CONNECTOR_SECRET_NAME, baseUrl)) : {}), + }; + } const sourceEnv = envResult.env; // CAP-673: `service_name` is free (same response as `env`, above); // `dokploy_project`/`environment` are only known when the caller @@ -1115,12 +1125,13 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect interactive: secretsInteractive, orgId: ctx.orgId, devMode: opts.devMode, + helpBaseUrl: resolvedBaseUrl, storeName: DOKPLOY_CONNECTOR_SECRET_NAME, deps: deps.getConnectorSecret ? { getConnectorSecret: deps.getConnectorSecret } : undefined, }); if (!resolved.ok) { const { reason } = describeDokployTokenProblem(resolved.code, tokenEnv, DOKPLOY_CONNECTOR_SECRET_NAME); - return { ok: false, code: resolved.code, message: `${reason}.` }; + return { ok: false, code: resolved.code, message: `${reason}.`, ...keyHelpFields(dokployKeyHelp(DOKPLOY_CONNECTOR_SECRET_NAME, resolvedBaseUrl)) }; } const client = createDokployClient(resolvedBaseUrl, resolved.value, deps.fetch); diff --git a/src/commands/connectors/dokployDiscovery.ts b/src/commands/connectors/dokployDiscovery.ts index 8790decd..30a536a2 100644 --- a/src/commands/connectors/dokployDiscovery.ts +++ b/src/commands/connectors/dokployDiscovery.ts @@ -118,7 +118,8 @@ import { isReservedProjectName } from '../../system/reservedProjectName'; import { KeepFile } from '../../types/index'; import type { AuthService } from '../../auth/authService'; import type { ServiceClient } from '../../service/serviceClient'; -import type { ImportOutcome } from './registry'; +import type { ConnectFailure, ImportOutcome } from './registry'; +import type { TokenCreatedReport } from '../../deploy/vercelCliToken'; import type { DiscoveryCommitOutcome } from '../../git/discoveryCommit'; /** @@ -1204,10 +1205,7 @@ export type DiscoveryOutcome = cancelled?: boolean; /** One entry per repo touched by the plan (dry run: every repo the plan would touch) — see `DiscoveryRepoCommitResult`'s own doc. Absent only when nothing was ever going to be committed (no matched repos at all). */ commits?: readonly DiscoveryRepoCommitResult[]; + /** Vercel `--create-token` only: the token this run created and saved. Never its value. */ + tokenCreated?: TokenCreatedReport; } - | { - ok: false; - /** Stable refusal code — branch on this, never on `message`. */ - code: string; - message: string; - }; + | ConnectFailure; diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index 75ebc164..70cfef89 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -2,6 +2,7 @@ import { ConnectorMetadata } from '../../types/index'; import type { Blocked } from '../../ui/screens/contract'; import type { DokployImportSource } from '../../deploy/dokployApi'; import type { VercelImportSource } from '../../deploy/vercelApi'; +import type { TokenCreatedReport } from '../../deploy/vercelCliToken'; import type { DiscoveryContext, DiscoveryOutcome } from './dokployDiscovery'; import { ResolvedContext } from './shared'; @@ -185,6 +186,15 @@ export interface ConnectOpts { * project again. */ vercelProject?: { id: string; name: string; productionBranch?: string }; + /** + * Vercel only (flag `--create-token`): create `_CONNECTOR_VERCEL_TOKEN` from + * the Vercel CLI's sign-in and save it to the org system store, without + * prompting. With `--project`/`--discover` the run then continues; alone, it + * stops after reporting the token. Refused under `--dry-run`. + */ + createToken?: boolean; + /** Vercel only (flag `--token-expires-days`): how many days the created token lasts. Absent = no expiry. */ + tokenExpiresDays?: string | number; /** * Vercel import only: the folder a saved deploy target's `.capy/deploy.json` * belongs in. Set INTERNALLY by discovery (each folder has its own); a plain @@ -315,13 +325,31 @@ export type ImportOutcome = * `--overwrite` decline branch, where nothing is touched at all. */ unchangedEntries?: readonly ImportedVarEntry[]; + /** Vercel `--create-token` only: the token this run created and saved. Never its value. */ + tokenCreated?: TokenCreatedReport; } - | { - ok: false; - /** Stable refusal code — branch on this, never on `message`. */ - code: string; - message: string; - }; + | ConnectFailure; + +/** + * A refusal. `link` + `remedy` (same field names as `Blocked`) say where to get + * a missing or rejected API key and what the human does next; `tokenId` names a + * Vercel token that was created and could not be removed (never its value); + * `tokenCreated` is set when a token WAS created and saved before the refusal. + * All additive. + */ +export interface ConnectFailure { + ok: false; + /** Stable refusal code — branch on this, never on `message`. */ + code: string; + message: string; + link?: { label: string; url: string }; + remedy?: string; + tokenId?: string; + tokenCreated?: TokenCreatedReport; +} + +/** `capy connect vercel --create-token` on its own: the token that was made and saved, or why not. */ +export type CreateTokenOutcome = { ok: true; tokenCreated: TokenCreatedReport } | ConnectFailure; export interface ConnectorModule { name: string; @@ -396,6 +424,13 @@ export interface ConnectorModule { * that implement it — no other provider does today. */ discover?(ctx: DiscoveryContext, opts: ConnectOpts): Promise; + /** + * `--create-token` with neither `--project` nor `--discover`: make and save + * the provider's API key, report it, import nothing. `orgIdOf` resolves the + * org (it may authenticate), so the connector can refuse a bad request + * before any of that. Present only on connectors that can make their key. + */ + createToken?(opts: ConnectOpts, orgIdOf: () => Promise): Promise; /** * A connector whose discovery plan has its own shape (Vercel: project name * + id, skipped names grouped by reason) prints and emits it here instead diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts index 79291188..8594dce8 100644 --- a/src/commands/connectors/vercel.ts +++ b/src/commands/connectors/vercel.ts @@ -21,6 +21,13 @@ * `legacy_secret_unreadable`, `vercel_system`, `unsupported_type`, * `duplicate_key`, `empty_value`. * + * `--create-token` makes `_CONNECTOR_VERCEL_TOKEN` from the Vercel CLI's own + * sign-in (see `deploy/vercelCliToken.ts`) instead of a hand-made one. With + * `--project`/`--discover` the run goes on to import; alone it stops after + * saving the token. Interactively, a missing token offers the same thing next + * to the hidden prompt. Every refusal for a missing or rejected token carries + * the Vercel token page (`link`) and what to do (`remedy`). + * * Deploy targets: like Dokploy, a target is OFFERED only after a single * import, only interactively (never under `--json`, `--dry-run`, or * non-interactive). Discovery never saves one by itself. @@ -52,12 +59,33 @@ import { resolveVercelToken, vercelSecretsMayPrompt, } from '../../deploy/vercelApi'; +import { + MintTarget, + TokenCreatedReport, + TokenFailure, + VercelCliDeps, + describeTokenCreated, + hasVercelCli, + mintTeamToken, + parseExpiryDays, +} from '../../deploy/vercelCliToken'; +import { formatKeyHelp, keyHelpFields, vercelKeyHelp } from '../../deploy/keyHelp'; import { ConnectorMetadata, ERROR_CODES } from '../../types/index'; import { isInteractive } from '../../ui/interactive'; import { listOrgProjectsOrUnavailable } from '../capyCommand'; import { defaultDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; import { fingerprint, ResolvedContext } from './shared'; -import { ConnectOpts, ConnectorModule, ConnectResult, ImportOutcome, ImportSkip, ImportWarning, RotateResult } from './registry'; +import { + ConnectFailure, + ConnectOpts, + ConnectorModule, + ConnectResult, + CreateTokenOutcome, + ImportOutcome, + ImportSkip, + ImportWarning, + RotateResult, +} from './registry'; import { CandidateRepo, DiscoveryCollision, @@ -88,10 +116,11 @@ import { peekLocalKeepForFolder, } from './dokploy'; import { buildVercelDiscoveryPlan, reportVercelDiscovery } from './vercelDiscovery'; +import { VERCEL_CONNECTOR_SECRET_NAME } from '../../deploy/vercelApi'; const VERCEL_COMMIT_MESSAGE = 'chore(capy): import Vercel environments'; -type Failure = { ok: false; code: string; message: string }; +type Failure = ConnectFailure; // ── Prompts (real `inquirer`; every one is injectable) ────────────────────── @@ -119,6 +148,24 @@ async function defaultPickTeam(teams: readonly VercelTeam[]): Promise { return picked; } +// COPY-FLAG: new user-facing string, minimal/neutral wording. +async function defaultAskCreateMethod(): Promise<'create' | 'paste'> { + const inquirer = (await import('inquirer')).default; + const { picked } = await inquirer.prompt([ + { + type: 'list', + name: 'picked', + message: `${VERCEL_CONNECTOR_SECRET_NAME} is not set. How do you want to provide it:`, + choices: [ + { name: 'Create one with the Vercel CLI sign-in', value: 'create' }, + { name: 'Paste a token', value: 'paste' }, + ], + default: 'create', + }, + ]); + return picked; +} + // COPY-FLAG: new user-facing string, minimal/neutral wording. async function defaultAskProject(): Promise { const inquirer = (await import('inquirer')).default; @@ -155,6 +202,18 @@ export interface VercelConnectorDeps { * singleton wires the real `system/systemStore.ts` lookup. */ getConnectorSecret?: (name: string, opts: VercelSystemStoreCallOptions) => Promise; + /** The Vercel CLI: how it is run and found. `env` is taken from `env` above. Tests inject a stub. */ + cli?: Pick; + /** Opens the org system store for `--create-token` (the same store the hidden prompt saves to). Defaults to the real one. */ + openStore?: (orgId: string, opts: { devMode?: boolean }) => Promise; + /** Interactive: create the token with the Vercel CLI, or paste one. */ + askCreateMethod?: () => Promise<'create' | 'paste'>; +} + +/** The part of the system store `--create-token` uses. */ +export interface CreateTokenStore { + listNames(): readonly { name: string }[]; + set(name: string, value: string): Promise; } // ── Connector entry (keep.lock metadata) ──────────────────────────────────── @@ -353,13 +412,105 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const askDiscoveryProjectName = deps.askDiscoveryProjectName ?? defaultAskDiscoveryProjectName; const askDiscoveryCommitBranchName = deps.askDiscoveryCommitBranchName ?? defaultAskDiscoveryCommitBranchName; + // ── Vercel CLI token creation ────────────────────────────────────────────── + + const cliDeps = (): VercelCliDeps => ({ env: env(), ...deps.cli }); + const askCreateMethod = deps.askCreateMethod ?? defaultAskCreateMethod; + const openStore = + deps.openStore ?? + (async (orgId: string, o: { devMode?: boolean }): Promise => + (await import('../../system/systemStore')).openSystemStore({ orgId, devMode: o.devMode })); + + /** A typed store error's code — never its message. */ + const storeCodeOf = (err: unknown): string => { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === 'string' && code !== '' ? code : ERROR_CODES.SERVICE_ERROR; + }; + + const tokenHelp = () => keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME)); + + /** Opens the store BEFORE a token exists, so a caller who cannot save one is refused before one is made. */ + const prepareStore = + (orgId: string, opts: ConnectOpts) => + async (): Promise => { + try { + const store = await openStore(orgId, { devMode: opts.devMode }); + return { + ok: true, + save: (value: string) => store.set(VERCEL_CONNECTOR_SECRET_NAME, value), + replacedExisting: store.listNames().some((n) => n.name === VERCEL_CONNECTOR_SECRET_NAME), + }; + } catch (err) { + const code = storeCodeOf(err); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code, message: `Could not open the org system store (${code}).`, ...tokenHelp() }; + } + }; + + /** `--create-token`'s whole run: validate, open the store, create, check, save. */ + const mintForRun = async (orgId: string, opts: ConnectOpts, promptable: boolean) => { + const expiry = parseExpiryDays(opts.tokenExpiresDays); + if (!expiry.ok) return expiry; + return mintTeamToken(cliDeps(), { + team: opts.team, + expiresDays: expiry.days, + pickTeam: promptable ? pickTeam : null, + prepare: prepareStore(orgId, opts), + askSignOut: promptable ? confirm : null, + }); + }; + + /** A preview never creates or saves a key. */ + const createTokenDryRunRefusal = (opts: ConnectOpts): Failure | null => + opts.createToken && opts.dryRun + ? { + ok: false, + code: ERROR_CODES.VERCEL_CREATE_TOKEN_DRY_RUN, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: 'A dry run never creates or saves a key. Drop --dry-run or --create-token.', + } + : null; + + /** Interactive and the token is missing: offer the Vercel CLI route next to the hidden prompt. A failure is shown and falls back to the prompt. */ + const offerCreateViaCli = (orgId: string, opts: ConnectOpts) => async (save: (value: string) => Promise): Promise => { + if ((await askCreateMethod()) !== 'create') return null; + const expiry = parseExpiryDays(opts.tokenExpiresDays); + const minted = expiry.ok + ? await mintTeamToken(cliDeps(), { + team: opts.team, + expiresDays: expiry.days, + pickTeam, + // The store is already open (the hidden prompt's own), so the admin rule has already applied. + prepare: async () => ({ ok: true as const, save, replacedExisting: false }), + askSignOut: confirm, + }) + : expiry; + if (!minted.ok) { + console.error(`\n ${minted.message}`); + console.error(formatKeyHelp({ link: minted.link ?? tokenHelp().link, remedy: minted.remedy ?? tokenHelp().remedy }) + '\n'); + return null; + } + describeTokenCreated(minted.report).forEach((line) => console.log(line)); + return minted.bearer; + }; + /** The token, then a client — or the coded refusal. Resolved BEFORE any Vercel request, so a missing token means zero requests. */ const openClient = async ( orgId: string, opts: ConnectOpts, interactive: boolean, - ): Promise<{ ok: true; client: VercelClient; tokenEnv: string } | Failure> => { + ): Promise<{ ok: true; client: VercelClient; tokenEnv: string; tokenCreated?: TokenCreatedReport } | Failure> => { const tokenEnv = opts.tokenEnv?.trim() || DEFAULT_VERCEL_TOKEN_ENV; + if (opts.createToken) { + const minted = await mintForRun(orgId, opts, interactive && !opts.json); + if (!minted.ok) return minted; + return { + ok: true, + tokenEnv, + client: createVercelClient(minted.bearer, { fetch: deps.fetch, sleep: deps.sleep }), + tokenCreated: minted.report, + }; + } // `--web`, `--json` and `--dry-run` each suppress ONLY the system store's // hidden prompt: a raw terminal prompt must not hang a browser run, be // interleaved with machine output, or save a key as a preview's side effect. @@ -371,10 +522,11 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector orgId, devMode: opts.devMode, getConnectorSecret: deps.getConnectorSecret, + ...(mayPrompt && hasVercelCli(cliDeps()) ? { onMissing: offerCreateViaCli(orgId, opts) } : {}), }); if (!resolved.ok) { const { reason } = describeVercelTokenProblem(resolved.code, tokenEnv); - return { ok: false, code: resolved.code, message: `${reason}.` }; + return { ok: false, code: resolved.code, message: `${reason}.`, ...tokenHelp() }; } return { ok: true, @@ -502,6 +654,208 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector }; }; + /** Everything after the client exists: read the source, plan, classify, return the outcome. */ + const importFrom = async (ctx: ResolvedContext, opts: ConnectOpts, client: VercelClient, interactive: boolean): Promise => { + // A dry run changes nothing; `--json` never has a prompt interleaved. + const dryRun = !!opts.dryRun; + const promptable = interactive && !opts.json; + + const source = await readSource(client, ctx, opts, promptable); + if (!source.ok) return source; + + const importSource: VercelImportSource = { + kind: 'vercel', + projectId: source.project.id, + projectName: source.project.name, + ...(source.teamId ? { teamId: source.teamId } : {}), + environment: source.environment.name, + }; + + if (opts.overwrite) { + return runOverwriteImport({ ctx, opts, source, importSource, client, promptable, dryRun }); + } + + const restrict = parseVarList(opts.var); + const restricted = source.importable.filter((r) => !restrict || restrict.includes(r.key)); + const values = await guard(() => resolveEnvValues(client, source.project.id, source.teamId, restricted)); + if (!values.ok) return values; + const candidates = values.value.entries.map((e) => ({ name: e.record.key, value: e.value })); + + // A dry run previews the WHOLE plan rather than a hand-picked subset. + const selectedNames = restrict || !promptable || dryRun ? candidates.map((c) => c.name) : await selectVars(candidates.map((c) => c.name)); + const selected = candidates.filter((c) => selectedNames.includes(c.name)); + + const { unchanged, skipped: conflictSkipped, toImport, wouldAsk } = await classifyImportCandidates(selected, ctx.localPlaintext, { + dryRun, + interactive: promptable, + confirm, + providerLabel: 'Vercel', + }); + + const importedAt = now().toISOString(); + const createdAtSec = Math.floor(now().getTime() / 1000); + const recordByName = new Map(values.value.entries.map((e) => [e.record.key, e.record] as const)); + const entryFor = (name: string, value: string) => importedEntry(recordByName.get(name)!, name, value, source, createdAtSec, importedAt); + const imported = toImport.map(({ name, value }) => entryFor(name, value)); + // A re-import over a name whose value already matches still refreshes + // its connector metadata — never populated under a dry run. + const unchangedEntries = dryRun ? [] : unchanged.map((name) => entryFor(name, selected.find((c) => c.name === name)!.value)); + + const inScope = (name: string): boolean => !restrict || restrict.includes(name); + const skipped: readonly ImportSkip[] = [ + ...[...source.skipped, ...values.value.empty].filter((s) => inScope(s.name)).map(toImportSkip), + ...conflictSkipped, + ]; + + const importedNames = [...imported.map((i) => i.varName), ...unchanged]; + const deployTargetSaved = + !dryRun && + (await maybeOfferDeployTarget({ + importedNames, + project: source.project, + teamId: source.teamId, + environment: source.environment, + allTargets: await listTargetsAt(opts.projectDir ?? cwd()), + promptable, + dir: opts.projectDir ?? cwd(), + confirm, + })); + + return { + ok: true, + source: importSource, + imported, + unchanged, + unchangedEntries, + skipped, + warnings: truncationWarnings(source), + deployTargetSaved, + ...(dryRun ? { wouldAsk } : {}), + }; + }; + + /** Everything after the client exists: resolve the team, build the plan, apply it. */ + const discoverFrom = async ( + ctx: DiscoveryContext, + opts: ConnectOpts, + client: VercelClient, + tokenEnv: string, + interactive: boolean, + ): Promise => { + const dryRun = !!opts.dryRun; + const promptable = interactive && !opts.json; + + const team = await chooseTeam(client, opts, promptable); + if (!team.ok) return team; + + const scan = findCandidateReposResult(cwd()); + const environmentFilter = parseVarList(opts.environment) ?? undefined; + const built = await guard(() => + buildVercelDiscoveryPlan( + client, + team.teamId, + scan.repos as readonly CandidateRepo[], + { peekLocalKeep: peekLocalKeepForFolder, listServerBranches: (projectId) => ctx.serviceClient.listBranches(projectId) }, + environmentFilter, + ), + ); + if (!built.ok) return built; + const plan: DiscoveryPlan = { + ...built.value, + unreadableRepos: scan.unreadable, + ...(team.teamId ? { team: { id: team.teamId, ...(team.teamSlug ? { slug: team.teamSlug } : {}) } } : {}), + }; + + if (environmentFilter) { + const unknown = environmentFilter.filter((name) => !plan.allEnvironmentNames.includes(name)); + if (unknown.length > 0) { + return { + ok: false, + code: ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `No such environment(s): ${unknown.join(', ')}. Environments found: ${plan.allEnvironmentNames.join(', ') || '(none)'}.`, + }; + } + } + + if (dryRun) { + // A dry run never asks for a branch name either; the DEFAULT is what it previews. + const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), VERCEL_COMMIT_MESSAGE); + return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}) }; + } + + // `--project` under `--discover` is the collision-winner hint (an id or a name), not an import source. + const preferredServiceId = preferredProjectId(plan, opts.project); + const resolution = await resolveCollisions(orderEnvironments(plan.collisions), { + preferredServiceId, + interactive: promptable, + askWinner: pickCollisionWinner, + }); + if (!resolution.ok) { + return { + ok: false, + code: ERROR_CODES.VERCEL_MAPPING_COLLISION, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: 'Several Vercel projects map to the same folder + branch. Pass --project to pick one, or run this interactively.', + }; + } + + const resolvedFolders = applyCollisionResolutions(plan.folders, resolution.resolutions); + if (resolvedFolders.length === 0) return { ok: true, dryRun: false, plan, applied: [] }; + + // A real run never writes without a yes: `--yes` skips the ask regardless of TTY. + if (promptable && !opts.yes) { + const proceed = await confirm(discoveryConfirmMessage(resolvedFolders, !!opts.overwrite, environmentFilter), false); + if (!proceed) return { ok: true, dryRun: false, plan, applied: [], cancelled: true }; + } else if (!promptable && !opts.yes) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code: ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED, message: 'Pass --yes to apply non-interactively, or run this interactively.' }; + } + + const commitBranchName = + promptable && !opts.yes + ? await askDiscoveryCommitBranchName(defaultDiscoveryCommitBranchName(new Date(), 'vercel')) + : defaultDiscoveryCommitBranchName(new Date(), 'vercel'); + + // Snapshotted BEFORE any write, per repo, so the commit step can tell + // "discovery made this change" from "already dirty before the run". + const beforeStatusByRepo = new Map( + [...new Set(resolvedFolders.map((f) => f.repoDir))].map((repoDir) => { + const paths = resolvedFolders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)); + return [repoDir, snapshotPathStatus(repoDir, paths)] as const; + }), + ); + + // Same snapshot-once rule as Dokploy's non-interactive `--yes` auto-create. + const existingProjectsSnapshot: ExistingProjectsSnapshot | undefined = + !promptable && !!opts.yes ? await listOrgProjectsOrUnavailable(ctx.serviceClient) : undefined; + const sameRunNameCollisions: ReadonlySet = !promptable && !!opts.yes ? computeSameRunNameCollisions(resolvedFolders) : new Set(); + + const sequenceDeps = buildRealDiscoverySequenceDeps(ctx, connector.import!, { + noPush: !!opts.noPush, + interactive: promptable, + json: !!opts.json, + yes: !!opts.yes, + baseUrl: '', + tokenEnv, + importOptsFor: (env, projectName, folderPath) => ({ + project: env.serviceId, + vercelProject: { id: env.serviceId, name: projectName, ...(env.gitBranch ? { productionBranch: env.gitBranch } : {}) }, + vercelTeam: { id: team.teamId }, + environment: env.environmentName, + tokenEnv, + projectDir: folderPath, + }), + askExistingOrNewProject, + askProjectName: askDiscoveryProjectName, + existingProjectsSnapshot, + sameRunNameCollisions, + }); + const applied = await runDiscoverySequences(resolvedFolders, { overwrite: !!opts.overwrite }, sequenceDeps); + const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, VERCEL_COMMIT_MESSAGE); + return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}) }; + }; + const connector: ConnectorModule = { name: 'vercel', // COPY-FLAG: new user-facing string, minimal/neutral wording. @@ -518,87 +872,15 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector }, async import(ctx: ResolvedContext, opts: ConnectOpts): Promise { + const refused = createTokenDryRunRefusal(opts); + if (refused) return refused; const interactive = isInteractive(opts.nonTty); - // A dry run changes nothing; `--json` never has a prompt interleaved. - const dryRun = !!opts.dryRun; - const promptable = interactive && !opts.json; const opened = await openClient(ctx.orgId, opts, interactive); if (!opened.ok) return opened; - const { client } = opened; - - const source = await readSource(client, ctx, opts, promptable); - if (!source.ok) return source; - - const importSource: VercelImportSource = { - kind: 'vercel', - projectId: source.project.id, - projectName: source.project.name, - ...(source.teamId ? { teamId: source.teamId } : {}), - environment: source.environment.name, - }; - if (opts.overwrite) { - return runOverwriteImport({ ctx, opts, source, importSource, client, promptable, dryRun }); - } - - const restrict = parseVarList(opts.var); - const restricted = source.importable.filter((r) => !restrict || restrict.includes(r.key)); - const values = await guard(() => resolveEnvValues(client, source.project.id, source.teamId, restricted)); - if (!values.ok) return values; - const candidates = values.value.entries.map((e) => ({ name: e.record.key, value: e.value })); - - // A dry run previews the WHOLE plan rather than a hand-picked subset. - const selectedNames = restrict || !promptable || dryRun ? candidates.map((c) => c.name) : await selectVars(candidates.map((c) => c.name)); - const selected = candidates.filter((c) => selectedNames.includes(c.name)); - - const { unchanged, skipped: conflictSkipped, toImport, wouldAsk } = await classifyImportCandidates(selected, ctx.localPlaintext, { - dryRun, - interactive: promptable, - confirm, - providerLabel: 'Vercel', - }); - - const importedAt = now().toISOString(); - const createdAtSec = Math.floor(now().getTime() / 1000); - const recordByName = new Map(values.value.entries.map((e) => [e.record.key, e.record] as const)); - const entryFor = (name: string, value: string) => importedEntry(recordByName.get(name)!, name, value, source, createdAtSec, importedAt); - const imported = toImport.map(({ name, value }) => entryFor(name, value)); - // A re-import over a name whose value already matches still refreshes - // its connector metadata — never populated under a dry run. - const unchangedEntries = dryRun ? [] : unchanged.map((name) => entryFor(name, selected.find((c) => c.name === name)!.value)); - - const inScope = (name: string): boolean => !restrict || restrict.includes(name); - const skipped: readonly ImportSkip[] = [ - ...[...source.skipped, ...values.value.empty].filter((s) => inScope(s.name)).map(toImportSkip), - ...conflictSkipped, - ]; - - const importedNames = [...imported.map((i) => i.varName), ...unchanged]; - const deployTargetSaved = - !dryRun && - (await maybeOfferDeployTarget({ - importedNames, - project: source.project, - teamId: source.teamId, - environment: source.environment, - allTargets: await listTargetsAt(opts.projectDir ?? cwd()), - promptable, - dir: opts.projectDir ?? cwd(), - confirm, - })); - - return { - ok: true, - source: importSource, - imported, - unchanged, - unchangedEntries, - skipped, - warnings: truncationWarnings(source), - deployTargetSaved, - ...(dryRun ? { wouldAsk } : {}), - }; + const outcome = await importFrom(ctx, opts, opened.client, interactive); + return withTokenHelp(withTokenCreated(outcome, opened.tokenCreated)); }, /** @@ -608,123 +890,26 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector * `vercelDiscovery.ts`. */ async discover(ctx: DiscoveryContext, opts: ConnectOpts): Promise { + const refused = createTokenDryRunRefusal(opts); + if (refused) return refused; const interactive = isInteractive(opts.nonTty); - const dryRun = !!opts.dryRun; - const promptable = interactive && !opts.json; const opened = await openClient(ctx.orgId, opts, interactive); if (!opened.ok) return opened; - const { client, tokenEnv } = opened; - - const team = await chooseTeam(client, opts, promptable); - if (!team.ok) return team; - - const scan = findCandidateReposResult(cwd()); - const environmentFilter = parseVarList(opts.environment) ?? undefined; - const built = await guard(() => - buildVercelDiscoveryPlan( - client, - team.teamId, - scan.repos as readonly CandidateRepo[], - { peekLocalKeep: peekLocalKeepForFolder, listServerBranches: (projectId) => ctx.serviceClient.listBranches(projectId) }, - environmentFilter, - ), - ); - if (!built.ok) return built; - const plan: DiscoveryPlan = { - ...built.value, - unreadableRepos: scan.unreadable, - ...(team.teamId ? { team: { id: team.teamId, ...(team.teamSlug ? { slug: team.teamSlug } : {}) } } : {}), - }; - - if (environmentFilter) { - const unknown = environmentFilter.filter((name) => !plan.allEnvironmentNames.includes(name)); - if (unknown.length > 0) { - return { - ok: false, - code: ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: `No such environment(s): ${unknown.join(', ')}. Environments found: ${plan.allEnvironmentNames.join(', ') || '(none)'}.`, - }; - } - } - if (dryRun) { - // A dry run never asks for a branch name either; the DEFAULT is what it previews. - const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), VERCEL_COMMIT_MESSAGE); - return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}) }; - } - - // `--project` under `--discover` is the collision-winner hint (an id or a name), not an import source. - const preferredServiceId = preferredProjectId(plan, opts.project); - const resolution = await resolveCollisions(orderEnvironments(plan.collisions), { - preferredServiceId, - interactive: promptable, - askWinner: pickCollisionWinner, - }); - if (!resolution.ok) { - return { - ok: false, - code: ERROR_CODES.VERCEL_MAPPING_COLLISION, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: 'Several Vercel projects map to the same folder + branch. Pass --project to pick one, or run this interactively.', - }; - } - - const resolvedFolders = applyCollisionResolutions(plan.folders, resolution.resolutions); - if (resolvedFolders.length === 0) return { ok: true, dryRun: false, plan, applied: [] }; - - // A real run never writes without a yes: `--yes` skips the ask regardless of TTY. - if (promptable && !opts.yes) { - const proceed = await confirm(discoveryConfirmMessage(resolvedFolders, !!opts.overwrite, environmentFilter), false); - if (!proceed) return { ok: true, dryRun: false, plan, applied: [], cancelled: true }; - } else if (!promptable && !opts.yes) { - // COPY-FLAG: new user-facing string, minimal/neutral wording. - return { ok: false, code: ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED, message: 'Pass --yes to apply non-interactively, or run this interactively.' }; - } + const outcome = await discoverFrom(ctx, opts, opened.client, opened.tokenEnv, interactive); + return withTokenHelp(withTokenCreated(outcome, opened.tokenCreated)); + }, - const commitBranchName = - promptable && !opts.yes - ? await askDiscoveryCommitBranchName(defaultDiscoveryCommitBranchName(new Date(), 'vercel')) - : defaultDiscoveryCommitBranchName(new Date(), 'vercel'); - - // Snapshotted BEFORE any write, per repo, so the commit step can tell - // "discovery made this change" from "already dirty before the run". - const beforeStatusByRepo = new Map( - [...new Set(resolvedFolders.map((f) => f.repoDir))].map((repoDir) => { - const paths = resolvedFolders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)); - return [repoDir, snapshotPathStatus(repoDir, paths)] as const; - }), - ); - - // Same snapshot-once rule as Dokploy's non-interactive `--yes` auto-create. - const existingProjectsSnapshot: ExistingProjectsSnapshot | undefined = - !promptable && !!opts.yes ? await listOrgProjectsOrUnavailable(ctx.serviceClient) : undefined; - const sameRunNameCollisions: ReadonlySet = !promptable && !!opts.yes ? computeSameRunNameCollisions(resolvedFolders) : new Set(); - - const sequenceDeps = buildRealDiscoverySequenceDeps(ctx, connector.import!, { - noPush: !!opts.noPush, - interactive: promptable, - json: !!opts.json, - yes: !!opts.yes, - baseUrl: '', - tokenEnv, - importOptsFor: (env, projectName, folderPath) => ({ - project: env.serviceId, - vercelProject: { id: env.serviceId, name: projectName, ...(env.gitBranch ? { productionBranch: env.gitBranch } : {}) }, - vercelTeam: { id: team.teamId }, - environment: env.environmentName, - tokenEnv, - projectDir: folderPath, - }), - askExistingOrNewProject, - askProjectName: askDiscoveryProjectName, - existingProjectsSnapshot, - sameRunNameCollisions, - }); - const applied = await runDiscoverySequences(resolvedFolders, { overwrite: !!opts.overwrite }, sequenceDeps); - const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, VERCEL_COMMIT_MESSAGE); - return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}) }; + /** `--create-token` on its own: make and save the token, report it, import nothing. */ + async createToken(opts: ConnectOpts, orgIdOf: () => Promise): Promise { + const refused = createTokenDryRunRefusal(opts); + if (refused) return refused; + const expiry = parseExpiryDays(opts.tokenExpiresDays); + if (!expiry.ok) return expiry; + const promptable = isInteractive(opts.nonTty) && !opts.json; + const minted = await mintForRun(await orgIdOf(), opts, promptable); + return minted.ok ? { ok: true, tokenCreated: minted.report } : minted; }, reportDiscovery: reportVercelDiscovery, @@ -734,6 +919,18 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector // ── small helpers ─────────────────────────────────────────────────────────── +/** Adds a created-token report to an outcome (success or refusal) when the run made one. */ +function withTokenCreated(outcome: T, report: TokenCreatedReport | undefined): T { + return report ? { ...outcome, tokenCreated: report } : outcome; +} + +/** A token Vercel rejected: say where to get another. (A missing token already carries it, from `openClient`.) */ +function withTokenHelp(outcome: T): T { + if (outcome.ok || outcome.link || outcome.code !== ERROR_CODES.VERCEL_AUTH_FAILED) return outcome; + return { ...outcome, ...keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME)) } as T; +} + + async function listTargetsAt(dir: string): Promise { const { listTargets } = await import('../../deploy/config'); return listTargets(dir); diff --git a/src/commands/connectors/vercelDiscovery.ts b/src/commands/connectors/vercelDiscovery.ts index 84da1d95..0a0604fb 100644 --- a/src/commands/connectors/vercelDiscovery.ts +++ b/src/commands/connectors/vercelDiscovery.ts @@ -49,6 +49,7 @@ import { orderEnvironments, } from './dokployDiscovery'; import type { ConnectOpts } from './registry'; +import { describeTokenCreated } from '../../deploy/vercelCliToken'; /** `listProjects`/`listCustomEnvironments`/`listEnvs` only — the read-only surface planning needs. */ export type VercelDiscoveryClient = Pick; @@ -293,11 +294,13 @@ export function reportVercelDiscovery( plan, ...(applied ? { applied } : {}), ...(commits ? { commits } : {}), + ...(outcome.tokenCreated ? { tokenCreated: outcome.tokenCreated } : {}), }), ); return { linked }; } + if (outcome.tokenCreated) describeTokenCreated(outcome.tokenCreated).forEach((l) => console.log(l)); const lines = humanLines(outcome, plan, applied, commits); lines.forEach((l) => console.log(l)); return { linked }; diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index dc2ecf6d..70231fc4 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -13,6 +13,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join, basename } from 'path'; import inquirer from 'inquirer'; +import { formatKeyHelp } from '../deploy/keyHelp'; import { FileManager } from '../files/fileManager'; import { DeployAdapter, @@ -225,6 +226,7 @@ async function resolveDokployApiKeyOnce( interactive, orgId, devMode, + helpBaseUrl: (target.options as { baseUrl?: string }).baseUrl, storeName: DOKPLOY_TARGET_SECRET_NAME, deps: { getConnectorSecret }, }); @@ -1971,6 +1973,11 @@ function renderPlan(target: TargetConfig, adapter: DeployAdapter): void { console.log(''); } +/** A key refusal says where to get a key and what to do (stderr). Prints nothing for any other refusal. */ +function printKeyHelp(r: { link?: { label: string; url: string }; remedy?: string }): void { + if (r.link && r.remedy) console.error('\n' + formatKeyHelp({ link: r.link, remedy: r.remedy }) + '\n'); +} + function renderResult(result: DeployResult): void { console.log(''); for (const step of result.steps) { @@ -1979,6 +1986,7 @@ function renderResult(result: DeployResult): void { const detail = step.detail ? ` ${DIM(step.detail)}` : ''; const url = step.url ? ` ${step.url}` : ''; console.log(` ${mark} ${step.label}${detail}${url}`); + if (step.status === 'fail') printKeyHelp(step); } // `result.warnings` (e.g. Dokploy's DOKPLOY_SHADOWED_VAR) is printed once, // right after preflight (see the `preflight.warnings` loop above this @@ -3338,6 +3346,7 @@ export async function deployCommand( if (result.ok) return { ok: true, target: t, preflight: result }; console.error(`${RED('✗')} preflight: ${result.reason}`); if (result.hint) console.error('\n' + result.hint); + printKeyHelp(result); if (!canFixInteractively) return { ok: false }; // COPY-FLAG: minimal neutral wording, pending Vince's approval. const action = await keypressConfirm({ message: 'Preflight failed. Press e to edit this target, c to check again, esc to cancel.' }); @@ -3410,6 +3419,7 @@ export async function deployCommand( if (!recheck.ok) { console.error(`${RED('✗')} preflight: ${recheck.reason}`); if (recheck.hint) console.error('\n' + recheck.hint); + printKeyHelp(recheck); return 1; } // Loop back to confirm prompt with the edited target. diff --git a/src/commands/systemCommand.ts b/src/commands/systemCommand.ts index ed48e9e9..6a342634 100644 --- a/src/commands/systemCommand.ts +++ b/src/commands/systemCommand.ts @@ -16,6 +16,7 @@ import inquirer from 'inquirer'; import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { openSystemStore, assertValidConnectorName } from '../system/systemStore'; import { CapyError, ERROR_CODES } from '../types/index'; +import { formatKeyHelp, keyHelpForName } from '../deploy/keyHelp'; export interface SystemCommandOpts { org?: string; @@ -117,6 +118,10 @@ export async function systemSetCommand(name: string, opts: SystemCommandOpts): P requireValidName(name, json); requireTty(json, `\`capy system set\` needs a terminal to prompt for the value — there is no --value flag.`); // COPY-FLAG + // For the four known key names: where to get the key. Stderr, so `--json` stays pure. + const help = keyHelpForName(name); + if (help) console.error(formatKeyHelp(help)); + const value = await promptForHiddenValue(name, json); try { diff --git a/src/deploy/adapter.ts b/src/deploy/adapter.ts index d856f3df..e468f9ff 100644 --- a/src/deploy/adapter.ts +++ b/src/deploy/adapter.ts @@ -115,6 +115,10 @@ export interface PreflightResult { * set both. */ code?: ErrorCode; + /** Where to get a missing or rejected API key (same shape as `Blocked.link`). Additive; set only by key refusals. */ + link?: { label: string; url: string }; + /** One plain instruction for the human that goes with `link`. */ + remedy?: string; /** Non-blocking heads-up(s), shown whether or not ok is true. */ warnings?: readonly DeployWarning[]; } @@ -127,6 +131,10 @@ export interface DeployStep { url?: string; /** Machine-readable companion to `detail` on a `'fail'` step — see `PreflightResult.code`'s own doc. */ code?: ErrorCode; + /** Where to get a missing or rejected API key — see `PreflightResult.link`. */ + link?: { label: string; url: string }; + /** See `PreflightResult.remedy`. */ + remedy?: string; } export interface DeployResult { diff --git a/src/deploy/adapters/dokploy.ts b/src/deploy/adapters/dokploy.ts index 4134e0e2..cd10ea56 100644 --- a/src/deploy/adapters/dokploy.ts +++ b/src/deploy/adapters/dokploy.ts @@ -36,6 +36,7 @@ * the NAME of the environment variable that holds it (default * DOKPLOY_API_KEY), read at deploy time. */ +import { dokployKeyHelp, keyHelpFields } from '../keyHelp'; import { AdapterCallContext, DeployAdapter, @@ -581,7 +582,7 @@ export function explainApiError( err: unknown, what: string, opts: DokployOptions, -): { reason: string; hint?: string } { +): { reason: string; hint?: string; link?: { label: string; url: string }; remedy?: string } { if (!(err instanceof DokployApiError)) { return { reason: `${what}: ${err instanceof Error ? err.message : String(err)}` }; } @@ -590,6 +591,7 @@ export function explainApiError( return { reason: `${what}: Dokploy rejected the API token in $${opts.tokenEnv} (HTTP ${err.status})`, hint: `Create an API token in the Dokploy dashboard and export it as ${opts.tokenEnv}.`, + ...keyHelpFields(dokployKeyHelp(DOKPLOY_TARGET_SECRET_NAME, opts.baseUrl)), }; case 'not_found': return opts.composeId @@ -1081,6 +1083,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt interactive: ctx.interactive ?? false, orgId: ctx.orgId, devMode: ctx.devMode, + helpBaseUrl: opts.baseUrl, storeName: DOKPLOY_TARGET_SECRET_NAME, deps: deps.getConnectorSecret ? { getConnectorSecret: deps.getConnectorSecret } : undefined, }); @@ -1114,7 +1117,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt const resolved = await resolveApiKeyFor(opts, ctx); if (!resolved.ok) { const { reason, hint } = describeDokployTokenProblem(resolved.code, opts.tokenEnv ?? DEFAULT_TOKEN_ENV, DOKPLOY_TARGET_SECRET_NAME); - return { ok: false, reason, hint }; + return { ok: false, reason, hint, ...keyHelpFields(dokployKeyHelp(DOKPLOY_TARGET_SECRET_NAME, opts.baseUrl)) }; } const client = createDokployClient(opts.baseUrl, resolved.value, deps.fetch); if (opts.composeId) { @@ -1178,7 +1181,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt const resolved = await resolveApiKeyFor(opts, ctx); if (!resolved.ok) { const { reason } = describeDokployTokenProblem(resolved.code, opts.tokenEnv ?? DEFAULT_TOKEN_ENV, DOKPLOY_TARGET_SECRET_NAME); - return fail([], { label: 'dokploy auth', status: 'fail', detail: reason }); + return fail([], { label: 'dokploy auth', status: 'fail', detail: reason, ...keyHelpFields(dokployKeyHelp(DOKPLOY_TARGET_SECRET_NAME, opts.baseUrl)) }); } const client = createDokployClient(opts.baseUrl, resolved.value, deps.fetch); diff --git a/src/deploy/adapters/vercel.ts b/src/deploy/adapters/vercel.ts index 920b324d..f36a8536 100644 --- a/src/deploy/adapters/vercel.ts +++ b/src/deploy/adapters/vercel.ts @@ -78,6 +78,7 @@ import { resolveVercelTargetToken, sortedNames, verifyApplied, + vercelTargetKeyHelp, } from '../vercelTargetApi'; type VercelEnv = 'production' | 'preview'; @@ -619,6 +620,11 @@ export interface VercelAdapterDeps { const SET_ENV_LABEL = 'set Vercel env'; const VERIFY_LABEL = 'verify Vercel env'; +/** A rejected token (HTTP 401) says where to get another; any other failure adds nothing. */ +function rejectedKeyHelp(code: string): { link?: { label: string; url: string }; remedy?: string } { + return code === ERROR_CODES.VERCEL_TOKEN_REJECTED ? vercelTargetKeyHelp() : {}; +} + function failResult(step: DeployStep): DeployResult { return { ok: false, steps: [step] }; } @@ -690,7 +696,7 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter const token = await resolveToken(ctx); if (!token.ok) { const { reason, hint } = describeVercelTokenProblem(token.code); - return { ok: false, reason, hint, code: asKnownErrorCode(token.code) }; + return { ok: false, reason, hint, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp() }; } const checked = await checkVercelTarget(clientFor(token.value, parsed.options), parsed.options); if (!checked.ok) { @@ -704,19 +710,19 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter const token = await resolveToken(ctx); if (!token.ok) { const { reason } = describeVercelTokenProblem(token.code); - return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code) }); + return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp() }); } const client = clientFor(token.value, opts); const scope = await resolveEnvScope(client, opts); if (!scope.ok) { - return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason }); + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason, ...rejectedKeyHelp(scope.code) }); } const listed = await client.listEnvs().then( (records) => ({ ok: true as const, records }), (err) => failureOf(err), ); if (!listed.ok) { - return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason }); + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code) }); } const plan = dryRunSummary(config.vars, listed.records, scope.scope); const names = (xs: readonly string[]) => (xs.length > 0 ? ` [${sortedNames(xs).join(', ')}]` : ''); @@ -755,12 +761,12 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter const token = await resolveToken(ctx); if (!token.ok) { const { reason } = describeVercelTokenProblem(token.code); - return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code) }); + return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp() }); } const client = clientFor(token.value, parsed.options); const scope = await resolveEnvScope(client, parsed.options); if (!scope.ok) { - return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason }); + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason, ...rejectedKeyHelp(scope.code) }); } const where = describeScope(scope.scope); const listed = await client.listEnvs().then( @@ -768,7 +774,7 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter (err) => failureOf(err), ); if (!listed.ok) { - return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason }); + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code) }); } const pairs = config.vars.map((name) => [name, ctx.env[name]] as const); diff --git a/src/deploy/dokployApi.ts b/src/deploy/dokployApi.ts index 5a175732..2b964066 100644 --- a/src/deploy/dokployApi.ts +++ b/src/deploy/dokployApi.ts @@ -645,6 +645,8 @@ export interface DokploySystemStoreCallOptions { interactive: boolean; devMode?: boolean; apiUrl?: string; + /** The Dokploy dashboard URL, so the prompt can link to the page that makes the key. Absent = the docs link. */ + helpBaseUrl?: string; } export interface ResolveDokployApiKeyDeps { @@ -700,6 +702,8 @@ export interface ResolveDokployApiKeyOptions { orgId?: string; devMode?: boolean; apiUrl?: string; + /** The Dokploy dashboard URL, passed to the store so the key prompt can link to the page that makes the key. */ + helpBaseUrl?: string; deps?: ResolveDokployApiKeyDeps; /** * Which system store entry to ask for (CAP-679 follow-up: deploy and @@ -753,6 +757,7 @@ export async function resolveDokployApiKey( interactive: opts.interactive, devMode: opts.devMode, apiUrl: opts.apiUrl, + ...(opts.helpBaseUrl ? { helpBaseUrl: opts.helpBaseUrl } : {}), }, opts.storeName ?? DOKPLOY_CONNECTOR_SECRET_NAME, ); diff --git a/src/deploy/keyHelp.ts b/src/deploy/keyHelp.ts new file mode 100644 index 00000000..0b2e0f71 --- /dev/null +++ b/src/deploy/keyHelp.ts @@ -0,0 +1,77 @@ +/** + * Where to get an API key (Vince's rule: every time Capy asks for, or refuses + * for lack of, a Vercel token or a Dokploy API key, it says where to get one). + * + * One shape for every surface — the hidden prompt, human refusals on stderr, + * and the `--json` refusal objects — so an agent can relay `link` + `remedy` + * to the human. `link` and `remedy` are the same field names as + * `Blocked` in `ui/screens/contract.ts`. + * + * Pages (checked 2026-10-01): + * - Vercel: the dashboard's token page, `https://vercel.com/account/settings/tokens`. + * Vercel's own CLI points at `https://vercel.com/account/tokens`, which + * redirects (308) to this path. + * - Dokploy: inside the user's OWN Dokploy, Settings → Profile → API/CLI + * section (docs: https://docs.dokploy.com/docs/api, "go to the + * /settings/profile page and go to API/CLI Section and generate the + * token"). In the dashboard that page is `/dashboard/settings/profile`. + * With no known base URL the docs page is the link. + */ + +export interface KeyHelp { + link: { label: string; url: string }; + /** One plain instruction for the human. */ + remedy: string; +} + +export const VERCEL_TOKEN_PAGE_URL = 'https://vercel.com/account/settings/tokens'; +export const DOKPLOY_API_DOCS_URL = 'https://docs.dokploy.com/docs/api'; + +const VERCEL_SECRET_NAMES: readonly string[] = ['_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN']; +const DOKPLOY_SECRET_NAMES: readonly string[] = ['_CONNECTOR_DOKPLOY_API_KEY', '_TARGET_DOKPLOY_API_KEY']; + +/** Where to get the Vercel token `secretName` holds. */ +export function vercelKeyHelp(secretName: string): KeyHelp { + return { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + link: { label: 'Vercel token page', url: VERCEL_TOKEN_PAGE_URL }, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + remedy: `Create a token at the link and save it with \`capy system set ${secretName}\`, or run \`capy connect vercel --create-token\`.`, + }; +} + +/** The Dokploy page that makes API keys: inside the user's own Dokploy when its address is known, else the docs. */ +export function dokployKeyPage(baseUrl: string | undefined): { label: string; url: string } { + const base = baseUrl?.trim().replace(/\/+$/, ''); + return base + ? // COPY-FLAG: new user-facing string, minimal/neutral wording. + { label: 'Dokploy API keys', url: `${base}/dashboard/settings/profile` } + : // COPY-FLAG: new user-facing string, minimal/neutral wording. + { label: 'Dokploy API docs', url: DOKPLOY_API_DOCS_URL }; +} + +/** Where to get the Dokploy API key `secretName` holds. */ +export function dokployKeyHelp(secretName: string, baseUrl?: string): KeyHelp { + return { + link: dokployKeyPage(baseUrl), + // COPY-FLAG: new user-facing string, minimal/neutral wording. + remedy: `Open the link, find the API/CLI section, generate a key, and save it with \`capy system set ${secretName}\`.`, + }; +} + +/** Help for one of the four known key names; `null` for any other name. */ +export function keyHelpForName(name: string, baseUrl?: string): KeyHelp | null { + if (VERCEL_SECRET_NAMES.includes(name)) return vercelKeyHelp(name); + if (DOKPLOY_SECRET_NAMES.includes(name)) return dokployKeyHelp(name, baseUrl); + return null; +} + +/** The two JSON/object fields, to spread into a refusal. */ +export function keyHelpFields(help: KeyHelp): { link: { label: string; url: string }; remedy: string } { + return { link: help.link, remedy: help.remedy }; +} + +/** Human text for stderr: the link, then the one instruction. */ +export function formatKeyHelp(help: KeyHelp): string { + return ` ${help.link.label}: ${help.link.url}\n ${help.remedy}`; +} diff --git a/src/deploy/vercelApi.ts b/src/deploy/vercelApi.ts index d3c8fb05..caaf69cd 100644 --- a/src/deploy/vercelApi.ts +++ b/src/deploy/vercelApi.ts @@ -445,6 +445,8 @@ export interface VercelSystemStoreCallOptions { interactive: boolean; devMode?: boolean; apiUrl?: string; + /** `system/systemStore.ts#OnMissingSecret`: runs when the token is missing and a human can be asked, before the hidden prompt. */ + onMissing?: (save: (value: string) => Promise) => Promise; } export interface ResolveVercelTokenOptions { @@ -456,6 +458,8 @@ export interface ResolveVercelTokenOptions { orgId?: string; devMode?: boolean; apiUrl?: string; + /** See `VercelSystemStoreCallOptions.onMissing`. */ + onMissing?: VercelSystemStoreCallOptions['onMissing']; /** * Reads (and, when missing + interactive + admin, prompts for and saves) * the org system store entry. Defaults to a function that never touches the @@ -509,6 +513,7 @@ export async function resolveVercelToken(opts: ResolveVercelTokenOptions): Promi interactive: opts.interactive, devMode: opts.devMode, apiUrl: opts.apiUrl, + ...(opts.onMissing ? { onMissing: opts.onMissing } : {}), }); if (storeOutcome.kind === 'value') return { ok: true, value: storeOutcome.value, source: 'system' }; diff --git a/src/deploy/vercelCliToken.ts b/src/deploy/vercelCliToken.ts new file mode 100644 index 00000000..253c6603 --- /dev/null +++ b/src/deploy/vercelCliToken.ts @@ -0,0 +1,464 @@ +/** + * Create the Vercel token for `capy connect vercel` from the Vercel CLI's own + * sign-in, so nobody has to make (or paste) one by hand. + * + * Vercel has no organization-owned key: every token belongs to a user. What it + * does have is a team-scoped token — `POST /v3/user/tokens?teamId=` makes + * one that sees ONE team only. This file asks the user's installed `vercel` + * program to make that call with the user's sign-in (the CLI handles its own + * sign-in refresh), checks the answer really is scoped to exactly the chosen + * team, and hands the value to a `save` function. The value: + * + * - never appears on a command line (it is only ever READ from a child's + * stdout, captured in memory), + * - is never printed, logged, or put in an error message or a report, + * - is not kept anywhere here: it goes to `save`, and to the caller that + * needs it for its next request, nowhere else. + * + * Commands run (every one is argv, never a shell string; stderr is dropped; + * `VERCEL_TOKEN` is removed from the child's environment so the sign-in is the + * only identity; `CI=1` keeps a logged-out CLI from opening a browser): + * + * vercel whoami exit status 0 = signed in + * vercel api /v2/teams?limit=100 JSON on stdout: { teams: [{ id, slug }] } + * vercel api /v3/user/tokens -X POST \ + * -f name= [-F expiresAt=] \ + * --scope JSON on stdout: { bearerToken, token: {…} } + * vercel tokens rm removes a token + * vercel logout signs the CLI out + * + * Facts read from the installed Vercel CLI (53.1.0) source: + * - `--scope ` sets the CLI's current team; `vercel api` then adds + * `teamId=` to the request (so it also reaches POST /v3/user/tokens). + * - `-f key=value` is a string field; `-F` parses numbers, so `expiresAt` + * goes out as a JSON number. A request with fields and no `-X` would be a + * POST, so a GET with a query string puts the query in the path. + * - A failed request exits 1 with its message on stderr; success exits 0 + * with the JSON on stdout. Nothing here reads that message. + * - `vercel tokens add` sends no team (`useCurrentTeam: false`), so it can + * only make a user-wide token; it is NOT used. + * - Not signed in: a CLI started by an AI agent (or on a terminal) begins its + * login flow and waits, so every call here has a time limit. + * - The Vercel CLI itself says a sign-in made by `vercel login` is an OAuth + * session that Vercel may refuse to create tokens with (HTTP 403). The + * refusal reaches this file only as a non-zero exit, so that case is + * reported as `VERCEL_TOKEN_CREATE_FAILED` with the token page as the way + * out. + */ +import { execFile } from 'child_process'; +import { accessSync, constants } from 'fs'; +import { delimiter, join } from 'path'; +import { ERROR_CODES } from '../types/index'; +import { VERCEL_CONNECTOR_SECRET_NAME, VercelTeam, resolveTeam } from './vercelApi'; +import { VERCEL_TOKEN_PAGE_URL } from './keyHelp'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface VercelCliRunResult { + /** The child's exit status; `null` when it never ran, was killed, or timed out. */ + status: number | null; + stdout: string; +} + +export type VercelCliRunner = (args: readonly string[], timeoutMs: number) => Promise; + +export interface VercelCliDeps { + /** Source of `PATH` for finding the binary, and the child's environment. */ + env: Record; + /** Defaults to really running `vercel` with `env`. Injected by tests. */ + run?: VercelCliRunner; + /** Whether the `vercel` binary can be found. Defaults to a `PATH` search of `env`. */ + hasBinary?: () => boolean; + now?: () => number; +} + +/** A refusal. Branch on `code`; `message` is for people. */ +export interface TokenFailure { + ok: false; + code: string; + message: string; + link?: { label: string; url: string }; + remedy?: string; + /** Set when a created token could not be removed: the id (never the value) a human can remove. */ + tokenId?: string; +} + +/** What a created token looks like in a report. Never the value. */ +export interface TokenCreatedReport { + name: string; + id: string; + team: { id: string; slug: string }; + /** ISO time the token expires; absent for no expiry. */ + expiresAt?: string; + /** Which system store entry holds it. */ + secretName: string; + /** True when that entry already held a value and was replaced. */ + replacedExisting: boolean; + /** What happened to the Vercel CLI's sign-in. */ + cliSignOut: 'done' | 'declined' | 'failed' | 'not_offered'; + /** The command that signs the CLI out, when it is still signed in. */ + next?: string; +} + +/** What `prepare` hands back once it has checked the save target is usable. */ +export interface MintTarget { + ok: true; + /** Saves the value (to the system store). Throws a coded error on failure. */ + save: (bearer: string) => Promise; + /** True when that store entry already held a value (for the report). */ + replacedExisting: boolean; +} + +export interface MintRequest { + /** `--team`: slug or id. */ + team?: string; + /** `--token-expires-days`, already validated. */ + expiresDays?: number; + /** Asks which team when several and this run can ask; `null` when it cannot. */ + pickTeam: ((teams: readonly VercelTeam[]) => Promise) | null; + /** + * Runs after the CLI and team are settled and BEFORE anything is created: + * opens the system store, which is where a non-admin is refused — so a + * token is never made for a caller who could not save it. + */ + prepare: () => Promise; + /** Asks "sign the Vercel CLI out now?" (default yes); `null` when this run cannot ask — it then never signs out. */ + askSignOut: ((message: string, defaultValue: boolean) => Promise) | null; + /** The store entry the value is saved to. Defaults to `_CONNECTOR_VERCEL_TOKEN`. */ + secretName?: string; +} + +export type MintOutcome = + | { ok: true; report: TokenCreatedReport; bearer: string } + | TokenFailure; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const VERCEL_CLI_INSTALL_URL = 'https://vercel.com/docs/cli'; +const WHOAMI_TIMEOUT_MS = 20_000; +const API_TIMEOUT_MS = 60_000; +const DAY_MS = 86_400_000; +const MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +/** A token id goes on a command line: refuse anything that could be read as a flag or a second argument. */ +const SAFE_TOKEN_ID = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; + +// ── Running the Vercel CLI ─────────────────────────────────────────────────── + +/** The environment the child sees: the caller's, minus `VERCEL_TOKEN`, with `CI=1`. */ +function childEnv(env: Record): NodeJS.ProcessEnv { + const { VERCEL_TOKEN: _dropped, ...rest } = env; + return { ...rest, CI: '1' }; +} + +function realRunner(env: Record): VercelCliRunner { + return (args, timeoutMs) => + new Promise((resolve) => { + const child = execFile( + 'vercel', + [...args], + { env: childEnv(env), timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: MAX_OUTPUT_BYTES, encoding: 'utf8' }, + (err, stdout) => { + const code = (err as { code?: unknown } | null)?.code; + const status = err === null ? 0 : typeof code === 'number' ? code : null; + resolve({ status, stdout: typeof stdout === 'string' ? stdout : '' }); + }, + ); + child.stdin?.end(); + }); +} + +/** Whether an executable called `vercel` is on `env.PATH`. */ +export function vercelBinaryOnPath(env: Record): boolean { + const names = process.platform === 'win32' ? ['vercel.cmd', 'vercel.exe', 'vercel'] : ['vercel']; + const dirs = (env.PATH ?? '').split(delimiter).filter((d) => d !== ''); + return dirs.some((dir) => + names.some((name) => { + try { + accessSync(join(dir, name), constants.X_OK); + return true; + } catch { + return false; + } + }), + ); +} + +export function hasVercelCli(deps: VercelCliDeps): boolean { + return (deps.hasBinary ?? (() => vercelBinaryOnPath(deps.env)))(); +} + +const runner = (deps: VercelCliDeps): VercelCliRunner => deps.run ?? realRunner(deps.env); + +function parseJsonObject(text: string): Record | null { + try { + const v: unknown = JSON.parse(text); + return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; + } catch { + return null; + } +} + +// ── Checks ─────────────────────────────────────────────────────────────────── + +/** The binary is there and the CLI is signed in (its own `whoami` exits 0), else a coded refusal. */ +export async function checkVercelCli(deps: VercelCliDeps): Promise<{ ok: true } | TokenFailure> { + if (!hasVercelCli(deps)) { + return { + ok: false, + code: ERROR_CODES.VERCEL_CLI_MISSING, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: 'The Vercel CLI (`vercel`) is not installed.', + // COPY-FLAG: new user-facing string, minimal/neutral wording. + link: { label: 'Install the Vercel CLI', url: VERCEL_CLI_INSTALL_URL }, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + remedy: 'Install the Vercel CLI, run `vercel login`, then run this again.', + }; + } + const who = await runner(deps)(['whoami'], WHOAMI_TIMEOUT_MS); + if (who.status !== 0) { + return { + ok: false, + code: ERROR_CODES.VERCEL_CLI_NOT_SIGNED_IN, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: 'The Vercel CLI is not signed in.', + // COPY-FLAG: new user-facing string, minimal/neutral wording. + remedy: 'Run `vercel login`, then run this again.', + }; + } + return { ok: true }; +} + +/** The teams the CLI's sign-in sees. */ +export async function listCliTeams(deps: VercelCliDeps): Promise<{ ok: true; teams: readonly VercelTeam[] } | TokenFailure> { + const res = await runner(deps)(['api', '/v2/teams?limit=100'], API_TIMEOUT_MS); + const body = res.status === 0 ? parseJsonObject(res.stdout) : null; + if (!body || !Array.isArray(body.teams)) { + return { + ok: false, + code: ERROR_CODES.VERCEL_API_ERROR, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: 'Could not list the Vercel teams through the Vercel CLI.', + }; + } + const teams = body.teams.flatMap((t): readonly VercelTeam[] => { + const rec = t !== null && typeof t === 'object' ? (t as Record) : {}; + return typeof rec.id === 'string' && rec.id !== '' && typeof rec.slug === 'string' && rec.slug !== '' + ? [{ id: rec.id, slug: rec.slug, ...(typeof rec.name === 'string' ? { name: rec.name } : {}) }] + : []; + }); + return { ok: true, teams }; +} + +/** A team token can only belong to a team: pick one (flag, the only one, a pick) or refuse. */ +export async function chooseTokenTeam( + teams: readonly VercelTeam[], + requested: string | undefined, + pick: ((teams: readonly VercelTeam[]) => Promise) | null, +): Promise<{ ok: true; team: { id: string; slug: string } } | TokenFailure> { + if (teams.length === 0) { + return { + ok: false, + code: ERROR_CODES.VERCEL_TEAM_REQUIRED, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: 'The Vercel CLI sign-in sees no team. A team token needs a team.', + }; + } + const resolved = await resolveTeam(teams, requested, pick); + if (!resolved.ok) return resolved; + const team = teams.find((t) => t.id === resolved.teamId); + return team ? { ok: true, team: { id: team.id, slug: team.slug } } : { ok: false, code: ERROR_CODES.VERCEL_TEAM_NOT_FOUND, message: 'No such Vercel team.' }; +} + +// ── Create / delete ────────────────────────────────────────────────────────── + +/** `--token-expires-days`: a whole number of days, 1 or more. */ +export function parseExpiryDays(raw: string | number | undefined): { ok: true; days: number | undefined } | TokenFailure { + if (raw === undefined) return { ok: true, days: undefined }; + const text = String(raw).trim(); + const days = /^\d+$/.test(text) ? Number(text) : NaN; + if (!Number.isSafeInteger(days) || days < 1 || days > 3650) { + return { + ok: false, + code: ERROR_CODES.VERCEL_TOKEN_EXPIRY_INVALID, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: '--token-expires-days must be a whole number of days, 1 to 3650.', + }; + } + return { ok: true, days }; +} + +interface CreatedToken { + bearer: string; + id: string; + name: string; + scopes: readonly unknown[]; + expiresAtMs?: number; +} + +function parseCreated(stdout: string): CreatedToken | null { + const body = parseJsonObject(stdout); + const token = body?.token !== null && typeof body?.token === 'object' ? (body?.token as Record) : null; + if (!body || !token) return null; + const bearer = body.bearerToken; + const id = token.id; + if (typeof bearer !== 'string' || bearer === '' || typeof id !== 'string' || id === '') return null; + return { + bearer, + id, + name: typeof token.name === 'string' ? token.name : '', + scopes: Array.isArray(token.scopes) ? token.scopes : [], + ...(typeof token.expiresAt === 'number' ? { expiresAtMs: token.expiresAt } : {}), + }; +} + +/** Exactly one scope, and it is the chosen team. A `user` scope, another team, or extra scopes all fail. */ +export function scopesAreExactlyTeam(scopes: readonly unknown[], teamId: string): boolean { + if (scopes.length !== 1) return false; + const only = scopes[0]; + const rec = only !== null && typeof only === 'object' ? (only as Record) : {}; + return rec.type === 'team' && rec.teamId === teamId; +} + +async function deleteCliToken(deps: VercelCliDeps, id: string): Promise { + if (!SAFE_TOKEN_ID.test(id)) return false; + const res = await runner(deps)(['tokens', 'rm', id], API_TIMEOUT_MS); + return res.status === 0; +} + +/** Removes a token that must not be kept; if that fails too, refuses with the id so a human can remove it. */ +async function removeOrReport(deps: VercelCliDeps, id: string, because: TokenFailure): Promise { + if (await deleteCliToken(deps, id)) return because; + return { + ok: false, + code: ERROR_CODES.VERCEL_TOKEN_CLEANUP_FAILED, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `A Vercel token (id ${id}) was created and could not be removed. Remove it at the Vercel token page.`, + link: { label: 'Vercel token page', url: VERCEL_TOKEN_PAGE_URL }, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + remedy: `Remove the token with id ${id} on the Vercel token page, or run \`vercel tokens rm ${id}\`.`, + tokenId: id, + }; +} + +function storeCode(err: unknown): string { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === 'string' && code !== '' ? code : ERROR_CODES.SERVICE_ERROR; +} + +async function trySave(save: (bearer: string) => Promise, bearer: string): Promise<{ ok: true } | { ok: false; code: string }> { + try { + await save(bearer); + return { ok: true }; + } catch (err) { + return { ok: false, code: storeCode(err) }; + } +} + +async function signOutStep( + deps: VercelCliDeps, + ask: MintRequest['askSignOut'], +): Promise<{ cliSignOut: TokenCreatedReport['cliSignOut']; next?: string }> { + const NEXT = 'vercel logout'; + if (!ask) return { cliSignOut: 'not_offered', next: NEXT }; + // COPY-FLAG: new user-facing string, minimal/neutral wording. + const yes = await ask('Sign the Vercel CLI out now?', true); + if (!yes) return { cliSignOut: 'declined', next: NEXT }; + const res = await runner(deps)(['logout'], WHOAMI_TIMEOUT_MS); + return res.status === 0 ? { cliSignOut: 'done' } : { cliSignOut: 'failed', next: NEXT }; +} + +/** + * The whole sequence: CLI present and signed in → team → create the token → + * check its scope → save it → (interactive only) offer to sign the CLI out. + * + * A token that fails the scope check, or cannot be saved, is deleted again + * straight away. + */ +export async function mintTeamToken(deps: VercelCliDeps, req: MintRequest): Promise { + const checked = await checkVercelCli(deps); + if (!checked.ok) return checked; + + const listed = await listCliTeams(deps); + if (!listed.ok) return listed; + const chosen = await chooseTokenTeam(listed.teams, req.team, req.pickTeam); + if (!chosen.ok) return chosen; + const { team } = chosen; + + const target = await req.prepare(); + if (!target.ok) return target; + + const now = (deps.now ?? (() => Date.now()))(); + const expiresAtMs = req.expiresDays === undefined ? undefined : now + req.expiresDays * DAY_MS; + const name = `capy-${team.slug}-import`; // COPY-FLAG: token name shown in Vercel. + const args: readonly string[] = [ + 'api', + '/v3/user/tokens', + '-X', + 'POST', + '-f', + `name=${name}`, + ...(expiresAtMs === undefined ? [] : ['-F', `expiresAt=${expiresAtMs}`]), + '--scope', + team.slug, + ]; + const created = await runner(deps)(args, API_TIMEOUT_MS); + const token = created.status === 0 ? parseCreated(created.stdout) : null; + if (!token) { + return { + ok: false, + code: ERROR_CODES.VERCEL_TOKEN_CREATE_FAILED, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: 'The Vercel CLI could not create a token. Vercel may not allow a `vercel login` sign-in to create tokens.', + link: { label: 'Vercel token page', url: VERCEL_TOKEN_PAGE_URL }, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + remedy: `Create a token at the link and save it with \`capy system set ${req.secretName ?? VERCEL_CONNECTOR_SECRET_NAME}\`.`, + }; + } + + if (!scopesAreExactlyTeam(token.scopes, team.id)) { + return removeOrReport(deps, token.id, { + ok: false, + code: ERROR_CODES.VERCEL_TOKEN_SCOPE_UNEXPECTED, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `The new Vercel token was not scoped to only the team ${team.slug}. It was deleted.`, + }); + } + + const secretName = req.secretName ?? VERCEL_CONNECTOR_SECRET_NAME; + const saved = await trySave(target.save, token.bearer); + if (!saved.ok) { + return removeOrReport(deps, token.id, { + ok: false, + code: saved.code, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `Could not save ${secretName}. The new Vercel token was deleted.`, + }); + } + + const signOut = await signOutStep(deps, req.askSignOut); + const finalExpiry = token.expiresAtMs ?? expiresAtMs; + return { + ok: true, + bearer: token.bearer, + report: { + name: token.name || name, + id: token.id, + team, + ...(finalExpiry === undefined ? {} : { expiresAt: new Date(finalExpiry).toISOString() }), + secretName, + replacedExisting: target.replacedExisting, + ...signOut, + }, + }; +} + +/** Human lines for a created-token report. Never the value. */ +export function describeTokenCreated(report: TokenCreatedReport): readonly string[] { + // COPY-FLAG: new user-facing strings, minimal/neutral wording. + return [ + ` ✓ Created Vercel token ${report.name} (id ${report.id}) for team ${report.team.slug}, saved as ${report.secretName}${report.replacedExisting ? ' (replaced the previous value)' : ''}.`, + ...(report.expiresAt ? [` Expires ${report.expiresAt}.`] : []), + ...(report.cliSignOut === 'done' ? [' Signed the Vercel CLI out.'] : []), + ...(report.next ? [` You can sign the Vercel CLI out with \`${report.next}\`.`] : []), + ]; +} diff --git a/src/deploy/vercelTargetApi.ts b/src/deploy/vercelTargetApi.ts index be466e6c..8cee49fe 100644 --- a/src/deploy/vercelTargetApi.ts +++ b/src/deploy/vercelTargetApi.ts @@ -41,6 +41,7 @@ */ import { createHash } from 'crypto'; import { ERROR_CODES, ErrorCode } from '../types/index'; +import { keyHelpFields, vercelKeyHelp } from './keyHelp'; // ── Token: system store, same shape as Dokploy's ─────────────────────────── @@ -145,6 +146,11 @@ export async function resolveVercelTargetToken( : { ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }; } +/** Where to get the deploy-side Vercel token: the `link` + `remedy` fields of a key refusal. */ +export function vercelTargetKeyHelp(): { link: { label: string; url: string }; remedy: string } { + return keyHelpFields(vercelKeyHelp(VERCEL_TARGET_SECRET_NAME)); +} + /** A token refusal as a printable reason + hint. Names only, never a value. */ export function describeVercelTokenProblem(code: string): { reason: string; hint: string } { if (code === ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY) { @@ -793,11 +799,11 @@ export function sortedNames(names: readonly string[]): string[] { export function describeVercelFailure( code: ErrorCode, status?: number, -): { reason: string; hint?: string } { +): { reason: string; hint?: string; link?: { label: string; url: string }; remedy?: string } { const http = status !== undefined ? ` (HTTP ${status})` : ''; if (code === ERROR_CODES.VERCEL_TOKEN_REJECTED) { // COPY-FLAG - return { reason: `Vercel rejected the token${http}`, hint: `Replace it: capy system set ${VERCEL_TARGET_SECRET_NAME}` }; + return { reason: `Vercel rejected the token${http}`, hint: `Replace it: capy system set ${VERCEL_TARGET_SECRET_NAME}`, ...vercelTargetKeyHelp() }; } if (code === ERROR_CODES.VERCEL_NO_ACCESS) { // COPY-FLAG diff --git a/src/index.ts b/src/index.ts index 46cecdbb..f7fabecc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -918,6 +918,11 @@ program .option('--token-env ', 'dokploy/vercel import: env var holding the API token') .option('--project ', 'vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision)') .option('--team ', 'vercel import/discover: Vercel team slug or id (default: the token\'s only team)') + .option( + '--create-token', + 'vercel: create the Vercel token from the Vercel CLI sign-in (team-scoped) and save it as _CONNECTOR_VERCEL_TOKEN; with --project/--discover the run then continues, alone it stops after saving. Not with --dry-run', + ) + .option('--token-expires-days ', 'vercel --create-token: days until the created token expires (default: never)') .option('--json', 'emit machine-readable JSON instead of the human UI (import connectors)') .option( '--dry-run', @@ -975,6 +980,8 @@ program environment: options.environment, project: options.project, team: options.team, + createToken: options.createToken === true, + tokenExpiresDays: options.tokenExpiresDays, }); }); diff --git a/src/system/systemStore.ts b/src/system/systemStore.ts index 7fc73d45..321f28f2 100644 --- a/src/system/systemStore.ts +++ b/src/system/systemStore.ts @@ -48,6 +48,7 @@ import { setSyncKeepHash, } from '../types/index'; import { SYSTEM_PROJECT_NAME } from './reservedProjectName'; +import { formatKeyHelp, keyHelpForName } from '../deploy/keyHelp'; export { SYSTEM_PROJECT_NAME } from './reservedProjectName'; @@ -422,12 +423,30 @@ export async function openSystemStore(opts: OpenSystemStoreOptions = {}): Promis }; } +/** + * Runs when a secret is missing and a human can be asked, BEFORE the hidden + * prompt. `save` stores a value through the already-open store (so the + * admin-only rule has already been applied). Returns the value when the hook + * made and saved one itself, or `null` to carry on to the hidden prompt. + */ +export type OnMissingSecret = (save: (value: string) => Promise) => Promise; + export interface GetConnectorSecretOptions { orgId?: string; apiUrl?: string; devMode?: boolean; /** No terminal → never prompts; a missing entry resolves to `null`. */ interactive: boolean; + /** Where the caller's service lives (Dokploy's dashboard URL), so the key help can link to the page that makes the key. */ + helpBaseUrl?: string; + /** See `OnMissingSecret`. */ + onMissing?: OnMissingSecret; +} + +/** Before a hidden key prompt: where to get that key, for the four known key names. Stderr, so it never mixes with machine output. */ +function printKeyHelpBeforePrompt(name: string, helpBaseUrl: string | undefined): void { + const help = keyHelpForName(name, helpBaseUrl); + if (help) console.error(formatKeyHelp(help)); } /** @@ -447,6 +466,10 @@ export async function getConnectorSecret(name: string, opts: GetConnectorSecretO if (existing !== null) return existing; if (!opts.interactive) return null; + const fromHook = await opts.onMissing?.((v) => pushSystemEnv(ctx, { ...ctx.plaintextEnv, [name]: v })); + if (fromHook) return fromHook; + printKeyHelpBeforePrompt(name, opts.helpBaseUrl); + const { value } = await inquirer.prompt([ { type: 'password', @@ -468,6 +491,10 @@ export interface GetDirectionalSecretOptions { devMode?: boolean; /** No terminal → never prompts; refuses instead when `fallbackName` has a value to offer (see below). */ interactive: boolean; + /** Where the caller's service lives (Dokploy's dashboard URL), so the key help can link to the page that makes the key. */ + helpBaseUrl?: string; + /** See `OnMissingSecret`. Runs only on the "make a new value" path, never when reusing the other direction's key. */ + onMissing?: OnMissingSecret; /** * Code to refuse with when `primaryName` is missing, `fallbackName` DOES * have a value, and there is no terminal to ask which one the caller @@ -563,6 +590,10 @@ export async function getDirectionalConnectorSecret( // else "add new" — fall through to the same prompt case 2 uses. } + const fromHook = await opts.onMissing?.((v) => pushSystemEnv(ctx, { ...ctx.plaintextEnv, [primaryName]: v })); + if (fromHook) return fromHook; + printKeyHelpBeforePrompt(primaryName, opts.helpBaseUrl); + const { value } = await inquirer.prompt([ { type: 'password', diff --git a/src/types/index.ts b/src/types/index.ts index d738cd33..580aa90a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -716,6 +716,21 @@ export const ERROR_CODES = { VERCEL_OPTIONS_INVALID: 'VERCEL_OPTIONS_INVALID', /** A shared record had this environment removed, but creating its replacement failed and the removal could not be undone. */ VERCEL_SPLIT_NOT_RESTORED: 'VERCEL_SPLIT_NOT_RESTORED', + // Creating the Vercel token from the Vercel CLI's sign-in (`capy connect vercel --create-token`) + /** The `vercel` program is not installed (or not on PATH). */ + VERCEL_CLI_MISSING: 'VERCEL_CLI_MISSING', + /** The Vercel CLI has no sign-in (its own `whoami` did not succeed). */ + VERCEL_CLI_NOT_SIGNED_IN: 'VERCEL_CLI_NOT_SIGNED_IN', + /** `--create-token` was combined with `--dry-run`; a preview never creates or saves a key. */ + VERCEL_CREATE_TOKEN_DRY_RUN: 'VERCEL_CREATE_TOKEN_DRY_RUN', + /** `--token-expires-days` is not a whole number of days, 1 or more. */ + VERCEL_TOKEN_EXPIRY_INVALID: 'VERCEL_TOKEN_EXPIRY_INVALID', + /** The Vercel CLI could not create the token (or its answer could not be read). */ + VERCEL_TOKEN_CREATE_FAILED: 'VERCEL_TOKEN_CREATE_FAILED', + /** The new token is not scoped to exactly the chosen team; it was deleted again. */ + VERCEL_TOKEN_SCOPE_UNEXPECTED: 'VERCEL_TOKEN_SCOPE_UNEXPECTED', + /** A token was created but could not be deleted again; its id is named so a human can remove it. */ + VERCEL_TOKEN_CLEANUP_FAILED: 'VERCEL_TOKEN_CLEANUP_FAILED', } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/tests/commands/connectVercelCreateToken.test.ts b/tests/commands/connectVercelCreateToken.test.ts new file mode 100644 index 00000000..ef32dcda --- /dev/null +++ b/tests/commands/connectVercelCreateToken.test.ts @@ -0,0 +1,367 @@ +/** + * `capy connect vercel --create-token` through the connector and + * `ConnectCommand`: with a stub `vercel` program on PATH and an in-memory + * stand-in for the org system store. + * + * The property throughout: the token the stub hands back is saved to the store + * under `_CONNECTOR_VERCEL_TOKEN`, is what the following import authenticates + * with, and appears on NO output surface (stdout, stderr, JSON) and in NO + * recorded argv. No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock, spyOn } from 'bun:test'; +import { createVercelConnector, type CreateTokenStore } from '../../src/commands/connectors/vercel'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { ERROR_CODES, type KeepFile } from '../../src/types/index'; +import { providers, type ConnectOpts } from '../../src/commands/connectors/registry'; +import type { DiscoveryContext } from '../../src/commands/connectors/dokployDiscovery'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { callsOf, fakeVercelFetch, project, type FakeVercelData } from '../helpers/fakeVercel'; +import { FAKE_BEARER, createdJson, teamsJson, withStubVercel } from '../helpers/stubVercelCli'; +import { captureConsole, withExitCodeRestored, withIsolatedGlobalDir, withTTY, withTempDir } from '../helpers/testSandbox'; + +const FILES = { 'teams.json': teamsJson([{ id: 'team_acme', slug: 'acme' }]), 'create.json': createdJson() } as const; + +const DATA: FakeVercelData = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: [{ id: 'env_1', key: 'PLAIN_PROD', type: 'plain', target: ['production'], listValue: 'plain-prod-SENT-1a2b' }] }, + custom: { prj_web: [] }, +}; + +function storeStub(existing: readonly string[] = []) { + const set = mock(async (_name: string, _value: string) => {}); + const store: CreateTokenStore = { listNames: () => existing.map((name) => ({ name })), set }; + const openStore = mock(async (_orgId: string, _o: { devMode?: boolean }) => store); + return { set, openStore }; +} + +function build(stubEnv: Record, extra: Partial[0]> = {}, data: FakeVercelData = DATA) { + const store = storeStub(); + const fetchMock = mock(fakeVercelFetch(data)); + const connector = createVercelConnector({ + fetch: fetchMock as unknown as VercelFetchLike, + env: stubEnv, + sleep: async () => {}, + openStore: store.openStore, + ...extra, + }); + return { connector, fetchMock, ...store }; +} + +const ctxWith = (): ResolvedContext => + ({ + keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} } as KeepFile, + branch: 'production', + orgId: 'org_1', + localPlaintext: {}, + }) as unknown as ResolvedContext; + +const discoveryCtx = (): DiscoveryContext => + ({ + orgId: 'org_v', + userId: 'user_v', + authService: {}, + serviceClient: { listBranches: async () => [], listProjects: async () => [] }, + }) as unknown as DiscoveryContext; + +const BASE: ConnectOpts = { nonTty: true, createToken: true }; +const orgId = async () => 'org_1'; + +const noBearer = (surfaces: readonly string[]) => expect(surfaces.join('\n')).not.toContain(FAKE_BEARER); + +describe('--create-token on its own (no --project, no --discover)', () => { + test('creates and saves the token, reports name/id/team, imports nothing, makes no Vercel REST call', async () => { + await withStubVercel(FILES, async (stub) => { + const { connector, set, openStore, fetchMock } = build(stub.env); + const out = await connector.createToken!(BASE, orgId); + expect(out).toMatchObject({ ok: true, tokenCreated: { name: 'capy-acme-import', id: 'tok_new_1', team: { id: 'team_acme', slug: 'acme' }, secretName: '_CONNECTOR_VERCEL_TOKEN' } }); + expect(set.mock.calls).toEqual([['_CONNECTOR_VERCEL_TOKEN', FAKE_BEARER]]); + expect(openStore.mock.calls[0][0]).toBe('org_1'); + expect(fetchMock.mock.calls.length).toBe(0); + expect(stub.rawLog()).not.toContain(FAKE_BEARER); + expect(JSON.stringify(out)).not.toContain(FAKE_BEARER); + }); + }); + + test('--json through ConnectCommand: stdout is ONE JSON object with tokenCreated; the value is nowhere; non-interactive never signs out', async () => { + await withStubVercel(FILES, async (stub) => { + const { connector } = build(stub.env); + const run = await captureConsole(() => new ConnectCommand(false)['executeCreateToken'](connector, 'vercel', { ...BASE, json: true }, orgId)); + expect(run.stdout.length).toBe(1); + expect(JSON.parse(run.stdout[0])).toMatchObject({ + ok: true, + provider: 'vercel', + tokenCreated: { name: 'capy-acme-import', id: 'tok_new_1', team: { slug: 'acme' }, cliSignOut: 'not_offered', next: 'vercel logout' }, + }); + noBearer([...run.stdout, ...run.stderr]); + expect(stub.calls().some((c) => c[0] === 'logout')).toBe(false); + }); + }); + + test('`capy connect vercel --create-token` (the whole command) creates, reports and STOPS: no keep.lock needed, no import, no Vercel REST call', async () => { + await withStubVercel(FILES, async (stub) => { + const { connector, fetchMock } = build(stub.env); + // the real command resolves the org by logging in; the test names it + const wired = { ...connector, createToken: (o: ConnectOpts) => connector.createToken!(o, orgId) }; + const loader = spyOn(providers, 'vercel').mockImplementation(async () => wired); + try { + const run = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false).execute('vercel', { createToken: true, nonTty: true, json: true }))); + expect(run.result).toEqual({ linked: false }); + expect(JSON.parse(run.stdout[0])).toMatchObject({ ok: true, provider: 'vercel', tokenCreated: { id: 'tok_new_1' } }); + expect(fetchMock.mock.calls.length).toBe(0); + } finally { + loader.mockRestore(); + } + }); + }); + + test('human output names the token and the logout next step, never the value', async () => { + await withStubVercel(FILES, async (stub) => { + const { connector } = build(stub.env); + const run = await captureConsole(() => new ConnectCommand(false)['executeCreateToken'](connector, 'vercel', BASE, orgId)); + const text = run.stdout.join('\n'); + expect(text).toContain('capy-acme-import'); + expect(text).toContain('tok_new_1'); + expect(text).toContain('vercel logout'); + noBearer([...run.stdout, ...run.stderr]); + }); + }); + + test('replacing an existing entry is reported', async () => { + await withStubVercel(FILES, async (stub) => { + const store = storeStub(['_CONNECTOR_VERCEL_TOKEN']); + const { connector } = build(stub.env, { openStore: store.openStore }); + const out = await connector.createToken!(BASE, orgId); + expect(out.ok && out.tokenCreated.replacedExisting).toBe(true); + }); + }); + + test('--token-expires-days reaches the CLI as expiresAt; an invalid value is refused before anything runs', async () => { + await withStubVercel(FILES, async (stub) => { + const { connector } = build(stub.env, { cli: { now: () => 5_000 } }); + await connector.createToken!({ ...BASE, tokenExpiresDays: '3' }, orgId); + expect(stub.calls().find((c) => c[1] === '/v3/user/tokens')).toContain(`expiresAt=${5_000 + 3 * 86_400_000}`); + }); + await withStubVercel(FILES, async (stub) => { + const { connector, openStore } = build(stub.env); + const out = await connector.createToken!({ ...BASE, tokenExpiresDays: 'soon' }, orgId); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_EXPIRY_INVALID }); + expect(stub.calls().length).toBe(0); + expect(openStore.mock.calls.length).toBe(0); + }); + }); +}); + +describe('--create-token refusals', () => { + test('--dry-run: VERCEL_CREATE_TOKEN_DRY_RUN from createToken, import and discover; nothing ran, nothing opened, nothing saved', async () => { + await withStubVercel(FILES, async (stub) => { + const { connector, openStore, set, fetchMock } = build(stub.env); + const dry = { ...BASE, dryRun: true }; + const a = await connector.createToken!(dry, orgId); + const b = await connector.import!(ctxWith(), { ...dry, project: 'web' }); + const c = await connector.discover!(discoveryCtx(), dry); + for (const out of [a, b, c]) expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_CREATE_TOKEN_DRY_RUN }); + expect(stub.calls().length).toBe(0); + expect(openStore.mock.calls.length).toBe(0); + expect(set.mock.calls.length).toBe(0); + expect(fetchMock.mock.calls.length).toBe(0); + }); + }); + + test('binary missing: VERCEL_CLI_MISSING (with link + remedy in JSON); the store is not asked to save anything', async () => { + const { connector, set } = build({ PATH: '/nonexistent-dir-for-test' }); + const run = await withExitCodeRestored(() => + captureConsole(() => new ConnectCommand(false)['executeCreateToken'](connector, 'vercel', { ...BASE, json: true }, orgId)), + ); + expect(run.stdout.length).toBe(1); + expect(JSON.parse(run.stdout[0])).toMatchObject({ ok: false, code: 'VERCEL_CLI_MISSING', link: { url: 'https://vercel.com/docs/cli' } }); + expect(JSON.parse(run.stdout[0]).remedy).toBeTruthy(); + expect(set.mock.calls.length).toBe(0); + }); + + test('not signed in: VERCEL_CLI_NOT_SIGNED_IN, human text on stderr only', async () => { + await withStubVercel({ ...FILES, 'whoami.exit': '1' }, async (stub) => { + const { connector } = build(stub.env); + const run = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeCreateToken'](connector, 'vercel', BASE, orgId))); + expect(run.stdout.join('')).toBe(''); + expect(run.stderr.join('\n')).toContain('vercel login'); + }); + }); + + test('a caller who cannot open the store (not an admin) is refused with the store\'s code BEFORE any token is created', async () => { + await withStubVercel(FILES, async (stub) => { + const openStore = mock(async () => { + throw Object.assign(new Error('x'), { code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }); + const { connector } = build(stub.env, { openStore }); + const out = await connector.createToken!(BASE, orgId); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + expect(stub.calls().some((c) => c[1] === '/v3/user/tokens')).toBe(false); + }); + }); + + test('a store save that fails deletes the new token and refuses with the store\'s code', async () => { + await withStubVercel(FILES, async (stub) => { + const store: CreateTokenStore = { + listNames: () => [], + set: async () => { + throw Object.assign(new Error('x'), { code: ERROR_CODES.SERVICE_ERROR }); + }, + }; + const { connector } = build(stub.env, { openStore: async () => store }); + const out = await connector.createToken!(BASE, orgId); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.SERVICE_ERROR }); + expect(stub.calls().filter((c) => c[0] === 'tokens')).toEqual([['tokens', 'rm', 'tok_new_1']]); + }); + }); +}); + +describe('--create-token with --project continues into the import', () => { + test('the import authenticates with the created token; tokenCreated is in the JSON; the value is nowhere', async () => { + await withStubVercel(FILES, async (stub) => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-ct', async (dir) => { + const { connector, fetchMock, set } = build(stub.env); + const ctx = { + ...ctxWith(), + pm: { readSyncState: () => null }, + fileManager: new (await import('../../src/files/fileManager')).FileManager(dir), + serviceClient: {}, + projectId: 'p', + userId: 'u', + projectKey: 'ct-project-key-0123456789', + } as unknown as ResolvedContext; + const run = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector, 'vercel', ctx, { ...BASE, project: 'web', environment: 'production', json: true, noPush: true }), + ); + expect(run.stdout.length).toBe(1); + const parsed = JSON.parse(run.stdout[0]); + expect(parsed).toMatchObject({ ok: true, imported: ['PLAIN_PROD'], tokenCreated: { id: 'tok_new_1', team: { slug: 'acme' } } }); + expect(set.mock.calls).toEqual([['_CONNECTOR_VERCEL_TOKEN', FAKE_BEARER]]); + // the import used the created token, and only ever read + const calls = callsOf(fetchMock); + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((c) => c.authorization === `Bearer ${FAKE_BEARER}` && c.method === 'GET')).toBe(true); + expect(calls.some((c) => c.url.includes(FAKE_BEARER))).toBe(false); + noBearer([...run.stdout, ...run.stderr, stub.rawLog()]); + }), + ), + ); + }); + }); + + test('a later refusal still reports the token that was made', async () => { + await withStubVercel(FILES, async (stub) => { + const { connector } = build(stub.env); + const run = await withExitCodeRestored(() => + captureConsole(() => new ConnectCommand(false)['executeImport'](connector, 'vercel', ctxWith(), { ...BASE, project: 'no-such-project', json: true })), + ); + const parsed = JSON.parse(run.stdout[0]); + expect(parsed).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_PROJECT_NOT_FOUND, tokenCreated: { id: 'tok_new_1' } }); + noBearer(run.stdout); + }); + }); + + test('--discover: the token is created, then discovery runs with it', async () => { + await withStubVercel(FILES, async (stub) => { + await withTempDir('capy-vercel-ct-disc', async (root) => { + const { connector, fetchMock } = build(stub.env, { cwd: root }, { ...DATA, projects: { team_acme: [] } }); + const out = await connector.discover!(discoveryCtx(), { ...BASE, discover: true, yes: true }); + expect(out).toMatchObject({ ok: true, tokenCreated: { id: 'tok_new_1' } }); + expect(callsOf(fetchMock).every((c) => c.authorization === `Bearer ${FAKE_BEARER}`)).toBe(true); + }); + }); + }); +}); + +describe('interactive: a missing token offers the Vercel CLI route next to the hidden prompt', () => { + /** The store's own hidden-prompt path, reduced to what the connector sees: it calls `onMissing` first, then (if that gave nothing) would prompt. */ + const storeThatCalls = (saved: { value?: string }) => + mock(async (_name: string, o: { onMissing?: (save: (v: string) => Promise) => Promise }) => { + const made = await o.onMissing?.(async () => {}); + return made ?? saved.value ?? null; + }); + + test('"create": the CLI makes the token, it is saved through the store\'s own save, and the import proceeds with it', async () => { + await withStubVercel(FILES, async (stub) => { + await withTTY(() => + withExitCodeRestored(async () => { + const saves = mock(async (_v: string) => {}); + const getConnectorSecret = mock(async (_n: string, o: { onMissing?: (save: (v: string) => Promise) => Promise }) => o.onMissing?.(saves) ?? null); + const confirm = mock(async (_m: string, d: boolean) => d); + const { connector, fetchMock } = build({ ...stub.env }, { getConnectorSecret, askCreateMethod: async () => 'create', confirm, selectVars: async (c) => c }); + const run = await captureConsole(() => connector.import!(ctxWith(), { project: 'web', environment: 'production', nonTty: false, noPush: true })); + expect(run.result.ok).toBe(true); + expect(saves.mock.calls).toEqual([[FAKE_BEARER]]); + expect(callsOf(fetchMock).every((c) => c.authorization === `Bearer ${FAKE_BEARER}`)).toBe(true); + noBearer([...run.stdout, ...run.stderr, stub.rawLog()]); + // the question "sign the Vercel CLI out now?" defaults to yes + expect(confirm.mock.calls[0][1]).toBe(true); + expect(stub.calls().some((c) => c[0] === 'logout')).toBe(true); + }), + ); + }); + }); + + test('answering no to "sign out?" leaves the CLI signed in', async () => { + await withStubVercel(FILES, async (stub) => { + await withTTY(() => + withExitCodeRestored(async () => { + const getConnectorSecret = mock(async (_n: string, o: { onMissing?: (save: (v: string) => Promise) => Promise }) => o.onMissing?.(async () => {}) ?? null); + const confirm = mock(async () => false); + const { connector } = build({ ...stub.env }, { getConnectorSecret, askCreateMethod: async () => 'create', confirm, selectVars: async (c) => c }); + await captureConsole(() => connector.import!(ctxWith(), { project: 'web', environment: 'production', nonTty: false, noPush: true })); + expect(stub.calls().some((c) => c[0] === 'logout')).toBe(false); + }), + ); + }); + }); + + test('"paste": the CLI is never run and the hidden prompt path carries on', async () => { + await withStubVercel(FILES, async (stub) => { + await withTTY(() => + withExitCodeRestored(async () => { + const saved: { value?: string } = { value: undefined }; + const getConnectorSecret = storeThatCalls(saved); + const { connector } = build({ ...stub.env }, { getConnectorSecret, askCreateMethod: async () => 'paste' }); + const run = await captureConsole(() => connector.import!(ctxWith(), { project: 'web', environment: 'production', nonTty: false, noPush: true })); + expect(run.result).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }); + expect(stub.calls().length).toBe(0); + }), + ); + }); + }); + + test('no `vercel` binary: the choice is not offered at all (the hook is not even passed)', async () => { + await withTTY(() => + withExitCodeRestored(async () => { + const getConnectorSecret = mock(async (_n: string, o: { onMissing?: unknown }) => { + expect(o.onMissing).toBeUndefined(); + return null; + }); + const askCreateMethod = mock(async () => 'create' as const); + const { connector } = build({ PATH: '/nonexistent-dir-for-test' }, { getConnectorSecret, askCreateMethod }); + await captureConsole(() => connector.import!(ctxWith(), { project: 'web', nonTty: false })); + expect(askCreateMethod.mock.calls.length).toBe(0); + }), + ); + }); + + test('a run that cannot prompt (--json) never offers it', async () => { + await withStubVercel(FILES, async (stub) => { + await withTTY(() => + withExitCodeRestored(async () => { + const getConnectorSecret = mock(async (_n: string, o: { onMissing?: unknown }) => { + expect(o.onMissing).toBeUndefined(); + return null; + }); + const { connector } = build({ ...stub.env }, { getConnectorSecret }); + await captureConsole(() => connector.import!(ctxWith(), { project: 'web', nonTty: false, json: true })); + expect(stub.calls().length).toBe(0); + }), + ); + }); + }); +}); diff --git a/tests/commands/keyHelpRefusals.test.ts b/tests/commands/keyHelpRefusals.test.ts new file mode 100644 index 00000000..d2b2ee1b --- /dev/null +++ b/tests/commands/keyHelpRefusals.test.ts @@ -0,0 +1,198 @@ +/** + * Every ask for, and refusal for lack of, a Vercel token or a Dokploy API key + * says where to get one: `link: { label, url }` + `remedy` on the JSON + * refusal (the `Blocked` field names), and the URL in the human text on + * stderr. Additive: the pre-existing fields are asserted still present. + * + * No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { createDokployConnector } from '../../src/commands/connectors/dokploy'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { createDokployAdapter } from '../../src/deploy/adapters/dokploy'; +import { createVercelAdapter } from '../../src/deploy/adapters/vercel'; +import { dokployKeyHelp, formatKeyHelp, keyHelpForName, vercelKeyHelp } from '../../src/deploy/keyHelp'; +import type { TargetConfig } from '../../src/deploy/adapter'; +import { ERROR_CODES, type KeepFile } from '../../src/types/index'; +import type { ConnectOpts } from '../../src/commands/connectors/registry'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { captureConsole, withExitCodeRestored } from '../helpers/testSandbox'; + +const VERCEL_PAGE = 'https://vercel.com/account/settings/tokens'; +const DOKPLOY_BASE = 'https://deploy.example.test'; + +const ctxWith = (): ResolvedContext => + ({ + keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} } as KeepFile, + branch: 'production', + orgId: 'org_1', + localPlaintext: {}, + }) as unknown as ResolvedContext; + +const unreachable: VercelFetchLike = async () => { + throw new Error('no request expected'); +}; + +/** Runs a refusal through the REAL `ConnectCommand` printing, once as JSON and once as human text. */ +async function printed(connector: ReturnType, provider: string, opts: ConnectOpts) { + const json = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeImport'](connector, provider, ctxWith(), { ...opts, json: true }))); + const human = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeImport'](connector, provider, ctxWith(), { ...opts, json: false }))); + return { json, human }; +} + +describe('keyHelp — the four known names', () => { + test('Vercel: the token page and the --create-token alternative, for both names', () => { + for (const name of ['_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN']) { + const help = keyHelpForName(name); + expect(help?.link.url).toBe(VERCEL_PAGE); + expect(help?.remedy).toContain(`capy system set ${name}`); + expect(help?.remedy).toContain('capy connect vercel --create-token'); + } + }); + + test('Dokploy: the page inside the user\'s own Dokploy when the base URL is known (trailing slashes ignored), else the docs', () => { + expect(dokployKeyHelp('_CONNECTOR_DOKPLOY_API_KEY', `${DOKPLOY_BASE}///`).link.url).toBe(`${DOKPLOY_BASE}/dashboard/settings/profile`); + expect(keyHelpForName('_TARGET_DOKPLOY_API_KEY')?.link.url).toBe('https://docs.dokploy.com/docs/api'); + expect(keyHelpForName('_TARGET_DOKPLOY_API_KEY', DOKPLOY_BASE)?.link.url).toBe(`${DOKPLOY_BASE}/dashboard/settings/profile`); + }); + + test('any other name has no help', () => { + expect(keyHelpForName('_CONNECTOR_STRIPE_API_KEY')).toBeNull(); + expect(keyHelpForName('_CONNECTOR_WORKOS_API_KEY')).toBeNull(); + }); + + test('the human text carries the URL and the remedy', () => { + const text = formatKeyHelp(vercelKeyHelp('_CONNECTOR_VERCEL_TOKEN')); + expect(text).toContain(VERCEL_PAGE); + expect(text).toContain('capy system set _CONNECTOR_VERCEL_TOKEN'); + }); +}); + +describe('capy connect vercel — key refusals', () => { + const opts: ConnectOpts = { nonTty: true, project: 'web' }; + + test('no token anywhere: VERCEL_TOKEN_MISSING carries link.url + remedy in JSON, and the URL on stderr; old fields intact', async () => { + const connector = createVercelConnector({ fetch: unreachable, env: {} }); + const { json, human } = await printed(connector, 'vercel', opts); + expect(json.stdout.length).toBe(1); + const parsed = JSON.parse(json.stdout[0]); + expect(parsed).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, link: { url: VERCEL_PAGE } }); + expect(typeof parsed.message).toBe('string'); + expect(parsed.link.label).toBeTruthy(); + expect(parsed.remedy).toContain('--create-token'); + expect(human.stdout.join('')).toBe(''); + expect(human.stderr.join('\n')).toContain(VERCEL_PAGE); + }); + + test('the store refusing (not an admin / the other direction already has a key) carries it too', async () => { + for (const code of [ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY, ERROR_CODES.VERCEL_CONNECTOR_KEY_MISSING]) { + const getConnectorSecret = async () => { + throw Object.assign(new Error('x'), { code }); + }; + const connector = createVercelConnector({ fetch: unreachable, env: {}, getConnectorSecret }); + const out = await connector.import!(ctxWith(), opts); + expect(out).toMatchObject({ ok: false, code, link: { url: VERCEL_PAGE } }); + expect((out as { remedy?: string }).remedy).toBeTruthy(); + } + }); + + test('a rejected token (HTTP 401): VERCEL_AUTH_FAILED carries the link', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const connector = createVercelConnector({ fetch: unauthorized, env: { T: 'x' }, sleep: async () => {} }); + const { json, human } = await printed(connector, 'vercel', { ...opts, tokenEnv: 'T' }); + expect(JSON.parse(json.stdout[0])).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_AUTH_FAILED, link: { url: VERCEL_PAGE } }); + expect(human.stderr.join('\n')).toContain(VERCEL_PAGE); + }); + + test('a refusal that is not about a key adds no link', async () => { + const down: VercelFetchLike = async () => ({ status: 500, ok: false, text: async () => '{}' }); + const connector = createVercelConnector({ fetch: down, env: { T: 'x' }, sleep: async () => {} }); + const out = await connector.import!(ctxWith(), { nonTty: true, project: 'web', tokenEnv: 'T' }); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_API_ERROR }); + expect((out as { link?: unknown }).link).toBeUndefined(); + expect((out as { remedy?: unknown }).remedy).toBeUndefined(); + }); +}); + +describe('capy connect dokploy — key refusals', () => { + const opts: ConnectOpts = { nonTty: true, baseUrl: DOKPLOY_BASE, application: 'app_1', tokenEnv: 'MISSING_TOKEN' }; + + test('missing key with a known base URL: link is that Dokploy\'s API-key page; JSON + stderr; old fields intact', async () => { + const connector = createDokployConnector({ fetch: unreachable as never, env: {} }); + const { json, human } = await printed(connector as never, 'dokploy', opts); + const parsed = JSON.parse(json.stdout[0]); + expect(parsed).toMatchObject({ ok: false, code: 'DOKPLOY_TOKEN_MISSING', link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + expect(typeof parsed.message).toBe('string'); + expect(parsed.remedy).toContain('capy system set _CONNECTOR_DOKPLOY_API_KEY'); + expect(human.stderr.join('\n')).toContain(`${DOKPLOY_BASE}/dashboard/settings/profile`); + }); + + test('a rejected key (401): DOKPLOY_AUTH_FAILED carries the same link', async () => { + const unauthorized = (async () => ({ status: 401, ok: false, text: async () => '{}', json: async () => ({}) })) as never; + const connector = createDokployConnector({ fetch: unauthorized, env: { TOK: 'x' } }); + const out = await connector.import!(ctxWith(), { ...opts, tokenEnv: 'TOK' }); + expect(out).toMatchObject({ ok: false, code: 'DOKPLOY_AUTH_FAILED', link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + }); + + test('--discover with a missing key carries the link too', async () => { + const connector = createDokployConnector({ fetch: unreachable as never, env: {} }); + const out = await connector.discover!( + { orgId: 'o', userId: 'u', authService: {}, serviceClient: {} } as never, + { nonTty: true, baseUrl: DOKPLOY_BASE, tokenEnv: 'MISSING_TOKEN', discover: true }, + ); + expect(out).toMatchObject({ ok: false, code: 'DOKPLOY_TOKEN_MISSING', link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + }); +}); + +describe('capy deploy — the preflight refusal for a missing or rejected key carries the link', () => { + const dokployTarget: TargetConfig = { + name: 'dk', + kind: 'dokploy', + branch: 'production', + vars: ['A'], + mode: 'ci', + options: { baseUrl: DOKPLOY_BASE, applicationId: 'app_1' }, + }; + + test('Dokploy, no key anywhere', async () => { + const adapter = createDokployAdapter({ env: {}, fetch: unreachable as never }); + const r = await adapter.preflight(dokployTarget, { cwd: '/nonexistent-cwd' }); + expect(r).toMatchObject({ ok: false, link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + expect(r.remedy).toContain('capy system set _TARGET_DOKPLOY_API_KEY'); + expect(r.reason).toBeTruthy(); + expect(r.hint).toBeTruthy(); + }); + + test('Dokploy, key rejected by Dokploy', async () => { + const unauthorized = (async () => ({ status: 401, ok: false, text: async () => '{}', json: async () => ({}) })) as never; + const adapter = createDokployAdapter({ env: { DOKPLOY_API_KEY: 'x' }, fetch: unauthorized }); + const r = await adapter.preflight({ ...dokployTarget, options: { ...dokployTarget.options, tokenEnv: 'DOKPLOY_API_KEY' } }, { cwd: '/nonexistent-cwd' }); + expect(r).toMatchObject({ ok: false, link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + }); + + const vercelTarget: TargetConfig = { + name: 'vc', + kind: 'vercel', + branch: 'production', + vars: ['A'], + mode: 'ci', + options: { projectDir: 'web', projectId: 'prj_1', vercelEnv: 'production' }, + }; + + test('Vercel API mode, no token anywhere', async () => { + const adapter = createVercelAdapter({ env: {}, fetch: unreachable as never, getTargetSecret: async () => null }); + const r = await adapter.preflight(vercelTarget, { cwd: '/nonexistent-cwd' }); + expect(r).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, link: { url: VERCEL_PAGE } }); + expect(r.remedy).toContain('capy system set _TARGET_VERCEL_TOKEN'); + expect(r.remedy).toContain('capy connect vercel --create-token'); + }); + + test('Vercel API mode, token rejected (401)', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const adapter = createVercelAdapter({ env: {}, fetch: unauthorized as never, getTargetSecret: async () => 'x', sleep: mock(async () => {}) }); + const r = await adapter.preflight(vercelTarget, { cwd: '/nonexistent-cwd' }); + expect(r).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_REJECTED, link: { url: VERCEL_PAGE } }); + }); +}); diff --git a/tests/commands/systemSetKeyHelp.test.ts b/tests/commands/systemSetKeyHelp.test.ts new file mode 100644 index 00000000..ae5e4aed --- /dev/null +++ b/tests/commands/systemSetKeyHelp.test.ts @@ -0,0 +1,59 @@ +/** + * `capy system set ` prints where to get the key before the hidden + * prompt, for the four known key names — and nothing new for any other name. + * The help goes to stderr, so `--json` stdout stays one pure JSON object. + * + * Command layer only: the store and `inquirer` are mocked (`mock.module`), so + * this file runs on its own (see ISOLATED_FILES in tests/run-tests.sh). + */ +import { describe, test, expect, mock, afterAll } from 'bun:test'; +import { captureConsole, withTTY } from '../helpers/testSandbox'; + +const setStore = mock(async (_name: string, _value: string) => {}); + +mock.module('../../src/system/systemStore', () => ({ + assertValidConnectorName: () => {}, + openSystemStore: async () => ({ orgId: 'org_x', userId: 'u', listNames: () => [], get: () => null, set: setStore, remove: async () => {} }), +})); + +const typed = mock(async (_questions: unknown) => ({ value: 'typed-fake-value' })); +mock.module('inquirer', () => ({ default: { prompt: typed, createPromptModule: () => typed } })); + +afterAll(() => mock.restore()); + +const run = async (name: string, json: boolean) => { + const { systemSetCommand } = await import('../../src/commands/systemCommand'); + return withTTY(() => captureConsole(() => systemSetCommand(name, { json }))); +}; + +const URLS: ReadonlyArray<[string, string]> = [ + ['_CONNECTOR_VERCEL_TOKEN', 'https://vercel.com/account/settings/tokens'], + ['_TARGET_VERCEL_TOKEN', 'https://vercel.com/account/settings/tokens'], + ['_CONNECTOR_DOKPLOY_API_KEY', 'https://docs.dokploy.com/docs/api'], + ['_TARGET_DOKPLOY_API_KEY', 'https://docs.dokploy.com/docs/api'], +]; + +describe('capy system set — key help before the hidden prompt', () => { + for (const [name, url] of URLS) { + test(`${name}: the link is on stderr; the saved value is what was typed; the value is never echoed`, async () => { + const out = await run(name, false); + expect(out.stderr.join('\n')).toContain(url); + expect(out.stderr.join('\n')).toContain(`capy system set ${name}`); + expect(setStore.mock.calls.at(-1)).toEqual([name, 'typed-fake-value']); + expect([...out.stdout, ...out.stderr].join('\n')).not.toContain('typed-fake-value'); + }); + } + + test('--json: stdout is exactly one JSON object; the link is on stderr only', async () => { + const out = await run('_CONNECTOR_VERCEL_TOKEN', true); + expect(out.stdout.length).toBe(1); + expect(JSON.parse(out.stdout[0])).toEqual({ ok: true, name: '_CONNECTOR_VERCEL_TOKEN' }); + expect(out.stderr.join('\n')).toContain('https://vercel.com/account/settings/tokens'); + }); + + test('any other name: nothing new is printed', async () => { + const out = await run('_CONNECTOR_STRIPE_API_KEY', false); + expect(out.stderr).toEqual([]); + expect(out.stdout).toEqual(['Saved _CONNECTOR_STRIPE_API_KEY.']); + }); +}); diff --git a/tests/deploy/vercelCliToken.test.ts b/tests/deploy/vercelCliToken.test.ts new file mode 100644 index 00000000..3a1fd850 --- /dev/null +++ b/tests/deploy/vercelCliToken.test.ts @@ -0,0 +1,264 @@ +/** + * Creating the Vercel token from the Vercel CLI's sign-in (`mintTeamToken`). + * + * A stub `vercel` program on PATH records every call's argv and answers from + * canned JSON / exit codes, so the REAL runner (execFile, PATH search, stdout + * capture) is what runs. The property under test: the token the stub hands + * back is exactly what reaches `save`, and appears nowhere else — not in any + * recorded argv, not in a report, not in a refusal. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { mintTeamToken, parseExpiryDays, scopesAreExactlyTeam, type MintRequest } from '../../src/deploy/vercelCliToken'; +import { ERROR_CODES } from '../../src/types/index'; +import { FAKE_BEARER, createdJson, teamsJson, withStubVercel } from '../helpers/stubVercelCli'; + +const ACME = { id: 'team_acme', slug: 'acme' }; +const OTHER = { id: 'team_other', slug: 'other' }; + +const OK_FILES = { 'teams.json': teamsJson([ACME]), 'create.json': createdJson() } as const; + +/** A save target that records what it was given in a `mock` (never in an array of the test's own). */ +function savingTarget() { + const save = mock(async (_value: string) => {}); + return { save, prepare: async () => ({ ok: true as const, save, replacedExisting: false }) }; +} + +function request(over: Partial = {}): MintRequest { + return { pickTeam: null, prepare: savingTarget().prepare, askSignOut: null, ...over }; +} + +const verbs = (calls: readonly (readonly string[])[]): readonly string[] => calls.map((c) => (c[0] === 'api' ? `api ${c[1].split('?')[0]}` : c.slice(0, 2).join(' '))); + +describe('mintTeamToken — happy path', () => { + test('saves exactly the stub bearer; the value is in no argv and no report', async () => { + await withStubVercel(OK_FILES, async (stub) => { + const target = savingTarget(); + const out = await mintTeamToken({ env: stub.env }, request({ prepare: target.prepare })); + expect(out.ok).toBe(true); + if (!out.ok) return; + expect(target.save.mock.calls).toEqual([[FAKE_BEARER]]); + expect(out.bearer).toBe(FAKE_BEARER); + // never on a command line, never in the report + expect(stub.rawLog()).not.toContain(FAKE_BEARER); + expect(JSON.stringify(out.report)).not.toContain(FAKE_BEARER); + expect(out.report).toMatchObject({ name: 'capy-acme-import', id: 'tok_new_1', team: ACME, secretName: '_CONNECTOR_VERCEL_TOKEN', replacedExisting: false }); + expect(verbs(stub.calls())).toEqual(['whoami', 'api /v2/teams', 'api /v3/user/tokens']); + }); + }); + + test('the create call is `api /v3/user/tokens -X POST -f name=… --scope `, with no expiry by default', async () => { + await withStubVercel(OK_FILES, async (stub) => { + await mintTeamToken({ env: stub.env }, request()); + const create = stub.calls().find((c) => c[1] === '/v3/user/tokens'); + expect(create).toEqual(['api', '/v3/user/tokens', '-X', 'POST', '-f', 'name=capy-acme-import', '--scope', 'acme']); + }); + }); + + test('--token-expires-days becomes expiresAt in milliseconds', async () => { + await withStubVercel(OK_FILES, async (stub) => { + const out = await mintTeamToken({ env: stub.env, now: () => 1_000_000 }, request({ expiresDays: 2 })); + const create = stub.calls().find((c) => c[1] === '/v3/user/tokens'); + expect(create).toContain('-F'); + expect(create).toContain(`expiresAt=${1_000_000 + 2 * 86_400_000}`); + expect(out.ok && out.report.expiresAt).toBe(new Date(1_000_000 + 2 * 86_400_000).toISOString()); + }); + }); + + test('replacedExisting is reported from the store step', async () => { + await withStubVercel(OK_FILES, async (stub) => { + const out = await mintTeamToken({ env: stub.env }, request({ prepare: async () => ({ ok: true, save: async () => {}, replacedExisting: true }) })); + expect(out.ok && out.report.replacedExisting).toBe(true); + }); + }); +}); + +describe('mintTeamToken — which team', () => { + const several = { 'teams.json': teamsJson([ACME, OTHER]), 'create.json': createdJson() }; + + test('--team and --team both pick that team', async () => { + await withStubVercel(several, async (stub) => { + const bySlug = await mintTeamToken({ env: stub.env }, request({ team: 'acme' })); + expect(bySlug.ok && bySlug.report.team).toEqual(ACME); + const byId = await mintTeamToken({ env: stub.env }, request({ team: 'team_acme' })); + expect(byId.ok && byId.report.team).toEqual(ACME); + }); + }); + + test('several teams and nobody to ask: VERCEL_TEAM_REQUIRED, nothing created, nothing saved', async () => { + await withStubVercel(several, async (stub) => { + const target = savingTarget(); + const out = await mintTeamToken({ env: stub.env }, request({ prepare: target.prepare })); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TEAM_REQUIRED }); + expect(target.save.mock.calls.length).toBe(0); + expect(stub.calls().some((c) => c[1] === '/v3/user/tokens')).toBe(false); + }); + }); + + test('several teams and a pick: the picked one', async () => { + await withStubVercel({ 'teams.json': teamsJson([ACME, OTHER]), 'create.json': createdJson({ scopes: [{ type: 'team', teamId: 'team_other' }] }) }, async (stub) => { + const out = await mintTeamToken({ env: stub.env }, request({ pickTeam: async () => 'team_other' })); + expect(out.ok && out.report.team).toEqual(OTHER); + }); + }); + + test('an unknown --team: VERCEL_TEAM_NOT_FOUND; no teams at all: VERCEL_TEAM_REQUIRED', async () => { + await withStubVercel(OK_FILES, async (stub) => { + expect(await mintTeamToken({ env: stub.env }, request({ team: 'nope' }))).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TEAM_NOT_FOUND }); + }); + await withStubVercel({ 'teams.json': teamsJson([]), 'create.json': createdJson() }, async (stub) => { + expect(await mintTeamToken({ env: stub.env }, request({}))).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TEAM_REQUIRED }); + }); + }); +}); + +describe('mintTeamToken — the Vercel CLI', () => { + test('binary missing: VERCEL_CLI_MISSING with an install link; nothing ran', async () => { + const out = await mintTeamToken({ env: { PATH: '/nonexistent-dir-for-test' } }, request()); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_CLI_MISSING }); + expect(out.ok === false && out.link?.url).toBe('https://vercel.com/docs/cli'); + }); + + test('not signed in (whoami exits non-zero): VERCEL_CLI_NOT_SIGNED_IN; no team list, no create', async () => { + await withStubVercel({ ...OK_FILES, 'whoami.exit': '1' }, async (stub) => { + const out = await mintTeamToken({ env: stub.env }, request()); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_CLI_NOT_SIGNED_IN }); + expect(verbs(stub.calls())).toEqual(['whoami']); + }); + }); + + test('VERCEL_TOKEN in the caller environment is not passed to the CLI (the sign-in is the only identity)', async () => { + await withStubVercel(OK_FILES, async (stub) => { + const out = await mintTeamToken({ env: { ...stub.env, VERCEL_TOKEN: 'vcp_ENV_TOKEN_SENTINEL' } }, request()); + expect(out.ok).toBe(true); + expect(stub.tokenEnvSeen().length).toBeGreaterThan(0); + expect(stub.tokenEnvSeen().every((l) => l === '[]')).toBe(true); + }); + }); + + test('the CLI refusing to create (non-zero exit): VERCEL_TOKEN_CREATE_FAILED with the token page', async () => { + await withStubVercel({ ...OK_FILES, 'create.exit': '1' }, async (stub) => { + const target = savingTarget(); + const out = await mintTeamToken({ env: stub.env }, request({ prepare: target.prepare })); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_CREATE_FAILED }); + expect(out.ok === false && out.link?.url).toBe('https://vercel.com/account/settings/tokens'); + expect(target.save.mock.calls.length).toBe(0); + }); + }); +}); + +describe('mintTeamToken — the new token must be scoped to exactly the chosen team', () => { + const bad: ReadonlyArray<[string, readonly unknown[]]> = [ + ['a user scope', [{ type: 'user' }]], + ['another team', [{ type: 'team', teamId: 'team_other' }]], + ['extra scopes', [{ type: 'team', teamId: 'team_acme' }, { type: 'team', teamId: 'team_other' }]], + ['no scopes', []], + ]; + + for (const [label, scopes] of bad) { + test(`${label}: the token is deleted at once and VERCEL_TOKEN_SCOPE_UNEXPECTED is returned; nothing saved`, async () => { + await withStubVercel({ 'teams.json': teamsJson([ACME]), 'create.json': createdJson({ scopes }) }, async (stub) => { + const target = savingTarget(); + const out = await mintTeamToken({ env: stub.env }, request({ prepare: target.prepare })); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_SCOPE_UNEXPECTED }); + expect(target.save.mock.calls.length).toBe(0); + expect(stub.calls().filter((c) => c[0] === 'tokens')).toEqual([['tokens', 'rm', 'tok_new_1']]); + expect(JSON.stringify(out)).not.toContain(FAKE_BEARER); + }); + }); + } + + test('scope check and delete both fail: VERCEL_TOKEN_CLEANUP_FAILED names the id, never the value', async () => { + await withStubVercel({ 'teams.json': teamsJson([ACME]), 'create.json': createdJson({ scopes: [{ type: 'user' }] }), 'rm.exit': '1' }, async (stub) => { + const out = await mintTeamToken({ env: stub.env }, request()); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_CLEANUP_FAILED, tokenId: 'tok_new_1' }); + expect(JSON.stringify(out)).toContain('tok_new_1'); + expect(JSON.stringify(out)).not.toContain(FAKE_BEARER); + }); + }); + + test('scopesAreExactlyTeam: exactly one team scope for that team and nothing else', () => { + expect(scopesAreExactlyTeam([{ type: 'team', teamId: 'team_acme' }], 'team_acme')).toBe(true); + expect(scopesAreExactlyTeam([{ type: 'user' }], 'team_acme')).toBe(false); + expect(scopesAreExactlyTeam([], 'team_acme')).toBe(false); + }); +}); + +describe('mintTeamToken — saving', () => { + test('save fails: the token is deleted and the STORE\'s code is returned', async () => { + await withStubVercel(OK_FILES, async (stub) => { + const failing = request({ + prepare: async () => ({ + ok: true, + save: async () => { + throw Object.assign(new Error('boom'), { code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }, + replacedExisting: false, + }), + }); + const out = await mintTeamToken({ env: stub.env }, failing); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + expect(stub.calls().filter((c) => c[0] === 'tokens')).toEqual([['tokens', 'rm', 'tok_new_1']]); + expect(JSON.stringify(out)).not.toContain(FAKE_BEARER); + expect(JSON.stringify(out)).not.toContain('boom'); + }); + }); + + test('save fails and delete fails too: VERCEL_TOKEN_CLEANUP_FAILED with the id', async () => { + await withStubVercel({ ...OK_FILES, 'rm.exit': '1' }, async (stub) => { + const failing = request({ prepare: async () => ({ ok: true, save: async () => Promise.reject(new Error('x')), replacedExisting: false }) }); + expect(await mintTeamToken({ env: stub.env }, failing)).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_CLEANUP_FAILED, tokenId: 'tok_new_1' }); + }); + }); + + test('the store step refusing (not an admin) happens BEFORE any token is created', async () => { + await withStubVercel(OK_FILES, async (stub) => { + const out = await mintTeamToken( + { env: stub.env }, + request({ prepare: async () => ({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY, message: 'no' }) }), + ); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + expect(stub.calls().some((c) => c[1] === '/v3/user/tokens')).toBe(false); + }); + }); +}); + +describe('mintTeamToken — signing the Vercel CLI out', () => { + test('interactive, yes: runs `vercel logout`', async () => { + await withStubVercel(OK_FILES, async (stub) => { + const ask = mock(async (_m: string, _d: boolean) => true); + const out = await mintTeamToken({ env: stub.env }, request({ askSignOut: ask })); + expect(ask.mock.calls[0][1]).toBe(true); // default yes + expect(stub.calls().some((c) => c[0] === 'logout')).toBe(true); + expect(out.ok && out.report.cliSignOut).toBe('done'); + expect(out.ok && out.report.next).toBeUndefined(); + }); + }); + + test('interactive, no: does not log out, and reports the next step', async () => { + await withStubVercel(OK_FILES, async (stub) => { + const out = await mintTeamToken({ env: stub.env }, request({ askSignOut: async () => false })); + expect(stub.calls().some((c) => c[0] === 'logout')).toBe(false); + expect(out.ok && out.report.cliSignOut).toBe('declined'); + expect(out.ok && out.report.next).toBe('vercel logout'); + }); + }); + + test('non-interactive: never logs out, only reports the next step', async () => { + await withStubVercel(OK_FILES, async (stub) => { + const out = await mintTeamToken({ env: stub.env }, request({ askSignOut: null })); + expect(stub.calls().some((c) => c[0] === 'logout')).toBe(false); + expect(out.ok && out.report.cliSignOut).toBe('not_offered'); + expect(out.ok && out.report.next).toBe('vercel logout'); + }); + }); +}); + +describe('parseExpiryDays', () => { + test('absent is fine; whole days 1..3650 are fine; anything else is VERCEL_TOKEN_EXPIRY_INVALID', () => { + expect(parseExpiryDays(undefined)).toEqual({ ok: true, days: undefined }); + expect(parseExpiryDays('30')).toEqual({ ok: true, days: 30 }); + for (const bad of ['0', '-1', '1.5', 'abc', '', '99999']) { + expect(parseExpiryDays(bad)).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_EXPIRY_INVALID }); + } + }); +}); diff --git a/tests/helpers/stubVercelCli.ts b/tests/helpers/stubVercelCli.ts new file mode 100644 index 00000000..0dc5bb06 --- /dev/null +++ b/tests/helpers/stubVercelCli.ts @@ -0,0 +1,96 @@ +/** + * A stand-in `vercel` program for the `--create-token` tests: a small shell + * script in a temp directory that appends every call's argv to a log file and + * answers from canned files. Nothing here is a real token — every value is a + * fake sentinel. + * + * Files in the stub directory (all optional): + * whoami.exit exit status for `vercel whoami` (default 0) + * logout.exit exit status for `vercel logout` (default 0) + * rm.exit exit status for `vercel tokens rm` (default 0) + * create.exit exit status for the token POST (default 0) + * teams.json stdout for `vercel api /v2/teams…` + * create.json stdout for `vercel api /v3/user/tokens` + */ +import { chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { withTempDir } from './testSandbox'; + +const SCRIPT = `#!/bin/sh +d="$STUB_DIR" +printf '%s\\037' "$@" >> "$d/argv.log" +printf '\\n' >> "$d/argv.log" +printf '[%s]\\n' "\${VERCEL_TOKEN:-}" >> "$d/env.log" +status() { if [ -f "$d/$1.exit" ]; then exit "$(cat "$d/$1.exit")"; fi; exit 0; } +case "$1" in + whoami) status whoami ;; + logout) status logout ;; + tokens) status rm ;; + api) + case "$2" in + /v2/teams*) cat "$d/teams.json"; exit 0 ;; + /v3/user/tokens) + if [ -f "$d/create.exit" ] && [ "$(cat "$d/create.exit")" != "0" ]; then exit "$(cat "$d/create.exit")"; fi + cat "$d/create.json"; exit 0 ;; + esac ;; +esac +exit 64 +`; + +export interface StubFiles { + readonly [name: string]: string; +} + +export interface Stub { + /** An environment whose PATH finds the stub `vercel`. */ + env: Record; + /** Every call so far, as its argv. */ + calls(): readonly (readonly string[])[]; + /** Every call so far, as one raw string — for "this value appears nowhere". */ + rawLog(): string; + /** What `$VERCEL_TOKEN` was in each call's environment (empty brackets = unset). */ + tokenEnvSeen(): readonly string[]; +} + +/** Runs `fn` with a stub `vercel` on PATH (and the stub's directory removed afterwards). */ +export async function withStubVercel(files: StubFiles, fn: (stub: Stub) => Promise): Promise { + return withTempDir('stub-vercel', async (dir) => { + const bin = join(dir, 'bin'); + const data = join(dir, 'data'); + mkdirSync(bin); + mkdirSync(data); + writeFileSync(join(bin, 'vercel'), SCRIPT); + chmodSync(join(bin, 'vercel'), 0o755); + Object.entries(files).forEach(([name, content]) => writeFileSync(join(data, name), content)); + const log = join(data, 'argv.log'); + const rawLog = (): string => (existsSync(log) ? readFileSync(log, 'utf-8') : ''); + const envLog = join(data, 'env.log'); + return fn({ + tokenEnvSeen: () => (existsSync(envLog) ? readFileSync(envLog, 'utf-8').split('\n').filter((l) => l !== '') : []), + env: { PATH: `${bin}:/usr/bin:/bin`, STUB_DIR: data }, + rawLog, + calls: () => + rawLog() + .split('\n') + .filter((l) => l !== '') + .map((l) => l.split('\u001f').filter((a) => a !== '')), + }); + }); +} + +/** A fake bearer value; tests assert it appears on no output surface. */ +export const FAKE_BEARER = 'vcp_FAKE_BEARER_SENTINEL_9f3a71c2'; + +export const teamsJson = (teams: ReadonlyArray<{ id: string; slug: string }>): string => JSON.stringify({ teams, pagination: null }); + +export const createdJson = (over: { id?: string; scopes?: readonly unknown[]; expiresAt?: number } = {}): string => + JSON.stringify({ + bearerToken: FAKE_BEARER, + token: { + id: over.id ?? 'tok_new_1', + name: 'capy-acme-import', + type: 'oauth2-token', + scopes: over.scopes ?? [{ type: 'team', teamId: 'team_acme' }], + ...(over.expiresAt === undefined ? {} : { expiresAt: over.expiresAt }), + }, + }); diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 10538937..2db037e0 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -71,6 +71,7 @@ ISOLATED_FILES=( tests/commands/pairCommand.test.ts tests/commands/deployVercelApiMode.test.ts tests/deploy/vercelApiModeTokenStore.test.ts + tests/commands/systemSetKeyHelp.test.ts ) # Build a grep pattern to exclude isolated files from the batch run diff --git a/tests/system/systemStore.test.ts b/tests/system/systemStore.test.ts index 4b38af58..ce5c7acc 100644 --- a/tests/system/systemStore.test.ts +++ b/tests/system/systemStore.test.ts @@ -6,7 +6,7 @@ * fake service, rather than checking that a function merely returned * something. Maps to docs/org-system-store.md's numbered Proofs where noted. */ -import { mock, describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { mock, spyOn, describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; import { mkdtempSync, rmSync, readdirSync, statSync, readFileSync } from 'fs'; import { join } from 'path'; import { randomBytes } from 'crypto'; @@ -404,6 +404,72 @@ describe('systemStore', () => { }); }); + // ── Key help before the hidden prompt, and the `onMissing` hook ── + describe('key help and onMissing (hidden-prompt path)', () => { + /** Runs `fn` with console.error captured; returns what it printed. */ + const stderrOf = async (fn: () => Promise): Promise => { + const spy = spyOn(console, 'error').mockImplementation((() => {}) as never); + try { + await fn(); + return spy.mock.calls.map((a) => a.map(String).join(' ')).join('\n'); + } finally { + spy.mockRestore(); + } + }; + + it('a known key name: the link is printed (stderr) before the prompt', async () => { + promptQueue.push({ value: 'typed-secret' }); + const text = await stderrOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true })); + expect(text).toContain('https://vercel.com/account/settings/tokens'); + expect(text).not.toContain('typed-secret'); + }); + + it('a known Dokploy key name with a base URL: the link is that Dokploy\'s page', async () => { + promptQueue.push({ value: 'typed-secret' }); + const text = await stderrOf(() => + getConnectorSecret('_CONNECTOR_DOKPLOY_API_KEY', { orgId: ORG_A, devMode: true, interactive: true, helpBaseUrl: 'https://d.example.test' }), + ); + expect(text).toContain('https://d.example.test/dashboard/settings/profile'); + }); + + it('any other name: nothing is printed', async () => { + promptQueue.push({ value: 'typed-secret' }); + const text = await stderrOf(() => getConnectorSecret('_CONNECTOR_FOO_KEY', { orgId: ORG_A, devMode: true, interactive: true })); + expect(text).toBe(''); + }); + + it('onMissing that makes a value: it is saved through the store\'s own save, returned, and the hidden prompt never runs', async () => { + const value = await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { + orgId: ORG_A, + devMode: true, + interactive: true, + onMissing: async (save) => { + await save('made-elsewhere'); + return 'made-elsewhere'; + }, + }); + expect(value).toBe('made-elsewhere'); + expect(promptedWith).toEqual([]); + const again = await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: false }); + expect(again).toBe('made-elsewhere'); + }); + + it('onMissing that returns null: the hidden prompt carries on', async () => { + promptQueue.push({ value: 'typed-secret' }); + const value = await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true, onMissing: async () => null }); + expect(value).toBe('typed-secret'); + expect(promptedWith).toHaveLength(1); + }); + + it('onMissing is never called when the entry exists, or when nobody can be asked', async () => { + const hook = mock(async () => 'x'); + expect(await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: false, onMissing: hook })).toBeNull(); + await setValue(ORG_A, '_CONNECTOR_VERCEL_TOKEN', 'present'); + expect(await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true, onMissing: hook })).toBe('present'); + expect(hook.mock.calls.length).toBe(0); + }); + }); + // ── CAP-679 follow-up: `getDirectionalConnectorSecret` — deploy's // `_TARGET_DOKPLOY_API_KEY` borrowing from import's // `_CONNECTOR_DOKPLOY_API_KEY` (or the reverse), and the one-level From 8f579e7c08e510d1b79abe09d377c6690ec59132 Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 2 Oct 2026 14:43:05 -0700 Subject: [PATCH 04/22] feat(deploy): Vercel deploy setup by dashboard link; connect vercel --targets; approved token instructions; drop --create-token - remove capy connect vercel --create-token / --token-expires-days, the Vercel CLI token module, its error codes, the system-store onMissing hook and their tests - every Vercel token ask or refusal (connect, deploy preflight and setup, capy system set) shows the approved instructions: link + remedy in JSON, stderr in the terminal, "Then paste it here." at a hidden prompt, the team slug when known - capy deploy target setup for Vercel is API mode only: one question (dashboard link or project name), live check (VERCEL_PROJECT_NOT_FOUND re-asks, VERCEL_URL_INVALID for a bad link), name-back confirm, environment (custom ones included), optional preview branch; no vercel binary, .vercel/project.json or vercel link; setup stays in the terminal - a target saved without being able to check the project keeps the team slug (teamSlug) and scopes requests by ?slug= - capy connect vercel --targets writes the vercel- target for each imported environment without asking (single import and --discover); --dry-run lists them; the discovery commit includes .capy/deploy.json --- docs/cli-reference.md | 11 +- src/commands/connectCommand.ts | 54 +- src/commands/connectors/dokploy.ts | 24 +- src/commands/connectors/dokployDiscovery.ts | 16 +- src/commands/connectors/registry.ts | 52 +- src/commands/connectors/shared.ts | 14 + src/commands/connectors/vercel.ts | 325 +++++------- src/commands/connectors/vercelDiscovery.ts | 28 +- src/commands/deployCommand.ts | 102 ++-- src/commands/systemCommand.ts | 27 +- src/deploy/adapters/vercel.ts | 31 +- src/deploy/keyHelp.ts | 62 ++- src/deploy/vercelApi.ts | 10 +- src/deploy/vercelCliToken.ts | 464 ------------------ src/deploy/vercelSetup.ts | 392 +++++++++++++++ src/deploy/vercelTargetApi.ts | 24 +- src/index.ts | 9 +- src/system/systemStore.ts | 30 +- src/types/index.ts | 17 +- tests/commands/connectVercel.test.ts | 157 ++++++ .../commands/connectVercelCreateToken.test.ts | 367 -------------- tests/commands/connectVercelDiscovery.test.ts | 181 +++++++ tests/commands/connectVercelFlags.test.ts | 47 ++ tests/commands/deployVercelApiMode.test.ts | 12 + .../commands/deployVercelSetupPicker.test.ts | 161 ++++++ tests/commands/keyHelpRefusals.test.ts | 73 ++- tests/commands/systemSetKeyHelp.test.ts | 85 +++- tests/deploy/vercelApiMode.test.ts | 14 + tests/deploy/vercelCliToken.test.ts | 264 ---------- tests/deploy/vercelSetup.test.ts | 344 +++++++++++++ tests/deploy/vercelSetupNoBinary.test.ts | 25 + tests/deploy/vercelSetupNoBinaryChild.ts | 43 ++ tests/deploy/vercelSetupTokenStore.test.ts | 205 ++++++++ tests/helpers/stubVercelCli.ts | 96 ---- tests/run-tests.sh | 2 + tests/system/systemStore.test.ts | 46 +- 36 files changed, 2114 insertions(+), 1700 deletions(-) delete mode 100644 src/deploy/vercelCliToken.ts create mode 100644 src/deploy/vercelSetup.ts delete mode 100644 tests/commands/connectVercelCreateToken.test.ts create mode 100644 tests/commands/connectVercelFlags.test.ts create mode 100644 tests/commands/deployVercelSetupPicker.test.ts delete mode 100644 tests/deploy/vercelCliToken.test.ts create mode 100644 tests/deploy/vercelSetup.test.ts create mode 100644 tests/deploy/vercelSetupNoBinary.test.ts create mode 100644 tests/deploy/vercelSetupNoBinaryChild.ts create mode 100644 tests/deploy/vercelSetupTokenStore.test.ts delete mode 100644 tests/helpers/stubVercelCli.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2c41cdfc..d44919d4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -622,9 +622,8 @@ capy connect [provider] [options] | `--token-env ` | dokploy/vercel import: env var holding the API token | | | `--project ` | vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision) | | | `--team ` | vercel import/discover: Vercel team slug or id (default: the token's only team) | | -| `--create-token` | vercel: create the Vercel token from the Vercel CLI sign-in (team-scoped) and save it as _CONNECTOR_VERCEL_TOKEN; with --project/--discover the run then continues, alone it stops after saving. Not with --dry-run | | -| `--token-expires-days ` | vercel --create-token: days until the created token expires (default: never) | | | `--json` | emit machine-readable JSON instead of the human UI (import connectors) | | +| `--targets` | Also write a Vercel deploy target for each imported environment | | | `--dry-run` | dokploy import/discover: preview the plan only — resolve settings + read Dokploy, write/push nothing | | | `--discover` | dokploy/vercel: find every Dokploy service / Vercel project matching a repo under cwd, instead of one named --application/--compose/--project | | | `-y, --yes` | dokploy import/discover: skip the confirmation prompt (import: --overwrite's clear/replace/import ask; discover: the real-run "proceed?" ask) — required non-interactively | | @@ -756,13 +755,7 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `VERCEL_VERIFY_MISMATCH` - `VERCEL_OPTIONS_INVALID` - `VERCEL_SPLIT_NOT_RESTORED` -- `VERCEL_CLI_MISSING` -- `VERCEL_CLI_NOT_SIGNED_IN` -- `VERCEL_CREATE_TOKEN_DRY_RUN` -- `VERCEL_TOKEN_EXPIRY_INVALID` -- `VERCEL_TOKEN_CREATE_FAILED` -- `VERCEL_TOKEN_SCOPE_UNEXPECTED` -- `VERCEL_TOKEN_CLEANUP_FAILED` +- `VERCEL_URL_INVALID` ## Conventions diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index e3998f86..2f3ad750 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -1,6 +1,5 @@ import { resolveContext, writeAndSync, writeImportOutcome, listManagedKeys, ResolvedContext } from './connectors/shared'; import { listProviders, loadProvider, ConnectFailure, ConnectOpts, ConnectorModule, ConnectResult } from './connectors/registry'; -import { describeTokenCreated } from '../deploy/vercelCliToken'; import { formatKeyHelp } from '../deploy/keyHelp'; import { connectPlan } from './connectors/plans'; import { isInteractive } from '../ui/interactive'; @@ -19,9 +18,9 @@ const B = (s: string) => `\x1b[1m${s}\x1b[0m`; /** * A refusal, in the terminal or as one JSON object. `link` + `remedy` (where to - * get a missing key, and what to do) and `tokenId` / `tokenCreated` are added - * only when the connector set them; without them the output is exactly what it - * has always been. Human text goes to stderr, never stdout. + * get a missing key, and what to do) are added only when the connector set + * them; without them the output is exactly what it has always been. Human text + * goes to stderr, never stdout. */ function printFailure(outcome: ConnectFailure, json: boolean): void { if (json) { @@ -32,16 +31,12 @@ function printFailure(outcome: ConnectFailure, json: boolean): void { message: outcome.message, ...(outcome.link ? { link: outcome.link } : {}), ...(outcome.remedy ? { remedy: outcome.remedy } : {}), - ...(outcome.tokenId ? { tokenId: outcome.tokenId } : {}), - ...(outcome.tokenCreated ? { tokenCreated: outcome.tokenCreated } : {}), }), ); return; } console.error(`\n ${outcome.message}\n`); - if (outcome.link && outcome.remedy) console.error(formatKeyHelp({ link: outcome.link, remedy: outcome.remedy }) + '\n'); - else if (outcome.remedy) console.error(` ${outcome.remedy}\n`); - if (outcome.tokenCreated) describeTokenCreated(outcome.tokenCreated).forEach((line) => console.error(line)); + if (outcome.remedy) console.error(formatKeyHelp({ remedy: outcome.remedy }) + '\n'); } /** Browser paths honour this so a test never opens the developer's real browser. */ @@ -276,12 +271,6 @@ export class ConnectCommand { // which has (or needs) a keep.lock yet. Discovery gets its OWN, lighter // context instead: org + auth + serviceClient only, no project key, no // `.env` decrypt — see `resolveDiscoveryContext`'s own doc. - // `--create-token` alone (no project to import, nothing to discover): make - // and save the key, report it, stop. Needs the org only — no keep.lock. - if (effective.createToken && mod.createToken && !effective.discover && !effective.project) { - return await this.executeCreateToken(mod, provider, effective); - } - if (effective.discover && mod.discover) { const discoveryCtx = await resolveDiscoveryContext(this.devMode); return await this.executeDiscovery(mod, provider, discoveryCtx, effective); @@ -442,33 +431,6 @@ export class ConnectCommand { return { linked: !failed }; } - /** - * `capy connect vercel --create-token` on its own. Names, ids and the team - * only — never the token's value. - */ - private async executeCreateToken( - mod: ConnectorModule, - provider: string, - opts: ConnectOpts, - // Org + sign-in only, like discovery. A parameter so a test can name the org without a login. - orgIdOf: () => Promise = async () => (await resolveDiscoveryContext(this.devMode)).orgId, - ): Promise<{ linked: boolean }> { - const outcome = await mod.createToken!(opts, orgIdOf); - if (!outcome.ok) { - printFailure(outcome, !!opts.json); - process.exitCode = 1; - return { linked: false }; - } - if (opts.json) { - console.log(JSON.stringify({ ok: true, provider, tokenCreated: outcome.tokenCreated })); - return { linked: false }; - } - console.log(''); - describeTokenCreated(outcome.tokenCreated).forEach((line) => console.log(line)); - console.log(''); - return { linked: false }; - } - /** * `capy connect ` — pulls the provider's variables into * `.env` in one pass, then reports. No browser ending page exists for this @@ -528,14 +490,14 @@ export class ConnectCommand { warnings: outcome.warnings, pushed, deployTargetSaved: outcome.deployTargetSaved, - ...(outcome.tokenCreated ? { tokenCreated: outcome.tokenCreated } : {}), + // `--targets` only: the deploy target by folder and name (`written: false` under --dry-run). + ...(outcome.deployTarget ? { deployTargets: [{ folder: '.', ...outcome.deployTarget }] } : {}), }), ); return { linked: wrote }; } console.log(''); - if (outcome.tokenCreated) describeTokenCreated(outcome.tokenCreated).forEach((line) => console.log(line)); if (dryRun) console.log(' Dry run — nothing written.'); if (!hasChange) { console.log(dryRun ? ' Nothing to import.' : ' Nothing new to import.'); @@ -579,6 +541,10 @@ export class ConnectCommand { mod.displayName ? ` Saved a ${label} deploy target.` : ` Saved a Dokploy deploy target for application ${outcome.applicationId}.`, ); } + if (outcome.deployTarget) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + console.log(` ${outcome.deployTarget.written ? 'Wrote' : 'Would write'} deploy target ${outcome.deployTarget.name}.`); + } console.log(''); return { linked: wrote }; } diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index f1275d5f..45fc0300 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -1329,10 +1329,14 @@ function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwr const folderPath = (repoDir: string, folder: string): string => (folder ? join(repoDir, folder) : repoDir); -/** keep.lock and .gitignore, relative to `repoDir` — the ONLY paths discovery's own commit step ever touches. */ -export function discoveryCommitPathsFor(folder: string): readonly string[] { +/** + * keep.lock and .gitignore, relative to `repoDir` — the ONLY paths discovery's + * own commit step ever touches. `deployConfig` (Vercel `--targets`) adds the + * folder's `.capy/deploy.json`, which the run itself wrote. + */ +export function discoveryCommitPathsFor(folder: string, opts: { deployConfig?: boolean } = {}): readonly string[] { const prefix = folder ? `${folder}/` : ''; - return [`${prefix}keep.lock`, `${prefix}.gitignore`]; + return [`${prefix}keep.lock`, `${prefix}.gitignore`, ...(opts.deployConfig ? [`${prefix}.capy/deploy.json`] : [])]; } /** @@ -1353,10 +1357,18 @@ export function commitPreviewForFolders( branchName: string, /** The commit subject; default is the Dokploy one. */ commitMessage?: string, + /** Vercel `--targets`: whether this folder's `.capy/deploy.json` is part of the commit. */ + deployConfigFor?: (folder: DiscoveryPlanFolder) => boolean, ): readonly DiscoveryRepoCommitResult[] { const repoDirs = [...new Set(folders.map((f) => f.repoDir))]; return repoDirs.map((repoDir) => { - const paths = [...new Set(folders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)))]; + const paths = [ + ...new Set( + folders + .filter((f) => f.repoDir === repoDir) + .flatMap((f) => discoveryCommitPathsFor(f.folder, { deployConfig: deployConfigFor?.(f) ?? false })), + ), + ]; const outcome = commitDiscoveryChanges(repoDir, paths, new Set(), { branchName, dryRun: true, summaryLines: [], ...(commitMessage ? { commitMessage } : {}) }); return { repoDir, ...outcome }; }); @@ -1375,12 +1387,14 @@ export function commitForAppliedRepos( branchName: string, /** The commit subject; default is the Dokploy one. */ commitMessage?: string, + /** Vercel `--targets`: whether this folder's run wrote a `.capy/deploy.json` that belongs in the commit. */ + deployConfigFor?: (folder: DiscoveryFolderResult) => boolean, ): readonly DiscoveryRepoCommitResult[] { const repoDirs = [...new Set(applied.map((f) => f.repoDir))]; return repoDirs.flatMap((repoDir) => { const okFolders = applied.filter((f) => f.repoDir === repoDir && f.ok); if (okFolders.length === 0) return []; - const paths = okFolders.flatMap((f) => discoveryCommitPathsFor(f.folder)); + const paths = okFolders.flatMap((f) => discoveryCommitPathsFor(f.folder, { deployConfig: deployConfigFor?.(f) ?? false })); const summaryLines = okFolders.map((f) => `- ${f.folder || '.'}: ${(f as Extract).activeBranch}`); const outcome = commitDiscoveryChanges(repoDir, paths, beforeStatusByRepo.get(repoDir) ?? new Set(), { branchName, diff --git a/src/commands/connectors/dokployDiscovery.ts b/src/commands/connectors/dokployDiscovery.ts index 30a536a2..508c5f38 100644 --- a/src/commands/connectors/dokployDiscovery.ts +++ b/src/commands/connectors/dokployDiscovery.ts @@ -119,7 +119,6 @@ import { KeepFile } from '../../types/index'; import type { AuthService } from '../../auth/authService'; import type { ServiceClient } from '../../service/serviceClient'; import type { ConnectFailure, ImportOutcome } from './registry'; -import type { TokenCreatedReport } from '../../deploy/vercelCliToken'; import type { DiscoveryCommitOutcome } from '../../git/discoveryCommit'; /** @@ -1194,6 +1193,17 @@ export async function runDiscoverySequences( */ export type DiscoveryRepoCommitResult = { repoDir: string } & DiscoveryCommitOutcome; +/** A deploy target Vercel `--targets` would write (dry run) or wrote, by folder and Capy branch. Names only. */ +export interface DiscoveryDeployTarget { + repoDir: string; + /** Relative to `repoDir`; `''` = repo root. */ + folder: string; + /** The Capy branch (= the Vercel environment) the target ships. */ + branch: string; + name: string; + written: boolean; +} + export type DiscoveryOutcome = | { ok: true; @@ -1205,7 +1215,7 @@ export type DiscoveryOutcome = cancelled?: boolean; /** One entry per repo touched by the plan (dry run: every repo the plan would touch) — see `DiscoveryRepoCommitResult`'s own doc. Absent only when nothing was ever going to be committed (no matched repos at all). */ commits?: readonly DiscoveryRepoCommitResult[]; - /** Vercel `--create-token` only: the token this run created and saved. Never its value. */ - tokenCreated?: TokenCreatedReport; + /** Vercel `--targets` under `--dry-run` only: the targets a real run would write. A real run reports them per environment (`applied`). */ + deployTargets?: readonly DiscoveryDeployTarget[]; } | ConnectFailure; diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index 70cfef89..a128971c 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -1,8 +1,8 @@ import { ConnectorMetadata } from '../../types/index'; +import type { TargetConfig } from '../../deploy/adapter'; import type { Blocked } from '../../ui/screens/contract'; import type { DokployImportSource } from '../../deploy/dokployApi'; import type { VercelImportSource } from '../../deploy/vercelApi'; -import type { TokenCreatedReport } from '../../deploy/vercelCliToken'; import type { DiscoveryContext, DiscoveryOutcome } from './dokployDiscovery'; import { ResolvedContext } from './shared'; @@ -186,21 +186,26 @@ export interface ConnectOpts { * project again. */ vercelProject?: { id: string; name: string; productionBranch?: string }; - /** - * Vercel only (flag `--create-token`): create `_CONNECTOR_VERCEL_TOKEN` from - * the Vercel CLI's sign-in and save it to the org system store, without - * prompting. With `--project`/`--discover` the run then continues; alone, it - * stops after reporting the token. Refused under `--dry-run`. - */ - createToken?: boolean; - /** Vercel only (flag `--token-expires-days`): how many days the created token lasts. Absent = no expiry. */ - tokenExpiresDays?: string | number; /** * Vercel import only: the folder a saved deploy target's `.capy/deploy.json` * belongs in. Set INTERNALLY by discovery (each folder has its own); a plain * import uses the current directory. */ projectDir?: string; + /** + * Vercel only (flag `--targets`): also write a Vercel deploy target for each + * environment that imports, without asking — in non-interactive and `--json` + * runs too. Under `--dry-run` the targets are listed, never written. + */ + targets?: boolean; +} + +/** A deploy target `--targets` wrote (`written`), or under `--dry-run` would write. Names only. */ +export interface DeployTargetReport { + name: string; + /** The Vercel environment (= the Capy branch) the target ships to. */ + environment: string; + written: boolean; } export interface RotateOpts { @@ -290,6 +295,14 @@ export type ImportOutcome = skipped: readonly ImportSkip[]; warnings: readonly ImportWarning[]; deployTargetSaved: boolean; + /** Vercel `--targets` only: the deploy target this import wrote (dry run: would write). Absent when none applies. */ + deployTarget?: DeployTargetReport; + /** + * Vercel `--targets` only, internal: the target for `writeImportOutcome` + * to save into `dir`'s `.capy/deploy.json` once the import itself is + * written — so a failed write leaves no target behind. Never set under a dry run. + */ + deployTargetToWrite?: { dir: string; target: TargetConfig }; /** * Dokploy import only: names with a different local value that a real * (non-dry-run) run would have prompted about. Only populated under @@ -325,17 +338,12 @@ export type ImportOutcome = * `--overwrite` decline branch, where nothing is touched at all. */ unchangedEntries?: readonly ImportedVarEntry[]; - /** Vercel `--create-token` only: the token this run created and saved. Never its value. */ - tokenCreated?: TokenCreatedReport; } | ConnectFailure; /** * A refusal. `link` + `remedy` (same field names as `Blocked`) say where to get - * a missing or rejected API key and what the human does next; `tokenId` names a - * Vercel token that was created and could not be removed (never its value); - * `tokenCreated` is set when a token WAS created and saved before the refusal. - * All additive. + * a missing or rejected API key and what the human does next. Additive. */ export interface ConnectFailure { ok: false; @@ -344,13 +352,8 @@ export interface ConnectFailure { message: string; link?: { label: string; url: string }; remedy?: string; - tokenId?: string; - tokenCreated?: TokenCreatedReport; } -/** `capy connect vercel --create-token` on its own: the token that was made and saved, or why not. */ -export type CreateTokenOutcome = { ok: true; tokenCreated: TokenCreatedReport } | ConnectFailure; - export interface ConnectorModule { name: string; description: string; @@ -424,13 +427,6 @@ export interface ConnectorModule { * that implement it — no other provider does today. */ discover?(ctx: DiscoveryContext, opts: ConnectOpts): Promise; - /** - * `--create-token` with neither `--project` nor `--discover`: make and save - * the provider's API key, report it, import nothing. `orgIdOf` resolves the - * org (it may authenticate), so the connector can refuse a bad request - * before any of that. Present only on connectors that can make their key. - */ - createToken?(opts: ConnectOpts, orgIdOf: () => Promise): Promise; /** * A connector whose discovery plan has its own shape (Vercel: project name * + id, skipped names grouped by reason) prints and emits it here instead diff --git a/src/commands/connectors/shared.ts b/src/commands/connectors/shared.ts index 5d2c443f..a85434ef 100644 --- a/src/commands/connectors/shared.ts +++ b/src/commands/connectors/shared.ts @@ -374,6 +374,20 @@ export async function writeImportOutcome( opts: { push: boolean; dryRun: boolean }, ): Promise<{ wrote: boolean }> { if (opts.dryRun) return { wrote: false }; + const written = await writeImportedValues(ctx, outcome, opts); + // `--targets`: the deploy target is saved only after the import itself landed. + if (outcome.deployTargetToWrite) { + const { upsertTarget } = await import('../../deploy/config'); + upsertTarget(outcome.deployTargetToWrite.dir, outcome.deployTargetToWrite.target); + } + return written; +} + +async function writeImportedValues( + ctx: ResolvedContext, + outcome: Extract, + opts: { push: boolean }, +): Promise<{ wrote: boolean }> { const cleared = outcome.cleared ?? []; // CAP-673: a dokploy import's same-value names still carry a FRESH // connector entry (see `ImportOutcome.unchangedEntries`'s doc) — merged diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts index 8594dce8..5ee02738 100644 --- a/src/commands/connectors/vercel.ts +++ b/src/commands/connectors/vercel.ts @@ -21,17 +21,14 @@ * `legacy_secret_unreadable`, `vercel_system`, `unsupported_type`, * `duplicate_key`, `empty_value`. * - * `--create-token` makes `_CONNECTOR_VERCEL_TOKEN` from the Vercel CLI's own - * sign-in (see `deploy/vercelCliToken.ts`) instead of a hand-made one. With - * `--project`/`--discover` the run goes on to import; alone it stops after - * saving the token. Interactively, a missing token offers the same thing next - * to the hidden prompt. Every refusal for a missing or rejected token carries - * the Vercel token page (`link`) and what to do (`remedy`). + * Every refusal for a missing or rejected token carries the Vercel token page + * (`link`) and what to do (`remedy`), with the team's slug when `--team` names it. * * Deploy targets: like Dokploy, a target is OFFERED only after a single * import, only interactively (never under `--json`, `--dry-run`, or * non-interactive). Discovery never saves one by itself. */ +import { join } from 'node:path'; import { TargetConfig } from '../../deploy/adapter'; import { classifyImportCandidates } from '../../deploy/dokployApi'; import { @@ -59,17 +56,7 @@ import { resolveVercelToken, vercelSecretsMayPrompt, } from '../../deploy/vercelApi'; -import { - MintTarget, - TokenCreatedReport, - TokenFailure, - VercelCliDeps, - describeTokenCreated, - hasVercelCli, - mintTeamToken, - parseExpiryDays, -} from '../../deploy/vercelCliToken'; -import { formatKeyHelp, keyHelpFields, vercelKeyHelp } from '../../deploy/keyHelp'; +import { keyHelpFields, teamSlugOf, vercelKeyHelp } from '../../deploy/keyHelp'; import { ConnectorMetadata, ERROR_CODES } from '../../types/index'; import { isInteractive } from '../../ui/interactive'; import { listOrgProjectsOrUnavailable } from '../capyCommand'; @@ -80,7 +67,6 @@ import { ConnectOpts, ConnectorModule, ConnectResult, - CreateTokenOutcome, ImportOutcome, ImportSkip, ImportWarning, @@ -92,6 +78,8 @@ import { DiscoveryContext, DiscoveryOutcome, DiscoveryPlan, + DiscoveryDeployTarget, + DiscoveryFolderResult, DiscoveryPlanFolder, applyCollisionResolutions, findCandidateReposResult, @@ -148,24 +136,6 @@ async function defaultPickTeam(teams: readonly VercelTeam[]): Promise { return picked; } -// COPY-FLAG: new user-facing string, minimal/neutral wording. -async function defaultAskCreateMethod(): Promise<'create' | 'paste'> { - const inquirer = (await import('inquirer')).default; - const { picked } = await inquirer.prompt([ - { - type: 'list', - name: 'picked', - message: `${VERCEL_CONNECTOR_SECRET_NAME} is not set. How do you want to provide it:`, - choices: [ - { name: 'Create one with the Vercel CLI sign-in', value: 'create' }, - { name: 'Paste a token', value: 'paste' }, - ], - default: 'create', - }, - ]); - return picked; -} - // COPY-FLAG: new user-facing string, minimal/neutral wording. async function defaultAskProject(): Promise { const inquirer = (await import('inquirer')).default; @@ -202,18 +172,6 @@ export interface VercelConnectorDeps { * singleton wires the real `system/systemStore.ts` lookup. */ getConnectorSecret?: (name: string, opts: VercelSystemStoreCallOptions) => Promise; - /** The Vercel CLI: how it is run and found. `env` is taken from `env` above. Tests inject a stub. */ - cli?: Pick; - /** Opens the org system store for `--create-token` (the same store the hidden prompt saves to). Defaults to the real one. */ - openStore?: (orgId: string, opts: { devMode?: boolean }) => Promise; - /** Interactive: create the token with the Vercel CLI, or paste one. */ - askCreateMethod?: () => Promise<'create' | 'paste'>; -} - -/** The part of the system store `--create-token` uses. */ -export interface CreateTokenStore { - listNames(): readonly { name: string }[]; - set(name: string, value: string): Promise; } // ── Connector entry (keep.lock metadata) ──────────────────────────────────── @@ -345,12 +303,52 @@ function uniqueTargetName(existing: readonly TargetConfig[], base: string): stri return names.has(base) ? numbered(2) : base; } -interface TargetOfferArgs { +/** The name a new `vercel-` target for this project + environment gets; `null` when one already exists. */ +function newTargetName(allTargets: readonly TargetConfig[], projectId: string, environmentName: string): string | null { + const already = allTargets.some((t) => { + const o = t.options as { projectId?: string; vercelEnv?: string }; + return t.kind === 'vercel' && o.projectId === projectId && o.vercelEnv === environmentName; + }); + return already ? null : uniqueTargetName(allTargets, `vercel-${environmentName}`); +} + +interface TargetArgs { importedNames: readonly string[]; project: { id: string; name: string; productionBranch?: string }; teamId: string | undefined; environment: VercelEnvironmentInfo; allTargets: readonly TargetConfig[]; +} + +/** + * The `vercel-` deploy target for the SAME project + environment + * an import just read — API mode, all-branches vars (no `gitBranch`), CI mode, + * `gitBaseBranch` the project's production branch for `production`. `null` + * when nothing was imported or the project + environment already has a target. + * The one definition both the interactive offer and `--targets` save. + */ +function buildDeployTarget(args: TargetArgs): TargetConfig | null { + if (args.importedNames.length === 0) return null; + const name = newTargetName(args.allTargets, args.project.id, args.environment.name); + if (name === null) return null; + return { + name, + kind: 'vercel', + branch: args.environment.name, + vars: [...args.importedNames], + mode: 'ci', + ...(args.environment.name === 'production' && args.project.productionBranch ? { gitBaseBranch: args.project.productionBranch } : {}), + options: { + projectDir: '.', + vercelEnv: args.environment.name, + projectId: args.project.id, + ...(args.teamId ? { teamId: args.teamId } : {}), + ...(args.environment.customEnvironmentId ? { customEnvironmentId: args.environment.customEnvironmentId } : {}), + }, + }; +} + +interface TargetOfferArgs extends TargetArgs { promptable: boolean; dir: string; confirm: (message: string, defaultValue: boolean) => Promise; @@ -358,18 +356,14 @@ interface TargetOfferArgs { /** * After a successful single import, offer to save a Vercel deploy target for - * the SAME project + environment — all-branches vars (no `gitBranch`), CI - * mode, `gitBaseBranch` the project's production branch for `production`. - * Only asked interactively; non-interactive, `--json` and `--dry-run` never - * ask and never save. + * the SAME project + environment. Only asked interactively; non-interactive, + * `--json` and `--dry-run` never ask and never save (`--targets` is the way to + * save one without asking). */ async function maybeOfferDeployTarget(args: TargetOfferArgs): Promise { if (args.importedNames.length === 0 || !args.promptable) return false; - const already = args.allTargets.some((t) => { - const o = t.options as { projectId?: string; vercelEnv?: string }; - return t.kind === 'vercel' && o.projectId === args.project.id && o.vercelEnv === args.environment.name; - }); - if (already) return false; + const target = buildDeployTarget(args); + if (!target) return false; // COPY-FLAG: new user-facing string, minimal/neutral wording. const yes = await args.confirm( @@ -379,24 +373,24 @@ async function maybeOfferDeployTarget(args: TargetOfferArgs): Promise { if (!yes) return false; const { upsertTarget } = await import('../../deploy/config'); - upsertTarget(args.dir, { - name: uniqueTargetName(args.allTargets, `vercel-${args.environment.name}`), - kind: 'vercel', - branch: args.environment.name, - vars: [...args.importedNames], - mode: 'ci', - ...(args.environment.name === 'production' && args.project.productionBranch ? { gitBaseBranch: args.project.productionBranch } : {}), - options: { - projectDir: '.', - vercelEnv: args.environment.name, - projectId: args.project.id, - ...(args.teamId ? { teamId: args.teamId } : {}), - ...(args.environment.customEnvironmentId ? { customEnvironmentId: args.environment.customEnvironmentId } : {}), - }, - }); + upsertTarget(args.dir, target); return true; } +/** + * `--targets`: the target this import would write, and where — planned here, + * saved by `writeImportOutcome` once the import itself is written (never under + * a dry run, which only reports it). + */ +function planTargetsOutcome( + args: TargetArgs & { dir: string; dryRun: boolean }, +): Pick, 'deployTarget' | 'deployTargetToWrite'> { + const target = buildDeployTarget(args); + if (!target) return {}; + const deployTarget = { name: target.name, environment: args.environment.name, written: !args.dryRun }; + return args.dryRun ? { deployTarget } : { deployTarget, deployTargetToWrite: { dir: args.dir, target } }; +} + // ── Connector ──────────────────────────────────────────────────────────────── export function createVercelConnector(deps: VercelConnectorDeps = {}): ConnectorModule { @@ -412,105 +406,16 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const askDiscoveryProjectName = deps.askDiscoveryProjectName ?? defaultAskDiscoveryProjectName; const askDiscoveryCommitBranchName = deps.askDiscoveryCommitBranchName ?? defaultAskDiscoveryCommitBranchName; - // ── Vercel CLI token creation ────────────────────────────────────────────── - - const cliDeps = (): VercelCliDeps => ({ env: env(), ...deps.cli }); - const askCreateMethod = deps.askCreateMethod ?? defaultAskCreateMethod; - const openStore = - deps.openStore ?? - (async (orgId: string, o: { devMode?: boolean }): Promise => - (await import('../../system/systemStore')).openSystemStore({ orgId, devMode: o.devMode })); - - /** A typed store error's code — never its message. */ - const storeCodeOf = (err: unknown): string => { - const code = (err as { code?: unknown } | null)?.code; - return typeof code === 'string' && code !== '' ? code : ERROR_CODES.SERVICE_ERROR; - }; - - const tokenHelp = () => keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME)); - - /** Opens the store BEFORE a token exists, so a caller who cannot save one is refused before one is made. */ - const prepareStore = - (orgId: string, opts: ConnectOpts) => - async (): Promise => { - try { - const store = await openStore(orgId, { devMode: opts.devMode }); - return { - ok: true, - save: (value: string) => store.set(VERCEL_CONNECTOR_SECRET_NAME, value), - replacedExisting: store.listNames().some((n) => n.name === VERCEL_CONNECTOR_SECRET_NAME), - }; - } catch (err) { - const code = storeCodeOf(err); - // COPY-FLAG: new user-facing string, minimal/neutral wording. - return { ok: false, code, message: `Could not open the org system store (${code}).`, ...tokenHelp() }; - } - }; - - /** `--create-token`'s whole run: validate, open the store, create, check, save. */ - const mintForRun = async (orgId: string, opts: ConnectOpts, promptable: boolean) => { - const expiry = parseExpiryDays(opts.tokenExpiresDays); - if (!expiry.ok) return expiry; - return mintTeamToken(cliDeps(), { - team: opts.team, - expiresDays: expiry.days, - pickTeam: promptable ? pickTeam : null, - prepare: prepareStore(orgId, opts), - askSignOut: promptable ? confirm : null, - }); - }; - - /** A preview never creates or saves a key. */ - const createTokenDryRunRefusal = (opts: ConnectOpts): Failure | null => - opts.createToken && opts.dryRun - ? { - ok: false, - code: ERROR_CODES.VERCEL_CREATE_TOKEN_DRY_RUN, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: 'A dry run never creates or saves a key. Drop --dry-run or --create-token.', - } - : null; - - /** Interactive and the token is missing: offer the Vercel CLI route next to the hidden prompt. A failure is shown and falls back to the prompt. */ - const offerCreateViaCli = (orgId: string, opts: ConnectOpts) => async (save: (value: string) => Promise): Promise => { - if ((await askCreateMethod()) !== 'create') return null; - const expiry = parseExpiryDays(opts.tokenExpiresDays); - const minted = expiry.ok - ? await mintTeamToken(cliDeps(), { - team: opts.team, - expiresDays: expiry.days, - pickTeam, - // The store is already open (the hidden prompt's own), so the admin rule has already applied. - prepare: async () => ({ ok: true as const, save, replacedExisting: false }), - askSignOut: confirm, - }) - : expiry; - if (!minted.ok) { - console.error(`\n ${minted.message}`); - console.error(formatKeyHelp({ link: minted.link ?? tokenHelp().link, remedy: minted.remedy ?? tokenHelp().remedy }) + '\n'); - return null; - } - describeTokenCreated(minted.report).forEach((line) => console.log(line)); - return minted.bearer; - }; + /** Where to get a token: the page, the Scope instruction, and the team's slug when `--team` names it. */ + const tokenHelp = (opts: ConnectOpts) => keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME, { team: opts.team })); /** The token, then a client — or the coded refusal. Resolved BEFORE any Vercel request, so a missing token means zero requests. */ const openClient = async ( orgId: string, opts: ConnectOpts, interactive: boolean, - ): Promise<{ ok: true; client: VercelClient; tokenEnv: string; tokenCreated?: TokenCreatedReport } | Failure> => { + ): Promise<{ ok: true; client: VercelClient; tokenEnv: string } | Failure> => { const tokenEnv = opts.tokenEnv?.trim() || DEFAULT_VERCEL_TOKEN_ENV; - if (opts.createToken) { - const minted = await mintForRun(orgId, opts, interactive && !opts.json); - if (!minted.ok) return minted; - return { - ok: true, - tokenEnv, - client: createVercelClient(minted.bearer, { fetch: deps.fetch, sleep: deps.sleep }), - tokenCreated: minted.report, - }; - } // `--web`, `--json` and `--dry-run` each suppress ONLY the system store's // hidden prompt: a raw terminal prompt must not hang a browser run, be // interleaved with machine output, or save a key as a preview's side effect. @@ -522,11 +427,11 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector orgId, devMode: opts.devMode, getConnectorSecret: deps.getConnectorSecret, - ...(mayPrompt && hasVercelCli(cliDeps()) ? { onMissing: offerCreateViaCli(orgId, opts) } : {}), + ...(teamSlugOf(opts.team) ? { helpTeam: teamSlugOf(opts.team) } : {}), }); if (!resolved.ok) { const { reason } = describeVercelTokenProblem(resolved.code, tokenEnv); - return { ok: false, code: resolved.code, message: `${reason}.`, ...tokenHelp() }; + return { ok: false, code: resolved.code, message: `${reason}.`, ...tokenHelp(opts) }; } return { ok: true, @@ -599,6 +504,26 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector return { ok: true, project: { id: found.project.id, name: found.project.name, ...(productionBranch ? { productionBranch } : {}) } }; }; + /** `--targets` only: the target this import writes (dry run: would write). Nothing when the flag is off. */ + const plannedTargets = async ( + opts: ConnectOpts, + source: SourceRead, + importedNames: readonly string[], + dryRun: boolean, + ): Promise, 'deployTarget' | 'deployTargetToWrite'>> => { + if (!opts.targets) return {}; + const dir = opts.projectDir ?? cwd(); + return planTargetsOutcome({ + importedNames, + project: source.project, + teamId: source.teamId, + environment: source.environment, + allTargets: await listTargetsAt(dir), + dir, + dryRun, + }); + }; + /** `--overwrite`'s own run: plan from Vercel's FULL readable set (never `--var`-restricted), confirm, return the outcome for the caller to write. */ const runOverwriteImport = async (a: { ctx: ResolvedContext; @@ -651,6 +576,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector skipped: [...source.skipped, ...values.value.empty].map(toImportSkip), warnings, deployTargetSaved: false, + ...(await plannedTargets(opts, source, [...written.map((w) => w.varName), ...plan.unchanged], dryRun)), }; }; @@ -708,16 +634,20 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector ]; const importedNames = [...imported.map((i) => i.varName), ...unchanged]; + const dir = opts.projectDir ?? cwd(); + // `--targets` writes without asking; otherwise the target is only offered, and only interactively. + const planned = await plannedTargets(opts, source, importedNames, dryRun); const deployTargetSaved = !dryRun && + !opts.targets && (await maybeOfferDeployTarget({ importedNames, project: source.project, teamId: source.teamId, environment: source.environment, - allTargets: await listTargetsAt(opts.projectDir ?? cwd()), + allTargets: await listTargetsAt(dir), promptable, - dir: opts.projectDir ?? cwd(), + dir, confirm, })); @@ -730,10 +660,25 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector skipped, warnings: truncationWarnings(source), deployTargetSaved, + ...planned, ...(dryRun ? { wouldAsk } : {}), }; }; + /** `--targets` under `--dry-run`: the target each plan environment would get, by folder. Names only. */ + const plannedTargetsFor = async (folders: readonly DiscoveryPlanFolder[]): Promise => { + const perFolder = await Promise.all( + folders.map(async (f) => { + const existing = await listTargetsAt(f.folder ? join(f.repoDir, f.folder) : f.repoDir); + return orderEnvironments(f.environments).flatMap((e): readonly DiscoveryDeployTarget[] => { + const name = e.variableCount > 0 ? newTargetName(existing, e.serviceId, e.environmentName) : null; + return name === null ? [] : [{ repoDir: f.repoDir, folder: f.folder, branch: e.environmentName, name, written: false }]; + }); + }), + ); + return perFolder.flat(); + }; + /** Everything after the client exists: resolve the team, build the plan, apply it. */ const discoverFrom = async ( ctx: DiscoveryContext, @@ -780,8 +725,11 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector if (dryRun) { // A dry run never asks for a branch name either; the DEFAULT is what it previews. - const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), VERCEL_COMMIT_MESSAGE); - return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}) }; + // `--targets`: the targets it would write are listed, and their file is part of the previewed commit. + const targets = opts.targets ? await plannedTargetsFor(plan.folders) : []; + const hasTargets = (folder: DiscoveryPlanFolder): boolean => targets.some((t) => t.repoDir === folder.repoDir && t.folder === folder.folder); + const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), VERCEL_COMMIT_MESSAGE, hasTargets); + return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}), ...(opts.targets ? { deployTargets: targets } : {}) }; } // `--project` under `--discover` is the collision-winner hint (an id or a name), not an import source. @@ -821,7 +769,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector // "discovery made this change" from "already dirty before the run". const beforeStatusByRepo = new Map( [...new Set(resolvedFolders.map((f) => f.repoDir))].map((repoDir) => { - const paths = resolvedFolders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)); + const paths = resolvedFolders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder, { deployConfig: !!opts.targets })); return [repoDir, snapshotPathStatus(repoDir, paths)] as const; }), ); @@ -845,6 +793,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector environment: env.environmentName, tokenEnv, projectDir: folderPath, + ...(opts.targets ? { targets: true } : {}), }), askExistingOrNewProject, askProjectName: askDiscoveryProjectName, @@ -852,7 +801,9 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector sameRunNameCollisions, }); const applied = await runDiscoverySequences(resolvedFolders, { overwrite: !!opts.overwrite }, sequenceDeps); - const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, VERCEL_COMMIT_MESSAGE); + // `--targets`: a folder that wrote a deploy target commits its `.capy/deploy.json` with its keep.lock. + const wroteTarget = (folder: DiscoveryFolderResult): boolean => folder.environments.some((e) => e.outcome.deployTarget?.written === true); + const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, VERCEL_COMMIT_MESSAGE, wroteTarget); return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}) }; }; @@ -872,15 +823,13 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector }, async import(ctx: ResolvedContext, opts: ConnectOpts): Promise { - const refused = createTokenDryRunRefusal(opts); - if (refused) return refused; const interactive = isInteractive(opts.nonTty); const opened = await openClient(ctx.orgId, opts, interactive); if (!opened.ok) return opened; const outcome = await importFrom(ctx, opts, opened.client, interactive); - return withTokenHelp(withTokenCreated(outcome, opened.tokenCreated)); + return withTokenHelp(outcome, opts); }, /** @@ -890,26 +839,13 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector * `vercelDiscovery.ts`. */ async discover(ctx: DiscoveryContext, opts: ConnectOpts): Promise { - const refused = createTokenDryRunRefusal(opts); - if (refused) return refused; const interactive = isInteractive(opts.nonTty); const opened = await openClient(ctx.orgId, opts, interactive); if (!opened.ok) return opened; const outcome = await discoverFrom(ctx, opts, opened.client, opened.tokenEnv, interactive); - return withTokenHelp(withTokenCreated(outcome, opened.tokenCreated)); - }, - - /** `--create-token` on its own: make and save the token, report it, import nothing. */ - async createToken(opts: ConnectOpts, orgIdOf: () => Promise): Promise { - const refused = createTokenDryRunRefusal(opts); - if (refused) return refused; - const expiry = parseExpiryDays(opts.tokenExpiresDays); - if (!expiry.ok) return expiry; - const promptable = isInteractive(opts.nonTty) && !opts.json; - const minted = await mintForRun(await orgIdOf(), opts, promptable); - return minted.ok ? { ok: true, tokenCreated: minted.report } : minted; + return withTokenHelp(outcome, opts); }, reportDiscovery: reportVercelDiscovery, @@ -919,15 +855,10 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector // ── small helpers ─────────────────────────────────────────────────────────── -/** Adds a created-token report to an outcome (success or refusal) when the run made one. */ -function withTokenCreated(outcome: T, report: TokenCreatedReport | undefined): T { - return report ? { ...outcome, tokenCreated: report } : outcome; -} - /** A token Vercel rejected: say where to get another. (A missing token already carries it, from `openClient`.) */ -function withTokenHelp(outcome: T): T { +function withTokenHelp(outcome: T, opts: ConnectOpts): T { if (outcome.ok || outcome.link || outcome.code !== ERROR_CODES.VERCEL_AUTH_FAILED) return outcome; - return { ...outcome, ...keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME)) } as T; + return { ...outcome, ...keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME, { team: opts.team })) } as T; } diff --git a/src/commands/connectors/vercelDiscovery.ts b/src/commands/connectors/vercelDiscovery.ts index 0a0604fb..a6e14687 100644 --- a/src/commands/connectors/vercelDiscovery.ts +++ b/src/commands/connectors/vercelDiscovery.ts @@ -49,7 +49,6 @@ import { orderEnvironments, } from './dokployDiscovery'; import type { ConnectOpts } from './registry'; -import { describeTokenCreated } from '../../deploy/vercelCliToken'; /** `listProjects`/`listCustomEnvironments`/`listEnvs` only — the read-only surface planning needs. */ export type VercelDiscoveryClient = Pick; @@ -209,6 +208,7 @@ function envReport(e: Extract['environments branchCreated: e.branchCreated, overwrote: e.overwrote, imported: e.outcome.imported.map((i) => i.varName), + ...(e.outcome.deployTarget ? { deployTarget: e.outcome.deployTarget } : {}), ...(e.outcome.replacedNames ? { replacedNames: e.outcome.replacedNames } : {}), ...(e.outcome.cleared ? { cleared: e.outcome.cleared } : {}), unchanged: e.outcome.unchanged, @@ -254,6 +254,17 @@ function appliedReport(applied: readonly DiscoveryFolderResult[]) { ); } +/** `--targets`: every deploy target by folder and name — written (a real run) or, under `--dry-run`, planned. */ +function deployTargetsReport(outcome: Extract) { + const written = (outcome.applied ?? []).flatMap((f) => + f.environments.flatMap((e) => + e.outcome.deployTarget ? [{ folder: f.folder || '.', branch: e.environmentName, name: e.outcome.deployTarget.name, written: e.outcome.deployTarget.written }] : [], + ), + ); + const planned = (outcome.deployTargets ?? []).map((t) => ({ folder: t.folder || '.', branch: t.branch, name: t.name, written: t.written })); + return [...planned, ...written]; +} + function commitsReport(commits: NonNullable['commits']>) { return commits.map((c) => c.ok @@ -294,14 +305,13 @@ export function reportVercelDiscovery( plan, ...(applied ? { applied } : {}), ...(commits ? { commits } : {}), - ...(outcome.tokenCreated ? { tokenCreated: outcome.tokenCreated } : {}), + ...(opts.targets ? { deployTargets: deployTargetsReport(outcome) } : {}), }), ); return { linked }; } - if (outcome.tokenCreated) describeTokenCreated(outcome.tokenCreated).forEach((l) => console.log(l)); - const lines = humanLines(outcome, plan, applied, commits); + const lines = [...humanLines(outcome, plan, applied, commits), ...(opts.targets ? targetLines(deployTargetsReport(outcome)) : [])]; lines.forEach((l) => console.log(l)); return { linked }; } @@ -358,6 +368,16 @@ function appliedLines(outcome: Extract, applied: ]); } +// COPY-FLAG: new user-facing string, minimal/neutral wording. +function targetLines(targets: ReturnType): readonly string[] { + if (targets.length === 0) return []; + return [ + ' Deploy targets:', + ...targets.map((t) => ` ${t.folder}: ${t.written ? 'wrote' : 'would write'} ${t.name} (${t.branch})`), + '', + ]; +} + // COPY-FLAG: report lines mirror Dokploy's discovery report word for word, with Vercel substituted; nothing new invented. function commitLine(c: ReturnType[number]): string { if (!c.ok) return ` ${c.repoDir}: ✗ ${c.code}: ${c.message}`; diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index 70231fc4..f533d4f6 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -13,7 +13,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join, basename } from 'path'; import inquirer from 'inquirer'; -import { formatKeyHelp } from '../deploy/keyHelp'; +import { formatKeyHelp, teamSlugOf } from '../deploy/keyHelp'; import { FileManager } from '../files/fileManager'; import { DeployAdapter, @@ -76,6 +76,7 @@ import { resolveDokployEnvironmentLabel, } from '../deploy/adapters/dokploy'; import type { ResolveDokployApiKeyResult, DokployServiceKind, DokployServiceUrlOk } from '../deploy/adapters/dokploy'; +import { resolveVercelSetup } from '../deploy/vercelSetup'; import { ResolveVercelTokenResult, getVercelTargetSecretFromStore, @@ -249,12 +250,14 @@ async function resolveVercelTokenOnce( ): Promise { if (adapter.id !== 'vercel') return undefined; if (!isVercelApiMode(target.options)) return undefined; + const team = teamSlugOf(typeof target.options.teamSlug === 'string' ? target.options.teamSlug : undefined); return resolveVercelTargetToken({ env: process.env, interactive, orgId, devMode, getSecret: getVercelTargetSecretFromStore, + ...(team ? { helpTeam: team } : {}), }); } @@ -988,9 +991,12 @@ async function promptVercelGitBranch( /** * Adapters whose settings step has no browser screen yet. They are left out of * the browser's adapter list, and a run that already knows it is one of them - * asks its questions in the terminal instead. + * asks its questions in the terminal instead. Vercel is one: its setup is the + * one link question plus live checks (`deploy/vercelSetup.ts`), which the old + * browser settings screen (project directory, environment, git branch) cannot + * ask — it would save a target on the removed `vercel link` path. */ -const TERMINAL_ONLY_SETUP: ReadonlySet = new Set(['dokploy']); +const TERMINAL_ONLY_SETUP: ReadonlySet = new Set(['dokploy', 'vercel']); function adapterChoices(): DeployAdapterChoice[] { return [ @@ -1491,6 +1497,8 @@ export async function resolveAdapterOptions( * existing caller that never had an org id to pass, is unaffected. */ orgId?: string, + /** `readOnly`: a dry run — the Vercel setup may read the token from the store but never asks for or saves one. */ + setup?: { readOnly?: boolean }, ): Promise> { if (adapter.id === 'cf-worker') { return await inquirer.prompt([ @@ -1510,48 +1518,11 @@ export async function resolveAdapterOptions( }, ]); } - if (adapter.id === 'vercel' && isVercelApiMode(existingOpts)) { - // API-mode target (CAP-696): its project/team/environment identity is set - // by whoever created it. Re-asking the CLI-path questions here would - // silently drop `projectId` and flip the target back to the CLI path. - return { ...existingOpts }; - } if (adapter.id === 'vercel') { - // Vercel: code ships via the keep.lock PR (Vercel git CI builds on merge), - // but capy pushes each var as a plaintext Environment Variable into the - // chosen Vercel environment via the vercel CLI — so the build reads them - // natively with no `capy run` decrypt step. Capture the app dir, which - // Vercel environment these vars go to, and — for Preview — exactly which - // git branch that Preview env is wired to. The Preview scope is a GIT - // branch Vercel knows about, which is NOT a capy branch name nor necessarily - // the branch you're checked out on, so we pick from the repo's real branches. - const ans = await inquirer.prompt([ - { - type: 'input', - name: 'projectDir', - message: 'Project directory (contains .vercel/project.json or package.json):', - default: existingOpts.projectDir ?? detectedOpts.projectDir ?? '.', - validate: (v: string) => (v.trim() ? true : 'required'), - }, - { - type: 'list', - name: 'vercelEnv', - message: 'Which Vercel environment should these secrets go to?', - choices: [ - { name: 'Preview — scoped to a specific git branch', value: 'preview' }, - { name: 'Production', value: 'production' }, - ], - default: existingOpts.vercelEnv ?? 'preview', - }, - ]); - // Drop gitBranch entirely for production — it has no meaning there. - return ans.vercelEnv === 'preview' - ? { - projectDir: ans.projectDir, - vercelEnv: 'preview', - gitBranch: await promptVercelGitBranch(cwd, existingOpts.gitBranch), - } - : { projectDir: ans.projectDir, vercelEnv: 'production' }; + // API mode only: the project's dashboard link (or name), checked live + // against Vercel. No `vercel` program, no `.vercel/project.json`, no + // `vercel link`. See `deploy/vercelSetup.ts`. + return (await resolveVercelSetup(existingOpts, orgId, { readOnly: setup?.readOnly })).options; } if (adapter.id === 'cf-pages') { return await inquirer.prompt([ @@ -1702,10 +1673,13 @@ async function resolveGitBaseBranch( cwd: string, mode: DeployMode, existing: TargetConfig | undefined, + /** A branch the platform itself names (Vercel's production branch): it leads the defaults. */ + platformBranch?: string, ): Promise { if (mode !== 'ci') return undefined; const local = listLocalBranches(cwd); const fallback = + platformBranch ?? currentBranch(cwd) ?? existing?.gitBaseBranch ?? (local.includes('main') ? 'main' : local.includes('master') ? 'master' : 'main'); @@ -1755,13 +1729,15 @@ async function pickNewVars(target: TargetConfig, added: readonly string[]): Prom return ans.vars; } -async function runPicker( +export async function runPicker( cwd: string, keep: KeepInfo, existing?: TargetConfig, /** When set, skip adapter-selection (caller has already picked). */ preselectedAdapterId?: string, web?: WebPickerOptions, + /** `readOnly`: a dry run — a setup question that would save a key to the store does not ask. */ + setup?: { readOnly?: boolean }, ): Promise { // Scope the picker to the ACTIVE branch's vars. keep.lock's `variables` is the // union across EVERY branch, so it would offer vars that only exist on @@ -1776,12 +1752,12 @@ async function runPicker( const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); const branchVars = keep.variables.filter((v) => branchVarSet.has(v)); - // Dokploy has no browser settings screen yet (browser screens are built in - // the Keep workbench first), so its setup stays in the terminal. + // Dokploy and Vercel have no browser settings screen (browser screens are + // built in the Keep workbench first), so their setup stays in the terminal. const knownAdapterId = existing?.kind ?? preselectedAdapterId; const browserSetup = !!web?.web && !(knownAdapterId && TERMINAL_ONLY_SETUP.has(knownAdapterId)); if (web?.web && !browserSetup) { - console.log(` ${DIM('Dokploy setup runs in the terminal.')}`); + console.log(` ${DIM(`${getAdapter(knownAdapterId ?? '')?.label ?? knownAdapterId} setup runs in the terminal.`)}`); } if (web?.web && browserSetup) { @@ -1829,12 +1805,7 @@ async function runPicker( branch: picked.branch, vars: picked.vars, knownVars: branchVars, - // An API-mode Vercel target keeps its saved options (CAP-696): the - // settings page only knows the CLI-path fields and would drop `projectId`. - options: - existing?.kind === 'vercel' && isVercelApiMode(existing.options) - ? { ...existing.options } - : picked.options, + options: picked.options, mode: picked.mode, gitBaseBranch: picked.gitBaseBranch, }; @@ -1872,7 +1843,12 @@ async function runPicker( // 4. Adapter-specific options. const detectedOpts = (detected.options ?? {}) as Record; const existingOpts = (existing?.options ?? {}) as Record; - const options = await resolveAdapterOptions(adapter, cwd, branchVars, detectedOpts, existingOpts, keep.orgId); + // Vercel's setup also learns the project's production branch, which becomes + // the default for the PR base branch below. + const vercelSetup = adapter.id === 'vercel' ? await resolveVercelSetup(existingOpts, keep.orgId, { readOnly: setup?.readOnly }) : undefined; + const options = vercelSetup + ? vercelSetup.options + : await resolveAdapterOptions(adapter, cwd, branchVars, detectedOpts, existingOpts, keep.orgId, setup); // 5. Var picking — show every var in keep.lock and pre-select the ones // most likely to be relevant for this adapter (runtime for cf-worker, @@ -1928,7 +1904,7 @@ async function runPicker( // shape. Text entry defaulting to the current branch (you usually open the // PR against the branch you're on), then the existing target's saved value, // then main/master. - const gitBaseBranch = await resolveGitBaseBranch(cwd, mode, existing); + const gitBaseBranch = await resolveGitBaseBranch(cwd, mode, existing, vercelSetup?.productionBranch); // 7. Target name. const defaultName = existing?.name ?? `${adapter.id}-${branch}`; @@ -1975,7 +1951,7 @@ function renderPlan(target: TargetConfig, adapter: DeployAdapter): void { /** A key refusal says where to get a key and what to do (stderr). Prints nothing for any other refusal. */ function printKeyHelp(r: { link?: { label: string; url: string }; remedy?: string }): void { - if (r.link && r.remedy) console.error('\n' + formatKeyHelp({ link: r.link, remedy: r.remedy }) + '\n'); + if (r.link && r.remedy) console.error('\n' + formatKeyHelp({ remedy: r.remedy }) + '\n'); } function renderResult(result: DeployResult): void { @@ -3091,7 +3067,7 @@ export async function deployCommand( if (chosen !== '__new__') { target = chosen; } else { - const built = await runPicker(cwd, keep, undefined, adapter.id, web.web ? web : undefined); + const built = await runPicker(cwd, keep, undefined, adapter.id, web.web ? web : undefined, { readOnly: !!options.dryRun }); if (!built) { console.log('Cancelled.'); return 0; @@ -3111,6 +3087,7 @@ export async function deployCommand( options.edit && targets.length === 1 ? targets[0] : undefined, undefined, web.web ? web : undefined, + { readOnly: !!options.dryRun }, ); if (!built) { console.log('Cancelled.'); @@ -3124,7 +3101,7 @@ export async function deployCommand( } else if (web.web) { const picked = await pickTargetInBrowser(cwd, targets); if (picked.action === 'new') { - const built = await runPicker(cwd, keep, undefined, undefined, web); + const built = await runPicker(cwd, keep, undefined, undefined, web, { readOnly: !!options.dryRun }); if (!built) { console.log('Cancelled.'); return 0; @@ -3156,7 +3133,7 @@ export async function deployCommand( }, ]); if (ans.name === '__new__') { - const built = await runPicker(cwd, keep); + const built = await runPicker(cwd, keep, undefined, undefined, undefined, { readOnly: !!options.dryRun }); if (!built) { console.log('Cancelled.'); return 0; @@ -3281,6 +3258,7 @@ export async function deployCommand( target, undefined, web.web ? { ...web, intent: 'reconfirm' } : undefined, + { readOnly: !!options.dryRun }, ); if (!reconfirmed) { console.log('Cancelled.'); @@ -3352,7 +3330,7 @@ export async function deployCommand( const action = await keypressConfirm({ message: 'Preflight failed. Press e to edit this target, c to check again, esc to cancel.' }); if (action === 'confirm') return preflightOrEdit(t); if (action !== 'edit') return { ok: false }; - const edited = await runPicker(cwd, keep, t, undefined, undefined); + const edited = await runPicker(cwd, keep, t, undefined, undefined, { readOnly: !!options.dryRun }); if (!edited) return { ok: false }; upsertTarget(cwd, edited); console.log(GREEN(`✓ Saved target "${edited.name}" to .capy/deploy.json`)); @@ -3402,7 +3380,7 @@ export async function deployCommand( return 0; } if (action === 'edit') { - const edited = await runPicker(cwd, keep, target, undefined, web.web ? web : undefined); + const edited = await runPicker(cwd, keep, target, undefined, web.web ? web : undefined, { readOnly: !!options.dryRun }); if (!edited) { console.log('Cancelled.'); return 0; diff --git a/src/commands/systemCommand.ts b/src/commands/systemCommand.ts index 6a342634..3524dacc 100644 --- a/src/commands/systemCommand.ts +++ b/src/commands/systemCommand.ts @@ -16,7 +16,7 @@ import inquirer from 'inquirer'; import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { openSystemStore, assertValidConnectorName } from '../system/systemStore'; import { CapyError, ERROR_CODES } from '../types/index'; -import { formatKeyHelp, keyHelpForName } from '../deploy/keyHelp'; +import { formatKeyHelp, keyHelpFields, keyHelpForName, KeyHelp } from '../deploy/keyHelp'; export interface SystemCommandOpts { org?: string; @@ -30,20 +30,22 @@ export interface SystemRmOpts extends SystemCommandOpts { } /** Pure JSON refusal on stdout — never on stderr, so `--json` output stays parseable. */ -function refuseJson(code: string, error: string, exitCode: number): never { - console.log(JSON.stringify({ ok: false, code, error }, null, 2)); +function refuseJson(code: string, error: string, exitCode: number, help: KeyHelp | null): never { + console.log(JSON.stringify({ ok: false, code, error, ...(help ? keyHelpFields(help) : {}) }, null, 2)); process.exit(exitCode); } /** Prose refusal on stderr — human mode only. */ -function refuseHuman(message: string, exitCode: number): never { +function refuseHuman(message: string, exitCode: number, help: KeyHelp | null): never { console.error(message); + if (help) console.error(formatKeyHelp(help)); process.exit(exitCode); } -function refuse(json: boolean, code: string, message: string, exitCode: number): never { - if (json) refuseJson(code, message, exitCode); - refuseHuman(message, exitCode); +/** `help`: where to get the key, for a refusal about one of the four known key names (additive; absent for every other refusal). */ +function refuse(json: boolean, code: string, message: string, exitCode: number, help: KeyHelp | null = null): never { + if (json) refuseJson(code, message, exitCode, help); + refuseHuman(message, exitCode, help); } function exitCodeFor(code: string): number { @@ -69,9 +71,9 @@ function requireValidName(name: string, json: boolean): void { } /** Exits with `SYSTEM_STORE_NEEDS_TTY` when there is no terminal to ask a human. */ -function requireTty(json: boolean, message: string): void { +function requireTty(json: boolean, message: string, help: KeyHelp | null = null): void { if (!isInteractive()) { - refuse(json, ERROR_CODES.SYSTEM_STORE_NEEDS_TTY, message, EXIT_NEEDS_INPUT); + refuse(json, ERROR_CODES.SYSTEM_STORE_NEEDS_TTY, message, EXIT_NEEDS_INPUT, help); } } @@ -116,10 +118,11 @@ export async function systemSetCommand(name: string, opts: SystemCommandOpts): P // Before any network call — see docs/org-system-store.md Proof 3. requireValidName(name, json); - requireTty(json, `\`capy system set\` needs a terminal to prompt for the value — there is no --value flag.`); // COPY-FLAG + // For the four known key names: where to get the key. Without a terminal it is part of the refusal. + requireTty(json, `\`capy system set\` needs a terminal to prompt for the value — there is no --value flag.`, keyHelpForName(name)); // COPY-FLAG - // For the four known key names: where to get the key. Stderr, so `--json` stays pure. - const help = keyHelpForName(name); + // With a terminal the hidden prompt follows right here, so the help ends "paste it here". Stderr, so `--json` stays pure. + const help = keyHelpForName(name, { mode: 'paste' }); if (help) console.error(formatKeyHelp(help)); const value = await promptForHiddenValue(name, json); diff --git a/src/deploy/adapters/vercel.ts b/src/deploy/adapters/vercel.ts index f36a8536..e4bf9c44 100644 --- a/src/deploy/adapters/vercel.ts +++ b/src/deploy/adapters/vercel.ts @@ -621,8 +621,8 @@ const SET_ENV_LABEL = 'set Vercel env'; const VERIFY_LABEL = 'verify Vercel env'; /** A rejected token (HTTP 401) says where to get another; any other failure adds nothing. */ -function rejectedKeyHelp(code: string): { link?: { label: string; url: string }; remedy?: string } { - return code === ERROR_CODES.VERCEL_TOKEN_REJECTED ? vercelTargetKeyHelp() : {}; +function rejectedKeyHelp(code: string, team: string | undefined): { link?: { label: string; url: string }; remedy?: string } { + return code === ERROR_CODES.VERCEL_TOKEN_REJECTED ? vercelTargetKeyHelp(team) : {}; } function failResult(step: DeployStep): DeployResult { @@ -667,7 +667,7 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter const clientDeps = { fetch: deps.fetch, sleep: deps.sleep, now: deps.now }; /** The Vercel token for one call: the caller's single pre-resolution wins; else resolve here. */ - const resolveToken = (ctx: AdapterCallContext): Promise => + const resolveToken = (ctx: AdapterCallContext, team?: string): Promise => ctx.resolvedVercelToken ? Promise.resolve(ctx.resolvedVercelToken) : resolveVercelTargetToken({ @@ -676,10 +676,11 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter orgId: ctx.orgId, devMode: ctx.devMode, getSecret: deps.getTargetSecret, + ...(team ? { helpTeam: team } : {}), }); const clientFor = (token: string, opts: VercelApiOptions): VercelClient => - createVercelClient(token, opts.projectId, opts.teamId, clientDeps); + createVercelClient(token, opts.projectId, opts.teamId, { ...clientDeps, ...(opts.teamSlug ? { teamSlug: opts.teamSlug } : {}) }); async function apiPreflight(config: TargetConfig, ctx: { cwd: string } & AdapterCallContext): Promise { const parsed = parseVercelApiOptions(config.options); @@ -693,36 +694,36 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter hint: 'Re-run with `--edit` and select at least one var.', }; } - const token = await resolveToken(ctx); + const token = await resolveToken(ctx, parsed.options.teamSlug); if (!token.ok) { const { reason, hint } = describeVercelTokenProblem(token.code); - return { ok: false, reason, hint, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp() }; + return { ok: false, reason, hint, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp(parsed.options.teamSlug) }; } const checked = await checkVercelTarget(clientFor(token.value, parsed.options), parsed.options); if (!checked.ok) { - return { ok: false, ...describeVercelFailure(checked.code, checked.status), code: checked.code }; + return { ok: false, ...describeVercelFailure(checked.code, checked.status, parsed.options.teamSlug), code: checked.code }; } return { ok: true }; } async function apiDryRun(config: TargetConfig, ctx: DeployContext, opts: VercelApiOptions): Promise { // Reads only — a dry run makes no write call of any kind. - const token = await resolveToken(ctx); + const token = await resolveToken(ctx, opts.teamSlug); if (!token.ok) { const { reason } = describeVercelTokenProblem(token.code); - return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp() }); + return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp(opts.teamSlug) }); } const client = clientFor(token.value, opts); const scope = await resolveEnvScope(client, opts); if (!scope.ok) { - return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason, ...rejectedKeyHelp(scope.code) }); + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason, ...rejectedKeyHelp(scope.code, opts.teamSlug) }); } const listed = await client.listEnvs().then( (records) => ({ ok: true as const, records }), (err) => failureOf(err), ); if (!listed.ok) { - return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code) }); + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code, opts.teamSlug) }); } const plan = dryRunSummary(config.vars, listed.records, scope.scope); const names = (xs: readonly string[]) => (xs.length > 0 ? ` [${sortedNames(xs).join(', ')}]` : ''); @@ -758,15 +759,15 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter detail: `missing in branch ${config.branch}: ${missing.join(', ')}`, }); } - const token = await resolveToken(ctx); + const token = await resolveToken(ctx, parsed.options.teamSlug); if (!token.ok) { const { reason } = describeVercelTokenProblem(token.code); - return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp() }); + return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp(parsed.options.teamSlug) }); } const client = clientFor(token.value, parsed.options); const scope = await resolveEnvScope(client, parsed.options); if (!scope.ok) { - return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason, ...rejectedKeyHelp(scope.code) }); + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason, ...rejectedKeyHelp(scope.code, parsed.options.teamSlug) }); } const where = describeScope(scope.scope); const listed = await client.listEnvs().then( @@ -774,7 +775,7 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter (err) => failureOf(err), ); if (!listed.ok) { - return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code) }); + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code, parsed.options.teamSlug) }); } const pairs = config.vars.map((name) => [name, ctx.env[name]] as const); diff --git a/src/deploy/keyHelp.ts b/src/deploy/keyHelp.ts index 0b2e0f71..ffbe19be 100644 --- a/src/deploy/keyHelp.ts +++ b/src/deploy/keyHelp.ts @@ -9,8 +9,9 @@ * * Pages (checked 2026-10-01): * - Vercel: the dashboard's token page, `https://vercel.com/account/settings/tokens`. - * Vercel's own CLI points at `https://vercel.com/account/tokens`, which - * redirects (308) to this path. + * A `vercel login` session is OAuth and cannot create tokens, so the token + * must be made in the dashboard; Vercel has no org-owned key, but a token + * can be scoped to one team ("Scope"). * - Dokploy: inside the user's OWN Dokploy, Settings → Profile → API/CLI * section (docs: https://docs.dokploy.com/docs/api, "go to the * /settings/profile page and go to API/CLI Section and generate the @@ -20,8 +21,10 @@ export interface KeyHelp { link: { label: string; url: string }; - /** One plain instruction for the human. */ + /** The instructions for the human, as one string. */ remedy: string; + /** The same instructions, one per terminal line (stderr). */ + lines: readonly string[]; } export const VERCEL_TOKEN_PAGE_URL = 'https://vercel.com/account/settings/tokens'; @@ -30,13 +33,39 @@ export const DOKPLOY_API_DOCS_URL = 'https://docs.dokploy.com/docs/api'; const VERCEL_SECRET_NAMES: readonly string[] = ['_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN']; const DOKPLOY_SECRET_NAMES: readonly string[] = ['_CONNECTOR_DOKPLOY_API_KEY', '_TARGET_DOKPLOY_API_KEY']; +/** `save`: the key is saved with `capy system set` (refusals, non-interactive). `paste`: a hidden prompt follows right here. */ +export type KeyHelpMode = 'save' | 'paste'; + +export interface KeyHelpOptions { + /** Dokploy: the dashboard's base URL, so the link is that Dokploy's own page. */ + baseUrl?: string; + /** Vercel: the slug of the team that owns the project, when Capy knows it. */ + team?: string; + mode?: KeyHelpMode; +} + +/** + * A team id (`team_…`) is not a slug and is never shown as one; `--team` + * takes either, so this is the one place that tells them apart. + */ +export function teamSlugOf(team: string | undefined): string | undefined { + const trimmed = team?.trim(); + if (!trimmed || trimmed.startsWith('team_')) return undefined; + return trimmed; +} + /** Where to get the Vercel token `secretName` holds. */ -export function vercelKeyHelp(secretName: string): KeyHelp { +export function vercelKeyHelp(secretName: string, opts: KeyHelpOptions = {}): KeyHelp { + const team = teamSlugOf(opts.team); + // COPY-FLAG (approved wording) + const first = `Create a token at ${VERCEL_TOKEN_PAGE_URL}. Set Scope to the team that owns the project${team ? ` (${team})` : ''}.`; + // COPY-FLAG (approved wording) + const second = opts.mode === 'paste' ? 'Then paste it here.' : `Then save it: capy system set ${secretName}`; return { // COPY-FLAG: new user-facing string, minimal/neutral wording. link: { label: 'Vercel token page', url: VERCEL_TOKEN_PAGE_URL }, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - remedy: `Create a token at the link and save it with \`capy system set ${secretName}\`, or run \`capy connect vercel --create-token\`.`, + remedy: `${first} ${second}`, + lines: [first, second], }; } @@ -52,17 +81,16 @@ export function dokployKeyPage(baseUrl: string | undefined): { label: string; ur /** Where to get the Dokploy API key `secretName` holds. */ export function dokployKeyHelp(secretName: string, baseUrl?: string): KeyHelp { - return { - link: dokployKeyPage(baseUrl), - // COPY-FLAG: new user-facing string, minimal/neutral wording. - remedy: `Open the link, find the API/CLI section, generate a key, and save it with \`capy system set ${secretName}\`.`, - }; + const link = dokployKeyPage(baseUrl); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + const remedy = `Open ${link.url}, find the API/CLI section, generate a key, and save it with \`capy system set ${secretName}\`.`; + return { link, remedy, lines: [remedy] }; } /** Help for one of the four known key names; `null` for any other name. */ -export function keyHelpForName(name: string, baseUrl?: string): KeyHelp | null { - if (VERCEL_SECRET_NAMES.includes(name)) return vercelKeyHelp(name); - if (DOKPLOY_SECRET_NAMES.includes(name)) return dokployKeyHelp(name, baseUrl); +export function keyHelpForName(name: string, opts: KeyHelpOptions = {}): KeyHelp | null { + if (VERCEL_SECRET_NAMES.includes(name)) return vercelKeyHelp(name, opts); + if (DOKPLOY_SECRET_NAMES.includes(name)) return dokployKeyHelp(name, opts.baseUrl); return null; } @@ -71,7 +99,7 @@ export function keyHelpFields(help: KeyHelp): { link: { label: string; url: stri return { link: help.link, remedy: help.remedy }; } -/** Human text for stderr: the link, then the one instruction. */ -export function formatKeyHelp(help: KeyHelp): string { - return ` ${help.link.label}: ${help.link.url}\n ${help.remedy}`; +/** Human text for stderr: one line per instruction. A refusal that carries only `remedy` prints it as one line (the remedy already holds the link). */ +export function formatKeyHelp(help: { remedy: string; lines?: readonly string[] }): string { + return (help.lines ?? [help.remedy]).map((line) => ` ${line}`).join('\n'); } diff --git a/src/deploy/vercelApi.ts b/src/deploy/vercelApi.ts index caaf69cd..336de352 100644 --- a/src/deploy/vercelApi.ts +++ b/src/deploy/vercelApi.ts @@ -445,8 +445,8 @@ export interface VercelSystemStoreCallOptions { interactive: boolean; devMode?: boolean; apiUrl?: string; - /** `system/systemStore.ts#OnMissingSecret`: runs when the token is missing and a human can be asked, before the hidden prompt. */ - onMissing?: (save: (value: string) => Promise) => Promise; + /** The slug of the team that owns the project, when known: the hidden prompt's token instructions name it. */ + helpTeam?: string; } export interface ResolveVercelTokenOptions { @@ -458,8 +458,8 @@ export interface ResolveVercelTokenOptions { orgId?: string; devMode?: boolean; apiUrl?: string; - /** See `VercelSystemStoreCallOptions.onMissing`. */ - onMissing?: VercelSystemStoreCallOptions['onMissing']; + /** See `VercelSystemStoreCallOptions.helpTeam`. */ + helpTeam?: string; /** * Reads (and, when missing + interactive + admin, prompts for and saves) * the org system store entry. Defaults to a function that never touches the @@ -513,7 +513,7 @@ export async function resolveVercelToken(opts: ResolveVercelTokenOptions): Promi interactive: opts.interactive, devMode: opts.devMode, apiUrl: opts.apiUrl, - ...(opts.onMissing ? { onMissing: opts.onMissing } : {}), + ...(opts.helpTeam ? { helpTeam: opts.helpTeam } : {}), }); if (storeOutcome.kind === 'value') return { ok: true, value: storeOutcome.value, source: 'system' }; diff --git a/src/deploy/vercelCliToken.ts b/src/deploy/vercelCliToken.ts deleted file mode 100644 index 253c6603..00000000 --- a/src/deploy/vercelCliToken.ts +++ /dev/null @@ -1,464 +0,0 @@ -/** - * Create the Vercel token for `capy connect vercel` from the Vercel CLI's own - * sign-in, so nobody has to make (or paste) one by hand. - * - * Vercel has no organization-owned key: every token belongs to a user. What it - * does have is a team-scoped token — `POST /v3/user/tokens?teamId=` makes - * one that sees ONE team only. This file asks the user's installed `vercel` - * program to make that call with the user's sign-in (the CLI handles its own - * sign-in refresh), checks the answer really is scoped to exactly the chosen - * team, and hands the value to a `save` function. The value: - * - * - never appears on a command line (it is only ever READ from a child's - * stdout, captured in memory), - * - is never printed, logged, or put in an error message or a report, - * - is not kept anywhere here: it goes to `save`, and to the caller that - * needs it for its next request, nowhere else. - * - * Commands run (every one is argv, never a shell string; stderr is dropped; - * `VERCEL_TOKEN` is removed from the child's environment so the sign-in is the - * only identity; `CI=1` keeps a logged-out CLI from opening a browser): - * - * vercel whoami exit status 0 = signed in - * vercel api /v2/teams?limit=100 JSON on stdout: { teams: [{ id, slug }] } - * vercel api /v3/user/tokens -X POST \ - * -f name= [-F expiresAt=] \ - * --scope JSON on stdout: { bearerToken, token: {…} } - * vercel tokens rm removes a token - * vercel logout signs the CLI out - * - * Facts read from the installed Vercel CLI (53.1.0) source: - * - `--scope ` sets the CLI's current team; `vercel api` then adds - * `teamId=` to the request (so it also reaches POST /v3/user/tokens). - * - `-f key=value` is a string field; `-F` parses numbers, so `expiresAt` - * goes out as a JSON number. A request with fields and no `-X` would be a - * POST, so a GET with a query string puts the query in the path. - * - A failed request exits 1 with its message on stderr; success exits 0 - * with the JSON on stdout. Nothing here reads that message. - * - `vercel tokens add` sends no team (`useCurrentTeam: false`), so it can - * only make a user-wide token; it is NOT used. - * - Not signed in: a CLI started by an AI agent (or on a terminal) begins its - * login flow and waits, so every call here has a time limit. - * - The Vercel CLI itself says a sign-in made by `vercel login` is an OAuth - * session that Vercel may refuse to create tokens with (HTTP 403). The - * refusal reaches this file only as a non-zero exit, so that case is - * reported as `VERCEL_TOKEN_CREATE_FAILED` with the token page as the way - * out. - */ -import { execFile } from 'child_process'; -import { accessSync, constants } from 'fs'; -import { delimiter, join } from 'path'; -import { ERROR_CODES } from '../types/index'; -import { VERCEL_CONNECTOR_SECRET_NAME, VercelTeam, resolveTeam } from './vercelApi'; -import { VERCEL_TOKEN_PAGE_URL } from './keyHelp'; - -// ── Types ──────────────────────────────────────────────────────────────────── - -export interface VercelCliRunResult { - /** The child's exit status; `null` when it never ran, was killed, or timed out. */ - status: number | null; - stdout: string; -} - -export type VercelCliRunner = (args: readonly string[], timeoutMs: number) => Promise; - -export interface VercelCliDeps { - /** Source of `PATH` for finding the binary, and the child's environment. */ - env: Record; - /** Defaults to really running `vercel` with `env`. Injected by tests. */ - run?: VercelCliRunner; - /** Whether the `vercel` binary can be found. Defaults to a `PATH` search of `env`. */ - hasBinary?: () => boolean; - now?: () => number; -} - -/** A refusal. Branch on `code`; `message` is for people. */ -export interface TokenFailure { - ok: false; - code: string; - message: string; - link?: { label: string; url: string }; - remedy?: string; - /** Set when a created token could not be removed: the id (never the value) a human can remove. */ - tokenId?: string; -} - -/** What a created token looks like in a report. Never the value. */ -export interface TokenCreatedReport { - name: string; - id: string; - team: { id: string; slug: string }; - /** ISO time the token expires; absent for no expiry. */ - expiresAt?: string; - /** Which system store entry holds it. */ - secretName: string; - /** True when that entry already held a value and was replaced. */ - replacedExisting: boolean; - /** What happened to the Vercel CLI's sign-in. */ - cliSignOut: 'done' | 'declined' | 'failed' | 'not_offered'; - /** The command that signs the CLI out, when it is still signed in. */ - next?: string; -} - -/** What `prepare` hands back once it has checked the save target is usable. */ -export interface MintTarget { - ok: true; - /** Saves the value (to the system store). Throws a coded error on failure. */ - save: (bearer: string) => Promise; - /** True when that store entry already held a value (for the report). */ - replacedExisting: boolean; -} - -export interface MintRequest { - /** `--team`: slug or id. */ - team?: string; - /** `--token-expires-days`, already validated. */ - expiresDays?: number; - /** Asks which team when several and this run can ask; `null` when it cannot. */ - pickTeam: ((teams: readonly VercelTeam[]) => Promise) | null; - /** - * Runs after the CLI and team are settled and BEFORE anything is created: - * opens the system store, which is where a non-admin is refused — so a - * token is never made for a caller who could not save it. - */ - prepare: () => Promise; - /** Asks "sign the Vercel CLI out now?" (default yes); `null` when this run cannot ask — it then never signs out. */ - askSignOut: ((message: string, defaultValue: boolean) => Promise) | null; - /** The store entry the value is saved to. Defaults to `_CONNECTOR_VERCEL_TOKEN`. */ - secretName?: string; -} - -export type MintOutcome = - | { ok: true; report: TokenCreatedReport; bearer: string } - | TokenFailure; - -// ── Constants ──────────────────────────────────────────────────────────────── - -const VERCEL_CLI_INSTALL_URL = 'https://vercel.com/docs/cli'; -const WHOAMI_TIMEOUT_MS = 20_000; -const API_TIMEOUT_MS = 60_000; -const DAY_MS = 86_400_000; -const MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -/** A token id goes on a command line: refuse anything that could be read as a flag or a second argument. */ -const SAFE_TOKEN_ID = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; - -// ── Running the Vercel CLI ─────────────────────────────────────────────────── - -/** The environment the child sees: the caller's, minus `VERCEL_TOKEN`, with `CI=1`. */ -function childEnv(env: Record): NodeJS.ProcessEnv { - const { VERCEL_TOKEN: _dropped, ...rest } = env; - return { ...rest, CI: '1' }; -} - -function realRunner(env: Record): VercelCliRunner { - return (args, timeoutMs) => - new Promise((resolve) => { - const child = execFile( - 'vercel', - [...args], - { env: childEnv(env), timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: MAX_OUTPUT_BYTES, encoding: 'utf8' }, - (err, stdout) => { - const code = (err as { code?: unknown } | null)?.code; - const status = err === null ? 0 : typeof code === 'number' ? code : null; - resolve({ status, stdout: typeof stdout === 'string' ? stdout : '' }); - }, - ); - child.stdin?.end(); - }); -} - -/** Whether an executable called `vercel` is on `env.PATH`. */ -export function vercelBinaryOnPath(env: Record): boolean { - const names = process.platform === 'win32' ? ['vercel.cmd', 'vercel.exe', 'vercel'] : ['vercel']; - const dirs = (env.PATH ?? '').split(delimiter).filter((d) => d !== ''); - return dirs.some((dir) => - names.some((name) => { - try { - accessSync(join(dir, name), constants.X_OK); - return true; - } catch { - return false; - } - }), - ); -} - -export function hasVercelCli(deps: VercelCliDeps): boolean { - return (deps.hasBinary ?? (() => vercelBinaryOnPath(deps.env)))(); -} - -const runner = (deps: VercelCliDeps): VercelCliRunner => deps.run ?? realRunner(deps.env); - -function parseJsonObject(text: string): Record | null { - try { - const v: unknown = JSON.parse(text); - return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; - } catch { - return null; - } -} - -// ── Checks ─────────────────────────────────────────────────────────────────── - -/** The binary is there and the CLI is signed in (its own `whoami` exits 0), else a coded refusal. */ -export async function checkVercelCli(deps: VercelCliDeps): Promise<{ ok: true } | TokenFailure> { - if (!hasVercelCli(deps)) { - return { - ok: false, - code: ERROR_CODES.VERCEL_CLI_MISSING, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: 'The Vercel CLI (`vercel`) is not installed.', - // COPY-FLAG: new user-facing string, minimal/neutral wording. - link: { label: 'Install the Vercel CLI', url: VERCEL_CLI_INSTALL_URL }, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - remedy: 'Install the Vercel CLI, run `vercel login`, then run this again.', - }; - } - const who = await runner(deps)(['whoami'], WHOAMI_TIMEOUT_MS); - if (who.status !== 0) { - return { - ok: false, - code: ERROR_CODES.VERCEL_CLI_NOT_SIGNED_IN, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: 'The Vercel CLI is not signed in.', - // COPY-FLAG: new user-facing string, minimal/neutral wording. - remedy: 'Run `vercel login`, then run this again.', - }; - } - return { ok: true }; -} - -/** The teams the CLI's sign-in sees. */ -export async function listCliTeams(deps: VercelCliDeps): Promise<{ ok: true; teams: readonly VercelTeam[] } | TokenFailure> { - const res = await runner(deps)(['api', '/v2/teams?limit=100'], API_TIMEOUT_MS); - const body = res.status === 0 ? parseJsonObject(res.stdout) : null; - if (!body || !Array.isArray(body.teams)) { - return { - ok: false, - code: ERROR_CODES.VERCEL_API_ERROR, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: 'Could not list the Vercel teams through the Vercel CLI.', - }; - } - const teams = body.teams.flatMap((t): readonly VercelTeam[] => { - const rec = t !== null && typeof t === 'object' ? (t as Record) : {}; - return typeof rec.id === 'string' && rec.id !== '' && typeof rec.slug === 'string' && rec.slug !== '' - ? [{ id: rec.id, slug: rec.slug, ...(typeof rec.name === 'string' ? { name: rec.name } : {}) }] - : []; - }); - return { ok: true, teams }; -} - -/** A team token can only belong to a team: pick one (flag, the only one, a pick) or refuse. */ -export async function chooseTokenTeam( - teams: readonly VercelTeam[], - requested: string | undefined, - pick: ((teams: readonly VercelTeam[]) => Promise) | null, -): Promise<{ ok: true; team: { id: string; slug: string } } | TokenFailure> { - if (teams.length === 0) { - return { - ok: false, - code: ERROR_CODES.VERCEL_TEAM_REQUIRED, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: 'The Vercel CLI sign-in sees no team. A team token needs a team.', - }; - } - const resolved = await resolveTeam(teams, requested, pick); - if (!resolved.ok) return resolved; - const team = teams.find((t) => t.id === resolved.teamId); - return team ? { ok: true, team: { id: team.id, slug: team.slug } } : { ok: false, code: ERROR_CODES.VERCEL_TEAM_NOT_FOUND, message: 'No such Vercel team.' }; -} - -// ── Create / delete ────────────────────────────────────────────────────────── - -/** `--token-expires-days`: a whole number of days, 1 or more. */ -export function parseExpiryDays(raw: string | number | undefined): { ok: true; days: number | undefined } | TokenFailure { - if (raw === undefined) return { ok: true, days: undefined }; - const text = String(raw).trim(); - const days = /^\d+$/.test(text) ? Number(text) : NaN; - if (!Number.isSafeInteger(days) || days < 1 || days > 3650) { - return { - ok: false, - code: ERROR_CODES.VERCEL_TOKEN_EXPIRY_INVALID, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: '--token-expires-days must be a whole number of days, 1 to 3650.', - }; - } - return { ok: true, days }; -} - -interface CreatedToken { - bearer: string; - id: string; - name: string; - scopes: readonly unknown[]; - expiresAtMs?: number; -} - -function parseCreated(stdout: string): CreatedToken | null { - const body = parseJsonObject(stdout); - const token = body?.token !== null && typeof body?.token === 'object' ? (body?.token as Record) : null; - if (!body || !token) return null; - const bearer = body.bearerToken; - const id = token.id; - if (typeof bearer !== 'string' || bearer === '' || typeof id !== 'string' || id === '') return null; - return { - bearer, - id, - name: typeof token.name === 'string' ? token.name : '', - scopes: Array.isArray(token.scopes) ? token.scopes : [], - ...(typeof token.expiresAt === 'number' ? { expiresAtMs: token.expiresAt } : {}), - }; -} - -/** Exactly one scope, and it is the chosen team. A `user` scope, another team, or extra scopes all fail. */ -export function scopesAreExactlyTeam(scopes: readonly unknown[], teamId: string): boolean { - if (scopes.length !== 1) return false; - const only = scopes[0]; - const rec = only !== null && typeof only === 'object' ? (only as Record) : {}; - return rec.type === 'team' && rec.teamId === teamId; -} - -async function deleteCliToken(deps: VercelCliDeps, id: string): Promise { - if (!SAFE_TOKEN_ID.test(id)) return false; - const res = await runner(deps)(['tokens', 'rm', id], API_TIMEOUT_MS); - return res.status === 0; -} - -/** Removes a token that must not be kept; if that fails too, refuses with the id so a human can remove it. */ -async function removeOrReport(deps: VercelCliDeps, id: string, because: TokenFailure): Promise { - if (await deleteCliToken(deps, id)) return because; - return { - ok: false, - code: ERROR_CODES.VERCEL_TOKEN_CLEANUP_FAILED, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: `A Vercel token (id ${id}) was created and could not be removed. Remove it at the Vercel token page.`, - link: { label: 'Vercel token page', url: VERCEL_TOKEN_PAGE_URL }, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - remedy: `Remove the token with id ${id} on the Vercel token page, or run \`vercel tokens rm ${id}\`.`, - tokenId: id, - }; -} - -function storeCode(err: unknown): string { - const code = (err as { code?: unknown } | null)?.code; - return typeof code === 'string' && code !== '' ? code : ERROR_CODES.SERVICE_ERROR; -} - -async function trySave(save: (bearer: string) => Promise, bearer: string): Promise<{ ok: true } | { ok: false; code: string }> { - try { - await save(bearer); - return { ok: true }; - } catch (err) { - return { ok: false, code: storeCode(err) }; - } -} - -async function signOutStep( - deps: VercelCliDeps, - ask: MintRequest['askSignOut'], -): Promise<{ cliSignOut: TokenCreatedReport['cliSignOut']; next?: string }> { - const NEXT = 'vercel logout'; - if (!ask) return { cliSignOut: 'not_offered', next: NEXT }; - // COPY-FLAG: new user-facing string, minimal/neutral wording. - const yes = await ask('Sign the Vercel CLI out now?', true); - if (!yes) return { cliSignOut: 'declined', next: NEXT }; - const res = await runner(deps)(['logout'], WHOAMI_TIMEOUT_MS); - return res.status === 0 ? { cliSignOut: 'done' } : { cliSignOut: 'failed', next: NEXT }; -} - -/** - * The whole sequence: CLI present and signed in → team → create the token → - * check its scope → save it → (interactive only) offer to sign the CLI out. - * - * A token that fails the scope check, or cannot be saved, is deleted again - * straight away. - */ -export async function mintTeamToken(deps: VercelCliDeps, req: MintRequest): Promise { - const checked = await checkVercelCli(deps); - if (!checked.ok) return checked; - - const listed = await listCliTeams(deps); - if (!listed.ok) return listed; - const chosen = await chooseTokenTeam(listed.teams, req.team, req.pickTeam); - if (!chosen.ok) return chosen; - const { team } = chosen; - - const target = await req.prepare(); - if (!target.ok) return target; - - const now = (deps.now ?? (() => Date.now()))(); - const expiresAtMs = req.expiresDays === undefined ? undefined : now + req.expiresDays * DAY_MS; - const name = `capy-${team.slug}-import`; // COPY-FLAG: token name shown in Vercel. - const args: readonly string[] = [ - 'api', - '/v3/user/tokens', - '-X', - 'POST', - '-f', - `name=${name}`, - ...(expiresAtMs === undefined ? [] : ['-F', `expiresAt=${expiresAtMs}`]), - '--scope', - team.slug, - ]; - const created = await runner(deps)(args, API_TIMEOUT_MS); - const token = created.status === 0 ? parseCreated(created.stdout) : null; - if (!token) { - return { - ok: false, - code: ERROR_CODES.VERCEL_TOKEN_CREATE_FAILED, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: 'The Vercel CLI could not create a token. Vercel may not allow a `vercel login` sign-in to create tokens.', - link: { label: 'Vercel token page', url: VERCEL_TOKEN_PAGE_URL }, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - remedy: `Create a token at the link and save it with \`capy system set ${req.secretName ?? VERCEL_CONNECTOR_SECRET_NAME}\`.`, - }; - } - - if (!scopesAreExactlyTeam(token.scopes, team.id)) { - return removeOrReport(deps, token.id, { - ok: false, - code: ERROR_CODES.VERCEL_TOKEN_SCOPE_UNEXPECTED, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: `The new Vercel token was not scoped to only the team ${team.slug}. It was deleted.`, - }); - } - - const secretName = req.secretName ?? VERCEL_CONNECTOR_SECRET_NAME; - const saved = await trySave(target.save, token.bearer); - if (!saved.ok) { - return removeOrReport(deps, token.id, { - ok: false, - code: saved.code, - // COPY-FLAG: new user-facing string, minimal/neutral wording. - message: `Could not save ${secretName}. The new Vercel token was deleted.`, - }); - } - - const signOut = await signOutStep(deps, req.askSignOut); - const finalExpiry = token.expiresAtMs ?? expiresAtMs; - return { - ok: true, - bearer: token.bearer, - report: { - name: token.name || name, - id: token.id, - team, - ...(finalExpiry === undefined ? {} : { expiresAt: new Date(finalExpiry).toISOString() }), - secretName, - replacedExisting: target.replacedExisting, - ...signOut, - }, - }; -} - -/** Human lines for a created-token report. Never the value. */ -export function describeTokenCreated(report: TokenCreatedReport): readonly string[] { - // COPY-FLAG: new user-facing strings, minimal/neutral wording. - return [ - ` ✓ Created Vercel token ${report.name} (id ${report.id}) for team ${report.team.slug}, saved as ${report.secretName}${report.replacedExisting ? ' (replaced the previous value)' : ''}.`, - ...(report.expiresAt ? [` Expires ${report.expiresAt}.`] : []), - ...(report.cliSignOut === 'done' ? [' Signed the Vercel CLI out.'] : []), - ...(report.next ? [` You can sign the Vercel CLI out with \`${report.next}\`.`] : []), - ]; -} diff --git a/src/deploy/vercelSetup.ts b/src/deploy/vercelSetup.ts new file mode 100644 index 00000000..112a4a36 --- /dev/null +++ b/src/deploy/vercelSetup.ts @@ -0,0 +1,392 @@ +/** + * `capy deploy`'s Vercel target setup — API mode only, modeled on Dokploy's + * one-question setup (`resolveDokployServiceOptions` in `commands/deployCommand.ts`): + * + * 1. One question: the project's dashboard link (or its name). + * 2. The token resolves like Dokploy's key does (`_TARGET_VERCEL_TOKEN`, a + * one-level reference to `_CONNECTOR_VERCEL_TOKEN`, then `$VERCEL_TOKEN`). + * No token: say so, with the instructions for making one, and save the + * answer as entered. + * 3. The project is checked live. A 404 is a coded refusal + * (`VERCEL_PROJECT_NOT_FOUND`), shown, and the question is asked again. + * Any other API problem is shown with its code and the answer is saved as + * entered. + * 4. The project is named back for a yes/no; no asks again. + * 5. Environment: production, preview, development, plus the project's custom + * environments. Preview asks an optional git branch. + * + * It needs no `vercel` program, no `.vercel/project.json` and no `vercel link`: + * every request is the REST API with the token. + * + * Vercel dashboard links look like `https://vercel.com//` + * (more path may follow: `/settings`, `/deployments`, …). + */ +import inquirer from 'inquirer'; +import { ERROR_CODES } from '../types/index'; +import { + VercelApiError, + VercelFetchLike, + VercelProject, + VercelTeam, + availableEnvironments, + createVercelClient, + mapVercelApiError, + resolveTeam, +} from './vercelApi'; +import { + GetVercelStoreSecret, + VERCEL_TARGET_SECRET_NAME, + getVercelTargetSecretFromStore, + resolveVercelTargetToken, +} from './vercelTargetApi'; +import { formatKeyHelp, vercelKeyHelp } from './keyHelp'; + +const DIM = (s: string) => `\x1b[90m${s}\x1b[0m`; +const RED = (s: string) => `\x1b[31m${s}\x1b[0m`; + +// ── Parsing the one answer ────────────────────────────────────────────────── + +export type VercelProjectInput = + | { kind: 'link'; team: string; project: string } + | { kind: 'name'; project: string }; + +export type ParsedVercelProjectInput = + | { ok: true; input: VercelProjectInput } + | { ok: false; code: string; reason: string }; + +/** First path segments on vercel.com that are not a team's slug. */ +const NOT_A_TEAM_SEGMENT: ReadonlySet = new Set([ + 'account', + 'dashboard', + 'docs', + 'login', + 'new', + 'pricing', + 'signup', + 'teams', +]); + +const PROJECT_NAME = /^[A-Za-z0-9._-]+$/; + +function invalid(reason: string): ParsedVercelProjectInput { + return { ok: false, code: ERROR_CODES.VERCEL_URL_INVALID, reason }; +} + +function asUrl(answer: string): URL | null { + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(answer) ? answer : /^(www\.)?vercel\.com\//i.test(answer) ? `https://${answer}` : null; + if (withScheme === null) return null; + try { + return new URL(withScheme); + } catch { + return null; + } +} + +/** + * The answer as a dashboard link (`https://vercel.com//[/…]`) + * or a bare project name/id. A link that is not a project's dashboard link is + * a coded refusal, never guessed at. + */ +export function parseVercelProjectInput(raw: string): ParsedVercelProjectInput { + const answer = raw.trim(); + const url = asUrl(answer); + if (url) { + if (url.protocol !== 'https:' || (url.hostname !== 'vercel.com' && url.hostname !== 'www.vercel.com')) { + // COPY-FLAG: minimal neutral wording. + return invalid('not a vercel.com link.'); + } + const [team, project] = url.pathname.split('/').filter((segment) => segment !== ''); + if (!team || !project || NOT_A_TEAM_SEGMENT.has(team)) { + // COPY-FLAG: minimal neutral wording. + return invalid('that is not a project link (expected https://vercel.com//).'); + } + return { ok: true, input: { kind: 'link', team, project } }; + } + if (!PROJECT_NAME.test(answer)) { + // COPY-FLAG: minimal neutral wording. + return invalid('that is neither a project link nor a project name.'); + } + return { ok: true, input: { kind: 'name', project: answer } }; +} + +// ── The setup ─────────────────────────────────────────────────────────────── + +/** The terminal's question asker: `inquirer.prompt`'s shape, narrowed to what this setup uses. */ +export type SetupPrompt = (questions: ReadonlyArray>) => Promise>; + +const defaultPrompt: SetupPrompt = (questions) => inquirer.prompt(questions as never) as Promise>; + +export interface VercelSetupDeps { + /** Injectable so a test answers without a terminal. Defaults to `inquirer.prompt`. */ + prompt?: SetupPrompt; + fetch?: VercelFetchLike; + env?: Record; + /** Reads the org system store's `_TARGET_VERCEL_TOKEN`. Defaults to the real store (with its reuse offer and hidden prompt). */ + getSecret?: GetVercelStoreSecret; +} + +export interface VercelSetupFlags { + /** A dry run changes nothing: the token is only read from the store, never asked for or saved. */ + readOnly?: boolean; + devMode?: boolean; +} + +export interface VercelSetupResult { + /** The target's API-mode options. */ + options: Record; + /** The project's production branch, when Vercel said: the default for the PR base branch. */ + productionBranch?: string; +} + +interface Scope { + teamId?: string; + teamSlug?: string; +} + +type Verified = + | { kind: 'verified'; project: VercelProject; scope: Scope; client: ReturnType } + | { kind: 'not_found' } + | { kind: 'unverified'; code: string; scope: Scope }; + +const STANDARD_ENVIRONMENTS: ReadonlyArray<{ name: string; value: string }> = [ + { name: 'Production', value: 'production' }, + { name: 'Preview', value: 'preview' }, + { name: 'Development', value: 'development' }, +]; + +/** The code of a failed Vercel call — the typed error's own code, never its text. */ +function codeOf(err: unknown): string { + return mapVercelApiError(err).code; +} + +async function attempt(fn: () => Promise): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> { + try { + return { ok: true, value: await fn() }; + } catch (error) { + return { ok: false, error }; + } +} + +async function askProjectInput(ask: SetupPrompt, existingOpts: Record): Promise { + const ans = (await ask([ + { + type: 'input', + name: 'projectUrl', + // COPY-FLAG (approved wording) + message: 'Vercel project URL (paste the dashboard link for this project, or its name):', + default: existingOpts.projectId, + validate: (v: string) => (v.trim() ? true : 'required'), + filter: (v: string) => v.trim(), + }, + ])) as { projectUrl: string }; + return ans.projectUrl; +} + +async function pickTeamPrompt(ask: SetupPrompt, teams: readonly VercelTeam[]): Promise { + const { picked } = (await ask([ + // COPY-FLAG: minimal neutral wording. + { type: 'list', name: 'picked', message: 'Which Vercel team:', choices: teams.map((t) => ({ name: t.slug, value: t.id })) }, + ])) as { picked: string }; + return picked; +} + +/** Looks the project up. A 404 is `not_found`; any other problem is `unverified`, carrying whatever scope was learned. */ +async function verifyProject( + input: VercelProjectInput, + token: string, + deps: VercelSetupDeps, + ask: SetupPrompt, +): Promise { + const client = createVercelClient(token, { fetch: deps.fetch }); + const linkSlug = input.kind === 'link' ? { teamSlug: input.team } : {}; + const teams = await attempt(() => client.listTeams()); + if (!teams.ok) return { kind: 'unverified', code: codeOf(teams.error), scope: linkSlug }; + + const scope = await scopeFor(input, teams.value, ask); + if (!scope.ok) return { kind: 'unverified', code: scope.code, scope: linkSlug }; + + const project = await attempt(() => client.getProject(input.project, scope.scope.teamId)); + if (project.ok) return { kind: 'verified', project: project.value, scope: scope.scope, client }; + if (project.error instanceof VercelApiError && project.error.code === 'not_found') return { kind: 'not_found' }; + return { kind: 'unverified', code: codeOf(project.error), scope: scope.scope }; +} + +/** Which team the project is under: the link's own, or — for a bare name — the only team the token sees, else a pick. */ +async function scopeFor( + input: VercelProjectInput, + teams: readonly VercelTeam[], + ask: SetupPrompt, +): Promise<{ ok: true; scope: Scope } | { ok: false; code: string }> { + if (input.kind === 'link') { + const hit = teams.find((t) => t.slug === input.team); + return { ok: true, scope: hit ? { teamId: hit.id, teamSlug: hit.slug } : {} }; + } + const resolved = await resolveTeam(teams, undefined, (all) => pickTeamPrompt(ask, all)); + if (!resolved.ok) return { ok: false, code: resolved.code }; + return { + ok: true, + scope: { ...(resolved.teamId ? { teamId: resolved.teamId } : {}), ...(resolved.teamSlug ? { teamSlug: resolved.teamSlug } : {}) }, + }; +} + +interface EnvironmentChoice { + vercelEnv: string; + customEnvironmentId?: string; + gitBranch?: string; +} + +async function askEnvironment( + ask: SetupPrompt, + existingOpts: Record, + custom: ReadonlyArray<{ id: string; slug: string }>, +): Promise { + const environments = availableEnvironments(custom); + const choices = [ + ...STANDARD_ENVIRONMENTS, + ...environments.filter((e) => e.kind === 'custom').map((e) => ({ name: e.name, value: e.name })), + ]; + const { vercelEnv } = (await ask([ + { + type: 'list', + name: 'vercelEnv', + message: 'Which Vercel environment should these secrets go to?', + choices, + default: existingOpts.vercelEnv ?? 'preview', + }, + ])) as { vercelEnv: string }; + const customEnvironmentId = environments.find((e) => e.kind === 'custom' && e.name === vercelEnv)?.customEnvironmentId; + const gitBranch = vercelEnv === 'preview' ? await askPreviewBranch(ask, existingOpts.gitBranch) : undefined; + return { + vercelEnv, + ...(customEnvironmentId ? { customEnvironmentId } : {}), + ...(gitBranch ? { gitBranch } : {}), + }; +} + +async function askPreviewBranch(ask: SetupPrompt, preferred: string | undefined): Promise { + const { gitBranch } = (await ask([ + { + type: 'input', + name: 'gitBranch', + // COPY-FLAG: minimal neutral wording. + message: 'Git branch for Preview (leave blank for all preview branches):', + default: preferred, + filter: (v: string) => v.trim(), + }, + ])) as { gitBranch: string }; + return gitBranch === '' ? undefined : gitBranch; +} + +async function confirmProject(ask: SetupPrompt, project: VercelProject, teamSlug: string | undefined): Promise { + const where = teamSlug ? ` (${teamSlug})` : ''; + const { confirmed } = (await ask([ + { + type: 'confirm', + name: 'confirmed', + // COPY-FLAG (approved wording) + message: `Use "${project.name}"${where} as this target?`, + default: true, + }, + ])) as { confirmed: boolean }; + return confirmed; +} + +/** What is known without Vercel's answer: the project as typed, the team as far as it is known. */ +function unverifiedOptions(input: VercelProjectInput, scope: Scope, env: EnvironmentChoice): Record { + const team = scope.teamId ? { teamId: scope.teamId } : scope.teamSlug ? { teamSlug: scope.teamSlug } : {}; + return { + projectDir: '.', + vercelEnv: env.vercelEnv, + projectId: input.project, + ...team, + ...(env.gitBranch ? { gitBranch: env.gitBranch } : {}), + }; +} + +function printTokenInstructions(team: string | undefined): void { + console.error(formatKeyHelp(vercelKeyHelp(VERCEL_TARGET_SECRET_NAME, { team, mode: 'save' }))); +} + +/** + * The whole setup: loops (a terminal only) until a project is confirmed, or + * the project could not be checked and is saved as entered. Each pass either + * returns or recurses on the next answer, so no loop state is reassigned. + */ +export async function resolveVercelSetup( + existingOpts: Record, + orgId: string | undefined, + flags: VercelSetupFlags = {}, + deps: VercelSetupDeps = {}, +): Promise { + const ask = deps.prompt ?? defaultPrompt; + const resolve = async (): Promise => { + const parsed = parseVercelProjectInput(await askProjectInput(ask, existingOpts)); + if (!parsed.ok) { + console.error(`${RED('✗')} ${parsed.code}: ${parsed.reason}`); + // COPY-FLAG: minimal neutral wording. + console.error(' Paste the Vercel dashboard link for this project, or just its name.'); + return resolve(); + } + const input = parsed.input; + const linkTeam = input.kind === 'link' ? input.team : undefined; + + const token = await resolveVercelTargetToken({ + env: deps.env ?? process.env, + interactive: !flags.readOnly, + orgId, + devMode: flags.devMode, + getSecret: deps.getSecret ?? getVercelTargetSecretFromStore, + ...(linkTeam ? { helpTeam: linkTeam } : {}), + }); + if (!token.ok) { + // COPY-FLAG: minimal neutral wording. + const why = token.code === ERROR_CODES.VERCEL_TOKEN_MISSING ? 'no token' : token.code; + console.log(` ${DIM(`Could not verify against Vercel yet (${why}) — saving as entered.`)}`); + printTokenInstructions(linkTeam); + return savedAsEntered(input, { ...(linkTeam ? { teamSlug: linkTeam } : {}) }); + } + + const verified = await verifyProject(input, token.value, deps, ask); + if (verified.kind === 'not_found') { + // COPY-FLAG: minimal neutral wording. + console.error(`${RED('✗')} ${ERROR_CODES.VERCEL_PROJECT_NOT_FOUND}: no Vercel project "${input.project}"${linkTeam ? ` in ${linkTeam}` : ''}.`); + console.error(' Check the link and try again.'); + return resolve(); + } + if (verified.kind === 'unverified') { + // COPY-FLAG: minimal neutral wording. + console.log(` ${DIM(`Could not verify against Vercel (${verified.code}) — saving as entered.`)}`); + if (verified.code === ERROR_CODES.VERCEL_AUTH_FAILED) printTokenInstructions(verified.scope.teamSlug ?? linkTeam); + return savedAsEntered(input, verified.scope); + } + + if (!(await confirmProject(ask, verified.project, verified.scope.teamSlug))) return resolve(); + + const custom = await attempt(() => verified.client.listCustomEnvironments(verified.project.id, verified.scope.teamId)); + if (!custom.ok) { + // COPY-FLAG: minimal neutral wording. + console.log(` ${DIM(`Could not list custom environments (${codeOf(custom.error)}) — offering the standard ones.`)}`); + } + const env = await askEnvironment(ask, existingOpts, custom.ok ? custom.value : []); + const productionBranch = verified.project.link?.productionBranch; + return { + options: { + projectDir: '.', + vercelEnv: env.vercelEnv, + projectId: verified.project.id, + ...(verified.scope.teamId ? { teamId: verified.scope.teamId } : {}), + ...(env.customEnvironmentId ? { customEnvironmentId: env.customEnvironmentId } : {}), + ...(env.gitBranch ? { gitBranch: env.gitBranch } : {}), + }, + ...(productionBranch ? { productionBranch } : {}), + }; + }; + + /** Saved as typed: the standard environments only (custom ones cannot be listed without Vercel). */ + const savedAsEntered = async (input: VercelProjectInput, scope: Scope): Promise => { + const env = await askEnvironment(ask, existingOpts, []); + return { options: unverifiedOptions(input, scope, env) }; + }; + + return resolve(); +} diff --git a/src/deploy/vercelTargetApi.ts b/src/deploy/vercelTargetApi.ts index 8cee49fe..f06b945c 100644 --- a/src/deploy/vercelTargetApi.ts +++ b/src/deploy/vercelTargetApi.ts @@ -63,6 +63,8 @@ export interface VercelSystemStoreCallOptions { interactive: boolean; devMode?: boolean; apiUrl?: string; + /** The slug of the team that owns the project, when known: the hidden prompt's token instructions name it. */ + helpTeam?: string; } export type GetVercelStoreSecret = ( @@ -114,6 +116,8 @@ export interface ResolveVercelTokenOptions { orgId?: string; devMode?: boolean; apiUrl?: string; + /** See `VercelSystemStoreCallOptions.helpTeam`. */ + helpTeam?: string; /** Defaults to a getter that never touches the store (env-only). Real callers pass `getVercelTargetSecretFromStore`. */ getSecret?: GetVercelStoreSecret; } @@ -137,6 +141,7 @@ export async function resolveVercelTargetToken( interactive: opts.interactive, devMode: opts.devMode, apiUrl: opts.apiUrl, + ...(opts.helpTeam ? { helpTeam: opts.helpTeam } : {}), }); if (outcome.kind === 'value') return { ok: true, value: outcome.value, source: 'system' }; const fallback = opts.env[DEFAULT_VERCEL_TOKEN_ENV]; @@ -147,8 +152,8 @@ export async function resolveVercelTargetToken( } /** Where to get the deploy-side Vercel token: the `link` + `remedy` fields of a key refusal. */ -export function vercelTargetKeyHelp(): { link: { label: string; url: string }; remedy: string } { - return keyHelpFields(vercelKeyHelp(VERCEL_TARGET_SECRET_NAME)); +export function vercelTargetKeyHelp(team?: string): { link: { label: string; url: string }; remedy: string } { + return keyHelpFields(vercelKeyHelp(VERCEL_TARGET_SECRET_NAME, { team })); } /** A token refusal as a printable reason + hint. Names only, never a value. */ @@ -198,6 +203,12 @@ export interface VercelApiOptions { vercelEnv: string; projectId: string; teamId?: string; + /** + * The team's slug. Saved by `capy deploy`'s setup ONLY when the project could + * not be checked against Vercel (so no `teamId` was learned): requests then + * scope by slug. Also lets the token instructions name the team. + */ + teamSlug?: string; /** Preview only. Absent means every preview branch. */ gitBranch?: string; customEnvironmentId?: string; @@ -235,6 +246,7 @@ export function parseVercelApiOptions(raw: Record): ParsedVerce vercelEnv, projectId, teamId: nonEmptyString(raw.teamId), + teamSlug: nonEmptyString(raw.teamSlug), gitBranch: nonEmptyString(raw.gitBranch), customEnvironmentId: nonEmptyString(raw.customEnvironmentId), }, @@ -284,6 +296,8 @@ export interface VercelClientDeps { sleep?: (ms: number) => Promise; /** Epoch milliseconds. */ now?: () => number; + /** Scope requests by the team's slug (`?slug=`) when there is no `teamId` to scope by. */ + teamSlug?: string; } export interface VercelClient { @@ -338,7 +352,8 @@ export function createVercelClient( const project = encodeURIComponent(projectId); const urlFor = (path: string, query: Readonly>): string => { - const params = new URLSearchParams({ ...query, ...(teamId ? { teamId } : {}) }); + const scope: Record = teamId ? { teamId } : deps.teamSlug ? { slug: deps.teamSlug } : {}; + const params = new URLSearchParams({ ...query, ...scope }); const qs = params.toString(); return `${API_BASE}${path}${qs ? `?${qs}` : ''}`; }; @@ -799,11 +814,12 @@ export function sortedNames(names: readonly string[]): string[] { export function describeVercelFailure( code: ErrorCode, status?: number, + team?: string, ): { reason: string; hint?: string; link?: { label: string; url: string }; remedy?: string } { const http = status !== undefined ? ` (HTTP ${status})` : ''; if (code === ERROR_CODES.VERCEL_TOKEN_REJECTED) { // COPY-FLAG - return { reason: `Vercel rejected the token${http}`, hint: `Replace it: capy system set ${VERCEL_TARGET_SECRET_NAME}`, ...vercelTargetKeyHelp() }; + return { reason: `Vercel rejected the token${http}`, hint: `Replace it: capy system set ${VERCEL_TARGET_SECRET_NAME}`, ...vercelTargetKeyHelp(team) }; } if (code === ERROR_CODES.VERCEL_NO_ACCESS) { // COPY-FLAG diff --git a/src/index.ts b/src/index.ts index f7fabecc..00ca3a5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -918,12 +918,8 @@ program .option('--token-env ', 'dokploy/vercel import: env var holding the API token') .option('--project ', 'vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision)') .option('--team ', 'vercel import/discover: Vercel team slug or id (default: the token\'s only team)') - .option( - '--create-token', - 'vercel: create the Vercel token from the Vercel CLI sign-in (team-scoped) and save it as _CONNECTOR_VERCEL_TOKEN; with --project/--discover the run then continues, alone it stops after saving. Not with --dry-run', - ) - .option('--token-expires-days ', 'vercel --create-token: days until the created token expires (default: never)') .option('--json', 'emit machine-readable JSON instead of the human UI (import connectors)') + .option('--targets', 'Also write a Vercel deploy target for each imported environment') // COPY-FLAG .option( '--dry-run', 'dokploy import/discover: preview the plan only — resolve settings + read Dokploy, write/push nothing', @@ -980,8 +976,7 @@ program environment: options.environment, project: options.project, team: options.team, - createToken: options.createToken === true, - tokenExpiresDays: options.tokenExpiresDays, + targets: options.targets === true, }); }); diff --git a/src/system/systemStore.ts b/src/system/systemStore.ts index 321f28f2..64473edd 100644 --- a/src/system/systemStore.ts +++ b/src/system/systemStore.ts @@ -423,14 +423,6 @@ export async function openSystemStore(opts: OpenSystemStoreOptions = {}): Promis }; } -/** - * Runs when a secret is missing and a human can be asked, BEFORE the hidden - * prompt. `save` stores a value through the already-open store (so the - * admin-only rule has already been applied). Returns the value when the hook - * made and saved one itself, or `null` to carry on to the hidden prompt. - */ -export type OnMissingSecret = (save: (value: string) => Promise) => Promise; - export interface GetConnectorSecretOptions { orgId?: string; apiUrl?: string; @@ -439,13 +431,13 @@ export interface GetConnectorSecretOptions { interactive: boolean; /** Where the caller's service lives (Dokploy's dashboard URL), so the key help can link to the page that makes the key. */ helpBaseUrl?: string; - /** See `OnMissingSecret`. */ - onMissing?: OnMissingSecret; + /** The slug of the Vercel team that owns the project, when known, so the token instructions can name it. */ + helpTeam?: string; } -/** Before a hidden key prompt: where to get that key, for the four known key names. Stderr, so it never mixes with machine output. */ -function printKeyHelpBeforePrompt(name: string, helpBaseUrl: string | undefined): void { - const help = keyHelpForName(name, helpBaseUrl); +/** Before a hidden key prompt: where to get that key, for the four known key names ("paste it here" — the prompt is right below). Stderr, so it never mixes with machine output. */ +function printKeyHelpBeforePrompt(name: string, helpBaseUrl: string | undefined, helpTeam: string | undefined): void { + const help = keyHelpForName(name, { baseUrl: helpBaseUrl, team: helpTeam, mode: 'paste' }); if (help) console.error(formatKeyHelp(help)); } @@ -466,9 +458,7 @@ export async function getConnectorSecret(name: string, opts: GetConnectorSecretO if (existing !== null) return existing; if (!opts.interactive) return null; - const fromHook = await opts.onMissing?.((v) => pushSystemEnv(ctx, { ...ctx.plaintextEnv, [name]: v })); - if (fromHook) return fromHook; - printKeyHelpBeforePrompt(name, opts.helpBaseUrl); + printKeyHelpBeforePrompt(name, opts.helpBaseUrl, opts.helpTeam); const { value } = await inquirer.prompt([ { @@ -493,8 +483,8 @@ export interface GetDirectionalSecretOptions { interactive: boolean; /** Where the caller's service lives (Dokploy's dashboard URL), so the key help can link to the page that makes the key. */ helpBaseUrl?: string; - /** See `OnMissingSecret`. Runs only on the "make a new value" path, never when reusing the other direction's key. */ - onMissing?: OnMissingSecret; + /** The slug of the Vercel team that owns the project, when known, so the token instructions can name it. */ + helpTeam?: string; /** * Code to refuse with when `primaryName` is missing, `fallbackName` DOES * have a value, and there is no terminal to ask which one the caller @@ -590,9 +580,7 @@ export async function getDirectionalConnectorSecret( // else "add new" — fall through to the same prompt case 2 uses. } - const fromHook = await opts.onMissing?.((v) => pushSystemEnv(ctx, { ...ctx.plaintextEnv, [primaryName]: v })); - if (fromHook) return fromHook; - printKeyHelpBeforePrompt(primaryName, opts.helpBaseUrl); + printKeyHelpBeforePrompt(primaryName, opts.helpBaseUrl, opts.helpTeam); const { value } = await inquirer.prompt([ { diff --git a/src/types/index.ts b/src/types/index.ts index 580aa90a..a509b0fe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -716,21 +716,8 @@ export const ERROR_CODES = { VERCEL_OPTIONS_INVALID: 'VERCEL_OPTIONS_INVALID', /** A shared record had this environment removed, but creating its replacement failed and the removal could not be undone. */ VERCEL_SPLIT_NOT_RESTORED: 'VERCEL_SPLIT_NOT_RESTORED', - // Creating the Vercel token from the Vercel CLI's sign-in (`capy connect vercel --create-token`) - /** The `vercel` program is not installed (or not on PATH). */ - VERCEL_CLI_MISSING: 'VERCEL_CLI_MISSING', - /** The Vercel CLI has no sign-in (its own `whoami` did not succeed). */ - VERCEL_CLI_NOT_SIGNED_IN: 'VERCEL_CLI_NOT_SIGNED_IN', - /** `--create-token` was combined with `--dry-run`; a preview never creates or saves a key. */ - VERCEL_CREATE_TOKEN_DRY_RUN: 'VERCEL_CREATE_TOKEN_DRY_RUN', - /** `--token-expires-days` is not a whole number of days, 1 or more. */ - VERCEL_TOKEN_EXPIRY_INVALID: 'VERCEL_TOKEN_EXPIRY_INVALID', - /** The Vercel CLI could not create the token (or its answer could not be read). */ - VERCEL_TOKEN_CREATE_FAILED: 'VERCEL_TOKEN_CREATE_FAILED', - /** The new token is not scoped to exactly the chosen team; it was deleted again. */ - VERCEL_TOKEN_SCOPE_UNEXPECTED: 'VERCEL_TOKEN_SCOPE_UNEXPECTED', - /** A token was created but could not be deleted again; its id is named so a human can remove it. */ - VERCEL_TOKEN_CLEANUP_FAILED: 'VERCEL_TOKEN_CLEANUP_FAILED', + /** `capy deploy`'s setup was given something that is neither a Vercel dashboard link for a project nor a project name. */ + VERCEL_URL_INVALID: 'VERCEL_URL_INVALID', } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/tests/commands/connectVercel.test.ts b/tests/commands/connectVercel.test.ts index 683ef11a..6dffed2a 100644 --- a/tests/commands/connectVercel.test.ts +++ b/tests/commands/connectVercel.test.ts @@ -436,6 +436,163 @@ describe('vercel import — deploy-target offer (interactive only, like Dokploy) }); }); +// ── --targets: write the deploy target without asking ────────────────────── + +describe('vercel import --targets — the deploy target is written without a question', () => { + const PRODUCTION_TARGET = { + name: 'vercel-production', + kind: 'vercel', + branch: 'production', + vars: ['PLAIN_PROD', 'ENC_ALL'], + mode: 'ci', + gitBaseBranch: 'main', + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, + }; + + const KEY = 'vercel-targets-project-key-0123456789'; + const realCtx = (dir: string, branch: string): ResolvedContext => + ({ + pm: { readSyncState: () => null }, + fileManager: new FileManager(dir), + serviceClient: { pushSecrets: () => { throw new Error('push failed'); } }, + orgId: 'org_1', + projectId: 'p', + branch, + userId: 'u', + projectKey: KEY, + keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }, + localPlaintext: {}, + }) as unknown as ResolvedContext; + + const importTo = (dir: string, branch: string, over: Partial = {}, extra: Partial[0]> = {}) => + captureConsole(() => + new ConnectCommand(false)['executeImport'](makeConnector(DATA, { cwd: dir, ...extra }).connector, 'vercel', realCtx(dir, branch), { ...BASE_OPTS, noPush: true, targets: true, ...over }), + ); + + test('non-interactive and --json: the target is written (no question), identical to what the interactive offer saves, and reported by folder and name', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-targets', async (dir) => { + const confirm = mock(async () => true); + const { stdout, stderr } = await importTo(dir, 'production', { json: true }, { confirm }); + expect(stdout.length).toBe(1); + expect(JSON.parse(stdout[0])).toMatchObject({ + ok: true, + dryRun: false, + deployTargets: [{ folder: '.', name: 'vercel-production', environment: 'production', written: true }], + }); + expect(confirm.mock.calls.length).toBe(0); + expect(listTargets(dir)).toEqual([PRODUCTION_TARGET]); + + // the same shape the interactive offer writes (a second directory, via the offer) + await withTempDir('capy-vercel-offer', async (offerDir) => { + await withTTY(() => + makeConnector(DATA, { confirm: async () => true, selectVars: async (c: readonly string[]) => c, cwd: offerDir }).connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, nonTty: false }), + ); + expect(listTargets(offerDir)).toEqual(listTargets(dir)); + }); + ALL_SENTINELS.concat('tok_fake_value').forEach((v) => expect([...stdout, ...stderr].join('\n') + readFileSync(join(dir, '.env'), 'utf-8')).not.toContain(v)); + }), + ), + ); + }); + + test('human output says what was written; preview has no gitBaseBranch and a custom environment records its ID', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-targets-envs', async (dir) => { + const human = await importTo(dir, 'preview'); + expect(human.stdout.join('\n')).toContain('Wrote deploy target vercel-preview.'); + await importTo(dir, 'staging'); + const byName = Object.fromEntries(listTargets(dir).map((t) => [t.name, t])); + expect(byName['vercel-preview'].gitBaseBranch).toBeUndefined(); + expect(byName['vercel-staging'].options).toEqual({ projectDir: '.', vercelEnv: 'staging', projectId: 'prj_web', teamId: 'team_acme', customEnvironmentId: 'env_c1' }); + }), + ), + ); + }); + + test('--dry-run lists the target it would write and writes nothing (no .capy/deploy.json)', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-targets-dry', async (dir) => { + const json = await importTo(dir, 'production', { dryRun: true, json: true }); + expect(JSON.parse(json.stdout[0])).toMatchObject({ + dryRun: true, + deployTargets: [{ folder: '.', name: 'vercel-production', environment: 'production', written: false }], + }); + const human = await importTo(dir, 'production', { dryRun: true }); + expect(human.stdout.join('\n')).toContain('Would write deploy target vercel-production.'); + expect(existsSync(join(dir, '.capy', 'deploy.json'))).toBe(false); + expect(listTargets(dir)).toEqual([]); + }), + ), + ); + }); + + test('it also works with --overwrite --yes', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-targets-overwrite', async (dir) => { + await importTo(dir, 'production', { overwrite: true, yes: true, json: true }); + expect(listTargets(dir).map((t) => t.name)).toEqual(['vercel-production']); + }), + ), + ); + }); + + test('a project + environment that already has a target gets no second one; nothing imported means no target', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-targets-existing', async (dir) => { + upsertTarget(dir, { name: 'mine', kind: 'vercel', branch: 'production', vars: [], options: { projectId: 'prj_web', vercelEnv: 'production' } }); + const again = await importTo(dir, 'production', { json: true }); + expect(JSON.parse(again.stdout[0]).deployTargets).toBeUndefined(); + expect(listTargets(dir).map((t) => t.name)).toEqual(['mine']); + }), + ), + ); + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-targets-empty', async (dir) => { + const empty = { ...DATA, envs: { prj_web: [] } }; + await captureConsole(() => + new ConnectCommand(false)['executeImport'](makeConnector(empty, { cwd: dir }).connector, 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, noPush: true, targets: true, json: true }), + ); + expect(listTargets(dir)).toEqual([]); + }), + ), + ); + }); + + test('without --targets nothing is written non-interactively, and no deployTargets key appears', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-no-targets', async (dir) => { + const out = await importTo(dir, 'production', { targets: false, json: true }); + expect(JSON.parse(out.stdout[0]).deployTargets).toBeUndefined(); + expect(listTargets(dir)).toEqual([]); + }), + ), + ); + }); + + test('the target is saved only AFTER the import landed: a failed write leaves no target behind', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-targets-failed', async (dir) => { + const failing = await captureConsole(() => + // push is on and the fake service refuses it + new ConnectCommand(false)['executeImport'](makeConnector(DATA, { cwd: dir }).connector, 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, noPush: false, targets: true, json: true }).catch((err: Error) => err.message), + ); + expect(typeof failing.result).toBe('string'); + expect(listTargets(dir)).toEqual([]); + }), + ), + ); + }); +}); + // ── end to end: real FileManager, real executeImport, no value on any surface ─ describe('vercel import — end to end through ConnectCommand.executeImport', () => { diff --git a/tests/commands/connectVercelCreateToken.test.ts b/tests/commands/connectVercelCreateToken.test.ts deleted file mode 100644 index ef32dcda..00000000 --- a/tests/commands/connectVercelCreateToken.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * `capy connect vercel --create-token` through the connector and - * `ConnectCommand`: with a stub `vercel` program on PATH and an in-memory - * stand-in for the org system store. - * - * The property throughout: the token the stub hands back is saved to the store - * under `_CONNECTOR_VERCEL_TOKEN`, is what the following import authenticates - * with, and appears on NO output surface (stdout, stderr, JSON) and in NO - * recorded argv. No `mock.module()`: this file runs in the normal batch. - */ -import { describe, test, expect, mock, spyOn } from 'bun:test'; -import { createVercelConnector, type CreateTokenStore } from '../../src/commands/connectors/vercel'; -import { ConnectCommand } from '../../src/commands/connectCommand'; -import { ERROR_CODES, type KeepFile } from '../../src/types/index'; -import { providers, type ConnectOpts } from '../../src/commands/connectors/registry'; -import type { DiscoveryContext } from '../../src/commands/connectors/dokployDiscovery'; -import type { ResolvedContext } from '../../src/commands/connectors/shared'; -import type { VercelFetchLike } from '../../src/deploy/vercelApi'; -import { callsOf, fakeVercelFetch, project, type FakeVercelData } from '../helpers/fakeVercel'; -import { FAKE_BEARER, createdJson, teamsJson, withStubVercel } from '../helpers/stubVercelCli'; -import { captureConsole, withExitCodeRestored, withIsolatedGlobalDir, withTTY, withTempDir } from '../helpers/testSandbox'; - -const FILES = { 'teams.json': teamsJson([{ id: 'team_acme', slug: 'acme' }]), 'create.json': createdJson() } as const; - -const DATA: FakeVercelData = { - teams: [{ id: 'team_acme', slug: 'acme' }], - projects: { team_acme: [project('prj_web', 'web')] }, - envs: { prj_web: [{ id: 'env_1', key: 'PLAIN_PROD', type: 'plain', target: ['production'], listValue: 'plain-prod-SENT-1a2b' }] }, - custom: { prj_web: [] }, -}; - -function storeStub(existing: readonly string[] = []) { - const set = mock(async (_name: string, _value: string) => {}); - const store: CreateTokenStore = { listNames: () => existing.map((name) => ({ name })), set }; - const openStore = mock(async (_orgId: string, _o: { devMode?: boolean }) => store); - return { set, openStore }; -} - -function build(stubEnv: Record, extra: Partial[0]> = {}, data: FakeVercelData = DATA) { - const store = storeStub(); - const fetchMock = mock(fakeVercelFetch(data)); - const connector = createVercelConnector({ - fetch: fetchMock as unknown as VercelFetchLike, - env: stubEnv, - sleep: async () => {}, - openStore: store.openStore, - ...extra, - }); - return { connector, fetchMock, ...store }; -} - -const ctxWith = (): ResolvedContext => - ({ - keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} } as KeepFile, - branch: 'production', - orgId: 'org_1', - localPlaintext: {}, - }) as unknown as ResolvedContext; - -const discoveryCtx = (): DiscoveryContext => - ({ - orgId: 'org_v', - userId: 'user_v', - authService: {}, - serviceClient: { listBranches: async () => [], listProjects: async () => [] }, - }) as unknown as DiscoveryContext; - -const BASE: ConnectOpts = { nonTty: true, createToken: true }; -const orgId = async () => 'org_1'; - -const noBearer = (surfaces: readonly string[]) => expect(surfaces.join('\n')).not.toContain(FAKE_BEARER); - -describe('--create-token on its own (no --project, no --discover)', () => { - test('creates and saves the token, reports name/id/team, imports nothing, makes no Vercel REST call', async () => { - await withStubVercel(FILES, async (stub) => { - const { connector, set, openStore, fetchMock } = build(stub.env); - const out = await connector.createToken!(BASE, orgId); - expect(out).toMatchObject({ ok: true, tokenCreated: { name: 'capy-acme-import', id: 'tok_new_1', team: { id: 'team_acme', slug: 'acme' }, secretName: '_CONNECTOR_VERCEL_TOKEN' } }); - expect(set.mock.calls).toEqual([['_CONNECTOR_VERCEL_TOKEN', FAKE_BEARER]]); - expect(openStore.mock.calls[0][0]).toBe('org_1'); - expect(fetchMock.mock.calls.length).toBe(0); - expect(stub.rawLog()).not.toContain(FAKE_BEARER); - expect(JSON.stringify(out)).not.toContain(FAKE_BEARER); - }); - }); - - test('--json through ConnectCommand: stdout is ONE JSON object with tokenCreated; the value is nowhere; non-interactive never signs out', async () => { - await withStubVercel(FILES, async (stub) => { - const { connector } = build(stub.env); - const run = await captureConsole(() => new ConnectCommand(false)['executeCreateToken'](connector, 'vercel', { ...BASE, json: true }, orgId)); - expect(run.stdout.length).toBe(1); - expect(JSON.parse(run.stdout[0])).toMatchObject({ - ok: true, - provider: 'vercel', - tokenCreated: { name: 'capy-acme-import', id: 'tok_new_1', team: { slug: 'acme' }, cliSignOut: 'not_offered', next: 'vercel logout' }, - }); - noBearer([...run.stdout, ...run.stderr]); - expect(stub.calls().some((c) => c[0] === 'logout')).toBe(false); - }); - }); - - test('`capy connect vercel --create-token` (the whole command) creates, reports and STOPS: no keep.lock needed, no import, no Vercel REST call', async () => { - await withStubVercel(FILES, async (stub) => { - const { connector, fetchMock } = build(stub.env); - // the real command resolves the org by logging in; the test names it - const wired = { ...connector, createToken: (o: ConnectOpts) => connector.createToken!(o, orgId) }; - const loader = spyOn(providers, 'vercel').mockImplementation(async () => wired); - try { - const run = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false).execute('vercel', { createToken: true, nonTty: true, json: true }))); - expect(run.result).toEqual({ linked: false }); - expect(JSON.parse(run.stdout[0])).toMatchObject({ ok: true, provider: 'vercel', tokenCreated: { id: 'tok_new_1' } }); - expect(fetchMock.mock.calls.length).toBe(0); - } finally { - loader.mockRestore(); - } - }); - }); - - test('human output names the token and the logout next step, never the value', async () => { - await withStubVercel(FILES, async (stub) => { - const { connector } = build(stub.env); - const run = await captureConsole(() => new ConnectCommand(false)['executeCreateToken'](connector, 'vercel', BASE, orgId)); - const text = run.stdout.join('\n'); - expect(text).toContain('capy-acme-import'); - expect(text).toContain('tok_new_1'); - expect(text).toContain('vercel logout'); - noBearer([...run.stdout, ...run.stderr]); - }); - }); - - test('replacing an existing entry is reported', async () => { - await withStubVercel(FILES, async (stub) => { - const store = storeStub(['_CONNECTOR_VERCEL_TOKEN']); - const { connector } = build(stub.env, { openStore: store.openStore }); - const out = await connector.createToken!(BASE, orgId); - expect(out.ok && out.tokenCreated.replacedExisting).toBe(true); - }); - }); - - test('--token-expires-days reaches the CLI as expiresAt; an invalid value is refused before anything runs', async () => { - await withStubVercel(FILES, async (stub) => { - const { connector } = build(stub.env, { cli: { now: () => 5_000 } }); - await connector.createToken!({ ...BASE, tokenExpiresDays: '3' }, orgId); - expect(stub.calls().find((c) => c[1] === '/v3/user/tokens')).toContain(`expiresAt=${5_000 + 3 * 86_400_000}`); - }); - await withStubVercel(FILES, async (stub) => { - const { connector, openStore } = build(stub.env); - const out = await connector.createToken!({ ...BASE, tokenExpiresDays: 'soon' }, orgId); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_EXPIRY_INVALID }); - expect(stub.calls().length).toBe(0); - expect(openStore.mock.calls.length).toBe(0); - }); - }); -}); - -describe('--create-token refusals', () => { - test('--dry-run: VERCEL_CREATE_TOKEN_DRY_RUN from createToken, import and discover; nothing ran, nothing opened, nothing saved', async () => { - await withStubVercel(FILES, async (stub) => { - const { connector, openStore, set, fetchMock } = build(stub.env); - const dry = { ...BASE, dryRun: true }; - const a = await connector.createToken!(dry, orgId); - const b = await connector.import!(ctxWith(), { ...dry, project: 'web' }); - const c = await connector.discover!(discoveryCtx(), dry); - for (const out of [a, b, c]) expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_CREATE_TOKEN_DRY_RUN }); - expect(stub.calls().length).toBe(0); - expect(openStore.mock.calls.length).toBe(0); - expect(set.mock.calls.length).toBe(0); - expect(fetchMock.mock.calls.length).toBe(0); - }); - }); - - test('binary missing: VERCEL_CLI_MISSING (with link + remedy in JSON); the store is not asked to save anything', async () => { - const { connector, set } = build({ PATH: '/nonexistent-dir-for-test' }); - const run = await withExitCodeRestored(() => - captureConsole(() => new ConnectCommand(false)['executeCreateToken'](connector, 'vercel', { ...BASE, json: true }, orgId)), - ); - expect(run.stdout.length).toBe(1); - expect(JSON.parse(run.stdout[0])).toMatchObject({ ok: false, code: 'VERCEL_CLI_MISSING', link: { url: 'https://vercel.com/docs/cli' } }); - expect(JSON.parse(run.stdout[0]).remedy).toBeTruthy(); - expect(set.mock.calls.length).toBe(0); - }); - - test('not signed in: VERCEL_CLI_NOT_SIGNED_IN, human text on stderr only', async () => { - await withStubVercel({ ...FILES, 'whoami.exit': '1' }, async (stub) => { - const { connector } = build(stub.env); - const run = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeCreateToken'](connector, 'vercel', BASE, orgId))); - expect(run.stdout.join('')).toBe(''); - expect(run.stderr.join('\n')).toContain('vercel login'); - }); - }); - - test('a caller who cannot open the store (not an admin) is refused with the store\'s code BEFORE any token is created', async () => { - await withStubVercel(FILES, async (stub) => { - const openStore = mock(async () => { - throw Object.assign(new Error('x'), { code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); - }); - const { connector } = build(stub.env, { openStore }); - const out = await connector.createToken!(BASE, orgId); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); - expect(stub.calls().some((c) => c[1] === '/v3/user/tokens')).toBe(false); - }); - }); - - test('a store save that fails deletes the new token and refuses with the store\'s code', async () => { - await withStubVercel(FILES, async (stub) => { - const store: CreateTokenStore = { - listNames: () => [], - set: async () => { - throw Object.assign(new Error('x'), { code: ERROR_CODES.SERVICE_ERROR }); - }, - }; - const { connector } = build(stub.env, { openStore: async () => store }); - const out = await connector.createToken!(BASE, orgId); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.SERVICE_ERROR }); - expect(stub.calls().filter((c) => c[0] === 'tokens')).toEqual([['tokens', 'rm', 'tok_new_1']]); - }); - }); -}); - -describe('--create-token with --project continues into the import', () => { - test('the import authenticates with the created token; tokenCreated is in the JSON; the value is nowhere', async () => { - await withStubVercel(FILES, async (stub) => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - withTempDir('capy-vercel-ct', async (dir) => { - const { connector, fetchMock, set } = build(stub.env); - const ctx = { - ...ctxWith(), - pm: { readSyncState: () => null }, - fileManager: new (await import('../../src/files/fileManager')).FileManager(dir), - serviceClient: {}, - projectId: 'p', - userId: 'u', - projectKey: 'ct-project-key-0123456789', - } as unknown as ResolvedContext; - const run = await captureConsole(() => - new ConnectCommand(false)['executeImport'](connector, 'vercel', ctx, { ...BASE, project: 'web', environment: 'production', json: true, noPush: true }), - ); - expect(run.stdout.length).toBe(1); - const parsed = JSON.parse(run.stdout[0]); - expect(parsed).toMatchObject({ ok: true, imported: ['PLAIN_PROD'], tokenCreated: { id: 'tok_new_1', team: { slug: 'acme' } } }); - expect(set.mock.calls).toEqual([['_CONNECTOR_VERCEL_TOKEN', FAKE_BEARER]]); - // the import used the created token, and only ever read - const calls = callsOf(fetchMock); - expect(calls.length).toBeGreaterThan(0); - expect(calls.every((c) => c.authorization === `Bearer ${FAKE_BEARER}` && c.method === 'GET')).toBe(true); - expect(calls.some((c) => c.url.includes(FAKE_BEARER))).toBe(false); - noBearer([...run.stdout, ...run.stderr, stub.rawLog()]); - }), - ), - ); - }); - }); - - test('a later refusal still reports the token that was made', async () => { - await withStubVercel(FILES, async (stub) => { - const { connector } = build(stub.env); - const run = await withExitCodeRestored(() => - captureConsole(() => new ConnectCommand(false)['executeImport'](connector, 'vercel', ctxWith(), { ...BASE, project: 'no-such-project', json: true })), - ); - const parsed = JSON.parse(run.stdout[0]); - expect(parsed).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_PROJECT_NOT_FOUND, tokenCreated: { id: 'tok_new_1' } }); - noBearer(run.stdout); - }); - }); - - test('--discover: the token is created, then discovery runs with it', async () => { - await withStubVercel(FILES, async (stub) => { - await withTempDir('capy-vercel-ct-disc', async (root) => { - const { connector, fetchMock } = build(stub.env, { cwd: root }, { ...DATA, projects: { team_acme: [] } }); - const out = await connector.discover!(discoveryCtx(), { ...BASE, discover: true, yes: true }); - expect(out).toMatchObject({ ok: true, tokenCreated: { id: 'tok_new_1' } }); - expect(callsOf(fetchMock).every((c) => c.authorization === `Bearer ${FAKE_BEARER}`)).toBe(true); - }); - }); - }); -}); - -describe('interactive: a missing token offers the Vercel CLI route next to the hidden prompt', () => { - /** The store's own hidden-prompt path, reduced to what the connector sees: it calls `onMissing` first, then (if that gave nothing) would prompt. */ - const storeThatCalls = (saved: { value?: string }) => - mock(async (_name: string, o: { onMissing?: (save: (v: string) => Promise) => Promise }) => { - const made = await o.onMissing?.(async () => {}); - return made ?? saved.value ?? null; - }); - - test('"create": the CLI makes the token, it is saved through the store\'s own save, and the import proceeds with it', async () => { - await withStubVercel(FILES, async (stub) => { - await withTTY(() => - withExitCodeRestored(async () => { - const saves = mock(async (_v: string) => {}); - const getConnectorSecret = mock(async (_n: string, o: { onMissing?: (save: (v: string) => Promise) => Promise }) => o.onMissing?.(saves) ?? null); - const confirm = mock(async (_m: string, d: boolean) => d); - const { connector, fetchMock } = build({ ...stub.env }, { getConnectorSecret, askCreateMethod: async () => 'create', confirm, selectVars: async (c) => c }); - const run = await captureConsole(() => connector.import!(ctxWith(), { project: 'web', environment: 'production', nonTty: false, noPush: true })); - expect(run.result.ok).toBe(true); - expect(saves.mock.calls).toEqual([[FAKE_BEARER]]); - expect(callsOf(fetchMock).every((c) => c.authorization === `Bearer ${FAKE_BEARER}`)).toBe(true); - noBearer([...run.stdout, ...run.stderr, stub.rawLog()]); - // the question "sign the Vercel CLI out now?" defaults to yes - expect(confirm.mock.calls[0][1]).toBe(true); - expect(stub.calls().some((c) => c[0] === 'logout')).toBe(true); - }), - ); - }); - }); - - test('answering no to "sign out?" leaves the CLI signed in', async () => { - await withStubVercel(FILES, async (stub) => { - await withTTY(() => - withExitCodeRestored(async () => { - const getConnectorSecret = mock(async (_n: string, o: { onMissing?: (save: (v: string) => Promise) => Promise }) => o.onMissing?.(async () => {}) ?? null); - const confirm = mock(async () => false); - const { connector } = build({ ...stub.env }, { getConnectorSecret, askCreateMethod: async () => 'create', confirm, selectVars: async (c) => c }); - await captureConsole(() => connector.import!(ctxWith(), { project: 'web', environment: 'production', nonTty: false, noPush: true })); - expect(stub.calls().some((c) => c[0] === 'logout')).toBe(false); - }), - ); - }); - }); - - test('"paste": the CLI is never run and the hidden prompt path carries on', async () => { - await withStubVercel(FILES, async (stub) => { - await withTTY(() => - withExitCodeRestored(async () => { - const saved: { value?: string } = { value: undefined }; - const getConnectorSecret = storeThatCalls(saved); - const { connector } = build({ ...stub.env }, { getConnectorSecret, askCreateMethod: async () => 'paste' }); - const run = await captureConsole(() => connector.import!(ctxWith(), { project: 'web', environment: 'production', nonTty: false, noPush: true })); - expect(run.result).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }); - expect(stub.calls().length).toBe(0); - }), - ); - }); - }); - - test('no `vercel` binary: the choice is not offered at all (the hook is not even passed)', async () => { - await withTTY(() => - withExitCodeRestored(async () => { - const getConnectorSecret = mock(async (_n: string, o: { onMissing?: unknown }) => { - expect(o.onMissing).toBeUndefined(); - return null; - }); - const askCreateMethod = mock(async () => 'create' as const); - const { connector } = build({ PATH: '/nonexistent-dir-for-test' }, { getConnectorSecret, askCreateMethod }); - await captureConsole(() => connector.import!(ctxWith(), { project: 'web', nonTty: false })); - expect(askCreateMethod.mock.calls.length).toBe(0); - }), - ); - }); - - test('a run that cannot prompt (--json) never offers it', async () => { - await withStubVercel(FILES, async (stub) => { - await withTTY(() => - withExitCodeRestored(async () => { - const getConnectorSecret = mock(async (_n: string, o: { onMissing?: unknown }) => { - expect(o.onMissing).toBeUndefined(); - return null; - }); - const { connector } = build({ ...stub.env }, { getConnectorSecret }); - await captureConsole(() => connector.import!(ctxWith(), { project: 'web', nonTty: false, json: true })); - expect(stub.calls().length).toBe(0); - }), - ); - }); - }); -}); diff --git a/tests/commands/connectVercelDiscovery.test.ts b/tests/commands/connectVercelDiscovery.test.ts index ec5c63b4..48748cd4 100644 --- a/tests/commands/connectVercelDiscovery.test.ts +++ b/tests/commands/connectVercelDiscovery.test.ts @@ -21,6 +21,7 @@ import { buildVercelDiscoveryPlan, matchVercelProject } from '../../src/commands import { findCandidateReposResult, type DiscoveryContext } from '../../src/commands/connectors/dokployDiscovery'; import { ConnectCommand } from '../../src/commands/connectCommand'; import { FileManager } from '../../src/files/fileManager'; +import { listTargets, upsertTarget } from '../../src/deploy/config'; import { resolveProjectKey, wrapAndSaveMasterKey } from '../../src/crypto/keyResolver'; import { ERROR_CODES, type KeepFile } from '../../src/types/index'; import type { ConnectOpts, ConnectorModule } from '../../src/commands/connectors/registry'; @@ -420,6 +421,32 @@ describe('connector.discover — a real run, end to end', () => { values: { p2: SECRET.sharedProd, s1: SECRET.bStage, s2: SECRET.sharedStage, h1: SECRET.hidden }, }); + /** An initialized, committed, clean repo + the fake service the real sequencer talks to. */ + const prepareRealRun = async (root: string, orgId: string) => { + const userId = `${orgId}_user`; + const projectId = `${orgId}_proj`; + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), orgId, userId, keyOps); + const keep: KeepFile = { version: '3.0', org_id: orgId, project_id: projectId, project_name: 'widgets', variables: {} }; + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + new FileManager(root).ensureCapyGitignore(); + git(root, ['add', 'keep.lock', '.gitignore']); + git(root, ['commit', '-q', '-m', 'add keep.lock']); + const pushSecrets = mock(async (_p: string, _keep: string, _env: string, _branch: string) => ({ keep_hash: 'h'.repeat(16) })); + const ctx = { + ...fakeDiscoveryCtx({ + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets, + }), + orgId, + userId, + } as DiscoveryContext; + return { ctx, pushSecrets, mainBranch: git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(), mainHead: git(root, ['rev-parse', 'HEAD']).trim() }; + }; + test('--yes: staging then production; each branch is pushed EXACTLY its Vercel environment\'s set (decrypt-verified), the discovery commit is local on its own branch, nothing is pushed to git, and no value is printed', async () => { await withIsolatedGlobalDir(() => withExitCodeRestored(() => @@ -503,4 +530,158 @@ describe('connector.discover — a real run, end to end', () => { ), ); }, 30_000); + + test('--targets --yes: each environment that imports gets its vercel- target (no prompt, non-interactive), and `.capy/deploy.json` is IN the local discovery commit — never pushed', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + inRepo(async (root) => { + const { ctx, mainBranch, mainHead } = await prepareRealRun(root, 'org_v_targets'); + const confirm = mock(async () => true); + const run = await captureConsole(() => connectorFor(root, e2eData(), { confirm }).connector.discover!(ctx, discoverOpts({ yes: true, targets: true }))); + const outcome = run.result; + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + + // the targets, written into the folder's own `.capy/deploy.json`, in the shape the interactive offer writes + const byName = Object.fromEntries(listTargets(root).map((t) => [t.name, t])); + expect(Object.keys(byName).toSorted()).toEqual(['vercel-production', 'vercel-staging']); + expect(byName['vercel-production']).toEqual({ + name: 'vercel-production', + kind: 'vercel', + branch: 'production', + vars: ['A', 'SHARED'], + mode: 'ci', + gitBaseBranch: 'main', + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, + }); + expect(byName['vercel-staging']).toEqual({ + name: 'vercel-staging', + kind: 'vercel', + branch: 'staging', + vars: ['B', 'SHARED'], + mode: 'ci', + options: { projectDir: '.', vercelEnv: 'staging', projectId: 'prj_web', teamId: 'team_acme', customEnvironmentId: 'c_stage' }, + }); + expect(confirm.mock.calls.length).toBe(0); + + // reported per environment (by name), never a value + const folder = outcome.applied?.[0]; + expect(folder?.ok && folder.environments.map((e) => [e.environmentName, e.outcome.deployTarget])).toEqual([ + ['staging', { name: 'vercel-staging', environment: 'staging', written: true }], + ['production', { name: 'vercel-production', environment: 'production', written: true }], + ]); + + // the local discovery commit carries `.capy/deploy.json` beside keep.lock — on its own branch, never pushed + const commit = outcome.commits?.[0]; + expect(commit).toMatchObject({ ok: true, dryRun: false }); + if (!commit?.ok || commit.dryRun) return; + expect([...commit.committedFiles].toSorted()).toEqual(['.capy/deploy.json', '.gitignore', 'keep.lock']); + // (`.gitignore` was already committed and the run left it unchanged, so git records only the two files that changed) + expect(git(root, ['show', '--name-only', '--format=', commit.branch]).split('\n').filter(Boolean).toSorted()).toEqual(['.capy/deploy.json', 'keep.lock']); + expect(JSON.parse(git(root, ['show', `${commit.branch}:.capy/deploy.json`])).targets['vercel-production'].kind).toBe('vercel'); + expect(git(root, ['rev-parse', mainBranch]).trim()).toBe(mainHead); + expect(git(root, ['status', '--porcelain', '--', '.capy/deploy.json']).trim()).toBe(''); + const printed = [...run.stdout, ...run.stderr].join('\n'); + [...ALL_SECRETS, 'tok_fake'].forEach((v) => expect(printed).not.toContain(v)); + }), + ), + ); + }, 30_000); + + test('--targets with a committed, clean `.capy/deploy.json`: the new targets are added beside the existing one and the file\'s change is committed; with an UNCOMMITTED one the commit is refused (never mixed in), though the files are still written', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + inRepo(async (root) => { + const prior = { name: 'mine', kind: 'cf-worker', branch: 'production', vars: [], options: {} }; + const clean = await prepareRealRun(root, 'org_v_clean_deploy'); + upsertTarget(root, prior); + git(root, ['add', '.capy/deploy.json']); + git(root, ['commit', '-q', '-m', 'add deploy.json']); + const cleanRun = await connectorFor(root, e2eData()).connector.discover!(clean.ctx, discoverOpts({ yes: true, targets: true })); + expect(cleanRun.ok).toBe(true); + if (!cleanRun.ok) return; + const commit = cleanRun.commits?.[0]; + expect(commit).toMatchObject({ ok: true, dryRun: false }); + if (commit?.ok && !commit.dryRun) { + expect(Object.keys(JSON.parse(git(root, ['show', `${commit.branch}:.capy/deploy.json`])).targets).toSorted()).toEqual(['mine', 'vercel-production', 'vercel-staging']); + } + }), + ), + ); + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + inRepo(async (root) => { + const dirty = await prepareRealRun(root, 'org_v_dirty_deploy'); + upsertTarget(root, { name: 'mine', kind: 'cf-worker', branch: 'production', vars: [], options: {} }); + const dirtyRun = await connectorFor(root, e2eData()).connector.discover!(dirty.ctx, discoverOpts({ yes: true, targets: true })); + expect(dirtyRun.ok).toBe(true); + if (!dirtyRun.ok) return; + expect(dirtyRun.commits?.[0]).toMatchObject({ ok: false, code: 'DOKPLOY_COMMIT_WOULD_MIX' }); + expect(Object.keys(listTargets(root).reduce((acc, t) => ({ ...acc, [t.name]: t }), {})).toSorted()).toEqual(['mine', 'vercel-production', 'vercel-staging']); + }), + ), + ); + }, 30_000); + + test('without --targets a run writes no deploy.json and the commit holds keep.lock and .gitignore only (unchanged behavior)', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + inRepo(async (root) => { + const { ctx } = await prepareRealRun(root, 'org_v_notargets'); + const outcome = await connectorFor(root, e2eData()).connector.discover!(ctx, discoverOpts({ yes: true })); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(existsSync(join(root, '.capy', 'deploy.json'))).toBe(false); + const commit = outcome.commits?.[0]; + if (commit?.ok && !commit.dryRun) expect([...commit.committedFiles].toSorted()).toEqual(['.gitignore', 'keep.lock']); + }), + ), + ); + }, 30_000); + + test('--targets --dry-run --json: ONE pure JSON object lists every target it would write, by folder and name; nothing is written; the previewed commit names `.capy/deploy.json`', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + const { connector, fetchMock } = connectorFor(root, e2eData()); + const run = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true, targets: true }))); + expect(run.stdout.length).toBe(1); + const out = JSON.parse(run.stdout[0]); + expect(out.dryRun).toBe(true); + expect(out.deployTargets).toEqual([ + { folder: '.', branch: 'staging', name: 'vercel-staging', written: false }, + { folder: '.', branch: 'production', name: 'vercel-production', written: false }, + ]); + expect(out.commits[0]).toMatchObject({ ok: true, dryRun: true }); + expect(out.commits[0].wouldCommitFiles).toContain('.capy/deploy.json'); + expect(existsSync(join(root, '.capy', 'deploy.json'))).toBe(false); + expect(git(root, ['status', '--porcelain']).trim()).toBe(''); + expect(callsOf(fetchMock).every((c) => c.method === 'GET')).toBe(true); + [...ALL_SECRETS, 'tok_fake'].forEach((v) => expect([...run.stdout, ...run.stderr].join('\n')).not.toContain(v)); + }), + ); + }); + + test('--dry-run without --targets reports no targets and previews no deploy.json', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + const { connector } = connectorFor(root, e2eData()); + const run = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true }))); + const out = JSON.parse(run.stdout[0]); + expect(out.deployTargets).toBeUndefined(); + expect(out.commits[0].wouldCommitFiles).not.toContain('.capy/deploy.json'); + }), + ); + }); + + test('--targets --dry-run skips a project + environment that already has a target, and numbers a name that is taken', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + upsertTarget(root, { name: 'mine', kind: 'vercel', branch: 'production', vars: [], options: { projectId: 'prj_web', vercelEnv: 'production' } }); + upsertTarget(root, { name: 'vercel-staging', kind: 'cf-worker', branch: 'x', vars: [], options: {} }); + const { connector } = connectorFor(root, e2eData()); + const run = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true, targets: true }))); + expect(JSON.parse(run.stdout[0]).deployTargets).toEqual([{ folder: '.', branch: 'staging', name: 'vercel-staging-2', written: false }]); + }), + ); + }); }); diff --git a/tests/commands/connectVercelFlags.test.ts b/tests/commands/connectVercelFlags.test.ts new file mode 100644 index 00000000..c753a01f --- /dev/null +++ b/tests/commands/connectVercelFlags.test.ts @@ -0,0 +1,47 @@ +/** + * The `capy connect vercel` flags, through the real CLI program: + * - `--create-token` and `--token-expires-days` are gone — an unknown option + * gets the CLI's normal refusal (exit 1), and neither is in `--help`; + * - `--targets` exists, with its approved help line. + * + * Spawns `src/index.ts` with an empty HOME, so nothing on this machine is read. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const ENTRY = join(import.meta.dir, '..', '..', 'src', 'index.ts'); + +const capy = (args: readonly string[]) => { + const home = mkdtempSync(join(tmpdir(), 'capy-flags-home-')); + try { + const r = spawnSync(process.execPath, ['run', ENTRY, ...args], { + encoding: 'utf-8', + env: { PATH: process.env.PATH ?? '', HOME: home }, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; + } finally { + rmSync(home, { recursive: true, force: true }); + } +}; + +describe('capy connect vercel — flags', () => { + test.each([['--create-token'], ['--token-expires-days']])('%s is an unknown option: the normal refusal, exit 1', (flag) => { + const r = capy(['connect', 'vercel', flag, ...(flag === '--create-token' ? [] : ['3'])]); + expect(r.status).toBe(1); + expect(r.stderr).toContain(flag); + expect(r.stdout).toBe(''); + }); + + test('--help lists --targets with its help line, and neither removed flag', () => { + const r = capy(['connect', '--help']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('--targets'); + // the help wraps lines to the terminal width: compare with whitespace collapsed + expect(r.stdout.replace(/\s+/g, ' ')).toContain('--targets Also write a Vercel deploy target for each imported environment'); + expect(r.stdout).not.toContain('--create-token'); + expect(r.stdout).not.toContain('--token-expires-days'); + }); +}); diff --git a/tests/commands/deployVercelApiMode.test.ts b/tests/commands/deployVercelApiMode.test.ts index 4cd1ff1b..4644dc4f 100644 --- a/tests/commands/deployVercelApiMode.test.ts +++ b/tests/commands/deployVercelApiMode.test.ts @@ -263,6 +263,18 @@ describe('capy deploy — Vercel API mode, end to end (CAP-696)', () => { expect(result.vercelCalls).toBe(0); expect(result.lines.some((l) => l.includes('_TARGET_VERCEL_TOKEN'))).toBe(true); expect(pushSecretsMock).not.toHaveBeenCalled(); + // The refusal says how to make a token, in the approved words (stderr). + const text = result.lines.join('\n'); + expect(text).toContain('Create a token at https://vercel.com/account/settings/tokens. Set Scope to the team that owns the project.'); + expect(text).toContain('Then save it: capy system set _TARGET_VERCEL_TOKEN'); + expect(text).not.toContain(TOKEN); + }, 30_000); + + test('the same refusal names the team when the saved target knows its slug', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production', teamSlug: 'acme' }); + const result = await withCwd(() => run({ yes: true }, {}, null)); + expect(result.code).toBe(1); + expect(result.lines.join('\n')).toContain('Set Scope to the team that owns the project (acme).'); }, 30_000); test('a token Vercel rejects: refused at preflight before any value is sent, nothing recorded', async () => { diff --git a/tests/commands/deployVercelSetupPicker.test.ts b/tests/commands/deployVercelSetupPicker.test.ts new file mode 100644 index 00000000..80d27696 --- /dev/null +++ b/tests/commands/deployVercelSetupPicker.test.ts @@ -0,0 +1,161 @@ +/** + * `capy deploy`'s target picker for Vercel (the wiring around + * `deploy/vercelSetup.ts`, which `tests/deploy/vercelSetup.test.ts` covers on + * its own): the target that gets saved, the PR base branch defaulting to the + * project's production branch, a legacy target coming out in API mode when + * re-edited, the setup staying in the terminal, and no `.vercel/project.json` + * or `vercel` anywhere. + * + * `mock.module('inquirer')` and `mock.module('…/systemStore')` are + * process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, mock, afterEach, spyOn } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync, existsSync, realpathSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; + +const promptMock = mock(async (_questions: unknown) => ({})); +mock.module('inquirer', () => ({ + default: { prompt: promptMock, Separator: class {} }, +})); + +const getSecret = mock(async (..._args: unknown[]) => 'vcp_FAKE_PICKER_TOKEN' as string | null); +mock.module('../../src/system/systemStore', () => ({ getDirectionalConnectorSecret: getSecret })); + +import { runPicker, resolveAdapterOptions } from '../../src/commands/deployCommand'; +import { getAdapter } from '../../src/deploy/registry'; +import type { TargetConfig } from '../../src/deploy/adapter'; +import { fakeVercelFetch, project } from '../helpers/fakeVercel'; +import { captureConsole } from '../helpers/testSandbox'; + +afterEach(() => { + promptMock.mockReset(); + getSecret.mockClear(); +}); + +const DATA = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web', { link: { type: 'github', org: 'acme', repo: 'widgets', productionBranch: 'trunk' } })] }, + envs: {}, + custom: {}, +}; + +/** A throwaway git repo on branch `feature-x` with a `.env` holding two variables. */ +function repo(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'capy-vercel-picker-'))); + const git = (...args: string[]) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' }); + git('init', '-q', '-b', 'feature-x'); + git('config', 'user.email', 't@e.com'); + git('config', 'user.name', 'T'); + git('config', 'commit.gpgsign', 'false'); + writeFileSync(join(dir, '.env'), 'API_KEY=v1\nDB_URL=v2\n'); + writeFileSync(join(dir, 'README.md'), 'x'); + git('add', 'README.md'); + git('commit', '-q', '-m', 'init'); + return dir; +} + +const KEEP = { orgId: 'org_1', projectId: 'proj_1', variables: ['API_KEY', 'DB_URL'], branches: ['production', 'development'] }; + +/** Answers each question by its name: `script[name]` (or its default when the script has none). */ +function answerBy(script: Record) { + promptMock.mockImplementation(async (questions: unknown) => { + const q = (questions as ReadonlyArray<{ name: string; default?: unknown }>)[0]; + return { [q.name]: q.name in script ? script[q.name] : q.default }; + }); +} + +const questionsAsked = () => promptMock.mock.calls.map(([qs]) => (qs as ReadonlyArray<{ name: string; default?: unknown; message?: string }>)[0]); + +const withFakeVercel = async (fn: () => Promise): Promise => { + const fake = fakeVercelFetch(DATA); + const spy = spyOn(globalThis, 'fetch').mockImplementation(((url: string, init: never) => fake(url, init)) as never); + try { + return await fn(); + } finally { + spy.mockRestore(); + } +}; + +describe('capy deploy picker — Vercel (API mode)', () => { + test('a NEW Vercel target: one link question, checked live, saved in API mode; the PR base defaults to the project\'s production branch; no .vercel/project.json', async () => { + const cwd = repo(); + try { + answerBy({ branch: 'production', projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production', vars: ['API_KEY'], name: 'vercel-production' }); + const saved = await withFakeVercel(() => captureConsole(() => runPicker(cwd, KEEP, undefined, 'vercel'))); + const target = saved.result as TargetConfig; + expect(target).toMatchObject({ + name: 'vercel-production', + kind: 'vercel', + branch: 'production', + vars: ['API_KEY'], + mode: 'ci', + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, + }); + // The question asked about the base branch defaults to Vercel's production branch, not the checked-out branch (feature-x). + const baseQuestion = questionsAsked().find((q) => q.name === 'gitBaseBranch'); + expect(baseQuestion?.default).toBe('trunk'); + expect(target.gitBaseBranch).toBe('trunk'); + // None of the old CLI-path questions exist. + expect(questionsAsked().map((q) => q.name)).not.toContain('projectDir'); + expect(questionsAsked().map((q) => q.message).join('\n')).not.toContain('.vercel/project.json'); + expect(existsSync(join(cwd, '.vercel'))).toBe(false); + expect(saved.stdout.join('\n') + saved.stderr.join('\n')).not.toContain('vcp_FAKE_PICKER_TOKEN'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('re-editing a LEGACY target (no projectId) goes through the new setup and is saved in API mode', async () => { + const cwd = repo(); + try { + const legacy: TargetConfig = { + name: 'old-vercel', + kind: 'vercel', + branch: 'production', + vars: ['API_KEY'], + mode: 'ci', + gitBaseBranch: 'main', + options: { projectDir: 'web', vercelEnv: 'preview', gitBranch: 'develop' }, + }; + answerBy({ branch: 'production', projectUrl: 'web', confirmed: true, vercelEnv: 'preview', gitBranch: 'develop', vars: ['API_KEY'], name: 'old-vercel' }); + const saved = await withFakeVercel(() => captureConsole(() => runPicker(cwd, KEEP, legacy))); + expect((saved.result as TargetConfig).options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_web', teamId: 'team_acme', gitBranch: 'develop' }); + expect(questionsAsked().find((q) => q.name === 'gitBranch')?.default).toBe('develop'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('with --web the Vercel setup stays in the terminal (no browser page is opened), like Dokploy\'s', async () => { + const cwd = repo(); + try { + answerBy({ branch: 'production', projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production', vars: ['API_KEY'], name: 'v' }); + const saved = await withFakeVercel(() => captureConsole(() => runPicker(cwd, KEEP, undefined, 'vercel', { web: true }))); + expect(saved.stdout.join('\n')).toContain('Vercel setup runs in the terminal.'); + expect((saved.result as TargetConfig).options).toMatchObject({ projectId: 'prj_web' }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('a dry run (readOnly) never lets the store ask for or save a token', async () => { + const cwd = repo(); + try { + answerBy({ branch: 'production', projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production', vars: ['API_KEY'], name: 'v' }); + await withFakeVercel(() => captureConsole(() => runPicker(cwd, KEEP, undefined, 'vercel', undefined, { readOnly: true }))); + expect(getSecret.mock.calls[0][2]).toMatchObject({ interactive: false }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('resolveAdapterOptions for Vercel returns the same API-mode options (what a caller saves verbatim)', async () => { + answerBy({ projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production' }); + const adapter = getAdapter('vercel'); + if (!adapter) throw new Error('vercel adapter not registered'); + const options = await withFakeVercel(() => resolveAdapterOptions(adapter, '/tmp', [], {}, {}, 'org_1')); + expect(options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }); + }); +}); diff --git a/tests/commands/keyHelpRefusals.test.ts b/tests/commands/keyHelpRefusals.test.ts index d2b2ee1b..6c34a860 100644 --- a/tests/commands/keyHelpRefusals.test.ts +++ b/tests/commands/keyHelpRefusals.test.ts @@ -21,6 +21,11 @@ import type { VercelFetchLike } from '../../src/deploy/vercelApi'; import { captureConsole, withExitCodeRestored } from '../helpers/testSandbox'; const VERCEL_PAGE = 'https://vercel.com/account/settings/tokens'; +/** Vince's approved wording, verbatim. */ +const CREATE = `Create a token at ${VERCEL_PAGE}. Set Scope to the team that owns the project`; +const sentence = (team?: string) => `${CREATE}${team ? ` (${team})` : ''}.`; +const SAVE = (name: string) => `Then save it: capy system set ${name}`; +const FAKE_TOKEN = 'vcp_FAKE_TOKEN_VALUE_123'; const DOKPLOY_BASE = 'https://deploy.example.test'; const ctxWith = (): ResolvedContext => @@ -43,19 +48,32 @@ async function printed(connector: ReturnType, prov } describe('keyHelp — the four known names', () => { - test('Vercel: the token page and the --create-token alternative, for both names', () => { + test('Vercel: the approved two sentences, for both names — one string in `remedy`, one line each for the terminal', () => { for (const name of ['_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN']) { const help = keyHelpForName(name); - expect(help?.link.url).toBe(VERCEL_PAGE); - expect(help?.remedy).toContain(`capy system set ${name}`); - expect(help?.remedy).toContain('capy connect vercel --create-token'); + expect(help?.link).toEqual({ label: 'Vercel token page', url: VERCEL_PAGE }); + expect(help?.remedy).toBe(`${sentence()} ${SAVE(name)}`); + expect(help?.lines).toEqual([sentence(), SAVE(name)]); + expect(help?.remedy).not.toContain('--create-token'); } }); + test('Vercel: the team slug is named when known; a team id is never shown as a slug', () => { + expect(vercelKeyHelp('_TARGET_VERCEL_TOKEN', { team: 'acme' }).lines[0]).toBe(sentence('acme')); + expect(vercelKeyHelp('_TARGET_VERCEL_TOKEN', { team: 'team_abc123' }).lines[0]).toBe(sentence()); + expect(keyHelpForName('_CONNECTOR_VERCEL_TOKEN', { team: 'acme' })?.remedy).toContain('(acme)'); + }); + + test('Vercel at a hidden prompt: "Then paste it here." instead of the save line', () => { + const help = vercelKeyHelp('_TARGET_VERCEL_TOKEN', { team: 'acme', mode: 'paste' }); + expect(help.lines).toEqual([sentence('acme'), 'Then paste it here.']); + expect(formatKeyHelp(help)).not.toContain('capy system set'); + }); + test('Dokploy: the page inside the user\'s own Dokploy when the base URL is known (trailing slashes ignored), else the docs', () => { expect(dokployKeyHelp('_CONNECTOR_DOKPLOY_API_KEY', `${DOKPLOY_BASE}///`).link.url).toBe(`${DOKPLOY_BASE}/dashboard/settings/profile`); expect(keyHelpForName('_TARGET_DOKPLOY_API_KEY')?.link.url).toBe('https://docs.dokploy.com/docs/api'); - expect(keyHelpForName('_TARGET_DOKPLOY_API_KEY', DOKPLOY_BASE)?.link.url).toBe(`${DOKPLOY_BASE}/dashboard/settings/profile`); + expect(keyHelpForName('_TARGET_DOKPLOY_API_KEY', { baseUrl: DOKPLOY_BASE })?.link.url).toBe(`${DOKPLOY_BASE}/dashboard/settings/profile`); }); test('any other name has no help', () => { @@ -65,8 +83,8 @@ describe('keyHelp — the four known names', () => { test('the human text carries the URL and the remedy', () => { const text = formatKeyHelp(vercelKeyHelp('_CONNECTOR_VERCEL_TOKEN')); - expect(text).toContain(VERCEL_PAGE); - expect(text).toContain('capy system set _CONNECTOR_VERCEL_TOKEN'); + expect(text).toBe(` ${sentence()}\n ${SAVE('_CONNECTOR_VERCEL_TOKEN')}`); + expect(formatKeyHelp(dokployKeyHelp('_CONNECTOR_DOKPLOY_API_KEY', DOKPLOY_BASE))).toContain(`${DOKPLOY_BASE}/dashboard/settings/profile`); }); }); @@ -81,9 +99,37 @@ describe('capy connect vercel — key refusals', () => { expect(parsed).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, link: { url: VERCEL_PAGE } }); expect(typeof parsed.message).toBe('string'); expect(parsed.link.label).toBeTruthy(); - expect(parsed.remedy).toContain('--create-token'); + expect(parsed.remedy).toBe(`${sentence()} ${SAVE('_CONNECTOR_VERCEL_TOKEN')}`); expect(human.stdout.join('')).toBe(''); - expect(human.stderr.join('\n')).toContain(VERCEL_PAGE); + expect(human.stderr.join('\n')).toContain(sentence()); + expect(human.stderr.join('\n')).toContain(SAVE('_CONNECTOR_VERCEL_TOKEN')); + expect([...json.stdout, ...json.stderr, ...human.stdout, ...human.stderr].join('\n')).not.toContain('--create-token'); + }); + + test('--team is named in the instructions (JSON remedy and stderr); a team id is not', async () => { + const connector = createVercelConnector({ fetch: unreachable, env: {} }); + const named = await printed(connector, 'vercel', { ...opts, team: 'acme' }); + expect(JSON.parse(named.json.stdout[0]).remedy).toBe(`${sentence('acme')} ${SAVE('_CONNECTOR_VERCEL_TOKEN')}`); + expect(named.human.stderr.join('\n')).toContain(sentence('acme')); + const byId = await printed(connector, 'vercel', { ...opts, team: 'team_abc123' }); + expect(JSON.parse(byId.json.stdout[0]).remedy).toContain(`${sentence()} `); + }); + + test('--discover with no token carries the same instructions (team slug included)', async () => { + const connector = createVercelConnector({ fetch: unreachable, env: {} }); + const out = await connector.discover!( + { orgId: 'o', userId: 'u', authService: {}, serviceClient: {} } as never, + { nonTty: true, discover: true, team: 'acme' }, + ); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, link: { url: VERCEL_PAGE } }); + expect((out as { remedy?: string }).remedy).toBe(`${sentence('acme')} ${SAVE('_CONNECTOR_VERCEL_TOKEN')}`); + }); + + test('a token value is never printed: the token env var holds a fake value and it appears nowhere', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const connector = createVercelConnector({ fetch: unauthorized, env: { T: FAKE_TOKEN }, sleep: async () => {} }); + const { json, human } = await printed(connector, 'vercel', { ...opts, tokenEnv: 'T' }); + expect([...json.stdout, ...json.stderr, ...human.stdout, ...human.stderr].join('\n')).not.toContain(FAKE_TOKEN); }); test('the store refusing (not an admin / the other direction already has a key) carries it too', async () => { @@ -185,8 +231,13 @@ describe('capy deploy — the preflight refusal for a missing or rejected key ca const adapter = createVercelAdapter({ env: {}, fetch: unreachable as never, getTargetSecret: async () => null }); const r = await adapter.preflight(vercelTarget, { cwd: '/nonexistent-cwd' }); expect(r).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, link: { url: VERCEL_PAGE } }); - expect(r.remedy).toContain('capy system set _TARGET_VERCEL_TOKEN'); - expect(r.remedy).toContain('capy connect vercel --create-token'); + expect(r.remedy).toBe(`${sentence()} ${SAVE('_TARGET_VERCEL_TOKEN')}`); + }); + + test('Vercel API mode, no token, target saved with its team slug: the slug is named', async () => { + const adapter = createVercelAdapter({ env: {}, fetch: unreachable as never, getTargetSecret: async () => null }); + const r = await adapter.preflight({ ...vercelTarget, options: { ...vercelTarget.options, teamSlug: 'acme' } }, { cwd: '/nonexistent-cwd' }); + expect(r.remedy).toBe(`${sentence('acme')} ${SAVE('_TARGET_VERCEL_TOKEN')}`); }); test('Vercel API mode, token rejected (401)', async () => { diff --git a/tests/commands/systemSetKeyHelp.test.ts b/tests/commands/systemSetKeyHelp.test.ts index ae5e4aed..68c69918 100644 --- a/tests/commands/systemSetKeyHelp.test.ts +++ b/tests/commands/systemSetKeyHelp.test.ts @@ -1,7 +1,11 @@ /** - * `capy system set ` prints where to get the key before the hidden - * prompt, for the four known key names — and nothing new for any other name. - * The help goes to stderr, so `--json` stdout stays one pure JSON object. + * `capy system set ` shows where to get the key, for the four known key + * names — and nothing new for any other name. + * + * - With a terminal: before the hidden prompt, ending "Then paste it here." + * (Vercel) — stderr, so `--json` stdout stays one pure JSON object. + * - Without one: part of the `SYSTEM_STORE_NEEDS_TTY` refusal — `link` + + * `remedy` in the JSON, the instructions on stderr in human mode. * * Command layer only: the store and `inquirer` are mocked (`mock.module`), so * this file runs on its own (see ISOLATED_FILES in tests/run-tests.sh). @@ -21,34 +25,59 @@ mock.module('inquirer', () => ({ default: { prompt: typed, createPromptModule: ( afterAll(() => mock.restore()); +const VERCEL_PAGE = 'https://vercel.com/account/settings/tokens'; +const VERCEL_SENTENCE = `Create a token at ${VERCEL_PAGE}. Set Scope to the team that owns the project.`; + const run = async (name: string, json: boolean) => { const { systemSetCommand } = await import('../../src/commands/systemCommand'); return withTTY(() => captureConsole(() => systemSetCommand(name, { json }))); }; -const URLS: ReadonlyArray<[string, string]> = [ - ['_CONNECTOR_VERCEL_TOKEN', 'https://vercel.com/account/settings/tokens'], - ['_TARGET_VERCEL_TOKEN', 'https://vercel.com/account/settings/tokens'], - ['_CONNECTOR_DOKPLOY_API_KEY', 'https://docs.dokploy.com/docs/api'], - ['_TARGET_DOKPLOY_API_KEY', 'https://docs.dokploy.com/docs/api'], -]; +/** `systemSetCommand` ends a refusal with `process.exit`; turn that into a throw so the output can be read. */ +const refused = async (name: string, json: boolean) => { + const { systemSetCommand } = await import('../../src/commands/systemCommand'); + const exit = mock((code?: number) => { + throw new Error(`exit:${code}`); + }); + const original = process.exit; + Object.defineProperty(process, 'exit', { value: exit, configurable: true }); + const saved = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + try { + const out = await captureConsole(() => systemSetCommand(name, { json }).catch((err: Error) => err.message)); + return { ...out, exitCodes: exit.mock.calls.map((c) => c[0]) }; + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: saved, configurable: true }); + Object.defineProperty(process, 'exit', { value: original, configurable: true }); + } +}; describe('capy system set — key help before the hidden prompt', () => { - for (const [name, url] of URLS) { - test(`${name}: the link is on stderr; the saved value is what was typed; the value is never echoed`, async () => { + for (const name of ['_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN']) { + test(`${name}: the approved sentence and "Then paste it here." are on stderr; the typed value is saved and never echoed`, async () => { const out = await run(name, false); - expect(out.stderr.join('\n')).toContain(url); - expect(out.stderr.join('\n')).toContain(`capy system set ${name}`); + const stderr = out.stderr.join('\n'); + expect(stderr).toContain(VERCEL_SENTENCE); + expect(stderr).toContain('Then paste it here.'); + expect(stderr).not.toContain('capy system set'); expect(setStore.mock.calls.at(-1)).toEqual([name, 'typed-fake-value']); expect([...out.stdout, ...out.stderr].join('\n')).not.toContain('typed-fake-value'); }); } - test('--json: stdout is exactly one JSON object; the link is on stderr only', async () => { + for (const name of ['_CONNECTOR_DOKPLOY_API_KEY', '_TARGET_DOKPLOY_API_KEY']) { + test(`${name}: the Dokploy docs link is on stderr (no base URL is known here)`, async () => { + const out = await run(name, false); + expect(out.stderr.join('\n')).toContain('https://docs.dokploy.com/docs/api'); + expect(setStore.mock.calls.at(-1)).toEqual([name, 'typed-fake-value']); + }); + } + + test('--json: stdout is exactly one JSON object; the help is on stderr only', async () => { const out = await run('_CONNECTOR_VERCEL_TOKEN', true); expect(out.stdout.length).toBe(1); expect(JSON.parse(out.stdout[0])).toEqual({ ok: true, name: '_CONNECTOR_VERCEL_TOKEN' }); - expect(out.stderr.join('\n')).toContain('https://vercel.com/account/settings/tokens'); + expect(out.stderr.join('\n')).toContain(VERCEL_PAGE); }); test('any other name: nothing new is printed', async () => { @@ -57,3 +86,29 @@ describe('capy system set — key help before the hidden prompt', () => { expect(out.stdout).toEqual(['Saved _CONNECTOR_STRIPE_API_KEY.']); }); }); + +describe('capy system set — no terminal: the refusal carries the instructions', () => { + test('--json: SYSTEM_STORE_NEEDS_TTY with link + remedy (the same two sentences as one string); stdout is one JSON object', async () => { + const out = await refused('_TARGET_VERCEL_TOKEN', true); + expect(out.stdout.length).toBe(1); + const parsed = JSON.parse(out.stdout[0]); + expect(parsed).toMatchObject({ ok: false, code: 'SYSTEM_STORE_NEEDS_TTY', link: { url: VERCEL_PAGE } }); + expect(parsed.link.label).toBeTruthy(); + expect(parsed.remedy).toBe(`${VERCEL_SENTENCE} Then save it: capy system set _TARGET_VERCEL_TOKEN`); + expect(out.exitCodes).toEqual([3]); + }); + + test('human: the instructions are on stderr, nothing on stdout', async () => { + const out = await refused('_CONNECTOR_VERCEL_TOKEN', false); + expect(out.stdout).toEqual([]); + expect(out.stderr.join('\n')).toContain(VERCEL_SENTENCE); + expect(out.stderr.join('\n')).toContain('Then save it: capy system set _CONNECTOR_VERCEL_TOKEN'); + }); + + test('another name: the refusal is what it always was, with no link', async () => { + const out = await refused('_CONNECTOR_STRIPE_API_KEY', true); + const parsed = JSON.parse(out.stdout[0]); + expect(parsed.link).toBeUndefined(); + expect(parsed.remedy).toBeUndefined(); + }); +}); diff --git a/tests/deploy/vercelApiMode.test.ts b/tests/deploy/vercelApiMode.test.ts index 5194f01a..83ce114c 100644 --- a/tests/deploy/vercelApiMode.test.ts +++ b/tests/deploy/vercelApiMode.test.ts @@ -379,6 +379,20 @@ describe('vercel API mode — preflight', () => { expect(fake.writeCalls()).toEqual([]); }); + test('a target saved with only the team\'s slug (setup could not check the project) scopes every request by ?slug=, never ?teamId=', async () => { + const { adapter, fake } = setUp({ teamId: undefined }); + const r = await prefl(adapter, { vercelEnv: 'production', teamId: undefined, teamSlug: 'acme' }); + expect(r.ok).toBe(true); + expect(fake.calls().length).toBeGreaterThan(0); + expect(fake.calls().every((c) => c.query.slug === 'acme' && c.query.teamId === undefined)).toBe(true); + }); + + test('a teamId wins over a slug when both are present', async () => { + const { adapter, fake } = setUp(); + await prefl(adapter, { vercelEnv: 'production', teamSlug: 'acme' }); + expect(fake.calls().every((c) => c.query.teamId === TEAM && c.query.slug === undefined)).toBe(true); + }); + test('an empty vars list is refused', async () => { const { adapter } = setUp(); const r = await adapter.preflight(target({ vercelEnv: 'production' }, []), { cwd: '/nonexistent-cwd' }); diff --git a/tests/deploy/vercelCliToken.test.ts b/tests/deploy/vercelCliToken.test.ts deleted file mode 100644 index 3a1fd850..00000000 --- a/tests/deploy/vercelCliToken.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Creating the Vercel token from the Vercel CLI's sign-in (`mintTeamToken`). - * - * A stub `vercel` program on PATH records every call's argv and answers from - * canned JSON / exit codes, so the REAL runner (execFile, PATH search, stdout - * capture) is what runs. The property under test: the token the stub hands - * back is exactly what reaches `save`, and appears nowhere else — not in any - * recorded argv, not in a report, not in a refusal. - */ -import { describe, test, expect, mock } from 'bun:test'; -import { mintTeamToken, parseExpiryDays, scopesAreExactlyTeam, type MintRequest } from '../../src/deploy/vercelCliToken'; -import { ERROR_CODES } from '../../src/types/index'; -import { FAKE_BEARER, createdJson, teamsJson, withStubVercel } from '../helpers/stubVercelCli'; - -const ACME = { id: 'team_acme', slug: 'acme' }; -const OTHER = { id: 'team_other', slug: 'other' }; - -const OK_FILES = { 'teams.json': teamsJson([ACME]), 'create.json': createdJson() } as const; - -/** A save target that records what it was given in a `mock` (never in an array of the test's own). */ -function savingTarget() { - const save = mock(async (_value: string) => {}); - return { save, prepare: async () => ({ ok: true as const, save, replacedExisting: false }) }; -} - -function request(over: Partial = {}): MintRequest { - return { pickTeam: null, prepare: savingTarget().prepare, askSignOut: null, ...over }; -} - -const verbs = (calls: readonly (readonly string[])[]): readonly string[] => calls.map((c) => (c[0] === 'api' ? `api ${c[1].split('?')[0]}` : c.slice(0, 2).join(' '))); - -describe('mintTeamToken — happy path', () => { - test('saves exactly the stub bearer; the value is in no argv and no report', async () => { - await withStubVercel(OK_FILES, async (stub) => { - const target = savingTarget(); - const out = await mintTeamToken({ env: stub.env }, request({ prepare: target.prepare })); - expect(out.ok).toBe(true); - if (!out.ok) return; - expect(target.save.mock.calls).toEqual([[FAKE_BEARER]]); - expect(out.bearer).toBe(FAKE_BEARER); - // never on a command line, never in the report - expect(stub.rawLog()).not.toContain(FAKE_BEARER); - expect(JSON.stringify(out.report)).not.toContain(FAKE_BEARER); - expect(out.report).toMatchObject({ name: 'capy-acme-import', id: 'tok_new_1', team: ACME, secretName: '_CONNECTOR_VERCEL_TOKEN', replacedExisting: false }); - expect(verbs(stub.calls())).toEqual(['whoami', 'api /v2/teams', 'api /v3/user/tokens']); - }); - }); - - test('the create call is `api /v3/user/tokens -X POST -f name=… --scope `, with no expiry by default', async () => { - await withStubVercel(OK_FILES, async (stub) => { - await mintTeamToken({ env: stub.env }, request()); - const create = stub.calls().find((c) => c[1] === '/v3/user/tokens'); - expect(create).toEqual(['api', '/v3/user/tokens', '-X', 'POST', '-f', 'name=capy-acme-import', '--scope', 'acme']); - }); - }); - - test('--token-expires-days becomes expiresAt in milliseconds', async () => { - await withStubVercel(OK_FILES, async (stub) => { - const out = await mintTeamToken({ env: stub.env, now: () => 1_000_000 }, request({ expiresDays: 2 })); - const create = stub.calls().find((c) => c[1] === '/v3/user/tokens'); - expect(create).toContain('-F'); - expect(create).toContain(`expiresAt=${1_000_000 + 2 * 86_400_000}`); - expect(out.ok && out.report.expiresAt).toBe(new Date(1_000_000 + 2 * 86_400_000).toISOString()); - }); - }); - - test('replacedExisting is reported from the store step', async () => { - await withStubVercel(OK_FILES, async (stub) => { - const out = await mintTeamToken({ env: stub.env }, request({ prepare: async () => ({ ok: true, save: async () => {}, replacedExisting: true }) })); - expect(out.ok && out.report.replacedExisting).toBe(true); - }); - }); -}); - -describe('mintTeamToken — which team', () => { - const several = { 'teams.json': teamsJson([ACME, OTHER]), 'create.json': createdJson() }; - - test('--team and --team both pick that team', async () => { - await withStubVercel(several, async (stub) => { - const bySlug = await mintTeamToken({ env: stub.env }, request({ team: 'acme' })); - expect(bySlug.ok && bySlug.report.team).toEqual(ACME); - const byId = await mintTeamToken({ env: stub.env }, request({ team: 'team_acme' })); - expect(byId.ok && byId.report.team).toEqual(ACME); - }); - }); - - test('several teams and nobody to ask: VERCEL_TEAM_REQUIRED, nothing created, nothing saved', async () => { - await withStubVercel(several, async (stub) => { - const target = savingTarget(); - const out = await mintTeamToken({ env: stub.env }, request({ prepare: target.prepare })); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TEAM_REQUIRED }); - expect(target.save.mock.calls.length).toBe(0); - expect(stub.calls().some((c) => c[1] === '/v3/user/tokens')).toBe(false); - }); - }); - - test('several teams and a pick: the picked one', async () => { - await withStubVercel({ 'teams.json': teamsJson([ACME, OTHER]), 'create.json': createdJson({ scopes: [{ type: 'team', teamId: 'team_other' }] }) }, async (stub) => { - const out = await mintTeamToken({ env: stub.env }, request({ pickTeam: async () => 'team_other' })); - expect(out.ok && out.report.team).toEqual(OTHER); - }); - }); - - test('an unknown --team: VERCEL_TEAM_NOT_FOUND; no teams at all: VERCEL_TEAM_REQUIRED', async () => { - await withStubVercel(OK_FILES, async (stub) => { - expect(await mintTeamToken({ env: stub.env }, request({ team: 'nope' }))).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TEAM_NOT_FOUND }); - }); - await withStubVercel({ 'teams.json': teamsJson([]), 'create.json': createdJson() }, async (stub) => { - expect(await mintTeamToken({ env: stub.env }, request({}))).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TEAM_REQUIRED }); - }); - }); -}); - -describe('mintTeamToken — the Vercel CLI', () => { - test('binary missing: VERCEL_CLI_MISSING with an install link; nothing ran', async () => { - const out = await mintTeamToken({ env: { PATH: '/nonexistent-dir-for-test' } }, request()); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_CLI_MISSING }); - expect(out.ok === false && out.link?.url).toBe('https://vercel.com/docs/cli'); - }); - - test('not signed in (whoami exits non-zero): VERCEL_CLI_NOT_SIGNED_IN; no team list, no create', async () => { - await withStubVercel({ ...OK_FILES, 'whoami.exit': '1' }, async (stub) => { - const out = await mintTeamToken({ env: stub.env }, request()); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_CLI_NOT_SIGNED_IN }); - expect(verbs(stub.calls())).toEqual(['whoami']); - }); - }); - - test('VERCEL_TOKEN in the caller environment is not passed to the CLI (the sign-in is the only identity)', async () => { - await withStubVercel(OK_FILES, async (stub) => { - const out = await mintTeamToken({ env: { ...stub.env, VERCEL_TOKEN: 'vcp_ENV_TOKEN_SENTINEL' } }, request()); - expect(out.ok).toBe(true); - expect(stub.tokenEnvSeen().length).toBeGreaterThan(0); - expect(stub.tokenEnvSeen().every((l) => l === '[]')).toBe(true); - }); - }); - - test('the CLI refusing to create (non-zero exit): VERCEL_TOKEN_CREATE_FAILED with the token page', async () => { - await withStubVercel({ ...OK_FILES, 'create.exit': '1' }, async (stub) => { - const target = savingTarget(); - const out = await mintTeamToken({ env: stub.env }, request({ prepare: target.prepare })); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_CREATE_FAILED }); - expect(out.ok === false && out.link?.url).toBe('https://vercel.com/account/settings/tokens'); - expect(target.save.mock.calls.length).toBe(0); - }); - }); -}); - -describe('mintTeamToken — the new token must be scoped to exactly the chosen team', () => { - const bad: ReadonlyArray<[string, readonly unknown[]]> = [ - ['a user scope', [{ type: 'user' }]], - ['another team', [{ type: 'team', teamId: 'team_other' }]], - ['extra scopes', [{ type: 'team', teamId: 'team_acme' }, { type: 'team', teamId: 'team_other' }]], - ['no scopes', []], - ]; - - for (const [label, scopes] of bad) { - test(`${label}: the token is deleted at once and VERCEL_TOKEN_SCOPE_UNEXPECTED is returned; nothing saved`, async () => { - await withStubVercel({ 'teams.json': teamsJson([ACME]), 'create.json': createdJson({ scopes }) }, async (stub) => { - const target = savingTarget(); - const out = await mintTeamToken({ env: stub.env }, request({ prepare: target.prepare })); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_SCOPE_UNEXPECTED }); - expect(target.save.mock.calls.length).toBe(0); - expect(stub.calls().filter((c) => c[0] === 'tokens')).toEqual([['tokens', 'rm', 'tok_new_1']]); - expect(JSON.stringify(out)).not.toContain(FAKE_BEARER); - }); - }); - } - - test('scope check and delete both fail: VERCEL_TOKEN_CLEANUP_FAILED names the id, never the value', async () => { - await withStubVercel({ 'teams.json': teamsJson([ACME]), 'create.json': createdJson({ scopes: [{ type: 'user' }] }), 'rm.exit': '1' }, async (stub) => { - const out = await mintTeamToken({ env: stub.env }, request()); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_CLEANUP_FAILED, tokenId: 'tok_new_1' }); - expect(JSON.stringify(out)).toContain('tok_new_1'); - expect(JSON.stringify(out)).not.toContain(FAKE_BEARER); - }); - }); - - test('scopesAreExactlyTeam: exactly one team scope for that team and nothing else', () => { - expect(scopesAreExactlyTeam([{ type: 'team', teamId: 'team_acme' }], 'team_acme')).toBe(true); - expect(scopesAreExactlyTeam([{ type: 'user' }], 'team_acme')).toBe(false); - expect(scopesAreExactlyTeam([], 'team_acme')).toBe(false); - }); -}); - -describe('mintTeamToken — saving', () => { - test('save fails: the token is deleted and the STORE\'s code is returned', async () => { - await withStubVercel(OK_FILES, async (stub) => { - const failing = request({ - prepare: async () => ({ - ok: true, - save: async () => { - throw Object.assign(new Error('boom'), { code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); - }, - replacedExisting: false, - }), - }); - const out = await mintTeamToken({ env: stub.env }, failing); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); - expect(stub.calls().filter((c) => c[0] === 'tokens')).toEqual([['tokens', 'rm', 'tok_new_1']]); - expect(JSON.stringify(out)).not.toContain(FAKE_BEARER); - expect(JSON.stringify(out)).not.toContain('boom'); - }); - }); - - test('save fails and delete fails too: VERCEL_TOKEN_CLEANUP_FAILED with the id', async () => { - await withStubVercel({ ...OK_FILES, 'rm.exit': '1' }, async (stub) => { - const failing = request({ prepare: async () => ({ ok: true, save: async () => Promise.reject(new Error('x')), replacedExisting: false }) }); - expect(await mintTeamToken({ env: stub.env }, failing)).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_CLEANUP_FAILED, tokenId: 'tok_new_1' }); - }); - }); - - test('the store step refusing (not an admin) happens BEFORE any token is created', async () => { - await withStubVercel(OK_FILES, async (stub) => { - const out = await mintTeamToken( - { env: stub.env }, - request({ prepare: async () => ({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY, message: 'no' }) }), - ); - expect(out).toMatchObject({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); - expect(stub.calls().some((c) => c[1] === '/v3/user/tokens')).toBe(false); - }); - }); -}); - -describe('mintTeamToken — signing the Vercel CLI out', () => { - test('interactive, yes: runs `vercel logout`', async () => { - await withStubVercel(OK_FILES, async (stub) => { - const ask = mock(async (_m: string, _d: boolean) => true); - const out = await mintTeamToken({ env: stub.env }, request({ askSignOut: ask })); - expect(ask.mock.calls[0][1]).toBe(true); // default yes - expect(stub.calls().some((c) => c[0] === 'logout')).toBe(true); - expect(out.ok && out.report.cliSignOut).toBe('done'); - expect(out.ok && out.report.next).toBeUndefined(); - }); - }); - - test('interactive, no: does not log out, and reports the next step', async () => { - await withStubVercel(OK_FILES, async (stub) => { - const out = await mintTeamToken({ env: stub.env }, request({ askSignOut: async () => false })); - expect(stub.calls().some((c) => c[0] === 'logout')).toBe(false); - expect(out.ok && out.report.cliSignOut).toBe('declined'); - expect(out.ok && out.report.next).toBe('vercel logout'); - }); - }); - - test('non-interactive: never logs out, only reports the next step', async () => { - await withStubVercel(OK_FILES, async (stub) => { - const out = await mintTeamToken({ env: stub.env }, request({ askSignOut: null })); - expect(stub.calls().some((c) => c[0] === 'logout')).toBe(false); - expect(out.ok && out.report.cliSignOut).toBe('not_offered'); - expect(out.ok && out.report.next).toBe('vercel logout'); - }); - }); -}); - -describe('parseExpiryDays', () => { - test('absent is fine; whole days 1..3650 are fine; anything else is VERCEL_TOKEN_EXPIRY_INVALID', () => { - expect(parseExpiryDays(undefined)).toEqual({ ok: true, days: undefined }); - expect(parseExpiryDays('30')).toEqual({ ok: true, days: 30 }); - for (const bad of ['0', '-1', '1.5', 'abc', '', '99999']) { - expect(parseExpiryDays(bad)).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_EXPIRY_INVALID }); - } - }); -}); diff --git a/tests/deploy/vercelSetup.test.ts b/tests/deploy/vercelSetup.test.ts new file mode 100644 index 00000000..3df0e094 --- /dev/null +++ b/tests/deploy/vercelSetup.test.ts @@ -0,0 +1,344 @@ +/** + * `capy deploy`'s Vercel target setup (API mode, modeled on Dokploy's one + * question): the link/name question, the live check, the name-back, the + * environment questions, and exactly what gets saved. + * + * Drives `resolveVercelSetup` with injected questions (`deps.prompt`), an + * in-memory Vercel (`fakeVercelFetch`: GET only — a stray write fails the test) + * and an injected token source. No `mock.module()`: this file runs in the + * normal batch. The real system store is covered in + * `vercelSetupTokenStore.test.ts`. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { parseVercelProjectInput, resolveVercelSetup, type SetupPrompt, type VercelSetupDeps } from '../../src/deploy/vercelSetup'; +import { ERROR_CODES } from '../../src/types/index'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { callsOf, fakeVercelFetch, project, type FakeVercelData } from '../helpers/fakeVercel'; +import { captureConsole } from '../helpers/testSandbox'; + +const TOKEN = 'vcp_FAKE_SETUP_TOKEN_9f3'; +const PAGE = 'https://vercel.com/account/settings/tokens'; +const CREATE = `Create a token at ${PAGE}. Set Scope to the team that owns the project`; + +const ONE_TEAM: FakeVercelData = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')], personal: [project('prj_mine', 'mine')] }, + envs: {}, + custom: { prj_web: [{ id: 'c_stage', slug: 'staging' }] }, +}; + +const TWO_TEAMS: FakeVercelData = { + ...ONE_TEAM, + teams: [{ id: 'team_acme', slug: 'acme' }, { id: 'team_beta', slug: 'beta' }], + projects: { team_acme: [project('prj_web', 'web')], team_beta: [project('prj_other', 'web')] }, +}; + +type Script = Readonly>>; + +/** + * A scripted asker: the Nth time a question named `X` is asked it answers + * `script.X[N]`. The count is read off the mock's own call record. + */ +function scripted(script: Script) { + const prompt = mock(async (questions: ReadonlyArray>) => { + const name = String(questions[0].name); + const askedBefore = prompt.mock.calls.filter(([qs]) => String(qs[0].name) === name).length - 1; + const answers = script[name] ?? []; + if (askedBefore >= answers.length) throw new Error(`unscripted question: ${name} (#${askedBefore + 1})`); + return { [name]: answers[askedBefore] }; + }); + const questionsNamed = (name: string) => prompt.mock.calls.map(([qs]) => qs[0]).filter((q) => q.name === name); + return { prompt: prompt as unknown as SetupPrompt, questionsNamed, count: (name: string) => questionsNamed(name).length }; +} + +const withToken = (data: FakeVercelData, over: Partial = {}) => { + const fetchMock = mock(fakeVercelFetch(data)); + const getSecret = mock(async (_name: string, _opts: unknown) => TOKEN as string | null); + const deps: VercelSetupDeps = { fetch: fetchMock as unknown as VercelFetchLike, env: {}, getSecret: getSecret as never, ...over }; + return { deps, fetchMock, getSecret }; +}; + +const run = (existing: Record, script: Script, deps: VercelSetupDeps, flags = {}) => { + const asker = scripted(script); + return captureConsole(() => resolveVercelSetup(existing, 'org_1', flags, { ...deps, prompt: asker.prompt })).then((out) => ({ ...out, asker })); +}; + +const text = (o: { stdout: readonly string[]; stderr: readonly string[] }) => [...o.stdout, ...o.stderr].join('\n'); + +describe('parseVercelProjectInput — a dashboard link or a name, nothing else', () => { + test.each([ + ['https://vercel.com/acme/web', 'acme', 'web'], + ['https://vercel.com/acme/web/', 'acme', 'web'], + ['https://vercel.com/acme/web/settings/environment-variables', 'acme', 'web'], + ['https://vercel.com/acme/web?tab=x#frag', 'acme', 'web'], + ['https://www.vercel.com/acme/web', 'acme', 'web'], + ['vercel.com/acme/web', 'acme', 'web'], + [' https://vercel.com/acme/web ', 'acme', 'web'], + ])('%s → team %s, project %s', (answer, team, name) => { + expect(parseVercelProjectInput(answer)).toEqual({ ok: true, input: { kind: 'link', team, project: name } }); + }); + + test.each([['web'], ['my-app.v2'], ['prj_AbC123']])('a bare name or id: %s', (answer) => { + expect(parseVercelProjectInput(answer)).toEqual({ ok: true, input: { kind: 'name', project: answer } }); + }); + + test.each([ + ['http://vercel.com/acme/web'], + ['https://example.com/acme/web'], + ['https://vercel.com/acme'], + ['https://vercel.com/'], + ['https://vercel.com/account/settings/tokens'], + ['https://vercel.com/new/acme'], + ['acme/web'], + ['has a space'], + ])('refused with the coded VERCEL_URL_INVALID: %s', (answer) => { + const r = parseVercelProjectInput(answer); + expect(r.ok).toBe(false); + expect(!r.ok && r.code).toBe(ERROR_CODES.VERCEL_URL_INVALID); + }); +}); + +describe('resolveVercelSetup — the questions, in order', () => { + test('a dashboard link, a token, a live check, a yes, an environment: asks exactly these, with the approved wording', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.asker.prompt.mock.calls.map(([qs]) => qs[0].name)).toEqual(['projectUrl', 'confirmed', 'vercelEnv']); + expect(out.asker.questionsNamed('projectUrl')[0].message).toBe('Vercel project URL (paste the dashboard link for this project, or its name):'); + expect(out.asker.questionsNamed('confirmed')[0].message).toBe('Use "web" (acme) as this target?'); + }); + + test('saved options are exactly the verified project: projectDir ".", the environment, the project ID and the team ID', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.result).toEqual({ + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, + productionBranch: 'main', + }); + }); + + test('it reads only: the teams, then the one project in its team, then custom environments — GET only, the token as the bearer, never anywhere else', async () => { + const { deps, fetchMock } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + const calls = callsOf(fetchMock); + expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}${new URL(c.url).search}`)).toEqual([ + 'GET /v2/teams?limit=100', + 'GET /v9/projects/web?teamId=team_acme', + 'GET /v9/projects/prj_web/custom-environments?teamId=team_acme', + ]); + expect(calls.every((c) => c.authorization === `Bearer ${TOKEN}`)).toBe(true); + expect(text(out)).not.toContain(TOKEN); + }); + + test('a bare name with ONE team: no team question; that team is used and named back', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['preview'], gitBranch: [''] }, deps); + expect(out.asker.count('picked')).toBe(0); + expect(out.asker.questionsNamed('confirmed')[0].message).toBe('Use "web" (acme) as this target?'); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_web', teamId: 'team_acme' }); + }); + + test('a bare name with SEVERAL teams: asks which, lists every team, and looks the project up in the chosen one', async () => { + const { deps, fetchMock } = withToken(TWO_TEAMS); + const out = await run({}, { projectUrl: ['web'], picked: ['team_beta'], confirmed: [true], vercelEnv: ['production'] }, deps); + const teamQuestion = out.asker.questionsNamed('picked')[0]; + expect((teamQuestion.choices as ReadonlyArray<{ name: string; value: string }>).map((c) => c.name)).toEqual(['acme', 'beta']); + expect(callsOf(fetchMock).some((c) => c.url.includes('/v9/projects/web?teamId=team_beta'))).toBe(true); + expect(out.result.options).toMatchObject({ projectId: 'prj_other', teamId: 'team_beta' }); + expect(out.asker.questionsNamed('confirmed')[0].message).toBe('Use "web" (beta) as this target?'); + }); + + test('a link names its team, so several teams are never asked about', async () => { + const { deps } = withToken(TWO_TEAMS); + const out = await run({}, { projectUrl: ['https://vercel.com/beta/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.asker.count('picked')).toBe(0); + expect(out.result.options).toMatchObject({ projectId: 'prj_other', teamId: 'team_beta' }); + }); + + test('a personal-account project (the link\'s slug is no team): looked up with no team, saved with no teamId, named back without a team', async () => { + const { deps } = withToken({ ...ONE_TEAM, teams: [] }); + const out = await run({}, { projectUrl: ['https://vercel.com/me/mine'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_mine' }); + expect(out.asker.questionsNamed('confirmed')[0].message).toBe('Use "mine" as this target?'); + }); +}); + +describe('resolveVercelSetup — refusals and re-asks', () => { + test('a project Vercel does not know: coded VERCEL_PROJECT_NOT_FOUND on stderr, the question is asked again, nothing is saved from the miss', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run( + {}, + { projectUrl: ['https://vercel.com/acme/ghost', 'https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, + deps, + ); + expect(out.stderr.join('\n')).toContain(ERROR_CODES.VERCEL_PROJECT_NOT_FOUND); + expect(out.asker.count('projectUrl')).toBe(2); + expect(out.asker.count('confirmed')).toBe(1); + expect(out.result.options).toMatchObject({ projectId: 'prj_web' }); + }); + + test('"no" at "Use … as this target?" asks the first question again', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run( + {}, + { projectUrl: ['https://vercel.com/acme/web', 'https://vercel.com/acme/web'], confirmed: [false, true], vercelEnv: ['production'] }, + deps, + ); + expect(out.asker.count('projectUrl')).toBe(2); + expect(out.asker.count('vercelEnv')).toBe(1); + }); + + test('something that is not a project link: coded VERCEL_URL_INVALID on stderr, asked again, no request made for it', async () => { + const { deps, fetchMock } = withToken(ONE_TEAM); + const out = await run( + {}, + { projectUrl: ['https://example.com/x/y', 'web'], confirmed: [true], vercelEnv: ['production'] }, + deps, + ); + expect(out.stderr.join('\n')).toContain(ERROR_CODES.VERCEL_URL_INVALID); + expect(out.asker.count('projectUrl')).toBe(2); + expect(callsOf(fetchMock).every((c) => !c.url.includes('example.com'))).toBe(true); + }); + + test('no token: says so, shows the approved instructions (with the link\'s team), saves what was typed — and sends zero requests', async () => { + const { deps, fetchMock } = withToken(ONE_TEAM, { getSecret: (async () => null) as never }); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], vercelEnv: ['production'] }, deps); + expect(fetchMock.mock.calls.length).toBe(0); + expect(text(out)).toContain('Could not verify against Vercel yet (no token) — saving as entered.'); + expect(out.stderr.join('\n')).toContain(`${CREATE} (acme).`); + expect(out.stderr.join('\n')).toContain('Then save it: capy system set _TARGET_VERCEL_TOKEN'); + expect(out.asker.count('confirmed')).toBe(0); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web', teamSlug: 'acme' }); + }); + + test('no token and a bare name: the instructions name no team; the project is saved by name alone', async () => { + const { deps } = withToken(ONE_TEAM, { getSecret: (async () => null) as never }); + const out = await run({}, { projectUrl: ['web'], vercelEnv: ['production'] }, deps); + expect(out.stderr.join('\n')).toContain(`${CREATE}.`); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web' }); + }); + + test('the store refusing (not an admin, …): its own code is shown, saved as entered', async () => { + const refusing = async () => { + throw Object.assign(new Error('x'), { code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }; + const { deps } = withToken(ONE_TEAM, { getSecret: refusing as never }); + const out = await run({}, { projectUrl: ['web'], vercelEnv: ['production'] }, deps); + expect(text(out)).toContain(`(${ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY})`); + expect(out.result.options).toMatchObject({ projectId: 'web' }); + }); + + test('a token Vercel rejects (401): the code is shown with the instructions, saved as entered', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const { deps } = withToken(ONE_TEAM, { fetch: unauthorized }); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], vercelEnv: ['production'] }, deps); + expect(text(out)).toContain(`(${ERROR_CODES.VERCEL_AUTH_FAILED})`); + expect(out.stderr.join('\n')).toContain(`${CREATE} (acme).`); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web', teamSlug: 'acme' }); + expect(text(out)).not.toContain(TOKEN); + }); + + test('any other API problem (500) after the team is known: shown with its code, saved as entered with the team ID', async () => { + const real = fakeVercelFetch(ONE_TEAM); + const failing: VercelFetchLike = async (url, init) => + new URL(url).pathname.startsWith('/v9/projects') ? { status: 500, ok: false, text: async () => '{}' } : real(url, init); + const { deps } = withToken(ONE_TEAM, { fetch: failing }); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], vercelEnv: ['production'] }, deps); + expect(text(out)).toContain(`(${ERROR_CODES.VERCEL_API_ERROR})`); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web', teamId: 'team_acme' }); + }); +}); + +describe('resolveVercelSetup — environments', () => { + test('lists production, preview, development and the project\'s custom environments; a custom one saves its ID', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['staging'] }, deps); + const choices = out.asker.questionsNamed('vercelEnv')[0].choices as ReadonlyArray<{ value: string }>; + expect(choices.map((c) => c.value)).toEqual(['production', 'preview', 'development', 'staging']); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'staging', projectId: 'prj_web', teamId: 'team_acme', customEnvironmentId: 'c_stage' }); + expect(out.asker.count('gitBranch')).toBe(0); + }); + + test('preview asks for an optional git branch: a branch is saved as gitBranch', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['preview'], gitBranch: ['develop'] }, deps); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_web', teamId: 'team_acme', gitBranch: 'develop' }); + expect(out.asker.questionsNamed('gitBranch')[0].message).toBe('Git branch for Preview (leave blank for all preview branches):'); + }); + + test('preview left blank means every preview branch: no gitBranch key at all', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['preview'], gitBranch: [''] }, deps); + expect(Object.keys(out.result.options)).not.toContain('gitBranch'); + }); + + test('production and development never ask for a branch', async () => { + const { deps } = withToken(ONE_TEAM); + const prod = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + const dev = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['development'] }, deps); + expect(prod.asker.count('gitBranch') + dev.asker.count('gitBranch')).toBe(0); + expect(dev.result.options).toMatchObject({ vercelEnv: 'development' }); + }); + + test('custom environments that cannot be listed: shown with the code, the standard ones are still offered', async () => { + const real = fakeVercelFetch(ONE_TEAM); + const failing: VercelFetchLike = async (url, init) => + new URL(url).pathname.endsWith('/custom-environments') ? { status: 500, ok: false, text: async () => '{}' } : real(url, init); + const { deps } = withToken(ONE_TEAM, { fetch: failing }); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(text(out)).toContain(ERROR_CODES.VERCEL_API_ERROR); + expect((out.asker.questionsNamed('vercelEnv')[0].choices as ReadonlyArray<{ value: string }>).map((c) => c.value)).toEqual(['production', 'preview', 'development']); + }); + + test('the project\'s production branch comes back for the PR base default (absent when Vercel has no git link)', async () => { + const { deps } = withToken(ONE_TEAM); + const withLink = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(withLink.result.productionBranch).toBe('main'); + const noLink = withToken({ ...ONE_TEAM, projects: { team_acme: [project('prj_web', 'web', { link: null })] } }); + const without = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, noLink.deps); + expect(without.result.productionBranch).toBeUndefined(); + }); +}); + +describe('resolveVercelSetup — re-editing a saved target', () => { + test('the question is prefilled with the saved project; the environment and branch defaults are the saved ones', async () => { + const { deps } = withToken(ONE_TEAM); + const existing = { projectDir: 'web', vercelEnv: 'preview', gitBranch: 'develop' }; + const legacy = await run(existing, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['preview'], gitBranch: ['develop'] }, deps); + expect(legacy.asker.questionsNamed('vercelEnv')[0].default).toBe('preview'); + expect(legacy.asker.questionsNamed('gitBranch')[0].default).toBe('develop'); + + const api = await run({ projectId: 'prj_web', teamId: 'team_acme', vercelEnv: 'production' }, { projectUrl: ['prj_web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(api.asker.questionsNamed('projectUrl')[0].default).toBe('prj_web'); + expect(api.asker.questionsNamed('vercelEnv')[0].default).toBe('production'); + }); + + test('a legacy target (no projectId) goes through this same setup and comes out in API mode with the legacy keys gone', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({ projectDir: 'web', vercelEnv: 'preview', gitBranch: 'develop' }, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }); + }); +}); + +describe('resolveVercelSetup — the token', () => { + test('is asked for as _TARGET_VERCEL_TOKEN through the store, interactively, naming the link\'s team for the prompt', async () => { + const { deps, getSecret } = withToken(ONE_TEAM); + await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(getSecret.mock.calls.length).toBe(1); + const [name, opts] = getSecret.mock.calls[0] as [string, Record]; + expect(name).toBe('_TARGET_VERCEL_TOKEN'); + expect(opts).toMatchObject({ interactive: true, orgId: 'org_1', helpTeam: 'acme' }); + }); + + test('a dry run only reads: the store is never allowed to ask or save', async () => { + const { deps, getSecret } = withToken(ONE_TEAM); + await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps, { readOnly: true }); + expect(getSecret.mock.calls[0][1]).toMatchObject({ interactive: false }); + }); + + test('$VERCEL_TOKEN is the last resort when the store has nothing', async () => { + const { deps, fetchMock } = withToken(ONE_TEAM, { getSecret: (async () => null) as never, env: { VERCEL_TOKEN: 'vcp_FROM_ENV' } }); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(callsOf(fetchMock)[0].authorization).toBe('Bearer vcp_FROM_ENV'); + expect(text(out)).not.toContain('vcp_FROM_ENV'); + }); +}); diff --git a/tests/deploy/vercelSetupNoBinary.test.ts b/tests/deploy/vercelSetupNoBinary.test.ts new file mode 100644 index 00000000..85172ba0 --- /dev/null +++ b/tests/deploy/vercelSetupNoBinary.test.ts @@ -0,0 +1,25 @@ +/** + * The Vercel setup needs no `vercel` binary, no `.vercel/project.json` and no + * `vercel link` (it is the REST API with a token): proven in a child process + * whose PATH contains nothing, in an empty directory, so a stray spawn of + * `vercel` would fail the run. + */ +import { test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { join } from 'path'; + +test('the setup completes with no vercel binary on PATH and no .vercel/project.json', () => { + const child = spawnSync(process.execPath, ['run', join(import.meta.dir, 'vercelSetupNoBinaryChild.ts')], { + encoding: 'utf-8', + env: { PATH: '/nonexistent-bin-dir', HOME: process.env.HOME ?? '/nonexistent-home' }, + }); + expect(child.status).toBe(0); + const out = JSON.parse(child.stdout.trim().split('\n').pop() ?? '{}'); + expect(out).toEqual({ + binaryAbsent: true, + noProjectFile: true, + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, + }); + expect(child.stdout).not.toContain('vcp_FAKE_CHILD_TOKEN'); + expect(child.stderr).not.toContain('vcp_FAKE_CHILD_TOKEN'); +}); diff --git a/tests/deploy/vercelSetupNoBinaryChild.ts b/tests/deploy/vercelSetupNoBinaryChild.ts new file mode 100644 index 00000000..9ebad041 --- /dev/null +++ b/tests/deploy/vercelSetupNoBinaryChild.ts @@ -0,0 +1,43 @@ +/** + * Child process for `vercelSetupNoBinary.test.ts`: runs `capy deploy`'s Vercel + * setup in a process whose PATH holds no `vercel` binary, in an empty + * directory (no `.vercel/project.json`), and prints one JSON line. Not a test + * file itself. + */ +import { spawnSync } from 'child_process'; +import { existsSync, mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { resolveVercelSetup, type SetupPrompt } from '../../src/deploy/vercelSetup'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { fakeVercelFetch, project } from '../helpers/fakeVercel'; + +const dir = mkdtempSync(join(tmpdir(), 'capy-vercel-setup-nobin-')); +process.chdir(dir); + +const answers: Record = { projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production' }; +const prompt: SetupPrompt = async (questions) => ({ [String(questions[0].name)]: answers[String(questions[0].name)] }); + +const probe = spawnSync('vercel', ['--version'], { encoding: 'utf-8' }); +const setup = await resolveVercelSetup( + {}, + 'org_1', + {}, + { + prompt, + env: {}, + getSecret: async () => 'vcp_FAKE_CHILD_TOKEN', + fetch: fakeVercelFetch({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: {}, + }) as VercelFetchLike, + }, +); +console.log( + JSON.stringify({ + binaryAbsent: probe.error !== undefined, + noProjectFile: !existsSync(join(dir, '.vercel', 'project.json')), + options: setup.options, + }), +); diff --git a/tests/deploy/vercelSetupTokenStore.test.ts b/tests/deploy/vercelSetupTokenStore.test.ts new file mode 100644 index 00000000..694c58b6 --- /dev/null +++ b/tests/deploy/vercelSetupTokenStore.test.ts @@ -0,0 +1,205 @@ +/** + * `capy deploy`'s Vercel setup against the REAL org system store (same fake + * service + on-disk master key as `tests/system/systemStore.test.ts`): the + * token is `_TARGET_VERCEL_TOKEN`, a one-level reference to + * `_CONNECTOR_VERCEL_TOKEN` is offered and saved when only that one exists, + * and the hidden prompt carries the approved instructions ("Then paste it + * here.", naming the link's team). The setup's own questions are injected; + * Vercel is an in-memory fake. + * + * mock.module is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { mock, describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; +import { project, fakeVercelFetch, callsOf } from '../helpers/fakeVercel'; +import { captureConsole } from '../helpers/testSandbox'; + +const tempHome = mkdtempSync(join(require('os').tmpdir(), 'capy-vercel-token-store-')); +mock.module('os', () => { + const actual = require('os'); + return { ...actual, homedir: () => tempHome }; +}); + +const ORG = 'org_a'; +const USER = 'user_a'; +const KMS_PREFIX = 'KMS1.'; + +interface FakeServer { + projectId: string; + role: 'owner' | 'member'; + envBlob: string; + keepFile?: string; + keepHash: string; + pushCount: number; +} +const servers = new Map(); +const serverFor = (orgId: string): FakeServer => + servers.get(orgId) ?? servers.set(orgId, { projectId: `proj_${orgId}`, role: 'owner', envBlob: '', keepHash: 'h0', pushCount: 0 }).get(orgId)!; +const serverForProject = (projectId: string): FakeServer => { + const found = [...servers.values()].find((s) => s.projectId === projectId); + if (!found) throw new Error(`no fake server for project ${projectId}`); + return found; +}; +function refuseNonAdmin(s: FakeServer): void { + if (s.role === 'owner') return; + const { CapyError, ERROR_CODES } = require('../../src/types/index'); + throw new CapyError('owners/admins only', ERROR_CODES.PERMISSION_DENIED, { status: 403, code: 'SYSTEM_STORE_ADMIN_ONLY' }); +} + +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + async getOrCreateSystemStore(orgId: string) { + const s = serverFor(orgId); + refuseNonAdmin(s); + return { project_id: s.projectId, branch: 'system' }; + } + async getDecryptData(projectId: string) { + const s = serverForProject(projectId); + refuseNonAdmin(s); + return { env_content: s.envBlob, decrypt_key: '', expires_at: new Date().toISOString(), keep_file: s.keepFile, keep_hash: s.keepHash }; + } + async pushSecrets(projectId: string, keepFile: string, envBlob: string) { + const s = serverForProject(projectId); + refuseNonAdmin(s); + s.envBlob = envBlob; + s.keepFile = keepFile; + s.pushCount += 1; + s.keepHash = `h${s.pushCount}`; + return { keep_hash: s.keepHash, keep_file: s.keepFile }; + } + async coDecrypt(_orgId: string, ciphertext: string) { + return { plaintext: ciphertext.slice(KMS_PREFIX.length) }; + } + async wrapOuterLayer(_orgId: string, plaintext: string) { + return { ciphertext: KMS_PREFIX + plaintext }; + } + }, +})); +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + async authenticateSilent() { + return { success: true, user_id: USER, user_email: 'a@capy.sc' }; + } + async authenticate() { + return { success: true, user_id: USER, user_email: 'a@capy.sc' }; + } + async getValidToken() { + return { access_token: 'tok', expires_at: Date.now() + 999999, organization_id: ORG, user_id: USER }; + } + }, +})); +const promptAnswers = new Map(); +mock.module('inquirer', () => ({ + default: { prompt: mock(async (questions: Array<{ name: string }>) => ({ [questions[0].name]: promptAnswers.get(questions[0].name) })) }, +})); + + +afterAll(() => { + mock.restore(); + rmSync(tempHome, { recursive: true, force: true }); +}); + +let openSystemStore: typeof import('../../src/system/systemStore').openSystemStore; +let resolveVercelSetup: typeof import('../../src/deploy/vercelSetup').resolveVercelSetup; +let ERROR_CODES: typeof import('../../src/types/index').ERROR_CODES; + +beforeAll(async () => { + openSystemStore = (await import('../../src/system/systemStore')).openSystemStore; + resolveVercelSetup = (await import('../../src/deploy/vercelSetup')).resolveVercelSetup; + ERROR_CODES = (await import('../../src/types/index')).ERROR_CODES; +}); + +beforeEach(() => { + servers.clear(); + promptAnswers.clear(); +}); + +/** A new org per test, with its own master key, so no test inherits another's cached store. */ +const freshOrg = async (): Promise => { + const org = `org_${randomBytes(4).toString('hex')}`; + const { wrapAndSaveMasterKey } = await import('../../src/crypto/keyResolver'); + await wrapAndSaveMasterKey(randomBytes(32), org, USER, { + coDecrypt: async (_o: string, ct: string) => ct.slice(KMS_PREFIX.length), + wrapOuterLayer: async (_o: string, pt: string) => KMS_PREFIX + pt, + }); + return org; +}; + +const setValue = async (org: string, name: string, value: string): Promise => { + const store = await openSystemStore({ orgId: org, devMode: true }); + await store.set(name, value); +}; + +const DATA = { teams: [{ id: 'team_acme', slug: 'acme' }], projects: { team_acme: [project('prj_web', 'web')] }, envs: {} }; + +const answers = (script: Record) => async (questions: ReadonlyArray>) => ({ + [String(questions[0].name)]: script[String(questions[0].name)], +}); + +const run = async (org: string, readOnly: boolean) => { + const fetchMock = mock(fakeVercelFetch(DATA)); + const out = await captureConsole(() => + resolveVercelSetup( + {}, + org, + { readOnly, devMode: true }, + { + fetch: fetchMock as never, + env: {}, + prompt: answers({ projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production' }) as never, + }, + ), + ); + return { ...out, fetchMock }; +}; + +const everything = (o: { stdout: readonly string[]; stderr: readonly string[] }) => [...o.stdout, ...o.stderr].join('\n'); + +describe('capy deploy Vercel setup — the token, through the real system store', () => { + it('only _CONNECTOR_VERCEL_TOKEN exists: the setup offers to reuse it, saves a ONE-LEVEL REFERENCE, and checks the project with it', async () => { + const org = await freshOrg(); + await setValue(org, '_CONNECTOR_VERCEL_TOKEN', 'tok_connector_fake'); + promptAnswers.set('choice', 'use'); + const out = await run(org, false); + expect(callsOf(out.fetchMock)[0].authorization).toBe('Bearer tok_connector_fake'); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }); + const reopened = await openSystemStore({ orgId: org, devMode: true }); + expect(reopened.listNames().find((e) => e.name === '_TARGET_VERCEL_TOKEN')?.referencesName).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(everything(out)).not.toContain('tok_connector_fake'); + }); + + it('nothing saved anywhere: the hidden prompt carries the approved instructions ("Then paste it here.", the link\'s team), the typed token is saved and used, and never printed', async () => { + const org = await freshOrg(); + promptAnswers.set('value', 'tok_typed_fake'); + const out = await run(org, false); + const stderr = out.stderr.join('\n'); + expect(stderr).toContain('Create a token at https://vercel.com/account/settings/tokens. Set Scope to the team that owns the project (acme).'); + expect(stderr).toContain('Then paste it here.'); + expect(stderr).not.toContain('capy system set'); + expect(callsOf(out.fetchMock)[0].authorization).toBe('Bearer tok_typed_fake'); + const reopened = await openSystemStore({ orgId: org, devMode: true }); + expect(reopened.get('_TARGET_VERCEL_TOKEN')).toBe('tok_typed_fake'); + expect(everything(out)).not.toContain('tok_typed_fake'); + }); + + it('a dry run (read only): never asks and never saves — says there is no token, with the "save it" instructions, and sends no request', async () => { + const org = await freshOrg(); + const out = await run(org, true); + expect(out.fetchMock.mock.calls.length).toBe(0); + expect(out.stderr.join('\n')).toContain('Then save it: capy system set _TARGET_VERCEL_TOKEN'); + const reopened = await openSystemStore({ orgId: org, devMode: true }); + expect(reopened.listNames()).toEqual([]); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web', teamSlug: 'acme' }); + }); + + it('read only with only the connector token: its coded refusal is shown (never borrowed silently)', async () => { + const org = await freshOrg(); + await setValue(org, '_CONNECTOR_VERCEL_TOKEN', 'tok_connector_fake'); + const out = await run(org, true); + expect(out.fetchMock.mock.calls.length).toBe(0); + expect(everything(out)).toContain(ERROR_CODES.VERCEL_TARGET_KEY_MISSING); + }); +}); diff --git a/tests/helpers/stubVercelCli.ts b/tests/helpers/stubVercelCli.ts deleted file mode 100644 index 0dc5bb06..00000000 --- a/tests/helpers/stubVercelCli.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * A stand-in `vercel` program for the `--create-token` tests: a small shell - * script in a temp directory that appends every call's argv to a log file and - * answers from canned files. Nothing here is a real token — every value is a - * fake sentinel. - * - * Files in the stub directory (all optional): - * whoami.exit exit status for `vercel whoami` (default 0) - * logout.exit exit status for `vercel logout` (default 0) - * rm.exit exit status for `vercel tokens rm` (default 0) - * create.exit exit status for the token POST (default 0) - * teams.json stdout for `vercel api /v2/teams…` - * create.json stdout for `vercel api /v3/user/tokens` - */ -import { chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { withTempDir } from './testSandbox'; - -const SCRIPT = `#!/bin/sh -d="$STUB_DIR" -printf '%s\\037' "$@" >> "$d/argv.log" -printf '\\n' >> "$d/argv.log" -printf '[%s]\\n' "\${VERCEL_TOKEN:-}" >> "$d/env.log" -status() { if [ -f "$d/$1.exit" ]; then exit "$(cat "$d/$1.exit")"; fi; exit 0; } -case "$1" in - whoami) status whoami ;; - logout) status logout ;; - tokens) status rm ;; - api) - case "$2" in - /v2/teams*) cat "$d/teams.json"; exit 0 ;; - /v3/user/tokens) - if [ -f "$d/create.exit" ] && [ "$(cat "$d/create.exit")" != "0" ]; then exit "$(cat "$d/create.exit")"; fi - cat "$d/create.json"; exit 0 ;; - esac ;; -esac -exit 64 -`; - -export interface StubFiles { - readonly [name: string]: string; -} - -export interface Stub { - /** An environment whose PATH finds the stub `vercel`. */ - env: Record; - /** Every call so far, as its argv. */ - calls(): readonly (readonly string[])[]; - /** Every call so far, as one raw string — for "this value appears nowhere". */ - rawLog(): string; - /** What `$VERCEL_TOKEN` was in each call's environment (empty brackets = unset). */ - tokenEnvSeen(): readonly string[]; -} - -/** Runs `fn` with a stub `vercel` on PATH (and the stub's directory removed afterwards). */ -export async function withStubVercel(files: StubFiles, fn: (stub: Stub) => Promise): Promise { - return withTempDir('stub-vercel', async (dir) => { - const bin = join(dir, 'bin'); - const data = join(dir, 'data'); - mkdirSync(bin); - mkdirSync(data); - writeFileSync(join(bin, 'vercel'), SCRIPT); - chmodSync(join(bin, 'vercel'), 0o755); - Object.entries(files).forEach(([name, content]) => writeFileSync(join(data, name), content)); - const log = join(data, 'argv.log'); - const rawLog = (): string => (existsSync(log) ? readFileSync(log, 'utf-8') : ''); - const envLog = join(data, 'env.log'); - return fn({ - tokenEnvSeen: () => (existsSync(envLog) ? readFileSync(envLog, 'utf-8').split('\n').filter((l) => l !== '') : []), - env: { PATH: `${bin}:/usr/bin:/bin`, STUB_DIR: data }, - rawLog, - calls: () => - rawLog() - .split('\n') - .filter((l) => l !== '') - .map((l) => l.split('\u001f').filter((a) => a !== '')), - }); - }); -} - -/** A fake bearer value; tests assert it appears on no output surface. */ -export const FAKE_BEARER = 'vcp_FAKE_BEARER_SENTINEL_9f3a71c2'; - -export const teamsJson = (teams: ReadonlyArray<{ id: string; slug: string }>): string => JSON.stringify({ teams, pagination: null }); - -export const createdJson = (over: { id?: string; scopes?: readonly unknown[]; expiresAt?: number } = {}): string => - JSON.stringify({ - bearerToken: FAKE_BEARER, - token: { - id: over.id ?? 'tok_new_1', - name: 'capy-acme-import', - type: 'oauth2-token', - scopes: over.scopes ?? [{ type: 'team', teamId: 'team_acme' }], - ...(over.expiresAt === undefined ? {} : { expiresAt: over.expiresAt }), - }, - }); diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 2db037e0..e341d8f3 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -72,6 +72,8 @@ ISOLATED_FILES=( tests/commands/deployVercelApiMode.test.ts tests/deploy/vercelApiModeTokenStore.test.ts tests/commands/systemSetKeyHelp.test.ts + tests/commands/deployVercelSetupPicker.test.ts + tests/deploy/vercelSetupTokenStore.test.ts ) # Build a grep pattern to exclude isolated files from the batch run diff --git a/tests/system/systemStore.test.ts b/tests/system/systemStore.test.ts index ce5c7acc..12e027c9 100644 --- a/tests/system/systemStore.test.ts +++ b/tests/system/systemStore.test.ts @@ -404,8 +404,8 @@ describe('systemStore', () => { }); }); - // ── Key help before the hidden prompt, and the `onMissing` hook ── - describe('key help and onMissing (hidden-prompt path)', () => { + // ── Key help before the hidden prompt ── + describe('key help (hidden-prompt path)', () => { /** Runs `fn` with console.error captured; returns what it printed. */ const stderrOf = async (fn: () => Promise): Promise => { const spy = spyOn(console, 'error').mockImplementation((() => {}) as never); @@ -417,13 +417,20 @@ describe('systemStore', () => { } }; - it('a known key name: the link is printed (stderr) before the prompt', async () => { + it('a known key name: the approved instructions end "Then paste it here." (stderr), before the prompt', async () => { promptQueue.push({ value: 'typed-secret' }); const text = await stderrOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true })); - expect(text).toContain('https://vercel.com/account/settings/tokens'); + expect(text).toContain('Create a token at https://vercel.com/account/settings/tokens. Set Scope to the team that owns the project.'); + expect(text).toContain('Then paste it here.'); expect(text).not.toContain('typed-secret'); }); + it('a known Vercel key name with a team slug: the slug is named', async () => { + promptQueue.push({ value: 'typed-secret' }); + const text = await stderrOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true, helpTeam: 'acme' })); + expect(text).toContain('Set Scope to the team that owns the project (acme).'); + }); + it('a known Dokploy key name with a base URL: the link is that Dokploy\'s page', async () => { promptQueue.push({ value: 'typed-secret' }); const text = await stderrOf(() => @@ -437,37 +444,6 @@ describe('systemStore', () => { const text = await stderrOf(() => getConnectorSecret('_CONNECTOR_FOO_KEY', { orgId: ORG_A, devMode: true, interactive: true })); expect(text).toBe(''); }); - - it('onMissing that makes a value: it is saved through the store\'s own save, returned, and the hidden prompt never runs', async () => { - const value = await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { - orgId: ORG_A, - devMode: true, - interactive: true, - onMissing: async (save) => { - await save('made-elsewhere'); - return 'made-elsewhere'; - }, - }); - expect(value).toBe('made-elsewhere'); - expect(promptedWith).toEqual([]); - const again = await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: false }); - expect(again).toBe('made-elsewhere'); - }); - - it('onMissing that returns null: the hidden prompt carries on', async () => { - promptQueue.push({ value: 'typed-secret' }); - const value = await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true, onMissing: async () => null }); - expect(value).toBe('typed-secret'); - expect(promptedWith).toHaveLength(1); - }); - - it('onMissing is never called when the entry exists, or when nobody can be asked', async () => { - const hook = mock(async () => 'x'); - expect(await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: false, onMissing: hook })).toBeNull(); - await setValue(ORG_A, '_CONNECTOR_VERCEL_TOKEN', 'present'); - expect(await getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true, onMissing: hook })).toBe('present'); - expect(hook.mock.calls.length).toBe(0); - }); }); // ── CAP-679 follow-up: `getDirectionalConnectorSecret` — deploy's From 28a804af8b7bd78e2f0fdd8f4f16d0ec03a4478b Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 2 Nov 2026 02:48:34 -0700 Subject: [PATCH 05/22] feat(deploy): capy deploy vercel --discover; connect vercel no longer writes targets Connect is for retrieval, target is for deploy. capy connect vercel imports values only: --targets and the interactive deploy-target offer are removed. capy deploy vercel --discover mirrors capy deploy dokploy --discover (facts, --plan --dry-run, --plan --confirm; one PR per repo, never prompts, names only). One target per Vercel project and environment that holds variables: production, preview (and a preview scoped to a git branch), custom environments, and development only when it is the only one. The Dokploy modules are shared through an additive KindOverrides seam; Dokploy output is unchanged. The plan schema is published as schemas.deploy_vercel_plan. --- docs/cli-reference.md | 75 ++- src/commands/connectCommand.ts | 10 - src/commands/connectors/dokploy.ts | 24 +- src/commands/connectors/dokployDiscovery.ts | 13 - src/commands/connectors/registry.ts | 31 -- src/commands/connectors/shared.ts | 14 - src/commands/connectors/vercel.ts | 183 +------- src/commands/connectors/vercelDiscovery.ts | 25 +- src/commands/deployDiscover/apply.ts | 38 +- src/commands/deployDiscover/command.ts | 125 +++-- src/commands/deployDiscover/discovery.ts | 47 +- src/commands/deployDiscover/evaluate.ts | 47 +- src/commands/deployDiscover/facts.ts | 53 ++- src/commands/deployDiscover/index.ts | 40 ++ src/commands/deployDiscover/kind.ts | 64 +++ src/commands/deployDiscover/plan.ts | 6 +- src/commands/deployDiscover/route.ts | 15 +- src/commands/deployDiscover/schema.ts | 27 ++ src/commands/deployDiscover/vercel.ts | 441 ++++++++++++++++++ src/core/cliHelpDoc.ts | 11 +- src/deploy/vercelApi.ts | 24 +- src/index-dev.ts | 6 +- src/index.ts | 8 +- src/types/index.ts | 5 + tests/commands/connectVercel.test.ts | 250 ++-------- tests/commands/connectVercelDiscovery.test.ts | 129 +---- tests/commands/connectVercelFlags.test.ts | 15 +- .../deployDiscoverVercelConfirm.test.ts | 298 ++++++++++++ .../deployDiscoverVercelFacts.test.ts | 368 +++++++++++++++ .../commands/deployDiscoverVercelPlan.test.ts | 180 +++++++ tests/deploy/vercelApi.test.ts | 31 ++ tests/helpers/deployDiscoverVercelWorld.ts | 215 +++++++++ 32 files changed, 2051 insertions(+), 767 deletions(-) create mode 100644 src/commands/deployDiscover/kind.ts create mode 100644 src/commands/deployDiscover/vercel.ts create mode 100644 tests/commands/deployDiscoverVercelConfirm.test.ts create mode 100644 tests/commands/deployDiscoverVercelFacts.test.ts create mode 100644 tests/commands/deployDiscoverVercelPlan.test.ts create mode 100644 tests/helpers/deployDiscoverVercelWorld.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d8ab34c3..8301d3f8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -128,9 +128,9 @@ capy deploy [target] [options] | `--env-name ` | gh-actions: env name when --scope env | | | `--no-deploy` | write and verify the target, but skip the platform deploy/redeploy (target mode) | | | `--json` | describe the route (unanswered stops + any known branch problem) as JSON instead of travelling it | | -| `--discover` | dokploy: find the services that match Capy projects and print JSON (never prompts) | | -| `--plan ` | dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`) | | -| `--confirm ` | dokploy --discover: write the plan that --dry-run printed (one PR per repo) | | +| `--discover` | dokploy/vercel: find the services that match Capy projects and print JSON (never prompts) | | +| `--plan ` | dokploy/vercel --discover: the plan file to check (see schemas.deploy_dokploy_plan and schemas.deploy_vercel_plan in `capy help --json`) | | +| `--confirm ` | dokploy/vercel --discover: write the plan that --dry-run printed (one PR per repo) | | | `--base-url ` | dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL) | | | `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | | `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | @@ -732,7 +732,6 @@ capy connect [provider] [options] | `--project ` | vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision) | | | `--team ` | vercel import/discover: Vercel team slug or id (default: the token's only team) | | | `--json` | emit machine-readable JSON instead of the human UI (import connectors) | | -| `--targets` | Also write a Vercel deploy target for each imported environment | | | `--dry-run` | dokploy import/discover: preview the plan only — resolve settings + read Dokploy, write/push nothing | | | `--discover` | dokploy/vercel: find every Dokploy service / Vercel project matching a repo under cwd, instead of one named --application/--compose/--project | | | `-y, --yes` | dokploy import/discover: skip the confirmation prompt (import: --overwrite's clear/replace/import ask) — discover never prompts and needs --yes to write | | @@ -848,6 +847,72 @@ JSON Schemas of the files commands read, as published under `schemas` in `capy h } ``` +### `deploy_vercel_plan` + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "capy deploy vercel --discover plan", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "entries" + ], + "properties": { + "version": { + "const": 1 + }, + "entries": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "project_id", + "branch", + "service_id", + "git_branch", + "vars" + ], + "properties": { + "project_id": { + "type": "string", + "minLength": 1, + "description": "Capy project id" + }, + "branch": { + "type": "string", + "minLength": 1, + "description": "Capy branch the target ships from" + }, + "service_id": { + "type": "string", + "minLength": 1, + "description": "Vercel environment id from the discovery facts (a project plus one of its environments)" + }, + "git_branch": { + "type": "string", + "minLength": 1, + "description": "git branch the Vercel project deploys from (the CI deploy PR base)" + }, + "vars": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "description": "Variable names the target delivers; each must exist on the Capy branch" + } + } + } + } + } +} +``` + ## Error codes Every refusal carries a stable `code` — branch on it, never on message text. @@ -955,6 +1020,8 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `VERCEL_OPTIONS_INVALID` - `VERCEL_SPLIT_NOT_RESTORED` - `VERCEL_URL_INVALID` +- `VERCEL_PROJECT_NOT_GITHUB` +- `VERCEL_ENV_LIST_TRUNCATED` - `EDIT_NEEDS_TTY` - `EDIT_STDIN_LOCAL_ONLY` - `STDIN_EMPTY` diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index ee26ae18..ad5e471e 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -282,9 +282,6 @@ export class ConnectCommand { skipped: outcome.skipped, warnings: outcome.warnings, pushed, - ...(outcome.deployTargetSaved === undefined ? {} : { deployTargetSaved: outcome.deployTargetSaved }), - // `--targets` only: the deploy target by folder and name (`written: false` under --dry-run). - ...(outcome.deployTarget ? { deployTargets: [{ folder: '.', ...outcome.deployTarget }] } : {}), }), ); return { linked: wrote }; @@ -329,13 +326,6 @@ export class ConnectCommand { for (const w of outcome.warnings) { console.log(` ⚠ ${w.code}: ${w.names.join(', ')}`); } - if (outcome.deployTargetSaved) { - console.log(` Saved a ${label} deploy target.`); - } - if (outcome.deployTarget) { - // COPY-FLAG: new user-facing string, minimal/neutral wording. - console.log(` ${outcome.deployTarget.written ? 'Wrote' : 'Would write'} deploy target ${outcome.deployTarget.name}.`); - } console.log(''); return { linked: wrote }; } diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index e23dd5f8..d656e602 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -1321,14 +1321,10 @@ function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwr const folderPath = (repoDir: string, folder: string): string => (folder ? join(repoDir, folder) : repoDir); -/** - * keep.lock and .gitignore, relative to `repoDir` — the ONLY paths discovery's - * own commit step ever touches. `deployConfig` (Vercel `--targets`) adds the - * folder's `.capy/deploy.json`, which the run itself wrote. - */ -export function discoveryCommitPathsFor(folder: string, opts: { deployConfig?: boolean } = {}): readonly string[] { +/** keep.lock and .gitignore, relative to `repoDir` — the ONLY paths discovery's own commit step ever touches. */ +export function discoveryCommitPathsFor(folder: string): readonly string[] { const prefix = folder ? `${folder}/` : ''; - return [`${prefix}keep.lock`, `${prefix}.gitignore`, ...(opts.deployConfig ? [`${prefix}.capy/deploy.json`] : [])]; + return [`${prefix}keep.lock`, `${prefix}.gitignore`]; } /** @@ -1349,18 +1345,10 @@ export function commitPreviewForFolders( branchName: string, /** The commit subject; default is the Dokploy one. */ commitMessage?: string, - /** Vercel `--targets`: whether this folder's `.capy/deploy.json` is part of the commit. */ - deployConfigFor?: (folder: DiscoveryPlanFolder) => boolean, ): readonly DiscoveryRepoCommitResult[] { const repoDirs = [...new Set(folders.map((f) => f.repoDir))]; return repoDirs.map((repoDir) => { - const paths = [ - ...new Set( - folders - .filter((f) => f.repoDir === repoDir) - .flatMap((f) => discoveryCommitPathsFor(f.folder, { deployConfig: deployConfigFor?.(f) ?? false })), - ), - ]; + const paths = [...new Set(folders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)))]; const outcome = commitDiscoveryChanges(repoDir, paths, new Set(), { branchName, dryRun: true, summaryLines: [], ...(commitMessage ? { commitMessage } : {}) }); return { repoDir, ...outcome }; }); @@ -1379,14 +1367,12 @@ export function commitForAppliedRepos( branchName: string, /** The commit subject; default is the Dokploy one. */ commitMessage?: string, - /** Vercel `--targets`: whether this folder's run wrote a `.capy/deploy.json` that belongs in the commit. */ - deployConfigFor?: (folder: DiscoveryFolderResult) => boolean, ): readonly DiscoveryRepoCommitResult[] { const repoDirs = [...new Set(applied.map((f) => f.repoDir))]; return repoDirs.flatMap((repoDir) => { const okFolders = applied.filter((f) => f.repoDir === repoDir && f.ok); if (okFolders.length === 0) return []; - const paths = okFolders.flatMap((f) => discoveryCommitPathsFor(f.folder, { deployConfig: deployConfigFor?.(f) ?? false })); + const paths = okFolders.flatMap((f) => discoveryCommitPathsFor(f.folder)); const summaryLines = okFolders.map((f) => `- ${f.folder || '.'}: ${(f as Extract).activeBranch}`); const outcome = commitDiscoveryChanges(repoDir, paths, beforeStatusByRepo.get(repoDir) ?? new Set(), { branchName, diff --git a/src/commands/connectors/dokployDiscovery.ts b/src/commands/connectors/dokployDiscovery.ts index b1f529ed..020063a3 100644 --- a/src/commands/connectors/dokployDiscovery.ts +++ b/src/commands/connectors/dokployDiscovery.ts @@ -1240,17 +1240,6 @@ export async function runDiscoverySequences( */ export type DiscoveryRepoCommitResult = { repoDir: string } & DiscoveryCommitOutcome; -/** A deploy target Vercel `--targets` would write (dry run) or wrote, by folder and Capy branch. Names only. */ -export interface DiscoveryDeployTarget { - repoDir: string; - /** Relative to `repoDir`; `''` = repo root. */ - folder: string; - /** The Capy branch (= the Vercel environment) the target ships. */ - branch: string; - name: string; - written: boolean; -} - export type DiscoveryOutcome = | { ok: true; @@ -1262,8 +1251,6 @@ export type DiscoveryOutcome = cancelled?: boolean; /** One entry per repo touched by the plan (dry run: every repo the plan would touch) — see `DiscoveryRepoCommitResult`'s own doc. Absent only when nothing was ever going to be committed (no matched repos at all). */ commits?: readonly DiscoveryRepoCommitResult[]; - /** Vercel `--targets` under `--dry-run` only: the targets a real run would write. A real run reports them per environment (`applied`). */ - deployTargets?: readonly DiscoveryDeployTarget[]; /** CAP-703: `{ base_url: true }` when this run saved the Dokploy base URL to the org system variable. */ saved?: { readonly base_url: true }; /** CAP-703: `BASE_URL_NOT_SAVED` / `BASE_URL_DIFFERS_FROM_STORED`, as codes. */ diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index c9de3f60..ad8a1cc5 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -1,5 +1,4 @@ import { ConnectorMetadata } from '../../types/index'; -import type { TargetConfig } from '../../deploy/adapter'; import type { Blocked } from '../../ui/screens/contract'; import type { DokployImportSource } from '../../deploy/dokployApi'; import type { VercelImportSource } from '../../deploy/vercelApi'; @@ -183,26 +182,6 @@ export interface ConnectOpts { * project again. */ vercelProject?: { id: string; name: string; productionBranch?: string }; - /** - * Vercel import only: the folder a saved deploy target's `.capy/deploy.json` - * belongs in. Set INTERNALLY by discovery (each folder has its own); a plain - * import uses the current directory. - */ - projectDir?: string; - /** - * Vercel only (flag `--targets`): also write a Vercel deploy target for each - * environment that imports, without asking — in non-interactive and `--json` - * runs too. Under `--dry-run` the targets are listed, never written. - */ - targets?: boolean; -} - -/** A deploy target `--targets` wrote (`written`), or under `--dry-run` would write. Names only. */ -export interface DeployTargetReport { - name: string; - /** The Vercel environment (= the Capy branch) the target ships to. */ - environment: string; - written: boolean; } export interface RotateOpts { @@ -294,16 +273,6 @@ export type ImportOutcome = unchanged: readonly string[]; skipped: readonly ImportSkip[]; warnings: readonly ImportWarning[]; - /** Vercel `--targets` only. */ - deployTargetSaved?: boolean; - /** Vercel `--targets` only: the deploy target this import wrote (dry run: would write). Absent when none applies. */ - deployTarget?: DeployTargetReport; - /** - * Vercel `--targets` only, internal: the target for `writeImportOutcome` - * to save into `dir`'s `.capy/deploy.json` once the import itself is - * written — so a failed write leaves no target behind. Never set under a dry run. - */ - deployTargetToWrite?: { dir: string; target: TargetConfig }; /** * Dokploy import only: names with a different local value that a real * (non-dry-run) run would have prompted about. Only populated under diff --git a/src/commands/connectors/shared.ts b/src/commands/connectors/shared.ts index d19581f1..448a823f 100644 --- a/src/commands/connectors/shared.ts +++ b/src/commands/connectors/shared.ts @@ -310,20 +310,6 @@ export async function writeImportOutcome( opts: { push: boolean; dryRun: boolean }, ): Promise<{ wrote: boolean }> { if (opts.dryRun) return { wrote: false }; - const written = await writeImportedValues(ctx, outcome, opts); - // `--targets`: the deploy target is saved only after the import itself landed. - if (outcome.deployTargetToWrite) { - const { upsertTarget } = await import('../../deploy/config'); - upsertTarget(outcome.deployTargetToWrite.dir, outcome.deployTargetToWrite.target); - } - return written; -} - -async function writeImportedValues( - ctx: ResolvedContext, - outcome: Extract, - opts: { push: boolean }, -): Promise<{ wrote: boolean }> { const cleared = outcome.cleared ?? []; // CAP-673: a dokploy import's same-value names still carry a FRESH // connector entry (see `ImportOutcome.unchangedEntries`'s doc) — merged diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts index 5a0fb0f6..b2ea030a 100644 --- a/src/commands/connectors/vercel.ts +++ b/src/commands/connectors/vercel.ts @@ -24,13 +24,12 @@ * Every refusal for a missing or rejected token carries the Vercel token page * (`link`) and what to do (`remedy`), with the team's slug when `--team` names it. * - * Deploy targets: like Dokploy, a target is OFFERED only after a single - * import, only interactively (never under `--json`, `--dry-run`, or - * non-interactive). Discovery never saves one by itself. + * "Connect is for retrieval, target is for deploy": this connector imports + * values and NEVER creates, offers or writes a deploy target. + * `capy deploy vercel --discover` is what creates targets, and it pushes no values. */ import { join } from 'node:path'; import { syncFolder, type FolderSync, type GateOptions } from '../../sync/gate'; -import { TargetConfig } from '../../deploy/adapter'; import { classifyImportCandidates } from '../../deploy/dokployApi'; import { DEFAULT_VERCEL_TOKEN_ENV, @@ -79,7 +78,6 @@ import { DiscoveryContext, DiscoveryOutcome, DiscoveryPlan, - DiscoveryDeployTarget, DiscoveryFolderResult, DiscoveryPlanFolder, applyCollisionResolutions, @@ -298,102 +296,6 @@ function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwr return [header, ...rows].join('\n'); } -// ── Deploy-target offer ───────────────────────────────────────────────────── - -function uniqueTargetName(existing: readonly TargetConfig[], base: string): string { - const names = new Set(existing.map((t) => t.name)); - const numbered = (n: number): string => (names.has(`${base}-${n}`) ? numbered(n + 1) : `${base}-${n}`); - return names.has(base) ? numbered(2) : base; -} - -/** The name a new `vercel-` target for this project + environment gets; `null` when one already exists. */ -function newTargetName(allTargets: readonly TargetConfig[], projectId: string, environmentName: string): string | null { - const already = allTargets.some((t) => { - const o = t.options as { projectId?: string; vercelEnv?: string }; - return t.kind === 'vercel' && o.projectId === projectId && o.vercelEnv === environmentName; - }); - return already ? null : uniqueTargetName(allTargets, `vercel-${environmentName}`); -} - -interface TargetArgs { - importedNames: readonly string[]; - project: { id: string; name: string; productionBranch?: string }; - teamId: string | undefined; - environment: VercelEnvironmentInfo; - allTargets: readonly TargetConfig[]; -} - -/** - * The `vercel-` deploy target for the SAME project + environment - * an import just read — API mode, all-branches vars (no `gitBranch`), CI mode, - * `gitBaseBranch` the project's production branch for `production`. `null` - * when nothing was imported or the project + environment already has a target. - * The one definition both the interactive offer and `--targets` save. - */ -function buildDeployTarget(args: TargetArgs): TargetConfig | null { - if (args.importedNames.length === 0) return null; - const name = newTargetName(args.allTargets, args.project.id, args.environment.name); - if (name === null) return null; - return { - name, - kind: 'vercel', - branch: args.environment.name, - vars: [...args.importedNames], - mode: 'ci', - ...(args.environment.name === 'production' && args.project.productionBranch ? { gitBaseBranch: args.project.productionBranch } : {}), - options: { - projectDir: '.', - vercelEnv: args.environment.name, - projectId: args.project.id, - ...(args.teamId ? { teamId: args.teamId } : {}), - ...(args.environment.customEnvironmentId ? { customEnvironmentId: args.environment.customEnvironmentId } : {}), - }, - }; -} - -interface TargetOfferArgs extends TargetArgs { - promptable: boolean; - dir: string; - confirm: (message: string, defaultValue: boolean) => Promise; -} - -/** - * After a successful single import, offer to save a Vercel deploy target for - * the SAME project + environment. Only asked interactively; non-interactive, - * `--json` and `--dry-run` never ask and never save (`--targets` is the way to - * save one without asking). - */ -async function maybeOfferDeployTarget(args: TargetOfferArgs): Promise { - if (args.importedNames.length === 0 || !args.promptable) return false; - const target = buildDeployTarget(args); - if (!target) return false; - - // COPY-FLAG: new user-facing string, minimal/neutral wording. - const yes = await args.confirm( - `Save a Vercel deploy target for ${args.project.name} (${args.environment.name}), so \`capy deploy\` can ship the runtime pair to it?`, - false, - ); - if (!yes) return false; - - const { upsertTarget } = await import('../../deploy/config'); - upsertTarget(args.dir, target); - return true; -} - -/** - * `--targets`: the target this import would write, and where — planned here, - * saved by `writeImportOutcome` once the import itself is written (never under - * a dry run, which only reports it). - */ -function planTargetsOutcome( - args: TargetArgs & { dir: string; dryRun: boolean }, -): Pick, 'deployTarget' | 'deployTargetToWrite'> { - const target = buildDeployTarget(args); - if (!target) return {}; - const deployTarget = { name: target.name, environment: args.environment.name, written: !args.dryRun }; - return args.dryRun ? { deployTarget } : { deployTarget, deployTargetToWrite: { dir: args.dir, target } }; -} - // ── Connector ──────────────────────────────────────────────────────────────── export function createVercelConnector(deps: VercelConnectorDeps = {}): ConnectorModule { @@ -507,26 +409,6 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector return { ok: true, project: { id: found.project.id, name: found.project.name, ...(productionBranch ? { productionBranch } : {}) } }; }; - /** `--targets` only: the target this import writes (dry run: would write). Nothing when the flag is off. */ - const plannedTargets = async ( - opts: ConnectOpts, - source: SourceRead, - importedNames: readonly string[], - dryRun: boolean, - ): Promise, 'deployTarget' | 'deployTargetToWrite'>> => { - if (!opts.targets) return {}; - const dir = opts.projectDir ?? cwd(); - return planTargetsOutcome({ - importedNames, - project: source.project, - teamId: source.teamId, - environment: source.environment, - allTargets: await listTargetsAt(dir), - dir, - dryRun, - }); - }; - /** `--overwrite`'s own run: plan from Vercel's FULL readable set (never `--var`-restricted), confirm, return the outcome for the caller to write. */ const runOverwriteImport = async (a: { ctx: ResolvedContext; @@ -552,7 +434,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector if (promptable && !opts.yes) { const proceed = await confirm(overwriteConfirmMessage(plan), false); if (!proceed) { - return { ok: true, source: importSource, imported: [], cleared: [], replacedNames: [], unchanged: plan.unchanged, skipped: [], warnings, deployTargetSaved: false }; + return { ok: true, source: importSource, imported: [], cleared: [], replacedNames: [], unchanged: plan.unchanged, skipped: [], warnings }; } } else if (!promptable && !opts.yes) { // COPY-FLAG: new user-facing string, minimal/neutral wording. @@ -578,8 +460,6 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector // overwrite leaves them alone, and saying so is the point of the list. skipped: [...source.skipped, ...values.value.empty].map(toImportSkip), warnings, - deployTargetSaved: false, - ...(await plannedTargets(opts, source, [...written.map((w) => w.varName), ...plan.unchanged], dryRun)), }; }; @@ -636,24 +516,6 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector ...conflictSkipped, ]; - const importedNames = [...imported.map((i) => i.varName), ...unchanged]; - const dir = opts.projectDir ?? cwd(); - // `--targets` writes without asking; otherwise the target is only offered, and only interactively. - const planned = await plannedTargets(opts, source, importedNames, dryRun); - const deployTargetSaved = - !dryRun && - !opts.targets && - (await maybeOfferDeployTarget({ - importedNames, - project: source.project, - teamId: source.teamId, - environment: source.environment, - allTargets: await listTargetsAt(dir), - promptable, - dir, - confirm, - })); - return { ok: true, source: importSource, @@ -662,26 +524,10 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector unchangedEntries, skipped, warnings: truncationWarnings(source), - deployTargetSaved, - ...planned, ...(dryRun ? { wouldAsk } : {}), }; }; - /** `--targets` under `--dry-run`: the target each plan environment would get, by folder. Names only. */ - const plannedTargetsFor = async (folders: readonly DiscoveryPlanFolder[]): Promise => { - const perFolder = await Promise.all( - folders.map(async (f) => { - const existing = await listTargetsAt(f.folder ? join(f.repoDir, f.folder) : f.repoDir); - return orderEnvironments(f.environments).flatMap((e): readonly DiscoveryDeployTarget[] => { - const name = e.variableCount > 0 ? newTargetName(existing, e.serviceId, e.environmentName) : null; - return name === null ? [] : [{ repoDir: f.repoDir, folder: f.folder, branch: e.environmentName, name, written: false }]; - }); - }), - ); - return perFolder.flat(); - }; - /** Everything after the client exists: resolve the team, build the plan, apply it. */ const discoverFrom = async ( ctx: DiscoveryContext, @@ -728,11 +574,8 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector if (dryRun) { // A dry run never asks for a branch name either; the DEFAULT is what it previews. - // `--targets`: the targets it would write are listed, and their file is part of the previewed commit. - const targets = opts.targets ? await plannedTargetsFor(plan.folders) : []; - const hasTargets = (folder: DiscoveryPlanFolder): boolean => targets.some((t) => t.repoDir === folder.repoDir && t.folder === folder.folder); - const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), VERCEL_COMMIT_MESSAGE, hasTargets); - return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}), ...(opts.targets ? { deployTargets: targets } : {}) }; + const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), VERCEL_COMMIT_MESSAGE); + return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}) }; } // `--project` under `--discover` is the collision-winner hint (an id or a name), not an import source. @@ -772,7 +615,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector // "discovery made this change" from "already dirty before the run". const beforeStatusByRepo = new Map( [...new Set(resolvedFolders.map((f) => f.repoDir))].map((repoDir) => { - const paths = resolvedFolders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder, { deployConfig: !!opts.targets })); + const paths = resolvedFolders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)); return [repoDir, snapshotPathStatus(repoDir, paths)] as const; }), ); @@ -798,8 +641,6 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector vercelTeam: { id: team.teamId }, environment: env.environmentName, tokenEnv, - projectDir: folderPath, - ...(opts.targets ? { targets: true } : {}), }), askExistingOrNewProject, askProjectName: askDiscoveryProjectName, @@ -807,9 +648,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector sameRunNameCollisions, }); const applied = await runDiscoverySequences(resolvedFolders, { overwrite: !!opts.overwrite }, sequenceDeps); - // `--targets`: a folder that wrote a deploy target commits its `.capy/deploy.json` with its keep.lock. - const wroteTarget = (folder: DiscoveryFolderResult): boolean => folder.environments.some((e) => e.outcome.deployTarget?.written === true); - const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, VERCEL_COMMIT_MESSAGE, wroteTarget); + const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, VERCEL_COMMIT_MESSAGE); return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}) }; }; @@ -867,12 +706,6 @@ function withTokenHelp(outcome: T, o return { ...outcome, ...keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME, { team: opts.team })) } as T; } - -async function listTargetsAt(dir: string): Promise { - const { listTargets } = await import('../../deploy/config'); - return listTargets(dir); -} - const toImportSkip = (s: VercelSkippedRecord): ImportSkip => ({ name: s.name, code: s.reason, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) }); function truncationWarnings(source: SourceRead): readonly ImportWarning[] { diff --git a/src/commands/connectors/vercelDiscovery.ts b/src/commands/connectors/vercelDiscovery.ts index a6e14687..84da1d95 100644 --- a/src/commands/connectors/vercelDiscovery.ts +++ b/src/commands/connectors/vercelDiscovery.ts @@ -208,7 +208,6 @@ function envReport(e: Extract['environments branchCreated: e.branchCreated, overwrote: e.overwrote, imported: e.outcome.imported.map((i) => i.varName), - ...(e.outcome.deployTarget ? { deployTarget: e.outcome.deployTarget } : {}), ...(e.outcome.replacedNames ? { replacedNames: e.outcome.replacedNames } : {}), ...(e.outcome.cleared ? { cleared: e.outcome.cleared } : {}), unchanged: e.outcome.unchanged, @@ -254,17 +253,6 @@ function appliedReport(applied: readonly DiscoveryFolderResult[]) { ); } -/** `--targets`: every deploy target by folder and name — written (a real run) or, under `--dry-run`, planned. */ -function deployTargetsReport(outcome: Extract) { - const written = (outcome.applied ?? []).flatMap((f) => - f.environments.flatMap((e) => - e.outcome.deployTarget ? [{ folder: f.folder || '.', branch: e.environmentName, name: e.outcome.deployTarget.name, written: e.outcome.deployTarget.written }] : [], - ), - ); - const planned = (outcome.deployTargets ?? []).map((t) => ({ folder: t.folder || '.', branch: t.branch, name: t.name, written: t.written })); - return [...planned, ...written]; -} - function commitsReport(commits: NonNullable['commits']>) { return commits.map((c) => c.ok @@ -305,13 +293,12 @@ export function reportVercelDiscovery( plan, ...(applied ? { applied } : {}), ...(commits ? { commits } : {}), - ...(opts.targets ? { deployTargets: deployTargetsReport(outcome) } : {}), }), ); return { linked }; } - const lines = [...humanLines(outcome, plan, applied, commits), ...(opts.targets ? targetLines(deployTargetsReport(outcome)) : [])]; + const lines = humanLines(outcome, plan, applied, commits); lines.forEach((l) => console.log(l)); return { linked }; } @@ -368,16 +355,6 @@ function appliedLines(outcome: Extract, applied: ]); } -// COPY-FLAG: new user-facing string, minimal/neutral wording. -function targetLines(targets: ReturnType): readonly string[] { - if (targets.length === 0) return []; - return [ - ' Deploy targets:', - ...targets.map((t) => ` ${t.folder}: ${t.written ? 'wrote' : 'would write'} ${t.name} (${t.branch})`), - '', - ]; -} - // COPY-FLAG: report lines mirror Dokploy's discovery report word for word, with Vercel substituted; nothing new invented. function commitLine(c: ReturnType[number]): string { if (!c.ok) return ` ${c.repoDir}: ✗ ${c.code}: ${c.message}`; diff --git a/src/commands/deployDiscover/apply.ts b/src/commands/deployDiscover/apply.ts index e513c099..e7b99c5c 100644 --- a/src/commands/deployDiscover/apply.ts +++ b/src/commands/deployDiscover/apply.ts @@ -20,7 +20,8 @@ import { ERROR_CODES } from '../../types/index'; import type { TargetConfig } from '../../deploy/adapter'; import type { GithubApi, RepoRef } from '../../deploy/githubApi'; import { codeForApiFailure, openFilesPullRequest, resolveDefaultBase } from '../keepLockPr'; -import { ExistingTarget, ParsedDeployJson, byText, configuredTarget, parseDeployJson, uniqueSorted } from './facts'; +import { ExistingTarget, ParsedDeployJson, ServiceFacts, byText, configuredTarget, parseDeployJson, uniqueSorted } from './facts'; +import type { KindOverrides } from './kind'; import { TargetToWrite, uniqueName } from './evaluate'; import { sortedCopy } from '../../deploy/dokployApi'; @@ -58,6 +59,8 @@ export interface ApplyResult { export interface ApplyDeps { readonly github: GithubApi | undefined; readonly branchName: () => string; + /** The provider's overrides; absent: Dokploy. */ + readonly kind?: KindOverrides; } const repoLabel = (repo: RepoRef): string => `${repo.owner}/${repo.name}`; @@ -72,8 +75,7 @@ export function mergeDeployJson(parsed: Extract, interface WrittenTarget { readonly target: TargetConfig; - readonly serviceKind: 'application' | 'compose'; - readonly serviceId: string; + readonly service: ServiceFacts; } interface FilePlan { @@ -90,26 +92,28 @@ async function planFile( base: string, path: string, targets: readonly TargetToWrite[], + kind?: KindOverrides, ): Promise<{ readonly ok: true; readonly plan: FilePlan } | { readonly ok: false; readonly code: string }> { + const configured = (existing: readonly ExistingTarget[], t: TargetToWrite): ExistingTarget | undefined => + kind === undefined ? configuredTarget(existing, t.service.service_id, t.entry.branch) : kind.configured(existing, t.service, t.entry.branch); const read = await github.getFile(repo, path, base); if (!read.ok) return { ok: false, code: codeForApiFailure(read, ERROR_CODES.KEEP_PR_READ_FAILED) }; const parsed = parseDeployJson(read.value); if (!parsed.ok) return { ok: false, code: ERROR_CODES.KEEP_PR_READ_FAILED }; const already = targets.flatMap((t): AppliedAlready[] => { - const hit = configuredTarget(parsed.targets, t.service.service_id, t.entry.branch); + const hit = configured(parsed.targets, t); return hit === undefined ? [] : [{ repo: repoLabel(repo), path, branch: t.entry.branch, service_id: t.service.service_id, target_name: hit.name }]; }); - const fresh = targets.filter((t) => configuredTarget(parsed.targets, t.service.service_id, t.entry.branch) === undefined); + const fresh = targets.filter((t) => configured(parsed.targets, t) === undefined); const named = fresh.reduce((acc, t) => { const taken = new Set([...parsed.targets.map((x: ExistingTarget) => x.name), ...acc.map((x) => x.target.name)]); return [ ...acc, { target: { ...t.target, name: uniqueName(t.target.name, taken) }, - serviceKind: t.service.kind, - serviceId: t.service.service_id, + service: t.service, }, ]; }, []); @@ -125,19 +129,21 @@ async function planFile( } // COPY-FLAG: new user-facing strings (the commit message and the PR body), names and ids only. -function wording(repo: RepoRef, files: readonly FilePlan[]) { +function wording(repo: RepoRef, files: readonly FilePlan[], kind?: KindOverrides) { + const title = kind?.pr.title ?? PR_TITLE; const lines = files.flatMap((f) => - f.written.map( - (w) => - `- \`${w.target.name}\` in \`${f.path}\`: Capy branch \`${w.target.branch}\`, Dokploy ${w.serviceKind} \`${w.serviceId}\`, git branch \`${w.target.gitBaseBranch ?? ''}\``, + f.written.map((w) => + kind !== undefined + ? kind.pr.line({ target: w.target, path: f.path, service: w.service }) + : `- \`${w.target.name}\` in \`${f.path}\`: Capy branch \`${w.target.branch}\`, Dokploy ${w.service.kind} \`${w.service.service_id}\`, git branch \`${w.target.gitBaseBranch ?? ''}\``, ), ); const names = files.flatMap((f) => f.written.map((w) => w.target.name)); return { - commitMessage: [PR_TITLE, '', `Targets: ${names.join(', ')}`].join('\n'), - title: PR_TITLE, + commitMessage: [title, '', `Targets: ${names.join(', ')}`].join('\n'), + title, body: [ - 'Adds Dokploy deploy targets to `.capy/deploy.json`.', + kind?.pr.intro ?? 'Adds Dokploy deploy targets to `.capy/deploy.json`.', '', ...lines, '', @@ -163,7 +169,7 @@ async function applyRepo(repo: RepoRef, targets: readonly TargetToWrite[], deps: const paths = uniqueSorted(targets.map((t) => t.file)); const planned = await Promise.all( paths.map((path) => - planFile(github, repo, base.base, path, targets.filter((t) => t.file === path)), + planFile(github, repo, base.base, path, targets.filter((t) => t.file === path), deps.kind), ), ); const failedRead = planned.find((p) => !p.ok); @@ -173,7 +179,7 @@ async function applyRepo(repo: RepoRef, targets: readonly TargetToWrite[], deps: const changed = files.filter((f) => f.written.length > 0); if (changed.length === 0) return { kind: 'unchanged', already }; - const words = wording(repo, changed); + const words = wording(repo, changed, deps.kind); const opened = await openFilesPullRequest( { repo, diff --git a/src/commands/deployDiscover/command.ts b/src/commands/deployDiscover/command.ts index 902557db..024670be 100644 --- a/src/commands/deployDiscover/command.ts +++ b/src/commands/deployDiscover/command.ts @@ -89,6 +89,7 @@ import { summaryLine, } from './plan'; import { sortedCopy } from '../../deploy/dokployApi'; +import type { KindOverrides } from './kind'; // ── Seams ──────────────────────────────────────────────────────────────────── @@ -109,21 +110,14 @@ export type ContextResult = | { readonly ok: true; readonly orgId: string; readonly client: CapyReads } | { readonly ok: false; readonly code: string; readonly message: string }; -/** Everything external, injected: tests pass fakes; `realDeployDiscoverIo` builds the real one. */ -export interface DeployDiscoverIo { +/** What every provider's discovery needs from the outside, injected: tests pass fakes. */ +export interface CommonIo { readonly devMode: boolean; readonly env: Readonly>; - /** The base URL of a Dokploy target already saved in this folder's deploy.json (the third place the shared resolver looks). */ - readonly savedBaseUrl: () => string | undefined; - /** Opens the org system store for the Dokploy base URL variable. Absent: the variable is not consulted. Rejects with a coded error for a non-admin. */ - readonly openBaseUrlStore?: (orgId: string) => Promise; /** Reads the plan file; throws when it cannot. */ readonly readFile: (path: string) => string; /** Silent auth only: never a prompt, never a browser. */ readonly context: () => Promise; - /** The org system store read for the Dokploy key. */ - readonly getConnectorSecret: (name: string, opts: DokploySystemStoreCallOptions) => Promise; - readonly dokploy: (baseUrl: string, token: string) => DiscoveryDokployClient; /** `undefined` when `gh` is not installed. */ readonly github: () => GithubApi | undefined; readonly branchName: () => string; @@ -131,6 +125,17 @@ export interface DeployDiscoverIo { readonly progress: (line: string) => void; } +/** Everything external, injected: tests pass fakes; `realDeployDiscoverIo` builds the real one. */ +export interface DeployDiscoverIo extends CommonIo { + /** The base URL of a Dokploy target already saved in this folder's deploy.json (the third place the shared resolver looks). */ + readonly savedBaseUrl: () => string | undefined; + /** Opens the org system store for the Dokploy base URL variable. Absent: the variable is not consulted. Rejects with a coded error for a non-admin. */ + readonly openBaseUrlStore?: (orgId: string) => Promise; + /** The org system store read for the Dokploy key. */ + readonly getConnectorSecret: (name: string, opts: DokploySystemStoreCallOptions) => Promise; + readonly dokploy: (baseUrl: string, token: string) => DiscoveryDokployClient; +} + export interface DeployDiscoverResult { readonly exitCode: number; /** The one JSON document the command prints. */ @@ -140,17 +145,17 @@ export interface DeployDiscoverResult { /** Exit code for a refusal that needs a different invocation (the plan to confirm), not a retry. */ const EXIT_NEEDS_INPUT = 3; -function refuse(code: string, error: string, extra: Readonly> = {}): DeployDiscoverResult { +export function refuse(code: string, error: string, extra: Readonly> = {}): DeployDiscoverResult { return { exitCode: code === ERROR_CODES.PLAN_CONFIRM_REQUIRED || code === 'DOKPLOY_SETTINGS_MISSING' ? EXIT_NEEDS_INPUT : 1, body: { ok: false, code, error, ...extra }, }; } -type Step = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly result: DeployDiscoverResult }; +export type Step = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly result: DeployDiscoverResult }; -const done = (value: T): Step => ({ ok: true, value }); -const stop = (result: DeployDiscoverResult): Step => ({ ok: false, result }); +export const done = (value: T): Step => ({ ok: true, value }); +export const stop = (result: DeployDiscoverResult): Step => ({ ok: false, result }); const codeOfError = (err: unknown): { readonly code: string; readonly message: string } => err instanceof CapyError @@ -191,13 +196,27 @@ const extrasBody = (extras: BaseUrlExtras): Readonly> => // ── The world: Dokploy, Capy ───────────────────────────────────────────────── -interface World { +export interface World { readonly orgId: string; readonly services: readonly ServiceFacts[]; readonly links: readonly OrgRepoLink[]; readonly projects: ReadonlyMap; } +/** The Capy side of every provider's world: ONE call each for the repo links and the secret NAME index, however many projects there are (the service limits /orgs). */ +export async function loadCapySide( + io: Pick, + client: CapyReads, + orgId: string, +): Promise }>> { + io.progress('Reading Capy projects…'); // COPY-FLAG + const repos = await capyCall(() => client.getOrgRepos(orgId)); + if (!repos.ok) return repos; + const index = await capyCall(() => client.getSecretIndex(orgId)); + if (!index.ok) return index; + return done({ links: repos.value.repos, projects: capyProjectsOf(repos.value.repos, index.value.rows) }); +} + async function dokployClient(io: DeployDiscoverIo, baseUrl: string, orgId: string): Promise> { const resolved = await resolveDokployApiKey({ tokenEnv: DEFAULT_TOKEN_ENV, @@ -226,7 +245,7 @@ function dokployFailure(err: unknown): DeployDiscoverResult { } /** A Capy call that waits out a 429 (bounded) and reports a failure as its code. */ -async function capyCall(call: () => Promise): Promise> { +export async function capyCall(call: () => Promise): Promise> { const answer = await callWithBackoff(() => call()); if (answer.ok) return done(answer.value); const { code, message } = codeOfError(answer.error); @@ -253,18 +272,9 @@ async function loadWorld( ); if (!details.ok) return stop(dokployFailure(details.err)); - // ONE call each, however many projects there are: the service limits /orgs. - io.progress('Reading Capy projects…'); // COPY-FLAG - const repos = await capyCall(() => client.getOrgRepos(orgId)); - if (!repos.ok) return repos; - const index = await capyCall(() => client.getSecretIndex(orgId)); - if (!index.ok) return index; - return done({ - orgId, - services: details.value.map(serviceFactsOf), - links: repos.value.repos, - projects: capyProjectsOf(repos.value.repos, index.value.rows), - }); + const capy = await loadCapySide(io, client, orgId); + if (!capy.ok) return capy; + return done({ orgId, services: details.value.map(serviceFactsOf), ...capy.value }); } // ── GitHub reads ───────────────────────────────────────────────────────────── @@ -272,12 +282,12 @@ async function loadWorld( const GITHUB_CONCURRENCY = 4; /** `fn` over `items`, a few at a time, results in input order. `fn` must not reject. */ -async function readEach(items: readonly T[], fn: (item: T) => Promise): Promise { +export async function readEach(items: readonly T[], fn: (item: T) => Promise): Promise { const pooled = await runPool({ items, limit: GITHUB_CONCURRENCY, run: async (item) => ({ result: await fn(item) }) }); return pooled.results; } -const refKey = (repo: RepoRef): string => `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}`; +export const refKey = (repo: RepoRef): string => `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}`; /** The default branch of each repo, or the code of why they could not be read. Keyed by `refKey`. */ async function readDefaultBranches( @@ -308,8 +318,8 @@ const isFailure = (v: readonly ExistingTarget[] | ReadFailure): v is ReadFailure // ── Discovery ──────────────────────────────────────────────────────────────── /** Default branches and deploy.json targets for the repos some Dokploy service tracks. */ -async function discoveryGithubFacts( - io: DeployDiscoverIo, +export async function discoveryGithubFacts( + io: Pick, world: World, ): Promise<{ readonly defaultBranches: Readonly>; @@ -376,12 +386,12 @@ async function runDiscovery(opts: DeployDiscoverOpts, io: DeployDiscoverIo): Pro // ── The plan ───────────────────────────────────────────────────────────────── -interface ParsedPlan { +export interface ParsedPlan { readonly entries: readonly PlanEntry[]; readonly plan: Plan; } -function readPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo): Step { +export function readPlan(opts: Pick, io: Pick): Step { const text = (() => { try { return { ok: true as const, text: io.readFile(opts.plan as string) }; @@ -404,12 +414,12 @@ function readPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo): Step> = {}): DeployDiscoverResult { +export function invalidPlan(errors: readonly PlanError[], extra: Readonly> = {}): DeployDiscoverResult { return refuse(ERROR_CODES.PLAN_INVALID, 'The plan has errors. Nothing was changed.', { errors, ...extra }); // COPY-FLAG } /** GitHub reads a plan needs: each repo's branches, each deploy.json at its repo's default branch. */ -async function planGithubReads(io: DeployDiscoverIo, needs: ReturnType): Promise }> { +export async function planGithubReads(io: Pick, needs: ReturnType): Promise }> { const github = io.github(); const unavailable: ReadFailure = { code: ERROR_CODES.KEEP_PR_GH_UNAVAILABLE }; if (github === undefined || needs.repos.length === 0) { @@ -437,7 +447,7 @@ async function planGithubReads(io: DeployDiscoverIo, needs: ReturnType @@ -450,7 +460,9 @@ function summaryOf(evaluated: readonly EvaluatedEntry[]): readonly string[] { const normalized: PlanEntry = { ...entry, vars: uniqueSorted(entry.vars) }; return e.status === 'already_configured' ? alreadyConfiguredLine({ project_name: project.project_name, entry: normalized, target_name: e.existingTargetName ?? '' }) - : summaryLine({ project_name: project.project_name, kind: service.kind, entry: normalized }); + : kind === undefined + ? summaryLine({ project_name: project.project_name, kind: service.kind, entry: normalized }) + : kind.summaryLine({ project_name: project.project_name, service, entry: normalized }); }); } @@ -475,13 +487,36 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P const world = await loadWorld(io, ctx.client, ctx.orgId, dokploy.value, new Set(entries.map((e) => e.service_id))); if (!world.ok) return world.result; - const resolved = resolveEntries(entries, new Map(world.value.services.map((s) => [s.service_id, s])), world.value.projects); + return finishPlan({ opts, io, parsed, planId, world: world.value, scope: baseUrl, extras: extrasBody(extras) }); +} + +/** + * Everything after the world is read, for every provider: check the plan against GitHub and Capy, then print the + * dry run, refuse a real run without its `--confirm`, or write the targets (one PR per repo). + */ +export async function finishPlan(args: { + readonly opts: Pick; + readonly io: Pick; + readonly parsed: ParsedPlan; + readonly planId: string; + readonly world: World; + /** What the targets are written for (Dokploy: the base URL). */ + readonly scope: string; + /** Fields merged into the output after the plan's own (Dokploy: the saved-base-URL report and notices). */ + readonly extras: Readonly>; + readonly kind?: KindOverrides; +}): Promise { + const { opts, io, parsed, planId, world, scope, extras, kind } = args; + const { entries, plan } = parsed; + const confirming = opts.confirm !== undefined && opts.dryRun !== true; + + const resolved = resolveEntries(entries, new Map(world.services.map((s) => [s.service_id, s])), world.projects, kind); const needs = githubNeeds(resolved); const reads = await planGithubReads(io, needs); - const evaluation = evaluatePlan(resolved, reads); + const evaluation = evaluatePlan(resolved, reads, kind); if (evaluation.errors.length > 0) return invalidPlan(evaluation.errors, { plan_id: planId }); - const toWrite = targetsToWrite(evaluation.entries, reads, baseUrl); + const toWrite = targetsToWrite(evaluation.entries, reads, scope, kind); const configured = evaluation.entries .filter((e) => e.status === 'already_configured') .map((e) => ({ @@ -490,7 +525,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P service_id: e.resolved.entry.service_id, target_name: e.existingTargetName ?? '', })); - const summary = summaryOf(evaluation.entries); + const summary = summaryOf(evaluation.entries, kind); const prPreview = uniqueSorted(toWrite.map((t) => t.repoKey)).map((k) => { const mine = toWrite.filter((t) => t.repoKey === k); return { @@ -500,7 +535,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P targets: mine.map((t) => t.target.name), }; }); - const dryBody = { plan, plan_id: planId, summary_lines: summary, already_configured: configured, prs: prPreview, ...extrasBody(extras) }; + const dryBody = { plan, plan_id: planId, summary_lines: summary, already_configured: configured, prs: prPreview, ...extras }; if (!confirming) { if (opts.dryRun === true) { @@ -513,7 +548,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P next_steps: [ { action: 'relay', field: 'summary_lines' }, { action: 'get_approval' }, - { run: `capy deploy dokploy --discover --plan ${opts.plan} --confirm ${planId}` }, + { run: `${kind?.command ?? 'capy deploy dokploy --discover'} --plan ${opts.plan} --confirm ${planId}` }, ], }, }; @@ -525,7 +560,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P ); } - const applied = await applyTargets(toWrite, { github: io.github(), branchName: io.branchName }); + const applied = await applyTargets(toWrite, { github: io.github(), branchName: io.branchName, ...(kind === undefined ? {} : { kind }) }); const failed = applied.failed.length > 0; return { exitCode: failed ? 1 : 0, @@ -537,7 +572,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P prs: applied.prs, failed: applied.failed, already_configured: configured, - ...extrasBody(extras), + ...extras, }, }; } diff --git a/src/commands/deployDiscover/discovery.ts b/src/commands/deployDiscover/discovery.ts index c769db15..68fdf979 100644 --- a/src/commands/deployDiscover/discovery.ts +++ b/src/commands/deployDiscover/discovery.ts @@ -23,6 +23,7 @@ import { createHash } from 'crypto'; import type { OrgRepoLink } from '../../service/serviceClient'; import { sortedCopy } from '../../deploy/dokployApi'; import { PLAN_SCHEMA_REF } from './schema'; +import type { KindOverrides, SchemaRef } from './kind'; import { CapyProjectFacts, ExistingTarget, @@ -85,7 +86,8 @@ export interface Proposal { /** The Capy branch. */ readonly branch: string; readonly git_branch: string; - readonly target: ProposalTarget; + /** Dokploy: a `ProposalTarget`. Another provider shows its own shape. */ + readonly target: ProposalTarget | Readonly>; readonly evidence: { readonly repo_match: boolean; readonly path_match: boolean; @@ -140,7 +142,7 @@ export interface ProjectOut { export type NextStep = | { readonly action: 'review'; readonly proposal_ids: readonly string[] } | { readonly action: 'resolve'; readonly service_ids: readonly string[] } - | { readonly action: 'write_plan'; readonly schema: typeof PLAN_SCHEMA_REF } + | { readonly action: 'write_plan'; readonly schema: SchemaRef } | { readonly run: string }; export interface Notice { @@ -149,20 +151,24 @@ export interface Notice { } export interface DeployDiscovery { - readonly base_url: string; + /** Dokploy. Another provider puts its own header fields here instead (`DiscoveryInput.header`). */ + readonly base_url?: string; + readonly teams?: readonly unknown[]; readonly services: readonly ServiceFacts[]; readonly projects: readonly ProjectOut[]; readonly proposals: readonly Proposal[]; readonly already_configured: readonly AlreadyConfigured[]; readonly unresolved: readonly Unresolved[]; readonly next_steps: readonly NextStep[]; - readonly plan_schema_ref: typeof PLAN_SCHEMA_REF; + readonly plan_schema_ref: SchemaRef; readonly notices: readonly Notice[]; } /** Everything discovery reads, already read. */ export interface DiscoveryInput { readonly baseUrl: string; + /** Replaces `base_url` in the document, for a provider that has none. Absent: `{ base_url }`. */ + readonly header?: Readonly>; readonly services: readonly ServiceFacts[]; readonly projects: ReadonlyMap; /** By `repoKey`: the repo's default branch. Absent / `null`: not known. */ @@ -303,10 +309,14 @@ function forProject( service: ServiceFacts, project: CapyProjectFacts, matched: Matched, + kind?: KindOverrides, ): ForProject { const repo = service.repo as RepoOut; const defaultBranch = input.defaultBranches[repoKey(repo)]; - const choice = chooseCapyBranch(service.branch, defaultBranch, service.environment, project.branches); + const choice = + kind === undefined + ? chooseCapyBranch(service.branch, defaultBranch, service.environment, project.branches) + : kind.chooseCapyBranch(service, defaultBranch, project.branches); if (choice === null || service.branch === null) { return { kind: 'unresolved', @@ -319,14 +329,15 @@ function forProject( }; } const gitBranch = service.branch; - const existing = configuredTarget(input.existingTargets[project.project_id] ?? [], service.service_id, choice.branch); + const projectTargets = input.existingTargets[project.project_id] ?? []; + const existing = kind === undefined ? configuredTarget(projectTargets, service.service_id, choice.branch) : kind.configured(projectTargets, service, choice.branch); if (existing !== undefined) { return { kind: 'configured', configured: { project_id: project.project_id, branch: choice.branch, service_id: service.service_id, target_name: existing.name }, }; } - const conflict = conflictingTarget(input.existingTargets[project.project_id] ?? [], service.service_id, choice.branch); + const conflict = kind === undefined ? conflictingTarget(projectTargets, service.service_id, choice.branch) : kind.conflicting(projectTargets, service, choice.branch); const projectNames = project.var_names_by_branch[choice.branch] ?? []; const overlap = overlapOf(service.env_var_names, projectNames); const vars = sharedNames(service.env_var_names, projectNames); @@ -336,7 +347,7 @@ function forProject( project_id: project.project_id, branch: choice.branch, git_branch: gitBranch, - target: targetOf(input.baseUrl, service, choice.branch, gitBranch, vars), + target: kind === undefined ? targetOf(input.baseUrl, service, choice.branch, gitBranch, vars) : kind.proposalTarget(service, choice.branch, gitBranch, vars), evidence: { repo_match: true, path_match: matched.exactPath, @@ -360,11 +371,11 @@ interface ServiceResult { readonly unresolved: readonly Unresolved[]; } -function resolveService(input: DiscoveryInput, service: ServiceFacts, links: readonly OrgRepoLink[]): ServiceResult { +function resolveService(input: DiscoveryInput, service: ServiceFacts, links: readonly OrgRepoLink[], kind?: KindOverrides): ServiceResult { const match = matchService(service, links, input.projects); if (match.kind === 'unresolved') return { drafts: [], configured: [], unresolved: [match.unresolved] }; const { matched } = match; - const perProject = matched.projects.map((project) => forProject(input, service, project, matched)); + const perProject = matched.projects.map((project) => forProject(input, service, project, matched, kind)); const ambiguous: readonly Unresolved[] = matched.projects.length > 1 ? [ @@ -403,14 +414,14 @@ function withCompeting(drafts: readonly Draft[]): readonly Proposal[] { const needsReview = (p: Proposal): boolean => p.confidence.project === 'needs_review' || p.confidence.branch === 'needs_review' || p.confidence.vars === 'needs_review'; -function nextStepsOf(proposals: readonly Proposal[], unresolved: readonly Unresolved[]): readonly NextStep[] { +function nextStepsOf(proposals: readonly Proposal[], unresolved: readonly Unresolved[], kind?: KindOverrides): readonly NextStep[] { const review = proposals.filter(needsReview).map((p) => p.proposal_id); const unresolvedServices = uniqueSorted(unresolved.flatMap((u) => (u.service_id === undefined ? [] : [u.service_id]))); return [ ...(review.length > 0 ? [{ action: 'review' as const, proposal_ids: review }] : []), ...(unresolvedServices.length > 0 ? [{ action: 'resolve' as const, service_ids: unresolvedServices }] : []), - { action: 'write_plan' as const, schema: PLAN_SCHEMA_REF }, - { run: 'capy deploy dokploy --discover --plan --dry-run' }, + { action: 'write_plan' as const, schema: kind?.schemaRef ?? PLAN_SCHEMA_REF }, + { run: `${kind?.command ?? 'capy deploy dokploy --discover'} --plan --dry-run` }, ]; } @@ -428,13 +439,13 @@ function projectOut(project: CapyProjectFacts, input: DiscoveryInput): ProjectOu } /** The whole discovery document. Pure. */ -export function buildDeployDiscovery(input: DiscoveryInput): DeployDiscovery { +export function buildDeployDiscovery(input: DiscoveryInput, kind?: KindOverrides): DeployDiscovery { const links = [...input.projects.values()].flatMap((p) => p.links); - const results = input.services.map((service) => resolveService(input, service, links)); + const results = input.services.map((service) => resolveService(input, service, links, kind)); const proposals = withCompeting(results.flatMap((r) => r.drafts)); const unresolved = results.flatMap((r) => r.unresolved); return { - base_url: input.baseUrl, + ...(input.header ?? { base_url: input.baseUrl }), services: input.services, projects: sortedCopy( [...input.projects.values()].filter((p) => p.links.length > 0).map((p) => projectOut(p, input)), @@ -443,8 +454,8 @@ export function buildDeployDiscovery(input: DiscoveryInput): DeployDiscovery { proposals, already_configured: results.flatMap((r) => r.configured), unresolved, - next_steps: nextStepsOf(proposals, unresolved), - plan_schema_ref: PLAN_SCHEMA_REF, + next_steps: nextStepsOf(proposals, unresolved, kind), + plan_schema_ref: kind?.schemaRef ?? PLAN_SCHEMA_REF, notices: input.notices ?? [], }; } diff --git a/src/commands/deployDiscover/evaluate.ts b/src/commands/deployDiscover/evaluate.ts index 51158da7..1a6d3fb3 100644 --- a/src/commands/deployDiscover/evaluate.ts +++ b/src/commands/deployDiscover/evaluate.ts @@ -36,6 +36,7 @@ import { uniqueSorted, } from './facts'; import { PlanError } from './plan'; +import type { KindOverrides } from './kind'; export interface ResolvedEntry { readonly index: number; @@ -96,10 +97,12 @@ export function resolveEntries( entries: readonly PlanEntry[], services: ReadonlyMap, projects: ReadonlyMap, + kind?: KindOverrides, ): readonly ResolvedEntry[] { + const duplicateKey = kind?.duplicateKey ?? ((e: PlanEntry): string => `${e.project_id}\u0000${e.branch}`); return entries.map((entry, index) => { const resolved = resolveEntry(entry, index, services, projects); - const duplicate = entries.slice(0, index).some((e) => e.project_id === entry.project_id && e.branch === entry.branch); + const duplicate = entries.slice(0, index).some((e) => duplicateKey(e) === duplicateKey(entry)); return duplicate ? { ...resolved, errors: [...resolved.errors, err(at(index), ERROR_CODES.DUPLICATE_ENTRY)] } : resolved; }); } @@ -153,7 +156,7 @@ export interface Evaluation { readonly entries: readonly EvaluatedEntry[]; } -function githubErrors(r: ResolvedEntry, reads: GithubReads): readonly PlanError[] { +function githubErrors(r: ResolvedEntry, reads: GithubReads, kind?: KindOverrides): readonly PlanError[] { if (r.link === undefined) return []; const branches = reads.branches.get(repoKey(r.link)); const branchErrors: readonly PlanError[] = @@ -170,19 +173,29 @@ function githubErrors(r: ResolvedEntry, reads: GithubReads): readonly PlanError[ ? [err(at(r.index, 'project_id'), ERROR_CODES.KEEP_PR_READ_FAILED)] : isFailure(targets) ? [err(at(r.index, 'project_id'), targets.code)] - : conflictingTarget(targets, r.entry.service_id, r.entry.branch) !== undefined + : conflictOf(targets, r, kind) !== undefined ? [err(at(r.index, 'branch'), ERROR_CODES.TARGET_EXISTS)] : []; return [...branchErrors, ...targetErrors]; } +function conflictOf(targets: readonly ExistingTarget[], r: ResolvedEntry, kind?: KindOverrides): ExistingTarget | undefined { + if (kind === undefined) return conflictingTarget(targets, r.entry.service_id, r.entry.branch); + return r.service === undefined ? undefined : kind.conflicting(targets, r.service, r.entry.branch); +} + +function configuredOf(targets: readonly ExistingTarget[], r: ResolvedEntry, kind?: KindOverrides): ExistingTarget | undefined { + if (kind === undefined) return configuredTarget(targets, r.entry.service_id, r.entry.branch); + return r.service === undefined ? undefined : kind.configured(targets, r.service, r.entry.branch); +} + /** Step 2: the plan against GitHub's branches and the repos' deploy.json files. */ -export function evaluatePlan(resolved: readonly ResolvedEntry[], reads: GithubReads): Evaluation { - const errors = resolved.flatMap((r) => [...r.errors, ...githubErrors(r, reads)]); +export function evaluatePlan(resolved: readonly ResolvedEntry[], reads: GithubReads, kind?: KindOverrides): Evaluation { + const errors = resolved.flatMap((r) => [...r.errors, ...githubErrors(r, reads, kind)]); const entries = resolved.flatMap((r): EvaluatedEntry[] => { if (r.service === undefined || r.project === undefined || r.link === undefined) return []; const targets = reads.deployJson.get(fileKeyOf(r.link)); - const existing = targets === undefined || isFailure(targets) ? undefined : configuredTarget(targets, r.entry.service_id, r.entry.branch); + const existing = targets === undefined || isFailure(targets) ? undefined : configuredOf(targets, r, kind); return [ { resolved: { ...r, service: r.service, project: r.project, link: r.link }, @@ -196,14 +209,14 @@ export function evaluatePlan(resolved: readonly ResolvedEntry[], reads: GithubRe // ── The targets to write ───────────────────────────────────────────────────── -/** `dokploy-`, restricted to the characters a target name allows. */ -export function targetBaseName(branch: string): string { +/** `-` (`dokploy-…` unless asked), restricted to the characters a target name allows. */ +export function targetBaseName(branch: string, provider: string = 'dokploy'): string { const cleaned = branch .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); - return cleaned === '' ? 'dokploy' : `dokploy-${cleaned}`; + return cleaned === '' ? provider : `${provider}-${cleaned}`; } /** `base`, or `base-2`, `base-3`, … when `taken` already has it. */ @@ -255,6 +268,7 @@ export function targetsToWrite( evaluated: readonly EvaluatedEntry[], reads: GithubReads, baseUrl: string, + kind?: KindOverrides, ): readonly TargetToWrite[] { const pending = sortedCopy( evaluated.filter((e) => e.status === 'write'), @@ -271,14 +285,13 @@ export function targetsToWrite( return read === undefined || isFailure(read) ? [] : read.map((t) => t.name); })(); const taken = new Set([...existingNames, ...acc.filter((t) => t.fileKey === key).map((t) => t.target.name)]); - const name = uniqueName(targetBaseName(entry.branch), taken); - const target = dokployTargetOf({ - name, - entry: { ...entry, vars: uniqueSorted(entry.vars) }, - service, - baseUrl, - knownVars: project.var_names_by_branch[entry.branch] ?? [], - }); + const normalizedEntry = { ...entry, vars: uniqueSorted(entry.vars) }; + const knownVars = project.var_names_by_branch[entry.branch] ?? []; + const name = uniqueName(kind === undefined ? targetBaseName(entry.branch) : kind.baseName(normalizedEntry, service), taken); + const target = + kind === undefined + ? dokployTargetOf({ name, entry: normalizedEntry, service, baseUrl, knownVars }) + : kind.targetFor({ name, entry: normalizedEntry, service, link, knownVars }); return [ ...acc, { diff --git a/src/commands/deployDiscover/facts.ts b/src/commands/deployDiscover/facts.ts index 51e604ff..7268c85e 100644 --- a/src/commands/deployDiscover/facts.ts +++ b/src/commands/deployDiscover/facts.ts @@ -57,7 +57,7 @@ export interface RepoOut { readonly name: string; } -export interface ServiceFacts { +export interface DokployServiceFacts { readonly service_id: string; readonly kind: 'application' | 'compose'; readonly name: string; @@ -71,6 +71,48 @@ export interface ServiceFacts { readonly env_var_names: readonly string[]; } +/** A Vercel environment of a project that holds variables (CAP-696): one deploy target is one of these. */ +export interface VercelServiceFacts { + /** `:`; see `vercelServiceId`. Opaque to the caller: copy it into a plan. */ + readonly service_id: string; + readonly kind: 'vercel'; + /** The Vercel project's name. */ + readonly name: string; + readonly vercel_project_id: string; + /** The team that owns the project; `null` for a personal-account project. */ + readonly team: { readonly id: string; readonly slug?: string } | null; + /** `production`, `preview`, `development`, or a custom environment's slug. */ + readonly environment: string; + readonly custom_environment_id: string | null; + /** A preview environment scoped to one git branch (its records carry that `gitBranch`); `null`: every preview branch. */ + readonly git_branch_scope: string | null; + /** The project's root directory as Vercel holds it; `null`: the repo root. */ + readonly root_directory: string | null; + readonly repo: RepoOut | null; + /** The project's production branch (the deploy PR base); `null` when Vercel names none. */ + readonly branch: string | null; + /** The REPO-RELATIVE folder of the project; `null` when it has no repo. */ + readonly path: string | null; + /** Names only: never a value. */ + readonly env_var_names: readonly string[]; +} + +/** What matching, planning and writing read from a service. */ +export type ServiceFacts = DokployServiceFacts | VercelServiceFacts; + +const VERCEL_STANDARD_ENVS: readonly string[] = ['production', 'preview', 'development']; + +/** + * The service id of a Vercel project + environment: `:production`, + * `:preview`, `:preview@` (a preview scoped + * to one branch), `:custom:`. The same id is read back off an + * existing target's options, so "already configured" is a plain comparison. + */ +export function vercelServiceId(projectId: string, vercelEnv: string, gitBranch?: string | null): string { + const env = VERCEL_STANDARD_ENVS.includes(vercelEnv) ? vercelEnv : `custom:${vercelEnv}`; + return `${projectId}:${env}${vercelEnv === 'preview' && gitBranch ? `@${gitBranch}` : ''}`; +} + /** The NAMES in a Dokploy env text (the Capy-managed block and Capy's own runtime pair left out). The parsed values are dropped on the spot. */ export function envVarNamesOf(rawEnv: string | null): readonly string[] { return uniqueSorted(listImportableEntries(rawEnv).map((entry) => entry.name)); @@ -82,7 +124,7 @@ export function envVarNamesOf(rawEnv: string | null): readonly string[] { * (`owner`, `repository`, `branch`), the same ones `capy connect dokploy * --discover` already matches on. */ -export function serviceFactsOf(detail: ServiceDetail): ServiceFacts { +export function serviceFactsOf(detail: ServiceDetail): DokployServiceFacts { const git = hasGitSource(detail); return { service_id: detail.serviceId, @@ -163,7 +205,7 @@ export interface ExistingTarget { readonly name: string; readonly kind: string; readonly branch: string; - /** The Dokploy service the target points at (`composeId` / `applicationId`), when it is a Dokploy target. */ + /** The service the target points at: a Dokploy `composeId` / `applicationId`, or a Vercel project + environment (`vercelServiceId`). */ readonly service_id: string | null; } @@ -188,7 +230,10 @@ export function parseDeployJson(content: string | null): ParsedDeployJson { const target = asObject(value); if (target === undefined) return []; const options = asObject(target.options) ?? {}; - const serviceId = options.composeId ?? options.applicationId; + const vercelProject = target.kind === 'vercel' && typeof options.projectId === 'string' && typeof options.vercelEnv === 'string'; + const serviceId = vercelProject + ? vercelServiceId(options.projectId as string, options.vercelEnv as string, typeof options.gitBranch === 'string' ? options.gitBranch : null) + : (options.composeId ?? options.applicationId); return [ { name, diff --git a/src/commands/deployDiscover/index.ts b/src/commands/deployDiscover/index.ts index fb9969c0..bf509c87 100644 --- a/src/commands/deployDiscover/index.ts +++ b/src/commands/deployDiscover/index.ts @@ -11,7 +11,10 @@ import { dokployConnectorSecret } from '../connectors/dokploy'; import { newPrBranchName } from '../keepLockPr'; import { realGithub } from '../secretsSet'; import { resolveSilentContext } from '../secretsSetCommand'; +import { createVercelClient } from '../../deploy/vercelApi'; +import { getVercelTargetSecretFromStore } from '../../deploy/vercelTargetApi'; import { DeployDiscoverIo, DeployDiscoverOpts, runDeployDokployDiscover } from './command'; +import { DeployVercelDiscoverIo, DeployVercelDiscoverOpts, runDeployVercelDiscover } from './vercel'; export async function realDeployDiscoverIo(devMode: boolean, cwd: string = process.cwd()): Promise { const savedBaseUrl = await savedDokployTargetUrl(cwd); @@ -50,3 +53,40 @@ export async function deployDokployDiscoverCommand( console.log(JSON.stringify(body, null, 2)); return exitCode; } + +/** The real wiring of `capy deploy vercel --discover` (CAP-696): silent auth, the org system store, the real (read-only) Vercel API, the user's own `gh` login. */ +export async function realVercelDiscoverIo(devMode: boolean): Promise { + return { + devMode, + env: process.env, + readFile: (path) => readFileSync(path, 'utf-8'), + context: async () => { + const resolved = await resolveSilentContext(devMode); + return resolved.ok + ? { ok: true, orgId: resolved.context.orgId, client: resolved.context.client } + : { ok: false, code: resolved.code, message: resolved.message }; + }, + getSecret: getVercelTargetSecretFromStore, + // Names only: the client drops any value a response carries. + vercel: (token) => createVercelClient(token, { dropValues: true }), + github: realGithub, + branchName: () => newPrBranchName(new Date(), undefined, 'vercel-targets'), + progress: (line) => { + process.stderr.write(`${line}\n`); + }, + }; +} + +/** + * `capy deploy vercel --discover`: prints ONE JSON document on stdout, in a terminal or not, and returns the exit code. + * `io` is the seam tests use; the real command passes none. + */ +export async function deployVercelDiscoverCommand( + opts: DeployVercelDiscoverOpts, + devMode: boolean = false, + io?: DeployVercelDiscoverIo, +): Promise { + const { exitCode, body } = await runDeployVercelDiscover(opts, io ?? (await realVercelDiscoverIo(devMode))); + console.log(JSON.stringify(body, null, 2)); + return exitCode; +} diff --git a/src/commands/deployDiscover/kind.ts b/src/commands/deployDiscover/kind.ts new file mode 100644 index 00000000..5ee7eff2 --- /dev/null +++ b/src/commands/deployDiscover/kind.ts @@ -0,0 +1,64 @@ +/** + * What differs between the providers `capy deploy --discover` + * serves, as one set of overrides (CAP-703 Dokploy, CAP-696 Vercel). + * + * The shared modules (`discovery.ts`, `plan.ts`, `evaluate.ts`, `apply.ts`, + * `command.ts`) behave exactly as they always did for Dokploy when no `kind` + * is passed. A provider that passes one replaces ONLY the points below; the + * matching of services to Capy projects, the plan checks, the GitHub reads + * and the one-PR-per-repo write are the same code for both. + * + * Types only: this file imports nothing at runtime, so any module may import it. + */ +import type { TargetConfig } from '../../deploy/adapter'; +import type { OrgRepoLink } from '../../service/serviceClient'; +import type { PlanEntry } from './discovery'; +import type { ExistingTarget, ServiceFacts } from './facts'; + +/** A pointer into the `capy help --json` document. */ +export interface SchemaRef { + readonly command: string; + readonly pointer: string; +} + +export interface BranchChoice { + readonly branch: string; + /** The standard mapping, as opposed to a fallback. */ + readonly standard: boolean; +} + +export interface KindOverrides { + /** `capy deploy --discover`: what a next step tells the agent to run, up to the flags. */ + readonly command: string; + readonly schemaRef: SchemaRef; + /** Mixed into `plan_id`, so a Dokploy plan and a Vercel plan never share one. */ + readonly planDomain: string; + /** The Capy branch for a service, or `null` when the project has none that fits. */ + readonly chooseCapyBranch: (service: ServiceFacts, defaultBranch: string | null | undefined, projectBranches: readonly string[]) => BranchChoice | null; + /** The existing target that already is this service's target on this Capy branch. */ + readonly configured: (targets: readonly ExistingTarget[], service: ServiceFacts, capyBranch: string) => ExistingTarget | undefined; + /** An existing target that would clash with a new one for this service on this Capy branch. */ + readonly conflicting: (targets: readonly ExistingTarget[], service: ServiceFacts, capyBranch: string) => ExistingTarget | undefined; + /** A plan entry's identity: two entries with the same key are `DUPLICATE_ENTRY`. */ + readonly duplicateKey: (entry: PlanEntry) => string; + /** The `target` a proposal shows. */ + readonly proposalTarget: (service: ServiceFacts, branch: string, gitBranch: string, vars: readonly string[]) => Readonly>; + /** The first part of a target's name (made unique per deploy.json by the shared code). */ + readonly baseName: (entry: PlanEntry, service: ServiceFacts) => string; + /** The target `.capy/deploy.json` gets. */ + readonly targetFor: (args: { + readonly name: string; + readonly entry: PlanEntry; + readonly service: ServiceFacts; + readonly link: OrgRepoLink; + readonly knownVars: readonly string[]; + }) => TargetConfig; + /** One line a person reads, for an entry that will be written. */ + readonly summaryLine: (args: { readonly project_name: string; readonly service: ServiceFacts; readonly entry: PlanEntry }) => string; + /** The pull request. Names and ids only. */ + readonly pr: { + readonly title: string; + readonly intro: string; + readonly line: (args: { readonly target: TargetConfig; readonly path: string; readonly service: ServiceFacts }) => string; + }; +} diff --git a/src/commands/deployDiscover/plan.ts b/src/commands/deployDiscover/plan.ts index cebc4269..c2f3fcaa 100644 --- a/src/commands/deployDiscover/plan.ts +++ b/src/commands/deployDiscover/plan.ts @@ -108,9 +108,9 @@ const PLAN_DOMAIN = 'capy:deploy-dokploy:plan:v1'; * with. Opaque, and the same for the same plan however its entries or vars * were ordered. It carries no value: a plan has none. */ -export function planIdOf(plan: Plan, baseUrl: string): string { +export function planIdOf(plan: Plan, baseUrl: string, domain: string = PLAN_DOMAIN): string { const canonical = JSON.stringify({ base_url: baseUrl, version: plan.version, entries: plan.entries }); - return createHash('sha256').update(`${PLAN_DOMAIN}\u0000${canonical}`).digest('hex').slice(0, 16); + return createHash('sha256').update(`${domain}\u0000${canonical}`).digest('hex').slice(0, 16); } /** First four characters and an ellipsis, so a person can tell services apart at a glance. */ @@ -119,7 +119,7 @@ const shortId = (id: string): string => (id.length > 8 ? `${id.slice(0, 4)}…` /** One line a person reads. The agent relays these verbatim. */ export function summaryLine(args: { readonly project_name: string; - readonly kind: 'application' | 'compose'; + readonly kind: string; readonly entry: PlanEntry; }): string { const { project_name, kind, entry } = args; diff --git a/src/commands/deployDiscover/route.ts b/src/commands/deployDiscover/route.ts index af89472a..7b023acb 100644 --- a/src/commands/deployDiscover/route.ts +++ b/src/commands/deployDiscover/route.ts @@ -4,7 +4,7 @@ * * `--discover` is an option of `capy deploy`, not a sub-command: a sub-command named * `dokploy` would take `capy deploy dokploy` away from the target flow. Only the `dokploy` - * target has a discovery; any other target is refused with a code. + * and `vercel` targets have a discovery; any other target is refused with a code. */ import { ERROR_CODES } from '../../types/index'; import { conflictFlagsOrExit, type RawConflictFlags } from '../../sync/conflicts'; @@ -36,8 +36,12 @@ export async function routeDeployDiscover( if (!discovering) { return printRefusal(ERROR_CODES.INVALID_FORMAT, '--plan, --confirm and --base-url need --discover.'); // COPY-FLAG } - if (target !== 'dokploy') { - return printRefusal(ERROR_CODES.DISCOVER_UNSUPPORTED_TARGET, '--discover is only available for the dokploy target: capy deploy dokploy --discover.'); // COPY-FLAG + if (target !== 'dokploy' && target !== 'vercel') { + return printRefusal(ERROR_CODES.DISCOVER_UNSUPPORTED_TARGET, '--discover is only available for the dokploy and vercel targets: capy deploy dokploy --discover, capy deploy vercel --discover.'); // COPY-FLAG + } + // Vercel has no dashboard URL to name: `--base-url` is a Dokploy flag. + if (target === 'vercel' && flags.baseUrl !== undefined) { + return printRefusal(ERROR_CODES.INVALID_FORMAT, '--base-url is only for the dokploy target.'); // COPY-FLAG } // Bare `capy` runs first, before anything is discovered, planned or written (docs/conflict-resolution.md). This run // writes no env: its only writes are `.capy/deploy.json` PRs into the discovered repos, through the GitHub API, so no @@ -45,7 +49,10 @@ export async function routeDeployDiscover( // (exit 3, the payload alone on stdout) and answered by a rerun with `--resolve` + `--conflict`. conflictFlagsOrExit(conflictFlags, true); await gateFirst({ devMode, json: true, nonTty: true, conflictFlags, dryRun }); - const { deployDokployDiscoverCommand } = await import('./index'); + const { deployDokployDiscoverCommand, deployVercelDiscoverCommand } = await import('./index'); + if (target === 'vercel') { + return deployVercelDiscoverCommand({ dryRun, plan: flags.plan, confirm: flags.confirm }, devMode); + } return deployDokployDiscoverCommand( { dryRun, plan: flags.plan, confirm: flags.confirm, baseUrl: flags.baseUrl }, devMode, diff --git a/src/commands/deployDiscover/schema.ts b/src/commands/deployDiscover/schema.ts index 798bb44f..0c0b5689 100644 --- a/src/commands/deployDiscover/schema.ts +++ b/src/commands/deployDiscover/schema.ts @@ -41,3 +41,30 @@ export const DEPLOY_DOKPLOY_PLAN_SCHEMA = { }, }, } as const; + +/** Where the Vercel plan's schema is published (`capy deploy vercel --discover`). */ +export const VERCEL_PLAN_SCHEMA_REF = { command: 'capy help --json', pointer: '/schemas/deploy_vercel_plan' } as const; + +/** + * The plan `capy deploy vercel --discover --plan ` reads: the same shape as the Dokploy plan, with the + * service named by the `service_id` of a Vercel environment (project + environment) from the discovery facts, + * and `git_branch` the project's production branch (the deploy PR base). IDs and names only. + */ +export const DEPLOY_VERCEL_PLAN_SCHEMA = { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA, + title: 'capy deploy vercel --discover plan', // COPY-FLAG + properties: { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA.properties, + entries: { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA.properties.entries, + items: { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA.properties.entries.items, + properties: { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA.properties.entries.items.properties, + service_id: { type: 'string', minLength: 1, description: 'Vercel environment id from the discovery facts (a project plus one of its environments)' }, // COPY-FLAG + git_branch: { type: 'string', minLength: 1, description: 'git branch the Vercel project deploys from (the CI deploy PR base)' }, // COPY-FLAG + }, + }, + }, + }, +} as const; diff --git a/src/commands/deployDiscover/vercel.ts b/src/commands/deployDiscover/vercel.ts new file mode 100644 index 00000000..26d7dc08 --- /dev/null +++ b/src/commands/deployDiscover/vercel.ts @@ -0,0 +1,441 @@ +/** + * `capy deploy vercel --discover` (CAP-696): create Vercel deploy targets for + * the Vercel projects that match Capy projects, and push NO values. + * + * The same command as `capy deploy dokploy --discover`, with Vercel for the + * provider: it ALWAYS prints one JSON document on stdout, never prompts and + * never opens a browser; three modes (facts, `--plan f --dry-run`, + * `--plan f --confirm ID`); ONE pull request per repo. The shared modules do + * the matching, the plan checks, the GitHub reads and the PR writing; this + * file is what is Vercel's own: reading the projects, the service facts, and + * the `KindOverrides` that turn a plan entry into a Vercel target. + * + * A "service" is one ENVIRONMENT of one Vercel project that holds variables: + * production, preview, each custom environment, and `development` only when + * it is the project's only environment with variables. A preview whose + * records are scoped to a git branch is its own service per branch. The + * deploy target is one per service. + * + * Names and ids only. The env list is asked for WITHOUT `decrypt` and the + * client drops any `value` the response carries (`dropValues`); the per-id + * value route is never called. The token is read from the org system store + * (`_TARGET_VERCEL_TOKEN`, which may be a reference to + * `_CONNECTOR_VERCEL_TOKEN`) or `$VERCEL_TOKEN`, and never asked for. + * + * Vercel REST endpoints (docs checked 2026-11-02, see `deploy/vercelApi.ts`): + * GET /v2/teams teams[].{id,slug,name}, pagination.next (`until`) + * GET /v10/projects?teamId= projects[].{id,name,rootDirectory,link}, pagination.next (`from`) + * GET /v9/projects/{id}/custom-environments environments[].{id,slug} + * GET /v10/projects/{id}/env?teamId= envs[].{key,type,target,gitBranch,customEnvironmentIds} + */ +import { posix } from 'node:path'; +import type { TargetConfig } from '../../deploy/adapter'; +import { + VercelApiError, + VercelClient, + VercelCustomEnvironment, + VercelEnvRecord, + VercelProject, + VercelTeam, + mapVercelApiError, +} from '../../deploy/vercelApi'; +import { GetVercelStoreSecret, describeVercelTokenProblem, resolveVercelTargetToken, vercelTargetKeyHelp } from '../../deploy/vercelTargetApi'; +import { ERROR_CODES } from '../../types/index'; +import { runPool } from '../../utils/pool'; +import { GITHUB_HOST } from '../secretsSet'; +import { + CommonIo, + DeployDiscoverResult, + Step, + World, + discoveryGithubFacts, + done, + finishPlan, + loadCapySide, + readPlan, + refuse, + stop, +} from './command'; +import { DiscoveryInput, PlanEntry, buildDeployDiscovery } from './discovery'; +import { targetBaseName } from './evaluate'; +import { + ExistingTarget, + RepoOut, + ServiceFacts, + VercelServiceFacts, + relPathOf, + uniqueSorted, + vercelServiceId, +} from './facts'; +import type { BranchChoice, KindOverrides } from './kind'; +import { planIdOf } from './plan'; +import { DEPLOY_VERCEL_PLAN_SCHEMA, VERCEL_PLAN_SCHEMA_REF } from './schema'; + +export { DEPLOY_VERCEL_PLAN_SCHEMA }; + +// ── Seams ──────────────────────────────────────────────────────────────────── + +export interface DeployVercelDiscoverOpts { + /** The program-level `--dry-run`. */ + readonly dryRun?: boolean; + /** `--plan ` */ + readonly plan?: string; + /** `--confirm ` */ + readonly confirm?: string; +} + +/** Everything external, injected: tests pass fakes; `realVercelDiscoverIo` builds the real one. */ +export interface DeployVercelDiscoverIo extends CommonIo { + /** The org system store read for the Vercel token. Never asked to prompt. */ + readonly getSecret: GetVercelStoreSecret; + /** The READ-ONLY Vercel client for a token. Built with `dropValues: true`. */ + readonly vercel: (token: string) => VercelClient; +} + +// ── Facts: Vercel projects → services ──────────────────────────────────────── + +/** One project, everything read for it. Names and ids only: the env records carry no value. */ +export interface VercelProjectScan { + readonly project: VercelProject; + readonly team: { readonly id: string; readonly slug?: string } | null; + readonly customEnvironments: readonly VercelCustomEnvironment[]; + readonly envs: readonly VercelEnvRecord[]; +} + +const namesOf = (records: readonly VercelEnvRecord[]): readonly string[] => uniqueSorted(records.map((r) => r.key)); + +/** The repo a project is connected to, when it is a GitHub one. */ +function repoOfProject(project: VercelProject): RepoOut | null { + return project.link?.kind === 'github' ? { host: GITHUB_HOST, owner: project.link.org, name: project.link.repo } : null; +} + +interface EnvSlice { + readonly environment: string; + readonly customEnvironmentId: string | null; + readonly gitBranchScope: string | null; + readonly names: readonly string[]; +} + +/** The environments of a project that hold variables. `system` records (Vercel's own) are never a variable of the project. */ +function slicesOf(scan: VercelProjectScan): readonly EnvSlice[] { + const records = scan.envs.filter((r) => r.type !== 'system'); + const standard = (target: string, keep: (r: VercelEnvRecord) => boolean = () => true): readonly VercelEnvRecord[] => + records.filter((r) => r.targets.includes(target) && keep(r)); + const production = standard('production'); + const previewAll = standard('preview', (r) => !r.gitBranch); + const previewScoped = standard('preview', (r) => !!r.gitBranch); + const scopes = uniqueSorted(previewScoped.map((r) => r.gitBranch as string)); + const custom = scan.customEnvironments.map((c) => ({ c, records: records.filter((r) => r.customEnvironmentIds.includes(c.id)) })); + const slices: readonly EnvSlice[] = [ + { environment: 'production', customEnvironmentId: null, gitBranchScope: null, names: namesOf(production) }, + { environment: 'preview', customEnvironmentId: null, gitBranchScope: null, names: namesOf(previewAll) }, + ...scopes.map((scope): EnvSlice => ({ + environment: 'preview', + customEnvironmentId: null, + gitBranchScope: scope, + names: namesOf(previewScoped.filter((r) => r.gitBranch === scope)), + })), + ...custom.map(({ c, records: mine }): EnvSlice => ({ environment: c.slug, customEnvironmentId: c.id, gitBranchScope: null, names: namesOf(mine) })), + ].filter((slice) => slice.names.length > 0); + // `development` is a target only when it is the one environment with variables. + const development: readonly EnvSlice[] = + slices.length === 0 + ? [{ environment: 'development', customEnvironmentId: null, gitBranchScope: null, names: namesOf(standard('development')) }].filter((s) => s.names.length > 0) + : []; + return [...slices, ...development]; +} + +/** The services of one project: one per environment that has variables. */ +export function vercelServicesOf(scan: VercelProjectScan): readonly VercelServiceFacts[] { + const { project } = scan; + const repo = repoOfProject(project); + const root = project.rootDirectory; + return slicesOf(scan).map( + (slice): VercelServiceFacts => ({ + service_id: vercelServiceId(project.id, slice.environment, slice.gitBranchScope), + kind: 'vercel', + name: project.name, + vercel_project_id: project.id, + team: scan.team, + environment: slice.environment, + custom_environment_id: slice.customEnvironmentId, + git_branch_scope: slice.gitBranchScope, + root_directory: root, + repo, + branch: project.link?.productionBranch ?? null, + path: repo === null ? null : relPathOf(root), + env_var_names: slice.names, + }), + ); +} + +// ── Reading Vercel ─────────────────────────────────────────────────────────── + +const SCAN_CONCURRENCY = 3; + +type Scope = { readonly team: { readonly id: string; readonly slug?: string } | null; readonly projects: readonly VercelProject[] }; + +/** Every team the token sees, then the token's own scope; a project listed twice counts once (the first listing wins). */ +async function readScopes(client: VercelClient): Promise { + const teams = await client.listTeams(); + const scopes = [...teams.map((t: VercelTeam) => ({ id: t.id as string | undefined, team: { id: t.id, slug: t.slug } as Scope['team'] })), { id: undefined, team: null as Scope['team'] }]; + const read = await scopes.reduce>(async (accPromise, scope) => { + const acc = await accPromise; + const seen = new Set(acc.flatMap((s) => s.projects.map((p) => p.id))); + const projects = (await client.listProjects(scope.id)).filter((p) => !seen.has(p.id)); + return [...acc, { team: scope.team, projects }]; + }, Promise.resolve([])); + // The token's own scope is only worth a mention when it holds a project no team listed. + return read.filter((s) => s.team !== null || s.projects.length > 0); +} + +interface ScanOutcome { + readonly scan?: VercelProjectScan; + readonly truncated: boolean; + readonly error?: unknown; +} + +async function scanProject(client: VercelClient, project: VercelProject, team: Scope['team']): Promise { + try { + const teamId = team?.id; + const customEnvironments = await client.listCustomEnvironments(project.id, teamId); + const list = await client.listEnvs(project.id, teamId); + return { scan: { project, team, customEnvironments, envs: list.envs }, truncated: list.truncated }; + } catch (error) { + return { truncated: false, error }; + } +} + +function vercelFailure(err: unknown): DeployDiscoverResult { + const mapped = mapVercelApiError(err); + // A rejected token says where to get another. + const help = err instanceof VercelApiError && err.code === 'unauthorized' ? vercelTargetKeyHelp() : {}; + return refuse(mapped.code, mapped.message, help); +} + +interface VercelWorld { + readonly services: readonly ServiceFacts[]; + readonly teams: ReadonlyArray<{ readonly id: string; readonly slug: string }>; + readonly notices: ReadonlyArray<{ readonly code: string; readonly detail?: string }>; +} + +/** + * The Vercel side. `only` (plan mode): the project ids the plan names, the only + * ones whose environments are read; every project is listed (the list carries + * the git link and root directory). + */ +async function loadVercel( + io: Pick, + client: VercelClient, + only?: ReadonlySet, +): Promise> { + io.progress('Reading Vercel projects…'); // COPY-FLAG + const listed = await readScopes(client).then( + (value) => ({ ok: true as const, value }), + (err: unknown) => ({ ok: false as const, err }), + ); + if (!listed.ok) return stop(vercelFailure(listed.err)); + const scopes = listed.value; + const withScope = scopes.flatMap((scope) => scope.projects.map((project) => ({ project, team: scope.team }))); + const wanted = withScope.filter(({ project }) => (only === undefined ? true : only.has(project.id))); + const readable = wanted.filter(({ project }) => repoOfProject(project) !== null); + const notLinked = wanted.filter(({ project }) => repoOfProject(project) === null); + const pooled = await runPool({ + items: readable, + limit: SCAN_CONCURRENCY, + run: async ({ project, team }) => ({ result: await scanProject(client, project, team) }), + onChange: (p) => { + if (p.done === p.total || p.done % 10 === 0) io.progress(`Read ${p.done} of ${p.total} Vercel projects`); // COPY-FLAG + }, + }); + const failed = pooled.results.find((r) => r.error !== undefined); + if (failed !== undefined) return stop(vercelFailure(failed.error)); + const scans = pooled.results.flatMap((r) => (r.scan === undefined ? [] : [r.scan])); + return done({ + services: scans.flatMap(vercelServicesOf), + teams: scopes.flatMap((s) => (s.team === null ? [] : [{ id: s.team.id, slug: s.team.slug as string }])), + notices: [ + ...notLinked.map(({ project }) => ({ code: ERROR_CODES.VERCEL_PROJECT_NOT_GITHUB, detail: project.name })), + ...pooled.results.flatMap((r) => (r.truncated && r.scan !== undefined ? [{ code: ERROR_CODES.VERCEL_ENV_LIST_TRUNCATED, detail: r.scan.project.name }] : [])), + ], + }); +} + +// ── The provider's overrides ───────────────────────────────────────────────── + +const asVercel = (service: ServiceFacts): VercelServiceFacts => service as VercelServiceFacts; + +/** The Capy branch named like the environment (a scoped preview: like its git branch first); production falls back to its production branch. */ +function chooseVercelBranch( + service: ServiceFacts, + _defaultBranch: string | null | undefined, + projectBranches: readonly string[], +): BranchChoice | null { + const v = asVercel(service); + const candidates: ReadonlyArray = [ + ...(v.git_branch_scope === null ? [] : [{ branch: v.git_branch_scope, standard: true }]), + { branch: v.environment, standard: v.git_branch_scope === null }, + ...(v.environment === 'production' && v.branch !== null ? [{ branch: v.branch, standard: false }] : []), + ]; + return candidates.find((c) => projectBranches.includes(c.branch)) ?? null; +} + +/** An existing Vercel target for this project + environment (+ preview git branch). The Capy branch it ships is not part of the question. */ +function vercelConfigured(targets: readonly ExistingTarget[], service: ServiceFacts): ExistingTarget | undefined { + return targets.find((t) => t.kind === 'vercel' && t.service_id === service.service_id); +} + +/** A project's folder relative to the folder the deploy.json lives in. */ +function projectDirFor(linkPath: string, servicePath: string | null): string { + const from = relPathOf(linkPath); + const to = servicePath === null ? '.' : relPathOf(servicePath); + return posix.relative(from === '.' ? '' : from, to === '.' ? '' : to) || '.'; +} + +const envLabel = (v: VercelServiceFacts): string => (v.git_branch_scope === null ? v.environment : `${v.environment}@${v.git_branch_scope}`); + +function vercelTargetOf(args: { + readonly name: string; + readonly entry: PlanEntry; + readonly service: ServiceFacts; + readonly link: { readonly path: string }; + readonly knownVars: readonly string[]; +}): TargetConfig { + const { name, entry, knownVars } = args; + const v = asVercel(args.service); + return { + name, + kind: 'vercel', + branch: entry.branch, + vars: [...uniqueSorted(entry.vars)], + knownVars: [...knownVars], + options: { + projectDir: projectDirFor(args.link.path, v.path), + vercelEnv: v.environment, + projectId: v.vercel_project_id, + ...(v.team === null ? {} : { teamId: v.team.id }), + ...(v.custom_environment_id === null ? {} : { customEnvironmentId: v.custom_environment_id }), + ...(v.git_branch_scope === null ? {} : { gitBranch: v.git_branch_scope }), + }, + mode: 'ci', + gitBaseBranch: entry.git_branch, + }; +} + +// COPY-FLAG: new user-facing strings below (summary line, PR title/body), minimal and neutral. +export const VERCEL_KIND: KindOverrides = { + command: 'capy deploy vercel --discover', + schemaRef: VERCEL_PLAN_SCHEMA_REF, + planDomain: 'capy:deploy-vercel:plan:v1', + chooseCapyBranch: chooseVercelBranch, + configured: (targets, service) => vercelConfigured(targets, service), + // Several Vercel projects (a monorepo) may ship one Capy branch: a target for another project is not a clash. + conflicting: () => undefined, + duplicateKey: (e) => [e.project_id, e.branch, e.service_id].join('\u0000'), + proposalTarget: (service, branch, gitBranch, vars) => { + const v = asVercel(service); + return { + kind: 'vercel', + projectId: v.vercel_project_id, + ...(v.team === null ? {} : { teamId: v.team.id }), + vercelEnv: v.environment, + ...(v.custom_environment_id === null ? {} : { customEnvironmentId: v.custom_environment_id }), + ...(v.git_branch_scope === null ? {} : { gitBranch: v.git_branch_scope }), + branch, + gitBaseBranch: gitBranch, + mode: 'ci', + vars, + }; + }, + baseName: (entry) => targetBaseName(entry.branch, 'vercel'), + targetFor: vercelTargetOf, + summaryLine: ({ project_name, service, entry }) => { + const v = asVercel(service); + return `${project_name} · ${entry.branch} → Vercel ${v.name} ${envLabel(v)} (${entry.git_branch}), ${entry.vars.length} vars`; + }, + pr: { + title: 'chore(capy): add Vercel deploy targets', + intro: 'Adds Vercel deploy targets to `.capy/deploy.json`.', + line: ({ target, path, service }) => { + const v = asVercel(service); + return `- \`${target.name}\` in \`${path}\`: Capy branch \`${target.branch}\`, Vercel project \`${v.vercel_project_id}\` (${envLabel(v)}), git branch \`${target.gitBaseBranch ?? ''}\``; + }, + }, +}; + +// ── The command ────────────────────────────────────────────────────────────── + +/** The token, never asked for: the system store (`_TARGET_VERCEL_TOKEN`, or the connector token it refers to), then `$VERCEL_TOKEN`. */ +async function vercelClient(io: DeployVercelDiscoverIo, orgId: string): Promise> { + const resolved = await resolveVercelTargetToken({ + env: io.env, + // Never a prompt: a missing token is a coded refusal that says where to get it. + interactive: false, + orgId, + devMode: io.devMode, + getSecret: io.getSecret, + }); + if (!resolved.ok) { + const { reason, hint } = describeVercelTokenProblem(resolved.code); + return stop(refuse(resolved.code, `${reason}.`, { hint, ...vercelTargetKeyHelp() })); + } + return done(io.vercel(resolved.value)); +} + +async function runDiscovery(io: DeployVercelDiscoverIo): Promise { + const ctx = await io.context(); + if (!ctx.ok) return refuse(ctx.code, ctx.message); + const client = await vercelClient(io, ctx.orgId); + if (!client.ok) return client.result; + const vercel = await loadVercel(io, client.value); + if (!vercel.ok) return vercel.result; + const capy = await loadCapySide(io, ctx.client, ctx.orgId); + if (!capy.ok) return capy.result; + const world: World = { orgId: ctx.orgId, services: vercel.value.services, ...capy.value }; + const github = await discoveryGithubFacts(io, world); + const input: DiscoveryInput = { + baseUrl: '', + header: { teams: vercel.value.teams }, + services: world.services, + projects: world.projects, + defaultBranches: github.defaultBranches, + existingTargets: github.existingTargets, + notices: [...vercel.value.notices, ...github.notices], + }; + return { exitCode: 0, body: { ok: true, ...buildDeployDiscovery(input, VERCEL_KIND) } }; +} + +/** The project ids a plan names: a service id is `:`. */ +const projectIdsOfPlan = (entries: readonly PlanEntry[]): ReadonlySet => + new Set(entries.map((e) => e.service_id.slice(0, Math.max(0, e.service_id.indexOf(':'))))); + +async function runPlan(opts: DeployVercelDiscoverOpts, io: DeployVercelDiscoverIo): Promise { + const parsed = readPlan(opts, io); + if (!parsed.ok) return parsed.result; + const { entries, plan } = parsed.value; + const confirming = opts.confirm !== undefined && opts.dryRun !== true; + + const ctx = await io.context(); + if (!ctx.ok) return refuse(ctx.code, ctx.message); + const planId = planIdOf(plan, '', VERCEL_KIND.planDomain); + // A plan that moved since it was confirmed: nothing more is looked up, nothing is done. + if (confirming && opts.confirm !== planId) { + return refuse(ERROR_CODES.PLAN_CHANGED, 'The plan is not the one that was confirmed. Nothing was changed.', { plan_id: planId, plan }); // COPY-FLAG + } + const client = await vercelClient(io, ctx.orgId); + if (!client.ok) return client.result; + const vercel = await loadVercel(io, client.value, projectIdsOfPlan(entries)); + if (!vercel.ok) return vercel.result; + const capy = await loadCapySide(io, ctx.client, ctx.orgId); + if (!capy.ok) return capy.result; + const world: World = { orgId: ctx.orgId, services: vercel.value.services, ...capy.value }; + return finishPlan({ opts, io, parsed: parsed.value, planId, world, scope: '', extras: {}, kind: VERCEL_KIND }); +} + +/** Runs the command and returns what it prints. Never prints, never exits, never prompts. */ +export async function runDeployVercelDiscover(opts: DeployVercelDiscoverOpts, io: DeployVercelDiscoverIo): Promise { + if (opts.confirm !== undefined && opts.plan === undefined) { + return refuse(ERROR_CODES.PLAN_REQUIRED, '--confirm needs --plan .'); // COPY-FLAG + } + return opts.plan === undefined ? runDiscovery(io) : runPlan(opts, io); +} diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index acaa0606..088268b3 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -11,7 +11,7 @@ */ import type { Argument, Command, Option } from 'commander'; import { ERROR_CODES } from '../types/index'; -import { DEPLOY_DOKPLOY_PLAN_SCHEMA } from '../commands/deployDiscover/schema'; +import { DEPLOY_DOKPLOY_PLAN_SCHEMA, DEPLOY_VERCEL_PLAN_SCHEMA } from '../commands/deployDiscover/schema'; export interface CliArgumentDoc { name: string; @@ -66,7 +66,7 @@ export interface CliHelpDoc { name: string; version: string; commands: CliCommandDoc[]; - /** JSON Schemas of the files commands read, by name (`deploy_dokploy_plan`). */ + /** JSON Schemas of the files commands read, by name (`deploy_dokploy_plan`, `deploy_vercel_plan`). */ schemas: Record; errorCodes: string[]; conventions: { @@ -124,9 +124,14 @@ export const DRY_RUN_COMMANDS: ReadonlySet = new Set([ /** The JSON Schemas published in `capy help --json`. */ export const HELP_SCHEMAS: Readonly> = { deploy_dokploy_plan: DEPLOY_DOKPLOY_PLAN_SCHEMA, + deploy_vercel_plan: DEPLOY_VERCEL_PLAN_SCHEMA, }; -/** Modes that are flags of a command rather than sub-commands, by command path. */ +/** + * Modes that are flags of a command rather than sub-commands, by command path. + * `capy deploy vercel --discover` is the same mode with another target: it is not listed + * separately, and its plan schema is published under `schemas.deploy_vercel_plan`. + */ export const COMMAND_MODES: Readonly> = { deploy: [ { diff --git a/src/deploy/vercelApi.ts b/src/deploy/vercelApi.ts index 336de352..499a1184 100644 --- a/src/deploy/vercelApi.ts +++ b/src/deploy/vercelApi.ts @@ -193,6 +193,12 @@ export interface VercelClientOptions { maxRetries?: number; /** A requested wait longer than this is refused rather than slept. Default 60 000 ms. */ maxWaitMs?: number; + /** + * Never keep an env record's `value`: the list response may carry one for a + * plain record, and a caller that only needs NAMES (`capy deploy vercel + * --discover`) must not hold it, even in memory. + */ + dropValues?: boolean; } export interface VercelEnvList { @@ -225,6 +231,13 @@ const nonEmptyString = (v: unknown): string | undefined => (typeof v === 'string const numberOrNull = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null); +/** A pagination cursor: a finite number (a timestamp) or a non-empty string (a continuation token), as text. */ +const cursorOf = (v: unknown): string | null => { + const n = numberOrNull(v); + if (n !== null) return String(n); + return typeof v === 'string' && v !== '' ? v : null; +}; + function parseJson(text: string): unknown { try { return JSON.parse(text); @@ -331,9 +344,10 @@ export function createVercelClient(token: string, options: VercelClientOptions = // A page with nothing new ends the walk: a server that ignores the // continuation parameter would otherwise be asked for page 1 forever. const fresh = projects.filter((p) => !seen.includes(p.id)); - const next = Array.isArray(body) ? null : numberOrNull(asRecord(asRecord(body)?.pagination)?.next); + // The docs call `from` a "timestamp or continuation token" and give `next` no type: take either. + const next = Array.isArray(body) ? null : cursorOf(asRecord(asRecord(body)?.pagination)?.next); if (next === null || fresh.length === 0 || page >= MAX_PAGES) return fresh; - return [...fresh, ...(await collect(String(next), [...seen, ...fresh.map((p) => p.id)], page + 1))]; + return [...fresh, ...(await collect(next, [...seen, ...fresh.map((p) => p.id)], page + 1))]; }; return collect(undefined, [], 1); }, @@ -360,7 +374,7 @@ export function createVercelClient(token: string, options: VercelClientOptions = async listEnvs(projectId, teamId) { const body = asRecord(await get(`/v10/projects/${enc(projectId)}/env`, { teamId })); const envs = (Array.isArray(body?.envs) ? body.envs : []).flatMap((e) => { - const parsed = parseEnvRecord(e); + const parsed = parseEnvRecord(e, options.dropValues === true); return parsed ? [parsed] : []; }); const next = numberOrNull(asRecord(body?.pagination)?.next); @@ -409,7 +423,7 @@ function parseTargets(raw: unknown): readonly string[] { return Array.isArray(raw) ? raw.filter((t): t is string => typeof t === 'string') : []; } -function parseEnvRecord(raw: unknown): VercelEnvRecord | null { +function parseEnvRecord(raw: unknown, dropValue: boolean = false): VercelEnvRecord | null { const rec = asRecord(raw); const id = nonEmptyString(rec?.id); const key = nonEmptyString(rec?.key); @@ -427,7 +441,7 @@ function parseEnvRecord(raw: unknown): VercelEnvRecord | null { : [], ...(gitBranch ? { gitBranch } : {}), ...(configurationId ? { configurationId } : {}), - ...(typeof rec.value === 'string' ? { value: rec.value } : {}), + ...(!dropValue && typeof rec.value === 'string' ? { value: rec.value } : {}), }; } diff --git a/src/index-dev.ts b/src/index-dev.ts index 339a1b25..7aff3ecb 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -345,9 +345,9 @@ const deploy = program .option('--scope ', 'gh-actions: "repo" or "env"') .option('--env-name ', 'gh-actions: env name when --scope env') // COPY-FLAG: the option descriptions of `deploy --discover` are minimal and neutral. - .option('--discover', 'dokploy: find the services that match Capy projects and print JSON (never prompts)') - .option('--plan ', 'dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`)') - .option('--confirm ', 'dokploy --discover: write the plan that --dry-run printed (one PR per repo)') + .option('--discover', 'dokploy/vercel: find the services that match Capy projects and print JSON (never prompts)') + .option('--plan ', 'dokploy/vercel --discover: the plan file to check (see schemas.deploy_dokploy_plan and schemas.deploy_vercel_plan in `capy help --json`)') + .option('--confirm ', 'dokploy/vercel --discover: write the plan that --dry-run printed (one PR per repo)') .option('--base-url ', 'dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL)') .addOption(resolveOption()) .addOption(conflictOption()) diff --git a/src/index.ts b/src/index.ts index 2066ae5c..a00c5b27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -382,9 +382,9 @@ const deploy = program .option('--no-deploy', 'write and verify the target, but skip the platform deploy/redeploy (target mode)') .option('--json', 'describe the route (unanswered stops + any known branch problem) as JSON instead of travelling it') // COPY-FLAG: the option descriptions of `deploy --discover` are minimal and neutral. - .option('--discover', 'dokploy: find the services that match Capy projects and print JSON (never prompts)') - .option('--plan ', 'dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`)') - .option('--confirm ', 'dokploy --discover: write the plan that --dry-run printed (one PR per repo)') + .option('--discover', 'dokploy/vercel: find the services that match Capy projects and print JSON (never prompts)') + .option('--plan ', 'dokploy/vercel --discover: the plan file to check (see schemas.deploy_dokploy_plan and schemas.deploy_vercel_plan in `capy help --json`)') + .option('--confirm ', 'dokploy/vercel --discover: write the plan that --dry-run printed (one PR per repo)') .option('--base-url ', 'dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL)') .addOption(resolveOption()) .addOption(conflictOption()) @@ -1059,7 +1059,6 @@ program .option('--project ', 'vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision)') .option('--team ', 'vercel import/discover: Vercel team slug or id (default: the token\'s only team)') .option('--json', 'emit machine-readable JSON instead of the human UI (import connectors)') - .option('--targets', 'Also write a Vercel deploy target for each imported environment') // COPY-FLAG .option( '--dry-run', 'dokploy import/discover: preview the plan only — resolve settings + read Dokploy, write/push nothing', @@ -1114,7 +1113,6 @@ program environment: options.environment, project: options.project, team: options.team, - targets: options.targets === true, conflictFlags: rawConflictFlagsOf(command), }); }); diff --git a/src/types/index.ts b/src/types/index.ts index 78e63c93..28521919 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -778,6 +778,11 @@ export const ERROR_CODES = { VERCEL_SPLIT_NOT_RESTORED: 'VERCEL_SPLIT_NOT_RESTORED', /** `capy deploy`'s setup was given something that is neither a Vercel dashboard link for a project nor a project name. */ VERCEL_URL_INVALID: 'VERCEL_URL_INVALID', + // `capy deploy vercel --discover` + /** A Vercel project has no GitHub link, so no repo can be matched; it is skipped (a notice). */ + VERCEL_PROJECT_NOT_GITHUB: 'VERCEL_PROJECT_NOT_GITHUB', + /** Vercel said a project's env list has another page that this client cannot ask for; the names read may be incomplete (a notice). */ + VERCEL_ENV_LIST_TRUNCATED: 'VERCEL_ENV_LIST_TRUNCATED', // --- Piped values: ` | capy edit NAME`, ` | capy add NAME` --- /** * `capy edit` with no name and no terminal. There is nothing to draw the diff --git a/tests/commands/connectVercel.test.ts b/tests/commands/connectVercel.test.ts index 6dffed2a..e1dd30bb 100644 --- a/tests/commands/connectVercel.test.ts +++ b/tests/commands/connectVercel.test.ts @@ -281,7 +281,7 @@ describe('vercel import — conflicts, --var, --overwrite, --dry-run', () => { const out = okOutcome(await connector.import!(ctxWith({ localPlaintext: { ENC_ALL: 'different-local' } }), { ...BASE_OPTS, dryRun: true })); expect(out.wouldAsk).toEqual(['ENC_ALL']); expect(out.unchangedEntries).toEqual([]); - expect(out.deployTargetSaved).toBe(false); + expect(out).not.toHaveProperty('deployTargetSaved'); expect(confirm.mock.calls.length).toBe(0); }); @@ -358,238 +358,44 @@ describe('vercel import — keep.lock connector metadata', () => { }); }); -// ── deploy-target offer ───────────────────────────────────────────────────── +// ── connect writes no deploy target ────────────────────────────────────────── -describe('vercel import — deploy-target offer (interactive only, like Dokploy)', () => { +describe('vercel import — "connect is for retrieval, target is for deploy"', () => { const interactiveDeps = (confirm: () => Promise) => ({ confirm, selectVars: async (c: readonly string[]) => c }); - const importAt = (dir: string, branch: string, over: Partial = {}, deps = interactiveDeps(async () => true)) => - withTTY(() => makeConnector(DATA, { ...deps, cwd: dir }).connector.import!(ctxWith({ branch }), { ...BASE_OPTS, nonTty: false, ...over })); - - test('production, interactive + yes: saves vercel-production with the imported names, CI mode, the project\'s production branch, no gitBranch', async () => { - await withTempDir('capy-vercel-target', async (dir) => { - const out = okOutcome(await importAt(dir, 'production')); - expect(out.deployTargetSaved).toBe(true); - const targets = listTargets(dir); - expect(targets.length).toBe(1); - expect(targets[0]).toEqual({ - name: 'vercel-production', - kind: 'vercel', - branch: 'production', - vars: ['PLAIN_PROD', 'ENC_ALL'], - mode: 'ci', - gitBaseBranch: 'main', - options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, - }); - expect('gitBranch' in (targets[0].options as object)).toBe(false); - }); - }); - - test('preview: no gitBaseBranch; custom environment: customEnvironmentId is recorded', async () => { - await withTempDir('capy-vercel-target-preview', async (dir) => { - okOutcome(await importAt(dir, 'preview')); - okOutcome(await importAt(dir, 'staging')); - const byName = Object.fromEntries(listTargets(dir).map((t) => [t.name, t])); - expect(byName['vercel-preview'].gitBaseBranch).toBeUndefined(); - expect(byName['vercel-preview'].vars).toEqual(['ENC_ALL', 'PREV_ALL']); - expect(byName['vercel-staging'].options).toEqual({ projectDir: '.', vercelEnv: 'staging', projectId: 'prj_web', teamId: 'team_acme', customEnvironmentId: 'env_c1' }); - }); - }); - test('interactive + no: nothing saved', async () => { - await withTempDir('capy-vercel-target-no', async (dir) => { - const out = okOutcome(await importAt(dir, 'production', {}, interactiveDeps(async () => false))); - expect(out.deployTargetSaved).toBe(false); - expect(listTargets(dir)).toEqual([]); - }); - }); - - test('non-interactive, --json and --dry-run: never asked, never saved', async () => { - await withTempDir('capy-vercel-target-quiet', async (dir) => { + test('an interactive import never offers a deploy target, and writes no .capy/deploy.json', async () => { + await withTempDir('capy-vercel-no-target', async (dir) => { const confirm = mock(async () => true); - const { connector } = makeConnector(DATA, { confirm, cwd: dir }); - okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); - okOutcome(await withTTY(() => connector.import!(ctxWith(), { ...BASE_OPTS, nonTty: false, json: true }))); - okOutcome(await withTTY(() => connector.import!(ctxWith(), { ...BASE_OPTS, nonTty: false, dryRun: true }))); - expect(confirm.mock.calls.length).toBe(0); + const out = okOutcome( + await withTTY(() => makeConnector(DATA, { ...interactiveDeps(confirm), cwd: dir }).connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, nonTty: false })), + ); + expect(out.imported.length).toBeGreaterThan(0); + expect(confirm).not.toHaveBeenCalled(); + expect(out).not.toHaveProperty('deployTargetSaved'); + expect(out).not.toHaveProperty('deployTarget'); + expect(out).not.toHaveProperty('deployTargetToWrite'); + expect(existsSync(join(dir, '.capy', 'deploy.json'))).toBe(false); expect(listTargets(dir)).toEqual([]); }); }); - test('already has a target for this project + environment: not offered again', async () => { - await withTempDir('capy-vercel-target-existing', async (dir) => { - upsertTarget(dir, { name: 'mine', kind: 'vercel', branch: 'production', vars: [], options: { projectId: 'prj_web', vercelEnv: 'production' } }); - const confirm = mock(async () => true); - const out = okOutcome(await importAt(dir, 'production', {}, interactiveDeps(confirm))); - expect(out.deployTargetSaved).toBe(false); - expect(confirm.mock.calls.length).toBe(0); - }); - }); - - test('nothing imported: never offered', async () => { - await withTempDir('capy-vercel-target-empty', async (dir) => { - const confirm = mock(async () => true); - const empty = { ...DATA, envs: { prj_web: [] } }; - const out = okOutcome(await withTTY(() => makeConnector(empty, { ...interactiveDeps(confirm), cwd: dir }).connector.import!(ctxWith(), { ...BASE_OPTS, nonTty: false }))); - expect(out.imported).toEqual([]); - expect(confirm.mock.calls.length).toBe(0); + test('--json and --dry-run report no target either', async () => { + await withTempDir('capy-vercel-no-target-json', async (dir) => { + const json = okOutcome(await makeConnector(DATA, { cwd: dir }).connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, json: true })); + const dry = okOutcome(await makeConnector(DATA, { cwd: dir }).connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, dryRun: true })); + [json, dry].forEach((out) => { + expect(out).not.toHaveProperty('deployTargetSaved'); + expect(out).not.toHaveProperty('deployTarget'); + }); + expect(existsSync(join(dir, '.capy', 'deploy.json'))).toBe(false); }); }); -}); - -// ── --targets: write the deploy target without asking ────────────────────── - -describe('vercel import --targets — the deploy target is written without a question', () => { - const PRODUCTION_TARGET = { - name: 'vercel-production', - kind: 'vercel', - branch: 'production', - vars: ['PLAIN_PROD', 'ENC_ALL'], - mode: 'ci', - gitBaseBranch: 'main', - options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, - }; - - const KEY = 'vercel-targets-project-key-0123456789'; - const realCtx = (dir: string, branch: string): ResolvedContext => - ({ - pm: { readSyncState: () => null }, - fileManager: new FileManager(dir), - serviceClient: { pushSecrets: () => { throw new Error('push failed'); } }, - orgId: 'org_1', - projectId: 'p', - branch, - userId: 'u', - projectKey: KEY, - keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }, - localPlaintext: {}, - }) as unknown as ResolvedContext; - - const importTo = (dir: string, branch: string, over: Partial = {}, extra: Partial[0]> = {}) => - captureConsole(() => - new ConnectCommand(false)['executeImport'](makeConnector(DATA, { cwd: dir, ...extra }).connector, 'vercel', realCtx(dir, branch), { ...BASE_OPTS, noPush: true, targets: true, ...over }), - ); - - test('non-interactive and --json: the target is written (no question), identical to what the interactive offer saves, and reported by folder and name', async () => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - withTempDir('capy-vercel-targets', async (dir) => { - const confirm = mock(async () => true); - const { stdout, stderr } = await importTo(dir, 'production', { json: true }, { confirm }); - expect(stdout.length).toBe(1); - expect(JSON.parse(stdout[0])).toMatchObject({ - ok: true, - dryRun: false, - deployTargets: [{ folder: '.', name: 'vercel-production', environment: 'production', written: true }], - }); - expect(confirm.mock.calls.length).toBe(0); - expect(listTargets(dir)).toEqual([PRODUCTION_TARGET]); - - // the same shape the interactive offer writes (a second directory, via the offer) - await withTempDir('capy-vercel-offer', async (offerDir) => { - await withTTY(() => - makeConnector(DATA, { confirm: async () => true, selectVars: async (c: readonly string[]) => c, cwd: offerDir }).connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, nonTty: false }), - ); - expect(listTargets(offerDir)).toEqual(listTargets(dir)); - }); - ALL_SENTINELS.concat('tok_fake_value').forEach((v) => expect([...stdout, ...stderr].join('\n') + readFileSync(join(dir, '.env'), 'utf-8')).not.toContain(v)); - }), - ), - ); - }); - - test('human output says what was written; preview has no gitBaseBranch and a custom environment records its ID', async () => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - withTempDir('capy-vercel-targets-envs', async (dir) => { - const human = await importTo(dir, 'preview'); - expect(human.stdout.join('\n')).toContain('Wrote deploy target vercel-preview.'); - await importTo(dir, 'staging'); - const byName = Object.fromEntries(listTargets(dir).map((t) => [t.name, t])); - expect(byName['vercel-preview'].gitBaseBranch).toBeUndefined(); - expect(byName['vercel-staging'].options).toEqual({ projectDir: '.', vercelEnv: 'staging', projectId: 'prj_web', teamId: 'team_acme', customEnvironmentId: 'env_c1' }); - }), - ), - ); - }); - - test('--dry-run lists the target it would write and writes nothing (no .capy/deploy.json)', async () => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - withTempDir('capy-vercel-targets-dry', async (dir) => { - const json = await importTo(dir, 'production', { dryRun: true, json: true }); - expect(JSON.parse(json.stdout[0])).toMatchObject({ - dryRun: true, - deployTargets: [{ folder: '.', name: 'vercel-production', environment: 'production', written: false }], - }); - const human = await importTo(dir, 'production', { dryRun: true }); - expect(human.stdout.join('\n')).toContain('Would write deploy target vercel-production.'); - expect(existsSync(join(dir, '.capy', 'deploy.json'))).toBe(false); - expect(listTargets(dir)).toEqual([]); - }), - ), - ); - }); - test('it also works with --overwrite --yes', async () => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - withTempDir('capy-vercel-targets-overwrite', async (dir) => { - await importTo(dir, 'production', { overwrite: true, yes: true, json: true }); - expect(listTargets(dir).map((t) => t.name)).toEqual(['vercel-production']); - }), - ), - ); - }); - - test('a project + environment that already has a target gets no second one; nothing imported means no target', async () => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - withTempDir('capy-vercel-targets-existing', async (dir) => { - upsertTarget(dir, { name: 'mine', kind: 'vercel', branch: 'production', vars: [], options: { projectId: 'prj_web', vercelEnv: 'production' } }); - const again = await importTo(dir, 'production', { json: true }); - expect(JSON.parse(again.stdout[0]).deployTargets).toBeUndefined(); - expect(listTargets(dir).map((t) => t.name)).toEqual(['mine']); - }), - ), - ); - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - withTempDir('capy-vercel-targets-empty', async (dir) => { - const empty = { ...DATA, envs: { prj_web: [] } }; - await captureConsole(() => - new ConnectCommand(false)['executeImport'](makeConnector(empty, { cwd: dir }).connector, 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, noPush: true, targets: true, json: true }), - ); - expect(listTargets(dir)).toEqual([]); - }), - ), - ); - }); - - test('without --targets nothing is written non-interactively, and no deployTargets key appears', async () => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - withTempDir('capy-vercel-no-targets', async (dir) => { - const out = await importTo(dir, 'production', { targets: false, json: true }); - expect(JSON.parse(out.stdout[0]).deployTargets).toBeUndefined(); - expect(listTargets(dir)).toEqual([]); - }), - ), - ); - }); - - test('the target is saved only AFTER the import landed: a failed write leaves no target behind', async () => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - withTempDir('capy-vercel-targets-failed', async (dir) => { - const failing = await captureConsole(() => - // push is on and the fake service refuses it - new ConnectCommand(false)['executeImport'](makeConnector(DATA, { cwd: dir }).connector, 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, noPush: false, targets: true, json: true }).catch((err: Error) => err.message), - ); - expect(typeof failing.result).toBe('string'); - expect(listTargets(dir)).toEqual([]); - }), - ), - ); + test('--targets is not a flag of connect any more (the deploy side owns targets: `capy deploy vercel --discover`)', () => { + expect(listProviders().map((p) => p.name)).toContain('vercel'); + const src = readFileSync(join(import.meta.dir, '../../src/commands/connectors/vercel.ts'), 'utf-8'); + expect(src).not.toContain('upsertTarget'); + expect(src).not.toContain('deployTargetToWrite'); }); }); diff --git a/tests/commands/connectVercelDiscovery.test.ts b/tests/commands/connectVercelDiscovery.test.ts index a1c79f2f..b5bfc6ea 100644 --- a/tests/commands/connectVercelDiscovery.test.ts +++ b/tests/commands/connectVercelDiscovery.test.ts @@ -533,99 +533,7 @@ describe('connector.discover — a real run, end to end', () => { ); }, 30_000); - test('--targets --yes: each environment that imports gets its vercel- target (no prompt, non-interactive), and `.capy/deploy.json` is IN the local discovery commit — never pushed', async () => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - inRepo(async (root) => { - const { ctx, mainBranch, mainHead } = await prepareRealRun(root, 'org_v_targets'); - const confirm = mock(async () => true); - const run = await captureConsole(() => connectorFor(root, e2eData(), { confirm }).connector.discover!(ctx, discoverOpts({ yes: true, targets: true }))); - const outcome = run.result; - expect(outcome.ok).toBe(true); - if (!outcome.ok) return; - - // the targets, written into the folder's own `.capy/deploy.json`, in the shape the interactive offer writes - const byName = Object.fromEntries(listTargets(root).map((t) => [t.name, t])); - expect(Object.keys(byName).toSorted()).toEqual(['vercel-production', 'vercel-staging']); - expect(byName['vercel-production']).toEqual({ - name: 'vercel-production', - kind: 'vercel', - branch: 'production', - vars: ['A', 'SHARED'], - mode: 'ci', - gitBaseBranch: 'main', - options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, - }); - expect(byName['vercel-staging']).toEqual({ - name: 'vercel-staging', - kind: 'vercel', - branch: 'staging', - vars: ['B', 'SHARED'], - mode: 'ci', - options: { projectDir: '.', vercelEnv: 'staging', projectId: 'prj_web', teamId: 'team_acme', customEnvironmentId: 'c_stage' }, - }); - expect(confirm.mock.calls.length).toBe(0); - - // reported per environment (by name), never a value - const folder = outcome.applied?.[0]; - expect(folder?.ok && folder.environments.map((e) => [e.environmentName, e.outcome.deployTarget])).toEqual([ - ['staging', { name: 'vercel-staging', environment: 'staging', written: true }], - ['production', { name: 'vercel-production', environment: 'production', written: true }], - ]); - - // the local discovery commit carries `.capy/deploy.json` beside keep.lock — on its own branch, never pushed - const commit = outcome.commits?.[0]; - expect(commit).toMatchObject({ ok: true, dryRun: false }); - if (!commit?.ok || commit.dryRun) return; - expect([...commit.committedFiles].toSorted()).toEqual(['.capy/deploy.json', '.gitignore', 'keep.lock']); - // (`.gitignore` was already committed and the run left it unchanged, so git records only the two files that changed) - expect(git(root, ['show', '--name-only', '--format=', commit.branch]).split('\n').filter(Boolean).toSorted()).toEqual(['.capy/deploy.json', 'keep.lock']); - expect(JSON.parse(git(root, ['show', `${commit.branch}:.capy/deploy.json`])).targets['vercel-production'].kind).toBe('vercel'); - expect(git(root, ['rev-parse', mainBranch]).trim()).toBe(mainHead); - expect(git(root, ['status', '--porcelain', '--', '.capy/deploy.json']).trim()).toBe(''); - const printed = [...run.stdout, ...run.stderr].join('\n'); - [...ALL_SECRETS, 'tok_fake'].forEach((v) => expect(printed).not.toContain(v)); - }), - ), - ); - }, 30_000); - - test('--targets with a committed, clean `.capy/deploy.json`: the new targets are added beside the existing one and the file\'s change is committed; with an UNCOMMITTED one the commit is refused (never mixed in), though the files are still written', async () => { - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - inRepo(async (root) => { - const prior = { name: 'mine', kind: 'cf-worker', branch: 'production', vars: [], options: {} }; - const clean = await prepareRealRun(root, 'org_v_clean_deploy'); - upsertTarget(root, prior); - git(root, ['add', '.capy/deploy.json']); - git(root, ['commit', '-q', '-m', 'add deploy.json']); - const cleanRun = await connectorFor(root, e2eData()).connector.discover!(clean.ctx, discoverOpts({ yes: true, targets: true })); - expect(cleanRun.ok).toBe(true); - if (!cleanRun.ok) return; - const commit = cleanRun.commits?.[0]; - expect(commit).toMatchObject({ ok: true, dryRun: false }); - if (commit?.ok && !commit.dryRun) { - expect(Object.keys(JSON.parse(git(root, ['show', `${commit.branch}:.capy/deploy.json`])).targets).toSorted()).toEqual(['mine', 'vercel-production', 'vercel-staging']); - } - }), - ), - ); - await withIsolatedGlobalDir(() => - withExitCodeRestored(() => - inRepo(async (root) => { - const dirty = await prepareRealRun(root, 'org_v_dirty_deploy'); - upsertTarget(root, { name: 'mine', kind: 'cf-worker', branch: 'production', vars: [], options: {} }); - const dirtyRun = await connectorFor(root, e2eData()).connector.discover!(dirty.ctx, discoverOpts({ yes: true, targets: true })); - expect(dirtyRun.ok).toBe(true); - if (!dirtyRun.ok) return; - expect(dirtyRun.commits?.[0]).toMatchObject({ ok: false, code: 'DOKPLOY_COMMIT_WOULD_MIX' }); - expect(Object.keys(listTargets(root).reduce((acc, t) => ({ ...acc, [t.name]: t }), {})).toSorted()).toEqual(['mine', 'vercel-production', 'vercel-staging']); - }), - ), - ); - }, 30_000); - - test('without --targets a run writes no deploy.json and the commit holds keep.lock and .gitignore only (unchanged behavior)', async () => { + test('a run writes no deploy.json (connect writes no target) and the commit holds keep.lock and .gitignore only', async () => { await withIsolatedGlobalDir(() => withExitCodeRestored(() => inRepo(async (root) => { @@ -641,29 +549,7 @@ describe('connector.discover — a real run, end to end', () => { ); }, 30_000); - test('--targets --dry-run --json: ONE pure JSON object lists every target it would write, by folder and name; nothing is written; the previewed commit names `.capy/deploy.json`', async () => { - await withExitCodeRestored(() => - inRepo(async (root) => { - const { connector, fetchMock } = connectorFor(root, e2eData()); - const run = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true, targets: true }))); - expect(run.stdout.length).toBe(1); - const out = JSON.parse(run.stdout[0]); - expect(out.dryRun).toBe(true); - expect(out.deployTargets).toEqual([ - { folder: '.', branch: 'staging', name: 'vercel-staging', written: false }, - { folder: '.', branch: 'production', name: 'vercel-production', written: false }, - ]); - expect(out.commits[0]).toMatchObject({ ok: true, dryRun: true }); - expect(out.commits[0].wouldCommitFiles).toContain('.capy/deploy.json'); - expect(existsSync(join(root, '.capy', 'deploy.json'))).toBe(false); - expect(git(root, ['status', '--porcelain']).trim()).toBe(''); - expect(callsOf(fetchMock).every((c) => c.method === 'GET')).toBe(true); - [...ALL_SECRETS, 'tok_fake'].forEach((v) => expect([...run.stdout, ...run.stderr].join('\n')).not.toContain(v)); - }), - ); - }); - - test('--dry-run without --targets reports no targets and previews no deploy.json', async () => { + test('--dry-run reports no targets and previews no deploy.json', async () => { await withExitCodeRestored(() => inRepo(async (root) => { const { connector } = connectorFor(root, e2eData()); @@ -675,15 +561,4 @@ describe('connector.discover — a real run, end to end', () => { ); }); - test('--targets --dry-run skips a project + environment that already has a target, and numbers a name that is taken', async () => { - await withExitCodeRestored(() => - inRepo(async (root) => { - upsertTarget(root, { name: 'mine', kind: 'vercel', branch: 'production', vars: [], options: { projectId: 'prj_web', vercelEnv: 'production' } }); - upsertTarget(root, { name: 'vercel-staging', kind: 'cf-worker', branch: 'x', vars: [], options: {} }); - const { connector } = connectorFor(root, e2eData()); - const run = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true, targets: true }))); - expect(JSON.parse(run.stdout[0]).deployTargets).toEqual([{ folder: '.', branch: 'staging', name: 'vercel-staging-2', written: false }]); - }), - ); - }); }); diff --git a/tests/commands/connectVercelFlags.test.ts b/tests/commands/connectVercelFlags.test.ts index c753a01f..f97c96bb 100644 --- a/tests/commands/connectVercelFlags.test.ts +++ b/tests/commands/connectVercelFlags.test.ts @@ -2,7 +2,7 @@ * The `capy connect vercel` flags, through the real CLI program: * - `--create-token` and `--token-expires-days` are gone — an unknown option * gets the CLI's normal refusal (exit 1), and neither is in `--help`; - * - `--targets` exists, with its approved help line. + * - `--targets` is gone too: connect writes no deploy target (`capy deploy vercel --discover` does). * * Spawns `src/index.ts` with an empty HOME, so nothing on this machine is read. */ @@ -35,12 +35,17 @@ describe('capy connect vercel — flags', () => { expect(r.stdout).toBe(''); }); - test('--help lists --targets with its help line, and neither removed flag', () => { + test('--targets is an unknown option too: connect imports values and writes no deploy target', () => { + const r = capy(['connect', 'vercel', '--targets']); + expect(r.status).toBe(1); + expect(r.stderr).toContain('--targets'); + expect(r.stdout).toBe(''); + }); + + test('--help lists none of the removed flags', () => { const r = capy(['connect', '--help']); expect(r.status).toBe(0); - expect(r.stdout).toContain('--targets'); - // the help wraps lines to the terminal width: compare with whitespace collapsed - expect(r.stdout.replace(/\s+/g, ' ')).toContain('--targets Also write a Vercel deploy target for each imported environment'); + expect(r.stdout).not.toContain('--targets'); expect(r.stdout).not.toContain('--create-token'); expect(r.stdout).not.toContain('--token-expires-days'); }); diff --git a/tests/commands/deployDiscoverVercelConfirm.test.ts b/tests/commands/deployDiscoverVercelConfirm.test.ts new file mode 100644 index 00000000..24c94ba2 --- /dev/null +++ b/tests/commands/deployDiscoverVercelConfirm.test.ts @@ -0,0 +1,298 @@ +/** + * `capy deploy vercel --discover --plan --confirm ` (CAP-696): writing a confirmed plan. + * + * The properties under test: a plan that moved since it was confirmed does NOTHING (`PLAN_CHANGED`, not + * even a request to Vercel); each Capy project's `.capy/deploy.json` target is exactly the shape the Vercel + * setup saves, merged into the repo's file without touching its other targets, in ONE PR per repo; a + * monorepo's root directory shows up in `projectDir`; target names are unique per file; a failure in one + * repo does not stop the others; running the plan again after the PRs merged opens nothing; and no value + * reaches GitHub or stdout. + * + * Fakes only (`tests/helpers/deployDiscoverVercelWorld.ts`): the "GitHub" records what it was asked to write. + */ +import { describe, test, expect } from 'bun:test'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { writtenBy } from '../helpers/deployDiscoverWorld'; +import { SENTINEL_CAPY } from '../helpers/deployDiscoverWorld'; +import { + ENTRY_CFG, + ENTRY_DUO_A, + ENTRY_DUO_B, + ENTRY_MADMIN, + ENTRY_MWEB, + ENTRY_PERSONAL, + ENTRY_SHOP, + ENTRY_SHOP_FEATURE, + ENTRY_SHOP_PREVIEW, + ENTRY_SHOP_STAGING, + EXISTING_MONO_DEPLOY, + GITHUB_REPOS, + SENTINEL_VERCEL, + VERCEL_TOKEN, + VercelWorld, + makeVercelWorld, + planFile, +} from '../helpers/deployDiscoverVercelWorld'; + +const PLAN = 'plan.json'; +const ENTRIES = [ENTRY_SHOP, ENTRY_SHOP_PREVIEW, ENTRY_SHOP_FEATURE, ENTRY_SHOP_STAGING, ENTRY_MWEB, ENTRY_MADMIN, ENTRY_DUO_A, ENTRY_DUO_B, ENTRY_PERSONAL]; + +async function run(text: string, opts: { dryRun?: boolean; confirm?: string }, world: VercelWorld = makeVercelWorld({ files: { [PLAN]: text } })) { + const result = await runDeployVercelDiscover( + { plan: PLAN, ...(opts.dryRun === undefined ? {} : { dryRun: opts.dryRun }), ...(opts.confirm === undefined ? {} : { confirm: opts.confirm }) }, + world.io, + ); + return { world, exitCode: result.exitCode, body: result.body as any }; +} + +/** The plan_id a dry run prints: what a human approved. */ +async function approvedId(text: string, world?: VercelWorld): Promise { + return (await run(text, { dryRun: true }, world)).body.plan_id; +} + +const nothingWritten = (world: VercelWorld): void => { + expect(world.github.createBlob).not.toHaveBeenCalled(); + expect(world.github.createTree).not.toHaveBeenCalled(); + expect(world.github.createCommit).not.toHaveBeenCalled(); + expect(world.github.createRef).not.toHaveBeenCalled(); + expect(world.github.createPull).not.toHaveBeenCalled(); +}; + +const fileOf = (world: VercelWorld, repo: string, path: string) => JSON.parse(writtenBy(world.github).commits.find((c) => c.repo === repo)?.files[path] as string); + +describe('confirm: a plan that moved is refused, and nothing is done', () => { + test('a wrong plan_id: PLAN_CHANGED, no request to Vercel, Capy or GitHub at all', async () => { + const world = makeVercelWorld({ files: { [PLAN]: planFile(ENTRIES) } }); + const { body, exitCode } = await run(planFile(ENTRIES), { confirm: 'not-the-id' }, world); + expect(exitCode).toBe(1); + expect(body.code).toBe('PLAN_CHANGED'); + expect(typeof body.plan_id).toBe('string'); + expect(world.fetchMock).not.toHaveBeenCalled(); + expect(world.getSecret).not.toHaveBeenCalled(); + expect(world.capy.getOrgRepos).not.toHaveBeenCalled(); + expect(world.github.getDefaultBranches).not.toHaveBeenCalled(); + nothingWritten(world); + }); + + test('a plan edited after the dry run (one variable dropped): the old plan_id no longer matches', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const edited = planFile([...ENTRIES.slice(0, 4), { ...ENTRY_MWEB, vars: ['WEB_KEY'] }, ...ENTRIES.slice(5)].map((e) => (e === ENTRY_SHOP ? { ...e, vars: ['DB_URL'] } : e))); + const world = makeVercelWorld({ files: { [PLAN]: edited } }); + const { body } = await run(edited, { confirm: approved }, world); + expect(body.code).toBe('PLAN_CHANGED'); + nothingWritten(world); + }); + + test('the same plan in another order still matches: ordering is not a change', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { body } = await run(planFile([...ENTRIES].reverse()), { confirm: approved }); + expect(body.ok).toBe(true); + }); +}); + +describe('confirm: deploy.json is written, one PR per repo', () => { + test('one PR per repo; a monorepo\'s several deploy.json paths go in ONE commit; the base is the default branch', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world, body, exitCode } = await run(planFile(ENTRIES), { confirm: approved }); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + expect(body.plan_id).toBe(approved); + expect(body.failed).toEqual([]); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/duo', 'acme/mono', 'acme/personal', 'acme/shop']); + expect(body.prs.find((p: any) => p.repo === 'acme/mono')).toMatchObject({ base: 'main', paths: ['apps/admin/.capy/deploy.json', 'apps/web/.capy/deploy.json'] }); + expect(body.prs.find((p: any) => p.repo === 'acme/personal')).toMatchObject({ base: 'trunk' }); + + const written = writtenBy(world.github); + expect(world.github.createPull).toHaveBeenCalledTimes(4); + expect(world.github.createCommit).toHaveBeenCalledTimes(4); + expect(Object.keys(written.commits.find((c) => c.repo === 'acme/mono')?.files ?? {})).toEqual(['apps/admin/.capy/deploy.json', 'apps/web/.capy/deploy.json']); + expect(written.pulls.map((p) => [p.repo, p.base])).toEqual([['acme/duo', 'main'], ['acme/mono', 'main'], ['acme/personal', 'trunk'], ['acme/shop', 'main']]); + }); + + test('every environment of a project is a target of its own: production, preview, a scoped preview, a custom environment', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + const shop = fileOf(world, 'acme/shop', '.capy/deploy.json'); + expect(Object.keys(shop.targets)).toEqual(['vercel-feature-x', 'vercel-preview', 'vercel-production', 'vercel-staging']); + expect(shop.targets['vercel-production']).toEqual({ + name: 'vercel-production', + kind: 'vercel', + branch: 'production', + vars: ['API_KEY', 'DB_URL', 'STRIPE_KEY'], + knownVars: ['API_KEY', 'DB_URL', 'STRIPE_KEY'], + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_shop', teamId: 'team_1' }, + mode: 'ci', + gitBaseBranch: 'main', + }); + // Same key order as the Vercel setup's own target. + expect(Object.keys(shop.targets['vercel-production'])).toEqual(['name', 'kind', 'branch', 'vars', 'knownVars', 'options', 'mode', 'gitBaseBranch']); + expect(Object.keys(shop.targets['vercel-production'].options)).toEqual(['projectDir', 'vercelEnv', 'projectId', 'teamId']); + }); + + test('a preview that is not scoped has no gitBranch; a scoped one has it; a custom environment has its id; the Capy branch is each target\'s own', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + const shop = fileOf(world, 'acme/shop', '.capy/deploy.json').targets; + expect(shop['vercel-preview'].options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_shop', teamId: 'team_1' }); + expect(shop['vercel-feature-x'].options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_shop', teamId: 'team_1', gitBranch: 'feature-x' }); + expect(shop['vercel-feature-x'].branch).toBe('feature-x'); + expect(shop['vercel-staging'].options).toEqual({ projectDir: '.', vercelEnv: 'staging', projectId: 'prj_shop', teamId: 'team_1', customEnvironmentId: 'env_stg' }); + expect(shop['vercel-staging'].branch).toBe('staging'); + }); + + test('knownVars is the project\'s whole variable set on that branch; vars is what the plan picked', async () => { + const approved = await approvedId(planFile([{ ...ENTRY_MWEB, vars: ['WEB_KEY'] }])); + const { world } = await run(planFile([{ ...ENTRY_MWEB, vars: ['WEB_KEY'] }]), { confirm: approved }); + const web = fileOf(world, 'acme/mono', 'apps/web/.capy/deploy.json').targets['vercel-production']; + expect(web.vars).toEqual(['WEB_KEY']); + expect(web.knownVars).toEqual(['WEB_KEY', 'WEB_OTHER']); + }); + + test('a project in a monorepo: projectDir is its root directory relative to the folder the deploy.json lives in', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + // The Capy project is linked at apps/web, and so is the Vercel project's root directory: the same folder. + expect(fileOf(world, 'acme/mono', 'apps/web/.capy/deploy.json').targets['vercel-production'].options.projectDir).toBe('.'); + // One Capy project at the repo root, two Vercel projects in folders below it: each target names its folder. + const duo = fileOf(world, 'acme/duo', '.capy/deploy.json').targets; + expect(duo['vercel-production'].options).toMatchObject({ projectDir: 'apps/a', projectId: 'prj_a' }); + expect(duo['vercel-production-2'].options).toMatchObject({ projectDir: 'apps/b', projectId: 'prj_b' }); + }); + + test('a target name is unique in its deploy.json: a second Vercel project on the same Capy branch gets a numeric suffix', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { body } = await run(planFile(ENTRIES), { confirm: approved }); + expect(body.prs.find((p: any) => p.repo === 'acme/duo').targets).toEqual(['vercel-production', 'vercel-production-2']); + }); + + test('a personal-account project has no teamId in its target', async () => { + const approved = await approvedId(planFile([ENTRY_PERSONAL])); + const { world } = await run(planFile([ENTRY_PERSONAL]), { confirm: approved }); + const target = fileOf(world, 'acme/personal', '.capy/deploy.json').targets['vercel-production']; + expect(target.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_personal' }); + expect(target.gitBaseBranch).toBe('trunk'); + }); + + test('the file is MERGED: another target already in deploy.json is left exactly as it was', async () => { + const approved = await approvedId(planFile([ENTRY_MWEB])); + const { world } = await run(planFile([ENTRY_MWEB]), { confirm: approved }); + const web = fileOf(world, 'acme/mono', 'apps/web/.capy/deploy.json'); + expect(Object.keys(web.targets)).toEqual(['ssm-prod', 'vercel-production']); + expect(web.targets['ssm-prod']).toEqual(JSON.parse(EXISTING_MONO_DEPLOY).targets['ssm-prod']); + expect(web.version).toBe('1'); + }); + + test('a target name already in the file is never reused: the new one gets a numeric suffix', async () => { + const taken = JSON.stringify({ version: '1', targets: { 'vercel-production': { name: 'vercel-production', kind: 'vercel', branch: 'other', vars: [], options: { projectId: 'prj_elsewhere', vercelEnv: 'production' } } } }, null, 2); + const repos = { ...GITHUB_REPOS, 'acme/shop': { ...GITHUB_REPOS['acme/shop'], files: { '.capy/deploy.json': taken } } }; + const text = planFile([ENTRY_SHOP]); + const world = makeVercelWorld({ files: { [PLAN]: text }, repos }); + const approved = await approvedId(text, world); + const { body } = await run(text, { confirm: approved }, world); + expect(body.prs[0].targets).toEqual(['vercel-production-2']); + const written = fileOf(world, 'acme/shop', '.capy/deploy.json'); + expect(Object.keys(written.targets)).toEqual(['vercel-production', 'vercel-production-2']); + expect(written.targets['vercel-production'].options).toEqual({ projectId: 'prj_elsewhere', vercelEnv: 'production' }); + }); + + test('the PR says what it adds, by name and id only', async () => { + const approved = await approvedId(planFile([ENTRY_SHOP])); + const { world } = await run(planFile([ENTRY_SHOP]), { confirm: approved }); + const written = writtenBy(world.github); + expect(written.pulls[0].title).toBe('chore(capy): add Vercel deploy targets'); + expect(written.pulls[0].body).toContain('vercel-production'); + expect(written.pulls[0].body).toContain('prj_shop'); + expect(written.pulls[0].head).toBe('capy/vercel-targets-test-0000'); + }); + + test('nothing is recorded or pushed on the Capy side, and nothing is written to Vercel: every request is a read', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + expect(new Set(Object.keys(world.capy.client))).toEqual(new Set(['getOrgRepos', 'getSecretIndex'])); + expect(world.vercelCalls().every((c) => c.method === 'GET')).toBe(true); + }); +}); + +describe('confirm: repos fail independently', () => { + test('a failure in one repo is a coded result for that repo; the others still open their PR', async () => { + const repos = { ...GITHUB_REPOS, 'acme/duo': { ...GITHUB_REPOS['acme/duo'], failing: ['createRef' as const] } }; + const text = planFile(ENTRIES); + const world = makeVercelWorld({ files: { [PLAN]: text }, repos }); + const approved = await approvedId(text, world); + const { body, exitCode } = await run(text, { confirm: approved }, world); + expect(exitCode).toBe(1); + expect(body.ok).toBe(false); + expect(body.code).toBe('DISCOVER_PARTIAL'); + expect(body.failed).toEqual([{ repo: 'acme/duo', code: 'KEEP_PR_BRANCH_FAILED' }]); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/mono', 'acme/personal', 'acme/shop']); + }); +}); + +describe('confirm: idempotent', () => { + test('after the PRs merge, the same plan reports already_configured and opens nothing', async () => { + const text = planFile(ENTRIES); + const approved = await approvedId(text); + const first = await run(text, { confirm: approved }); + const written = writtenBy(first.world.github); + + // The merged world: each written deploy.json is now on the default branch. + const merged = Object.fromEntries( + written.commits.map((c) => [c.repo, { ...GITHUB_REPOS[c.repo], files: { ...(GITHUB_REPOS[c.repo].files ?? {}), ...c.files } }] as const), + ); + const world = makeVercelWorld({ files: { [PLAN]: text }, repos: { ...GITHUB_REPOS, ...merged } }); + const { body, exitCode } = await run(text, { confirm: approved }, world); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + expect(body.prs).toEqual([]); + expect(body.already_configured).toHaveLength(ENTRIES.length); + nothingWritten(world); + }); + + test('an environment that already has a target is skipped without a PR, even next to ones that are written', async () => { + const text = planFile([ENTRY_CFG, ENTRY_SHOP]); + const approved = await approvedId(text); + const { body, world } = await run(text, { confirm: approved }); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/shop']); + expect(body.already_configured).toEqual([{ project_id: ENTRY_CFG.project_id, branch: 'production', service_id: ENTRY_CFG.service_id, target_name: 'vercel-production' }]); + expect(world.github.createPull).toHaveBeenCalledTimes(1); + }); + + test('--dry-run with --confirm changes nothing: the dry run wins', async () => { + const world = makeVercelWorld({ files: { [PLAN]: planFile(ENTRIES) } }); + const approved = await approvedId(planFile(ENTRIES), world); + const { body } = await run(planFile(ENTRIES), { dryRun: true, confirm: approved }, world); + expect(body.dry_run).toBe(true); + nothingWritten(world); + }); +}); + +describe('confirm: no value is read, printed, written or sent', () => { + test('no sentinel and no token is in the output, the progress lines, any blob, the commit message or the PR', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world, body } = await run(planFile(ENTRIES), { confirm: approved }); + const written = writtenBy(world.github); + const everything = [ + JSON.stringify(body), + world.progress.mock.calls.map(([l]) => String(l)).join('\n'), + world.github.createBlob.mock.calls.map(([, content]) => String(content)).join('\n'), + written.commitMessages.join('\n'), + written.pulls.map((p) => `${p.title}\n${p.body}`).join('\n'), + world.github.createCommit.mock.calls.map(([, params]) => JSON.stringify(params)).join('\n'), + ].join('\n'); + expect(everything).not.toContain(SENTINEL_VERCEL); + expect(everything).not.toContain(SENTINEL_CAPY); + expect(everything).not.toContain(VERCEL_TOKEN); + // The check bites: names ARE written, so an empty capture cannot pass this. + expect(everything).toContain('STRIPE_KEY'); + expect(world.github.createBlob.mock.calls.length).toBeGreaterThan(0); + }); + + test('the env lists are read without decrypt and the per-id value route is never called, across a whole confirm run', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls.some((u) => u.includes('/env'))).toBe(true); + expect(urls.some((u) => u.includes('decrypt'))).toBe(false); + expect(urls.some((u) => /\/v1\/projects\//.test(u) || /\/env\/[^?]/.test(u))).toBe(false); + }); +}); diff --git a/tests/commands/deployDiscoverVercelFacts.test.ts b/tests/commands/deployDiscoverVercelFacts.test.ts new file mode 100644 index 00000000..a6f8697d --- /dev/null +++ b/tests/commands/deployDiscoverVercelFacts.test.ts @@ -0,0 +1,368 @@ +/** + * `capy deploy vercel --discover` (CAP-696): the facts document. + * + * The properties under test: a Vercel project becomes one service per ENVIRONMENT that holds variables + * (production, preview, a preview scoped to a git branch, each custom environment; `development` only when + * it is the only one); a project is matched to a Capy project by its GitHub link and root directory; the + * proposal's target is exactly the shape the Vercel setup saves; an existing target for the same project + + * environment is `already_configured`; the token is read from the system store, never asked for; and no + * value is ever read, asked for or printed. + * + * Fakes only (`tests/helpers/deployDiscoverVercelWorld.ts`). + */ +import { describe, test, expect, spyOn } from 'bun:test'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { routeDeployDiscover } from '../../src/commands/deployDiscover/route'; +import { + P_CONF, + P_DUO, + P_MADMIN, + P_MWEB, + P_SHOP, + SENTINEL_VERCEL, + SVC, + VERCEL_DATA, + VERCEL_TOKEN, + makeVercelWorld, +} from '../helpers/deployDiscoverVercelWorld'; +import { SENTINEL_CAPY } from '../helpers/deployDiscoverWorld'; + +async function facts(world = makeVercelWorld()) { + const result = await runDeployVercelDiscover({}, world.io); + return { world, exitCode: result.exitCode, body: result.body as any }; +} + +describe('facts: services are the environments that hold variables', () => { + test('one service per environment with variables: production, preview, a branch-scoped preview, a custom environment; development is left out when others exist', async () => { + const { body, exitCode } = await facts(); + expect(exitCode).toBe(0); + const ids = body.services.map((s: any) => s.service_id); + expect(ids).toEqual( + expect.arrayContaining([SVC.shopProd, SVC.shopPreview, SVC.shopPreviewFeature, SVC.shopStaging, SVC.mwebProd, SVC.madmProd, SVC.aProd, SVC.bProd, SVC.cfgProd, SVC.personalProd]), + ); + // prj_shop also has DB_URL on `development`, but it has other environments: no development service. + expect(ids).not.toContain('prj_shop:development'); + }); + + test('development is a service when it is the project\'s only environment with variables', async () => { + const { body } = await facts(); + expect(body.services.find((s: any) => s.service_id === SVC.devOnly)).toMatchObject({ environment: 'development', env_var_names: ['DEV_KEY'] }); + }); + + test('a service carries names only: sorted, de-duplicated, Vercel\'s own `system` variables left out, a sensitive one still named', async () => { + const { body } = await facts(); + const prod = body.services.find((s: any) => s.service_id === SVC.shopProd); + expect(prod.env_var_names).toEqual(['API_KEY', 'DB_URL', 'STRIPE_KEY']); + expect(prod.env_var_names).not.toContain('VERCEL_URL'); + }); + + test('the branch-scoped preview and the custom environment are their own services with their own names', async () => { + const { body } = await facts(); + expect(body.services.find((s: any) => s.service_id === SVC.shopPreviewFeature)).toMatchObject({ + environment: 'preview', + git_branch_scope: 'feature-x', + env_var_names: ['DB_URL'], + }); + expect(body.services.find((s: any) => s.service_id === SVC.shopPreview)).toMatchObject({ git_branch_scope: null, env_var_names: ['API_KEY', 'DB_URL'] }); + expect(body.services.find((s: any) => s.service_id === SVC.shopStaging)).toMatchObject({ + environment: 'staging', + custom_environment_id: 'env_stg', + env_var_names: ['DB_URL'], + }); + }); + + test('a service names its repo, production branch, team, and its folder relative to the repo root', async () => { + const { body } = await facts(); + expect(body.services.find((s: any) => s.service_id === SVC.mwebProd)).toMatchObject({ + kind: 'vercel', + name: 'mono-web', + vercel_project_id: 'prj_mweb', + team: { id: 'team_1', slug: 'acme-team' }, + root_directory: 'apps/web', + path: 'apps/web', + branch: 'main', + repo: { host: 'github.com', owner: 'acme', name: 'mono' }, + }); + expect(body.services.find((s: any) => s.service_id === SVC.shopProd)).toMatchObject({ root_directory: null, path: '.' }); + // A personal-account project has no team. + expect(body.services.find((s: any) => s.service_id === SVC.personalProd)).toMatchObject({ team: null, branch: 'trunk' }); + }); + + test('a project that is not connected to GitHub is skipped and reported by name; its environments are never read', async () => { + const { body, world } = await facts(); + expect(body.services.some((s: any) => s.vercel_project_id === 'prj_nolink')).toBe(false); + expect(body.notices).toContainEqual({ code: 'VERCEL_PROJECT_NOT_GITHUB', detail: 'nolink' }); + expect(world.vercelCalls().some((c) => c.url.includes('prj_nolink'))).toBe(false); + }); + + test('the teams the token sees are listed', async () => { + const { body } = await facts(); + expect(body.teams).toEqual([{ id: 'team_1', slug: 'acme-team' }]); + }); +}); + +describe('facts: matching a service to a Capy project', () => { + test('a root directory matches the Capy project linked at that folder of the repo', async () => { + const { body } = await facts(); + const web = body.proposals.find((p: any) => p.service_id === SVC.mwebProd); + const admin = body.proposals.find((p: any) => p.service_id === SVC.madmProd); + expect(web).toMatchObject({ project_id: P_MWEB, branch: 'production', git_branch: 'main' }); + expect(admin).toMatchObject({ project_id: P_MADMIN, branch: 'production' }); + expect(web.evidence).toMatchObject({ repo_match: true, path_match: true }); + expect(web.confidence).toEqual({ project: 'certain', branch: 'certain', vars: 'certain' }); + }); + + test('several Vercel projects (one per root directory) under ONE Capy project are two proposals, each needs_review, each naming the other', async () => { + const { body } = await facts(); + const a = body.proposals.find((p: any) => p.service_id === SVC.aProd); + const b = body.proposals.find((p: any) => p.service_id === SVC.bProd); + expect(a.project_id).toBe(P_DUO); + expect(b.project_id).toBe(P_DUO); + expect(a.competing_proposal_ids).toEqual([b.proposal_id]); + expect(b.competing_proposal_ids).toEqual([a.proposal_id]); + expect(a.confidence.project).toBe('needs_review'); + expect(a.evidence.path_match).toBe(false); + }); + + test('the Capy branch is the one named like the environment; a scoped preview uses its git branch', async () => { + const { body } = await facts(); + const byService = (id: string) => body.proposals.find((p: any) => p.service_id === id); + expect(byService(SVC.shopProd).branch).toBe('production'); + expect(byService(SVC.shopPreview).branch).toBe('preview'); + expect(byService(SVC.shopPreviewFeature).branch).toBe('feature-x'); + expect(byService(SVC.shopStaging).branch).toBe('staging'); + expect(byService(SVC.devOnly).branch).toBe('development'); + }); + + test('a service whose environment has no Capy branch is unresolved BRANCH_UNMAPPED, naming the branches the project has', async () => { + const projects = [{ id: P_SHOP, name: 'shop', repos: [{ owner: 'acme', name: 'shop', path: '.' }], branches: { production: ['DB_URL'] } }]; + const { body } = await facts(makeVercelWorld({ projects })); + const unmapped = body.unresolved.filter((u: any) => u.reason === 'BRANCH_UNMAPPED' && u.project_id === P_SHOP); + expect(unmapped.map((u: any) => u.service_id).sort()).toEqual([SVC.shopPreview, SVC.shopPreviewFeature, SVC.shopStaging].sort()); + expect(unmapped[0].candidates).toEqual([{ project_id: P_SHOP, branch: 'production' }]); + }); + + test('a repo no Capy project is linked to is unresolved NO_REPO_LINK', async () => { + const { body } = await facts(makeVercelWorld({ projects: [] })); + expect(body.proposals).toEqual([]); + expect(body.unresolved.some((u: any) => u.reason === 'NO_REPO_LINK' && u.service_id === SVC.shopProd)).toBe(true); + }); +}); + +describe('facts: the proposal\'s target is the shape the Vercel setup saves', () => { + test('production: project, team, environment, the production branch as the PR base, CI mode, the shared names', async () => { + const { body } = await facts(); + const p = body.proposals.find((x: any) => x.service_id === SVC.shopProd); + expect(p.target).toEqual({ + kind: 'vercel', + projectId: 'prj_shop', + teamId: 'team_1', + vercelEnv: 'production', + branch: 'production', + gitBaseBranch: 'main', + mode: 'ci', + vars: ['API_KEY', 'DB_URL', 'STRIPE_KEY'], + }); + expect(p.plan_entry).toEqual({ project_id: P_SHOP, branch: 'production', service_id: SVC.shopProd, git_branch: 'main', vars: ['API_KEY', 'DB_URL', 'STRIPE_KEY'] }); + }); + + test('custom environment adds customEnvironmentId; a scoped preview adds gitBranch; a personal project has no teamId; its PR base is its own production branch', async () => { + const { body } = await facts(); + const byService = (id: string) => body.proposals.find((p: any) => p.service_id === id).target; + expect(byService(SVC.shopStaging)).toMatchObject({ vercelEnv: 'staging', customEnvironmentId: 'env_stg' }); + expect(byService(SVC.shopPreviewFeature)).toMatchObject({ vercelEnv: 'preview', gitBranch: 'feature-x' }); + expect(byService(SVC.shopPreview)).not.toHaveProperty('gitBranch'); + expect(byService(SVC.personalProd)).not.toHaveProperty('teamId'); + expect(byService(SVC.personalProd)).toMatchObject({ gitBaseBranch: 'trunk' }); + }); + + test('only variables the Capy branch also holds are proposed; the rest are overlap evidence', async () => { + const { body } = await facts(); + const web = body.proposals.find((p: any) => p.service_id === SVC.mwebProd); + expect(web.target.vars).toEqual(['WEB_KEY', 'WEB_OTHER']); + expect(web.evidence.var_overlap).toEqual({ shared: 2, only_in_service: [], only_in_project: [] }); + }); +}); + +describe('facts: already configured', () => { + test('a deploy.json target with the same project and environment is already_configured and gets no proposal', async () => { + const { body } = await facts(); + expect(body.already_configured).toEqual([{ project_id: P_CONF, branch: 'production', service_id: SVC.cfgProd, target_name: 'vercel-production' }]); + expect(body.proposals.some((p: any) => p.service_id === SVC.cfgProd)).toBe(false); + }); + + test('another environment of the same project is not covered by it', async () => { + const existing = JSON.stringify({ + version: '1', + targets: { 'vercel-preview': { name: 'vercel-preview', kind: 'vercel', branch: 'preview', vars: [], options: { projectId: 'prj_shop', vercelEnv: 'preview', gitBranch: 'feature-x' } } }, + }); + const repos = { 'acme/shop': { defaultBranch: 'main', branches: ['main'], files: { '.capy/deploy.json': existing } } }; + const { body } = await facts(makeVercelWorld({ repos })); + // Only the branch-scoped preview is configured: the unscoped preview and production are not. + expect(body.already_configured.map((a: any) => a.service_id)).toEqual([SVC.shopPreviewFeature]); + expect(body.proposals.some((p: any) => p.service_id === SVC.shopPreview)).toBe(true); + }); + + test('a custom environment target is matched by its slug', async () => { + const existing = JSON.stringify({ + version: '1', + targets: { 'vercel-staging': { name: 'vercel-staging', kind: 'vercel', branch: 'staging', vars: [], options: { projectId: 'prj_shop', vercelEnv: 'staging', customEnvironmentId: 'env_stg' } } }, + }); + const repos = { 'acme/shop': { defaultBranch: 'main', branches: ['main'], files: { '.capy/deploy.json': existing } } }; + const { body } = await facts(makeVercelWorld({ repos })); + expect(body.already_configured.map((a: any) => a.service_id)).toEqual([SVC.shopStaging]); + }); +}); + +describe('facts: what is asked of Vercel', () => { + test('teams, then each team\'s projects page by page (`from` continues), then the token\'s own scope; team-scoped calls carry teamId, the personal ones none', async () => { + const { world } = await facts(); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls[0]).toBe('https://api.vercel.com/v2/teams?limit=100'); + expect(urls.filter((u) => u.includes('/v10/projects?'))).toEqual([ + 'https://api.vercel.com/v10/projects?limit=100&teamId=team_1', + 'https://api.vercel.com/v10/projects?limit=100&from=3&teamId=team_1', + 'https://api.vercel.com/v10/projects?limit=100&from=6&teamId=team_1', + 'https://api.vercel.com/v10/projects?limit=100', + ]); + // Every environment read of a team project says which team; the personal project's says none. + expect(urls.filter((u) => u.includes('prj_shop') || u.includes('prj_mweb')).every((u) => u.includes('teamId=team_1'))).toBe(true); + expect(urls.filter((u) => u.includes('prj_personal')).every((u) => !u.includes('teamId'))).toBe(true); + }); + + test('every request is a GET with the token as a bearer; the project, env and custom-environment routes are the documented ones', async () => { + const { world } = await facts(); + const calls = world.vercelCalls(); + expect(calls.every((c) => c.method === 'GET' && c.authorization === `Bearer ${VERCEL_TOKEN}`)).toBe(true); + const paths = new Set(calls.map((c) => new URL(c.url).pathname.replace(/prj_[a-z]+/, 'PRJ'))); + expect(paths).toEqual(new Set(['/v2/teams', '/v10/projects', '/v9/projects/PRJ/custom-environments', '/v10/projects/PRJ/env'])); + }); + + test('a project\'s variables are listed WITHOUT decrypt, and the per-id value route is never called', async () => { + const { world } = await facts(); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls.some((u) => u.includes('decrypt'))).toBe(false); + expect(urls.some((u) => /\/v1\/projects\//.test(u))).toBe(false); + expect(urls.some((u) => /\/env\/[^?]/.test(u))).toBe(false); + }); + + test('an env list Vercel says has another page is reported: the names read may be incomplete', async () => { + const data = { ...VERCEL_DATA, envListNext: 1700000000000 }; + const { body } = await facts(makeVercelWorld({ data })); + expect(body.notices).toContainEqual({ code: 'VERCEL_ENV_LIST_TRUNCATED', detail: 'shop-web' }); + }); + + test('GitHub is read for the repos some Vercel project tracks; Capy is asked ONCE for the links and ONCE for the name index', async () => { + const { world } = await facts(); + expect(world.capy.getOrgRepos).toHaveBeenCalledTimes(1); + expect(world.capy.getSecretIndex).toHaveBeenCalledTimes(1); + expect(world.github.getDefaultBranches).toHaveBeenCalledTimes(1); + }); + + test('the output carries the plan schema ref and the next step is a dry run of a vercel plan', async () => { + const { body } = await facts(); + expect(body.plan_schema_ref).toEqual({ command: 'capy help --json', pointer: '/schemas/deploy_vercel_plan' }); + expect(body.next_steps.at(-1)).toEqual({ run: 'capy deploy vercel --discover --plan --dry-run' }); + expect(body).not.toHaveProperty('base_url'); + }); +}); + +describe('facts: no value is read, asked for, printed or sent', () => { + test('neither the Vercel nor the Capy sentinel is in the output or the progress lines', async () => { + const { body, world } = await facts(); + const everything = [JSON.stringify(body), world.progress.mock.calls.map(([l]) => String(l)).join('\n')].join('\n'); + expect(everything).not.toContain(SENTINEL_VERCEL); + expect(everything).not.toContain(SENTINEL_CAPY); + expect(everything).not.toContain(VERCEL_TOKEN); + // The check bites: names ARE printed. + expect(everything).toContain('STRIPE_KEY'); + }); + + test('a sensitive record (no value to give) and an encrypted one are named the same way as a plain one', async () => { + const { body } = await facts(); + expect(body.services.find((s: any) => s.service_id === SVC.shopProd).env_var_names).toEqual(expect.arrayContaining(['STRIPE_KEY', 'API_KEY', 'DB_URL'])); + }); +}); + +describe('facts: the token', () => { + test('is read from the org system store, by the target key name, and never asked for', async () => { + const { world } = await facts(); + expect(world.getSecret).toHaveBeenCalledTimes(1); + const [name, opts] = world.getSecret.mock.calls[0] as [string, Record]; + expect(name).toBe('_TARGET_VERCEL_TOKEN'); + expect(opts).toMatchObject({ interactive: false, orgId: 'org_1' }); + }); + + test('missing everywhere: VERCEL_TOKEN_MISSING with where to get one (link, remedy), zero requests to Vercel, Capy or GitHub', async () => { + const world = makeVercelWorld({ token: null }); + const { body, exitCode } = await facts(world); + expect(exitCode).toBe(1); + expect(body).toMatchObject({ ok: false, code: 'VERCEL_TOKEN_MISSING' }); + expect(body.link.url).toContain('vercel.com'); + expect(body.remedy).toContain('_TARGET_VERCEL_TOKEN'); + expect(world.fetchMock).not.toHaveBeenCalled(); + expect(world.capy.getOrgRepos).not.toHaveBeenCalled(); + expect(world.github.getDefaultBranches).not.toHaveBeenCalled(); + }); + + test('a store that refuses with a code is that code, with the same help', async () => { + const world = makeVercelWorld({ io: { getSecret: (async () => Promise.reject(Object.assign(new Error('x'), { code: 'VERCEL_TARGET_KEY_MISSING' }))) as never } }); + const { body } = await facts(world); + expect(body).toMatchObject({ ok: false, code: 'VERCEL_TARGET_KEY_MISSING' }); + expect(body.link).toBeDefined(); + expect(body.remedy).toBeDefined(); + expect(world.fetchMock).not.toHaveBeenCalled(); + }); + + test('$VERCEL_TOKEN is used when the store has none', async () => { + const world = makeVercelWorld({ token: null, io: { env: { VERCEL_TOKEN } as never } }); + const { body, exitCode } = await facts(world); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + }); + + test('a token Vercel rejects (401): VERCEL_AUTH_FAILED with where to get another; the token is not in the output', async () => { + const world = makeVercelWorld({ acceptedToken: 'some-other-token' }); + const { body, exitCode } = await facts(world); + expect(exitCode).toBe(1); + expect(body).toMatchObject({ ok: false, code: 'VERCEL_AUTH_FAILED' }); + expect(body.link.url).toContain('vercel.com'); + expect(body.remedy).toContain('_TARGET_VERCEL_TOKEN'); + expect(JSON.stringify(body)).not.toContain(VERCEL_TOKEN); + expect(world.capy.getOrgRepos).not.toHaveBeenCalled(); + }); +}); + +describe('facts: routing from `capy deploy vercel --discover`', () => { + test('--base-url is a Dokploy flag: refused with a code', async () => { + const out = spyOn(console, 'log').mockImplementation((() => undefined) as never); + try { + const code = await routeDeployDiscover('vercel', { discover: true, baseUrl: 'https://x.example.com' }, false, false); + expect(code).toBe(1); + expect(JSON.parse(String(out.mock.calls[0][0])).code).toBe('INVALID_FORMAT'); + } finally { + out.mockRestore(); + } + }); +}); + +describe('facts: several teams', () => { + test('every team the token sees is read with its own teamId; a project is attributed to the team that lists it; a project listed twice counts once', async () => { + const data = { + ...VERCEL_DATA, + teams: [VERCEL_DATA.teams![0], { id: 'team_2', slug: 'other-team' }], + projects: { + ...VERCEL_DATA.projects, + team_2: [VERCEL_DATA.projects.team_1[0], { ...VERCEL_DATA.projects.personal[0], id: 'prj_t2', name: 'team-two-site' }], + }, + envs: { ...VERCEL_DATA.envs, prj_t2: VERCEL_DATA.envs.prj_personal }, + }; + const { body, world } = await facts(makeVercelWorld({ data })); + expect(body.teams.map((t: any) => t.slug)).toEqual(['acme-team', 'other-team']); + expect(body.services.filter((s: any) => s.vercel_project_id === 'prj_shop' && s.environment === 'production')).toHaveLength(1); + expect(body.services.find((s: any) => s.vercel_project_id === 'prj_t2').team).toEqual({ id: 'team_2', slug: 'other-team' }); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls.filter((u) => u.includes('/v10/projects?') && u.includes('teamId=team_2'))).toHaveLength(1); + expect(urls.filter((u) => u.includes('prj_t2')).every((u) => u.includes('teamId=team_2'))).toBe(true); + }); +}); diff --git a/tests/commands/deployDiscoverVercelPlan.test.ts b/tests/commands/deployDiscoverVercelPlan.test.ts new file mode 100644 index 00000000..88e4f212 --- /dev/null +++ b/tests/commands/deployDiscoverVercelPlan.test.ts @@ -0,0 +1,180 @@ +/** + * `capy deploy vercel --discover --plan --dry-run` (CAP-696): validating a plan, changing nothing. + * + * The properties under test: a plan that is valid prints the lines a person approves and the `plan_id` + * that ties `--confirm` to exactly this plan; every error is path-precise and coded; several Vercel + * projects may ship one Capy branch (a monorepo) but the same environment twice is a duplicate; only the + * projects a plan names have their environments read; nothing is written anywhere. + * + * Fakes only (`tests/helpers/deployDiscoverVercelWorld.ts`). + */ +import { describe, test, expect } from 'bun:test'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { + ENTRY_CFG, + ENTRY_DUO_A, + ENTRY_DUO_B, + ENTRY_MADMIN, + ENTRY_MWEB, + ENTRY_SHOP, + ENTRY_SHOP_FEATURE, + ENTRY_SHOP_PREVIEW, + ENTRY_SHOP_STAGING, + P_MWEB, + P_SHOP, + SENTINEL_VERCEL, + SVC, + VercelWorld, + makeVercelWorld, + planFile, +} from '../helpers/deployDiscoverVercelWorld'; + +const PLAN = 'plan.json'; + +async function dryRun(text: string, world: VercelWorld = makeVercelWorld({ files: { [PLAN]: text } })) { + const result = await runDeployVercelDiscover({ plan: PLAN, dryRun: true }, world.io); + return { world, exitCode: result.exitCode, body: result.body as any }; +} + +const nothingWritten = (world: VercelWorld): void => { + expect(world.github.createBlob).not.toHaveBeenCalled(); + expect(world.github.createTree).not.toHaveBeenCalled(); + expect(world.github.createCommit).not.toHaveBeenCalled(); + expect(world.github.createRef).not.toHaveBeenCalled(); + expect(world.github.createPull).not.toHaveBeenCalled(); +}; + +const ENTRIES = [ENTRY_SHOP, ENTRY_SHOP_PREVIEW, ENTRY_SHOP_FEATURE, ENTRY_SHOP_STAGING, ENTRY_MWEB, ENTRY_MADMIN, ENTRY_DUO_A, ENTRY_DUO_B]; + +describe('dry run: a valid plan', () => { + test('prints the plan, its plan_id, the lines to relay, the PRs it would open, and the confirm command; writes nothing', async () => { + const { body, exitCode, world } = await dryRun(planFile(ENTRIES)); + expect(exitCode).toBe(0); + expect(body).toMatchObject({ ok: true, dry_run: true }); + expect(body.plan_id).toMatch(/^[0-9a-f]{16}$/); + expect(body.plan.version).toBe(1); + expect(body.plan.entries).toHaveLength(8); + expect(body.summary_lines).toHaveLength(8); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/duo', 'acme/mono', 'acme/shop']); + expect(body.prs.find((p: any) => p.repo === 'acme/mono')).toMatchObject({ base: 'main', files: ['apps/admin/.capy/deploy.json', 'apps/web/.capy/deploy.json'] }); + expect(body.next_steps.at(-1)).toEqual({ run: `capy deploy vercel --discover --plan ${PLAN} --confirm ${body.plan_id}` }); + nothingWritten(world); + }); + + test('a summary line names the Capy project and branch, the Vercel project and environment, the PR base and the number of variables', async () => { + const { body } = await dryRun(planFile([ENTRY_SHOP_FEATURE, ENTRY_SHOP_STAGING])); + expect(body.summary_lines).toEqual([ + 'shop · feature-x → Vercel shop-web preview@feature-x (main), 1 vars', + 'shop · staging → Vercel shop-web staging (main), 1 vars', + ]); + }); + + test('the plan_id does not depend on the order of entries or of variables, and changes when an entry does', async () => { + const a = await dryRun(planFile(ENTRIES)); + const b = await dryRun(planFile([...ENTRIES].reverse().map((e) => ({ ...e, vars: [...e.vars].reverse() })))); + expect(b.body.plan_id).toBe(a.body.plan_id); + const c = await dryRun(planFile(ENTRIES.slice(1))); + expect(c.body.plan_id).not.toBe(a.body.plan_id); + }); + + test('two Vercel projects of one monorepo may ship the same Capy branch of the same Capy project', async () => { + const { body, exitCode } = await dryRun(planFile([ENTRY_DUO_A, ENTRY_DUO_B])); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + expect(body.prs).toHaveLength(1); + expect(body.prs[0].targets).toEqual(['vercel-production', 'vercel-production-2']); + }); + + test('an entry whose environment already has a target is reported already_configured and is not in the PR preview', async () => { + const { body } = await dryRun(planFile([ENTRY_CFG, ENTRY_SHOP])); + expect(body.already_configured).toEqual([{ project_id: ENTRY_CFG.project_id, branch: 'production', service_id: SVC.cfgProd, target_name: 'vercel-production' }]); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/shop']); + expect(body.summary_lines).toContain('configured · production: already configured (vercel-production), nothing to do'); + }); + + test('only the projects the plan names have their environments read', async () => { + const { world } = await dryRun(planFile([ENTRY_SHOP])); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls.filter((u) => u.includes('/env')).every((u) => u.includes('prj_shop'))).toBe(true); + expect(urls.some((u) => u.includes('prj_mweb'))).toBe(false); + }); + + test('no value anywhere in the output', async () => { + const { body } = await dryRun(planFile(ENTRIES)); + expect(JSON.stringify(body)).not.toContain(SENTINEL_VERCEL); + }); +}); + +describe('dry run: errors are path-precise and coded, and nothing is changed', () => { + test('an unknown service id', async () => { + const { body, exitCode, world } = await dryRun(planFile([{ ...ENTRY_SHOP, service_id: 'prj_shop:custom:nope' }])); + expect(exitCode).toBe(1); + expect(body.code).toBe('PLAN_INVALID'); + expect(body.errors).toContainEqual({ path: 'entries[0].service_id', code: 'SERVICE_NOT_FOUND' }); + nothingWritten(world); + }); + + test('the same environment twice is DUPLICATE_ENTRY, at the later entry', async () => { + const { body } = await dryRun(planFile([ENTRY_SHOP, ENTRY_SHOP])); + expect(body.errors).toEqual([{ path: 'entries[1]', code: 'DUPLICATE_ENTRY' }]); + }); + + test('a variable the Capy branch does not hold', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, vars: ['DB_URL', 'NOPE'] }])); + expect(body.errors).toEqual([{ path: 'entries[0].vars[1]', code: 'VAR_NOT_FOUND' }]); + }); + + test('a Capy branch the project does not have', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, branch: 'nope' }])); + expect(body.errors).toContainEqual({ path: 'entries[0].branch', code: 'BRANCH_NOT_FOUND' }); + }); + + test('a PR base the repo does not have', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, git_branch: 'nope' }])); + expect(body.errors).toEqual([{ path: 'entries[0].git_branch', code: 'BRANCH_NOT_FOUND' }]); + }); + + test('a Capy project that is not linked to the Vercel project\'s repo', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, project_id: P_MWEB, vars: ['WEB_KEY'], branch: 'production' }])); + expect(body.errors).toContainEqual({ path: 'entries[0].service_id', code: 'REPO_MISMATCH' }); + }); + + test('a project that does not exist (or the caller cannot see)', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, project_id: 'proj_nope' }])); + expect(body.errors).toContainEqual({ path: 'entries[0].project_id', code: 'PROJECT_NOT_FOUND' }); + }); + + test('a plan file that is not a plan: the shape errors come first, before anything is looked up', async () => { + const { body, world } = await dryRun(JSON.stringify({ version: 1, entries: [{ project_id: P_SHOP }] })); + expect(body.code).toBe('PLAN_INVALID'); + expect(body.errors.map((e: any) => e.path)).toEqual(expect.arrayContaining(['entries[0].branch', 'entries[0].service_id', 'entries[0].git_branch', 'entries[0].vars'])); + expect(world.fetchMock).not.toHaveBeenCalled(); + }); + + test('an unreadable plan file is PLAN_FILE_UNREADABLE', async () => { + const world = makeVercelWorld(); + const result = await runDeployVercelDiscover({ plan: 'missing.json', dryRun: true }, world.io); + expect((result.body as any).code).toBe('PLAN_FILE_UNREADABLE'); + }); + + test('--confirm without --plan is PLAN_REQUIRED', async () => { + const result = await runDeployVercelDiscover({ confirm: 'abc' }, makeVercelWorld().io); + expect((result.body as any).code).toBe('PLAN_REQUIRED'); + }); + + test('a real run without --confirm is refused with the id to confirm (exit 3), and writes nothing', async () => { + const world = makeVercelWorld({ files: { [PLAN]: planFile([ENTRY_SHOP]) } }); + const result = await runDeployVercelDiscover({ plan: PLAN }, world.io); + expect(result.exitCode).toBe(3); + expect((result.body as any).code).toBe('PLAN_CONFIRM_REQUIRED'); + expect((result.body as any).unanswered[0]).toMatchObject({ id: 'confirm', flag: '--confirm' }); + nothingWritten(world); + }); + + test('the token is checked before anything is read: missing, and nothing is requested', async () => { + const world = makeVercelWorld({ token: null, files: { [PLAN]: planFile([ENTRY_SHOP]) } }); + const { body } = await dryRun(planFile([ENTRY_SHOP]), world); + expect(body.code).toBe('VERCEL_TOKEN_MISSING'); + expect(world.fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/deploy/vercelApi.test.ts b/tests/deploy/vercelApi.test.ts index d0ffa490..54b1af24 100644 --- a/tests/deploy/vercelApi.test.ts +++ b/tests/deploy/vercelApi.test.ts @@ -146,6 +146,17 @@ describe('pagination', () => { expect(projects.map((p) => p.name)).toEqual(['a']); }); + test('projects: a continuation TOKEN (a string `next`) is followed as it is, not mistaken for the end', async () => { + const reply = (body: unknown) => ({ status: 200, ok: true, text: async () => JSON.stringify(body) }); + const fetchMock = mock() + .mockResolvedValueOnce(reply({ projects: [project('prj_a', 'a')], pagination: { count: 1, next: 'tok_abc' } })) + .mockResolvedValueOnce(reply({ projects: [project('prj_b', 'b')], pagination: { count: 1, next: null } })); + const projects = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike }).listProjects('team_1'); + expect(projects.map((p) => p.id)).toEqual(['prj_a', 'prj_b']); + expect(new URL(callsOf(fetchMock)[1].url).searchParams.get('from')).toBe('tok_abc'); + expect(new URL(callsOf(fetchMock)[1].url).searchParams.get('teamId')).toBe('team_1'); + }); + test('teams: follows pagination.next via `until`', async () => { const reply = (body: unknown) => ({ status: 200, ok: true, text: async () => JSON.stringify(body) }); const fetchMock = mock() @@ -467,3 +478,23 @@ describe('resolveVercelToken', () => { expect(vercelSecretsMayPrompt(false, false)).toBe(false); }); }); + +describe('dropValues — a client that only needs names never holds a value', () => { + const data = { + projects: {}, + envs: { prj_1: [{ id: 'e1', key: 'A', type: 'plain', target: ['production'], listValue: 'SECRET-LIST-VALUE' }] }, + }; + + test('by default the list keeps a plain record\'s value (the import needs it)', async () => { + const client = createVercelClient(TOKEN, { fetch: fakeVercelFetch(data) }); + expect((await client.listEnvs('prj_1')).envs[0].value).toBe('SECRET-LIST-VALUE'); + }); + + test('with dropValues no record carries a value, and the request does not ask for decrypt', async () => { + const fetchMock = mock(fakeVercelFetch(data)); + const list = await createVercelClient(TOKEN, { fetch: fetchMock, dropValues: true }).listEnvs('prj_1', 'team_1'); + expect(list.envs.map((e) => e.key)).toEqual(['A']); + expect(JSON.stringify(list)).not.toContain('SECRET-LIST-VALUE'); + expect(callsOf(fetchMock).map((c) => c.url)).toEqual(['https://api.vercel.com/v10/projects/prj_1/env?teamId=team_1']); + }); +}); diff --git a/tests/helpers/deployDiscoverVercelWorld.ts b/tests/helpers/deployDiscoverVercelWorld.ts new file mode 100644 index 00000000..2e143742 --- /dev/null +++ b/tests/helpers/deployDiscoverVercelWorld.ts @@ -0,0 +1,215 @@ +/** + * A fake world for `capy deploy vercel --discover` tests (CAP-696): a Vercel + * account (teams, projects, environments), the Capy repo links and secret NAME + * index, and GitHub. Everything is an in-memory fake; nothing touches the + * network, `gh` or a real service. The Capy and GitHub fakes are + * `tests/helpers/deployDiscoverWorld.ts`'s own. + * + * SENTINEL VALUES are planted in every Vercel env record (as the list's + * `value` and as the per-id decrypted value) and in every Capy index row. The + * command must never print, write or send any of them, and must never call the + * per-id value route or ask for `decrypt`. + */ +import { mock } from 'bun:test'; +import { createVercelClient } from '../../src/deploy/vercelApi'; +import type { DeployVercelDiscoverIo } from '../../src/commands/deployDiscover/vercel'; +import { FakeProject, FakeRepo, capyFake, githubFake } from './deployDiscoverWorld'; +import { FakeVercelData, RawEnv, callsOf, fakeVercelFetch, project } from './fakeVercel'; + +export const SENTINEL_VERCEL = 'SENTINEL-VERCEL-VALUE-5d0c8e17'; +export const VERCEL_TOKEN = 'vercel-token-for-this-test-only'; + +const rawEnv = (id: string, key: string, over: Partial = {}): RawEnv => ({ + id, + key, + type: 'plain', + target: ['production'], + listValue: SENTINEL_VERCEL, + ...over, +}); + +// ── Capy ───────────────────────────────────────────────────────────────────── + +export const P_SHOP = 'proj_shop'; +export const P_MWEB = 'proj_mweb'; +export const P_MADMIN = 'proj_madmin'; +export const P_DUO = 'proj_duo'; +export const P_CONF = 'proj_conf'; +export const P_PERSONAL = 'proj_personal'; +export const P_DEVONLY = 'proj_devonly'; + +export const PROJECTS: readonly FakeProject[] = [ + { + id: P_SHOP, + name: 'shop', + repos: [{ owner: 'acme', name: 'shop', path: '.' }], + branches: { + production: ['DB_URL', 'API_KEY', 'STRIPE_KEY'], + preview: ['DB_URL', 'API_KEY'], + staging: ['DB_URL'], + 'feature-x': ['DB_URL'], + }, + }, + { id: P_MWEB, name: 'mono-web', repos: [{ owner: 'acme', name: 'mono', path: 'apps/web' }], branches: { production: ['WEB_KEY', 'WEB_OTHER'] } }, + { id: P_MADMIN, name: 'mono-admin', repos: [{ owner: 'acme', name: 'mono', path: 'apps/admin' }], branches: { production: ['ADM_KEY'] } }, + { id: P_DUO, name: 'duo', repos: [{ owner: 'acme', name: 'duo', path: '.' }], branches: { production: ['DUO_KEY'] } }, + { id: P_CONF, name: 'configured', repos: [{ owner: 'acme', name: 'conf', path: '.' }], branches: { production: ['C1'] } }, + { id: P_PERSONAL, name: 'personal', repos: [{ owner: 'acme', name: 'personal', path: '.' }], branches: { production: ['PKEY'] } }, + { id: P_DEVONLY, name: 'devonly', repos: [{ owner: 'acme', name: 'devonly', path: '.' }], branches: { development: ['DEV_KEY'] } }, +]; + +// ── Vercel ─────────────────────────────────────────────────────────────────── + +export const TEAM = { id: 'team_1', slug: 'acme-team', name: 'Acme' }; + +const link = (repo: string, productionBranch: string | undefined = 'main') => ({ type: 'github', org: 'acme', repo, ...(productionBranch ? { productionBranch } : {}) }); + +export const VERCEL_DATA: FakeVercelData = { + teams: [TEAM], + pageSize: 3, + projects: { + team_1: [ + project('prj_shop', 'shop-web', { link: link('shop') }), + project('prj_mweb', 'mono-web', { rootDirectory: 'apps/web', link: link('mono') }), + project('prj_madm', 'mono-admin', { rootDirectory: 'apps/admin', link: link('mono') }), + project('prj_a', 'duo-a', { rootDirectory: 'apps/a', link: link('duo') }), + project('prj_b', 'duo-b', { rootDirectory: 'apps/b', link: link('duo') }), + project('prj_nolink', 'nolink', { link: null }), + project('prj_dev', 'dev-only', { link: link('devonly') }), + project('prj_cfg', 'configured', { link: link('conf') }), + ], + personal: [project('prj_personal', 'personal-site', { link: link('personal', 'trunk') })], + }, + custom: { prj_shop: [{ id: 'env_stg', slug: 'staging' }] }, + envs: { + prj_shop: [ + rawEnv('e1', 'DB_URL', { target: ['production', 'preview', 'development'] }), + rawEnv('e2', 'API_KEY', { type: 'encrypted', target: ['production', 'preview'] }), + rawEnv('e3', 'STRIPE_KEY', { type: 'sensitive', target: ['production'], listValue: undefined }), + rawEnv('e4', 'VERCEL_URL', { type: 'system', target: ['production', 'preview'] }), + rawEnv('e5', 'DB_URL', { target: ['preview'], gitBranch: 'feature-x' }), + rawEnv('e6', 'DB_URL', { target: [], customEnvironmentIds: ['env_stg'] }), + ], + prj_mweb: [rawEnv('m1', 'WEB_KEY'), rawEnv('m2', 'WEB_OTHER')], + prj_madm: [rawEnv('m3', 'ADM_KEY')], + prj_a: [rawEnv('d1', 'DUO_KEY')], + prj_b: [rawEnv('d2', 'DUO_KEY')], + prj_nolink: [rawEnv('n1', 'N1')], + prj_dev: [rawEnv('v1', 'DEV_KEY', { target: ['development'] })], + prj_cfg: [rawEnv('c1', 'C1')], + prj_personal: [rawEnv('p1', 'PKEY')], + }, + values: Object.fromEntries( + ['e1', 'e2', 'e3', 'e5', 'e6', 'm1', 'm2', 'm3', 'd1', 'd2', 'n1', 'v1', 'c1', 'p1'].map((id) => [id, SENTINEL_VERCEL]), + ), +}; + +/** The ids a service gets: `:`. */ +export const SVC = { + shopProd: 'prj_shop:production', + shopPreview: 'prj_shop:preview', + shopPreviewFeature: 'prj_shop:preview@feature-x', + shopStaging: 'prj_shop:custom:staging', + mwebProd: 'prj_mweb:production', + madmProd: 'prj_madm:production', + aProd: 'prj_a:production', + bProd: 'prj_b:production', + devOnly: 'prj_dev:development', + cfgProd: 'prj_cfg:production', + personalProd: 'prj_personal:production', +} as const; + +// ── GitHub ─────────────────────────────────────────────────────────────────── + +const target = (name: string, kind: string, branch: string, options: Record) => ({ + name, + kind, + branch, + vars: ['X'], + options, + mode: 'ci', + gitBaseBranch: 'main', +}); + +/** A deploy.json that already holds a non-Vercel target (the merge must leave it exactly as it is). */ +export const EXISTING_MONO_DEPLOY = + JSON.stringify({ version: '1', targets: { 'ssm-prod': target('ssm-prod', 'aws-ssm', 'production', { prefix: '/app' }) } }, null, 2) + '\n'; + +/** A deploy.json that already holds the Vercel target for `prj_cfg` on production. */ +export const EXISTING_CONF_DEPLOY = + JSON.stringify( + { version: '1', targets: { 'vercel-production': target('vercel-production', 'vercel', 'production', { projectDir: '.', vercelEnv: 'production', projectId: 'prj_cfg', teamId: 'team_1' }) } }, + null, + 2, + ) + '\n'; + +export const GITHUB_REPOS: Readonly> = { + 'acme/shop': { defaultBranch: 'main', branches: ['main', 'develop', 'feature-x'] }, + 'acme/mono': { defaultBranch: 'main', branches: ['main'], files: { 'apps/web/.capy/deploy.json': EXISTING_MONO_DEPLOY } }, + 'acme/duo': { defaultBranch: 'main', branches: ['main'] }, + 'acme/conf': { defaultBranch: 'main', branches: ['main'], files: { '.capy/deploy.json': EXISTING_CONF_DEPLOY } }, + 'acme/personal': { defaultBranch: 'trunk', branches: ['trunk'] }, + 'acme/devonly': { defaultBranch: 'main', branches: ['main'] }, +}; + +// ── The io ─────────────────────────────────────────────────────────────────── + +export interface VercelWorldOptions { + readonly data?: FakeVercelData; + readonly projects?: readonly FakeProject[]; + readonly repos?: Readonly>; + /** Plan files by path. */ + readonly files?: Readonly>; + /** What the system store answers for the token (`null`: none). */ + readonly token?: string | null; + /** The token the fake Vercel accepts; anything else is a 401. */ + readonly acceptedToken?: string; + readonly io?: Partial; +} + +const json = (body: unknown, status: number) => ({ status, ok: false, headers: { get: () => null }, text: async () => JSON.stringify(body) }); + +export function makeVercelWorld(options: VercelWorldOptions = {}) { + const capy = capyFake(options.projects ?? PROJECTS); + const github = githubFake(options.repos ?? GITHUB_REPOS); + const progress = mock((_line: string) => undefined); + const getSecret = mock(async (_name: string, _opts: unknown) => (options.token === undefined ? VERCEL_TOKEN : options.token) as string | null); + const accepted = options.acceptedToken ?? VERCEL_TOKEN; + const inner = fakeVercelFetch(options.data ?? VERCEL_DATA); + const fetchMock = mock(async (url: string, init: { method: 'GET'; headers: Record }) => + init.headers.Authorization === `Bearer ${accepted}` ? inner(url, init as never) : json({ error: { code: 'forbidden', message: 'no' } }, 401), + ); + const files = options.files ?? {}; + const io: DeployVercelDiscoverIo = { + devMode: false, + env: {}, + readFile: (path: string) => { + const text = files[path]; + if (text === undefined) throw new Error('no such file'); + return text; + }, + context: async () => ({ ok: true, orgId: 'org_1', client: capy.client as never }), + getSecret: getSecret as never, + vercel: (token) => createVercelClient(token, { fetch: fetchMock as never, sleep: async () => {}, dropValues: true }), + github: () => github.api, + branchName: () => 'capy/vercel-targets-test-0000', + progress, + ...options.io, + }; + return { io, capy, github, progress, getSecret, fetchMock, vercelCalls: () => callsOf(fetchMock) }; +} + +export type VercelWorld = ReturnType; + +export const planFile = (entries: readonly unknown[]): string => JSON.stringify({ version: 1, entries }); + +export const ENTRY_SHOP = { project_id: P_SHOP, branch: 'production', service_id: SVC.shopProd, git_branch: 'main', vars: ['STRIPE_KEY', 'DB_URL', 'API_KEY'] }; +export const ENTRY_SHOP_PREVIEW = { project_id: P_SHOP, branch: 'preview', service_id: SVC.shopPreview, git_branch: 'main', vars: ['DB_URL', 'API_KEY'] }; +export const ENTRY_SHOP_FEATURE = { project_id: P_SHOP, branch: 'feature-x', service_id: SVC.shopPreviewFeature, git_branch: 'main', vars: ['DB_URL'] }; +export const ENTRY_SHOP_STAGING = { project_id: P_SHOP, branch: 'staging', service_id: SVC.shopStaging, git_branch: 'main', vars: ['DB_URL'] }; +export const ENTRY_MWEB = { project_id: P_MWEB, branch: 'production', service_id: SVC.mwebProd, git_branch: 'main', vars: ['WEB_KEY'] }; +export const ENTRY_MADMIN = { project_id: P_MADMIN, branch: 'production', service_id: SVC.madmProd, git_branch: 'main', vars: ['ADM_KEY'] }; +export const ENTRY_DUO_A = { project_id: P_DUO, branch: 'production', service_id: SVC.aProd, git_branch: 'main', vars: ['DUO_KEY'] }; +export const ENTRY_DUO_B = { project_id: P_DUO, branch: 'production', service_id: SVC.bProd, git_branch: 'main', vars: ['DUO_KEY'] }; +export const ENTRY_PERSONAL = { project_id: P_PERSONAL, branch: 'production', service_id: SVC.personalProd, git_branch: 'trunk', vars: ['PKEY'] }; +export const ENTRY_CFG = { project_id: P_CONF, branch: 'production', service_id: SVC.cfgProd, git_branch: 'main', vars: ['C1'] }; From 5b6dceb44286c290f9f16989d405e46ec991bdd1 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 2 Nov 2026 15:58:57 -0700 Subject: [PATCH 06/22] fix(deploy): vercel --discover reads the connector token, like dokploy's discovery Discovery only reads Vercel, so it uses _CONNECTOR_VERCEL_TOKEN (then $VERCEL_TOKEN), not the deploy-side _TARGET_VERCEL_TOKEN. --- src/commands/connectors/vercel.ts | 25 +++++++++------- src/commands/deployDiscover/index.ts | 5 ++-- src/commands/deployDiscover/vercel.ts | 30 ++++++++++++++----- .../deployDiscoverVercelFacts.test.ts | 12 ++++---- 4 files changed, 46 insertions(+), 26 deletions(-) diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts index b2ea030a..596a7005 100644 --- a/src/commands/connectors/vercel.ts +++ b/src/commands/connectors/vercel.ts @@ -742,19 +742,22 @@ function preferredProjectId(plan: DiscoveryPlan, project: string | undefined): s return ids.find((p) => p.id === want || p.name === want)?.id ?? want; } +/** + * The real system-store read for the import direction: `_CONNECTOR_VERCEL_TOKEN`, + * borrowing from `_TARGET_VERCEL_TOKEN` (the deploy side) the same way Dokploy's does. + * Also what `capy deploy vercel --discover` reads, since discovery only reads Vercel. + */ +export async function vercelConnectorSecret(name: string, opts: VercelSystemStoreCallOptions): Promise { + const { getDirectionalConnectorSecret } = await import('../../system/systemStore'); + return getDirectionalConnectorSecret(name, VERCEL_TARGET_SECRET_NAME, { + ...opts, + missingWithFallbackCode: ERROR_CODES.VERCEL_CONNECTOR_KEY_MISSING, + }); +} + /** * The `capy connect vercel` production instance — the only place this file * wires the org system store for real (every other construction, including * every test, gets the safe env-only default — see `VercelConnectorDeps`). */ -export const vercelConnector: ConnectorModule = createVercelConnector({ - getConnectorSecret: async (name, opts) => { - // Import's own direction: `_CONNECTOR_VERCEL_TOKEN`, borrowing from - // `_TARGET_VERCEL_TOKEN` (the deploy side) the same way Dokploy's does. - const { getDirectionalConnectorSecret } = await import('../../system/systemStore'); - return getDirectionalConnectorSecret(name, VERCEL_TARGET_SECRET_NAME, { - ...opts, - missingWithFallbackCode: ERROR_CODES.VERCEL_CONNECTOR_KEY_MISSING, - }); - }, -}); +export const vercelConnector: ConnectorModule = createVercelConnector({ getConnectorSecret: vercelConnectorSecret }); diff --git a/src/commands/deployDiscover/index.ts b/src/commands/deployDiscover/index.ts index bf509c87..7ca35646 100644 --- a/src/commands/deployDiscover/index.ts +++ b/src/commands/deployDiscover/index.ts @@ -8,11 +8,11 @@ import { readFileSync } from 'node:fs'; import { createDokployClient } from '../../deploy/dokployApi'; import { realBaseUrlStoreOpener, savedDokployTargetUrl } from '../../deploy/dokployBaseUrl'; import { dokployConnectorSecret } from '../connectors/dokploy'; +import { vercelConnectorSecret } from '../connectors/vercel'; import { newPrBranchName } from '../keepLockPr'; import { realGithub } from '../secretsSet'; import { resolveSilentContext } from '../secretsSetCommand'; import { createVercelClient } from '../../deploy/vercelApi'; -import { getVercelTargetSecretFromStore } from '../../deploy/vercelTargetApi'; import { DeployDiscoverIo, DeployDiscoverOpts, runDeployDokployDiscover } from './command'; import { DeployVercelDiscoverIo, DeployVercelDiscoverOpts, runDeployVercelDiscover } from './vercel'; @@ -66,7 +66,8 @@ export async function realVercelDiscoverIo(devMode: boolean): Promise createVercelClient(token, { dropValues: true }), github: realGithub, diff --git a/src/commands/deployDiscover/vercel.ts b/src/commands/deployDiscover/vercel.ts index 26d7dc08..efcd45fc 100644 --- a/src/commands/deployDiscover/vercel.ts +++ b/src/commands/deployDiscover/vercel.ts @@ -39,7 +39,14 @@ import { VercelTeam, mapVercelApiError, } from '../../deploy/vercelApi'; -import { GetVercelStoreSecret, describeVercelTokenProblem, resolveVercelTargetToken, vercelTargetKeyHelp } from '../../deploy/vercelTargetApi'; +import { + DEFAULT_VERCEL_TOKEN_ENV, + VERCEL_CONNECTOR_SECRET_NAME, + describeVercelTokenProblem, + resolveVercelToken, +} from '../../deploy/vercelApi'; +import { keyHelpFields, vercelKeyHelp } from '../../deploy/keyHelp'; +import type { GetVercelStoreSecret } from '../../deploy/vercelTargetApi'; import { ERROR_CODES } from '../../types/index'; import { runPool } from '../../utils/pool'; import { GITHUB_HOST } from '../secretsSet'; @@ -209,7 +216,7 @@ async function scanProject(client: VercelClient, project: VercelProject, team: S function vercelFailure(err: unknown): DeployDiscoverResult { const mapped = mapVercelApiError(err); // A rejected token says where to get another. - const help = err instanceof VercelApiError && err.code === 'unauthorized' ? vercelTargetKeyHelp() : {}; + const help = err instanceof VercelApiError && err.code === 'unauthorized' ? vercelTokenHelp() : {}; return refuse(mapped.code, mapped.message, help); } @@ -365,19 +372,28 @@ export const VERCEL_KIND: KindOverrides = { // ── The command ────────────────────────────────────────────────────────────── -/** The token, never asked for: the system store (`_TARGET_VERCEL_TOKEN`, or the connector token it refers to), then `$VERCEL_TOKEN`. */ +/** Where to get a Vercel token, for discovery's refusals: the import-side name, since discovery only reads. */ +function vercelTokenHelp(): { link: { label: string; url: string }; remedy: string } { + return keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME)); +} + +/** + * The token, never asked for: discovery only reads Vercel, so it uses the + * import-side token (`_CONNECTOR_VERCEL_TOKEN`), as Dokploy's discovery does, + * then `$VERCEL_TOKEN`. + */ async function vercelClient(io: DeployVercelDiscoverIo, orgId: string): Promise> { - const resolved = await resolveVercelTargetToken({ + const resolved = await resolveVercelToken({ env: io.env, // Never a prompt: a missing token is a coded refusal that says where to get it. interactive: false, orgId, devMode: io.devMode, - getSecret: io.getSecret, + getConnectorSecret: io.getSecret, }); if (!resolved.ok) { - const { reason, hint } = describeVercelTokenProblem(resolved.code); - return stop(refuse(resolved.code, `${reason}.`, { hint, ...vercelTargetKeyHelp() })); + const { reason, hint } = describeVercelTokenProblem(resolved.code, DEFAULT_VERCEL_TOKEN_ENV); + return stop(refuse(resolved.code, `${reason}.`, { hint, ...vercelTokenHelp() })); } return done(io.vercel(resolved.value)); } diff --git a/tests/commands/deployDiscoverVercelFacts.test.ts b/tests/commands/deployDiscoverVercelFacts.test.ts index a6f8697d..00cd53af 100644 --- a/tests/commands/deployDiscoverVercelFacts.test.ts +++ b/tests/commands/deployDiscoverVercelFacts.test.ts @@ -285,11 +285,11 @@ describe('facts: no value is read, asked for, printed or sent', () => { }); describe('facts: the token', () => { - test('is read from the org system store, by the target key name, and never asked for', async () => { + test('is read from the org system store, by the connector key name (discovery only reads), and never asked for', async () => { const { world } = await facts(); expect(world.getSecret).toHaveBeenCalledTimes(1); const [name, opts] = world.getSecret.mock.calls[0] as [string, Record]; - expect(name).toBe('_TARGET_VERCEL_TOKEN'); + expect(name).toBe('_CONNECTOR_VERCEL_TOKEN'); expect(opts).toMatchObject({ interactive: false, orgId: 'org_1' }); }); @@ -299,16 +299,16 @@ describe('facts: the token', () => { expect(exitCode).toBe(1); expect(body).toMatchObject({ ok: false, code: 'VERCEL_TOKEN_MISSING' }); expect(body.link.url).toContain('vercel.com'); - expect(body.remedy).toContain('_TARGET_VERCEL_TOKEN'); + expect(body.remedy).toContain('_CONNECTOR_VERCEL_TOKEN'); expect(world.fetchMock).not.toHaveBeenCalled(); expect(world.capy.getOrgRepos).not.toHaveBeenCalled(); expect(world.github.getDefaultBranches).not.toHaveBeenCalled(); }); test('a store that refuses with a code is that code, with the same help', async () => { - const world = makeVercelWorld({ io: { getSecret: (async () => Promise.reject(Object.assign(new Error('x'), { code: 'VERCEL_TARGET_KEY_MISSING' }))) as never } }); + const world = makeVercelWorld({ io: { getSecret: (async () => Promise.reject(Object.assign(new Error('x'), { code: 'VERCEL_CONNECTOR_KEY_MISSING' }))) as never } }); const { body } = await facts(world); - expect(body).toMatchObject({ ok: false, code: 'VERCEL_TARGET_KEY_MISSING' }); + expect(body).toMatchObject({ ok: false, code: 'VERCEL_CONNECTOR_KEY_MISSING' }); expect(body.link).toBeDefined(); expect(body.remedy).toBeDefined(); expect(world.fetchMock).not.toHaveBeenCalled(); @@ -327,7 +327,7 @@ describe('facts: the token', () => { expect(exitCode).toBe(1); expect(body).toMatchObject({ ok: false, code: 'VERCEL_AUTH_FAILED' }); expect(body.link.url).toContain('vercel.com'); - expect(body.remedy).toContain('_TARGET_VERCEL_TOKEN'); + expect(body.remedy).toContain('_CONNECTOR_VERCEL_TOKEN'); expect(JSON.stringify(body)).not.toContain(VERCEL_TOKEN); expect(world.capy.getOrgRepos).not.toHaveBeenCalled(); }); From e5462e7b5cce1b534397b93ac42c53ac26424124 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 2 Nov 2026 16:53:14 -0700 Subject: [PATCH 07/22] feat(edit): capy edit NAME on a terminal asks for the value like capy add NAME --force A name on a terminal now uses add's hidden masked prompt and its write path (encrypt, sync, push), creating or overwriting without an overwrite question. Piped values and the no-name editor are unchanged. --dry-run refuses with DRY_RUN_UNSUPPORTED before any prompt. --- docs/cli-reference.md | 4 +- src/commands/addCommand.ts | 157 ++++++++------ src/commands/editCommand.ts | 43 +++- src/commands/editPiped.ts | 18 +- src/commands/pipedValue.ts | 1 + src/index.ts | 7 +- tests/commands/dryRunInteractive.test.ts | 4 +- tests/commands/editMode.test.ts | 4 +- tests/commands/editNamePrompt.test.ts | 247 +++++++++++++++++++++++ tests/run-tests.sh | 1 + 10 files changed, 399 insertions(+), 87 deletions(-) create mode 100644 tests/commands/editNamePrompt.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ec960961..83d445a1 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -34,7 +34,7 @@ JSON support: yes (`--json`) ## `capy edit` -Inspect and edit secrets in an interactive TUI, or set one variable from a piped value +Inspect and edit secrets in an interactive TUI, or set one variable from a hidden prompt or a piped value ``` capy edit [name] [options] @@ -42,7 +42,7 @@ capy edit [name] [options] | Option | Description | Default | |---|---|---| -| `--no-push` | piped value: write .env only; do not push to Capy | | +| `--no-push` | with a name: write .env only; do not push to Capy | | | `--json` | emit machine-readable JSON instead of the human UI (piped value) | | | `--non-tty` | treat stdin as not a terminal; never prompt (agents/CI) | | | `--pr` | create a PR with the keep.lock change (answers the prompt) | | diff --git a/src/commands/addCommand.ts b/src/commands/addCommand.ts index ff3cc3a2..7eb482a4 100644 --- a/src/commands/addCommand.ts +++ b/src/commands/addCommand.ts @@ -9,6 +9,7 @@ import { refuseBadPrFlags, reportKeepLockHuman, runKeepLockPrStep, + type KeepLockCommandName, type PrFlags, } from './keepLockPr'; @@ -74,6 +75,93 @@ async function writeInOrder( return writeInOrder({ ...ctx, localPlaintext: { ...ctx.localPlaintext, [name]: value } }, rest, push); } +/** What the prompted set needs beyond the flags: who is asking, and what a dry run says. */ +export interface PromptedSetArgs { + readonly names: readonly string[]; + readonly opts: AddOpts; + readonly devMode: boolean; + /** Which command is writing: named in the PR body. */ + readonly command: KeepLockCommandName; + /** The `DRY_RUN_UNSUPPORTED` sentence for this command. */ + readonly dryRunMessage: string; +} + +/** + * The hidden terminal prompt and the one write path behind it, shared by `capy add NAME` + * and `capy edit NAME` on a terminal. Asks for each value (masked), writes, syncs, runs the + * keep.lock PR step. With `opts.force` an existing name is overwritten without a question. + */ +export async function runPromptedSet(args: PromptedSetArgs): Promise { + const { names, opts, devMode, command, dryRunMessage } = args; + + // The prompts would really save: a dry run never runs for real. Before any prompt. + if (opts.dryRun === true) return refuseDryRunUnsupported(opts.json === true, dryRunMessage); + + if (names.length === 0) { + throw new CapyError('No variable name given.', ERROR_CODES.INVALID_FORMAT); + } + for (const name of names) { + if (!VAR_RE.test(name)) { + throw new CapyError(`"${name}" is not a valid environment variable name.`, ERROR_CODES.INVALID_FORMAT); + } + } + + // Bare `capy` runs before anything else: the folder and Capy must agree before this writes (docs/conflict-resolution.md). + const gate = gateOptionsOf(opts, devMode); + conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true); + await gateFirst(gate); + + const ctx = await resolveContext({ devMode, gate }); + const push = opts.noPush !== true; + + const existing = names.filter((n) => n in ctx.localPlaintext); + if (existing.length > 0 && !opts.force && !opts.nonTty) { + const inquirer = (await import('inquirer')).default; + const { ok } = await inquirer.prompt([ + { type: 'confirm', name: 'ok', message: `${existing.join(', ')} already exist(s). Overwrite?`, default: false }, + ]); + if (!ok) { + console.log('Aborted.'); + return; + } + } + + const savedNames = await collectViaTerminal(ctx, names, opts, push); + + const where = push ? ` and synced to ${ctx.branch}` : ' (.env only — not pushed)'; + console.log(`✓ Saved ${savedNames.length} variable(s): ${savedNames.join(', ')}${where}.`); + + const outcome = await runKeepLockPrStep({ + command, + cwd: process.cwd(), + records: recordsForWrite(ctx.keep, ctx.pm.readKeepFile(), ctx.branch, savedNames), + localKeep: ctx.keep, + flags: opts.pr ?? {}, + json: opts.json === true, + nonTty: opts.nonTty, + }); + reportKeepLockHuman(outcome, { successTo: 'stdout', noteUnanswered: false }); +} + +/** The hidden terminal prompt, one value per name. */ +async function collectViaTerminal( + ctx: ResolvedContext, + names: readonly string[], + opts: AddOpts, + push: boolean, +): Promise { + if (opts.nonTty) { + throw new CapyError( + // COPY-FLAG + 'Non-interactive add reads the value from stdin: | capy add NAME', + ERROR_CODES.INVALID_FORMAT, + ); + } + const pairs = await promptForValues(names); + await writeInOrder(ctx, pairs, push); + return pairs.map((p) => p.name); +} + export class AddCommand { constructor(private readonly devMode: boolean = false) {} @@ -86,53 +174,13 @@ export class AddCommand { // ` | capy add NAME`: the value comes from stdin and nothing prompts. if (isPipedAdd()) return this.executePiped(names, opts); - // The prompts would really save: a dry run never runs for real. Before any prompt. - if (opts.dryRun === true) return refuseDryRunUnsupported(opts.json === true, DRY_RUN_UNSUPPORTED_MESSAGES.add); - - if (names.length === 0) { - throw new CapyError('No variable name given.', ERROR_CODES.INVALID_FORMAT); - } - for (const name of names) { - if (!VAR_RE.test(name)) { - throw new CapyError(`"${name}" is not a valid environment variable name.`, ERROR_CODES.INVALID_FORMAT); - } - } - - // Bare `capy` runs before anything else: the folder and Capy must agree before this writes (docs/conflict-resolution.md). - const gate = gateOptionsOf(opts, this.devMode); - conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true); - await gateFirst(gate); - - const ctx = await resolveContext({ devMode: this.devMode, gate }); - const push = opts.noPush !== true; - - const existing = names.filter((n) => n in ctx.localPlaintext); - if (existing.length > 0 && !opts.force && !opts.nonTty) { - const inquirer = (await import('inquirer')).default; - const { ok } = await inquirer.prompt([ - { type: 'confirm', name: 'ok', message: `${existing.join(', ')} already exist(s). Overwrite?`, default: false }, - ]); - if (!ok) { - console.log('Aborted.'); - return; - } - } - - const savedNames = await this.collectViaTerminal(ctx, names, opts, push); - - const where = push ? ` and synced to ${ctx.branch}` : ' (.env only — not pushed)'; - console.log(`✓ Saved ${savedNames.length} variable(s): ${savedNames.join(', ')}${where}.`); - - const outcome = await runKeepLockPrStep({ + return runPromptedSet({ + names, + opts, + devMode: this.devMode, command: 'add', - cwd: process.cwd(), - records: recordsForWrite(ctx.keep, ctx.pm.readKeepFile(), ctx.branch, savedNames), - localKeep: ctx.keep, - flags: opts.pr ?? {}, - json: opts.json === true, - nonTty: opts.nonTty, + dryRunMessage: DRY_RUN_UNSUPPORTED_MESSAGES.add, }); - reportKeepLockHuman(outcome, { successTo: 'stdout', noteUnanswered: false }); } /** ` | capy add NAME`. One name, one pipe; an existing name needs `--force`. */ @@ -174,23 +222,4 @@ export class AddCommand { : undefined, }); } - - /** The hidden terminal prompt, one value per name. */ - private async collectViaTerminal( - ctx: ResolvedContext, - names: readonly string[], - opts: AddOpts, - push: boolean, - ): Promise { - if (opts.nonTty) { - throw new CapyError( - // COPY-FLAG - 'Non-interactive add reads the value from stdin: | capy add NAME', - ERROR_CODES.INVALID_FORMAT, - ); - } - const pairs = await promptForValues(names); - await writeInOrder(ctx, pairs, push); - return pairs.map((p) => p.name); - } } diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index e733f121..74b5649d 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -7,7 +7,7 @@ import { readSecretsLocal, LOCAL_USER_ID } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { hashValue } from './statusCommand'; -import { EditScreen, EditRow, EditState, EditLoaded, classifiedRow, focusedOn, withPendingEdits } from '../ui/editScreen'; +import { EditScreen, EditRow, EditState, EditLoaded, classifiedRow, withPendingEdits } from '../ui/editScreen'; import { deployedHashesFor } from '../core/deployStatus'; import { readDefaultBranchTargets } from '../deploy/configuredTargets'; import { KeepFile } from '../types/index'; @@ -16,7 +16,8 @@ import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; import { gateFirst, type GateOptions } from '../sync/gate'; import { reportKeepLockHuman, refuseBadPrFlags, runKeepLockPrStep, type PrFlags } from './keepLockPr'; import { startSaveLog } from './sessionSaveLog'; -import { decideEditMode, editPipedCommand, refuseEditNeedsTty } from './editPiped'; +import { decideEditMode, editPipedCommand, refuseEditNeedsTty, refuseEditPromptLocalOnly } from './editPiped'; +import { runPromptedSet } from './addCommand'; import { DRY_RUN_UNSUPPORTED_MESSAGES, refuseDryRunUnsupported, refuseInvalidName } from './pipedValue'; import { isValidVarName } from './pipedWrite'; import { printRepoLinkWarnings, reportRepoLink } from '../core/repoLinkReporter'; @@ -218,9 +219,9 @@ export async function saveEdits( export interface EditOpts { /** - * The variable to work on (`capy edit NAME`). With a terminal: the TUI opens - * with the cursor on it, and an unknown name opens its new-variable entry. - * Without one: the value is read from stdin (piped mode). + * The variable to work on (`capy edit NAME`). With a terminal: the hidden value prompt of + * `capy add NAME --force` (a new name is created, an existing one overwritten). Without one: + * the value is read from stdin (piped mode). */ name?: string; /** Piped mode: pure JSON on stdout. */ @@ -231,7 +232,7 @@ export interface EditOpts { nonTty?: boolean; /** `--pr` / `--no-pr` / `--pr-base`: answers the keep.lock PR step. */ pr?: PrFlags; - /** The global `--dry-run`: piped mode previews; the TUI refuses (`DRY_RUN_UNSUPPORTED`). */ + /** The global `--dry-run`: piped mode previews; the TUI and the value prompt refuse (`DRY_RUN_UNSUPPORTED`). */ dryRun?: boolean; /** `--resolve` / `--conflict` as typed (CAP-717): they answer the differences bare `capy` finds first. */ conflictFlags?: RawConflictFlags; @@ -309,6 +310,10 @@ export class EditCommand { if (mode === 'tui' && opts.dryRun === true) { return refuseDryRunUnsupported(opts.json === true, DRY_RUN_UNSUPPORTED_MESSAGES.edit); } + // The value prompt would really save: a dry run never runs for real. Before any prompt. + if (mode === 'prompt' && opts.dryRun === true) { + return refuseDryRunUnsupported(opts.json === true, DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt); + } if (opts.name !== undefined && !isValidVarName(opts.name)) return refuseInvalidName(opts.json === true); // A bad flag is refused here, before stdin is read or anything is authenticated. conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true); @@ -324,6 +329,26 @@ export class EditCommand { }); } + // `capy edit NAME` on a terminal is `capy add NAME --force`: the same hidden prompt and the same write path. + if (mode === 'prompt' && opts.name !== undefined) { + if (isLocalOnly()) return refuseEditPromptLocalOnly(opts.json === true); + return runPromptedSet({ + names: [opts.name], + opts: { + force: true, + noPush: opts.noPush, + nonTty: opts.nonTty, + json: opts.json, + pr: opts.pr, + dryRun: opts.dryRun, + conflictFlags: opts.conflictFlags, + }, + devMode: this.devMode, + command: 'edit', + dryRunMessage: DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt, + }); + } + // Bare `capy` runs before the screen opens: the folder and Capy must agree before anything here can write. await gateFirst(gate); @@ -376,10 +401,6 @@ export class EditCommand { localMode, seen: loaded.seen, }; - // `capy edit NAME` on a terminal: the cursor starts on NAME (a new name opens - // its value entry). - const state = opts.name !== undefined ? focusedOn(baseState, opts.name) : baseState; - const screen = new EditScreen(); const printExpiryAfter = async () => { const { printExpiryWarnings } = await import('./connectors/shared'); @@ -410,7 +431,7 @@ export class EditCommand { const fresh = await load(); return session(withPendingEdits({ ...baseState, rows: fresh.rows, remoteAvailable: fresh.remoteAvailable, seen: fresh.seen }, exit.pending)); }; - await session(state); + await session(baseState); if (repoLinkPending) printRepoLinkWarnings(await repoLinkPending); // The TUI has already released stdin (raw mode off, its key listener // removed) by the time `run` resolves, so the prompts in keepLockPr.ts start diff --git a/src/commands/editPiped.ts b/src/commands/editPiped.ts index 14a542d5..27bf77b2 100644 --- a/src/commands/editPiped.ts +++ b/src/commands/editPiped.ts @@ -9,10 +9,13 @@ * * | name | stdin | mode | * |------|---------|----------| - * | any | TTY | `tui` | + * | no | TTY | `tui` | + * | yes | TTY | `prompt` | * | yes | not TTY | `piped` | * | no | not TTY | `refuse` | * + * `prompt` is `capy add NAME --force`: the hidden value prompt, then the one write path. + * * `--non-tty` takes the TTY off the table. A name with `--non-tty` on a real * terminal has nothing piped to read, so it refuses rather than block on the * keyboard. @@ -24,7 +27,7 @@ import { isValidVarName, runPipedWrite } from './pipedWrite'; import type { PrFlags } from './keepLockPr'; import type { GateOptions } from '../sync/gate'; -export type EditMode = 'tui' | 'piped' | 'refuse'; +export type EditMode = 'tui' | 'prompt' | 'piped' | 'refuse'; export interface EditModeFacts { readonly hasName: boolean; @@ -34,7 +37,7 @@ export interface EditModeFacts { export function decideEditMode(facts: EditModeFacts): EditMode { const { hasName, stdinIsTTY, nonTty } = facts; - if (stdinIsTTY && !nonTty) return 'tui'; + if (stdinIsTTY && !nonTty) return hasName ? 'prompt' : 'tui'; const somethingIsPiped = !stdinIsTTY; if (hasName) return somethingIsPiped ? 'piped' : 'refuse'; return 'refuse'; @@ -61,6 +64,15 @@ export interface EditPipedOpts { readonly gate: GateOptions; } +/** `EDIT_STDIN_LOCAL_ONLY`, exit 1: `capy edit NAME` on a terminal writes through Capy, which a local-only profile has no organization for. */ +export function refuseEditPromptLocalOnly(json: boolean): never { + return refusePiped( + json, + ERROR_CODES.EDIT_STDIN_LOCAL_ONLY, + 'Setting a value by name needs an organization. This profile is local-only. Run capy edit to use the editor.', // COPY-FLAG + ); +} + /** ` | capy edit NAME`: read stdin, set the variable, report. Never prompts. */ export async function editPipedCommand(name: string, opts: EditPipedOpts): Promise { if (!isValidVarName(name)) return refuseInvalidName(opts.json); diff --git a/src/commands/pipedValue.ts b/src/commands/pipedValue.ts index 43884df2..c18f456a 100644 --- a/src/commands/pipedValue.ts +++ b/src/commands/pipedValue.ts @@ -159,6 +159,7 @@ export function refusePiped(json: boolean, code: string, error: string): never { /** Why each interactive path cannot preview. Minimal and neutral. */ export const DRY_RUN_UNSUPPORTED_MESSAGES = { edit: "--dry-run isn't available in the interactive editor. Pipe a value with capy edit NAME --dry-run.", // COPY-FLAG + editPrompt: "--dry-run isn't available for an interactive edit. Pipe a value with: | capy edit NAME --dry-run", // COPY-FLAG add: "--dry-run isn't available for an interactive add. Pipe a value with: | capy add NAME --dry-run", // COPY-FLAG remove: "--dry-run isn't available at the confirmation prompt. Add --yes, --json or --non-tty to preview.", // COPY-FLAG } as const; diff --git a/src/index.ts b/src/index.ts index 981d2a82..6804d44a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,9 +133,9 @@ program program .command('edit [name]') - // COPY-FLAG — minimal-neutral; names the two modes. - .description('Inspect and edit secrets in an interactive TUI, or set one variable from a piped value') - .option('--no-push', 'piped value: write .env only; do not push to Capy') + // COPY-FLAG — minimal-neutral; names the three modes. + .description('Inspect and edit secrets in an interactive TUI, or set one variable from a hidden prompt or a piped value') + .option('--no-push', 'with a name: write .env only; do not push to Capy') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI (piped value)') .option('--non-tty', 'treat stdin as not a terminal; never prompt (agents/CI)') .option('--pr', 'create a PR with the keep.lock change (answers the prompt)') // COPY-FLAG @@ -145,6 +145,7 @@ program 'after', // COPY-FLAG '\n' + + 'With a name on a terminal, asks for the value (hidden) and sets that variable, like: capy add NAME --force\n' + 'With a name and a piped value, sets that one variable. The value is read from stdin only:\n' + ' | capy edit NAME --json\n', ) diff --git a/tests/commands/dryRunInteractive.test.ts b/tests/commands/dryRunInteractive.test.ts index 9b809da1..3012f455 100644 --- a/tests/commands/dryRunInteractive.test.ts +++ b/tests/commands/dryRunInteractive.test.ts @@ -78,12 +78,12 @@ describe('interactive paths refuse under --dry-run, before anything else', () => expect(out.raw).not.toContain('\x1b[?1049h'); // the alt screen was never entered }); - test('the same with a name (the TUI would open on it), in human mode: the sentence on stderr', async () => { + test('the same with a name (the hidden value prompt would ask), in human mode: the sentence on stderr', async () => { setTTY(true); const out = await capture(() => new EditCommand().execute({ name: 'SOME_VAR', dryRun: true })); expect(out.exitCode).toBe(1); expect(out.stdout).toBe(''); - expect(out.stderr).toBe("--dry-run isn't available in the interactive editor. Pipe a value with capy edit NAME --dry-run."); + expect(out.stderr).toBe(DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt); expect(out.raw).not.toContain('\x1b[?1049h'); }); diff --git a/tests/commands/editMode.test.ts b/tests/commands/editMode.test.ts index 2439a454..ca4f097c 100644 --- a/tests/commands/editMode.test.ts +++ b/tests/commands/editMode.test.ts @@ -23,8 +23,8 @@ describe('decideEditMode', () => { expect(decideEditMode(facts({ stdinIsTTY: true }))).toBe('tui'); }); - test('2. a terminal and a name is still the TUI (focused on NAME)', () => { - expect(decideEditMode(facts({ stdinIsTTY: true, hasName: true }))).toBe('tui'); + test('2. a terminal and a name is the hidden value prompt (capy add NAME --force), not the TUI', () => { + expect(decideEditMode(facts({ stdinIsTTY: true, hasName: true }))).toBe('prompt'); }); test('a name with stdin not a terminal is piped mode', () => { diff --git a/tests/commands/editNamePrompt.test.ts b/tests/commands/editNamePrompt.test.ts new file mode 100644 index 00000000..c4814461 --- /dev/null +++ b/tests/commands/editNamePrompt.test.ts @@ -0,0 +1,247 @@ +/** + * `capy edit NAME` on a terminal is `capy add NAME --force`: the same hidden prompt, the same write path. + * + * - it asks for the value once, with the masked password prompt `capy add` uses, and never asks "overwrite?"; + * - a new name is created, an existing one overwritten, through `writeAndSync` (encrypt + sync + push); + * - `--no-push` writes `.env` only (the real `writeAndSync`, a real `FileManager` in a temp folder); + * - `--dry-run` refuses with DRY_RUN_UNSUPPORTED before any prompt, any read or any write; + * - no terminal (or `--non-tty`) and no piped value is a coded refusal with no prompt; + * - `capy edit` with no name does not take this path. + * + * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). All values are fake. + */ +import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'; +import { mkdirSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { stubbedGate, gateRan } from '../helpers/gateMock'; +import * as realShared from '../../src/commands/connectors/shared'; +import * as realKeepLockPr from '../../src/commands/keepLockPr'; +import * as realProfileConfig from '../../src/config/profileConfig'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import type { KeepFile } from '../../src/types/index'; + +const TEST_DIR = join(tmpdir(), `capy-edit-name-prompt-${process.pid}`); +const ORIGINAL_CWD = process.cwd(); +const PROJECT_KEY = 'test-project-key'; +const FAKE_VALUE = 'fake-value-for-test-only'; + +mock.module('../../src/sync/gate', () => stubbedGate()); + +/** The questions each `inquirer.prompt` call was given. */ +const promptSpy = mock(async (_questions: readonly unknown[]) => ({ value: FAKE_VALUE, ok: true })); +mock.module('inquirer', () => ({ default: { prompt: promptSpy } })); + +// Captured BEFORE mock.module: bun rewrites the already-imported namespace in place, so `realShared.writeAndSync` +// would afterwards be the wrapper below, and the wrapper calling it would recurse forever. +const realWriteAndSync = realShared.writeAndSync; + +const contextSpy = mock(async (_opts: unknown): Promise => { + throw new Error('contextSpy: no context set for this test'); +}); +const writeSpy = mock(async (_ctx: ResolvedContext, _name: string, _value: string | undefined, _opts: { push: boolean }) => undefined); +mock.module('../../src/commands/connectors/shared', () => ({ + ...realShared, + resolveContext: contextSpy, + // The pushing path is the server's (tested elsewhere): it is recorded. The `.env`-only path is the real one. + writeAndSync: async (ctx: ResolvedContext, name: string, value: string | undefined, opts: { push: boolean }) => { + await writeSpy(ctx, name, value, opts); + if (!opts.push) await realWriteAndSync(ctx, name, value, opts); + }, +})); + +const prStepSpy = mock(async (_req: unknown) => ({ keep_lock: { changed: false } })); +mock.module('../../src/commands/keepLockPr', () => ({ ...realKeepLockPr, runKeepLockPrStep: prStepSpy })); + +const localOnlySpy = mock(() => false); +mock.module('../../src/config/profileConfig', () => ({ ...realProfileConfig, isLocalOnly: localOnlySpy })); + +import { EditCommand } from '../../src/commands/editCommand'; +import { FileManager } from '../../src/files/fileManager'; +import { ProjectManager } from '../../src/core/projectManager'; +import { readLocalPlaintext } from '../../src/sync/commit'; +import { ERROR_CODES } from '../../src/types/index'; +import { DRY_RUN_UNSUPPORTED_MESSAGES } from '../../src/commands/pipedValue'; + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('process.exit called'); + } +} + +const ORIGINAL_STDIN_TTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const ORIGINAL_STDOUT_TTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + +function setTTY(on: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { value: on, configurable: true, writable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: on, configurable: true, writable: true }); +} + +function restoreTTY(stream: NodeJS.WriteStream | NodeJS.ReadStream, original: PropertyDescriptor | undefined): void { + if (original === undefined) { + Reflect.deleteProperty(stream, 'isTTY'); + return; + } + Object.defineProperty(stream, 'isTTY', original); +} + +function renderCalls(calls: readonly unknown[][]): string { + return calls.map((args) => args.map(String).join(' ')).join('\n'); +} + +/** Runs `fn`, capturing stdout/stderr and the exit code; `process.exit` never exits the runner. */ +async function capture(fn: () => Promise) { + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code); + }) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + const thrown: unknown = await fn().then(() => null, (e: unknown) => e); + const stdout = renderCalls(logSpy.mock.calls); + const stderr = renderCalls(errSpy.mock.calls); + const exits = exitSpy.mock.calls; + [exitSpy, logSpy, errSpy].forEach((s) => s.mockRestore()); + return { exitCode: exits.length > 0 ? (exits[exits.length - 1][0] as number | undefined) : undefined, stdout, stderr, thrown: thrown instanceof ExitSignal ? null : thrown }; +} + +function fakeContext(localPlaintext: Record): ResolvedContext { + const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + return { + pm: new ProjectManager(TEST_DIR), + fileManager: new FileManager(TEST_DIR), + orgId: 'o', + projectId: 'p', + branch: 'development', + userId: 'u', + projectKey: PROJECT_KEY, + keep, + localPlaintext, + } as unknown as ResolvedContext; +} + +const questionsOf = (callIndex: number): readonly Record[] => promptSpy.mock.calls[callIndex][0] as readonly Record[]; + +beforeEach(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.chdir(TEST_DIR); + setTTY(true); + [promptSpy, contextSpy, writeSpy, prStepSpy, localOnlySpy, gateRan].forEach((m) => m.mockClear()); + localOnlySpy.mockImplementation(() => false); + contextSpy.mockImplementation(async () => fakeContext({})); +}); + +afterEach(() => { + process.chdir(ORIGINAL_CWD); + rmSync(TEST_DIR, { recursive: true, force: true }); + restoreTTY(process.stdin, ORIGINAL_STDIN_TTY); + restoreTTY(process.stdout, ORIGINAL_STDOUT_TTY); +}); + +describe('capy edit NAME on a terminal', () => { + test('asks once, with the masked password prompt capy add uses, and sets a new name', async () => { + const out = await capture(() => new EditCommand().execute({ name: 'NEW_NAME' })); + expect(out.exitCode).toBeUndefined(); + expect(out.thrown).toBeNull(); + expect(promptSpy.mock.calls.length).toBe(1); + const [question] = questionsOf(0); + expect(question).toMatchObject({ type: 'password', mask: '*' }); + expect(String(question.message)).toContain('NEW_NAME'); + expect(writeSpy.mock.calls.length).toBe(1); + const [, name, value, opts] = writeSpy.mock.calls[0]; + expect([name, value, opts]).toEqual(['NEW_NAME', FAKE_VALUE, { push: true }]); + expect(gateRan.mock.calls.length).toBe(1); // bare `capy` ran first, like add + expect(out.stdout).toContain('Saved 1 variable(s): NEW_NAME'); + }); + + test('overwrites an existing name without asking "overwrite?"', async () => { + contextSpy.mockImplementation(async () => fakeContext({ EXISTING: 'old-fake-value' })); + const out = await capture(() => new EditCommand().execute({ name: 'EXISTING' })); + expect(out.exitCode).toBeUndefined(); + expect(out.thrown).toBeNull(); + // One question in total: the value. No confirm question was ever asked. + expect(promptSpy.mock.calls.length).toBe(1); + expect(questionsOf(0).map((q) => q.type)).toEqual(['password']); + expect(writeSpy.mock.calls.map((c) => [c[1], c[2]])).toEqual([['EXISTING', FAKE_VALUE]]); + }); + + test('an empty answer is refused with INVALID_FORMAT and writes nothing', async () => { + promptSpy.mockImplementationOnce(async () => ({ value: '', ok: true })); + const out = await capture(() => new EditCommand().execute({ name: 'EMPTY_ONE' })); + expect(out.thrown).toMatchObject({ code: ERROR_CODES.INVALID_FORMAT }); + expect(out.exitCode).toBeUndefined(); + expect(writeSpy.mock.calls.length).toBe(0); + }); + + test('--no-push writes .env only: the value is in .env (encrypted), and nothing is pushed', async () => { + const out = await capture(() => new EditCommand().execute({ name: 'LOCAL_ONLY_WRITE', noPush: true })); + expect(out.exitCode).toBeUndefined(); + expect(out.thrown).toBeNull(); + expect(writeSpy.mock.calls.map((c) => c[3])).toEqual([{ push: false }]); + const held = readLocalPlaintext(new FileManager(TEST_DIR), PROJECT_KEY); + expect(held.LOCAL_ONLY_WRITE).toBe(FAKE_VALUE); + expect(out.stdout).toContain('.env only'); + }); + + test('--pr / --no-pr / --pr-base and --resolve/--conflict reach the same steps add uses, and the PR step is named edit', async () => { + const pr = { prBase: 'trunk' }; + const conflictFlags = { resolve: ['LEFT=local'], conflict: 'c_0123456789ab' }; + await capture(() => new EditCommand().execute({ name: 'WITH_FLAGS', pr, conflictFlags: conflictFlags as never })); + const [req] = prStepSpy.mock.calls[0] as [{ command: string; flags: unknown; records: readonly unknown[] }]; + expect(req.command).toBe('edit'); + expect(req.flags).toEqual(pr); + const [gateOpts] = gateRan.mock.calls[0] as [{ conflictFlags: unknown }]; + expect(gateOpts.conflictFlags).toEqual(conflictFlags); + }); + + test('--dry-run: DRY_RUN_UNSUPPORTED before any prompt, any read and any write', async () => { + const json = await capture(() => new EditCommand().execute({ name: 'DRY_ONE', dryRun: true, json: true })); + expect(json.exitCode).toBe(1); + expect(JSON.parse(json.stdout)).toEqual({ ok: false, code: ERROR_CODES.DRY_RUN_UNSUPPORTED, error: DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt }); + const human = await capture(() => new EditCommand().execute({ name: 'DRY_ONE', dryRun: true })); + expect(human.exitCode).toBe(1); + expect(human.stderr).toBe(DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt); + expect(promptSpy.mock.calls.length).toBe(0); + expect(contextSpy.mock.calls.length).toBe(0); + expect(writeSpy.mock.calls.length).toBe(0); + expect(gateRan.mock.calls.length).toBe(0); + }); + + test('no terminal and no piped value (--non-tty on a terminal): EDIT_NEEDS_TTY, no prompt', async () => { + const out = await capture(() => new EditCommand().execute({ name: 'NO_TTY', nonTty: true, json: true })); + expect(out.exitCode).toBe(3); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: ERROR_CODES.EDIT_NEEDS_TTY }); + expect(promptSpy.mock.calls.length).toBe(0); + expect(writeSpy.mock.calls.length).toBe(0); + }); + + test('a bad name is refused with INVALID_FORMAT, not echoed, before any prompt', async () => { + const out = await capture(() => new EditCommand().execute({ name: 'BAD-NAME=hunter2', json: true })); + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: ERROR_CODES.INVALID_FORMAT }); + expect(out.stdout).not.toContain('hunter2'); + expect(promptSpy.mock.calls.length).toBe(0); + }); + + test('a local-only profile is refused with a code (it has no organization to write to), before any prompt', async () => { + localOnlySpy.mockImplementation(() => true); + const out = await capture(() => new EditCommand().execute({ name: 'LOCAL_PROFILE', json: true })); + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: ERROR_CODES.EDIT_STDIN_LOCAL_ONLY }); + expect(promptSpy.mock.calls.length).toBe(0); + expect(writeSpy.mock.calls.length).toBe(0); + }); +}); + +describe('capy edit with no name still opens the editor', () => { + test('it does not prompt, resolve a write context or write: it goes on to the editor\'s own setup (keep.lock check)', async () => { + const out = await capture(() => new EditCommand().execute({})); + // The temp folder has no keep.lock: the editor's setup is what answers. + expect(out.exitCode).toBe(1); + expect(out.stderr).toContain('No keep.lock found'); + expect(gateRan.mock.calls.length).toBe(1); + expect(promptSpy.mock.calls.length).toBe(0); + expect(contextSpy.mock.calls.length).toBe(0); + expect(writeSpy.mock.calls.length).toBe(0); + }); +}); diff --git a/tests/run-tests.sh b/tests/run-tests.sh index ef59e404..242f973f 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -65,6 +65,7 @@ ISOLATED_FILES=( tests/commands/rotatePromotesThenRotates.test.ts tests/commands/rotateRefusals.test.ts tests/commands/removeCommand.test.ts + tests/commands/editNamePrompt.test.ts tests/commands/connectDokploy.test.ts tests/commands/gateFirst.test.ts tests/commands/bootstrapNoSecrets.test.ts From 183c3b15f2f312fe6a8f8a07c353eace6494ae59 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 2 Nov 2026 17:04:15 -0700 Subject: [PATCH 08/22] fix(vercel): say why a token stops a run, and confirm a saved token A 403 is no longer reported as a rejected token. It is VERCEL_NO_ACCESS and names what the token could not access: the team list, a project, or a project's environment variables. 401 stays VERCEL_AUTH_FAILED. Discovery says it needs a token scoped to the whole team. A token problem carries the token page and two numbered steps. At a terminal, Enter asks for a new token (saved over the old one) and runs again from the token step; one press is one retry. Elsewhere nothing waits. The system store prints one line naming the secret after saving a value from its hidden prompt. --- src/commands/connectCommand.ts | 2 +- src/commands/connectors/vercel.ts | 128 +++++-- src/commands/deployDiscover/vercel.ts | 8 +- src/deploy/adapters/vercel.ts | 7 +- src/deploy/keyHelp.ts | 22 ++ src/deploy/vercelApi.ts | 91 ++++- src/deploy/vercelSetup.ts | 2 +- src/deploy/vercelTargetApi.ts | 9 +- src/system/systemStore.ts | 72 ++-- src/types/index.ts | 4 +- .../connectVercelTokenProblem.test.ts | 351 ++++++++++++++++++ tests/deploy/vercelApi.test.ts | 51 ++- tests/system/systemStore.test.ts | 67 ++++ 13 files changed, 729 insertions(+), 85 deletions(-) create mode 100644 tests/commands/connectVercelTokenProblem.test.ts diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index ad5e471e..d351dc68 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -32,7 +32,7 @@ function printFailure(outcome: ConnectFailure, json: boolean): void { // The refusal says what is still needed, as flags: print each hint beside it. (outcome.unanswered ?? []).forEach((u) => console.error(` ${u.flag}: ${u.hint}`)); console.error(''); - if (outcome.remedy) console.error(formatKeyHelp({ remedy: outcome.remedy }) + '\n'); + if (outcome.remedy) console.error(formatKeyHelp({ remedy: outcome.remedy, lines: outcome.remedy.split('\n') }) + '\n'); } /** A refusal that only a flag (or a person) can answer exits `EXIT_NEEDS_INPUT`, so an agent branches on it; every other refusal exits 1. */ diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts index 596a7005..ac5b15e0 100644 --- a/src/commands/connectors/vercel.ts +++ b/src/commands/connectors/vercel.ts @@ -45,6 +45,7 @@ import { VercelSkippedRecord, VercelSystemStoreCallOptions, VercelTeam, + VercelTokenSource, availableEnvironments, buildEnvironmentPlans, createVercelClient, @@ -56,7 +57,8 @@ import { resolveVercelToken, vercelSecretsMayPrompt, } from '../../deploy/vercelApi'; -import { keyHelpFields, teamSlugOf, vercelKeyHelp } from '../../deploy/keyHelp'; +import { formatKeyHelp, keyHelpFields, teamSlugOf, vercelKeyHelp, vercelTokenProblemHelp } from '../../deploy/keyHelp'; +import { createInterface } from 'node:readline'; import { ConnectorMetadata, ERROR_CODES } from '../../types/index'; import { isInteractive } from '../../ui/interactive'; import { listOrgProjectsOrUnavailable } from '../capyCommand'; @@ -173,6 +175,30 @@ export interface VercelConnectorDeps { * singleton wires the real `system/systemStore.ts` lookup. */ getConnectorSecret?: (name: string, opts: VercelSystemStoreCallOptions) => Promise; + /** + * Asks for a NEW value of the system store entry (hidden input) and saves it + * over the old one. Absent (the default): a rejected token only shows the + * steps and stops; the exported `vercelConnector` singleton wires the real one. + */ + replaceConnectorSecret?: (name: string, opts: VercelSystemStoreCallOptions) => Promise; + /** Waits for the person to press Enter; `false` when they abort (Ctrl-C). Used only when `replaceConnectorSecret` is set. */ + waitForEnter?: () => Promise; +} + +/** Waits for Enter on the terminal. Ctrl-C (or a closed input) is `false`. */ +function defaultWaitForEnter(): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + rl.once('SIGINT', () => { + rl.close(); + resolve(false); + }); + rl.once('close', () => resolve(false)); + rl.question('', () => { + resolve(true); + rl.close(); + }); + }); } // ── Connector entry (keep.lock metadata) ──────────────────────────────────── @@ -214,11 +240,12 @@ interface SourceRead { truncated: boolean; } -async function guard(fn: () => Promise): Promise<{ ok: true; value: T } | Failure> { +/** Runs a Vercel read; a failure becomes a coded refusal. `discovery` makes a 403 on the team or project list say discovery needs a token scoped to the whole team. */ +async function guard(fn: () => Promise, discovery: boolean = false): Promise<{ ok: true; value: T } | Failure> { try { return { ok: true, value: await fn() }; } catch (err) { - return { ok: false, ...mapVercelApiError(err) }; + return { ok: false, ...mapVercelApiError(err, { discovery }) }; } } @@ -319,7 +346,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector orgId: string, opts: ConnectOpts, interactive: boolean, - ): Promise<{ ok: true; client: VercelClient; tokenEnv: string } | Failure> => { + ): Promise<{ ok: true; client: VercelClient; tokenEnv: string; tokenSource: VercelTokenSource } | Failure> => { const tokenEnv = opts.tokenEnv?.trim() || DEFAULT_VERCEL_TOKEN_ENV; // `--json` and `--dry-run` each suppress ONLY the system store's hidden // prompt: a raw terminal prompt must not be interleaved with machine @@ -341,17 +368,69 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector return { ok: true, tokenEnv, + tokenSource: resolved.source, client: createVercelClient(resolved.value, { fetch: deps.fetch, sleep: deps.sleep }), }; }; + /** Whether a rejected token can be replaced right here: a person at a terminal, a store-held token, and the means to ask. */ + const canReplaceToken = (opts: ConnectOpts, interactive: boolean, tokenSource: VercelTokenSource): boolean => + !!deps.replaceConnectorSecret && + tokenSource === 'system' && + vercelSecretsMayPrompt(interactive, !!opts.json || !!opts.dryRun); + + /** + * A token problem stops the run. At a terminal: say why and list the two + * steps, wait for Enter, ask for a new token (saved over the old one) and + * run again from the token step. One press, one retry: a token that fails + * again shows the steps again and waits again. Anywhere else (or Ctrl-C): + * the same coded refusal, with the steps in `remedy`. + */ + const withTokenRecovery = async ( + orgId: string, + opts: ConnectOpts, + interactive: boolean, + run: (opened: { client: VercelClient; tokenEnv: string }) => Promise, + ): Promise => { + const opened = await openClient(orgId, opts, interactive); + if (!opened.ok) return opened as T; + const outcome = await run(opened); + if (outcome.ok || !isTokenProblem(outcome.code) || !canReplaceToken(opts, interactive, opened.tokenSource)) return outcome; + + // The reason line is the refusal's own message; the two steps are approved copy. + console.error(`\n ${outcome.message}`); + console.error(formatKeyHelp(vercelTokenProblemHelp(VERCEL_CONNECTOR_SECRET_NAME, { pressEnter: true }))); + if (!(await (deps.waitForEnter ?? defaultWaitForEnter)())) return outcome; + + const replaced = await replaceToken(orgId, opts); + if (!replaced.ok) return replaced as T; + return withTokenRecovery(orgId, opts, interactive, run); + }; + + /** The store's hidden prompt for a new token. A refusal (not an admin, …) is returned with its own code; an empty answer saves nothing and stops. */ + const replaceToken = async (orgId: string, opts: ConnectOpts): Promise<{ ok: true } | Failure> => { + try { + const saved = await deps.replaceConnectorSecret!(VERCEL_CONNECTOR_SECRET_NAME, { + orgId, + interactive: true, + devMode: opts.devMode, + ...(teamSlugOf(opts.team) ? { helpTeam: teamSlugOf(opts.team) } : {}), + }); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return saved ? { ok: true } : { ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, message: 'No token was entered. Nothing was changed.' }; + } catch (err) { + return { ok: false, code: storeRefusalCode(err), message: err instanceof Error ? err.message : String(err) }; + } + }; + /** Team scope: `--team`, the token's only team, a pick (only when this run can ask), or a coded refusal. */ const chooseTeam = async ( client: VercelClient, opts: ConnectOpts, promptable: boolean, + discovery: boolean = false, ): Promise<{ ok: true; teamId: string | undefined; teamSlug?: string } | Failure> => { - const teams = await guard(() => client.listTeams()); + const teams = await guard(() => client.listTeams(), discovery); if (!teams.ok) return teams; const team = await resolveTeam(teams.value, opts.team, promptable ? pickTeam : null); return team; @@ -539,7 +618,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const dryRun = !!opts.dryRun; const promptable = interactive && !opts.json; - const team = await chooseTeam(client, opts, promptable); + const team = await chooseTeam(client, opts, promptable, true); if (!team.ok) return team; const scan = findCandidateReposResult(cwd()); @@ -552,6 +631,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector { peekLocalKeep: peekLocalKeepForFolder, listServerBranches: (projectId) => ctx.serviceClient.listBranches(projectId) }, environmentFilter, ), + true, ); if (!built.ok) return built; const plan: DiscoveryPlan = { @@ -670,11 +750,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector async import(ctx: ResolvedContext, opts: ConnectOpts): Promise { const interactive = isInteractive(opts.nonTty); - const opened = await openClient(ctx.orgId, opts, interactive); - if (!opened.ok) return opened; - - const outcome = await importFrom(ctx, opts, opened.client, interactive); - return withTokenHelp(outcome, opts); + return withTokenRecovery(ctx.orgId, opts, interactive, (opened) => importFrom(ctx, opts, opened.client, interactive)); }, /** @@ -686,11 +762,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector async discover(ctx: DiscoveryContext, opts: ConnectOpts): Promise { const interactive = isInteractive(opts.nonTty); - const opened = await openClient(ctx.orgId, opts, interactive); - if (!opened.ok) return opened; - - const outcome = await discoverFrom(ctx, opts, opened.client, opened.tokenEnv, interactive); - return withTokenHelp(outcome, opts); + return withTokenRecovery(ctx.orgId, opts, interactive, (opened) => discoverFrom(ctx, opts, opened.client, opened.tokenEnv, interactive)); }, reportDiscovery: reportVercelDiscovery, @@ -700,10 +772,15 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector // ── small helpers ─────────────────────────────────────────────────────────── -/** A token Vercel rejected: say where to get another. (A missing token already carries it, from `openClient`.) */ -function withTokenHelp(outcome: T, opts: ConnectOpts): T { - if (outcome.ok || outcome.link || outcome.code !== ERROR_CODES.VERCEL_AUTH_FAILED) return outcome; - return { ...outcome, ...keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME, { team: opts.team })) } as T; +/** A token problem the person can fix with a new token: rejected (401), or valid without access (403). */ +function isTokenProblem(code: string): boolean { + return code === ERROR_CODES.VERCEL_AUTH_FAILED || code === ERROR_CODES.VERCEL_NO_ACCESS; +} + +/** A typed store error (`CapyError`-shaped: `{code: string}`) as its code, never its text. */ +function storeRefusalCode(err: unknown): string { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : ERROR_CODES.VERCEL_TOKEN_MISSING; } const toImportSkip = (s: VercelSkippedRecord): ImportSkip => ({ name: s.name, code: s.reason, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) }); @@ -755,9 +832,18 @@ export async function vercelConnectorSecret(name: string, opts: VercelSystemStor }); } +/** The real store prompt that replaces `_CONNECTOR_VERCEL_TOKEN` with a new token (owners/admins only). */ +export async function vercelReplaceConnectorSecret(name: string, opts: VercelSystemStoreCallOptions): Promise { + const { replaceConnectorSecret } = await import('../../system/systemStore'); + return replaceConnectorSecret(name, opts); +} + /** * The `capy connect vercel` production instance — the only place this file * wires the org system store for real (every other construction, including * every test, gets the safe env-only default — see `VercelConnectorDeps`). */ -export const vercelConnector: ConnectorModule = createVercelConnector({ getConnectorSecret: vercelConnectorSecret }); +export const vercelConnector: ConnectorModule = createVercelConnector({ + getConnectorSecret: vercelConnectorSecret, + replaceConnectorSecret: vercelReplaceConnectorSecret, +}); diff --git a/src/commands/deployDiscover/vercel.ts b/src/commands/deployDiscover/vercel.ts index efcd45fc..191c3b4c 100644 --- a/src/commands/deployDiscover/vercel.ts +++ b/src/commands/deployDiscover/vercel.ts @@ -31,7 +31,6 @@ import { posix } from 'node:path'; import type { TargetConfig } from '../../deploy/adapter'; import { - VercelApiError, VercelClient, VercelCustomEnvironment, VercelEnvRecord, @@ -214,10 +213,9 @@ async function scanProject(client: VercelClient, project: VercelProject, team: S } function vercelFailure(err: unknown): DeployDiscoverResult { - const mapped = mapVercelApiError(err); - // A rejected token says where to get another. - const help = err instanceof VercelApiError && err.code === 'unauthorized' ? vercelTokenHelp() : {}; - return refuse(mapped.code, mapped.message, help); + const { code, message, link, remedy } = mapVercelApiError(err, { discovery: true }); + // A rejected token, or one without access, says where to get another and what to do. + return refuse(code, message, { ...(link ? { link } : {}), ...(remedy ? { remedy } : {}) }); } interface VercelWorld { diff --git a/src/deploy/adapters/vercel.ts b/src/deploy/adapters/vercel.ts index 1cf28b94..8fcd3035 100644 --- a/src/deploy/adapters/vercel.ts +++ b/src/deploy/adapters/vercel.ts @@ -80,6 +80,7 @@ import { sortedNames, verifyApplied, vercelTargetKeyHelp, + vercelTargetTokenProblemHelp, } from '../vercelTargetApi'; type VercelEnv = 'production' | 'preview'; @@ -626,9 +627,11 @@ export interface VercelAdapterDeps { const SET_ENV_LABEL = 'set Vercel env'; const VERIFY_LABEL = 'verify Vercel env'; -/** A rejected token (HTTP 401) says where to get another; any other failure adds nothing. */ +/** A rejected token (HTTP 401) or one without access (HTTP 403) says where to get another; any other failure adds nothing. */ function rejectedKeyHelp(code: string, team: string | undefined): { link?: { label: string; url: string }; remedy?: string } { - return code === ERROR_CODES.VERCEL_TOKEN_REJECTED ? vercelTargetKeyHelp(team) : {}; + if (code === ERROR_CODES.VERCEL_TOKEN_REJECTED) return vercelTargetKeyHelp(team); + if (code === ERROR_CODES.VERCEL_NO_ACCESS) return vercelTargetTokenProblemHelp(); + return {}; } function failResult(step: DeployStep): DeployResult { diff --git a/src/deploy/keyHelp.ts b/src/deploy/keyHelp.ts index ffbe19be..9156f89b 100644 --- a/src/deploy/keyHelp.ts +++ b/src/deploy/keyHelp.ts @@ -69,6 +69,28 @@ export function vercelKeyHelp(secretName: string, opts: KeyHelpOptions = {}): Ke }; } +export interface TokenProblemHelpOptions { + /** True when the run waits for Enter to replace the token right here; the second step then says so. */ + pressEnter?: boolean; +} + +/** + * What to do when a Vercel token stops a run (rejected, or valid but without + * access): two numbered steps. `remedy` is both, one per line. + */ +export function vercelTokenProblemHelp(secretName: string, opts: TokenProblemHelpOptions = {}): KeyHelp { + // Approved wording. + const first = `1) Create a token with team access at ${VERCEL_TOKEN_PAGE_URL}`; + // Approved wording. + const second = `2) Run capy system set ${secretName}${opts.pressEnter ? ' (or press enter to continue)' : ''}`; + return { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + link: { label: 'Vercel token page', url: VERCEL_TOKEN_PAGE_URL }, + remedy: `${first}\n${second}`, + lines: [first, second], + }; +} + /** The Dokploy page that makes API keys: inside the user's own Dokploy when its address is known, else the docs. */ export function dokployKeyPage(baseUrl: string | undefined): { label: string; url: string } { const base = baseUrl?.trim().replace(/\/+$/, ''); diff --git a/src/deploy/vercelApi.ts b/src/deploy/vercelApi.ts index 499a1184..953bcc4a 100644 --- a/src/deploy/vercelApi.ts +++ b/src/deploy/vercelApi.ts @@ -43,6 +43,7 @@ */ import { ERROR_CODES } from '../types/index'; import { sortedCopy } from './dokployApi'; +import { keyHelpFields, vercelTokenProblemHelp } from './keyHelp'; // ── Names ──────────────────────────────────────────────────────────────────── @@ -118,17 +119,30 @@ export interface VercelImportSource { export type VercelErrorCode = | 'unreachable' | 'unauthorized' + | 'forbidden' | 'not_found' | 'bad_request' | 'rate_limited' | 'server_error' | 'bad_response'; +/** + * What a request was reading, set by the client method that made it. A 403 + * carries it so the refusal can name what the token could not access: it is a + * structured field, never parsed out of a message. + */ +export type VercelResource = + | { readonly kind: 'teams' } + | { readonly kind: 'projects' } + | { readonly kind: 'project'; readonly project: string } + | { readonly kind: 'project_envs'; readonly project: string }; + export class VercelApiError extends Error { constructor( public readonly code: VercelErrorCode, public readonly status: number | null, message: string, + public readonly resource?: VercelResource, ) { super(message); this.name = 'VercelApiError'; @@ -136,22 +150,67 @@ export class VercelApiError extends Error { } function codeForStatus(status: number): VercelErrorCode { - if (status === 401 || status === 403) return 'unauthorized'; + if (status === 401) return 'unauthorized'; + if (status === 403) return 'forbidden'; if (status === 404) return 'not_found'; if (status === 429) return 'rate_limited'; if (status >= 400 && status < 500) return 'bad_request'; return 'server_error'; } +/** A failed Vercel call as a refusal: a stable code and a message, plus where to get a better token when the token is the problem. */ +export interface MappedVercelError { + code: string; + message: string; + link?: { label: string; url: string }; + remedy?: string; +} + +export interface MapVercelApiErrorOptions { + /** Discovery reads every team and project: a 403 on those lists says the token must cover the whole team. */ + discovery?: boolean; +} + +function deniedResource(resource: VercelResource | undefined): string { + if (!resource) return 'the request'; + if (resource.kind === 'teams') return 'the team list'; + if (resource.kind === 'projects') return 'the project list'; + if (resource.kind === 'project') return `project ${resource.project}`; + return `the environment variables of project ${resource.project}`; +} + +function forbiddenMessage(err: VercelApiError, discovery: boolean): string { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + const denied = `Vercel denied access to ${deniedResource(err.resource)} (HTTP ${err.status}).`; + const kind = err.resource?.kind; + if (discovery && (kind === 'teams' || kind === 'projects')) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return `${denied} Discovery needs a token scoped to the whole team.`; + } + return denied; +} + /** * A failed Vercel call as a stable refusal code + message. Never a value. - * Branches on the typed error's `code`, never on its text. + * Branches on the typed error's `code`, never on its text. A token problem + * (401 rejected, 403 no access) carries the token page and the two steps. */ -export function mapVercelApiError(err: unknown): { code: string; message: string } { +export function mapVercelApiError(err: unknown, options: MapVercelApiErrorOptions = {}): MappedVercelError { if (err instanceof VercelApiError) { if (err.code === 'unauthorized') { - // COPY-FLAG: new user-facing string, minimal/neutral wording. - return { code: ERROR_CODES.VERCEL_AUTH_FAILED, message: `Vercel rejected the token (HTTP ${err.status}).` }; + return { + code: ERROR_CODES.VERCEL_AUTH_FAILED, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `Vercel rejected the token (HTTP ${err.status}).`, + ...keyHelpFields(vercelTokenProblemHelp(VERCEL_CONNECTOR_SECRET_NAME)), + }; + } + if (err.code === 'forbidden') { + return { + code: ERROR_CODES.VERCEL_NO_ACCESS, + message: forbiddenMessage(err, options.discovery === true), + ...keyHelpFields(vercelTokenProblemHelp(VERCEL_CONNECTOR_SECRET_NAME)), + }; } if (err.code === 'rate_limited') { return { code: ERROR_CODES.VERCEL_RATE_LIMITED, message: err.message }; @@ -286,7 +345,7 @@ export function createVercelClient(token: string, options: VercelClientOptions = return `${base}${path}${qs ? `?${qs}` : ''}`; }; - const attempt = async (url: string, retriesLeft: number): Promise => { + const attempt = async (url: string, retriesLeft: number, resource: VercelResource): Promise => { const res = await fetchImpl(url, { method: 'GET', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, @@ -303,24 +362,24 @@ export function createVercelClient(token: string, options: VercelClientOptions = throw new VercelApiError('rate_limited', 429, 'Vercel rate limit reached; try again later.'); } await sleep(waitMs); - return attempt(url, retriesLeft - 1); + return attempt(url, retriesLeft - 1, resource); } if (!res.ok) { // COPY-FLAG: new user-facing string, minimal/neutral wording. - throw new VercelApiError(codeForStatus(res.status), res.status, `Vercel returned HTTP ${res.status}.`); + throw new VercelApiError(codeForStatus(res.status), res.status, `Vercel returned HTTP ${res.status}.`, resource); } return parseJson(text); }; - const get = (path: string, query: Readonly> = {}): Promise => - attempt(urlFor(path, query), maxRetries); + const get = (path: string, resource: VercelResource, query: Readonly> = {}): Promise => + attempt(urlFor(path, query), maxRetries, resource); const enc = encodeURIComponent; return { async listTeams() { const collect = async (until: string | undefined, seen: readonly string[], page: number): Promise => { - const body = asRecord(await get('/v2/teams', { limit: PAGE_LIMIT, until })); + const body = asRecord(await get('/v2/teams', { kind: 'teams' }, { limit: PAGE_LIMIT, until })); const teams = (Array.isArray(body?.teams) ? body.teams : []).flatMap((t) => { const parsed = parseTeam(t); return parsed ? [parsed] : []; @@ -335,7 +394,7 @@ export function createVercelClient(token: string, options: VercelClientOptions = async listProjects(teamId) { const collect = async (from: string | undefined, seen: readonly string[], page: number): Promise => { - const body = await get('/v10/projects', { limit: PAGE_LIMIT, from, teamId }); + const body = await get('/v10/projects', { kind: 'projects' }, { limit: PAGE_LIMIT, from, teamId }); const list = Array.isArray(body) ? body : asRecord(body)?.projects; const projects = (Array.isArray(list) ? list : []).flatMap((p) => { const parsed = parseProject(p); @@ -353,7 +412,7 @@ export function createVercelClient(token: string, options: VercelClientOptions = }, async getProject(idOrName, teamId) { - const parsed = parseProject(await get(`/v9/projects/${enc(idOrName)}`, { teamId })); + const parsed = parseProject(await get(`/v9/projects/${enc(idOrName)}`, { kind: 'project', project: idOrName }, { teamId })); if (!parsed) { // COPY-FLAG: new user-facing string, minimal/neutral wording. throw new VercelApiError('bad_response', null, 'Vercel returned a project this client cannot read.'); @@ -362,7 +421,7 @@ export function createVercelClient(token: string, options: VercelClientOptions = }, async listCustomEnvironments(projectId, teamId) { - const body = asRecord(await get(`/v9/projects/${enc(projectId)}/custom-environments`, { teamId })); + const body = asRecord(await get(`/v9/projects/${enc(projectId)}/custom-environments`, { kind: 'project_envs', project: projectId }, { teamId })); return (Array.isArray(body?.environments) ? body.environments : []).flatMap((e) => { const rec = asRecord(e); const id = nonEmptyString(rec?.id); @@ -372,7 +431,7 @@ export function createVercelClient(token: string, options: VercelClientOptions = }, async listEnvs(projectId, teamId) { - const body = asRecord(await get(`/v10/projects/${enc(projectId)}/env`, { teamId })); + const body = asRecord(await get(`/v10/projects/${enc(projectId)}/env`, { kind: 'project_envs', project: projectId }, { teamId })); const envs = (Array.isArray(body?.envs) ? body.envs : []).flatMap((e) => { const parsed = parseEnvRecord(e, options.dropValues === true); return parsed ? [parsed] : []; @@ -382,7 +441,7 @@ export function createVercelClient(token: string, options: VercelClientOptions = }, async getEnvValue(projectId, envId, teamId) { - const body = asRecord(await get(`/v1/projects/${enc(projectId)}/env/${enc(envId)}`, { teamId })); + const body = asRecord(await get(`/v1/projects/${enc(projectId)}/env/${enc(envId)}`, { kind: 'project_envs', project: projectId }, { teamId })); return typeof body?.value === 'string' ? body.value : undefined; }, }; diff --git a/src/deploy/vercelSetup.ts b/src/deploy/vercelSetup.ts index 112a4a36..bf2d4fab 100644 --- a/src/deploy/vercelSetup.ts +++ b/src/deploy/vercelSetup.ts @@ -356,7 +356,7 @@ export async function resolveVercelSetup( if (verified.kind === 'unverified') { // COPY-FLAG: minimal neutral wording. console.log(` ${DIM(`Could not verify against Vercel (${verified.code}) — saving as entered.`)}`); - if (verified.code === ERROR_CODES.VERCEL_AUTH_FAILED) printTokenInstructions(verified.scope.teamSlug ?? linkTeam); + if (verified.code === ERROR_CODES.VERCEL_AUTH_FAILED || verified.code === ERROR_CODES.VERCEL_NO_ACCESS) printTokenInstructions(verified.scope.teamSlug ?? linkTeam); return savedAsEntered(input, verified.scope); } diff --git a/src/deploy/vercelTargetApi.ts b/src/deploy/vercelTargetApi.ts index f06b945c..63ea3763 100644 --- a/src/deploy/vercelTargetApi.ts +++ b/src/deploy/vercelTargetApi.ts @@ -41,7 +41,7 @@ */ import { createHash } from 'crypto'; import { ERROR_CODES, ErrorCode } from '../types/index'; -import { keyHelpFields, vercelKeyHelp } from './keyHelp'; +import { keyHelpFields, vercelKeyHelp, vercelTokenProblemHelp } from './keyHelp'; // ── Token: system store, same shape as Dokploy's ─────────────────────────── @@ -156,6 +156,11 @@ export function vercelTargetKeyHelp(team?: string): { link: { label: string; url return keyHelpFields(vercelKeyHelp(VERCEL_TARGET_SECRET_NAME, { team })); } +/** The two steps for a deploy token that is valid but has no access: the token page and `capy system set` for the deploy-side name. */ +export function vercelTargetTokenProblemHelp(): { link: { label: string; url: string }; remedy: string } { + return keyHelpFields(vercelTokenProblemHelp(VERCEL_TARGET_SECRET_NAME)); +} + /** A token refusal as a printable reason + hint. Names only, never a value. */ export function describeVercelTokenProblem(code: string): { reason: string; hint: string } { if (code === ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY) { @@ -823,7 +828,7 @@ export function describeVercelFailure( } if (code === ERROR_CODES.VERCEL_NO_ACCESS) { // COPY-FLAG - return { reason: `the Vercel token has no access to this project${http}` }; + return { reason: `the Vercel token has no access to this project${http}`, ...vercelTargetTokenProblemHelp() }; } if (code === ERROR_CODES.VERCEL_PROJECT_NOT_FOUND) { // COPY-FLAG diff --git a/src/system/systemStore.ts b/src/system/systemStore.ts index a87ea4e0..d49eec46 100644 --- a/src/system/systemStore.ts +++ b/src/system/systemStore.ts @@ -416,6 +416,46 @@ function printKeyHelpBeforePrompt(name: string, helpBaseUrl: string | undefined, if (help) console.error(formatKeyHelp(help)); } +/** + * The hidden prompt for one secret, then the save, then one line saying it was + * saved (the name only, never the value). The line goes to stderr with the key + * help, so it never mixes with machine output. An empty answer saves nothing. + */ +async function promptAndSaveSecret( + ctx: SystemStoreContext, + name: string, + help: { baseUrl: string | undefined; team: string | undefined }, +): Promise { + printKeyHelpBeforePrompt(name, help.baseUrl, help.team); + + const { value } = await inquirer.prompt([ + { + type: 'password', + name: 'value', + message: `Enter a value for ${name}:`, // COPY-FLAG + mask: '*', + }, + ]); + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (!trimmed) return null; + + await commitSystemStore(ctx, setting(name, trimmed)); + process.stderr.write(`Saved ${name} in the org system store.\n`); // COPY-FLAG + return trimmed; +} + +/** + * Asks for a NEW value of a connector's secret (hidden input) and saves it + * over the stored one, through the same save path as the first-use prompt. + * Opening the store requires org owner/admin: anyone else gets the store's own + * coded refusal. Returns `null` when nothing was entered (nothing is saved). + */ +export async function replaceConnectorSecret(name: string, opts: GetConnectorSecretOptions): Promise { + assertValidConnectorName(name); + const ctx = await openSystemStoreContext(opts); + return promptAndSaveSecret(ctx, name, { baseUrl: opts.helpBaseUrl, team: opts.helpTeam }); +} + /** * Returns a connector's secret, prompting to collect and save it when it's * missing and the caller can ask a human. @@ -433,21 +473,7 @@ export async function getConnectorSecret(name: string, opts: GetConnectorSecretO if (existing !== null) return existing; if (!opts.interactive) return null; - printKeyHelpBeforePrompt(name, opts.helpBaseUrl, opts.helpTeam); - - const { value } = await inquirer.prompt([ - { - type: 'password', - name: 'value', - message: `Enter a value for ${name}:`, // COPY-FLAG - mask: '*', - }, - ]); - const trimmed = typeof value === 'string' ? value.trim() : ''; - if (!trimmed) return null; - - await commitSystemStore(ctx, setting(name, trimmed)); - return trimmed; + return promptAndSaveSecret(ctx, name, { baseUrl: opts.helpBaseUrl, team: opts.helpTeam }); } export interface GetDirectionalSecretOptions { @@ -555,19 +581,5 @@ export async function getDirectionalConnectorSecret( // else "add new" — fall through to the same prompt case 2 uses. } - printKeyHelpBeforePrompt(primaryName, opts.helpBaseUrl, opts.helpTeam); - - const { value } = await inquirer.prompt([ - { - type: 'password', - name: 'value', - message: `Enter a value for ${primaryName}:`, // COPY-FLAG - mask: '*', - }, - ]); - const trimmed = typeof value === 'string' ? value.trim() : ''; - if (!trimmed) return null; - - await commitSystemStore(ctx, setting(primaryName, trimmed)); - return trimmed; + return promptAndSaveSecret(ctx, primaryName, { baseUrl: opts.helpBaseUrl, team: opts.helpTeam }); } diff --git a/src/types/index.ts b/src/types/index.ts index 28521919..c4eb0447 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -739,7 +739,7 @@ export const ERROR_CODES = { VERCEL_TOKEN_MISSING: 'VERCEL_TOKEN_MISSING', /** `_CONNECTOR_VERCEL_TOKEN` is absent, `_TARGET_VERCEL_TOKEN` holds a token, and there is no TTY to ask which to use. */ VERCEL_CONNECTOR_KEY_MISSING: 'VERCEL_CONNECTOR_KEY_MISSING', - /** Vercel answered 401/403 to the token. */ + /** Vercel answered 401 to the token: it is invalid or expired. */ VERCEL_AUTH_FAILED: 'VERCEL_AUTH_FAILED', /** Any other failed or unreadable Vercel response. */ VERCEL_API_ERROR: 'VERCEL_API_ERROR', @@ -766,7 +766,7 @@ export const ERROR_CODES = { VERCEL_TARGET_KEY_MISSING: 'VERCEL_TARGET_KEY_MISSING', /** Vercel answered 401: the token is not valid. */ VERCEL_TOKEN_REJECTED: 'VERCEL_TOKEN_REJECTED', - /** Vercel answered 403: the token has no access to this project or team. */ + /** Vercel answered 403: the token is valid but cannot access what was asked for (the team list, a project, or a project's environment variables). */ VERCEL_NO_ACCESS: 'VERCEL_NO_ACCESS', /** The request never got an answer from Vercel. */ VERCEL_NETWORK_ERROR: 'VERCEL_NETWORK_ERROR', diff --git a/tests/commands/connectVercelTokenProblem.test.ts b/tests/commands/connectVercelTokenProblem.test.ts new file mode 100644 index 00000000..fe0d0bec --- /dev/null +++ b/tests/commands/connectVercelTokenProblem.test.ts @@ -0,0 +1,351 @@ +/** + * A Vercel token problem stops a run with the real reason and the next step. + * + * - 401 stays `VERCEL_AUTH_FAILED` (the token is invalid or expired). + * - 403 is `VERCEL_NO_ACCESS` and names what the token could not access: the + * team list, a project, or a project's environment variables. + * - Both carry the token page and two numbered steps (`link` + `remedy`). + * - At a terminal the steps end "(or press enter to continue)": Enter asks + * for a new token (saved over the old one) and runs again from the token + * step; one press is one retry, so a token that fails again shows the steps + * again and waits again. Anywhere else nothing waits. + * - `--discover` needs a token scoped to the whole team. + * + * Fake tokens only. No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { createVercelClient, type VercelFetchLike } from '../../src/deploy/vercelApi'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { ERROR_CODES, CapyError, type KeepFile } from '../../src/types/index'; +import type { ConnectOpts, ImportOutcome } from '../../src/commands/connectors/registry'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import { callsOf, fakeVercelFetch, project, type FakeVercelData } from '../helpers/fakeVercel'; +import { makeVercelWorld } from '../helpers/deployDiscoverVercelWorld'; +import { describeVercelFailure } from '../../src/deploy/vercelTargetApi'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { captureConsole, withExitCodeRestored, withTTY } from '../helpers/testSandbox'; + +const PAGE = 'https://vercel.com/account/settings/tokens'; +const STEP_1 = `1) Create a token with team access at ${PAGE}`; +const STEP_2 = '2) Run capy system set _CONNECTOR_VERCEL_TOKEN'; +const STEP_2_ENTER = `${STEP_2} (or press enter to continue)`; +const OLD_TOKEN = 'tok_old_fake_value'; +const NEW_TOKEN = 'tok_new_fake_value'; + +const DATA: FakeVercelData = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: [{ id: 'env_1', key: 'PLAIN_PROD', type: 'plain', target: ['production'], listValue: 'v-1' }] }, + custom: { prj_web: [] }, +}; + +const ctxWith = (): ResolvedContext => + ({ + keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} } as KeepFile, + branch: 'production', + orgId: 'org_1', + localPlaintext: {}, + }) as unknown as ResolvedContext; + +const denied = { status: 403, ok: false, headers: { get: () => null }, text: async () => '{}' }; + +/** The fake Vercel, except that every path matching `deny` answers 403. */ +const denying = (deny: RegExp): VercelFetchLike => { + const inner = fakeVercelFetch(DATA); + return (async (url: string, init: never) => (deny.test(new URL(url).pathname) ? denied : inner(url, init))) as VercelFetchLike; +}; + +/** Accepts only `accepted` as the bearer token; any other token is `status`. */ +const acceptingOnly = (accepted: string, status: 401 | 403): VercelFetchLike => { + const inner = fakeVercelFetch(DATA); + return (async (url: string, init: { headers: Record }) => + init.headers.Authorization === `Bearer ${accepted}` ? inner(url, init as never) : { ...denied, status }) as VercelFetchLike; +}; + +const BASE: ConnectOpts = { nonTty: true, project: 'web', tokenEnv: 'T' }; +const failure = (o: ImportOutcome): Extract => { + if (o.ok) throw new Error('expected a refusal'); + return o; +}; + +describe('a token problem names the reason and carries the two steps', () => { + test('401 stays VERCEL_AUTH_FAILED: same code and message as before, plus the link and the two steps', async () => { + const connector = createVercelConnector({ fetch: acceptingOnly('other', 401), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const out = failure(await connector.import!(ctxWith(), BASE)); + expect(out.code).toBe(ERROR_CODES.VERCEL_AUTH_FAILED); + expect(out.message).toBe('Vercel rejected the token (HTTP 401).'); + expect(out.link?.url).toBe(PAGE); + expect(out.remedy).toBe(`${STEP_1}\n${STEP_2}`); + }); + + test('403 on the team list: VERCEL_NO_ACCESS naming the team list', async () => { + const connector = createVercelConnector({ fetch: denying(/^\/v2\/teams$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const out = failure(await connector.import!(ctxWith(), BASE)); + expect(out.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(out.message).toBe('Vercel denied access to the team list (HTTP 403).'); + expect(out.link?.url).toBe(PAGE); + expect(out.remedy).toBe(`${STEP_1}\n${STEP_2}`); + }); + + test('403 on the project: VERCEL_NO_ACCESS naming the project', async () => { + const connector = createVercelConnector({ fetch: denying(/^\/v9\/projects\/web$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const out = failure(await connector.import!(ctxWith(), BASE)); + expect(out.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(out.message).toBe('Vercel denied access to project web (HTTP 403).'); + expect(out.remedy).toBe(`${STEP_1}\n${STEP_2}`); + }); + + test("403 on the project's environment variables: VERCEL_NO_ACCESS naming them, for the list and for a value", async () => { + const list = createVercelConnector({ fetch: denying(/^\/v10\/projects\/prj_web\/env$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const listed = failure(await list.import!(ctxWith(), BASE)); + expect(listed.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(listed.message).toBe('Vercel denied access to the environment variables of project prj_web (HTTP 403).'); + + const withEncrypted: FakeVercelData = { ...DATA, envs: { prj_web: [{ id: 'env_2', key: 'ENC', type: 'encrypted', target: ['production'], listValue: 'opaque' }] } }; + const inner = fakeVercelFetch(withEncrypted); + const valueDenied = (async (url: string, init: never) => (/\/v1\/projects\//.test(url) ? denied : inner(url, init))) as VercelFetchLike; + const value = createVercelConnector({ fetch: valueDenied, env: { T: OLD_TOKEN }, sleep: async () => {} }); + const read = failure(await value.import!(ctxWith(), BASE)); + expect(read.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(read.message).toBe('Vercel denied access to the environment variables of project prj_web (HTTP 403).'); + }); + + test('a 500 is still VERCEL_API_ERROR, with no steps', async () => { + const down: VercelFetchLike = async () => ({ status: 500, ok: false, text: async () => '{}' }); + const connector = createVercelConnector({ fetch: down, env: { T: OLD_TOKEN }, sleep: async () => {} }); + const out = failure(await connector.import!(ctxWith(), BASE)); + expect(out.code).toBe(ERROR_CODES.VERCEL_API_ERROR); + expect(out.remedy).toBeUndefined(); + }); + + test('--discover: a 403 on the team list or the project list says discovery needs a token scoped to the whole team', async () => { + const teamsDenied = createVercelConnector({ fetch: denying(/^\/v2\/teams$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const a = await teamsDenied.discover!({ orgId: 'o', userId: 'u', authService: {}, serviceClient: {} } as never, { nonTty: true, discover: true, tokenEnv: 'T' }); + expect(a).toMatchObject({ + ok: false, + code: ERROR_CODES.VERCEL_NO_ACCESS, + message: 'Vercel denied access to the team list (HTTP 403). Discovery needs a token scoped to the whole team.', + }); + + const projectsDenied = createVercelConnector({ fetch: denying(/^\/v10\/projects$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const b = await projectsDenied.discover!( + { orgId: 'o', userId: 'u', authService: {}, serviceClient: { listBranches: async () => [] } } as never, + { nonTty: true, discover: true, tokenEnv: 'T', yes: true }, + ); + expect(b).toMatchObject({ + ok: false, + code: ERROR_CODES.VERCEL_NO_ACCESS, + message: 'Vercel denied access to the project list (HTTP 403). Discovery needs a token scoped to the whole team.', + }); + expect((b as { remedy?: string }).remedy).toBe(`${STEP_1}\n${STEP_2}`); + }); + + test('capy deploy vercel --discover: a 403 uses the same code, the whole-team message, and the steps', async () => { + const world = makeVercelWorld({ + io: { vercel: (token) => createVercelClient(token, { fetch: (async () => denied) as never, sleep: async () => {}, dropValues: true }) }, + }); + const result = await runDeployVercelDiscover({}, world.io); + expect(result.exitCode).toBe(1); + expect(result.body).toMatchObject({ + ok: false, + code: ERROR_CODES.VERCEL_NO_ACCESS, + error: 'Vercel denied access to the team list (HTTP 403). Discovery needs a token scoped to the whole team.', + link: { url: PAGE }, + remedy: `${STEP_1}\n${STEP_2}`, + }); + }); + + test('capy deploy vercel --discover: a 401 stays VERCEL_AUTH_FAILED', async () => { + const world = makeVercelWorld({ acceptedToken: 'something-else' }); + const result = await runDeployVercelDiscover({}, world.io); + expect(result.body).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_AUTH_FAILED, remedy: `${STEP_1}\n${STEP_2}` }); + }); +}); + +describe('at a terminal: Enter replaces the token and runs again', () => { + /** A connector whose store hands out `OLD_TOKEN` then `tokens`, one per read, and a Vercel that accepts only `accepted`. */ + function interactive(args: { accepted: string; status?: 401 | 403; reads: readonly string[]; presses: readonly boolean[]; replaced?: string | null }) { + const reads = mock(async (_n: string, _o: unknown) => null as string | null); + args.reads.slice(0, -1).forEach((t) => reads.mockResolvedValueOnce(t)); + reads.mockResolvedValue(args.reads[args.reads.length - 1]); + const presses = mock(async () => false); + args.presses.forEach((p) => presses.mockResolvedValueOnce(p)); + const replace = mock(async (_n: string, _o: unknown) => (args.replaced === undefined ? NEW_TOKEN : args.replaced)); + const fetchMock = mock(acceptingOnly(args.accepted, args.status ?? 403)); + const connector = createVercelConnector({ + fetch: fetchMock as unknown as VercelFetchLike, + env: {}, + sleep: async () => {}, + getConnectorSecret: reads, + replaceConnectorSecret: replace, + waitForEnter: presses, + selectVars: async (names) => names, + confirm: async () => false, + }); + return { connector, reads, presses, replace, fetchMock }; + } + const run = (connector: ReturnType, over: Partial = {}) => + withTTY(() => captureConsole(() => connector.import!(ctxWith(), { nonTty: false, project: 'web', ...over }))); + + test('the reason, then the two steps in order with the link and the press-enter clause, then waits', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [false] }); + const { result, stderr } = await run(t.connector); + const text = stderr.join('\n'); + expect(failure(result).code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(text).toContain('Vercel denied access to the team list (HTTP 403).'); + expect(text.indexOf(STEP_1)).toBeGreaterThan(text.indexOf('Vercel denied access')); + expect(text.indexOf(STEP_2_ENTER)).toBeGreaterThan(text.indexOf(STEP_1)); + expect(text).toContain(PAGE); + expect(t.presses).toHaveBeenCalledTimes(1); + }); + + test('Enter → hidden prompt for a new token → saved → run again with it, and the run succeeds', async () => { + const t = interactive({ accepted: NEW_TOKEN, reads: [OLD_TOKEN, NEW_TOKEN], presses: [true] }); + const { result, stderr } = await run(t.connector); + expect(result.ok).toBe(true); + expect(t.presses).toHaveBeenCalledTimes(1); + expect(t.replace).toHaveBeenCalledTimes(1); + expect(t.replace.mock.calls[0][0]).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(t.replace.mock.calls[0][1]).toMatchObject({ orgId: 'org_1', interactive: true }); + const bearers = callsOf(t.fetchMock).map((c) => c.authorization); + expect(bearers[0]).toBe(`Bearer ${OLD_TOKEN}`); + expect(bearers[bearers.length - 1]).toBe(`Bearer ${NEW_TOKEN}`); + expect(stderr.join('\n')).not.toContain(NEW_TOKEN); + }); + + test('Enter → the new token fails too → the steps are shown again and it waits again; no loop without another press', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN, NEW_TOKEN], presses: [true, false] }); + const { result, stderr } = await run(t.connector); + expect(failure(result).code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(t.presses).toHaveBeenCalledTimes(2); + expect(t.replace).toHaveBeenCalledTimes(1); + expect(stderr.filter((l) => l.includes(STEP_2_ENTER))).toHaveLength(2); + expect(stderr.filter((l) => l.includes('Vercel denied access to the team list')).length).toBe(2); + }); + + test('every press is one more retry: two presses, two replacements', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN, 'tok_b', 'tok_c'], presses: [true, true, false] }); + await run(t.connector); + expect(t.presses).toHaveBeenCalledTimes(3); + expect(t.replace).toHaveBeenCalledTimes(2); + }); + + test('a 401 offers the same Enter', async () => { + const t = interactive({ accepted: NEW_TOKEN, status: 401, reads: [OLD_TOKEN, NEW_TOKEN], presses: [true] }); + const { result, stderr } = await run(t.connector); + expect(result.ok).toBe(true); + expect(stderr.join('\n')).toContain('Vercel rejected the token (HTTP 401).'); + expect(stderr.join('\n')).toContain(STEP_2_ENTER); + }); + + test('abort (Ctrl-C): the same coded refusal, nothing replaced', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [false] }); + const { result } = await run(t.connector); + expect(failure(result)).toMatchObject({ code: ERROR_CODES.VERCEL_NO_ACCESS, link: { url: PAGE }, remedy: `${STEP_1}\n${STEP_2}` }); + expect(t.replace).not.toHaveBeenCalled(); + }); + + test('an empty answer at the new-token prompt stops with a refusal and does not retry', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [true], replaced: null }); + const { result } = await run(t.connector); + expect(failure(result).code).toBe(ERROR_CODES.VERCEL_TOKEN_MISSING); + expect(callsOf(t.fetchMock).every((c) => c.authorization === `Bearer ${OLD_TOKEN}`)).toBe(true); + }); + + test("a non-admin: the store's own coded refusal is returned, not silence", async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [true] }); + const refusing = createVercelConnector({ + fetch: t.fetchMock as unknown as VercelFetchLike, + env: {}, + getConnectorSecret: t.reads, + waitForEnter: t.presses, + replaceConnectorSecret: async () => { + throw new CapyError('owners/admins only', ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY); + }, + }); + const { result } = await run(refusing); + expect(failure(result)).toMatchObject({ code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY, message: 'owners/admins only' }); + }); + + test('--discover offers the same Enter and retries the whole discovery', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [false] }); + const { result, stderr } = await withTTY(() => + captureConsole(() => + t.connector.discover!({ orgId: 'o', userId: 'u', authService: {}, serviceClient: {} } as never, { nonTty: false, discover: true }), + ), + ); + expect(result).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_NO_ACCESS }); + expect(stderr.join('\n')).toContain('Discovery needs a token scoped to the whole team.'); + expect(stderr.join('\n')).toContain(STEP_2_ENTER); + expect(t.presses).toHaveBeenCalledTimes(1); + }); +}); + +describe('anywhere else: nothing waits, and the steps have no press-enter clause', () => { + const never = mock(async () => true); + const replace = mock(async () => NEW_TOKEN); + const withDeps = (env: Record) => + createVercelConnector({ + fetch: acceptingOnly('never', 403), + env, + sleep: async () => {}, + getConnectorSecret: async () => OLD_TOKEN, + replaceConnectorSecret: replace, + waitForEnter: never, + }); + + test('--non-tty', async () => { + const { result, stderr } = await captureConsole(() => withDeps({}).import!(ctxWith(), { nonTty: true, project: 'web' })); + expect(failure(result).remedy).toBe(`${STEP_1}\n${STEP_2}`); + expect(failure(result).remedy).not.toContain('press enter'); + expect(stderr).toEqual([]); + expect(never).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); + }); + + test('--json and --dry-run at a terminal', async () => { + const json = await withTTY(() => captureConsole(() => withDeps({}).import!(ctxWith(), { nonTty: false, project: 'web', json: true }))); + const dry = await withTTY(() => captureConsole(() => withDeps({}).import!(ctxWith(), { nonTty: false, project: 'web', dryRun: true }))); + expect(failure(json.result).remedy).toBe(`${STEP_1}\n${STEP_2}`); + expect(failure(dry.result).remedy).toBe(`${STEP_1}\n${STEP_2}`); + expect([...json.stderr, ...dry.stderr]).toEqual([]); + expect(never).not.toHaveBeenCalled(); + }); + + test('a token that came from the environment, not the store, is not replaceable here', async () => { + const connector = createVercelConnector({ + fetch: acceptingOnly('never', 403), + env: { T: OLD_TOKEN }, + sleep: async () => {}, + replaceConnectorSecret: replace, + waitForEnter: never, + }); + const { result } = await withTTY(() => captureConsole(() => connector.import!(ctxWith(), { nonTty: false, project: 'web', tokenEnv: 'T' }))); + expect(failure(result).code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(never).not.toHaveBeenCalled(); + }); +}); + +describe('the same steps, printed', () => { + test('connect: JSON carries the link and the two steps in `remedy`; the terminal prints one indented line per step', async () => { + const connector = createVercelConnector({ fetch: denying(/^\/v2\/teams$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const opts: ConnectOpts = { nonTty: true, project: 'web', tokenEnv: 'T' }; + const json = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeImport'](connector, 'vercel', ctxWith(), { ...opts, json: true }))); + expect(JSON.parse(json.stdout[0])).toMatchObject({ + ok: false, + code: ERROR_CODES.VERCEL_NO_ACCESS, + link: { url: PAGE }, + remedy: `${STEP_1}\n${STEP_2}`, + }); + const human = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeImport'](connector, 'vercel', ctxWith(), { ...opts, json: false }))); + expect(human.stderr.join('\n')).toContain(` ${STEP_1}\n ${STEP_2}`); + }); + + test('deploy: a 403 on the deploy token names the deploy-side secret in step 2 and carries the link', () => { + const described = describeVercelFailure(ERROR_CODES.VERCEL_NO_ACCESS, 403); + expect(described.reason).toBe('the Vercel token has no access to this project (HTTP 403)'); + expect(described.link?.url).toBe(PAGE); + expect(described.remedy).toBe(`${STEP_1}\n2) Run capy system set _TARGET_VERCEL_TOKEN`); + }); +}); diff --git a/tests/deploy/vercelApi.test.ts b/tests/deploy/vercelApi.test.ts index 54b1af24..25472fc9 100644 --- a/tests/deploy/vercelApi.test.ts +++ b/tests/deploy/vercelApi.test.ts @@ -228,11 +228,52 @@ describe('rate limits — 429', () => { describe('errors map to stable codes and never carry the token', () => { const status = (s: number): VercelFetchLike => async () => ({ status: s, ok: false, text: async () => JSON.stringify({ error: { message: TOKEN } }) }); - test('401/403 → VERCEL_AUTH_FAILED', async () => { - for (const s of [401, 403]) { - const err = await createVercelClient(TOKEN, { fetch: status(s) }).listTeams().catch((e: unknown) => e); - expect(mapVercelApiError(err).code).toBe(ERROR_CODES.VERCEL_AUTH_FAILED); - } + const STEPS = [ + '1) Create a token with team access at https://vercel.com/account/settings/tokens', + '2) Run capy system set _CONNECTOR_VERCEL_TOKEN', + ].join('\n'); + + test('401 → VERCEL_AUTH_FAILED, with the token page and the two steps', async () => { + const err = await createVercelClient(TOKEN, { fetch: status(401) }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(err)).toMatchObject({ + code: ERROR_CODES.VERCEL_AUTH_FAILED, + message: 'Vercel rejected the token (HTTP 401).', + link: { url: 'https://vercel.com/account/settings/tokens' }, + remedy: STEPS, + }); + }); + + test('403 → VERCEL_NO_ACCESS, naming what could not be accessed, with the same two steps', async () => { + const client = createVercelClient(TOKEN, { fetch: status(403) }); + const caught = (p: Promise) => p.catch((e: unknown) => e); + const cases: ReadonlyArray = [ + ['teams', await caught(client.listTeams()), 'the team list'], + ['projects', await caught(client.listProjects('team_a')), 'the project list'], + ['project', await caught(client.getProject('web', 'team_a')), 'project web'], + ['custom environments', await caught(client.listCustomEnvironments('prj_web', 'team_a')), 'the environment variables of project prj_web'], + ['envs', await caught(client.listEnvs('prj_web', 'team_a')), 'the environment variables of project prj_web'], + ['env value', await caught(client.getEnvValue('prj_web', 'env_1', 'team_a')), 'the environment variables of project prj_web'], + ]; + cases.forEach(([, err, denied]) => { + const mapped = mapVercelApiError(err); + expect(mapped.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(mapped.message).toBe(`Vercel denied access to ${denied} (HTTP 403).`); + expect(mapped.link?.url).toBe('https://vercel.com/account/settings/tokens'); + expect(mapped.remedy).toBe(STEPS); + expect(`${mapped.message}${mapped.remedy}`).not.toContain(TOKEN); + }); + }); + + test('403 under discovery on the team or project list: discovery needs a token scoped to the whole team', async () => { + const client = createVercelClient(TOKEN, { fetch: status(403) }); + const teams = mapVercelApiError(await client.listTeams().catch((e: unknown) => e), { discovery: true }); + const projects = mapVercelApiError(await client.listProjects().catch((e: unknown) => e), { discovery: true }); + expect(teams.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(teams.message).toBe('Vercel denied access to the team list (HTTP 403). Discovery needs a token scoped to the whole team.'); + expect(projects.message).toBe('Vercel denied access to the project list (HTTP 403). Discovery needs a token scoped to the whole team.'); + // a project's own variables: the plain message, even under discovery + const envs = mapVercelApiError(await client.listEnvs('prj_web').catch((e: unknown) => e), { discovery: true }); + expect(envs.message).toBe('Vercel denied access to the environment variables of project prj_web (HTTP 403).'); }); test('404 → not_found; 500 → VERCEL_API_ERROR; neither message contains the token', async () => { diff --git a/tests/system/systemStore.test.ts b/tests/system/systemStore.test.ts index 6ccb9521..4e709b3a 100644 --- a/tests/system/systemStore.test.ts +++ b/tests/system/systemStore.test.ts @@ -141,6 +141,7 @@ afterAll(() => { let openSystemStore: typeof import('../../src/system/systemStore').openSystemStore; let getConnectorSecret: typeof import('../../src/system/systemStore').getConnectorSecret; let getDirectionalConnectorSecret: typeof import('../../src/system/systemStore').getDirectionalConnectorSecret; +let replaceConnectorSecret: typeof import('../../src/system/systemStore').replaceConnectorSecret; let assertValidConnectorName: typeof import('../../src/system/systemStore').assertValidConnectorName; let wrapAndSaveMasterKey: typeof import('../../src/crypto/keyResolver').wrapAndSaveMasterKey; let resolveProjectKey: typeof import('../../src/crypto/keyResolver').resolveProjectKey; @@ -157,6 +158,7 @@ beforeAll(async () => { openSystemStore = ss.openSystemStore; getConnectorSecret = ss.getConnectorSecret; getDirectionalConnectorSecret = ss.getDirectionalConnectorSecret; + replaceConnectorSecret = ss.replaceConnectorSecret; assertValidConnectorName = ss.assertValidConnectorName; const kr = await import('../../src/crypto/keyResolver'); @@ -481,6 +483,71 @@ describe('systemStore', () => { }); }); + // ── The saved confirmation after the hidden prompt ── + describe('saved confirmation (hidden-prompt path)', () => { + /** Runs `fn` with stderr writes captured; returns what was written. */ + const stderrWritesOf = async (fn: () => Promise): Promise => { + const spy = spyOn(process.stderr, 'write').mockImplementation((() => true) as never); + try { + await fn(); + return spy.mock.calls.map((a) => String(a[0])).join(''); + } finally { + spy.mockRestore(); + } + }; + const SAVED = 'Saved _CONNECTOR_VERCEL_TOKEN in the org system store.\n'; + + it('getConnectorSecret: one line after the save, naming the secret and never its value', async () => { + promptQueue.push({ value: 'typed-secret-value' }); + const text = await stderrWritesOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true })); + expect(text).toBe(SAVED); + expect(text).not.toContain('typed-secret-value'); + }); + + it('getDirectionalConnectorSecret: the same single line', async () => { + promptQueue.push({ value: 'typed-secret-value' }); + const text = await stderrWritesOf(() => + getDirectionalConnectorSecret('_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN', { + orgId: ORG_A, + devMode: true, + interactive: true, + missingWithFallbackCode: 'X', + }), + ); + expect(text).toBe(SAVED); + }); + + it('an empty answer saves nothing and says nothing; an existing entry and a non-interactive call say nothing', async () => { + promptQueue.push({ value: ' ' }); + expect(await stderrWritesOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true }))).toBe(''); + expect(await stderrWritesOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: false }))).toBe(''); + const store = await openSystemStore({ orgId: ORG_A, devMode: true }); + await store.set('_CONNECTOR_VERCEL_TOKEN', 'present'); + expect(await stderrWritesOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true }))).toBe(''); + }); + + it('replaceConnectorSecret: prompts again over a saved value, replaces it, and confirms once', async () => { + const store = await openSystemStore({ orgId: ORG_A, devMode: true }); + await store.set('_CONNECTOR_VERCEL_TOKEN', 'old-value'); + promptQueue.push({ value: 'new-value' }); + const text = await stderrWritesOf(async () => { + expect(await replaceConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true })).toBe('new-value'); + }); + expect(text).toBe(SAVED); + expect(promptedWith).toHaveLength(1); + expect(promptedWith[0][0].type).toBe('password'); + expect((await openSystemStore({ orgId: ORG_A, devMode: true })).get('_CONNECTOR_VERCEL_TOKEN')).toBe('new-value'); + }); + + it('replaceConnectorSecret: an empty answer keeps the old value', async () => { + const store = await openSystemStore({ orgId: ORG_A, devMode: true }); + await store.set('_CONNECTOR_VERCEL_TOKEN', 'old-value'); + promptQueue.push({ value: '' }); + expect(await replaceConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true })).toBeNull(); + expect((await openSystemStore({ orgId: ORG_A, devMode: true })).get('_CONNECTOR_VERCEL_TOKEN')).toBe('old-value'); + }); + }); + // ── CAP-679 follow-up: `getDirectionalConnectorSecret` — deploy's // `_TARGET_DOKPLOY_API_KEY` borrowing from import's // `_CONNECTOR_DOKPLOY_API_KEY` (or the reverse), and the one-level From e28dd8c98ad5ae49abe4fcd09c1fcff298bff912 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 2 Nov 2026 17:06:48 -0700 Subject: [PATCH 09/22] docs: regenerate the CLI reference --- docs/cli-reference.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5cf87525..8301d3f8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -34,7 +34,7 @@ JSON support: yes (`--json`) ## `capy edit` -Inspect and edit secrets in an interactive TUI, or set one variable from a hidden prompt or a piped value +Inspect and edit secrets in an interactive TUI, or set one variable from a piped value ``` capy edit [name] [options] @@ -42,7 +42,7 @@ capy edit [name] [options] | Option | Description | Default | |---|---|---| -| `--no-push` | with a name: write .env only; do not push to Capy | | +| `--no-push` | piped value: write .env only; do not push to Capy | | | `--json` | emit machine-readable JSON instead of the human UI (piped value) | | | `--non-tty` | treat stdin as not a terminal; never prompt (agents/CI) | | | `--pr` | create a PR with the keep.lock change (answers the prompt) | | From 8cc46c555eaef7e1f2e703bac5510d7cf02ea0b8 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 2 Nov 2026 17:43:55 -0700 Subject: [PATCH 10/22] feat(vercel): values Vercel will not give back become placeholders A value Vercel has but will not return (sensitive, legacy secret, or a value route answer with decrypted: false) is recorded as a placeholder on the Capy branch of every environment it is in: a typed flag on the variable's keep.lock entry with an empty value_hash, a comment line in .env, and nothing else. Writing a real value for the branch removes the flag. --- src/commands/connectCommand.ts | 17 +- src/commands/connectors/registry.ts | 16 + src/commands/connectors/shared.ts | 26 +- src/commands/connectors/vercel.ts | 53 ++- src/commands/connectors/vercelDiscovery.ts | 19 +- src/commands/listCommand.ts | 9 +- src/commands/secretsCommand.ts | 3 + src/commands/statusCommand.ts | 18 +- src/core/placeholder.ts | 146 +++++++++ src/deploy/vercelApi.ts | 46 ++- src/files/envUpsert.ts | 31 +- src/files/fileManager.ts | 32 +- src/sync/commit.ts | 19 +- src/sync/syncEngine.ts | 6 +- src/types/index.ts | 22 ++ src/ui/secretsScreen.ts | 7 +- tests/commands/connectVercel.test.ts | 10 +- tests/commands/connectVercelDiscovery.test.ts | 3 +- tests/commands/secretsCommand.test.ts | 13 + tests/commands/vercelPlaceholders.test.ts | 308 ++++++++++++++++++ tests/helpers/fakeVercel.ts | 4 +- tests/sync/placeholders.test.ts | 258 +++++++++++++++ tests/ui/secretsScreen.test.ts | 13 + 23 files changed, 1021 insertions(+), 58 deletions(-) create mode 100644 src/core/placeholder.ts create mode 100644 tests/commands/vercelPlaceholders.test.ts create mode 100644 tests/sync/placeholders.test.ts diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index d351dc68..8da81094 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -1,4 +1,5 @@ import { resolveContext, writeAndSync, writeImportOutcome, ResolvedContext } from './connectors/shared'; +import { placeholderSetCommand, placeholdersReport } from '../core/placeholder'; import { listProviders, loadProvider, ConnectFailure, ConnectOpts, ConnectorModule } from './connectors/registry'; import { formatKeyHelp } from '../deploy/keyHelp'; import { EXIT_NEEDS_INPUT, isInteractive } from '../ui/interactive'; @@ -263,7 +264,8 @@ export class ConnectCommand { const { wrote } = await writeImportOutcome(ctx, outcome, { push: !opts.noPush, dryRun }); const pushed = !opts.noPush && wrote; const cleared = outcome.cleared ?? []; - const hasChange = outcome.imported.length > 0 || cleared.length > 0; + const placeholders = outcome.placeholders ?? []; + const hasChange = outcome.imported.length > 0 || cleared.length > 0 || placeholders.length > 0; if (opts.json) { console.log( @@ -280,6 +282,8 @@ export class ConnectCommand { unchanged: outcome.unchanged, ...(outcome.wouldAsk ? { wouldAsk: outcome.wouldAsk } : {}), skipped: outcome.skipped, + // Names Vercel has but will not give back: recorded with no value (names and codes only). + ...(outcome.placeholders ? { placeholders: placeholdersReport(outcome.placeholders) } : {}), warnings: outcome.warnings, pushed, }), @@ -304,7 +308,7 @@ export class ConnectCommand { ` ✓ Imported ${outcome.imported.map((e) => B(e.varName)).join(', ')} from ${label}` + `${pushed ? ' and pushed' : ' (not pushed)'}.`, ); - } else { + } else if (cleared.length > 0) { console.log(` ✓ Cleared from ${label}${pushed ? ' and pushed' : ' (not pushed)'}.`); } if (cleared.length > 0) { @@ -320,6 +324,11 @@ export class ConnectCommand { if (outcome.wouldAsk && outcome.wouldAsk.length > 0) { console.log(` Conflicts (would ask): ${outcome.wouldAsk.join(', ')}`); } + if (placeholders.length > 0) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + console.log(` ${dryRun ? 'Would record' : 'Recorded'} with no value yet: ${placeholders.map((p) => `${p.name} (${p.code})`).join(', ')}`); + console.log(` Set one with: ${placeholderSetCommand('NAME')}`); // COPY-FLAG + } if (outcome.skipped.length > 0) { console.log(` Skipped: ${outcome.skipped.map((s) => `${s.name} (${s.code})`).join(', ')}`); } @@ -406,6 +415,7 @@ export class ConnectCommand { ...(e.outcome.cleared ? { cleared: e.outcome.cleared } : {}), unchanged: e.outcome.unchanged, skipped: e.outcome.skipped, + ...(e.outcome.placeholders ? { placeholders: placeholdersReport(e.outcome.placeholders) } : {}), warnings: e.outcome.warnings, })), } @@ -426,6 +436,7 @@ export class ConnectCommand { ...(e.outcome.cleared ? { cleared: e.outcome.cleared } : {}), unchanged: e.outcome.unchanged, skipped: e.outcome.skipped, + ...(e.outcome.placeholders ? { placeholders: placeholdersReport(e.outcome.placeholders) } : {}), warnings: e.outcome.warnings, })), }, @@ -440,7 +451,7 @@ export class ConnectCommand { const linked = !!outcome.applied && outcome.applied.some((f) => - f.environments.some((e) => e.outcome.imported.length > 0 || (e.outcome.cleared?.length ?? 0) > 0), + f.environments.some((e) => e.outcome.imported.length > 0 || (e.outcome.cleared?.length ?? 0) > 0 || (e.outcome.placeholders?.length ?? 0) > 0), ); // A per-folder failure still lets the OTHER folders' steps run (see // `runDiscoverySequences`) — but the run as a whole did not fully diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index ad8a1cc5..2ce827d5 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -249,6 +249,17 @@ export interface ImportSkip { gitBranch?: string; } +/** + * A name the provider has but will not give back, recorded as a placeholder on the branch: it exists + * in Capy with no value until one is set (core/placeholder.ts). `code` is the provider's stable + * reason code; `provider` is the connector that could not read it. + */ +export interface ImportPlaceholder { + name: string; + code: string; + provider: string; +} + /** A non-blocking heads-up about the import, grouped by code like `DeployWarning`. */ export interface ImportWarning { code: string; @@ -272,6 +283,11 @@ export type ImportOutcome = imported: readonly ImportedVarEntry[]; unchanged: readonly string[]; skipped: readonly ImportSkip[]; + /** + * Vercel import only: names Vercel holds but will not give back. `writeImportOutcome` records each + * as a placeholder on the branch; none is in `skipped`. Absent when there are none. + */ + placeholders?: readonly ImportPlaceholder[]; warnings: readonly ImportWarning[]; /** * Dokploy import only: names with a different local value that a real diff --git a/src/commands/connectors/shared.ts b/src/commands/connectors/shared.ts index 448a823f..0323590a 100644 --- a/src/commands/connectors/shared.ts +++ b/src/commands/connectors/shared.ts @@ -7,6 +7,7 @@ import { commitGated, type GateOptions } from '../../sync/gate'; import { KeepFile, ConnectorMetadata, CapyError, ERROR_CODES, AuthResult } from '../../types/index'; import type { ImportOutcome } from './registry'; import { reportRepoLinkForCommand, type RepoLinkWarning } from '../../core/repoLinkReporter'; +import { clearedPlaceholders, withPlaceholders, type PlaceholderRequest } from '../../core/placeholder'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -217,7 +218,9 @@ export async function writeAndSync( }, ): Promise { const { fileManager, branch, projectKey, keep, localPlaintext } = ctx; - const attach = (k: KeepFile): KeepFile => applyConnectors(k, branch, varName, opts.connector, opts.alsoConnect); + // A value is being written for `varName`: it is no longer a placeholder. + const attach = (k: KeepFile): KeepFile => + clearedPlaceholders(applyConnectors(k, branch, varName, opts.connector, opts.alsoConnect), branch, value === undefined ? [] : [varName]); if (!opts.push) { // Local-only path. Even though we're not hitting the service, we still @@ -254,6 +257,8 @@ export async function writeImportedAndSync( entries: ReadonlyArray<{ varName: string; value: string; entry: ConnectorMetadata }>, opts: { push: boolean; + /** Names to record as placeholders on the branch (no value). Written in the same save as `entries`. */ + placeholders?: readonly PlaceholderRequest[]; /** * `--overwrite` clear-only writes: proceed even when `entries` is empty. * Clearing removes names (`opts.clear`) rather than adding an entry, so a @@ -266,7 +271,8 @@ export async function writeImportedAndSync( clear?: readonly string[]; }, ): Promise { - if (entries.length === 0 && !opts.forceWrite) return; + const placeholders = opts.placeholders ?? []; + if (entries.length === 0 && placeholders.length === 0 && !opts.forceWrite) return; const { fileManager, branch, projectKey, keep, localPlaintext } = ctx; const clear = opts.clear ?? []; const also = entries.map((e) => ({ varName: e.varName, entry: e.entry })); @@ -275,14 +281,19 @@ export async function writeImportedAndSync( // (always `undefined` here) is set — so on a clear-only call (`entries` // empty, `forceWrite: true`) this placeholder is never actually consulted, // but `entries[0].varName` would still throw evaluating it eagerly. - const attachAll = (k: KeepFile): KeepFile => applyConnectors(k, branch, entries[0]?.varName ?? '', undefined, also); + const attachAll = (k: KeepFile): KeepFile => + withPlaceholders( + clearedPlaceholders(applyConnectors(k, branch, entries[0]?.varName ?? '', undefined, also), branch, entries.map((e) => e.varName)), + branch, + placeholders, + ); const set = Object.fromEntries(entries.map((e) => [e.varName, e.value])); if (!opts.push) { const finalEnv = { ...Object.fromEntries(Object.entries(localPlaintext).filter(([k]) => !clear.includes(k))), ...set }; const merged = attachAll(keep); if (merged !== keep) fileManager.writeKeepFile(merged); - fileManager.writeEncryptedEnvFile(finalEnv, projectKey, undefined, merged, branch); + fileManager.writeEncryptedEnvFile(finalEnv, projectKey, undefined, merged, branch, clear); return; } @@ -319,13 +330,16 @@ export async function writeImportOutcome( // caller (link-kind connectors don't set it; hand-built outcomes in // existing tests don't either), so this is a strict no-op there. const unchangedEntries = outcome.unchangedEntries ?? []; - const hasWrite = outcome.imported.length > 0 || cleared.length > 0 || unchangedEntries.length > 0; + // A placeholder is a keep.lock entry with no value: recorded in the same save as the imported values. + const placeholders = (outcome.placeholders ?? []).map((p): PlaceholderRequest => ({ name: p.name, marker: { provider: p.provider, reason: p.code } })); + const hasWrite = outcome.imported.length > 0 || cleared.length > 0 || unchangedEntries.length > 0 || placeholders.length > 0; if (!hasWrite) return { wrote: false }; await writeImportedAndSync(ctx, [...outcome.imported, ...unchangedEntries], { push: opts.push, - forceWrite: cleared.length > 0 || unchangedEntries.length > 0, + forceWrite: cleared.length > 0 || unchangedEntries.length > 0 || placeholders.length > 0, clear: cleared, + placeholders, }); return { wrote: true }; } diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts index ac5b15e0..6ab44c78 100644 --- a/src/commands/connectors/vercel.ts +++ b/src/commands/connectors/vercel.ts @@ -17,9 +17,15 @@ * custom environment's slug. One Vercel record that targets several * environments counts in each. What is NOT imported is reported by stable * reason, names only: `git_branch_scoped` (a preview record scoped to one git - * branch — it is not an all-branches preview var), `sensitive_unreadable`, - * `legacy_secret_unreadable`, `vercel_system`, `unsupported_type`, - * `duplicate_key`, `empty_value`. + * branch — it is not an all-branches preview var), `vercel_system`, + * `unsupported_type`, `duplicate_key`, `empty_value`. + * + * A value Vercel has but will not give back (`sensitive_unreadable`, + * `legacy_secret_unreadable`, or a value route answer with `decrypted: false`, + * `value_not_decrypted`) is not skipped: it becomes a PLACEHOLDER on the Capy + * branch of every environment it is in. A placeholder is a flag on the + * variable's keep.lock entry, with no value (`core/placeholder.ts`). A name + * that already has a value in Capy keeps it and is reported as skipped. * * Every refusal for a missing or rejected token carries the Vercel token page * (`link`) and what to do (`remedy`), with the team's slug when `--team` names it. @@ -47,6 +53,7 @@ import { VercelTeam, VercelTokenSource, availableEnvironments, + isPlaceholderReason, buildEnvironmentPlans, createVercelClient, describeVercelTokenProblem, @@ -70,6 +77,7 @@ import { ConnectorModule, ConnectResult, ImportOutcome, + ImportPlaceholder, ImportSkip, ImportWarning, RotateResult, @@ -504,7 +512,8 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const fullCandidates = values.value.entries.map((e) => ({ name: e.record.key, value: e.value })); // A name Vercel HAS but will not show us (sensitive, scoped, empty, …) is // never cleared locally just because it is unreadable. - const protectedNames = [...source.skipped, ...values.value.empty].map((s) => s.name); + const unreadable = [...source.skipped, ...values.value.empty, ...values.value.undecrypted]; + const protectedNames = unreadable.map((s) => s.name); const plan = computeOverwritePlan(fullCandidates, protectedNames, ctx.localPlaintext); const warnings = truncationWarnings(source); @@ -527,6 +536,8 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const entryFor = (name: string, value: string) => importedEntry(recordByName.get(name)!, name, value, source, createdAtSec, importedAt); const written = [...plan.toImport, ...plan.toReplace].map(({ name, value }) => entryFor(name, value)); const unchangedEntries = plan.unchanged.map((name) => entryFor(name, fullCandidates.find((c) => c.name === name)!.value)); + // A name Vercel will not give back, with no value here, is a placeholder; one that has a local value keeps it and is reported as skipped. + const split = splitPlaceholders(unreadable, new Set([...Object.keys(ctx.localPlaintext), ...fullCandidates.map((c) => c.name)])); return { ok: true, source: importSource, @@ -537,7 +548,8 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector unchangedEntries, // The names Vercel has but will not show are reported here too: an // overwrite leaves them alone, and saying so is the point of the list. - skipped: [...source.skipped, ...values.value.empty].map(toImportSkip), + skipped: split.skips.map(toImportSkip), + placeholders: split.placeholders, warnings, }; }; @@ -590,10 +602,12 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const unchangedEntries = dryRun ? [] : unchanged.map((name) => entryFor(name, selected.find((c) => c.name === name)!.value)); const inScope = (name: string): boolean => !restrict || restrict.includes(name); - const skipped: readonly ImportSkip[] = [ - ...[...source.skipped, ...values.value.empty].filter((s) => inScope(s.name)).map(toImportSkip), - ...conflictSkipped, - ]; + // A name Vercel will not give back, with no value here, is a placeholder on this branch; one that has a value keeps it. + const split = splitPlaceholders( + [...source.skipped, ...values.value.empty, ...values.value.undecrypted].filter((s) => inScope(s.name)), + new Set([...Object.keys(ctx.localPlaintext), ...candidates.map((c) => c.name)]), + ); + const skipped: readonly ImportSkip[] = [...split.skips.map(toImportSkip), ...conflictSkipped]; return { ok: true, @@ -602,6 +616,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector unchanged, unchangedEntries, skipped, + placeholders: split.placeholders, warnings: truncationWarnings(source), ...(dryRun ? { wouldAsk } : {}), }; @@ -783,6 +798,26 @@ function storeRefusalCode(err: unknown): string { return typeof code === 'string' ? code : ERROR_CODES.VERCEL_TOKEN_MISSING; } +/** + * Splits what Vercel would not give back: a name whose reason is "has a value, not readable" and that has no + * value in Capy becomes a placeholder; everything else stays a skip. `valued` is every name that has (or is + * being given) a value here. + */ +function splitPlaceholders( + unreadable: readonly VercelSkippedRecord[], + valued: ReadonlySet, +): { placeholders: readonly ImportPlaceholder[]; skips: readonly VercelSkippedRecord[] } { + const isPlaceholder = (s: VercelSkippedRecord): boolean => isPlaceholderReason(s.reason) && !valued.has(s.name); + const firstOfName = (s: VercelSkippedRecord, i: number, all: readonly VercelSkippedRecord[]): boolean => all.findIndex((o) => o.name === s.name) === i; + return { + placeholders: unreadable + .filter(isPlaceholder) + .filter(firstOfName) + .map((s): ImportPlaceholder => ({ name: s.name, code: s.reason, provider: 'vercel' })), + skips: unreadable.filter((s) => !isPlaceholder(s)), + }; +} + const toImportSkip = (s: VercelSkippedRecord): ImportSkip => ({ name: s.name, code: s.reason, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) }); function truncationWarnings(source: SourceRead): readonly ImportWarning[] { diff --git a/src/commands/connectors/vercelDiscovery.ts b/src/commands/connectors/vercelDiscovery.ts index 84da1d95..91daa1db 100644 --- a/src/commands/connectors/vercelDiscovery.ts +++ b/src/commands/connectors/vercelDiscovery.ts @@ -25,6 +25,7 @@ */ import { existsSync } from 'node:fs'; import { sortedCopy } from '../../deploy/dokployApi'; +import { placeholdersReport } from '../../core/placeholder'; import { join } from 'node:path'; import { VercelClient, @@ -212,6 +213,7 @@ function envReport(e: Extract['environments ...(e.outcome.cleared ? { cleared: e.outcome.cleared } : {}), unchanged: e.outcome.unchanged, skipped: e.outcome.skipped, + ...(e.outcome.placeholders ? { placeholders: placeholdersReport(e.outcome.placeholders) } : {}), warnings: e.outcome.warnings, }; } @@ -278,7 +280,9 @@ export function reportVercelDiscovery( const commits = outcome.commits ? commitsReport(outcome.commits) : undefined; const linked = !!outcome.applied && - outcome.applied.some((f) => f.environments.some((e) => e.outcome.imported.length > 0 || (e.outcome.cleared?.length ?? 0) > 0)); + outcome.applied.some((f) => + f.environments.some((e) => e.outcome.imported.length > 0 || (e.outcome.cleared?.length ?? 0) > 0 || (e.outcome.placeholders?.length ?? 0) > 0), + ); // One folder failing still lets the others run, but the run as a whole did // not fully succeed — CI watching the exit code must see that. if (outcome.applied?.some((f) => !f.ok)) process.exitCode = 1; @@ -343,13 +347,16 @@ function appliedLines(outcome: Extract, applied: if (!applied) return []; return applied.flatMap((f) => [ ` ${B(f.folder)}${f.ok && f.projectCreated ? ' (new project)' : ''}`, - ...f.environments.map((e) => { + ...f.environments.flatMap((e) => { const clearedCount = e.cleared?.length ?? 0; - return ( + const placeholderNames = (e.placeholders ?? []).map((p) => p.name); + return [ ` ${e.branch}${e.branchCreated ? ' (new branch)' : ''}${e.overwrote ? ' (--overwrite)' : ''}: ` + - `imported ${e.imported.length}, unchanged ${e.unchanged.length}, skipped ${e.skipped.length}` + - (clearedCount > 0 ? `, cleared ${clearedCount}` : '') - ); + `imported ${e.imported.length}, unchanged ${e.unchanged.length}, skipped ${e.skipped.length}` + + (placeholderNames.length > 0 ? `, no value yet ${placeholderNames.length}` : '') + // COPY-FLAG + (clearedCount > 0 ? `, cleared ${clearedCount}` : ''), + ...(placeholderNames.length > 0 ? [` no value yet: ${placeholderNames.join(', ')}`] : []), // COPY-FLAG + ]; }), ...(f.ok ? (f.activeBranch ? [` folder is on branch ${f.activeBranch}`] : []) : [` ✗ ${f.code}: ${f.message}`]), ]); diff --git a/src/commands/listCommand.ts b/src/commands/listCommand.ts index a1bbf429..3cdb6c3d 100644 --- a/src/commands/listCommand.ts +++ b/src/commands/listCommand.ts @@ -1,5 +1,6 @@ import { ProjectManager } from '../core/projectManager'; import { listAllVarsOnBranch, listManagedKeys, findManagedConnector } from './connectors/shared'; +import { placeholderLabel, placeholderOf, placeholderSetCommand } from '../core/placeholder'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; const DIM = '\x1b[90m'; @@ -45,9 +46,12 @@ export class ListCommand { const managed = new Set(listManagedKeys(keep, branch).map((m) => m.varName)); const variables = listAllVarsOnBranch(keep, branch).map((name) => { const c = managed.has(name) ? findManagedConnector(keep, name, branch) : undefined; + const marker = placeholderOf(keep, name, branch); return { name, managed: !!c, + // No value yet (a placeholder): the provider and its reason code, and the command that sets one. + placeholder: marker ? { provider: marker.provider, reason: marker.reason, setWith: placeholderSetCommand(name) } : null, connector: c ? { provider: c.provider, @@ -72,7 +76,10 @@ export class ListCommand { console.log(` ${keep.project_name} ${DIM}·${RESET} ${branch} ${DIM}(${variables.length})${RESET}`); for (const v of variables) { const tag = v.connector ? ` ${DIM}(${v.connector.provider})${RESET}` : ''; - console.log(` ${v.name}${tag}`); + const marker = placeholderOf(keep, v.name, branch); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + const note = marker ? ` ${DIM}${placeholderLabel(marker)} — set it with: ${placeholderSetCommand(v.name)}${RESET}` : ''; + console.log(` ${v.name}${tag}${note}`); } console.log(''); } diff --git a/src/commands/secretsCommand.ts b/src/commands/secretsCommand.ts index b4110749..cc4ec368 100644 --- a/src/commands/secretsCommand.ts +++ b/src/commands/secretsCommand.ts @@ -3,6 +3,7 @@ import { resolveOrgContext } from '../core/orgContext'; import { Spinner } from '../ui/spinner'; import { CapyError, ERROR_CODES } from '../types/index'; import { rowIdOf } from './secretsRowId'; +import { placeholderSetCommand } from '../core/placeholder'; import { secretRowStatus, secretRowStatusJson, type SecretRowStatus } from '../core/deployStatus'; /** @@ -130,6 +131,8 @@ export class SecretsCommand { const withIds = rows.map((row) => ({ ...row, row_id: rowIdOf(row.name, row.value_hash), + // An empty value hash is a name with no value yet (a placeholder, or a connector entry before its first push). + ...(row.value_hash === '' ? { no_value: true, set_with: placeholderSetCommand(row.name) } : {}), ...statusFields(secretRowStatus(row)), locations: row.locations.map((loc) => loc.targets === undefined ? loc : { ...loc, targets: loc.targets.map((t) => ({ ...t, up_to_date: !t.stale })) }, diff --git a/src/commands/statusCommand.ts b/src/commands/statusCommand.ts index 7931309c..8838811e 100644 --- a/src/commands/statusCommand.ts +++ b/src/commands/statusCommand.ts @@ -8,6 +8,8 @@ import { CapyError, ERROR_CODES, KeepFile } from '../types/index'; import { fetchSecretsWithCache, readSecretsLocal } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; +import { pinnedHashes } from '../sync/commit'; +import { placeholdersOnBranch, placeholderSetCommand } from '../core/placeholder'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -210,12 +212,9 @@ export class StatusCommand { const branchLabel = branch; // Build pinned hashes from keep.lock for active branch - const pinned: Record = Object.fromEntries( - Object.entries(keep.variables).flatMap(([varName, entries]) => { - const entry = entries.find(e => e.branch === branch); - return entry ? [[varName, entry.value_hash] as const] : []; - }), - ); + // A placeholder pins no value, so it is never compared; it is listed on its own. + const pinned = pinnedHashes(keep, branch); + const placeholders = placeholdersOnBranch(keep, branch).map((p) => p.name); // Build local hashes from .env. Two facts, deliberately separate: the // sentence a person reads, and the verdict the report branches on. @@ -260,6 +259,8 @@ export class StatusCommand { remoteMatchesPinned: !showRemote, remoteFailure: remoteFailure ?? null, diffs, + // Names with no value yet (see `capy list`); they are not differences. + placeholders, }; if (opts.json) { @@ -279,6 +280,11 @@ export class StatusCommand { // Full output console.log(`${B('capy')}: ${keep.project_name} (${branchLabel})`); console.log(''); + if (placeholders.length > 0) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + console.log(` No value yet: ${placeholders.join(', ')}. Set one with: ${placeholderSetCommand('NAME')}`); + console.log(''); + } if (diffs.length === 0) { console.log(`> ${totalSecrets} secret${totalSecrets !== 1 ? 's' : ''} match pinned branch.`); diff --git a/src/core/placeholder.ts b/src/core/placeholder.ts new file mode 100644 index 00000000..861d1c47 --- /dev/null +++ b/src/core/placeholder.ts @@ -0,0 +1,146 @@ +/** + * Placeholders: a name that exists on a branch with NO value yet. + * + * An import connector that cannot read a value (Vercel never returns a sensitive one) + * still records that the name exists. That record is a typed flag on the variable's + * keep.lock entry for the branch (`KeepVariableEntry.placeholder`), with an empty + * `value_hash`. It is NOT a value: nothing is encrypted, nothing is pushed as a secret, + * and no sentinel string stands in for one. + * + * Placeholder state comes from keep.lock and nowhere else. The `.env` comment line below + * is written for a person to read; nothing parses it back. + * + * Writing a real value for the branch removes the flag (`SyncEngine.mergeWithKeep`). + */ +import { deriveResourceId } from '../crypto/resourceId'; +import type { KeepFile, KeepVariableEntry, PlaceholderMarker } from '../types/index'; + +/** One name to record as a placeholder on a branch. */ +export interface PlaceholderRequest { + readonly name: string; + readonly marker: PlaceholderMarker; +} + +/** The placeholder marker on `name`'s entry for `branch`, if it has one. */ +export function placeholderOf(keep: KeepFile | null | undefined, name: string, branch: string): PlaceholderMarker | undefined { + return keep?.variables[name]?.find((e) => e.branch === branch)?.placeholder; +} + +/** Every placeholder on `branch`, by name, in keep.lock order. */ +export function placeholdersOnBranch(keep: KeepFile | null | undefined, branch: string): ReadonlyArray<{ readonly name: string; readonly marker: PlaceholderMarker }> { + return Object.entries(keep?.variables ?? {}).flatMap(([name, entries]) => { + const marker = entries.find((e) => e.branch === branch)?.placeholder; + return marker === undefined ? [] : [{ name, marker }]; + }); +} + +/** True when the entry holds a real value (a non-empty hash). */ +const hasValue = (entry: KeepVariableEntry | undefined): boolean => entry !== undefined && entry.value_hash !== '' && entry.placeholder === undefined; + +function sameMarker(a: PlaceholderMarker | undefined, b: PlaceholderMarker): boolean { + return a !== undefined && a.provider === b.provider && a.reason === b.reason; +} + +function withPlaceholder(keep: KeepFile, branch: string, request: PlaceholderRequest): KeepFile { + const entries = keep.variables[request.name] ?? []; + const current = entries.find((e) => e.branch === branch); + // A name that already holds a real value on this branch keeps it: a placeholder is only ever the absence of one. + if (hasValue(current)) return keep; + if (current !== undefined && current.value_hash === '' && sameMarker(current.placeholder, request.marker)) return keep; + const next: KeepVariableEntry = { + ...(current ?? { resource_id: deriveResourceId(branch, request.name) }), + branch, + value_hash: '', + placeholder: request.marker, + }; + const updated = current === undefined ? [...entries, next] : entries.map((e) => (e === current ? next : e)); + return { ...keep, variables: { ...keep.variables, [request.name]: updated } }; +} + +/** + * `keep` with each request recorded as a placeholder on `branch`. Returns `keep` itself when + * nothing changes, so a repeat import writes nothing. A name that already has a value on the + * branch is left alone. + */ +export function withPlaceholders(keep: KeepFile, branch: string, requests: readonly PlaceholderRequest[]): KeepFile { + return requests.reduce((acc, request) => withPlaceholder(acc, branch, request), keep); +} + +/** The entry with its placeholder flag removed (a real value is being written for it). */ +export function withoutPlaceholder(entry: KeepVariableEntry): KeepVariableEntry { + const { placeholder: _removed, ...rest } = entry; + return rest; +} + +/** + * `keep` with the flag removed from `names`' entries on `branch`: a value is being written locally for them + * (the pin itself moves on the next push). Returns `keep` itself when none of them is a placeholder. + */ +export function clearedPlaceholders(keep: KeepFile, branch: string, names: readonly string[]): KeepFile { + const flagged = names.filter((name) => placeholderOf(keep, name, branch) !== undefined); + if (flagged.length === 0) return keep; + return { + ...keep, + variables: { + ...keep.variables, + ...Object.fromEntries(flagged.map((name) => [name, keep.variables[name].map((e) => (e.branch === branch ? withoutPlaceholder(e) : e))] as const)), + }, + }; +} + +// ── Words ─────────────────────────────────────────────────────────────────── + +const PROVIDER_LABELS: Readonly> = { vercel: 'Vercel' }; + +const providerLabel = (provider: string): string => PROVIDER_LABELS[provider] ?? provider; + +/** What the provider holds the value as, for the "no value yet" line. A code in, words out. */ +function reasonPhrase(marker: PlaceholderMarker): string { + const provider = providerLabel(marker.provider); + if (marker.reason === 'sensitive_unreadable') return `sensitive in ${provider}`; // COPY-FLAG + if (marker.reason === 'legacy_secret_unreadable') return `legacy secret in ${provider}`; // COPY-FLAG + if (marker.reason === 'value_not_decrypted') return `not decrypted by ${provider}`; // COPY-FLAG + return `not readable from ${provider}`; // COPY-FLAG +} + +/** `no value yet (sensitive in Vercel)`. */ +export function placeholderLabel(marker: PlaceholderMarker): string { + return `no value yet (${reasonPhrase(marker)})`; // COPY-FLAG +} + +/** How to give the name a value. */ +export function placeholderSetCommand(name: string): string { + return `capy edit ${name}`; +} + +/** + * The `.env` comment for a placeholder. A comment line only, never a value. It is written for a + * person to read; nothing in Capy reads it back. + */ +export function placeholderEnvNote(name: string, marker: PlaceholderMarker): string { + return `# ${name} — not readable from ${providerLabel(marker.provider)}; set it with: ${placeholderSetCommand(name)}`; // COPY-FLAG +} + +/** + * Every note Capy has ever written for `name`, whatever provider it named: the exact lines to + * remove once the name has a value or is gone. Whole-line equality, not a pattern. + */ +export function placeholderEnvNoteVariants(name: string): readonly string[] { + return Object.keys(PROVIDER_LABELS).map((provider) => placeholderEnvNote(name, { provider, reason: '' })); +} + +/** Placeholders as reported (human and JSON): the name and the stable code, nothing else. */ +export function placeholdersReport(placeholders: ReadonlyArray<{ readonly name: string; readonly code: string }>): ReadonlyArray<{ name: string; code: string }> { + return placeholders.map(({ name, code }) => ({ name, code })); +} + +/** + * `capy secrets` only: the org-wide index carries each location's value hash and not the placeholder + * flag, so a row with an empty hash is the one it can call "no value yet" (the reason is not in the index). + */ +export const NO_VALUE_YET = 'no value yet'; // COPY-FLAG + +/** The line a `capy secrets` row with no value shows where its value would be. */ +export function noValueYetLine(name: string): string { + return `${NO_VALUE_YET} — set it with: ${placeholderSetCommand(name)}`; // COPY-FLAG +} diff --git a/src/deploy/vercelApi.ts b/src/deploy/vercelApi.ts index 953bcc4a..561e6fb3 100644 --- a/src/deploy/vercelApi.ts +++ b/src/deploy/vercelApi.ts @@ -273,10 +273,24 @@ export interface VercelClient { getProject(idOrName: string, teamId?: string): Promise; listCustomEnvironments(projectId: string, teamId?: string): Promise; listEnvs(projectId: string, teamId?: string): Promise; - /** The decrypted value of one env record, or `undefined` when Vercel returned none. */ - getEnvValue(projectId: string, envId: string, teamId?: string): Promise; + /** + * The decrypted value of one env record, `undefined` when Vercel returned none, or + * `{ decrypted: false }` when Vercel says it did not decrypt the value (its structured + * `decrypted: false` field; never judged by the value's text). + */ + getEnvValue(projectId: string, envId: string, teamId?: string): Promise; +} + +/** Vercel answered the per-id route but did not decrypt the value (for instance a role that cannot read production values). */ +export interface VercelUndecryptedValue { + readonly decrypted: false; } +/** What the per-id value route gave back: the value, nothing, or "not decrypted". */ +export type VercelEnvValue = string | undefined | VercelUndecryptedValue; + +export const isUndecrypted = (value: VercelEnvValue): value is VercelUndecryptedValue => typeof value === 'object'; + const DEFAULT_MAX_RETRIES = 3; const DEFAULT_MAX_WAIT_MS = 60_000; const DEFAULT_RETRY_WAIT_MS = 1_000; @@ -442,6 +456,7 @@ export function createVercelClient(token: string, options: VercelClientOptions = async getEnvValue(projectId, envId, teamId) { const body = asRecord(await get(`/v1/projects/${enc(projectId)}/env/${enc(envId)}`, { kind: 'project_envs', project: projectId }, { teamId })); + if (body?.decrypted === false) return { decrypted: false }; return typeof body?.value === 'string' ? body.value : undefined; }, }; @@ -697,7 +712,17 @@ export type VercelSkipReason = | 'legacy_secret_unreadable' | 'unsupported_type' | 'duplicate_key' - | 'empty_value'; + | 'empty_value' + /** Vercel returned the value route's answer with `decrypted: false`: it would not give the value back. */ + | 'value_not_decrypted'; + +/** + * The reasons that mean "Vercel has this value and will not give it back". Each becomes a placeholder + * on its Capy branch (core/placeholder.ts); every other reason stays a skip. + */ +export const PLACEHOLDER_SKIP_REASONS: readonly VercelSkipReason[] = ['sensitive_unreadable', 'legacy_secret_unreadable', 'value_not_decrypted']; + +export const isPlaceholderReason = (reason: VercelSkipReason): boolean => PLACEHOLDER_SKIP_REASONS.includes(reason); export interface VercelSkippedRecord { name: string; @@ -723,10 +748,12 @@ export function availableEnvironments(custom: readonly VercelCustomEnvironment[] function skipReasonFor(rec: VercelEnvRecord, kind: VercelEnvironmentKind): VercelSkipReason | null { if (rec.type === 'system') return 'vercel_system'; + const known = rec.type === 'plain' || rec.type === 'encrypted' || rec.type === 'sensitive' || rec.type === 'secret'; + if (!known) return 'unsupported_type'; + // A preview record scoped to one branch of the repo is out of scope whatever its type: it is never a placeholder on the all-branches preview. + if (kind === 'preview' && rec.gitBranch) return 'git_branch_scoped'; if (rec.type === 'sensitive') return 'sensitive_unreadable'; if (rec.type === 'secret') return 'legacy_secret_unreadable'; - if (rec.type !== 'plain' && rec.type !== 'encrypted') return 'unsupported_type'; - if (kind === 'preview' && rec.gitBranch) return 'git_branch_scoped'; return null; } @@ -776,6 +803,8 @@ export interface ResolvedEnvValues { entries: ReadonlyArray<{ record: VercelEnvRecord; value: string }>; /** Records that came back empty/absent although typed readable — never imported as empty strings. */ empty: readonly VercelSkippedRecord[]; + /** Records Vercel would not decrypt (`decrypted: false`): they have a value Capy cannot read, so they are placeholders, not empty. */ + undecrypted: readonly VercelSkippedRecord[]; } /** @@ -791,7 +820,7 @@ export async function resolveEnvValues( teamId: string | undefined, records: readonly VercelEnvRecord[], ): Promise { - const fetched = await records.reduce>>( + const fetched = await records.reduce>>( async (accPromise, record) => { const acc = await accPromise; const value = @@ -803,10 +832,13 @@ export async function resolveEnvValues( Promise.resolve([]), ); return { - entries: fetched.flatMap(({ record, value }) => (value === undefined || value === '' ? [] : [{ record, value }])), + entries: fetched.flatMap(({ record, value }) => (value === undefined || value === '' || isUndecrypted(value) ? [] : [{ record, value }])), empty: fetched.flatMap(({ record, value }): readonly VercelSkippedRecord[] => value === undefined || value === '' ? [{ name: record.key, reason: 'empty_value' }] : [], ), + undecrypted: fetched.flatMap(({ record, value }): readonly VercelSkippedRecord[] => + isUndecrypted(value) ? [{ name: record.key, reason: 'value_not_decrypted' }] : [], + ), }; } diff --git a/src/files/envUpsert.ts b/src/files/envUpsert.ts index e5d17fd3..3e903ee2 100644 --- a/src/files/envUpsert.ts +++ b/src/files/envUpsert.ts @@ -18,6 +18,18 @@ * server (the pushed blob and keep.lock are built from parsed values). */ +/** + * Whole comment lines Capy owns (never a variable). `ensure`: each appears exactly once (kept where it + * is when already there, appended otherwise). `drop`: each is removed wherever it appears. Matching is + * whole-line equality with a line Capy would itself write; nothing is interpreted. + */ +export interface EnvNotes { + readonly ensure: readonly string[]; + readonly drop: readonly string[]; +} + +const NO_NOTES: EnvNotes = { ensure: [], drop: [] }; + export interface EnvHeader { org_id?: string; project_id?: string; @@ -108,8 +120,15 @@ function headerLineKey(content: string): (typeof HEADER_KEYS)[number] | null { * `entries` is the complete desired set, in order, with each value already * rendered as it should appear after `=`. */ -export function upsertEnvText(existing: string, header: EnvHeader, entries: ReadonlyArray): string { +export function upsertEnvText( + existing: string, + header: EnvHeader, + entries: ReadonlyArray, + notes: EnvNotes = NO_NOTES, +): string { const lines = splitLines(existing); + const ensureSet = new Set(notes.ensure); + const dropSet = new Set(notes.drop.filter((l) => !ensureSet.has(l))); const wanted = new Map(entries); const headerValues = HEADER_KEYS.flatMap((k) => (header[k] ? [[k, header[k] as string] as const] : [])); @@ -117,7 +136,7 @@ export function upsertEnvText(existing: string, header: EnvHeader, entries: Read const headerText = headerValues.map(([k, v]) => `# capy:${k}=${v}`).join('\n'); const body = entries.map(([k, v]) => `${k}=${v}`).join('\n'); const joined = headerText ? `${headerText}\n\n${body}` : body; - return joined + '\n'; + return [joined, ...notes.ensure].filter((part) => part !== '').join('\n') + '\n'; } const eol = dominantEol(lines); @@ -138,11 +157,17 @@ export function upsertEnvText(existing: string, header: EnvHeader, entries: Read return [`${def.prefix}${def.key}=${wanted.get(def.key)}${def.inlineComment}${lastEol || (i === lines.length - 1 ? '' : eol)}`]; } if (coveredByDefinition.has(i)) return []; + if (dropSet.has(line.content)) return []; + // A note that appears twice keeps its first line. + if (ensureSet.has(line.content) && lines.findIndex((l) => l.content === line.content) !== i) return []; return [line.content + line.eol]; }); const missingHeader = headerValues.filter(([k]) => !presentHeaderKeys.has(k)).map(([k, v]) => `# capy:${k}=${v}${eol}`); - const newEntries = entries.filter(([k]) => !presentKeys.has(k)).map(([k, v]) => `${k}=${v}${eol}`); + const newEntries = [ + ...entries.filter(([k]) => !presentKeys.has(k)).map(([k, v]) => `${k}=${v}${eol}`), + ...notes.ensure.filter((note) => !lines.some((l) => l.content === note)).map((note) => `${note}${eol}`), + ]; const body = kept.join(''); const bodyWithTerminator = body.length > 0 && !/(\r\n|\n|\r)$/.test(body) && newEntries.length > 0 ? body + eol : body; const headerBlock = missingHeader.length > 0 diff --git a/src/files/fileManager.ts b/src/files/fileManager.ts index 0a600d6e..fd0867ac 100644 --- a/src/files/fileManager.ts +++ b/src/files/fileManager.ts @@ -5,7 +5,8 @@ import { parse as parseDotenv } from 'dotenv'; import { Encryptor } from '../crypto/encryptor'; import { deriveResourceId } from '../crypto/resourceId'; import { debug } from '../ui/debug'; -import { upsertEnvText, EnvHeader } from './envUpsert'; +import { upsertEnvText, EnvHeader, type EnvNotes } from './envUpsert'; +import { placeholderEnvNote, placeholderEnvNoteVariants, placeholdersOnBranch } from '../core/placeholder'; const KEEP_COMMENT = 'Capy lockfile — pins env var identities and value hashes per branch. Safe to commit; no secrets here. Plaintext lives in .env (gitignored). Regenerated by the Capy CLI on sync/push. See https://capy.sc/keep-docs'; @@ -68,6 +69,20 @@ export function serializeKeep(keep: KeepFile): string { return JSON.stringify(sorted, null, 2) + '\n'; } +/** The comment lines a branch's placeholders get in `.env`, and the stale ones to remove. */ +function placeholderNotes( + variables: Readonly>, + retired: readonly string[], + keep: KeepFile | null | undefined, + branch: string | undefined, +): EnvNotes { + const open = branch === undefined ? [] : placeholdersOnBranch(keep, branch).filter(({ name }) => !Object.hasOwn(variables, name)); + return { + ensure: open.map(({ name, marker }) => placeholderEnvNote(name, marker)), + drop: [...Object.keys(variables), ...retired].flatMap(placeholderEnvNoteVariants), + }; +} + export class FileManager { private projectRoot: string; @@ -152,7 +167,18 @@ export class FileManager { } } - writeEncryptedEnvFile(variables: Record, encryptionKey: string, path?: string, keep?: KeepFile | null, branch?: string): void { + /** + * `retired`: names that are gone from the branch; the note a placeholder left for them is removed. + * A name in `variables` has a value now, so its note is removed too. + */ + writeEncryptedEnvFile( + variables: Record, + encryptionKey: string, + path?: string, + keep?: KeepFile | null, + branch?: string, + retired: readonly string[] = [], + ): void { const envPath = path || join(this.projectRoot, '.env'); debug(`Encrypting and writing ${Object.keys(variables).length} variables to ${envPath}`); const backup = this.createBackup(envPath); @@ -188,7 +214,7 @@ export class FileManager { ...(branch ? { branch } : {}), }; const existing = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; - const content = upsertEnvText(existing, header, encryptedEntries); + const content = upsertEnvText(existing, header, encryptedEntries, placeholderNotes(variables, retired, keep, branch)); this.ensureDirectoryExists(dirname(envPath)); writeFileSync(envPath, content, { encoding: 'utf-8', mode: 0o600 }); diff --git a/src/sync/commit.ts b/src/sync/commit.ts index 29c09bc4..9c21d83f 100644 --- a/src/sync/commit.ts +++ b/src/sync/commit.ts @@ -27,6 +27,7 @@ import type { ProjectManager } from '../core/projectManager'; import type { ServiceClient } from '../service/serviceClient'; import { CapyError, ERROR_CODES, KeepFile, setSyncKeepHash } from '../types/index'; import { hashValue } from './compareSecrets'; +import { placeholderOf } from '../core/placeholder'; import { SyncEngine } from './syncEngine'; import type { RateInfo } from '../utils/pool'; @@ -61,7 +62,8 @@ export function pinnedHashes(keep: KeepFile | null | undefined, branch: string): return Object.fromEntries( Object.entries(keep?.variables ?? {}).flatMap(([name, entries]) => { const entry = entries.find((e) => e.branch === branch); - return entry ? ([[name, entry.value_hash]] as const) : []; + // A placeholder pins no value: it is neither compared, restored nor dropped with the pinned names. + return entry && entry.placeholder === undefined ? ([[name, entry.value_hash]] as const) : []; }), ); } @@ -243,6 +245,11 @@ export const envBlobOf = (lines: Readonly>): string => .map(([name, line]) => `${name}=${line}`) .join('\n'); +/** The names `changes` removes that are placeholders on `branch` (and are not being given a value in the same change). */ +function removedPlaceholders(keep: KeepFile, branch: string, changes: Changes): readonly string[] { + return changes.remove.filter((name) => !has(changes.set, name) && placeholderOf(keep, name, branch) !== undefined); +} + /** `keep` with this branch's slice rebuilt from `env`: every name set, every other pinned name dropped. */ export function keepForEnv(keep: KeepFile, branch: string, env: Readonly>): KeepFile { const stale = Object.keys(pinnedHashes(keep, branch)).filter((name) => !has(env, name)); @@ -325,9 +332,11 @@ export async function uploadChanges(input: UploadInput): Promise { changes, encryptLine: (name, value) => `capy:${resourceIdOf(name)}:${Encryptor.encrypt(value, projectKey)}`, }); - const changed = keepWithChanges(latestKeep, branch, applied.written, applied.removed, resourceIdOf); + // A name that is only a placeholder has no blob line to remove, but removing it still drops its entry. + const removed = [...applied.removed, ...removedPlaceholders(latestKeep, branch, changes)]; + const changed = keepWithChanges(latestKeep, branch, applied.written, removed, resourceIdOf); const keep = transformKeep ? transformKeep(changed) : changed; - const base = { snapshot, keep, lines: applied.lines, env: applied.env, written: Object.keys(applied.written), removed: applied.removed }; + const base = { snapshot, keep, lines: applied.lines, env: applied.env, written: Object.keys(applied.written), removed }; const readRate = 'rate' in read && read.rate !== undefined ? { read: read.rate } : {}; if (keep === latestKeep) return { ...base, result: undefined, rates: readRate }; @@ -491,7 +500,7 @@ function commitLocalCopy(ctx: FolderContext, changes: Changes, transformKeep: Fo const lines = Object.fromEntries( Object.entries(env).map(([name, value]) => [name, `capy:${deriveResourceId(ctx.branch, name)}:${Encryptor.encrypt(value, ctx.projectKey)}`]), ); - const rebuilt = keepForEnv(ctx.keep, ctx.branch, env); + const rebuilt = keepWithChanges(keepForEnv(ctx.keep, ctx.branch, env), ctx.branch, {}, removedPlaceholders(ctx.keep, ctx.branch, changes), (name) => deriveResourceId(ctx.branch, name)); const keep = transformKeep ? transformKeep(rebuilt) : rebuilt; return { env, @@ -537,7 +546,7 @@ export async function commitFolder(ctx: FolderContext, changes: Changes, opts: F writeKeepCache(ctx.orgId, ctx.projectId, done.cacheHash, envBlobOf(done.lines)); ctx.fileManager.writeKeepFile(done.adopted); - ctx.fileManager.writeEncryptedEnvFile({ ...done.env }, ctx.projectKey, undefined, done.adopted, ctx.branch); + ctx.fileManager.writeEncryptedEnvFile({ ...done.env }, ctx.projectKey, undefined, done.adopted, ctx.branch, done.removed); const existingSyncState = ctx.pm.readSyncState(); ctx.fileManager.writeSyncState({ diff --git a/src/sync/syncEngine.ts b/src/sync/syncEngine.ts index 70979d8b..70655e15 100644 --- a/src/sync/syncEngine.ts +++ b/src/sync/syncEngine.ts @@ -9,6 +9,7 @@ import { KeepVariableEntry, SyncState, } from '../types/index'; +import { withoutPlaceholder } from '../core/placeholder'; export class SyncEngine { /** @@ -144,8 +145,11 @@ export class SyncEngine { // Preserve any extra fields on the existing entry (e.g. `connector` // metadata set by `capy connect`). Without the spread, every sync // would clobber them. + // A value is being written, so the name is no longer a placeholder (core/placeholder.ts). + const found: Partial = idx >= 0 ? entries[idx] : {}; + const existing = data.value_hash && idx >= 0 ? withoutPlaceholder(entries[idx]) : found; const entry: KeepVariableEntry = { - ...(idx >= 0 ? entries[idx] : {}), + ...existing, resource_id: data.resource_id, value_hash: data.value_hash || '', branch: targetBranch, diff --git a/src/types/index.ts b/src/types/index.ts index c4eb0447..ce927ef5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -157,6 +157,16 @@ export interface TargetDelivery { superseded_deploy_ids?: readonly string[]; } +/** + * Why a variable has no value yet. `provider` is the import connector that could not + * read it; `reason` is that connector's own stable skip code (e.g. `sensitive_unreadable`). + * Both are codes, never sentences. + */ +export interface PlaceholderMarker { + provider: string; + reason: string; +} + /** v3 keep.lock variable entry — per-branch value hashes */ export interface KeepVariableEntry { resource_id: string; @@ -172,6 +182,13 @@ export interface KeepVariableEntry { changed_at?: string; /** Set when this variable was provisioned by `capy connect `. */ connector?: ConnectorMetadata; + /** + * Set when the name exists on this branch but has NO value yet: the source it was + * imported from would not give the value back. `value_hash` is '' and the blob holds + * no line for it. A real value written for this branch removes the field. + * See `core/placeholder.ts`. + */ + placeholder?: PlaceholderMarker; /** * Every `capy deploy` target this variable has been delivered to (CAP-679). * Optional and additive — absent on every entry written before this field @@ -778,6 +795,11 @@ export const ERROR_CODES = { VERCEL_SPLIT_NOT_RESTORED: 'VERCEL_SPLIT_NOT_RESTORED', /** `capy deploy`'s setup was given something that is neither a Vercel dashboard link for a project nor a project name. */ VERCEL_URL_INVALID: 'VERCEL_URL_INVALID', + /** + * A deploy would replace a value Vercel holds as sensitive (Capy cannot read it, so cannot tell whether it differs) for the first time + * for this target, and it was not confirmed: no terminal to ask on, and no `--replace-sensitive`; or the answer was no. Nothing was written. + */ + VERCEL_SENSITIVE_REPLACE_UNCONFIRMED: 'VERCEL_SENSITIVE_REPLACE_UNCONFIRMED', // `capy deploy vercel --discover` /** A Vercel project has no GitHub link, so no repo can be matched; it is skipped (a notice). */ VERCEL_PROJECT_NOT_GITHUB: 'VERCEL_PROJECT_NOT_GITHUB', diff --git a/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index d15c6640..1e1ee4e5 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -25,6 +25,7 @@ import { CANCELLED_NOTHING, DRY_RUN_LABEL } from '../commands/secretsSetText'; import type { RunProgress } from '../commands/secretsSet'; import { clipLine } from './pickerTable'; import { BEHIND_LABEL, DEPLOYED_LABEL, NO_TARGET_LABEL, TARGET_STATUS_HEADING, formatSecretRowStatus, locationState, secretRowStatus } from '../core/deployStatus'; +import { NO_VALUE_YET, noValueYetLine } from '../core/placeholder'; import { statusBadge, statusColor } from './statusBadge'; import { EditEffect, @@ -1002,7 +1003,9 @@ export function render(state: SecretsScreenState, termWidth: number, termHeight: // empty query carries no reasons anyway, but this also guards // `matchActive` for callers that ever hand in a non-empty `reasons` // alongside a cleared query). - const tag = matchActive && reasons.length > 0 ? `${ACCENT}[${reasons[0]}]${RESET}` : ''; + const matchTag = matchActive && reasons.length > 0 ? `${ACCENT}[${reasons[0]}]${RESET}` : ''; + // A row with no value yet says so beside its name (a search-match tag takes the place when one is active). + const tag = matchTag !== '' || row.value_hash !== '' ? matchTag : `${DIM}${NO_VALUE_YET}${RESET}`; const nameCell = buildNameCell(pointer + row.name, tag, nameW); const middleCell = pad(formatMiddleCell(row, state.column, middleW), middleW); const updatedCell = pad(formatUpdatedCell(row), updatedW); @@ -1099,7 +1102,7 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): '', `${indent}${inner}${BOLD}${truncate(row.name, contentWidth)}${RESET}`, '', - field('value', renderValueLine(popup.value, popup.revealed, popup.panOffset, valueWidth)), + field('value', row.value_hash === '' ? noValueYetLine(row.name) : renderValueLine(popup.value, popup.revealed, popup.panOffset, valueWidth)), field('updated', formatUpdatedCell(row)), field(TARGET_STATUS_HEADING.toLowerCase(), secretRowStatusBadge(row)), '', diff --git a/tests/commands/connectVercel.test.ts b/tests/commands/connectVercel.test.ts index e1dd30bb..625e9278 100644 --- a/tests/commands/connectVercel.test.ts +++ b/tests/commands/connectVercel.test.ts @@ -190,15 +190,17 @@ describe('vercel import — environment mapping and skips', () => { expect(callsOf(fetchMock).some((c) => c.url.includes('/env/env_1'))).toBe(false); }); - test('skipped names carry stable reasons: sensitive, system, legacy secret, empty value', async () => { + test('skipped names carry stable reasons: system, empty value; sensitive and legacy secret are placeholders, not skips', async () => { const { connector } = makeConnector(); const out = okOutcome(await connector.import!(ctxWith({ branch: 'production' }), BASE_OPTS)); expect(Object.fromEntries(out.skipped.map((s) => [s.name, s.code]))).toEqual({ - SENS: 'sensitive_unreadable', SYS: 'vercel_system', - OLD: 'legacy_secret_unreadable', EMPTY_ENC: 'empty_value', }); + expect(Object.fromEntries((out.placeholders ?? []).map((p) => [p.name, p.code]))).toEqual({ + SENS: 'sensitive_unreadable', + OLD: 'legacy_secret_unreadable', + }); expect(out.imported.map((i) => i.varName)).not.toContain('EMPTY_ENC'); }); @@ -443,7 +445,7 @@ describe('vercel import — end to end through ConnectCommand.executeImport', () imported: ['PLAIN_PROD', 'ENC_ALL'], source: { kind: 'vercel', projectId: 'prj_web', environment: 'production' }, }); - expect(parsed.skipped).toContainEqual({ name: 'SENS', code: 'sensitive_unreadable' }); + expect(parsed.placeholders).toContainEqual({ name: 'SENS', code: 'sensitive_unreadable' }); // the property: decrypting what landed on disk gives Vercel's values const decrypted = new FileManager(dir).readEncryptedEnvFile(PROJECT_KEY); diff --git a/tests/commands/connectVercelDiscovery.test.ts b/tests/commands/connectVercelDiscovery.test.ts index b5bfc6ea..30f130f7 100644 --- a/tests/commands/connectVercelDiscovery.test.ts +++ b/tests/commands/connectVercelDiscovery.test.ts @@ -507,7 +507,8 @@ describe('connector.discover — a real run, end to end', () => { // each imported entry's connector metadata names the project, team and environment const stagingEntry = folder.environments.find((env) => env.environmentName === 'staging')!.outcome.imported.find((i) => i.varName === 'B')!.entry; expect(stagingEntry).toMatchObject({ provider: 'vercel', vercel_project_id: 'prj_web', vercel_project: 'web', team_id: 'team_acme', environment: 'staging', env_id: 's1' }); - expect(outcome.applied?.[0].ok && (outcome.applied[0].environments[1].outcome.skipped)).toEqual([{ name: 'HIDDEN', code: 'sensitive_unreadable' }]); + expect(outcome.applied?.[0].ok && (outcome.applied[0].environments[1].outcome.skipped)).toEqual([]); + expect(outcome.applied?.[0].ok && (outcome.applied[0].environments[1].outcome.placeholders)).toEqual([{ name: 'HIDDEN', code: 'sensitive_unreadable', provider: 'vercel' }]); // the local discovery commit: its own branch, the Vercel message, never pushed expect(outcome.commits?.[0]).toMatchObject({ ok: true, dryRun: false }); diff --git a/tests/commands/secretsCommand.test.ts b/tests/commands/secretsCommand.test.ts index 414a6966..bee76fcd 100644 --- a/tests/commands/secretsCommand.test.ts +++ b/tests/commands/secretsCommand.test.ts @@ -383,6 +383,19 @@ describe('SecretsCommand', () => { expect(noTargets.status).toBe('no target'); expect(noTargets.status_locations).toBeUndefined(); }); + + it('a row with an empty value hash (a name with no value yet) says so and how to set it; a row with a value does not', async () => { + getSecretIndexImpl.mockImplementation(async () => ({ + org_id: 'org_1', + rows: [row({ name: 'NO_VALUE', value_hash: '' }), row({ name: 'HAS_VALUE', value_hash: 'abc123' })], + skipped: [], + })); + const { stdout } = await capture(() => new SecretsCommand().execute({ json: true })); + const [noValue, hasValue] = JSON.parse(stdout).rows; + expect(noValue).toMatchObject({ no_value: true, set_with: 'capy edit NO_VALUE' }); + expect(hasValue).not.toHaveProperty('no_value'); + expect(hasValue).not.toHaveProperty('set_with'); + }); }); describe('SecretsCommand — interactive launch (CAP-680: full TTY, no --json)', () => { diff --git a/tests/commands/vercelPlaceholders.test.ts b/tests/commands/vercelPlaceholders.test.ts new file mode 100644 index 00000000..4af82b1a --- /dev/null +++ b/tests/commands/vercelPlaceholders.test.ts @@ -0,0 +1,308 @@ +/** + * `capy connect vercel`: a value Vercel will not give back becomes a PLACEHOLDER on the Capy branch it maps to, + * in every environment (production, preview, development, custom). + * + * "Will not give back" is `sensitive_unreadable`, `legacy_secret_unreadable`, or a value route that answers + * with the structured field `decrypted: false` (`value_not_decrypted`). A value that really is an empty string + * (`decrypted: true`, value '') stays an `empty_value` skip, and a git-branch-scoped record stays a skip. + * + * A placeholder is a flag on the variable's keep.lock entry (core/placeholder.ts), never a value; the run, list and + * status surfaces are checked here too. Every value in here is a fake sentinel; nothing reaches the network. + */ +import { describe, test, expect } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { ListCommand } from '../../src/commands/listCommand'; +import { FileManager } from '../../src/files/fileManager'; +import { placeholderEnvNote, placeholderOf } from '../../src/core/placeholder'; +import type { ConnectOpts, ImportOutcome } from '../../src/commands/connectors/registry'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import type { KeepFile } from '../../src/types/index'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, withExitCodeRestored, withIsolatedGlobalDir, withTempDir } from '../helpers/testSandbox'; + +const ALL = ['production', 'preview', 'development']; +const SENTINEL = { + plain: 'plain-SENT-1a2b', + sensitive: 'sensitive-SENT-must-never-be-read', + legacy: 'legacy-SENT-3c4d', + hidden: 'hidden-SENT-5e6f', + custom: 'custom-SENT-7a8b', +} as const; + +const env = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +const ENVS: ReadonlyArray = [ + env({ id: 'e_plain', key: 'PLAIN', type: 'plain', target: ALL, listValue: SENTINEL.plain }), + env({ id: 'e_sens', key: 'SENS', type: 'sensitive', target: ALL }), + env({ id: 'e_old', key: 'OLD', type: 'secret', target: ['preview'] }), + env({ id: 'e_hidden', key: 'HIDDEN', type: 'encrypted', target: ['development'], listValue: 'opaque' }), + env({ id: 'e_empty', key: 'GENUINELY_EMPTY', type: 'encrypted', target: ['production'], listValue: 'opaque' }), + env({ id: 'e_scoped', key: 'SCOPED', type: 'sensitive', target: ['preview'], gitBranch: 'feature/x' }), + env({ id: 'e_custom', key: 'CUSTOM_SENS', type: 'sensitive', target: [], customEnvironmentIds: ['c1'] }), +]; + +const DATA: FakeVercelData = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: ENVS }, + custom: { prj_web: [{ id: 'c1', slug: 'staging' }] }, + // e_hidden: Vercel answers `decrypted: false`; e_empty: it decrypted a value that really is ''. + values: { e_hidden: SENTINEL.hidden, e_empty: '', e_sens: SENTINEL.sensitive }, + undecrypted: ['e_hidden'], +}; + +const BASE_OPTS: ConnectOpts = { nonTty: true, project: 'web', tokenEnv: 'T' }; + +function connector(data: FakeVercelData = DATA) { + return createVercelConnector({ fetch: fakeVercelFetch(data) as unknown as VercelFetchLike, env: { T: 'tok_fake_value' }, sleep: async () => {} }); +} + +const ctxWith = (branch: string, localPlaintext: Record = {}): ResolvedContext => + ({ + keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }, + branch, + orgId: 'org_1', + localPlaintext, + }) as unknown as ResolvedContext; + +const okOutcome = (o: ImportOutcome): Extract => { + if (!o.ok) throw new Error(`expected ok, got ${o.code}: ${o.message}`); + return o; +}; + +const codes = (xs: ReadonlyArray<{ name: string; code: string }> | undefined): Record => + Object.fromEntries((xs ?? []).map((x) => [x.name, x.code])); + +describe('import: what becomes a placeholder, in every environment', () => { + test('production: the sensitive value is a placeholder; a genuine empty string is an empty_value skip', async () => { + const out = okOutcome(await connector().import!(ctxWith('production'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable' }); + expect(codes(out.skipped)).toEqual({ GENUINELY_EMPTY: 'empty_value' }); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + }); + + test('preview: sensitive and legacy secret are placeholders; the git-branch-scoped one stays a skip', async () => { + const out = okOutcome(await connector().import!(ctxWith('preview'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable', OLD: 'legacy_secret_unreadable' }); + expect(out.skipped).toEqual([{ name: 'SCOPED', code: 'git_branch_scoped', gitBranch: 'feature/x' }]); + }); + + test('development: a value Vercel answers with decrypted:false is a placeholder, whatever the answer carries', async () => { + const out = okOutcome(await connector().import!(ctxWith('development'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable', HIDDEN: 'value_not_decrypted' }); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + }); + + test('a custom environment: its sensitive value is a placeholder on the branch named by its slug', async () => { + const out = okOutcome(await connector().import!(ctxWith('staging'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ CUSTOM_SENS: 'sensitive_unreadable' }); + }); + + test('a name that already has a value here keeps it: it is a skip, not a placeholder', async () => { + const out = okOutcome(await connector().import!(ctxWith('production', { SENS: 'mine-already' }), BASE_OPTS)); + expect(out.placeholders).toEqual([]); + expect(codes(out.skipped)).toEqual({ SENS: 'sensitive_unreadable', GENUINELY_EMPTY: 'empty_value' }); + }); + + test('--var restricts the placeholders to the named variables', async () => { + const out = okOutcome(await connector().import!(ctxWith('development'), { ...BASE_OPTS, var: 'HIDDEN' })); + expect(codes(out.placeholders)).toEqual({ HIDDEN: 'value_not_decrypted' }); + }); + + test('--overwrite --yes: a placeholder is reported, and a name with a local value is never cleared or turned into one', async () => { + const out = okOutcome(await connector().import!(ctxWith('development', { SENS: 'mine-already' }), { ...BASE_OPTS, overwrite: true, yes: true })); + expect(codes(out.placeholders)).toEqual({ HIDDEN: 'value_not_decrypted' }); + expect(out.cleared).toEqual([]); + expect(codes(out.skipped)).toEqual({ SENS: 'sensitive_unreadable' }); + }); + + test('no value is read for a sensitive record, and none is in the outcome', async () => { + const out = okOutcome(await connector().import!(ctxWith('production'), BASE_OPTS)); + expect(JSON.stringify(out.placeholders)).not.toContain(SENTINEL.sensitive); + expect(JSON.stringify(out)).not.toContain(SENTINEL.sensitive); + }); +}); + +// ── end to end: a real folder, the real report, keep.lock, .env, list, run ──── + +const PROJECT_KEY = 'placeholder-e2e-project-key-0123456789'; + +const realCtx = (dir: string, branch: string, keep?: KeepFile, localPlaintext: Record = {}): ResolvedContext => + ({ + pm: { readSyncState: () => null }, + fileManager: new FileManager(dir), + serviceClient: { + pushSecrets: () => { + throw new Error('must not push — these runs pass noPush'); + }, + }, + orgId: 'org_1', + projectId: 'p', + branch, + userId: 'u', + projectKey: PROJECT_KEY, + keep: keep ?? { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }, + localPlaintext, + }) as unknown as ResolvedContext; + +const importInto = (dir: string, branch: string, opts: ConnectOpts, keep?: KeepFile, local?: Record) => + captureConsole(() => new ConnectCommand(false)['executeImport'](connector(), 'vercel', realCtx(dir, branch, keep, local), { ...BASE_OPTS, noPush: true, ...opts })); + +const readKeep = (dir: string): KeepFile => JSON.parse(readFileSync(join(dir, '.capy', 'keep.lock'), 'utf-8')) as KeepFile; +const SENS_NOTE = placeholderEnvNote('SENS', { provider: 'vercel', reason: 'sensitive_unreadable' }); + +describe('import end to end (real FileManager, real report)', () => { + test('--json: the placeholders are their own list, names and codes only; no value anywhere', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-ph-json', async (dir) => { + const { stdout, stderr } = await importInto(dir, 'production', { json: true }); + expect(stdout).toHaveLength(1); + const parsed = JSON.parse(stdout[0]); + expect(parsed.placeholders).toEqual([{ name: 'SENS', code: 'sensitive_unreadable' }]); + expect(parsed.skipped).toEqual([{ name: 'GENUINELY_EMPTY', code: 'empty_value' }]); + const everything = [...stdout, ...stderr, readFileSync(join(dir, '.env'), 'utf-8'), readFileSync(join(dir, '.capy', 'keep.lock'), 'utf-8')].join('\n'); + Object.values(SENTINEL).forEach((s) => expect(everything).not.toContain(s)); + }), + ), + ); + }); + + test('human output names the placeholders and how to set one, without a value', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-human', async (dir) => { + const { stdout } = await importInto(dir, 'production', {}); + const text = stdout.join('\n'); + expect(text).toContain('SENS (sensitive_unreadable)'); + expect(text).toContain('capy edit'); + expect(text).not.toContain('Skipped: SENS'); + }), + ); + }); + + test('keep.lock: a typed flag with an empty hash and no connector; the .env has no variable for it, only a comment', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-keep', async (dir) => { + await importInto(dir, 'production', { json: true }); + const entry = readKeep(dir).variables.SENS?.find((e) => e.branch === 'production'); + expect(entry).toMatchObject({ branch: 'production', value_hash: '', placeholder: { provider: 'vercel', reason: 'sensitive_unreadable' } }); + expect(entry).not.toHaveProperty('connector'); + expect(placeholderOf(readKeep(dir), 'SENS', 'production')).toEqual({ provider: 'vercel', reason: 'sensitive_unreadable' }); + + const dotenv = readFileSync(join(dir, '.env'), 'utf-8'); + expect(dotenv.split('\n').filter((l) => l === SENS_NOTE)).toHaveLength(1); + expect(dotenv).not.toMatch(/^SENS=/m); + expect(new FileManager(dir).readEnvFile()).not.toHaveProperty('SENS'); + expect(new FileManager(dir).readEncryptedEnvFile(PROJECT_KEY)).toEqual({ PLAIN: SENTINEL.plain }); + }), + ); + }); + + test('importing again writes the comment once and leaves the person\'s own lines where they were', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-again', async (dir) => { + await importInto(dir, 'production', { json: true }); + writeFileSync(join(dir, '.env'), `# mine\n${readFileSync(join(dir, '.env'), 'utf-8')}`); + const keep = readKeep(dir); + await importInto(dir, 'production', { json: true }, keep, new FileManager(dir).readEncryptedEnvFile(PROJECT_KEY)); + const dotenv = readFileSync(join(dir, '.env'), 'utf-8'); + expect(dotenv.startsWith('# mine\n')).toBe(true); + expect(dotenv.split('\n').filter((l) => l === SENS_NOTE)).toHaveLength(1); + expect(placeholderOf(readKeep(dir), 'SENS', 'production')).toBeDefined(); + }), + ); + }); + + test('--dry-run lists the placeholders and writes nothing', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-ph-dry', async (dir) => { + const { stdout } = await importInto(dir, 'production', { json: true, dryRun: true }); + expect(JSON.parse(stdout[0])).toMatchObject({ dryRun: true, placeholders: [{ name: 'SENS', code: 'sensitive_unreadable' }] }); + expect(existsSync(join(dir, '.env'))).toBe(false); + expect(existsSync(join(dir, '.capy', 'keep.lock'))).toBe(false); + }), + ), + ); + }); + + test('another branch gets its own placeholders (preview has two, production one)', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-branches', async (dir) => { + await importInto(dir, 'production', { json: true }); + await importInto(dir, 'preview', { json: true }, readKeep(dir)); + const keep = readKeep(dir); + expect(placeholderOf(keep, 'OLD', 'preview')?.reason).toBe('legacy_secret_unreadable'); + expect(placeholderOf(keep, 'OLD', 'production')).toBeUndefined(); + expect(placeholderOf(keep, 'SENS', 'preview')).toBeDefined(); + expect(placeholderOf(keep, 'SENS', 'production')).toBeDefined(); + }), + ); + }); +}); + +describe('surfaces', () => { + const withImportedFolder = async (fn: (dir: string) => Promise): Promise => + withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-surface', async (dir) => { + await importInto(dir, 'production', { json: true }); + await fn(dir); + }), + ); + + /** `fn` with the process working directory in `dir`, put back afterwards. */ + const inDir = async (dir: string, fn: () => Promise): Promise => { + const original = process.cwd(); + process.chdir(dir); + try { + return await fn(); + } finally { + process.chdir(original); + } + }; + + test('capy list --json: the placeholder, its reason and how to set it', async () => { + await withImportedFolder(async (dir) => { + const { stdout } = await inDir(dir, () => captureConsole(() => new ListCommand().execute({ json: true }))); + const listed = JSON.parse(stdout.join('\n')); + const sens = listed.variables.find((v: { name: string }) => v.name === 'SENS'); + expect(sens.placeholder).toEqual({ provider: 'vercel', reason: 'sensitive_unreadable', setWith: 'capy edit SENS' }); + expect(listed.variables.find((v: { name: string }) => v.name === 'PLAIN').placeholder).toBeNull(); + }); + }); + + test('capy list: "no value yet (sensitive in Vercel)" and the command', async () => { + await withImportedFolder(async (dir) => { + const { stdout } = await inDir(dir, () => captureConsole(() => new ListCommand().execute({}))); + const line = stdout.find((l) => l.includes('SENS')) ?? ''; + expect(line).toContain('no value yet (sensitive in Vercel)'); + expect(line).toContain('capy edit SENS'); + expect(stdout.find((l) => l.includes('PLAIN')) ?? '').not.toContain('no value yet'); + }); + }); + + test('capy run: the variable is not set (the comment is not a variable)', async () => { + await withImportedFolder(async (dir) => { + const dotenv = readFileSync(join(dir, '.env'), 'utf-8'); + // `capy run` needs no key when no value in the file is encrypted: keep the placeholder's file and one plain variable. + writeFileSync(join(dir, '.env'), `${dotenv.split('\n').filter((l) => !l.startsWith('PLAIN=')).join('\n')}\nOTHER=here\n`); + const home = mkdtempSync(join(tmpdir(), 'capy-ph-run-home-')); + mkdirSync(join(home, '.capy'), { recursive: true }); + const { SENS: _inherited, ...cleanEnv } = process.env; + const cli = join(__dirname, '../../dist/index.js'); + const printed = execFileSync('node', [cli, 'run', '--', 'node', '-e', 'console.log(JSON.stringify({ has: "SENS" in process.env, other: process.env.OTHER }))'], { + cwd: dir, + env: { ...cleanEnv, HOME: home, USERPROFILE: home }, + encoding: 'utf-8', + }); + rmSync(home, { recursive: true, force: true }); + expect(JSON.parse(printed.trim())).toEqual({ has: false, other: 'here' }); + }); + }); +}); diff --git a/tests/helpers/fakeVercel.ts b/tests/helpers/fakeVercel.ts index 883def23..2ec43cee 100644 --- a/tests/helpers/fakeVercel.ts +++ b/tests/helpers/fakeVercel.ts @@ -28,6 +28,8 @@ export interface FakeVercelData { custom?: Readonly>>; /** Per-id decrypted values, keyed by env record id. A missing key = Vercel returned no `value`. */ values?: Readonly>; + /** Env record ids whose per-id answer says `decrypted: false` (Vercel would not decrypt the value, e.g. a role that cannot read production values). */ + undecrypted?: readonly string[]; /** Project list page size, to exercise pagination. */ pageSize?: number; /** Set `pagination.next` on the env list, to exercise the truncation warning. */ @@ -109,7 +111,7 @@ export function fakeVercelFetch(data: FakeVercelData): VercelFetchLike { if (!rec) return json({ error: { code: 'not_found', message: 'nope' } }, 404); const value = data.values?.[envId]; const { listValue: _omit, ...meta } = rec; - return json({ ...meta, decrypted: true, ...(value === undefined ? {} : { value }) }); + return json({ ...meta, decrypted: !(data.undecrypted ?? []).includes(envId), ...(value === undefined ? {} : { value }) }); } throw new Error(`fakeVercel: unexpected path ${path}`); }) as VercelFetchLike; diff --git a/tests/sync/placeholders.test.ts b/tests/sync/placeholders.test.ts new file mode 100644 index 00000000..9818d4d9 --- /dev/null +++ b/tests/sync/placeholders.test.ts @@ -0,0 +1,258 @@ +/** + * Placeholders in keep.lock: a name on a branch with NO value yet (an import could not read it). + * + * The properties, on an in-memory server (tests/helpers/fakeCapyServer.ts) and a real folder on disk: + * - a placeholder is a typed flag on the branch's keep entry with an empty hash; the blob holds no line for it, + * and no value or stand-in string appears anywhere; + * - it round-trips: server keep, the folder's keep.lock, and a fresh read of the folder all say the same; + * - `.env` gets one comment line for it (idempotent, layout preserved), and no variable; + * - sync does not treat it as pinned, missing or conflicting, and rebuilding the branch's pins keeps it; + * - writing a real value by any path (`capy edit`, `capy add --force`, the editor screen) removes the flag and the note. + */ +import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'; +import { readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import { saveEdits } from '../../src/commands/editCommand'; +import { writeAndSync, writeImportedAndSync, type ResolvedContext } from '../../src/commands/connectors/shared'; +import { classifyPipedWrite, variableExists } from '../../src/commands/pipedWrite'; +import { placeholderEnvNote, placeholderOf, placeholdersOnBranch } from '../../src/core/placeholder'; +import { upsertEnvText } from '../../src/files/envUpsert'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { commitFolder, keepForEnv, pinnedHashes } from '../../src/sync/commit'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import type { KeepFile } from '../../src/types/index'; +import { fakeCapyServer, seedServer, serverLine, serverValues } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +const KEY = Encryptor.generateKey(); +const DEV = 'development'; +const MARKER = { provider: 'vercel', reason: 'sensitive_unreadable' } as const; +const SEED = seedServer({ projectKey: KEY, values: { [DEV]: { X: 'x-1', Y: 'y-1' }, production: { X: 'x-prod' } } }); +const NOTE = placeholderEnvNote('SECRET_KEY', MARKER); + +const globalDirName = `.capy-test-placeholders-${process.pid}`; +const savedGlobalDirName = process.env.CAPY_GLOBAL_DIR_NAME; +beforeAll(() => { + process.env.CAPY_GLOBAL_DIR_NAME = globalDirName; +}); +afterAll(() => { + rmSync(getGlobalCapyDir(), { recursive: true, force: true }); + if (savedGlobalDirName === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = savedGlobalDirName; +}); + +const made = mock((_: Folder) => undefined); +afterEach(() => made.mock.calls.forEach(([folder]) => removeFolder(folder))); + +function world() { + const server = fakeCapyServer({ initial: SEED }); + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: SEED.keep as KeepFile, env: { X: 'x-1', Y: 'y-1' }, client: server.client }); + made(folder); + return { server, folder }; +} + +/** The folder as a connector's write sees it. */ +const asContext = (folder: Folder, server: ReturnType): ResolvedContext => + ({ ...folder.ctx, serviceClient: server.client, keep: folder.keep(), localPlaintext: folder.env() }) as unknown as ResolvedContext; + +const envText = (folder: Folder): string => readFileSync(join(folder.dir, '.env'), 'utf-8'); +const noteLines = (text: string): readonly string[] => text.split('\n').filter((l) => l === NOTE); + +/** Records SECRET_KEY as a placeholder on the folder's branch, pushed. */ +async function importPlaceholder(w: ReturnType): Promise { + await writeImportedAndSync(asContext(w.folder, w.server), [], { + push: true, + placeholders: [{ name: 'SECRET_KEY', marker: MARKER }], + }); +} + +describe('recording a placeholder', () => { + test('is a typed flag with an empty hash on the branch entry; the blob has no line for it; nothing else moves', async () => { + const w = world(); + const before = w.server.state(); + await importPlaceholder(w); + const after = w.server.state(); + + const entry = after.keep?.variables.SECRET_KEY?.find((e) => e.branch === DEV); + expect(entry).toMatchObject({ branch: DEV, value_hash: '', placeholder: MARKER }); + expect(entry).not.toHaveProperty('connector'); + expect(serverValues(after, DEV, KEY)).toEqual({ X: 'x-1', Y: 'y-1' }); + expect(serverLine(after, DEV, 'SECRET_KEY')).toBeUndefined(); + expect(serverLine(after, DEV, 'X')).toBe(serverLine(before, DEV, 'X')); + const entryOf = (state: typeof after, branch: string) => state.keep?.variables.X?.find((e) => e.branch === branch); + expect(entryOf(after, DEV)?.value_hash).toBe(entryOf(before, DEV)?.value_hash); + expect(entryOf(after, 'production')).toEqual(entryOf(before, 'production')); + }); + + test('round-trips: the folder\'s keep.lock equals the server\'s branch slice, and a fresh read still has the flag', async () => { + const w = world(); + await importPlaceholder(w); + const onServer = w.server.state().keep?.variables.SECRET_KEY?.find((e) => e.branch === DEV); + const onDisk = w.folder.keep().variables.SECRET_KEY?.find((e) => e.branch === DEV); + expect(onDisk).toEqual(onServer); + expect(placeholderOf(w.folder.pm.readKeepFile(), 'SECRET_KEY', DEV)).toEqual(MARKER); + expect(placeholdersOnBranch(w.folder.keep(), DEV).map((p) => p.name)).toEqual(['SECRET_KEY']); + }); + + test('never writes a value or a stand-in string: not in keep.lock, not in .env, not on the server', async () => { + const w = world(); + await importPlaceholder(w); + const everything = [ + readFileSync(join(w.folder.dir, '.capy', 'keep.lock'), 'utf-8'), + envText(w.folder), + JSON.stringify(w.server.state()), + ].join('\n'); + expect(everything).not.toMatch(/\[vercel-sensitive-value\]|\[.*sensitive.*value.*\]/i); + expect(w.folder.env()).toEqual({ X: 'x-1', Y: 'y-1' }); + }); + + test('.env gets exactly one comment line and no variable; a second import writes nothing and changes nothing', async () => { + const w = world(); + await importPlaceholder(w); + const first = envText(w.folder); + expect(noteLines(first)).toHaveLength(1); + expect(first).not.toMatch(/^SECRET_KEY=/m); + const pushesAfterFirst = w.server.tape.mock.calls.length; + + await importPlaceholder(w); + // (A value line is re-encrypted on every write, so its ciphertext differs; the shape and the rest do not.) + const shape = (text: string): string => text.replace(/=capy:([a-z0-9]+):[^\n]*/g, '=capy:$1:*'); + expect(shape(envText(w.folder))).toBe(shape(first)); + expect(w.server.tape.mock.calls.length).toBe(pushesAfterFirst); + }); + + test('a name that already has a value on the branch keeps it: the flag is never put over a value', async () => { + const w = world(); + await writeImportedAndSync(asContext(w.folder, w.server), [], { push: true, placeholders: [{ name: 'X', marker: MARKER }] }); + expect(placeholderOf(w.server.state().keep, 'X', DEV)).toBeUndefined(); + expect(serverValues(w.server.state(), DEV, KEY).X).toBe('x-1'); + expect(w.server.state().keep?.variables.X?.find((e) => e.branch === DEV)?.value_hash).toBe(hashValue('x-1')); + }); + + test('the same name on another branch is its own entry: production keeps its value', async () => { + const w = world(); + await importPlaceholder(w); + expect(placeholderOf(w.server.state().keep, 'SECRET_KEY', 'production')).toBeUndefined(); + expect(w.server.state().keep?.variables.X?.find((e) => e.branch === 'production')?.value_hash).toBe(hashValue('x-prod')); + }); +}); + +describe('.env layout', () => { + const existing = ['# my notes', '', 'A=1', '', '# --- infra ---', 'B=2 # keep', ''].join('\n'); + + test('the note is appended once; every other line is byte for byte as it was, in the same order', () => { + const out = upsertEnvText(existing, {}, [['A', '1'], ['B', '2']], { ensure: [NOTE], drop: [] }); + expect(out.startsWith(existing)).toBe(true); + expect(out.slice(existing.length)).toBe(`${NOTE}\n`); + expect(upsertEnvText(out, {}, [['A', '1'], ['B', '2']], { ensure: [NOTE], drop: [] })).toBe(out); + }); + + test('a note that is already somewhere stays where it is (no duplicate, no move)', () => { + const text = ['A=1', NOTE, 'B=2', ''].join('\n'); + expect(upsertEnvText(text, {}, [['A', '1'], ['B', '2']], { ensure: [NOTE], drop: [] })).toBe(text); + }); + + test('a note to drop is removed and nothing else is', () => { + const text = ['A=1', NOTE, 'B=2', ''].join('\n'); + expect(upsertEnvText(text, {}, [['A', '1'], ['B', '2']], { ensure: [], drop: [NOTE] })).toBe('A=1\nB=2\n'); + }); + + test('a real file keeps the person\'s comments and order through an import', async () => { + const w = world(); + const before = envText(w.folder); + writeFileSync(join(w.folder.dir, '.env'), `# my notes\n${before}`); + await importPlaceholder(w); + const after = envText(w.folder); + expect(after.startsWith('# my notes\n')).toBe(true); + expect(noteLines(after)).toHaveLength(1); + expect(after.indexOf('X=')).toBeLessThan(after.indexOf('Y=')); + }); +}); + +describe('sync does not see a placeholder as a value', () => { + test('it is not pinned, so it is never compared, restored or dropped with the pins', async () => { + const w = world(); + await importPlaceholder(w); + expect(Object.keys(pinnedHashes(w.folder.keep(), DEV)).sort()).toEqual(['X', 'Y']); + expect(pinnedHashes(w.folder.keep(), DEV)).not.toHaveProperty('SECRET_KEY'); + }); + + test('rebuilding the branch\'s pins from an env (retrieve / row-by-row) keeps the placeholder', async () => { + const w = world(); + await importPlaceholder(w); + const rebuilt = keepForEnv(w.folder.keep(), DEV, { X: 'x-1' }); + expect(placeholderOf(rebuilt, 'SECRET_KEY', DEV)).toEqual(MARKER); + expect(rebuilt.variables.Y).toBeUndefined(); + }); + + test('committing the folder\'s values (bare `capy`, commit local) leaves the placeholder alone', async () => { + const w = world(); + await importPlaceholder(w); + await commitFolder({ ...asContext(w.folder, w.server) }, { set: { Y: 'y-2' }, remove: [] }); + expect(placeholderOf(w.server.state().keep, 'SECRET_KEY', DEV)).toEqual(MARKER); + expect(noteLines(envText(w.folder))).toHaveLength(1); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-1', Y: 'y-2' }); + }); + + test('the keep hash agrees between client and the server\'s copy (the flag does not perturb it)', async () => { + const w = world(); + await importPlaceholder(w); + expect(SyncEngine.computeKeepHash(w.folder.keep(), DEV)).toBe(SyncEngine.computeKeepHash(w.server.state().keep as KeepFile, DEV)); + }); + + test('capy edit NAME / capy add NAME see it as existing (so --force is what writes it)', async () => { + const w = world(); + await importPlaceholder(w); + const ctx = asContext(w.folder, w.server); + expect(variableExists(ctx, 'SECRET_KEY')).toBe(true); + expect(classifyPipedWrite(ctx, 'SECRET_KEY', 'v')).toBe('updated'); + }); +}); + +describe('a real value clears it', () => { + const cleared = (w: ReturnType, value: string) => { + const server = w.server.state(); + expect(placeholderOf(server.keep, 'SECRET_KEY', DEV)).toBeUndefined(); + expect(server.keep?.variables.SECRET_KEY?.find((e) => e.branch === DEV)).toMatchObject({ value_hash: hashValue(value) }); + expect(placeholderOf(w.folder.keep(), 'SECRET_KEY', DEV)).toBeUndefined(); + expect(serverValues(server, DEV, KEY).SECRET_KEY).toBe(value); + expect(w.folder.env().SECRET_KEY).toBe(value); + expect(noteLines(envText(w.folder))).toHaveLength(0); + }; + + test('capy edit NAME (piped), capy edit NAME (prompt) and capy add NAME --force: the single-variable save', async () => { + const w = world(); + await importPlaceholder(w); + await writeAndSync(asContext(w.folder, w.server), 'SECRET_KEY', 'real-value-1', { push: true }); + cleared(w, 'real-value-1'); + }); + + test('the editor screen\'s save', async () => { + const w = world(); + await importPlaceholder(w); + const { keep: _keep, localPlaintext: _local, ...folder } = asContext(w.folder, w.server); + await saveEdits(folder, w.folder.keep(), { SECRET_KEY: 'real-value-2' }, { record: () => undefined }); + cleared(w, 'real-value-2'); + }); + + test('without pushing (--no-push): the folder\'s keep.lock and .env are cleared too', async () => { + const w = world(); + await importPlaceholder(w); + await writeAndSync({ ...asContext(w.folder, w.server) }, 'SECRET_KEY', 'real-value-3', { push: false }); + expect(placeholderOf(w.folder.keep(), 'SECRET_KEY', DEV)).toBeUndefined(); + expect(w.folder.env().SECRET_KEY).toBe('real-value-3'); + expect(noteLines(envText(w.folder))).toHaveLength(0); + }); + + test('removing the name (capy remove) drops the entry and the note', async () => { + const w = world(); + await importPlaceholder(w); + await commitFolder(asContext(w.folder, w.server), { set: {}, remove: ['SECRET_KEY'] }); + expect(w.server.state().keep?.variables.SECRET_KEY).toBeUndefined(); + expect(w.folder.keep().variables.SECRET_KEY).toBeUndefined(); + expect(noteLines(envText(w.folder))).toHaveLength(0); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-1', Y: 'y-1' }); + }); +}); diff --git a/tests/ui/secretsScreen.test.ts b/tests/ui/secretsScreen.test.ts index 6602a4b1..9807fbac 100644 --- a/tests/ui/secretsScreen.test.ts +++ b/tests/ui/secretsScreen.test.ts @@ -1197,3 +1197,16 @@ describe('CAP-679 rendering: "+N" survives truncation, INTEGRATIONS overflow, po expect(stripAnsiForTest(frame)).not.toMatch(/\[[a-z-]+\]/); // no `[provider]` label anywhere }); }); + +describe('a row with no value yet', () => { + test('the list says "no value yet" beside the name; a row with a value does not', () => { + const frame = stripAnsiForTest(render(initialSecretsScreenState([row({ name: 'EMPTY', value_hash: '' }), row({ name: 'FULL', value_hash: 'abc' })]), 100, 20)); + expect(frame.split('\n').find((l) => l.includes('EMPTY'))).toContain('no value yet'); + expect(frame.split('\n').find((l) => l.includes('FULL'))).not.toContain('no value yet'); + }); + + test('the details view names the command that sets one, where the value would be', () => { + const frame = stripAnsiForTest(render(handleKey(initialSecretsScreenState([row({ name: 'EMPTY', value_hash: '' })]), ENTER).state, 100, 30)); + expect(frame).toContain('no value yet — set it with: capy edit EMPTY'); + }); +}); From a144072a2e2e33e108e283e1080537c7c9ecde82 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 2 Nov 2026 17:43:55 -0700 Subject: [PATCH 11/22] feat(vercel): deploy never overwrites with a placeholder A target var that is a placeholder on the branch is skipped and reported as kept. Replacing a value Vercel holds as sensitive asks the first time for a target (terminal), or needs --replace-sensitive off a terminal; the delivery record that follows is the record that it was confirmed. Dry run reports both. --- docs/cli-reference.md | 6 +- src/commands/deployCommand.ts | 58 ++++- src/deploy/adapter.ts | 15 ++ src/deploy/adapters/vercel.ts | 100 +++++++- src/deploy/batchDeploy.ts | 4 +- src/deploy/deliveryRecord.ts | 16 ++ src/deploy/vercelTargetApi.ts | 23 ++ src/index.ts | 2 + tests/deploy/vercelApiMode.test.ts | 5 +- tests/deploy/vercelPlaceholderDeploy.test.ts | 231 +++++++++++++++++++ 10 files changed, 443 insertions(+), 17 deletions(-) create mode 100644 tests/deploy/vercelPlaceholderDeploy.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8301d3f8..ae11a36a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -34,7 +34,7 @@ JSON support: yes (`--json`) ## `capy edit` -Inspect and edit secrets in an interactive TUI, or set one variable from a piped value +Inspect and edit secrets in an interactive TUI, or set one variable from a hidden prompt or a piped value ``` capy edit [name] [options] @@ -42,7 +42,7 @@ capy edit [name] [options] | Option | Description | Default | |---|---|---| -| `--no-push` | piped value: write .env only; do not push to Capy | | +| `--no-push` | with a name: write .env only; do not push to Capy | | | `--json` | emit machine-readable JSON instead of the human UI (piped value) | | | `--non-tty` | treat stdin as not a terminal; never prompt (agents/CI) | | | `--pr` | create a PR with the keep.lock change (answers the prompt) | | @@ -127,6 +127,7 @@ capy deploy [target] [options] | `--scope ` | gh-actions: "repo" or "env" | | | `--env-name ` | gh-actions: env name when --scope env | | | `--no-deploy` | write and verify the target, but skip the platform deploy/redeploy (target mode) | | +| `--replace-sensitive` | vercel: allow replacing a value Vercel holds as sensitive, for the first time for a target, without asking | | | `--json` | describe the route (unanswered stops + any known branch problem) as JSON instead of travelling it | | | `--discover` | dokploy/vercel: find the services that match Capy projects and print JSON (never prompts) | | | `--plan ` | dokploy/vercel --discover: the plan file to check (see schemas.deploy_dokploy_plan and schemas.deploy_vercel_plan in `capy help --json`) | | @@ -1020,6 +1021,7 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `VERCEL_OPTIONS_INVALID` - `VERCEL_SPLIT_NOT_RESTORED` - `VERCEL_URL_INVALID` +- `VERCEL_SENSITIVE_REPLACE_UNCONFIRMED` - `VERCEL_PROJECT_NOT_GITHUB` - `VERCEL_ENV_LIST_TRUNCATED` - `EDIT_NEEDS_TTY` diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index cdae4327..6615d4a6 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -66,6 +66,7 @@ import { SyncEngine } from '../sync/syncEngine'; import type { AuthService } from '../auth/authService'; import type { ServiceClient } from '../service/serviceClient'; import { ProjectManager } from '../core/projectManager'; +import { placeholdersOnBranch } from '../core/placeholder'; import { tmpdir } from 'os'; import { ALL_ADAPTERS, getAdapter, listPlanned } from '../deploy/registry'; import { detectAwsRegion, leafFor } from '../deploy/adapters/awsSsm'; @@ -93,7 +94,7 @@ import { resolveVercelTokenOnce } from '../deploy/vercelTokenOnce'; import { resolveVercelSetup } from '../deploy/vercelSetup'; import { isVercelApiMode } from '../deploy/vercelTargetApi'; import type { AdapterCallContext } from '../deploy/adapter'; -import { deliveryFor, recordDeliveriesOnServer, targetRefFor } from '../deploy/deliveryRecord'; +import { deliveredBefore, deliveryFor, recordDeliveriesOnServer, targetRefFor } from '../deploy/deliveryRecord'; import { deployPlan, unansweredDeployStops, type DeployStopId } from '../core/deployPlan'; import { conflictFlagsOrExit, @@ -156,6 +157,11 @@ export interface DeployCliOptions { * ships it later. `targets` are still recorded — the write happened. */ noDeploy?: boolean; + /** + * `--replace-sensitive`: allow replacing a value Vercel holds as sensitive, for the first time for a target, without asking. + * Off a terminal this is the only yes; without it such a deploy is refused (`VERCEL_SENSITIVE_REPLACE_UNCONFIRMED`). + */ + replaceSensitive?: boolean; /** * `--resolve` / `--conflict` as typed (CAP-717): they decide the conflicts between the folder, Capy and the * platform off a terminal. Without them such a conflict stops the deploy (exit 3, the payload on stdout). @@ -417,6 +423,51 @@ async function loadDeploySecrets( } } +// ── Names with no value yet, and replacing a sensitive Vercel value ───────── + +/** The names the picker and the var-set check know on the active branch: the `.env` names, plus the placeholders (no value, but they exist). */ +function branchVarNames(cwd: string): ReadonlySet { + const pm = new ProjectManager(cwd); + const branch = pm.deriveActiveBranch(); + const placeholders = branch ? placeholdersOnBranch(pm.readKeepFile(), branch).map((p) => p.name) : []; + return new Set([...Object.keys(new FileManager(cwd).readEnvFile()), ...placeholders]); +} + +/** + * What the adapter needs to leave placeholders alone and to ask before replacing a sensitive Vercel value for the first time. + * Read from keep.lock; the answer to the question is recorded by the delivery itself (`deliveredBefore`). + */ +function placeholderDeliveryContext( + cwd: string, + target: TargetConfig, + adapter: DeployAdapter, + options: DeployCliOptions, +): Pick { + const keep = new ProjectManager(cwd).readKeepFile(); + const canAsk = process.stdin.isTTY === true && !options.yes && !options.json && !options.dryRun; + return { + placeholders: placeholdersOnBranch(keep, target.branch).map((p) => p.name), + sensitiveReplace: { + delivered: deliveredBefore(keep, target, adapter), + allowed: options.replaceSensitive === true, + ...(canAsk ? { ask: askToReplaceSensitive } : {}), + }, + }; +} + +async function askToReplaceSensitive(names: readonly string[]): Promise { + const ans = await inquirer.prompt([ + { + type: 'confirm', + name: 'yes', + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `Replace the value Vercel holds as sensitive for ${names.join(', ')}? Capy cannot read it, so it cannot tell whether it differs.`, + default: false, + }, + ]); + return !!ans.yes; +} + // ── Targets recording (CAP-679) ───────────────────────────────────────────── /** @@ -1415,7 +1466,7 @@ export async function runPicker( // off a different directory is how the picker ends up ticking a variable the // target does not have. Identical whenever the two agree, which is every // production call. - const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); + const branchVarSet = branchVarNames(cwd); const branchVars = keep.variables.filter((v) => branchVarSet.has(v)); // 1. Pick adapter (when not pre-selected). Real adapters are selectable; @@ -2358,7 +2409,7 @@ async function reconcileTargetVars( target: TargetConfig, options: DeployCliOptions, ): Promise> { - const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); + const branchVarSet = branchVarNames(cwd); const currentVars = keep.variables.filter((v) => branchVarSet.has(v)); // Legacy targets have no `knownVars` baseline; treat current as known so we // don't false-flag intentionally-unselected vars as "newly added". @@ -2769,6 +2820,7 @@ export async function deployCommand( secretsOnly: mode === 'ci', noDeploy: !!options.noDeploy, cwd, + ...(adapter.id === 'vercel' ? placeholderDeliveryContext(cwd, target, adapter, options) : {}), ...adapterCallCtx, // A dry run's delivery stays a plain dry run (it reads nothing): the preview of conflicts was the step before. ...(settled.targetCheck === undefined || options.dryRun ? {} : { targetCheck: settled.targetCheck }), diff --git a/src/deploy/adapter.ts b/src/deploy/adapter.ts index e7817c54..09ee4f99 100644 --- a/src/deploy/adapter.ts +++ b/src/deploy/adapter.ts @@ -194,6 +194,21 @@ export interface DeployContext extends AdapterCallContext { * `conflicts` and writes nothing. */ targetCheck?: TargetCheck; + /** + * Names on the branch that exist with no value yet (placeholders, core/placeholder.ts). A target var in this list + * is never written: the platform keeps its own value. Vercel only; absent: none. + */ + placeholders?: readonly string[]; + /** + * Vercel API mode: replacing a value Vercel holds as sensitive. `delivered` are the target vars Capy has delivered to + * this target before (a delivery record in keep.lock): the first replacement of the others needs a yes. + * `allowed` is `--replace-sensitive`. `ask` is set only where a person can answer; it resolves the answer. + */ + sensitiveReplace?: { + readonly delivered: readonly string[]; + readonly allowed: boolean; + readonly ask?: (names: readonly string[]) => Promise; + }; /** * Deliver only the names in `config.vars`, leaving every other line Capy already manages on the platform exactly * as it is (the batch deploy of picked variables, CAP-715). Absent: the managed block holds the target's vars. diff --git a/src/deploy/adapters/vercel.ts b/src/deploy/adapters/vercel.ts index 8fcd3035..41d8dd49 100644 --- a/src/deploy/adapters/vercel.ts +++ b/src/deploy/adapters/vercel.ts @@ -75,7 +75,9 @@ import { isVercelApiMode, parseVercelApiOptions, planAll, + plansReplacingSensitive, resolveEnvScope, + sensitiveReplacements, resolveVercelTargetToken, sortedNames, verifyApplied, @@ -85,6 +87,24 @@ import { type VercelEnv = 'production' | 'preview'; +const KEPT_LABEL = 'kept Vercel value'; // COPY-FLAG + +/** + * The target vars that have no value in Capy yet (placeholders): never written, so Vercel keeps its own. + * A name that does have a value in the decrypted env is not one. + */ +function keptNames(config: TargetConfig, ctx: Pick): readonly string[] { + const placeholders = ctx.placeholders ?? []; + return config.vars.filter((name) => placeholders.includes(name) && !(name in ctx.env)); +} + +/** The step that names what was left alone, or none. */ +function keptSteps(kept: readonly string[]): readonly DeployStep[] { + if (kept.length === 0) return []; + // COPY-FLAG: names only, never a value. + return [{ label: KEPT_LABEL, status: 'skip', detail: `kept Vercel's value (no value in Capy yet): ${sortedNames(kept).join(', ')}` }]; +} + interface VercelOptions { /** Directory holding the Vercel app (package.json / .vercel). Relative to root. */ projectDir: string; @@ -544,6 +564,11 @@ async function legacyDeploy(config: TargetConfig, ctx: DeployContext): Promise !kept.includes(name)); + if (ctx.dryRun) { return { ok: true, @@ -551,19 +576,21 @@ async function legacyDeploy(config: TargetConfig, ctx: DeployContext): Promise 0 ? { skipped: kept } : {}), }; } // Push each declared var as a plaintext Vercel Environment Variable, scoped // to the chosen Vercel env. Filter the decrypted branch env to the declared // names; fail loudly if any are missing rather than pushing a partial set. - const filtered: ReadonlyArray = config.vars + const filtered: ReadonlyArray = pushNames .filter((name) => name in ctx.env) .map((name) => [name, ctx.env[name]] as const); - const missing = config.vars.filter((name) => !(name in ctx.env)); + const missing = pushNames.filter((name) => !(name in ctx.env)); if (missing.length > 0) { return { ok: false, @@ -604,12 +631,14 @@ async function legacyDeploy(config: TargetConfig, ctx: DeployContext): Promise 0 ? { skipped: kept } : {}), }; } @@ -625,6 +654,30 @@ export interface VercelAdapterDeps { } const SET_ENV_LABEL = 'set Vercel env'; +const SENSITIVE_LABEL = 'replace sensitive value'; // COPY-FLAG + +/** + * The refusal (a failed step) when a plan would replace, for the first time for this target, a value Vercel holds as + * sensitive and nobody said yes: `--replace-sensitive`, or a person at a terminal. `undefined` when it may go on. + * Capy cannot read such a value, so it cannot know whether the one it holds differs. Nothing has been written yet. + */ +async function sensitiveReplaceRefusal(plans: readonly VarPlan[], ctx: DeployContext): Promise { + const delivered = ctx.sensitiveReplace?.delivered ?? []; + const names = plansReplacingSensitive(plans).filter((name) => !delivered.includes(name)); + if (names.length === 0 || ctx.sensitiveReplace?.allowed === true) return undefined; + const ask = ctx.sensitiveReplace?.ask; + if (ask !== undefined && (await ask(names))) return undefined; + return { + label: SENSITIVE_LABEL, + status: 'fail', + code: ERROR_CODES.VERCEL_SENSITIVE_REPLACE_UNCONFIRMED, + // COPY-FLAG: names only. + detail: + ask === undefined + ? `would replace a sensitive value in Vercel, not confirmed: ${sortedNames(names).join(', ')}. Pass --replace-sensitive to allow it.` + : `a sensitive value in Vercel was not replaced (not confirmed): ${sortedNames(names).join(', ')}`, + }; +} const VERIFY_LABEL = 'verify Vercel env'; /** A rejected token (HTTP 401) or one without access (HTTP 403) says where to get another; any other failure adds nothing. */ @@ -734,8 +787,13 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter if (!listed.ok) { return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code, opts.teamSlug) }); } - const plan = dryRunSummary(config.vars, listed.records, scope.scope); + const kept = keptNames(config, ctx); + const pushNames = config.vars.filter((name) => !kept.includes(name)); + const plan = dryRunSummary(pushNames, listed.records, scope.scope); const names = (xs: readonly string[]) => (xs.length > 0 ? ` [${sortedNames(xs).join(', ')}]` : ''); + // The first replacement of a value Vercel holds as sensitive asks (unless this target delivered the name before). + const delivered = ctx.sensitiveReplace?.delivered ?? []; + const needsConfirmation = sensitiveReplacements(pushNames, listed.records, scope.scope).filter((name) => !delivered.includes(name)); return { ok: true, steps: [ @@ -744,12 +802,24 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter status: 'skip', // COPY-FLAG: names and counts only. detail: - `dry-run — would push ${config.vars.length} var(s) to ${describeScope(scope.scope)}: ` + + `dry-run — would push ${pushNames.length} var(s) to ${describeScope(scope.scope)}: ` + `create ${plan.create.length}${names(plan.create)}, ` + `update ${plan.update.length}${names(plan.update)}, ` + `split from a shared record ${plan.split.length}${names(plan.split)}; then open the deploy PR`, }, + ...keptSteps(kept), + ...(needsConfirmation.length > 0 + ? [ + { + label: SENSITIVE_LABEL, + status: 'skip' as const, + // COPY-FLAG: names only. + detail: `would replace a sensitive value (needs confirmation): ${sortedNames(needsConfirmation).join(', ')}`, + }, + ] + : []), ], + ...(kept.length > 0 ? { skipped: kept } : {}), }; } @@ -760,7 +830,14 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter } if (ctx.dryRun) return apiDryRun(config, ctx, parsed.options); - const missing = config.vars.filter((name) => !(name in ctx.env)); + // A var with no value in Capy yet is never written: Vercel keeps its own value. It is not "missing". + const kept = keptNames(config, ctx); + const pushNames = config.vars.filter((name) => !kept.includes(name)); + if (pushNames.length === 0) { + // Nothing to write, so nothing is asked of Vercel at all. + return { ok: true, steps: [...keptSteps(kept)], skipped: kept }; + } + const missing = pushNames.filter((name) => !(name in ctx.env)); if (missing.length > 0) { return failResult({ label: SET_ENV_LABEL, @@ -787,8 +864,13 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code, parsed.options.teamSlug) }); } - const pairs = config.vars.map((name) => [name, ctx.env[name]] as const); + const pairs = pushNames.map((name) => [name, ctx.env[name]] as const); const plans: readonly VarPlan[] = planAll(pairs, listed.records, scope.scope); + + // Replacing a value Vercel holds as sensitive: the first time for this target needs a yes, before anything is written. + const refused = await sensitiveReplaceRefusal(plans, ctx); + if (refused) return failResult(refused); + const applied = await applyPlans(client, plans, scope.scope); if (!applied.ok) { return failResult({ @@ -804,7 +886,7 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter const writeStep: DeployStep = { label: SET_ENV_LABEL, status: 'ok', - detail: `${config.vars.length} var(s) pushed to ${where} (${writeSummary(applied.applied)})`, + detail: `${pushNames.length} var(s) pushed to ${where} (${writeSummary(applied.applied)})`, }; if (!verified.ok) { return { @@ -823,7 +905,7 @@ export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter }; return vStep.status === 'fail' ? { ok: false, steps: [writeStep, vStep] } - : { ok: true, steps: [writeStep, vStep, buildStep] }; + : { ok: true, steps: [writeStep, ...keptSteps(kept), vStep, buildStep], ...(kept.length > 0 ? { skipped: kept } : {}) }; } return { diff --git a/src/deploy/batchDeploy.ts b/src/deploy/batchDeploy.ts index 34e1a49b..3f259aa9 100644 --- a/src/deploy/batchDeploy.ts +++ b/src/deploy/batchDeploy.ts @@ -38,7 +38,7 @@ import type { GithubApi, RepoRef } from './githubApi'; import type { ResolveDokployApiKeyResult } from './adapters/dokploy'; import { createDokployAdapter } from './adapters/dokploy'; import { resolveDokployApiKeyOnce } from './dokployApiKeyOnce'; -import { RecordClient, RecordResult, deliveryFor, recordDeliveriesOnServer } from './deliveryRecord'; +import { RecordClient, RecordResult, deliveredBefore, deliveryFor, recordDeliveriesOnServer } from './deliveryRecord'; import { buildDeployPrBody } from './deployPrBody'; import { foldDeployKeep, hashValue } from './keepGate'; import { parseTargets } from './configuredTargets'; @@ -618,6 +618,8 @@ async function passOverTarget( noDeploy: false, keepOtherManaged: true, cwd: process.cwd(), + // Vercel API mode: a value Vercel holds as sensitive is replaced here only where Capy delivered it before (this pass asks nobody). + sensitiveReplace: { delivered: deliveredBefore(serverKeep, { ...effective.config, branch: item.branch }, env.adapter), allowed: false }, ...callCtx, ...(targetCheck === undefined ? {} : { targetCheck }), }; diff --git a/src/deploy/deliveryRecord.ts b/src/deploy/deliveryRecord.ts index 586cf8eb..65f4217b 100644 --- a/src/deploy/deliveryRecord.ts +++ b/src/deploy/deliveryRecord.ts @@ -16,6 +16,7 @@ import { isVercelApiMode } from './vercelTargetApi'; import { TargetDeliveryDescriptor, VarDelivery, recordTargetDeliveries } from './targetsGate'; import { uploadKeepOnly } from '../sync/commit'; import type { RateInfo } from '../utils/pool'; +import type { KeepFile } from '../types/index'; /** * Adapter-specific handle for what a target actually points at, when one is @@ -68,6 +69,21 @@ export function deliveryFor( return { delivery, values }; } +/** + * The target vars Capy has delivered to this target before: a delivery record with a value hash on the branch's entry. + * (A target that is only configured, with no push yet, has a record without one.) This is how "the first replacement + * of a sensitive Vercel value was confirmed" is remembered: the delivery that followed it is the record. + */ +export function deliveredBefore(keep: KeepFile | null | undefined, target: Pick, adapter: Pick): readonly string[] { + return target.vars.filter((name) => + (keep?.variables[name] ?? []) + .filter((entry) => entry.branch === target.branch) + .some((entry) => + (entry.targets ?? []).some((t) => t.provider === adapter.id && t.target === target.name && t.deployed_value_hash !== undefined), + ), + ); +} + /** What the recorder needs from the service client. */ export interface RecordClient { getLatestSecrets(projectId: string, branch: string): Promise<{ env_file: string; keep_hash: string; keep_file?: string } | null>; diff --git a/src/deploy/vercelTargetApi.ts b/src/deploy/vercelTargetApi.ts index 63ea3763..c1e2c843 100644 --- a/src/deploy/vercelTargetApi.ts +++ b/src/deploy/vercelTargetApi.ts @@ -609,6 +609,29 @@ export function planVar( return { kind: 'create', name, value, type: 'encrypted' }; } +/** + * The record a plan would replace the value of, when Vercel holds it as sensitive. Capy cannot read such a + * value, so it cannot tell whether the new one differs: every deploy would replace it. + */ +function replacedRecord(plan: VarPlan): VercelEnvRecord | undefined { + if (plan.kind === 'update') return plan.record; + return plan.kind === 'split' ? plan.shared : undefined; +} + +/** The names whose plan replaces a value Vercel holds as sensitive. */ +export function plansReplacingSensitive(plans: readonly VarPlan[]): readonly string[] { + return plans.filter((plan) => replacedRecord(plan)?.type === 'sensitive').map((plan) => plan.name); +} + +/** Dry-run counterpart of `plansReplacingSensitive`: the names a deploy would replace a sensitive value of (names only, no values needed). */ +export function sensitiveReplacements(names: readonly string[], records: readonly VercelEnvRecord[], scope: EnvScope): readonly string[] { + const replaced = (name: string): VercelEnvRecord | undefined => { + const serving = records.filter((r) => covers(r, name, scope)); + return serving.find((r) => envKeysOf(r).length === 1) ?? serving[0]; + }; + return names.filter((name) => replaced(name)?.type === 'sensitive'); +} + /** `planVar` for every name, in order. */ export function planAll( vars: ReadonlyArray, diff --git a/src/index.ts b/src/index.ts index bc99fd3b..438f8f8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -381,6 +381,7 @@ const deploy = program .option('--scope ', 'gh-actions: "repo" or "env"') .option('--env-name ', 'gh-actions: env name when --scope env') .option('--no-deploy', 'write and verify the target, but skip the platform deploy/redeploy (target mode)') + .option('--replace-sensitive', 'vercel: allow replacing a value Vercel holds as sensitive, for the first time for a target, without asking') // COPY-FLAG .option('--json', 'describe the route (unanswered stops + any known branch problem) as JSON instead of travelling it') // COPY-FLAG: the option descriptions of `deploy --discover` are minimal and neutral. .option('--discover', 'dokploy/vercel: find the services that match Capy projects and print JSON (never prompts)') @@ -413,6 +414,7 @@ const deploy = program force: options.force, // commander negates `--no-deploy` onto the positive `deploy` property. noDeploy: options.deploy === false, + replaceSensitive: !!options.replaceSensitive, json: !!options.json, conflictFlags: rawConflictFlagsOf(cmd), }); diff --git a/tests/deploy/vercelApiMode.test.ts b/tests/deploy/vercelApiMode.test.ts index 83ce114c..ffb5f73e 100644 --- a/tests/deploy/vercelApiMode.test.ts +++ b/tests/deploy/vercelApiMode.test.ts @@ -148,7 +148,8 @@ describe('vercel API mode — write plan, other cases', () => { const { fake, adapter } = setUp({ records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', type: 'sensitive', target: ['production'] })], }); - const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + // Replacing a sensitive value needs a yes the first time (see vercelPlaceholderDeploy.test.ts); here it is given. + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: true } })); expect(result.ok).toBe(true); expect(fake.records()[0]).toMatchObject({ type: 'sensitive', value: 'new' }); // The PATCH never sends a type, so it cannot downgrade the record. @@ -163,7 +164,7 @@ describe('vercel API mode — write plan, other cases', () => { const { fake, adapter } = setUp({ records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old', type: 'sensitive', target: ['production', 'preview'] })], }); - const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: true } })); expect(result.ok).toBe(true); const created = fake.records().find((r) => r.id !== 'env_shared'); expect(created).toMatchObject({ type: 'sensitive', target: ['production'], value: 'new' }); diff --git a/tests/deploy/vercelPlaceholderDeploy.test.ts b/tests/deploy/vercelPlaceholderDeploy.test.ts new file mode 100644 index 00000000..99ef803f --- /dev/null +++ b/tests/deploy/vercelPlaceholderDeploy.test.ts @@ -0,0 +1,231 @@ +/** + * Deploying to Vercel with placeholders (names with no value in Capy yet) and sensitive Vercel values. + * + * Properties, asserted on an in-memory Vercel (`vercelApiModeFake.ts`): "what does Vercel hold afterwards?" + * - a placeholder is never written: Vercel keeps its own value, and the deploy reports it as kept (not "missing in branch"); + * a var that is truly missing still fails; + * - replacing a value Vercel holds as `sensitive` (Capy cannot read it, so cannot tell whether it differs) needs a yes the + * FIRST time for a target: a person at a terminal, or `--replace-sensitive`; otherwise a coded refusal naming the + * vars and nothing is written. The delivery that follows is the record that it was confirmed: later deploys do not ask; + * - a dry run says so without writing anything. + * The CLI-mode (binary) path leaves placeholders alone too. Every token and value is a fake. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createVercelAdapter } from '../../src/deploy/adapters/vercel'; +import type { DeployAdapter, DeployContext, TargetConfig } from '../../src/deploy/adapter'; +import { deliveredBefore, deliveryFor } from '../../src/deploy/deliveryRecord'; +import { recordTargetDeliveries } from '../../src/deploy/targetsGate'; +import { ERROR_CODES, type KeepFile } from '../../src/types/index'; +import { createFakeVercel, type FakeRecord, type FakeVercelOptions } from './vercelApiModeFake'; + +const TOKEN = 'tok_fake_value'; +const PROJECT = 'prj_1'; +const TEAM = 'team_1'; + +const rec = (over: Partial & Pick): FakeRecord => ({ type: 'encrypted', ...over }); + +function setUp(fakeOpts: FakeVercelOptions = {}) { + const fake = createFakeVercel({ projectId: PROJECT, teamId: TEAM, token: TOKEN, ...fakeOpts }); + const adapter: DeployAdapter = createVercelAdapter({ + fetch: fake.fetch, + sleep: mock(async (_ms: number) => {}), + now: () => 1_000_000_000_000, + env: {}, + getTargetSecret: async () => TOKEN, + }); + return { fake, adapter }; +} + +const target = (vars: readonly string[], options: Record = {}): TargetConfig => ({ + name: 'vercel-api', + kind: 'vercel', + branch: 'production', + vars: [...vars], + mode: 'ci', + options: { projectDir: 'web', projectId: PROJECT, teamId: TEAM, vercelEnv: 'production', ...options }, +}); + +const ctx = (env: Record, over: Partial = {}): DeployContext => ({ + env, + dryRun: false, + secretsOnly: true, + cwd: '/nonexistent-cwd', + interactive: false, + ...over, +}); + +const SENSITIVE = rec({ id: 'env_sens', key: 'API_KEY', value: 'vercel-only', type: 'sensitive', target: ['production'] }); + +describe('a placeholder is never written', () => { + test('Vercel keeps its value, the other var is written, and the deploy reports the placeholder as kept', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE, rec({ id: 'env_db', key: 'DB_URL', value: 'old-db', target: ['production'] })] }); + const result = await adapter.deploy(target(['API_KEY', 'DB_URL']), ctx({ DB_URL: 'new-db' }, { placeholders: ['API_KEY'] })); + + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('vercel-only'); + expect(fake.seenBy('production', 'DB_URL')).toBe('new-db'); + expect(fake.writeCalls().every((c) => !c.path.includes('env_sens'))).toBe(true); + expect(result.skipped).toEqual(['API_KEY']); + const kept = result.steps.find((s) => s.label === 'kept Vercel value'); + expect(kept?.detail).toContain('API_KEY'); + expect(result.steps.find((s) => s.label === 'set Vercel env')?.detail).toContain('1 var(s) pushed'); + expect(JSON.stringify(result)).not.toContain('vercel-only'); + }); + + test('a var that is truly missing still fails the deploy, and nothing is written', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY', 'GONE']), ctx({}, { placeholders: ['API_KEY'] })); + expect(result.ok).toBe(false); + expect(result.steps[0].detail).toContain('missing in branch production: GONE'); + expect(result.steps[0].detail).not.toContain('API_KEY'); + expect(fake.writeCalls()).toEqual([]); + }); + + test('a target of nothing but placeholders asks Vercel nothing at all', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({}, { placeholders: ['API_KEY'] })); + expect(result.ok).toBe(true); + expect(result.skipped).toEqual(['API_KEY']); + expect(fake.calls()).toEqual([]); + }); + + test('a name that has a value in the env is written even if it is also listed as a placeholder', async () => { + const { fake, adapter } = setUp({ records: [rec({ id: 'e', key: 'API_KEY', value: 'old', target: ['production'] })] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'now-real' }, { placeholders: ['API_KEY'] })); + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('now-real'); + expect(result.skipped).toBeUndefined(); + }); + + test('the CLI-mode path (no projectId) leaves a placeholder alone and still fails on a truly missing var', async () => { + const { adapter } = setUp(); + const legacy: TargetConfig = { name: 'v', kind: 'vercel', branch: 'production', vars: ['API_KEY'], options: { projectDir: 'web', vercelEnv: 'production' } }; + const kept = await adapter.deploy(legacy, ctx({}, { placeholders: ['API_KEY'] })); + expect(kept.ok).toBe(true); + expect(kept.skipped).toEqual(['API_KEY']); + expect(kept.steps.find((s) => s.label === 'kept Vercel value')?.detail).toContain('API_KEY'); + + const missing = await adapter.deploy({ ...legacy, vars: ['API_KEY', 'GONE'] }, ctx({}, { placeholders: ['API_KEY'] })); + expect(missing.ok).toBe(false); + expect(missing.steps[0].detail).toContain('missing in branch production: GONE'); + }); +}); + +describe('replacing a value Vercel holds as sensitive', () => { + test('off a terminal, without the flag: a coded refusal naming the vars; nothing is written', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(false); + expect(result.steps[0]).toMatchObject({ status: 'fail', code: ERROR_CODES.VERCEL_SENSITIVE_REPLACE_UNCONFIRMED }); + expect(result.steps[0].detail).toContain('API_KEY'); + expect(fake.writeCalls()).toEqual([]); + expect(fake.seenBy('production', 'API_KEY')).toBe('vercel-only'); + }); + + test('with the flag: it proceeds', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: true } })); + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('new'); + }); + + test('at a terminal it asks once, naming the vars; yes proceeds, no writes nothing', async () => { + const yes = mock(async (_names: readonly string[]) => true); + const a = setUp({ records: [SENSITIVE] }); + const ok = await a.adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: false, ask: yes } })); + expect(ok.ok).toBe(true); + expect(yes.mock.calls).toEqual([[['API_KEY']]]); + expect(a.fake.seenBy('production', 'API_KEY')).toBe('new'); + + const no = mock(async (_names: readonly string[]) => false); + const b = setUp({ records: [SENSITIVE] }); + const refused = await b.adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: false, ask: no } })); + expect(refused.ok).toBe(false); + expect(refused.steps[0]).toMatchObject({ code: ERROR_CODES.VERCEL_SENSITIVE_REPLACE_UNCONFIRMED }); + expect(b.fake.writeCalls()).toEqual([]); + }); + + test('a var this target delivered before is not asked about again', async () => { + const ask = mock(async (_names: readonly string[]) => false); + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'newer' }, { sensitiveReplace: { delivered: ['API_KEY'], allowed: false, ask } })); + expect(result.ok).toBe(true); + expect(ask).not.toHaveBeenCalled(); + expect(fake.seenBy('production', 'API_KEY')).toBe('newer'); + }); + + test('only the sensitive vars are asked about; a readable record is replaced as it always was', async () => { + const ask = mock(async (_names: readonly string[]) => true); + const { adapter } = setUp({ records: [SENSITIVE, rec({ id: 'env_db', key: 'DB_URL', value: 'old', target: ['production'] })] }); + const result = await adapter.deploy(target(['API_KEY', 'DB_URL']), ctx({ API_KEY: 'a', DB_URL: 'b' }, { sensitiveReplace: { delivered: [], allowed: false, ask } })); + expect(result.ok).toBe(true); + expect(ask.mock.calls).toEqual([[['API_KEY']]]); + }); + + test('a split off a sensitive shared record counts as replacing a sensitive value', async () => { + const shared = rec({ id: 'env_shared', key: 'API_KEY', value: 'old', type: 'sensitive', target: ['production', 'preview'] }); + const { fake, adapter } = setUp({ records: [shared] }); + const refused = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' })); + expect(refused.ok).toBe(false); + expect(fake.writeCalls()).toEqual([]); + expect(fake.seenBy('preview', 'API_KEY')).toBe('old'); + }); + + test('a var with no record on Vercel yet is created without asking', async () => { + const { fake, adapter } = setUp(); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'first' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('first'); + }); +}); + +describe('dry run', () => { + test('says which sensitive values would need confirmation and which placeholders are kept; writes nothing', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE, rec({ id: 'env_keep', key: 'KEPT', value: 'v', type: 'sensitive', target: ['production'] })] }); + const result = await adapter.deploy(target(['API_KEY', 'KEPT']), ctx({}, { dryRun: true, placeholders: ['KEPT'] })); + expect(result.ok).toBe(true); + expect(fake.writeCalls()).toEqual([]); + expect(result.steps.find((s) => s.label === 'replace sensitive value')?.detail).toBe('would replace a sensitive value (needs confirmation): API_KEY'); + expect(result.steps.find((s) => s.label === 'kept Vercel value')?.detail).toContain('KEPT'); + expect(result.steps[0].detail).toContain('would push 1 var(s)'); + }); + + test('a sensitive var delivered before is not listed as needing confirmation', async () => { + const { adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({}, { dryRun: true, sensitiveReplace: { delivered: ['API_KEY'], allowed: false } })); + expect(result.steps.find((s) => s.label === 'replace sensitive value')).toBeUndefined(); + }); +}); + +describe('the record that a replacement was confirmed is the delivery that followed it', () => { + const delivery = (over: Record) => ({ provider: 'vercel', target: 'vercel-api', ...over }); + const keepWith = (entry: Record): KeepFile => ({ + version: '3.0', + org_id: 'o', + project_id: 'p', + project_name: 'demo', + variables: { API_KEY: [{ resource_id: 'r', branch: 'production', value_hash: 'h', ...entry }] } as KeepFile['variables'], + }); + const t = { name: 'vercel-api', branch: 'production', vars: ['API_KEY', 'OTHER'] }; + const adapter = { id: 'vercel' }; + + test('a delivery with a value hash on this branch and target counts', () => { + expect(deliveredBefore(keepWith({ targets: [delivery({ deployed_value_hash: 'abc' })] }), t, adapter)).toEqual(['API_KEY']); + }); + + test('end to end: a keep with no delivery asks; the same keep after the delivery is recorded does not', () => { + const before = keepWith({}); + const { delivery, values } = deliveryFor({ ...t, kind: 'vercel', options: {} } as TargetConfig, adapter, undefined, false, { API_KEY: 'abc' }); + const after = recordTargetDeliveries(before, 'production', delivery, '2026-11-02T00:00:00.000Z', values); + expect(deliveredBefore(before, t, adapter)).toEqual([]); + expect(deliveredBefore(after, t, adapter)).toEqual(['API_KEY']); + }); + + test('a target that is only configured (no push yet), another target, another provider or another branch do not', () => { + expect(deliveredBefore(keepWith({ targets: [delivery({})] }), t, adapter)).toEqual([]); + expect(deliveredBefore(keepWith({ targets: [delivery({ target: 'other', deployed_value_hash: 'abc' })] }), t, adapter)).toEqual([]); + expect(deliveredBefore(keepWith({ targets: [delivery({ provider: 'dokploy', deployed_value_hash: 'abc' })] }), t, adapter)).toEqual([]); + expect(deliveredBefore(keepWith({ targets: [delivery({ deployed_value_hash: 'abc' })] }), { ...t, branch: 'preview' }, adapter)).toEqual([]); + expect(deliveredBefore(null, t, adapter)).toEqual([]); + }); +}); From 615a5b6850949a6d5db76a137c6c3f85631416a3 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 2 Nov 2026 19:25:33 -0700 Subject: [PATCH 12/22] feat(vercel): report names Vercel will not return, with a Capy value, as kept instead of skipped A sensitive, legacy-secret or not-decrypted name that already has a value in Capy keeps that value untouched. It is now listed under `kept` (code CAPY_VALUE_KEPT, with the Vercel reason) in the plain import, --overwrite, --dry-run, --json and the discover preview/confirm/result, and no longer under `skipped`. --- docs/cli-reference.md | 1 + src/commands/connectCommand.ts | 11 +- src/commands/connectors/dokployDiscovery.ts | 6 + src/commands/connectors/registry.ts | 15 ++ src/commands/connectors/vercel.ts | 54 +++++--- src/commands/connectors/vercelDiscovery.ts | 35 ++++- src/core/placeholder.ts | 10 ++ src/types/index.ts | 3 + tests/commands/connectVercelDiscovery.test.ts | 130 +++++++++++++++++- tests/commands/vercelPlaceholders.test.ts | 114 ++++++++++++++- 10 files changed, 353 insertions(+), 26 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ae11a36a..4195945d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1024,6 +1024,7 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `VERCEL_SENSITIVE_REPLACE_UNCONFIRMED` - `VERCEL_PROJECT_NOT_GITHUB` - `VERCEL_ENV_LIST_TRUNCATED` +- `CAPY_VALUE_KEPT` - `EDIT_NEEDS_TTY` - `EDIT_STDIN_LOCAL_ONLY` - `STDIN_EMPTY` diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index 8da81094..aedb0ed5 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -1,5 +1,5 @@ import { resolveContext, writeAndSync, writeImportOutcome, ResolvedContext } from './connectors/shared'; -import { placeholderSetCommand, placeholdersReport } from '../core/placeholder'; +import { keptReport, placeholderSetCommand, placeholdersReport } from '../core/placeholder'; import { listProviders, loadProvider, ConnectFailure, ConnectOpts, ConnectorModule } from './connectors/registry'; import { formatKeyHelp } from '../deploy/keyHelp'; import { EXIT_NEEDS_INPUT, isInteractive } from '../ui/interactive'; @@ -265,6 +265,7 @@ export class ConnectCommand { const pushed = !opts.noPush && wrote; const cleared = outcome.cleared ?? []; const placeholders = outcome.placeholders ?? []; + const kept = outcome.kept ?? []; const hasChange = outcome.imported.length > 0 || cleared.length > 0 || placeholders.length > 0; if (opts.json) { @@ -284,6 +285,8 @@ export class ConnectCommand { skipped: outcome.skipped, // Names Vercel has but will not give back: recorded with no value (names and codes only). ...(outcome.placeholders ? { placeholders: placeholdersReport(outcome.placeholders) } : {}), + // Names Vercel will not give back where Capy already has a value: left untouched (names and codes only). + ...(outcome.kept ? { kept: keptReport(outcome.kept) } : {}), warnings: outcome.warnings, pushed, }), @@ -329,6 +332,10 @@ export class ConnectCommand { console.log(` ${dryRun ? 'Would record' : 'Recorded'} with no value yet: ${placeholders.map((p) => `${p.name} (${p.code})`).join(', ')}`); console.log(` Set one with: ${placeholderSetCommand('NAME')}`); // COPY-FLAG } + if (kept.length > 0) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + console.log(` Kept the Capy value for ${kept.length} name(s) Vercel will not return: ${kept.map((k) => `${k.name} (${k.reason})`).join(', ')}`); + } if (outcome.skipped.length > 0) { console.log(` Skipped: ${outcome.skipped.map((s) => `${s.name} (${s.code})`).join(', ')}`); } @@ -416,6 +423,7 @@ export class ConnectCommand { unchanged: e.outcome.unchanged, skipped: e.outcome.skipped, ...(e.outcome.placeholders ? { placeholders: placeholdersReport(e.outcome.placeholders) } : {}), + ...(e.outcome.kept ? { kept: keptReport(e.outcome.kept) } : {}), warnings: e.outcome.warnings, })), } @@ -437,6 +445,7 @@ export class ConnectCommand { unchanged: e.outcome.unchanged, skipped: e.outcome.skipped, ...(e.outcome.placeholders ? { placeholders: placeholdersReport(e.outcome.placeholders) } : {}), + ...(e.outcome.kept ? { kept: keptReport(e.outcome.kept) } : {}), warnings: e.outcome.warnings, })), }, diff --git a/src/commands/connectors/dokployDiscovery.ts b/src/commands/connectors/dokployDiscovery.ts index 020063a3..8ff6ee9e 100644 --- a/src/commands/connectors/dokployDiscovery.ts +++ b/src/commands/connectors/dokployDiscovery.ts @@ -597,6 +597,12 @@ export interface DiscoveryPlanEnv { * record). Names only. Absent for Dokploy. */ skipped?: ReadonlyArray<{ name: string; code: string; gitBranch?: string }>; + /** + * Vercel plan only: names Vercel will not give back where this environment's branch already holds a Capy + * value (known from the local keep.lock). The value stays untouched, so they are not in `skipped` or counted + * in `skippedCount`. A branch the run will create is not known yet: its unreadable names stay in `skipped`. + */ + kept?: ReadonlyArray<{ name: string; code: string; reason: string }>; /** * PREVIEW ONLY, and only known when `DiscoveryPlanFolder.initialized` — * whether this environment's branch already exists on the server, i.e. diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index 2ce827d5..4fbe3e0c 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -260,6 +260,16 @@ export interface ImportPlaceholder { provider: string; } +/** + * A name the provider has but will not give back, where Capy already has a value: the Capy value was + * left untouched. `code` is the stable `CAPY_VALUE_KEPT`; `reason` is the provider's own reason code. + */ +export interface ImportKept { + name: string; + code: string; + reason: string; +} + /** A non-blocking heads-up about the import, grouped by code like `DeployWarning`. */ export interface ImportWarning { code: string; @@ -288,6 +298,11 @@ export type ImportOutcome = * as a placeholder on the branch; none is in `skipped`. Absent when there are none. */ placeholders?: readonly ImportPlaceholder[]; + /** + * Vercel import only: names Vercel holds but will not give back, where Capy already has a value. The value is + * left untouched; none of these is in `skipped` or `placeholders`. Absent when there are none. + */ + kept?: readonly ImportKept[]; warnings: readonly ImportWarning[]; /** * Dokploy import only: names with a different local value that a real diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts index 6ab44c78..e268dee2 100644 --- a/src/commands/connectors/vercel.ts +++ b/src/commands/connectors/vercel.ts @@ -25,7 +25,8 @@ * `value_not_decrypted`) is not skipped: it becomes a PLACEHOLDER on the Capy * branch of every environment it is in. A placeholder is a flag on the * variable's keep.lock entry, with no value (`core/placeholder.ts`). A name - * that already has a value in Capy keeps it and is reported as skipped. + * that already has a value in Capy keeps it, untouched, and is reported in + * `kept` (code `CAPY_VALUE_KEPT`), not in `skipped`. * * Every refusal for a missing or rejected token carries the Vercel token page * (`link`) and what to do (`remedy`), with the team's slug when `--team` names it. @@ -77,6 +78,7 @@ import { ConnectorModule, ConnectResult, ImportOutcome, + ImportKept, ImportPlaceholder, ImportSkip, ImportWarning, @@ -316,7 +318,10 @@ function overwriteConfirmMessage(plan: OverwritePlan): string { } // COPY-FLAG: new user-facing string, minimal/neutral wording. -function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwrite: boolean, environmentFilter?: readonly string[]): string { +const keptNote = (kept: readonly unknown[] | undefined): string => (kept && kept.length > 0 ? `, ${kept.length} kept` : ''); + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +export function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwrite: boolean, environmentFilter?: readonly string[]): string { const totalSteps = folders.reduce((n, f) => n + f.environments.length, 0); const filterNote = environmentFilter && environmentFilter.length > 0 ? ` (--environment ${environmentFilter.join(',')})` : ''; const header = `Run ${totalSteps} step(s) across ${folders.length} folder(s) from Vercel${overwrite ? ' (--overwrite)' : ''}${filterNote}?`; @@ -325,7 +330,7 @@ function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwr ...orderEnvironments(f.environments).map((e) => { const checkoutCmd = e.branchExists === true ? `capy checkout ${e.environmentName}` : `capy checkout -b ${e.environmentName}`; const importCmd = `capy connect vercel --project ${e.serviceId} --environment ${e.environmentName}${overwrite || e.willAutoOverwrite ? ' --overwrite' : ''}`; - return ` ${checkoutCmd} && ${importCmd} (${e.variableCount} var(s), ${e.skippedCount} skipped)${e.willAutoOverwrite && !overwrite ? ' [auto-overwrite]' : ''}`; + return ` ${checkoutCmd} && ${importCmd} (${e.variableCount} var(s), ${e.skippedCount} skipped${keptNote(e.kept)})${e.willAutoOverwrite && !overwrite ? ' [auto-overwrite]' : ''}`; }), ]); return [header, ...rows].join('\n'); @@ -536,8 +541,8 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const entryFor = (name: string, value: string) => importedEntry(recordByName.get(name)!, name, value, source, createdAtSec, importedAt); const written = [...plan.toImport, ...plan.toReplace].map(({ name, value }) => entryFor(name, value)); const unchangedEntries = plan.unchanged.map((name) => entryFor(name, fullCandidates.find((c) => c.name === name)!.value)); - // A name Vercel will not give back, with no value here, is a placeholder; one that has a local value keeps it and is reported as skipped. - const split = splitPlaceholders(unreadable, new Set([...Object.keys(ctx.localPlaintext), ...fullCandidates.map((c) => c.name)])); + // A name Vercel will not give back, with no value here, is a placeholder; one that has a local value keeps it and is reported as kept. + const split = splitPlaceholders(unreadable, new Set(Object.keys(ctx.localPlaintext)), new Set(fullCandidates.map((c) => c.name))); return { ok: true, source: importSource, @@ -546,10 +551,11 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector replacedNames: plan.toReplace.map((e) => e.name), unchanged: plan.unchanged, unchangedEntries, - // The names Vercel has but will not show are reported here too: an - // overwrite leaves them alone, and saying so is the point of the list. + // The names Vercel has but will not show are reported too (`kept`, or + // `placeholders` when Capy has no value): an overwrite leaves them alone. skipped: split.skips.map(toImportSkip), placeholders: split.placeholders, + kept: split.kept, warnings, }; }; @@ -602,10 +608,11 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const unchangedEntries = dryRun ? [] : unchanged.map((name) => entryFor(name, selected.find((c) => c.name === name)!.value)); const inScope = (name: string): boolean => !restrict || restrict.includes(name); - // A name Vercel will not give back, with no value here, is a placeholder on this branch; one that has a value keeps it. + // A name Vercel will not give back, with no value here, is a placeholder on this branch; one that has a value keeps it and is reported as kept. const split = splitPlaceholders( [...source.skipped, ...values.value.empty, ...values.value.undecrypted].filter((s) => inScope(s.name)), - new Set([...Object.keys(ctx.localPlaintext), ...candidates.map((c) => c.name)]), + new Set(Object.keys(ctx.localPlaintext)), + new Set(candidates.map((c) => c.name)), ); const skipped: readonly ImportSkip[] = [...split.skips.map(toImportSkip), ...conflictSkipped]; @@ -617,6 +624,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector unchangedEntries, skipped, placeholders: split.placeholders, + kept: split.kept, warnings: truncationWarnings(source), ...(dryRun ? { wouldAsk } : {}), }; @@ -799,22 +807,32 @@ function storeRefusalCode(err: unknown): string { } /** - * Splits what Vercel would not give back: a name whose reason is "has a value, not readable" and that has no - * value in Capy becomes a placeholder; everything else stays a skip. `valued` is every name that has (or is - * being given) a value here. + * Splits what Vercel would not give back. A name whose reason is "has a value, not readable" is: + * - a placeholder, when Capy has no value for it; + * - kept (the Capy value is left untouched), when Capy has one and it is not being replaced; + * everything else stays a skip. `local` is every name with a value in Capy; `importing` is every name that is + * being given a value by this run (a name in both is not "kept": its value is not guaranteed to stay). */ function splitPlaceholders( unreadable: readonly VercelSkippedRecord[], - valued: ReadonlySet, -): { placeholders: readonly ImportPlaceholder[]; skips: readonly VercelSkippedRecord[] } { - const isPlaceholder = (s: VercelSkippedRecord): boolean => isPlaceholderReason(s.reason) && !valued.has(s.name); + local: ReadonlySet, + importing: ReadonlySet, +): { placeholders: readonly ImportPlaceholder[]; kept: readonly ImportKept[]; skips: readonly VercelSkippedRecord[] } { + const outcomeOf = (s: VercelSkippedRecord): 'placeholder' | 'kept' | 'skip' => { + if (!isPlaceholderReason(s.reason)) return 'skip'; + if (importing.has(s.name)) return 'skip'; + return local.has(s.name) ? 'kept' : 'placeholder'; + }; const firstOfName = (s: VercelSkippedRecord, i: number, all: readonly VercelSkippedRecord[]): boolean => all.findIndex((o) => o.name === s.name) === i; + const inGroup = (group: 'placeholder' | 'kept' | 'skip') => unreadable.filter((s) => outcomeOf(s) === group); return { - placeholders: unreadable - .filter(isPlaceholder) + placeholders: inGroup('placeholder') .filter(firstOfName) .map((s): ImportPlaceholder => ({ name: s.name, code: s.reason, provider: 'vercel' })), - skips: unreadable.filter((s) => !isPlaceholder(s)), + kept: inGroup('kept') + .filter(firstOfName) + .map((s): ImportKept => ({ name: s.name, code: ERROR_CODES.CAPY_VALUE_KEPT, reason: s.reason })), + skips: inGroup('skip'), }; } diff --git a/src/commands/connectors/vercelDiscovery.ts b/src/commands/connectors/vercelDiscovery.ts index 91daa1db..06208b53 100644 --- a/src/commands/connectors/vercelDiscovery.ts +++ b/src/commands/connectors/vercelDiscovery.ts @@ -25,15 +25,17 @@ */ import { existsSync } from 'node:fs'; import { sortedCopy } from '../../deploy/dokployApi'; -import { placeholdersReport } from '../../core/placeholder'; +import { hasValueOnBranch, keptReport, placeholdersReport } from '../../core/placeholder'; import { join } from 'node:path'; import { VercelClient, VercelProject, VercelEnvironmentPlan, + PLACEHOLDER_SKIP_REASONS, buildEnvironmentPlans, groupSkipped, } from '../../deploy/vercelApi'; +import { ERROR_CODES, type KeepFile } from '../../types/index'; import { CandidateRepo, DiscoveryFolderResult, @@ -132,6 +134,27 @@ async function readProjectPlans( }, Promise.resolve([])); } +type PlanEnvDraft = Omit; + +/** + * Moves the names Vercel will not give back out of `skipped` into `kept` when the folder's local keep.lock + * already holds a value for them on this environment's branch (the import leaves that value alone). + */ +function withKeptSplit(localKeep: KeepFile | null): (e: PlanEnvDraft) => PlanEnvDraft { + return (e) => { + const isKept = (s: { name: string; code: string }): boolean => + PLACEHOLDER_SKIP_REASONS.some((r) => r === s.code) && hasValueOnBranch(localKeep, s.name, e.environmentName); + const skipped = e.skipped ?? []; + const keptRecords = skipped.filter(isKept); + if (keptRecords.length === 0) return e; + const kept = keptRecords + .filter((s, i) => keptRecords.findIndex((o) => o.name === s.name) === i) + .map((s) => ({ name: s.name, code: ERROR_CODES.CAPY_VALUE_KEPT, reason: s.code })); + const remaining = skipped.filter((s) => !isKept(s)); + return { ...e, skipped: remaining, skippedCount: remaining.length, kept }; + }; +} + async function toPlanFolder( pp: ProjectPlan, environmentFilter: ReadonlySet | undefined, @@ -144,7 +167,7 @@ async function toPlanFolder( const initialized = !!localKeep; const serverBranchNames = localKeep ? new Set((await peek.listServerBranches(localKeep.project_id)).map((b) => b.name)) : null; const firstName = orderEnvironments(envs)[0]?.environmentName; - const environments: readonly DiscoveryPlanEnv[] = envs.map((e) => { + const environments: readonly DiscoveryPlanEnv[] = envs.map(withKeptSplit(localKeep)).map((e) => { const branchExists = serverBranchNames ? serverBranchNames.has(e.environmentName) : undefined; const willCreateBranch = branchExists === false || !initialized; const willAutoOverwrite = willCreateBranch && !(!initialized && firstName === e.environmentName); @@ -214,6 +237,7 @@ function envReport(e: Extract['environments unchanged: e.outcome.unchanged, skipped: e.outcome.skipped, ...(e.outcome.placeholders ? { placeholders: placeholdersReport(e.outcome.placeholders) } : {}), + ...(e.outcome.kept ? { kept: keptReport(e.outcome.kept) } : {}), warnings: e.outcome.warnings, }; } @@ -232,6 +256,7 @@ function planReport(plan: DiscoveryPlan) { branch: e.environmentName, variableCount: e.variableCount, skippedCount: e.skippedCount, + ...(e.kept && e.kept.length > 0 ? { keptCount: e.kept.length, kept: keptReport(e.kept) } : {}), skipped: groupSkipped((e.skipped ?? []).map((s) => ({ name: s.name, reason: s.code, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) }))), ...(e.branchExists !== undefined ? { branchExists: e.branchExists } : {}), willAutoOverwrite: e.willAutoOverwrite, @@ -325,7 +350,8 @@ function humanLines( ...plan.folders.flatMap((f) => [ ` ${B(f.folder)} (${f.vercelProject})${f.initialized ? '' : ' — capy (init)'}`, ...f.environments.flatMap((e) => [ - ` ${e.branchExists === true ? 'checkout' : 'checkout -b'} ${e.branch}: ${e.variableCount} var(s), ${e.skippedCount} skipped${e.willAutoOverwrite ? ' [auto-overwrite]' : ''}`, + ` ${e.branchExists === true ? 'checkout' : 'checkout -b'} ${e.branch}: ${e.variableCount} var(s), ${e.skippedCount} skipped${e.keptCount ? `, ${e.keptCount} kept` : ''}${e.willAutoOverwrite ? ' [auto-overwrite]' : ''}`, // COPY-FLAG: new user-facing string, minimal/neutral wording. + ...(e.kept ? [` kept (Capy value untouched): ${e.kept.map((k) => `${k.name} (${k.reason})`).join(', ')}`] : []), // COPY-FLAG: new user-facing string, minimal/neutral wording. ...Object.entries(e.skipped).map( ([reason, items]) => ` skipped ${reason}: ${items.map((i) => (i.gitBranch ? `${i.name} (${i.gitBranch})` : i.name)).join(', ')}`, ), @@ -350,12 +376,15 @@ function appliedLines(outcome: Extract, applied: ...f.environments.flatMap((e) => { const clearedCount = e.cleared?.length ?? 0; const placeholderNames = (e.placeholders ?? []).map((p) => p.name); + const keptNames = (e.kept ?? []).map((k) => k.name); return [ ` ${e.branch}${e.branchCreated ? ' (new branch)' : ''}${e.overwrote ? ' (--overwrite)' : ''}: ` + `imported ${e.imported.length}, unchanged ${e.unchanged.length}, skipped ${e.skipped.length}` + (placeholderNames.length > 0 ? `, no value yet ${placeholderNames.length}` : '') + // COPY-FLAG + (keptNames.length > 0 ? `, kept ${keptNames.length}` : '') + // COPY-FLAG (clearedCount > 0 ? `, cleared ${clearedCount}` : ''), ...(placeholderNames.length > 0 ? [` no value yet: ${placeholderNames.join(', ')}`] : []), // COPY-FLAG + ...(keptNames.length > 0 ? [` kept (Capy value untouched): ${keptNames.join(', ')}`] : []), // COPY-FLAG ]; }), ...(f.ok ? (f.activeBranch ? [` folder is on branch ${f.activeBranch}`] : []) : [` ✗ ${f.code}: ${f.message}`]), diff --git a/src/core/placeholder.ts b/src/core/placeholder.ts index 861d1c47..2f09ce59 100644 --- a/src/core/placeholder.ts +++ b/src/core/placeholder.ts @@ -34,6 +34,11 @@ export function placeholdersOnBranch(keep: KeepFile | null | undefined, branch: }); } +/** True when `name` holds a real value (a non-empty hash, no placeholder flag) on `branch` in this keep.lock. */ +export function hasValueOnBranch(keep: KeepFile | null | undefined, name: string, branch: string): boolean { + return hasValue(keep?.variables[name]?.find((e) => e.branch === branch)); +} + /** True when the entry holds a real value (a non-empty hash). */ const hasValue = (entry: KeepVariableEntry | undefined): boolean => entry !== undefined && entry.value_hash !== '' && entry.placeholder === undefined; @@ -134,6 +139,11 @@ export function placeholdersReport(placeholders: ReadonlyArray<{ readonly name: return placeholders.map(({ name, code }) => ({ name, code })); } +/** Kept names as reported (human and JSON): the name, the stable code and the provider's reason, nothing else. */ +export function keptReport(kept: ReadonlyArray<{ readonly name: string; readonly code: string; readonly reason: string }>): ReadonlyArray<{ name: string; code: string; reason: string }> { + return kept.map(({ name, code, reason }) => ({ name, code, reason })); +} + /** * `capy secrets` only: the org-wide index carries each location's value hash and not the placeholder * flag, so a row with an empty hash is the one it can call "no value yet" (the reason is not in the index). diff --git a/src/types/index.ts b/src/types/index.ts index ce927ef5..906cdcfe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -805,6 +805,9 @@ export const ERROR_CODES = { VERCEL_PROJECT_NOT_GITHUB: 'VERCEL_PROJECT_NOT_GITHUB', /** Vercel said a project's env list has another page that this client cannot ask for; the names read may be incomplete (a notice). */ VERCEL_ENV_LIST_TRUNCATED: 'VERCEL_ENV_LIST_TRUNCATED', + // --- Import connectors --- + /** `capy connect vercel`: Vercel will not return this name's value, and Capy already has one. The Capy value was left untouched (reported, not an error). */ + CAPY_VALUE_KEPT: 'CAPY_VALUE_KEPT', // --- Piped values: ` | capy edit NAME`, ` | capy add NAME` --- /** * `capy edit` with no name and no terminal. There is nothing to draw the diff --git a/tests/commands/connectVercelDiscovery.test.ts b/tests/commands/connectVercelDiscovery.test.ts index 30f130f7..f2057914 100644 --- a/tests/commands/connectVercelDiscovery.test.ts +++ b/tests/commands/connectVercelDiscovery.test.ts @@ -17,7 +17,8 @@ import { randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createVercelConnector } from '../../src/commands/connectors/vercel'; -import { buildVercelDiscoveryPlan, matchVercelProject } from '../../src/commands/connectors/vercelDiscovery'; +import { buildVercelDiscoveryPlan, matchVercelProject, reportVercelDiscovery } from '../../src/commands/connectors/vercelDiscovery'; +import { discoveryConfirmMessage } from '../../src/commands/connectors/vercel'; import { findCandidateReposResult, type DiscoveryContext } from '../../src/commands/connectors/dokployDiscovery'; import { ConnectCommand } from '../../src/commands/connectCommand'; import { FileManager } from '../../src/files/fileManager'; @@ -236,6 +237,133 @@ describe('buildVercelDiscoveryPlan', () => { }); }); +// A keep.lock whose `production` branch already holds a value for HIDDEN (hash only; the plan reads no plaintext). +const keepWithHidden = (placeholder: boolean): KeepFile => ({ + version: '3.0', + org_id: 'o', + project_id: 'proj_x', + project_name: 'widgets', + variables: { + HIDDEN: [ + { + resource_id: 'r_hidden', + branch: 'production', + value_hash: placeholder ? '' : 'hash-of-a-real-value', + ...(placeholder ? { placeholder: { provider: 'vercel', reason: 'sensitive_unreadable' } } : {}), + }, + ], + }, +}); + +describe('buildVercelDiscoveryPlan — names Vercel will not return where Capy has a value are kept', () => { + test('a name with a value on that branch moves from skipped to kept; the counts follow; other environments are unchanged', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const keep = keepWithHidden(false); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, { peekLocalKeep: () => keep, listServerBranches: async () => [{ name: 'production' }] }); + const web = plan.folders[0]; + const prod = web.environments.find((env) => env.environmentName === 'production')!; + expect(prod.kept).toEqual([{ name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(prod.skipped).toEqual([]); + expect([prod.variableCount, prod.skippedCount]).toEqual([2, 0]); + const preview = web.environments.find((env) => env.environmentName === 'preview')!; + expect(preview.kept).toBeUndefined(); + expect(preview.skippedCount).toBe(1); + }); + }); + + test('a placeholder (no value) on that branch is not kept: it stays in the unreadable list as before', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const keep = keepWithHidden(true); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, { peekLocalKeep: () => keep, listServerBranches: async () => [{ name: 'production' }] }); + const prod = plan.folders[0].environments.find((env) => env.environmentName === 'production')!; + expect(prod.kept).toBeUndefined(); + expect([prod.skippedCount, prod.skipped]).toEqual([1, [{ name: 'HIDDEN', code: 'sensitive_unreadable' }]]); + }); + }); + + test('the confirm message counts them as kept, not skipped', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const keep = keepWithHidden(false); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, { peekLocalKeep: () => keep, listServerBranches: async () => [{ name: 'production' }] }); + const message = discoveryConfirmMessage(plan.folders, false); + expect(message).toContain('(2 var(s), 0 skipped, 1 kept)'); + expect(message).toContain('(1 var(s), 1 skipped)'); // preview: no kept suffix when there are none + }); + }); + + test('--dry-run --json and the human preview show kept (names, code, reason), never a value', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keepWithHidden(false))); + const { connector } = connectorFor(root, planData()); + const json = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true }))); + const out = JSON.parse(json.stdout[0]); + const web = out.plan.folders.find((f: { vercelProjectId: string }) => f.vercelProjectId === 'prj_web'); + const prod = web.environments.find((env: { branch: string }) => env.branch === 'production'); + expect(prod).toMatchObject({ variableCount: 2, skippedCount: 0, keptCount: 1, kept: [{ name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }], skipped: {} }); + + const human = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true }))); + const text = human.stdout.join('\n'); + expect(text).toContain('2 var(s), 0 skipped, 1 kept'); + expect(text).toContain('kept (Capy value untouched): HIDDEN (sensitive_unreadable)'); + expect(text).not.toContain('skipped sensitive_unreadable: HIDDEN'); + ALL_SECRETS.forEach((v) => expect([...json.stdout, ...text].join('\n')).not.toContain(v)); + }), + ); + }); +}); + +describe('reportVercelDiscovery — the applied per-environment result carries kept', () => { + const outcomeWithKept = { + ok: true as const, + dryRun: false, + plan: { folders: [], collisions: [], unmatched: [], unreadableRepos: [], allEnvironmentNames: [] }, + applied: [ + { + ok: true as const, + repoDir: '/r', + folder: '', + projectCreated: false, + activeBranch: 'production', + environments: [ + { + environmentName: 'production', + branchCreated: false, + overwrote: false, + outcome: { + ok: true as const, + imported: [], + unchanged: [], + skipped: [], + placeholders: [], + kept: [{ name: 'HIDDEN', code: ERROR_CODES.CAPY_VALUE_KEPT, reason: 'sensitive_unreadable' }], + warnings: [], + }, + }, + ], + }, + ], + } as unknown as Parameters[0]; + + test('--json: kept sits beside placeholders, and it is not a change (nothing linked)', async () => { + const run = await captureConsole(async () => reportVercelDiscovery(outcomeWithKept, { json: true } as ConnectOpts, 'vercel')); + const env0 = JSON.parse(run.stdout[0]).applied[0].environments[0]; + expect(env0.kept).toEqual([{ name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(env0.skipped).toEqual([]); + expect(run.result.linked).toBe(false); + }); + + test('human: the per-environment line counts kept and names them', async () => { + const run = await captureConsole(async () => reportVercelDiscovery(outcomeWithKept, {} as ConnectOpts, 'vercel')); + const text = run.stdout.join('\n'); + expect(text).toContain('imported 0, unchanged 0, skipped 0, kept 1'); + expect(text).toContain('kept (Capy value untouched): HIDDEN'); + }); +}); + // ── connector.discover / ConnectCommand.executeDiscovery ──────────────────── const fakeDiscoveryCtx = (over: Record = {}): DiscoveryContext => ({ diff --git a/tests/commands/vercelPlaceholders.test.ts b/tests/commands/vercelPlaceholders.test.ts index 4af82b1a..34829ead 100644 --- a/tests/commands/vercelPlaceholders.test.ts +++ b/tests/commands/vercelPlaceholders.test.ts @@ -104,10 +104,42 @@ describe('import: what becomes a placeholder, in every environment', () => { expect(codes(out.placeholders)).toEqual({ CUSTOM_SENS: 'sensitive_unreadable' }); }); - test('a name that already has a value here keeps it: it is a skip, not a placeholder', async () => { + test('a name that already has a value here keeps it: it is reported as kept, not skipped, and not a placeholder', async () => { const out = okOutcome(await connector().import!(ctxWith('production', { SENS: 'mine-already' }), BASE_OPTS)); expect(out.placeholders).toEqual([]); - expect(codes(out.skipped)).toEqual({ SENS: 'sensitive_unreadable', GENUINELY_EMPTY: 'empty_value' }); + expect(out.kept).toEqual([{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(codes(out.skipped)).toEqual({ GENUINELY_EMPTY: 'empty_value' }); + // the value is untouched: nothing is imported, cleared or replaced for it + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + expect(out.cleared).toBeUndefined(); + expect(out.unchangedEntries ?? []).toEqual([]); + }); + + test('a value_not_decrypted name with a local value is kept too, with its own reason', async () => { + const out = okOutcome(await connector().import!(ctxWith('development', { HIDDEN: 'mine-already', SENS: 'also-mine' }), BASE_OPTS)); + expect(out.placeholders).toEqual([]); + expect(out.kept).toEqual([ + { name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }, + { name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'value_not_decrypted' }, + ]); + expect(out.skipped).toEqual([]); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + }); + + test('a legacy secret with a local value is kept', async () => { + const out = okOutcome(await connector().import!(ctxWith('preview', { OLD: 'mine-already' }), BASE_OPTS)); + expect(out.kept).toEqual([{ name: 'OLD', code: 'CAPY_VALUE_KEPT', reason: 'legacy_secret_unreadable' }]); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable' }); + }); + + test('--var keeps only the in-scope names', async () => { + const out = okOutcome(await connector().import!(ctxWith('development', { HIDDEN: 'h', SENS: 's' }), { ...BASE_OPTS, var: 'HIDDEN' })); + expect(out.kept?.map((k) => k.name)).toEqual(['HIDDEN']); + }); + + test('no kept list entries when nothing has a local value', async () => { + const out = okOutcome(await connector().import!(ctxWith('production'), BASE_OPTS)); + expect(out.kept).toEqual([]); }); test('--var restricts the placeholders to the named variables', async () => { @@ -119,7 +151,17 @@ describe('import: what becomes a placeholder, in every environment', () => { const out = okOutcome(await connector().import!(ctxWith('development', { SENS: 'mine-already' }), { ...BASE_OPTS, overwrite: true, yes: true })); expect(codes(out.placeholders)).toEqual({ HIDDEN: 'value_not_decrypted' }); expect(out.cleared).toEqual([]); - expect(codes(out.skipped)).toEqual({ SENS: 'sensitive_unreadable' }); + expect(out.kept).toEqual([{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(out.skipped).toEqual([]); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + }); + + test('--overwrite --yes: a value_not_decrypted name with a local value is kept, never cleared, not skipped', async () => { + const out = okOutcome(await connector().import!(ctxWith('development', { HIDDEN: 'mine-already', STALE: 'gone-from-vercel' }), { ...BASE_OPTS, overwrite: true, yes: true })); + expect(out.kept).toEqual([{ name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'value_not_decrypted' }]); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable' }); + expect(out.cleared).toEqual(['STALE']); + expect(out.skipped).toEqual([]); }); test('no value is read for a sensitive record, and none is in the outcome', async () => { @@ -232,6 +274,72 @@ describe('import end to end (real FileManager, real report)', () => { ); }); + test('--json: a name with a local value is under kept (code and reason), not skipped or a placeholder, and its value is untouched', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-kept-json', async (dir) => { + const { stdout } = await importInto(dir, 'production', { json: true }, undefined, { SENS: 'mine-already' }); + expect(stdout).toHaveLength(1); + const parsed = JSON.parse(stdout[0]); + expect(parsed.kept).toEqual([{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(parsed.placeholders).toEqual([]); + expect(parsed.skipped).toEqual([{ name: 'GENUINELY_EMPTY', code: 'empty_value' }]); + expect(parsed.imported).toEqual(['PLAIN']); + // SENS is not a placeholder (no flag, no comment) and its Capy value is still the one it had + expect(placeholderOf(readKeep(dir), 'SENS', 'production')).toBeUndefined(); + expect(readFileSync(join(dir, '.env'), 'utf-8')).not.toContain(SENS_NOTE); + expect(new FileManager(dir).readEncryptedEnvFile(PROJECT_KEY)).toEqual({ SENS: 'mine-already', PLAIN: SENTINEL.plain }); + const everything = [...stdout, readFileSync(join(dir, '.capy', 'keep.lock'), 'utf-8')].join('\n'); + Object.values(SENTINEL).forEach((s) => expect(everything).not.toContain(s)); + }), + ), + ); + }); + + test('human output: one neutral line for the kept names, and they are not listed as skipped', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-kept-human', async (dir) => { + const { stdout } = await importInto(dir, 'development', {}, undefined, { SENS: 'mine', HIDDEN: 'mine-too' }); + const text = stdout.join('\n'); + expect(text).toContain('Kept the Capy value for 2 name(s) Vercel will not return: SENS (sensitive_unreadable), HIDDEN (value_not_decrypted)'); + expect(text).not.toContain('Skipped: SENS'); + expect(text).not.toContain('Skipped: HIDDEN'); + expect(text).not.toContain('with no value yet'); + }), + ); + }); + + test('--overwrite --json: the kept list is there, and the kept name is neither cleared nor skipped', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-kept-overwrite', async (dir) => { + const { stdout } = await importInto(dir, 'production', { json: true, overwrite: true, yes: true }, undefined, { SENS: 'mine-already' }); + const parsed = JSON.parse(stdout[0]); + expect(parsed.kept).toEqual([{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(parsed.cleared ?? []).not.toContain('SENS'); + expect(parsed.skipped.map((s: { name: string }) => s.name)).not.toContain('SENS'); + expect(parsed.placeholders).toEqual([]); + }), + ), + ); + }); + + test('--dry-run shows kept (JSON and human) and writes nothing', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-kept-dry', async (dir) => { + const json = await importInto(dir, 'production', { json: true, dryRun: true }, undefined, { SENS: 'mine-already' }); + expect(JSON.parse(json.stdout[0])).toMatchObject({ dryRun: true, kept: [{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }], placeholders: [] }); + expect(JSON.parse(json.stdout[0]).skipped.map((s: { name: string }) => s.name)).not.toContain('SENS'); + const human = await importInto(dir, 'production', { dryRun: true }, undefined, { SENS: 'mine-already' }); + expect(human.stdout.join('\n')).toContain('Kept the Capy value for 1 name(s) Vercel will not return: SENS (sensitive_unreadable)'); + expect(existsSync(join(dir, '.env'))).toBe(false); + expect(existsSync(join(dir, '.capy', 'keep.lock'))).toBe(false); + }), + ), + ); + }); + test('another branch gets its own placeholders (preview has two, production one)', async () => { await withIsolatedGlobalDir(() => withTempDir('capy-vercel-ph-branches', async (dir) => { From b064d2a624698026a9010e1830ead8f13f3ef527 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 2 Nov 2026 20:12:04 -0700 Subject: [PATCH 13/22] feat(connect): provider-neutral discovery steps; an empty or placeholder-only branch saves Move the discovery steps both Dokploy and Vercel run out of dokploy.ts into discoverySteps.ts. Refusal codes come from a per-provider table: Dokploy keeps its DOKPLOY_* codes, Vercel reports CONNECTOR_* codes (added to ERROR_CODES and the CLI reference). A typed service error on upload keeps its own code for neutral providers. A branch with no variables (placeholders only, or everything cleared) uploaded an empty env_blob, which the service refuses with 400. The blob of an empty branch is now a comment line that every reader parses as no variables. --- docs/cli-reference.md | 18 + src/commands/connectors/discoveryCodes.ts | 84 ++ src/commands/connectors/discoverySteps.ts | 886 +++++++++++++++++ src/commands/connectors/dokploy.ts | 892 +----------------- src/commands/connectors/dokployDiscovery.ts | 15 +- src/commands/connectors/vercel.ts | 14 +- src/git/discoveryCommit.ts | 23 +- src/sync/commit.ts | 17 +- src/types/index.ts | 39 + .../connectDiscoveryNeutralCodes.test.ts | 373 ++++++++ .../commands/connectDokployDiscovery.test.ts | 3 +- .../commands/connectVercelEmptyBranch.test.ts | 203 ++++ tests/commands/projectPickerWiring.test.ts | 2 +- tests/helpers/fakeCapyServer.ts | 4 + tests/sync/emptyEnvBlob.test.ts | 144 +++ 15 files changed, 1825 insertions(+), 892 deletions(-) create mode 100644 src/commands/connectors/discoveryCodes.ts create mode 100644 src/commands/connectors/discoverySteps.ts create mode 100644 tests/commands/connectDiscoveryNeutralCodes.test.ts create mode 100644 tests/commands/connectVercelEmptyBranch.test.ts create mode 100644 tests/sync/emptyEnvBlob.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4195945d..3c28d301 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1025,6 +1025,24 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `VERCEL_PROJECT_NOT_GITHUB` - `VERCEL_ENV_LIST_TRUNCATED` - `CAPY_VALUE_KEPT` +- `CONNECTOR_PUSH_FAILED` +- `CONNECTOR_FOLDER_NOT_INITIALIZED` +- `CONNECTOR_PROJECT_LOOKUP_FAILED` +- `CONNECTOR_PROJECT_NAME_EXISTS` +- `CONNECTOR_PROJECT_NOT_FOUND` +- `CONNECTOR_INIT_FAILED` +- `CONNECTOR_PROJECT_NAME_TOO_LONG` +- `CONNECTOR_KEY_RESOLUTION_FAILED` +- `CONNECTOR_FOLDER_DIRTY` +- `CONNECTOR_BRANCH_PROTECTED` +- `CONNECTOR_KEEP_MISSING` +- `CONNECTOR_CHECKOUT_FAILED` +- `CONNECTOR_REMOTE_UNREADABLE` +- `CONNECTOR_COMMIT_WOULD_MIX` +- `CONNECTOR_COMMIT_BRANCH_INVALID` +- `CONNECTOR_COMMIT_BRANCH_EXISTS` +- `CONNECTOR_GIT_IDENTITY_MISSING` +- `CONNECTOR_COMMIT_FAILED` - `EDIT_NEEDS_TTY` - `EDIT_STDIN_LOCAL_ONLY` - `STDIN_EMPTY` diff --git a/src/commands/connectors/discoveryCodes.ts b/src/commands/connectors/discoveryCodes.ts new file mode 100644 index 00000000..b70c2ba2 --- /dev/null +++ b/src/commands/connectors/discoveryCodes.ts @@ -0,0 +1,84 @@ +/** + * The refusal codes of the steps every connector's `--discover` shares + * (`discoverySteps.ts`, `dokployDiscovery.ts#findCandidateReposResult`, + * `git/discoveryCommit.ts`). + * + * Each step is the same for every provider, but a provider decides how its + * codes are spelled: Dokploy keeps the `DOKPLOY_*` spellings it has always + * reported (its output does not change), and every other provider reports the + * provider-neutral `CONNECTOR_*` ones. The table is passed in; a code is only + * ever compared, never parsed. + */ +import { ERROR_CODES } from '../../types/index'; + +export interface DiscoveryCodes { + /** + * Whether a typed error from the service (for example `PERMISSION_DENIED`) that stops a folder's upload keeps its own code, + * instead of becoming `pushFailed`. Dokploy has always reported `pushFailed` for it, so it stays `false` there. + */ + readonly keepsServiceCode: boolean; + readonly pushFailed: string; + readonly folderNotInitialized: string; + readonly projectLookupFailed: string; + readonly projectNameExists: string; + readonly projectNotFound: string; + readonly initFailed: string; + readonly projectNameTooLong: string; + readonly keyResolutionFailed: string; + readonly folderDirty: string; + readonly branchProtected: string; + readonly keepMissing: string; + readonly checkoutFailed: string; + readonly remoteUnreadable: string; + readonly commitWouldMix: string; + readonly commitBranchInvalid: string; + readonly commitBranchExists: string; + readonly gitIdentityMissing: string; + readonly commitFailed: string; +} + +/** Dokploy's spellings: exactly the strings its discovery has always reported. */ +export const DOKPLOY_DISCOVERY_CODES: DiscoveryCodes = { + keepsServiceCode: false, + pushFailed: 'DOKPLOY_PUSH_FAILED', + folderNotInitialized: 'DOKPLOY_FOLDER_NOT_INITIALIZED', + projectLookupFailed: 'DOKPLOY_PROJECT_LOOKUP_FAILED', + projectNameExists: 'DOKPLOY_PROJECT_NAME_EXISTS', + projectNotFound: 'DOKPLOY_PROJECT_NOT_FOUND', + initFailed: 'DOKPLOY_INIT_FAILED', + projectNameTooLong: 'DOKPLOY_PROJECT_NAME_TOO_LONG', + keyResolutionFailed: 'DOKPLOY_KEY_RESOLUTION_FAILED', + folderDirty: 'DOKPLOY_FOLDER_DIRTY', + branchProtected: 'DOKPLOY_BRANCH_PROTECTED', + keepMissing: 'DOKPLOY_KEEP_MISSING', + checkoutFailed: 'DOKPLOY_CHECKOUT_FAILED', + remoteUnreadable: 'DOKPLOY_REMOTE_UNREADABLE', + commitWouldMix: 'DOKPLOY_COMMIT_WOULD_MIX', + commitBranchInvalid: 'DOKPLOY_COMMIT_BRANCH_INVALID', + commitBranchExists: 'DOKPLOY_COMMIT_BRANCH_EXISTS', + gitIdentityMissing: 'DOKPLOY_GIT_IDENTITY_MISSING', + commitFailed: 'DOKPLOY_COMMIT_FAILED', +}; + +/** The provider-neutral spellings, for every provider other than Dokploy. */ +export const CONNECTOR_DISCOVERY_CODES: DiscoveryCodes = { + keepsServiceCode: true, + pushFailed: ERROR_CODES.CONNECTOR_PUSH_FAILED, + folderNotInitialized: ERROR_CODES.CONNECTOR_FOLDER_NOT_INITIALIZED, + projectLookupFailed: ERROR_CODES.CONNECTOR_PROJECT_LOOKUP_FAILED, + projectNameExists: ERROR_CODES.CONNECTOR_PROJECT_NAME_EXISTS, + projectNotFound: ERROR_CODES.CONNECTOR_PROJECT_NOT_FOUND, + initFailed: ERROR_CODES.CONNECTOR_INIT_FAILED, + projectNameTooLong: ERROR_CODES.CONNECTOR_PROJECT_NAME_TOO_LONG, + keyResolutionFailed: ERROR_CODES.CONNECTOR_KEY_RESOLUTION_FAILED, + folderDirty: ERROR_CODES.CONNECTOR_FOLDER_DIRTY, + branchProtected: ERROR_CODES.CONNECTOR_BRANCH_PROTECTED, + keepMissing: ERROR_CODES.CONNECTOR_KEEP_MISSING, + checkoutFailed: ERROR_CODES.CONNECTOR_CHECKOUT_FAILED, + remoteUnreadable: ERROR_CODES.CONNECTOR_REMOTE_UNREADABLE, + commitWouldMix: ERROR_CODES.CONNECTOR_COMMIT_WOULD_MIX, + commitBranchInvalid: ERROR_CODES.CONNECTOR_COMMIT_BRANCH_INVALID, + commitBranchExists: ERROR_CODES.CONNECTOR_COMMIT_BRANCH_EXISTS, + gitIdentityMissing: ERROR_CODES.CONNECTOR_GIT_IDENTITY_MISSING, + commitFailed: ERROR_CODES.CONNECTOR_COMMIT_FAILED, +}; diff --git a/src/commands/connectors/discoverySteps.ts b/src/commands/connectors/discoverySteps.ts new file mode 100644 index 00000000..31e99152 --- /dev/null +++ b/src/commands/connectors/discoverySteps.ts @@ -0,0 +1,886 @@ +/** + * The discovery steps every connector's `--discover` shares (`capy connect + * dokploy --discover`, `capy connect vercel --discover`): bare `capy` in each + * folder, init or pick the folder's project, the dirty-folder guard, checkout + * of each environment's branch, the per-environment import and its write, and + * the one discovery commit per repo. They were written for Dokploy first and + * lived in `dokploy.ts`; they are moved here unchanged except that nothing in + * them names a provider: + * + * - the refusal codes come in as a `DiscoveryCodes` table (`discoveryCodes.ts`): + * Dokploy passes its own `DOKPLOY_*` spellings, so its output is exactly what + * it was; the other providers pass the neutral `CONNECTOR_*` ones; + * - the options of each step's import come from the provider (`importOptsFor`). + * + * The sequence itself (`runDiscoverySequences`) and the plan types live in + * `dokployDiscovery.ts`. + */ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { CapyError, ERROR_CODES, KeepFile } from '../../types/index'; +import { isStaleBase } from '../../sync/commit'; +import { ProjectManager } from '../../core/projectManager'; +import { FileManager } from '../../files/fileManager'; +import { resolveProjectKey } from '../../crypto/keyResolver'; +import { SyncEngine } from '../../sync/syncEngine'; +import { assertProjectNameAllowed, isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../../system/reservedProjectName'; +import { findDirtyBranchIssue } from '../checkoutCommand'; +import { listOrgProjectsOrUnavailable } from '../capyCommand'; +import { commitDiscoveryChanges, isValidDiscoveryCommitBranchName } from '../../git/discoveryCommit'; +import type { FolderSync, GateOptions } from '../../sync/gate'; +import { isConflictRefusal } from '../../sync/conflicts'; +import { writeImportOutcome, ResolvedContext } from './shared'; +import { ConnectOpts, ImportOutcome } from './registry'; +import { discoveryProjectQuestion } from '../../ui/projectQuestions'; +import type { DiscoveryCodes } from './discoveryCodes'; +import { + DISCOVERY_PROJECT_NAME_MAX_LENGTH, + DiscoveryCollision, + DiscoveryContext, + DiscoveryFolderResult, + DiscoveryPlanEnv, + DiscoveryPlanFolder, + DiscoveryRepoCommitResult, + DiscoverySequenceDeps, + GitRemoteRef, + defaultDiscoveryProjectName, +} from './dokployDiscovery'; + +export async function defaultConfirm(message: string, defaultValue: boolean): Promise { + const inquirer = (await import('inquirer')).default; + const { ok } = await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: defaultValue }]); + return ok; +} + +/** Sentinel `askExistingOrNewProject` returns to mean "create a new project", never a real project id. */ +export const DISCOVERY_NEW_PROJECT = '__capy_discovery_new_project__'; + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +export async function defaultAskExistingOrNewProject( + existing: ReadonlyArray<{ id: string; name: string }>, + defaultName: string, +): Promise { + const inquirer = (await import('inquirer')).default; + const { projectChoice } = await inquirer.prompt([ + discoveryProjectQuestion(existing, defaultName, DISCOVERY_NEW_PROJECT), + ]); + return projectChoice; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +/** + * Validates a discovery project-name answer: non-empty, at most + * `DISCOVERY_PROJECT_NAME_MAX_LENGTH` chars, not the reserved `_system` name + * (Vince, 2026-09-26, decision #4). Deliberately no charset restriction — + * `/` (and anything else `defaultDiscoveryProjectName` might produce) must + * stay allowed here; that's a separate concern from this ticket's scope. + */ +function discoveryProjectNameProblem(trimmed: string): string | null { + if (!trimmed) return 'A project name is required.'; + if (trimmed.length > DISCOVERY_PROJECT_NAME_MAX_LENGTH) { + return `Project name must be ${DISCOVERY_PROJECT_NAME_MAX_LENGTH} characters or fewer.`; + } + if (isReservedProjectName(trimmed)) return PROJECT_NAME_RESERVED_MESSAGE; + return null; +} + +export async function defaultAskDiscoveryProjectName(defaultName: string): Promise { + const inquirer = (await import('inquirer')).default; + const { name } = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: 'New Capy project name:', + default: defaultName, + filter: (v: string) => v.trim(), + // Re-asks automatically (inquirer's own validate loop) rather than + // ever reaching `ensureProjectSafe` with a name it would refuse + // the project-name-too-long refusal / `PROJECT_NAME_RESERVED` for. + validate: (v: string) => discoveryProjectNameProblem(v.trim()) ?? true, + }, + ]); + return name; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +export async function defaultAskDiscoveryCommitBranchName(defaultName: string): Promise { + const inquirer = (await import('inquirer')).default; + const { name } = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: 'Commit the import to which git branch:', + default: defaultName, + filter: (v: string) => v.trim(), + // Re-asks automatically (inquirer's own validate loop) rather than + // ever reaching `commitDiscoveryChanges` with a name it would refuse + // the commit-branch-invalid refusal for — that refusal is still the + // authoritative, zero-mutation-guaranteed check for every OTHER + // path into this function (non-interactive, an injected test dep). + validate: (v: string) => (isValidDiscoveryCommitBranchName(v.trim()) ? true : 'Not a valid git branch name.'), + }, + ]); + return name; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +export async function defaultPickCollisionWinner(collision: DiscoveryCollision): Promise { + const inquirer = (await import('inquirer')).default; + const { winner } = await inquirer.prompt([ + { + type: 'list', + name: 'winner', + message: `${collision.folder || '.'} / ${collision.environmentName}: which service feeds this branch?`, + choices: collision.candidates.map((c) => ({ name: `${c.projectName} / ${c.serviceName}`, value: c.serviceId })), + }, + ]); + return winner; +} + +// ── --overwrite ────────────────────────────────────────────────────────────── + +export interface OverwritePlan { + /** Local names the provider no longer has, and that aren't a reference value — removed entirely. */ + toClear: readonly string[]; + /** Present in both with a DIFFERENT value — overwritten with the provider's. */ + toReplace: ReadonlyArray<{ name: string; value: string }>; + /** Only at the provider — a plain new write. */ + toImport: ReadonlyArray<{ name: string; value: string }>; + /** Present in both with the SAME value — no-op. */ + unchanged: readonly string[]; +} + +/** + * `--overwrite`'s own rule: the branch's vars become EXACTLY the provider's + * importable set. `localPlaintext` names absent from the provider's set are + * cleared — EXCEPT a reference-valued name (`${{...}}`, `referenceNames`): + * there is no resolved value to replace it with, so it is left alone rather + * than cleared (the caller's reference-value warning already says + * so). `sourceCandidates` must be the provider's FULL importable set, never one + * already narrowed by `--var` — narrowing it here would make a restricted + * overwrite clear a name the provider still has, just outside the restriction + * (see the call site in `import()`, which computes this from the + * unrestricted list on purpose). + */ +export function computeOverwritePlan( + sourceCandidates: ReadonlyArray<{ name: string; value: string }>, + referenceNames: readonly string[], + localPlaintext: Readonly>, +): OverwritePlan { + const sourceNames = new Set(sourceCandidates.map((c) => c.name)); + const referenceSet = new Set(referenceNames); + return { + toClear: Object.keys(localPlaintext).filter((k) => !sourceNames.has(k) && !referenceSet.has(k)), + toReplace: sourceCandidates.filter((c) => localPlaintext[c.name] !== undefined && localPlaintext[c.name] !== c.value), + toImport: sourceCandidates.filter((c) => localPlaintext[c.name] === undefined), + unchanged: sourceCandidates.filter((c) => localPlaintext[c.name] === c.value).map((c) => c.name), + }; +} + +/** Plan-time-only local keep.lock read (no auth) — see `PeekLocalKeep`'s own doc. */ +export function peekLocalKeepForFolder(repoDir: string, folder: string): KeepFile | null { + return new ProjectManager(folder ? join(repoDir, folder) : repoDir).readKeepFile(); +} + +const folderPath = (repoDir: string, folder: string): string => (folder ? join(repoDir, folder) : repoDir); + +/** keep.lock and .gitignore, relative to `repoDir` — the ONLY paths discovery's own commit step ever touches. */ +export function discoveryCommitPathsFor(folder: string): readonly string[] { + const prefix = folder ? `${folder}/` : ''; + return [`${prefix}keep.lock`, `${prefix}.gitignore`]; +} + +/** + * Every REPO the plan touches, and the branch/files a real run WOULD commit + * for it — dry-run preview only, never runs a git-mutating command (see + * `commitDiscoveryChanges`'s own `dryRun` handling). + * + * `paths` is de-duplicated per repo (cosmetic defect fix): two DISTINCT + * `DiscoveryPlanFolder` entries can share the same (repoDir, folder) — e.g. + * two colliding services that both resolve to the same folder, still shown + * as separate candidates in the pre-resolution `plan.folders` a dry run + * previews (a dry run never asks for a collision winner) — and + * `discoveryCommitPathsFor` would otherwise be counted once per entry, + * reporting `keep.lock`/`.gitignore` twice for what is really one write. + */ +export function commitPreviewForFolders( + folders: readonly DiscoveryPlanFolder[], + branchName: string, + /** How this provider spells the commit refusals. */ + codes: DiscoveryCodes, + /** The commit subject. */ + commitMessage?: string, +): readonly DiscoveryRepoCommitResult[] { + const repoDirs = [...new Set(folders.map((f) => f.repoDir))]; + return repoDirs.map((repoDir) => { + const paths = [...new Set(folders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)))]; + const outcome = commitDiscoveryChanges(repoDir, paths, new Set(), { branchName, dryRun: true, summaryLines: [], codes, ...(commitMessage ? { commitMessage } : {}) }); + return { repoDir, ...outcome }; + }); +} + +/** + * The commit step's real run: per repo touched by `applied`, commits ONLY + * the SUCCESSFULLY-applied folders' keep.lock/.gitignore (see + * `commitDiscoveryChanges`'s own doc for the refusal codes) — a repo with + * zero successful folders is skipped entirely (nothing to commit, no + * refusal either). + */ +export function commitForAppliedRepos( + applied: readonly DiscoveryFolderResult[], + beforeStatusByRepo: ReadonlyMap>, + branchName: string, + /** How this provider spells the commit refusals. */ + codes: DiscoveryCodes, + /** The commit subject. */ + commitMessage?: string, +): readonly DiscoveryRepoCommitResult[] { + const repoDirs = [...new Set(applied.map((f) => f.repoDir))]; + return repoDirs.flatMap((repoDir) => { + const okFolders = applied.filter((f) => f.repoDir === repoDir && f.ok); + if (okFolders.length === 0) return []; + const paths = okFolders.flatMap((f) => discoveryCommitPathsFor(f.folder)); + const summaryLines = okFolders.map((f) => `- ${f.folder || '.'}: ${(f as Extract).activeBranch}`); + const outcome = commitDiscoveryChanges(repoDir, paths, beforeStatusByRepo.get(repoDir) ?? new Set(), { + branchName, + dryRun: false, + summaryLines, + codes, + ...(commitMessage ? { commitMessage } : {}), + }); + return [{ repoDir, ...outcome }]; + }); +} + +/** What `listOrgProjectsOrUnavailable` returns — named here so the non-interactive `--yes` snapshot (see `ensureProjectSafe`'s doc) and the interactive per-folder lookup share one type. */ +export type ExistingProjectsSnapshot = { + existingProjects: ReadonlyArray<{ id: string; name: string; organization_id: string }>; + projectsUnavailable: boolean; +}; + +/** A project name, normalized for EQUALITY comparison only (never for storage/display) — trimmed, then lower-cased, so `" Widgets "` and `widgets` compare equal (Vince, 2026-09-26 decision). */ +function normalizeProjectName(name: string): string { + return name.trim().toLowerCase(); +} + +/** The project-name-exists refusal's message — shared by the org-snapshot collision and the same-run collision below, since a human reads the same instruction either way. */ +// COPY-FLAG: new user-facing string, minimal/neutral wording. +function projectNameExistsMessage(folder: string, name: string): string { + return `${folder || '.'}: a project named "${name}" already exists — reuse or duplicate is a human choice; run interactively or \`capy\` in ${folder || '.'}.`; +} + +/** Stable per-(repoDir, folder) key — mirrors `dokployDiscovery.ts`'s own `groupKey` shape (`\u0000`-joined, so neither part can collide with the separator). */ +function sameRunFolderKey(repoDir: string, folder: string): string { + return `${repoDir}\u0000${folder}`; +} + +/** + * Pure, run-once precomputation of "two folders in this SAME run would try + * to create a project under the SAME default name" (Vince, 2026-09-26 + * decision) — computed from `resolvedFolders` alone, entirely before any + * folder's own `ensureProject` call runs, so no mutable "names created so + * far" state needs to be threaded through the sequential per-folder loop + * (`runDiscoverySequences`'s `ensureProject` dependency has no such + * accumulator parameter, and this file's own immutability rule forbids + * inventing one via a mutated `Set`/`Map`). + * + * Only folders WITHOUT a local keep.lock are candidates (`!f.initialized`) — + * an already-initialized folder never creates anything, so it can never + * collide with, or be collided into by, another folder's create. Folded over + * `folders` in their OWN array order — the exact order `runDiscoverySequences` + * processes them in — so "the first one wins" here means the same thing it + * means at run time. Returns the set of `sameRunFolderKey`s for every folder + * that is NOT the first candidate to claim its (normalized) default name; + * the very first claimant of a name is never in the returned set. + */ +export function computeSameRunNameCollisions(folders: readonly DiscoveryPlanFolder[]): ReadonlySet { + const candidates = folders + .filter((f) => !f.initialized) + .map((f) => ({ key: sameRunFolderKey(f.repoDir, f.folder), normalizedName: normalizeProjectName(defaultDiscoveryProjectName(f.repoDir, f.folder, f.remote)) })); + + return candidates.reduce( + (acc, c) => + acc.seenNames.has(c.normalizedName) + ? { seenNames: acc.seenNames, collisionKeys: new Set([...acc.collisionKeys, c.key]) } + : { seenNames: new Set([...acc.seenNames, c.normalizedName]), collisionKeys: acc.collisionKeys }, + { seenNames: new Set(), collisionKeys: new Set() }, + ).collisionKeys; +} + +/** + * No keep.lock at (repoDir, folder) → interactive: reuses `capy`'s own + * existing-project-picking (list the org's projects, ask new-vs-existing — + * see `defaultAskExistingOrNewProject`), never invents its own project flow. + * A keep.lock already there is just read for its project id — no network + * call. + * + * Non-interactive WITHOUT `--yes` refuses `codes.folderNotInitialized` + * naming the folder and never auto-creates — unchanged. + * + * Non-interactive WITH `--yes` (Vince, 2026-09-26 decision, SUPERSEDING the + * original "org has none" rule): creates a new project named `defaultName` + * UNLESS a project with that EXACT name (trimmed, compared case-insensitively + * — see `normalizeProjectName`) already exists — an UNRELATED existing + * project in the org (e.g. a stray empty `root`) no longer blocks anything. + * A same-name collision refuses `codes.projectNameExists`, naming both + * the project and the folder — reusing or duplicating an existing project is + * a genuine human choice with no safe default, same reasoning the old rule + * used for "org has ANY project", now narrowed to "org has THIS name". + * `opts.existingProjectsSnapshot` is this run's OWN snapshot (see + * `buildRealDiscoverySequenceDeps`'s doc): taken ONCE, before any folder in + * this run starts, so a project an EARLIER folder in this same run created + * is never counted as "existing" when deciding a LATER folder's own + * name-collision answer. A snapshot that failed to load is + * `codes.projectLookupFailed`, same as the interactive branch below and + * same as before this decision. + * + * The snapshot alone cannot catch two DIFFERENT folders in this SAME run + * both defaulting to the same name (neither one is "existing" yet when the + * snapshot was taken) — `opts.sameRunNameCollision` is `ensureProjectSafe`'s + * OWN pure precomputed answer to exactly that (see + * `computeSameRunNameCollisions`), and refuses the SAME + * `codes.projectNameExists` code for whichever folder is the SECOND (or + * later) occurrence in run order; the first occurrence proceeds normally. + * + * Deliberately NOT a call into `CapyCommand` itself: that class calls + * `process.exit()` on several failure paths and always builds its own + * `ProjectManager()`/`FileManager()` against `process.cwd()`, neither of + * which a multi-folder run can tolerate (one folder's exit would kill every + * other folder still to run, and there is no way to point it at a folder + * other than `cwd`). This reuses the SAME underlying `ServiceClient` calls + * (`listProjects`, `initializeProject`) and mirrors `capyCommand.ts`'s own + * `bootstrapExistingProject` for the existing-project branch (see + * `bootstrapExistingProjectSafe`, below), scoped to one folder and returning + * a coded outcome instead of exiting. + */ +async function ensureProjectSafe( + ctx: DiscoveryContext, + repoDir: string, + folder: string, + /** This folder's repo's own parsed `origin`, when known — see `defaultDiscoveryProjectName`'s decision #2 doc; passed through rather than re-read here. */ + remote: GitRemoteRef | undefined, + opts: { + interactive: boolean; + /** Discovery's OWN `--yes` — skips the new-project NAME prompt (defaulting to `defaultName`) the same way it skips every other confirmation, never the existing-vs-new picker itself (that one isn't a confirmation, it's a genuine choice with no safe default) — EXCEPT non-interactively, where a same-name collision has no choice left to make (see this function's own doc). */ + yes: boolean; + askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; + askProjectName: (defaultName: string) => Promise; + /** This run's ONE pre-apply snapshot of the org's existing projects — only consulted on the non-interactive `--yes` auto-create path; `undefined` there means the snapshot itself failed to load. */ + existingProjectsSnapshot?: ExistingProjectsSnapshot; + /** This folder's own precomputed answer to "does an EARLIER folder in this SAME run already claim this default name" (see `computeSameRunNameCollisions`) — only consulted on the non-interactive `--yes` auto-create path. */ + sameRunNameCollision: boolean; + /** How this provider spells the refusals. */ + codes: DiscoveryCodes; + }, +): Promise<{ ok: true; projectId: string; created: boolean } | { ok: false; code: string; message: string }> { + const path = folderPath(repoDir, folder); + const pm = new ProjectManager(path); + const existing = pm.readKeepFile(); + if (existing) { + return { ok: true, projectId: existing.project_id, created: false }; + } + const defaultName = defaultDiscoveryProjectName(repoDir, folder, remote); + + if (!opts.interactive) { + if (!opts.yes) { + return { + ok: false, + code: opts.codes.folderNotInitialized, + message: `${folder || '.'} has no keep.lock. Run \`capy\` there to initialize it, then re-run discovery.`, + }; + } + const snapshot = opts.existingProjectsSnapshot; + if (!snapshot || snapshot.projectsUnavailable) { + return { + ok: false, + code: opts.codes.projectLookupFailed, + message: `${folder || '.'}: could not list this org's existing projects — refusing to offer "create new" blind.`, + }; + } + const sameNameProject = snapshot.existingProjects.find((p) => normalizeProjectName(p.name) === normalizeProjectName(defaultName)); + if (sameNameProject || opts.sameRunNameCollision) { + return { + ok: false, + code: opts.codes.projectNameExists, + message: projectNameExistsMessage(folder, defaultName), + }; + } + return createNewProjectSafe(ctx, path, folder, defaultName, opts.codes); + } + + // A lookup failure is NOT "this org has no projects" — offering only + // "create new" on a transient network/auth error risks a duplicate + // project the human never asked for. Abort THIS folder instead (a + // follow-up defect fix) — the SAME distinction `capy`'s own init makes + // (`listOrgProjectsOrUnavailable`, extracted from `capyCommand.ts`). + const { existingProjects, projectsUnavailable } = await listOrgProjectsOrUnavailable(ctx.serviceClient); + if (projectsUnavailable) { + return { + ok: false, + code: opts.codes.projectLookupFailed, + message: `${folder || '.'}: could not list this org's existing projects — refusing to offer "create new" blind.`, + }; + } + const picked = await opts.askExistingOrNewProject(existingProjects.map((p) => ({ id: p.id, name: p.name })), defaultName); + + if (picked !== DISCOVERY_NEW_PROJECT) { + const project = existingProjects.find((p) => p.id === picked); + if (!project) { + return { ok: false, code: opts.codes.projectNotFound, message: `No project ${picked} in this org.` }; + } + try { + await bootstrapExistingProjectSafe(ctx, pm, new FileManager(path), project); + return { ok: true, projectId: project.id, created: false }; + } catch (err) { + return { ok: false, code: opts.codes.initFailed, message: err instanceof Error ? err.message : String(err) }; + } + } + // Mirrors `capyCommand.ts#resolveProjectName`: ask for the new project's + // name (default = `defaultName`). `--yes` skips the ask — same "proceed + // without asking" contract as every other discovery confirmation — and + // uses the default outright. + const chosenName = opts.yes ? defaultName : await opts.askProjectName(defaultName); + return createNewProjectSafe(ctx, path, folder, chosenName, opts.codes); +} + +/** + * Creates a brand-new project named `chosenName` and initializes (repoDir, + * folder) onto it — the exact write sequence the interactive "New project" + * choice runs (`ensureProjectSafe`'s own interactive branch), reused + * verbatim by the non-interactive `--yes` auto-create path (Vince, + * 2026-09-26 decision) so the two can never drift apart. + */ +async function createNewProjectSafe( + ctx: DiscoveryContext, + path: string, + folder: string, + chosenName: string, + codes: DiscoveryCodes, +): Promise<{ ok: true; projectId: string; created: boolean } | { ok: false; code: string; message: string }> { + // Vince, 2026-09-26, decision #4: refuse before any write when the name + // (default or human-typed) is over 255 chars — never truncated. The real + // `defaultAskDiscoveryProjectName` prompt validates this itself and + // re-asks (so an interactive human self-corrects before ever returning + // here); this is the zero-write guarantee for `--yes` and for any + // injected `askProjectName` test double that skips that validation. + if (chosenName.length > DISCOVERY_PROJECT_NAME_MAX_LENGTH) { + return { + ok: false, + code: codes.projectNameTooLong, + message: `${folder || '.'}: project name is longer than ${DISCOVERY_PROJECT_NAME_MAX_LENGTH} characters.`, + }; + } + try { + assertProjectNameAllowed(chosenName); + const initResult = await ctx.serviceClient.initializeProject(chosenName, ctx.orgId); + const keep: KeepFile = { + version: '3.0', + org_id: initResult.org_id, + project_id: initResult.project_id, + project_name: initResult.project_name, + variables: {}, + }; + // A discovered folder several levels deep (e.g. `backend/deployment`) + // may not exist yet under `repoDir` — `writeKeepFile` writes directly, + // with no directory creation of its own (unlike `writeEncryptedEnvFile`, + // which does). + mkdirSync(path, { recursive: true }); + const fm = new FileManager(path); + fm.writeKeepFile(keep); + fm.ensureCapyGitignore(); + return { ok: true, projectId: initResult.project_id, created: true }; + } catch (err) { + return { ok: false, code: codes.initFailed, message: err instanceof Error ? err.message : String(err) }; + } +} + +/** The entries of `encrypted` that `encryptionKey` decrypts; the rest (the person lacks variable-level permission) are skipped. */ +function decryptableEntries(fm: FileManager, encrypted: Readonly>, encryptionKey: string): Record { + return Object.fromEntries( + Object.entries(encrypted).flatMap(([k, v]): ReadonlyArray => { + try { + return [[k, fm.decryptValue(v, encryptionKey)]]; + } catch { + return []; + } + }), + ); +} + +/** + * Bootstrap an EXISTING project into (repoDir, folder) — mirrors + * `capyCommand.ts#bootstrapExistingProject` exactly (pull `development`'s + * keep.json + env blob, decrypt, write keep.lock + `.env`), scoped to one + * folder's own `pm`/`fm` rather than `process.cwd()`, and — unlike the + * original — branching on `err.details?.status === 404` alone rather than + * ALSO matching a `/No secrets/i` message (Rule 5: never branch on + * human-readable text; the status code alone already says "no snapshot"). + */ +async function bootstrapExistingProjectSafe( + ctx: DiscoveryContext, + pm: ProjectManager, + fm: FileManager, + project: { id: string; name: string; organization_id: string }, +): Promise { + const branch = 'development'; + const keyOps = { + coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), + wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), + }; + const encryptionKey = await resolveProjectKey(project.organization_id, project.id, ctx.userId, keyOps); + + const decryptData = await ctx.serviceClient.getDecryptData(project.id, branch, undefined, true).catch((err) => { + if ((err as { details?: { status?: number } })?.details?.status === 404) { + return { env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }; + } + throw err; + }); + + const stub: KeepFile = { version: '3.0', org_id: project.organization_id, project_id: project.id, project_name: project.name, variables: {} }; + const serverKeep: KeepFile = decryptData.keep_file + ? { ...(JSON.parse(decryptData.keep_file) as KeepFile), org_id: project.organization_id, project_id: project.id, project_name: project.name } + : stub; + + const plaintext: Record = decryptData.env_content + ? decryptableEntries(fm, fm.parseEnvContent(decryptData.env_content), encryptionKey) + : {}; + + fm.writeKeepFile(serverKeep); + pm.writeActiveBranch(branch); + fm.ensureCapyGitignore(); + fm.writeEncryptedEnvFile(plaintext, encryptionKey, undefined, serverKeep, branch); +} + +/** + * The SAME dirty-working-tree guard `capy checkout` enforces + * (`checkoutCommand.ts#findDirtyBranchIssue`), run ONCE per folder before + * its FIRST checkout — never per environment, and never for a folder whose + * project was just created this run (nothing to be dirty about yet). A + * dirty folder aborts with zero writes; every later checkout in the SAME + * folder just switched away from a branch this run itself wrote, which is + * clean by construction, so re-checking per environment would be pure + * overhead for a result that can't change mid-run. + */ +async function checkFolderDirtySafe( + ctx: DiscoveryContext, + repoDir: string, + folder: string, + projectId: string, + codes: DiscoveryCodes, +): Promise<{ ok: true } | { ok: false; code: string; message: string }> { + const path = folderPath(repoDir, folder); + const pm = new ProjectManager(path); + const fm = new FileManager(path); + const keyOps = { + coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), + wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), + }; + const tryResolveKey = async (): Promise<{ ok: true; value: string } | { ok: false; message: string }> => { + try { + return { ok: true, value: await resolveProjectKey(ctx.orgId, projectId, ctx.userId, keyOps) }; + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) }; + } + }; + const keyResult = await tryResolveKey(); + if (!keyResult.ok) { + return { ok: false, code: codes.keyResolutionFailed, message: keyResult.message }; + } + const issue = findDirtyBranchIssue(pm, fm, keyResult.value); + if (!issue) return { ok: true }; + const detail = issue.code === 'UNCOMMITTED_CHANGES' ? `an uncommitted change (${issue.varName})` : 'unpushed changes'; + return { + ok: false, + code: codes.folderDirty, + message: `${folder || '.'} has ${detail} on "${issue.branch}". Run \`capy\` there to push, or discard the change, then re-run discovery.`, + }; +} + +/** + * Switches (repoDir, folder) onto `branchName` — pulls it if it already + * exists on the server (same as `capy checkout `), or creates it, + * seeded from the folder's CURRENT `.env` (same as `capy checkout -b + * ` — the git-model copy), otherwise. Mirrors + * `checkoutCommand.ts#_execute`'s core — including its dirty-working-tree + * guard, run ONCE per folder before the first call to this function (see + * `DiscoverySequenceDeps.checkFolderDirty`; every LATER checkout in the same + * folder just switched away from a branch this run itself wrote, which is + * clean by construction) — scoped to ONE folder and never `process.exit`s; + * a failure comes back as `{ ok: false }` so the caller can abort just this + * folder. `createBranch(..., false)`: a discovery-created branch is never + * protected — there is no flow yet for discovery to ask "should this be + * invite-only", so it always defaults to the same open access every other + * branch this run creates gets. + */ +async function checkoutBranchSafe( + ctx: DiscoveryContext, + repoDir: string, + folder: string, + projectId: string, + branchName: string, + codes: DiscoveryCodes, +): Promise<{ ok: true; created: boolean } | { ok: false; code: string; message: string }> { + const path = folderPath(repoDir, folder); + const pm = new ProjectManager(path); + const fm = new FileManager(path); + try { + const keyOps = { + coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), + wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), + }; + const encryptionKey = await resolveProjectKey(ctx.orgId, projectId, ctx.userId, keyOps); + + const branches = await ctx.serviceClient.listBranches(projectId); + const created = !branches.some((b) => b.name === branchName); + if (created) { + await ctx.serviceClient.createBranch(projectId, branchName, false); + } + + const decryptResult = await (async (): Promise< + | { ok: true; data: Awaited> } + | { ok: false; code: string; message: string } + > => { + try { + return { ok: true, data: await ctx.serviceClient.getDecryptData(projectId, branchName, undefined, true) }; + } catch (err) { + const status = (err as { details?: { status?: number } })?.details?.status; + if (status === 404) { + return { ok: true, data: { env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() } }; + } + if (status === 403) { + return { ok: false, code: codes.branchProtected, message: `"${branchName}" is a protected branch — access is invite-only.` }; + } + throw err; // an anomaly — let the outer catch report it as `codes.checkoutFailed`. + } + })(); + if (!decryptResult.ok) return decryptResult; + const decryptData = decryptResult.data; + + const existingKeep = pm.readKeepFile(); + if (!existingKeep) { + return { ok: false, code: codes.keepMissing, message: `${folder || '.'} has no keep.lock.` }; + } + // Splice in only THIS branch's server pins — keep.lock holds every + // branch's metadata and is git-owned; a sibling branch's pins must not + // move just because this one was checked out. + const keepForWrite: KeepFile = decryptData.keep_file + ? SyncEngine.spliceKeepBranch(existingKeep, JSON.parse(decryptData.keep_file) as KeepFile, branchName) + : existingKeep; + if (keepForWrite !== existingKeep) fm.writeKeepFile(keepForWrite); + + if (decryptData.env_content) { + const decrypted = decryptableEntries(fm, fm.parseEnvContent(decryptData.env_content), encryptionKey); + fm.writeEncryptedEnvFile(decrypted, encryptionKey, undefined, keepForWrite, branchName); + } else if (created) { + // Git model: a brand-new branch inherits the folder's CURRENT `.env` — + // the exact seed `capy checkout -b` performs. Unreadable current + // `.env` (or none yet) falls through to an empty seed. + const tryReadCurrentEnv = (): Record => { + try { + return fm.readEncryptedEnvFile(encryptionKey); + } catch { + return {}; + } + }; + fm.writeEncryptedEnvFile(tryReadCurrentEnv(), encryptionKey, undefined, keepForWrite, branchName); + } else { + // An EXISTING branch with no snapshot yet: stamp an empty file for it. + fm.writeEncryptedEnvFile({}, encryptionKey, undefined, keepForWrite, branchName); + } + + pm.writeActiveBranch(branchName); + return { ok: true, created }; + } catch (err) { + return { ok: false, code: codes.checkoutFailed, message: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Runs the single-service import itself (`importFn` — the very function + * `import()` above defines, reused directly rather than going through + * `ConnectCommand`: that class's `executeImport` prints its own terminal/ + * JSON lines as a side effect, which would corrupt discovery's OWN single + * JSON line under `--json`, and `resolveContext()` re-authenticates and + * re-reads `process.cwd()` rather than accepting an already-resolved + * folder + session) against the branch `checkoutBranchSafe` just switched + * onto, then writes the result via the SAME `writeImportOutcome` helper + * `executeImport` uses — same push, same skip-auto-commit, same + * clear-pruning for `--overwrite`. + */ +async function importIntoBranchSafe( + ctx: DiscoveryContext, + importFn: (ctx: ResolvedContext, opts: ConnectOpts) => Promise, + repoDir: string, + folder: string, + env: DiscoveryPlanEnv, + overwrite: boolean, + runOpts: { + noPush: boolean; + interactive: boolean; + json: boolean; + yes: boolean; + gate: GateOptions; + /** The provider supplies the per-step import options itself (its source, its token, the names already known from its plan). */ + importOptsFor: (env: DiscoveryPlanEnv, projectName: string, folderPath: string) => ConnectOpts; + /** How this provider spells the refusals. */ + codes: DiscoveryCodes; + }, + /** This environment's provider PROJECT name — already known from the plan's own read, threaded through so `import()` never re-fetches it. */ + projectName: string, +): Promise { + const path = folderPath(repoDir, folder); + const pm = new ProjectManager(path); + const fm = new FileManager(path); + + const projectState = await pm.detectProjectState(); + if (!projectState.initialized || !projectState.organizationId || !projectState.projectId || !projectState.activeBranch) { + return { ok: false, code: runOpts.codes.keepMissing, message: `${folder || '.'} is not on a branch after checkout.` }; + } + const keep = pm.readKeepFile(); + if (!keep) { + return { ok: false, code: runOpts.codes.keepMissing, message: `${folder || '.'} has no keep.lock.` }; + } + + const keyOps = { + coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), + wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), + }; + const tryResolveProjectKey = async (): Promise<{ ok: true; value: string } | { ok: false; message: string }> => { + try { + return { ok: true, value: await resolveProjectKey(projectState.organizationId!, projectState.projectId!, ctx.userId, keyOps) }; + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) }; + } + }; + const projectKeyResult = await tryResolveProjectKey(); + if (!projectKeyResult.ok) { + return { ok: false, code: runOpts.codes.keyResolutionFailed, message: projectKeyResult.message }; + } + const projectKey = projectKeyResult.value; + + const localPlaintext: Record = Object.fromEntries( + Object.entries(fm.readEnvFile()).flatMap(([k, v]): ReadonlyArray => { + if (!v.startsWith('capy:')) return [[k, v]]; + try { + return [[k, fm.decryptValue(v, projectKey)]]; + } catch { + // Skip undecryptable. + return []; + } + }), + ); + + const resolvedCtx: ResolvedContext = { + pm, + fileManager: fm, + authService: ctx.authService, + serviceClient: ctx.serviceClient, + orgId: projectState.organizationId, + projectId: projectState.projectId, + branch: projectState.activeBranch, + userId: ctx.userId, + projectKey, + keep, + localPlaintext, + // This folder's own gate: if Capy refuses the upload as stale, bare `capy` runs again IN THIS FOLDER and the write retries. + gate: { ...runOpts.gate, cwd: path, throwConflicts: true }, + }; + const sharedImportOpts = { + overwrite, + noPush: runOpts.noPush, + nonTty: !runOpts.interactive, + json: runOpts.json, + yes: runOpts.yes, + }; + const importOpts: ConnectOpts = { ...sharedImportOpts, ...runOpts.importOptsFor(env, projectName, path) }; + + const outcome = await importFn(resolvedCtx, importOpts); + if (!outcome.ok) return outcome; + + // A push failure here must become THIS folder's coded failure, never an + // uncaught rejection — `runDiscoverySequences` awaits one folder's + // sequence at a time, so letting this throw would abort every folder + // still queued behind this one, not just this one. + try { + await writeImportOutcome(resolvedCtx, outcome, { push: !runOpts.noPush, dryRun: false }); + } catch (err) { + // A refused-as-stale upload keeps its own code; anything else is the push failing. + return { ok: false, code: pushFailureCode(err, runOpts.codes), message: err instanceof Error ? err.message : String(err) }; + } + return outcome; +} + +/** + * The coded reason a folder's upload failed: a difference found by the retry's bare `capy` and a second stale refusal keep + * their own codes. For a provider that reports the neutral codes (`DiscoveryCodes.keepsServiceCode`), a typed error from + * the service (`PERMISSION_DENIED`, `QUOTA_EXCEEDED`, …) keeps its own code too, instead of being flattened to the push code; + * the generic `SERVICE_ERROR` and an untyped error are the push code. The decision reads the code, never the message. + */ +function pushFailureCode(err: unknown, codes: DiscoveryCodes): string { + if (isConflictRefusal(err)) return err.code; + if (isStaleBase(err)) return ERROR_CODES.SECRETS_STALE_BASE; + if (codes.keepsServiceCode && err instanceof CapyError && err.code !== ERROR_CODES.SERVICE_ERROR) return err.code; + return codes.pushFailed; +} + +/** + * The REAL `DiscoverySequenceDeps`, wired to an already-authenticated `ctx` + * — the discovery scan's own org/session, reused for every folder rather + * than re-authenticating per folder. + */ +export function buildRealDiscoverySequenceDeps( + ctx: DiscoveryContext, + importFn: (ctx: ResolvedContext, opts: ConnectOpts) => Promise, + runOpts: { + noPush: boolean; + interactive: boolean; + json: boolean; + yes: boolean; + /** The provider's per-step import options (see `importIntoBranchSafe`). */ + importOptsFor: (env: DiscoveryPlanEnv, projectName: string, folderPath: string) => ConnectOpts; + /** How this provider spells the refusals. */ + codes: DiscoveryCodes; + /** How bare `capy` runs in each discovered folder (discovery never prompts: it answers by protocol). */ + gate: GateOptions; + /** Runs bare `capy` in one folder: the real `syncFolder` unless a test supplies its own. */ + syncFolder: (dir: string, gate: GateOptions) => Promise; + askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; + askProjectName: (defaultName: string) => Promise; + /** + * The non-interactive `--yes` auto-create snapshot (see + * `ensureProjectSafe`'s doc) — taken ONCE by `discover()` itself, before + * `runDiscoverySequences` starts, and handed to EVERY folder's own + * `ensureProject` call unchanged, so a project one folder creates this + * run never alters a later folder's "org has none" answer. `undefined` + * when this run's mode never needs it (interactive, or non-interactive + * without `--yes`). + */ + existingProjectsSnapshot?: ExistingProjectsSnapshot; + /** + * This run's OWN pure precomputation (see `computeSameRunNameCollisions`) + * of "which candidate folders' default project name collides with an + * EARLIER folder in this same run" — keyed by `sameRunFolderKey`. Passed + * through unchanged to every folder's own `ensureProject` call, same + * shape as `existingProjectsSnapshot` above. + */ + sameRunNameCollisions: ReadonlySet; + }, +): DiscoverySequenceDeps { + return { + syncFolder: (repoDir, folder) => runOpts.syncFolder(folderPath(repoDir, folder), runOpts.gate), + ensureProject: (repoDir, folder, remote) => + ensureProjectSafe(ctx, repoDir, folder, remote, { + interactive: runOpts.interactive, + yes: runOpts.yes, + askExistingOrNewProject: runOpts.askExistingOrNewProject, + askProjectName: runOpts.askProjectName, + existingProjectsSnapshot: runOpts.existingProjectsSnapshot, + sameRunNameCollision: runOpts.sameRunNameCollisions.has(sameRunFolderKey(repoDir, folder)), + codes: runOpts.codes, + }), + checkFolderDirty: (repoDir, folder, projectId) => checkFolderDirtySafe(ctx, repoDir, folder, projectId, runOpts.codes), + checkoutBranch: (repoDir, folder, projectId, branchName) => checkoutBranchSafe(ctx, repoDir, folder, projectId, branchName, runOpts.codes), + importIntoBranch: (repoDir, folder, env, overwrite, projectName) => + importIntoBranchSafe(ctx, importFn, repoDir, folder, env, overwrite, runOpts, projectName), + }; +} diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index d656e602..5421b2e3 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -32,8 +32,6 @@ * how you refresh it, and `capy rotate` refuses on a dokploy-managed var * (see `rotateCommand.ts`). */ -import { mkdirSync } from 'node:fs'; -import { join } from 'node:path'; import { TargetConfig } from '../../deploy/adapter'; import { dokployKeyHelp, keyHelpFields } from '../../deploy/keyHelp'; import { baseUrlProblem } from '../../deploy/adapters/dokploy'; @@ -53,39 +51,39 @@ import { listImportableEntries, resolveDokployApiKey, } from '../../deploy/dokployApi'; -import { ConnectorMetadata, ERROR_CODES, KeepFile } from '../../types/index'; -import { isStaleBase } from '../../sync/commit'; +import { ConnectorMetadata, ERROR_CODES } from '../../types/index'; import { isInteractive } from '../../ui/interactive'; -import { ProjectManager } from '../../core/projectManager'; -import { FileManager } from '../../files/fileManager'; -import { resolveProjectKey } from '../../crypto/keyResolver'; -import { SyncEngine } from '../../sync/syncEngine'; -import { assertProjectNameAllowed, isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../../system/reservedProjectName'; -import { findDirtyBranchIssue } from '../checkoutCommand'; import { listOrgProjectsOrUnavailable } from '../capyCommand'; -import { commitDiscoveryChanges, defaultDiscoveryCommitBranchName, isValidDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; +import { defaultDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; import { BaseUrlStore, Unanswered, offerSaveAskedBaseUrl, realBaseUrlStoreOpener, resolveDokployBaseUrl, settingsUnanswered } from '../../deploy/dokployBaseUrl'; import { syncFolder, type FolderSync, type GateOptions } from '../../sync/gate'; -import { isConflictRefusal } from '../../sync/conflicts'; -import { fingerprint, writeImportedAndSync, writeImportOutcome, ResolvedContext } from './shared'; +import { fingerprint, ResolvedContext } from './shared'; import { ConnectOpts, ConnectorModule, ConnectResult, ImportOutcome, ImportWarning, RotateResult } from './registry'; -import { discoveryProjectQuestion } from '../../ui/projectQuestions'; +import { DOKPLOY_DISCOVERY_CODES } from './discoveryCodes'; +import { + ExistingProjectsSnapshot, + OverwritePlan, + buildRealDiscoverySequenceDeps, + commitForAppliedRepos, + commitPreviewForFolders, + computeOverwritePlan, + computeSameRunNameCollisions, + defaultAskDiscoveryCommitBranchName, + defaultAskDiscoveryProjectName, + defaultAskExistingOrNewProject, + defaultConfirm, + defaultPickCollisionWinner, + discoveryCommitPathsFor, + peekLocalKeepForFolder, +} from './discoverySteps'; import { - CandidateRepo, - DISCOVERY_PROJECT_NAME_MAX_LENGTH, DiscoveryCollision, DiscoveryContext, - DiscoveryFolderResult, DiscoveryOutcome, DiscoveryPlan, - DiscoveryPlanEnv, DiscoveryPlanFolder, - DiscoveryRepoCommitResult, - DiscoverySequenceDeps, - GitRemoteRef, applyCollisionResolutions, buildDiscoveryPlan, - defaultDiscoveryProjectName, findCandidateReposResult, orderEnvironments, resolveCollisions, @@ -443,12 +441,6 @@ export interface DokployConnectorDeps { getConnectorSecret?: (name: string, opts: DokploySystemStoreCallOptions) => Promise; } -export async function defaultConfirm(message: string, defaultValue: boolean): Promise { - const inquirer = (await import('inquirer')).default; - const { ok } = await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: defaultValue }]); - return ok; -} - async function defaultSelectVars(candidates: readonly string[]): Promise { if (candidates.length === 0) return []; const inquirer = (await import('inquirer')).default; @@ -544,91 +536,6 @@ async function defaultAskDiscoveryBaseUrl(): Promise { return baseUrl; } -/** Sentinel `askExistingOrNewProject` returns to mean "create a new project", never a real project id. */ -export const DISCOVERY_NEW_PROJECT = '__capy_discovery_new_project__'; - -// COPY-FLAG: new user-facing string, minimal/neutral wording. -export async function defaultAskExistingOrNewProject( - existing: ReadonlyArray<{ id: string; name: string }>, - defaultName: string, -): Promise { - const inquirer = (await import('inquirer')).default; - const { projectChoice } = await inquirer.prompt([ - discoveryProjectQuestion(existing, defaultName, DISCOVERY_NEW_PROJECT), - ]); - return projectChoice; -} - -// COPY-FLAG: new user-facing string, minimal/neutral wording. -/** - * Validates a discovery project-name answer: non-empty, at most - * `DISCOVERY_PROJECT_NAME_MAX_LENGTH` chars, not the reserved `_system` name - * (Vince, 2026-09-26, decision #4). Deliberately no charset restriction — - * `/` (and anything else `defaultDiscoveryProjectName` might produce) must - * stay allowed here; that's a separate concern from this ticket's scope. - */ -function discoveryProjectNameProblem(trimmed: string): string | null { - if (!trimmed) return 'A project name is required.'; - if (trimmed.length > DISCOVERY_PROJECT_NAME_MAX_LENGTH) { - return `Project name must be ${DISCOVERY_PROJECT_NAME_MAX_LENGTH} characters or fewer.`; - } - if (isReservedProjectName(trimmed)) return PROJECT_NAME_RESERVED_MESSAGE; - return null; -} - -export async function defaultAskDiscoveryProjectName(defaultName: string): Promise { - const inquirer = (await import('inquirer')).default; - const { name } = await inquirer.prompt([ - { - type: 'input', - name: 'name', - message: 'New Capy project name:', - default: defaultName, - filter: (v: string) => v.trim(), - // Re-asks automatically (inquirer's own validate loop) rather than - // ever reaching `ensureProjectSafe` with a name it would refuse - // `DOKPLOY_PROJECT_NAME_TOO_LONG`/`PROJECT_NAME_RESERVED` for. - validate: (v: string) => discoveryProjectNameProblem(v.trim()) ?? true, - }, - ]); - return name; -} - -// COPY-FLAG: new user-facing string, minimal/neutral wording. -export async function defaultAskDiscoveryCommitBranchName(defaultName: string): Promise { - const inquirer = (await import('inquirer')).default; - const { name } = await inquirer.prompt([ - { - type: 'input', - name: 'name', - message: 'Commit the import to which git branch:', - default: defaultName, - filter: (v: string) => v.trim(), - // Re-asks automatically (inquirer's own validate loop) rather than - // ever reaching `commitDiscoveryChanges` with a name it would refuse - // `DOKPLOY_COMMIT_BRANCH_INVALID` for — that refusal is still the - // authoritative, zero-mutation-guaranteed check for every OTHER - // path into this function (non-interactive, an injected test dep). - validate: (v: string) => (isValidDiscoveryCommitBranchName(v.trim()) ? true : 'Not a valid git branch name.'), - }, - ]); - return name; -} - -// COPY-FLAG: new user-facing string, minimal/neutral wording. -export async function defaultPickCollisionWinner(collision: DiscoveryCollision): Promise { - const inquirer = (await import('inquirer')).default; - const { winner } = await inquirer.prompt([ - { - type: 'list', - name: 'winner', - message: `${collision.folder || '.'} / ${collision.environmentName}: which service feeds this branch?`, - choices: collision.candidates.map((c) => ({ name: `${c.projectName} / ${c.serviceName}`, value: c.serviceId })), - }, - ]); - return winner; -} - /** `--var` on `capy connect dokploy`: comma-separated names, restricting the import. */ function parseVarRestriction(raw: string | undefined): readonly string[] | null { if (!raw) return null; @@ -639,46 +546,6 @@ function parseVarRestriction(raw: string | undefined): readonly string[] | null return names.length > 0 ? names : null; } -// ── --overwrite ────────────────────────────────────────────────────────────── - -export interface OverwritePlan { - /** Local names Dokploy no longer has, and that aren't a reference value — removed entirely. */ - toClear: readonly string[]; - /** Present in both with a DIFFERENT value — overwritten with Dokploy's. */ - toReplace: ReadonlyArray<{ name: string; value: string }>; - /** Only in Dokploy — a plain new write. */ - toImport: ReadonlyArray<{ name: string; value: string }>; - /** Present in both with the SAME value — no-op. */ - unchanged: readonly string[]; -} - -/** - * `--overwrite`'s own rule: the branch's vars become EXACTLY Dokploy's - * importable set. `localPlaintext` names absent from Dokploy's set are - * cleared — EXCEPT a reference-valued name (`${{...}}`, `referenceNames`): - * there is no resolved value to replace it with, so it is left alone rather - * than cleared (the caller's `DOKPLOY_REFERENCE_VALUE` warning already says - * so). `dokployCandidates` must be Dokploy's FULL importable set, never one - * already narrowed by `--var` — narrowing it here would make a restricted - * overwrite clear a name Dokploy still has, just outside the restriction - * (see the call site in `import()`, which computes this from the - * unrestricted list on purpose). - */ -export function computeOverwritePlan( - dokployCandidates: ReadonlyArray<{ name: string; value: string }>, - referenceNames: readonly string[], - localPlaintext: Readonly>, -): OverwritePlan { - const dokployNames = new Set(dokployCandidates.map((c) => c.name)); - const referenceSet = new Set(referenceNames); - return { - toClear: Object.keys(localPlaintext).filter((k) => !dokployNames.has(k) && !referenceSet.has(k)), - toReplace: dokployCandidates.filter((c) => localPlaintext[c.name] !== undefined && localPlaintext[c.name] !== c.value), - toImport: dokployCandidates.filter((c) => localPlaintext[c.name] === undefined), - unchanged: dokployCandidates.filter((c) => localPlaintext[c.name] === c.value).map((c) => c.name), - }; -} - // COPY-FLAG: new user-facing string, minimal/neutral wording. function overwriteConfirmMessage( plan: OverwritePlan, @@ -1124,7 +991,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect } const client = createDokployClient(resolvedBaseUrl, resolved.value, deps.fetch); - const scan = findCandidateReposResult(cwd()); + const scan = findCandidateReposResult(cwd(), DOKPLOY_DISCOVERY_CODES); const environmentFilter = parseVarRestriction(opts.environment) ?? undefined; const planCore = await buildDiscoveryPlan( client, @@ -1151,7 +1018,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect if (dryRun) { // Vince's rule: a dry run changes nothing — never asks for a // branch name either; the DEFAULT is what it previews. - const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName()); + const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(), DOKPLOY_DISCOVERY_CODES); return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}), ...baseUrlReport }; } @@ -1260,8 +1127,15 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect interactive: promptable, json: !!opts.json, yes: !!opts.yes, - baseUrl: resolvedBaseUrl, - tokenEnv, + codes: DOKPLOY_DISCOVERY_CODES, + importOptsFor: (env, projectName) => ({ + ...(env.serviceKind === 'application' ? { application: env.serviceId } : { compose: env.serviceId }), + baseUrl: resolvedBaseUrl, + tokenEnv, + // Already known from the plan — see `dokployProjectName`'s doc. + dokployProjectName: projectName, + dokployEnvironmentName: env.environmentName, + }), // Discovery never prompts: a folder that differs from Capy is reported (its rows and `conflict_id`), not asked. gate: { devMode: opts.devMode, json: true, nonTty: true, conflictFlags: opts.conflictFlags }, syncFolder: deps.syncFolder ?? syncFolder, @@ -1279,7 +1153,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect // whether or not `--no-push` was passed — keep.lock/.env are written // locally either way (see `writeImportOutcome`), so there is always // something local for this step to pin. - const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName); + const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, DOKPLOY_DISCOVERY_CODES); return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}), ...baseUrlReport }; }, @@ -1287,11 +1161,6 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect return connector; } -/** Plan-time-only local keep.lock read (no auth) — see `PeekLocalKeep`'s own doc. */ -export function peekLocalKeepForFolder(repoDir: string, folder: string): KeepFile | null { - return new ProjectManager(folder ? join(repoDir, folder) : repoDir).readKeepFile(); -} - /** * The plan-level "proceed at all?" prompt's own message — the EXACT command * sequence per folder (never a clear/replace/import COUNT: discovery itself @@ -1319,703 +1188,6 @@ function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwr return [header, ...rows].join('\n'); } -const folderPath = (repoDir: string, folder: string): string => (folder ? join(repoDir, folder) : repoDir); - -/** keep.lock and .gitignore, relative to `repoDir` — the ONLY paths discovery's own commit step ever touches. */ -export function discoveryCommitPathsFor(folder: string): readonly string[] { - const prefix = folder ? `${folder}/` : ''; - return [`${prefix}keep.lock`, `${prefix}.gitignore`]; -} - -/** - * Every REPO the plan touches, and the branch/files a real run WOULD commit - * for it — dry-run preview only, never runs a git-mutating command (see - * `commitDiscoveryChanges`'s own `dryRun` handling). - * - * `paths` is de-duplicated per repo (cosmetic defect fix): two DISTINCT - * `DiscoveryPlanFolder` entries can share the same (repoDir, folder) — e.g. - * two colliding services that both resolve to the same folder, still shown - * as separate candidates in the pre-resolution `plan.folders` a dry run - * previews (a dry run never asks for a collision winner) — and - * `discoveryCommitPathsFor` would otherwise be counted once per entry, - * reporting `keep.lock`/`.gitignore` twice for what is really one write. - */ -export function commitPreviewForFolders( - folders: readonly DiscoveryPlanFolder[], - branchName: string, - /** The commit subject; default is the Dokploy one. */ - commitMessage?: string, -): readonly DiscoveryRepoCommitResult[] { - const repoDirs = [...new Set(folders.map((f) => f.repoDir))]; - return repoDirs.map((repoDir) => { - const paths = [...new Set(folders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)))]; - const outcome = commitDiscoveryChanges(repoDir, paths, new Set(), { branchName, dryRun: true, summaryLines: [], ...(commitMessage ? { commitMessage } : {}) }); - return { repoDir, ...outcome }; - }); -} - -/** - * The commit step's real run: per repo touched by `applied`, commits ONLY - * the SUCCESSFULLY-applied folders' keep.lock/.gitignore (see - * `commitDiscoveryChanges`'s own doc for the refusal codes) — a repo with - * zero successful folders is skipped entirely (nothing to commit, no - * refusal either). - */ -export function commitForAppliedRepos( - applied: readonly DiscoveryFolderResult[], - beforeStatusByRepo: ReadonlyMap>, - branchName: string, - /** The commit subject; default is the Dokploy one. */ - commitMessage?: string, -): readonly DiscoveryRepoCommitResult[] { - const repoDirs = [...new Set(applied.map((f) => f.repoDir))]; - return repoDirs.flatMap((repoDir) => { - const okFolders = applied.filter((f) => f.repoDir === repoDir && f.ok); - if (okFolders.length === 0) return []; - const paths = okFolders.flatMap((f) => discoveryCommitPathsFor(f.folder)); - const summaryLines = okFolders.map((f) => `- ${f.folder || '.'}: ${(f as Extract).activeBranch}`); - const outcome = commitDiscoveryChanges(repoDir, paths, beforeStatusByRepo.get(repoDir) ?? new Set(), { - branchName, - dryRun: false, - summaryLines, - ...(commitMessage ? { commitMessage } : {}), - }); - return [{ repoDir, ...outcome }]; - }); -} - -/** What `listOrgProjectsOrUnavailable` returns — named here so the non-interactive `--yes` snapshot (see `ensureProjectSafe`'s doc) and the interactive per-folder lookup share one type. */ -export type ExistingProjectsSnapshot = { - existingProjects: ReadonlyArray<{ id: string; name: string; organization_id: string }>; - projectsUnavailable: boolean; -}; - -/** A project name, normalized for EQUALITY comparison only (never for storage/display) — trimmed, then lower-cased, so `" Widgets "` and `widgets` compare equal (Vince, 2026-09-26 decision). */ -function normalizeProjectName(name: string): string { - return name.trim().toLowerCase(); -} - -/** `DOKPLOY_PROJECT_NAME_EXISTS`'s message — shared by the org-snapshot collision and the same-run collision below, since a human reads the same instruction either way. */ -// COPY-FLAG: new user-facing string, minimal/neutral wording. -function dokployProjectNameExistsMessage(folder: string, name: string): string { - return `${folder || '.'}: a project named "${name}" already exists — reuse or duplicate is a human choice; run interactively or \`capy\` in ${folder || '.'}.`; -} - -/** Stable per-(repoDir, folder) key — mirrors `dokployDiscovery.ts`'s own `groupKey` shape (`\u0000`-joined, so neither part can collide with the separator). */ -function sameRunFolderKey(repoDir: string, folder: string): string { - return `${repoDir}\u0000${folder}`; -} - -/** - * Pure, run-once precomputation of "two folders in this SAME run would try - * to create a project under the SAME default name" (Vince, 2026-09-26 - * decision) — computed from `resolvedFolders` alone, entirely before any - * folder's own `ensureProject` call runs, so no mutable "names created so - * far" state needs to be threaded through the sequential per-folder loop - * (`runDiscoverySequences`'s `ensureProject` dependency has no such - * accumulator parameter, and this file's own immutability rule forbids - * inventing one via a mutated `Set`/`Map`). - * - * Only folders WITHOUT a local keep.lock are candidates (`!f.initialized`) — - * an already-initialized folder never creates anything, so it can never - * collide with, or be collided into by, another folder's create. Folded over - * `folders` in their OWN array order — the exact order `runDiscoverySequences` - * processes them in — so "the first one wins" here means the same thing it - * means at run time. Returns the set of `sameRunFolderKey`s for every folder - * that is NOT the first candidate to claim its (normalized) default name; - * the very first claimant of a name is never in the returned set. - */ -export function computeSameRunNameCollisions(folders: readonly DiscoveryPlanFolder[]): ReadonlySet { - const candidates = folders - .filter((f) => !f.initialized) - .map((f) => ({ key: sameRunFolderKey(f.repoDir, f.folder), normalizedName: normalizeProjectName(defaultDiscoveryProjectName(f.repoDir, f.folder, f.remote)) })); - - return candidates.reduce( - (acc, c) => - acc.seenNames.has(c.normalizedName) - ? { seenNames: acc.seenNames, collisionKeys: new Set([...acc.collisionKeys, c.key]) } - : { seenNames: new Set([...acc.seenNames, c.normalizedName]), collisionKeys: acc.collisionKeys }, - { seenNames: new Set(), collisionKeys: new Set() }, - ).collisionKeys; -} - -/** - * No keep.lock at (repoDir, folder) → interactive: reuses `capy`'s own - * existing-project-picking (list the org's projects, ask new-vs-existing — - * see `defaultAskExistingOrNewProject`), never invents its own project flow. - * A keep.lock already there is just read for its project id — no network - * call. - * - * Non-interactive WITHOUT `--yes` refuses `DOKPLOY_FOLDER_NOT_INITIALIZED` - * naming the folder and never auto-creates — unchanged. - * - * Non-interactive WITH `--yes` (Vince, 2026-09-26 decision, SUPERSEDING the - * original "org has none" rule): creates a new project named `defaultName` - * UNLESS a project with that EXACT name (trimmed, compared case-insensitively - * — see `normalizeProjectName`) already exists — an UNRELATED existing - * project in the org (e.g. a stray empty `root`) no longer blocks anything. - * A same-name collision refuses `DOKPLOY_PROJECT_NAME_EXISTS`, naming both - * the project and the folder — reusing or duplicating an existing project is - * a genuine human choice with no safe default, same reasoning the old rule - * used for "org has ANY project", now narrowed to "org has THIS name". - * `opts.existingProjectsSnapshot` is this run's OWN snapshot (see - * `buildRealDiscoverySequenceDeps`'s doc): taken ONCE, before any folder in - * this run starts, so a project an EARLIER folder in this same run created - * is never counted as "existing" when deciding a LATER folder's own - * name-collision answer. A snapshot that failed to load is - * `DOKPLOY_PROJECT_LOOKUP_FAILED`, same as the interactive branch below and - * same as before this decision. - * - * The snapshot alone cannot catch two DIFFERENT folders in this SAME run - * both defaulting to the same name (neither one is "existing" yet when the - * snapshot was taken) — `opts.sameRunNameCollision` is `ensureProjectSafe`'s - * OWN pure precomputed answer to exactly that (see - * `computeSameRunNameCollisions`), and refuses the SAME - * `DOKPLOY_PROJECT_NAME_EXISTS` code for whichever folder is the SECOND (or - * later) occurrence in run order; the first occurrence proceeds normally. - * - * Deliberately NOT a call into `CapyCommand` itself: that class calls - * `process.exit()` on several failure paths and always builds its own - * `ProjectManager()`/`FileManager()` against `process.cwd()`, neither of - * which a multi-folder run can tolerate (one folder's exit would kill every - * other folder still to run, and there is no way to point it at a folder - * other than `cwd`). This reuses the SAME underlying `ServiceClient` calls - * (`listProjects`, `initializeProject`) and mirrors `capyCommand.ts`'s own - * `bootstrapExistingProject` for the existing-project branch (see - * `bootstrapExistingProjectSafe`, below), scoped to one folder and returning - * a coded outcome instead of exiting. - */ -async function ensureProjectSafe( - ctx: DiscoveryContext, - repoDir: string, - folder: string, - /** This folder's repo's own parsed `origin`, when known — see `defaultDiscoveryProjectName`'s decision #2 doc; passed through rather than re-read here. */ - remote: GitRemoteRef | undefined, - opts: { - interactive: boolean; - /** Discovery's OWN `--yes` — skips the new-project NAME prompt (defaulting to `defaultName`) the same way it skips every other confirmation, never the existing-vs-new picker itself (that one isn't a confirmation, it's a genuine choice with no safe default) — EXCEPT non-interactively, where a same-name collision has no choice left to make (see this function's own doc). */ - yes: boolean; - askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; - askProjectName: (defaultName: string) => Promise; - /** This run's ONE pre-apply snapshot of the org's existing projects — only consulted on the non-interactive `--yes` auto-create path; `undefined` there means the snapshot itself failed to load. */ - existingProjectsSnapshot?: ExistingProjectsSnapshot; - /** This folder's own precomputed answer to "does an EARLIER folder in this SAME run already claim this default name" (see `computeSameRunNameCollisions`) — only consulted on the non-interactive `--yes` auto-create path. */ - sameRunNameCollision: boolean; - }, -): Promise<{ ok: true; projectId: string; created: boolean } | { ok: false; code: string; message: string }> { - const path = folderPath(repoDir, folder); - const pm = new ProjectManager(path); - const existing = pm.readKeepFile(); - if (existing) { - return { ok: true, projectId: existing.project_id, created: false }; - } - const defaultName = defaultDiscoveryProjectName(repoDir, folder, remote); - - if (!opts.interactive) { - if (!opts.yes) { - return { - ok: false, - code: 'DOKPLOY_FOLDER_NOT_INITIALIZED', - message: `${folder || '.'} has no keep.lock. Run \`capy\` there to initialize it, then re-run discovery.`, - }; - } - const snapshot = opts.existingProjectsSnapshot; - if (!snapshot || snapshot.projectsUnavailable) { - return { - ok: false, - code: 'DOKPLOY_PROJECT_LOOKUP_FAILED', - message: `${folder || '.'}: could not list this org's existing projects — refusing to offer "create new" blind.`, - }; - } - const sameNameProject = snapshot.existingProjects.find((p) => normalizeProjectName(p.name) === normalizeProjectName(defaultName)); - if (sameNameProject || opts.sameRunNameCollision) { - return { - ok: false, - code: 'DOKPLOY_PROJECT_NAME_EXISTS', - message: dokployProjectNameExistsMessage(folder, defaultName), - }; - } - return createNewProjectSafe(ctx, path, folder, defaultName); - } - - // A lookup failure is NOT "this org has no projects" — offering only - // "create new" on a transient network/auth error risks a duplicate - // project the human never asked for. Abort THIS folder instead (CAP-657 - // follow-up defect fix) — the SAME distinction `capy`'s own init makes - // (`listOrgProjectsOrUnavailable`, extracted from `capyCommand.ts`). - const { existingProjects, projectsUnavailable } = await listOrgProjectsOrUnavailable(ctx.serviceClient); - if (projectsUnavailable) { - return { - ok: false, - code: 'DOKPLOY_PROJECT_LOOKUP_FAILED', - message: `${folder || '.'}: could not list this org's existing projects — refusing to offer "create new" blind.`, - }; - } - const picked = await opts.askExistingOrNewProject(existingProjects.map((p) => ({ id: p.id, name: p.name })), defaultName); - - if (picked !== DISCOVERY_NEW_PROJECT) { - const project = existingProjects.find((p) => p.id === picked); - if (!project) { - return { ok: false, code: 'DOKPLOY_PROJECT_NOT_FOUND', message: `No project ${picked} in this org.` }; - } - try { - await bootstrapExistingProjectSafe(ctx, pm, new FileManager(path), project); - return { ok: true, projectId: project.id, created: false }; - } catch (err) { - return { ok: false, code: 'DOKPLOY_INIT_FAILED', message: err instanceof Error ? err.message : String(err) }; - } - } - // Mirrors `capyCommand.ts#resolveProjectName`: ask for the new project's - // name (default = `defaultName`). `--yes` skips the ask — same "proceed - // without asking" contract as every other discovery confirmation — and - // uses the default outright. - const chosenName = opts.yes ? defaultName : await opts.askProjectName(defaultName); - return createNewProjectSafe(ctx, path, folder, chosenName); -} - -/** - * Creates a brand-new project named `chosenName` and initializes (repoDir, - * folder) onto it — the exact write sequence the interactive "New project" - * choice runs (`ensureProjectSafe`'s own interactive branch), reused - * verbatim by the non-interactive `--yes` auto-create path (Vince, - * 2026-09-26 decision) so the two can never drift apart. - */ -async function createNewProjectSafe( - ctx: DiscoveryContext, - path: string, - folder: string, - chosenName: string, -): Promise<{ ok: true; projectId: string; created: boolean } | { ok: false; code: string; message: string }> { - // Vince, 2026-09-26, decision #4: refuse before any write when the name - // (default or human-typed) is over 255 chars — never truncated. The real - // `defaultAskDiscoveryProjectName` prompt validates this itself and - // re-asks (so an interactive human self-corrects before ever returning - // here); this is the zero-write guarantee for `--yes` and for any - // injected `askProjectName` test double that skips that validation. - if (chosenName.length > DISCOVERY_PROJECT_NAME_MAX_LENGTH) { - return { - ok: false, - code: 'DOKPLOY_PROJECT_NAME_TOO_LONG', - message: `${folder || '.'}: project name is longer than ${DISCOVERY_PROJECT_NAME_MAX_LENGTH} characters.`, - }; - } - try { - assertProjectNameAllowed(chosenName); - const initResult = await ctx.serviceClient.initializeProject(chosenName, ctx.orgId); - const keep: KeepFile = { - version: '3.0', - org_id: initResult.org_id, - project_id: initResult.project_id, - project_name: initResult.project_name, - variables: {}, - }; - // A discovered folder several levels deep (e.g. `backend/deployment`) - // may not exist yet under `repoDir` — `writeKeepFile` writes directly, - // with no directory creation of its own (unlike `writeEncryptedEnvFile`, - // which does). - mkdirSync(path, { recursive: true }); - const fm = new FileManager(path); - fm.writeKeepFile(keep); - fm.ensureCapyGitignore(); - return { ok: true, projectId: initResult.project_id, created: true }; - } catch (err) { - return { ok: false, code: 'DOKPLOY_INIT_FAILED', message: err instanceof Error ? err.message : String(err) }; - } -} - -/** - * Bootstrap an EXISTING project into (repoDir, folder) — mirrors - * `capyCommand.ts#bootstrapExistingProject` exactly (pull `development`'s - * keep.json + env blob, decrypt, write keep.lock + `.env`), scoped to one - * folder's own `pm`/`fm` rather than `process.cwd()`, and — unlike the - * original — branching on `err.details?.status === 404` alone rather than - * ALSO matching a `/No secrets/i` message (Rule 5: never branch on - * human-readable text; the status code alone already says "no snapshot"). - */ -async function bootstrapExistingProjectSafe( - ctx: DiscoveryContext, - pm: ProjectManager, - fm: FileManager, - project: { id: string; name: string; organization_id: string }, -): Promise { - const branch = 'development'; - const keyOps = { - coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), - }; - const encryptionKey = await resolveProjectKey(project.organization_id, project.id, ctx.userId, keyOps); - - const decryptData = await ctx.serviceClient.getDecryptData(project.id, branch, undefined, true).catch((err) => { - if ((err as { details?: { status?: number } })?.details?.status === 404) { - return { env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }; - } - throw err; - }); - - const stub: KeepFile = { version: '3.0', org_id: project.organization_id, project_id: project.id, project_name: project.name, variables: {} }; - const serverKeep: KeepFile = decryptData.keep_file - ? { ...(JSON.parse(decryptData.keep_file) as KeepFile), org_id: project.organization_id, project_id: project.id, project_name: project.name } - : stub; - - const plaintext: Record = {}; - if (decryptData.env_content) { - const encrypted = fm.parseEnvContent(decryptData.env_content); - for (const [k, v] of Object.entries(encrypted)) { - try { - plaintext[k] = fm.decryptValue(v, encryptionKey); - } catch { - // Skip undecryptable (user lacks variable-level permission). - } - } - } - - fm.writeKeepFile(serverKeep); - pm.writeActiveBranch(branch); - fm.ensureCapyGitignore(); - fm.writeEncryptedEnvFile(plaintext, encryptionKey, undefined, serverKeep, branch); -} - -/** - * The SAME dirty-working-tree guard `capy checkout` enforces - * (`checkoutCommand.ts#findDirtyBranchIssue`), run ONCE per folder before - * its FIRST checkout — never per environment, and never for a folder whose - * project was just created this run (nothing to be dirty about yet). A - * dirty folder aborts with zero writes; every later checkout in the SAME - * folder just switched away from a branch this run itself wrote, which is - * clean by construction, so re-checking per environment would be pure - * overhead for a result that can't change mid-run. - */ -async function checkFolderDirtySafe( - ctx: DiscoveryContext, - repoDir: string, - folder: string, - projectId: string, -): Promise<{ ok: true } | { ok: false; code: string; message: string }> { - const path = folderPath(repoDir, folder); - const pm = new ProjectManager(path); - const fm = new FileManager(path); - const keyOps = { - coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), - }; - const tryResolveKey = async (): Promise<{ ok: true; value: string } | { ok: false; message: string }> => { - try { - return { ok: true, value: await resolveProjectKey(ctx.orgId, projectId, ctx.userId, keyOps) }; - } catch (err) { - return { ok: false, message: err instanceof Error ? err.message : String(err) }; - } - }; - const keyResult = await tryResolveKey(); - if (!keyResult.ok) { - return { ok: false, code: 'DOKPLOY_KEY_RESOLUTION_FAILED', message: keyResult.message }; - } - const issue = findDirtyBranchIssue(pm, fm, keyResult.value); - if (!issue) return { ok: true }; - const detail = issue.code === 'UNCOMMITTED_CHANGES' ? `an uncommitted change (${issue.varName})` : 'unpushed changes'; - return { - ok: false, - code: 'DOKPLOY_FOLDER_DIRTY', - message: `${folder || '.'} has ${detail} on "${issue.branch}". Run \`capy\` there to push, or discard the change, then re-run discovery.`, - }; -} - -/** - * Switches (repoDir, folder) onto `branchName` — pulls it if it already - * exists on the server (same as `capy checkout `), or creates it, - * seeded from the folder's CURRENT `.env` (same as `capy checkout -b - * ` — the git-model copy), otherwise. Mirrors - * `checkoutCommand.ts#_execute`'s core — including its dirty-working-tree - * guard, run ONCE per folder before the first call to this function (see - * `DiscoverySequenceDeps.checkFolderDirty`; every LATER checkout in the same - * folder just switched away from a branch this run itself wrote, which is - * clean by construction) — scoped to ONE folder and never `process.exit`s; - * a failure comes back as `{ ok: false }` so the caller can abort just this - * folder. `createBranch(..., false)`: a discovery-created branch is never - * protected — there is no flow yet for discovery to ask "should this be - * invite-only", so it always defaults to the same open access every other - * branch this run creates gets. - */ -async function checkoutBranchSafe( - ctx: DiscoveryContext, - repoDir: string, - folder: string, - projectId: string, - branchName: string, -): Promise<{ ok: true; created: boolean } | { ok: false; code: string; message: string }> { - const path = folderPath(repoDir, folder); - const pm = new ProjectManager(path); - const fm = new FileManager(path); - try { - const keyOps = { - coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), - }; - const encryptionKey = await resolveProjectKey(ctx.orgId, projectId, ctx.userId, keyOps); - - const branches = await ctx.serviceClient.listBranches(projectId); - const created = !branches.some((b) => b.name === branchName); - if (created) { - await ctx.serviceClient.createBranch(projectId, branchName, false); - } - - const decryptResult = await (async (): Promise< - | { ok: true; data: Awaited> } - | { ok: false; code: string; message: string } - > => { - try { - return { ok: true, data: await ctx.serviceClient.getDecryptData(projectId, branchName, undefined, true) }; - } catch (err) { - const status = (err as { details?: { status?: number } })?.details?.status; - if (status === 404) { - return { ok: true, data: { env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() } }; - } - if (status === 403) { - return { ok: false, code: 'DOKPLOY_BRANCH_PROTECTED', message: `"${branchName}" is a protected branch — access is invite-only.` }; - } - throw err; // an anomaly — let the outer catch report it as DOKPLOY_CHECKOUT_FAILED. - } - })(); - if (!decryptResult.ok) return decryptResult; - const decryptData = decryptResult.data; - - const existingKeep = pm.readKeepFile(); - if (!existingKeep) { - return { ok: false, code: 'DOKPLOY_KEEP_MISSING', message: `${folder || '.'} has no keep.lock.` }; - } - // Splice in only THIS branch's server pins — keep.lock holds every - // branch's metadata and is git-owned; a sibling branch's pins must not - // move just because this one was checked out (CAP-303). - const keepForWrite: KeepFile = decryptData.keep_file - ? SyncEngine.spliceKeepBranch(existingKeep, JSON.parse(decryptData.keep_file) as KeepFile, branchName) - : existingKeep; - if (keepForWrite !== existingKeep) fm.writeKeepFile(keepForWrite); - - if (decryptData.env_content) { - const remoteEnv = fm.parseEnvContent(decryptData.env_content); - const decrypted: Record = {}; - for (const [k, v] of Object.entries(remoteEnv)) { - try { - decrypted[k] = fm.decryptValue(v, encryptionKey); - } catch { - // Skip undecryptable. - } - } - fm.writeEncryptedEnvFile(decrypted, encryptionKey, undefined, keepForWrite, branchName); - } else if (created) { - // Git model: a brand-new branch inherits the folder's CURRENT `.env` — - // the exact seed `capy checkout -b` performs. Unreadable current - // `.env` (or none yet) falls through to an empty seed. - const tryReadCurrentEnv = (): Record => { - try { - return fm.readEncryptedEnvFile(encryptionKey); - } catch { - return {}; - } - }; - fm.writeEncryptedEnvFile(tryReadCurrentEnv(), encryptionKey, undefined, keepForWrite, branchName); - } else { - // An EXISTING branch with no snapshot yet: stamp an empty file for it. - fm.writeEncryptedEnvFile({}, encryptionKey, undefined, keepForWrite, branchName); - } - - pm.writeActiveBranch(branchName); - return { ok: true, created }; - } catch (err) { - return { ok: false, code: 'DOKPLOY_CHECKOUT_FAILED', message: err instanceof Error ? err.message : String(err) }; - } -} - -/** - * Runs the single-service import itself (`importFn` — the very function - * `import()` above defines, reused directly rather than going through - * `ConnectCommand`: that class's `executeImport` prints its own terminal/ - * JSON lines as a side effect, which would corrupt discovery's OWN single - * JSON line under `--json`, and `resolveContext()` re-authenticates and - * re-reads `process.cwd()` rather than accepting an already-resolved - * folder + session) against the branch `checkoutBranchSafe` just switched - * onto, then writes the result via the SAME `writeImportOutcome` helper - * `executeImport` uses — same push, same skip-auto-commit, same - * clear-pruning for `--overwrite`. - */ -async function importIntoBranchSafe( - ctx: DiscoveryContext, - importFn: (ctx: ResolvedContext, opts: ConnectOpts) => Promise, - repoDir: string, - folder: string, - env: DiscoveryPlanEnv, - overwrite: boolean, - runOpts: { - noPush: boolean; - interactive: boolean; - json: boolean; - yes: boolean; - baseUrl: string; - tokenEnv: string; - gate: GateOptions; - /** Another provider's discovery (Vercel, CAP-696) supplies the per-step import options itself; absent = the Dokploy options below, unchanged. */ - importOptsFor?: (env: DiscoveryPlanEnv, projectName: string, folderPath: string) => ConnectOpts; - }, - /** CAP-673: this environment's Dokploy PROJECT name — already known from the plan's own `project.all` read, threaded through so `import()` never re-fetches it (see `ConnectOpts.dokployProjectName`'s doc). */ - projectName: string, -): Promise { - const path = folderPath(repoDir, folder); - const pm = new ProjectManager(path); - const fm = new FileManager(path); - - const projectState = await pm.detectProjectState(); - if (!projectState.initialized || !projectState.organizationId || !projectState.projectId || !projectState.activeBranch) { - return { ok: false, code: 'DOKPLOY_KEEP_MISSING', message: `${folder || '.'} is not on a branch after checkout.` }; - } - const keep = pm.readKeepFile(); - if (!keep) { - return { ok: false, code: 'DOKPLOY_KEEP_MISSING', message: `${folder || '.'} has no keep.lock.` }; - } - - const keyOps = { - coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), - }; - const tryResolveProjectKey = async (): Promise<{ ok: true; value: string } | { ok: false; message: string }> => { - try { - return { ok: true, value: await resolveProjectKey(projectState.organizationId!, projectState.projectId!, ctx.userId, keyOps) }; - } catch (err) { - return { ok: false, message: err instanceof Error ? err.message : String(err) }; - } - }; - const projectKeyResult = await tryResolveProjectKey(); - if (!projectKeyResult.ok) { - return { ok: false, code: 'DOKPLOY_KEY_RESOLUTION_FAILED', message: projectKeyResult.message }; - } - const projectKey = projectKeyResult.value; - - const localPlaintext: Record = Object.fromEntries( - Object.entries(fm.readEnvFile()).flatMap(([k, v]): ReadonlyArray => { - if (!v.startsWith('capy:')) return [[k, v]]; - try { - return [[k, fm.decryptValue(v, projectKey)]]; - } catch { - // Skip undecryptable. - return []; - } - }), - ); - - const resolvedCtx: ResolvedContext = { - pm, - fileManager: fm, - authService: ctx.authService, - serviceClient: ctx.serviceClient, - orgId: projectState.organizationId, - projectId: projectState.projectId, - branch: projectState.activeBranch, - userId: ctx.userId, - projectKey, - keep, - localPlaintext, - // This folder's own gate: if Capy refuses the upload as stale, bare `capy` runs again IN THIS FOLDER and the write retries. - gate: { ...runOpts.gate, cwd: path, throwConflicts: true }, - }; - const sharedImportOpts = { - overwrite, - noPush: runOpts.noPush, - nonTty: !runOpts.interactive, - json: runOpts.json, - yes: runOpts.yes, - }; - const importOpts: ConnectOpts = runOpts.importOptsFor - ? { ...sharedImportOpts, ...runOpts.importOptsFor(env, projectName, path) } - : { - ...(env.serviceKind === 'application' ? { application: env.serviceId } : { compose: env.serviceId }), - ...sharedImportOpts, - baseUrl: runOpts.baseUrl, - tokenEnv: runOpts.tokenEnv, - // CAP-673: already known from the plan — see `dokployProjectName`'s doc. - dokployProjectName: projectName, - dokployEnvironmentName: env.environmentName, - }; - - const outcome = await importFn(resolvedCtx, importOpts); - if (!outcome.ok) return outcome; - - // A push failure here must become THIS folder's coded failure, never an - // uncaught rejection — `runDiscoverySequences` awaits one folder's - // sequence at a time, so letting this throw would abort every folder - // still queued behind this one, not just this one. - try { - await writeImportOutcome(resolvedCtx, outcome, { push: !runOpts.noPush, dryRun: false }); - } catch (err) { - // A refused-as-stale upload keeps its own code; anything else is the push failing. - return { ok: false, code: stalePushCode(err), message: err instanceof Error ? err.message : String(err) }; - } - return outcome; -} - -/** The coded reason a folder's upload failed: a difference found by the retry's bare `capy` and a second stale refusal keep their own codes. */ -function stalePushCode(err: unknown): string { - if (isConflictRefusal(err)) return err.code; - return isStaleBase(err) ? ERROR_CODES.SECRETS_STALE_BASE : 'DOKPLOY_PUSH_FAILED'; -} - -/** - * The REAL `DiscoverySequenceDeps`, wired to an already-authenticated `ctx` - * — the discovery scan's own org/session, reused for every folder rather - * than re-authenticating per folder. - */ -export function buildRealDiscoverySequenceDeps( - ctx: DiscoveryContext, - importFn: (ctx: ResolvedContext, opts: ConnectOpts) => Promise, - runOpts: { - noPush: boolean; - interactive: boolean; - json: boolean; - yes: boolean; - baseUrl: string; - tokenEnv: string; - importOptsFor?: (env: DiscoveryPlanEnv, projectName: string, folderPath: string) => ConnectOpts; - /** How bare `capy` runs in each discovered folder (discovery never prompts: it answers by protocol). */ - gate: GateOptions; - /** Runs bare `capy` in one folder: the real `syncFolder` unless a test supplies its own. */ - syncFolder: (dir: string, gate: GateOptions) => Promise; - askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; - askProjectName: (defaultName: string) => Promise; - /** - * The non-interactive `--yes` auto-create snapshot (see - * `ensureProjectSafe`'s doc) — taken ONCE by `discover()` itself, before - * `runDiscoverySequences` starts, and handed to EVERY folder's own - * `ensureProject` call unchanged, so a project one folder creates this - * run never alters a later folder's "org has none" answer. `undefined` - * when this run's mode never needs it (interactive, or non-interactive - * without `--yes`). - */ - existingProjectsSnapshot?: ExistingProjectsSnapshot; - /** - * This run's OWN pure precomputation (see `computeSameRunNameCollisions`) - * of "which candidate folders' default project name collides with an - * EARLIER folder in this same run" — keyed by `sameRunFolderKey`. Passed - * through unchanged to every folder's own `ensureProject` call, same - * shape as `existingProjectsSnapshot` above. - */ - sameRunNameCollisions: ReadonlySet; - }, -): DiscoverySequenceDeps { - return { - syncFolder: (repoDir, folder) => runOpts.syncFolder(folderPath(repoDir, folder), runOpts.gate), - ensureProject: (repoDir, folder, remote) => - ensureProjectSafe(ctx, repoDir, folder, remote, { - interactive: runOpts.interactive, - yes: runOpts.yes, - askExistingOrNewProject: runOpts.askExistingOrNewProject, - askProjectName: runOpts.askProjectName, - existingProjectsSnapshot: runOpts.existingProjectsSnapshot, - sameRunNameCollision: runOpts.sameRunNameCollisions.has(sameRunFolderKey(repoDir, folder)), - }), - checkFolderDirty: (repoDir, folder, projectId) => checkFolderDirtySafe(ctx, repoDir, folder, projectId), - checkoutBranch: (repoDir, folder, projectId, branchName) => checkoutBranchSafe(ctx, repoDir, folder, projectId, branchName), - importIntoBranch: (repoDir, folder, env, overwrite, projectName) => - importIntoBranchSafe(ctx, importFn, repoDir, folder, env, overwrite, runOpts, projectName), - }; -} - /** * The real org system store read for the Dokploy IMPORT key (`_CONNECTOR_DOKPLOY_API_KEY`). * Shared by `capy connect dokploy` and `capy deploy dokploy --discover`, which read the same diff --git a/src/commands/connectors/dokployDiscovery.ts b/src/commands/connectors/dokployDiscovery.ts index 8ff6ee9e..01fdcf0a 100644 --- a/src/commands/connectors/dokployDiscovery.ts +++ b/src/commands/connectors/dokployDiscovery.ts @@ -26,7 +26,7 @@ * clear/replace/import plan itself, and it never touches Dokploy vars past * what `--compose`/`--application`'s own single-service import (below, and * in `dokploy.ts`) already does. The only new behaviour that import gained - * for this is `--overwrite` (see `dokploy.ts#computeOverwritePlan`) — a + * for this is `--overwrite` (see `discoverySteps.ts#computeOverwritePlan`) — a * flag usable standalone too, not something discovery invented for itself. * Discovery DOES decide, per step, whether to pass `--overwrite` * automatically — see "Auto-overwrite", below — but the actual clear/ @@ -122,6 +122,7 @@ import type { ConnectFailure, ImportOutcome } from './registry'; import type { FolderSync } from '../../sync/gate'; import type { ConflictPayload } from '../../sync/conflicts'; import type { DiscoveryCommitOutcome } from '../../git/discoveryCommit'; +import { DOKPLOY_DISCOVERY_CODES, type DiscoveryCodes } from './discoveryCodes'; /** * The context `capy connect dokploy --discover` runs with — deliberately @@ -256,8 +257,8 @@ export interface CandidateRepo { export interface DiscoveryRemoteReadError { repoDir: string; - /** Stable refusal code — branch on this, never on `message`. */ - code: 'DOKPLOY_REMOTE_UNREADABLE'; + /** Stable refusal code — branch on this, never on `message`. `DOKPLOY_REMOTE_UNREADABLE` for Dokploy, `CONNECTOR_REMOTE_UNREADABLE` for the other providers. */ + code: string; /** git's own stderr (or a fallback naming its exit code) — for display only. */ message: string; } @@ -276,10 +277,10 @@ export interface CandidateReposResult { * defect a live sandbox run hit: git's "dubious ownership" refusal made * every single service come back `no_remote_match`). */ -export function findCandidateReposResult(cwd: string): CandidateReposResult { +export function findCandidateReposResult(cwd: string, codes: Pick = DOKPLOY_DISCOVERY_CODES): CandidateReposResult { const self = probeRepoDir(cwd); if (self.kind === 'unreadable') { - return { repos: [], unreadable: [{ repoDir: cwd, code: 'DOKPLOY_REMOTE_UNREADABLE', message: self.message }] }; + return { repos: [], unreadable: [{ repoDir: cwd, code: codes.remoteUnreadable, message: self.message }] }; } if (self.kind === 'ok') { const remote = readOriginRemote(self.repoRoot); @@ -293,7 +294,7 @@ export function findCandidateReposResult(cwd: string): CandidateReposResult { return remote ? [{ repoDir: probe.repoRoot, remote }] : []; }), unreadable: childProbes.flatMap(({ dir, probe }) => - probe.kind === 'unreadable' ? [{ repoDir: dir, code: 'DOKPLOY_REMOTE_UNREADABLE' as const, message: probe.message }] : [], + probe.kind === 'unreadable' ? [{ repoDir: dir, code: codes.remoteUnreadable, message: probe.message }] : [], ), }; } @@ -1024,7 +1025,7 @@ export function orderEnvironments(environ * ever call `process.exit`: a multi-folder run must be able to abort ONE * folder on a coded failure and continue with the next, which the CLI's own * command classes (`CapyCommand`, `CheckoutCommand`) cannot do as they - * stand today (see `dokploy.ts`'s own doc on `buildRealDiscoverySequenceDeps` + * stand today (see `discoverySteps.ts`'s own doc on `buildRealDiscoverySequenceDeps` * for exactly where this deviates from literally invoking those classes, and * why). */ diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts index e268dee2..28d8b33c 100644 --- a/src/commands/connectors/vercel.ts +++ b/src/commands/connectors/vercel.ts @@ -35,7 +35,6 @@ * values and NEVER creates, offers or writes a deploy target. * `capy deploy vercel --discover` is what creates targets, and it pushes no values. */ -import { join } from 'node:path'; import { syncFolder, type FolderSync, type GateOptions } from '../../sync/gate'; import { classifyImportCandidates } from '../../deploy/dokployApi'; import { @@ -90,7 +89,6 @@ import { DiscoveryContext, DiscoveryOutcome, DiscoveryPlan, - DiscoveryFolderResult, DiscoveryPlanFolder, applyCollisionResolutions, findCandidateReposResult, @@ -98,6 +96,7 @@ import { resolveCollisions, runDiscoverySequences, } from './dokployDiscovery'; +import { CONNECTOR_DISCOVERY_CODES } from './discoveryCodes'; import { ExistingProjectsSnapshot, buildRealDiscoverySequenceDeps, @@ -113,7 +112,7 @@ import { defaultPickCollisionWinner, discoveryCommitPathsFor, peekLocalKeepForFolder, -} from './dokploy'; +} from './discoverySteps'; import { buildVercelDiscoveryPlan, reportVercelDiscovery } from './vercelDiscovery'; import { VERCEL_CONNECTOR_SECRET_NAME } from '../../deploy/vercelApi'; @@ -644,7 +643,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const team = await chooseTeam(client, opts, promptable, true); if (!team.ok) return team; - const scan = findCandidateReposResult(cwd()); + const scan = findCandidateReposResult(cwd(), CONNECTOR_DISCOVERY_CODES); const environmentFilter = parseVarList(opts.environment) ?? undefined; const built = await guard(() => buildVercelDiscoveryPlan( @@ -677,7 +676,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector if (dryRun) { // A dry run never asks for a branch name either; the DEFAULT is what it previews. - const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), VERCEL_COMMIT_MESSAGE); + const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), CONNECTOR_DISCOVERY_CODES, VERCEL_COMMIT_MESSAGE); return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}) }; } @@ -733,8 +732,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector interactive: promptable, json: !!opts.json, yes: !!opts.yes, - baseUrl: '', - tokenEnv, + codes: CONNECTOR_DISCOVERY_CODES, // Discovery never prompts: a folder that differs from Capy is reported (its rows and `conflict_id`), not asked. gate: { devMode: opts.devMode, json: true, nonTty: true, conflictFlags: opts.conflictFlags }, syncFolder: deps.syncFolder ?? syncFolder, @@ -751,7 +749,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector sameRunNameCollisions, }); const applied = await runDiscoverySequences(resolvedFolders, { overwrite: !!opts.overwrite }, sequenceDeps); - const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, VERCEL_COMMIT_MESSAGE); + const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, CONNECTOR_DISCOVERY_CODES, VERCEL_COMMIT_MESSAGE); return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}) }; }; diff --git a/src/git/discoveryCommit.ts b/src/git/discoveryCommit.ts index 901f24f9..43f67c2e 100644 --- a/src/git/discoveryCommit.ts +++ b/src/git/discoveryCommit.ts @@ -51,6 +51,7 @@ import { execFileSync } from 'node:child_process'; import { dirname, join } from 'node:path'; import { restorePathsToHead, syncTrackedKeepFromWorkingCopy } from '../deploy/git'; +import { DOKPLOY_DISCOVERY_CODES, type DiscoveryCodes } from '../commands/connectors/discoveryCodes'; function git(repoRoot: string, args: readonly string[]): string { // `env: process.env` explicitly: Bun's `execFileSync` (unlike Node's) @@ -120,12 +121,7 @@ export type DiscoveryCommitOutcome = | { ok: false; /** Stable refusal code — branch on this, never on `message`. */ - code: - | 'DOKPLOY_COMMIT_WOULD_MIX' - | 'DOKPLOY_COMMIT_BRANCH_INVALID' - | 'DOKPLOY_COMMIT_BRANCH_EXISTS' - | 'DOKPLOY_GIT_IDENTITY_MISSING' - | 'DOKPLOY_COMMIT_FAILED'; + code: string; message: string; }; @@ -149,8 +145,11 @@ export function commitDiscoveryChanges( summaryLines: readonly string[]; /** The commit subject. Default is the Dokploy one, so every existing caller's commit is unchanged. */ commitMessage?: string; + /** How the refusals are spelled. Default is Dokploy's, so every existing caller's codes are unchanged. */ + codes?: Pick; }, ): DiscoveryCommitOutcome { + const codes = opts.codes ?? DOKPLOY_DISCOVERY_CODES; if (paths.length === 0) { return { ok: true, dryRun: true, branch: opts.branchName, wouldCommitFiles: [] }; } @@ -159,13 +158,13 @@ export function commitDiscoveryChanges( if (mixedFiles.length > 0) { return { ok: false, - code: 'DOKPLOY_COMMIT_WOULD_MIX', + code: codes.commitWouldMix, message: `${mixedFiles.join(', ')} already had uncommitted changes before this run — refusing to mix them into an automatic commit.`, }; } if (!isValidDiscoveryCommitBranchName(opts.branchName)) { - return { ok: false, code: 'DOKPLOY_COMMIT_BRANCH_INVALID', message: `"${opts.branchName}" is not a valid git branch name.` }; + return { ok: false, code: codes.commitBranchInvalid, message: `"${opts.branchName}" is not a valid git branch name.` }; } if (opts.dryRun) { @@ -174,7 +173,7 @@ export function commitDiscoveryChanges( const branchExists = tryGit(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${opts.branchName}`]); if (branchExists.ok) { - return { ok: false, code: 'DOKPLOY_COMMIT_BRANCH_EXISTS', message: `Branch "${opts.branchName}" already exists.` }; + return { ok: false, code: codes.commitBranchExists, message: `Branch "${opts.branchName}" already exists.` }; } const identityName = tryGit(repoRoot, ['config', '--get', 'user.name']); @@ -182,14 +181,14 @@ export function commitDiscoveryChanges( if (!identityName.ok || !identityName.stdout.trim() || !identityEmail.ok || !identityEmail.stdout.trim()) { return { ok: false, - code: 'DOKPLOY_GIT_IDENTITY_MISSING', + code: codes.gitIdentityMissing, message: 'git has no configured identity in this repo — set user.name and user.email, then re-run.', }; } const created = tryGit(repoRoot, ['checkout', '-b', opts.branchName]); if (!created.ok) { - return { ok: false, code: 'DOKPLOY_COMMIT_FAILED', message: `Could not create branch "${opts.branchName}".` }; + return { ok: false, code: codes.commitFailed, message: `Could not create branch "${opts.branchName}".` }; } // The keep.lock path(s) among `paths` (one per folder this commit covers) @@ -219,7 +218,7 @@ export function commitDiscoveryChanges( if (keepPaths.length > 0) restorePathsToHead(repoRoot, [...keepPaths]); tryGit(repoRoot, ['checkout', '-']); tryGit(repoRoot, ['branch', '-D', opts.branchName]); - return { ok: false, code: 'DOKPLOY_COMMIT_FAILED', message: 'git commit failed.' }; + return { ok: false, code: codes.commitFailed, message: 'git commit failed.' }; } const sha = tryGit(repoRoot, ['rev-parse', 'HEAD']); diff --git a/src/sync/commit.ts b/src/sync/commit.ts index 9c21d83f..20c0f50d 100644 --- a/src/sync/commit.ts +++ b/src/sync/commit.ts @@ -240,10 +240,21 @@ export function keepWithChanges( return removed.length === 0 ? merged : dropBranchEntries(merged, branch, removed); } +/** + * What a branch with no variables is stored as. The blob is one `NAME=capy::` line per variable, so a + * branch with none would be an empty string, and the service refuses an empty `env_blob` (it answers 400 to `!env_blob`). + * A branch can have none and still need a save: an import whose names are all placeholders, a `--overwrite` that clears + * the last name. A comment line is a non-empty blob that every reader (`dotenv` parse) reads as no variables; there is + * no per-variable ciphertext to put in it, because there is no variable. + */ +export const EMPTY_ENV_BLOB = '# capy: no variables'; + export const envBlobOf = (lines: Readonly>): string => - Object.entries(lines) - .map(([name, line]) => `${name}=${line}`) - .join('\n'); + Object.keys(lines).length === 0 + ? EMPTY_ENV_BLOB + : Object.entries(lines) + .map(([name, line]) => `${name}=${line}`) + .join('\n'); /** The names `changes` removes that are placeholders on `branch` (and are not being given a value in the same change). */ function removedPlaceholders(keep: KeepFile, branch: string, changes: Changes): readonly string[] { diff --git a/src/types/index.ts b/src/types/index.ts index 906cdcfe..bf7bd506 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -808,6 +808,45 @@ export const ERROR_CODES = { // --- Import connectors --- /** `capy connect vercel`: Vercel will not return this name's value, and Capy already has one. The Capy value was left untouched (reported, not an error). */ CAPY_VALUE_KEPT: 'CAPY_VALUE_KEPT', + // --- Connector discovery, any provider (`capy connect --discover`) --- + // One per step a folder's run can stop on. Every provider but Dokploy reports these; + // Dokploy keeps its own `DOKPLOY_*` spellings of the same steps. + /** The folder's upload failed for a reason other than a stale base. That folder's run stopped; the other folders still ran. */ + CONNECTOR_PUSH_FAILED: 'CONNECTOR_PUSH_FAILED', + /** Non-interactive run without `--yes`: the folder has no keep.lock, and a person has to initialize it. */ + CONNECTOR_FOLDER_NOT_INITIALIZED: 'CONNECTOR_FOLDER_NOT_INITIALIZED', + /** The org's project list could not be read, so a new project is not offered blind. */ + CONNECTOR_PROJECT_LOOKUP_FAILED: 'CONNECTOR_PROJECT_LOOKUP_FAILED', + /** Non-interactive `--yes`: a project with the default name already exists (in the org, or created earlier in the same run). */ + CONNECTOR_PROJECT_NAME_EXISTS: 'CONNECTOR_PROJECT_NAME_EXISTS', + /** The picked project is not in this org. */ + CONNECTOR_PROJECT_NOT_FOUND: 'CONNECTOR_PROJECT_NOT_FOUND', + /** Creating or bootstrapping the folder's project failed. */ + CONNECTOR_INIT_FAILED: 'CONNECTOR_INIT_FAILED', + /** The new project's name is over the length limit. Nothing was created. */ + CONNECTOR_PROJECT_NAME_TOO_LONG: 'CONNECTOR_PROJECT_NAME_TOO_LONG', + /** The project key could not be resolved for the folder. */ + CONNECTOR_KEY_RESOLUTION_FAILED: 'CONNECTOR_KEY_RESOLUTION_FAILED', + /** The folder has an uncommitted or unpushed change; nothing was written into it. */ + CONNECTOR_FOLDER_DIRTY: 'CONNECTOR_FOLDER_DIRTY', + /** The branch is protected (invite-only). */ + CONNECTOR_BRANCH_PROTECTED: 'CONNECTOR_BRANCH_PROTECTED', + /** The folder has no keep.lock, or is not on a branch after checkout. */ + CONNECTOR_KEEP_MISSING: 'CONNECTOR_KEEP_MISSING', + /** Switching the folder onto the branch failed. */ + CONNECTOR_CHECKOUT_FAILED: 'CONNECTOR_CHECKOUT_FAILED', + /** git refused to read a repo's remote (for example "dubious ownership"); the repo is reported, not silently treated as unmatched. */ + CONNECTOR_REMOTE_UNREADABLE: 'CONNECTOR_REMOTE_UNREADABLE', + /** The discovery commit was refused: a path it would commit already had uncommitted changes before the run. */ + CONNECTOR_COMMIT_WOULD_MIX: 'CONNECTOR_COMMIT_WOULD_MIX', + /** The discovery commit was refused: the branch name is not a valid git branch name. */ + CONNECTOR_COMMIT_BRANCH_INVALID: 'CONNECTOR_COMMIT_BRANCH_INVALID', + /** The discovery commit was refused: the branch name is already taken. */ + CONNECTOR_COMMIT_BRANCH_EXISTS: 'CONNECTOR_COMMIT_BRANCH_EXISTS', + /** The discovery commit was refused: the repo has no git identity (user.name / user.email). */ + CONNECTOR_GIT_IDENTITY_MISSING: 'CONNECTOR_GIT_IDENTITY_MISSING', + /** The discovery commit failed in git and was rolled back. */ + CONNECTOR_COMMIT_FAILED: 'CONNECTOR_COMMIT_FAILED', // --- Piped values: ` | capy edit NAME`, ` | capy add NAME` --- /** * `capy edit` with no name and no terminal. There is nothing to draw the diff --git a/tests/commands/connectDiscoveryNeutralCodes.test.ts b/tests/commands/connectDiscoveryNeutralCodes.test.ts new file mode 100644 index 00000000..07618efd --- /dev/null +++ b/tests/commands/connectDiscoveryNeutralCodes.test.ts @@ -0,0 +1,373 @@ +/** + * The steps `capy connect --discover` shares (commands/connectors/discoverySteps.ts) report their refusals + * with the codes of the provider that runs them. + * + * A Vercel run reports the neutral `CONNECTOR_*` codes and never a code or a string that names Dokploy. A Dokploy run + * reports exactly the `DOKPLOY_*` codes it always has. The same steps run under both tables here, and each table is + * compared with the full list of what it must say. + * + * No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { randomBytes } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { CONNECTOR_DISCOVERY_CODES, DOKPLOY_DISCOVERY_CODES, type DiscoveryCodes } from '../../src/commands/connectors/discoveryCodes'; +import { buildRealDiscoverySequenceDeps } from '../../src/commands/connectors/discoverySteps'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { reportVercelDiscovery } from '../../src/commands/connectors/vercelDiscovery'; +import { defaultDiscoveryProjectName, findCandidateReposResult, type DiscoveryContext, type DiscoveryPlanEnv } from '../../src/commands/connectors/dokployDiscovery'; +import type { ConnectOpts, ImportOutcome } from '../../src/commands/connectors/registry'; +import { FileManager } from '../../src/files/fileManager'; +import { wrapAndSaveMasterKey } from '../../src/crypto/keyResolver'; +import { commitDiscoveryChanges } from '../../src/git/discoveryCommit'; +import { CapyError, ERROR_CODES, type KeepFile } from '../../src/types/index'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, git, initRepo, withExitCodeRestored, withIsolatedGlobalDir, withTempDir } from '../helpers/testSandbox'; + +const ORG = 'org_codes'; +const USER = 'user_codes'; +const PROJECT = 'proj_codes'; + +const mentionsDokploy = (text: string): boolean => /dokploy/i.test(text); +const stringValues = (table: DiscoveryCodes): readonly string[] => Object.values(table).filter((v): v is string => typeof v === 'string'); + +// ── the tables ────────────────────────────────────────────────────────────── + +describe('the code tables', () => { + test('the neutral table names no provider, and every code in it is in ERROR_CODES', () => { + const codes = stringValues(CONNECTOR_DISCOVERY_CODES); + expect(codes).toHaveLength(18); + expect(codes.filter(mentionsDokploy)).toEqual([]); + codes.forEach((code) => expect(Object.values(ERROR_CODES)).toContain(code)); + expect(new Set(codes).size).toBe(codes.length); + }); + + test('Dokploy\'s table is exactly the strings its discovery has always reported', () => { + expect(DOKPLOY_DISCOVERY_CODES).toEqual({ + keepsServiceCode: false, + pushFailed: 'DOKPLOY_PUSH_FAILED', + folderNotInitialized: 'DOKPLOY_FOLDER_NOT_INITIALIZED', + projectLookupFailed: 'DOKPLOY_PROJECT_LOOKUP_FAILED', + projectNameExists: 'DOKPLOY_PROJECT_NAME_EXISTS', + projectNotFound: 'DOKPLOY_PROJECT_NOT_FOUND', + initFailed: 'DOKPLOY_INIT_FAILED', + projectNameTooLong: 'DOKPLOY_PROJECT_NAME_TOO_LONG', + keyResolutionFailed: 'DOKPLOY_KEY_RESOLUTION_FAILED', + folderDirty: 'DOKPLOY_FOLDER_DIRTY', + branchProtected: 'DOKPLOY_BRANCH_PROTECTED', + keepMissing: 'DOKPLOY_KEEP_MISSING', + checkoutFailed: 'DOKPLOY_CHECKOUT_FAILED', + remoteUnreadable: 'DOKPLOY_REMOTE_UNREADABLE', + commitWouldMix: 'DOKPLOY_COMMIT_WOULD_MIX', + commitBranchInvalid: 'DOKPLOY_COMMIT_BRANCH_INVALID', + commitBranchExists: 'DOKPLOY_COMMIT_BRANCH_EXISTS', + gitIdentityMissing: 'DOKPLOY_GIT_IDENTITY_MISSING', + commitFailed: 'DOKPLOY_COMMIT_FAILED', + }); + }); + + test('Vercel\'s discovery modules do not import the Dokploy connector', () => { + const importLines = (file: string): readonly string[] => + readFileSync(join(__dirname, '../../src/commands/connectors', file), 'utf-8') + .split('\n') + .filter((l) => /^\s*(import|\}\s*from)\b/.test(l) && /from\s+'/.test(l)); + ['vercel.ts', 'vercelDiscovery.ts'].forEach((file) => { + expect(importLines(file).filter((l) => /from\s+'\.\/dokploy'/.test(l))).toEqual([]); + }); + }); +}); + +// ── the shared steps under each table ─────────────────────────────────────── + +const planEnv = { environmentName: 'preview', serviceId: 'prj_x', serviceName: 'x', serviceKind: 'vercel' } as unknown as DiscoveryPlanEnv; + +type ServiceOverrides = Partial>; + +interface StepWorld { + readonly root: string; + readonly ensure: (opts: { interactive: boolean; yes: boolean; snapshot?: { existingProjects: ReadonlyArray<{ id: string; name: string; organization_id: string }>; projectsUnavailable: boolean }; pick?: string; sameRun?: boolean }) => Promise<{ ok: boolean; code?: string }>; + readonly checkout: () => Promise<{ ok: boolean; code?: string }>; + readonly importAndPush: (outcome: ImportOutcome) => Promise<{ ok: boolean; code?: string }>; +} + +/** A real folder (with or without a keep.lock) and the real step functions for it, under `codes`. */ +async function stepWorld(root: string, codes: DiscoveryCodes, opts: { keep: boolean; service?: ServiceOverrides }): Promise { + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), ORG, USER, keyOps); + const keep: KeepFile = { version: '3.0', org_id: ORG, project_id: PROJECT, project_name: 'widgets', variables: {} }; + if (opts.keep) { + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + new FileManager(root).ensureCapyGitignore(); + } + const serviceClient = { + listBranches: async () => [], + listProjects: async () => [], + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets: async () => ({ keep_hash: 'h'.repeat(16) }), + ...(opts.service ?? {}), + }; + const ctx = { orgId: ORG, userId: USER, authService: {}, serviceClient } as unknown as DiscoveryContext; + const depsFor = (over: { interactive: boolean; yes: boolean; snapshot?: Parameters[0]['snapshot']; pick?: string; sameRun?: boolean }, importFn: (c: never, o: ConnectOpts) => Promise) => + buildRealDiscoverySequenceDeps(ctx, importFn as never, { + noPush: false, + interactive: over.interactive, + json: true, + yes: over.yes, + importOptsFor: () => ({}), + codes, + gate: { json: true, nonTty: true }, + syncFolder: async () => ({ ok: true }), + askExistingOrNewProject: async () => over.pick ?? '__capy_discovery_new_project__', + askProjectName: async (name) => name, + existingProjectsSnapshot: over.snapshot, + sameRunNameCollisions: new Set(over.sameRun ? [`${root}\u0000`] : []), + }); + const noImport = async (): Promise => ({ ok: true, source: undefined, imported: [], unchanged: [], skipped: [], warnings: [] }) as unknown as ImportOutcome; + const codeOf = (r: { ok: boolean; code?: string }) => ({ ok: r.ok, ...(r.ok ? {} : { code: (r as { code: string }).code }) }); + return { + root, + ensure: async (o) => codeOf(await depsFor(o, noImport).ensureProject(root, '', undefined)), + checkout: async () => codeOf(await depsFor({ interactive: false, yes: true }, noImport).checkoutBranch(root, '', PROJECT, 'preview')), + importAndPush: async (outcome) => { + const deps = depsFor({ interactive: false, yes: true }, async () => outcome); + await deps.checkoutBranch(root, '', PROJECT, 'preview'); + return codeOf(await deps.importIntoBranch(root, '', planEnv, false, 'x')); + }, + }; +} + +const placeholderOutcome: ImportOutcome = { + ok: true, + source: undefined, + imported: [], + unchanged: [], + skipped: [], + warnings: [], + placeholders: [{ name: 'HIDDEN', code: 'sensitive_unreadable', provider: 'vercel' }], +} as unknown as ImportOutcome; + +/** Every step that can refuse, run once under `codes`, as the code each one reported. */ +async function observe(codes: DiscoveryCodes): Promise> { + const run = (keep: boolean, service: ServiceOverrides | undefined, fn: (w: StepWorld) => Promise): Promise => + withTempDir('capy-codes', async (root) => fn(await stepWorld(root, codes, { keep, service }))); + const codeOf = (r: { ok: boolean; code?: string }) => r.code; + const defaultName = (root: string) => defaultDiscoveryProjectName(root, '', undefined); + return { + folderNotInitialized: await run(false, undefined, async (w) => codeOf(await w.ensure({ interactive: false, yes: false }))), + projectLookupFailed: await run(false, undefined, async (w) => + codeOf(await w.ensure({ interactive: false, yes: true, snapshot: { existingProjects: [], projectsUnavailable: true } })), + ), + projectNameExists: await run(false, undefined, async (w) => + codeOf( + await w.ensure({ + interactive: false, + yes: true, + snapshot: { existingProjects: [{ id: 'p', name: defaultName(w.root), organization_id: ORG }], projectsUnavailable: false }, + }), + ), + ), + projectNameExistsSameRun: await run(false, undefined, async (w) => + codeOf(await w.ensure({ interactive: false, yes: true, sameRun: true, snapshot: { existingProjects: [], projectsUnavailable: false } })), + ), + projectNotFound: await run(false, { listProjects: async () => [{ id: 'p1', name: 'a', organization_id: ORG }] }, async (w) => + codeOf(await w.ensure({ interactive: true, yes: false, pick: 'nope' })), + ), + initFailed: await run(false, { listProjects: async () => [{ id: 'p1', name: 'a', organization_id: ORG }], getDecryptData: async () => { throw new Error('refused'); } }, async (w) => + codeOf(await w.ensure({ interactive: true, yes: false, pick: 'p1' })), + ), + branchProtected: await run(true, { getDecryptData: async () => { throw new CapyError('no', ERROR_CODES.PERMISSION_DENIED, { status: 403 }); } }, async (w) => codeOf(await w.checkout())), + checkoutFailed: await run(true, { getDecryptData: async () => { throw new Error('boom'); } }, async (w) => codeOf(await w.checkout())), + keepMissing: await run(false, undefined, async (w) => codeOf(await w.checkout())), + pushFailed: await run(true, { pushSecrets: async () => { throw new Error('upload refused'); } }, async (w) => codeOf(await w.importAndPush(placeholderOutcome))), + }; +} + +const NEUTRAL_EXPECTED = { + folderNotInitialized: 'CONNECTOR_FOLDER_NOT_INITIALIZED', + projectLookupFailed: 'CONNECTOR_PROJECT_LOOKUP_FAILED', + projectNameExists: 'CONNECTOR_PROJECT_NAME_EXISTS', + projectNameExistsSameRun: 'CONNECTOR_PROJECT_NAME_EXISTS', + projectNotFound: 'CONNECTOR_PROJECT_NOT_FOUND', + initFailed: 'CONNECTOR_INIT_FAILED', + branchProtected: 'CONNECTOR_BRANCH_PROTECTED', + checkoutFailed: 'CONNECTOR_CHECKOUT_FAILED', + keepMissing: 'CONNECTOR_KEEP_MISSING', + pushFailed: 'CONNECTOR_PUSH_FAILED', +}; + +const DOKPLOY_EXPECTED = { + folderNotInitialized: 'DOKPLOY_FOLDER_NOT_INITIALIZED', + projectLookupFailed: 'DOKPLOY_PROJECT_LOOKUP_FAILED', + projectNameExists: 'DOKPLOY_PROJECT_NAME_EXISTS', + projectNameExistsSameRun: 'DOKPLOY_PROJECT_NAME_EXISTS', + projectNotFound: 'DOKPLOY_PROJECT_NOT_FOUND', + initFailed: 'DOKPLOY_INIT_FAILED', + branchProtected: 'DOKPLOY_BRANCH_PROTECTED', + checkoutFailed: 'DOKPLOY_CHECKOUT_FAILED', + keepMissing: 'DOKPLOY_KEEP_MISSING', + pushFailed: 'DOKPLOY_PUSH_FAILED', +}; + +describe('the shared steps, run under each provider\'s table', () => { + test('neutral table: every refusal is the CONNECTOR_* code, and none names Dokploy', async () => { + await withIsolatedGlobalDir(async () => { + const seen = await observe(CONNECTOR_DISCOVERY_CODES); + expect(seen).toEqual(NEUTRAL_EXPECTED as never); + Object.values(seen).forEach((code) => expect(mentionsDokploy(String(code))).toBe(false)); + }); + }, 60_000); + + test('Dokploy table: every refusal is exactly the DOKPLOY_* code it has always been (output unchanged)', async () => { + await withIsolatedGlobalDir(async () => { + expect(await observe(DOKPLOY_DISCOVERY_CODES)).toEqual(DOKPLOY_EXPECTED as never); + }); + }, 60_000); +}); + +describe('repo scan and discovery commit', () => { + test('a repo git cannot read is CONNECTOR_REMOTE_UNREADABLE for Vercel; DOKPLOY_REMOTE_UNREADABLE by default and for Dokploy', async () => { + await withTempDir('capy-unreadable', async (root) => { + mkdirSync(join(root, '.git'), { recursive: true }); // a .git that is not a repository + expect(findCandidateReposResult(root, CONNECTOR_DISCOVERY_CODES).unreadable.map((u) => u.code)).toEqual(['CONNECTOR_REMOTE_UNREADABLE']); + expect(findCandidateReposResult(root, DOKPLOY_DISCOVERY_CODES).unreadable.map((u) => u.code)).toEqual(['DOKPLOY_REMOTE_UNREADABLE']); + expect(findCandidateReposResult(root).unreadable.map((u) => u.code)).toEqual(['DOKPLOY_REMOTE_UNREADABLE']); + }); + }); + + test('the commit refusals: neutral for Vercel, Dokploy\'s by default and when asked', async () => { + await withTempDir('capy-commit-codes', async (root) => { + initRepo(root); + const refusal = (codes: DiscoveryCodes | undefined, over: { branchName?: string; before?: ReadonlySet }) => + commitDiscoveryChanges(root, ['keep.lock'], over.before ?? new Set(), { + branchName: over.branchName ?? 'capy/x', + dryRun: false, + summaryLines: [], + ...(codes ? { codes } : {}), + }); + const codeOf = (r: ReturnType) => (r.ok ? 'ok' : r.code); + + expect(codeOf(refusal(CONNECTOR_DISCOVERY_CODES, { before: new Set(['keep.lock']) }))).toBe('CONNECTOR_COMMIT_WOULD_MIX'); + expect(codeOf(refusal(CONNECTOR_DISCOVERY_CODES, { branchName: 'bad name' }))).toBe('CONNECTOR_COMMIT_BRANCH_INVALID'); + git(root, ['branch', 'taken']); + expect(codeOf(refusal(CONNECTOR_DISCOVERY_CODES, { branchName: 'taken' }))).toBe('CONNECTOR_COMMIT_BRANCH_EXISTS'); + + expect(codeOf(refusal(undefined, { before: new Set(['keep.lock']) }))).toBe('DOKPLOY_COMMIT_WOULD_MIX'); + expect(codeOf(refusal(DOKPLOY_DISCOVERY_CODES, { branchName: 'bad name' }))).toBe('DOKPLOY_COMMIT_BRANCH_INVALID'); + expect(codeOf(refusal(undefined, { branchName: 'taken' }))).toBe('DOKPLOY_COMMIT_BRANCH_EXISTS'); + }); + }); +}); + +// ── a Vercel run, end to end ───────────────────────────────────────────────── + +const e = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +const vercelData = (): FakeVercelData => ({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: [e({ id: 'p1', key: 'A', type: 'plain', listValue: 'a-value-SENT-11aa' })] }, + custom: {}, + values: {}, +}); + +describe('a Vercel run whose upload fails', () => { + /** A repo with a committed keep.lock, a Vercel connector over a fake Vercel, and a service whose upload fails with `uploadError`. */ + const runWithUploadError = (uploadError: unknown) => + withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-push-fail', async (root) => { + initRepo(root, 'git@github.com:acme/widgets.git'); + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), ORG, USER, keyOps); + const keep: KeepFile = { version: '3.0', org_id: ORG, project_id: PROJECT, project_name: 'widgets', variables: {} }; + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + new FileManager(root).ensureCapyGitignore(); + git(root, ['add', 'keep.lock', '.gitignore']); + git(root, ['commit', '-q', '-m', 'add keep.lock']); + + const ctx = { + orgId: ORG, + userId: USER, + authService: {}, + serviceClient: { + listBranches: async () => [], + listProjects: async () => [], + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets: mock(async () => { + throw uploadError; + }), + }, + } as unknown as DiscoveryContext; + const connector = createVercelConnector({ + fetch: fakeVercelFetch(vercelData()) as unknown as VercelFetchLike, + env: { T: 'tok_fake' }, + cwd: root, + sleep: async () => {}, + syncFolder: async () => ({ ok: true }), + }); + const run = await captureConsole(async () => { + const outcome = await connector.discover!(ctx, { nonTty: true, tokenEnv: 'T', yes: true, json: true } as ConnectOpts); + if (outcome.ok) await reportVercelDiscovery(outcome, { nonTty: true, tokenEnv: 'T', yes: true, json: true } as ConnectOpts, 'vercel'); + return outcome; + }); + return { outcome: run.result, printed: [...run.stdout, ...run.stderr].join('\n') }; + }), + ), + ); + + const failedFolder = (outcome: Awaited>['outcome']) => { + expect(outcome.ok).toBe(true); + if (!outcome.ok) throw new Error('unreachable'); + const folder = outcome.applied?.[0]; + expect(folder?.ok).toBe(false); + if (folder?.ok !== false) throw new Error('unreachable'); + return folder; + }; + + test('the incident: the service\'s "keep_file and env_blob are required" 400 reports CONNECTOR_PUSH_FAILED, never a code that names Dokploy', async () => { + const { outcome, printed } = await runWithUploadError(new CapyError('keep_file and env_blob are required', ERROR_CODES.SERVICE_ERROR, { status: 400 })); + const folder = failedFolder(outcome); + expect(folder.code).toBe('CONNECTOR_PUSH_FAILED'); + expect(folder.message).toBe('keep_file and env_blob are required'); + expect(mentionsDokploy(JSON.stringify(outcome))).toBe(false); + expect(mentionsDokploy(printed)).toBe(false); + expect(printed).toContain('CONNECTOR_PUSH_FAILED'); + }, 30_000); + + test('an untyped failure is CONNECTOR_PUSH_FAILED too', async () => { + const { outcome, printed } = await runWithUploadError(new Error('socket hang up')); + expect(failedFolder(outcome).code).toBe('CONNECTOR_PUSH_FAILED'); + expect(mentionsDokploy(printed)).toBe(false); + }, 30_000); + + test('a typed error from the service keeps its own code through the re-wrap (decided on the code, not the message)', async () => { + const { outcome, printed } = await runWithUploadError(new CapyError('Members cannot write to protected branches', ERROR_CODES.PERMISSION_DENIED, { status: 403 })); + const folder = failedFolder(outcome); + expect(folder.code).toBe(ERROR_CODES.PERMISSION_DENIED); + expect(mentionsDokploy(printed)).toBe(false); + }, 30_000); + +}); + +describe('Dokploy keeps its code for a typed service error (its output does not change)', () => { + test('a typed PERMISSION_DENIED on the upload is still DOKPLOY_PUSH_FAILED', async () => { + await withIsolatedGlobalDir(async () => { + const seen = await withTempDir('capy-dokploy-push', async (root) => { + const w = await stepWorld(root, DOKPLOY_DISCOVERY_CODES, { + keep: true, + service: { pushSecrets: async () => { throw new CapyError('no', ERROR_CODES.PERMISSION_DENIED, { status: 403 }); } }, + }); + return w.importAndPush(placeholderOutcome); + }); + expect(seen).toEqual({ ok: false, code: 'DOKPLOY_PUSH_FAILED' }); + }); + }, 30_000); +}); diff --git a/tests/commands/connectDokployDiscovery.test.ts b/tests/commands/connectDokployDiscovery.test.ts index a6c19f38..0c8ffe2f 100644 --- a/tests/commands/connectDokployDiscovery.test.ts +++ b/tests/commands/connectDokployDiscovery.test.ts @@ -56,7 +56,8 @@ import { type DiscoveryPlanEnv, type DiscoveryPlanFolder, } from '../../src/commands/connectors/dokployDiscovery'; -import { createDokployConnector as createRealDokployConnector, computeOverwritePlan, DISCOVERY_NEW_PROJECT } from '../../src/commands/connectors/dokploy'; +import { createDokployConnector as createRealDokployConnector } from '../../src/commands/connectors/dokploy'; +import { computeOverwritePlan, DISCOVERY_NEW_PROJECT } from '../../src/commands/connectors/discoverySteps'; import type { DokployConnectorDeps } from '../../src/commands/connectors/dokploy'; // These tests drive discovery over throwaway folders with a fake service and no session; bare `capy` in each folder diff --git a/tests/commands/connectVercelEmptyBranch.test.ts b/tests/commands/connectVercelEmptyBranch.test.ts new file mode 100644 index 00000000..3f2a8744 --- /dev/null +++ b/tests/commands/connectVercelEmptyBranch.test.ts @@ -0,0 +1,203 @@ +/** + * `capy connect vercel --discover` on a Vercel environment that leaves its Capy branch with no value. + * + * Two shapes, both of which used to upload an empty `env_blob` and be refused by the service (400 + * "keep_file and env_blob are required"): + * - a placeholder-only branch: every name in the environment is one Vercel will not return (sensitive), so keep.lock + * gets the placeholders and the blob holds no line; + * - an emptied branch: `--overwrite` on a branch that had values, in an environment with nothing readable left. + * + * The service here is the in-memory one of tests/helpers/fakeCapyServer.ts, which refuses an empty payload the way the + * real one does, and keeps what was saved so that a second run pulls it back (checkout of an existing branch). + * + * No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { randomBytes } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import type { DiscoveryContext, DiscoveryOutcome } from '../../src/commands/connectors/dokployDiscovery'; +import type { ConnectOpts } from '../../src/commands/connectors/registry'; +import { placeholdersOnBranch } from '../../src/core/placeholder'; +import { FileManager } from '../../src/files/fileManager'; +import { resolveProjectKey, wrapAndSaveMasterKey } from '../../src/crypto/keyResolver'; +import { ProjectManager } from '../../src/core/projectManager'; +import { EMPTY_ENV_BLOB } from '../../src/sync/commit'; +import type { KeepFile } from '../../src/types/index'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { fakeCapyServer, serverValues } from '../helpers/fakeCapyServer'; +import { fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, git, initRepo, withExitCodeRestored, withIsolatedGlobalDir, withTempDir } from '../helpers/testSandbox'; + +const ORG = 'org_empty'; +const USER = 'user_empty'; +const PROJECT = 'proj_empty'; + +const e = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +/** staging has B, production has A; `preview` is whatever the test puts there. */ +const vercelData = (preview: readonly RawEnv[]): FakeVercelData => ({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { + prj_web: [ + e({ id: 'p1', key: 'A', type: 'plain', listValue: 'a-value-SENT-11aa' }), + e({ id: 's1', key: 'B', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + ...preview, + ], + }, + custom: { prj_web: [{ id: 'c_stage', slug: 'staging' }] }, + values: { s1: 'b-value-SENT-22bb' }, +}); + +const PLACEHOLDER_PREVIEW = [e({ id: 'h1', key: 'HIDDEN', type: 'sensitive', target: ['preview'] })]; +const VALUE_PREVIEW = [e({ id: 'x1', key: 'X', type: 'plain', target: ['preview'], listValue: 'x-value-SENT-33cc' })]; +const NOTHING_READABLE_PREVIEW = [e({ id: 'bs1', key: 'BRANCHY', type: 'encrypted', target: ['preview'], gitBranch: 'dev', listValue: 'opaque' })]; + +type Outcome = DiscoveryOutcome; + +/** A repo with a committed keep.lock, and the service the real sequencer talks to; `run` discovers against a given Vercel. */ +async function inWorld( + fn: (w: { + root: string; + server: ReturnType; + projectKey: string; + run: (data: FakeVercelData, opts?: Partial) => Promise; + }) => Promise, +): Promise { + return withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-empty', async (root) => { + initRepo(root, 'git@github.com:acme/widgets.git'); + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), ORG, USER, keyOps); + const projectKey = await resolveProjectKey(ORG, PROJECT, USER, keyOps); + const keep: KeepFile = { version: '3.0', org_id: ORG, project_id: PROJECT, project_name: 'widgets', variables: {} }; + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + new FileManager(root).ensureCapyGitignore(); + git(root, ['add', 'keep.lock', '.gitignore']); + git(root, ['commit', '-q', '-m', 'add keep.lock']); + + const server = fakeCapyServer(); + /** Branches the service was asked to create: the record of the mock is the registry. */ + const createBranch = mock(async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false })); + const discover = async (data: FakeVercelData, opts: Partial = {}): Promise => { + const ctx = { + orgId: ORG, + userId: USER, + authService: {}, + serviceClient: { + ...server.client, + listBranches: async () => [...new Set(createBranch.mock.calls.map(([, name]) => name))].map((name) => ({ name })), + listProjects: async () => [], + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch, + }, + } as unknown as DiscoveryContext; + const connector = createVercelConnector({ + fetch: fakeVercelFetch(data) as unknown as VercelFetchLike, + env: { T: 'tok_fake' }, + cwd: root, + sleep: async () => {}, + syncFolder: async () => ({ ok: true }), + }); + const run = await captureConsole(() => connector.discover!(ctx, { nonTty: true, tokenEnv: 'T', yes: true, ...opts } as ConnectOpts)); + return run.result; + }; + return fn({ root, server, projectKey, run: discover }); + }), + ), + ); +} + +const appliedFolder = (outcome: Outcome) => { + expect(outcome.ok).toBe(true); + if (!outcome.ok) throw new Error('unreachable'); + const folder = outcome.applied?.[0]; + if (folder === undefined) throw new Error('no folder applied'); + return folder; +}; + +describe('a placeholder-only branch (every name in the environment is one Vercel will not return)', () => { + test('the upload is accepted: a non-empty env_blob and a keep_file carrying the placeholder; no value anywhere', async () => { + await inWorld(async ({ server, projectKey, run }) => { + const folder = appliedFolder(await run(vercelData(PLACEHOLDER_PREVIEW))); + expect(folder).toMatchObject({ ok: true }); + if (!folder.ok) return; + expect(folder.environments.map((env) => env.environmentName)).toEqual(['staging', 'preview', 'production']); + + const preview = folder.environments.find((env) => env.environmentName === 'preview')!; + expect(preview.outcome.imported).toEqual([]); + expect(preview.outcome.placeholders).toEqual([{ name: 'HIDDEN', code: 'sensitive_unreadable', provider: 'vercel' }]); + + const pushes = server.accepted().filter((c) => c.branch === 'preview'); + expect(pushes.length).toBeGreaterThan(0); + pushes.forEach((c) => { + expect(c.envBlob.length).toBeGreaterThan(0); + expect(c.keepFile.length).toBeGreaterThan(0); + expect(() => JSON.parse(c.keepFile)).not.toThrow(); + }); + const lastPreview = pushes[pushes.length - 1]; + expect(lastPreview.envBlob).toBe(EMPTY_ENV_BLOB); + expect(placeholdersOnBranch(JSON.parse(lastPreview.keepFile) as KeepFile, 'preview').map((p) => p.name)).toEqual(['HIDDEN']); + + expect(serverValues(server.state(), 'preview', projectKey)).toEqual({}); + expect(server.tape.mock.calls.every(([c]) => (c as { envBlob: string }).envBlob.length > 0)).toBe(true); + }); + }, 60_000); + + test('round-trips: a second run checks the saved branch out (a pull of an empty-env blob) without an error, and the placeholder is still there', async () => { + await inWorld(async ({ root, server, projectKey, run }) => { + const first = appliedFolder(await run(vercelData(PLACEHOLDER_PREVIEW))); + expect(first.ok).toBe(true); + + const second = appliedFolder(await run(vercelData(PLACEHOLDER_PREVIEW))); + expect(second).toMatchObject({ ok: true }); + if (!second.ok) return; + const preview = second.environments.find((env) => env.environmentName === 'preview')!; + expect(preview.branchCreated).toBe(false); // pulled from the server, not made again + + // checking `preview` out again reads its saved blob: the folder holds no variable and the placeholder + const checkedOut = await run(vercelData(PLACEHOLDER_PREVIEW), { environment: 'preview' }); + expect(appliedFolder(checkedOut)).toMatchObject({ ok: true, activeBranch: 'preview' }); + expect(new FileManager(root).readEncryptedEnvFile(projectKey)).toEqual({}); + expect(placeholdersOnBranch(new ProjectManager(root).readKeepFile(), 'preview').map((p) => p.name)).toEqual(['HIDDEN']); + expect(serverValues(server.state(), 'preview', projectKey)).toEqual({}); + }); + }, 60_000); +}); + +describe('an emptied branch (--overwrite on a branch that had values, in an environment with nothing readable left)', () => { + test('the upload is accepted: a non-empty env_blob, and the branch reads back with no variable', async () => { + await inWorld(async ({ server, projectKey, run }) => { + const first = appliedFolder(await run(vercelData(VALUE_PREVIEW))); + expect(first.ok).toBe(true); + expect(serverValues(server.state(), 'preview', projectKey)).toEqual({ X: 'x-value-SENT-33cc' }); + + const second = appliedFolder(await run(vercelData(NOTHING_READABLE_PREVIEW), { overwrite: true })); + expect(second).toMatchObject({ ok: true }); + if (!second.ok) return; + const preview = second.environments.find((env) => env.environmentName === 'preview')!; + expect(preview.outcome.cleared).toEqual(['X']); + + const lastPreview = server.accepted().filter((c) => c.branch === 'preview').pop()!; + expect(lastPreview.envBlob).toBe(EMPTY_ENV_BLOB); + expect(lastPreview.keepFile.length).toBeGreaterThan(0); + expect(serverValues(server.state(), 'preview', projectKey)).toEqual({}); + expect(server.tape.mock.calls.every(([c]) => (c as { envBlob: string }).envBlob.length > 0)).toBe(true); + }); + }, 60_000); + + test('round-trips: checking the emptied branch out again pulls an empty env with no error', async () => { + await inWorld(async ({ root, projectKey, run }) => { + await run(vercelData(VALUE_PREVIEW)); + await run(vercelData(NOTHING_READABLE_PREVIEW), { overwrite: true }); + + const again = appliedFolder(await run(vercelData(NOTHING_READABLE_PREVIEW), { environment: 'preview' })); + expect(again).toMatchObject({ ok: true, activeBranch: 'preview' }); + expect(new FileManager(root).readEncryptedEnvFile(projectKey)).toEqual({}); + }); + }, 60_000); +}); diff --git a/tests/commands/projectPickerWiring.test.ts b/tests/commands/projectPickerWiring.test.ts index d83192e8..b9bef5a0 100644 --- a/tests/commands/projectPickerWiring.test.ts +++ b/tests/commands/projectPickerWiring.test.ts @@ -21,7 +21,7 @@ afterAll(() => { import { defaultAskExistingOrNewProject, DISCOVERY_NEW_PROJECT, -} from '../../src/commands/connectors/dokploy'; +} from '../../src/commands/connectors/discoverySteps'; import { orderProjectsForPicker, vercelProjectQuestion, diff --git a/tests/helpers/fakeCapyServer.ts b/tests/helpers/fakeCapyServer.ts index 4e9c7edb..e90c77fb 100644 --- a/tests/helpers/fakeCapyServer.ts +++ b/tests/helpers/fakeCapyServer.ts @@ -149,6 +149,10 @@ export function fakeCapyServer(opts: FakeServerOpts = {}) { const pushSecrets = mock(async (projectId: string, keepFile: string, envBlob: string, branch: string, _onRate?: unknown, baseKeepHash?: string) => { const call: PushCall = { projectId, keepFile, envBlob, branch, baseKeepHash }; + // The real service's first check (service/src/routes/secrets.ts): an empty keep_file or env_blob is a 400, not a save. + if (!keepFile || !envBlob) { + throw new CapyError('keep_file and env_blob are required', ERROR_CODES.SERVICE_ERROR, { status: 400 }); + } const before = stateNow(); tape(call); if (staleAgainst(before, call)) { diff --git a/tests/sync/emptyEnvBlob.test.ts b/tests/sync/emptyEnvBlob.test.ts new file mode 100644 index 00000000..a4e14b53 --- /dev/null +++ b/tests/sync/emptyEnvBlob.test.ts @@ -0,0 +1,144 @@ +/** + * A branch with no variables must still save. + * + * The service answers 400 to an empty `keep_file` or `env_blob` (`!env_blob`). A branch can have no variable and still + * need a save: an import whose names are all placeholders (keep.lock holds them, the blob holds no line), or a + * `--overwrite` that clears the last name. The blob used to be '' then, and the save was refused. + * + * On the in-memory server of tests/helpers/fakeCapyServer.ts, which refuses an empty payload the way the service does: + * - an empty branch and a placeholder-only branch push a non-empty `env_blob` and a non-empty, valid `keep_file`; + * - the pushed state reads back as no variables, without an error, by every reader the CLI has; + * - encrypting an empty value gives a non-empty ciphertext that decrypts to empty (it is not what the blob holds, and why). + */ +import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'; +import { rmSync } from 'fs'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import { writeImportedAndSync, type ResolvedContext } from '../../src/commands/connectors/shared'; +import { placeholdersOnBranch } from '../../src/core/placeholder'; +import { FileManager } from '../../src/files/fileManager'; +import { EMPTY_ENV_BLOB, commitFolder, envBlobOf, readSnapshot } from '../../src/sync/commit'; +import type { KeepFile } from '../../src/types/index'; +import { fakeCapyServer, seedServer, serverValues } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +const KEY = Encryptor.generateKey(); +const PREVIEW = 'preview'; +const MARKER = { provider: 'vercel', reason: 'sensitive_unreadable' } as const; +/** `preview` holds two values; `production` holds one. */ +const SEED = seedServer({ projectKey: KEY, values: { production: { X: 'x-prod' }, [PREVIEW]: { X: 'x-1', Y: 'y-1' } } }); +/** Only `production` exists on the server: `preview` is a branch with nothing saved yet. */ +const SEED_PRODUCTION_ONLY = seedServer({ projectKey: KEY, values: { production: { X: 'x-prod' } } }); + +const globalDirName = `.capy-test-empty-blob-${process.pid}`; +const savedGlobalDirName = process.env.CAPY_GLOBAL_DIR_NAME; +beforeAll(() => { + process.env.CAPY_GLOBAL_DIR_NAME = globalDirName; +}); +afterAll(() => { + rmSync(getGlobalCapyDir(), { recursive: true, force: true }); + if (savedGlobalDirName === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = savedGlobalDirName; +}); + +const made = mock((_: Folder) => undefined); +afterEach(() => made.mock.calls.forEach(([folder]) => removeFolder(folder))); + +/** A folder on `preview` holding `env`, and a server that already holds `seed`. */ +function world(env: Record, seed = SEED) { + const server = fakeCapyServer({ initial: seed }); + const folder = makeFolder({ projectKey: KEY, branch: PREVIEW, keep: seed.keep as KeepFile, env, client: server.client }); + made(folder); + return { server, folder }; +} + +const asContext = (folder: Folder, server: ReturnType): ResolvedContext => + ({ ...folder.ctx, serviceClient: server.client, keep: folder.keep(), localPlaintext: folder.env() }) as unknown as ResolvedContext; + +describe('the blob of a branch with no variables', () => { + test('is a non-empty comment line, and an empty set of lines is the only thing that makes it', () => { + expect(EMPTY_ENV_BLOB.length).toBeGreaterThan(0); + expect(envBlobOf({})).toBe(EMPTY_ENV_BLOB); + expect(envBlobOf({ A: 'capy:r1:abc', B: 'capy:r2:def' })).toBe('A=capy:r1:abc\nB=capy:r2:def'); + }); + + test('reads back as no variables by every reader: dotenv parse, the folder\'s own parse, and a line parser that skips what is not NAME=value', () => { + expect(new FileManager().parseEnvContent(EMPTY_ENV_BLOB)).toEqual({}); + const lineParsed = EMPTY_ENV_BLOB.split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith('#') && l.includes('=')); + expect(lineParsed).toEqual([]); + }); + + test('encrypting an empty value gives a non-empty ciphertext that decrypts to empty (the blob still cannot be one: it has no NAME to hang that line on)', () => { + const cipher = Encryptor.encrypt('', KEY); + expect(cipher.length).toBeGreaterThan(0); + expect(Encryptor.decrypt(cipher, KEY)).toBe(''); + }); +}); + +describe('an empty branch saves (clearing the last name)', () => { + test('pushes a non-empty env_blob and a valid keep_file; the server accepts it and the branch reads back empty', async () => { + const w = world({ X: 'x-1', Y: 'y-1' }); + await commitFolder(w.folder.ctx, { set: {}, remove: ['X', 'Y'] }); + + const pushed = w.server.accepted(); + expect(pushed).toHaveLength(1); + expect(pushed[0].envBlob).toBe(EMPTY_ENV_BLOB); + expect(pushed[0].keepFile.length).toBeGreaterThan(0); + const keep = JSON.parse(pushed[0].keepFile) as KeepFile; + expect(keep.project_id).toBe('proj'); + expect(Object.values(keep.variables).flat().filter((e) => e.branch === PREVIEW)).toEqual([]); + expect(Object.values(keep.variables).flat().filter((e) => e.branch === 'production')).toHaveLength(1); // the other branch is untouched + + expect(serverValues(w.server.state(), PREVIEW, KEY)).toEqual({}); + expect(w.folder.env()).toEqual({}); + }); + + test('round-trips: the next read of the branch decrypts to an empty env with no error, and a later write on it works', async () => { + const w = world({ X: 'x-1', Y: 'y-1' }); + await commitFolder(w.folder.ctx, { set: {}, remove: ['X', 'Y'] }); + + const snapshot = await readSnapshot(w.server.client, 'proj', PREVIEW, KEY); + expect(snapshot.entries).toEqual({}); + expect(new FileManager(w.folder.dir).readEncryptedEnvFile(KEY)).toEqual({}); + + await commitFolder({ ...w.folder.ctx, keep: w.folder.keep(), localPlaintext: w.folder.env() }, { set: { Z: 'z-1' }, remove: [] }); + expect(serverValues(w.server.state(), PREVIEW, KEY)).toEqual({ Z: 'z-1' }); + }); +}); + +describe('a placeholder-only branch saves', () => { + test('pushes a non-empty env_blob and a keep_file carrying the placeholder; the server accepts it', async () => { + const w = world({}, SEED_PRODUCTION_ONLY); + await writeImportedAndSync(asContext(w.folder, w.server), [], { push: true, placeholders: [{ name: 'HIDDEN', marker: MARKER }] }); + + const pushed = w.server.accepted(); + expect(pushed).toHaveLength(1); + expect(pushed[0].envBlob).toBe(EMPTY_ENV_BLOB); + const keep = JSON.parse(pushed[0].keepFile) as KeepFile; + expect(placeholdersOnBranch(keep, PREVIEW).map((p) => [p.name, p.marker])).toEqual([['HIDDEN', MARKER]]); + + expect(serverValues(w.server.state(), PREVIEW, KEY)).toEqual({}); + expect(placeholdersOnBranch(w.server.state().keep, PREVIEW).map((p) => p.name)).toEqual(['HIDDEN']); + }); + + test('round-trips: the folder and a fresh read both say no variables and one placeholder, with no error', async () => { + const w = world({}, SEED_PRODUCTION_ONLY); + await writeImportedAndSync(asContext(w.folder, w.server), [], { push: true, placeholders: [{ name: 'HIDDEN', marker: MARKER }] }); + + const snapshot = await readSnapshot(w.server.client, 'proj', PREVIEW, KEY); + expect(snapshot.entries).toEqual({}); + expect(placeholdersOnBranch(snapshot.keep, PREVIEW).map((p) => p.name)).toEqual(['HIDDEN']); + expect(w.folder.env()).toEqual({}); + expect(placeholdersOnBranch(w.folder.keep(), PREVIEW).map((p) => p.name)).toEqual(['HIDDEN']); + }); + + test('an empty set of values with an unrelated placeholder still keeps the branch\'s other values out of the blob (no stand-in line)', async () => { + const w = world({}, SEED_PRODUCTION_ONLY); + await writeImportedAndSync(asContext(w.folder, w.server), [], { push: true, placeholders: [{ name: 'HIDDEN', marker: MARKER }] }); + const blob = w.server.accepted()[0].envBlob; + expect(blob).not.toContain('HIDDEN'); + expect(blob.split('\n').filter((l) => l.includes('='))).toEqual([]); + }); +}); From 2e8aa5c5109f00be737495afbc1dd87b8cc0e839 Mon Sep 17 00:00:00 2001 From: cvince Date: Tue, 3 Nov 2026 07:14:57 -0700 Subject: [PATCH 14/22] test: removing the last variable pushes the empty-branch blob --- tests/commands/removeCommand.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/commands/removeCommand.test.ts b/tests/commands/removeCommand.test.ts index 1bef860f..0da00f00 100644 --- a/tests/commands/removeCommand.test.ts +++ b/tests/commands/removeCommand.test.ts @@ -43,6 +43,7 @@ import { hashValue } from '../../src/commands/statusCommand'; import { Encryptor } from '../../src/crypto/encryptor'; import { SyncEngine } from '../../src/sync/syncEngine'; import { fakeCapyServer } from '../helpers/fakeCapyServer'; +import { EMPTY_ENV_BLOB } from '../../src/sync/commit'; import { FileManager } from '../../src/files/fileManager'; import { ProjectManager } from '../../src/core/projectManager'; import { upsertTarget } from '../../src/deploy/config'; @@ -291,7 +292,8 @@ describe('removeAndSync', () => { await removeAndSync(ctx, ['ONLY_ONE']); expect(server.pushSecrets.mock.calls.length).toBe(1); - expect(server.pushSecrets.mock.calls[0][2]).toBe(''); + // A branch with no variables is stored as a comment line: the service refuses an empty env_blob. + expect(server.pushSecrets.mock.calls[0][2]).toBe(EMPTY_ENV_BLOB); const pushedKeep = JSON.parse(server.pushSecrets.mock.calls[0][1] as string) as KeepFile; expect(Object.keys(pushedKeep.variables)).toEqual([]); }); From 5e4b98e14abb1d0001453d20c820eee64818e005 Mon Sep 17 00:00:00 2001 From: cvince Date: Wed, 4 Nov 2026 17:09:33 -0700 Subject: [PATCH 15/22] fix(vercel): a 404 on one variable's value becomes a placeholder, not a failed environment --- src/commands/connectors/vercel.ts | 7 +-- src/deploy/vercelApi.ts | 52 +++++++++++++++++------ tests/commands/vercelPlaceholders.test.ts | 51 ++++++++++++++++++++++ tests/deploy/vercelApi.test.ts | 33 ++++++++++++++ tests/helpers/fakeVercel.ts | 4 ++ 5 files changed, 131 insertions(+), 16 deletions(-) diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts index 28d8b33c..132503d9 100644 --- a/src/commands/connectors/vercel.ts +++ b/src/commands/connectors/vercel.ts @@ -22,7 +22,8 @@ * * A value Vercel has but will not give back (`sensitive_unreadable`, * `legacy_secret_unreadable`, or a value route answer with `decrypted: false`, - * `value_not_decrypted`) is not skipped: it becomes a PLACEHOLDER on the Capy + * `value_not_decrypted`, or a 404 on that one + * record's value route, `value_not_found`) is not skipped: it becomes a PLACEHOLDER on the Capy * branch of every environment it is in. A placeholder is a flag on the * variable's keep.lock entry, with no value (`core/placeholder.ts`). A name * that already has a value in Capy keeps it, untouched, and is reported in @@ -516,7 +517,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const fullCandidates = values.value.entries.map((e) => ({ name: e.record.key, value: e.value })); // A name Vercel HAS but will not show us (sensitive, scoped, empty, …) is // never cleared locally just because it is unreadable. - const unreadable = [...source.skipped, ...values.value.empty, ...values.value.undecrypted]; + const unreadable = [...source.skipped, ...values.value.empty, ...values.value.undecrypted, ...values.value.notFound]; const protectedNames = unreadable.map((s) => s.name); const plan = computeOverwritePlan(fullCandidates, protectedNames, ctx.localPlaintext); const warnings = truncationWarnings(source); @@ -609,7 +610,7 @@ export function createVercelConnector(deps: VercelConnectorDeps = {}): Connector const inScope = (name: string): boolean => !restrict || restrict.includes(name); // A name Vercel will not give back, with no value here, is a placeholder on this branch; one that has a value keeps it and is reported as kept. const split = splitPlaceholders( - [...source.skipped, ...values.value.empty, ...values.value.undecrypted].filter((s) => inScope(s.name)), + [...source.skipped, ...values.value.empty, ...values.value.undecrypted, ...values.value.notFound].filter((s) => inScope(s.name)), new Set(Object.keys(ctx.localPlaintext)), new Set(candidates.map((c) => c.name)), ); diff --git a/src/deploy/vercelApi.ts b/src/deploy/vercelApi.ts index 561e6fb3..481c99c8 100644 --- a/src/deploy/vercelApi.ts +++ b/src/deploy/vercelApi.ts @@ -714,13 +714,20 @@ export type VercelSkipReason = | 'duplicate_key' | 'empty_value' /** Vercel returned the value route's answer with `decrypted: false`: it would not give the value back. */ - | 'value_not_decrypted'; + | 'value_not_decrypted' + /** Vercel answered the value route for this one record with HTTP 404: the record is listed but its value is not there to read. */ + | 'value_not_found'; /** * The reasons that mean "Vercel has this value and will not give it back". Each becomes a placeholder * on its Capy branch (core/placeholder.ts); every other reason stays a skip. */ -export const PLACEHOLDER_SKIP_REASONS: readonly VercelSkipReason[] = ['sensitive_unreadable', 'legacy_secret_unreadable', 'value_not_decrypted']; +export const PLACEHOLDER_SKIP_REASONS: readonly VercelSkipReason[] = [ + 'sensitive_unreadable', + 'legacy_secret_unreadable', + 'value_not_decrypted', + 'value_not_found', +]; export const isPlaceholderReason = (reason: VercelSkipReason): boolean => PLACEHOLDER_SKIP_REASONS.includes(reason); @@ -805,6 +812,30 @@ export interface ResolvedEnvValues { empty: readonly VercelSkippedRecord[]; /** Records Vercel would not decrypt (`decrypted: false`): they have a value Capy cannot read, so they are placeholders, not empty. */ undecrypted: readonly VercelSkippedRecord[]; + /** Records whose per-id value route answered HTTP 404: the run goes on without them, and they are placeholders like `undecrypted`. */ + notFound: readonly VercelSkippedRecord[]; +} + +/** One record's value read: a value (or none, or "not decrypted"), or the route's 404 for that record. */ +type FetchedValue = { readonly record: VercelEnvRecord; readonly value: VercelEnvValue } | { readonly record: VercelEnvRecord; readonly notFound: true }; + +/** The structured signal for "Vercel has no value route answer for this id": a Vercel 404, by class and status, never by message. */ +const isValueNotFound = (err: unknown): boolean => err instanceof VercelApiError && (err.status === 404 || err.code === 'not_found'); + +/** Reads one record's value; a 404 on the per-id route is that record's outcome, every other failure still throws. */ +async function fetchOneValue( + client: Pick, + projectId: string, + teamId: string | undefined, + record: VercelEnvRecord, +): Promise { + if (record.type === 'plain' && record.value !== undefined) return { record, value: record.value }; + try { + return { record, value: await client.getEnvValue(projectId, record.id, teamId) }; + } catch (err) { + if (isValueNotFound(err)) return { record, notFound: true }; + throw err; + } } /** @@ -820,17 +851,11 @@ export async function resolveEnvValues( teamId: string | undefined, records: readonly VercelEnvRecord[], ): Promise { - const fetched = await records.reduce>>( - async (accPromise, record) => { - const acc = await accPromise; - const value = - record.type === 'plain' && record.value !== undefined - ? record.value - : await client.getEnvValue(projectId, record.id, teamId); - return [...acc, { record, value }]; - }, - Promise.resolve([]), - ); + const all = await records.reduce>(async (accPromise, record) => { + const acc = await accPromise; + return [...acc, await fetchOneValue(client, projectId, teamId, record)]; + }, Promise.resolve([])); + const fetched = all.flatMap((f): ReadonlyArray<{ record: VercelEnvRecord; value: VercelEnvValue }> => ('notFound' in f ? [] : [f])); return { entries: fetched.flatMap(({ record, value }) => (value === undefined || value === '' || isUndecrypted(value) ? [] : [{ record, value }])), empty: fetched.flatMap(({ record, value }): readonly VercelSkippedRecord[] => @@ -839,6 +864,7 @@ export async function resolveEnvValues( undecrypted: fetched.flatMap(({ record, value }): readonly VercelSkippedRecord[] => isUndecrypted(value) ? [{ name: record.key, reason: 'value_not_decrypted' }] : [], ), + notFound: all.flatMap((f): readonly VercelSkippedRecord[] => ('notFound' in f ? [{ name: f.record.key, reason: 'value_not_found' }] : [])), }; } diff --git a/tests/commands/vercelPlaceholders.test.ts b/tests/commands/vercelPlaceholders.test.ts index 34829ead..ab5468a6 100644 --- a/tests/commands/vercelPlaceholders.test.ts +++ b/tests/commands/vercelPlaceholders.test.ts @@ -414,3 +414,54 @@ describe('surfaces', () => { }); }); }); + +// ── one variable's value route answers 404: that name is a placeholder, the rest still import ── + +const GONE_ENV = env({ id: 'e_gone', key: 'GONE', type: 'encrypted', target: ['production'], listValue: 'opaque' }); +const GONE_DATA = (status: number): FakeVercelData => ({ + ...DATA, + envs: { prj_web: [...ENVS, GONE_ENV] }, + valueStatus: { e_gone: status }, +}); + +describe('import: a 404 on one variable\'s value route', () => { + test('production: the 404 name is a placeholder with reason value_not_found; the other names import', async () => { + const out = okOutcome(await connector(GONE_DATA(404)).import!(ctxWith('production'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable', GONE: 'value_not_found' }); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + expect(codes(out.skipped)).toEqual({ GENUINELY_EMPTY: 'empty_value' }); + }); + + test('--overwrite --yes: the 404 name is a placeholder and is never cleared when it has a local value', async () => { + const out = okOutcome(await connector(GONE_DATA(404)).import!(ctxWith('production'), { ...BASE_OPTS, overwrite: true, yes: true })); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable', GONE: 'value_not_found' }); + const kept = okOutcome(await connector(GONE_DATA(404)).import!(ctxWith('production', { GONE: 'mine-already' }), { ...BASE_OPTS, overwrite: true, yes: true })); + expect(kept.kept).toEqual([{ name: 'GONE', code: 'CAPY_VALUE_KEPT', reason: 'value_not_found' }]); + expect(kept.cleared).toEqual([]); + }); + + test('--json: the 404 name is in placeholders with its reason; keep.lock holds a flag, no value', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-ph-404', async (dir) => { + const { stdout } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector(GONE_DATA(404)), 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, noPush: true, json: true }), + ); + const parsed = JSON.parse(stdout[0]); + expect(parsed.placeholders).toEqual([ + { name: 'SENS', code: 'sensitive_unreadable' }, + { name: 'GONE', code: 'value_not_found' }, + ]); + expect(parsed.imported).toEqual(['PLAIN']); + expect(placeholderOf(readKeep(dir), 'GONE', 'production')).toEqual({ provider: 'vercel', reason: 'value_not_found' }); + }), + ), + ); + }); + + test('a 500 on the same route still fails the run, with the Vercel error code', async () => { + const out = await connector(GONE_DATA(500)).import!(ctxWith('production'), BASE_OPTS); + expect(out.ok).toBe(false); + expect(out.ok === false && out.code).toBe('VERCEL_API_ERROR'); + }); +}); diff --git a/tests/deploy/vercelApi.test.ts b/tests/deploy/vercelApi.test.ts index 25472fc9..8cbff5de 100644 --- a/tests/deploy/vercelApi.test.ts +++ b/tests/deploy/vercelApi.test.ts @@ -15,6 +15,7 @@ import { VERCEL_CONNECTOR_SECRET_NAME, VERCEL_TARGET_SECRET_NAME, VercelApiError, + isPlaceholderReason, buildEnvironmentPlans, createVercelClient, groupSkipped, @@ -470,6 +471,38 @@ describe('resolveEnvValues', () => { }); }); +describe('resolveEnvValues: a failed value read for one record', () => { + const records = [ + rec({ id: 'e1', key: 'A', type: 'encrypted' }), + rec({ id: 'e2', key: 'B', type: 'encrypted' }), + rec({ id: 'e3', key: 'C', type: 'encrypted' }), + ]; + + test('a 404 on one record becomes value_not_found; the others resolve normally and all are asked for', async () => { + const getEnvValue = mock(async (_p: string, envId: string, _t?: string) => { + if (envId === 'e2') throw new VercelApiError('not_found', 404, 'Vercel returned HTTP 404.'); + return `decrypted-${envId}`; + }); + const r = await resolveEnvValues({ getEnvValue }, 'prj', undefined, records); + expect(getEnvValue.mock.calls.map(([, envId]) => envId)).toEqual(['e1', 'e2', 'e3']); + expect(r.entries.map((e) => [e.record.key, e.value])).toEqual([ + ['A', 'decrypted-e1'], + ['C', 'decrypted-e3'], + ]); + expect(r.notFound).toEqual([{ name: 'B', reason: 'value_not_found' }]); + expect(r.empty).toEqual([]); + expect(r.undecrypted).toEqual([]); + expect(isPlaceholderReason('value_not_found')).toBe(true); + }); + + test('a 500 still throws, as does a non-VercelApiError', async () => { + const serverError = { getEnvValue: async () => { throw new VercelApiError('server_error', 500, 'Vercel returned HTTP 500.'); } }; + await expect(resolveEnvValues(serverError, 'prj', undefined, records)).rejects.toMatchObject({ code: 'server_error', status: 500 }); + const plain = { getEnvValue: async () => { throw new Error('boom'); } }; + await expect(resolveEnvValues(plain, 'prj', undefined, records)).rejects.toThrow('boom'); + }); +}); + describe('resolveVercelToken', () => { const base = { env: {} as Record, interactive: false }; diff --git a/tests/helpers/fakeVercel.ts b/tests/helpers/fakeVercel.ts index 2ec43cee..17c9661f 100644 --- a/tests/helpers/fakeVercel.ts +++ b/tests/helpers/fakeVercel.ts @@ -30,6 +30,8 @@ export interface FakeVercelData { values?: Readonly>; /** Env record ids whose per-id answer says `decrypted: false` (Vercel would not decrypt the value, e.g. a role that cannot read production values). */ undecrypted?: readonly string[]; + /** Env record ids whose per-id value route answers with this HTTP status instead of a value (e.g. 404 or 500). */ + valueStatus?: Readonly>; /** Project list page size, to exercise pagination. */ pageSize?: number; /** Set `pagination.next` on the env list, to exercise the truncation warning. */ @@ -107,6 +109,8 @@ export function fakeVercelFetch(data: FakeVercelData): VercelFetchLike { if (envValueRoute) { const projectId = decodeURIComponent(envValueRoute[1]); const envId = decodeURIComponent(envValueRoute[2]); + const forcedStatus = data.valueStatus?.[envId]; + if (forcedStatus !== undefined) return json({ error: { code: 'forced', message: 'forced' } }, forcedStatus); const rec = (data.envs[projectId] ?? []).find((e) => e.id === envId); if (!rec) return json({ error: { code: 'not_found', message: 'nope' } }, 404); const value = data.values?.[envId]; From 3220f2d3f2cd2041653b0f967947e5f137bbe555 Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 5 Nov 2026 21:05:49 -0700 Subject: [PATCH 16/22] feat(vercel): judge a preview's git branch from deployments, scoped variables and the default branch (CAP-730) The all-branches preview no longer takes the production branch by default. Recent preview deployments, branch-scoped variables and the GitHub default branch (when it is not the production branch) each name a branch; agreement is certain, disagreement takes the strongest signal and needs review, no signal keeps the production branch and needs review. A custom environment's branch rule names its branch. Signals are listed in the proposal's evidence. A preview scoped to a git branch with no variable in common with its Capy branch stays unresolved (BRANCH_SCOPED_NO_VARIABLES) instead of becoming an empty proposal. A failed deployments read is a coded notice, never a failed discovery. --- docs/cli-reference.md | 1 + src/commands/deployDiscover/discovery.ts | 23 +- src/commands/deployDiscover/facts.ts | 47 +- src/commands/deployDiscover/kind.ts | 11 +- src/commands/deployDiscover/vercel.ts | 86 +++- src/commands/deployDiscover/vercelBranch.ts | 173 ++++++++ src/deploy/vercelApi.ts | 46 +- src/types/index.ts | 2 + .../deployDiscoverVercelFacts.test.ts | 3 +- .../deployDiscoverVercelPreviewBranch.test.ts | 412 ++++++++++++++++++ tests/deploy/vercelApi.test.ts | 3 +- tests/helpers/fakeVercel.ts | 26 +- 12 files changed, 816 insertions(+), 17 deletions(-) create mode 100644 src/commands/deployDiscover/vercelBranch.ts create mode 100644 tests/commands/deployDiscoverVercelPreviewBranch.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3c28d301..40ba6312 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1024,6 +1024,7 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `VERCEL_SENSITIVE_REPLACE_UNCONFIRMED` - `VERCEL_PROJECT_NOT_GITHUB` - `VERCEL_ENV_LIST_TRUNCATED` +- `VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE` - `CAPY_VALUE_KEPT` - `CONNECTOR_PUSH_FAILED` - `CONNECTOR_FOLDER_NOT_INITIALIZED` diff --git a/src/commands/deployDiscover/discovery.ts b/src/commands/deployDiscover/discovery.ts index 68fdf979..b83e389f 100644 --- a/src/commands/deployDiscover/discovery.ts +++ b/src/commands/deployDiscover/discovery.ts @@ -50,7 +50,9 @@ export type UnresolvedReason = | 'AMBIGUOUS' | 'BRANCH_UNMAPPED' | 'NO_REPO_LINK' - | 'SERVICE_HAS_NO_GIT_SOURCE'; + | 'SERVICE_HAS_NO_GIT_SOURCE' + /** A Vercel preview scoped to one git branch whose variables match none of the Capy branch's: evidence for the preview's branch, not a target. */ + | 'BRANCH_SCOPED_NO_VARIABLES'; /** One entry of the plan file (`--plan`). IDs and names only. */ export interface PlanEntry { @@ -95,6 +97,8 @@ export interface Proposal { readonly var_overlap: VarOverlap; /** The name of an existing Dokploy target on this project branch that points at a DIFFERENT service (a plan entry for it is refused `TARGET_EXISTS`), else `null`. */ readonly conflicting_target: string | null; + /** Vercel: how the service's git branch was settled, and every signal read for it (CAP-730). Absent for a service that is not judged. */ + readonly git_branch?: Readonly>; }; readonly confidence: { readonly project: Confidence; @@ -341,6 +345,20 @@ function forProject( const projectNames = project.var_names_by_branch[choice.branch] ?? []; const overlap = overlapOf(service.env_var_names, projectNames); const vars = sharedNames(service.env_var_names, projectNames); + const emptyReason = vars.length === 0 ? kind?.emptyProposalReason?.(service) ?? null : null; + if (emptyReason !== null) { + return { + kind: 'unresolved', + unresolved: { + reason: emptyReason, + service_id: service.service_id, + project_id: project.project_id, + candidates: [{ project_id: project.project_id, project_name: project.project_name, branch: choice.branch }], + }, + }; + } + const gitBranchEvidence = kind?.gitBranchEvidence?.(service); + const branchConfidence = kind?.branchConfidence?.(service, choice, defaultBranch); const draft: Draft = { proposal_id: proposalIdOf(service.service_id, project.project_id, choice.branch), service_id: service.service_id, @@ -354,10 +372,11 @@ function forProject( branch_match: choice.standard, var_overlap: overlap, conflicting_target: conflict?.name ?? null, + ...(gitBranchEvidence === undefined ? {} : { git_branch: gitBranchEvidence }), }, confidence: { project: matched.exactPath && matched.projects.length === 1 && conflict === undefined ? 'certain' : 'needs_review', - branch: choice.standard && typeof defaultBranch === 'string' ? 'certain' : 'needs_review', + branch: branchConfidence ?? (choice.standard && typeof defaultBranch === 'string' ? 'certain' : 'needs_review'), vars: varsConfidence(overlap, service.env_var_names.length), }, plan_entry: { project_id: project.project_id, branch: choice.branch, service_id: service.service_id, git_branch: gitBranch, vars }, diff --git a/src/commands/deployDiscover/facts.ts b/src/commands/deployDiscover/facts.ts index 7268c85e..3905e454 100644 --- a/src/commands/deployDiscover/facts.ts +++ b/src/commands/deployDiscover/facts.ts @@ -71,6 +71,41 @@ export interface DokployServiceFacts { readonly env_var_names: readonly string[]; } +/** + * What points at a git branch for a Vercel environment (CAP-730). `RECENT_PREVIEW_DEPLOYMENTS`: the branches the + * latest preview deployments built from; `BRANCH_SCOPED_VARIABLES`: preview variables scoped to a branch; + * `GITHUB_DEFAULT_BRANCH`: the repo's default branch, when it is not the production branch; + * `CUSTOM_ENVIRONMENT_RULE`: a custom environment's branch matcher. + */ +export type BranchSignalCode = + | 'RECENT_PREVIEW_DEPLOYMENTS' + | 'BRANCH_SCOPED_VARIABLES' + | 'GITHUB_DEFAULT_BRANCH' + | 'CUSTOM_ENVIRONMENT_RULE'; + +export interface BranchSignal { + readonly code: BranchSignalCode; + /** The branches this signal names, strongest first: `weight` is the deployment count, the variable count, or 1. */ + readonly branches: ReadonlyArray<{ + readonly branch: string; + readonly weight: number; + /** `BRANCH_SCOPED_VARIABLES`: the names of the variables scoped to this branch. Never a value. */ + readonly names?: readonly string[]; + /** `BRANCH_SCOPED_VARIABLES`: those of `names` the all-branches preview also has (Vercel reads the scoped one on that branch). */ + readonly shadows?: readonly string[]; + }>; + /** `CUSTOM_ENVIRONMENT_RULE`: the matcher as Vercel holds it. */ + readonly rule?: { readonly type: string; readonly pattern: string }; +} + +/** How the git branch of a Vercel service was settled. */ +export type GitBranchBasis = + | 'signals_agree' + | 'strongest_signal' + | 'no_signals' + | 'custom_environment_rule' + | 'custom_environment_pattern'; + /** A Vercel environment of a project that holds variables (CAP-696): one deploy target is one of these. */ export interface VercelServiceFacts { /** `:`; see `vercelServiceId`. Opaque to the caller: copy it into a plan. */ @@ -89,8 +124,18 @@ export interface VercelServiceFacts { /** The project's root directory as Vercel holds it; `null`: the repo root. */ readonly root_directory: string | null; readonly repo: RepoOut | null; - /** The project's production branch (the deploy PR base); `null` when Vercel names none. */ + /** + * The git branch the service deploys from (the deploy PR base). Production, `development` and a branch-scoped preview: the + * project's production branch. The all-branches preview and a custom environment: judged from `git_branch_signals` (CAP-730), + * the production branch when nothing points elsewhere. `null` when Vercel names none. + */ readonly branch: string | null; + /** All-branches preview and custom environments only: every signal read for the branch (names and counts, never a value). */ + readonly git_branch_signals?: readonly BranchSignal[]; + /** Set with `git_branch_signals` once the repo's default branch is known: how `branch` was settled. */ + readonly git_branch_basis?: GitBranchBasis; + /** Set with `git_branch_basis`: `certain` only when the signals agree (or a custom environment's rule names one branch). */ + readonly git_branch_confidence?: 'certain' | 'needs_review'; /** The REPO-RELATIVE folder of the project; `null` when it has no repo. */ readonly path: string | null; /** Names only: never a value. */ diff --git a/src/commands/deployDiscover/kind.ts b/src/commands/deployDiscover/kind.ts index 5ee7eff2..be4ef466 100644 --- a/src/commands/deployDiscover/kind.ts +++ b/src/commands/deployDiscover/kind.ts @@ -12,7 +12,7 @@ */ import type { TargetConfig } from '../../deploy/adapter'; import type { OrgRepoLink } from '../../service/serviceClient'; -import type { PlanEntry } from './discovery'; +import type { Confidence, PlanEntry, UnresolvedReason } from './discovery'; import type { ExistingTarget, ServiceFacts } from './facts'; /** A pointer into the `capy help --json` document. */ @@ -39,6 +39,15 @@ export interface KindOverrides { readonly configured: (targets: readonly ExistingTarget[], service: ServiceFacts, capyBranch: string) => ExistingTarget | undefined; /** An existing target that would clash with a new one for this service on this Capy branch. */ readonly conflicting: (targets: readonly ExistingTarget[], service: ServiceFacts, capyBranch: string) => ExistingTarget | undefined; + /** Replaces the branch confidence of a proposal (`undefined`: the shared rule stands). */ + readonly branchConfidence?: (service: ServiceFacts, choice: BranchChoice, defaultBranch: string | null | undefined) => Confidence | undefined; + /** What a proposal's `evidence.git_branch` shows: how the service's git branch was settled (names, ids and counts only). */ + readonly gitBranchEvidence?: (service: ServiceFacts) => Readonly> | undefined; + /** + * A service that would be proposed with NO variable is not proposed when this returns a reason: it stays unresolved with that + * reason instead, so an empty proposal never competes with a real one. `null`: propose as usual. + */ + readonly emptyProposalReason?: (service: ServiceFacts) => UnresolvedReason | null; /** A plan entry's identity: two entries with the same key are `DUPLICATE_ENTRY`. */ readonly duplicateKey: (entry: PlanEntry) => string; /** The `target` a proposal shows. */ diff --git a/src/commands/deployDiscover/vercel.ts b/src/commands/deployDiscover/vercel.ts index 191c3b4c..841d6fbc 100644 --- a/src/commands/deployDiscover/vercel.ts +++ b/src/commands/deployDiscover/vercel.ts @@ -10,6 +10,10 @@ * file is what is Vercel's own: reading the projects, the service facts, and * the `KindOverrides` that turn a plan entry into a Vercel target. * + * The git branch of a service (the deploy PR base): production's is the project's production branch. The all-branches + * preview is judged from recent preview deployments, branch-scoped variables and the GitHub default branch; a custom + * environment from its branch rule (CAP-730, see `vercelBranch.ts`). The signals are listed in the proposal's evidence. + * * A "service" is one ENVIRONMENT of one Vercel project that holds variables: * production, preview, each custom environment, and `development` only when * it is the project's only environment with variables. A preview whose @@ -27,12 +31,14 @@ * GET /v10/projects?teamId= projects[].{id,name,rootDirectory,link}, pagination.next (`from`) * GET /v9/projects/{id}/custom-environments environments[].{id,slug} * GET /v10/projects/{id}/env?teamId= envs[].{key,type,target,gitBranch,customEnvironmentIds} + * GET /v6/deployments?projectId=&target=preview&limit= deployments[].{uid,meta.githubCommitRef} */ import { posix } from 'node:path'; import type { TargetConfig } from '../../deploy/adapter'; import { VercelClient, VercelCustomEnvironment, + VercelDeployment, VercelEnvRecord, VercelProject, VercelTeam, @@ -65,17 +71,20 @@ import { import { DiscoveryInput, PlanEntry, buildDeployDiscovery } from './discovery'; import { targetBaseName } from './evaluate'; import { + BranchSignal, ExistingTarget, RepoOut, ServiceFacts, VercelServiceFacts, relPathOf, + repoKey, uniqueSorted, vercelServiceId, } from './facts'; import type { BranchChoice, KindOverrides } from './kind'; import { planIdOf } from './plan'; import { DEPLOY_VERCEL_PLAN_SCHEMA, VERCEL_PLAN_SCHEMA_REF } from './schema'; +import { customRuleSignal, deploymentsSignal, scopedVariablesSignal, withGitBranch } from './vercelBranch'; export { DEPLOY_VERCEL_PLAN_SCHEMA }; @@ -106,6 +115,8 @@ export interface VercelProjectScan { readonly team: { readonly id: string; readonly slug?: string } | null; readonly customEnvironments: readonly VercelCustomEnvironment[]; readonly envs: readonly VercelEnvRecord[]; + /** The project's recent preview deployments; absent when they were not read, or could not be (CAP-730). */ + readonly previewDeployments?: readonly VercelDeployment[]; } const namesOf = (records: readonly VercelEnvRecord[]): readonly string[] => uniqueSorted(records.map((r) => r.key)); @@ -151,13 +162,33 @@ function slicesOf(scan: VercelProjectScan): readonly EnvSlice[] { return [...slices, ...development]; } +/** The preview variables scoped to a branch of the repo, by branch: names only. */ +function scopedPreviewNames(scan: VercelProjectScan): ReadonlyArray<{ readonly branch: string; readonly names: readonly string[] }> { + const scoped = scan.envs.filter((r) => r.type !== 'system' && r.targets.includes('preview') && !!r.gitBranch); + return uniqueSorted(scoped.map((r) => r.gitBranch as string)).map((branch) => ({ + branch, + names: namesOf(scoped.filter((r) => r.gitBranch === branch)), + })); +} + +/** What points at a branch for a slice, before the repo's default branch is known: the all-branches preview's two signals, a custom environment's rule. `undefined`: the slice is not judged. */ +function branchSignalsOf(scan: VercelProjectScan, slice: EnvSlice): readonly BranchSignal[] | undefined { + if (slice.environment === 'preview' && slice.gitBranchScope === null && slice.customEnvironmentId === null) { + return [deploymentsSignal(scan.previewDeployments ?? []), scopedVariablesSignal(scopedPreviewNames(scan), slice.names)].flatMap((s) => (s === null ? [] : [s])); + } + const custom = scan.customEnvironments.find((c) => c.id === slice.customEnvironmentId); + const rule = custom === undefined ? null : customRuleSignal(custom.branchMatcher); + return rule === null ? undefined : [rule]; +} + /** The services of one project: one per environment that has variables. */ export function vercelServicesOf(scan: VercelProjectScan): readonly VercelServiceFacts[] { const { project } = scan; const repo = repoOfProject(project); const root = project.rootDirectory; - return slicesOf(scan).map( - (slice): VercelServiceFacts => ({ + return slicesOf(scan).map((slice): VercelServiceFacts => { + const signals = branchSignalsOf(scan, slice); + return { service_id: vercelServiceId(project.id, slice.environment, slice.gitBranchScope), kind: 'vercel', name: project.name, @@ -168,11 +199,13 @@ export function vercelServicesOf(scan: VercelProjectScan): readonly VercelServic git_branch_scope: slice.gitBranchScope, root_directory: root, repo, + // The production branch; `withGitBranch` replaces it for a service the signals judge, once the repo's default branch is read. branch: project.link?.productionBranch ?? null, path: repo === null ? null : relPathOf(root), env_var_names: slice.names, - }), - ); + ...(signals === undefined ? {} : { git_branch_signals: signals }), + }; + }); } // ── Reading Vercel ─────────────────────────────────────────────────────────── @@ -198,15 +231,37 @@ async function readScopes(client: VercelClient): Promise { interface ScanOutcome { readonly scan?: VercelProjectScan; readonly truncated: boolean; + /** The recent preview deployments were asked for and could not be read: no signal, a notice (CAP-730). */ + readonly deploymentsUnavailable?: boolean; readonly error?: unknown; } -async function scanProject(client: VercelClient, project: VercelProject, team: Scope['team']): Promise { +/** Does the project have an all-branches preview with variables (the only service the deployments judge)? */ +const hasAllBranchesPreview = (scan: VercelProjectScan): boolean => + slicesOf(scan).some((slice) => slice.environment === 'preview' && slice.gitBranchScope === null && slice.customEnvironmentId === null); + +type DeploymentsRead = { readonly ok: true; readonly deployments: readonly VercelDeployment[] } | { readonly ok: false }; + +/** The recent preview deployments. A failure of any kind (no access, rate limit, an unreadable answer) is no signal, never an error. */ +async function readPreviewDeployments(client: VercelClient, projectId: string, teamId: string | undefined): Promise { + try { + return { ok: true, deployments: await client.listPreviewDeployments(projectId, teamId) }; + } catch { + return { ok: false }; + } +} + +async function scanProject(client: VercelClient, project: VercelProject, team: Scope['team'], readDeployments: boolean): Promise { try { const teamId = team?.id; const customEnvironments = await client.listCustomEnvironments(project.id, teamId); const list = await client.listEnvs(project.id, teamId); - return { scan: { project, team, customEnvironments, envs: list.envs }, truncated: list.truncated }; + const scan: VercelProjectScan = { project, team, customEnvironments, envs: list.envs }; + if (!readDeployments || !hasAllBranchesPreview(scan)) return { scan, truncated: list.truncated }; + const read = await readPreviewDeployments(client, project.id, teamId); + return read.ok + ? { scan: { ...scan, previewDeployments: read.deployments }, truncated: list.truncated } + : { scan, truncated: list.truncated, deploymentsUnavailable: true }; } catch (error) { return { truncated: false, error }; } @@ -248,7 +303,8 @@ async function loadVercel( const pooled = await runPool({ items: readable, limit: SCAN_CONCURRENCY, - run: async ({ project, team }) => ({ result: await scanProject(client, project, team) }), + // A plan names its branches itself: the deployments are read only for discovery (`only` is plan mode). + run: async ({ project, team }) => ({ result: await scanProject(client, project, team, only === undefined) }), onChange: (p) => { if (p.done === p.total || p.done % 10 === 0) io.progress(`Read ${p.done} of ${p.total} Vercel projects`); // COPY-FLAG }, @@ -262,6 +318,7 @@ async function loadVercel( notices: [ ...notLinked.map(({ project }) => ({ code: ERROR_CODES.VERCEL_PROJECT_NOT_GITHUB, detail: project.name })), ...pooled.results.flatMap((r) => (r.truncated && r.scan !== undefined ? [{ code: ERROR_CODES.VERCEL_ENV_LIST_TRUNCATED, detail: r.scan.project.name }] : [])), + ...pooled.results.flatMap((r) => (r.deploymentsUnavailable === true && r.scan !== undefined ? [{ code: ERROR_CODES.VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE, detail: r.scan.project.name }] : [])), ], }); } @@ -333,6 +390,17 @@ export const VERCEL_KIND: KindOverrides = { schemaRef: VERCEL_PLAN_SCHEMA_REF, planDomain: 'capy:deploy-vercel:plan:v1', chooseCapyBranch: chooseVercelBranch, + // The branch of a preview or a custom environment with a rule is judged from its signals (CAP-730); everything else keeps the shared rule. + branchConfidence: (service, choice) => { + const confidence = asVercel(service).git_branch_confidence; + return confidence === undefined ? undefined : choice.standard ? confidence : 'needs_review'; + }, + gitBranchEvidence: (service) => { + const v = asVercel(service); + return v.git_branch_basis === undefined ? undefined : { basis: v.git_branch_basis, signals: v.git_branch_signals ?? [] }; + }, + // A preview scoped to one branch is evidence for that branch's preview, never an empty target of its own. + emptyProposalReason: (service) => (asVercel(service).git_branch_scope === null ? null : 'BRANCH_SCOPED_NO_VARIABLES'), configured: (targets, service) => vercelConfigured(targets, service), // Several Vercel projects (a monorepo) may ship one Capy branch: a target for another project is not a clash. conflicting: () => undefined, @@ -407,10 +475,12 @@ async function runDiscovery(io: DeployVercelDiscoverIo): Promise withGitBranch(service, service.repo === null ? null : github.defaultBranches[repoKey(service.repo)])); const input: DiscoveryInput = { baseUrl: '', header: { teams: vercel.value.teams }, - services: world.services, + services, projects: world.projects, defaultBranches: github.defaultBranches, existingTargets: github.existingTargets, diff --git a/src/commands/deployDiscover/vercelBranch.ts b/src/commands/deployDiscover/vercelBranch.ts new file mode 100644 index 00000000..17449edf --- /dev/null +++ b/src/commands/deployDiscover/vercelBranch.ts @@ -0,0 +1,173 @@ +/** + * Which git branch a Vercel environment deploys from (CAP-730), as pure functions. + * + * Production is the project's production branch, always. The all-branches + * PREVIEW environment is usually fed from another branch (a repo whose default + * branch is `develop` and whose production branch is `main`), so it is judged + * from three signals, and a custom environment from its branch rule: + * + * 1. RECENT_PREVIEW_DEPLOYMENTS the branch most recent preview deployments built from + * 2. BRANCH_SCOPED_VARIABLES the branch most preview variables are scoped to + * 3. GITHUB_DEFAULT_BRANCH the repo's default branch, when it is not the production branch + * + * Signals agree (every signal names the same single branch): that branch, + * `certain`. They disagree: the strongest signal wins, in the order above + * (what was actually deployed outranks what is configured, which outranks a + * repo-wide default), and the choice is `needs_review` with every signal + * listed. No signal: the production branch, `needs_review`. + * + * Names, ids and counts only. Nothing in here ever holds a variable value. + */ +import { sortedCopy } from '../../deploy/dokployApi'; +import type { VercelBranchMatcher, VercelDeployment } from '../../deploy/vercelApi'; +import type { BranchSignal, BranchSignalCode, GitBranchBasis, ServiceFacts, VercelServiceFacts } from './facts'; +import { byText, uniqueSorted } from './facts'; + +/** The order signals outrank each other when they disagree. */ +export const BRANCH_SIGNAL_ORDER: readonly BranchSignalCode[] = ['RECENT_PREVIEW_DEPLOYMENTS', 'BRANCH_SCOPED_VARIABLES', 'GITHUB_DEFAULT_BRANCH']; + +export interface BranchInference { + readonly branch: string | null; + readonly confidence: 'certain' | 'needs_review'; + readonly basis: GitBranchBasis; +} + +const byWeightThenName = (a: { readonly branch: string; readonly weight: number }, b: { readonly branch: string; readonly weight: number }): number => + b.weight - a.weight || byText(a.branch, b.branch); + +/** A signal's branches, strongest first. */ +const rankedBranches = (branches: BranchSignal['branches']): BranchSignal['branches'] => sortedCopy(branches, byWeightThenName); + +/** The branches tied for the strongest weight in a signal (one branch when there is a clear leader). */ +export function leadersOf(signal: BranchSignal): readonly string[] { + const top = Math.max(0, ...signal.branches.map((b) => b.weight)); + return uniqueSorted(signal.branches.filter((b) => b.weight === top).map((b) => b.branch)); +} + +/** A signal that names at least one branch. */ +const names = (signal: BranchSignal): boolean => signal.branches.length > 0; + +// ── The signals ────────────────────────────────────────────────────────────── + +/** Branch counts over a project's recent preview deployments. A deployment with no git branch (a CLI deploy) counts for none. */ +export function deploymentsSignal(deployments: readonly VercelDeployment[]): BranchSignal | null { + const branches = uniqueSorted(deployments.flatMap((d) => (d.gitBranch === undefined ? [] : [d.gitBranch]))); + return branches.length === 0 + ? null + : { + code: 'RECENT_PREVIEW_DEPLOYMENTS', + branches: rankedBranches(branches.map((branch) => ({ branch, weight: deployments.filter((d) => d.gitBranch === branch).length }))), + }; +} + +/** The branches preview variables are scoped to, with the names under each (and the ones the all-branches preview shadows). */ +export function scopedVariablesSignal( + scoped: ReadonlyArray<{ readonly branch: string; readonly names: readonly string[] }>, + allBranchesNames: readonly string[], +): BranchSignal | null { + return scoped.length === 0 + ? null + : { + code: 'BRANCH_SCOPED_VARIABLES', + branches: rankedBranches( + scoped.map(({ branch, names: scopedNames }) => ({ + branch, + weight: scopedNames.length, + names: scopedNames, + shadows: scopedNames.filter((n) => allBranchesNames.includes(n)), + })), + ), + }; +} + +/** The repo's default branch, but only when it says something the production branch does not. */ +export function defaultBranchSignal(defaultBranch: string | null | undefined, productionBranch: string | null): BranchSignal | null { + return typeof defaultBranch === 'string' && defaultBranch !== '' && defaultBranch !== productionBranch + ? { code: 'GITHUB_DEFAULT_BRANCH', branches: [{ branch: defaultBranch, weight: 1 }] } + : null; +} + +/** A custom environment's branch matcher. Only `equals` names one branch; a prefix or suffix rule is carried as the rule alone. */ +export function customRuleSignal(matcher: VercelBranchMatcher | undefined): BranchSignal | null { + return matcher === undefined + ? null + : { + code: 'CUSTOM_ENVIRONMENT_RULE', + branches: matcher.type === 'equals' ? [{ branch: matcher.pattern, weight: 1 }] : [], + rule: { type: matcher.type, pattern: matcher.pattern }, + }; +} + +// ── The choice ─────────────────────────────────────────────────────────────── + +const rankOf = (signal: BranchSignal): number => { + const at = BRANCH_SIGNAL_ORDER.indexOf(signal.code); + return at === -1 ? BRANCH_SIGNAL_ORDER.length : at; +}; + +/** Every signal names the same single branch. */ +function agreement(active: readonly BranchSignal[]): string | null { + const leaders = active.map(leadersOf); + const first = leaders[0]?.[0]; + return first !== undefined && leaders.every((l) => l.length === 1 && l[0] === first) ? first : null; +} + +/** + * The strongest signal wins; a tie inside it is broken by the next signal down + * (when it names one of the tied branches), then by branch name. + */ +function strongest(active: readonly BranchSignal[]): string { + const ordered = sortedCopy(active, (a, b) => rankOf(a) - rankOf(b)); + const narrowed = ordered.slice(1).reduce((candidates, signal) => { + const common = leadersOf(signal).filter((b) => candidates.includes(b)); + return common.length > 0 ? common : candidates; + }, leadersOf(ordered[0])); + return sortedCopy(narrowed, byText)[0]; +} + +/** The git branch of an all-branches preview from its signals. */ +export function inferPreviewBranch(signals: readonly BranchSignal[], productionBranch: string | null): BranchInference { + const active = signals.filter(names); + if (active.length === 0) return { branch: productionBranch, confidence: 'needs_review', basis: 'no_signals' }; + const agreed = agreement(active); + if (agreed !== null) return { branch: agreed, confidence: 'certain', basis: 'signals_agree' }; + return { branch: strongest(active), confidence: 'needs_review', basis: 'strongest_signal' }; +} + +/** The git branch of a custom environment from its rule: an exact rule names the branch; a pattern names none. */ +export function inferCustomBranch(signals: readonly BranchSignal[], productionBranch: string | null): BranchInference | null { + const rule = signals.find((s) => s.code === 'CUSTOM_ENVIRONMENT_RULE'); + if (rule === undefined) return null; + const exact = rule.branches[0]; + return exact === undefined + ? { branch: productionBranch, confidence: 'needs_review', basis: 'custom_environment_pattern' } + : { branch: exact.branch, confidence: 'certain', basis: 'custom_environment_rule' }; +} + +// ── Applying it to a service ───────────────────────────────────────────────── + +const asVercel = (service: ServiceFacts): VercelServiceFacts => service as VercelServiceFacts; + +const isAllBranchesPreview = (v: VercelServiceFacts): boolean => v.environment === 'preview' && v.git_branch_scope === null; + +/** + * A service with its git branch settled. Only the all-branches preview and a + * custom environment with a rule are judged; every other service (production, + * `development`, a branch-scoped preview) is returned as it is. The default + * branch is the repo's, from GitHub, and joins the signals here because it is + * read after the Vercel side. + */ +export function withGitBranch(service: ServiceFacts, defaultBranch: string | null | undefined): ServiceFacts { + const v = asVercel(service); + if (v.git_branch_signals === undefined) return service; + if (isAllBranchesPreview(v)) { + const defaultSignal = defaultBranchSignal(defaultBranch, v.branch); + const signals = [...v.git_branch_signals, ...(defaultSignal === null ? [] : [defaultSignal])]; + const inferred = inferPreviewBranch(signals, v.branch); + return { ...v, branch: inferred.branch, git_branch_signals: signals, git_branch_basis: inferred.basis, git_branch_confidence: inferred.confidence }; + } + const custom = inferCustomBranch(v.git_branch_signals, v.branch); + return custom === null + ? service + : { ...v, branch: custom.branch, git_branch_basis: custom.basis, git_branch_confidence: custom.confidence }; +} diff --git a/src/deploy/vercelApi.ts b/src/deploy/vercelApi.ts index 481c99c8..ef0c870f 100644 --- a/src/deploy/vercelApi.ts +++ b/src/deploy/vercelApi.ts @@ -83,9 +83,25 @@ export interface VercelProject { link: VercelGitLink | null; } +/** How a custom environment picks the git branches it deploys: Vercel's branch matcher. A type this client does not know is dropped, never guessed at. */ +export type VercelBranchMatcherType = 'equals' | 'startsWith' | 'endsWith'; + +export interface VercelBranchMatcher { + readonly type: VercelBranchMatcherType; + readonly pattern: string; +} + export interface VercelCustomEnvironment { id: string; slug: string; + /** The environment's branch rule; absent when it has none. */ + branchMatcher?: VercelBranchMatcher; +} + +/** One preview deployment, as far as discovery reads it: the git branch it built from (`meta.githubCommitRef`), when Vercel names one. A name only. */ +export interface VercelDeployment { + readonly id: string; + readonly gitBranch?: string; } export interface VercelEnvRecord { @@ -135,7 +151,8 @@ export type VercelResource = | { readonly kind: 'teams' } | { readonly kind: 'projects' } | { readonly kind: 'project'; readonly project: string } - | { readonly kind: 'project_envs'; readonly project: string }; + | { readonly kind: 'project_envs'; readonly project: string } + | { readonly kind: 'project_deployments'; readonly project: string }; export class VercelApiError extends Error { constructor( @@ -176,6 +193,7 @@ function deniedResource(resource: VercelResource | undefined): string { if (resource.kind === 'teams') return 'the team list'; if (resource.kind === 'projects') return 'the project list'; if (resource.kind === 'project') return `project ${resource.project}`; + if (resource.kind === 'project_deployments') return `the deployments of project ${resource.project}`; return `the environment variables of project ${resource.project}`; } @@ -273,6 +291,8 @@ export interface VercelClient { getProject(idOrName: string, teamId?: string): Promise; listCustomEnvironments(projectId: string, teamId?: string): Promise; listEnvs(projectId: string, teamId?: string): Promise; + /** The project's most recent preview deployments (one page, newest first, at most `limit`). */ + listPreviewDeployments(projectId: string, teamId?: string, limit?: number): Promise; /** * The decrypted value of one env record, `undefined` when Vercel returned none, or * `{ decrypted: false }` when Vercel says it did not decrypt the value (its structured @@ -296,6 +316,8 @@ const DEFAULT_MAX_WAIT_MS = 60_000; const DEFAULT_RETRY_WAIT_MS = 1_000; const MAX_PAGES = 100; const PAGE_LIMIT = '100'; +/** How many recent preview deployments discovery counts branches over (Vercel's maximum page). */ +export const PREVIEW_DEPLOYMENTS_LIMIT = 100; const asRecord = (v: unknown): Record | null => v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; @@ -440,7 +462,20 @@ export function createVercelClient(token: string, options: VercelClientOptions = const rec = asRecord(e); const id = nonEmptyString(rec?.id); const slug = nonEmptyString(rec?.slug); - return id && slug ? [{ id, slug }] : []; + const branchMatcher = parseBranchMatcher(rec?.branchMatcher); + return id && slug ? [{ id, slug, ...(branchMatcher ? { branchMatcher } : {}) }] : []; + }); + }, + + async listPreviewDeployments(projectId, teamId, limit = PREVIEW_DEPLOYMENTS_LIMIT) { + const body = asRecord( + await get('/v6/deployments', { kind: 'project_deployments', project: projectId }, { projectId, target: 'preview', limit: String(limit), teamId }), + ); + return (Array.isArray(body?.deployments) ? body.deployments : []).flatMap((d): readonly VercelDeployment[] => { + const rec = asRecord(d); + const id = nonEmptyString(rec?.uid) ?? nonEmptyString(rec?.id); + const gitBranch = nonEmptyString(asRecord(rec?.meta)?.githubCommitRef); + return id ? [{ id, ...(gitBranch ? { gitBranch } : {}) }] : []; }); }, @@ -462,6 +497,13 @@ export function createVercelClient(token: string, options: VercelClientOptions = }; } +function parseBranchMatcher(raw: unknown): VercelBranchMatcher | undefined { + const rec = asRecord(raw); + const type = rec?.type; + const pattern = nonEmptyString(rec?.pattern); + return pattern && (type === 'equals' || type === 'startsWith' || type === 'endsWith') ? { type, pattern } : undefined; +} + function parseTeam(raw: unknown): VercelTeam | null { const rec = asRecord(raw); const id = nonEmptyString(rec?.id); diff --git a/src/types/index.ts b/src/types/index.ts index bf7bd506..4874a847 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -805,6 +805,8 @@ export const ERROR_CODES = { VERCEL_PROJECT_NOT_GITHUB: 'VERCEL_PROJECT_NOT_GITHUB', /** Vercel said a project's env list has another page that this client cannot ask for; the names read may be incomplete (a notice). */ VERCEL_ENV_LIST_TRUNCATED: 'VERCEL_ENV_LIST_TRUNCATED', + /** Vercel's recent preview deployments of a project could not be read (the call failed, or the token cannot see them); that project's preview branch is judged without them (a notice). Discovery goes on. */ + VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE: 'VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE', // --- Import connectors --- /** `capy connect vercel`: Vercel will not return this name's value, and Capy already has one. The Capy value was left untouched (reported, not an error). */ CAPY_VALUE_KEPT: 'CAPY_VALUE_KEPT', diff --git a/tests/commands/deployDiscoverVercelFacts.test.ts b/tests/commands/deployDiscoverVercelFacts.test.ts index 00cd53af..1176e0bf 100644 --- a/tests/commands/deployDiscoverVercelFacts.test.ts +++ b/tests/commands/deployDiscoverVercelFacts.test.ts @@ -235,7 +235,8 @@ describe('facts: what is asked of Vercel', () => { const calls = world.vercelCalls(); expect(calls.every((c) => c.method === 'GET' && c.authorization === `Bearer ${VERCEL_TOKEN}`)).toBe(true); const paths = new Set(calls.map((c) => new URL(c.url).pathname.replace(/prj_[a-z]+/, 'PRJ'))); - expect(paths).toEqual(new Set(['/v2/teams', '/v10/projects', '/v9/projects/PRJ/custom-environments', '/v10/projects/PRJ/env'])); + // CAP-730 adds the recent preview deployments (read-only, `target=preview`) to the routes asked. + expect(paths).toEqual(new Set(['/v2/teams', '/v10/projects', '/v9/projects/PRJ/custom-environments', '/v10/projects/PRJ/env', '/v6/deployments'])); }); test('a project\'s variables are listed WITHOUT decrypt, and the per-id value route is never called', async () => { diff --git a/tests/commands/deployDiscoverVercelPreviewBranch.test.ts b/tests/commands/deployDiscoverVercelPreviewBranch.test.ts new file mode 100644 index 00000000..986d12dc --- /dev/null +++ b/tests/commands/deployDiscoverVercelPreviewBranch.test.ts @@ -0,0 +1,412 @@ +/** + * `capy deploy vercel --discover` (CAP-730): the git branch of a preview environment. + * + * Production keeps the project's production branch. The all-branches preview is judged from three signals (recent + * preview deployments, branch-scoped variables, the GitHub default branch when it is not the production branch); + * a custom environment from its branch rule. Agreement is `certain`; disagreement takes the strongest signal + * (deployments, then scoped variables, then the default branch), is `needs_review`, and lists every signal; no signal + * is the production branch, `needs_review`. A preview scoped to a git branch never becomes an empty proposal. The + * deployments call failing is a coded notice, never a failed discovery. Names, ids and counts only. + */ +import { describe, test, expect } from 'bun:test'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { inferPreviewBranch, leadersOf } from '../../src/commands/deployDiscover/vercelBranch'; +import type { BranchSignal } from '../../src/commands/deployDiscover/facts'; +import { createVercelClient } from '../../src/deploy/vercelApi'; +import type { FakeProject } from '../helpers/deployDiscoverWorld'; +import type { FakeVercelData, RawEnv } from '../helpers/fakeVercel'; +import { fakeVercelFetch, project as vercelProject } from '../helpers/fakeVercel'; +import { SENTINEL_VERCEL, makeVercelWorld } from '../helpers/deployDiscoverVercelWorld'; + +const PRJ = 'prj_ss'; +const SVC_PREVIEW = `${PRJ}:preview`; +const SVC_SCOPED = `${PRJ}:preview@develop`; + +const env = (id: string, key: string, over: Partial = {}): RawEnv => ({ + id, + key, + type: 'plain', + target: ['preview'], + listValue: SENTINEL_VERCEL, + ...over, +}); + +interface Setup { + readonly production?: string; + readonly defaultBranch?: string; + readonly deployments?: ReadonlyArray; + readonly deploymentsStatus?: number; + readonly scoped?: ReadonlyArray<{ branch: string; names: readonly string[] }>; + readonly custom?: FakeVercelData['custom']; + readonly extraEnvs?: readonly RawEnv[]; + readonly capyBranches?: Readonly>; +} + +const capyProject = (branches: Readonly>): readonly FakeProject[] => [ + { id: 'proj_ss', name: 'slidespeak', repos: [{ owner: 'acme', name: 'ss', path: '.' }], branches: branches as Record }, +]; + +function worldOf(setup: Setup = {}) { + const scopedEnvs = (setup.scoped ?? []).flatMap(({ branch, names }) => names.map((n, i) => env(`s_${branch}_${i}`, n, { gitBranch: branch }))); + const data: FakeVercelData = { + teams: [{ id: 'team_1', slug: 'acme-team' }], + projects: { + team_1: [vercelProject(PRJ, 'slidespeak-app', { link: { type: 'github', org: 'acme', repo: 'ss', productionBranch: setup.production ?? 'main' } })], + personal: [], + }, + envs: { + [PRJ]: [ + env('p1', 'API_KEY', { target: ['production', 'preview'] }), + env('p2', 'DB_URL', { target: ['production', 'preview'] }), + ...scopedEnvs, + ...(setup.extraEnvs ?? []), + ], + }, + ...(setup.custom === undefined ? {} : { custom: setup.custom }), + deployments: { [PRJ]: (setup.deployments ?? []).map((branch) => ({ branch })) }, + ...(setup.deploymentsStatus === undefined ? {} : { deploymentsStatus: { [PRJ]: setup.deploymentsStatus } }), + }; + return makeVercelWorld({ + data, + projects: capyProject(setup.capyBranches ?? { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL'] }), + repos: { 'acme/ss': { defaultBranch: setup.defaultBranch ?? setup.production ?? 'main', branches: ['main', 'develop', 'staging', 'release'] } }, + }); +} + +async function discover(setup: Setup = {}) { + const world = worldOf(setup); + const result = await runDeployVercelDiscover({}, world.io); + const body = result.body as any; + const previewService = body.services.find((s: any) => s.service_id === SVC_PREVIEW); + const previewProposal = body.proposals.find((p: any) => p.service_id === SVC_PREVIEW); + return { world, result, body, previewService, previewProposal }; +} + +const deploys = (branch: string, n: number): readonly string[] => Array.from({ length: n }, () => branch); + +describe('preview git branch: one signal', () => { + test('recent preview deployments: the branch most of them built from', async () => { + const { previewProposal, previewService } = await discover({ deployments: [...deploys('develop', 5), ...deploys('feature-a', 2)] }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewService.branch).toBe('develop'); + expect(previewProposal.evidence.git_branch.signals).toEqual([ + { code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [{ branch: 'develop', weight: 5 }, { branch: 'feature-a', weight: 2 }] }, + ]); + // Several deployment branches is one signal with a clear leader: no disagreement. + expect(previewProposal.evidence.git_branch.basis).toBe('signals_agree'); + expect(previewProposal.confidence.branch).toBe('certain'); + }); + + test('branch-scoped variables: the branch with the most scoped variables, with their names and the names the all-branches preview shadows', async () => { + const { previewProposal } = await discover({ scoped: [{ branch: 'develop', names: ['DB_URL', 'NEW_FLAG'] }, { branch: 'staging', names: ['ONLY_STAGING'] }] }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.evidence.git_branch.signals).toEqual([ + { + code: 'BRANCH_SCOPED_VARIABLES', + branches: [ + { branch: 'develop', weight: 2, names: ['DB_URL', 'NEW_FLAG'], shadows: ['DB_URL'] }, + { branch: 'staging', weight: 1, names: ['ONLY_STAGING'], shadows: [] }, + ], + }, + ]); + expect(previewProposal.confidence.branch).toBe('certain'); + }); + + test('the GitHub default branch, when it differs from the production branch', async () => { + const { previewProposal } = await discover({ defaultBranch: 'develop' }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.evidence.git_branch.signals).toEqual([{ code: 'GITHUB_DEFAULT_BRANCH', branches: [{ branch: 'develop', weight: 1 }] }]); + expect(previewProposal.confidence.branch).toBe('certain'); + }); + + test('a default branch equal to the production branch is not a signal', async () => { + const { previewProposal } = await discover({ defaultBranch: 'main', production: 'main' }); + expect(previewProposal.evidence.git_branch.signals).toEqual([]); + }); + + test('deployments with no git branch (a CLI deploy) count for no branch', async () => { + const { previewProposal } = await discover({ deployments: [null, null, 'develop'] }); + expect(previewProposal.evidence.git_branch.signals).toEqual([{ code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [{ branch: 'develop', weight: 1 }] }]); + }); +}); + +describe('preview git branch: agreement, disagreement, nothing', () => { + test('all three signals name the same branch: that branch, certain, every signal listed', async () => { + const { previewProposal } = await discover({ + defaultBranch: 'develop', + deployments: deploys('develop', 3), + scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }], + }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch.basis).toBe('signals_agree'); + expect(previewProposal.evidence.git_branch.signals.map((s: any) => s.code)).toEqual([ + 'RECENT_PREVIEW_DEPLOYMENTS', + 'BRANCH_SCOPED_VARIABLES', + 'GITHUB_DEFAULT_BRANCH', + ]); + }); + + test('deployments outrank scoped variables and the default branch: needs_review, every signal listed', async () => { + const { previewProposal } = await discover({ + defaultBranch: 'release', + deployments: deploys('staging', 4), + scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }], + }); + expect(previewProposal.git_branch).toBe('staging'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + expect(previewProposal.evidence.git_branch.basis).toBe('strongest_signal'); + expect(previewProposal.evidence.git_branch.signals.map((s: any) => s.code)).toEqual([ + 'RECENT_PREVIEW_DEPLOYMENTS', + 'BRANCH_SCOPED_VARIABLES', + 'GITHUB_DEFAULT_BRANCH', + ]); + expect(previewProposal.evidence.git_branch.signals.flatMap((s: any) => s.branches.map((b: any) => b.branch))).toEqual(['staging', 'develop', 'release']); + }); + + test('scoped variables outrank the default branch when there are no deployments', async () => { + const { previewProposal } = await discover({ defaultBranch: 'release', scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }] }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + }); + + test('deployments outrank the default branch when there are no scoped variables', async () => { + const { previewProposal } = await discover({ defaultBranch: 'release', deployments: deploys('develop', 2) }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + }); + + test('a tie inside the strongest signal is broken by the next signal, never left to chance', async () => { + const { previewProposal } = await discover({ deployments: ['develop', 'staging'], defaultBranch: 'staging' }); + expect(previewProposal.git_branch).toBe('staging'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + }); + + test('a tie nothing else breaks is settled by branch name, and needs_review', async () => { + const { previewProposal } = await discover({ deployments: ['staging', 'develop'] }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + }); + + test('no signal at all: the production branch, needs_review', async () => { + const { previewProposal, previewService } = await discover({ production: 'main', defaultBranch: 'main' }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewService.branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + expect(previewProposal.evidence.git_branch).toEqual({ basis: 'no_signals', signals: [] }); + }); + + test('a proposal that needs review is in the next step\'s review list', async () => { + const { body, previewProposal } = await discover({ defaultBranch: 'release', deployments: deploys('develop', 1) }); + expect(body.next_steps.find((s: any) => s.action === 'review').proposal_ids).toContain(previewProposal.proposal_id); + }); + + test('production keeps the production branch whatever the signals say', async () => { + const { body } = await discover({ defaultBranch: 'develop', deployments: deploys('develop', 9), scoped: [{ branch: 'develop', names: ['X'] }] }); + const production = body.proposals.find((p: any) => p.service_id === `${PRJ}:production`); + expect(production.git_branch).toBe('main'); + expect(production.evidence.git_branch).toBeUndefined(); + expect(body.services.find((s: any) => s.service_id === `${PRJ}:production`).git_branch_signals).toBeUndefined(); + }); +}); + +describe('preview git branch: the deployments call', () => { + test('asks for preview deployments of the project, with a limit, team-scoped, by GET', async () => { + const { world } = await discover({ deployments: deploys('develop', 1) }); + const call = world.vercelCalls().find((c) => new URL(c.url).pathname === '/v6/deployments'); + expect(call).toBeDefined(); + const url = new URL(call!.url); + expect(call!.method).toBe('GET'); + expect(Object.fromEntries(url.searchParams)).toEqual({ projectId: PRJ, target: 'preview', limit: '100', teamId: 'team_1' }); + }); + + test.each([403, 500, 404])('HTTP %i: no signal and a coded notice; discovery still succeeds and the other signals are used', async (status) => { + const { result, body, previewProposal } = await discover({ deploymentsStatus: status, defaultBranch: 'develop' }); + expect(result.exitCode).toBe(0); + expect(body.ok).toBe(true); + expect(body.notices).toContainEqual({ code: 'VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE', detail: 'slidespeak-app' }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.evidence.git_branch.signals.map((s: any) => s.code)).toEqual(['GITHUB_DEFAULT_BRANCH']); + }); + + test('a failed call with nothing else to go on: the production branch, needs_review, plus the notice', async () => { + const { body, previewProposal } = await discover({ deploymentsStatus: 403 }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + expect(body.notices.map((n: any) => n.code)).toContain('VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE'); + }); + + test('a call that works adds no notice', async () => { + const { body } = await discover({ deployments: deploys('develop', 1) }); + expect(body.notices.map((n: any) => n.code)).not.toContain('VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE'); + }); + + test('not asked at all for a project with no all-branches preview variables', async () => { + const world = makeVercelWorld({ + data: { + teams: [{ id: 'team_1', slug: 'acme-team' }], + projects: { team_1: [vercelProject(PRJ, 'slidespeak-app', { link: { type: 'github', org: 'acme', repo: 'ss', productionBranch: 'main' } })], personal: [] }, + envs: { [PRJ]: [env('p1', 'API_KEY', { target: ['production'] })] }, + }, + projects: capyProject({ production: ['API_KEY'] }), + repos: { 'acme/ss': { defaultBranch: 'main', branches: ['main'] } }, + }); + await runDeployVercelDiscover({}, world.io); + expect(world.vercelCalls().some((c) => new URL(c.url).pathname === '/v6/deployments')).toBe(false); + }); +}); + +describe('plan mode', () => { + test('does not read deployments: the plan names its own git branch', async () => { + const plan = JSON.stringify({ version: 1, entries: [{ project_id: 'proj_ss', branch: 'preview', service_id: SVC_PREVIEW, git_branch: 'develop', vars: ['API_KEY'] }] }); + const base = worldOf({ deployments: deploys('develop', 1) }); + const result = await runDeployVercelDiscover({ plan: 'plan.json', dryRun: true }, { ...base.io, readFile: () => plan }); + expect((result.body as any).ok).toBe(true); + expect(base.vercelCalls().some((c) => new URL(c.url).pathname === '/v6/deployments')).toBe(false); + }); +}); + +describe('preview git branch: records scoped to a git branch', () => { + const scoped = [{ branch: 'develop', names: ['NEW_FLAG'] }]; + + test('a scoped preview with no variable in common with its Capy branch is NOT a proposal: it stays unresolved with a coded reason, and the real preview proposal is the only one', async () => { + const { body, previewProposal } = await discover({ + scoped, + capyBranches: { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL'], develop: ['SOMETHING_ELSE'] }, + }); + expect(body.proposals.map((p: any) => p.service_id)).not.toContain(SVC_SCOPED); + expect(body.proposals.filter((p: any) => p.service_id.startsWith(`${PRJ}:preview`))).toHaveLength(1); + expect(previewProposal.competing_proposal_ids).toEqual([]); + expect(body.unresolved).toContainEqual({ + reason: 'BRANCH_SCOPED_NO_VARIABLES', + service_id: SVC_SCOPED, + project_id: 'proj_ss', + candidates: [{ project_id: 'proj_ss', project_name: 'slidespeak', branch: 'develop' }], + }); + expect(body.next_steps.find((s: any) => s.action === 'resolve').service_ids).toContain(SVC_SCOPED); + }); + + test('the scoped variables are evidence on the preview proposal and are not among its variables', async () => { + const { previewProposal } = await discover({ + scoped, + capyBranches: { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL', 'NEW_FLAG'], develop: ['SOMETHING_ELSE'] }, + }); + expect(previewProposal.plan_entry.vars).toEqual(['API_KEY', 'DB_URL']); + expect(previewProposal.evidence.git_branch.signals[0]).toMatchObject({ code: 'BRANCH_SCOPED_VARIABLES', branches: [{ branch: 'develop', names: ['NEW_FLAG'] }] }); + }); + + test('the scoped service is still listed in the facts, with its names, and keeps the production branch as its PR base', async () => { + const { body } = await discover({ scoped, defaultBranch: 'develop' }); + expect(body.services.find((s: any) => s.service_id === SVC_SCOPED)).toMatchObject({ git_branch_scope: 'develop', env_var_names: ['NEW_FLAG'], branch: 'main' }); + }); + + test('a scoped preview that does share variables with its Capy branch is still proposed, aimed at its own git-branch-scoped records', async () => { + const { body } = await discover({ + scoped: [{ branch: 'develop', names: ['DB_URL'] }], + capyBranches: { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL'], develop: ['DB_URL'] }, + }); + const proposal = body.proposals.find((p: any) => p.service_id === SVC_SCOPED); + expect(proposal.plan_entry.vars).toEqual(['DB_URL']); + expect(proposal.target).toMatchObject({ vercelEnv: 'preview', gitBranch: 'develop', branch: 'develop' }); + }); +}); + +describe('custom environments with a branch rule', () => { + const customEnvs = (matcher: { type: string; pattern: string } | undefined): Pick => ({ + custom: { [PRJ]: [{ id: 'env_stg', slug: 'staging', ...(matcher === undefined ? {} : { branchMatcher: matcher }) }] }, + extraEnvs: [env('c1', 'DB_URL', { target: [], customEnvironmentIds: ['env_stg'] })], + capyBranches: { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL'], staging: ['DB_URL'] }, + }); + const stagingProposal = (body: any) => body.proposals.find((p: any) => p.service_id === `${PRJ}:custom:staging`); + + test('an exact rule names the branch: certain', async () => { + const { body } = await discover(customEnvs({ type: 'equals', pattern: 'staging' })); + expect(stagingProposal(body).git_branch).toBe('staging'); + expect(stagingProposal(body).confidence.branch).toBe('certain'); + expect(stagingProposal(body).evidence.git_branch).toEqual({ + basis: 'custom_environment_rule', + signals: [{ code: 'CUSTOM_ENVIRONMENT_RULE', branches: [{ branch: 'staging', weight: 1 }], rule: { type: 'equals', pattern: 'staging' } }], + }); + }); + + test('a prefix rule names no single branch: the production branch, needs_review, the rule listed', async () => { + const { body } = await discover(customEnvs({ type: 'startsWith', pattern: 'release/' })); + expect(stagingProposal(body).git_branch).toBe('main'); + expect(stagingProposal(body).confidence.branch).toBe('needs_review'); + expect(stagingProposal(body).evidence.git_branch).toEqual({ + basis: 'custom_environment_pattern', + signals: [{ code: 'CUSTOM_ENVIRONMENT_RULE', branches: [], rule: { type: 'startsWith', pattern: 'release/' } }], + }); + }); + + test('no rule: as before, the production branch with no judgement', async () => { + const { body } = await discover(customEnvs(undefined)); + expect(stagingProposal(body).git_branch).toBe('main'); + expect(stagingProposal(body).evidence.git_branch).toBeUndefined(); + }); + + test('a rule type the client does not know is dropped, not guessed', async () => { + const { body } = await discover(customEnvs({ type: 'regex', pattern: '.*' })); + expect(stagingProposal(body).evidence.git_branch).toBeUndefined(); + }); +}); + +describe('preview git branch: names only', () => { + test('no value, and no value-route call, appears anywhere', async () => { + const { body, world } = await discover({ deployments: deploys('develop', 2), scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }], defaultBranch: 'develop' }); + expect(JSON.stringify(body)).not.toContain(SENTINEL_VERCEL); + expect(world.vercelCalls().every((c) => !c.url.includes('/v1/projects/') && !c.url.includes('decrypt'))).toBe(true); + }); +}); + +describe('inferPreviewBranch (pure)', () => { + const signal = (code: BranchSignal['code'], ...branches: ReadonlyArray): BranchSignal => ({ + code, + branches: branches.map(([branch, weight]) => ({ branch, weight })), + }); + + test('leadersOf lists every branch tied for the top weight', () => { + expect(leadersOf(signal('RECENT_PREVIEW_DEPLOYMENTS', ['b', 3], ['a', 3], ['c', 1]))).toEqual(['a', 'b']); + }); + + test('a signal that names nothing is ignored', () => { + expect(inferPreviewBranch([signal('RECENT_PREVIEW_DEPLOYMENTS'), signal('GITHUB_DEFAULT_BRANCH', ['develop', 1])], 'main')).toEqual({ + branch: 'develop', + confidence: 'certain', + basis: 'signals_agree', + }); + }); + + test('a signal that disagrees with itself (a tie) is not agreement', () => { + expect(inferPreviewBranch([signal('RECENT_PREVIEW_DEPLOYMENTS', ['a', 1], ['b', 1])], 'main')).toMatchObject({ branch: 'a', confidence: 'needs_review' }); + }); + + test('without a production branch and without signals the answer is null, needs_review', () => { + expect(inferPreviewBranch([], null)).toEqual({ branch: null, confidence: 'needs_review', basis: 'no_signals' }); + }); +}); + +describe('the Vercel client: preview deployments and branch rules', () => { + const client = (fake: FakeVercelData) => ({ api: createVercelClient('t', { fetch: fakeVercelFetch(fake), sleep: async () => {} }) }); + const data = (over: Partial): FakeVercelData => ({ projects: {}, envs: {}, ...over }); + + test('reads githubCommitRef only; a deployment without one still comes back, with no branch', async () => { + const { api } = client(data({ deployments: { p: [{ branch: 'develop' }, { branch: null }] } })); + expect(await api.listPreviewDeployments('p', 'team_1', 10)).toEqual([{ id: 'dpl_p_0', gitBranch: 'develop' }, { id: 'dpl_p_1' }]); + }); + + test('a custom environment\'s branch matcher is parsed; an unknown type is dropped', async () => { + const { api } = client( + data({ + custom: { + p: [ + { id: 'a', slug: 'a', branchMatcher: { type: 'endsWith', pattern: '-stg' } }, + { id: 'b', slug: 'b', branchMatcher: { type: 'glob', pattern: '*' } }, + { id: 'c', slug: 'c' }, + ], + }, + }), + ); + expect(await api.listCustomEnvironments('p')).toEqual([{ id: 'a', slug: 'a', branchMatcher: { type: 'endsWith', pattern: '-stg' } }, { id: 'b', slug: 'b' }, { id: 'c', slug: 'c' }]); + }); +}); diff --git a/tests/deploy/vercelApi.test.ts b/tests/deploy/vercelApi.test.ts index 8cbff5de..20b1ec83 100644 --- a/tests/deploy/vercelApi.test.ts +++ b/tests/deploy/vercelApi.test.ts @@ -52,8 +52,9 @@ describe('names', () => { describe('createVercelClient — read-only, bearer auth, team scope', () => { test('exposes no method that could write', () => { const client = createVercelClient(TOKEN, { fetch: fakeVercelFetch({ projects: {}, envs: {} }) }); + // CAP-730 adds `listPreviewDeployments`: a GET, like the rest. expect(Object.keys(client).toSorted()).toEqual( - ['getEnvValue', 'getProject', 'listCustomEnvironments', 'listEnvs', 'listProjects', 'listTeams'].toSorted(), + ['getEnvValue', 'getProject', 'listCustomEnvironments', 'listEnvs', 'listPreviewDeployments', 'listProjects', 'listTeams'].toSorted(), ); }); diff --git a/tests/helpers/fakeVercel.ts b/tests/helpers/fakeVercel.ts index 17c9661f..7d1810ca 100644 --- a/tests/helpers/fakeVercel.ts +++ b/tests/helpers/fakeVercel.ts @@ -25,7 +25,14 @@ export interface FakeVercelData { /** Projects per scope: a team id, or `personal`. */ projects: Readonly>>>; envs: Readonly>>; - custom?: Readonly>>; + custom?: Readonly>>; + /** + * Recent preview deployments per project id, newest first: the git branch each built from (`meta.githubCommitRef`; + * `null`: a deployment with no git branch, such as a CLI deploy). A project missing here has none. + */ + deployments?: Readonly>>; + /** Project ids whose deployments call answers with this HTTP status instead of a list (e.g. 403 for a token that cannot see deployments). */ + deploymentsStatus?: Readonly>; /** Per-id decrypted values, keyed by env record id. A missing key = Vercel returned no `value`. */ values?: Readonly>; /** Env record ids whose per-id answer says `decrypted: false` (Vercel would not decrypt the value, e.g. a role that cannot read production values). */ @@ -105,6 +112,23 @@ export function fakeVercelFetch(data: FakeVercelData): VercelFetchLike { ...(data.envListNext === undefined ? {} : { pagination: { count: records.length, next: data.envListNext, prev: null } }), }); } + if (path === '/v6/deployments') { + const projectId = u.searchParams.get('projectId') ?? ''; + // The route is asked for preview deployments only, with a limit. + if (u.searchParams.get('target') !== 'preview' || !u.searchParams.get('limit')) { + return json({ error: { code: 'bad_request', message: 'target=preview and limit are required' } }, 400); + } + const forcedStatus = data.deploymentsStatus?.[projectId]; + if (forcedStatus !== undefined) return json({ error: { code: 'forced', message: 'forced' } }, forcedStatus); + const limit = Number(u.searchParams.get('limit')); + return json({ + deployments: (data.deployments?.[projectId] ?? []).slice(0, limit).map((d, i) => ({ + uid: `dpl_${projectId}_${i}`, + meta: d.branch === null ? {} : { githubCommitRef: d.branch }, + })), + pagination: { count: 0, next: null, prev: null }, + }); + } const envValueRoute = /^\/v1\/projects\/([^/]+)\/env\/([^/]+)$/.exec(path); if (envValueRoute) { const projectId = decodeURIComponent(envValueRoute[1]); From 4bf22a598cca25bb08919817b969b6ceb95ee196 Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 5 Nov 2026 21:11:22 -0700 Subject: [PATCH 17/22] fix(vercel): a preview's branch is a long-lived branch; deployments only rank the candidates (CAP-730) Candidates are the production branch, the GitHub default branch and the branches with scoped variables. Recent preview deployments never add one, so a feature branch or a one-off import branch can no longer become the PR base. One candidate: the production branch, certain. Several: ranked by deployments, scoped variables, default branch; certain only when every signal that names a candidate names the winner. Deployment counts in the evidence are kept for candidates only, with one total for the other branches. --- src/commands/deployDiscover/facts.ts | 4 + src/commands/deployDiscover/vercel.ts | 5 +- src/commands/deployDiscover/vercelBranch.ts | 119 +++++++++----- .../deployDiscoverVercelPreviewBranch.test.ts | 153 ++++++++++++------ 4 files changed, 192 insertions(+), 89 deletions(-) diff --git a/src/commands/deployDiscover/facts.ts b/src/commands/deployDiscover/facts.ts index 3905e454..94529e90 100644 --- a/src/commands/deployDiscover/facts.ts +++ b/src/commands/deployDiscover/facts.ts @@ -94,12 +94,16 @@ export interface BranchSignal { /** `BRANCH_SCOPED_VARIABLES`: those of `names` the all-branches preview also has (Vercel reads the scoped one on that branch). */ readonly shadows?: readonly string[]; }>; + /** `RECENT_PREVIEW_DEPLOYMENTS`, in the output: the deployments on branches that are not candidates (feature branches, one-off import branches), as one total. */ + readonly other_branches?: number; /** `CUSTOM_ENVIRONMENT_RULE`: the matcher as Vercel holds it. */ readonly rule?: { readonly type: string; readonly pattern: string }; } /** How the git branch of a Vercel service was settled. */ export type GitBranchBasis = + /** No long-lived branch other than the production branch: the production branch. */ + | 'production_branch_only' | 'signals_agree' | 'strongest_signal' | 'no_signals' diff --git a/src/commands/deployDiscover/vercel.ts b/src/commands/deployDiscover/vercel.ts index 841d6fbc..29698233 100644 --- a/src/commands/deployDiscover/vercel.ts +++ b/src/commands/deployDiscover/vercel.ts @@ -11,8 +11,9 @@ * the `KindOverrides` that turn a plan entry into a Vercel target. * * The git branch of a service (the deploy PR base): production's is the project's production branch. The all-branches - * preview is judged from recent preview deployments, branch-scoped variables and the GitHub default branch; a custom - * environment from its branch rule (CAP-730, see `vercelBranch.ts`). The signals are listed in the proposal's evidence. + * preview's is one of the long-lived branches (production, the GitHub default branch, branches with scoped variables), + * ranked by recent preview deployments; a custom environment's is its branch rule (CAP-730, see `vercelBranch.ts`). + * The signals are listed in the proposal's evidence. * * A "service" is one ENVIRONMENT of one Vercel project that holds variables: * production, preview, each custom environment, and `development` only when diff --git a/src/commands/deployDiscover/vercelBranch.ts b/src/commands/deployDiscover/vercelBranch.ts index 17449edf..7eab3641 100644 --- a/src/commands/deployDiscover/vercelBranch.ts +++ b/src/commands/deployDiscover/vercelBranch.ts @@ -2,19 +2,22 @@ * Which git branch a Vercel environment deploys from (CAP-730), as pure functions. * * Production is the project's production branch, always. The all-branches - * PREVIEW environment is usually fed from another branch (a repo whose default - * branch is `develop` and whose production branch is `main`), so it is judged - * from three signals, and a custom environment from its branch rule: + * PREVIEW environment's branch is the deploy PR base, so it must be a + * LONG-LIVED branch. The candidates are ONLY: * - * 1. RECENT_PREVIEW_DEPLOYMENTS the branch most recent preview deployments built from - * 2. BRANCH_SCOPED_VARIABLES the branch most preview variables are scoped to - * 3. GITHUB_DEFAULT_BRANCH the repo's default branch, when it is not the production branch + * - the project's production branch + * - the repo's GitHub default branch + * - a branch that preview variables are scoped to * - * Signals agree (every signal names the same single branch): that branch, - * `certain`. They disagree: the strongest signal wins, in the order above - * (what was actually deployed outranks what is configured, which outranks a - * repo-wide default), and the choice is `needs_review` with every signal - * listed. No signal: the production branch, `needs_review`. + * (a custom environment's `equals` rule names its own branch). Recent preview + * deployments never add a candidate: a feature branch, or a one-off import + * branch, was deployed once and is gone. Deployments only RANK the candidates. + * + * With one candidate (the production branch) that is the answer, `certain`. + * With several, they are ranked by deployments on each, then by scoped + * variables, then by being the default branch (then by name). The winner is + * `certain` only when every signal that names a candidate names the winner and + * only the winner; otherwise it is `needs_review`. Every signal is listed. * * Names, ids and counts only. Nothing in here ever holds a variable value. */ @@ -23,7 +26,7 @@ import type { VercelBranchMatcher, VercelDeployment } from '../../deploy/vercelA import type { BranchSignal, BranchSignalCode, GitBranchBasis, ServiceFacts, VercelServiceFacts } from './facts'; import { byText, uniqueSorted } from './facts'; -/** The order signals outrank each other when they disagree. */ +/** The order the criteria rank the candidates in (see `inferPreviewBranch`). */ export const BRANCH_SIGNAL_ORDER: readonly BranchSignalCode[] = ['RECENT_PREVIEW_DEPLOYMENTS', 'BRANCH_SCOPED_VARIABLES', 'GITHUB_DEFAULT_BRANCH']; export interface BranchInference { @@ -44,9 +47,6 @@ export function leadersOf(signal: BranchSignal): readonly string[] { return uniqueSorted(signal.branches.filter((b) => b.weight === top).map((b) => b.branch)); } -/** A signal that names at least one branch. */ -const names = (signal: BranchSignal): boolean => signal.branches.length > 0; - // ── The signals ────────────────────────────────────────────────────────────── /** Branch counts over a project's recent preview deployments. A deployment with no git branch (a CLI deploy) counts for none. */ @@ -100,38 +100,67 @@ export function customRuleSignal(matcher: VercelBranchMatcher | undefined): Bran // ── The choice ─────────────────────────────────────────────────────────────── -const rankOf = (signal: BranchSignal): number => { - const at = BRANCH_SIGNAL_ORDER.indexOf(signal.code); - return at === -1 ? BRANCH_SIGNAL_ORDER.length : at; -}; +const signalOf = (signals: readonly BranchSignal[], code: BranchSignalCode): BranchSignal | undefined => signals.find((s) => s.code === code); -/** Every signal names the same single branch. */ -function agreement(active: readonly BranchSignal[]): string | null { - const leaders = active.map(leadersOf); - const first = leaders[0]?.[0]; - return first !== undefined && leaders.every((l) => l.length === 1 && l[0] === first) ? first : null; +const weightOf = (signals: readonly BranchSignal[], code: BranchSignalCode, branch: string): number => + signalOf(signals, code)?.branches.find((b) => b.branch === branch)?.weight ?? 0; + +/** The long-lived branches a preview may deploy from: production, the default branch, the scoped branches. Never a deployed-from branch. */ +export function previewCandidates(signals: readonly BranchSignal[], productionBranch: string | null): readonly string[] { + const named = [signalOf(signals, 'BRANCH_SCOPED_VARIABLES'), signalOf(signals, 'GITHUB_DEFAULT_BRANCH')].flatMap((s) => + s === undefined ? [] : s.branches.map((b) => b.branch), + ); + return uniqueSorted([...(productionBranch === null ? [] : [productionBranch]), ...named]); } -/** - * The strongest signal wins; a tie inside it is broken by the next signal down - * (when it names one of the tied branches), then by branch name. - */ -function strongest(active: readonly BranchSignal[]): string { - const ordered = sortedCopy(active, (a, b) => rankOf(a) - rankOf(b)); - const narrowed = ordered.slice(1).reduce((candidates, signal) => { - const common = leadersOf(signal).filter((b) => candidates.includes(b)); - return common.length > 0 ? common : candidates; - }, leadersOf(ordered[0])); - return sortedCopy(narrowed, byText)[0]; +/** Candidates best first: deployments, then scoped variables, then the default branch, then name. */ +function rankCandidates(candidates: readonly string[], signals: readonly BranchSignal[]): readonly string[] { + const score = (branch: string): readonly number[] => [ + weightOf(signals, 'RECENT_PREVIEW_DEPLOYMENTS', branch), + weightOf(signals, 'BRANCH_SCOPED_VARIABLES', branch), + weightOf(signals, 'GITHUB_DEFAULT_BRANCH', branch), + ]; + return sortedCopy(candidates, (a, b) => { + const [sa, sb] = [score(a), score(b)]; + return sb[0] - sa[0] || sb[1] - sa[1] || sb[2] - sa[2] || byText(a, b); + }); +} + +/** What a signal names among the candidates: its strongest candidate(s); none when it names no candidate. */ +function votesOf(signal: BranchSignal, candidates: readonly string[]): readonly string[] { + const mine = signal.branches.filter((b) => candidates.includes(b.branch) && b.weight > 0); + const top = Math.max(0, ...mine.map((b) => b.weight)); + return uniqueSorted(mine.filter((b) => b.weight === top).map((b) => b.branch)); +} + +export interface PreviewInference extends BranchInference { + readonly candidates: readonly string[]; } /** The git branch of an all-branches preview from its signals. */ -export function inferPreviewBranch(signals: readonly BranchSignal[], productionBranch: string | null): BranchInference { - const active = signals.filter(names); - if (active.length === 0) return { branch: productionBranch, confidence: 'needs_review', basis: 'no_signals' }; - const agreed = agreement(active); - if (agreed !== null) return { branch: agreed, confidence: 'certain', basis: 'signals_agree' }; - return { branch: strongest(active), confidence: 'needs_review', basis: 'strongest_signal' }; +export function inferPreviewBranch(signals: readonly BranchSignal[], productionBranch: string | null): PreviewInference { + const candidates = previewCandidates(signals, productionBranch); + if (candidates.length === 0) return { branch: productionBranch, confidence: 'needs_review', basis: 'no_signals', candidates }; + if (candidates.length === 1 && candidates[0] === productionBranch) { + return { branch: productionBranch, confidence: 'certain', basis: 'production_branch_only', candidates }; + } + const winner = rankCandidates(candidates, signals)[0]; + const votes = signals.map((s) => votesOf(s, candidates)).filter((v) => v.length > 0); + const agreed = votes.every((v) => v.length === 1 && v[0] === winner); + return { branch: winner, confidence: agreed ? 'certain' : 'needs_review', basis: agreed ? 'signals_agree' : 'strongest_signal', candidates }; +} + +/** + * The deployments signal for output: counts for the candidate branches only, and one total for every other branch, so + * the evidence is not a list of every feature branch that was ever deployed. + */ +export function deploymentsForOutput(signal: BranchSignal, candidates: readonly string[]): BranchSignal { + const mine = signal.branches.filter((b) => candidates.includes(b.branch)); + return { + ...signal, + branches: mine, + other_branches: signal.branches.filter((b) => !candidates.includes(b.branch)).reduce((total, b) => total + b.weight, 0), + }; } /** The git branch of a custom environment from its rule: an exact rule names the branch; a pattern names none. */ @@ -164,7 +193,13 @@ export function withGitBranch(service: ServiceFacts, defaultBranch: string | nul const defaultSignal = defaultBranchSignal(defaultBranch, v.branch); const signals = [...v.git_branch_signals, ...(defaultSignal === null ? [] : [defaultSignal])]; const inferred = inferPreviewBranch(signals, v.branch); - return { ...v, branch: inferred.branch, git_branch_signals: signals, git_branch_basis: inferred.basis, git_branch_confidence: inferred.confidence }; + return { + ...v, + branch: inferred.branch, + git_branch_signals: signals.map((s) => (s.code === 'RECENT_PREVIEW_DEPLOYMENTS' ? deploymentsForOutput(s, inferred.candidates) : s)), + git_branch_basis: inferred.basis, + git_branch_confidence: inferred.confidence, + }; } const custom = inferCustomBranch(v.git_branch_signals, v.branch); return custom === null diff --git a/tests/commands/deployDiscoverVercelPreviewBranch.test.ts b/tests/commands/deployDiscoverVercelPreviewBranch.test.ts index 986d12dc..56a3cb15 100644 --- a/tests/commands/deployDiscoverVercelPreviewBranch.test.ts +++ b/tests/commands/deployDiscoverVercelPreviewBranch.test.ts @@ -1,12 +1,13 @@ /** - * `capy deploy vercel --discover` (CAP-730): the git branch of a preview environment. + * `capy deploy vercel --discover` (CAP-730): the branch of a preview environment. * - * Production keeps the project's production branch. The all-branches preview is judged from three signals (recent - * preview deployments, branch-scoped variables, the GitHub default branch when it is not the production branch); - * a custom environment from its branch rule. Agreement is `certain`; disagreement takes the strongest signal - * (deployments, then scoped variables, then the default branch), is `needs_review`, and lists every signal; no signal - * is the production branch, `needs_review`. A preview scoped to a git branch never becomes an empty proposal. The - * deployments call failing is a coded notice, never a failed discovery. Names, ids and counts only. + * Production keeps the project's production branch. The all-branches preview's branch is the deploy PR base, so it is + * a LONG-LIVED branch: the candidates are only the production branch, the GitHub default branch and the branches + * preview variables are scoped to. Recent preview deployments never add a candidate; they rank the ones there are. + * One candidate: it, certain. Several: ranked by deployments, scoped variables, default branch; certain only when every + * signal that names a candidate names the winner. A custom environment uses its branch rule. A preview scoped to a + * branch never becomes an empty proposal. The deployments call failing is a coded notice, never a failed discovery. + * Names, ids and counts only. */ import { describe, test, expect } from 'bun:test'; import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; @@ -84,19 +85,72 @@ async function discover(setup: Setup = {}) { const deploys = (branch: string, n: number): readonly string[] => Array.from({ length: n }, () => branch); -describe('preview git branch: one signal', () => { - test('recent preview deployments: the branch most of them built from', async () => { - const { previewProposal, previewService } = await discover({ deployments: [...deploys('develop', 5), ...deploys('feature-a', 2)] }); +describe('preview branch: candidates are long-lived branches; deployments only rank them', () => { + test('(a) default branch = production = main, deployments spread over feature branches: main, certain; the feature branches are one total, not a list', async () => { + const { previewProposal, previewService, body } = await discover({ + deployments: [...deploys('feat/SLI-191', 3), ...deploys('fix/replicate-output-shape', 3), ...deploys('feature-b', 2)], + }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewService.branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch).toEqual({ + basis: 'production_branch_only', + signals: [{ code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [], other_branches: 8 }], + }); + expect(JSON.stringify(body)).not.toContain('SLI-191'); + }); + + test('(b) only a one-off capy/* import branch was deployed, once: main, certain', async () => { + const { previewProposal } = await discover({ deployments: ['capy/vercel-import-20261104-2058'] }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch.basis).toBe('production_branch_only'); + }); + + test('(c) the slidespeak-app shape (production main, default develop, scoped develop, develop 25 deploys, many features, main 0): develop, certain, counts only for candidates', async () => { + const features = Array.from({ length: 20 }, (_, i) => `feature-${i}`); + const { previewProposal, previewService, body } = await discover({ + defaultBranch: 'develop', + scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }], + deployments: [...deploys('develop', 25), ...features], + }); expect(previewProposal.git_branch).toBe('develop'); expect(previewService.branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch.basis).toBe('signals_agree'); expect(previewProposal.evidence.git_branch.signals).toEqual([ - { code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [{ branch: 'develop', weight: 5 }, { branch: 'feature-a', weight: 2 }] }, + { code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [{ branch: 'develop', weight: 25 }], other_branches: 20 }, + { code: 'BRANCH_SCOPED_VARIABLES', branches: [{ branch: 'develop', weight: 1, names: ['NEW_FLAG'], shadows: [] }] }, + { code: 'GITHUB_DEFAULT_BRANCH', branches: [{ branch: 'develop', weight: 1 }] }, ]); - // Several deployment branches is one signal with a clear leader: no disagreement. - expect(previewProposal.evidence.git_branch.basis).toBe('signals_agree'); + expect(JSON.stringify(body)).not.toContain('feature-7'); + }); + + test('(d) default develop but the deployments are mostly on main: main wins the ranking, needs_review, every signal listed', async () => { + const { previewProposal } = await discover({ defaultBranch: 'develop', deployments: [...deploys('main', 6), ...deploys('develop', 1), 'feature-a'] }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + expect(previewProposal.evidence.git_branch.basis).toBe('strongest_signal'); + expect(previewProposal.evidence.git_branch.signals).toEqual([ + { code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [{ branch: 'main', weight: 6 }, { branch: 'develop', weight: 1 }], other_branches: 1 }, + { code: 'GITHUB_DEFAULT_BRANCH', branches: [{ branch: 'develop', weight: 1 }] }, + ]); + }); + + test('a branch that was only deployed from never becomes a candidate, however many deployments it has', async () => { + const { previewProposal } = await discover({ defaultBranch: 'develop', deployments: deploys('feat/x', 40) }); + expect(previewProposal.git_branch).toBe('develop'); expect(previewProposal.confidence.branch).toBe('certain'); }); + test('deployments with no git branch (a CLI deploy) count for no branch', async () => { + const { previewProposal } = await discover({ deployments: [null, null, 'develop'] }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewProposal.evidence.git_branch.signals).toEqual([{ code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [], other_branches: 1 }]); + }); +}); + +describe('preview git branch: scoped variables and the default branch', () => { test('branch-scoped variables: the branch with the most scoped variables, with their names and the names the all-branches preview shadows', async () => { const { previewProposal } = await discover({ scoped: [{ branch: 'develop', names: ['DB_URL', 'NEW_FLAG'] }, { branch: 'staging', names: ['ONLY_STAGING'] }] }); expect(previewProposal.git_branch).toBe('develop'); @@ -123,14 +177,9 @@ describe('preview git branch: one signal', () => { const { previewProposal } = await discover({ defaultBranch: 'main', production: 'main' }); expect(previewProposal.evidence.git_branch.signals).toEqual([]); }); - - test('deployments with no git branch (a CLI deploy) count for no branch', async () => { - const { previewProposal } = await discover({ deployments: [null, null, 'develop'] }); - expect(previewProposal.evidence.git_branch.signals).toEqual([{ code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [{ branch: 'develop', weight: 1 }] }]); - }); }); -describe('preview git branch: agreement, disagreement, nothing', () => { +describe('preview git branch: ranking and disagreement', () => { test('all three signals name the same branch: that branch, certain, every signal listed', async () => { const { previewProposal } = await discover({ defaultBranch: 'develop', @@ -147,13 +196,13 @@ describe('preview git branch: agreement, disagreement, nothing', () => { ]); }); - test('deployments outrank scoped variables and the default branch: needs_review, every signal listed', async () => { + test('deployments outrank scoped variables and the default branch among the candidates: needs_review, every signal listed', async () => { const { previewProposal } = await discover({ defaultBranch: 'release', - deployments: deploys('staging', 4), + deployments: deploys('release', 4), scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }], }); - expect(previewProposal.git_branch).toBe('staging'); + expect(previewProposal.git_branch).toBe('release'); expect(previewProposal.confidence.branch).toBe('needs_review'); expect(previewProposal.evidence.git_branch.basis).toBe('strongest_signal'); expect(previewProposal.evidence.git_branch.signals.map((s: any) => s.code)).toEqual([ @@ -161,43 +210,36 @@ describe('preview git branch: agreement, disagreement, nothing', () => { 'BRANCH_SCOPED_VARIABLES', 'GITHUB_DEFAULT_BRANCH', ]); - expect(previewProposal.evidence.git_branch.signals.flatMap((s: any) => s.branches.map((b: any) => b.branch))).toEqual(['staging', 'develop', 'release']); }); - test('scoped variables outrank the default branch when there are no deployments', async () => { + test('scoped variables outrank the default branch when no candidate was deployed', async () => { const { previewProposal } = await discover({ defaultBranch: 'release', scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }] }); expect(previewProposal.git_branch).toBe('develop'); expect(previewProposal.confidence.branch).toBe('needs_review'); }); - test('deployments outrank the default branch when there are no scoped variables', async () => { - const { previewProposal } = await discover({ defaultBranch: 'release', deployments: deploys('develop', 2) }); - expect(previewProposal.git_branch).toBe('develop'); - expect(previewProposal.confidence.branch).toBe('needs_review'); - }); - - test('a tie inside the strongest signal is broken by the next signal, never left to chance', async () => { - const { previewProposal } = await discover({ deployments: ['develop', 'staging'], defaultBranch: 'staging' }); + test('a tie in deployments between candidates is broken by the next criterion, and is not agreement', async () => { + const { previewProposal } = await discover({ deployments: ['main', 'staging'], defaultBranch: 'staging' }); expect(previewProposal.git_branch).toBe('staging'); expect(previewProposal.confidence.branch).toBe('needs_review'); }); test('a tie nothing else breaks is settled by branch name, and needs_review', async () => { - const { previewProposal } = await discover({ deployments: ['staging', 'develop'] }); + const { previewProposal } = await discover({ scoped: [{ branch: 'staging', names: ['A'] }, { branch: 'develop', names: ['B'] }] }); expect(previewProposal.git_branch).toBe('develop'); expect(previewProposal.confidence.branch).toBe('needs_review'); }); - test('no signal at all: the production branch, needs_review', async () => { + test('no other long-lived branch: the production branch, certain, whatever else was read', async () => { const { previewProposal, previewService } = await discover({ production: 'main', defaultBranch: 'main' }); expect(previewProposal.git_branch).toBe('main'); expect(previewService.branch).toBe('main'); - expect(previewProposal.confidence.branch).toBe('needs_review'); - expect(previewProposal.evidence.git_branch).toEqual({ basis: 'no_signals', signals: [] }); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch).toEqual({ basis: 'production_branch_only', signals: [] }); }); test('a proposal that needs review is in the next step\'s review list', async () => { - const { body, previewProposal } = await discover({ defaultBranch: 'release', deployments: deploys('develop', 1) }); + const { body, previewProposal } = await discover({ defaultBranch: 'release', scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }] }); expect(body.next_steps.find((s: any) => s.action === 'review').proposal_ids).toContain(previewProposal.proposal_id); }); @@ -229,10 +271,10 @@ describe('preview git branch: the deployments call', () => { expect(previewProposal.evidence.git_branch.signals.map((s: any) => s.code)).toEqual(['GITHUB_DEFAULT_BRANCH']); }); - test('a failed call with nothing else to go on: the production branch, needs_review, plus the notice', async () => { + test('a failed call with no other long-lived branch: the production branch, plus the notice (deployments never decide alone)', async () => { const { body, previewProposal } = await discover({ deploymentsStatus: 403 }); expect(previewProposal.git_branch).toBe('main'); - expect(previewProposal.confidence.branch).toBe('needs_review'); + expect(previewProposal.confidence.branch).toBe('certain'); expect(body.notices.map((n: any) => n.code)).toContain('VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE'); }); @@ -369,20 +411,41 @@ describe('inferPreviewBranch (pure)', () => { expect(leadersOf(signal('RECENT_PREVIEW_DEPLOYMENTS', ['b', 3], ['a', 3], ['c', 1]))).toEqual(['a', 'b']); }); - test('a signal that names nothing is ignored', () => { - expect(inferPreviewBranch([signal('RECENT_PREVIEW_DEPLOYMENTS'), signal('GITHUB_DEFAULT_BRANCH', ['develop', 1])], 'main')).toEqual({ + test('deployments alone add no candidate: the production branch, certain', () => { + expect(inferPreviewBranch([signal('RECENT_PREVIEW_DEPLOYMENTS', ['feat/x', 9], ['fix/y', 3])], 'main')).toMatchObject({ + branch: 'main', + confidence: 'certain', + basis: 'production_branch_only', + candidates: ['main'], + }); + }); + + test('candidates are the production branch, the default branch and the scoped branches only', () => { + const inferred = inferPreviewBranch( + [signal('RECENT_PREVIEW_DEPLOYMENTS', ['feat/x', 9]), signal('BRANCH_SCOPED_VARIABLES', ['staging', 2]), signal('GITHUB_DEFAULT_BRANCH', ['develop', 1])], + 'main', + ); + expect(inferred.candidates).toEqual(['develop', 'main', 'staging']); + }); + + test('the default branch alone beats the production branch, and everyone naming it is agreement', () => { + expect(inferPreviewBranch([signal('RECENT_PREVIEW_DEPLOYMENTS', ['feat/x', 9]), signal('GITHUB_DEFAULT_BRANCH', ['develop', 1])], 'main')).toMatchObject({ branch: 'develop', confidence: 'certain', basis: 'signals_agree', }); }); - test('a signal that disagrees with itself (a tie) is not agreement', () => { - expect(inferPreviewBranch([signal('RECENT_PREVIEW_DEPLOYMENTS', ['a', 1], ['b', 1])], 'main')).toMatchObject({ branch: 'a', confidence: 'needs_review' }); + test('a signal that disagrees with itself (a tie between candidates) is not agreement', () => { + expect(inferPreviewBranch([signal('BRANCH_SCOPED_VARIABLES', ['a', 1], ['b', 1])], 'main')).toMatchObject({ branch: 'a', confidence: 'needs_review' }); + }); + + test('without a production branch and without candidates the answer is null, needs_review', () => { + expect(inferPreviewBranch([], null)).toMatchObject({ branch: null, confidence: 'needs_review', basis: 'no_signals' }); }); - test('without a production branch and without signals the answer is null, needs_review', () => { - expect(inferPreviewBranch([], null)).toEqual({ branch: null, confidence: 'needs_review', basis: 'no_signals' }); + test('without a production branch, a single named candidate is the answer', () => { + expect(inferPreviewBranch([signal('GITHUB_DEFAULT_BRANCH', ['develop', 1])], null)).toMatchObject({ branch: 'develop', confidence: 'certain' }); }); }); From 3fac8160ece1710f897795c94a954aab24c64733 Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 5 Nov 2026 21:12:25 -0700 Subject: [PATCH 18/22] release: Capy CLI v0.9.14 --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 62ea2590..6d44d227 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.13 +0.9.14 diff --git a/package.json b/package.json index cff19a88..03d3ce89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@capysc/cli", - "version": "0.9.13", + "version": "0.9.14", "description": "Capy CLI - SecretOps for the AI age", "main": "dist/index.js", "bin": { From 2eb7e37824d01f7ffef3f32b8a697349b0eb76c8 Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 6 Nov 2026 00:07:20 -0700 Subject: [PATCH 19/22] feat(secrets): Ctrl+S cycles the list sort (CAP-731) Six sorts: name A-Z/Z-A, deploy status A-Z/Z-A, updated newest/oldest. Search still decides which rows show; the sort orders them, and the highlighted row stays highlighted. The footer shows the current sort and drops whole hints (never mid-word) when the terminal is narrow. --- src/ui/secretsScreen.ts | 180 ++++++++++++++++- tests/ui/secretsScreen.test.ts | 3 +- tests/ui/secretsScreenSort.test.ts | 308 +++++++++++++++++++++++++++++ 3 files changed, 480 insertions(+), 11 deletions(-) create mode 100644 tests/ui/secretsScreenSort.test.ts diff --git a/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index 1e1ee4e5..fa3fc193 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -25,6 +25,7 @@ import { CANCELLED_NOTHING, DRY_RUN_LABEL } from '../commands/secretsSetText'; import type { RunProgress } from '../commands/secretsSet'; import { clipLine } from './pickerTable'; import { BEHIND_LABEL, DEPLOYED_LABEL, NO_TARGET_LABEL, TARGET_STATUS_HEADING, formatSecretRowStatus, locationState, secretRowStatus } from '../core/deployStatus'; +import type { SecretRowStatus } from '../core/deployStatus'; import { NO_VALUE_YET, noValueYetLine } from '../core/placeholder'; import { statusBadge, statusColor } from './statusBadge'; import { @@ -116,6 +117,8 @@ const KEY_CTRL_C = '\x03'; const KEY_CTRL_E = '\x05'; /** Ctrl+D: deploy the selected row's Dokploy targets, from the list while no filter is typed. The plain `d` is only a hotkey in the details view. */ const KEY_CTRL_D = '\x04'; +/** Ctrl+S: cycle the list's sort. Ignored while the details view (or an edit/deploy screen) is open. */ +const KEY_CTRL_S = '\x13'; const KEY_BACKSPACE = '\x7f'; const KEY_BACKSPACE2 = '\b'; @@ -224,6 +227,98 @@ function nextColumn(column: ColumnMode, dir: 1 | -1): ColumnMode { return COLUMN_ORDER[nextIdx]; } +// ── Sorting (CAP-731) ──────────────────────────────────────────────────────── +// +// Ctrl+S cycles the list through SORT_ORDER (wrapping). Search still decides +// WHICH rows show; the sort only orders them. Every comparison keys off a +// machine value (the name string, the status `kind`, a timestamp), never the +// words the screen prints. Ties always fall back to a deterministic key, so the +// order never depends on how the server (or a previous sort) happened to arrive. + +export type SortMode = 'name-asc' | 'name-desc' | 'status-asc' | 'status-desc' | 'updated-newest' | 'updated-oldest'; +/** Ctrl+S walks this order and wraps; the first entry is the default. */ +export const SORT_ORDER: readonly SortMode[] = ['name-asc', 'name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest']; + +/** The next sort after `mode`, wrapping from the last back to the first. Pure. */ +export function nextSort(mode: SortMode): SortMode { + return SORT_ORDER[(SORT_ORDER.indexOf(mode) + 1) % SORT_ORDER.length]; +} + +/** What the footer says each sort is. */ +const SORT_LABELS: Readonly> = { + 'name-asc': 'name A-Z', // COPY-FLAG + 'name-desc': 'name Z-A', // COPY-FLAG + 'status-asc': 'deploy status A-Z', // COPY-FLAG + 'status-desc': 'deploy status Z-A', // COPY-FLAG + 'updated-newest': 'updated newest', // COPY-FLAG + 'updated-oldest': 'updated oldest', // COPY-FLAG +}; + +/** + * Deploy status order for the A-Z sort, keyed by `secretRowStatus(row).kind` + * (a code, not the displayed word). Z-A is this reversed. Typed as a full + * Record so a new kind will not compile until it has a rank. + */ +const STATUS_RANK: Readonly> = { + deployed: 0, + behind: 1, + unknown: 2, + 'no target': 3, +}; + +/** Case-insensitive, accent-insensitive, locale-aware ('en'): the same family of order the service uses for its own row order (`localeCompare`), but with a fixed locale so it does not change per machine. */ +const NAME_COLLATOR = new Intl.Collator('en', { sensitivity: 'base' }); + +const compareText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** Name A-Z; a tie (same name ignoring case) breaks by value hash so the order is deterministic. */ +function compareByName(a: SecretIndexRow, b: SecretIndexRow): number { + return NAME_COLLATOR.compare(a.name, b.name) || compareText(a.name, b.name) || compareText(a.value_hash, b.value_hash); +} + +/** Epoch milliseconds of the row's most recent change, or `null` when it has none (or one that is not a date). */ +function changedAtMs(row: SecretIndexRow): number | null { + const ts = mostRecentChangedAt(row); + const ms = ts === undefined ? Number.NaN : new Date(ts).getTime(); + return Number.isNaN(ms) ? null : ms; +} + +/** Updated sort: rows with no time go last in BOTH directions; equal times fall back to name A-Z. */ +function compareByUpdated(direction: 1 | -1): (a: SecretIndexRow, b: SecretIndexRow) => number { + return (a, b) => { + const am = changedAtMs(a); + const bm = changedAtMs(b); + if (am === null && bm === null) return compareByName(a, b); + if (am === null) return 1; + if (bm === null) return -1; + return (direction === 1 ? bm - am : am - bm) || compareByName(a, b); + }; +} + +/** Deploy status sort: by rank (reversed for Z-A); a tie breaks by name A-Z in both directions. */ +function compareByStatus(direction: 1 | -1): (a: SecretIndexRow, b: SecretIndexRow) => number { + return (a, b) => direction * (STATUS_RANK[secretRowStatus(a).kind] - STATUS_RANK[secretRowStatus(b).kind]) || compareByName(a, b); +} + +function comparatorFor(sort: SortMode): (a: SecretIndexRow, b: SecretIndexRow) => number { + if (sort === 'name-desc') return (a, b) => compareByName(b, a); + if (sort === 'status-asc') return compareByStatus(1); + if (sort === 'status-desc') return compareByStatus(-1); + if (sort === 'updated-newest') return compareByUpdated(1); + if (sort === 'updated-oldest') return compareByUpdated(-1); + return compareByName; +} + +/** A sorted copy of `items` (`toSorted` is ES2023 and this package supports Node 18): the input is never changed. */ +function sortedCopy(items: readonly T[], cmp: (a: T, b: T) => number): readonly T[] { + return Array.from(items).sort(cmp); +} + +/** `rows` in `sort` order, as a new array. */ +export function sortRows(rows: readonly SecretIndexRow[], sort: SortMode): readonly SecretIndexRow[] { + return sortedCopy(rows, comparatorFor(sort)); +} + // ── State ──────────────────────────────────────────────────────────────────── export type { ValueState }; @@ -256,6 +351,8 @@ export interface SearchState { export interface SecretsScreenState { readonly rows: readonly SecretIndexRow[]; readonly column: ColumnMode; + /** How the list is ordered (Ctrl+S cycles it); search decides which rows show, this decides their order. */ + readonly sort: SortMode; readonly cursorIndex: number; readonly search: SearchState; readonly popup: PopupState | null; @@ -276,6 +373,7 @@ export function initialSecretsScreenState(rows: readonly SecretIndexRow[], dryRu return { rows, column: COLUMN_ORDER[0], + sort: SORT_ORDER[0], cursorIndex: 0, search: { query: '' }, popup: null, @@ -365,7 +463,13 @@ function rowMatchReasons(row: SecretIndexRow, qLower: string, rawHash: string, t * reasons" before any hashing happens (mirrors the old NAME-only filter's * behavior when the bar is empty). */ -function computeFilteredRows(rows: readonly SecretIndexRow[], query: string): readonly MatchedRow[] { +function computeFilteredRows(rows: readonly SecretIndexRow[], query: string, sort: SortMode): readonly MatchedRow[] { + const order = comparatorFor(sort); + return sortedCopy(matchRows(rows, query), (a, b) => order(a.row, b.row)); +} + +/** The rows that match `query`, each with why, in the order they arrived (`computeFilteredRows` applies the sort). */ +function matchRows(rows: readonly SecretIndexRow[], query: string): readonly MatchedRow[] { const trimmed = query.trim(); const qLower = normalizeQuery(query); if (!qLower) return rows.map((row) => ({ row, reasons: [] as const })); @@ -381,12 +485,12 @@ function computeFilteredRows(rows: readonly SecretIndexRow[], query: string): re /** Rows currently visible, plus why each one matched — see the module note above `computeFilteredRows`. */ export function filteredRowsWithReasons(state: SecretsScreenState): readonly MatchedRow[] { - return computeFilteredRows(state.rows, state.search.query); + return computeFilteredRows(state.rows, state.search.query, state.sort); } /** Rows currently visible, after the live filter (name, project, branch, connector, target, or exact value — see `computeFilteredRows`). */ export function filteredRows(state: SecretsScreenState): readonly SecretIndexRow[] { - return computeFilteredRows(state.rows, state.search.query).map((m) => m.row); + return computeFilteredRows(state.rows, state.search.query, state.sort).map((m) => m.row); } function clampIndex(index: number, length: number): number { @@ -520,16 +624,30 @@ export function applyRunDone(state: SecretsScreenState, finished: RunFinished): return { ...state, edit: applyRunFinished(state.edit, finished) }; } -function filteredRowsFor(rows: readonly SecretIndexRow[], query: string): readonly SecretIndexRow[] { - return computeFilteredRows(rows, query).map((m) => m.row); +function filteredRowsFor(rows: readonly SecretIndexRow[], query: string, sort: SortMode): readonly SecretIndexRow[] { + return computeFilteredRows(rows, query, sort).map((m) => m.row); } /** Sets the search query and reclamps the cursor into the (possibly now-shorter) filtered set — never resets it to the top just because the query changed. */ function applyQuery(state: SecretsScreenState, query: string): SecretsScreenState { - const rows = filteredRowsFor(state.rows, query); + const rows = filteredRowsFor(state.rows, query, state.sort); return { ...state, search: { query }, cursorIndex: clampIndex(state.cursorIndex, rows.length) }; } +/** + * Switches to `sort` and keeps the highlighted row highlighted: it is found again + * by name + value hash in the new order (a row is one (name, value_hash) pair). + * With no highlighted row (an empty list) the cursor just stays clamped. + */ +function applySort(state: SecretsScreenState, sort: SortMode): SecretsScreenState { + const before = filteredRows(state); + const current = before[clampIndex(state.cursorIndex, before.length)]; + const resorted = { ...state, sort }; + const after = filteredRows(resorted); + const found = current === undefined ? -1 : after.findIndex((r) => r.name === current.name && r.value_hash === current.value_hash); + return { ...resorted, cursorIndex: found >= 0 ? found : clampIndex(state.cursorIndex, after.length) }; +} + function handlePopupKey(state: SecretsScreenState, key: string): SecretsScreenState { const popup = state.popup; if (!popup) return state; @@ -585,6 +703,9 @@ function handleListKey(state: SecretsScreenState, key: string): ReduceResult { if (key === KEY_CTRL_E && state.search.query === '') return openEditOnCursor(state); if (key === KEY_CTRL_D && state.search.query === '') return openDeployOnCursor(state); + // Works with or without a typed filter: the sort only orders the rows the search shows. + if (key === KEY_CTRL_S) return noEffect(applySort(state, nextSort(state.sort))); + if (key === KEY_TAB) { return noEffect({ ...state, column: nextColumn(state.column, 1) }); } @@ -1028,7 +1149,7 @@ export function render(state: SecretsScreenState, termWidth: number, termHeight: rows.length === 0 ? [`${m}${DIM}No secrets match.${RESET}`] : slice.map((line) => m + line); const noteLines: readonly string[] = state.note === null ? [] : [`${m}${DIM}${state.note}${RESET}`]; - const footerLines: readonly string[] = ['', ...noteLines, m + footerLine(state)]; + const footerLines: readonly string[] = ['', ...noteLines, m + clipLine(footerLine(state, termWidth - MARGIN), Math.max(1, termWidth - MARGIN))]; const lines: readonly string[] = [...preBodyLines, ...bodyOutputLines, ...footerLines]; @@ -1195,12 +1316,51 @@ function fitLocation(project: string, tails: readonly string[], width: number): return rest.length > 0 ? fitLocation(project, rest, width) : truncate(full, width); } -function footerLine(state: SecretsScreenState): string { +/** One key hint of the list footer. `key` is `null` for a hint that is all label. */ +interface FooterHint { + readonly id: 'navigate' | 'column' | 'inspect' | 'edit' | 'deploy' | 'esc' | 'sort'; + readonly key: string | null; + readonly label: string; +} + +/** + * When the footer is wider than the terminal, whole hints are dropped in this + * order (never cut mid-word) until it fits. `esc` and `sort` are never dropped. + */ +const FOOTER_DROP_ORDER: readonly FooterHint['id'][] = ['deploy', 'edit', 'navigate', 'inspect', 'column']; + +const renderHint = (h: FooterHint): string => (h.key === null ? `${DIM}${h.label}${RESET}` : `${BOLD}${h.key}${RESET}${DIM} ${h.label}${RESET}`); +const joinHints = (hints: readonly FooterHint[]): string => hints.map(renderHint).join(`${DIM} · ${RESET}`); + +/** `hints` with the droppable ones removed, in `FOOTER_DROP_ORDER`, just until the joined line fits `width`. */ +function fitHints(hints: readonly FooterHint[], width: number): readonly FooterHint[] { + return FOOTER_DROP_ORDER.reduce( + (kept, id) => (visLen(joinHints(kept)) <= width ? kept : kept.filter((h) => h.id !== id)), + hints, + ); +} + +function footerLine(state: SecretsScreenState, width: number): string { if (state.popup) { const revealLabel = state.popup.revealed ? 'hide' : 'reveal'; const panHint = state.popup.revealed ? `${DIM} · ${RESET}${BOLD}←/→${RESET}${DIM} pan${RESET}` : ''; return `${BOLD}r${RESET}${DIM} ${revealLabel}${RESET}${panHint}${DIM} · ${RESET}${BOLD}tab${RESET}${DIM} column · ${RESET}${BOLD}e${RESET}${DIM} edit${RESET}${DIM} · ${RESET}${BOLD}d${RESET}${DIM} deploy${RESET}${DIM} · ${RESET}${BOLD}esc${RESET}${DIM}/${RESET}${BOLD}q${RESET}${DIM} close${RESET}`; // COPY-FLAG } - const editHint = state.search.query === '' ? `${BOLD}ctrl+e${RESET}${DIM} edit · ${RESET}${BOLD}ctrl+d${RESET}${DIM} deploy · ${RESET}` : ''; // COPY-FLAG - return `${DIM}↑↓ navigate · ${RESET}${BOLD}tab${RESET}${DIM} column · ${RESET}${BOLD}enter${RESET}${DIM} inspect · ${RESET}${editHint}${BOLD}esc${RESET}${DIM} clear/quit${RESET}`; // COPY-FLAG + // Edit and deploy are not offered while a filter is typed: the search owns the keyboard then. + const editHints: readonly FooterHint[] = + state.search.query === '' + ? [ + { id: 'edit', key: 'ctrl+e', label: 'edit' }, // COPY-FLAG + { id: 'deploy', key: 'ctrl+d', label: 'deploy' }, // COPY-FLAG + ] + : []; + const hints: readonly FooterHint[] = [ + { id: 'navigate', key: null, label: '↑↓ navigate' }, // COPY-FLAG + { id: 'column', key: 'tab', label: 'column' }, // COPY-FLAG + { id: 'inspect', key: 'enter', label: 'inspect' }, // COPY-FLAG + ...editHints, + { id: 'esc', key: 'esc', label: 'clear/quit' }, // COPY-FLAG + { id: 'sort', key: 'ctrl+s', label: `sort: ${SORT_LABELS[state.sort]}` }, // COPY-FLAG + ]; + return joinHints(fitHints(hints, width)); } diff --git a/tests/ui/secretsScreen.test.ts b/tests/ui/secretsScreen.test.ts index 9807fbac..270f5442 100644 --- a/tests/ui/secretsScreen.test.ts +++ b/tests/ui/secretsScreen.test.ts @@ -10,6 +10,7 @@ import { buildLocationTable, filteredRows, filteredRowsWithReasons, + sortRows, formatUsersCell, formatBranchCell, formatProjectCell, @@ -575,7 +576,7 @@ describe('search bar — always-on, fzf-style (no "/" mode)', () => { const s = pressKeys(type(initialSecretsScreenState(rows), 'db'), ESC); expect(s.search.query).toBe(''); expect(s.quit).toBe(false); - expect(filteredRows(s)).toEqual(rows); + expect(filteredRows(s)).toEqual(sortRows(rows, 'name-asc')); // CAP-731: the default sort is name A-Z, no longer arrival order }); test('Esc with an already-empty query quits', () => { diff --git a/tests/ui/secretsScreenSort.test.ts b/tests/ui/secretsScreenSort.test.ts new file mode 100644 index 00000000..16c83739 --- /dev/null +++ b/tests/ui/secretsScreenSort.test.ts @@ -0,0 +1,308 @@ +import { describe, test, expect } from 'bun:test'; +import { + initialSecretsScreenState, + handleKey, + render, + filteredRows, + nextSort, + sortRows, + SORT_ORDER, + SecretsScreenState, + SortMode, +} from '../../src/ui/secretsScreen'; +import type { SecretIndexLocation, SecretIndexRow } from '../../src/service/serviceClient'; + +const ESC = '\x1b'; +const CTRL_S = '\x13'; +const ENTER = '\r'; +const KEY_DOWN = `${ESC}[B`; +const KEY_TAB = '\t'; + +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*[mK]/g, ''); +} + +function loc(over: Partial = {}): SecretIndexLocation { + return { project_id: 'p1', project_name: 'web', branch: 'production', protected: false, service: null, ...over }; +} + +function row(over: Partial = {}): SecretIndexRow { + return { name: 'API_KEY', value_hash: 'hash1', locations: [loc()], users: [], ...over }; +} + +const target = (stale: boolean) => ({ provider: 'dokploy', target: 'app', stale }); +/** A row whose deploy status kind is `deployed`, `behind`, `unknown` (targets not sent) or `no target`. */ +function withStatus(name: string, kind: 'deployed' | 'behind' | 'unknown' | 'no target', over: Partial = {}): SecretIndexRow { + const location = + kind === 'deployed' ? loc({ targets: [target(false)] }) : kind === 'behind' ? loc({ targets: [target(true)] }) : kind === 'no target' ? loc({ targets: [] }) : loc(); + return row({ name, value_hash: `h-${name}`, locations: [location], ...over }); +} +function withTime(name: string, changedAt: string | undefined): SecretIndexRow { + return row({ name, value_hash: `h-${name}`, locations: [loc(changedAt === undefined ? {} : { changed_at: changedAt })] }); +} + +const press = (state: SecretsScreenState, ...keys: readonly string[]): SecretsScreenState => + keys.reduce((acc: SecretsScreenState, k) => handleKey(acc, k).state, state); +const typed = (state: SecretsScreenState, text: string): SecretsScreenState => press(state, ...text.split('')); +const withSort = (rows: readonly SecretIndexRow[], sort: SortMode): SecretsScreenState => ({ ...initialSecretsScreenState(rows), sort }); +const names = (state: SecretsScreenState): readonly string[] => filteredRows(state).map((r) => r.name); +const cursorName = (state: SecretsScreenState): string | undefined => filteredRows(state)[state.cursorIndex]?.name; + +describe('nextSort', () => { + test('walks the six sorts in order and wraps back to name A-Z', () => { + expect(SORT_ORDER).toEqual(['name-asc', 'name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest']); + expect(SORT_ORDER.map(nextSort)).toEqual(['name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest', 'name-asc']); + }); + + test('the screen starts on name A-Z', () => { + expect(initialSecretsScreenState([]).sort).toBe('name-asc'); + }); +}); + +describe('Ctrl+S cycles the list sort', () => { + const rows = [row({ name: 'B', value_hash: 'b' }), row({ name: 'C', value_hash: 'c' }), row({ name: 'A', value_hash: 'a' })]; + + test('six presses visit each sort and come back to name A-Z', () => { + const visited = Array.from({ length: 6 }).reduce<{ readonly state: SecretsScreenState; readonly seen: readonly SortMode[] }>( + (acc) => { + const state = press(acc.state, CTRL_S); + return { state, seen: [...acc.seen, state.sort] }; + }, + { state: initialSecretsScreenState(rows), seen: [] }, + ); + expect(visited.seen).toEqual(['name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest', 'name-asc']); + }); + + test('name A-Z then name Z-A order the rows', () => { + expect(names(initialSecretsScreenState(rows))).toEqual(['A', 'B', 'C']); + expect(names(press(initialSecretsScreenState(rows), CTRL_S))).toEqual(['C', 'B', 'A']); + }); + + test('Tab and Shift+Tab still cycle columns and leave the sort alone', () => { + const s = press(initialSecretsScreenState(rows), CTRL_S, KEY_TAB); + expect(s.column).toBe('branch'); + expect(s.sort).toBe('name-desc'); + expect(press(s, `${ESC}[Z`).column).toBe('project'); + }); + + test('Ctrl+S is not typed into the search bar', () => { + expect(press(initialSecretsScreenState(rows), CTRL_S).search.query).toBe(''); + }); +}); + +describe('name sorts', () => { + test('are case-insensitive: lower and upper case interleave', () => { + const rows = [row({ name: 'beta', value_hash: '1' }), row({ name: 'Alpha', value_hash: '2' }), row({ name: 'ALPHA2', value_hash: '3' }), row({ name: 'Zed', value_hash: '4' })]; + expect(sortRows(rows, 'name-asc').map((r) => r.name)).toEqual(['Alpha', 'ALPHA2', 'beta', 'Zed']); + expect(sortRows(rows, 'name-desc').map((r) => r.name)).toEqual(['Zed', 'beta', 'ALPHA2', 'Alpha']); + }); + + test('names that differ only by case tie, and the value hash decides', () => { + const rows = [row({ name: 'key', value_hash: 'bbb' }), row({ name: 'KEY', value_hash: 'aaa' })]; + expect(sortRows(rows, 'name-asc').map((r) => r.value_hash)).toEqual(['aaa', 'bbb']); + expect(sortRows(rows.toReversed(), 'name-asc').map((r) => r.value_hash)).toEqual(['aaa', 'bbb']); + }); + + test('the same name with two values keeps a stable order whatever order they arrive in', () => { + const rows = [row({ name: 'DB', value_hash: 'z' }), row({ name: 'DB', value_hash: 'a' })]; + expect(sortRows(rows, 'name-asc').map((r) => r.value_hash)).toEqual(['a', 'z']); + expect(sortRows(rows.toReversed(), 'name-asc').map((r) => r.value_hash)).toEqual(['a', 'z']); + }); + + test('sorting never changes the input array', () => { + const rows = [row({ name: 'B' }), row({ name: 'A' })]; + sortRows(rows, 'name-asc'); + expect(rows.map((r) => r.name)).toEqual(['B', 'A']); + }); +}); + +describe('deploy status sorts (by the status kind, not the displayed words)', () => { + const rows = [ + withStatus('E_NONE', 'no target'), + withStatus('D_UNKNOWN', 'unknown'), + withStatus('C_BEHIND', 'behind'), + withStatus('B_DEPLOYED', 'deployed'), + ]; + + test('A-Z: deployed, behind, unknown, no target', () => { + expect(names(withSort(rows, 'status-asc'))).toEqual(['B_DEPLOYED', 'C_BEHIND', 'D_UNKNOWN', 'E_NONE']); + }); + + test('Z-A is the reverse: no target, unknown, behind, deployed', () => { + expect(names(withSort(rows, 'status-desc'))).toEqual(['E_NONE', 'D_UNKNOWN', 'C_BEHIND', 'B_DEPLOYED']); + }); + + test('rows of the same status break the tie by name A-Z in BOTH directions', () => { + const tied = [withStatus('Z', 'behind'), withStatus('A', 'behind'), withStatus('M', 'deployed'), withStatus('B', 'deployed')]; + expect(names(withSort(tied, 'status-asc'))).toEqual(['B', 'M', 'A', 'Z']); + expect(names(withSort(tied, 'status-desc'))).toEqual(['A', 'Z', 'B', 'M']); + }); + + test('a row used in 33 places with 1 behind still ranks as behind', () => { + const many = row({ name: 'MANY', value_hash: 'm', locations: [...Array.from({ length: 32 }, () => loc({ targets: [target(false)] })), loc({ targets: [target(true)] })] }); + expect(names(withSort([withStatus('A', 'deployed'), many], 'status-asc'))).toEqual(['A', 'MANY']); + expect(names(withSort([withStatus('A', 'unknown'), many], 'status-asc'))).toEqual(['MANY', 'A']); + }); +}); + +describe('updated sorts', () => { + const rows = [ + withTime('OLD', '2026-01-01T00:00:00.000Z'), + withTime('NEW', '2026-06-01T00:00:00.000Z'), + withTime('NONE', undefined), + withTime('MID', '2026-03-01T00:00:00.000Z'), + ]; + + test('newest first, rows with no time last', () => { + expect(names(withSort(rows, 'updated-newest'))).toEqual(['NEW', 'MID', 'OLD', 'NONE']); + }); + + test('oldest first, rows with no time STILL last', () => { + expect(names(withSort(rows, 'updated-oldest'))).toEqual(['OLD', 'MID', 'NEW', 'NONE']); + }); + + test('a row uses its most recent location time', () => { + const multi = row({ name: 'MULTI', value_hash: 'x', locations: [loc({ changed_at: '2026-01-01T00:00:00.000Z' }), loc({ changed_at: '2026-12-01T00:00:00.000Z' })] }); + expect(names(withSort([withTime('NEW', '2026-06-01T00:00:00.000Z'), multi], 'updated-newest'))).toEqual(['MULTI', 'NEW']); + }); + + test('equal times and several no-time rows break the tie by name A-Z', () => { + const tied = [ + withTime('Z', '2026-01-01T00:00:00.000Z'), + withTime('A', '2026-01-01T00:00:00.000Z'), + withTime('Y_NONE', undefined), + withTime('B_NONE', undefined), + ]; + expect(names(withSort(tied, 'updated-newest'))).toEqual(['A', 'Z', 'B_NONE', 'Y_NONE']); + expect(names(withSort(tied, 'updated-oldest'))).toEqual(['A', 'Z', 'B_NONE', 'Y_NONE']); + }); + + test('a time that is not a date counts as no time', () => { + expect(names(withSort([withTime('BAD', 'not-a-date'), withTime('OK', '2026-01-01T00:00:00.000Z')], 'updated-oldest'))).toEqual(['OK', 'BAD']); + }); +}); + +describe('the cursor stays on its row', () => { + const rows = [row({ name: 'A', value_hash: 'a' }), row({ name: 'B', value_hash: 'b' }), row({ name: 'C', value_hash: 'c' }), row({ name: 'D', value_hash: 'd' })]; + + test('after each sort change the highlighted row is the same row', () => { + const onB = press(initialSecretsScreenState(rows), KEY_DOWN); + expect(cursorName(onB)).toBe('B'); + const after = SORT_ORDER.slice(1).reduce((acc) => [...acc, press(acc[acc.length - 1], CTRL_S)], [onB]); + after.forEach((s) => expect(cursorName(s)).toBe('B')); + expect(after[1].cursorIndex).toBe(2); // name Z-A: D, C, B, A + }); + + test('the same row is kept for two rows with one name and different values', () => { + const dup = [row({ name: 'DB', value_hash: 'a' }), row({ name: 'DB', value_hash: 'b' })]; + const onSecond = press(initialSecretsScreenState(dup), KEY_DOWN); + const sorted = press(onSecond, CTRL_S, CTRL_S); + expect(filteredRows(sorted)[sorted.cursorIndex].value_hash).toBe('b'); + }); + + test('Enter after a sort opens the details of the highlighted row', () => { + const s = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S, ENTER); + expect(s.popup?.rowName).toBe('B'); + }); +}); + +describe('sort and search together', () => { + const rows = [ + row({ name: 'API_ONE', value_hash: '1' }), + row({ name: 'DB_URL', value_hash: '2' }), + row({ name: 'API_TWO', value_hash: '3' }), + row({ name: 'API_THREE', value_hash: '4' }), + ]; + + test('search decides which rows show, the sort orders them', () => { + const s = typed(initialSecretsScreenState(rows), 'api'); + expect(names(s)).toEqual(['API_ONE', 'API_THREE', 'API_TWO']); + expect(names(press(s, CTRL_S))).toEqual(['API_TWO', 'API_THREE', 'API_ONE']); + }); + + test('typing after a sort keeps that sort', () => { + const s = typed(press(initialSecretsScreenState(rows), CTRL_S), 'api'); + expect(s.sort).toBe('name-desc'); + expect(names(s)).toEqual(['API_TWO', 'API_THREE', 'API_ONE']); + }); + + test('the cursor stays on its row when Ctrl+S is pressed with a filter typed', () => { + const s = press(typed(initialSecretsScreenState(rows), 'api'), KEY_DOWN); + expect(cursorName(s)).toBe('API_THREE'); + expect(cursorName(press(s, CTRL_S))).toBe('API_THREE'); + }); + + test('a search that matches by value orders its matches by the sort too', () => { + const s = typed(initialSecretsScreenState(rows), 'zzzz-no-match'); + expect(names(s)).toEqual([]); + expect(press(s, CTRL_S).cursorIndex).toBe(0); + }); +}); + +describe('Ctrl+S in the details view', () => { + test('is ignored while the popup is open', () => { + const rows = [row({ name: 'A', value_hash: 'a' }), row({ name: 'B', value_hash: 'b' })]; + const open = press(initialSecretsScreenState(rows), ENTER); + expect(open.popup).not.toBeNull(); + const after = handleKey(open, CTRL_S); + expect(after.state.sort).toBe('name-asc'); + expect(after.state.popup).toEqual(open.popup); + expect(after.effect).toBeNull(); + }); +}); + +describe('footer hint', () => { + const rows = [row({ name: 'A', value_hash: 'a' })]; + const footer = (state: SecretsScreenState, width = 120): string => stripAnsi(render(state, width, 30)).split('\n').filter((l) => l.trim() !== '').slice(-1)[0]; + + test('shows the key and the current sort', () => { + expect(footer(initialSecretsScreenState(rows))).toContain('ctrl+s sort: name A-Z'); + }); + + test('follows the sort through the whole cycle', () => { + const labels = SORT_ORDER.reduce((acc, _s, i) => [...acc, footer(SORT_ORDER.slice(0, i).reduce((st) => press(st, CTRL_S), initialSecretsScreenState(rows)))], []); + expect(labels.map((l) => l.match(/ctrl\+s sort: (.*)$/)?.[1])).toEqual([ + 'name A-Z', + 'name Z-A', + 'deploy status A-Z', + 'deploy status Z-A', + 'updated newest', + 'updated oldest', + ]); + }); + + test('is still shown while a search is typed', () => { + expect(footer(typed(initialSecretsScreenState(rows), 'a'))).toContain('ctrl+s sort: name A-Z'); + }); + + test('is not in the details view footer', () => { + expect(footer(press(initialSecretsScreenState(rows), ENTER))).not.toContain('ctrl+s'); + }); + + test('wide terminal: every hint is shown', () => { + const line = footer(initialSecretsScreenState(rows), 140); + ['↑↓ navigate', 'tab column', 'enter inspect', 'ctrl+e edit', 'ctrl+d deploy', 'esc clear/quit', 'ctrl+s sort: name A-Z'].forEach((hint) => expect(line).toContain(hint)); + }); + + test('narrower terminal: whole hints are dropped (deploy, then edit, ...) but esc and sort stay', () => { + const at100 = footer(initialSecretsScreenState(rows), 100); + expect(at100).not.toContain('ctrl+d deploy'); + expect(at100).toContain('ctrl+e edit'); + const at80 = footer(initialSecretsScreenState(rows), 80); + expect(at80).not.toContain('ctrl+e edit'); + expect(at80).toContain('esc clear/quit'); + expect(at80).toContain('ctrl+s sort: name A-Z'); + expect(at80).not.toContain('…'); + }); + + test('a very narrow terminal clips to one line within the width', () => { + [20, 40, 60, 80, 100].forEach((width) => { + expect(Array.from(footer(initialSecretsScreenState(rows), width)).length).toBeLessThanOrEqual(width); + }); + }); + + test('the longest sort label still fits at 80 columns', () => { + const s = { ...initialSecretsScreenState(rows), sort: 'status-desc' as const }; + expect(footer(s, 80)).toContain('ctrl+s sort: deploy status Z-A'); + }); +}); From f28ab0ee7678fef90e1e999e16777699fbc8dfac Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 6 Nov 2026 00:10:38 -0700 Subject: [PATCH 20/22] docs(cli): regenerate the CLI reference for 0.9.14 --- docs/cli-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 40ba6312..13e4bcc4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,7 +2,7 @@ # capy CLI reference -Version `0.9.13`. Generated from `capy help --json`. +Version `0.9.14`. Generated from `capy help --json`. ## Commands From 88c3f4c0e65c7c14ac0d81a6d28906abe2b470c7 Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 6 Nov 2026 01:19:19 -0700 Subject: [PATCH 21/22] feat(secrets): Ctrl+S opens a sort menu; choosing a sort goes back to the top (CAP-731) --- src/ui/secretsScreen.ts | 98 +++++++++--- tests/ui/secretsScreenSort.test.ts | 236 +++++++++++++++++++++++------ 2 files changed, 271 insertions(+), 63 deletions(-) diff --git a/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index fa3fc193..18b6898a 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -229,22 +229,18 @@ function nextColumn(column: ColumnMode, dir: 1 | -1): ColumnMode { // ── Sorting (CAP-731) ──────────────────────────────────────────────────────── // -// Ctrl+S cycles the list through SORT_ORDER (wrapping). Search still decides +// Ctrl+S opens a sort menu listing SORT_ORDER; Enter applies the chosen sort and +// goes back to the top of the list. Search still decides // WHICH rows show; the sort only orders them. Every comparison keys off a // machine value (the name string, the status `kind`, a timestamp), never the // words the screen prints. Ties always fall back to a deterministic key, so the // order never depends on how the server (or a previous sort) happened to arrive. export type SortMode = 'name-asc' | 'name-desc' | 'status-asc' | 'status-desc' | 'updated-newest' | 'updated-oldest'; -/** Ctrl+S walks this order and wraps; the first entry is the default. */ +/** The order the sort menu lists; the first entry is the default. */ export const SORT_ORDER: readonly SortMode[] = ['name-asc', 'name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest']; -/** The next sort after `mode`, wrapping from the last back to the first. Pure. */ -export function nextSort(mode: SortMode): SortMode { - return SORT_ORDER[(SORT_ORDER.indexOf(mode) + 1) % SORT_ORDER.length]; -} - -/** What the footer says each sort is. */ +/** What the footer and the sort menu say each sort is. */ const SORT_LABELS: Readonly> = { 'name-asc': 'name A-Z', // COPY-FLAG 'name-desc': 'name Z-A', // COPY-FLAG @@ -348,11 +344,17 @@ export interface SearchState { readonly query: string; } +export interface SortMenuState { + readonly cursor: number; +} + export interface SecretsScreenState { readonly rows: readonly SecretIndexRow[]; readonly column: ColumnMode; - /** How the list is ordered (Ctrl+S cycles it); search decides which rows show, this decides their order. */ + /** How the list is ordered (chosen in the Ctrl+S menu); search decides which rows show, this decides their order. */ readonly sort: SortMode; + /** The Ctrl+S sort menu, open while a sort is being chosen: `cursor` indexes `SORT_ORDER`. `null` when closed. */ + readonly sortMenu: SortMenuState | null; readonly cursorIndex: number; readonly search: SearchState; readonly popup: PopupState | null; @@ -374,6 +376,7 @@ export function initialSecretsScreenState(rows: readonly SecretIndexRow[], dryRu rows, column: COLUMN_ORDER[0], sort: SORT_ORDER[0], + sortMenu: null, cursorIndex: 0, search: { query: '' }, popup: null, @@ -522,6 +525,9 @@ export function handleKey(state: SecretsScreenState, rawKey: string): ReduceResu if (state.edit) return handleEditFlowKey(state, state.edit, key); if (state.deploy) return handleDeployFlowKey(state, state.deploy, key); + // The sort menu owns the keyboard: nothing reaches the search, edit, deploy or column keys. + if (state.sortMenu) return noEffect(handleSortMenuKey(state, state.sortMenu, key)); + if (state.popup) { if (key === 'd' || key === 'D' || key === KEY_CTRL_D) return openDeployOnCursor(state); return key === 'e' || key === 'E' || key === KEY_CTRL_E ? openEditOnCursor(state) : noEffect(handlePopupKey(state, key)); @@ -635,17 +641,26 @@ function applyQuery(state: SecretsScreenState, query: string): SecretsScreenStat } /** - * Switches to `sort` and keeps the highlighted row highlighted: it is found again - * by name + value hash in the new order (a row is one (name, value_hash) pair). - * With no highlighted row (an empty list) the cursor just stays clamped. + * Switches to `sort` and goes back to the top: the first row of the new order is + * highlighted, so the list shows from its start (Vince, 2026-11-06). The scroll + * window follows the cursor, so a cursor of 0 is the top of the list. */ function applySort(state: SecretsScreenState, sort: SortMode): SecretsScreenState { - const before = filteredRows(state); - const current = before[clampIndex(state.cursorIndex, before.length)]; - const resorted = { ...state, sort }; - const after = filteredRows(resorted); - const found = current === undefined ? -1 : after.findIndex((r) => r.name === current.name && r.value_hash === current.value_hash); - return { ...resorted, cursorIndex: found >= 0 ? found : clampIndex(state.cursorIndex, after.length) }; + return { ...state, sort, cursorIndex: 0 }; +} + +/** Moves the sort menu's cursor by `step`, wrapping at both ends. */ +function stepSortMenu(menu: SortMenuState, step: 1 | -1): SortMenuState { + return { cursor: (menu.cursor + step + SORT_ORDER.length) % SORT_ORDER.length }; +} + +/** The sort menu's keys: ↑/↓ move (wrapping), Enter applies and closes, Esc or `q` close unchanged. Every other key does nothing. */ +function handleSortMenuKey(state: SecretsScreenState, menu: SortMenuState, key: string): SecretsScreenState { + if (key === KEY_UP) return { ...state, sortMenu: stepSortMenu(menu, -1) }; + if (key === KEY_DOWN) return { ...state, sortMenu: stepSortMenu(menu, 1) }; + if (key === '\r') return applySort({ ...state, sortMenu: null }, SORT_ORDER[menu.cursor]); + if (key === KEY_ESC || key === KEY_ESC_ESC || key === 'q' || key === 'Q') return { ...state, sortMenu: null }; + return state; } function handlePopupKey(state: SecretsScreenState, key: string): SecretsScreenState { @@ -704,7 +719,7 @@ function handleListKey(state: SecretsScreenState, key: string): ReduceResult { if (key === KEY_CTRL_D && state.search.query === '') return openDeployOnCursor(state); // Works with or without a typed filter: the sort only orders the rows the search shows. - if (key === KEY_CTRL_S) return noEffect(applySort(state, nextSort(state.sort))); + if (key === KEY_CTRL_S) return noEffect({ ...state, sortMenu: { cursor: Math.max(0, SORT_ORDER.indexOf(state.sort)) } }); if (key === KEY_TAB) { return noEffect({ ...state, column: nextColumn(state.column, 1) }); @@ -1095,6 +1110,7 @@ export function mainTableLayout(available: number): { readonly nameW: number; re export function render(state: SecretsScreenState, termWidth: number, termHeight: number): string { if (state.edit) return renderEditScreen(state.edit, termWidth, termHeight, state.dryRun); if (state.deploy) return renderDeployScreen(state.deploy, termWidth, termHeight, state.dryRun); + if (state.sortMenu) return renderSortMenuScreen(state, state.sortMenu, termWidth, termHeight); const m = ' '.repeat(MARGIN); const available = Math.max(40, termWidth - MARGIN * 2); const matched = filteredRowsWithReasons(state); @@ -1191,6 +1207,50 @@ function renderDeployScreen(flow: DeployFlow, termWidth: number, termHeight: num return out.map((l) => clipLine(l, termWidth) + CLEAR_EOL).join('\n'); } +const SORT_MENU_TITLE = 'sort by'; // COPY-FLAG +const SORT_MENU_CURRENT = '●'; // COPY-FLAG + +/** The sort menu's key hints. */ +const SORT_MENU_HINTS: readonly FooterHint[] = [ + { id: 'navigate', key: null, label: '↑↓ choose' }, // COPY-FLAG + { id: 'inspect', key: 'enter', label: 'sort' }, // COPY-FLAG + { id: 'esc', key: 'esc', label: 'cancel' }, // COPY-FLAG +]; + +/** The `size` menu entries to show around `cursor`: all of them when they fit, else a window that keeps the cursor in view. */ +function sortMenuWindowStart(cursor: number, size: number): number { + return Math.min(Math.max(0, cursor - size + 1), Math.max(0, SORT_ORDER.length - size)); +} + +/** + * The sort menu's screen, laid out like the edit and deploy screens: the header, + * then the menu, then its key hints. Each entry is drawn like a list row (`▶` and + * inverse on the cursor), and the sort now in use carries a `●`. On a short + * terminal only the entries around the cursor are shown, so it never overflows. + */ +function renderSortMenuScreen(state: SecretsScreenState, menu: SortMenuState, termWidth: number, termHeight: number): string { + const m = ' '.repeat(MARGIN); + const width = Math.max(1, termWidth - MARGIN); + const entryRoom = Math.max(1, termHeight - 5); // header, blank, title, blank, footer + const size = Math.min(SORT_ORDER.length, entryRoom); + const start = sortMenuWindowStart(menu.cursor, size); + const entries: readonly string[] = SORT_ORDER.slice(start, start + size).map((mode, i) => { + const selected = start + i === menu.cursor; + const text = `${selected ? '▶ ' : ' '}${mode === state.sort ? SORT_MENU_CURRENT : ' '} ${SORT_LABELS[mode]}`; + return selected ? INVERSE + padVis(truncate(text, width), Math.min(width, 32)) + RESET : text; + }); + const out: readonly string[] = [ + `${m}${BOLD}capy secrets${RESET}${dryRunTag(state.dryRun)}`, + '', + `${m}${BOLD}${SORT_MENU_TITLE}${RESET}`, + ...entries.map((l) => m + l), + '', + m + joinHints(SORT_MENU_HINTS), + ]; + // No line is ever wider than the terminal, and the whole screen never taller than it. + return out.slice(0, Math.max(1, termHeight)).map((l) => clipLine(l, termWidth) + CLEAR_EOL).join('\n'); +} + function spliceIn(bodyLines: readonly string[], afterIndex: number, insert: readonly string[]): string[] { return [...bodyLines.slice(0, afterIndex + 1), ...insert, ...bodyLines.slice(afterIndex + 1)]; } diff --git a/tests/ui/secretsScreenSort.test.ts b/tests/ui/secretsScreenSort.test.ts index 16c83739..63bf483c 100644 --- a/tests/ui/secretsScreenSort.test.ts +++ b/tests/ui/secretsScreenSort.test.ts @@ -4,7 +4,6 @@ import { handleKey, render, filteredRows, - nextSort, sortRows, SORT_ORDER, SecretsScreenState, @@ -16,6 +15,9 @@ const ESC = '\x1b'; const CTRL_S = '\x13'; const ENTER = '\r'; const KEY_DOWN = `${ESC}[B`; +const KEY_UP = `${ESC}[A`; +const CTRL_E = '\x05'; +const CTRL_D = '\x04'; const KEY_TAB = '\t'; function stripAnsi(s: string): string { @@ -46,47 +48,125 @@ const press = (state: SecretsScreenState, ...keys: readonly string[]): SecretsSc const typed = (state: SecretsScreenState, text: string): SecretsScreenState => press(state, ...text.split('')); const withSort = (rows: readonly SecretIndexRow[], sort: SortMode): SecretsScreenState => ({ ...initialSecretsScreenState(rows), sort }); const names = (state: SecretsScreenState): readonly string[] => filteredRows(state).map((r) => r.name); +/** Opens the sort menu, moves down to `mode`, and presses Enter. */ +const choose = (state: SecretsScreenState, mode: SortMode): SecretsScreenState => + press(state, CTRL_S, ...Array.from({ length: (SORT_ORDER.indexOf(mode) - SORT_ORDER.indexOf(state.sort) + SORT_ORDER.length) % SORT_ORDER.length }, () => KEY_DOWN), ENTER); const cursorName = (state: SecretsScreenState): string | undefined => filteredRows(state)[state.cursorIndex]?.name; -describe('nextSort', () => { - test('walks the six sorts in order and wraps back to name A-Z', () => { +describe('the sort order', () => { + test('lists the six sorts in order, and the screen starts on name A-Z', () => { expect(SORT_ORDER).toEqual(['name-asc', 'name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest']); - expect(SORT_ORDER.map(nextSort)).toEqual(['name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest', 'name-asc']); - }); - - test('the screen starts on name A-Z', () => { expect(initialSecretsScreenState([]).sort).toBe('name-asc'); + expect(initialSecretsScreenState([]).sortMenu).toBeNull(); }); }); -describe('Ctrl+S cycles the list sort', () => { +describe('Ctrl+S opens the sort menu', () => { const rows = [row({ name: 'B', value_hash: 'b' }), row({ name: 'C', value_hash: 'c' }), row({ name: 'A', value_hash: 'a' })]; - test('six presses visit each sort and come back to name A-Z', () => { - const visited = Array.from({ length: 6 }).reduce<{ readonly state: SecretsScreenState; readonly seen: readonly SortMode[] }>( - (acc) => { - const state = press(acc.state, CTRL_S); - return { state, seen: [...acc.seen, state.sort] }; - }, - { state: initialSecretsScreenState(rows), seen: [] }, - ); - expect(visited.seen).toEqual(['name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest', 'name-asc']); + test('it opens with its cursor on the current sort and changes nothing else', () => { + const open = press(initialSecretsScreenState(rows), CTRL_S); + expect(open.sortMenu).toEqual({ cursor: 0 }); + expect(open.sort).toBe('name-asc'); + expect(open.search.query).toBe(''); + expect(press(withSort(rows, 'status-desc'), CTRL_S).sortMenu).toEqual({ cursor: 3 }); + }); + + test('Up and Down move in the menu and wrap at both ends', () => { + const open = press(initialSecretsScreenState(rows), CTRL_S); + expect(press(open, KEY_DOWN).sortMenu).toEqual({ cursor: 1 }); + expect(press(open, KEY_UP).sortMenu).toEqual({ cursor: 5 }); + expect(press(open, ...Array.from({ length: 6 }, () => KEY_DOWN)).sortMenu).toEqual({ cursor: 0 }); + expect(press(open, KEY_DOWN, KEY_DOWN, KEY_UP).sortMenu).toEqual({ cursor: 1 }); + }); + + test('moving in the menu does not change the sort or the list cursor until Enter', () => { + const moved = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S, KEY_DOWN, KEY_DOWN); + expect(moved.sort).toBe('name-asc'); + expect(moved.cursorIndex).toBe(1); + expect(names(moved)).toEqual(['A', 'B', 'C']); + }); + + test('Enter applies the highlighted sort, closes the menu, and puts the list cursor at 0', () => { + const s = press(initialSecretsScreenState(rows), KEY_DOWN, KEY_DOWN, CTRL_S, KEY_DOWN, ENTER); + expect(s.sort).toBe('name-desc'); + expect(s.sortMenu).toBeNull(); + expect(s.cursorIndex).toBe(0); + expect(names(s)).toEqual(['C', 'B', 'A']); + }); + + test('Enter on the current sort closes the menu and still goes to the top', () => { + const s = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S, ENTER); + expect(s.sort).toBe('name-asc'); + expect(s.sortMenu).toBeNull(); + expect(s.cursorIndex).toBe(0); + }); + + test('each of the six sorts can be chosen', () => { + expect(SORT_ORDER.map((mode) => choose(initialSecretsScreenState(rows), mode).sort)).toEqual([...SORT_ORDER]); + }); + + test('Esc closes the menu with no change', () => { + const before = press(initialSecretsScreenState(rows), KEY_DOWN); + expect(press(before, CTRL_S, KEY_DOWN, KEY_DOWN, ESC)).toEqual(before); + }); + + test('a double Esc and `q` also close it with no change', () => { + const before = press(initialSecretsScreenState(rows), KEY_DOWN); + expect(press(before, CTRL_S, KEY_DOWN, `${ESC}${ESC}`)).toEqual(before); + expect(press(before, CTRL_S, KEY_DOWN, 'q')).toEqual(before); + expect(press(before, CTRL_S, KEY_DOWN, 'Q')).toEqual(before); + }); + + test('Esc in the menu does not quit the screen or clear a typed search', () => { + const s = press(typed(initialSecretsScreenState(rows), 'a'), CTRL_S, ESC); + expect(s.quit).toBe(false); + expect(s.search.query).toBe('a'); + }); + + test('letters do not reach the search while the menu is open', () => { + const open = press(initialSecretsScreenState(rows), CTRL_S); + const s = press(open, 'a', 'b', 'x', ' ', 'j', 'k', '/'); + expect(s).toEqual(open); + expect(s.search.query).toBe(''); + }); + + test('Tab, Shift+Tab, Ctrl+E, Ctrl+D, Ctrl+S, Backspace and paging do nothing in the menu', () => { + const open = press(initialSecretsScreenState(rows), CTRL_S); + [KEY_TAB, `${ESC}[Z`, CTRL_E, CTRL_D, CTRL_S, '\x7f', `${ESC}[5~`, `${ESC}[6~`].forEach((k) => { + const after = handleKey(open, k); + expect(after.state).toEqual(open); + expect(after.effect).toBeNull(); + }); }); - test('name A-Z then name Z-A order the rows', () => { - expect(names(initialSecretsScreenState(rows))).toEqual(['A', 'B', 'C']); - expect(names(press(initialSecretsScreenState(rows), CTRL_S))).toEqual(['C', 'B', 'A']); + test('Ctrl+E and Ctrl+D do nothing in the menu even with the list on a row', () => { + const open = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S); + expect(handleKey(open, CTRL_E)).toEqual({ state: open, effect: null }); + expect(handleKey(open, CTRL_D)).toEqual({ state: open, effect: null }); }); - test('Tab and Shift+Tab still cycle columns and leave the sort alone', () => { - const s = press(initialSecretsScreenState(rows), CTRL_S, KEY_TAB); + test('Enter in the menu applies the sort and does not open the details', () => { + const after = handleKey(press(initialSecretsScreenState(rows), CTRL_S, KEY_DOWN), ENTER); + expect(after.state.popup).toBeNull(); + expect(after.state.edit).toBeNull(); + expect(after.state.deploy).toBeNull(); + expect(after.effect).toBeNull(); + }); + + test('Ctrl+C still quits from the menu', () => { + expect(press(initialSecretsScreenState(rows), CTRL_S, '\x03').quit).toBe(true); + }); + + test('Tab and Shift+Tab still cycle columns in the list and leave the sort alone', () => { + const s = press(choose(initialSecretsScreenState(rows), 'name-desc'), KEY_TAB); expect(s.column).toBe('branch'); expect(s.sort).toBe('name-desc'); expect(press(s, `${ESC}[Z`).column).toBe('project'); }); test('Ctrl+S is not typed into the search bar', () => { - expect(press(initialSecretsScreenState(rows), CTRL_S).search.query).toBe(''); + expect(press(initialSecretsScreenState(rows), CTRL_S, ENTER).search.query).toBe(''); }); }); @@ -182,27 +262,34 @@ describe('updated sorts', () => { }); }); -describe('the cursor stays on its row', () => { +describe('a sort change goes back to the top', () => { const rows = [row({ name: 'A', value_hash: 'a' }), row({ name: 'B', value_hash: 'b' }), row({ name: 'C', value_hash: 'c' }), row({ name: 'D', value_hash: 'd' })]; - test('after each sort change the highlighted row is the same row', () => { - const onB = press(initialSecretsScreenState(rows), KEY_DOWN); - expect(cursorName(onB)).toBe('B'); - const after = SORT_ORDER.slice(1).reduce((acc) => [...acc, press(acc[acc.length - 1], CTRL_S)], [onB]); - after.forEach((s) => expect(cursorName(s)).toBe('B')); - expect(after[1].cursorIndex).toBe(2); // name Z-A: D, C, B, A + test('after each sort change the first row of the new order is highlighted', () => { + const onC = press(initialSecretsScreenState(rows), KEY_DOWN, KEY_DOWN); + expect(cursorName(onC)).toBe('C'); + const after = SORT_ORDER.map((mode) => choose(onC, mode)); + after.forEach((s) => { + expect(s.cursorIndex).toBe(0); + expect(cursorName(s)).toBe(filteredRows(s)[0].name); + }); + expect(cursorName(after[1])).toBe('D'); // name Z-A: D, C, B, A }); - test('the same row is kept for two rows with one name and different values', () => { - const dup = [row({ name: 'DB', value_hash: 'a' }), row({ name: 'DB', value_hash: 'b' })]; - const onSecond = press(initialSecretsScreenState(dup), KEY_DOWN); - const sorted = press(onSecond, CTRL_S, CTRL_S); - expect(filteredRows(sorted)[sorted.cursorIndex].value_hash).toBe('b'); + test('the rendered list starts at the first row after a sort change', () => { + const many = Array.from({ length: 60 }, (_, i) => row({ name: `K${String(i).padStart(2, '0')}`, value_hash: `h${i}` })); + const scrolled = press(initialSecretsScreenState(many), ...Array.from({ length: 50 }, () => KEY_DOWN)); + expect(stripAnsi(render(scrolled, 120, 20))).not.toContain('K00'); + const sorted = choose(scrolled, 'name-asc'); + expect(sorted.cursorIndex).toBe(0); + const text = stripAnsi(render(sorted, 120, 20)); + expect(text).toContain('K00'); + expect(text).not.toContain('K59'); }); - test('Enter after a sort opens the details of the highlighted row', () => { - const s = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S, ENTER); - expect(s.popup?.rowName).toBe('B'); + test('Enter after a sort opens the details of the first row', () => { + const s = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S, KEY_DOWN, ENTER, ENTER); + expect(s.popup?.rowName).toBe('D'); }); }); @@ -217,25 +304,25 @@ describe('sort and search together', () => { test('search decides which rows show, the sort orders them', () => { const s = typed(initialSecretsScreenState(rows), 'api'); expect(names(s)).toEqual(['API_ONE', 'API_THREE', 'API_TWO']); - expect(names(press(s, CTRL_S))).toEqual(['API_TWO', 'API_THREE', 'API_ONE']); + expect(names(choose(s, 'name-desc'))).toEqual(['API_TWO', 'API_THREE', 'API_ONE']); }); test('typing after a sort keeps that sort', () => { - const s = typed(press(initialSecretsScreenState(rows), CTRL_S), 'api'); + const s = typed(choose(initialSecretsScreenState(rows), 'name-desc'), 'api'); expect(s.sort).toBe('name-desc'); expect(names(s)).toEqual(['API_TWO', 'API_THREE', 'API_ONE']); }); - test('the cursor stays on its row when Ctrl+S is pressed with a filter typed', () => { + test('choosing a sort with a filter typed goes back to the first match', () => { const s = press(typed(initialSecretsScreenState(rows), 'api'), KEY_DOWN); expect(cursorName(s)).toBe('API_THREE'); - expect(cursorName(press(s, CTRL_S))).toBe('API_THREE'); + expect(cursorName(choose(s, 'name-desc'))).toBe('API_TWO'); }); test('a search that matches by value orders its matches by the sort too', () => { const s = typed(initialSecretsScreenState(rows), 'zzzz-no-match'); expect(names(s)).toEqual([]); - expect(press(s, CTRL_S).cursorIndex).toBe(0); + expect(choose(s, 'name-desc').cursorIndex).toBe(0); }); }); @@ -246,6 +333,7 @@ describe('Ctrl+S in the details view', () => { expect(open.popup).not.toBeNull(); const after = handleKey(open, CTRL_S); expect(after.state.sort).toBe('name-asc'); + expect(after.state.sortMenu).toBeNull(); expect(after.state.popup).toEqual(open.popup); expect(after.effect).toBeNull(); }); @@ -260,7 +348,7 @@ describe('footer hint', () => { }); test('follows the sort through the whole cycle', () => { - const labels = SORT_ORDER.reduce((acc, _s, i) => [...acc, footer(SORT_ORDER.slice(0, i).reduce((st) => press(st, CTRL_S), initialSecretsScreenState(rows)))], []); + const labels = SORT_ORDER.map((mode) => footer(choose(initialSecretsScreenState(rows), mode))); expect(labels.map((l) => l.match(/ctrl\+s sort: (.*)$/)?.[1])).toEqual([ 'name A-Z', 'name Z-A', @@ -306,3 +394,63 @@ describe('footer hint', () => { expect(footer(s, 80)).toContain('ctrl+s sort: deploy status Z-A'); }); }); + +describe('the sort menu on screen', () => { + const rows = [row({ name: 'A', value_hash: 'a' }), row({ name: 'B', value_hash: 'b' })]; + const lines = (state: SecretsScreenState, width: number, height: number): readonly string[] => stripAnsi(render(state, width, height)).split('\n'); + const open = (mode: SortMode = 'name-asc'): SecretsScreenState => press(withSort(rows, mode), CTRL_S); + const LABELS = ['name A-Z', 'name Z-A', 'deploy status A-Z', 'deploy status Z-A', 'updated newest', 'updated oldest']; + + test('shows the title, the six labels, and the menu footer', () => { + const l = lines(open(), 100, 30); + LABELS.forEach((label) => expect(l.join('\n')).toContain(label)); + expect(l.join('\n')).toContain('sort by'); + expect(l.filter((x) => x.trim() !== '').slice(-1)[0]).toContain('↑↓ choose · enter sort · esc cancel'); + }); + + test('marks the current sort and puts the pointer on the menu cursor', () => { + const l = lines(press(open('status-asc'), KEY_DOWN), 100, 30); + const marked = l.filter((x) => x.includes('●')); + expect(marked.length).toBe(1); + expect(marked[0]).toContain('deploy status A-Z'); + const pointed = l.filter((x) => x.includes('▶')); + expect(pointed.length).toBe(1); + expect(pointed[0]).toContain('deploy status Z-A'); + }); + + test('the list and its footer are not drawn while the menu is open', () => { + const text = lines(open(), 100, 30).join('\n'); + expect(text).not.toContain('search:'); + expect(text).not.toContain('ctrl+s sort'); + }); + + test('the list is back, with the new sort in its footer, after Enter', () => { + const text = lines(choose(initialSecretsScreenState(rows), 'updated-newest'), 100, 30).join('\n'); + expect(text).toContain('search:'); + expect(text).toContain('ctrl+s sort: updated newest'); + expect(text).not.toContain('sort by'); + }); + + test('fits 60 columns by 12 lines: all six labels, no line wider or taller than the terminal', () => { + const l = lines(open(), 60, 12); + expect(l.length).toBeLessThanOrEqual(12); + l.forEach((x) => expect(Array.from(x).length).toBeLessThanOrEqual(60)); + LABELS.forEach((label) => expect(l.join('\n')).toContain(label)); + expect(l.filter((x) => x.trim() !== '').slice(-1)[0]).toContain('esc cancel'); + }); + + test('on a very short or narrow terminal it windows around the cursor and never overflows', () => { + [[40, 8], [30, 7], [20, 6], [80, 5]].forEach(([w, h]) => { + SORT_ORDER.forEach((_m, i) => { + const l = lines(press(open(), ...Array.from({ length: i }, () => KEY_DOWN)), w, h); + expect(l.length).toBeLessThanOrEqual(h); + l.forEach((x) => expect(Array.from(x).length).toBeLessThanOrEqual(w)); + expect(l.filter((x) => x.includes('▶')).length).toBe(1); + }); + }); + }); + + test('the dry-run tag is on the menu screen', () => { + expect(lines(press(initialSecretsScreenState(rows, true), CTRL_S), 100, 30).join('\n')).toContain('DRY RUN'); + }); +}); From 1f3f4e08e4ba61a07ab0c5652de9aab5cbaa1448 Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 6 Nov 2026 01:40:25 -0700 Subject: [PATCH 22/22] test(vercel): compare the request host, not a URL substring --- tests/deploy/vercelSetup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/deploy/vercelSetup.test.ts b/tests/deploy/vercelSetup.test.ts index 3df0e094..48b0ae7e 100644 --- a/tests/deploy/vercelSetup.test.ts +++ b/tests/deploy/vercelSetup.test.ts @@ -196,7 +196,7 @@ describe('resolveVercelSetup — refusals and re-asks', () => { ); expect(out.stderr.join('\n')).toContain(ERROR_CODES.VERCEL_URL_INVALID); expect(out.asker.count('projectUrl')).toBe(2); - expect(callsOf(fetchMock).every((c) => !c.url.includes('example.com'))).toBe(true); + expect(callsOf(fetchMock).every((c) => new URL(c.url).hostname !== 'example.com')).toBe(true); }); test('no token: says so, shows the approved instructions (with the link\'s team), saves what was typed — and sends zero requests', async () => {