From 40ee3bbd591108a60d750dd04d747a7a9282af8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 09:14:21 +0000 Subject: [PATCH] feat(sprint): onboard gate, orient drift detection, platform secret push, VPS switcher, SSH pre-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #19, #20, #21, #22, #23. Onboard now precedes everything: first contact with a repo gates the plan behind a 5-question interview; greenfield repos get a decisive platform pick that becomes the team mandate. Orient runs on every subsequent plan as drift detection between declared preferences and repo artifacts — drift is surfaced, never silently re-scored. stage-secrets pushes values to the chosen platform (fly/vercel/ railway/cloudrun) when auth allows, falling back to local-only. The web plan page gains a VPS option in the platform switcher with inline host input. vps bootstrap probes SSH before acting and explains exactly how to fix key auth instead of "ssh connectivity". Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adapters/railway/runner.ts | 15 +- src/adapters/vps/runner.ts | 69 ++++++++ src/cli.ts | 255 +++++++++++++++++++++++++--- src/core/secret-push.ts | 205 ++++++++++++++++++++++ src/onboard/index.ts | 4 +- src/onboard/preferences.ts | 3 + src/onboard/quick.ts | 216 +++++++++++++++++++++++ src/orient/drift.test.ts | 82 +++++++++ src/orient/drift.ts | 138 +++++++++++++++ src/planner/index.ts | 23 ++- src/planner/picker.ts | 34 +++- web/app/actions.ts | 72 +++++++- web/app/plans/[id]/config-panel.tsx | 72 +++++++- web/app/plans/[id]/page.tsx | 14 +- 14 files changed, 1154 insertions(+), 48 deletions(-) create mode 100644 src/core/secret-push.ts create mode 100644 src/onboard/quick.ts create mode 100644 src/orient/drift.test.ts create mode 100644 src/orient/drift.ts diff --git a/src/adapters/railway/runner.ts b/src/adapters/railway/runner.ts index 2358df9..376604b 100644 --- a/src/adapters/railway/runner.ts +++ b/src/adapters/railway/runner.ts @@ -69,12 +69,21 @@ async function pollRailwayStatus(cwd: string, projectId?: string): Promise, cwd: string, projectId?: string): Promise { for (const [key, value] of Object.entries(secrets)) { - const args = ['variables', 'set', `${key}=${value}`]; - if (projectId) args.push('--project', projectId); - await exec('railway', args, { cwd }); + await railwaySetVariable(key, value, cwd, projectId); } } +/** + * Set a single variable on the linked Railway service. Modern Railway CLI + * syntax is `railway variables --set KEY=value`; requires the cwd to be + * linked (`railway link`) or a project id. + */ +export async function railwaySetVariable(key: string, value: string, cwd: string, projectId?: string): Promise { + const args = ['variables', '--set', `${key}=${value}`]; + if (projectId) args.push('--project', projectId); + await exec('railway', args, { cwd }); +} + export async function railwayRollback(cwd: string, projectId?: string): Promise { try { // Get prior deployment ID and redeploy it diff --git a/src/adapters/vps/runner.ts b/src/adapters/vps/runner.ts index ee7fb07..e6dbc02 100644 --- a/src/adapters/vps/runner.ts +++ b/src/adapters/vps/runner.ts @@ -1,4 +1,7 @@ import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; /** * Low-level VPS deploy primitives over SSH + rsync. Convoy is the control @@ -113,6 +116,72 @@ export async function rsyncAvailable(): Promise { return result.ok; } +/** + * Look for the operator's default SSH private keys (~/.ssh/id_ed25519, + * id_rsa, id_ecdsa). Discovery only — Convoy never generates keys; if none + * exist the caller prints the `ssh-keygen -t ed25519` recipe instead. + */ +export function discoverLocalSshKeys(): string[] { + const home = homedir(); + const candidates = ['id_ed25519', 'id_rsa', 'id_ecdsa']; + const found: string[] = []; + for (const name of candidates) { + const p = join(home, '.ssh', name); + if (existsSync(p)) found.push(p); + } + return found; +} + +export interface SshProbeResult { + status: 'connected' | 'auth-failed' | 'unreachable'; + /** Remote user when connected (from `whoami`). */ + user?: string; + /** Operator-facing line: what happened + the exact fix. */ + detail: string; +} + +/** + * Cheap connectivity + key-auth probe, run before bootstrap touches the box. + * `ssh -o BatchMode=yes -o ConnectTimeout=5 [-i key] user@host` with a + * sentinel command, classified into the three states an operator can act on: + * + * connected → exit 0, sentinel echoed back + * auth-failed → ssh reached the box but key auth was refused + * ("Permission denied" in stderr — fix: ssh-copy-id) + * unreachable → timeout / refused / no route ("check the IP and firewall") + */ +export async function probeSshAuth( + destination: string, + opts: { identityFile?: string; port?: number } = {}, +): Promise { + const args: string[] = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5']; + if (opts.port) args.push('-p', String(opts.port)); + if (opts.identityFile) args.push('-i', opts.identityFile); + args.push(destination, 'echo convoy-probe && whoami'); + + const result = await spawnCapture('ssh', args, { timeoutMs: 10_000 }); + if (result.ok && result.stdout.includes('convoy-probe')) { + const user = result.stdout.split('\n').map((l) => l.trim()).filter(Boolean).pop() ?? ''; + return { + status: 'connected', + ...(user && user !== 'convoy-probe' ? { user } : {}), + detail: `connected${user && user !== 'convoy-probe' ? ` as ${user}` : ''}`, + }; + } + + const stderr = result.stderr.toLowerCase(); + if (/permission denied|too many authentication failures|no supported authentication/.test(stderr)) { + return { + status: 'auth-failed', + detail: `SSH key auth failed — upload your public key: ssh-copy-id ${destination} (or pass --ssh-key)`, + }; + } + return { + status: 'unreachable', + detail: 'Host unreachable — check the IP and firewall', + }; +} + /** * Verify the box is reachable, has docker, and is willing to talk to us. * Returns a structured report instead of a single boolean — the caller diff --git a/src/cli.ts b/src/cli.ts index eed1a9c..7debff4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ import type { Platform, Run, RunEvent, StageName } from './core/types.js'; import { probePlatformConnection } from './adapters/connections.js'; import type { ConnectionCheck, ConnectionStatus } from './adapters/types.js'; import { buildPlan } from './planner/index.js'; +import type { PlatformAdjustments } from './planner/picker.js'; import { loadByokConfig, resolveAnthropicKey } from './core/key-resolver.js'; import { scanRepository } from './planner/scanner.js'; import { resolveTarget } from './planner/target-resolver.js'; @@ -502,6 +503,100 @@ interface ShipOpts extends ApplyOpts { platform?: string; workspace?: string; noAi?: boolean; + skipOnboard?: boolean; + skipOrient?: boolean; + strictDrift?: boolean; +} + +/** + * Onboard gate + orient drift detection, shared by `convoy plan` and + * `convoy ship` (issues #23 + #20). Doctrine: + * + * first contact: onboard → plan (Convoy asks before it assumes) + * every run after: orient → plan (Convoy checks what changed) + * greenfield: onboard → plan, fast (Convoy leads — its pick IS the pattern) + * + * No preferences on file + interactive + no --skip-onboard → BLOCK on the + * 5-question interview before scoring anything. Non-interactive / CI / + * --skip-onboard → warn and proceed with defaults. + * + * Preferences on file → orientRepo() runs on every plan and is compared + * against them. Consistent → silent. Drift → explicit warning (or hard + * pause under --strict-drift); the mandate still wins, never a silent + * re-score. No mandate → orient signals feed the picker as score + * adjustments so they show in the candidates table. + */ +async function applyOnboardAndOrient( + localPath: string, + opts: { skipOnboard?: boolean; skipOrient?: boolean; strictDrift?: boolean; interactive: boolean }, +): Promise<{ mandate: Platform | null; adjustments: PlatformAdjustments | undefined }> { + const { loadPreferences } = await import('./onboard/preferences.js'); + let prefs = loadPreferences(localPath); + + if (!prefs && !opts.skipOnboard) { + if (opts.interactive && stdin.isTTY) { + // Gate, not a warning: first contact blocks on the interview. + const { runQuickOnboard } = await import('./onboard/quick.js'); + prefs = await runQuickOnboard(localPath); + } else { + process.stderr.write(`${pc.yellow('⚠')} No team preferences on file and no TTY to ask — proceeding with defaults.\n`); + process.stderr.write(` Run ${pc.bold(`convoy onboard ${localPath}`)} to capture platform mandate, approvers, and compliance.\n`); + process.stderr.write(` Pass --skip-onboard to suppress this warning.\n\n`); + } + } + + if (!prefs) { + return { mandate: null, adjustments: undefined }; + } + + const mandateRaw = prefs.platform.mandate; + const mandate = mandateRaw && isPlatform(mandateRaw) ? mandateRaw : null; + + if (opts.skipOrient) { + return { mandate, adjustments: undefined }; + } + + // Per-run drift detection: what does the repo say today vs. what the team + // declared at onboard time? + try { + const { orientRepo } = await import('./orient/index.js'); + const { collectPlatformSignals, detectDrift, signalsToAdjustments } = await import('./orient/drift.js'); + const orient = await orientRepo(localPath); + const signals = collectPlatformSignals(orient, prefs); + + if (mandate) { + const drift = detectDrift(mandate, signals); + if (drift.length > 0) { + for (const finding of drift) { + process.stderr.write( + `${pc.yellow('⚠')} ${pc.bold('Drift:')} you onboarded with platform=${mandate}, but ${finding.evidence} appeared since. ` + + `Still shipping to ${mandate}. If the team moved: ${pc.bold(`convoy onboard --platform=${finding.detectedPlatform}`)}\n`, + ); + } + if (opts.strictDrift) { + process.stderr.write( + `\n${pc.red('✗')} ${pc.bold('--strict-drift: pausing on drift.')} Re-run after either updating the mandate ` + + `(${pc.bold('convoy onboard --platform=

