mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 08:01:17 +00:00
fix(export): redact secrets in-place instead of aborting (#3383)
* fix(export): redact secrets in-place instead of aborting
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.
* fix(export): gate redaction behind a host-side confirmation prompt
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.
---------
Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
parent
46bbeea751
commit
3152a1911f
3 changed files with 298 additions and 30 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "1.0.33",
|
||||
"version": "1.0.35",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
@ -185,7 +190,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 +202,39 @@ 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 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");
|
||||
// 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("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="[]"');
|
||||
expect(s).toContain('"redacted":%s');
|
||||
});
|
||||
|
||||
it("uses gh repo create with the cloud and slug from the script", () => {
|
||||
|
|
@ -367,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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +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 abort the export.
|
||||
// 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";
|
||||
|
||||
|
|
@ -64,12 +74,23 @@ 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),
|
||||
slug: v.string(),
|
||||
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),
|
||||
|
|
@ -194,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",
|
||||
|
|
@ -204,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',
|
||||
|
|
@ -293,14 +320,36 @@ 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 — 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',
|
||||
' 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',
|
||||
' 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',
|
||||
" 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="$HITS_JSON"',
|
||||
"fi",
|
||||
"",
|
||||
"# 10. Commit and push.",
|
||||
|
|
@ -308,8 +357,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");
|
||||
}
|
||||
|
|
@ -446,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(),
|
||||
|
|
@ -454,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<v.InferOutput<typeof ResultSchema>> {
|
||||
// 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)}`);
|
||||
|
|
@ -469,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));
|
||||
|
|
@ -491,14 +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)}`);
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue