From 42c134a5fce1e354d4fd0a3b9ddf564a82226e6a Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 4 Oct 2026 01:29:38 -0700 Subject: [PATCH 1/2] feat(deploy): capy deploy dokploy --discover Matches Dokploy services to Capy projects (repo + repo-relative path + branch) and writes Dokploy deploy targets: discover -> plan file -> --dry-run (plan_id) -> --confirm, one keep PR per repo. Always JSON on stdout, never prompts. Pushes no values. - The Dokploy environment name outranks the default-branch rule when they name different Capy branches (a develop default branch deployed to a staging environment maps to staging, needs_review). - capy connect dokploy --discover no longer creates deploy targets. - The Dokploy base URL is the org system variable _CONNECTOR_DOKPLOY_BASE_URL. Only org admins save it; a missing URL is a structured refusal (unanswered: base_url, exit 3). --- docs/cli-reference.md | 95 ++- src/commands/connectCommand.ts | 47 +- src/commands/connectors/dokploy.ts | 221 +++---- src/commands/connectors/dokployDiscovery.ts | 92 ++- src/commands/connectors/registry.ts | 3 +- src/commands/deployCommand.ts | 45 +- src/commands/deployDiscover/apply.ts | 218 +++++++ src/commands/deployDiscover/command.ts | 556 ++++++++++++++++++ src/commands/deployDiscover/discovery.ts | 450 ++++++++++++++ src/commands/deployDiscover/evaluate.ts | 296 ++++++++++ src/commands/deployDiscover/facts.ts | 214 +++++++ src/commands/deployDiscover/index.ts | 52 ++ src/commands/deployDiscover/plan.ts | 134 +++++ src/commands/deployDiscover/route.ts | 44 ++ src/commands/deployDiscover/schema.ts | 43 ++ src/commands/keepLockPr.ts | 110 +++- src/commands/secretsSet.ts | 3 +- src/commands/secretsSetCommand.ts | 36 +- src/core/cliHelpDoc.ts | 47 ++ src/core/cliReferenceMarkdown.ts | 60 +- src/deploy/dokployApi.ts | 16 + src/deploy/dokployBaseUrl.ts | 224 +++++++ src/index-dev.ts | 13 +- src/index.ts | 13 +- src/types/index.ts | 21 + .../commands/connectDiscoveryRouting.test.ts | 65 ++ tests/commands/connectDokploy.test.ts | 70 +-- .../commands/connectDokployDiscovery.test.ts | 92 ++- .../connectDokploySystemStoreSentinel.test.ts | 22 +- .../commands/connectImportJsonPurity.test.ts | 1 - tests/commands/deployDiscoverConfirm.test.ts | 269 +++++++++ .../commands/deployDiscoverDiscovery.test.ts | 488 +++++++++++++++ tests/commands/deployDiscoverFacts.test.ts | 116 ++++ tests/commands/deployDiscoverPlan.test.ts | 240 ++++++++ .../commands/deployPickerBaseUrlSave.test.ts | 124 ++++ tests/commands/dokployBaseUrl.test.ts | 490 +++++++++++++++ tests/commands/helpJsonCli.test.ts | 39 ++ tests/commands/systemCommand.test.ts | 13 + tests/core/cliHelpDoc.test.ts | 19 + tests/helpers/deployDiscoverWorld.ts | 347 +++++++++++ tests/helpers/processState.ts | 41 ++ tests/run-tests.sh | 1 + 42 files changed, 5244 insertions(+), 246 deletions(-) create mode 100644 src/commands/deployDiscover/apply.ts create mode 100644 src/commands/deployDiscover/command.ts create mode 100644 src/commands/deployDiscover/discovery.ts create mode 100644 src/commands/deployDiscover/evaluate.ts create mode 100644 src/commands/deployDiscover/facts.ts create mode 100644 src/commands/deployDiscover/index.ts create mode 100644 src/commands/deployDiscover/plan.ts create mode 100644 src/commands/deployDiscover/route.ts create mode 100644 src/commands/deployDiscover/schema.ts create mode 100644 src/deploy/dokployBaseUrl.ts create mode 100644 tests/commands/deployDiscoverConfirm.test.ts create mode 100644 tests/commands/deployDiscoverDiscovery.test.ts create mode 100644 tests/commands/deployDiscoverFacts.test.ts create mode 100644 tests/commands/deployDiscoverPlan.test.ts create mode 100644 tests/commands/deployPickerBaseUrlSave.test.ts create mode 100644 tests/commands/dokployBaseUrl.test.ts create mode 100644 tests/helpers/deployDiscoverWorld.ts create mode 100644 tests/helpers/processState.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 337c3314..e40ed85b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -119,11 +119,24 @@ 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) | | +| `--base-url ` | dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL) | | JSON support: yes (`--json`) Dry run: yes (`--dry-run`) +Mode: `capy deploy dokploy --discover` + +Find the Dokploy services that match Capy projects, check a plan for them, and write the deploy targets as one pull request per repo. Pushes no values. + +- Flags: `--discover`, `--plan`, `--confirm`, `--base-url`, `--dry-run`, `--json` +- Always prints JSON: yes +- Dry run: yes +- Plan schema: `schemas.deploy_dokploy_plan` in `capy help --json` + ### `capy deploy revoke` Revoke a deploy token @@ -669,7 +682,7 @@ capy connect [provider] [options] | `--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 | | -| `-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 | | +| `-y, --yes` | dokploy import/discover: skip the confirmation prompt (import: --overwrite's clear/replace/import ask) — discover never prompts and needs --yes to write | | | `--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) | | @@ -708,6 +721,76 @@ _No options._ JSON support: no +## Schemas + +JSON Schemas of the files commands read, as published under `schemas` in `capy help --json`. + +### `deploy_dokploy_plan` + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "capy deploy dokploy --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": "Dokploy application or compose service id" + }, + "git_branch": { + "type": "string", + "minLength": 1, + "description": "git branch the Dokploy service tracks (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. @@ -808,6 +891,16 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `GITHUB_TIMEOUT` - `RATE_LIMITED` - `GITHUB_RATE_LIMITED` +- `SERVICE_NOT_FOUND` +- `DUPLICATE_ENTRY` +- `TARGET_EXISTS` +- `NO_REPO_LINK` +- `REPO_MISMATCH` +- `PLAN_FILE_UNREADABLE` +- `PLAN_INVALID` +- `PLAN_REQUIRED` +- `DISCOVER_UNSUPPORTED_TARGET` +- `DISCOVER_PARTIAL` ## Conventions diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index 9c0f16c8..7bd16bf1 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -1,11 +1,16 @@ import { resolveContext, writeAndSync, writeImportOutcome, ResolvedContext } from './connectors/shared'; import { listProviders, loadProvider, ConnectOpts, ConnectorModule } from './connectors/registry'; -import { isInteractive } from '../ui/interactive'; +import { EXIT_NEEDS_INPUT, isInteractive } from '../ui/interactive'; import { resolveOrgContext } from '../core/orgContext'; import type { DiscoveryContext } from './connectors/dokployDiscovery'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; +/** 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. */ +function exitCodeForRefusalCode(code: string): number { + return code === 'DOKPLOY_SETTINGS_MISSING' ? EXIT_NEEDS_INPUT : 1; +} + /** * Discovery's own context — org + auth + serviceClient, exactly what * `resolveOrgContext` already gives org-level commands (`invite`, `kick`, @@ -84,8 +89,13 @@ export class ConnectCommand { // context instead: org + auth + serviceClient only, no project key, no // `.env` decrypt — see `resolveDiscoveryContext`'s own doc. if (effective.discover && mod.discover) { + // Discover mode (Vince, 2026-10-03): ALWAYS prints exactly what + // `--json` prints, in a terminal or not, and never prompts — every + // choice comes from flags (`--base-url`, `--yes`, …) or is refused + // with a code. + const discoveryOpts: ConnectOpts = { ...effective, json: true, nonTty: true }; const discoveryCtx = await resolveDiscoveryContext(this.devMode); - return await this.executeDiscovery(mod, provider, discoveryCtx, effective); + return await this.executeDiscovery(mod, provider, discoveryCtx, discoveryOpts); } const ctx = await resolveContext({ devMode: this.devMode, dryRun: effective.dryRun === true }); @@ -178,11 +188,21 @@ export class ConnectCommand { if (!outcome.ok) { if (opts.json) { - console.log(JSON.stringify({ ok: false, code: outcome.code, message: outcome.message })); + console.log( + JSON.stringify({ + ok: false, + code: outcome.code, + message: outcome.message, + ...(outcome.unanswered === undefined ? {} : { unanswered: outcome.unanswered }), + }), + ); } else { - console.error(`\n ${outcome.message}\n`); + console.error(`\n ${outcome.message}`); + // 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(''); } - process.exitCode = 1; + process.exitCode = exitCodeForRefusalCode(outcome.code); return { linked: false }; } @@ -217,7 +237,6 @@ export class ConnectCommand { skipped: outcome.skipped, warnings: outcome.warnings, pushed, - deployTargetSaved: outcome.deployTargetSaved, }), ); return { linked: wrote }; @@ -262,9 +281,6 @@ export class ConnectCommand { for (const w of outcome.warnings) { console.log(` ⚠ ${w.code}: ${w.names.join(', ')}`); } - if (outcome.deployTargetSaved) { - console.log(` Saved a Dokploy deploy target for application ${outcome.applicationId}.`); - } console.log(''); return { linked: wrote }; } @@ -284,11 +300,18 @@ export class ConnectCommand { if (!outcome.ok) { if (opts.json) { - console.log(JSON.stringify({ ok: false, code: outcome.code, message: outcome.message })); + console.log( + JSON.stringify({ + ok: false, + code: outcome.code, + message: outcome.message, + ...(outcome.unanswered === undefined ? {} : { unanswered: outcome.unanswered }), + }), + ); } else { console.error(`\n ${outcome.message}\n`); } - process.exitCode = 1; + process.exitCode = exitCodeForRefusalCode(outcome.code); return { linked: false }; } @@ -392,6 +415,8 @@ export class ConnectCommand { provider, dryRun: outcome.dryRun, cancelled: !!outcome.cancelled, + ...(outcome.saved === undefined ? {} : { saved: outcome.saved }), + ...(outcome.notices === undefined ? {} : { notices: outcome.notices }), plan: planForOutput, ...(appliedForOutput ? { applied: appliedForOutput } : {}), ...(commitsForOutput ? { commits: commitsForOutput } : {}), diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index 091a6060..dd3dba97 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -6,12 +6,13 @@ * of the target org's Dokploy services is a Compose service, not an * Application). * - * This is the PULL half of the Dokploy integration; `capy deploy` (the PUSH - * half, see `../../deploy/adapters/dokploy.ts` and - * `docs/dokploy-deploy-adapter.md`) delivers Capy's own runtime pair back out - * to an Application only — there is no compose deploy adapter, so a compose - * import never offers a deploy target (see `maybeOfferDeployTarget`'s call - * site in `import()`, below). + * This is the PULL half of the Dokploy integration: "connect is for + * retrieval, target is for deploy" (Vince, 2026-10-03). It imports values + * (and keep.lock connector entries) and NEVER creates, offers or writes a + * deploy target — `capy deploy dokploy --discover` (CAP-703) is what creates + * targets, and it pushes no values. `capy deploy` (the PUSH half, see + * `../../deploy/adapters/dokploy.ts` and `docs/dokploy-deploy-adapter.md`) + * delivers Capy's own runtime pair back out. * * Import is READ-ONLY on the Dokploy side: the only call this file ever makes * is `GET application.one` or `GET compose.one` — never a write. The API @@ -61,6 +62,7 @@ import { assertProjectNameAllowed, isReservedProjectName, PROJECT_NAME_RESERVED_ import { findDirtyBranchIssue } from '../checkoutCommand'; import { listOrgProjectsOrUnavailable } from '../capyCommand'; import { commitDiscoveryChanges, defaultDiscoveryCommitBranchName, isValidDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; +import { BaseUrlStore, Unanswered, offerSaveAskedBaseUrl, realBaseUrlStoreOpener, resolveDokployBaseUrl, settingsUnanswered } from '../../deploy/dokployBaseUrl'; import { fingerprint, writeImportedAndSync, writeImportOutcome, ResolvedContext } from './shared'; import { ConnectOpts, ConnectorModule, ConnectResult, ImportOutcome, ImportWarning, RotateResult } from './registry'; import { discoveryProjectQuestion } from '../../ui/projectQuestions'; @@ -96,8 +98,8 @@ interface DokployTargetOptions { // ── Settings resolution ───────────────────────────────────────────────────── export type DokploySettingsResult = - | { ok: true; baseUrl: string; applicationId: string; tokenEnv: string } - | { ok: false; code: 'DOKPLOY_TARGET_AMBIGUOUS' | 'DOKPLOY_SETTINGS_MISSING'; message: string }; + | { ok: true; baseUrl: string; applicationId: string; tokenEnv: string; /** CAP-703: the URL was typed at a prompt (so it may be offered to the org). */ baseUrlAsked?: true } + | { ok: false; code: 'DOKPLOY_TARGET_AMBIGUOUS' | 'DOKPLOY_SETTINGS_MISSING'; message: string; unanswered?: readonly Unanswered[] }; export interface DokploySettingsDeps { pickTarget: (names: readonly string[]) => Promise; @@ -164,6 +166,7 @@ export async function resolveDokploySettings( ok: false, code: 'DOKPLOY_SETTINGS_MISSING', message: 'No Dokploy settings found. Pass --base-url and --application , or run this interactively.', + unanswered: settingsUnanswered({ baseUrl: !opts.baseUrl, service: !opts.application }), }; } const asked = await deps.askSettings(); @@ -172,15 +175,17 @@ export async function resolveDokploySettings( baseUrl: asked.baseUrl.trim(), applicationId: asked.applicationId.trim(), tokenEnv: tokenEnvFlag || DEFAULT_TOKEN_ENV, + baseUrlAsked: true, }; } export type DokploySourceResult = - | { ok: true; baseUrl: string; source: DokployImportSource; tokenEnv: string } + | { ok: true; baseUrl: string; source: DokployImportSource; tokenEnv: string; baseUrlAsked?: true } | { ok: false; code: 'DOKPLOY_SOURCE_AMBIGUOUS' | 'DOKPLOY_TARGET_AMBIGUOUS' | 'DOKPLOY_SETTINGS_MISSING'; message: string; + unanswered?: readonly Unanswered[]; }; export interface DokploySourceDeps extends DokploySettingsDeps { @@ -193,7 +198,13 @@ export interface DokploySourceDeps extends DokploySettingsDeps { /** `resolveDokploySettings`'s application result, reshaped as a `DokployImportSource`. */ function settingsToSource(r: DokploySettingsResult): DokploySourceResult { if (!r.ok) return r; - return { ok: true, baseUrl: r.baseUrl, source: { kind: 'application', id: r.applicationId }, tokenEnv: r.tokenEnv }; + return { + ok: true, + baseUrl: r.baseUrl, + source: { kind: 'application', id: r.applicationId }, + tokenEnv: r.tokenEnv, + ...(r.baseUrlAsked ? { baseUrlAsked: true as const } : {}), + }; } /** @@ -244,6 +255,7 @@ export async function resolveDokployImportSource( ok: false, code: 'DOKPLOY_SETTINGS_MISSING', message: 'No Dokploy base URL. Pass --base-url , or run this interactively.', + unanswered: settingsUnanswered({ baseUrl: true }), }; } const asked = await deps.askComposeSettings(); @@ -252,6 +264,7 @@ export async function resolveDokployImportSource( baseUrl: asked.baseUrl.trim(), source: { kind: 'compose', id: asked.composeId.trim() }, tokenEnv, + baseUrlAsked: true, }; } @@ -265,6 +278,7 @@ export async function resolveDokployImportSource( code: 'DOKPLOY_SETTINGS_MISSING', message: 'No Dokploy settings found. Pass --base-url and --application or --compose , or run this interactively.', + unanswered: settingsUnanswered({ baseUrl: !opts.baseUrl, service: true }), }; } @@ -276,6 +290,7 @@ export async function resolveDokployImportSource( baseUrl: asked.baseUrl.trim(), source: { kind: 'compose', id: asked.composeId.trim() }, tokenEnv, + baseUrlAsked: true, }; } return settingsToSource(await resolveDokploySettings(opts, dokployTargets, interactive, deps)); @@ -351,58 +366,6 @@ async function fetchComposeEnv(client: DokployClient, composeId: string): Promis } } -// ── Deploy-target offer ───────────────────────────────────────────────────── - -function uniqueTargetName(existing: readonly TargetConfig[], base: string): string { - const names = new Set(existing.map((t) => t.name)); - if (!names.has(base)) return base; - const numbered = (n: number): string => (names.has(`${base}-${n}`) ? numbered(n + 1) : `${base}-${n}`); - return numbered(2); -} - -interface DeployTargetOfferArgs { - importedNames: readonly string[]; - applicationId: string; - baseUrl: string; - tokenEnv: string; - branch: string; - allTargets: readonly TargetConfig[]; - interactive: boolean; - cwd: string; - confirm: (message: string, defaultValue: boolean) => Promise; -} - -/** - * After a successful import, offer to save a Dokploy deploy target for the - * SAME application — so `capy deploy` can ship Capy's runtime pair back to - * it. Only offered when nothing imported (nothing to offer) and only asked - * interactively; non-interactive never asks and never saves. - */ -async function maybeOfferDeployTarget(args: DeployTargetOfferArgs): Promise { - if (args.importedNames.length === 0) return false; - const already = args.allTargets.some( - (t) => t.kind === 'dokploy' && (t.options as DokployTargetOptions).applicationId === args.applicationId, - ); - if (already || !args.interactive) return false; - - // COPY-FLAG: new user-facing string, minimal/neutral wording. - const yes = await args.confirm( - 'Save a Dokploy deploy target for this application, so `capy deploy` can ship the runtime pair to it?', - false, - ); - if (!yes) return false; - - const { upsertTarget } = await import('../../deploy/config'); - upsertTarget(args.cwd, { - name: uniqueTargetName(args.allTargets, 'dokploy'), - kind: 'dokploy', - branch: args.branch, - vars: [...args.importedNames], - options: { baseUrl: args.baseUrl, applicationId: args.applicationId, tokenEnv: args.tokenEnv }, - }); - return true; -} - // ── Connector ──────────────────────────────────────────────────────────────── export interface DokployConnectorDeps { @@ -424,6 +387,12 @@ export interface DokployConnectorDeps { askSourceKind?: () => Promise<'application' | 'compose'>; /** Discovery mode only. Injectable for tests. Real `inquirer` input otherwise: the base URL to scan when nothing else resolves it. */ askDiscoveryBaseUrl?: () => Promise; + /** + * Opens the org system store for the Dokploy base URL variable (`_CONNECTOR_DOKPLOY_BASE_URL`). Opt-in like + * `getConnectorSecret`: every test constructs the connector without it and never touches the store; the + * exported `dokployConnector` singleton wires the real one. + */ + openBaseUrlStore?: (orgId: string, devMode: boolean) => Promise; /** Discovery mode only. Injectable for tests. Real `inquirer` list otherwise: which candidate wins one mapping collision. */ pickCollisionWinner?: (collision: DiscoveryCollision) => Promise; /** @@ -807,7 +776,6 @@ async function runOverwriteImport(args: { ...(dollarWarnedNames.length > 0 ? [{ code: 'DOKPLOY_VALUE_HAS_DOLLAR', names: dollarWarnedNames }] : []), ...(quotedNames.length > 0 ? [{ code: 'DOKPLOY_VALUE_QUOTED', names: quotedNames }] : []), ], - deployTargetSaved: false, }; } } else if (!promptable && !opts.yes) { @@ -854,10 +822,24 @@ async function runOverwriteImport(args: { ...(quotedNames.length > 0 ? [{ code: 'DOKPLOY_VALUE_QUOTED', names: quotedNames }] : []), ...(written.length > 0 && !dryRun ? [{ code: 'DOKPLOY_PLAINTEXT_REMAINS', names: written.map((w) => w.varName) }] : []), ], - deployTargetSaved: false, }; } +/** The stored base URL for a run that was not given `--base-url`; `undefined` when a flag was given, the variable is unset, or it cannot be read. */ +async function storedBaseUrlFor( + opts: ConnectOpts, + orgId: string, + openBaseUrlStore: DokployConnectorDeps['openBaseUrlStore'], +): Promise { + if (opts.baseUrl?.trim() || openBaseUrlStore === undefined) return undefined; + const resolved = await resolveDokployBaseUrl({ + dryRun: true, + openStore: () => openBaseUrlStore(orgId, !!opts.devMode), + savedTargetUrl: () => undefined, + }); + return resolved.ok ? resolved.baseUrl : undefined; +} + export function createDokployConnector(deps: DokployConnectorDeps = {}): ConnectorModule { const cwd = () => deps.cwd ?? process.cwd(); const now = deps.now ?? (() => new Date()); @@ -915,16 +897,20 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect const dryRun = !!opts.dryRun; const { listTargets } = await import('../../deploy/config'); - const allTargets = listTargets(cwd()); - const dokployTargets = allTargets.filter((t) => t.kind === 'dokploy'); + const dokployTargets = listTargets(cwd()).filter((t) => t.kind === 'dokploy'); - const settings = await resolveDokployImportSource(opts, dokployTargets, interactive, { + // CAP-703: with no `--base-url`, the org system variable (`_CONNECTOR_DOKPLOY_BASE_URL`) comes before a saved + // target and the interactive ask - the same shared resolver, read-only here (the discover commands save it). + const storedBaseUrl = await storedBaseUrlFor(opts, ctx.orgId, deps.openBaseUrlStore); + const settings = await resolveDokployImportSource(storedBaseUrl === undefined ? opts : { ...opts, baseUrl: storedBaseUrl }, dokployTargets, interactive, { pickTarget, askSettings, askComposeSettings, askSourceKind, }); - if (!settings.ok) return { ok: false, code: settings.code, message: settings.message }; + if (!settings.ok) { + return { ok: false, code: settings.code, message: settings.message, ...(settings.unanswered === undefined ? {} : { unanswered: settings.unanswered }) }; + } const { baseUrl, source, tokenEnv } = settings; // Resolved BEFORE any Dokploy request — a missing/refused token still @@ -955,6 +941,15 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect ? await fetchComposeEnv(client, source.id) : await fetchApplicationEnv(client, source.id); if (!envResult.ok) return { ok: false, code: envResult.code, message: envResult.message }; + // CAP-703: a URL typed at a prompt is offered to the org once Dokploy has answered with it - saved only when the + // variable is unset and the caller is an org admin (never overwritten), a dry run saves nothing. + if (settings.baseUrlAsked === true && !dryRun) { + const openStore = deps.openBaseUrlStore; + await offerSaveAskedBaseUrl({ + baseUrl, + openStore: openStore === undefined ? undefined : () => openStore(ctx.orgId, !!opts.devMode), + }); + } const sourceEnv = envResult.env; // CAP-673: `service_name` is free (same response as `env`, above); // `dokploy_project`/`environment` are only known when the caller @@ -1032,24 +1027,6 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect : []), ]; - // Compose has no deploy target in this change — there is no compose - // deploy adapter yet, so it is never offered, regardless of TTY. A dry - // run never saves anything either way (Vince's rule: changes nothing). - const deployTargetSaved = - !dryRun && source.kind === 'application' - ? await maybeOfferDeployTarget({ - importedNames: imported.map((i) => i.varName), - applicationId: source.id, - baseUrl, - tokenEnv, - branch: ctx.branch, - allTargets, - interactive, - cwd: cwd(), - confirm, - }) - : false; - return { ok: true, ...(source.kind === 'application' ? { applicationId: source.id } : {}), @@ -1059,7 +1036,6 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect unchangedEntries, skipped, warnings, - deployTargetSaved, ...(dryRun ? { wouldAsk } : {}), }; }, @@ -1079,21 +1055,37 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect const dokployTargets = listTargets(cwd()).filter((t) => t.kind === 'dokploy'); const tokenEnv = opts.tokenEnv?.trim() || DEFAULT_TOKEN_ENV; - const flagBaseUrl = opts.baseUrl?.trim(); + // The one shared base-URL resolver: --base-url, then the org system + // variable, then the saved deploy target, else a structured refusal. + const openBaseUrlStore = deps.openBaseUrlStore; const savedBaseUrl = (dokployTargets[0]?.options as DokployTargetOptions | undefined)?.baseUrl; - const baseUrl = flagBaseUrl || savedBaseUrl; - const resolvedBaseUrl: string | null = baseUrl - ? baseUrl - : interactive + const baseUrlResolution = await resolveDokployBaseUrl({ + flag: opts.baseUrl, + dryRun, + openStore: openBaseUrlStore === undefined ? undefined : () => openBaseUrlStore(ctx.orgId, !!opts.devMode), + savedTargetUrl: () => savedBaseUrl, + }); + // A direct caller at a real terminal may still be asked; the CLI never is (see `ConnectCommand`). + const asked: string | null = + !baseUrlResolution.ok && baseUrlResolution.code === 'DOKPLOY_SETTINGS_MISSING' && interactive ? (await askDiscoveryBaseUrl()).trim() : null; - if (!resolvedBaseUrl) { + if (!baseUrlResolution.ok && !asked) { return { ok: false, - code: 'DOKPLOY_SETTINGS_MISSING', - message: 'No Dokploy base URL. Pass --base-url , or run this interactively.', + code: baseUrlResolution.code, + message: baseUrlResolution.error, + ...(baseUrlResolution.unanswered === undefined ? {} : { unanswered: baseUrlResolution.unanswered }), }; } + const resolvedBaseUrl: string = baseUrlResolution.ok ? baseUrlResolution.baseUrl : (asked as string); + // Reported in the discover output: `saved: { base_url: true }` and the coded notices. + const baseUrlReport = baseUrlResolution.ok + ? { + ...(baseUrlResolution.saved === undefined ? {} : { saved: baseUrlResolution.saved }), + ...(baseUrlResolution.notices.length === 0 ? {} : { notices: baseUrlResolution.notices }), + } + : {}; // Same suppression rule as the single-service import: never prompt for // the key under --json/--dry-run. @@ -1141,7 +1133,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect // 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()); - return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}) }; + return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}), ...baseUrlReport }; } // `--json` must never have a prompt interleaved with it, same reason @@ -1178,7 +1170,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect const resolvedFolders = applyCollisionResolutions(plan.folders, resolution.resolutions); if (resolvedFolders.length === 0) { - return { ok: true, dryRun: false, plan, applied: [] }; + return { ok: true, dryRun: false, plan, applied: [], ...baseUrlReport }; } // A real (non-dry-run) run must never write without confirmation. @@ -1193,7 +1185,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect 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 }; + return { ok: true, dryRun: false, plan, applied: [], cancelled: true, ...baseUrlReport }; } } else if (!promptable && !opts.yes) { return { @@ -1267,7 +1259,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect // something local for this step to pin. const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName); - return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}) }; + return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}), ...baseUrlReport }; }, }; return connector; @@ -1965,21 +1957,30 @@ function buildRealDiscoverySequenceDeps( }; } +/** + * 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 + * Dokploy inventory with the same key. + * + * CAP-679 follow-up: import's own direction — see + * `system/systemStore.ts#getDirectionalConnectorSecret`'s doc, and + * `deploy/adapters/dokploy.ts`'s `dokployAdapter` for the symmetric + * deploy-side wiring. + */ +export async function dokployConnectorSecret(name: string, opts: DokploySystemStoreCallOptions): Promise { + const { getDirectionalConnectorSecret } = await import('../../system/systemStore'); + return getDirectionalConnectorSecret(name, DOKPLOY_TARGET_SECRET_NAME, { + ...opts, + missingWithFallbackCode: ERROR_CODES.DOKPLOY_CONNECTOR_KEY_MISSING, + }); +} + /** * The `capy connect dokploy` 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 `DokployConnectorDeps`). */ export const dokployConnector: ConnectorModule = createDokployConnector({ - getConnectorSecret: async (name, opts) => { - // CAP-679 follow-up: import's own direction — see - // `system/systemStore.ts#getDirectionalConnectorSecret`'s doc, and - // `deploy/adapters/dokploy.ts`'s `dokployAdapter` for the symmetric - // deploy-side wiring. - const { getDirectionalConnectorSecret } = await import('../../system/systemStore'); - return getDirectionalConnectorSecret(name, DOKPLOY_TARGET_SECRET_NAME, { - ...opts, - missingWithFallbackCode: ERROR_CODES.DOKPLOY_CONNECTOR_KEY_MISSING, - }); - }, + getConnectorSecret: dokployConnectorSecret, + openBaseUrlStore: (orgId, devMode) => realBaseUrlStoreOpener(orgId, devMode)(), }); diff --git a/src/commands/connectors/dokployDiscovery.ts b/src/commands/connectors/dokployDiscovery.ts index 5265fe30..05e73992 100644 --- a/src/commands/connectors/dokployDiscovery.ts +++ b/src/commands/connectors/dokployDiscovery.ts @@ -413,7 +413,9 @@ export const DISCOVERY_PROJECT_NAME_MAX_LENGTH = 255; export type DiscoveryServiceKind = 'application' | 'compose'; -interface ServiceDetail { +export interface ServiceDetail { + /** Dokploy PROJECT id (CAP-703); `projectName` alone is not unique. */ + projectId?: string; projectName: string; environmentId: string; environmentName: string; @@ -425,6 +427,8 @@ interface ServiceDetail { repository?: string; branch?: string; composePath?: string; + /** Application only (CAP-703): the build directory within the repo. UNVERIFIED live — see `DokployApplication.buildPath`. */ + buildPath?: string; rawEnv: string | null; } @@ -453,6 +457,7 @@ function serviceDisplayName(ref: { name?: string; appName?: string; id: string } /** Every service across every project/environment, with full detail (`GET application.one`/`GET compose.one` each). Sequential — deterministic call order for tests, and gentle on the Dokploy instance. */ interface ServiceRefContext { + projectId: string; projectName: string; environmentId: string; environmentName: string; @@ -466,6 +471,7 @@ function flattenServiceRefs(projects: readonly DokployProjectSummary[]): readonl return projects.flatMap((project) => project.environments.flatMap((env) => [ ...env.applications.map((ref) => ({ + projectId: project.projectId, projectName: project.name, environmentId: env.environmentId, environmentName: env.name, @@ -474,6 +480,7 @@ function flattenServiceRefs(projects: readonly DokployProjectSummary[]): readonl serviceName: serviceDisplayName(ref), })), ...env.composes.map((ref) => ({ + projectId: project.projectId, projectName: project.name, environmentId: env.environmentId, environmentName: env.name, @@ -485,45 +492,61 @@ function flattenServiceRefs(projects: readonly DokployProjectSummary[]): readonl ); } -async function fetchAllServiceDetails(client: DiscoveryDokployClient): Promise { +/** + * Every service with its full detail. `onProgress` (CAP-703) is told after + * each service has been read, so a caller can report progress somewhere that + * is not stdout. + */ +export async function fetchAllServiceDetails( + client: DiscoveryDokployClient, + onProgress?: (done: number, total: number) => void, + /** Only these service ids are read in full (a plan names a few of them); the rest are skipped without a request. */ + only?: ReadonlySet, +): Promise { const projects = await client.listProjects(); - const refs = flattenServiceRefs(projects); + const refs = flattenServiceRefs(projects).filter((ref) => only === undefined || only.has(ref.serviceId)); // Sequential (not parallel): deterministic call order for tests, and // gentle on the Dokploy instance. A `.reduce` over a promise keeps that // order without a mutable accumulator — same pattern the single-service // import's own conflict-classification reduce uses. return refs.reduce>(async (accPromise, ref) => { const acc = await accPromise; - if (ref.serviceKind === 'application') { - const app = await client.getApplication(ref.serviceId); - return [ - ...acc, - { - ...ref, - sourceType: app.sourceType, - owner: app.owner, - repository: app.repository, - branch: app.branch, - rawEnv: app.env, - }, - ]; - } - const compose = await client.getCompose(ref.serviceId); - return [ - ...acc, - { - ...ref, - sourceType: compose.sourceType, - owner: compose.owner, - repository: compose.repository, - branch: compose.branch, - composePath: compose.composePath, - rawEnv: compose.env, - }, - ]; + const detail = await fetchOneServiceDetail(client, ref); + onProgress?.(acc.length + 1, refs.length); + return [...acc, detail]; }, Promise.resolve([])); } +async function fetchOneServiceDetail(client: DiscoveryDokployClient, ref: ServiceRefContext): Promise { + if (ref.serviceKind === 'application') { + const app = await client.getApplication(ref.serviceId); + return { + ...ref, + sourceType: app.sourceType, + owner: app.owner, + repository: app.repository, + branch: app.branch, + buildPath: app.buildPath, + rawEnv: app.env, + }; + } + const compose = await client.getCompose(ref.serviceId); + return { + ...ref, + sourceType: compose.sourceType, + owner: compose.owner, + repository: compose.repository, + branch: compose.branch, + composePath: compose.composePath, + rawEnv: compose.env, + }; +} + +/** The one rule for "this service tracks a git repo": an owner and a repository, and not a `raw` source. Shared by `capy connect dokploy --discover` and `capy deploy dokploy --discover`. */ +export function hasGitSource(detail: Pick): boolean { + return !!detail.owner && !!detail.repository && detail.sourceType?.toLowerCase() !== 'raw'; +} + function matchDetail( detail: ServiceDetail, repos: readonly CandidateRepo[], @@ -534,8 +557,7 @@ function matchDetail( serviceKind: detail.serviceKind, serviceId: detail.serviceId, }; - const hasGitSource = !!detail.owner && !!detail.repository && detail.sourceType?.toLowerCase() !== 'raw'; - if (!hasGitSource) { + if (!hasGitSource(detail)) { return { kind: 'unmatched', value: { ...base, reason: 'no_git_source' } }; } const hit = repos.find( @@ -1187,10 +1209,16 @@ 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[]; + /** 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. */ + notices?: ReadonlyArray<{ readonly code: string; readonly reason?: string; readonly stored?: string; readonly used?: string }>; } | { ok: false; /** Stable refusal code — branch on this, never on `message`. */ code: string; message: string; + /** CAP-703: what a caller must still supply, as flags (`DOKPLOY_SETTINGS_MISSING`). */ + unanswered?: ReadonlyArray<{ readonly id: string; readonly flag: string; readonly hint: string }>; }; diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index fd85b332..b7308b33 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -237,7 +237,6 @@ export type ImportOutcome = unchanged: readonly string[]; skipped: readonly ImportSkip[]; warnings: readonly ImportWarning[]; - deployTargetSaved: boolean; /** * Dokploy import only: names with a different local value that a real * (non-dry-run) run would have prompted about. Only populated under @@ -279,6 +278,8 @@ export type ImportOutcome = /** Stable refusal code — branch on this, never on `message`. */ code: string; message: string; + /** CAP-703: what a caller must still supply, as flags (`DOKPLOY_SETTINGS_MISSING`). */ + unanswered?: ReadonlyArray<{ readonly id: string; readonly flag: string; readonly hint: string; readonly alternative?: string }>; }; export interface ConnectorModule { diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index f1067e6e..926d52ea 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -978,6 +978,25 @@ async function resolveDokployApiKeyForPicker( }); } +/** + * The org system variable for the Dokploy URL, read through the shared resolver (CAP-703); `undefined` when it is + * unset, cannot be read (not an admin) or there is no org. Never prompts. + */ +async function storedDokployBaseUrl(orgId: string | undefined): Promise { + if (orgId === undefined) return undefined; + try { + const { resolveDokployBaseUrl, realBaseUrlStoreOpener } = await import('../deploy/dokployBaseUrl'); + const resolved = await resolveDokployBaseUrl({ + dryRun: true, + openStore: realBaseUrlStoreOpener(orgId, false), + savedTargetUrl: () => undefined, + }); + return resolved.ok ? resolved.baseUrl : undefined; + } catch { + return undefined; + } +} + /** `new URL(raw).host`, or `undefined` for anything that doesn't parse — never throws. */ function hostOf(raw: string | undefined): string | undefined { if (!raw) return undefined; @@ -1004,6 +1023,8 @@ async function askDokployKindAndBaseUrl( id: string, existingOpts: Record, existingKind: DokployServiceKind, + /** The org system variable `_CONNECTOR_DOKPLOY_BASE_URL`, offered as the default when the target has no URL of its own (CAP-703). */ + storedBaseUrl?: string, ): Promise { const ans = (await inquirer.prompt([ { @@ -1021,7 +1042,7 @@ async function askDokployKindAndBaseUrl( type: 'input', name: 'baseUrl', message: 'Dokploy URL:', - default: existingOpts.baseUrl, + default: existingOpts.baseUrl ?? storedBaseUrl, validate: (v: string) => baseUrlProblem(v) ?? true, filter: (v: string) => v.trim(), }, @@ -1091,7 +1112,7 @@ export async function resolveDokployServiceOptions( const answer = await askUrlOrId(); if (isBareDokployId(answer)) { - return askDokployKindAndBaseUrl(answer, existingOpts, existingKind); + return askDokployKindAndBaseUrl(answer, existingOpts, existingKind, await storedDokployBaseUrl(orgId)); } const parsed = parseDokployServiceUrl(answer); @@ -1163,7 +1184,25 @@ export async function resolveDokployServiceOptions( return confirmed ? parsed : resolve(); }; - return resolve(); + const resolved = await resolve(); + await offerSaveEnteredDokployUrl(orgId, resolved.baseUrl, existingOpts.baseUrl); + return resolved; +} + +/** + * CAP-703: the URL the person typed at the setup prompt is offered to the org (the system variable + * `_CONNECTOR_DOKPLOY_BASE_URL`) through the shared resolver's save path: only when the variable is unset and the + * caller is an org admin, never overwriting. A URL the target already had is not "entered", so it is left alone. + * Never fails the setup. + */ +async function offerSaveEnteredDokployUrl(orgId: string | undefined, baseUrl: string, existingBaseUrl: string | undefined): Promise { + if (orgId === undefined || baseUrl === existingBaseUrl) return; + try { + const { offerSaveAskedBaseUrl, realBaseUrlStoreOpener } = await import('../deploy/dokployBaseUrl'); + await offerSaveAskedBaseUrl({ baseUrl, openStore: realBaseUrlStoreOpener(orgId, false) }); + } catch { + // Best effort: a failed offer never fails the target setup. + } } /** diff --git a/src/commands/deployDiscover/apply.ts b/src/commands/deployDiscover/apply.ts new file mode 100644 index 00000000..e513c099 --- /dev/null +++ b/src/commands/deployDiscover/apply.ts @@ -0,0 +1,218 @@ +/** + * Writing a confirmed plan (CAP-703): each project's `.capy/deploy.json` + * target, as ONE pull request per repo on the repo's default branch, through + * the GitHub API (keepLockPr's `openFilesPullRequest`: blob per file, one + * tree, one commit, one ref, one PR). Several projects of one monorepo are + * several files in one commit; the path is `/.capy/deploy.json`. + * + * Nothing is cloned or checked out, and no value is read or sent: a target + * holds names and ids only. + * + * Repos fail independently: a failure in one is its own coded result and the + * others still go ahead. A target already in the file (the same Dokploy + * service on the same Capy branch) is left alone, so running the same plan + * again after its PR merged opens nothing. + * + * Merging into an existing file keeps every other target and every other + * top-level field exactly as they were read. + */ +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 { TargetToWrite, uniqueName } from './evaluate'; +import { sortedCopy } from '../../deploy/dokployApi'; + +// COPY-FLAG: new user-facing strings, minimal and neutral. +const PR_TITLE = 'chore(capy): add Dokploy deploy targets'; // COPY-FLAG + +export interface RepoPrResult { + readonly repo: string; + readonly pr_url: string; + readonly base: string; + readonly paths: readonly string[]; + readonly targets: readonly string[]; +} + +export interface RepoFailure { + readonly repo: string; + readonly code: string; +} + +export interface AppliedAlready { + readonly repo: string; + readonly path: string; + readonly branch: string; + readonly service_id: string; + readonly target_name: string; +} + +export interface ApplyResult { + readonly prs: readonly RepoPrResult[]; + readonly failed: readonly RepoFailure[]; + /** Targets found already in the repo's deploy.json when it was read for the write. */ + readonly already_configured: readonly AppliedAlready[]; +} + +export interface ApplyDeps { + readonly github: GithubApi | undefined; + readonly branchName: () => string; +} + +const repoLabel = (repo: RepoRef): string => `${repo.owner}/${repo.name}`; + +/** The text of a deploy.json holding `existing` plus `added`; every other target and top-level field is carried over as read. */ +export function mergeDeployJson(parsed: Extract, added: readonly TargetConfig[]): string { + const base = parsed.raw ?? { version: '1', targets: {} }; + const previous = (typeof base.targets === 'object' && base.targets !== null && !Array.isArray(base.targets) ? base.targets : {}) as Record; + const targets = { ...previous, ...Object.fromEntries(added.map((t) => [t.name, t])) }; + return JSON.stringify({ ...base, version: '1', targets }, null, 2) + '\n'; +} + +interface WrittenTarget { + readonly target: TargetConfig; + readonly serviceKind: 'application' | 'compose'; + readonly serviceId: string; +} + +interface FilePlan { + readonly path: string; + readonly content: string; + readonly written: readonly WrittenTarget[]; + readonly already: readonly AppliedAlready[]; +} + +/** Reads a file at `base` and works out what to add to it. `null`: the file could not be read or is not a version-1 deploy.json. */ +async function planFile( + github: GithubApi, + repo: RepoRef, + base: string, + path: string, + targets: readonly TargetToWrite[], +): Promise<{ readonly ok: true; readonly plan: FilePlan } | { readonly ok: false; readonly code: string }> { + 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); + 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 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, + }, + ]; + }, []); + return { + ok: true, + plan: { + path, + content: named.length === 0 ? '' : mergeDeployJson(parsed, named.map((w) => w.target)), + written: named, + already, + }, + }; +} + +// COPY-FLAG: new user-facing strings (the commit message and the PR body), names and ids only. +function wording(repo: RepoRef, files: readonly FilePlan[]) { + 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 ?? ''}\``, + ), + ); + const names = files.flatMap((f) => f.written.map((w) => w.target.name)); + return { + commitMessage: [PR_TITLE, '', `Targets: ${names.join(', ')}`].join('\n'), + title: PR_TITLE, + body: [ + 'Adds Dokploy deploy targets to `.capy/deploy.json`.', + '', + ...lines, + '', + `Repo: \`${repoLabel(repo)}\``, + 'Names and ids only. No secret values appear in this PR.', + ].join('\n'), + }; +} + +type RepoOutcome = + | { readonly kind: 'pr'; readonly pr: RepoPrResult; readonly already: readonly AppliedAlready[] } + | { readonly kind: 'unchanged'; readonly already: readonly AppliedAlready[] } + | { readonly kind: 'failed'; readonly failure: RepoFailure }; + +async function applyRepo(repo: RepoRef, targets: readonly TargetToWrite[], deps: ApplyDeps): Promise { + const label = repoLabel(repo); + const fail = (code: string): RepoOutcome => ({ kind: 'failed', failure: { repo: label, code } }); + const { github } = deps; + if (github === undefined) return fail(ERROR_CODES.KEEP_PR_GH_UNAVAILABLE); + const base = await resolveDefaultBase(github, repo); + if (!base.ok) return fail(base.code); + + 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)), + ), + ); + const failedRead = planned.find((p) => !p.ok); + if (failedRead !== undefined && !failedRead.ok) return fail(failedRead.code); + const files = planned.flatMap((p) => (p.ok ? [p.plan] : [])); + const already = files.flatMap((f) => f.already); + const changed = files.filter((f) => f.written.length > 0); + if (changed.length === 0) return { kind: 'unchanged', already }; + + const words = wording(repo, changed); + const opened = await openFilesPullRequest( + { + repo, + base: base.base, + files: changed.map((f) => ({ path: f.path, content: f.content })), + ...words, + }, + { github, branchName: deps.branchName }, + ); + if (!opened.ok) return fail(opened.code); + return { + kind: 'pr', + already, + pr: { + repo: label, + pr_url: opened.pr_url, + base: opened.base, + paths: opened.paths, + targets: changed.flatMap((f) => f.written.map((w) => w.target.name)), + }, + }; +} + +/** One PR per repo. Sequential, so a repo's failure never depends on another's timing; each repo reports on its own. */ +export async function applyTargets(targets: readonly TargetToWrite[], deps: ApplyDeps): Promise { + const repos = uniqueSorted(targets.map((t) => t.repoKey)).map((key) => { + const mine = targets.filter((t) => t.repoKey === key); + return { repo: mine[0].repo, targets: mine }; + }); + const outcomes = await repos.reduce>(async (accPromise, r) => { + const acc = await accPromise; + return [...acc, await applyRepo(r.repo, r.targets, deps)]; + }, Promise.resolve([])); + return { + prs: outcomes.flatMap((o) => (o.kind === 'pr' ? [o.pr] : [])), + failed: outcomes.flatMap((o) => (o.kind === 'failed' ? [o.failure] : [])), + already_configured: sortedCopy( + outcomes.flatMap((o) => (o.kind === 'failed' ? [] : o.already)), + (a, b) => byText(a.repo, b.repo) || byText(a.path, b.path) || byText(a.branch, b.branch), + ), + }; +} diff --git a/src/commands/deployDiscover/command.ts b/src/commands/deployDiscover/command.ts new file mode 100644 index 00000000..902557db --- /dev/null +++ b/src/commands/deployDiscover/command.ts @@ -0,0 +1,556 @@ +/** + * `capy deploy dokploy --discover` (CAP-703): create Dokploy deploy targets for + * the services that match Capy projects, and push NO values. + * + * "Connect is for retrieval, target is for deploy": `capy connect dokploy + * --discover` imports values and never touches a target; this command writes + * targets and never touches a value. + * + * ALWAYS prints one JSON document on stdout (exactly what `--json` would + * print), in a terminal or not. It never prompts and never opens a browser: + * progress goes to stderr, every choice is a flag or a coded refusal. The + * agent applies judgement, the human approves, this command checks and writes. + * + * capy deploy dokploy --discover facts: services, projects, proposals + * capy deploy dokploy --discover --plan f.json --dry-run validate the plan, change nothing + * capy deploy dokploy --discover --plan f.json --confirm ID write deploy.json: ONE PR per repo + * + * The Capy service limits `/orgs` calls (30 a minute), so a run makes ONE + * call for the repo links and ONE for the secret NAME index, however many + * projects and services there are. GitHub is read in batches (one GraphQL + * call for every default branch) and the rest is one read per repo. + * + * Names and ids only, everywhere. No Dokploy value and no Capy value is read, + * printed or sent: the only things taken out of a Dokploy env are its names. + * + * Server-side recording: `capy deploy` records a target's DELIVERY in the + * server's keep.lock (`recordDeployTargets`), after a delivery or a + * `--no-deploy` run, and that needs the variables' value hashes. Writing a + * target here delivers nothing, so there is nothing to record; the record + * appears when the target is first run with `capy deploy ` (a + * `--no-deploy` run records it as pending). The target exists in git first. + */ +import { ERROR_CODES } from '../../types/index'; +import type { OrgRepoLink, OrgReposResponse, SecretIndexResponse, ServiceClient } from '../../service/serviceClient'; +import { CapyError } from '../../types/index'; +import { callWithBackoff } from '../../service/capyCalls'; +import { + DEFAULT_TOKEN_ENV, + DOKPLOY_CONNECTOR_SECRET_NAME, + DokployApiError, + DokploySystemStoreCallOptions, + describeDokployTokenProblem, + resolveDokployApiKey, +} from '../../deploy/dokployApi'; +import { BaseUrlNotice, BaseUrlStore, resolveDokployBaseUrl } from '../../deploy/dokployBaseUrl'; +import type { GithubApi, RepoRef } from '../../deploy/githubApi'; +import { codeForApiFailure } from '../keepLockPr'; +import { runPool } from '../../utils/pool'; +import { DiscoveryDokployClient, fetchAllServiceDetails } from '../connectors/dokployDiscovery'; +import { applyTargets } from './apply'; +import { + DeployDiscovery, + DiscoveryInput, + Notice, + PlanEntry, + buildDeployDiscovery, +} from './discovery'; +import { + CapyProjectFacts, + ExistingTarget, + ServiceFacts, + byText, + capyProjectsOf, + deployJsonPathOf, + parseDeployJson, + repoKey, + serviceFactsOf, + uniqueSorted, +} from './facts'; +import { + EvaluatedEntry, + GithubReads, + ReadFailure, + evaluatePlan, + fileKeyOf, + githubNeeds, + repoRefOf, + resolveEntries, + targetsToWrite, +} from './evaluate'; +import { + Plan, + PlanError, + alreadyConfiguredLine, + normalizePlan, + planEntriesOf, + planIdOf, + planShapeErrors, + summaryLine, +} from './plan'; +import { sortedCopy } from '../../deploy/dokployApi'; + +// ── Seams ──────────────────────────────────────────────────────────────────── + +export interface DeployDiscoverOpts { + /** The program-level `--dry-run`. */ + readonly dryRun?: boolean; + /** `--plan ` */ + readonly plan?: string; + /** `--confirm ` */ + readonly confirm?: string; + /** `--base-url ` */ + readonly baseUrl?: string; +} + +export type CapyReads = Pick; + +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 { + 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; + /** Progress, for stderr. Never stdout. */ + readonly progress: (line: string) => void; +} + +export interface DeployDiscoverResult { + readonly exitCode: number; + /** The one JSON document the command prints. */ + readonly body: Readonly>; +} + +/** 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 { + 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 }; + +const done = (value: T): Step => ({ ok: true, value }); +const stop = (result: DeployDiscoverResult): Step => ({ ok: false, result }); + +const codeOfError = (err: unknown): { readonly code: string; readonly message: string } => + err instanceof CapyError + ? { code: err.code, message: err.message } + : { code: ERROR_CODES.SERVICE_ERROR, message: 'The service request failed.' }; // COPY-FLAG + +// ── Settings ───────────────────────────────────────────────────────────────── + +/** What resolving the base URL leaves for the output: whether it was saved, and the notices. */ +interface BaseUrlExtras { + readonly saved?: { readonly base_url: true }; + readonly notices: readonly BaseUrlNotice[]; +} + +/** The shared resolver (`deploy/dokployBaseUrl.ts`): `--base-url`, then the system variable, then the folder's deploy.json, else a structured refusal. */ +async function resolveBaseUrl( + opts: DeployDiscoverOpts, + io: DeployDiscoverIo, + orgId: string, +): Promise> { + const openStore = io.openBaseUrlStore; + const resolved = await resolveDokployBaseUrl({ + flag: opts.baseUrl, + dryRun: opts.dryRun === true, + openStore: openStore === undefined ? undefined : () => openStore(orgId), + savedTargetUrl: io.savedBaseUrl, + }); + if (!resolved.ok) { + return stop(refuse(resolved.code, resolved.error, resolved.unanswered === undefined ? {} : { unanswered: resolved.unanswered })); + } + return done({ baseUrl: resolved.baseUrl, extras: { saved: resolved.saved, notices: resolved.notices } }); +} + +const extrasBody = (extras: BaseUrlExtras): Readonly> => ({ + ...(extras.saved === undefined ? {} : { saved: extras.saved }), + ...(extras.notices.length === 0 ? {} : { notices: extras.notices }), +}); + +// ── The world: Dokploy, Capy ───────────────────────────────────────────────── + +interface World { + readonly orgId: string; + readonly services: readonly ServiceFacts[]; + readonly links: readonly OrgRepoLink[]; + readonly projects: ReadonlyMap; +} + +async function dokployClient(io: DeployDiscoverIo, baseUrl: string, orgId: string): Promise> { + const resolved = await resolveDokployApiKey({ + tokenEnv: DEFAULT_TOKEN_ENV, + env: io.env, + // Never a prompt: a missing key is a coded refusal that says where to get it. + interactive: false, + orgId, + devMode: io.devMode, + storeName: DOKPLOY_CONNECTOR_SECRET_NAME, + deps: { getConnectorSecret: io.getConnectorSecret }, + }); + if (!resolved.ok) { + const { reason, hint } = describeDokployTokenProblem(resolved.code, DEFAULT_TOKEN_ENV, DOKPLOY_CONNECTOR_SECRET_NAME); + return stop(refuse(resolved.code, `${reason}.`, { hint, dashboard: baseUrl })); + } + return done(io.dokploy(baseUrl, resolved.value)); +} + +function dokployFailure(err: unknown): DeployDiscoverResult { + if (err instanceof DokployApiError) { + return err.code === 'unauthorized' + ? refuse('DOKPLOY_AUTH_FAILED', `Dokploy rejected the API token (HTTP ${err.status}).`) // COPY-FLAG + : refuse('DOKPLOY_API_ERROR', err.message); + } + return refuse('DOKPLOY_API_ERROR', 'The Dokploy request failed.'); // COPY-FLAG +} + +/** A Capy call that waits out a 429 (bounded) and reports a failure as its code. */ +async function capyCall(call: () => Promise): Promise> { + const answer = await callWithBackoff(() => call()); + if (answer.ok) return done(answer.value); + const { code, message } = codeOfError(answer.error); + return stop(refuse(code, message)); +} + +async function loadWorld( + io: DeployDiscoverIo, + client: CapyReads, + orgId: string, + dokploy: DiscoveryDokployClient, + only?: ReadonlySet, +): Promise> { + io.progress('Reading Dokploy services…'); // COPY-FLAG + const details = await fetchAllServiceDetails( + dokploy, + (readCount, total) => { + if (readCount === total || readCount % 10 === 0) io.progress(`Read ${readCount} of ${total} Dokploy services`); // COPY-FLAG + }, + only, + ).then( + (value) => ({ ok: true as const, value }), + (err: unknown) => ({ ok: false as const, err }), + ); + 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), + }); +} + +// ── GitHub reads ───────────────────────────────────────────────────────────── + +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 { + 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()}`; + +/** The default branch of each repo, or the code of why they could not be read. Keyed by `refKey`. */ +async function readDefaultBranches( + github: GithubApi, + repos: readonly RepoRef[], +): Promise<{ readonly ok: true; readonly branches: ReadonlyMap } | { readonly ok: false; readonly code: string }> { + if (repos.length === 0) return { ok: true, branches: new Map() }; + const answer = await github.getDefaultBranches(repos); + if (!answer.ok) return { ok: false, code: codeForApiFailure(answer, ERROR_CODES.KEEP_PR_BASE_UNRESOLVED) }; + return { ok: true, branches: new Map(repos.map((r, i): [string, string | null] => [refKey(r), answer.value[i] ?? null])) }; +} + +/** A deploy.json at `ref`: its targets, or the code of why it could not be read. */ +async function readDeployJson( + github: GithubApi, + repo: RepoRef, + path: string, + ref: string, +): Promise { + const read = await github.getFile(repo, path, ref); + if (!read.ok) return { code: codeForApiFailure(read, ERROR_CODES.KEEP_PR_READ_FAILED) }; + const parsed = parseDeployJson(read.value); + return parsed.ok ? parsed.targets : { code: ERROR_CODES.KEEP_PR_READ_FAILED }; +} + +const isFailure = (v: readonly ExistingTarget[] | ReadFailure): v is ReadFailure => !Array.isArray(v); + +// ── Discovery ──────────────────────────────────────────────────────────────── + +/** Default branches and deploy.json targets for the repos some Dokploy service tracks. */ +async function discoveryGithubFacts( + io: DeployDiscoverIo, + world: World, +): Promise<{ + readonly defaultBranches: Readonly>; + readonly existingTargets: Readonly>; + readonly notices: readonly Notice[]; +}> { + const serviceRepoKeys = new Set(world.services.flatMap((s) => (s.repo === null ? [] : [repoKey(s.repo)]))); + const links = world.links.filter((l) => serviceRepoKeys.has(repoKey(l))); + if (links.length === 0) return { defaultBranches: {}, existingTargets: {}, notices: [] }; + const github = io.github(); + if (github === undefined) { + return { defaultBranches: {}, existingTargets: {}, notices: [{ code: ERROR_CODES.KEEP_PR_GH_UNAVAILABLE }] }; + } + io.progress('Reading GitHub…'); // COPY-FLAG + const repos = uniqueSorted(links.map(repoKey)).map((k) => repoRefOf(links.find((l) => repoKey(l) === k) as OrgRepoLink)); + const defaults = await readDefaultBranches(github, repos); + if (!defaults.ok) return { defaultBranches: {}, existingTargets: {}, notices: [{ code: defaults.code }] }; + const defaultBranches = Object.fromEntries( + uniqueSorted(links.map(repoKey)).map((k) => [k, defaults.branches.get(refKey(repoRefOf(links.find((l) => repoKey(l) === k) as OrgRepoLink))) ?? null]), + ); + const fileKeys = uniqueSorted(links.map(fileKeyOf)); + const reads = await readEach(fileKeys, async (key) => { + const link = links.find((l) => fileKeyOf(l) === key) as OrgRepoLink; + const ref = defaultBranches[repoKey(link)]; + return { key, targets: ref === null ? ({ code: ERROR_CODES.KEEP_PR_BASE_UNRESOLVED } as ReadFailure) : await readDeployJson(github, repoRefOf(link), deployJsonPathOf(link.path), ref) }; + }); + const byFile = new Map(reads.map((r) => [r.key, r.targets] as const)); + // A project's targets are known only when EVERY one of its deploy.json files was read. + const projectIds = uniqueSorted(links.map((l) => l.project_id)); + const existingTargets = Object.fromEntries( + projectIds.flatMap((id): Array<[string, readonly ExistingTarget[]]> => { + const mine = links.filter((l) => l.project_id === id).map((l) => byFile.get(fileKeyOf(l))); + return mine.every((t) => t !== undefined && !isFailure(t)) + ? [[id, mine.flatMap((t) => (t === undefined || isFailure(t) ? [] : t))]] + : []; + }), + ); + const failures = uniqueSorted(reads.flatMap((r) => (isFailure(r.targets) ? [r.targets.code] : []))); + return { defaultBranches, existingTargets, notices: failures.map((code) => ({ code })) }; +} + +async function runDiscovery(opts: DeployDiscoverOpts, io: DeployDiscoverIo): Promise { + const ctx = await io.context(); + if (!ctx.ok) return refuse(ctx.code, ctx.message); + const base = await resolveBaseUrl(opts, io, ctx.orgId); + if (!base.ok) return base.result; + const { baseUrl, extras } = base.value; + const dokploy = await dokployClient(io, baseUrl, ctx.orgId); + if (!dokploy.ok) return dokploy.result; + const world = await loadWorld(io, ctx.client, ctx.orgId, dokploy.value); + if (!world.ok) return world.result; + const github = await discoveryGithubFacts(io, world.value); + const input: DiscoveryInput = { + baseUrl, + services: world.value.services, + projects: world.value.projects, + defaultBranches: github.defaultBranches, + existingTargets: github.existingTargets, + notices: [...extras.notices, ...github.notices], + }; + const discovery: DeployDiscovery = buildDeployDiscovery(input); + return { exitCode: 0, body: { ok: true, ...(extras.saved === undefined ? {} : { saved: extras.saved }), ...discovery } }; +} + +// ── The plan ───────────────────────────────────────────────────────────────── + +interface ParsedPlan { + readonly entries: readonly PlanEntry[]; + readonly plan: Plan; +} + +function readPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo): Step { + const text = (() => { + try { + return { ok: true as const, text: io.readFile(opts.plan as string) }; + } catch { + return { ok: false as const }; + } + })(); + if (!text.ok) return stop(refuse(ERROR_CODES.PLAN_FILE_UNREADABLE, 'The plan file could not be read.')); // COPY-FLAG + const raw = (() => { + try { + return { ok: true as const, value: JSON.parse(text.text) as unknown }; + } catch { + return { ok: false as const }; + } + })(); + const errors: readonly PlanError[] = raw.ok ? planShapeErrors(raw.value) : [{ path: '', code: ERROR_CODES.INVALID_FORMAT }]; + if (!raw.ok || errors.length > 0) return stop(invalidPlan(errors)); + const entries = planEntriesOf(raw.value); + const plan = normalizePlan(entries); + return done({ entries, plan }); +} + +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 }> { + const github = io.github(); + const unavailable: ReadFailure = { code: ERROR_CODES.KEEP_PR_GH_UNAVAILABLE }; + if (github === undefined || needs.repos.length === 0) { + return { + branches: new Map(needs.repos.map((r): [string, ReadFailure] => [r.key, unavailable])), + deployJson: new Map(needs.files.map((f): [string, ReadFailure] => [f.key, unavailable])), + defaultBranches: new Map(), + }; + } + io.progress('Reading GitHub…'); // COPY-FLAG + const defaults = await readDefaultBranches(github, needs.repos.map((r) => r.repo)); + const defaultBranches = defaults.ok ? defaults.branches : new Map(); + const branchReads = await readEach(needs.repos, async ({ repo, key }) => { + const listed = await github.listBranches(repo); + return [key, listed.ok ? (new Set(listed.value) as ReadonlySet) : { code: codeForApiFailure(listed, ERROR_CODES.KEEP_PR_READ_FAILED) }] as const; + }); + const fileReads = await readEach(needs.files, async (file) => { + const ref = defaults.ok ? defaultBranches.get(refKey(file.repo)) : undefined; + const targets: readonly ExistingTarget[] | ReadFailure = + ref === undefined || ref === null + ? { code: defaults.ok ? ERROR_CODES.KEEP_PR_BASE_UNRESOLVED : defaults.code } + : await readDeployJson(github, file.repo, file.path, ref); + return [file.key, targets] as const; + }); + return { branches: new Map(branchReads), deployJson: new Map(fileReads), defaultBranches }; +} + +function summaryOf(evaluated: readonly EvaluatedEntry[]): readonly string[] { + const sorted = sortedCopy( + evaluated, + (a, b) => + byText(a.resolved.entry.project_id, b.resolved.entry.project_id) || + byText(a.resolved.entry.branch, b.resolved.entry.branch) || + byText(a.resolved.entry.service_id, b.resolved.entry.service_id), + ); + return sorted.map((e) => { + const { project, service, entry } = e.resolved; + 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 }); + }); +} + +async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: ParsedPlan): Promise { + const { entries, plan } = parsed; + const confirming = opts.confirm !== undefined && opts.dryRun !== true; + + const ctx = await io.context(); + if (!ctx.ok) return refuse(ctx.code, ctx.message); + const base = await resolveBaseUrl(opts, io, ctx.orgId); + if (!base.ok) return base.result; + const { baseUrl, extras } = base.value; + // The id covers the RESOLVED URL, so a dry run and its confirm agree wherever the URL came from. + const planId = planIdOf(plan, baseUrl); + // 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 dokploy = await dokployClient(io, baseUrl, ctx.orgId); + if (!dokploy.ok) return dokploy.result; + 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); + const needs = githubNeeds(resolved); + const reads = await planGithubReads(io, needs); + const evaluation = evaluatePlan(resolved, reads); + if (evaluation.errors.length > 0) return invalidPlan(evaluation.errors, { plan_id: planId }); + + const toWrite = targetsToWrite(evaluation.entries, reads, baseUrl); + const configured = evaluation.entries + .filter((e) => e.status === 'already_configured') + .map((e) => ({ + project_id: e.resolved.entry.project_id, + branch: e.resolved.entry.branch, + service_id: e.resolved.entry.service_id, + target_name: e.existingTargetName ?? '', + })); + const summary = summaryOf(evaluation.entries); + const prPreview = uniqueSorted(toWrite.map((t) => t.repoKey)).map((k) => { + const mine = toWrite.filter((t) => t.repoKey === k); + return { + repo: `${mine[0].repo.owner}/${mine[0].repo.name}`, + base: reads.defaultBranches.get(refKey(mine[0].repo)) ?? null, + files: uniqueSorted(mine.map((t) => t.file)), + targets: mine.map((t) => t.target.name), + }; + }); + const dryBody = { plan, plan_id: planId, summary_lines: summary, already_configured: configured, prs: prPreview, ...extrasBody(extras) }; + + if (!confirming) { + if (opts.dryRun === true) { + return { + exitCode: 0, + body: { + ok: true, + dry_run: true, + ...dryBody, + next_steps: [ + { action: 'relay', field: 'summary_lines' }, + { action: 'get_approval' }, + { run: `capy deploy dokploy --discover --plan ${opts.plan} --confirm ${planId}` }, + ], + }, + }; + } + return refuse( + ERROR_CODES.PLAN_CONFIRM_REQUIRED, + `A real run needs --confirm ${planId}. Run with --dry-run to see the plan.`, // COPY-FLAG + { ...dryBody, unanswered: [{ id: 'confirm', flag: '--confirm', value: planId }] }, + ); + } + + const applied = await applyTargets(toWrite, { github: io.github(), branchName: io.branchName }); + const failed = applied.failed.length > 0; + return { + exitCode: failed ? 1 : 0, + body: { + ok: !failed, + ...(failed ? { code: ERROR_CODES.DISCOVER_PARTIAL } : {}), + plan_id: planId, + summary_lines: summary, + prs: applied.prs, + failed: applied.failed, + already_configured: configured, + ...extrasBody(extras), + }, + }; +} + +// ── The command ────────────────────────────────────────────────────────────── + +/** Runs the command and returns what it prints. Never prints, never exits, never prompts. */ +export async function runDeployDokployDiscover(opts: DeployDiscoverOpts, io: DeployDiscoverIo): Promise { + if (opts.confirm !== undefined && opts.plan === undefined) { + return refuse(ERROR_CODES.PLAN_REQUIRED, '--confirm needs --plan .'); // COPY-FLAG + } + if (opts.plan === undefined) return runDiscovery(opts, io); + const parsed = readPlan(opts, io); + if (!parsed.ok) return parsed.result; + return runPlan(opts, io, parsed.value); +} diff --git a/src/commands/deployDiscover/discovery.ts b/src/commands/deployDiscover/discovery.ts new file mode 100644 index 00000000..c769db15 --- /dev/null +++ b/src/commands/deployDiscover/discovery.ts @@ -0,0 +1,450 @@ +/** + * `capy deploy dokploy --discover`: the discovery document (CAP-703). + * + * The CLI gathers FACTS and CHECKS; an agent applies judgement; the human + * approves. This module is the facts-and-checks half, as a pure function: it + * is handed everything already read (the Dokploy services, the Capy repo + * links and secret NAME index, GitHub's default branches and the repos' + * `.capy/deploy.json` files) and reads nothing itself, so a run makes one + * round of calls and the matching can be tested without any network. + * + * What it matches: repo (host/owner/name) + REPO-RELATIVE path + branch, on + * both sides. A local filesystem path appears nowhere. + * + * Names and ids only. A Dokploy env value or a Capy value never reaches this + * module: the inputs it is given carry names (`ServiceFacts.env_var_names`, + * `CapyProjectFacts.var_names_by_branch`) and nothing else. + * + * Confidence is per field: `certain` or `needs_review`. Anything weak is + * `needs_review`: no exact repo + path match, a var overlap under half of the + * service's variables, a branch that needed a fallback, several candidates. + */ +import { createHash } from 'crypto'; +import type { OrgRepoLink } from '../../service/serviceClient'; +import { sortedCopy } from '../../deploy/dokployApi'; +import { PLAN_SCHEMA_REF } from './schema'; +import { + CapyProjectFacts, + ExistingTarget, + RepoOut, + ServiceFacts, + bestLink, + byText, + configuredTarget, + conflictingTarget, + isSameOrAncestorPath, + linksOnRepo, + relPathOf, + repoKey, + uniqueSorted, +} from './facts'; + +/** A variable overlap below this share of the service's own variables is `needs_review`. */ +export const VAR_OVERLAP_MIN_RATIO = 0.5; + +export type Confidence = 'certain' | 'needs_review'; + +export type UnresolvedReason = + | 'NO_MATCHING_PROJECT' + | 'AMBIGUOUS' + | 'BRANCH_UNMAPPED' + | 'NO_REPO_LINK' + | 'SERVICE_HAS_NO_GIT_SOURCE'; + +/** One entry of the plan file (`--plan`). IDs and names only. */ +export interface PlanEntry { + readonly project_id: string; + readonly branch: string; + readonly service_id: string; + readonly git_branch: string; + readonly vars: readonly string[]; +} + +export interface ProposalTarget { + readonly kind: 'dokploy'; + readonly baseUrl: string; + readonly applicationId?: string; + readonly composeId?: string; + /** The Capy branch the target ships from. */ + readonly branch: string; + readonly gitBaseBranch: string; + readonly mode: 'ci'; + readonly vars: readonly string[]; +} + +export interface VarOverlap { + readonly shared: number; + readonly only_in_service: readonly string[]; + readonly only_in_project: readonly string[]; +} + +export interface Proposal { + readonly proposal_id: string; + readonly service_id: string; + readonly project_id: string; + /** The Capy branch. */ + readonly branch: string; + readonly git_branch: string; + readonly target: ProposalTarget; + readonly evidence: { + readonly repo_match: boolean; + readonly path_match: boolean; + readonly branch_match: boolean; + 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; + }; + readonly confidence: { + readonly project: Confidence; + readonly branch: Confidence; + readonly vars: Confidence; + }; + /** Other proposals that claim the same project + Capy branch. */ + readonly competing_proposal_ids: readonly string[]; + /** The exact plan entry to copy into the plan file if this proposal is accepted as it stands. */ + readonly plan_entry: PlanEntry; +} + +export interface UnresolvedCandidate { + readonly project_id?: string; + readonly project_name?: string; + readonly path?: string; + readonly branch?: string; +} + +export interface Unresolved { + readonly reason: UnresolvedReason; + readonly service_id?: string; + readonly project_id?: string; + readonly candidates: readonly UnresolvedCandidate[]; +} + +export interface AlreadyConfigured { + readonly project_id: string; + readonly branch: string; + readonly service_id: string; + readonly target_name: string; +} + +export interface ProjectOut { + readonly project_id: string; + readonly project_name: string; + readonly repos: ReadonlyArray; + readonly branches: readonly string[]; + readonly var_names_by_branch: Readonly>; + readonly existing_targets: readonly ExistingTarget[]; + /** `false`: the repo's deploy.json could not be read (no `gh`, or the read failed), so `existing_targets` is not known. */ + readonly existing_targets_read: boolean; +} + +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 run: string }; + +export interface Notice { + readonly code: string; + readonly detail?: string; +} + +export interface DeployDiscovery { + readonly base_url: string; + 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 notices: readonly Notice[]; +} + +/** Everything discovery reads, already read. */ +export interface DiscoveryInput { + readonly baseUrl: string; + readonly services: readonly ServiceFacts[]; + readonly projects: ReadonlyMap; + /** By `repoKey`: the repo's default branch. Absent / `null`: not known. */ + readonly defaultBranches: Readonly>; + /** By project id: the targets in the project's deploy.json files. Absent: not read. */ + readonly existingTargets: Readonly>; + readonly notices?: readonly Notice[]; +} + +// ── Matching ───────────────────────────────────────────────────────────────── + +interface Matched { + readonly projects: readonly CapyProjectFacts[]; + /** True when the match is the exact folder; false when a folder ABOVE the service's. */ + readonly exactPath: boolean; +} + +type Match = { readonly kind: 'matched'; readonly matched: Matched } | { readonly kind: 'unresolved'; readonly unresolved: Unresolved }; + +const candidateOf = (link: OrgRepoLink): UnresolvedCandidate => ({ + project_id: link.project_id, + project_name: link.project_name, + path: relPathOf(link.path), +}); + +function projectsOfLinks(links: readonly OrgRepoLink[], all: ReadonlyMap): readonly CapyProjectFacts[] { + return uniqueSorted(links.map((l) => l.project_id)).flatMap((id) => { + const project = all.get(id); + return project === undefined ? [] : [project]; + }); +} + +/** Which Capy projects a service's repo + path point at. */ +function matchService(service: ServiceFacts, links: readonly OrgRepoLink[], all: ReadonlyMap): Match { + if (service.repo === null || service.path === null) { + return { kind: 'unresolved', unresolved: { reason: 'SERVICE_HAS_NO_GIT_SOURCE', service_id: service.service_id, candidates: [] } }; + } + const servicePath = service.path; + const key = repoKey(service.repo); + const onRepo = links.filter((l) => repoKey(l) === key); + if (onRepo.length === 0) { + return { kind: 'unresolved', unresolved: { reason: 'NO_REPO_LINK', service_id: service.service_id, candidates: [] } }; + } + const exact = onRepo.filter((l) => relPathOf(l.path) === servicePath); + if (exact.length > 0) return { kind: 'matched', matched: { projects: projectsOfLinks(exact, all), exactPath: true } }; + const above = onRepo.filter((l) => isSameOrAncestorPath(relPathOf(l.path), servicePath)); + const deepest = Math.max(0, ...above.map((l) => relPathOf(l.path).length)); + const nearest = above.filter((l) => relPathOf(l.path).length === deepest); + if (nearest.length > 0) return { kind: 'matched', matched: { projects: projectsOfLinks(nearest, all), exactPath: false } }; + return { + kind: 'unresolved', + unresolved: { reason: 'NO_MATCHING_PROJECT', service_id: service.service_id, candidates: onRepo.map(candidateOf) }, + }; +} + +// ── Branch mapping ─────────────────────────────────────────────────────────── + +interface BranchChoice { + readonly branch: string; + /** The standard mapping (the repo's default branch is `production`, any other branch its own name). */ + readonly standard: boolean; +} + +/** + * The Capy branch for a git branch. The repo's default branch maps to + * `production`, any other branch to the same-named Capy branch. When the + * project has no such branch: the git branch itself (for a default branch), + * then the Dokploy environment's name (which is what `capy connect dokploy + * --discover` names its branches) - both `needs_review`. + * + * The Dokploy environment outranks the standard mapping when the two name + * DIFFERENT Capy branches: a repo whose default branch is `develop` deploys + * `develop` to a `staging` environment, and calling that `production` would + * point a staging service at production values. That choice is `needs_review`. + */ +function chooseCapyBranch( + gitBranch: string | null, + defaultBranch: string | null | undefined, + environment: string, + projectBranches: readonly string[], +): BranchChoice | null { + const has = (b: string): boolean => projectBranches.includes(b); + const standardName = gitBranch === null ? null : defaultBranch !== null && defaultBranch !== undefined && gitBranch === defaultBranch ? 'production' : gitBranch; + const standardHas = standardName !== null && has(standardName); + if (standardHas && has(environment) && standardName !== environment) return { branch: environment, standard: false }; + if (standardHas) return { branch: standardName, standard: true }; + const fallbacks = [...(gitBranch === null ? [] : [gitBranch]), environment].filter(has); + return fallbacks.length > 0 ? { branch: fallbacks[0], standard: false } : null; +} + +// ── Variables ──────────────────────────────────────────────────────────────── + +function overlapOf(serviceNames: readonly string[], projectNames: readonly string[]): VarOverlap { + const inProject = new Set(projectNames); + const inService = new Set(serviceNames); + return { + shared: serviceNames.filter((n) => inProject.has(n)).length, + only_in_service: serviceNames.filter((n) => !inProject.has(n)), + only_in_project: projectNames.filter((n) => !inService.has(n)), + }; +} + +const sharedNames = (serviceNames: readonly string[], projectNames: readonly string[]): readonly string[] => { + const inProject = new Set(projectNames); + return serviceNames.filter((n) => inProject.has(n)); +}; + +const varsConfidence = (overlap: VarOverlap, serviceVarCount: number): Confidence => + overlap.shared > 0 && overlap.shared / Math.max(1, serviceVarCount) >= VAR_OVERLAP_MIN_RATIO ? 'certain' : 'needs_review'; + +// ── Proposals ──────────────────────────────────────────────────────────────── + +const proposalIdOf = (serviceId: string, projectId: string, branch: string): string => + `prop_${createHash('sha256').update([serviceId, projectId, branch].join('\u0000')).digest('hex').slice(0, 10)}`; + +function targetOf(baseUrl: string, service: ServiceFacts, branch: string, gitBranch: string, vars: readonly string[]): ProposalTarget { + return { + kind: 'dokploy', + baseUrl, + ...(service.kind === 'compose' ? { composeId: service.service_id } : { applicationId: service.service_id }), + branch, + gitBaseBranch: gitBranch, + mode: 'ci', + vars, + }; +} + +/** A proposal before the competing ones are known. */ +type Draft = Omit; + +type ForProject = + | { readonly kind: 'proposal'; readonly draft: Draft } + | { readonly kind: 'configured'; readonly configured: AlreadyConfigured } + | { readonly kind: 'unresolved'; readonly unresolved: Unresolved }; + +function forProject( + input: DiscoveryInput, + service: ServiceFacts, + project: CapyProjectFacts, + matched: Matched, +): ForProject { + const repo = service.repo as RepoOut; + const defaultBranch = input.defaultBranches[repoKey(repo)]; + const choice = chooseCapyBranch(service.branch, defaultBranch, service.environment, project.branches); + if (choice === null || service.branch === null) { + return { + kind: 'unresolved', + unresolved: { + reason: 'BRANCH_UNMAPPED', + service_id: service.service_id, + project_id: project.project_id, + candidates: project.branches.map((branch) => ({ project_id: project.project_id, branch })), + }, + }; + } + const gitBranch = service.branch; + const existing = configuredTarget(input.existingTargets[project.project_id] ?? [], service.service_id, 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 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 draft: Draft = { + proposal_id: proposalIdOf(service.service_id, project.project_id, choice.branch), + service_id: service.service_id, + project_id: project.project_id, + branch: choice.branch, + git_branch: gitBranch, + target: targetOf(input.baseUrl, service, choice.branch, gitBranch, vars), + evidence: { + repo_match: true, + path_match: matched.exactPath, + branch_match: choice.standard, + var_overlap: overlap, + conflicting_target: conflict?.name ?? null, + }, + confidence: { + project: matched.exactPath && matched.projects.length === 1 && conflict === undefined ? 'certain' : 'needs_review', + branch: 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 }, + }; + return { kind: 'proposal', draft }; +} + +interface ServiceResult { + readonly drafts: readonly Draft[]; + readonly configured: readonly AlreadyConfigured[]; + readonly unresolved: readonly Unresolved[]; +} + +function resolveService(input: DiscoveryInput, service: ServiceFacts, links: readonly OrgRepoLink[]): 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 ambiguous: readonly Unresolved[] = + matched.projects.length > 1 + ? [ + { + reason: 'AMBIGUOUS', + service_id: service.service_id, + candidates: matched.projects.map((p) => ({ + project_id: p.project_id, + project_name: p.project_name, + path: relPathOf(bestLink(linksOnRepo(p, service.repo as RepoOut), service.path as string)?.path), + })), + }, + ] + : []; + return { + drafts: perProject.flatMap((r) => (r.kind === 'proposal' ? [r.draft] : [])), + configured: perProject.flatMap((r) => (r.kind === 'configured' ? [r.configured] : [])), + unresolved: [...ambiguous, ...perProject.flatMap((r) => (r.kind === 'unresolved' ? [r.unresolved] : []))], + }; +} + +/** A proposal's final form: the other proposals on the same project + branch make its project assignment `needs_review`. */ +function withCompeting(drafts: readonly Draft[]): readonly Proposal[] { + return drafts.map((draft): Proposal => { + const competing = drafts + .filter((d) => d.project_id === draft.project_id && d.branch === draft.branch && d.proposal_id !== draft.proposal_id) + .map((d) => d.proposal_id); + return { + ...draft, + competing_proposal_ids: competing, + confidence: { ...draft.confidence, project: competing.length > 0 ? 'needs_review' : draft.confidence.project }, + }; + }); +} + +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[] { + 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' }, + ]; +} + +function projectOut(project: CapyProjectFacts, input: DiscoveryInput): ProjectOut { + const existing = input.existingTargets[project.project_id]; + return { + project_id: project.project_id, + project_name: project.project_name, + repos: project.links.map((l) => ({ host: l.host, owner: l.owner, name: l.name, path: relPathOf(l.path) })), + branches: project.branches, + var_names_by_branch: project.var_names_by_branch, + existing_targets: existing ?? [], + existing_targets_read: existing !== undefined, + }; +} + +/** The whole discovery document. Pure. */ +export function buildDeployDiscovery(input: DiscoveryInput): DeployDiscovery { + const links = [...input.projects.values()].flatMap((p) => p.links); + const results = input.services.map((service) => resolveService(input, service, links)); + const proposals = withCompeting(results.flatMap((r) => r.drafts)); + const unresolved = results.flatMap((r) => r.unresolved); + return { + base_url: input.baseUrl, + services: input.services, + projects: sortedCopy( + [...input.projects.values()].filter((p) => p.links.length > 0).map((p) => projectOut(p, input)), + (a, b) => byText(a.project_name, b.project_name) || byText(a.project_id, b.project_id), + ), + proposals, + already_configured: results.flatMap((r) => r.configured), + unresolved, + next_steps: nextStepsOf(proposals, unresolved), + plan_schema_ref: PLAN_SCHEMA_REF, + notices: input.notices ?? [], + }; +} diff --git a/src/commands/deployDiscover/evaluate.ts b/src/commands/deployDiscover/evaluate.ts new file mode 100644 index 00000000..51158da7 --- /dev/null +++ b/src/commands/deployDiscover/evaluate.ts @@ -0,0 +1,296 @@ +/** + * Checking a plan against the world (CAP-703): `capy deploy dokploy --discover + * --plan --dry-run` and the same checks again before `--confirm` writes. + * + * Two pure steps around the reads: + * 1. `resolveEntries` - everything knowable from the Dokploy services and + * the Capy projects: the service exists, the project exists and the + * caller can see it, the Capy branch holds every named variable, the + * project is linked to the service's repo, nothing is assigned twice. + * 2. `evaluatePlan` - with GitHub read (the repo's branches, each + * deploy.json): the git branch exists, and the project branch has no + * conflicting Dokploy target. + * + * Every error is `{ path, code }`, path-precise. A project the caller cannot + * see is `PROJECT_NOT_FOUND`: the repo-link and secret-index endpoints only + * list what the caller may read, so existence and access are one question. + */ +import type { OrgRepoLink } from '../../service/serviceClient'; +import { ERROR_CODES } from '../../types/index'; +import type { TargetConfig } from '../../deploy/adapter'; +import { sortedCopy } from '../../deploy/dokployApi'; +import type { RepoRef } from '../../deploy/githubApi'; +import { GITHUB_HOST } from '../secretsSet'; +import { PlanEntry } from './discovery'; +import { + CapyProjectFacts, + ExistingTarget, + ServiceFacts, + bestLink, + byText, + configuredTarget, + conflictingTarget, + deployJsonPathOf, + linksOnRepo, + repoKey, + uniqueSorted, +} from './facts'; +import { PlanError } from './plan'; + +export interface ResolvedEntry { + readonly index: number; + /** As written in the plan file (vars in file order). */ + readonly entry: PlanEntry; + readonly service?: ServiceFacts; + readonly project?: CapyProjectFacts; + /** The project link whose repo the deploy.json PR goes to. */ + readonly link?: OrgRepoLink; + readonly errors: readonly PlanError[]; +} + +const at = (index: number, field?: string): string => (field === undefined ? `entries[${index}]` : `entries[${index}].${field}`); +const err = (path: string, code: string): PlanError => ({ path, code }); + +/** The repo a link names, as a GitHub ref. */ +export const repoRefOf = (link: { readonly owner: string; readonly name: string }): RepoRef => ({ owner: link.owner, name: link.name }); + +function resolveEntry( + entry: PlanEntry, + index: number, + services: ReadonlyMap, + projects: ReadonlyMap, +): ResolvedEntry { + const service = services.get(entry.service_id); + const project = projects.get(entry.project_id); + const serviceErrors = service === undefined ? [err(at(index, 'service_id'), ERROR_CODES.SERVICE_NOT_FOUND)] : []; + const projectErrors = project === undefined ? [err(at(index, 'project_id'), ERROR_CODES.PROJECT_NOT_FOUND)] : []; + const branchNames = project?.var_names_by_branch[entry.branch]; + const branchErrors = + project !== undefined && branchNames === undefined ? [err(at(index, 'branch'), ERROR_CODES.BRANCH_NOT_FOUND)] : []; + const varErrors = + branchNames === undefined + ? [] + : entry.vars.flatMap((v, j) => (branchNames.includes(v) ? [] : [err(`${at(index, 'vars')}[${j}]`, ERROR_CODES.VAR_NOT_FOUND)])); + const githubLinks = project?.links.filter((l) => l.host.toLowerCase() === GITHUB_HOST) ?? []; + const noLink = project !== undefined && githubLinks.length === 0 ? [err(at(index, 'project_id'), ERROR_CODES.NO_REPO_LINK)] : []; + const onServiceRepo = + project !== undefined && service?.repo != null && githubLinks.length > 0 + ? linksOnRepo({ ...project, links: githubLinks }, service.repo) + : []; + const mismatch = + project !== undefined && service !== undefined && githubLinks.length > 0 && onServiceRepo.length === 0 + ? [err(at(index, 'service_id'), ERROR_CODES.REPO_MISMATCH)] + : []; + return { + index, + entry, + service, + project, + link: service?.path != null ? bestLink(onServiceRepo, service.path) : undefined, + errors: [...serviceErrors, ...projectErrors, ...branchErrors, ...varErrors, ...noLink, ...mismatch], + }; +} + +/** Step 1: what the Dokploy services and Capy projects alone settle. */ +export function resolveEntries( + entries: readonly PlanEntry[], + services: ReadonlyMap, + projects: ReadonlyMap, +): readonly ResolvedEntry[] { + 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); + return duplicate ? { ...resolved, errors: [...resolved.errors, err(at(index), ERROR_CODES.DUPLICATE_ENTRY)] } : resolved; + }); +} + +// ── GitHub reads ───────────────────────────────────────────────────────────── + +export const fileKeyOf = (link: { readonly host: string; readonly owner: string; readonly name: string; readonly path: string }): string => + `${repoKey(link)}\u0000${deployJsonPathOf(link.path)}`; + +/** What a plan needs read from GitHub: each repo's branches once, each deploy.json once. */ +export function githubNeeds(resolved: readonly ResolvedEntry[]): { + /** Each repo once, with the `repoKey` the reads are filed under. */ + readonly repos: ReadonlyArray<{ readonly repo: RepoRef; readonly key: string }>; + readonly files: ReadonlyArray<{ readonly repo: RepoRef; readonly path: string; readonly key: string }>; +} { + const links = resolved.flatMap((r) => (r.link === undefined ? [] : [r.link])); + const repoKeys = uniqueSorted(links.map(repoKey)); + const fileKeys = uniqueSorted(links.map(fileKeyOf)); + return { + repos: repoKeys.map((k) => ({ repo: repoRefOf(links.find((l) => repoKey(l) === k) as OrgRepoLink), key: k })), + files: fileKeys.map((key) => { + const link = links.find((l) => fileKeyOf(l) === key) as OrgRepoLink; + return { repo: repoRefOf(link), path: deployJsonPathOf(link.path), key }; + }), + }; +} + +export type ReadFailure = { readonly code: string }; + +export interface GithubReads { + /** By `repoKey`: the repo's branch names, or why they could not be read. */ + readonly branches: ReadonlyMap | ReadFailure>; + /** By `fileKeyOf`: the targets already in that deploy.json, or why it could not be read. */ + readonly deployJson: ReadonlyMap; +} + +const isFailure = (value: unknown): value is ReadFailure => + typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Set) && 'code' in value; + +// ── Evaluation ─────────────────────────────────────────────────────────────── + +export interface EvaluatedEntry { + readonly resolved: ResolvedEntry & { readonly service: ServiceFacts; readonly project: CapyProjectFacts; readonly link: OrgRepoLink }; + /** `already_configured`: the project branch already has this service's target; nothing to write. */ + readonly status: 'write' | 'already_configured'; + readonly existingTargetName?: string; +} + +export interface Evaluation { + readonly errors: readonly PlanError[]; + readonly entries: readonly EvaluatedEntry[]; +} + +function githubErrors(r: ResolvedEntry, reads: GithubReads): readonly PlanError[] { + if (r.link === undefined) return []; + const branches = reads.branches.get(repoKey(r.link)); + const branchErrors: readonly PlanError[] = + branches === undefined + ? [err(at(r.index, 'git_branch'), ERROR_CODES.KEEP_PR_READ_FAILED)] + : isFailure(branches) + ? [err(at(r.index, 'git_branch'), branches.code)] + : branches.has(r.entry.git_branch) + ? [] + : [err(at(r.index, 'git_branch'), ERROR_CODES.BRANCH_NOT_FOUND)]; + const targets = reads.deployJson.get(fileKeyOf(r.link)); + const targetErrors: readonly PlanError[] = + targets === undefined + ? [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 + ? [err(at(r.index, 'branch'), ERROR_CODES.TARGET_EXISTS)] + : []; + return [...branchErrors, ...targetErrors]; +} + +/** 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)]); + 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); + return [ + { + resolved: { ...r, service: r.service, project: r.project, link: r.link }, + status: existing === undefined ? 'write' : 'already_configured', + ...(existing === undefined ? {} : { existingTargetName: existing.name }), + }, + ]; + }); + return { errors, entries }; +} + +// ── The targets to write ───────────────────────────────────────────────────── + +/** `dokploy-`, restricted to the characters a target name allows. */ +export function targetBaseName(branch: string): string { + const cleaned = branch + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + return cleaned === '' ? 'dokploy' : `dokploy-${cleaned}`; +} + +/** `base`, or `base-2`, `base-3`, … when `taken` already has it. */ +export function uniqueName(base: string, taken: ReadonlySet): string { + const numbered = (n: number): string => (taken.has(`${base}-${n}`) ? numbered(n + 1) : `${base}-${n}`); + return taken.has(base) ? numbered(2) : base; +} + +/** The deploy target `capy deploy` writes today for a Dokploy service: CI mode, no tokenEnv (the org system store holds the key). */ +export function dokployTargetOf(args: { + readonly name: string; + readonly entry: PlanEntry; + readonly service: ServiceFacts; + readonly baseUrl: string; + readonly knownVars: readonly string[]; +}): TargetConfig { + const { name, entry, service, baseUrl, knownVars } = args; + return { + name, + kind: 'dokploy', + branch: entry.branch, + vars: [...uniqueSorted(entry.vars)], + knownVars: [...knownVars], + options: { baseUrl, ...(service.kind === 'compose' ? { composeId: service.service_id } : { applicationId: service.service_id }) }, + mode: 'ci', + gitBaseBranch: entry.git_branch, + }; +} + +export interface TargetToWrite { + readonly repo: RepoRef; + readonly repoKey: string; + /** Repo-relative path of the deploy.json. */ + readonly file: string; + readonly fileKey: string; + readonly projectName: string; + readonly entry: PlanEntry; + readonly service: ServiceFacts; + readonly target: TargetConfig; +} + +/** + * Every target a confirmed plan writes, named. Entries are taken in the + * normalized order (project, branch, service) so the names never depend on how + * the plan file was ordered; a name already in that deploy.json, or taken by an + * earlier target of the same file, gets a numeric suffix. + */ +export function targetsToWrite( + evaluated: readonly EvaluatedEntry[], + reads: GithubReads, + baseUrl: string, +): readonly TargetToWrite[] { + const pending = sortedCopy( + evaluated.filter((e) => e.status === 'write'), + (a, b) => + byText(a.resolved.entry.project_id, b.resolved.entry.project_id) || + byText(a.resolved.entry.branch, b.resolved.entry.branch) || + byText(a.resolved.entry.service_id, b.resolved.entry.service_id), + ); + return pending.reduce((acc, e) => { + const { entry, service, project, link } = e.resolved; + const key = fileKeyOf(link); + const existingNames = (() => { + const read = reads.deployJson.get(key); + 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] ?? [], + }); + return [ + ...acc, + { + repo: repoRefOf(link), + repoKey: repoKey(link), + file: deployJsonPathOf(link.path), + fileKey: key, + projectName: project.project_name, + entry, + service, + target, + }, + ]; + }, []); +} diff --git a/src/commands/deployDiscover/facts.ts b/src/commands/deployDiscover/facts.ts new file mode 100644 index 00000000..775e080f --- /dev/null +++ b/src/commands/deployDiscover/facts.ts @@ -0,0 +1,214 @@ +/** + * The facts `capy deploy dokploy --discover` (CAP-703) reasons about, as plain + * data: what a Dokploy service tracks, what a Capy project is linked to, and + * what a repo's `.capy/deploy.json` already holds. + * + * Names and ids only. Nothing in here ever holds a variable VALUE: the only + * thing read out of a Dokploy env is each entry's NAME (`envVarNamesOf`), and + * the Capy side is the secret NAME index, which carries no value at all. + * + * Matching is repo-based and folder-agnostic: a repo (host/owner/name), a + * REPO-RELATIVE path (`.` for the root) and a branch, on both sides. A local + * filesystem path appears nowhere. + */ +import { posix } from 'node:path'; +import type { OrgRepoLink, SecretIndexRow } from '../../service/serviceClient'; +import { listImportableEntries, sortedCopy } from '../../deploy/dokployApi'; +import { ServiceDetail, hasGitSource, normalizeRelativeDir } from '../connectors/dokployDiscovery'; +import { GITHUB_HOST, repoKey } from '../secretsSet'; + +export { repoKey }; + +export const byText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +export const uniqueSorted = (items: readonly string[]): readonly string[] => sortedCopy([...new Set(items)], byText); + +// ── Paths ─────────────────────────────────────────────────────────────────── + +/** A repo-relative directory, forward-slashed, `.` for the repo root. */ +export function relPathOf(raw: string | undefined | null): string { + const normalized = normalizeRelativeDir(raw ?? ''); + return normalized === '' ? '.' : normalized; +} + +/** `/.capy/deploy.json`, repo-relative (`.capy/deploy.json` at the root). */ +export function deployJsonPathOf(projectPath: string): string { + const rel = relPathOf(projectPath); + return rel === '.' ? '.capy/deploy.json' : `${rel}/.capy/deploy.json`; +} + +/** Is `ancestor` the same folder as `path`, or a folder above it? `.` is above everything. */ +export function isSameOrAncestorPath(ancestor: string, path: string): boolean { + return ancestor === '.' || ancestor === path || path.startsWith(`${ancestor}/`); +} + +/** The repo-relative build/compose directory of a Dokploy service. A compose service's is its compose file's folder. */ +export function servicePathOf(detail: Pick): string { + if (detail.serviceKind === 'application') return relPathOf(detail.buildPath); + const composePath = normalizeRelativeDir(detail.composePath ?? ''); + return relPathOf(posix.dirname(composePath === '' ? '.' : composePath)); +} + +// ── Dokploy side ──────────────────────────────────────────────────────────── + +export interface RepoOut { + readonly host: string; + readonly owner: string; + readonly name: string; +} + +export interface ServiceFacts { + readonly service_id: string; + readonly kind: 'application' | 'compose'; + readonly name: string; + readonly dokploy_project: string; + readonly environment: string; + readonly repo: RepoOut | null; + readonly branch: string | null; + /** Repo-relative build/compose directory, `.` for the root. `null` for a service with no git source. */ + readonly path: string | null; + /** Names only: never a value. */ + readonly env_var_names: readonly string[]; +} + +/** 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)); +} + +/** + * What a Dokploy service tracks. A service with a git source is reported on + * `github.com`: the Dokploy fields read for it are the GitHub-provider ones + * (`owner`, `repository`, `branch`), the same ones `capy connect dokploy + * --discover` already matches on. + */ +export function serviceFactsOf(detail: ServiceDetail): ServiceFacts { + const git = hasGitSource(detail); + return { + service_id: detail.serviceId, + kind: detail.serviceKind, + name: detail.serviceName, + dokploy_project: detail.projectName, + environment: detail.environmentName, + repo: git ? { host: GITHUB_HOST, owner: detail.owner as string, name: detail.repository as string } : null, + branch: git && detail.branch ? detail.branch : null, + path: git ? servicePathOf(detail) : null, + env_var_names: envVarNamesOf(detail.rawEnv), + }; +} + +// ── Capy side ─────────────────────────────────────────────────────────────── + +export interface CapyProjectFacts { + readonly project_id: string; + readonly project_name: string; + /** Every repo link (CAP-697) of this project; `path` is `.` for the repo root. */ + readonly links: readonly OrgRepoLink[]; + /** Branches that hold at least one variable (the secret NAME index lists no others). */ + readonly branches: readonly string[]; + /** Variable NAMES per branch. */ + readonly var_names_by_branch: Readonly>; +} + +/** Every Capy project the caller can see, from its repo links and the secret NAME index. Names only: the index's value hashes are never read. */ +export function capyProjectsOf( + links: readonly OrgRepoLink[], + indexRows: readonly SecretIndexRow[], +): ReadonlyMap { + const located = indexRows.flatMap((row) => + row.locations.map((l) => ({ project_id: l.project_id, project_name: l.project_name, branch: l.branch, name: row.name })), + ); + const ids = uniqueSorted([...links.map((l) => l.project_id), ...located.map((l) => l.project_id)]); + return new Map( + ids.map((id): [string, CapyProjectFacts] => { + const mine = located.filter((l) => l.project_id === id); + const myLinks = links.filter((l) => l.project_id === id); + const branches = uniqueSorted(mine.map((l) => l.branch)); + return [ + id, + { + project_id: id, + project_name: myLinks[0]?.project_name ?? mine[0]?.project_name ?? id, + links: myLinks, + branches, + var_names_by_branch: Object.fromEntries( + branches.map((branch) => [branch, uniqueSorted(mine.filter((l) => l.branch === branch).map((l) => l.name))]), + ), + }, + ]; + }), + ); +} + +/** The links of `project` on the same repo as `repo`. */ +export function linksOnRepo(project: CapyProjectFacts, repo: RepoOut): readonly OrgRepoLink[] { + const key = repoKey(repo); + return project.links.filter((l) => repoKey(l) === key); +} + +/** The best link of a project for a service path: the exact folder, else the deepest folder above it, else the first. */ +export function bestLink(links: readonly OrgRepoLink[], servicePath: string): OrgRepoLink | undefined { + const exact = links.find((l) => relPathOf(l.path) === servicePath); + if (exact !== undefined) return exact; + const above = links.filter((l) => isSameOrAncestorPath(relPathOf(l.path), servicePath)); + const deepest = sortedCopy(above, (a, b) => relPathOf(b.path).length - relPathOf(a.path).length)[0]; + return deepest ?? sortedCopy(links, (a, b) => byText(relPathOf(a.path), relPathOf(b.path)))[0]; +} + +// ── deploy.json side ──────────────────────────────────────────────────────── + +/** One target already in a `.capy/deploy.json`. */ +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. */ + readonly service_id: string | null; +} + +export type ParsedDeployJson = + | { readonly ok: true; readonly targets: readonly ExistingTarget[]; readonly raw: Record | null } + | { readonly ok: false }; + +const asObject = (value: unknown): Record | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : undefined; + +/** Reads a `.capy/deploy.json` text. `null` (no file) is a valid, empty answer; anything that does not parse as a version-1 file is `ok: false`. */ +export function parseDeployJson(content: string | null): ParsedDeployJson { + if (content === null) return { ok: true, targets: [], raw: null }; + try { + const parsed = asObject(JSON.parse(content)); + if (parsed === undefined || parsed.version !== '1') return { ok: false }; + const targets = asObject(parsed.targets) ?? {}; + return { + ok: true, + raw: parsed, + targets: Object.entries(targets).flatMap(([name, value]): ExistingTarget[] => { + const target = asObject(value); + if (target === undefined) return []; + const options = asObject(target.options) ?? {}; + const serviceId = options.composeId ?? options.applicationId; + return [ + { + name, + kind: typeof target.kind === 'string' ? target.kind : '', + branch: typeof target.branch === 'string' ? target.branch : '', + service_id: typeof serviceId === 'string' ? serviceId : null, + }, + ]; + }), + }; + } catch { + return { ok: false }; + } +} + +/** Is there already a Dokploy target for this service on this Capy branch? */ +export function configuredTarget(targets: readonly ExistingTarget[], serviceId: string, branch: string): ExistingTarget | undefined { + return targets.find((t) => t.kind === 'dokploy' && t.branch === branch && t.service_id === serviceId); +} + +/** A Dokploy target on this Capy branch that points at a DIFFERENT service. */ +export function conflictingTarget(targets: readonly ExistingTarget[], serviceId: string, branch: string): ExistingTarget | undefined { + return targets.find((t) => t.kind === 'dokploy' && t.branch === branch && t.service_id !== serviceId); +} diff --git a/src/commands/deployDiscover/index.ts b/src/commands/deployDiscover/index.ts new file mode 100644 index 00000000..fb9969c0 --- /dev/null +++ b/src/commands/deployDiscover/index.ts @@ -0,0 +1,52 @@ +/** + * The real wiring of `capy deploy dokploy --discover` (CAP-703): silent auth, + * the org system store, the real Dokploy API, the user's own `gh` login. + * The behaviour lives in `./command.ts`, which takes all of it as injectable + * seams; this file only plugs the real ones in and prints. + */ +import { readFileSync } from 'node:fs'; +import { createDokployClient } from '../../deploy/dokployApi'; +import { realBaseUrlStoreOpener, savedDokployTargetUrl } from '../../deploy/dokployBaseUrl'; +import { dokployConnectorSecret } from '../connectors/dokploy'; +import { newPrBranchName } from '../keepLockPr'; +import { realGithub } from '../secretsSet'; +import { resolveSilentContext } from '../secretsSetCommand'; +import { DeployDiscoverIo, DeployDiscoverOpts, runDeployDokployDiscover } from './command'; + +export async function realDeployDiscoverIo(devMode: boolean, cwd: string = process.cwd()): Promise { + const savedBaseUrl = await savedDokployTargetUrl(cwd); + return { + devMode, + env: process.env, + savedBaseUrl: () => savedBaseUrl, + openBaseUrlStore: (orgId) => realBaseUrlStoreOpener(orgId, devMode)(), + 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 }; + }, + getConnectorSecret: dokployConnectorSecret, + dokploy: (baseUrl, token) => createDokployClient(baseUrl, token), + github: realGithub, + branchName: () => newPrBranchName(new Date(), undefined, 'dokploy-targets'), + progress: (line) => { + process.stderr.write(`${line}\n`); + }, + }; +} + +/** + * `capy deploy dokploy --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 deployDokployDiscoverCommand( + opts: DeployDiscoverOpts, + devMode: boolean = false, + io?: DeployDiscoverIo, +): Promise { + const { exitCode, body } = await runDeployDokployDiscover(opts, io ?? (await realDeployDiscoverIo(devMode))); + console.log(JSON.stringify(body, null, 2)); + return exitCode; +} diff --git a/src/commands/deployDiscover/plan.ts b/src/commands/deployDiscover/plan.ts new file mode 100644 index 00000000..cebc4269 --- /dev/null +++ b/src/commands/deployDiscover/plan.ts @@ -0,0 +1,134 @@ +/** + * The plan file of `capy deploy dokploy --discover --plan ` (CAP-703): + * parsing it, normalizing it, and the `plan_id` that ties a `--confirm` to the + * exact plan a `--dry-run` showed. + * + * { "version": 1, "entries": [ { project_id, branch, service_id, git_branch, vars } ] } + * + * IDs and names only. The schema is published in `capy help --json` + * (`schemas.deploy_dokploy_plan`). + * + * Errors are PATH-PRECISE and coded: `{ path: "entries[3].service_id", code: "INVALID_FORMAT" }`. + * A path says where, a code says what; nothing is ever decided from a sentence. + */ +import { createHash } from 'crypto'; +import { ERROR_CODES } from '../../types/index'; +import { isValidVarName } from '../pipedWrite'; +import { PlanEntry } from './discovery'; +import { byText, uniqueSorted } from './facts'; +import { sortedCopy } from '../../deploy/dokployApi'; + +export interface PlanError { + readonly path: string; + readonly code: string; +} + +export interface Plan { + readonly version: 1; + readonly entries: readonly PlanEntry[]; +} + +/** More entries than this is a mistake, not a plan. */ +export const MAX_PLAN_ENTRIES = 500; + +const ENTRY_KEYS = ['project_id', 'branch', 'service_id', 'git_branch', 'vars'] as const; + +const invalid = (path: string): PlanError => ({ path, code: ERROR_CODES.INVALID_FORMAT }); + +const asObject = (value: unknown): Record | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : undefined; + +/** A non-empty string with no leading or trailing space. */ +const isId = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.trim() === value; + +function entryErrors(raw: unknown, i: number): readonly PlanError[] { + const at = `entries[${i}]`; + const entry = asObject(raw); + if (entry === undefined) return [invalid(at)]; + const unknownKeys = Object.keys(entry).filter((k) => !(ENTRY_KEYS as readonly string[]).includes(k)); + const idErrors = (['project_id', 'branch', 'service_id', 'git_branch'] as const).filter((k) => !isId(entry[k])).map((k) => invalid(`${at}.${k}`)); + const vars = entry.vars; + const varErrors: readonly PlanError[] = + !Array.isArray(vars) || vars.length === 0 + ? [invalid(`${at}.vars`)] + : vars.flatMap((v, j) => (typeof v === 'string' && isValidVarName(v) ? [] : [invalid(`${at}.vars[${j}]`)])); + return [...unknownKeys.map((k) => invalid(`${at}.${k}`)), ...idErrors, ...varErrors]; +} + +/** The plan's SHAPE, before anything is looked up. Errors name the path. */ +export function planShapeErrors(raw: unknown): readonly PlanError[] { + const plan = asObject(raw); + if (plan === undefined) return [invalid('')]; + const topKeys = Object.keys(plan).filter((k) => k !== 'version' && k !== 'entries'); + const versionErrors = plan.version === 1 ? [] : [invalid('version')]; + const entries = plan.entries; + const entriesErrors: readonly PlanError[] = + !Array.isArray(entries) || entries.length === 0 || entries.length > MAX_PLAN_ENTRIES + ? [invalid('entries')] + : entries.flatMap((e, i) => entryErrors(e, i)); + return [...topKeys.map((k) => invalid(k)), ...versionErrors, ...entriesErrors]; +} + +/** The entries of a plan that passed `planShapeErrors`, in file order, with each entry's vars exactly as written. */ +export function planEntriesOf(raw: unknown): readonly PlanEntry[] { + const entries = (asObject(raw)?.entries ?? []) as ReadonlyArray>; + return entries.map((e) => ({ + project_id: e.project_id as string, + branch: e.branch as string, + service_id: e.service_id as string, + git_branch: e.git_branch as string, + vars: e.vars as readonly string[], + })); +} + +/** Entries sorted by (project, branch, service) with each entry's vars sorted and de-duplicated, keys in a fixed order. */ +export function normalizePlan(entries: readonly PlanEntry[]): Plan { + const normalized = entries.map( + (e): PlanEntry => ({ + project_id: e.project_id, + branch: e.branch, + service_id: e.service_id, + git_branch: e.git_branch, + vars: uniqueSorted(e.vars), + }), + ); + return { + version: 1, + entries: sortedCopy( + normalized, + (a, b) => byText(a.project_id, b.project_id) || byText(a.branch, b.branch) || byText(a.service_id, b.service_id), + ), + }; +} + +const PLAN_DOMAIN = 'capy:deploy-dokploy:plan:v1'; + +/** + * A hash of the normalized plan and the Dokploy base URL it will be written + * 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 { + 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); +} + +/** First four characters and an ellipsis, so a person can tell services apart at a glance. */ +const shortId = (id: string): string => (id.length > 8 ? `${id.slice(0, 4)}…` : id); + +/** One line a person reads. The agent relays these verbatim. */ +export function summaryLine(args: { + readonly project_name: string; + readonly kind: 'application' | 'compose'; + readonly entry: PlanEntry; +}): string { + const { project_name, kind, entry } = args; + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return `${project_name} · ${entry.branch} → Dokploy ${kind} ${shortId(entry.service_id)} (${entry.git_branch}), ${entry.vars.length} vars`; +} + +/** The line for an entry that needs nothing done. */ +export function alreadyConfiguredLine(args: { readonly project_name: string; readonly entry: PlanEntry; readonly target_name: string }): string { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return `${args.project_name} · ${args.entry.branch}: already configured (${args.target_name}), nothing to do`; +} diff --git a/src/commands/deployDiscover/route.ts b/src/commands/deployDiscover/route.ts new file mode 100644 index 00000000..b1f178b2 --- /dev/null +++ b/src/commands/deployDiscover/route.ts @@ -0,0 +1,44 @@ +/** + * Routing `capy deploy --discover [--plan ] [--confirm ] [--base-url ]` + * (CAP-703), shared by both entrypoints (`index.ts`, `index-dev.ts`) so they cannot drift. + * + * `--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. + */ +import { ERROR_CODES } from '../../types/index'; + +export interface DeployDiscoverFlags { + readonly discover?: boolean; + readonly plan?: string; + readonly confirm?: string; + readonly baseUrl?: string; +} + +function printRefusal(code: string, error: string): number { + console.log(JSON.stringify({ ok: false, code, error }, null, 2)); + return 1; +} + +/** The exit code when these flags were a discovery run (or a refusal of one), `undefined` when they were not. */ +export async function routeDeployDiscover( + target: string | undefined, + flags: DeployDiscoverFlags, + dryRun: boolean, + devMode: boolean, +): Promise { + const discovering = flags.discover === true; + const discoverOnly = flags.plan !== undefined || flags.confirm !== undefined || flags.baseUrl !== undefined; + if (!discovering && !discoverOnly) return undefined; + 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 + } + const { deployDokployDiscoverCommand } = await import('./index'); + 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 new file mode 100644 index 00000000..798bb44f --- /dev/null +++ b/src/commands/deployDiscover/schema.ts @@ -0,0 +1,43 @@ +/** + * The plan file `capy deploy dokploy --discover --plan ` reads, as a JSON + * Schema. Published in `capy help --json` under `schemas.deploy_dokploy_plan` + * (see `core/cliHelpDoc.ts`), which is where `--discover`'s `plan_schema_ref` + * points. + * + * IDs and names only: a plan never carries a value. + */ + +/** Where the schema is published: a pointer into the `capy help --json` document. */ +export const PLAN_SCHEMA_REF = { command: 'capy help --json', pointer: '/schemas/deploy_dokploy_plan' } as const; + +export const DEPLOY_DOKPLOY_PLAN_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'capy deploy dokploy --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' }, // COPY-FLAG + branch: { type: 'string', minLength: 1, description: 'Capy branch the target ships from' }, // COPY-FLAG + service_id: { type: 'string', minLength: 1, description: 'Dokploy application or compose service id' }, // COPY-FLAG + git_branch: { type: 'string', minLength: 1, description: 'git branch the Dokploy service tracks (the CI deploy PR base)' }, // COPY-FLAG + 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', // COPY-FLAG + }, + }, + }, + }, + }, +} as const; diff --git a/src/commands/keepLockPr.ts b/src/commands/keepLockPr.ts index 0c0b65a7..aff75787 100644 --- a/src/commands/keepLockPr.ts +++ b/src/commands/keepLockPr.ts @@ -243,11 +243,18 @@ export function filterBranches(branches: readonly string[], term: string | undef return needle.length === 0 ? branches : branches.filter((b) => b.toLowerCase().includes(needle)); } -/** `capy/keep-lock--<4 hex chars>` — deterministic shape, unique per run. */ -export function newPrBranchName(now: Date = new Date(), rand: string = randomBytes(2).toString('hex')): string { +/** + * `capy/--<4 hex chars>` — deterministic shape, unique per run. + * `kind` is `keep-lock` unless a caller opens a different kind of PR (`dokploy-targets`). + */ +export function newPrBranchName( + now: Date = new Date(), + rand: string = randomBytes(2).toString('hex'), + kind: string = 'keep-lock', +): string { const iso = now.toISOString(); const stamp = `${iso.slice(0, 10).replace(/-/g, '')}-${iso.slice(11, 19).replace(/:/g, '')}`; - return `capy/keep-lock-${stamp}-${rand}`; + return `capy/${kind}-${stamp}-${rand}`; } const PR_TITLE = 'chore(capy): update keep.lock'; // COPY-FLAG @@ -326,6 +333,14 @@ function failApi(failure: ApiFailure, stepCode: string): Failure { return failWith(failure.kind === 'TIMEOUT' ? ERROR_CODES.GITHUB_TIMEOUT : stepCode); } +/** + * The code a failed GitHub call carries: `gh` missing / not logged in, a timeout and a rate limit have their own, + * everything else is `stepCode`. Decided by the failure's kind, never by text. + */ +export function codeForApiFailure(failure: ApiFailure, stepCode: string): string { + return failureCode(failApi(failure, stepCode)); +} + interface Target { readonly repo: RepoRef; readonly github: GithubApi; @@ -423,13 +438,20 @@ async function planCommit(target: Target, base: string, specs: readonly KeepLock return ok({ files: changed, headCommit: head.value.commitSha, baseTree: head.value.treeSha, bases: bases.value }); } +/** What a pull request that writes files says about itself. Names only: never a value. */ +interface PullWording { + readonly commitMessage: string; + readonly title: string; + readonly body: string; +} + /** blob(s) -> tree -> commit -> ref -> pull. Each failing step is its own code. */ -async function publish( +async function publishFiles( target: Target, base: string, - plan: Plan, + plan: Pick, branch: string, - wording: { readonly command: KeepLockCommandName; readonly records: readonly EditSaveRecord[] }, + wording: PullWording, ): Promise { const { github, repo } = target; const blobs = await Promise.all(plan.files.map((f) => github.createBlob(repo, f.content))); @@ -445,7 +467,7 @@ async function publish( ); if (!tree.ok) return failApi(tree, ERROR_CODES.KEEP_PR_COMMIT_FAILED).report; const commit = await github.createCommit(repo, { - message: buildCommitMessage(wording.records), + message: wording.commitMessage, tree: tree.value.sha, parent: plan.headCommit, }); @@ -453,8 +475,8 @@ async function publish( const ref = await github.createRef(repo, branch, commit.value.sha); if (!ref.ok) return failApi(ref, ERROR_CODES.KEEP_PR_BRANCH_FAILED).report; const pull = await github.createPull(repo, { - title: PR_TITLE, - body: buildPrBody(wording.command, wording.records, plan.files.map((f) => f.path)), + title: wording.title, + body: wording.body, head: branch, base, }); @@ -462,6 +484,21 @@ async function publish( return { changed: true, committed: true, pr_url: pull.value.url, base }; } +/** The keep.lock PR: `publishFiles` with the keep.lock wording. */ +async function publish( + target: Target, + base: string, + plan: Plan, + branch: string, + wording: { readonly command: KeepLockCommandName; readonly records: readonly EditSaveRecord[] }, +): Promise { + return publishFiles(target, base, plan, branch, { + commitMessage: buildCommitMessage(wording.records), + title: PR_TITLE, + body: buildPrBody(wording.command, wording.records, plan.files.map((f) => f.path)), + }); +} + async function openPr( req: KeepLockPrRequest, interactive: boolean, @@ -566,6 +603,61 @@ export async function openKeepLockPullRequest( } } +/** Whole files to write onto a repo's base branch as ONE pull request (the files are already the final content). */ +export interface FilesPullRequest { + readonly repo: RepoRef; + /** The PR base. Absent: the repo's default branch. */ + readonly base?: string; + readonly files: ReadonlyArray<{ readonly path: string; readonly content: string }>; + readonly commitMessage: string; + readonly title: string; + readonly body: string; +} + +export type FilesPullRequestResult = + | { readonly ok: true; readonly pr_url: string; readonly base: string; readonly paths: readonly string[] } + | { readonly ok: false; readonly code: string }; + +/** + * Opens ONE pull request that writes every file in `files` (one blob each, one tree, one commit), on + * `base` (default: the repo's default branch). The same remote sequence as `openKeepLockPullRequest` + * (`publishFiles`), for content that is not a keep.lock fold (`.capy/deploy.json`). Never prompts, never + * throws; a failure is its own code. + */ +export async function openFilesPullRequest( + req: FilesPullRequest, + deps: Pick & { readonly github: GithubApi }, +): Promise { + try { + const target: Target = { repo: req.repo, github: deps.github }; + const base = + req.base !== undefined ? { ok: true as const, base: req.base } : await resolveDefaultBase(deps.github, req.repo); + if (!base.ok) return { ok: false, code: base.code }; + const head = await deps.github.getBranchHead(req.repo, base.base); + if (!head.ok) { + return { + ok: false, + code: failureCode(failApi(head, head.kind === 'NOT_FOUND' ? ERROR_CODES.KEEP_PR_BASE_UNRESOLVED : ERROR_CODES.KEEP_PR_READ_FAILED)), + }; + } + const branch = deps.branchName(); + if (!isValidBranchName(branch)) return { ok: false, code: ERROR_CODES.KEEP_PR_BRANCH_FAILED }; + const report = await publishFiles( + target, + base.base, + { files: req.files, headCommit: head.value.commitSha, baseTree: head.value.treeSha }, + branch, + { commitMessage: req.commitMessage, title: req.title, body: req.body }, + ); + if (report.changed && report.committed) { + return { ok: true, pr_url: report.pr_url, base: base.base, paths: req.files.map((f) => f.path) }; + } + return { ok: false, code: report.changed && !report.committed && report.error ? report.error.code : ERROR_CODES.KEEP_PR_CREATE_FAILED }; + } catch { + return { ok: false, code: ERROR_CODES.KEEP_PR_CREATE_FAILED }; + } +} + /** Human wording for a keep.lock PR failure code (the same copy the PR step uses). */ export function keepPrErrorMessage(code: string): string { return ERROR_MESSAGES[code] ?? code; diff --git a/src/commands/secretsSet.ts b/src/commands/secretsSet.ts index be1d1d25..744e2655 100644 --- a/src/commands/secretsSet.ts +++ b/src/commands/secretsSet.ts @@ -612,7 +612,8 @@ export async function runSecretSet(req: SetRequest, env: SetEnv, hooks: RunHooks // Real environment // --------------------------------------------------------------------------- -function realGithub(): GithubApi | undefined { +/** The real GitHub API through the user's own `gh` login, or `undefined` when `gh` is not installed. */ +export function realGithub(): GithubApi | undefined { const gh = resolveGh(); // Asynchronous: the screen keeps drawing (and reading keys) while `gh` runs, and calls can overlap. return gh === null ? undefined : createGhApi(spawnGhRunnerAsync(gh)); diff --git a/src/commands/secretsSetCommand.ts b/src/commands/secretsSetCommand.ts index fa2179fb..f1dee880 100644 --- a/src/commands/secretsSetCommand.ts +++ b/src/commands/secretsSetCommand.ts @@ -473,21 +473,47 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr /** How long `--dry-run` waits for a piped value it does not need. */ const DRY_RUN_PEEK_MS = 1000; -/** Silent auth only: never a prompt, never a browser. The org is the cwd keep.lock's, else the session's. */ -async function silentContext(devMode: boolean, json: boolean) { +/** The silent session a non-interactive command runs under: the org, the user and a client. */ +export interface SilentContext { + readonly orgId: string; + readonly userId: string; + readonly client: ServiceClient; +} + +export type SilentContextResult = + | { readonly ok: true; readonly context: SilentContext } + | { readonly ok: false; readonly code: string; readonly message: string }; + +/** + * Silent auth only: never a prompt, never a browser. The org is the cwd keep.lock's, else the session's. + * Returns the refusal instead of exiting, so a caller that owns its own output (`capy deploy dokploy --discover`) + * can reuse it. + */ +export async function resolveSilentContext(devMode: boolean): Promise { const state = await new ProjectManager().detectProjectState().catch(() => undefined); const authService = new AuthService(undefined, devMode, state?.userId); const client = new ServiceClient(undefined, devMode); client.setTokenProvider(() => authService.getValidToken()); const forOrg = await authService.authenticateSilent(state?.organizationId); const auth = forOrg.success ? forOrg : await authService.authenticateSilent(); - if (!auth.success || !auth.user_id) refuse(json, ERROR_CODES.AUTH_FAILED, silentAuthFailureMessage(auth)); + if (!auth.success || !auth.user_id) { + return { ok: false, code: ERROR_CODES.AUTH_FAILED, message: silentAuthFailureMessage(auth) }; + } const orgs = auth.organizations ?? []; const orgId = state?.organizationId ?? auth.organization_id ?? (orgs.length === 1 ? orgs[0].id : undefined); if (orgId === undefined) { - refuse(json, ERROR_CODES.ORG_NOT_FOUND, 'No organization could be chosen without a prompt. Run from a project folder.'); // COPY-FLAG + return { + ok: false, + code: ERROR_CODES.ORG_NOT_FOUND, + message: 'No organization could be chosen without a prompt. Run from a project folder.', // COPY-FLAG + }; } - return { orgId, userId: auth.user_id as string, client }; + return { ok: true, context: { orgId, userId: auth.user_id as string, client } }; +} + +async function silentContext(devMode: boolean, json: boolean): Promise { + const resolved = await resolveSilentContext(devMode); + return resolved.ok ? resolved.context : refuse(json, resolved.code, resolved.message); } /** diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index 5745118a..e69e987f 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -11,6 +11,7 @@ */ import type { Argument, Command, Option } from 'commander'; import { ERROR_CODES } from '../types/index'; +import { DEPLOY_DOKPLOY_PLAN_SCHEMA } from '../commands/deployDiscover/schema'; export interface CliArgumentDoc { name: string; @@ -26,6 +27,25 @@ export interface CliOptionDoc { negatable: boolean; } +/** + * A way of running a command that is not a sub-command of its own (`capy deploy dokploy --discover`): + * what to type, which flags belong to it, and what it reads. Structured, so an agent never has to + * parse prose to learn that a mode exists. + */ +export interface CliModeDoc { + /** What is typed, e.g. `capy deploy dokploy --discover`. */ + invocation: string; + description: string; + /** The flags that belong to this mode (a subset of the command's `options`). */ + flags: string[]; + supportsJson: boolean; + /** Always prints exactly what `--json` prints, in a terminal or not, and never prompts. */ + alwaysJson: boolean; + supportsDryRun: boolean; + /** Key under the top-level `schemas` of the file this mode reads (`--plan`), when it reads one. */ + planSchema?: string; +} + export interface CliCommandDoc { name: string; /** Full space-joined path from the root, e.g. "deploy targets-remove". */ @@ -36,6 +56,8 @@ export interface CliCommandDoc { supportsJson: boolean; /** Honors the global `-d/--dry-run` (previews, or refuses a path that cannot preview with `DRY_RUN_UNSUPPORTED`). */ supportsDryRun: boolean; + /** Modes of this command that are not sub-commands, when it has any. */ + modes?: CliModeDoc[]; subcommands: CliCommandDoc[]; } @@ -44,6 +66,8 @@ export interface CliHelpDoc { name: string; version: string; commands: CliCommandDoc[]; + /** JSON Schemas of the files commands read, by name (`deploy_dokploy_plan`). */ + schemas: Record; errorCodes: string[]; conventions: { json: string; @@ -94,6 +118,27 @@ export const DRY_RUN_COMMANDS: ReadonlySet = new Set([ 'secrets set', ]); +/** The JSON Schemas published in `capy help --json`. */ +export const HELP_SCHEMAS: Readonly> = { + deploy_dokploy_plan: DEPLOY_DOKPLOY_PLAN_SCHEMA, +}; + +/** Modes that are flags of a command rather than sub-commands, by command path. */ +export const COMMAND_MODES: Readonly> = { + deploy: [ + { + invocation: 'capy deploy dokploy --discover', + // COPY-FLAG: minimal and neutral. + description: 'Find the Dokploy services that match Capy projects, check a plan for them, and write the deploy targets as one pull request per repo. Pushes no values.', + flags: ['--discover', '--plan', '--confirm', '--base-url', '--dry-run', '--json'], + supportsJson: true, + alwaysJson: true, + supportsDryRun: true, + planSchema: 'deploy_dokploy_plan', + }, + ], +}; + function commandDoc(cmd: Command, parentPath: string): CliCommandDoc { const name = cmd.name(); const path = parentPath ? `${parentPath} ${name}` : name; @@ -108,6 +153,7 @@ function commandDoc(cmd: Command, parentPath: string): CliCommandDoc { options, supportsJson: options.some((o) => o.long === '--json'), supportsDryRun: DRY_RUN_COMMANDS.has(path), + ...(COMMAND_MODES[path] ? { modes: COMMAND_MODES[path].map((m) => ({ ...m, flags: [...m.flags] })) } : {}), subcommands, }; } @@ -121,6 +167,7 @@ export function buildCliHelpDoc(program: Command): CliHelpDoc { name: program.name(), version: program.version() ?? '', commands, + schemas: { ...HELP_SCHEMAS }, errorCodes: Object.values(ERROR_CODES), conventions: { json: 'Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON.', diff --git a/src/core/cliReferenceMarkdown.ts b/src/core/cliReferenceMarkdown.ts index 87fa459e..8f3c1895 100644 --- a/src/core/cliReferenceMarkdown.ts +++ b/src/core/cliReferenceMarkdown.ts @@ -4,7 +4,7 @@ * `scripts/gen-cli-reference.ts` and the staleness test both call the exact * same renderer — there is nowhere for the two to drift apart. */ -import type { CliCommandDoc, CliHelpDoc, CliOptionDoc } from './cliHelpDoc'; +import type { CliCommandDoc, CliHelpDoc, CliModeDoc, CliOptionDoc } from './cliHelpDoc'; function usageLine(cmd: CliCommandDoc): string { const args = cmd.arguments @@ -27,6 +27,21 @@ function optionsTable(options: CliOptionDoc[]): string { return ['| Option | Description | Default |', '|---|---|---|', ...rows].join('\n'); } +function renderMode(mode: CliModeDoc): string[] { + // COPY-FLAG: minimal and neutral wording. + return [ + `Mode: \`${mode.invocation}\``, + '', + mode.description, + '', + `- Flags: ${mode.flags.map((f) => `\`${f}\``).join(', ')}`, + `- Always prints JSON: ${mode.alwaysJson ? 'yes' : 'no'}`, + `- Dry run: ${mode.supportsDryRun ? 'yes' : 'no'}`, + ...(mode.planSchema === undefined ? [] : [`- Plan schema: \`schemas.${mode.planSchema}\` in \`capy help --json\``]), + '', + ]; +} + function renderCommand(cmd: CliCommandDoc, depth: number): string { const heading = '#'.repeat(Math.min(depth + 2, 6)); const lines = [ @@ -43,13 +58,26 @@ function renderCommand(cmd: CliCommandDoc, depth: number): string { `JSON support: ${cmd.supportsJson ? 'yes (`--json`)' : 'no'}`, '', ...(cmd.supportsDryRun === true ? ['Dry run: yes (`--dry-run`)', ''] : []), // COPY-FLAG + ...(cmd.modes ?? []).flatMap((mode) => renderMode(mode)), + ...cmd.subcommands.map((sub) => renderCommand(sub, depth + 1)), ]; - for (const sub of cmd.subcommands) { - lines.push(renderCommand(sub, depth + 1)); - } return lines.join('\n'); } +/** The schemas section; a doc with no schemas (an older one) has none. */ +function renderSchemas(doc: CliHelpDoc): string[] { + const entries = Object.entries(doc.schemas ?? {}); + return entries.length === 0 + ? [] + : [ + '## Schemas', + '', + 'JSON Schemas of the files commands read, as published under `schemas` in `capy help --json`.', // COPY-FLAG + '', + ...entries.flatMap(([name, schema]) => [`### \`${name}\``, '', '```json', JSON.stringify(schema, null, 2), '```', '']), + ]; +} + /** Pure: the same doc always renders the same markdown, byte for byte. */ export function renderCliReferenceMarkdown(doc: CliHelpDoc): string { const lines = [ @@ -61,17 +89,19 @@ export function renderCliReferenceMarkdown(doc: CliHelpDoc): string { '', '## Commands', '', + ...doc.commands.map((cmd) => renderCommand(cmd, 0)), + ...renderSchemas(doc), + '## Error codes', + '', + 'Every refusal carries a stable `code` — branch on it, never on message text.', + '', + ...doc.errorCodes.map((code) => `- \`${code}\``), + '', + '## Conventions', + '', + `- **JSON**: ${doc.conventions.json}`, + `- **Codes**: ${doc.conventions.codes}`, + '', ]; - for (const cmd of doc.commands) { - lines.push(renderCommand(cmd, 0)); - } - lines.push('## Error codes', ''); - lines.push('Every refusal carries a stable `code` — branch on it, never on message text.', ''); - lines.push(...doc.errorCodes.map((code) => `- \`${code}\``)); - lines.push(''); - lines.push('## Conventions', ''); - lines.push(`- **JSON**: ${doc.conventions.json}`); - lines.push(`- **Codes**: ${doc.conventions.codes}`); - lines.push(''); return lines.join('\n'); } diff --git a/src/deploy/dokployApi.ts b/src/deploy/dokployApi.ts index 2ae8e148..251076d8 100644 --- a/src/deploy/dokployApi.ts +++ b/src/deploy/dokployApi.ts @@ -42,6 +42,13 @@ export interface DokployApplication { repository?: string; owner?: string; branch?: string; + /** + * The build directory within the repo (e.g. `/` or `backend`). Read by + * `capy deploy dokploy --discover` (CAP-703) to match an Application to a + * Capy project folder. UNVERIFIED live: assumed to be `application.one`'s + * `buildPath`, a string where `/` (or empty) means the repo root. + */ + buildPath?: string; /** Which Dokploy environment this Application belongs to, within its project. */ environmentId?: string; /** @@ -466,6 +473,7 @@ export function createDokployClient( repository: typeof body.repository === 'string' ? body.repository : undefined, owner: typeof body.owner === 'string' ? body.owner : undefined, branch: typeof body.branch === 'string' ? body.branch : undefined, + buildPath: typeof body.buildPath === 'string' ? body.buildPath : undefined, environmentId: typeof body.environmentId === 'string' ? body.environmentId : undefined, autoDeploy: typeof body.autoDeploy === 'boolean' ? body.autoDeploy : null, customGitBranch: typeof body.customGitBranch === 'string' ? body.customGitBranch : null, @@ -619,6 +627,14 @@ export function resolveDokployToken( */ export const DOKPLOY_CONNECTOR_SECRET_NAME = '_CONNECTOR_DOKPLOY_API_KEY'; +/** + * Name of the org system variable that holds the Dokploy dashboard URL (CAP-703): the same store and the + * same `_CONNECTOR__` convention as the API key, with `BASE_URL` as the name. The URL is + * not a secret, but it travels the same encrypted path anyway. See `dokployBaseUrl.ts` for the one + * resolver every Dokploy path uses to read it. + */ +export const DOKPLOY_BASE_URL_VAR_NAME = '_CONNECTOR_DOKPLOY_BASE_URL'; + /** * Name of the org system store entry that holds the Dokploy API key for * DEPLOY (`capy deploy`, outbound) — CAP-679 follow-up. Deliberately a diff --git a/src/deploy/dokployBaseUrl.ts b/src/deploy/dokployBaseUrl.ts new file mode 100644 index 00000000..8d7b48f1 --- /dev/null +++ b/src/deploy/dokployBaseUrl.ts @@ -0,0 +1,224 @@ +/** + * The Dokploy dashboard URL: ONE resolver for every path that needs it (CAP-703) - + * `capy deploy dokploy --discover`, `capy connect dokploy --discover`, the + * single-service `capy connect dokploy` import and the `capy deploy` setup picker. + * + * Order: + * 1. `--base-url` + * 2. the org system variable `_CONNECTOR_DOKPLOY_BASE_URL` (same store as the API key) + * 3. the Dokploy target already saved in the current folder's `.capy/deploy.json` + * 4. a structured refusal: `DOKPLOY_SETTINGS_MISSING` with `unanswered: [{ id: 'base_url', ... }]` + * + * Saving: a `--base-url` given while the variable is unset, by someone who can open the (admin-only) + * system store, is saved to it - normalised, no prompt. A non-admin gets a `BASE_URL_NOT_SAVED` + * notice with the reason code. A flag that differs from the stored URL wins for this run, never + * overwrites, and adds `BASE_URL_DIFFERS_FROM_STORED`. A dry run saves nothing. + * + * The URL is not a secret and may be printed; it still goes through the encrypted store path. + * Decisions key on codes and statuses only, never on message text. + */ +import { baseUrlProblem } from './adapters/dokploy'; +import { DOKPLOY_BASE_URL_VAR_NAME } from './dokployApi'; +import { ERROR_CODES } from '../types/index'; + +export type BaseUrlSource = 'flag' | 'system' | 'saved_target'; + +export interface BaseUrlNotice { + readonly code: 'BASE_URL_NOT_SAVED' | 'BASE_URL_DIFFERS_FROM_STORED'; + /** `BASE_URL_NOT_SAVED`: why, as a code (`SYSTEM_STORE_ADMIN_ONLY`, `DRY_RUN`, ...). */ + readonly reason?: string; + /** `BASE_URL_DIFFERS_FROM_STORED`: the stored URL and the one used for this run. */ + readonly stored?: string; + readonly used?: string; +} + +/** The two operations on the system variable. Opening the store is what needs an admin: a failure carries a code. */ +export interface BaseUrlStore { + get(name: string): string | null; + set(name: string, value: string): Promise; +} + +export interface ResolveBaseUrlOptions { + /** `--base-url` */ + readonly flag?: string; + /** Saves nothing when true. */ + readonly dryRun?: boolean; + /** Opens the org system store. Absent: the system variable is not consulted (an offline caller). Rejects with a coded error when the caller cannot open it. */ + readonly openStore?: () => Promise; + /** The base URL of a Dokploy target saved in the current folder's deploy.json. */ + readonly savedTargetUrl: () => string | undefined; +} + +export type ResolvedBaseUrl = + | { + readonly ok: true; + readonly baseUrl: string; + readonly source: BaseUrlSource; + /** `{ base_url: true }` when this run saved it to the system variable. */ + readonly saved?: { readonly base_url: true }; + readonly notices: readonly BaseUrlNotice[]; + } + | { + readonly ok: false; + readonly code: string; + readonly error: string; + readonly unanswered?: readonly Unanswered[]; + }; + +/** Trimmed, no trailing slash. `null` when it is not a usable Dokploy URL (the shared rule: https, or http on loopback). */ +export function normalizeDokployBaseUrl(raw: string): string | null { + const trimmed = raw.trim().replace(/\/+$/, ''); + return baseUrlProblem(trimmed) === null ? trimmed : null; +} + +const codeOf = (err: unknown): string => { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : ERROR_CODES.SERVICE_ERROR; +}; + +/** One thing a refusal says is still needed, as a flag. */ +export interface Unanswered { + readonly id: string; + readonly flag: string; + readonly hint: string; + readonly alternative?: string; +} + +const BASE_URL_UNANSWERED: Unanswered = { id: 'base_url', flag: '--base-url', hint: 'your Dokploy dashboard URL, e.g. https://dokploy.example.com' }; // COPY-FLAG +const SERVICE_UNANSWERED: Unanswered = { id: 'service', flag: '--application', alternative: '--compose', hint: 'the Dokploy application or compose service id' }; // COPY-FLAG + +/** + * The `unanswered` of every `DOKPLOY_SETTINGS_MISSING` refusal, in every Dokploy command that cannot ask: the base URL + * (always), and the service id when that is missing too. Same shape everywhere, so an agent branches on one thing. + */ +export function settingsUnanswered(needs: { readonly baseUrl: boolean; readonly service?: boolean }): readonly Unanswered[] { + return [...(needs.baseUrl ? [BASE_URL_UNANSWERED] : []), ...(needs.service === true ? [SERVICE_UNANSWERED] : [])]; +} + +const missing = (): ResolvedBaseUrl => ({ + ok: false, + code: 'DOKPLOY_SETTINGS_MISSING', + error: 'No Dokploy base URL. Pass --base-url .', // COPY-FLAG + unanswered: [BASE_URL_UNANSWERED], +}); + +const invalid = (what: string): ResolvedBaseUrl => ({ + ok: false, + code: ERROR_CODES.DOKPLOY_URL_INVALID, + error: `The ${what} is not a usable Dokploy URL: it must be https (http only on localhost).`, // COPY-FLAG +}); + +type StoreRead = + | { readonly kind: 'none' } + | { readonly kind: 'unreadable'; readonly code: string } + | { readonly kind: 'ready'; readonly store: BaseUrlStore; readonly stored: string | null }; + +async function readStore(openStore: ResolveBaseUrlOptions['openStore']): Promise { + if (openStore === undefined) return { kind: 'none' }; + try { + const store = await openStore(); + return { kind: 'ready', store, stored: store.get(DOKPLOY_BASE_URL_VAR_NAME) }; + } catch (err) { + return { kind: 'unreadable', code: codeOf(err) }; + } +} + +/** First `--base-url`: save it when the variable is unset and the caller may; otherwise say why not. */ +async function flagOutcome(flagUrl: string, read: StoreRead, dryRun: boolean): Promise> { + const base = { ok: true as const, baseUrl: flagUrl, source: 'flag' as const }; + if (read.kind === 'none') return { ...base, notices: [] }; + if (read.kind === 'unreadable') return { ...base, notices: [{ code: 'BASE_URL_NOT_SAVED', reason: read.code }] }; + const stored = read.stored === null ? null : normalizeDokployBaseUrl(read.stored) ?? read.stored; + if (stored !== null) { + return { + ...base, + notices: stored === flagUrl ? [] : [{ code: 'BASE_URL_DIFFERS_FROM_STORED', stored, used: flagUrl }], + }; + } + if (dryRun) return { ...base, notices: [{ code: 'BASE_URL_NOT_SAVED', reason: 'DRY_RUN' }] }; + try { + await read.store.set(DOKPLOY_BASE_URL_VAR_NAME, flagUrl); + return { ...base, saved: { base_url: true }, notices: [] }; + } catch (err) { + return { ...base, notices: [{ code: 'BASE_URL_NOT_SAVED', reason: codeOf(err) }] }; + } +} + +/** The one resolver. See the file doc for the order and the saving rules. */ +export async function resolveDokployBaseUrl(opts: ResolveBaseUrlOptions): Promise { + const flagRaw = opts.flag?.trim(); + const flagUrl = flagRaw ? normalizeDokployBaseUrl(flagRaw) : undefined; + if (flagRaw && flagUrl === null) return invalid('--base-url'); + const read = await readStore(opts.openStore); + if (flagUrl) return flagOutcome(flagUrl, read, opts.dryRun === true); + + if (read.kind === 'ready' && read.stored !== null) { + const stored = normalizeDokployBaseUrl(read.stored); + return stored === null + ? invalid(`system variable ${DOKPLOY_BASE_URL_VAR_NAME}`) + : { ok: true, baseUrl: stored, source: 'system', notices: [] }; + } + const savedRaw = opts.savedTargetUrl()?.trim(); + if (savedRaw) { + const saved = normalizeDokployBaseUrl(savedRaw); + return saved === null ? invalid('Dokploy target saved in deploy.json') : { ok: true, baseUrl: saved, source: 'saved_target', notices: [] }; + } + return missing(); +} + +/** The real system store, opened for `orgId` (admin-only: it rejects with `SYSTEM_STORE_ADMIN_ONLY` otherwise). */ +export function realBaseUrlStoreOpener(orgId: string, devMode: boolean): () => Promise { + return async () => { + const { openSystemStore } = await import('../system/systemStore'); + return openSystemStore({ orgId, devMode }); + }; +} + +/** The URL of the first Dokploy target saved in `cwd`'s deploy.json, or `undefined` (no file, malformed, none). */ +export async function savedDokployTargetUrl(cwd: string): Promise { + const { listTargets } = await import('./config'); + try { + const target = listTargets(cwd).find((t) => t.kind === 'dokploy'); + const baseUrl = (target?.options as { baseUrl?: unknown } | undefined)?.baseUrl; + return typeof baseUrl === 'string' ? baseUrl : undefined; + } catch { + return undefined; + } +} + +// ── A prompt collected the URL: offer to save it (admins only) ─────────────── + +const SAVED_LINE = 'Saved the Dokploy URL for your org.'; // COPY-FLAG +const NOT_ADMIN_LINE = 'Not saved for the org: only org admins can save the Dokploy URL.'; // COPY-FLAG + +const dim = (line: string): string => `\x1b[90m${line}\x1b[0m`; + +/** + * After a terminal prompt collected a valid Dokploy URL: save it to the system variable through the shared resolver's save + * path, and ONLY when the variable is unset and the caller may open the (admin-only) store. An existing value is never + * overwritten. One short dim line on stderr says what happened: saved, or not saved because only admins can. Whether the + * caller is an admin is the store's own structured answer (`SYSTEM_STORE_ADMIN_ONLY`), never a message. + */ +export async function offerSaveAskedBaseUrl(args: { + readonly baseUrl: string; + readonly openStore?: () => Promise; + readonly dryRun?: boolean; + /** Where the one line goes. Default: dim, on stderr. */ + readonly log?: (line: string) => void; +}): Promise<{ readonly saved: boolean }> { + if (args.openStore === undefined) return { saved: false }; + const log = args.log ?? ((line: string) => console.error(dim(line))); + const resolved = await resolveDokployBaseUrl({ + flag: args.baseUrl, + dryRun: args.dryRun === true, + openStore: args.openStore, + savedTargetUrl: () => undefined, + }); + if (!resolved.ok) return { saved: false }; + if (resolved.saved !== undefined) { + log(SAVED_LINE); + return { saved: true }; + } + if (resolved.notices.some((n) => n.code === 'BASE_URL_NOT_SAVED' && n.reason === ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY)) log(NOT_ADMIN_LINE); + return { saved: false }; +} diff --git a/src/index-dev.ts b/src/index-dev.ts index d5ff899b..040c588e 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -345,9 +345,19 @@ const deploy = program .option('--mode ', 'skip mode picker: "connector" or "token"') .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('--base-url ', 'dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL)') .action(async (target: string | undefined, options: any, cmd: any) => { const merged = cmd.optsWithGlobals ? cmd.optsWithGlobals() : options; + // `capy-dev deploy dokploy --discover` (CAP-703): always JSON, never prompts. + const { routeDeployDiscover } = await import('./commands/deployDiscover/route'); + const discoverCode = await routeDeployDiscover(target, options, (options.dryRun ?? merged.dryRun) === true, true); + if (discoverCode !== undefined) process.exit(discoverCode); + // CI/explicit connector path — go straight to the adapter flow (devMode). if (options.target || options.connect || target) { const { deployCommand } = await import('./commands/deployCommand'); @@ -1047,7 +1057,8 @@ program ) .option( '-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', + // COPY-FLAG: discover never prompts (it always prints JSON), so `--yes` is what lets it write. + 'dokploy import/discover: skip the confirmation prompt (import: --overwrite\'s clear/replace/import ask) — discover never prompts and needs --yes to write', ) .option( '--overwrite', diff --git a/src/index.ts b/src/index.ts index 31a57cf5..1b505202 100644 --- a/src/index.ts +++ b/src/index.ts @@ -385,12 +385,22 @@ const deploy = program .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('--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('--base-url ', 'dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL)') .action(async (target: string | undefined, options: any, cmd: any) => { assertNotLocalOnly('deploy'); // Top-level program also defines --dry-run; merge globals so either // `capy --dry-run deploy ...` or `capy deploy ... --dry-run` works. const merged = cmd.optsWithGlobals ? cmd.optsWithGlobals() : options; + // `capy deploy dokploy --discover` (CAP-703): always JSON, never prompts. + const { routeDeployDiscover } = await import('./commands/deployDiscover/route'); + const discoverCode = await routeDeployDiscover(target, options, (options.dryRun ?? merged.dryRun) === true, false); + if (discoverCode !== undefined) process.exit(discoverCode); + // CI/explicit target path — go straight to the adapter flow. if (options.target || options.connect || target) { const { deployCommand } = await import('./commands/deployCommand'); @@ -992,7 +1002,8 @@ program ) .option( '-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', + // COPY-FLAG: discover never prompts (it always prints JSON), so `--yes` is what lets it write. + 'dokploy import/discover: skip the confirmation prompt (import: --overwrite\'s clear/replace/import ask) — discover never prompts and needs --yes to write', ) .option( '--overwrite', diff --git a/src/types/index.ts b/src/types/index.ts index f94b7133..5879f003 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -723,6 +723,27 @@ export const ERROR_CODES = { RATE_LIMITED: 'RATE_LIMITED', /** GitHub refused with a rate limit (429, or 403 with a `retry-after` / `x-ratelimit-remaining: 0` header) and the bounded retries ran out. */ GITHUB_RATE_LIMITED: 'GITHUB_RATE_LIMITED', + // --- `capy deploy dokploy --discover` (CAP-703) --- + /** A plan entry's `service_id` names no Dokploy service (path-precise: `entries[i].service_id`). */ + SERVICE_NOT_FOUND: 'SERVICE_NOT_FOUND', + /** Two plan entries assign the same (project_id, branch) (path-precise: the later `entries[i]`). */ + DUPLICATE_ENTRY: 'DUPLICATE_ENTRY', + /** A plan entry would add a Dokploy target to a project branch that already has a Dokploy target for a DIFFERENT service. */ + TARGET_EXISTS: 'TARGET_EXISTS', + /** A plan entry's project has no GitHub repo link, so no deploy.json PR can be opened for it. */ + NO_REPO_LINK: 'NO_REPO_LINK', + /** A plan entry's service tracks a repo that is not one of its project's linked repos. */ + REPO_MISMATCH: 'REPO_MISMATCH', + /** `--plan ` could not be read as a file. */ + PLAN_FILE_UNREADABLE: 'PLAN_FILE_UNREADABLE', + /** The plan failed validation; the refusal carries `errors: [{ path, code }]`. Nothing was changed. */ + PLAN_INVALID: 'PLAN_INVALID', + /** `--confirm ` without `--plan `. */ + PLAN_REQUIRED: 'PLAN_REQUIRED', + /** `--discover` on a `capy deploy` target that has no discovery (only `dokploy` has one). */ + DISCOVER_UNSUPPORTED_TARGET: 'DISCOVER_UNSUPPORTED_TARGET', + /** `--confirm` wrote some repos' deploy.json PRs and at least one repo failed; the refusal lists `failed: [{ repo, code }]`. Exit 1. */ + DISCOVER_PARTIAL: 'DISCOVER_PARTIAL', } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/tests/commands/connectDiscoveryRouting.test.ts b/tests/commands/connectDiscoveryRouting.test.ts index 5d31266f..b652df9d 100644 --- a/tests/commands/connectDiscoveryRouting.test.ts +++ b/tests/commands/connectDiscoveryRouting.test.ts @@ -33,6 +33,7 @@ * file, so the store refuses with `AUTH_FAILED` instantly and locally. */ import { mock, describe, test, expect, spyOn, afterAll } from 'bun:test'; +import { withEnv, withTty } from '../helpers/processState'; import { join } from 'node:path'; const FAKE_ORG_CONTEXT = { @@ -138,3 +139,67 @@ describe('ConnectCommand.execute() --discover routing (isolated: mock.module on } }); }); + +// ── CAP-703: --discover ALWAYS prints JSON and never prompts, in a terminal or not ── + +describe('ConnectCommand.execute() --discover is always JSON and never prompts (CAP-703)', () => { + test('a real TTY and no base URL: no prompt — one JSON refusal on stdout, nothing prose on stderr', async () => { + const ROOT = mkdtempSync(join(tmpdir(), 'capy-discover-json-tty-')); + const originalCwd = process.cwd(); + const savedExitCode = process.exitCode; + 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); + try { + process.chdir(ROOT); + const result = await withTty({ stdin: true }, () => new ConnectCommand(false).execute('dokploy', { discover: true } as ConnectOpts)); + expect(result).toEqual({ linked: false }); + expect(logMock).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(String(logMock.mock.calls[0][0])); + expect(parsed.ok).toBe(false); + expect(parsed.code).toBe('DOKPLOY_SETTINGS_MISSING'); + expect(errMock).not.toHaveBeenCalled(); + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + process.chdir(originalCwd); + process.exitCode = savedExitCode ?? 0; + rmSync(ROOT, { recursive: true, force: true }); + } + }); + + test('a real TTY, a dry run that reaches Dokploy: stdout is exactly one JSON document (what --json prints)', async () => { + const ROOT = mkdtempSync(join(tmpdir(), 'capy-discover-json-dry-')); + const originalCwd = process.cwd(); + const logMock = mock((..._a: unknown[]) => {}); + const logSpy = spyOn(console, 'log').mockImplementation(logMock as never); + const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { + const body = String(url).includes('project.all') ? [] : {}; + return new Response(JSON.stringify(body), { status: 200 }); + }) as never); + try { + process.chdir(ROOT); + // The token comes from an environment variable that is already set (any will do: fetch is stubbed above). + await withEnv({ CAPY_DISCOVER_JSON_TEST_TOKEN: 'token-for-this-test-only' }, () => + withTty({ stdin: true }, () => + new ConnectCommand(false).execute('dokploy', { + discover: true, + dryRun: true, + baseUrl: 'https://dokploy.example.com', + tokenEnv: 'CAPY_DISCOVER_JSON_TEST_TOKEN', + } as ConnectOpts), + ), + ); + expect(logMock).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(String(logMock.mock.calls[0][0])); + expect(parsed).toMatchObject({ ok: true, provider: 'dokploy', dryRun: true, cancelled: false }); + expect(parsed.plan.folders).toEqual([]); + } finally { + fetchSpy.mockRestore(); + logSpy.mockRestore(); + process.chdir(originalCwd); + rmSync(ROOT, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/commands/connectDokploy.test.ts b/tests/commands/connectDokploy.test.ts index 466610a1..b8b68e5f 100644 --- a/tests/commands/connectDokploy.test.ts +++ b/tests/commands/connectDokploy.test.ts @@ -207,6 +207,8 @@ describe('resolveDokploySettings', () => { baseUrl: 'https://asked.example.com', applicationId: 'app_asked', tokenEnv: 'DOKPLOY_API_KEY', + // CAP-703: a URL typed at a prompt is marked, so the caller can offer it to the org. + baseUrlAsked: true, }); }); }); @@ -568,61 +570,49 @@ describe('dokploy import — keep.lock metadata + warnings', () => { }); }); -describe('dokploy import — deploy-target offer', () => { +// CAP-703 (Vince, 2026-10-03): "connect is for retrieval, target is for deploy". A connector +// import never creates, offers or writes a deploy target. This block used to cover the offer +// (`maybeOfferDeployTarget`); it now pins that the offer is gone: no question asked, nothing +// saved, in a terminal or not, and an existing deploy.json is left exactly as it was. +describe('dokploy import — never offers or saves a deploy target', () => { const ROOT = mkdtempSync(join(tmpdir(), 'capy-dokploy-import-')); afterEach(() => { if (existsSync(join(ROOT, '.capy'))) rmSync(join(ROOT, '.capy'), { recursive: true, force: true }); }); - test('non-interactive: never asks, never saves', async () => { + test('non-interactive: nothing saved, and the outcome carries no deploy-target field', async () => { const calls: FakeCall[] = []; const connector = createDokployConnector({ fetch: fakeFetch({ env: 'A=1', calls }), env: { T: 'x' }, cwd: ROOT }); const outcome = await connector.import!(ctxWith(), { nonTty: true, baseUrl: 'https://d', application: 'app_new', tokenEnv: 'T' }); expect(outcome.ok).toBe(true); if (!outcome.ok) return; - expect(outcome.deployTargetSaved).toBe(false); + expect(outcome).not.toHaveProperty('deployTargetSaved'); expect(listTargets(ROOT)).toEqual([]); }); - test('interactive + yes: saves a target with the imported names', async () => { + test('interactive, with a confirm that would say yes to anything: no target question, nothing saved', async () => { const calls: FakeCall[] = []; + const asked = mock(async (_message: string, _defaultValue: boolean) => true); const connector = createDokployConnector({ fetch: fakeFetch({ env: 'A=1', calls }), env: { T: 'x' }, cwd: ROOT, - confirm: async () => true, + confirm: asked, selectVars: async (c: readonly string[]) => c, }); const outcome = await withTTY(() => connector.import!(ctxWith(), { nonTty: false, baseUrl: 'https://d', application: 'app_yes', tokenEnv: 'T' })); expect(outcome.ok).toBe(true); if (!outcome.ok) return; - expect(outcome.deployTargetSaved).toBe(true); - const targets = listTargets(ROOT); - expect(targets.length).toBe(1); - expect(targets[0].kind).toBe('dokploy'); - expect(targets[0].vars).toEqual(['A']); - expect((targets[0].options as { applicationId: string }).applicationId).toBe('app_yes'); - }); - - test('interactive + no: nothing saved', async () => { - const calls: FakeCall[] = []; - const connector = createDokployConnector({ - fetch: fakeFetch({ env: 'A=1', calls }), - env: { T: 'x' }, - cwd: ROOT, - confirm: async () => false, - selectVars: async (c: readonly string[]) => c, - }); - const outcome = await withTTY(() => connector.import!(ctxWith(), { nonTty: false, baseUrl: 'https://d', application: 'app_no', tokenEnv: 'T' })); - expect(outcome.ok).toBe(true); - if (!outcome.ok) return; - expect(outcome.deployTargetSaved).toBe(false); + expect(outcome.imported.map((i) => i.varName)).toEqual(['A']); + expect(asked).not.toHaveBeenCalled(); expect(listTargets(ROOT)).toEqual([]); + expect(existsSync(join(ROOT, '.capy', 'deploy.json'))).toBe(false); }); - test('already has a target for this application: not offered again', async () => { + test('an existing target for this application is left exactly as it was', async () => { upsertTarget(ROOT, DOKPLOY_TARGET({ options: { baseUrl: 'https://d', applicationId: 'app_existing', tokenEnv: 'T' } })); + const before = readFileSync(join(ROOT, '.capy', 'deploy.json'), 'utf-8'); let confirmCalled = false; const calls: FakeCall[] = []; const connector = createDokployConnector({ @@ -638,11 +628,11 @@ describe('dokploy import — deploy-target offer', () => { const outcome = await withTTY(() => connector.import!(ctxWith(), { nonTty: false, baseUrl: 'https://d', application: 'app_existing', tokenEnv: 'T' })); expect(outcome.ok).toBe(true); if (!outcome.ok) return; - expect(outcome.deployTargetSaved).toBe(false); expect(confirmCalled).toBe(false); + expect(readFileSync(join(ROOT, '.capy', 'deploy.json'), 'utf-8')).toBe(before); }); - test('nothing imported: never offered', async () => { + test('nothing imported: nothing asked, nothing saved', async () => { const calls: FakeCall[] = []; let confirmCalled = false; const connector = createDokployConnector({ @@ -657,8 +647,8 @@ describe('dokploy import — deploy-target offer', () => { const outcome = await withTTY(() => connector.import!(ctxWith(), { nonTty: false, baseUrl: 'https://d', application: 'app_empty', tokenEnv: 'T' })); expect(outcome.ok).toBe(true); if (!outcome.ok) return; - expect(outcome.deployTargetSaved).toBe(false); expect(confirmCalled).toBe(false); + expect(listTargets(ROOT)).toEqual([]); }); }); @@ -770,7 +760,6 @@ describe('writeImportOutcome — unchanged-value connector backfill', () => { unchangedEntries: [{ varName: 'PORT', value: '3000', entry: backfillEntry }], skipped: [], warnings: [], - deployTargetSaved: false, }; const { wrote } = await writeImportOutcome(ctx, outcome, { push: true, dryRun: false }); @@ -808,7 +797,6 @@ describe('writeImportOutcome — unchanged-value connector backfill', () => { unchanged: [], skipped: [], warnings: [], - deployTargetSaved: false, }; const { wrote } = await writeImportOutcome(ctx, outcome, { push: true, dryRun: false }); expect(wrote).toBe(false); @@ -829,7 +817,6 @@ describe('writeImportOutcome — unchanged-value connector backfill', () => { unchangedEntries: [{ varName: 'PORT', value: '3000', entry: { provider: 'dokploy', source: 'import', created_at: 1, fingerprint: 'x' } }], skipped: [], warnings: [], - deployTargetSaved: false, }; const { wrote } = await writeImportOutcome(ctx, outcome, { push: true, dryRun: true }); expect(wrote).toBe(false); @@ -903,7 +890,6 @@ describe('connectCommand import output — no secret value on any output surface unchanged: [], skipped: [], warnings: [{ code: 'DOKPLOY_PLAINTEXT_REMAINS', names: ['API_KEY'] }], - deployTargetSaved: false, }); /** @@ -1446,6 +1432,8 @@ describe('resolveDokployImportSource', () => { baseUrl: 'https://asked.example.com', source: { kind: 'compose', id: 'compose_asked' }, tokenEnv: 'DOKPLOY_API_KEY', + // CAP-703: a URL typed at a prompt is marked, so the caller can offer it to the org. + baseUrlAsked: true, }); }); @@ -1491,6 +1479,8 @@ describe('resolveDokployImportSource', () => { baseUrl: 'https://asked.example.com', source: { kind: 'compose', id: 'compose_asked' }, tokenEnv: 'DOKPLOY_API_KEY', + // CAP-703: a URL typed at a prompt is marked, so the caller can offer it to the org. + baseUrlAsked: true, }); }); @@ -1505,6 +1495,8 @@ describe('resolveDokployImportSource', () => { baseUrl: 'https://asked.example.com', source: { kind: 'application', id: 'app_asked' }, tokenEnv: 'DOKPLOY_API_KEY', + // CAP-703: a URL typed at a prompt is marked, so the caller can offer it to the org. + baseUrlAsked: true, }); }); }); @@ -1630,7 +1622,7 @@ describe('dokploy import — compose source, end to end', () => { ); expect(outcome.ok).toBe(true); if (!outcome.ok) return; - expect(outcome.deployTargetSaved).toBe(false); + expect(outcome).not.toHaveProperty('deployTargetSaved'); expect(confirmCalls.length).toBe(0); expect(listTargets(ROOT)).toEqual([]); } finally { @@ -1681,7 +1673,6 @@ describe('dokploy import — compose source, end to end', () => { unchanged: [], skipped: [], warnings: [{ code: 'DOKPLOY_PLAINTEXT_REMAINS', names: ['SECRET_TOKEN'] }], - deployTargetSaved: false, }; const fakeMod: ConnectorModule = { @@ -1902,7 +1893,7 @@ describe('dokploy import — dry run', () => { ); expect(outcome.ok).toBe(true); if (!outcome.ok) return; - expect(outcome.deployTargetSaved).toBe(false); + expect(outcome).not.toHaveProperty('deployTargetSaved'); expect(confirmCalls.length).toBe(0); expect(listTargets(ROOT)).toEqual([]); } finally { @@ -1959,7 +1950,6 @@ describe('dokploy import — dry run', () => { unchanged: ['UNCHANGED_VAR'], skipped: [{ name: 'SKIPPED_VAR', code: 'IMPORT_CONFLICT_SKIPPED' }], warnings: [], - deployTargetSaved: false, wouldAsk: ['CONFLICT_VAR'], }; } @@ -2011,7 +2001,7 @@ describe('dokploy import — dry run', () => { expect(parsed.wouldAsk).toEqual(['CONFLICT_VAR']); expect(parsed.skipped).toEqual([{ name: 'SKIPPED_VAR', code: 'IMPORT_CONFLICT_SKIPPED' }]); expect(parsed.pushed).toBe(false); - expect(parsed.deployTargetSaved).toBe(false); + expect(parsed).not.toHaveProperty('deployTargetSaved'); }); test('terminal: no write/push, prints names only (never the sentinel value)', async () => { @@ -2285,7 +2275,7 @@ describe('dokploy import — --overwrite', () => { localPlaintext: { GONE: 'old-value', KEPT: 'still-here' }, } as unknown as ResolvedContext; - const outcome: ImportOutcome = { ok: true, source: { kind: 'compose', id: 'c1' }, imported: [], cleared: ['GONE'], replacedNames: [], unchanged: ['KEPT'], skipped: [], warnings: [], deployTargetSaved: false }; + const outcome: ImportOutcome = { ok: true, source: { kind: 'compose', id: 'c1' }, imported: [], cleared: ['GONE'], replacedNames: [], unchanged: ['KEPT'], skipped: [], warnings: [] }; const fakeModule: ConnectorModule = { name: 'dokploy', description: 'test double', diff --git a/tests/commands/connectDokployDiscovery.test.ts b/tests/commands/connectDokployDiscovery.test.ts index 29983a77..fdbc6fa4 100644 --- a/tests/commands/connectDokployDiscovery.test.ts +++ b/tests/commands/connectDokployDiscovery.test.ts @@ -28,7 +28,7 @@ * Every network-touching test injects its own `fetch` — no `mock.module()`, * so this file runs in `run-tests.sh`'s normal batch, not its isolated list. */ -import { describe, test, expect, spyOn } from 'bun:test'; +import { describe, test, expect, spyOn, mock } from 'bun:test'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; @@ -64,6 +64,7 @@ import type { ImportOutcome } from '../../src/commands/connectors/registry'; import type { FetchLike } from '../../src/deploy/dokployApi'; import type { ConnectOpts } from '../../src/commands/connectors/registry'; import type { KeepFile } from '../../src/types/index'; +import { withEnv, withTty } from '../helpers/processState'; function git(cwd: string, args: string[]): string { return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' }); @@ -758,7 +759,6 @@ const okImport = (names: readonly string[]): Extract { @@ -3954,3 +3954,91 @@ function tryGitRevParse(repoRoot: string, ref: string): boolean { return false; } } + +// ── CAP-703: connect --discover never creates, offers or writes a deploy target ── + +describe('connector.discover — never creates, offers or writes a deploy target (CAP-703)', () => { + test('an APPLICATION service, a real TTY, --yes: the import runs, nothing asks about a target, no .capy/deploy.json appears', async () => { + const ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'capy-discover-notarget-'))); + const globalDirName = `.capy-test-notarget-${process.pid}-${Date.now()}`; + try { + initRepo(ROOT, 'git@github.com:acme/widgets.git'); + const orgId = 'org_notarget_test'; + const userId = 'user_notarget_test'; + await withEnv({ CAPY_GLOBAL_DIR_NAME: globalDirName }, async () => { + await wrapAndSaveMasterKey(randomBytes(32), orgId, userId, { + coDecrypt: async (_oid: string, ct: string) => ct, + wrapOuterLayer: async (_oid: string, pt: string) => pt, + }); + const projectId = 'proj_notarget'; + writeFileSync( + join(ROOT, 'keep.lock'), + JSON.stringify({ version: '3.0', org_id: orgId, project_id: projectId, project_name: 'widgets', variables: {} }), + ); + const ctx: DiscoveryContext = { + orgId, + userId, + authService: {} as unknown as DiscoveryContext['authService'], + serviceClient: { + coDecrypt: async (_oid: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_oid: string, pt: string) => ({ ciphertext: pt }), + listBranches: async () => [], + createBranch: async () => ({ id: 'b1', name: 'production', project_id: projectId, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets: async () => { + throw new Error('must not be called — --no-push'); + }, + } as unknown as DiscoveryContext['serviceClient'], + }; + const fetchImpl: FetchLike = (async (url: string) => { + if (url.includes('project.all')) { + return { + status: 200, + ok: true, + text: async () => + JSON.stringify([ + { + projectId: 'proj_1', + name: 'acme', + environments: [{ environmentId: 'env_1', name: 'production', applications: [{ applicationId: 'app_1', name: 'widgets' }] }], + }, + ]), + }; + } + return { + status: 200, + ok: true, + text: async () => + JSON.stringify({ applicationId: 'app_1', env: 'A=1', createEnvFile: true, owner: 'acme', repository: 'widgets', sourceType: 'github', branch: 'main' }), + }; + }) as FetchLike; + + // Would say yes to ANY question, including a deploy-target offer if one still existed. + const confirm = mock(async (_message: string, _defaultValue: boolean) => true); + const connector = createDokployConnector({ + fetch: fetchImpl, + env: { T: 'x' }, + cwd: ROOT, + confirm, + selectVars: async (c: readonly string[]) => c, + }); + const outcome = await withTty({ stdin: true }, () => + connector.discover!(ctx, { nonTty: false, json: true, baseUrl: 'https://d', tokenEnv: 'T', yes: true, noPush: true } as ConnectOpts), + ); + + 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[0].outcome.imported.map((e) => e.varName)).toEqual(['A']); + expect(folder.environments[0].outcome).not.toHaveProperty('deployTargetSaved'); + expect(confirm.mock.calls.filter(([message]) => message.toLowerCase().includes('deploy target'))).toEqual([]); + expect(existsSync(join(ROOT, '.capy', 'deploy.json'))).toBe(false); + }); + } finally { + rmSync(join(homedir(), globalDirName), { recursive: true, force: true }); + rmSync(ROOT, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/commands/connectDokploySystemStoreSentinel.test.ts b/tests/commands/connectDokploySystemStoreSentinel.test.ts index b801897c..3dff562e 100644 --- a/tests/commands/connectDokploySystemStoreSentinel.test.ts +++ b/tests/commands/connectDokploySystemStoreSentinel.test.ts @@ -6,8 +6,9 @@ * with the org system store's `getConnectorSecret` returning a sentinel API * token. Proves the token never lands on any output surface OR in any file * this run writes — `.env`/keep.lock (via `writeImportedAndSync`) and - * `.capy/deploy.json` (via the deploy-target offer, `maybeOfferDeployTarget` - * → `upsertTarget`). + * `.capy/deploy.json` (CAP-703: an import no longer writes one at all, so this + * file's own assertion is that none exists — a target is `capy deploy dokploy + * --discover`'s job, never a connector import's). * * The token is deliberately DIFFERENT from the imported app variable's * value, so a pass here can't be an accident of both sentinels happening to @@ -31,7 +32,7 @@ import type { KeepFile } from '../../src/types/index'; const TOKEN_SENTINEL = 'sk_store_token_never_leak_4d2'; const APP_VALUE_SENTINEL = 'app-env-value-not-the-token'; -/** Fakes a real TTY — needed for the interactive deploy-target offer to even ask. */ +/** Fakes a real TTY — so a deploy-target question, if one ever came back, WOULD be asked. */ async function withTTY(fn: () => Promise): Promise { const saved = process.stdin.isTTY; Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); @@ -90,7 +91,7 @@ function scriptedFetch(): FetchLike { } describe('capy connect dokploy — system store token never appears in output or files (CAP-664)', () => { - test('the sentinel is on none of: console.log, console.error, process.stdout.write, keep.lock, or .capy/deploy.json', async () => { + test('the sentinel is on none of: console.log, console.error, process.stdout.write, or keep.lock — and no deploy.json is written', async () => { const dir = mkdtempSync(join(tmpdir(), 'capy-connect-dokploy-store-sentinel-')); const logMock = mock((..._a: unknown[]) => {}); @@ -111,8 +112,8 @@ describe('capy connect dokploy — system store token never appears in output or cwd: dir, getConnectorSecret, selectVars: async (candidates: readonly string[]) => candidates, - // Say yes to the deploy-target offer, so `.capy/deploy.json` gets - // written too (the offer only fires when something was imported). + // Would say yes to a deploy-target offer, if one still existed + // (CAP-703 removed it): the assertion below is that no target is written. confirm: async () => true, }); @@ -146,13 +147,8 @@ describe('capy connect dokploy — system store token never appears in output or expect(existsSync(keepLockPath)).toBe(true); expect(readFileSync(keepLockPath, 'utf-8')).not.toContain(TOKEN_SENTINEL); - const deployJsonPath = join(dir, '.capy', 'deploy.json'); - expect(existsSync(deployJsonPath)).toBe(true); - const deployJson = readFileSync(deployJsonPath, 'utf-8'); - expect(deployJson).not.toContain(TOKEN_SENTINEL); - // The saved target stores the app's connection details — never the - // token — so `applicationId` legitimately appears; the token must not. - expect(deployJson).toContain('app_1'); + // CAP-703: connect imports values; it never writes a deploy target. + expect(existsSync(join(dir, '.capy', 'deploy.json'))).toBe(false); } finally { logSpy.mockRestore(); errSpy.mockRestore(); diff --git a/tests/commands/connectImportJsonPurity.test.ts b/tests/commands/connectImportJsonPurity.test.ts index 2da708e9..f37fbe39 100644 --- a/tests/commands/connectImportJsonPurity.test.ts +++ b/tests/commands/connectImportJsonPurity.test.ts @@ -68,7 +68,6 @@ const successOutcome = (): ImportOutcome => ({ unchanged: [], skipped: [], warnings: [{ code: 'DOKPLOY_PLAINTEXT_REMAINS', names: ['API_KEY'] }], - deployTargetSaved: false, }); function fakeImportModule(outcome: ImportOutcome): ConnectorModule { diff --git a/tests/commands/deployDiscoverConfirm.test.ts b/tests/commands/deployDiscoverConfirm.test.ts new file mode 100644 index 00000000..e711e5c6 --- /dev/null +++ b/tests/commands/deployDiscoverConfirm.test.ts @@ -0,0 +1,269 @@ +/** + * `capy deploy dokploy --discover --plan --confirm ` (CAP-703): writing a confirmed plan. + * + * The properties under test: a plan that moved since it was confirmed does NOTHING (`PLAN_CHANGED`); + * each project's `.capy/deploy.json` target is merged into the repo's file without touching its other + * targets, in ONE PR per repo with several paths in one commit; a failure in one repo does not stop the + * others; running it again after the PRs merged opens nothing; and no value reaches GitHub or stdout. + * + * Fakes only (`tests/helpers/deployDiscoverWorld.ts`): the "GitHub" records what it was asked to write. + */ +import { describe, test, expect } from 'bun:test'; +import { runDeployDokployDiscover } from '../../src/commands/deployDiscover/command'; +import { + BASE_URL, + ENTRY_API, + ENTRY_API_STG, + ENTRY_SOLO, + ENTRY_WEB, + EXISTING_API_DEPLOY, + GITHUB_REPOS, + SENTINEL_CAPY, + SENTINEL_DOKPLOY, + githubFake, + makeWorld, + planFile, + writtenBy, +} from '../helpers/deployDiscoverWorld'; + +const PLAN = 'plan.json'; +const ENTRIES = [ENTRY_SOLO, ENTRY_API, ENTRY_API_STG, ENTRY_WEB]; + +async function run(text: string, opts: { dryRun?: boolean; confirm?: string }, world = makeWorld({ files: { [PLAN]: text } })) { + const result = await runDeployDokployDiscover( + { baseUrl: BASE_URL, 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): Promise { + return (await run(text, { dryRun: true })).body.plan_id; +} + +const nothingWritten = (world: ReturnType): 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(); +}; + +describe('confirm: a plan that moved is refused, and nothing is done', () => { + test('a wrong plan_id: PLAN_CHANGED, no request to Dokploy, Capy or GitHub at all', async () => { + const world = makeWorld({ 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.dokploy.listProjects).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([ENTRY_SOLO, ENTRY_API, ENTRY_API_STG, { ...ENTRY_WEB, vars: ['SESSION_SECRET'] }]); + const world = makeWorld({ 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 reordered = planFile([ENTRY_WEB, ENTRY_API_STG, ENTRY_API, ENTRY_SOLO]); + const { body } = await run(reordered, { 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/backend', 'acme/solo']); + expect(body.prs[0]).toMatchObject({ base: 'main', paths: ['api/.capy/deploy.json', 'web/.capy/deploy.json'], targets: ['dokploy-production', 'dokploy-staging', 'dokploy-production'] }); + expect(body.prs[1]).toMatchObject({ base: 'main', paths: ['.capy/deploy.json'], targets: ['dokploy-production'] }); + + const written = writtenBy(world.github); + expect(world.github.createPull).toHaveBeenCalledTimes(2); + expect(world.github.createCommit).toHaveBeenCalledTimes(2); + expect(world.github.createTree).toHaveBeenCalledTimes(2); + expect(written.commits.find((c) => c.repo === 'acme/backend')?.files).toBeDefined(); + expect(Object.keys(written.commits.find((c) => c.repo === 'acme/backend')?.files ?? {})).toEqual(['api/.capy/deploy.json', 'web/.capy/deploy.json']); + expect(Object.keys(written.commits.find((c) => c.repo === 'acme/solo')?.files ?? {})).toEqual(['.capy/deploy.json']); + expect(written.pulls.map((p) => [p.repo, p.base])).toEqual([['acme/backend', 'main'], ['acme/solo', 'main']]); + }); + + test('the target is the one `capy deploy` writes: CI mode, the Capy branch, the git base branch, the service id, no token', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + const solo = JSON.parse(writtenBy(world.github).commits.find((c) => c.repo === 'acme/solo')?.files['.capy/deploy.json'] as string); + expect(solo).toEqual({ + version: '1', + targets: { + 'dokploy-production': { + name: 'dokploy-production', + kind: 'dokploy', + branch: 'production', + vars: ['A', 'B', 'C', 'D'], + knownVars: ['A', 'B', 'C', 'D'], + options: { baseUrl: BASE_URL, composeId: 'cmp_solo' }, + mode: 'ci', + gitBaseBranch: 'main', + }, + }, + }); + // Same key order as the deploy flow's own target. + expect(Object.keys(solo.targets['dokploy-production'])).toEqual(['name', 'kind', 'branch', 'vars', 'knownVars', 'options', 'mode', 'gitBaseBranch']); + }); + + test('an Application target carries applicationId; knownVars is the project\'s whole variable set on that branch', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + const api = JSON.parse(writtenBy(world.github).commits.find((c) => c.repo === 'acme/backend')?.files['api/.capy/deploy.json'] as string); + expect(api.targets['dokploy-staging']).toMatchObject({ + kind: 'dokploy', + branch: 'staging', + vars: ['API_KEY', 'DATABASE_URL'], + knownVars: ['API_KEY', 'DATABASE_URL', 'REDIS_URL'], + options: { baseUrl: BASE_URL, applicationId: 'app_api_stg' }, + gitBaseBranch: 'staging', + }); + }); + + test('the file is MERGED: another target and other fields already in deploy.json are left exactly as they were', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + const api = JSON.parse(writtenBy(world.github).commits.find((c) => c.repo === 'acme/backend')?.files['api/.capy/deploy.json'] as string); + expect(Object.keys(api.targets)).toEqual(['ssm-prod', 'dokploy-production', 'dokploy-staging']); + expect(api.targets['ssm-prod']).toEqual(JSON.parse(EXISTING_API_DEPLOY).targets['ssm-prod']); + expect(api.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: { 'dokploy-production': { name: 'dokploy-production', kind: 'dokploy', branch: 'other', vars: [], options: { composeId: 'cmp_elsewhere' } } } }, + null, + 2, + ); + const repos = { ...GITHUB_REPOS, 'acme/solo': { ...GITHUB_REPOS['acme/solo'], files: { '.capy/deploy.json': taken } } }; + const text = planFile([ENTRY_SOLO]); + const world = makeWorld({ files: { [PLAN]: text }, repos }); + const approved = (await run(text, { dryRun: true }, world)).body.plan_id; + const { body } = await run(text, { confirm: approved }, world); + expect(body.prs[0].targets).toEqual(['dokploy-production-2']); + const written = JSON.parse(writtenBy(world.github).commits[0].files['.capy/deploy.json']); + expect(Object.keys(written.targets)).toEqual(['dokploy-production', 'dokploy-production-2']); + expect(written.targets['dokploy-production'].options).toEqual({ composeId: 'cmp_elsewhere' }); + }); + + test('the PR says what it adds, by name and id only', async () => { + const approved = await approvedId(planFile([ENTRY_SOLO])); + const { world } = await run(planFile([ENTRY_SOLO]), { confirm: approved }); + const written = writtenBy(world.github); + expect(written.pulls[0].title.length).toBeGreaterThan(0); + expect(written.pulls[0].body).toContain('dokploy-production'); + expect(written.pulls[0].body).toContain('cmp_solo'); + expect(written.pulls[0].head).toBe('capy/dokploy-targets-test-0000'); + }); + + test('nothing is recorded or pushed on the Capy side: the only Capy calls are the two reads', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + // The fake Capy client has no other method: any other call would have thrown. + expect(new Set(Object.keys(world.capy.client))).toEqual(new Set(['getOrgRepos', 'getSecretIndex'])); + }); +}); + +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/solo': { ...GITHUB_REPOS['acme/solo'], failing: ['createRef' as const] } }; + const text = planFile(ENTRIES); + const world = makeWorld({ files: { [PLAN]: text }, repos }); + const approved = (await run(text, { dryRun: true }, world)).body.plan_id; + 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/solo', code: 'KEEP_PR_BRANCH_FAILED' }]); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/backend']); + expect(world.github.createPull).toHaveBeenCalledTimes(1); + }); + + test('the first repo failing does not stop the second', async () => { + const repos = { ...GITHUB_REPOS, 'acme/backend': { ...GITHUB_REPOS['acme/backend'], failing: ['createCommit' as const] } }; + const text = planFile(ENTRIES); + const world = makeWorld({ files: { [PLAN]: text }, repos }); + const approved = (await run(text, { dryRun: true }, world)).body.plan_id; + const { body } = await run(text, { confirm: approved }, world); + expect(body.failed).toEqual([{ repo: 'acme/backend', code: 'KEEP_PR_COMMIT_FAILED' }]); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/solo']); + }); +}); + +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 repos = { ...GITHUB_REPOS, ...merged }; + const world = makeWorld({ files: { [PLAN]: text }, repos }); + 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(4); + expect(body.already_configured.map((a: any) => a.target_name)).toEqual(expect.arrayContaining(['dokploy-production', 'dokploy-staging'])); + nothingWritten(world); + }); + + test('--dry-run with --confirm changes nothing: the dry run wins', async () => { + const world = makeWorld({ files: { [PLAN]: planFile(ENTRIES) } }); + const approved = (await run(planFile(ENTRIES), { dryRun: true }, world)).body.plan_id; + 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('neither sentinel 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_DOKPLOY); + expect(everything).not.toContain(SENTINEL_CAPY); + // The check bites: names ARE written, so an empty capture cannot pass this. + expect(everything).toContain('SESSION_SECRET'); + expect(world.github.createBlob.mock.calls.length).toBeGreaterThan(0); + }); + + test('GitHub is read through the existing GithubApi only: a fake whose every method is a mock sees exactly the reads and the writes of one run', async () => { + const gh = githubFake(GITHUB_REPOS); + expect(new Set(Object.keys(gh.api))).toEqual( + new Set(['createBlob', 'createCommit', 'createPull', 'createRef', 'createTree', 'getBranchHead', 'getDefaultBranches', 'getFile', 'getRepo', 'listBranches']), + ); + }); +}); diff --git a/tests/commands/deployDiscoverDiscovery.test.ts b/tests/commands/deployDiscoverDiscovery.test.ts new file mode 100644 index 00000000..4768b6a3 --- /dev/null +++ b/tests/commands/deployDiscoverDiscovery.test.ts @@ -0,0 +1,488 @@ +/** + * `capy deploy dokploy --discover` (CAP-703): the discovery document. + * + * Fakes only: a fake Dokploy, fake Capy reads and a fake GitHub (see + * `tests/helpers/deployDiscoverWorld.ts`). Nothing touches a network or `gh`. + * + * The properties under test: repo + repo-relative path + branch matching (never a + * local path), per-field confidence, the branch mapping, the variable overlap, the + * unresolved reasons, idempotence (`already_configured`), ONE round of Capy calls + * however many projects there are, JSON on stdout (in a terminal too) with progress on + * stderr, and NO value anywhere in the output. + */ +import { describe, test, expect, spyOn } from 'bun:test'; +import { runDeployDokployDiscover } from '../../src/commands/deployDiscover/command'; +import { deployDokployDiscoverCommand } from '../../src/commands/deployDiscover/index'; +import { routeDeployDiscover } from '../../src/commands/deployDiscover/route'; +import { + BASE_URL, + GITHUB_REPOS, + P_API, + P_CONF, + P_NOBR, + P_SOLO, + P_TWIN1, + P_TWIN2, + P_WEAK, + P_WEB, + PROJECTS, + SENTINEL_CAPY, + SENTINEL_DOKPLOY, + SERVICES, + githubFake, + makeWorld, +} from '../helpers/deployDiscoverWorld'; +import { withTty } from '../helpers/processState'; + +const discover = async (world = makeWorld()) => { + const result = await runDeployDokployDiscover({ baseUrl: BASE_URL }, world.io); + return { world, ...result, body: result.body as any }; +}; + +const proposalFor = (body: any, serviceId: string, projectId?: string): any => + body.proposals.find((p: any) => p.service_id === serviceId && (projectId === undefined || p.project_id === projectId)); + +const unresolvedFor = (body: any, reason: string, serviceId: string): any => + body.unresolved.find((u: any) => u.reason === reason && u.service_id === serviceId); + +describe('discovery: an exact repo + path + branch match is certain', () => { + test('the standalone repo: its default branch maps to production, every field certain, the target is the CI target shape', async () => { + const { body, exitCode } = await discover(); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + const p = proposalFor(body, 'cmp_solo'); + expect(p.project_id).toBe(P_SOLO); + expect(p.branch).toBe('production'); + expect(p.git_branch).toBe('main'); + expect(p.evidence).toEqual({ + repo_match: true, + path_match: true, + branch_match: true, + var_overlap: { shared: 4, only_in_service: [], only_in_project: [] }, + conflicting_target: null, + }); + expect(p.confidence).toEqual({ project: 'certain', branch: 'certain', vars: 'certain' }); + expect(p.competing_proposal_ids).toEqual([]); + expect(p.target).toEqual({ + kind: 'dokploy', + baseUrl: BASE_URL, + composeId: 'cmp_solo', + branch: 'production', + gitBaseBranch: 'main', + mode: 'ci', + vars: ['A', 'B', 'C', 'D'], + }); + expect(p.plan_entry).toEqual({ project_id: P_SOLO, branch: 'production', service_id: 'cmp_solo', git_branch: 'main', vars: ['A', 'B', 'C', 'D'] }); + }); + + test('an Application is matched on its build path and carries applicationId, not composeId', async () => { + const { body } = await discover(); + const p = proposalFor(body, 'app_api_stg'); + expect(p.project_id).toBe(P_API); + expect(p.evidence.path_match).toBe(true); + expect(p.target.applicationId).toBe('app_api_stg'); + expect(p.target).not.toHaveProperty('composeId'); + }); +}); + +describe('discovery: a monorepo with two services at two paths', () => { + test('each service lands on the project linked to ITS folder of the same repo', async () => { + const { body } = await discover(); + const api = proposalFor(body, 'cmp_api'); + const web = proposalFor(body, 'cmp_web'); + expect(api.project_id).toBe(P_API); + expect(web.project_id).toBe(P_WEB); + expect(api.evidence.path_match).toBe(true); + expect(web.evidence.path_match).toBe(true); + expect(api.competing_proposal_ids).toEqual([]); + expect(web.competing_proposal_ids).toEqual([]); + }); + + test('services report the repo-relative path, the branch and variable NAMES', async () => { + const { body } = await discover(); + const api = body.services.find((s: any) => s.service_id === 'cmp_api'); + expect(api).toMatchObject({ + kind: 'compose', + name: 'api', + repo: { host: 'github.com', owner: 'acme', name: 'backend' }, + branch: 'main', + path: 'api', + }); + expect(api.env_var_names).toEqual(['API_KEY', 'DATABASE_URL', 'JWT_SECRET', 'ONLY_IN_SERVICE', 'REDIS_URL']); + const stg = body.services.find((s: any) => s.service_id === 'app_api_stg'); + expect(stg.path).toBe('api'); + const solo = body.services.find((s: any) => s.service_id === 'cmp_solo'); + expect(solo.path).toBe('.'); + }); + + test('projects list every linked repo + path, the branches and the variable names per branch', async () => { + const { body } = await discover(); + const project = body.projects.find((p: any) => p.project_id === P_API); + expect(project.repos).toEqual([{ host: 'github.com', owner: 'acme', name: 'backend', path: 'api' }]); + expect(project.branches).toEqual(['production', 'staging']); + expect(project.var_names_by_branch.staging).toEqual(['API_KEY', 'DATABASE_URL', 'REDIS_URL']); + expect(project.existing_targets_read).toBe(true); + expect(project.existing_targets.map((t: any) => t.name)).toEqual(['ssm-prod']); + // A project with no repo link cannot be matched or written to: it is not listed. + expect(body.projects.some((p: any) => p.project_id === 'proj_nolink')).toBe(false); + }); +}); + +describe('discovery: what cannot be matched is unresolved, with a code', () => { + test('a service with no git source: SERVICE_HAS_NO_GIT_SOURCE', async () => { + const { body } = await discover(); + expect(unresolvedFor(body, 'SERVICE_HAS_NO_GIT_SOURCE', 'cmp_raw')).toEqual({ reason: 'SERVICE_HAS_NO_GIT_SOURCE', service_id: 'cmp_raw', candidates: [] }); + expect(body.services.find((s: any) => s.service_id === 'cmp_raw')).toMatchObject({ repo: null, path: null }); + }); + + test('a repo no Capy project is linked to: NO_REPO_LINK', async () => { + const { body } = await discover(); + expect(unresolvedFor(body, 'NO_REPO_LINK', 'cmp_nolink')).toBeDefined(); + }); + + test('a linked repo but a folder under none of its projects: NO_MATCHING_PROJECT, naming the repo\'s projects', async () => { + const { body } = await discover(); + const u = unresolvedFor(body, 'NO_MATCHING_PROJECT', 'cmp_nomatch'); + expect(u.candidates.map((c: any) => [c.project_id, c.path])).toEqual([[P_API, 'api'], [P_WEB, 'web']]); + }); + + test('two projects match the same repo + path: AMBIGUOUS, and each candidate is a proposal that needs review', async () => { + const { body } = await discover(); + const u = unresolvedFor(body, 'AMBIGUOUS', 'cmp_twin'); + expect(u.candidates.map((c: any) => c.project_id)).toEqual([P_TWIN1, P_TWIN2]); + const proposals = body.proposals.filter((p: any) => p.service_id === 'cmp_twin'); + expect(proposals.map((p: any) => p.project_id)).toEqual([P_TWIN1, P_TWIN2]); + expect(proposals.every((p: any) => p.confidence.project === 'needs_review')).toBe(true); + const review = body.next_steps.find((s: any) => s.action === 'review'); + expect(review.proposal_ids).toEqual(expect.arrayContaining(proposals.map((p: any) => p.proposal_id))); + }); + + test('a git branch the project has no branch for: BRANCH_UNMAPPED, listing the project\'s branches', async () => { + const { body } = await discover(); + const u = unresolvedFor(body, 'BRANCH_UNMAPPED', 'cmp_feature'); + expect(u.project_id).toBe(P_NOBR); + expect(u.candidates).toEqual([{ project_id: P_NOBR, branch: 'production' }]); + expect(proposalFor(body, 'cmp_feature')).toBeUndefined(); + }); +}); + +describe('discovery: branch mapping', () => { + test('the repo\'s default branch is production; any other branch is the same-named Capy branch', async () => { + const { body } = await discover(); + expect(proposalFor(body, 'cmp_api').branch).toBe('production'); + expect(proposalFor(body, 'cmp_api').git_branch).toBe('main'); + const staging = proposalFor(body, 'app_api_stg'); + expect(staging.branch).toBe('staging'); + expect(staging.git_branch).toBe('staging'); + expect(staging.confidence.branch).toBe('certain'); + }); + + test('a default branch GitHub could not tell: the branch is only reached through the Dokploy environment name, needs_review, and the notice says why', async () => { + const gh = githubFake(GITHUB_REPOS); + const world = makeWorld({ + io: { github: () => ({ ...gh.api, getDefaultBranches: async () => ({ ok: false, kind: 'REQUEST_FAILED' as const }) }) as never }, + }); + const { body } = await discover(world); + // "main" is not a Capy branch of the solo project and the default is unknown, so production is NOT derived + // from it; the Dokploy environment is named "production", which the project does have. + const p = proposalFor(body, 'cmp_solo'); + expect(p.branch).toBe('production'); + expect(p.evidence.branch_match).toBe(false); + expect(p.confidence.branch).toBe('needs_review'); + expect(body.notices).toEqual([{ code: 'KEEP_PR_BASE_UNRESOLVED' }]); + }); + + test('a repo whose default branch is develop: the develop service in the "staging" environment goes to staging, never production', async () => { + const project = { + id: 'proj_mono', + name: 'mono', + repos: [{ owner: 'acme', name: 'mono', path: 'deploy' }], + branches: { production: ['P1', 'S1'], staging: ['P1', 'S1'] }, + }; + const service = (id: string, environment: string, branch: string) => ({ + id, + kind: 'compose' as const, + name: id, + environment, + owner: 'acme', + repository: 'mono', + sourceType: 'github', + branch, + composePath: 'deploy/docker-compose.yml', + envNames: ['P1', 'S1'], + }); + const world = makeWorld({ + projects: [project], + services: [service('cmp_stg', 'staging', 'develop'), service('cmp_prod', 'production', 'main')], + repos: { 'acme/mono': { defaultBranch: 'develop', branches: ['develop', 'main'] } }, + }); + const { body } = await discover(world); + const stg = proposalFor(body, 'cmp_stg'); + expect(stg.branch).toBe('staging'); + expect(stg.git_branch).toBe('develop'); + expect(stg.evidence.branch_match).toBe(false); + expect(stg.confidence.branch).toBe('needs_review'); + const prod = proposalFor(body, 'cmp_prod'); + expect(prod.branch).toBe('production'); + // The two services no longer compete for one Capy branch. + expect(stg.competing_proposal_ids).toEqual([]); + expect(prod.competing_proposal_ids).toEqual([]); + }); + + test('a git branch with no Capy branch of its name, on a service whose environment names none either: BRANCH_UNMAPPED, not a guess', async () => { + const { body } = await discover(); + expect(unresolvedFor(body, 'BRANCH_UNMAPPED', 'cmp_feature')).toBeDefined(); + }); +}); + +describe('discovery: the variable overlap', () => { + test('shared names are the proposed vars; the rest are listed on each side', async () => { + const { body } = await discover(); + const api = proposalFor(body, 'cmp_api'); + expect(api.evidence.var_overlap).toEqual({ shared: 4, only_in_service: ['ONLY_IN_SERVICE'], only_in_project: [] }); + expect(api.target.vars).toEqual(['API_KEY', 'DATABASE_URL', 'JWT_SECRET', 'REDIS_URL']); + expect(api.confidence.vars).toBe('certain'); + const web = proposalFor(body, 'cmp_web'); + expect(web.evidence.var_overlap).toEqual({ shared: 3, only_in_service: [], only_in_project: [] }); + }); + + test('an overlap under half of the service\'s variables is needs_review', async () => { + const { body } = await discover(); + const weak = proposalFor(body, 'cmp_weak', P_WEAK); + expect(weak.evidence.var_overlap.shared).toBe(1); + expect(weak.evidence.var_overlap.only_in_service).toEqual(['Y1', 'Y2', 'Y3']); + expect(weak.evidence.var_overlap.only_in_project).toHaveLength(9); + expect(weak.confidence.vars).toBe('needs_review'); + expect(weak.confidence.project).toBe('certain'); + expect(body.next_steps.find((s: any) => s.action === 'review').proposal_ids).toContain(weak.proposal_id); + }); + + test('a service with no variables in common is needs_review with an empty overlap', async () => { + const world = makeWorld({ + services: [{ ...SERVICES[0], id: 'cmp_disjoint', envNames: ['NOT_IN_PROJECT'] }], + }); + const { body } = await discover(world); + const p = proposalFor(body, 'cmp_disjoint'); + expect(p.evidence.var_overlap.shared).toBe(0); + expect(p.target.vars).toEqual([]); + expect(p.confidence.vars).toBe('needs_review'); + }); +}); + +describe('discovery: idempotent', () => { + test('a project branch that already has this service\'s Dokploy target is already_configured, not proposed again', async () => { + const { body } = await discover(); + expect(body.already_configured).toEqual([{ project_id: P_CONF, branch: 'production', service_id: 'cmp_conf', target_name: 'dokploy-production' }]); + expect(proposalFor(body, 'cmp_conf')).toBeUndefined(); + }); + + test('another service for a branch that already has a Dokploy target is proposed, flagged with the conflicting target and needs_review', async () => { + const { body } = await discover(); + const other = proposalFor(body, 'cmp_conf_other'); + expect(other.project_id).toBe(P_CONF); + expect(other.evidence.conflicting_target).toBe('dokploy-production'); + expect(other.confidence.project).toBe('needs_review'); + }); + + test('without GitHub the existing targets are unknown, and discovery still answers with a coded notice', async () => { + const world = makeWorld({ io: { github: () => undefined } }); + const { body, exitCode } = await discover(world); + expect(exitCode).toBe(0); + expect(body.notices).toEqual([{ code: 'KEEP_PR_GH_UNAVAILABLE' }]); + expect(body.projects.every((p: any) => p.existing_targets_read === false)).toBe(true); + expect(body.already_configured).toEqual([]); + }); +}); + +describe('discovery: the structured answer', () => { + test('next_steps are structured actions, not prose, and the plan schema is referenced', async () => { + const { body } = await discover(); + expect(body.plan_schema_ref).toEqual({ command: 'capy help --json', pointer: '/schemas/deploy_dokploy_plan' }); + expect(body.next_steps).toContainEqual({ action: 'write_plan', schema: body.plan_schema_ref }); + expect(body.next_steps).toContainEqual({ run: 'capy deploy dokploy --discover --plan --dry-run' }); + const resolve = body.next_steps.find((s: any) => s.action === 'resolve'); + expect(resolve.service_ids).toEqual(expect.arrayContaining(['cmp_raw', 'cmp_nolink', 'cmp_nomatch', 'cmp_feature', 'cmp_twin'])); + }); + + test('--dry-run alone (no --plan) is discovery', async () => { + const world = makeWorld(); + const result = await runDeployDokployDiscover({ baseUrl: BASE_URL, dryRun: true }, world.io); + expect(result.exitCode).toBe(0); + expect((result.body as any).proposals.length).toBeGreaterThan(0); + }); +}); + +describe('discovery: one round of calls, however many projects and services', () => { + test('Capy is asked ONCE for the repo links and ONCE for the secret index; GitHub reads default branches in ONE batched call', async () => { + const world = makeWorld(); + await discover(world); + expect(world.capy.getOrgRepos).toHaveBeenCalledTimes(1); + expect(world.capy.getSecretIndex).toHaveBeenCalledTimes(1); + expect(world.github.getDefaultBranches).toHaveBeenCalledTimes(1); + expect(world.github.getRepo).toHaveBeenCalledTimes(0); + expect(world.github.listBranches).toHaveBeenCalledTimes(0); + // Dokploy: the tree once, then each service exactly once, never per project. + expect(world.dokploy.listProjects).toHaveBeenCalledTimes(1); + expect(world.dokploy.getCompose.mock.calls.length + world.dokploy.getApplication.mock.calls.length).toBe(SERVICES.length); + // One deploy.json read per linked folder that a service tracks, never more. + expect(world.github.getFile.mock.calls.length).toBeLessThanOrEqual(8); + }); + + test('adding projects adds no Capy calls', async () => { + const many = Array.from({ length: 40 }, (_, i) => ({ + id: `proj_extra_${i}`, + name: `extra-${i}`, + repos: [{ owner: 'acme', name: 'solo', path: `extra/${i}` }], + branches: { production: ['E1'] }, + })); + const world = makeWorld({ projects: [...PROJECTS, ...many] }); + await discover(world); + expect(world.capy.getOrgRepos).toHaveBeenCalledTimes(1); + expect(world.capy.getSecretIndex).toHaveBeenCalledTimes(1); + expect(world.github.getDefaultBranches).toHaveBeenCalledTimes(1); + }); +}); + +describe('discovery: refusals are coded', () => { + test('no base URL: DOKPLOY_SETTINGS_MISSING, before anything is read', async () => { + const world = makeWorld(); + const result = await runDeployDokployDiscover({}, world.io); + // A refusal only a flag can answer exits 3 (needs input), so an agent branches on it. + expect(result.exitCode).toBe(3); + expect((result.body as any).code).toBe('DOKPLOY_SETTINGS_MISSING'); + expect(world.dokploy.listProjects).not.toHaveBeenCalled(); + }); + + test('a saved Dokploy target\'s base URL is used when --base-url is not passed (the way connect --discover reads it)', async () => { + const world = makeWorld({ io: { savedBaseUrl: () => BASE_URL } }); + const result = await runDeployDokployDiscover({}, world.io); + expect(result.exitCode).toBe(0); + expect((result.body as any).base_url).toBe(BASE_URL); + }); + + test('a missing Dokploy key: DOKPLOY_TOKEN_MISSING with where to get it, zero Dokploy requests, no prompt', async () => { + const world = makeWorld(); + const io = { ...world.io, getConnectorSecret: async () => null }; + const result = await runDeployDokployDiscover({ baseUrl: BASE_URL }, io); + const body = result.body as any; + expect(result.exitCode).toBe(1); + expect(body.code).toBe('DOKPLOY_TOKEN_MISSING'); + expect(typeof body.hint).toBe('string'); + expect(body.dashboard).toBe(BASE_URL); + expect(world.dokploy.listProjects).not.toHaveBeenCalled(); + }); + + test('the key is asked for without a prompt (interactive false) and from the connector entry', async () => { + const world = makeWorld(); + await discover(world); + expect(world.getConnectorSecret).toHaveBeenCalledTimes(1); + const [name, opts] = world.getConnectorSecret.mock.calls[0] as [string, { interactive: boolean }]; + expect(name).toBe('_CONNECTOR_DOKPLOY_API_KEY'); + expect(opts.interactive).toBe(false); + }); + + test('--confirm without --plan: PLAN_REQUIRED', async () => { + const world = makeWorld(); + const result = await runDeployDokployDiscover({ baseUrl: BASE_URL, confirm: 'abc' }, world.io); + expect((result.body as any).code).toBe('PLAN_REQUIRED'); + expect(result.exitCode).toBe(1); + }); + + test('a repo-links 404 from an older service is a coded refusal', async () => { + const world = makeWorld(); + const { CapyError } = await import('../../src/types/index'); + const client = { ...world.capy.client, getOrgRepos: async () => { throw new CapyError('x', 'REPO_LINKS_UNSUPPORTED'); } }; + const result = await runDeployDokployDiscover({ baseUrl: BASE_URL }, { ...world.io, context: async () => ({ ok: true, orgId: 'org_1', client: client as never }) }); + expect((result.body as any).code).toBe('REPO_LINKS_UNSUPPORTED'); + }); +}); + +describe('discovery: JSON on stdout (terminal or not), progress on stderr', () => { + test('in a fake terminal the command still prints exactly one JSON document and exits 0: no refusal, no prompt', async () => { + const world = makeWorld(); + const out = spyOn(console, 'log').mockImplementation((() => undefined) as never); + try { + const code = await withTty({ stdin: true, stdout: true }, () => deployDokployDiscoverCommand({ baseUrl: BASE_URL }, false, world.io)); + expect(code).toBe(0); + expect(out).toHaveBeenCalledTimes(1); + const printed = JSON.parse(String(out.mock.calls[0][0])); + expect(printed.ok).toBe(true); + expect(printed.proposals.length).toBeGreaterThan(0); + expect(printed).not.toHaveProperty('code'); + } finally { + out.mockRestore(); + } + }); + + test('progress goes to the progress sink (stderr), never into the JSON on stdout', async () => { + const world = makeWorld(); + const out = spyOn(console, 'log').mockImplementation((() => undefined) as never); + try { + await deployDokployDiscoverCommand({ baseUrl: BASE_URL }, false, world.io); + const stdoutText = String(out.mock.calls[0][0]); + const lines = world.progress.mock.calls.map(([line]) => String(line)); + expect(lines.length).toBeGreaterThan(0); + lines.forEach((line) => expect(stdoutText).not.toContain(line)); + expect(() => JSON.parse(stdoutText)).not.toThrow(); + } finally { + out.mockRestore(); + } + }); + + test('a refusal is JSON on stdout too', async () => { + const world = makeWorld(); + const out = spyOn(console, 'log').mockImplementation((() => undefined) as never); + try { + const code = await deployDokployDiscoverCommand({}, false, world.io); + expect(code).toBe(3); + expect(JSON.parse(String(out.mock.calls[0][0])).code).toBe('DOKPLOY_SETTINGS_MISSING'); + } finally { + out.mockRestore(); + } + }); +}); + +describe('discovery: routing from `capy deploy --discover`', () => { + test('only the dokploy target has a discovery: any other is DISCOVER_UNSUPPORTED_TARGET', async () => { + const out = spyOn(console, 'log').mockImplementation((() => undefined) as never); + try { + const code = await routeDeployDiscover('aws-ssm', { discover: true }, false, false); + expect(code).toBe(1); + expect(JSON.parse(String(out.mock.calls[0][0])).code).toBe('DISCOVER_UNSUPPORTED_TARGET'); + } finally { + out.mockRestore(); + } + }); + + test('--plan, --confirm and --base-url without --discover are refused INVALID_FORMAT', async () => { + const out = spyOn(console, 'log').mockImplementation((() => undefined) as never); + try { + const code = await routeDeployDiscover('dokploy', { plan: 'p.json' }, false, false); + expect(code).toBe(1); + expect(JSON.parse(String(out.mock.calls[0][0])).code).toBe('INVALID_FORMAT'); + } finally { + out.mockRestore(); + } + }); + + test('no discover flags at all: not ours (undefined), so `capy deploy dokploy` keeps its own flow', async () => { + expect(await routeDeployDiscover('dokploy', {}, false, false)).toBeUndefined(); + expect(await routeDeployDiscover(undefined, {}, true, false)).toBeUndefined(); + }); +}); + +describe('discovery: no value appears anywhere', () => { + test('neither the Dokploy sentinel nor the Capy sentinel is in the document, the progress lines or the refusals', async () => { + const world = makeWorld(); + const { body } = await discover(world); + const everything = JSON.stringify(body) + world.progress.mock.calls.map(([l]) => String(l)).join('\n'); + expect(everything).not.toContain(SENTINEL_DOKPLOY); + expect(everything).not.toContain(SENTINEL_CAPY); + expect(everything).not.toContain('SENTINEL'); + // The check bites: the planted values ARE in what the fakes returned, and the NAMES do appear in the output. + expect((await world.dokploy.getCompose('cmp_solo')).env).toContain(SENTINEL_DOKPLOY); + expect((await world.capy.getSecretIndex('org_1')).rows[0].value_hash).toContain(SENTINEL_CAPY); + expect(JSON.stringify(body)).toContain('DATABASE_URL'); + }); +}); + diff --git a/tests/commands/deployDiscoverFacts.test.ts b/tests/commands/deployDiscoverFacts.test.ts new file mode 100644 index 00000000..4d02befa --- /dev/null +++ b/tests/commands/deployDiscoverFacts.test.ts @@ -0,0 +1,116 @@ +/** + * The small pure pieces under `capy deploy dokploy --discover` (CAP-703): repo-relative paths, + * the deploy.json reader and the merge. No fakes needed. + */ +import { describe, test, expect } from 'bun:test'; +import { deployJsonPathOf, parseDeployJson, relPathOf, servicePathOf, envVarNamesOf, isSameOrAncestorPath } from '../../src/commands/deployDiscover/facts'; +import { mergeDeployJson } from '../../src/commands/deployDiscover/apply'; +import { dokployTargetOf, targetBaseName } from '../../src/commands/deployDiscover/evaluate'; +import { runDeployDokployDiscover } from '../../src/commands/deployDiscover/command'; +import { BASE_URL, GITHUB_REPOS, ENTRY_SOLO, SENTINEL_DOKPLOY, makeWorld, planFile } from '../helpers/deployDiscoverWorld'; + +describe('repo-relative paths, never a local path', () => { + test('the root is ".", slashes and "./" are normalized', () => { + expect(relPathOf('')).toBe('.'); + expect(relPathOf('/')).toBe('.'); + expect(relPathOf('./api/')).toBe('api'); + expect(relPathOf(undefined)).toBe('.'); + expect(relPathOf('a\\b')).toBe('a/b'); + }); + + test('a compose service lives in its compose file\'s folder; an Application in its build path', () => { + expect(servicePathOf({ serviceKind: 'compose', composePath: 'docker-compose.yml' })).toBe('.'); + expect(servicePathOf({ serviceKind: 'compose', composePath: './backend/deployment/prod/docker-compose.yml' })).toBe('backend/deployment/prod'); + expect(servicePathOf({ serviceKind: 'compose' })).toBe('.'); + expect(servicePathOf({ serviceKind: 'application', buildPath: '/' })).toBe('.'); + expect(servicePathOf({ serviceKind: 'application', buildPath: '/api' })).toBe('api'); + expect(servicePathOf({ serviceKind: 'application' })).toBe('.'); + }); + + test('deploy.json sits under the project path', () => { + expect(deployJsonPathOf('.')).toBe('.capy/deploy.json'); + expect(deployJsonPathOf('backend/api')).toBe('backend/api/.capy/deploy.json'); + }); + + test('a folder is above another only on a whole path segment', () => { + expect(isSameOrAncestorPath('.', 'a/b')).toBe(true); + expect(isSameOrAncestorPath('a', 'a/b')).toBe(true); + expect(isSameOrAncestorPath('a', 'ab/c')).toBe(false); + expect(isSameOrAncestorPath('a/b', 'a')).toBe(false); + }); +}); + +describe('env names', () => { + test('only NAMES are taken from a Dokploy env: sorted, unique, the Capy block and runtime pair left out, the value dropped', () => { + const env = [`B=${SENTINEL_DOKPLOY}`, `A=${SENTINEL_DOKPLOY}`, `A=again`, '_SECRETS_BLOB=x', 'PROJECT_KEY=y'].join('\n'); + const names = envVarNamesOf(env); + expect(names).toEqual(['A', 'B']); + expect(JSON.stringify(names)).not.toContain(SENTINEL_DOKPLOY); + expect(envVarNamesOf(null)).toEqual([]); + }); +}); + +describe('deploy.json reading and merging', () => { + test('no file is an empty, valid answer; a non-version-1 or non-JSON file is not', () => { + expect(parseDeployJson(null)).toEqual({ ok: true, targets: [], raw: null }); + expect(parseDeployJson('{nope')).toEqual({ ok: false }); + expect(parseDeployJson(JSON.stringify({ version: '2', targets: {} }))).toEqual({ ok: false }); + expect(parseDeployJson('[]')).toEqual({ ok: false }); + }); + + test('targets are read with their kind, branch and Dokploy service id', () => { + const parsed = parseDeployJson( + JSON.stringify({ + version: '1', + targets: { + a: { kind: 'dokploy', branch: 'production', options: { composeId: 'c1' } }, + b: { kind: 'dokploy', branch: 'staging', options: { applicationId: 'a1' } }, + c: { kind: 'aws-ssm', branch: 'production', options: {} }, + }, + }), + ); + expect(parsed.ok && parsed.targets).toEqual([ + { name: 'a', kind: 'dokploy', branch: 'production', service_id: 'c1' }, + { name: 'b', kind: 'dokploy', branch: 'staging', service_id: 'a1' }, + { name: 'c', kind: 'aws-ssm', branch: 'production', service_id: null }, + ]); + }); + + test('a new file gets version 1 and the targets; an existing one keeps every other field, target and its order', () => { + const target = dokployTargetOf({ + name: 'dokploy-production', + entry: { project_id: 'p', branch: 'production', service_id: 'c9', git_branch: 'main', vars: ['B', 'A'] }, + service: { service_id: 'c9', kind: 'compose', name: 'x', dokploy_project: 'd', environment: 'production', repo: null, branch: null, path: null, env_var_names: [] }, + baseUrl: BASE_URL, + knownVars: ['A', 'B', 'C'], + }); + const fresh = parseDeployJson(null); + if (!fresh.ok) throw new Error('unreachable'); + expect(JSON.parse(mergeDeployJson(fresh, [target]))).toEqual({ version: '1', targets: { 'dokploy-production': target } }); + expect(mergeDeployJson(fresh, [target]).endsWith('\n')).toBe(true); + + const existing = parseDeployJson(JSON.stringify({ version: '1', note: 'keep me', targets: { zeta: { kind: 'x' }, alpha: { kind: 'y' } } })); + if (!existing.ok) throw new Error('unreachable'); + const merged = JSON.parse(mergeDeployJson(existing, [target])); + expect(merged.note).toBe('keep me'); + expect(Object.keys(merged.targets)).toEqual(['zeta', 'alpha', 'dokploy-production']); + expect(target.vars).toEqual(['A', 'B']); + }); + + test('a target name restricted to what deploy allows (lowercase alphanumerics and dashes)', () => { + expect(targetBaseName('production')).toBe('dokploy-production'); + expect(targetBaseName('Feature/X_1')).toBe('dokploy-feature-x-1'); + expect(targetBaseName('///')).toBe('dokploy'); + }); +}); + +describe('an unreadable deploy.json is a coded error, never a silent overwrite', () => { + test('a malformed file on the default branch: the plan is invalid with KEEP_PR_READ_FAILED, and nothing is written', async () => { + const repos = { ...GITHUB_REPOS, 'acme/solo': { ...GITHUB_REPOS['acme/solo'], files: { '.capy/deploy.json': '{not json' } } }; + const text = planFile([ENTRY_SOLO]); + const world = makeWorld({ files: { 'p.json': text }, repos }); + const result = await runDeployDokployDiscover({ baseUrl: BASE_URL, plan: 'p.json', dryRun: true }, world.io); + expect((result.body as any).errors).toEqual([{ path: 'entries[0].project_id', code: 'KEEP_PR_READ_FAILED' }]); + expect(world.github.createPull).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/deployDiscoverPlan.test.ts b/tests/commands/deployDiscoverPlan.test.ts new file mode 100644 index 00000000..4ba9166e --- /dev/null +++ b/tests/commands/deployDiscoverPlan.test.ts @@ -0,0 +1,240 @@ +/** + * `capy deploy dokploy --discover --plan --dry-run` (CAP-703): validating a plan. + * + * Every check has a coded, PATH-PRECISE error (`{ path: "entries[3].service_id", code: "SERVICE_NOT_FOUND" }`), + * a valid plan gets a stable `plan_id` and the summary lines an agent relays to the human, and a dry run + * changes nothing. Fakes only (`tests/helpers/deployDiscoverWorld.ts`). + */ +import { describe, test, expect } from 'bun:test'; +import { runDeployDokployDiscover } from '../../src/commands/deployDiscover/command'; +import { + BASE_URL, + ENTRY_API, + ENTRY_API_STG, + ENTRY_SOLO, + ENTRY_WEB, + P_API, + P_CONF, + P_NOLINK, + SENTINEL_CAPY, + SENTINEL_DOKPLOY, + makeWorld, + planFile, +} from '../helpers/deployDiscoverWorld'; + +const PLAN = 'plan.json'; + +async function check(text: string, over: { dryRun?: boolean; confirm?: string; baseUrl?: string; world?: ReturnType } = {}) { + const world = over.world ?? makeWorld({ files: { [PLAN]: text } }); + const result = await runDeployDokployDiscover( + { baseUrl: over.baseUrl ?? BASE_URL, plan: PLAN, dryRun: over.dryRun ?? true, ...(over.confirm === undefined ? {} : { confirm: over.confirm }) }, + world.io, + ); + return { world, exitCode: result.exitCode, body: result.body as any }; +} + +const errorsOf = async (entries: readonly unknown[]) => (await check(planFile(entries))).body.errors as Array<{ path: string; code: string }>; + +describe('plan: the shape is checked first, path-precisely', () => { + test('a file that is not JSON: INVALID_FORMAT at the root', async () => { + const { body, exitCode } = await check('{nope'); + expect(exitCode).toBe(1); + expect(body).toMatchObject({ ok: false, code: 'PLAN_INVALID', errors: [{ path: '', code: 'INVALID_FORMAT' }] }); + }); + + test('a file that cannot be read: PLAN_FILE_UNREADABLE', async () => { + const world = makeWorld({ files: {} }); + const result = await runDeployDokployDiscover({ baseUrl: BASE_URL, plan: 'missing.json', dryRun: true }, world.io); + expect((result.body as any).code).toBe('PLAN_FILE_UNREADABLE'); + }); + + test('a wrong version, an unknown key and a bad variable name each name their own path', async () => { + const text = JSON.stringify({ + version: 2, + extra: true, + entries: [{ ...ENTRY_SOLO }, { ...ENTRY_API, vars: ['DATABASE_URL', 'bad name'], extra: 1 }, { project_id: '', branch: 'production', service_id: 'x', git_branch: 'main', vars: [] }], + }); + const { body } = await check(text); + expect(body.code).toBe('PLAN_INVALID'); + expect(body.errors).toEqual([ + { path: 'extra', code: 'INVALID_FORMAT' }, + { path: 'version', code: 'INVALID_FORMAT' }, + { path: 'entries[1].extra', code: 'INVALID_FORMAT' }, + { path: 'entries[1].vars[1]', code: 'INVALID_FORMAT' }, + { path: 'entries[2].project_id', code: 'INVALID_FORMAT' }, + { path: 'entries[2].vars', code: 'INVALID_FORMAT' }, + ]); + }); + + test('no entries: INVALID_FORMAT at entries', async () => { + const { body } = await check(JSON.stringify({ version: 1, entries: [] })); + expect(body.errors).toEqual([{ path: 'entries', code: 'INVALID_FORMAT' }]); + }); + + test('a shape error stops before anything is read from Dokploy, Capy or GitHub', async () => { + const world = makeWorld({ files: { [PLAN]: '{nope' } }); + await check('{nope', { world }); + expect(world.dokploy.listProjects).not.toHaveBeenCalled(); + expect(world.capy.getOrgRepos).not.toHaveBeenCalled(); + expect(world.github.getDefaultBranches).not.toHaveBeenCalled(); + }); +}); + +describe('plan: every lookup has its own coded error at a precise path', () => { + test('SERVICE_NOT_FOUND at entries[1].service_id', async () => { + expect(await errorsOf([ENTRY_SOLO, { ...ENTRY_API, service_id: 'cmp_missing' }])).toContainEqual({ path: 'entries[1].service_id', code: 'SERVICE_NOT_FOUND' }); + }); + + test('PROJECT_NOT_FOUND at entries[2].project_id (a project the caller cannot see is not found)', async () => { + expect(await errorsOf([ENTRY_SOLO, ENTRY_API, { ...ENTRY_WEB, project_id: 'proj_nobody' }])).toContainEqual({ path: 'entries[2].project_id', code: 'PROJECT_NOT_FOUND' }); + }); + + test('BRANCH_NOT_FOUND for a Capy branch (entries[0].branch) and for a git branch GitHub does not have (entries[0].git_branch)', async () => { + const errors = await errorsOf([{ ...ENTRY_SOLO, branch: 'no-such-branch', git_branch: 'no-such-git-branch' }]); + expect(errors).toContainEqual({ path: 'entries[0].branch', code: 'BRANCH_NOT_FOUND' }); + expect(errors).toContainEqual({ path: 'entries[0].git_branch', code: 'BRANCH_NOT_FOUND' }); + }); + + test('VAR_NOT_FOUND at the exact variable', async () => { + expect(await errorsOf([{ ...ENTRY_SOLO, vars: ['A', 'NOT_IN_PROJECT', 'B'] }])).toEqual([{ path: 'entries[0].vars[1]', code: 'VAR_NOT_FOUND' }]); + }); + + test('DUPLICATE_ENTRY at the later entry that assigns the same project + branch', async () => { + const errors = await errorsOf([ENTRY_SOLO, ENTRY_API, { ...ENTRY_SOLO, service_id: 'cmp_weak' }]); + expect(errors).toContainEqual({ path: 'entries[2]', code: 'DUPLICATE_ENTRY' }); + }); + + test('TARGET_EXISTS at the branch: a Dokploy target for a DIFFERENT service is already on that project branch', async () => { + const errors = await errorsOf([{ project_id: P_CONF, branch: 'production', service_id: 'cmp_conf_other', git_branch: 'main', vars: ['C1'] }]); + expect(errors).toEqual([{ path: 'entries[0].branch', code: 'TARGET_EXISTS' }]); + }); + + test('NO_REPO_LINK: a project with no GitHub repo link has nowhere for a PR to go', async () => { + expect(await errorsOf([{ project_id: P_NOLINK, branch: 'production', service_id: 'cmp_solo', git_branch: 'main', vars: ['Z1'] }])).toContainEqual({ + path: 'entries[0].project_id', + code: 'NO_REPO_LINK', + }); + }); + + test('REPO_MISMATCH: a service that tracks a repo the project is not linked to', async () => { + const errors = await errorsOf([{ project_id: P_API, branch: 'production', service_id: 'cmp_solo', git_branch: 'main', vars: ['DATABASE_URL'] }]); + expect(errors).toContainEqual({ path: 'entries[0].service_id', code: 'REPO_MISMATCH' }); + }); + + test('several problems at once are all reported, each at its own path, in entry order', async () => { + const errors = await errorsOf([ + { ...ENTRY_SOLO, vars: ['A', 'NOPE'] }, + { ...ENTRY_API, service_id: 'cmp_missing' }, + { ...ENTRY_WEB, project_id: 'proj_nobody' }, + ]); + expect(errors.map((e) => `${e.path}:${e.code}`)).toEqual([ + 'entries[0].vars[1]:VAR_NOT_FOUND', + 'entries[1].service_id:SERVICE_NOT_FOUND', + 'entries[2].project_id:PROJECT_NOT_FOUND', + ]); + }); + + test('GitHub unavailable: the git branch and the existing targets cannot be checked, and that is a coded error, not a pass', async () => { + const world = makeWorld({ files: { [PLAN]: planFile([ENTRY_SOLO]) }, io: { github: () => undefined } }); + const { body } = await check(planFile([ENTRY_SOLO]), { world }); + expect(body.errors).toEqual([ + { path: 'entries[0].git_branch', code: 'KEEP_PR_GH_UNAVAILABLE' }, + { path: 'entries[0].project_id', code: 'KEEP_PR_GH_UNAVAILABLE' }, + ]); + }); + + test('an invalid plan opens nothing on GitHub', async () => { + const world = makeWorld({ files: { [PLAN]: planFile([{ ...ENTRY_SOLO, vars: ['NOPE'] }]) } }); + await check(planFile([{ ...ENTRY_SOLO, vars: ['NOPE'] }]), { world, dryRun: false, confirm: 'whatever' }); + expect(world.github.createPull).not.toHaveBeenCalled(); + expect(world.github.createBlob).not.toHaveBeenCalled(); + }); +}); + +describe('plan: a valid plan', () => { + const ENTRIES = [ENTRY_SOLO, ENTRY_API, ENTRY_API_STG, ENTRY_WEB]; + + test('is normalized (entries sorted by project, branch, service; vars sorted) and carries a plan_id and the summary lines', async () => { + const { body, exitCode } = await check(planFile(ENTRIES)); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + expect(body.dry_run).toBe(true); + expect(body.plan.version).toBe(1); + expect(body.plan.entries.map((e: any) => `${e.project_id}/${e.branch}`)).toEqual(['proj_api/production', 'proj_api/staging', 'proj_solo/production', 'proj_web/production']); + expect(body.plan.entries[2].vars).toEqual(['A', 'B', 'C', 'D']); + expect(body.plan_id).toMatch(/^[0-9a-f]{16}$/); + expect(body.summary_lines).toEqual([ + 'backend-api · production → Dokploy compose cmp_api (main), 4 vars', + 'backend-api · staging → Dokploy application app_… (staging), 2 vars', + 'solo-app · production → Dokploy compose cmp_solo (main), 4 vars', + 'backend-web · production → Dokploy compose cmp_web (main), 3 vars', + ]); + }); + + test('plan_id is stable across runs and across entry and variable order, and moves when the plan, or the base URL, does', async () => { + const first = (await check(planFile(ENTRIES))).body.plan_id; + const second = (await check(planFile(ENTRIES))).body.plan_id; + const shuffled = ( + await check(planFile([ENTRY_WEB, ENTRY_SOLO, { ...ENTRY_API, vars: ['JWT_SECRET', 'API_KEY', 'REDIS_URL', 'DATABASE_URL'] }, ENTRY_API_STG])) + ).body.plan_id; + expect(second).toBe(first); + expect(shuffled).toBe(first); + const fewerVars = (await check(planFile([ENTRY_SOLO, ENTRY_API, ENTRY_API_STG, { ...ENTRY_WEB, vars: ['SESSION_SECRET'] }]))).body.plan_id; + expect(fewerVars).not.toBe(first); + const otherUrl = (await check(planFile(ENTRIES), { baseUrl: 'https://other.example.com' })).body.plan_id; + expect(otherUrl).not.toBe(first); + }); + + test('shows the PRs a confirm would open: one per repo, the deploy.json paths and the target names', async () => { + const { body } = await check(planFile(ENTRIES)); + expect(body.prs).toEqual([ + { repo: 'acme/backend', base: 'main', files: ['api/.capy/deploy.json', 'web/.capy/deploy.json'], targets: ['dokploy-production', 'dokploy-staging', 'dokploy-production'] }, + { repo: 'acme/solo', base: 'main', files: ['.capy/deploy.json'], targets: ['dokploy-production'] }, + ]); + expect(body.already_configured).toEqual([]); + }); + + test('its next step is the confirm command, with the file and the plan_id', async () => { + const { body } = await check(planFile(ENTRIES)); + expect(body.next_steps).toContainEqual({ run: `capy deploy dokploy --discover --plan ${PLAN} --confirm ${body.plan_id}` }); + }); + + test('an entry whose target is already there is not an error: it is already_configured and says so', async () => { + const entry = { project_id: P_CONF, branch: 'production', service_id: 'cmp_conf', git_branch: 'main', vars: ['C1', 'C2'] }; + const { body } = await check(planFile([entry])); + expect(body.ok).toBe(true); + expect(body.already_configured).toEqual([{ project_id: P_CONF, branch: 'production', service_id: 'cmp_conf', target_name: 'dokploy-production' }]); + expect(body.summary_lines).toEqual(['configured · production: already configured (dokploy-production), nothing to do']); + expect(body.prs).toEqual([]); + }); + + test('a dry run writes nothing, and reads only the services the plan names', async () => { + const world = makeWorld({ files: { [PLAN]: planFile(ENTRIES) } }); + await check(planFile(ENTRIES), { world }); + 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(); + expect(world.dokploy.getCompose.mock.calls.length + world.dokploy.getApplication.mock.calls.length).toBe(4); + }); + + test('without --dry-run and without --confirm: PLAN_CONFIRM_REQUIRED (exit 3), carrying the plan_id and the plan', async () => { + const { body, exitCode } = await check(planFile(ENTRIES), { dryRun: false }); + expect(exitCode).toBe(3); + expect(body.code).toBe('PLAN_CONFIRM_REQUIRED'); + expect(body.unanswered).toEqual([{ id: 'confirm', flag: '--confirm', value: body.plan_id }]); + expect(body.summary_lines).toHaveLength(4); + }); +}); + +describe('plan: no value appears anywhere', () => { + test('neither sentinel is in the dry-run document or the progress lines', async () => { + const world = makeWorld({ files: { [PLAN]: planFile([ENTRY_SOLO, ENTRY_API]) } }); + const { body } = await check(planFile([ENTRY_SOLO, ENTRY_API]), { world }); + const everything = JSON.stringify(body) + world.progress.mock.calls.map(([l]) => String(l)).join('\n'); + expect(everything).not.toContain(SENTINEL_DOKPLOY); + expect(everything).not.toContain(SENTINEL_CAPY); + expect(everything).toContain('cmp_api'); + }); +}); diff --git a/tests/commands/deployPickerBaseUrlSave.test.ts b/tests/commands/deployPickerBaseUrlSave.test.ts new file mode 100644 index 00000000..a3491843 --- /dev/null +++ b/tests/commands/deployPickerBaseUrlSave.test.ts @@ -0,0 +1,124 @@ +/** + * The `capy deploy` Dokploy setup prompt and the org system variable `_CONNECTOR_DOKPLOY_BASE_URL` (CAP-703). + * + * A Dokploy URL typed at the setup prompt is offered to the org through the shared resolver's save path: saved + * ONLY when the variable is unset and the caller is an org admin, never overwriting, with one short dim line on + * stderr either way. The stored value is also the prompt's default. The Dokploy API key is never printed. + * + * `mock.module('inquirer', ...)` and `mock.module('../../src/system/systemStore', ...)` are process-wide: this + * file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, mock, afterEach, spyOn } from 'bun:test'; +import { CapyError } from '../../src/types/index'; + +const promptMock = mock(async (..._args: unknown[]) => ({}) as Record); +mock.module('inquirer', () => ({ default: { prompt: promptMock, Separator: class {} } })); + +const API_KEY = 'dokploy-api-key-that-must-never-print'; +const set = mock(async (_name: string, _value: string) => undefined); +/** What the variable holds (undefined: unset) and whether the caller may open the store. */ +const world = { stored: undefined as string | undefined, admin: true }; +const openSystemStore = mock(async (_opts: unknown) => { + if (!world.admin) throw new CapyError('x', 'SYSTEM_STORE_ADMIN_ONLY'); + return { get: (_name: string) => world.stored ?? null, set }; +}); +mock.module('../../src/system/systemStore', () => ({ + getDirectionalConnectorSecret: mock(async () => API_KEY), + openSystemStore, +})); + +import { resolveAdapterOptions } from '../../src/commands/deployCommand'; +import { getAdapter } from '../../src/deploy/registry'; + +const adapter = getAdapter('dokploy'); +if (!adapter) throw new Error('dokploy adapter not registered'); + +const URL_ENTERED = 'https://dokploy.example.com'; + +function answerBareId(baseUrl: string) { + promptMock.mockImplementationOnce(async () => ({ serviceUrl: 'compose_1' })); + promptMock.mockImplementationOnce(async () => ({ kind: 'compose', baseUrl })); +} + +/** Runs the picker with stderr and stdout captured; returns what was written. */ +async function setup(existingOpts: Record, orgId: string | null = 'org_1') { + const errors = mock((..._a: unknown[]) => undefined); + const logs = mock((..._a: unknown[]) => undefined); + const errSpy = spyOn(console, 'error').mockImplementation(errors as never); + const logSpy = spyOn(console, 'log').mockImplementation(logs as never); + try { + const options = await resolveAdapterOptions(adapter!, '/tmp', [], {}, existingOpts, orgId ?? undefined); + const text = (m: typeof errors) => m.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + return { options, stderr: text(errors), stdout: text(logs) }; + } finally { + errSpy.mockRestore(); + logSpy.mockRestore(); + } +} + +afterEach(() => { + promptMock.mockReset(); + set.mockClear(); + openSystemStore.mockClear(); + world.stored = undefined; + world.admin = true; +}); + +describe('capy deploy setup: the Dokploy URL prompt offers the URL to the org (admins only)', () => { + test('an admin, variable unset: the typed URL is saved, with one short line', async () => { + answerBareId(URL_ENTERED); + const { options, stderr } = await setup({}); + expect(options).toEqual({ baseUrl: URL_ENTERED, composeId: 'compose_1' }); + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith('_CONNECTOR_DOKPLOY_BASE_URL', URL_ENTERED); + expect(stderr.split('\n').filter((l) => l.includes('Saved the Dokploy URL for your org.'))).toHaveLength(1); + }); + + test('an admin, variable already set to something else: never overwritten, no line', async () => { + world.stored = 'https://stored.example.com'; + answerBareId(URL_ENTERED); + const { stderr } = await setup({}); + expect(set).not.toHaveBeenCalled(); + expect(stderr).not.toContain('Saved the Dokploy URL'); + expect(stderr).not.toContain('Not saved'); + }); + + test('a non-admin: nothing saved, and one short line says why', async () => { + world.admin = false; + answerBareId(URL_ENTERED); + const { options, stderr } = await setup({}); + expect(options).toEqual({ baseUrl: URL_ENTERED, composeId: 'compose_1' }); + expect(set).not.toHaveBeenCalled(); + expect(stderr.split('\n').filter((l) => l.includes('Not saved for the org: only org admins can save the Dokploy URL.'))).toHaveLength(1); + }); + + test('the stored URL is the prompt default, and accepting it saves nothing new', async () => { + world.stored = URL_ENTERED; + promptMock.mockImplementationOnce(async () => ({ serviceUrl: 'compose_1' })); + promptMock.mockImplementationOnce(async (qs: unknown) => { + expect((qs as ReadonlyArray<{ name: string; default?: unknown }>).find((q) => q.name === 'baseUrl')?.default).toBe(URL_ENTERED); + return { kind: 'compose', baseUrl: URL_ENTERED }; + }); + const { stderr } = await setup({}); + expect(set).not.toHaveBeenCalled(); + expect(stderr).toBe(''); + }); + + test('a URL the target already had is not "entered": nothing is saved', async () => { + answerBareId('https://old.example.com'); + await setup({ baseUrl: 'https://old.example.com', composeId: 'compose_1' }); + expect(set).not.toHaveBeenCalled(); + }); + + test('without an org there is nothing to save to', async () => { + answerBareId(URL_ENTERED); + await setup({}, null); + expect(openSystemStore).not.toHaveBeenCalled(); + }); + + test('the API key is never printed', async () => { + answerBareId(URL_ENTERED); + const { stderr, stdout } = await setup({}); + expect(stderr + stdout).not.toContain(API_KEY); + }); +}); diff --git a/tests/commands/dokployBaseUrl.test.ts b/tests/commands/dokployBaseUrl.test.ts new file mode 100644 index 00000000..5c7ff896 --- /dev/null +++ b/tests/commands/dokployBaseUrl.test.ts @@ -0,0 +1,490 @@ +/** + * The Dokploy base URL as an org system variable (CAP-703): ONE resolver for every path that needs it. + * + * Order: `--base-url`, the system variable `_CONNECTOR_DOKPLOY_BASE_URL`, the Dokploy target saved in the + * folder's deploy.json, else a structured refusal with `unanswered`. The first `--base-url` is saved by an + * admin; a non-admin gets a coded notice; a flag that differs from the stored URL wins for the run and never + * overwrites. Fakes only: the system store is an in-memory object. + */ +import { describe, test, expect, mock, spyOn } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CapyError } from '../../src/types/index'; +import { DOKPLOY_BASE_URL_VAR_NAME, type FetchLike } from '../../src/deploy/dokployApi'; +import { normalizeDokployBaseUrl, offerSaveAskedBaseUrl, resolveDokployBaseUrl, type BaseUrlStore } from '../../src/deploy/dokployBaseUrl'; +import { withTty } from '../helpers/processState'; +import { runDeployDokployDiscover } from '../../src/commands/deployDiscover/command'; +import { createDokployConnector } from '../../src/commands/connectors/dokploy'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import type { ConnectOpts } from '../../src/commands/connectors/registry'; +import type { DiscoveryContext } from '../../src/commands/connectors/dokployDiscovery'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import { ENTRY_SOLO, SENTINEL_CAPY, SENTINEL_DOKPLOY, TOKEN, makeWorld, planFile } from '../helpers/deployDiscoverWorld'; + +const STORED = 'https://stored.example.com'; +const FLAG = 'https://flag.example.com'; +const SAVED_TARGET = 'https://saved-target.example.com'; + +/** An in-memory system store: `initial` is what the variable holds (absent: unset). */ +function storeFake(initial?: string) { + const set = mock(async (_name: string, _value: string) => undefined); + const store: BaseUrlStore = { get: (name) => (name === DOKPLOY_BASE_URL_VAR_NAME ? (initial ?? null) : null), set }; + const open = mock(async () => store); + return { store, set, open }; +} + +const nonAdmin = () => mock(async (): Promise => { throw new CapyError('x', 'SYSTEM_STORE_ADMIN_ONLY'); }); + +describe('the variable name follows the system store convention', () => { + test('_CONNECTOR__, next to the API key', () => { + expect(DOKPLOY_BASE_URL_VAR_NAME).toBe('_CONNECTOR_DOKPLOY_BASE_URL'); + expect(/^_(?:CONNECTOR|TARGET)_[A-Z0-9]+_[A-Z0-9_]+$/.test(DOKPLOY_BASE_URL_VAR_NAME)).toBe(true); + }); +}); + +describe('the resolver: each step, in order', () => { + test('1. --base-url wins over the variable and the saved target', async () => { + const { open } = storeFake(STORED); + const r = await resolveDokployBaseUrl({ flag: FLAG, openStore: open, savedTargetUrl: () => SAVED_TARGET }); + expect(r).toMatchObject({ ok: true, baseUrl: FLAG, source: 'flag' }); + }); + + test('2. the system variable, when there is no flag, wins over the saved target', async () => { + const { open } = storeFake(STORED); + const r = await resolveDokployBaseUrl({ openStore: open, savedTargetUrl: () => SAVED_TARGET }); + expect(r).toEqual({ ok: true, baseUrl: STORED, source: 'system', notices: [] }); + }); + + test('3. the saved deploy.json target, when the variable is unset', async () => { + const { open } = storeFake(undefined); + const r = await resolveDokployBaseUrl({ openStore: open, savedTargetUrl: () => SAVED_TARGET }); + expect(r).toEqual({ ok: true, baseUrl: SAVED_TARGET, source: 'saved_target', notices: [] }); + }); + + test('3. ...and when the store cannot be opened (not an admin)', async () => { + const r = await resolveDokployBaseUrl({ openStore: nonAdmin(), savedTargetUrl: () => SAVED_TARGET }); + expect(r).toMatchObject({ ok: true, baseUrl: SAVED_TARGET, source: 'saved_target' }); + }); + + test('4. nothing anywhere: the structured refusal with `unanswered`', async () => { + const r = await resolveDokployBaseUrl({ openStore: storeFake(undefined).open, savedTargetUrl: () => undefined }); + expect(r).toEqual({ + ok: false, + code: 'DOKPLOY_SETTINGS_MISSING', + error: expect.any(String), + unanswered: [{ id: 'base_url', flag: '--base-url', hint: expect.any(String) }], + }); + }); + + test('without a store at all (an offline caller) the variable is skipped', async () => { + const r = await resolveDokployBaseUrl({ flag: FLAG, savedTargetUrl: () => undefined }); + expect(r).toEqual({ ok: true, baseUrl: FLAG, source: 'flag', notices: [] }); + }); +}); + +describe('the resolver: validation and normalisation', () => { + test('a trailing slash is dropped and spaces trimmed; http is only for localhost', () => { + expect(normalizeDokployBaseUrl(' https://dokploy.example.com/ ')).toBe('https://dokploy.example.com'); + expect(normalizeDokployBaseUrl('http://localhost:3000/')).toBe('http://localhost:3000'); + expect(normalizeDokployBaseUrl('http://dokploy.example.com')).toBeNull(); + expect(normalizeDokployBaseUrl('not a url')).toBeNull(); + }); + + test('a bad --base-url is DOKPLOY_URL_INVALID, and nothing is read or saved', async () => { + const { open, set } = storeFake(undefined); + const r = await resolveDokployBaseUrl({ flag: 'http://dokploy.example.com', openStore: open, savedTargetUrl: () => undefined }); + expect(r).toMatchObject({ ok: false, code: 'DOKPLOY_URL_INVALID' }); + expect(open).not.toHaveBeenCalled(); + expect(set).not.toHaveBeenCalled(); + }); + + test('a bad stored value is DOKPLOY_URL_INVALID too, not silently skipped', async () => { + const r = await resolveDokployBaseUrl({ openStore: storeFake('ftp://nope').open, savedTargetUrl: () => SAVED_TARGET }); + expect(r).toMatchObject({ ok: false, code: 'DOKPLOY_URL_INVALID' }); + }); +}); + +describe('the resolver: saving the first --base-url', () => { + test('an admin, variable unset: the NORMALISED URL is saved to the variable, and reported', async () => { + const { open, set } = storeFake(undefined); + const r = await resolveDokployBaseUrl({ flag: `${FLAG}/`, openStore: open, savedTargetUrl: () => undefined }); + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith('_CONNECTOR_DOKPLOY_BASE_URL', FLAG); + expect(r).toEqual({ ok: true, baseUrl: FLAG, source: 'flag', saved: { base_url: true }, notices: [] }); + }); + + test('not an admin: nothing saved, and a coded notice carries the reason', async () => { + const r = await resolveDokployBaseUrl({ flag: FLAG, openStore: nonAdmin(), savedTargetUrl: () => undefined }); + expect(r).toEqual({ ok: true, baseUrl: FLAG, source: 'flag', notices: [{ code: 'BASE_URL_NOT_SAVED', reason: 'SYSTEM_STORE_ADMIN_ONLY' }] }); + }); + + test('a save that fails is a notice too, never a failure of the run', async () => { + const { store } = storeFake(undefined); + const failing: BaseUrlStore = { ...store, set: async () => { throw new CapyError('x', 'SERVICE_ERROR'); } }; + const r = await resolveDokployBaseUrl({ flag: FLAG, openStore: async () => failing, savedTargetUrl: () => undefined }); + expect(r).toMatchObject({ ok: true, baseUrl: FLAG, notices: [{ code: 'BASE_URL_NOT_SAVED', reason: 'SERVICE_ERROR' }] }); + expect(r).not.toHaveProperty('saved'); + }); + + test('the variable already holds a DIFFERENT URL: the flag is used for this run, nothing is overwritten, and a notice says so', async () => { + const { open, set } = storeFake(STORED); + const r = await resolveDokployBaseUrl({ flag: FLAG, openStore: open, savedTargetUrl: () => undefined }); + expect(set).not.toHaveBeenCalled(); + expect(r).toEqual({ ok: true, baseUrl: FLAG, source: 'flag', notices: [{ code: 'BASE_URL_DIFFERS_FROM_STORED', stored: STORED, used: FLAG }] }); + }); + + test('the variable already holds the same URL: no notice, no save', async () => { + const { open, set } = storeFake(`${FLAG}/`); + const r = await resolveDokployBaseUrl({ flag: FLAG, openStore: open, savedTargetUrl: () => undefined }); + expect(set).not.toHaveBeenCalled(); + expect(r).toMatchObject({ ok: true, notices: [] }); + }); + + test('a dry run saves nothing (a dry run changes nothing) and says why', async () => { + const { open, set } = storeFake(undefined); + const r = await resolveDokployBaseUrl({ flag: FLAG, dryRun: true, openStore: open, savedTargetUrl: () => undefined }); + expect(set).not.toHaveBeenCalled(); + expect(r).toMatchObject({ ok: true, notices: [{ code: 'BASE_URL_NOT_SAVED', reason: 'DRY_RUN' }] }); + }); +}); + +describe('capy deploy dokploy --discover uses the resolver', () => { + test('the stored URL is used with no flag, and is the base_url of the output and of every proposed target', async () => { + const world = makeWorld({ io: { openBaseUrlStore: storeFake(STORED).open } }); + const result = await runDeployDokployDiscover({}, world.io); + const body = result.body as any; + expect(body.base_url).toBe(STORED); + expect(body.proposals.every((p: any) => p.target.baseUrl === STORED)).toBe(true); + expect(body).not.toHaveProperty('saved'); + }); + + test('the first --base-url as an admin is saved and reported as saved: { base_url: true }', async () => { + const fake = storeFake(undefined); + const world = makeWorld({ io: { openBaseUrlStore: fake.open } }); + const body = (await runDeployDokployDiscover({ baseUrl: FLAG }, world.io)).body as any; + expect(fake.set).toHaveBeenCalledWith('_CONNECTOR_DOKPLOY_BASE_URL', FLAG); + expect(body.saved).toEqual({ base_url: true }); + }); + + test('a non-admin gets BASE_URL_NOT_SAVED in notices, and the run still works', async () => { + const world = makeWorld({ io: { openBaseUrlStore: nonAdmin() as never } }); + const result = await runDeployDokployDiscover({ baseUrl: FLAG }, world.io); + const body = result.body as any; + expect(result.exitCode).toBe(0); + expect(body.notices).toContainEqual({ code: 'BASE_URL_NOT_SAVED', reason: 'SYSTEM_STORE_ADMIN_ONLY' }); + expect(body).not.toHaveProperty('saved'); + }); + + test('a differing --base-url is used, not saved over the stored one, and noticed', async () => { + const fake = storeFake(STORED); + const world = makeWorld({ io: { openBaseUrlStore: fake.open } }); + const body = (await runDeployDokployDiscover({ baseUrl: FLAG }, world.io)).body as any; + expect(body.base_url).toBe(FLAG); + expect(fake.set).not.toHaveBeenCalled(); + expect(body.notices).toContainEqual({ code: 'BASE_URL_DIFFERS_FROM_STORED', stored: STORED, used: FLAG }); + }); + + test('no flag, no variable, no saved target: the structured refusal (exit 3, needs input) with unanswered', async () => { + const world = makeWorld({ io: { openBaseUrlStore: storeFake(undefined).open } }); + const result = await runDeployDokployDiscover({}, world.io); + expect(result.exitCode).toBe(3); + expect(result.body).toEqual({ + ok: false, + code: 'DOKPLOY_SETTINGS_MISSING', + error: expect.any(String), + unanswered: [{ id: 'base_url', flag: '--base-url', hint: expect.any(String) }], + }); + expect(world.dokploy.listProjects).not.toHaveBeenCalled(); + }); + + test('plan_id covers the RESOLVED URL: a dry run on the flag and a confirm on the stored variable agree', async () => { + const text = planFile([ENTRY_SOLO]); + const files = { 'p.json': text }; + const dry = makeWorld({ files, io: { openBaseUrlStore: storeFake(undefined).open } }); + const approved = ((await runDeployDokployDiscover({ baseUrl: STORED, plan: 'p.json', dryRun: true }, dry.io)).body as any).plan_id; + const confirm = makeWorld({ files, io: { openBaseUrlStore: storeFake(STORED).open } }); + const result = await runDeployDokployDiscover({ plan: 'p.json', confirm: approved }, confirm.io); + expect((result.body as any).ok).toBe(true); + expect(confirm.github.createPull).toHaveBeenCalled(); + // And a different resolved URL is a different plan. + const other = makeWorld({ files, io: { openBaseUrlStore: storeFake('https://another.example.com').open } }); + expect(((await runDeployDokployDiscover({ plan: 'p.json', confirm: approved }, other.io)).body as any).code).toBe('PLAN_CHANGED'); + }); +}); + +describe('capy connect dokploy --discover uses the same resolver', () => { + const ROOT = mkdtempSync(join(tmpdir(), 'capy-baseurl-connect-')); + const ctx = { orgId: 'org_1', userId: 'user_1', serviceClient: {}, authService: {} } as unknown as DiscoveryContext; + + function recordingFetch() { + const urls = mock((_url: string) => undefined); + const fetchImpl: FetchLike = (async (url: string) => { + urls(url); + return { status: 200, ok: true, text: async () => '[]' }; + }) as FetchLike; + return { urls, fetchImpl }; + } + + test('with no flag the stored URL is where Dokploy is read', async () => { + const { urls, fetchImpl } = recordingFetch(); + const connector = createDokployConnector({ fetch: fetchImpl, env: { T: 'x' }, cwd: ROOT, openBaseUrlStore: storeFake(STORED).open as never }); + const outcome = await connector.discover!(ctx, { nonTty: true, dryRun: true, tokenEnv: 'T' } as ConnectOpts); + expect(outcome.ok).toBe(true); + expect(String(urls.mock.calls[0][0]).startsWith(`${STORED}/api/`)).toBe(true); + }); + + test('the first --base-url is saved and reported; a differing one is noticed', async () => { + const { fetchImpl } = recordingFetch(); + const fake = storeFake(undefined); + const first = createDokployConnector({ fetch: fetchImpl, env: { T: 'x' }, cwd: ROOT, openBaseUrlStore: fake.open as never }); + await first.discover!(ctx, { nonTty: true, tokenEnv: 'T', baseUrl: FLAG, yes: false } as ConnectOpts); + // No --yes: it stops at the confirmation, after the URL was resolved and saved. + expect(fake.set).toHaveBeenCalledWith('_CONNECTOR_DOKPLOY_BASE_URL', FLAG); + const differs = createDokployConnector({ fetch: fetchImpl, env: { T: 'x' }, cwd: ROOT, openBaseUrlStore: storeFake(STORED).open as never }); + const outcome = await differs.discover!(ctx, { nonTty: true, dryRun: true, tokenEnv: 'T', baseUrl: FLAG } as ConnectOpts); + expect(outcome.ok && outcome.notices).toContainEqual({ code: 'BASE_URL_DIFFERS_FROM_STORED', stored: STORED, used: FLAG }); + }); + + test('nothing anywhere: the same structured refusal, unanswered included', async () => { + const connector = createDokployConnector({ env: { T: 'x' }, cwd: ROOT, openBaseUrlStore: storeFake(undefined).open as never }); + const outcome = await connector.discover!(ctx, { nonTty: true, tokenEnv: 'T' } as ConnectOpts); + expect(outcome).toEqual({ + ok: false, + code: 'DOKPLOY_SETTINGS_MISSING', + message: expect.any(String), + unanswered: [{ id: 'base_url', flag: '--base-url', hint: expect.any(String) }], + }); + }); + + test('the JSON ConnectCommand prints carries saved, notices and unanswered', async () => { + const { fetchImpl } = recordingFetch(); + const printed = mock((..._a: unknown[]) => undefined); + const spy = spyOn(console, 'log').mockImplementation(printed as never); + const saved = process.exitCode; + try { + const withSaved = createDokployConnector({ fetch: fetchImpl, env: { T: 'x' }, cwd: ROOT, openBaseUrlStore: storeFake(undefined).open as never }); + await (new ConnectCommand(false) as unknown as { executeDiscovery: Function }).executeDiscovery(withSaved, 'dokploy', ctx, { + json: true, nonTty: true, dryRun: false, tokenEnv: 'T', baseUrl: FLAG, + } as ConnectOpts); + const refused = createDokployConnector({ env: { T: 'x' }, cwd: ROOT }); + await (new ConnectCommand(false) as unknown as { executeDiscovery: Function }).executeDiscovery(refused, 'dokploy', ctx, { json: true, nonTty: true, tokenEnv: 'T' } as ConnectOpts); + } finally { + spy.mockRestore(); + process.exitCode = saved ?? 0; + } + const outputs = printed.mock.calls.map(([line]) => JSON.parse(String(line))); + expect(outputs[0].saved).toEqual({ base_url: true }); + expect(outputs[1]).toMatchObject({ ok: false, code: 'DOKPLOY_SETTINGS_MISSING', unanswered: [{ id: 'base_url', flag: '--base-url' }] }); + rmSync(ROOT, { recursive: true, force: true }); + }); +}); + +describe('the single-service connect import reads the variable too', () => { + test('--application with no --base-url reads Dokploy at the stored URL', async () => { + const calls = mock((_url: string) => undefined); + const fetchImpl: FetchLike = (async (url: string) => { + calls(url); + return { + status: 200, + ok: true, + text: async () => JSON.stringify({ applicationId: 'app_1', name: 'a', env: 'A=1', buildArgs: null, buildSecrets: null, createEnvFile: true }), + }; + }) as FetchLike; + const connector = createDokployConnector({ fetch: fetchImpl, env: { T: 'x' }, cwd: tmpdir(), openBaseUrlStore: storeFake(STORED).open as never }); + const keep = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + const ctx = { keep, branch: 'development', localPlaintext: {}, orgId: 'org_1' } as unknown as ResolvedContext; + const outcome = await connector.import!(ctx, { nonTty: true, application: 'app_1', tokenEnv: 'T', dryRun: true } as ConnectOpts); + expect(outcome.ok).toBe(true); + expect(String(calls.mock.calls[0][0]).startsWith(`${STORED}/api/`)).toBe(true); + }); +}); + +describe('the API key is never printed; the URL may be', () => { + test('neither the Dokploy key, nor either value sentinel, is in a discovery or plan output; the URL is', async () => { + const world = makeWorld({ files: { 'p.json': planFile([ENTRY_SOLO]) }, io: { openBaseUrlStore: storeFake(undefined).open } }); + const discovery = (await runDeployDokployDiscover({ baseUrl: FLAG }, world.io)).body; + const plan = (await runDeployDokployDiscover({ baseUrl: FLAG, plan: 'p.json', dryRun: true }, world.io)).body; + const refusal = (await runDeployDokployDiscover({}, makeWorld({ io: { openBaseUrlStore: storeFake(undefined).open } }).io)).body; + const everything = JSON.stringify([discovery, plan, refusal]) + world.progress.mock.calls.map(([l]) => String(l)).join('\n'); + expect(everything).not.toContain(TOKEN); + expect(everything).not.toContain(SENTINEL_DOKPLOY); + expect(everything).not.toContain(SENTINEL_CAPY); + expect(everything).toContain(FLAG); + }); + + test('a missing key refuses without echoing anything secret', async () => { + const world = makeWorld({ io: { getConnectorSecret: async () => null, openBaseUrlStore: storeFake(STORED).open } }); + const body = (await runDeployDokployDiscover({}, world.io)).body as any; + expect(body.code).toBe('DOKPLOY_TOKEN_MISSING'); + expect(body.dashboard).toBe(STORED); + expect(JSON.stringify(body)).not.toContain(TOKEN); + }); +}); + +// ── CAP-703: a prompt collected the URL; non-TTY refusals are structured ───── + +describe('a terminal prompt that collects the URL offers it to the org (admins only)', () => { + const SAVED_LINE = 'Saved the Dokploy URL for your org.'; + const NOT_ADMIN_LINE = 'Not saved for the org: only org admins can save the Dokploy URL.'; + + test('the shared save path: an admin with the variable unset saves, and says so', async () => { + const { open, set } = storeFake(undefined); + const log = mock((_l: string) => undefined); + const r = await offerSaveAskedBaseUrl({ baseUrl: FLAG, openStore: open, log }); + expect(r).toEqual({ saved: true }); + expect(set).toHaveBeenCalledWith('_CONNECTOR_DOKPLOY_BASE_URL', FLAG); + expect(log.mock.calls.map(([l]) => l)).toEqual([SAVED_LINE]); + }); + + test('an admin with the variable already set: never overwritten, silent', async () => { + const { open, set } = storeFake(STORED); + const log = mock((_l: string) => undefined); + expect(await offerSaveAskedBaseUrl({ baseUrl: FLAG, openStore: open, log })).toEqual({ saved: false }); + expect(set).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + test('a non-admin (decided by the store\'s code): not saved, one line', async () => { + const log = mock((_l: string) => undefined); + expect(await offerSaveAskedBaseUrl({ baseUrl: FLAG, openStore: nonAdmin(), log })).toEqual({ saved: false }); + expect(log.mock.calls.map(([l]) => l)).toEqual([NOT_ADMIN_LINE]); + }); + + test('another failure to save (not an admin problem) is silent: it would only be noise at a prompt', async () => { + const { store } = storeFake(undefined); + const failing: BaseUrlStore = { ...store, set: async () => { throw new CapyError('x', 'SERVICE_ERROR'); } }; + const log = mock((_l: string) => undefined); + await offerSaveAskedBaseUrl({ baseUrl: FLAG, openStore: async () => failing, log }); + expect(log).not.toHaveBeenCalled(); + }); + + /** Runs one interactive single-service import that asks for its settings; returns what went to stderr and the outcome. */ + async function interactiveImport(openStore: ReturnType['open'] | ReturnType, opts: Partial = {}, status = 200) { + const fetchImpl: FetchLike = (async () => ({ + status, + ok: status === 200, + text: async () => JSON.stringify({ applicationId: 'app_1', name: 'a', env: `A=${SENTINEL_DOKPLOY}`, buildArgs: null, buildSecrets: null, createEnvFile: true }), + })) as FetchLike; + const connector = createDokployConnector({ + fetch: fetchImpl, + env: { T: 'x' }, + cwd: tmpdir(), + openBaseUrlStore: openStore as never, + askSourceKind: async () => 'application' as const, + askSettings: async () => ({ baseUrl: FLAG, applicationId: 'app_1' }), + selectVars: async (c: readonly string[]) => c, + confirm: async () => true, + }); + const keep = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + const ctx = { keep, branch: 'development', localPlaintext: {}, orgId: 'org_1' } as unknown as ResolvedContext; + const errors = mock((..._a: unknown[]) => undefined); + const errSpy = spyOn(console, 'error').mockImplementation(errors as never); + try { + const outcome = await withTty({ stdin: true }, () => connector.import!(ctx, { nonTty: false, tokenEnv: 'T', ...opts } as ConnectOpts)); + return { outcome, stderr: errors.mock.calls.map((c) => c.map(String).join(' ')).join('\n') }; + } finally { + errSpy.mockRestore(); + } + } + + test('capy connect dokploy, an admin, variable unset: the URL typed at the prompt is saved once Dokploy has answered', async () => { + const { open, set } = storeFake(undefined); + const { outcome, stderr } = await interactiveImport(open); + expect(outcome.ok).toBe(true); + expect(set).toHaveBeenCalledWith('_CONNECTOR_DOKPLOY_BASE_URL', FLAG); + expect(stderr).toContain(SAVED_LINE); + }); + + test('capy connect dokploy, an admin, variable already set: not overwritten, silent', async () => { + const { open, set } = storeFake(STORED); + // The stored URL is used for the import, so nothing is asked at all. + const { outcome, stderr } = await interactiveImport(open); + expect(outcome.ok).toBe(true); + expect(set).not.toHaveBeenCalled(); + expect(stderr).not.toContain(SAVED_LINE); + }); + + test('capy connect dokploy, a non-admin: nothing saved, one line says why', async () => { + const { outcome, stderr } = await interactiveImport(nonAdmin()); + expect(outcome.ok).toBe(true); + expect(stderr.split('\n').filter((l) => l.includes(NOT_ADMIN_LINE))).toHaveLength(1); + }); + + test('nothing is saved when Dokploy refused the URL, or under --dry-run', async () => { + const refused = storeFake(undefined); + const bad = await interactiveImport(refused.open, {}, 404); + expect(bad.outcome.ok).toBe(false); + expect(refused.set).not.toHaveBeenCalled(); + const dry = storeFake(undefined); + await interactiveImport(dry.open, { dryRun: true }); + expect(dry.set).not.toHaveBeenCalled(); + }); + + test('the API key and the Dokploy value are never printed', async () => { + const { stderr } = await interactiveImport(storeFake(undefined).open); + expect(stderr).not.toContain(SENTINEL_DOKPLOY); + expect(stderr).not.toContain(TOKEN); + }); +}); + +describe('the single-service non-TTY refusal is structured, like every other Dokploy command', () => { + const keep = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + const ctx = { keep, branch: 'development', localPlaintext: {}, orgId: 'org_1' } as unknown as ResolvedContext; + + test('nothing given: DOKPLOY_SETTINGS_MISSING with the base URL and the service in `unanswered`', async () => { + const connector = createDokployConnector({ env: { T: 'x' }, cwd: tmpdir() }); + const outcome = await connector.import!(ctx, { nonTty: true, tokenEnv: 'T' } as ConnectOpts); + expect(outcome).toEqual({ + ok: false, + code: 'DOKPLOY_SETTINGS_MISSING', + message: expect.any(String), + unanswered: [ + { id: 'base_url', flag: '--base-url', hint: 'your Dokploy dashboard URL, e.g. https://dokploy.example.com' }, + { id: 'service', flag: '--application', alternative: '--compose', hint: expect.any(String) }, + ], + }); + }); + + test('--compose without a URL: only the base URL is unanswered', async () => { + const connector = createDokployConnector({ env: { T: 'x' }, cwd: tmpdir() }); + const outcome = await connector.import!(ctx, { nonTty: true, tokenEnv: 'T', compose: 'c1' } as ConnectOpts); + expect(outcome).toMatchObject({ ok: false, code: 'DOKPLOY_SETTINGS_MISSING', unanswered: [{ id: 'base_url', flag: '--base-url' }] }); + }); + + async function printed(opts: Partial) { + const out = mock((..._a: unknown[]) => undefined); + const err = mock((..._a: unknown[]) => undefined); + const logSpy = spyOn(console, 'log').mockImplementation(out as never); + const errSpy = spyOn(console, 'error').mockImplementation(err as never); + const before = process.exitCode; + try { + const connector = createDokployConnector({ env: { T: 'x' }, cwd: tmpdir() }); + await (new ConnectCommand(false) as unknown as { executeImport: Function }).executeImport(connector, 'dokploy', ctx, { nonTty: true, tokenEnv: 'T', ...opts } as ConnectOpts); + return { exitCode: process.exitCode, out: out.mock.calls.map(([l]) => String(l)), err: err.mock.calls.map((c) => c.map(String).join(' ')).join('\n') }; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + process.exitCode = before ?? 0; + } + } + + test('--json: the same JSON refusal as the other commands, and exit 3 (needs input)', async () => { + const r = await printed({ json: true }); + expect(r.exitCode).toBe(3); + expect(JSON.parse(r.out[0])).toMatchObject({ ok: false, code: 'DOKPLOY_SETTINGS_MISSING', unanswered: [{ id: 'base_url', flag: '--base-url' }, { id: 'service' }] }); + }); + + test('human mode: the error and each hint on stderr, nothing on stdout, exit 3', async () => { + const r = await printed({}); + expect(r.exitCode).toBe(3); + expect(r.out).toEqual([]); + expect(r.err).toContain('--base-url'); + expect(r.err).toContain('https://dokploy.example.com'); + }); + + test('another refusal still exits 1', async () => { + const r = await printed({ json: true, application: 'a', compose: 'c' }); + expect(r.exitCode).toBe(1); + }); +}); diff --git a/tests/commands/helpJsonCli.test.ts b/tests/commands/helpJsonCli.test.ts index f4b52559..06f6b984 100644 --- a/tests/commands/helpJsonCli.test.ts +++ b/tests/commands/helpJsonCli.test.ts @@ -84,6 +84,45 @@ describe('capy help --json', () => { }); }); +describe('capy deploy dokploy --discover (CAP-703), both entrypoints', () => { + test.each([ + ['prod', PROD_CLI], + ['dev', DEV_CLI], + ])('%s: the deploy command lists --discover, --plan, --confirm and --base-url', (_name, cli) => { + const { stdout } = run(cli, ['deploy', '--help']); + for (const flag of ['--discover', '--plan ', '--confirm ', '--base-url ']) { + expect(stdout, `missing ${flag}`).toContain(flag); + } + }); + + test.each([ + ['prod', PROD_CLI], + ['dev', DEV_CLI], + ])('%s: --discover on another target is a JSON refusal on stdout, in a terminal or not', (_name, cli) => { + const { stdout, code } = run(cli, ['deploy', 'aws-ssm', '--discover']); + expect(code).toBe(1); + expect(JSON.parse(stdout)).toMatchObject({ ok: false, code: 'DISCOVER_UNSUPPORTED_TARGET' }); + }); + + test.each([ + ['prod', PROD_CLI], + ['dev', DEV_CLI], + ])('%s: --confirm without --plan is a coded refusal before anything is read', (_name, cli) => { + const { stdout, code } = run(cli, ['deploy', 'dokploy', '--discover', '--confirm', 'abc', '--base-url', 'https://dokploy.example.com']); + expect(code).toBe(1); + expect(JSON.parse(stdout)).toMatchObject({ ok: false, code: 'PLAN_REQUIRED' }); + }); + + test('help --json lists the mode with supportsDryRun and publishes the plan schema', () => { + const doc = JSON.parse(run(PROD_CLI, ['help', '--json']).stdout); + const deploy = flatten(doc.commands).find((c: any) => c.path === 'deploy'); + expect(deploy.supportsDryRun).toBe(true); + expect(deploy.modes[0]).toMatchObject({ invocation: 'capy deploy dokploy --discover', supportsDryRun: true, alwaysJson: true, planSchema: 'deploy_dokploy_plan' }); + expect(doc.schemas.deploy_dokploy_plan.properties.entries.items.required).toContain('service_id'); + expect(doc.errorCodes).toEqual(expect.arrayContaining(['PLAN_CHANGED', 'SERVICE_NOT_FOUND', 'DUPLICATE_ENTRY', 'TARGET_EXISTS'])); + }); +}); + function flatten(commands: any[]): any[] { return commands.flatMap((c) => [c, ...flatten(c.subcommands ?? [])]); } diff --git a/tests/commands/systemCommand.test.ts b/tests/commands/systemCommand.test.ts index 29969a38..f25b715e 100644 --- a/tests/commands/systemCommand.test.ts +++ b/tests/commands/systemCommand.test.ts @@ -205,6 +205,19 @@ describe('systemCommand', () => { expect(stdout.includes('_CONNECTOR_FOO_KEY')).toBe(true); }); + // CAP-703: the Dokploy base URL variable is listed exactly like the API key: by name (and when it changed), no value. + it('lists _CONNECTOR_DOKPLOY_BASE_URL beside _CONNECTOR_DOKPLOY_API_KEY, names only', async () => { + listReturn = [ + { name: '_CONNECTOR_DOKPLOY_API_KEY', changed_at: '2026-01-01T00:00:00Z' }, + { name: '_CONNECTOR_DOKPLOY_BASE_URL', changed_at: '2026-01-02T00:00:00Z' }, + ]; + const human = await capture(() => systemListCommand({})); + expect(human.stdout).toContain('_CONNECTOR_DOKPLOY_API_KEY'); + expect(human.stdout).toContain('_CONNECTOR_DOKPLOY_BASE_URL'); + const json = await capture(() => systemListCommand({ json: true })); + expect(JSON.parse(json.stdout).names.map((n: { name: string }) => n.name)).toEqual(['_CONNECTOR_DOKPLOY_API_KEY', '_CONNECTOR_DOKPLOY_BASE_URL']); + }); + // CAP-679 follow-up: a reference entry shows what it points at — a NAME, // never a value, in both human and --json output. it('a reference entry shows its target NAME in human mode, never a value', async () => { diff --git a/tests/core/cliHelpDoc.test.ts b/tests/core/cliHelpDoc.test.ts index 9ea512f1..a3cbc72c 100644 --- a/tests/core/cliHelpDoc.test.ts +++ b/tests/core/cliHelpDoc.test.ts @@ -43,6 +43,25 @@ describe('buildCliHelpDoc', () => { expect(typeof doc.conventions.codes).toBe('string'); }); + test('CAP-703: publishes the deploy_dokploy_plan schema and the dokploy --discover mode (structured, supportsDryRun) under deploy', () => { + const doc = buildCliHelpDoc(buildProgram()); + const schema = doc.schemas.deploy_dokploy_plan as any; + expect(schema.required).toEqual(['version', 'entries']); + expect(schema.properties.entries.items.required).toEqual(['project_id', 'branch', 'service_id', 'git_branch', 'vars']); + const deploy = doc.commands.find((c) => c.name === 'deploy')!; + expect(deploy.modes).toHaveLength(1); + expect(deploy.modes![0]).toMatchObject({ + invocation: 'capy deploy dokploy --discover', + supportsDryRun: true, + supportsJson: true, + alwaysJson: true, + planSchema: 'deploy_dokploy_plan', + }); + expect(deploy.modes![0].flags).toEqual(expect.arrayContaining(['--discover', '--plan', '--confirm', '--base-url', '--dry-run'])); + // A command with no modes carries no `modes` field at all. + expect(doc.commands.find((c) => c.name === 'status')).not.toHaveProperty('modes'); + }); + test('includes every registered error code', () => { const doc = buildCliHelpDoc(buildProgram()); expect(doc.errorCodes).toEqual(Object.values(ERROR_CODES)); diff --git a/tests/helpers/deployDiscoverWorld.ts b/tests/helpers/deployDiscoverWorld.ts new file mode 100644 index 00000000..7f0fe3b0 --- /dev/null +++ b/tests/helpers/deployDiscoverWorld.ts @@ -0,0 +1,347 @@ +/** + * A fake world for `capy deploy dokploy --discover` tests (CAP-703): a Dokploy + * instance, 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. + * + * Two SENTINEL VALUES are planted: one in every Dokploy service env, one in + * every Capy index row (as the value hash). The command must never print, + * write or send either. + * + * No mutation: the fakes are static data plus `mock()` functions, whose + * recorded calls the tests read. + */ +import { createHash } from 'node:crypto'; +import { mock } from 'bun:test'; +import type { OrgRepoLink, SecretIndexRow } from '../../src/service/serviceClient'; +import type { ApiResult, GithubApi, RepoRef } from '../../src/deploy/githubApi'; +import type { DiscoveryDokployClient } from '../../src/commands/connectors/dokployDiscovery'; +import type { DeployDiscoverIo } from '../../src/commands/deployDiscover/command'; + +export const SENTINEL_DOKPLOY = 'SENTINEL-DOKPLOY-VALUE-7f3a9c41'; +export const SENTINEL_CAPY = 'SENTINEL-CAPY-VALUE-b21e44d0'; +export const TOKEN = 'dokploy-token-for-this-test-only'; +export const BASE_URL = 'https://dokploy.example.com'; + +// ── Dokploy ────────────────────────────────────────────────────────────────── + +export interface FakeService { + readonly id: string; + readonly kind: 'compose' | 'application'; + readonly name: string; + readonly environment: string; + readonly owner?: string; + readonly repository?: string; + readonly sourceType?: string; + readonly branch?: string; + readonly composePath?: string; + readonly buildPath?: string; + /** Env variable NAMES; each gets the Dokploy sentinel as its value. */ + readonly envNames: readonly string[]; +} + +export const envTextOf = (names: readonly string[]): string => names.map((n) => `${n}=${SENTINEL_DOKPLOY}`).join('\n'); + +export function dokployFake(services: readonly FakeService[]) { + const environments = [...new Set(services.map((s) => s.environment))]; + const projects = [ + { + projectId: 'dp_1', + name: 'Dokploy Project', + environments: environments.map((env) => ({ + environmentId: `env_${env}`, + name: env, + applications: services.filter((s) => s.environment === env && s.kind === 'application').map((s) => ({ id: s.id, kind: 'application' as const, name: s.name })), + composes: services.filter((s) => s.environment === env && s.kind === 'compose').map((s) => ({ id: s.id, kind: 'compose' as const, name: s.name })), + })), + }, + ]; + const byId = new Map(services.map((s) => [s.id, s] as const)); + const listProjects = mock(async () => projects); + const getApplication = mock(async (id: string) => { + const s = byId.get(id) as FakeService; + return { + applicationId: id, + name: s.name, + env: envTextOf(s.envNames), + buildArgs: null, + buildSecrets: null, + createEnvFile: true, + owner: s.owner, + repository: s.repository, + sourceType: s.sourceType, + branch: s.branch, + buildPath: s.buildPath, + }; + }); + const getCompose = mock(async (id: string) => { + const s = byId.get(id) as FakeService; + return { + composeId: id, + name: s.name, + env: envTextOf(s.envNames), + createEnvFile: true, + owner: s.owner, + repository: s.repository, + sourceType: s.sourceType, + branch: s.branch, + composePath: s.composePath, + }; + }); + const client: DiscoveryDokployClient = { listProjects, getApplication, getCompose } as unknown as DiscoveryDokployClient; + return { client, listProjects, getApplication, getCompose }; +} + +// ── Capy ───────────────────────────────────────────────────────────────────── + +export interface FakeProject { + readonly id: string; + readonly name: string; + /** Repo links (`path` is `.` for the repo root). Empty: the project has no repo link. */ + readonly repos: ReadonlyArray<{ readonly owner: string; readonly name: string; readonly path: string }>; + /** Variable NAMES per Capy branch. */ + readonly branches: Readonly>; +} + +export function capyFake(projects: readonly FakeProject[]) { + const links: OrgRepoLink[] = projects.flatMap((p) => + p.repos.map((r) => ({ + project_id: p.id, + project_name: p.name, + host: 'github.com', + owner: r.owner, + name: r.name, + path: r.path, + github_repo_id: 1, + last_seen_at: '2026-10-01T00:00:00Z', + })), + ); + const names = [...new Set(projects.flatMap((p) => Object.values(p.branches).flat()))]; + const rows: SecretIndexRow[] = names.flatMap((name) => + projects.map((p) => ({ + name, + value_hash: `${SENTINEL_CAPY}-${name}-${p.id}`, + locations: Object.entries(p.branches) + .filter(([, vars]) => vars.includes(name)) + .map(([branch]) => ({ project_id: p.id, project_name: p.name, branch, protected: false, service: null })), + users: [{ user_id: 'user_1', email: 'someone@example.com' }], + })), + ).filter((row) => row.locations.length > 0); + const getOrgRepos = mock(async (_orgId: string) => ({ org_id: 'org_1', repos: links })); + const getSecretIndex = mock(async (_orgId: string) => ({ org_id: 'org_1', rows, skipped: [] })); + return { client: { getOrgRepos, getSecretIndex }, getOrgRepos, getSecretIndex }; +} + +// ── GitHub ─────────────────────────────────────────────────────────────────── + +export interface FakeRepo { + readonly defaultBranch: string; + readonly branches: readonly string[]; + /** Files on the default branch, by repo-relative path. */ + readonly files?: Readonly>; + /** Methods that fail for this repo. */ + readonly failing?: readonly (keyof GithubApi)[]; +} + +const keyOf = (r: RepoRef): string => `${r.owner}/${r.name}`.toLowerCase(); +const okResult = (value: T): ApiResult => ({ ok: true, value }); +const failure = (): ApiResult => ({ ok: false, kind: 'REQUEST_FAILED' }); + +export function githubFake(repos: Readonly>) { + const spec = (r: RepoRef): FakeRepo | undefined => repos[keyOf(r)]; + const fails = (r: RepoRef, method: keyof GithubApi): boolean => spec(r)?.failing?.includes(method) === true; + const getRepo = mock(async (r: RepoRef) => (spec(r) ? okResult({ defaultBranch: (spec(r) as FakeRepo).defaultBranch }) : ({ ok: false, kind: 'NOT_FOUND' } as const))); + const getDefaultBranches = mock(async (rs: readonly RepoRef[]) => okResult(rs.map((r) => spec(r)?.defaultBranch ?? null))); + const listBranches = mock(async (r: RepoRef) => (fails(r, 'listBranches') || !spec(r) ? failure() : okResult([...(spec(r) as FakeRepo).branches]))); + const getBranchHead = mock(async (r: RepoRef, _b: string) => + fails(r, 'getBranchHead') ? failure() : okResult({ commitSha: `head-${keyOf(r)}`, treeSha: `tree-${keyOf(r)}` }), + ); + const getFile = mock(async (r: RepoRef, path: string, _ref: string) => + fails(r, 'getFile') ? failure() : okResult((spec(r)?.files?.[path] ?? null) as string | null), + ); + const createBlob = mock(async (r: RepoRef, content: string) => + fails(r, 'createBlob') ? failure() : okResult({ sha: `blob-${createHash('sha1').update(content).digest('hex')}` }), + ); + const createTree = mock(async (r: RepoRef, _params: unknown) => (fails(r, 'createTree') ? failure() : okResult({ sha: `newtree-${keyOf(r)}` }))); + const createCommit = mock(async (r: RepoRef, _params: unknown) => (fails(r, 'createCommit') ? failure() : okResult({ sha: `newcommit-${keyOf(r)}` }))); + const createRef = mock(async (r: RepoRef, branch: string, _sha: string) => (fails(r, 'createRef') ? failure() : okResult({ ref: `refs/heads/${branch}` }))); + const createPull = mock(async (r: RepoRef, _params: unknown) => + fails(r, 'createPull') ? failure() : okResult({ url: `https://github.com/${r.owner}/${r.name}/pull/1` }), + ); + const api = { getRepo, getDefaultBranches, listBranches, getBranchHead, getFile, createBlob, createTree, createCommit, createRef, createPull } as unknown as GithubApi; + return { api, getRepo, getDefaultBranches, listBranches, getBranchHead, getFile, createBlob, createTree, createCommit, createRef, createPull }; +} + +export type GithubMocks = ReturnType; + +/** What a fake GitHub was asked to write: per repo, the PR's commit (paths + parsed contents), its title and body. */ +export function writtenBy(gh: GithubMocks) { + const contentBySha = new Map(gh.createBlob.mock.calls.map(([, content]) => [`blob-${createHash('sha1').update(content as string).digest('hex')}`, content as string] as const)); + const treeCalls = gh.createTree.mock.calls.map(([repo, params]) => { + const p = params as { entries?: ReadonlyArray<{ path: string; blobSha: string }>; path?: string; blobSha?: string }; + const entries = p.entries ?? [{ path: p.path as string, blobSha: p.blobSha as string }]; + return { repo: keyOf(repo as RepoRef), files: Object.fromEntries(entries.map((e) => [e.path, contentBySha.get(e.blobSha) as string])) as Record }; + }); + return { + commits: treeCalls, + pulls: gh.createPull.mock.calls.map(([repo, params]) => ({ repo: keyOf(repo as RepoRef), ...(params as { title: string; body: string; head: string; base: string }) })), + commitMessages: gh.createCommit.mock.calls.map(([, params]) => (params as { message: string }).message), + }; +} + +// ── The shared fixture ─────────────────────────────────────────────────────── + +export const P_SOLO = 'proj_solo'; +export const P_API = 'proj_api'; +export const P_WEB = 'proj_web'; +export const P_TWIN1 = 'proj_twin1'; +export const P_TWIN2 = 'proj_twin2'; +export const P_NOBR = 'proj_nobr'; +export const P_WEAK = 'proj_weak'; +export const P_CONF = 'proj_conf'; +export const P_NOLINK = 'proj_nolink'; + +export const PROJECTS: readonly FakeProject[] = [ + { id: P_SOLO, name: 'solo-app', repos: [{ owner: 'acme', name: 'solo', path: '.' }], branches: { production: ['A', 'B', 'C', 'D'] } }, + { + id: P_API, + name: 'backend-api', + repos: [{ owner: 'acme', name: 'backend', path: 'api' }], + branches: { production: ['DATABASE_URL', 'REDIS_URL', 'API_KEY', 'JWT_SECRET'], staging: ['DATABASE_URL', 'REDIS_URL', 'API_KEY'] }, + }, + { + id: P_WEB, + name: 'backend-web', + repos: [{ owner: 'acme', name: 'backend', path: 'web' }], + branches: { production: ['DATABASE_URL', 'SESSION_SECRET', 'NEXT_PUBLIC_URL'] }, + }, + { id: P_TWIN1, name: 'twin-one', repos: [{ owner: 'acme', name: 'twin', path: '.' }], branches: { production: ['T1', 'T2'] } }, + { id: P_TWIN2, name: 'twin-two', repos: [{ owner: 'acme', name: 'twin', path: '.' }], branches: { production: ['T1', 'T2'] } }, + { id: P_NOBR, name: 'no-branch', repos: [{ owner: 'acme', name: 'nobr', path: '.' }], branches: { production: ['N1'] } }, + { + id: P_WEAK, + name: 'weak-overlap', + repos: [{ owner: 'acme', name: 'weak', path: '.' }], + branches: { production: ['X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7', 'X8', 'X9', 'X10'] }, + }, + { id: P_CONF, name: 'configured', repos: [{ owner: 'acme', name: 'conf', path: '.' }], branches: { production: ['C1', 'C2'] } }, + { id: P_NOLINK, name: 'no-link-project', repos: [], branches: { production: ['Z1'] } }, +]; + +export const SERVICES: readonly FakeService[] = [ + { id: 'cmp_solo', kind: 'compose', name: 'solo', environment: 'production', owner: 'acme', repository: 'solo', sourceType: 'github', branch: 'main', composePath: 'docker-compose.yml', envNames: ['A', 'B', 'C', 'D'] }, + { + id: 'cmp_api', + kind: 'compose', + name: 'api', + environment: 'production', + owner: 'acme', + repository: 'backend', + sourceType: 'github', + branch: 'main', + composePath: 'api/docker-compose.yml', + envNames: ['DATABASE_URL', 'REDIS_URL', 'API_KEY', 'JWT_SECRET', 'ONLY_IN_SERVICE'], + }, + { + id: 'app_api_stg', + kind: 'application', + name: 'api-staging', + environment: 'staging', + owner: 'acme', + repository: 'backend', + sourceType: 'github', + branch: 'staging', + buildPath: '/api', + envNames: ['DATABASE_URL', 'REDIS_URL', 'API_KEY'], + }, + { + id: 'cmp_web', + kind: 'compose', + name: 'web', + environment: 'production', + owner: 'acme', + repository: 'backend', + sourceType: 'github', + branch: 'main', + composePath: 'web/docker-compose.yml', + envNames: ['DATABASE_URL', 'SESSION_SECRET', 'NEXT_PUBLIC_URL'], + }, + { id: 'cmp_raw', kind: 'compose', name: 'raw-image', environment: 'production', sourceType: 'raw', envNames: ['R1'] }, + { id: 'cmp_nolink', kind: 'compose', name: 'unlinked', environment: 'production', owner: 'acme', repository: 'unlinked', sourceType: 'github', branch: 'main', composePath: 'docker-compose.yml', envNames: ['U1'] }, + { id: 'cmp_twin', kind: 'compose', name: 'twin', environment: 'production', owner: 'acme', repository: 'twin', sourceType: 'github', branch: 'main', composePath: 'docker-compose.yml', envNames: ['T1', 'T2'] }, + { id: 'cmp_nomatch', kind: 'compose', name: 'other-folder', environment: 'production', owner: 'acme', repository: 'backend', sourceType: 'github', branch: 'main', composePath: 'other/docker-compose.yml', envNames: ['O1'] }, + { id: 'cmp_feature', kind: 'compose', name: 'feature', environment: 'preview', owner: 'acme', repository: 'nobr', sourceType: 'github', branch: 'feature-x', composePath: 'docker-compose.yml', envNames: ['N1'] }, + { id: 'cmp_weak', kind: 'compose', name: 'weak', environment: 'production', owner: 'acme', repository: 'weak', sourceType: 'github', branch: 'main', composePath: 'docker-compose.yml', envNames: ['X1', 'Y1', 'Y2', 'Y3'] }, + { id: 'cmp_conf', kind: 'compose', name: 'conf', environment: 'production', owner: 'acme', repository: 'conf', sourceType: 'github', branch: 'main', composePath: 'docker-compose.yml', envNames: ['C1', 'C2'] }, + { id: 'cmp_conf_other', kind: 'compose', name: 'conf-other', environment: 'production', owner: 'acme', repository: 'conf', sourceType: 'github', branch: 'main', composePath: 'docker-compose.yml', envNames: ['C1'] }, +]; + +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-Dokploy target (the merge must leave it exactly as it is). */ +export const EXISTING_API_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 Dokploy target for `cmp_conf` on `production`. */ +export const EXISTING_CONF_DEPLOY = + JSON.stringify({ version: '1', targets: { 'dokploy-production': target('dokploy-production', 'dokploy', 'production', { baseUrl: BASE_URL, composeId: 'cmp_conf' }) } }, null, 2) + '\n'; + +export const GITHUB_REPOS: Readonly> = { + 'acme/solo': { defaultBranch: 'main', branches: ['main', 'develop'] }, + 'acme/backend': { defaultBranch: 'main', branches: ['main', 'staging', 'develop'], files: { 'api/.capy/deploy.json': EXISTING_API_DEPLOY } }, + 'acme/twin': { defaultBranch: 'main', branches: ['main'] }, + 'acme/nobr': { defaultBranch: 'main', branches: ['main', 'feature-x'] }, + 'acme/weak': { defaultBranch: 'main', branches: ['main'] }, + 'acme/conf': { defaultBranch: 'main', branches: ['main'], files: { '.capy/deploy.json': EXISTING_CONF_DEPLOY } }, +}; + +// ── The io ─────────────────────────────────────────────────────────────────── + +export interface WorldOptions { + readonly services?: readonly FakeService[]; + readonly projects?: readonly FakeProject[]; + readonly repos?: Readonly>; + /** Plan files by path. */ + readonly files?: Readonly>; + readonly io?: Partial; +} + +export function makeWorld(options: WorldOptions = {}) { + const dokploy = dokployFake(options.services ?? SERVICES); + const capy = capyFake(options.projects ?? PROJECTS); + const github = githubFake(options.repos ?? GITHUB_REPOS); + const progress = mock((_line: string) => undefined); + const getConnectorSecret = mock(async (_name: string, _opts: unknown) => TOKEN as string | null); + const files = options.files ?? {}; + const io: DeployDiscoverIo = { + devMode: false, + env: {}, + savedBaseUrl: () => undefined, + 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 }), + getConnectorSecret: getConnectorSecret as never, + dokploy: () => dokploy.client, + github: () => github.api, + branchName: () => 'capy/dokploy-targets-test-0000', + progress, + ...options.io, + }; + return { io, dokploy, capy, github, progress, getConnectorSecret }; +} + +export const planFile = (entries: readonly unknown[]): string => JSON.stringify({ version: 1, entries }); + +export const ENTRY_SOLO = { project_id: P_SOLO, branch: 'production', service_id: 'cmp_solo', git_branch: 'main', vars: ['D', 'A', 'B', 'C'] }; +export const ENTRY_API = { project_id: P_API, branch: 'production', service_id: 'cmp_api', git_branch: 'main', vars: ['DATABASE_URL', 'REDIS_URL', 'API_KEY', 'JWT_SECRET'] }; +export const ENTRY_API_STG = { project_id: P_API, branch: 'staging', service_id: 'app_api_stg', git_branch: 'staging', vars: ['DATABASE_URL', 'API_KEY'] }; +export const ENTRY_WEB = { project_id: P_WEB, branch: 'production', service_id: 'cmp_web', git_branch: 'main', vars: ['SESSION_SECRET', 'DATABASE_URL', 'NEXT_PUBLIC_URL'] }; diff --git a/tests/helpers/processState.ts b/tests/helpers/processState.ts new file mode 100644 index 00000000..c490cf36 --- /dev/null +++ b/tests/helpers/processState.ts @@ -0,0 +1,41 @@ +/** + * Borrowing process-global state for one test (CAP-703): `process.env` and the `isTTY` flags of stdin and + * stdout. These are third-party objects that only offer in-place mutation, so the mutation is confined here, + * each helper puts back exactly what it found, and every test body stays free of it. + * + * `isTTY` is restored from its ORIGINAL property descriptor, defined writable and configurable (a CI run once + * failed on a test that left it read-only): when there was none, it goes back to an ordinary `undefined`. + */ + +type TtyTarget = NodeJS.ReadStream | NodeJS.WriteStream; + +function setTty(stream: TtyTarget, descriptor: PropertyDescriptor): void { + Object.defineProperty(stream, 'isTTY', descriptor); +} + +/** Runs `fn` with `process.stdin.isTTY` and / or `process.stdout.isTTY` forced to `true`. */ +export async function withTty(which: { readonly stdin?: boolean; readonly stdout?: boolean }, fn: () => Promise): Promise { + const streams: ReadonlyArray = [ + ...(which.stdin === true ? ([[process.stdin, Object.getOwnPropertyDescriptor(process.stdin, 'isTTY')]] as const) : []), + ...(which.stdout === true ? ([[process.stdout, Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')]] as const) : []), + ]; + streams.forEach(([stream]) => setTty(stream, { value: true, writable: true, configurable: true })); + try { + return await fn(); + } finally { + streams.forEach(([stream, original]) => setTty(stream, original ?? { value: undefined, writable: true, configurable: true })); + } +} + +/** Runs `fn` with these environment variables set (`undefined` removes one), then puts every one back as it was. */ +export async function withEnv(vars: Readonly>, fn: () => Promise): Promise { + const saved = Object.entries(vars).map(([key]) => [key, process.env[key]] as const); + const apply = (entries: ReadonlyArray): void => + entries.forEach(([key, value]) => (value === undefined ? Reflect.deleteProperty(process.env, key) : Reflect.set(process.env, key, value))); + apply(Object.entries(vars)); + try { + return await fn(); + } finally { + apply(saved); + } +} diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 7f822c14..b41c4823 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -46,6 +46,7 @@ ISOLATED_FILES=( tests/commands/deployRevokeWiring.test.ts tests/commands/deployDirectModeTiming.test.ts tests/commands/deployDokployPickerTokenEnv.test.ts + tests/commands/deployPickerBaseUrlSave.test.ts tests/commands/projectPickerWiring.test.ts tests/files/fileManager.test.ts tests/ui/promptEngine.test.ts From c5543edfa5312bb404d5a9cfa8be3f1fab895dfe Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 4 Oct 2026 01:29:49 -0700 Subject: [PATCH 2/2] chore(release): v0.9.10 --- VERSION | 2 +- docs/cli-reference.md | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 7e310bae..56f31511 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.9 +0.9.10 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e40ed85b..97494089 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,7 +2,7 @@ # capy CLI reference -Version `0.9.9`. Generated from `capy help --json`. +Version `0.9.10`. Generated from `capy help --json`. ## Commands diff --git a/package.json b/package.json index caefac7c..dfca0d43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@capysc/cli", - "version": "0.9.9", + "version": "0.9.10", "description": "Capy CLI - SecretOps for the AI age", "main": "dist/index.js", "bin": {