')}) or removing the stray platform config, or drop --strict-drift to proceed with ${mandate}.\n`, + ); + process.exit(2); + } + } + // Mandate wins; orient signals never re-score a mandated platform. + return { mandate, adjustments: undefined }; + } + + // Preferences exist but no mandate: orient signals feed the picker so + // existing fly.toml / vercel.json / CI deploy steps / a VPS host on + // file tilt the score table visibly. + const adjustments = signalsToAdjustments(signals); + return { mandate: null, adjustments: Object.keys(adjustments).length > 0 ? adjustments : undefined }; + } catch (err) { + // Orientation must never block planning — it's advisory context. + process.stderr.write( + `${pc.yellow('!')} ${pc.dim(`orient pass failed (${err instanceof Error ? err.message : String(err)}); continuing without drift detection`)}\n`, + ); + return { mandate, adjustments: undefined }; + } } @@ -530,7 +625,7 @@ async function runShip( platformOverride = opts.platform; } - const thinking = startThinking(); + let thinking = startThinking(); try { const resolved = await resolveTarget(target, { localBaseDir: targetInvocationDir(), @@ -543,6 +638,19 @@ async function runShip( const inferredRepoUrl = resolved.repoUrl ?? undefined; + // Onboard gate + orient drift detection — same doctrine as `convoy plan`. + thinking.stop(); + const { mandate, adjustments } = await applyOnboardAndOrient(resolved.localPath, { + ...(opts.skipOnboard !== undefined && { skipOnboard: opts.skipOnboard }), + ...(opts.skipOrient !== undefined && { skipOrient: opts.skipOrient }), + ...(opts.strictDrift !== undefined && { strictDrift: opts.strictDrift }), + interactive: true, + }); + if (mandate && platformOverride === undefined) { + platformOverride = mandate; + } + thinking = startThinking(); + const byokKey = await resolveAnthropicKey(loadByokConfig(resolved.localPath)); const { plan, enrichmentSource } = await buildPlan(resolved.localPath, { ...(inferredRepoUrl !== undefined && { repoUrl: inferredRepoUrl }), @@ -550,6 +658,7 @@ async function runShip( ...(resolved.sha !== undefined && { sha: resolved.sha }), ...(platformOverride !== undefined && { platformOverride }), ...(opts.workspace !== undefined && { workspace: opts.workspace }), + ...(adjustments !== undefined && { platformAdjustments: adjustments }), ai: opts.noAi ? { disable: true } : { ...(byokKey && { apiKey: byokKey }) }, }); thinking.stop(); @@ -601,6 +710,9 @@ interface PlanOpts { noAi?: boolean; open?: boolean; skipOnboard?: boolean; + skipOrient?: boolean; + strictDrift?: boolean; + vpsHost?: string; } async function runPlan(path: string, opts: PlanOpts): Promise { @@ -618,7 +730,7 @@ async function runPlan(path: string, opts: PlanOpts): Promise { platformOverride = opts.platform; } - const thinking = opts.json ? null : startThinking(); + let thinking = opts.json ? null : startThinking(); try { const resolved = await resolveTarget(path, { localBaseDir: targetInvocationDir(), @@ -633,21 +745,36 @@ async function runPlan(path: string, opts: PlanOpts): Promise { const inferredRepoUrl = opts.repoUrl ?? resolved.repoUrl ?? undefined; - // Check for preferences and warn if missing - const { loadPreferences } = await import('./onboard/preferences.js'); - const prefs = loadPreferences(resolved.localPath); - if (!prefs && !opts.skipOnboard) { - process.stderr.write(`${pc.yellow('⚠')} No team preferences on file. Run ${pc.bold(`convoy onboard ${resolved.localPath}`)} first to capture deployment style,\n`); - process.stderr.write(` approvers, compliance requirements, and platform preferences.\n`); - process.stderr.write(` Using defaults for now. Pass --skip-onboard to suppress this warning.\n\n`); + // --vps-host: make the host visible to the picker (env signal) and + // persist it into team preferences when they exist, so the web plan + // page can pre-fill it next time. + if (opts.vpsHost) { + process.env['CONVOY_VPS_HOST'] = opts.vpsHost; + } + + // Onboard gate + orient drift detection (interactive prompts — pause + // the spinner first). + thinking?.stop(); + const { mandate, adjustments } = await applyOnboardAndOrient(resolved.localPath, { + ...(opts.skipOnboard !== undefined && { skipOnboard: opts.skipOnboard }), + ...(opts.skipOrient !== undefined && { skipOrient: opts.skipOrient }), + ...(opts.strictDrift !== undefined && { strictDrift: opts.strictDrift }), + interactive: !opts.json, + }); + if (mandate && !opts.platform) { + platformOverride = mandate; } - // If platform mandate is set, use it as override - if (prefs?.platform.mandate && !opts.platform) { - opts.platform = prefs.platform.mandate; - if (isPlatform(opts.platform)) { - platformOverride = opts.platform; + if (opts.vpsHost) { + const { loadPreferences, mergePreferences, savePreferences } = await import('./onboard/preferences.js'); + const current = loadPreferences(resolved.localPath); + if (current) { + savePreferences( + resolved.localPath, + mergePreferences(current, { deployment: { ...current.deployment, vpsHost: opts.vpsHost } }), + ); } } + thinking = opts.json ? null : startThinking(); const byokKey = await resolveAnthropicKey(loadByokConfig(resolved.localPath)); const { plan, enrichmentSource } = await buildPlan(resolved.localPath, { @@ -656,6 +783,7 @@ async function runPlan(path: string, opts: PlanOpts): Promise { ...(resolved.sha !== undefined && { sha: resolved.sha }), ...(platformOverride !== undefined && { platformOverride }), ...(opts.workspace !== undefined && { workspace: opts.workspace }), + ...(adjustments !== undefined && { platformAdjustments: adjustments }), ai: opts.noAi ? { disable: true } : { ...(byokKey && { apiKey: byokKey }) }, }); thinking?.stop(); @@ -2380,13 +2508,15 @@ interface VpsBootstrapOpts { deployRoot?: string; sshPort?: string; identityFile?: string; + sshUser?: string; + sshKey?: string; noDocker?: boolean; noCaddy?: boolean; yes?: boolean; } async function runVpsBootstrap(host: string, opts: VpsBootstrapOpts): Promise { - const { sshAvailable } = await import('./adapters/vps/runner.js'); + const { sshAvailable, discoverLocalSshKeys, probeSshAuth } = await import('./adapters/vps/runner.js'); const { bootstrapVps } = await import('./adapters/vps/bootstrap.js'); if (!(await sshAvailable())) { @@ -2394,20 +2524,62 @@ async function runVpsBootstrap(host: string, opts: VpsBootstrapOpts): Promise')}.)\n`); + if (opts.yes === true) { + process.exit(2); + } + } else if (!identityFile) { + process.stdout.write(`${pc.dim(`SSH keys available: ${localKeys.join(', ')} (ssh picks via ~/.ssh/config / agent; force one with --ssh-key)`)}\n`); + } + const deployRoot = opts.deployRoot ?? '/opt/convoy'; const target = { - host, + host: destination, deployRoot, ...(opts.sshPort !== undefined && { port: parseInt(opts.sshPort, 10) }), - ...(opts.identityFile !== undefined && { identityFile: opts.identityFile }), + ...(identityFile !== undefined && { identityFile }), }; - process.stdout.write(`${pc.dim('Probing')} ${pc.bold(host)} ${pc.dim('...')}\n`); + process.stdout.write(`${pc.dim('Probing')} ${pc.bold(destination)} ${pc.dim('...')}\n`); + + // Connectivity + key-auth probe before any state change. The three + // outcomes each carry their own fix, so "Bootstrap failed. ssh + // connectivity" never happens again. + const probe = await probeSshAuth(destination, { + ...(identityFile !== undefined && { identityFile }), + ...(opts.sshPort !== undefined && { port: parseInt(opts.sshPort, 10) }), + }); + if (probe.status === 'connected') { + process.stdout.write(`${pc.green('✓')} ${probe.detail}\n`); + } else { + process.stdout.write(`${pc.red('✗')} ${probe.detail}\n`); + if (opts.yes === true) { + process.stdout.write(`\n${pc.red('Bootstrap aborted before any change was made.')} Fix the SSH connection and re-run.\n`); + process.exit(2); + } + } if (opts.yes !== true) { // Dry-run: show what would happen without executing - process.stdout.write(`\n${pc.bold('vps bootstrap plan')} for ${pc.bold(host)}\n`); - process.stdout.write(`${pc.dim('deploy root:')} ${deployRoot}\n\n`); + process.stdout.write(`\n${pc.bold('vps bootstrap plan')} for ${pc.bold(destination)}\n`); + process.stdout.write(`${pc.dim('deploy root:')} ${deployRoot}\n`); + process.stdout.write( + `${pc.dim('ssh probe:')} ${probe.status === 'connected' ? pc.green(`✓ ${probe.detail}`) : pc.red(`✗ ${probe.detail}`)}\n\n`, + ); if (opts.noDocker !== true) { process.stdout.write(` ${pc.cyan('1.')} Install Docker (get.docker.com — skip if already present)\n`); @@ -2554,6 +2726,23 @@ async function runStageSecrets(planId: string): Promise { process.stdout.write( `${pc.green('✓')} Wrote ${Object.keys(valuesToStage).length} value${Object.keys(valuesToStage).length === 1 ? '' : 's'} to ${secretsPath}\n`, ); + + // Also push each value to the plan's chosen platform when its CLI + + // auth are available. Best-effort: the local write above is the source + // of truth, and a failed push degrades to "staged at deploy time" — + // never throws, never blocks the staging flow. + const { buildPushContextFromPlan, pushSecretToPlatform } = await import('./core/secret-push.js'); + const pushCtx = await buildPushContextFromPlan(plan); + for (const [key, value] of Object.entries(valuesToStage)) { + const pushed = await pushSecretToPlatform(key, value, pushCtx); + if (pushed.ok) { + process.stdout.write(` ${pc.green('✓')} ${pc.cyan(key)} pushed to ${pushCtx.platform}\n`); + } else { + process.stdout.write( + ` ${pc.yellow('⚠')} ${pc.cyan(key)} saved locally — ${pushed.reason ?? 'push failed'}; will be staged at deploy time\n`, + ); + } + } } if (toMarkAlreadySet.length > 0) { @@ -3819,8 +4008,11 @@ const program = new Command() program .command('ship ') .description('Plan + apply end-to-end. Real by default. Accepts a local path or a GitHub URL / owner/repo.') - .option('--platform ', 'explicit platform choice: fly | railway | vercel | cloudrun') + .option('--platform ', 'explicit platform choice: fly | railway | vercel | cloudrun | vps') .option('--workspace ', 'target a specific subdirectory (e.g. backend, apps/web)') + .option('--skip-onboard', 'skip the first-contact onboarding gate (CI: proceeds with defaults)') + .option('--skip-orient', 'skip per-run drift detection between preferences and repo artifacts') + .option('--strict-drift', 'turn drift warnings into a hard pause (exit 2 with a blocker message)') .option('-y, --auto-approve', 'auto-approve every gate. Default: pause at every gate; decide from the web UI') .option('--open', 'open the run in the web UI (http://localhost:3737) when it starts') .option('--trust-repo', 'allow real rehearsal to inherit cloud credentials from the parent env (default: scrubbed — only PATH/HOME/NODE_ENV + explicit --env)') @@ -3864,14 +4056,17 @@ program program .command('plan ') .description('Produce an inspectable plan of what `convoy apply` would do. Reads the target path or GitHub URL; does not write or deploy anything.') - .option('--platform ', 'explicit platform choice: fly | railway | vercel | cloudrun') + .option('--platform ', 'explicit platform choice: fly | railway | vercel | cloudrun | vps') .option('--repo-url ', 'annotate the plan with a remote repo URL (does not fetch)') .option('--workspace ', 'target a specific subdirectory (e.g. backend, apps/web) for monorepos') .option('--save', 'persist the plan to .convoy/plans/.json', false) .option('--open', 'open the saved plan in the web UI (requires --save)') .option('--json', 'output the raw plan as JSON instead of the human-readable render', false) .option('--no-ai', 'skip the Opus narrative pass and use the deterministic output') - .option('--skip-onboard', 'suppress the "no team preferences on file" warning') + .option('--skip-onboard', 'skip the first-contact onboarding gate (CI: proceeds with defaults)') + .option('--skip-orient', 'skip per-run drift detection between preferences and repo artifacts') + .option('--strict-drift', 'turn drift warnings into a hard pause (exit 2 with a blocker message)') + .option('--vps-host ', 'VPS destination (user@host or IP) — informs the vps lane and is saved to team preferences') .action(async (path: string, options: PlanOpts) => { await runPlan(path, options); }); @@ -4051,7 +4246,9 @@ program .argument('[host]', 'SSH destination: user@host') .option('--deploy-root ', 'base deploy directory on the box (default: /opt/convoy)') .option('--ssh-port ', 'SSH port (default: 22)') - .option('--identity-file ', 'path to SSH private key') + .option('--ssh-user ', 'SSH user when the host has no user@ prefix (default: root)') + .option('--ssh-key ', 'path to SSH private key (default: discovered ~/.ssh/id_ed25519 / id_rsa / id_ecdsa via ssh config/agent)') + .option('--identity-file ', 'path to SSH private key (alias of --ssh-key)') .option('--no-docker', 'skip Docker install') .option('--no-caddy', 'skip Caddy install') .option('-y, --yes', 'actually run the install commands (otherwise prints what it would do and exits)') @@ -4136,13 +4333,21 @@ program .command('onboard [path]') .description('Capture team deployment preferences interactively. Persists to .convoy/preferences.json.') .option('--answers ', 'pre-fill answers as JSON (for non-interactive/MCP use)') - .action(async (targetPath: string | undefined, opts: { answers?: string }) => { + .option('--platform ', 'set the platform mandate directly: fly | railway | vercel | cloudrun | vps (skips that question)') + .action(async (targetPath: string | undefined, opts: { answers?: string; platform?: string }) => { const localPath = resolve(targetPath ?? '.'); if (!existsSync(localPath)) { process.stderr.write(`${pc.red('error')} path not found: ${localPath}\n`); process.exit(1); } const prefilled = opts.answers ? JSON.parse(opts.answers) as Record : {}; + if (opts.platform !== undefined) { + if (!isPlatform(opts.platform)) { + process.stderr.write(pc.red(`Unknown platform "${opts.platform}". Supported: ${SUPPORTED_PLATFORMS.join(', ')}\n`)); + process.exit(2); + } + prefilled['platformMandate'] = opts.platform; + } const { runOnboard } = await import('./onboard/index.js'); await runOnboard(localPath, prefilled); }); diff --git a/src/core/secret-push.ts b/src/core/secret-push.ts new file mode 100644 index 0000000..8967935 --- /dev/null +++ b/src/core/secret-push.ts @@ -0,0 +1,205 @@ +/** + * Shared "push one secret to the plan's chosen platform" logic (issue #22). + * + * Used by both the CLI (`convoy stage-secrets`) and the web plan page's + * per-row "push to platform" button (server action in web/app/actions.ts — + * web already imports src/ directly, see adapters/connections.js usage). + * + * Contract: NEVER throws. The local .env.convoy-secrets write is the source + * of truth and always happens in the caller regardless; a platform push is + * opportunistic. On any failure (CLI missing, unauthenticated, unlinked) + * the caller falls back gracefully — the value will be staged at deploy + * time via the existing realFly/realVercel/... secrets paths. + */ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import type { Platform } from './types.js'; + +export interface SecretPushContext { + platform: Platform; + /** Directory the platform CLI runs in (project binding lives here). */ + cwd: string; + /** Fly app name / Cloud Run service name (auto-derived when omitted). */ + appName: string; + cloudrunRegion?: string; + cloudrunProject?: string; + railwayProject?: string; + /** SSH destination for the VPS lane; push is skipped when absent. */ + vpsHost?: string | null; + vpsKey?: string; + vpsPort?: number; + vpsDeployRoot?: string; +} + +export interface SecretPushResult { + ok: boolean; + /** Human-readable failure reason ("flyctl not installed", stderr tail…). */ + reason?: string; +} + +/** + * Minimal structural view of a plan — satisfied by both the core ConvoyPlan + * and the web viewer's PlanSummary, so one context builder serves both. + */ +export interface PushablePlan { + id: string; + platform: { chosen: string }; + target: { name: string; localPath: string; workspace?: string | null }; +} + +/** Same slug heuristic the CLI uses for auto fly app names. */ +export function autoAppName(targetName: string, planId: string): string { + const base = targetName.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'convoy-app'; + const hash = planId.slice(0, 6); + return `convoy-${base}-${hash}`.slice(0, 30); +} + +const PLATFORMS: readonly Platform[] = ['fly', 'railway', 'vercel', 'cloudrun', 'vps']; + +/** + * Derive a push context from a saved plan. The VPS host comes from team + * preferences (deployment.vpsHost) or CONVOY_VPS_HOST — same precedence the + * deploy lane uses. + */ +export async function buildPushContextFromPlan(plan: PushablePlan): Promise { + const chosen = PLATFORMS.includes(plan.platform.chosen as Platform) + ? (plan.platform.chosen as Platform) + : 'fly'; + const cwd = plan.target.workspace + ? resolve(plan.target.localPath, plan.target.workspace) + : plan.target.localPath; + const appName = autoAppName(plan.target.name, plan.id); + + let vpsHost: string | null = process.env['CONVOY_VPS_HOST'] ?? null; + try { + const { loadPreferences } = await import('../onboard/preferences.js'); + const prefs = loadPreferences(plan.target.localPath); + vpsHost = prefs?.deployment.vpsHost ?? vpsHost; + } catch { + // preferences unreadable — env fallback already applied + } + + return { + platform: chosen, + cwd: existsSync(cwd) ? cwd : plan.target.localPath, + appName, + cloudrunRegion: 'us-central1', + vpsHost, + vpsDeployRoot: `/srv/${appName}`, + }; +} + +export async function pushSecretToPlatform( + key: string, + value: string, + ctx: SecretPushContext, +): Promise { + try { + switch (ctx.platform) { + case 'fly': + return await pushFly(key, value, ctx); + case 'vercel': + return await pushVercel(key, value, ctx); + case 'railway': + return await pushRailway(key, value, ctx); + case 'cloudrun': + return await pushCloudRun(key, value, ctx); + case 'vps': + return await pushVps(key, value, ctx); + default: + return { ok: false, reason: `no push path for platform "${ctx.platform}"` }; + } + } catch (err) { + return { ok: false, reason: trimReason(err instanceof Error ? err.message : String(err)) }; + } +} + +async function pushFly(key: string, value: string, ctx: SecretPushContext): Promise { + const { flyctlAvailable, flyAuthStatus, flySetSecrets } = await import('../adapters/fly/runner.js'); + if (!(await flyctlAvailable())) return { ok: false, reason: 'flyctl not installed' }; + const auth = await flyAuthStatus(); + if (!auth.ok) return { ok: false, reason: `fly not authenticated (${auth.error ?? 'fly auth login'})` }; + try { + await flySetSecrets(ctx.appName, { [key]: value }); + return { ok: true }; + } catch (err) { + return { ok: false, reason: trimReason(err instanceof Error ? err.message : String(err)) }; + } +} + +async function pushVercel(key: string, value: string, ctx: SecretPushContext): Promise { + const { vercelAvailable, vercelSetEnv } = await import('../adapters/vercel/runner.js'); + if (!(await vercelAvailable())) return { ok: false, reason: 'vercel CLI not installed' }; + try { + // vercelSetEnv pipes the value via stdin (`vercel env add KEY production` + // with --force) — no interactive TTY needed, value never hits argv. + await vercelSetEnv(ctx.cwd, key, value, 'production'); + return { ok: true }; + } catch (err) { + return { ok: false, reason: trimReason(err instanceof Error ? err.message : String(err)) }; + } +} + +async function pushRailway(key: string, value: string, ctx: SecretPushContext): Promise { + const { railwayAvailable, railwaySetVariable } = await import('../adapters/railway/runner.js'); + if (!(await railwayAvailable())) return { ok: false, reason: 'railway CLI not installed' }; + try { + await railwaySetVariable(key, value, ctx.cwd, ctx.railwayProject); + return { ok: true }; + } catch (err) { + return { ok: false, reason: trimReason(err instanceof Error ? err.message : String(err)) }; + } +} + +async function pushCloudRun(key: string, value: string, ctx: SecretPushContext): Promise { + const { gcloudAvailable, cloudRunSetEnvVars } = await import('../adapters/cloudrun/runner.js'); + if (!(await gcloudAvailable())) return { ok: false, reason: 'gcloud not installed' }; + try { + await cloudRunSetEnvVars({ + service: ctx.appName, + region: ctx.cloudrunRegion ?? 'us-central1', + envVars: { [key]: value }, + ...(ctx.cloudrunProject ? { project: ctx.cloudrunProject } : {}), + }); + return { ok: true }; + } catch (err) { + return { ok: false, reason: trimReason(err instanceof Error ? err.message : String(err)) }; + } +} + +async function pushVps(key: string, value: string, ctx: SecretPushContext): Promise { + if (!ctx.vpsHost || ctx.vpsHost.trim().length === 0) { + return { ok: false, reason: 'no VPS host configured (set it via convoy onboard or CONVOY_VPS_HOST)' }; + } + const { sshAvailable, sshExec } = await import('../adapters/vps/runner.js'); + if (!(await sshAvailable())) return { ok: false, reason: 'ssh not installed' }; + const deployRoot = ctx.vpsDeployRoot ?? `/srv/${ctx.appName}`; + const target = { + host: ctx.vpsHost, + deployRoot, + ...(ctx.vpsPort !== undefined ? { port: ctx.vpsPort } : {}), + ...(ctx.vpsKey !== undefined ? { identityFile: ctx.vpsKey } : {}), + }; + // Replace-or-append: drop any prior line for this key, then append the new + // one. The value travels over stdin, never argv. Convoy owns everything + // under deployRoot, so the env file is Convoy-authored by definition. + const envFile = `${deployRoot}/.env.convoy`; + const cmd = [ + `mkdir -p '${deployRoot}'`, + `touch '${envFile}'`, + `grep -v '^${key}=' '${envFile}' > '${envFile}.tmp' || true`, + `cat >> '${envFile}.tmp'`, + `mv '${envFile}.tmp' '${envFile}'`, + `chmod 600 '${envFile}'`, + ].join(' && '); + const result = await sshExec(target, cmd, { input: `${key}=${value}\n`, timeoutMs: 20_000 }); + if (!result.ok) { + return { ok: false, reason: trimReason(result.stderr || `ssh exited ${result.code}`) }; + } + return { ok: true }; +} + +function trimReason(reason: string): string { + return reason.replace(/\s+/g, ' ').trim().slice(0, 200); +} diff --git a/src/onboard/index.ts b/src/onboard/index.ts index e7053ad..c9c78f0 100644 --- a/src/onboard/index.ts +++ b/src/onboard/index.ts @@ -29,7 +29,9 @@ export async function runOnboard(repoPath: string, prefilled: Partial { + const localPath = resolve(repoPath); + const existing = loadPreferences(localPath); + + const rl = readline.createInterface({ input, output }); + const ask = async (q: string): Promise => (await rl.question(q)).trim(); + const askChoice = async (q: string, choices: readonly T[], def: T): Promise => { + const list = choices.map((c, i) => ` ${i + 1}. ${c}${c === def ? ' (default)' : ''}`).join('\n'); + output.write(`${q}\n${list}\n`); + const answer = await ask(`${pc.dim('choice')} [${choices.indexOf(def) + 1}]: `); + if (!answer) return def; + const idx = parseInt(answer, 10) - 1; + if (idx >= 0 && idx < choices.length) return choices[idx]!; + const byName = choices.find((c) => c.toLowerCase() === answer.toLowerCase()); + return byName ?? def; + }; + + output.write(`\n${pc.bold('■ First contact')} ${pc.dim('— I have no team preferences on file for this repo.')}\n`); + output.write(`${pc.dim("Five quick questions before I score anything. Full interview later: ")}${pc.bold('convoy onboard')}\n\n`); + + let mandate: string | null = null; + let vpsHost: string | null = null; + let greenfieldPick: { platform: string; reason: string } | null = null; + + try { + // Q1 — existing platform → hard mandate. + const q1 = await askChoice( + '1/5 Do you already deploy this service somewhere?', + PLATFORM_CHOICES, + 'not yet', + ); + if (q1 !== 'not yet') { + mandate = q1; + if (q1 === 'vps') { + const host = await ask(` ${pc.dim('VPS host (user@host or IP, Enter to skip):')} `); + vpsHost = host.length > 0 ? host : null; + } + } + + // Q2 — first deploy vs update. + const q2 = await askChoice<'first' | 'update'>( + '\n2/5 Is this a first deploy or an update to a live service?', + ['first', 'update'], + 'first', + ); + + // Q3 — compliance / sensitive data → compliance requirements on file, + // auto-approve disabled (approver-gated releases). + const q3raw = (await ask( + '\n3/5 Does this service require SOC2/HIPAA/PCI/GDPR compliance or handle payment/health data? (y/n) [n]: ', + )).toLowerCase(); + const q3yes = q3raw.startsWith('y'); + let compliance: ComplianceKind[] = []; + if (q3yes) { + const which = (await ask( + ` ${pc.dim('Which? (soc2, hipaa, pci, gdpr — comma-separated, Enter for all):')} `, + )).toLowerCase(); + const named = which + .split(',') + .map((s) => s.trim()) + .filter((s): s is ComplianceKind => (COMPLIANCE_KINDS as readonly string[]).includes(s)); + compliance = named.length > 0 ? named : [...COMPLIANCE_KINDS]; + } + + // Q4 — approvers. + const q4 = await ask( + '\n4/5 Who needs to approve a production deploy? (GitHub handles, comma-separated; Enter to skip): ', + ); + const approvers = q4 + .split(',') + .map((s) => s.trim().replace(/^@/, '')) + .filter((s) => s.length > 0); + + // Q5 — secrets already on the platform → recorded as already-set + // declarations so preflight stops asking. No platform queries; the + // operator is vouching. + const q5 = await ask( + '\n5/5 Any secrets already set on the platform? (var names, comma-separated; Enter to skip): ', + ); + const alreadySet = q5 + .split(',') + .map((s) => s.trim()) + .filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s)); + if (alreadySet.length > 0) { + recordAlreadySet(localPath, alreadySet); + } + + // Greenfield: never shipped + first deploy → Convoy leads. Score from + // repo signals decisively; the pick becomes the mandate. + if (mandate === null && q2 === 'first') { + const scan = scanRepository(localPath); + const decision = pickPlatform(scan); + greenfieldPick = { platform: decision.chosen, reason: decision.reason }; + mandate = decision.chosen; + } + + const prefs = mergePreferences(existing, { + deployment: { + ...(existing?.deployment ?? DEFAULT_PREFERENCES.deployment), + mode: q2, + approvers, + vpsHost: vpsHost ?? existing?.deployment.vpsHost ?? null, + }, + platform: { + ...(existing?.platform ?? DEFAULT_PREFERENCES.platform), + mandate, + }, + secrets: { + ...(existing?.secrets ?? DEFAULT_PREFERENCES.secrets), + compliance, + }, + ...(q3yes + ? { + release: { + ...(existing?.release ?? DEFAULT_PREFERENCES.release), + // Compliance disables auto-approve: production needs a named + // approver gate, not a clean rehearsal alone. + gate: 'pr-staging-approver' as const, + }, + } + : {}), + }); + savePreferences(localPath, prefs); + + output.write('\n'); + if (greenfieldPick) { + output.write( + `${pc.cyan('▲')} ${pc.bold(`This service has never shipped. I'm taking it to ${greenfieldPick.platform}`)} — ${greenfieldPick.reason}.\n`, + ); + output.write( + ` ${pc.dim('This becomes your default; change it anytime with')} ${pc.bold('convoy onboard')}${pc.dim('.')}\n`, + ); + } else if (mandate) { + output.write( + `${pc.cyan('▲')} I'll ship this to ${pc.bold(mandate)} from now on — that's the team mandate. Change it with ${pc.bold('convoy onboard')}.\n`, + ); + } + if (compliance.length > 0) { + output.write( + ` ${pc.dim(`Compliance on file (${compliance.join(', ')}) — auto-approve is off; production waits for a named approver.`)}\n`, + ); + } + if (alreadySet.length > 0) { + output.write( + ` ${pc.dim(`Recorded ${alreadySet.length} var${alreadySet.length === 1 ? '' : 's'} as already set on the platform: ${alreadySet.join(', ')}`)}\n`, + ); + } + output.write(`${pc.green('✓')} Preferences saved to .convoy/preferences.json\n`); + output.write( + ` ${pc.dim('Tip: run')} ${pc.bold(`convoy onboard ${repoPath}`)} ${pc.dim('later for the full interview (canary strategy, budget, observability).')}\n\n`, + ); + + return prefs; + } finally { + rl.close(); + } +} + +/** + * Append KEY= lines to .env.convoy-already-set, skipping keys already + * declared so re-running the interview stays idempotent. + */ +function recordAlreadySet(localPath: string, keys: string[]): void { + const filePath = resolve(localPath, '.env.convoy-already-set'); + const prior = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''; + const have = new Set( + prior + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => line.split('=')[0]!.trim()), + ); + const fresh = keys.filter((k) => !have.has(k)); + if (fresh.length === 0) return; + const separator = prior.length > 0 && !prior.endsWith('\n') ? '\n' : ''; + appendFileSync(filePath, `${separator}${fresh.map((k) => `${k}=`).join('\n')}\n`, 'utf8'); +} diff --git a/src/orient/drift.test.ts b/src/orient/drift.test.ts new file mode 100644 index 0000000..e682564 --- /dev/null +++ b/src/orient/drift.test.ts @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { collectPlatformSignals, detectDrift, signalsToAdjustments } from './drift.js'; +import type { OrientResult } from './index.js'; +import type { DeployPreferences } from '../onboard/preferences.js'; +import { DEFAULT_PREFERENCES } from '../onboard/preferences.js'; + +function orientResult(overrides: { + topLevelFiles?: string[]; + topLevelDirs?: string[]; + deploySteps?: string[]; +} = {}): OrientResult { + return { + graph: { + localPath: '/tmp/repo', + scanRoot: '/tmp/repo', + workspace: null, + isMonorepo: false, + monorepoTool: null, + readmeTitle: null, + readmeFirstPara: null, + topLevelDirs: overrides.topLevelDirs ?? [], + topLevelFiles: overrides.topLevelFiles ?? [], + evidence: [], + risks: [], + nodes: [], + } as unknown as OrientResult['graph'], + ciWorkflows: overrides.deploySteps + ? [{ file: '.github/workflows/deploy.yml', triggers: ['push'], deploySteps: overrides.deploySteps, secretsReferenced: [] }] + : [], + secretsManager: null, + observability: [], + summary: '', + }; +} + +function prefs(overrides: { vpsHost?: string | null } = {}): DeployPreferences { + const now = new Date().toISOString(); + return { + version: 1, + createdAt: now, + updatedAt: now, + ...DEFAULT_PREFERENCES, + deployment: { ...DEFAULT_PREFERENCES.deployment, vpsHost: overrides.vpsHost ?? null }, + }; +} + +test('vercel.json against a fly mandate is drift; fly.toml is not', () => { + const signals = collectPlatformSignals( + orientResult({ topLevelFiles: ['fly.toml', 'vercel.json'] }), + prefs(), + ); + const drift = detectDrift('fly', signals); + assert.equal(drift.length, 1); + assert.equal(drift[0]!.detectedPlatform, 'vercel'); + assert.equal(drift[0]!.evidence, 'vercel.json'); +}); + +test('consistent repo (mandate platform only) produces no drift', () => { + const signals = collectPlatformSignals(orientResult({ topLevelFiles: ['fly.toml'] }), prefs()); + assert.equal(detectDrift('fly', signals).length, 0); +}); + +test('CI deploy steps surface as +30 adjustments; config files as +40', () => { + const signals = collectPlatformSignals( + orientResult({ topLevelFiles: ['railway.json'], deploySteps: ['run: gcloud run deploy api --image x'] }), + prefs(), + ); + const adjustments = signalsToAdjustments(signals); + assert.equal(adjustments.railway?.[0]?.delta, 40); + assert.equal(adjustments.cloudrun?.[0]?.delta, 30); + assert.match(adjustments.cloudrun?.[0]?.label ?? '', /CI already configured/); +}); + +test('VPS host in preferences yields a +50 vps signal', () => { + const signals = collectPlatformSignals(orientResult(), prefs({ vpsHost: 'root@203.0.113.7' })); + const adjustments = signalsToAdjustments(signals); + assert.equal(adjustments.vps?.[0]?.delta, 50); + // ...and counts as drift when the mandate points elsewhere. + assert.equal(detectDrift('fly', signals).length, 1); +}); diff --git a/src/orient/drift.ts b/src/orient/drift.ts new file mode 100644 index 0000000..c339f9f --- /dev/null +++ b/src/orient/drift.ts @@ -0,0 +1,138 @@ +import type { Platform } from '../core/types.js'; +import type { DeployPreferences } from '../onboard/preferences.js'; +import type { PlatformAdjustments } from '../planner/picker.js'; +import type { OrientResult } from './index.js'; + +/** + * One platform-shaped artifact the orient pass found in the repo (or in + * team preferences). Signals do double duty: + * + * - With a platform mandate on file, any signal pointing at a DIFFERENT + * platform is drift — surfaced loudly, never silently re-scored. + * - Without a mandate, every signal feeds the picker as a score + * adjustment so it shows up in the candidate table (CLI, plan JSON, web). + */ +export interface OrientSignal { + platform: Platform; + /** Short artifact name for drift messages, e.g. "vercel.json". */ + evidence: string; + delta: number; + /** Score-table label, e.g. "existing platform (fly.toml)". */ + label: string; +} + +export interface DriftFinding { + detectedPlatform: Platform; + evidence: string; +} + +const PLATFORM_CONFIG_FILES: Array<{ file: string; platform: Platform }> = [ + { file: 'fly.toml', platform: 'fly' }, + { file: 'vercel.json', platform: 'vercel' }, + { file: 'railway.json', platform: 'railway' }, + { file: 'railway.toml', platform: 'railway' }, +]; + +const CI_DEPLOY_PATTERNS: Array<{ match: RegExp; platform: Platform; what: string }> = [ + { match: /fly\s+deploy|flyctl\s+deploy/, platform: 'fly', what: 'fly deploy' }, + { match: /vercel\s+(--prod|deploy)/, platform: 'vercel', what: 'vercel --prod' }, + { match: /railway\s+up/, platform: 'railway', what: 'railway up' }, + { match: /gcloud\s+run\s+deploy/, platform: 'cloudrun', what: 'gcloud run deploy' }, +]; + +/** + * Collect platform-pointing signals from an orient result + preferences. + * Pure over its inputs — no filesystem access — so it's unit-testable. + */ +export function collectPlatformSignals( + orient: OrientResult, + prefs: DeployPreferences | null, +): OrientSignal[] { + const signals: OrientSignal[] = []; + const seen = new Set(); + const push = (s: OrientSignal) => { + const key = `${s.platform}:${s.label}`; + if (seen.has(key)) return; + seen.add(key); + signals.push(s); + }; + + const topFiles = orient.graph.topLevelFiles; + const topDirs = orient.graph.topLevelDirs; + + for (const { file, platform } of PLATFORM_CONFIG_FILES) { + if (topFiles.includes(file)) { + push({ platform, evidence: file, delta: 40, label: `existing platform (${file})` }); + } + } + if (topDirs.includes('.vercel')) { + push({ platform: 'vercel', evidence: '.vercel/', delta: 40, label: 'existing platform (.vercel project link)' }); + } + + // Per-service platform configs detected by the scanner (covers configs + // living inside workspace members, not just repo root). + for (const node of orient.graph.nodes) { + if (node.existingPlatform) { + push({ + platform: node.existingPlatform, + evidence: `${node.path === '.' ? '' : `${node.path}/`}${node.existingPlatform} config`, + delta: 40, + label: `existing platform (${node.name})`, + }); + } + } + + // CI workflows with deploy steps referencing a platform CLI. + for (const wf of orient.ciWorkflows) { + for (const step of wf.deploySteps) { + for (const { match, platform, what } of CI_DEPLOY_PATTERNS) { + if (match.test(step)) { + push({ + platform, + evidence: `${wf.file} (${what})`, + delta: 30, + label: `CI already configured (${what})`, + }); + } + } + } + } + + const vpsHost = prefs?.deployment.vpsHost; + if (vpsHost && vpsHost.trim().length > 0) { + push({ + platform: 'vps', + evidence: `preferences vpsHost=${vpsHost}`, + delta: 50, + label: 'VPS host on file in team preferences', + }); + } + + return signals; +} + +/** + * Compare collected signals against the team's platform mandate. Any signal + * pointing somewhere else is drift. The mandate still wins — callers warn + * (or hard-pause under --strict-drift), never silently re-score. + */ +export function detectDrift(mandate: string, signals: OrientSignal[]): DriftFinding[] { + // One finding per stray platform — the first (highest-priority) artifact + // is evidence enough; listing every echo of the same platform is noise. + const byPlatform = new Map(); + for (const signal of signals) { + if (signal.platform === mandate) continue; + if (byPlatform.has(signal.platform)) continue; + byPlatform.set(signal.platform, { detectedPlatform: signal.platform, evidence: signal.evidence }); + } + return [...byPlatform.values()]; +} + +/** Fold signals into the picker's extra-adjustments shape. */ +export function signalsToAdjustments(signals: OrientSignal[]): PlatformAdjustments { + const out: PlatformAdjustments = {}; + for (const signal of signals) { + (out[signal.platform] ??= []).push({ delta: signal.delta, label: signal.label }); + } + return out; +} diff --git a/src/planner/index.ts b/src/planner/index.ts index 04a380f..ffe91e6 100644 --- a/src/planner/index.ts +++ b/src/planner/index.ts @@ -21,7 +21,7 @@ import type { Platform } from '../core/types.js'; import { draftAuthorSection } from './author.js'; import { enrichPlan, type EnrichmentOptions } from './enricher.js'; -import { pickPlatform, pickPlatformForLane } from './picker.js'; +import { pickPlatform, pickPlatformForLane, type PlatformAdjustments } from './picker.js'; import { scanRepository, scanServiceGraph, repoName, type ScanResult, type ServiceNode } from './scanner.js'; import { resolveTarget, type ResolveOptions, type TargetResolution } from './target-resolver.js'; @@ -32,6 +32,12 @@ export interface BuildPlanOptions { platformOverride?: Platform; workspace?: string; ai?: EnrichmentOptions; + /** + * Extra score deltas from the orient pass (existing platform configs, CI + * deploy steps, VPS host in team preferences). Surfaced in the candidate + * score table like any scanner-derived adjustment. + */ + platformAdjustments?: PlatformAdjustments; } export type BuildPlanResult = { @@ -47,10 +53,10 @@ export async function buildPlan( const graph = scanServiceGraph(localPath, opts.workspace ? { workspace: opts.workspace } : {}); const deployability = toPlanDeployability(scan); - const platform = resolvePlatform(scan, opts.platformOverride, deployability); + const platform = resolvePlatform(scan, opts.platformOverride, deployability, opts.platformAdjustments); const lanes = deployability.verdict === 'not-cloud-deployable' ? [] as DeploymentLane[] - : graph.nodes.map((node) => buildLane(node, opts.platformOverride)); + : graph.nodes.map((node) => buildLane(node, opts.platformOverride, opts.platformAdjustments)); const dependencies = buildDependencies(lanes); const connectionRequirements = buildConnectionRequirements(lanes); // Each lane's files are already path-prefixed by draftAuthorSection (e.g. @@ -125,8 +131,12 @@ export async function buildPlan( return { plan: enriched.plan, enrichmentSource: enriched.source }; } -function buildLane(node: ServiceNode, override?: Platform): DeploymentLane { - const platformDecision = pickPlatformForLane(node, override); +function buildLane( + node: ServiceNode, + override?: Platform, + adjustments?: PlatformAdjustments, +): DeploymentLane { + const platformDecision = pickPlatformForLane(node, override, adjustments); const author = draftAuthorSection(node.scan, platformDecision.chosen, node.path); const rehearsal = defaultRehearsal(node.scan, platformDecision.chosen); const promotion = defaultPromotion(); @@ -232,6 +242,7 @@ function resolvePlatform( scan: ScanResult, override: Platform | undefined, deployability: PlanDeployabilitySection, + adjustments?: PlatformAdjustments, ): PlanPlatformDecision { if (deployability.verdict === 'not-cloud-deployable') { return { @@ -242,7 +253,7 @@ function resolvePlatform( candidates: [], }; } - return pickPlatform(scan, override); + return pickPlatform(scan, override, adjustments); } function toPlanRisks(scan: ScanResult): PlanRisk[] { diff --git a/src/planner/picker.ts b/src/planner/picker.ts index 932b703..5f9d3ef 100644 --- a/src/planner/picker.ts +++ b/src/planner/picker.ts @@ -4,16 +4,27 @@ import type { ScanResult, ServiceNode } from './scanner.js'; const SUPPORTED: Platform[] = ['fly', 'railway', 'vercel', 'cloudrun', 'vps']; +/** + * Extra adjustments fed in from outside the scan — today, the orient pass + * (issue #20): existing fly.toml / vercel.json / CI deploy steps / VPS host + * in team preferences. They flow through the same adjustments mechanism the + * deterministic scorer uses, so the deltas show up in the score table (CLI + * render, plan JSON, web candidate cards) instead of being invisible + * if-chains. + */ +export type PlatformAdjustments = Partial>; + export function pickPlatform( scan: ScanResult, override?: Platform, + extraAdjustments?: PlatformAdjustments, ): PlanPlatformDecision { if (override !== undefined) { return { chosen: override, reason: `respecting explicit --platform=${override} override`, source: 'override', - candidates: scoreAll(scan), + candidates: scoreAll(scan, extraAdjustments), }; } if (scan.existingPlatform) { @@ -21,10 +32,10 @@ export function pickPlatform( chosen: scan.existingPlatform, reason: `continuing existing ${scan.existingPlatform} setup detected in the repo`, source: 'existing-config', - candidates: scoreAll(scan), + candidates: scoreAll(scan, extraAdjustments), }; } - const candidates = scoreAll(scan); + const candidates = scoreAll(scan, extraAdjustments); const top = candidates[0]!; return { chosen: top.platform, @@ -37,17 +48,22 @@ export function pickPlatform( export function pickPlatformForLane( node: ServiceNode, override?: Platform, + extraAdjustments?: PlatformAdjustments, ): PlanPlatformDecision { - return pickPlatform(node.scan, override); + return pickPlatform(node.scan, override, extraAdjustments); } -function scoreAll(scan: ScanResult): PlanPlatformCandidate[] { - const out = SUPPORTED.map((p) => scoreOne(p, scan)); +function scoreAll(scan: ScanResult, extra?: PlatformAdjustments): PlanPlatformCandidate[] { + const out = SUPPORTED.map((p) => scoreOne(p, scan, extra?.[p])); out.sort((a, b) => b.score - a.score); return out; } -function scoreOne(platform: Platform, scan: ScanResult): PlanPlatformCandidate { +function scoreOne( + platform: Platform, + scan: ScanResult, + extra?: PlanPlatformAdjustment[], +): PlanPlatformCandidate { let score = 50; const adjustments: PlanPlatformAdjustment[] = []; @@ -121,6 +137,10 @@ function scoreOne(platform: Platform, scan: ScanResult): PlanPlatformCandidate { } } + for (const a of extra ?? []) { + adj(a.delta, a.label); + } + score = Math.max(0, Math.min(100, score)); const reasons = adjustments .filter((a) => a.delta > 0) diff --git a/web/app/actions.ts b/web/app/actions.ts index 37c1903..7e7774a 100644 --- a/web/app/actions.ts +++ b/web/app/actions.ts @@ -1,13 +1,14 @@ 'use server'; import { spawn } from 'node:child_process'; -import { mkdirSync, openSync } from 'node:fs'; +import { existsSync, mkdirSync, openSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import Anthropic from '@anthropic-ai/sdk'; import { revalidatePath } from 'next/cache'; import { probePlatformConnection } from '../../src/adapters/connections.js'; +import { buildPushContextFromPlan, pushSecretToPlatform as pushViaShared } from '../../src/core/secret-push.js'; import { appendChatTurn, listChatTurns } from '@/lib/medic-chat'; import { appendAlreadySet, @@ -506,7 +507,10 @@ ${toolCallSummary || '(none recorded)'}`; // --------------------------------------------------------------------------- const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; -const SUPPORTED_PLATFORMS_LIST = ['fly', 'railway', 'vercel', 'cloudrun']; +const SUPPORTED_PLATFORMS_LIST = ['fly', 'railway', 'vercel', 'cloudrun', 'vps']; +// user@host, bare hostname, or IP. Conservative charset — the value travels +// on argv (no shell), but we still refuse anything that isn't host-shaped. +const VALID_VPS_HOST = /^[A-Za-z0-9](?:[A-Za-z0-9_.@:\-]*[A-Za-z0-9])?$/; /** * Stage a KEY=value pair into the plan's .env.convoy-secrets file. If the @@ -850,11 +854,19 @@ export async function applyPlan( export async function changePlanPlatform( planId: string, platform: string, + vpsHost?: string, ): Promise<{ ok: boolean; newPlanId?: string; reason?: string }> { if (!planId || typeof planId !== 'string') return { ok: false, reason: 'invalid planId' }; if (!SUPPORTED_PLATFORMS_LIST.includes(platform)) { return { ok: false, reason: `platform must be one of ${SUPPORTED_PLATFORMS_LIST.join(', ')}` }; } + const host = typeof vpsHost === 'string' ? vpsHost.trim() : ''; + if (platform === 'vps') { + if (host.length === 0) return { ok: false, reason: 'vps needs a host — enter an IP or hostname' }; + if (host.length > 253 || !VALID_VPS_HOST.test(host)) { + return { ok: false, reason: 'invalid host — expected user@host, a hostname, or an IP' }; + } + } const plan = getPlan(planId); if (!plan) return { ok: false, reason: 'plan not found' }; @@ -869,6 +881,11 @@ export async function changePlanPlatform( '--no-ai', `--platform=${platform}`, ]; + if (platform === 'vps' && host.length > 0) { + // The CLI threads this into the picker (CONVOY_VPS_HOST) and persists + // it to team preferences so the input pre-fills next time. + args.push(`--vps-host=${host}`); + } if (plan.target.repoUrl) { args.push(`--repo-url=${plan.target.repoUrl}`); } @@ -904,3 +921,54 @@ export async function changePlanPlatform( revalidatePath('/'); return { ok: true, newPlanId: match[1] }; } + +/** + * Push one already-staged secret to the plan's chosen platform (issue #22). + * Reads the value from the plan's local .env.convoy-secrets (the row must be + * staged first) and hands it to the shared push module — the same code path + * `convoy stage-secrets` uses, so CLI and web can't drift apart. + * + * Never throws; a failed push returns the graceful "saved locally" fallback + * so the UI can render it inline. The local staging is untouched either way. + */ +export async function pushStagedSecret( + planId: string, + key: string, +): Promise<{ ok: boolean; platform?: string; reason?: string }> { + if (!planId || typeof planId !== 'string') return { ok: false, reason: 'invalid planId' }; + if (!key || !VALID_ENV_KEY.test(key)) return { ok: false, reason: 'invalid env key' }; + + const plan = getPlan(planId); + if (!plan) return { ok: false, reason: 'plan not found' }; + + const { secretsPath } = computeStagedState(plan); + let value: string | undefined; + try { + const text = existsSync(secretsPath) ? readFileSync(secretsPath, 'utf8') : ''; + value = parseEnvText(text)[key]; + } catch (err) { + return { ok: false, reason: `could not read staged secrets: ${err instanceof Error ? err.message : String(err)}` }; + } + if (value === undefined || value.length === 0) { + return { ok: false, reason: 'key is not staged locally — stage a value first' }; + } + + try { + const ctx = await buildPushContextFromPlan(plan); + const result = await pushViaShared(key, value, ctx); + if (result.ok) { + revalidatePath(`/plans/${planId}`); + return { ok: true, platform: ctx.platform }; + } + return { + ok: false, + platform: ctx.platform, + reason: `saved locally — ${result.reason ?? 'push failed'}; will be staged at deploy time`, + }; + } catch (err) { + return { + ok: false, + reason: `saved locally — ${err instanceof Error ? err.message : String(err)}; will be staged at deploy time`, + }; + } +} diff --git a/web/app/plans/[id]/config-panel.tsx b/web/app/plans/[id]/config-panel.tsx index 547a78c..9529ad6 100644 --- a/web/app/plans/[id]/config-panel.tsx +++ b/web/app/plans/[id]/config-panel.tsx @@ -9,6 +9,7 @@ import { changePlanPlatform, importEnvVars, markEnvVarAlreadySet, + pushStagedSecret, setRecurring, stageEnvVar, unstageEnvVar, @@ -28,6 +29,7 @@ export function ConfigPanel({ alternatives, secretsPath, alreadySetPath, + vpsHost = '', }: { planId: string; platform: string; @@ -36,6 +38,7 @@ export function ConfigPanel({ alternatives: string[]; secretsPath: string; alreadySetPath: string; + vpsHost?: string; }) { const staged = rows.filter((r) => r.state !== 'missing').length; const missing = rows.length - staged; @@ -82,6 +85,7 @@ export function ConfigPanel({ planId={planId} current={platform} alternatives={alternatives} + initialVpsHost={vpsHost} /> {rows.length === 0 ? ( @@ -121,6 +125,7 @@ export function ConfigPanel({

  • (null); + const needsHost = choice === 'vps'; + const hostMissing = needsHost && host.trim().length === 0; + const submit = () => { - if (choice === current) return; + if (choice === current || hostMissing) return; setError(null); startTransition(async () => { - const result = await changePlanPlatform(planId, choice); + const result = await changePlanPlatform( + planId, + choice, + needsHost ? host.trim() : undefined, + ); if (!result.ok) { setError(result.reason ?? 'failed'); return; @@ -246,10 +261,24 @@ function PlatformSwitchRow({ ))} + {needsHost ? ( + setHost(e.target.value)} + placeholder="IP or hostname (e.g. root@203.0.113.7)" + disabled={pending} + spellCheck={false} + autoComplete="off" + aria-label="VPS host" + className="text-sm font-mono bg-paper border border-rule rounded-md px-2 py-1 min-w-[220px] focus:border-accent focus:outline-none disabled:opacity-50" + /> + ) : null} + ) : null}