From 769e5349d67818627c2213b7640091d3e5e1a12e Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Sat, 2 May 2026 00:18:43 -0700 Subject: [PATCH 1/2] fix(export): redact secrets in-place instead of aborting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: any staged file matching the secret regex caused the export to fail with `{"ok":false,"error":"Possible secrets detected..."}`, forcing the user to SSH in and clean things up by hand. After: matched strings are replaced with `***REDACTED-BY-SPAWN-EXPORT***` via sed -i -E, the file is re-staged, and the export proceeds. The list of redacted files is included in the success result and surfaced as a warning on the host CLI: ✓ Exported to https://github.com/alice/my-vm ⚠ Redacted potential secrets in 1 file: - project/test/brain-sync.test.ts The regex is unchanged. The redact placeholder is intentionally loud so a casual reader of the published repo can tell that something was scrubbed and isn't just blank. Bumps CLI 1.0.33 -> 1.0.34. --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/export.test.ts | 21 +++++++++++-- packages/cli/src/commands/export.ts | 37 ++++++++++++++++++----- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 9c78f68f3..f83d274e2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.33", + "version": "1.0.34", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/export.test.ts b/packages/cli/src/__tests__/export.test.ts index 60a84a9f9..8fab6dd5a 100644 --- a/packages/cli/src/__tests__/export.test.ts +++ b/packages/cli/src/__tests__/export.test.ts @@ -185,7 +185,7 @@ describe("buildExportScript", () => { expect(s).toContain('"error":"gh is not authenticated'); }); - it("scans staged files for known API-key patterns and aborts on hit", () => { + it("scans staged files for known API-key patterns", () => { const s = buildExportScript(opts); expect(s).toContain("SECRET_REGEX="); // Verify a representative pattern from each provider family is present @@ -197,7 +197,24 @@ describe("buildExportScript", () => { expect(s).toContain("hcloud_"); // Hetzner expect(s).toContain("dop_v1_"); // DigitalOcean expect(s).toContain("BEGIN ([A-Z]+ )?PRIVATE KEY"); // PEM - expect(s).toContain("Possible secrets detected"); + }); + + it("redacts matched secrets in-place rather than aborting", () => { + const s = buildExportScript(opts); + // Redact placeholder is defined and used as the sed replacement. + expect(s).toContain("REDACT_PLACEHOLDER='***REDACTED-BY-SPAWN-EXPORT***'"); + expect(s).toContain("sed -i -E"); + // The script re-stages after redacting so the redacted blobs replace + // the originals. + expect(s).toMatch(/sed -i -E[\s\S]*git add -A/); + // The legacy abort path is gone — no false "ok":false on secret hits. + expect(s).not.toContain("Possible secrets detected in staged files; aborting"); + }); + + it("includes the redacted file list in the success result", () => { + const s = buildExportScript(opts); + expect(s).toContain('REDACTED_JSON="[]"'); + expect(s).toContain('"redacted":%s'); }); it("uses gh repo create with the cloud and slug from the script", () => { diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 64485683a..e5b0648ec 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -13,7 +13,8 @@ // comes from `gh api user`. // - Before the commit, every staged file is scanned for known API-key // shapes (Anthropic, OpenRouter, OpenAI, GitHub, AWS, PEM, Hetzner, -// DigitalOcean). Hits abort the export. +// DigitalOcean). Hits are redacted in-place; the file list is +// surfaced to the host CLI as a warning. import type { SpawnRecord } from "../history.js"; @@ -70,6 +71,7 @@ const ResultSchema = v.union([ ok: v.literal(true), slug: v.string(), url: v.string(), + redacted: v.optional(v.array(v.string())), }), v.object({ ok: v.literal(false), @@ -293,14 +295,27 @@ export function buildExportScript(opts: { "git init -q -b main", "git add -A", "", - "# 9. SECRETS SCAN — abort if any staged file matches known API-key shapes.", + "# 9. SECRETS SCAN — redact any matched API-key shapes in-place. The export", + "# proceeds; the redacted file list is included in the result JSON so the", + "# host CLI can warn the user.", "SECRET_REGEX='(sk-or-v1-[a-f0-9]{20,})|(sk-ant-api[0-9-]+_[A-Za-z0-9_-]{20,})|(sk-proj-[A-Za-z0-9_-]{20,})|(gh[ops]_[A-Za-z0-9]{36})|(AKIA[0-9A-Z]{16})|(hcloud_[a-zA-Z0-9_-]{20,})|(dop_v1_[a-f0-9]{32,})|(-----BEGIN ([A-Z]+ )?PRIVATE KEY-----)'", + "REDACT_PLACEHOLDER='***REDACTED-BY-SPAWN-EXPORT***'", 'SECRET_HITS="$(git ls-files -z | xargs -0 grep -lEa "$SECRET_REGEX" 2>/dev/null || true)"', + 'REDACTED_JSON="[]"', 'if [ -n "$SECRET_HITS" ]; then', - ' printf \'%s\\n\' \'{"ok":false,"error":"Possible secrets detected in staged files; aborting export. SSH in and inspect the files listed below."}\' > "$RESULT_PATH"', - ' echo "✗ Possible secrets detected in:" >&2', + ' echo "⚠ Redacting potential secrets in:" >&2', ' printf "%s\\n" "$SECRET_HITS" >&2', - " exit 1", + " while IFS= read -r f; do", + ' [ -z "$f" ] && continue', + ' sed -i -E "s|${SECRET_REGEX}|${REDACT_PLACEHOLDER}|g" "$f"', + ' done <<< "$SECRET_HITS"', + " # Re-stage so the redacted blobs replace the originals in the index.", + " git add -A", + ' REDACTED_JSON="$(printf "%s\\n" "$SECRET_HITS" | _PATHS_RAW="$SECRET_HITS" bun -e "', + " const raw = process.env._PATHS_RAW || '';", + " const arr = raw.split('\\n').map(s => s.trim()).filter(Boolean);", + " process.stdout.write(JSON.stringify(arr));", + ' ")"', "fi", "", "# 10. Commit and push.", @@ -308,8 +323,8 @@ export function buildExportScript(opts: { "", 'gh repo create "$SLUG" "$VISIBILITY_FLAG" --source=. --push --description="Exported with spawn"', "", - "# 11. Emit the success result.", - 'printf \'{"ok":true,"slug":"%s","url":"https://github.com/%s"}\\n\' "$SLUG" "$SLUG" > "$RESULT_PATH"', + "# 11. Emit the success result (with the list of redacted files, if any).", + 'printf \'{"ok":true,"slug":"%s","url":"https://github.com/%s","redacted":%s}\\n\' "$SLUG" "$SLUG" "$REDACTED_JSON" > "$RESULT_PATH"', "", ].join("\n"); } @@ -497,6 +512,14 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti } console.log(); p.log.success(`Exported to ${pc.cyan(parsed.url)}`); + if (parsed.redacted && parsed.redacted.length > 0) { + p.log.warn( + `Redacted potential secrets in ${parsed.redacted.length} file${parsed.redacted.length === 1 ? "" : "s"}:`, + ); + for (const f of parsed.redacted) { + console.log(pc.dim(` - ${f}`)); + } + } console.log(); console.log(pc.dim("Re-spawn with:")); console.log(` ${pc.cyan(`spawn ${CLAUDE_AGENT} ${r.cloud} --repo ${parsed.slug} --steps ${steps}`)}`); From 855a89e42c83c3b3642f5ca61229cfa61647b5bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 07:27:19 +0000 Subject: [PATCH 2/2] fix(export): gate redaction behind a host-side confirmation prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the VM would silently redact any staged files matching the secret regex and push the repo — meaning a regex miss (#3381 tracks broadening) would publish a real secret without the user ever seeing the file list. That's a fail-open posture on a tool that can push to public GitHub. New flow: - buildExportScript takes allowRedact: boolean. - First pass (allowRedact=false): VM stages, runs the secret scan, and on hits writes a needs_confirmation result (hits=[...]) and exits 0 before any commit or push. No hits → commit + push as before. - Host reads the result. If needs_confirmation: print the file list, explain that the regex has known gaps, and ask "Redact these N files and continue pushing?" (initialValue false). Decline → exit 0, no push. Approve → re-run the script with allowRedact=true, which now actually does the sed + re-stage + commit + push. Other changes: - ResultSchema gains the needs_confirmation variant. - cmdExport factors the runServer + downloadFile + parse cycle into runPassAndParseResult so the two-pass orchestration is readable. - Tests: 4 new cases cover the gate scripting (ALLOW_REDACT=0 writes needs_confirmation and exits 0, ALLOW_REDACT=1 redacts) and the end-to-end host flow (approve → two passes with ALLOW_REDACT 0→1; decline → one pass, exit 0; no-secrets happy path → one pass, no confirm). 38/38 export tests, 2176/0 fail overall. - CLI 1.0.34 → 1.0.35. --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/export.test.ts | 147 +++++++++++++++++++- packages/cli/src/commands/export.ts | 161 +++++++++++++++++----- 3 files changed, 269 insertions(+), 41 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index f83d274e2..25cd4c825 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.34", + "version": "1.0.35", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/export.test.ts b/packages/cli/src/__tests__/export.test.ts index 8fab6dd5a..6d99e1a26 100644 --- a/packages/cli/src/__tests__/export.test.ts +++ b/packages/cli/src/__tests__/export.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { writeFileSync } from "node:fs"; import { mockClackPrompts } from "./test-helpers"; -mockClackPrompts(); +const clackMocks = mockClackPrompts(); import type { SpawnRecord } from "../history"; @@ -154,6 +155,10 @@ describe("buildExportScript", () => { steps: "github,auto-update,security-scan", visibility: "private" as const, resultPath: "/tmp/spawn-export-result.json", + // Second-pass behaviour. Flipping this to true is what enables the + // sed-based redact + commit + push. Flipping to false exercises the + // pre-commit gate that pauses for host confirmation. + allowRedact: true, }; it("uses set -eo pipefail", () => { @@ -199,8 +204,9 @@ describe("buildExportScript", () => { expect(s).toContain("BEGIN ([A-Z]+ )?PRIVATE KEY"); // PEM }); - it("redacts matched secrets in-place rather than aborting", () => { - const s = buildExportScript(opts); + it("redacts matched secrets in-place when ALLOW_REDACT=1 (second pass)", () => { + const s = buildExportScript(opts); // opts.allowRedact = true + expect(s).toContain("ALLOW_REDACT=1"); // Redact placeholder is defined and used as the sed replacement. expect(s).toContain("REDACT_PLACEHOLDER='***REDACTED-BY-SPAWN-EXPORT***'"); expect(s).toContain("sed -i -E"); @@ -211,6 +217,20 @@ describe("buildExportScript", () => { expect(s).not.toContain("Possible secrets detected in staged files; aborting"); }); + it("pauses before commit with needs_confirmation when ALLOW_REDACT=0 (first pass)", () => { + const s = buildExportScript({ + ...opts, + allowRedact: false, + }); + expect(s).toContain("ALLOW_REDACT=0"); + // The gate path emits a structured result the host can parse. + expect(s).toContain('"needsConfirmation":true,"hits":%s'); + // Exit 0, not 1 — a gate is not a failure. + expect(s).toMatch(/needsConfirmation":true[\s\S]*exit 0/); + // The redact path is conditional on ALLOW_REDACT=1. + expect(s).toContain('if [ "$ALLOW_REDACT" != "1" ]; then'); + }); + it("includes the redacted file list in the success result", () => { const s = buildExportScript(opts); expect(s).toContain('REDACTED_JSON="[]"'); @@ -384,4 +404,125 @@ describe("cmdExport", () => { ).rejects.toThrow("__exit__"); expect(exitSpy).toHaveBeenCalledWith(1); }); + + // ── Gate flow ───────────────────────────────────────────────────────────── + // + // When the first pass returns needs_confirmation, the host prompts the user. + // Approve → re-run with ALLOW_REDACT=1 → success. Decline → exit 0, no push. + + function makeSequencedRunner(resultsJson: string[]) { + const calls: { + allowRedact: string; + }[] = []; + let callIndex = 0; + const runner = { + runServer: async (script: string) => { + const m = script.match(/\nALLOW_REDACT=([01])\n/); + calls.push({ + allowRedact: m ? m[1] : "?", + }); + }, + uploadFile: async () => {}, + downloadFile: async (_remote: string, local: string) => { + const idx = Math.min(callIndex, resultsJson.length - 1); + callIndex += 1; + writeFileSync(local, resultsJson[idx]); + }, + }; + return { + runner, + calls, + }; + } + + it("prompts and re-runs with ALLOW_REDACT=1 when the user approves redaction", async () => { + const { runner, calls } = makeSequencedRunner([ + JSON.stringify({ + ok: false, + needsConfirmation: true, + hits: [ + "project/test/brain-sync.test.ts", + ], + }), + JSON.stringify({ + ok: true, + slug: "alice/my-vm", + url: "https://github.com/alice/my-vm", + redacted: [ + "project/test/brain-sync.test.ts", + ], + }), + ]); + // Default confirm returns true → user approves the gate. + clackMocks.confirm.mockImplementation(async () => true); + + await cmdExport(undefined, { + records: [ + baseRecord, + ], + visibility: "private", + makeRunner: () => runner, + }); + + expect(calls).toHaveLength(2); + expect(calls[0].allowRedact).toBe("0"); + expect(calls[1].allowRedact).toBe("1"); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("cancels the export cleanly when the user declines redaction", async () => { + const { runner, calls } = makeSequencedRunner([ + JSON.stringify({ + ok: false, + needsConfirmation: true, + hits: [ + "project/leaky.ts", + ], + }), + ]); + // User declines at the gate. + clackMocks.confirm.mockImplementation(async () => false); + + await expect( + cmdExport(undefined, { + records: [ + baseRecord, + ], + visibility: "private", + makeRunner: () => runner, + }), + ).rejects.toThrow("__exit__"); + + // Exactly one pass happened (ALLOW_REDACT=0) — nothing got pushed. + expect(calls).toHaveLength(1); + expect(calls[0].allowRedact).toBe("0"); + // exit(0) — cancellation is not a failure. + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it("runs once and succeeds when the first pass finds no secrets", async () => { + const { runner, calls } = makeSequencedRunner([ + JSON.stringify({ + ok: true, + slug: "alice/clean-repo", + url: "https://github.com/alice/clean-repo", + }), + ]); + // confirm shouldn't fire at all on the happy path. + clackMocks.confirm.mockImplementation(async () => { + throw new Error("confirm should not be called when no secrets are found"); + }); + + await cmdExport(undefined, { + records: [ + baseRecord, + ], + visibility: "private", + makeRunner: () => runner, + }); + + expect(calls).toHaveLength(1); + expect(calls[0].allowRedact).toBe("0"); + expect(exitSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index e5b0648ec..60e73ccc7 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -13,8 +13,17 @@ // comes from `gh api user`. // - Before the commit, every staged file is scanned for known API-key // shapes (Anthropic, OpenRouter, OpenAI, GitHub, AWS, PEM, Hetzner, -// DigitalOcean). Hits are redacted in-place; the file list is -// surfaced to the host CLI as a warning. +// DigitalOcean). When hits are found, the VM pauses before commit +// and writes a `needs_confirmation` result. The host lists the files +// and asks the user whether to redact and push. Only on approval +// does a second pass run with ALLOW_REDACT=1, which replaces the +// matches with a loud placeholder and finalizes the export. +// +// The gate exists because redaction depends on a regex with known +// gaps (#3381): auto-redacting and pushing means a regex miss gets +// published without the user ever seeing the file list. The prompt +// moves the decision back to the human before the `gh repo create +// --push` happens. import type { SpawnRecord } from "../history.js"; @@ -65,7 +74,12 @@ export function resolveSteps(record: SpawnRecord): string { return parseStepsFromLaunchCmd(record.connection?.launch_cmd) ?? DEFAULT_STEPS; } -/** Result the on-VM script writes to REMOTE_RESULT_PATH. */ +/** Result the on-VM script writes to REMOTE_RESULT_PATH. + * Three shapes: + * - success: ok=true with the repo URL (and optionally the redacted list). + * - needs_confirmation: ok=false with hits=[...]. The host prompts, and + * on approval re-runs the script with ALLOW_REDACT=1. + * - error: ok=false with a human-readable error string. */ const ResultSchema = v.union([ v.object({ ok: v.literal(true), @@ -73,6 +87,11 @@ const ResultSchema = v.union([ url: v.string(), redacted: v.optional(v.array(v.string())), }), + v.object({ + ok: v.literal(false), + needsConfirmation: v.literal(true), + hits: v.array(v.string()), + }), v.object({ ok: v.literal(false), error: v.string(), @@ -196,8 +215,13 @@ export function buildExportScript(opts: { steps: string; visibility: "private" | "public"; resultPath: string; + /** First pass = false → the script stops before commit when hits are + * found and writes a needs_confirmation result. Second pass = true → + * the script redacts in-place and pushes. */ + allowRedact: boolean; }): string { const visibilityFlag = opts.visibility === "public" ? "--public" : "--private"; + const allowRedact = opts.allowRedact ? "1" : "0"; return [ "#!/bin/bash", "set -eo pipefail", @@ -206,6 +230,7 @@ export function buildExportScript(opts: { `CLOUD=${shSingleQuote(opts.cloud)}`, `STEPS=${shSingleQuote(opts.steps)}`, `VISIBILITY_FLAG=${visibilityFlag}`, + `ALLOW_REDACT=${allowRedact}`, "", 'EXPORT_DIR="$(mktemp -d)"', 'trap "rm -rf \\"$EXPORT_DIR\\"" EXIT', @@ -295,14 +320,27 @@ export function buildExportScript(opts: { "git init -q -b main", "git add -A", "", - "# 9. SECRETS SCAN — redact any matched API-key shapes in-place. The export", - "# proceeds; the redacted file list is included in the result JSON so the", - "# host CLI can warn the user.", + "# 9. SECRETS SCAN — first pass just detects and stops if hits exist so the", + "# host can confirm before pushing. Second pass (ALLOW_REDACT=1) redacts", + "# in-place and continues to commit/push.", "SECRET_REGEX='(sk-or-v1-[a-f0-9]{20,})|(sk-ant-api[0-9-]+_[A-Za-z0-9_-]{20,})|(sk-proj-[A-Za-z0-9_-]{20,})|(gh[ops]_[A-Za-z0-9]{36})|(AKIA[0-9A-Z]{16})|(hcloud_[a-zA-Z0-9_-]{20,})|(dop_v1_[a-f0-9]{32,})|(-----BEGIN ([A-Z]+ )?PRIVATE KEY-----)'", "REDACT_PLACEHOLDER='***REDACTED-BY-SPAWN-EXPORT***'", 'SECRET_HITS="$(git ls-files -z | xargs -0 grep -lEa "$SECRET_REGEX" 2>/dev/null || true)"', 'REDACTED_JSON="[]"', 'if [ -n "$SECRET_HITS" ]; then', + ' HITS_JSON="$(_PATHS_RAW="$SECRET_HITS" bun -e "', + " const raw = process.env._PATHS_RAW || '';", + " const arr = raw.split('\\n').map(s => s.trim()).filter(Boolean);", + " process.stdout.write(JSON.stringify(arr));", + ' ")"', + ' if [ "$ALLOW_REDACT" != "1" ]; then', + " # First pass: stop before commit; host will prompt the user.", + ' echo "⚠ Potential secrets detected in:" >&2', + ' printf "%s\\n" "$SECRET_HITS" >&2', + ' printf \'{"ok":false,"needsConfirmation":true,"hits":%s}\\n\' "$HITS_JSON" > "$RESULT_PATH"', + " exit 0", + " fi", + " # Second pass: redact in-place and continue.", ' echo "⚠ Redacting potential secrets in:" >&2', ' printf "%s\\n" "$SECRET_HITS" >&2', " while IFS= read -r f; do", @@ -311,11 +349,7 @@ export function buildExportScript(opts: { ' done <<< "$SECRET_HITS"', " # Re-stage so the redacted blobs replace the originals in the index.", " git add -A", - ' REDACTED_JSON="$(printf "%s\\n" "$SECRET_HITS" | _PATHS_RAW="$SECRET_HITS" bun -e "', - " const raw = process.env._PATHS_RAW || '';", - " const arr = raw.split('\\n').map(s => s.trim()).filter(Boolean);", - " process.stdout.write(JSON.stringify(arr));", - ' ")"', + ' REDACTED_JSON="$HITS_JSON"', "fi", "", "# 10. Commit and push.", @@ -461,7 +495,12 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti visibility = makePublic === true ? "public" : "private"; } const steps = resolveSteps(r); - const script = buildExportScript({ + + // Pick a runner: tests inject one; sprite uses sprite's exec channel; everything + // else goes over SSH using the connection's ip/user. + const runner = options?.makeRunner ? options.makeRunner(conn.ip, conn.user, []) : await buildRunnerForRecord(r); + + const scriptOpts = { spawnMd: buildSpawnMd(r), readmeTemplate: buildReadmeTemplate(), gitignore: buildGitignore(), @@ -469,14 +508,80 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti steps, visibility, resultPath: REMOTE_RESULT_PATH, - }); - - // Pick a runner: tests inject one; sprite uses sprite's exec channel; everything - // else goes over SSH using the connection's ip/user. - const runner = options?.makeRunner ? options.makeRunner(conn.ip, conn.user, []) : await buildRunnerForRecord(r); + }; - // Run the export script. 10-min timeout — large repos take time to push. + // First pass: never redact. If hits are found, the script writes a + // needs_confirmation result and exits. If not, it pushes. p.log.step("Running export on the VM (claude is naming the repo)..."); + let parsed = await runPassAndParseResult( + runner, + buildExportScript({ + ...scriptOpts, + allowRedact: false, + }), + ); + + // Gate: if the VM reported needs_confirmation, show the file list and + // prompt the user. On approval, re-run with ALLOW_REDACT=1. + if (!parsed.ok && "needsConfirmation" in parsed && parsed.needsConfirmation === true) { + console.log(); + p.log.warn(`Potential secrets detected in ${parsed.hits.length} file${parsed.hits.length === 1 ? "" : "s"}:`); + for (const f of parsed.hits) { + console.log(pc.dim(` - ${f}`)); + } + console.log(); + p.log.info( + "Matches will be replaced with '***REDACTED-BY-SPAWN-EXPORT***' before the repo is pushed. The regex has known gaps — review the list above and cancel if anything looks like a real secret you'd rather scrub by hand.", + ); + const approved = await p.confirm({ + message: `Redact ${parsed.hits.length === 1 ? "this file" : "these files"} and continue pushing to GitHub?`, + initialValue: false, + }); + if (p.isCancel(approved) || approved !== true) { + p.log.info("Export cancelled. Nothing was pushed."); + process.exit(0); + } + p.log.step("Re-running export with redaction enabled..."); + parsed = await runPassAndParseResult( + runner, + buildExportScript({ + ...scriptOpts, + allowRedact: true, + }), + ); + } + + if (!parsed.ok) { + // Any remaining non-ok shape is a hard error. + p.log.error("error" in parsed ? parsed.error : "Export ran but produced no parseable result."); + process.exit(1); + } + + console.log(); + p.log.success(`Exported to ${pc.cyan(parsed.url)}`); + if (parsed.redacted && parsed.redacted.length > 0) { + p.log.warn( + `Redacted potential secrets in ${parsed.redacted.length} file${parsed.redacted.length === 1 ? "" : "s"}:`, + ); + for (const f of parsed.redacted) { + console.log(pc.dim(` - ${f}`)); + } + } + console.log(); + console.log(pc.dim("Re-spawn with:")); + console.log(` ${pc.cyan(`spawn ${CLAUDE_AGENT} ${r.cloud} --repo ${parsed.slug} --steps ${steps}`)}`); + console.log(); +} + +/** Run the export script on the VM, download the result file, parse it, and + * return the validated shape. Exits the process on any infrastructure-level + * failure (ssh, download, unparseable JSON) — the caller only has to handle + * the three valid result shapes. */ +async function runPassAndParseResult( + runner: ExportRunner, + script: string, +): Promise> { + // 10-min timeout — large repos take time to push. const runResult = await asyncTryCatch(() => runner.runServer(script, 600)); if (!runResult.ok) { p.log.error(`Export failed: ${getErrorMessage(runResult.error)}`); @@ -484,7 +589,6 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti process.exit(1); } - // Download result file const localTmp = mkdtempSync(join(tmpdir(), "spawn-export-")); const localResult = join(localTmp, "result.json"); const dlResult = await asyncTryCatch(() => runner.downloadFile(REMOTE_RESULT_PATH, localResult)); @@ -506,22 +610,5 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti p.log.error("Export ran but produced no parseable result."); process.exit(1); } - if (!parsed.ok) { - p.log.error(parsed.error); - process.exit(1); - } - console.log(); - p.log.success(`Exported to ${pc.cyan(parsed.url)}`); - if (parsed.redacted && parsed.redacted.length > 0) { - p.log.warn( - `Redacted potential secrets in ${parsed.redacted.length} file${parsed.redacted.length === 1 ? "" : "s"}:`, - ); - for (const f of parsed.redacted) { - console.log(pc.dim(` - ${f}`)); - } - } - console.log(); - console.log(pc.dim("Re-spawn with:")); - console.log(` ${pc.cyan(`spawn ${CLAUDE_AGENT} ${r.cloud} --repo ${parsed.slug} --steps ${steps}`)}`); - console.log(); + return parsed; }