diff --git a/packages/cli/package.json b/packages/cli/package.json index 8b2c002aa..c137b229b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.27", + "version": "1.0.32", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/export.test.ts b/packages/cli/src/__tests__/export.test.ts new file mode 100644 index 000000000..5bfc088ab --- /dev/null +++ b/packages/cli/src/__tests__/export.test.ts @@ -0,0 +1,348 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { mockClackPrompts } from "./test-helpers"; + +mockClackPrompts(); + +import type { SpawnRecord } from "../history"; + +import { + buildExportScript, + buildGitignore, + buildReadmeTemplate, + buildSpawnMd, + cmdExport, + parseStepsFromLaunchCmd, + resolveSteps, +} from "../commands/export"; +import { parseSpawnMd } from "../shared/spawn-md"; + +const baseRecord: SpawnRecord = { + id: "abc-123", + agent: "claude", + cloud: "hetzner", + timestamp: "2026-05-01T00:00:00Z", + name: "demo session", + connection: { + ip: "1.2.3.4", + user: "spawn", + cloud: "hetzner", + server_id: "srv-1", + server_name: "demo-server", + }, +}; + +let stderrSpy: ReturnType; +let stdoutSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + stderrSpy = spyOn(process.stderr, "write").mockReturnValue(true); + stdoutSpy = spyOn(process.stdout, "write").mockReturnValue(true); + exitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { + throw new Error("__exit__"); + }); +}); + +afterEach(() => { + stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); + exitSpy.mockRestore(); + mock.restore(); +}); + +// ── Pure builders ─────────────────────────────────────────────────────────── + +describe("buildSpawnMd", () => { + it("emits valid frontmatter that parses through parseSpawnMd", () => { + const md = buildSpawnMd(baseRecord); + const parsed = parseSpawnMd(md); + expect(parsed).not.toBeNull(); + expect(parsed?.name).toBe("demo session"); + expect(parsed?.description).toContain("abc-123"); + }); + + it("falls back to a default heading when name is missing", () => { + const noName: SpawnRecord = { + ...baseRecord, + name: undefined, + }; + const md = buildSpawnMd(noName); + expect(md).toContain("# spawn export"); + }); +}); + +describe("buildReadmeTemplate", () => { + it("uses placeholders the bash script will substitute", () => { + const tpl = buildReadmeTemplate(); + expect(tpl).toContain("__NAME__"); + expect(tpl).toContain("__CLOUD__"); + expect(tpl).toContain("__SLUG__"); + expect(tpl).toContain("__STEPS__"); + expect(tpl).toContain("spawn claude __CLOUD__ --repo __SLUG__ --steps __STEPS__"); + }); + + it("renders a github-friendly checklist", () => { + const tpl = buildReadmeTemplate(); + expect(tpl).toContain("- [ ] `gh auth login`"); + expect(tpl).toContain("- [ ] Re-OAuth"); + }); +}); + +describe("buildGitignore", () => { + it("excludes node_modules, env files, and known credential paths", () => { + const gi = buildGitignore(); + expect(gi).toContain("node_modules/"); + expect(gi).toContain(".env"); + expect(gi).toContain(".env.*"); + expect(gi).toContain(".spawnrc"); + expect(gi).toContain(".aws/"); + expect(gi).toContain(".config/spawn/"); + expect(gi).toContain(".config/gcloud/"); + }); +}); + +describe("parseStepsFromLaunchCmd", () => { + it("returns null when launch_cmd is undefined or has no --steps", () => { + expect(parseStepsFromLaunchCmd(undefined)).toBeNull(); + expect(parseStepsFromLaunchCmd("spawn claude hetzner")).toBeNull(); + }); + + it("parses space-separated --steps", () => { + expect(parseStepsFromLaunchCmd("spawn claude hetzner --steps github,browser")).toBe("github,browser"); + }); + + it("parses --steps=value form", () => { + expect(parseStepsFromLaunchCmd("spawn claude hetzner --steps=github,auto-update")).toBe("github,auto-update"); + }); + + it("ignores --steps inside other flags", () => { + // --no-steps shouldn't match + expect(parseStepsFromLaunchCmd("spawn claude hetzner --no-steps")).toBeNull(); + }); + + it("does not over-match --no-steps=value", () => { + // Without word-boundary anchoring, --no-steps=foo would match and + // return "foo". The regex must only fire on the real --steps flag. + expect(parseStepsFromLaunchCmd("spawn claude hetzner --no-steps=foo")).toBeNull(); + expect(parseStepsFromLaunchCmd("spawn claude hetzner --no-steps foo")).toBeNull(); + }); +}); + +describe("resolveSteps", () => { + it("returns the parsed value when launch_cmd carries --steps", () => { + const r: SpawnRecord = { + ...baseRecord, + connection: { + ...baseRecord.connection!, + launch_cmd: "spawn claude hetzner --steps github,reuse-api-key", + }, + }; + expect(resolveSteps(r)).toBe("github,reuse-api-key"); + }); + + it("falls back to a default when launch_cmd has no --steps", () => { + expect(resolveSteps(baseRecord)).toBe("github,auto-update,security-scan"); + }); +}); + +describe("buildExportScript", () => { + const opts = { + spawnMd: "---\nname: x\n---\n", + readmeTemplate: "# __NAME__\n", + gitignore: "node_modules/\n", + cloud: "hetzner", + steps: "github,auto-update,security-scan", + visibility: "private" as const, + resultPath: "/tmp/spawn-export-result.json", + }; + + it("uses set -eo pipefail", () => { + expect(buildExportScript(opts)).toContain("set -eo pipefail"); + }); + + it("rsyncs the working tree and the claude system dir", () => { + const s = buildExportScript(opts); + expect(s).toContain("rsync -a --exclude=node_modules"); + expect(s).toContain('"$HOME/project/"'); + expect(s).toContain('"$HOME/.claude/$d/"'); + }); + + it("invokes claude -p to suggest the repo name", () => { + const s = buildExportScript(opts); + expect(s).toContain("claude -p"); + expect(s).toContain("kebab-case"); + }); + + it("falls back through basename(~/project) then a timestamp slug", () => { + const s = buildExportScript(opts); + expect(s).toContain('basename "$HOME/project"'); + expect(s).toContain("spawn-export-$(date +%s)"); + }); + + it("looks up the gh user and aborts if gh isn't authed", () => { + const s = buildExportScript(opts); + expect(s).toContain("gh api user --jq .login"); + expect(s).toContain('"error":"gh is not authenticated'); + }); + + it("scans staged files for known API-key patterns and aborts on hit", () => { + const s = buildExportScript(opts); + expect(s).toContain("SECRET_REGEX="); + // Verify a representative pattern from each provider family is present + expect(s).toContain("sk-or-v1-"); // OpenRouter + expect(s).toContain("sk-ant-api"); // Anthropic + expect(s).toContain("sk-proj-"); // OpenAI + expect(s).toContain("gh[ops]_"); // GitHub PAT/OAuth/server + expect(s).toContain("AKIA"); // AWS access key + 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("uses gh repo create with the cloud and slug from the script", () => { + const s = buildExportScript(opts); + expect(s).toContain('gh repo create "$SLUG" "$VISIBILITY_FLAG" --source=. --push'); + }); + + it("flips to --public when visibility is public", () => { + const s = buildExportScript({ + ...opts, + visibility: "public", + }); + expect(s).toContain("VISIBILITY_FLAG=--public"); + expect(s).not.toContain("VISIBILITY_FLAG=--private"); + }); + + it("emits --private when visibility is private (safe default)", () => { + // `opts.visibility` is "private" above; lock that in so a future default + // flip to public doesn't go unnoticed. + const s = buildExportScript(opts); + expect(s).toContain("VISIBILITY_FLAG=--private"); + expect(s).not.toContain("VISIBILITY_FLAG=--public"); + }); + + it("excludes .git when copying claude subdirs so nested checkouts don't leak", () => { + const s = buildExportScript(opts); + // The claude subdir rsync (skills/commands/hooks) targets "$HOME/.claude/$d/". + // Without --exclude=.git, a skill that happens to be a git checkout would + // ship its history in the exported repo. + expect(s).toContain('rsync -a --exclude=.git "$HOME/.claude/$d/"'); + }); + + it("writes the result JSON to the supplied path", () => { + const s = buildExportScript({ + ...opts, + resultPath: "/tmp/custom.json", + }); + expect(s).toContain("RESULT_PATH='/tmp/custom.json'"); + expect(s).toContain('"ok":true,"slug":"%s","url":"https://github.com/%s"'); + }); + + it("emits a structured failure result when gh isn't authed", () => { + const s = buildExportScript(opts); + expect(s).toContain('"ok":false,"error":"gh is not authenticated'); + }); + + it("recursively scrubs nested settings.json fields, not just top-level", () => { + const s = buildExportScript(opts); + expect(s).toContain("const scrub = (obj) =>"); + expect(s).toContain("scrub(parsed)"); + }); + + it("bakes the steps list into the script and substitutes __STEPS__", () => { + const s = buildExportScript(opts); + expect(s).toContain("STEPS='github,auto-update,security-scan'"); + expect(s).toContain("s|__STEPS__|$STEPS|g"); + }); +}); + +// ── cmdExport orchestration ───────────────────────────────────────────────── + +describe("cmdExport", () => { + it("errors out when no exportable claude spawns exist", async () => { + await expect( + cmdExport(undefined, { + records: [], + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("filters out non-claude agents", async () => { + const codexRecord: SpawnRecord = { + ...baseRecord, + agent: "codex", + }; + await expect( + cmdExport(undefined, { + records: [ + codexRecord, + ], + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("filters out spawns without connection info", async () => { + const noConn: SpawnRecord = { + ...baseRecord, + connection: undefined, + }; + await expect( + cmdExport(undefined, { + records: [ + noConn, + ], + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("filters out deleted spawns", async () => { + const deleted: SpawnRecord = { + ...baseRecord, + connection: { + ...baseRecord.connection!, + deleted: true, + }, + }; + await expect( + cmdExport(undefined, { + records: [ + deleted, + ], + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("filters out sprite-console connections", async () => { + const spriteConsole: SpawnRecord = { + ...baseRecord, + connection: { + ...baseRecord.connection!, + ip: "sprite-console", + }, + }; + await expect( + cmdExport(undefined, { + records: [ + spriteConsole, + ], + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("errors with a target hint when the named spawn doesn't exist", async () => { + await expect( + cmdExport("nonexistent", { + records: [ + baseRecord, + ], + }), + ).rejects.toThrow("__exit__"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts new file mode 100644 index 000000000..63756dd15 --- /dev/null +++ b/packages/cli/src/commands/export.ts @@ -0,0 +1,478 @@ +// commands/export.ts — `spawn export [name|id]` +// +// Captures a running claude spawn as a redistributable github repo. The +// output is the symmetric inverse of `--repo`: today `spawn claude hetzner +// --repo user/template` consumes a repo. After `spawn export`, the user +// gets a `spawn claude --repo user/` line they can hand off +// or re-run. +// +// v1 scope: claude only. +// - When the user has multiple claude spawns, a picker lists them. +// - The repo name is decided by claude on the VM (`claude -p` with a +// name-suggestion prompt) — the human is never asked. The gh username +// 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. + +import type { SpawnRecord } from "../history.js"; + +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import { getErrorMessage } from "@openrouter/spawn-shared"; +import pc from "picocolors"; +import * as v from "valibot"; +import { filterHistory } from "../history.js"; +import { parseJsonWith } from "../shared/parse.js"; +import { asyncTryCatch } from "../shared/result.js"; +import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js"; +import { makeSshRunner } from "../shared/ssh-runner.js"; +import { buildRecordLabel, buildRecordSubtitle } from "./list.js"; +import { handleCancel } from "./shared.js"; + +const CLAUDE_AGENT = "claude"; +const REMOTE_RESULT_PATH = "/tmp/spawn-export-result.json"; +/** Default --steps list when the original launch_cmd doesn't carry one. + * Picked to be the standard "0 prompts" claude provisioning set: + * github auth + auto-update + security-scan are all defaultOn-equivalent + * for normal spawns. */ +const DEFAULT_STEPS = "github,auto-update,security-scan"; + +/** Parse `--steps ` (or `--steps=`) out of a saved launch_cmd. + * Returns the comma-separated string verbatim, or null if the flag is + * absent. The respawn consumer re-validates the names. */ +export function parseStepsFromLaunchCmd(cmd: string | undefined): string | null { + if (!cmd) { + return null; + } + // Anchor to start or whitespace so `--no-steps` etc. never match. + const eq = cmd.match(/(?:^|\s)--steps=([^\s]+)/); + if (eq) { + return eq[1]; + } + const space = cmd.match(/(?:^|\s)--steps\s+([^\s]+)/); + if (space) { + return space[1]; + } + return null; +} + +/** Resolve the --steps value to bake into the spawn link. */ +export function resolveSteps(record: SpawnRecord): string { + return parseStepsFromLaunchCmd(record.connection?.launch_cmd) ?? DEFAULT_STEPS; +} + +/** Result the on-VM script writes to REMOTE_RESULT_PATH. */ +const ResultSchema = v.union([ + v.object({ + ok: v.literal(true), + slug: v.string(), + url: v.string(), + }), + v.object({ + ok: v.literal(false), + error: v.string(), + }), +]); + +/** Filter to records the export can actually drive: claude, with a live SSH + * connection, not deleted, not sprite-console. */ +function exportableClaudeRecords(records: SpawnRecord[]): SpawnRecord[] { + return records.filter((r) => { + if (r.agent !== CLAUDE_AGENT) { + return false; + } + const c = r.connection; + if (!c) { + return false; + } + if (c.deleted) { + return false; + } + if (c.ip === "sprite-console") { + return false; + } + return true; + }); +} + +/** Find a claude spawn by name or id. */ +function matchTarget(records: SpawnRecord[], target: string): SpawnRecord | null { + return records.find((r) => r.id === target || r.name === target || r.connection?.server_name === target) ?? null; +} + +/** Build the spawn.md content from a record. Re-spawning consumes this. */ +export function buildSpawnMd(record: SpawnRecord): string { + const lines: string[] = [ + "---", + ]; + if (record.name) { + lines.push(`name: ${JSON.stringify(record.name)}`); + } + lines.push(`description: ${JSON.stringify(`Exported from spawn ${record.id}`)}`); + lines.push("---"); + lines.push(""); + lines.push(`# ${record.name ?? "spawn export"}`); + lines.push(""); + lines.push("This template was generated by `spawn export`. Re-spawn it with the"); + lines.push("command in the README."); + lines.push(""); + return lines.join("\n"); +} + +/** Aggressive default .gitignore. The pre-commit secret scan is the real + * backstop; this just keeps obviously-private paths out of the staged tree + * before the scan runs. */ +export function buildGitignore(): string { + return [ + "# spawn export defaults", + "node_modules/", + "dist/", + "build/", + ".next/", + "target/", + ".cache/", + "coverage/", + "*.log", + ".env", + ".env.*", + ".spawnrc", + ".bash_history", + ".zsh_history", + ".aws/", + ".config/spawn/", + ".config/gcloud/", + ".gnupg/", + "*.key", + "*.pem", + "*.token", + "*.credentials", + "id_rsa*", + "id_ed25519*", + ".DS_Store", + "", + ].join("\n"); +} + +/** README template — the bash script substitutes __SLUG__, __CLOUD__, + * __NAME__, __STEPS__ at runtime once claude has picked a name. */ +export function buildReadmeTemplate(): string { + return [ + "# __NAME__", + "", + "Exported from a [spawn](https://github.com/OpenRouterTeam/spawn) session on `__CLOUD__`.", + "", + "## Quickstart", + "", + "```bash", + "spawn claude __CLOUD__ --repo __SLUG__ --steps __STEPS__", + "```", + "", + "Re-spawning is non-interactive — the `--steps` list bakes in the same", + "setup decisions the original spawn made, so you won't be prompted.", + "", + "## First-run checklist", + "", + "- [ ] `gh auth login` — re-auth GitHub on the new VM", + "- [ ] Re-OAuth any MCP servers used in the original session (Spotify, Linear, etc.)", + "- [ ] Run any project-specific install commands (e.g. `npm install`) in `project/`", + "", + "## What's in this repo", + "", + "- `project/` — the working tree at `~/project` from the source VM", + "- `claude/` — sanitized agent config: skills, commands, hooks, CLAUDE.md, settings", + "- `spawn.md` — machine-readable re-spawn metadata", + "", + ].join("\n"); +} + +/** Generate the bash script that runs on the VM. */ +export function buildExportScript(opts: { + spawnMd: string; + readmeTemplate: string; + gitignore: string; + cloud: string; + steps: string; + visibility: "private" | "public"; + resultPath: string; +}): string { + const visibilityFlag = opts.visibility === "public" ? "--public" : "--private"; + return [ + "#!/bin/bash", + "set -eo pipefail", + "", + `RESULT_PATH=${shSingleQuote(opts.resultPath)}`, + `CLOUD=${shSingleQuote(opts.cloud)}`, + `STEPS=${shSingleQuote(opts.steps)}`, + `VISIBILITY_FLAG=${visibilityFlag}`, + "", + 'EXPORT_DIR="$(mktemp -d)"', + 'trap "rm -rf \\"$EXPORT_DIR\\"" EXIT', + "", + "# 1. Heredoc the static files (spawn.md, .gitignore, README template)", + `cat > "$EXPORT_DIR/spawn.md" <<'SPAWN_MD_EOF'`, + opts.spawnMd, + "SPAWN_MD_EOF", + "", + `cat > "$EXPORT_DIR/.gitignore" <<'GITIGNORE_EOF'`, + opts.gitignore, + "GITIGNORE_EOF", + "", + `cat > "$EXPORT_DIR/README.md" <<'README_EOF'`, + opts.readmeTemplate, + "README_EOF", + "", + "# 2. Copy working tree (rsync excludes the obvious junk).", + 'if [ -d "$HOME/project" ]; then', + ' mkdir -p "$EXPORT_DIR/project"', + ' rsync -a --exclude=node_modules --exclude=.git --exclude=dist --exclude=.next --exclude=target --exclude=.env --exclude=".env.*" "$HOME/project/" "$EXPORT_DIR/project/"', + "fi", + "", + "# 3. Copy sanitized claude system dir.", + 'mkdir -p "$EXPORT_DIR/claude"', + "for d in skills commands hooks; do", + ' if [ -d "$HOME/.claude/$d" ]; then', + ' rsync -a --exclude=.git "$HOME/.claude/$d/" "$EXPORT_DIR/claude/$d/"', + " fi", + "done", + "for f in CLAUDE.md AGENTS.md settings.json; do", + ' if [ -f "$HOME/.claude/$f" ]; then', + ' cp "$HOME/.claude/$f" "$EXPORT_DIR/claude/$f"', + " fi", + "done", + "", + "# 4. Strip token-shaped keys from settings.json.", + 'if [ -f "$EXPORT_DIR/claude/settings.json" ] && command -v bun >/dev/null; then', + ' _SETTINGS_PATH="$EXPORT_DIR/claude/settings.json" bun -e "', + " const path = process.env._SETTINGS_PATH;", + " const raw = await Bun.file(path).text();", + " let parsed; try { parsed = JSON.parse(raw); } catch { process.exit(0); }", + " if (parsed && typeof parsed === 'object') {", + " const denyRe = /(token|secret|password|api[_-]?key|auth)/i;", + " const scrub = (obj) => {", + " if (!obj || typeof obj !== 'object') return;", + " for (const k of Object.keys(obj)) {", + " if (denyRe.test(k)) { delete obj[k]; continue; }", + " if (typeof obj[k] === 'object') scrub(obj[k]);", + " }", + " };", + " scrub(parsed);", + " await Bun.write(path, JSON.stringify(parsed, null, 2));", + " }", + ' " || true', + "fi", + "", + "# 5. Ask claude to suggest a kebab-case repo name.", + 'PROJECT_NAME=""', + "if command -v claude >/dev/null; then", + ' CLAUDE_PROMPT="You are choosing a github repo name for an export of this VM. Look at ~/project (the working tree) and any README/package.json to infer the project. Output ONLY a short kebab-case repo name, max 40 chars, lowercase, [a-z0-9-] only. No explanation, no quotes."', + ' SUGGESTED="$(claude -p "$CLAUDE_PROMPT" 2>/dev/null | head -n 1 || true)"', + ' PROJECT_NAME="$(printf "%s" "$SUGGESTED" | tr "A-Z" "a-z" | sed -E "s/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//" | cut -c1-40)"', + "fi", + 'if [ -z "$PROJECT_NAME" ]; then', + ' if [ -d "$HOME/project" ]; then', + ' PROJECT_NAME="$(basename "$HOME/project" | tr "A-Z" "a-z" | sed -E "s/[^a-z0-9-]+/-/g" | cut -c1-40)"', + " fi", + "fi", + 'if [ -z "$PROJECT_NAME" ]; then', + ' PROJECT_NAME="spawn-export-$(date +%s)"', + "fi", + "", + "# 6. Look up the gh user. Required.", + 'GH_USER="$(gh api user --jq .login 2>/dev/null || true)"', + 'if [ -z "$GH_USER" ]; then', + ' printf \'%s\\n\' \'{"ok":false,"error":"gh is not authenticated on the VM. Run `gh auth login` and retry."}\' > "$RESULT_PATH"', + " exit 1", + "fi", + 'SLUG="$GH_USER/$PROJECT_NAME"', + "", + "# 7. Substitute placeholders into README.", + 'sed -i "s|__NAME__|$PROJECT_NAME|g; s|__CLOUD__|$CLOUD|g; s|__SLUG__|$SLUG|g; s|__STEPS__|$STEPS|g" "$EXPORT_DIR/README.md"', + "", + "# 8. Stage everything.", + 'cd "$EXPORT_DIR"', + "git init -q -b main", + "git add -A", + "", + "# 9. SECRETS SCAN — abort if any staged file matches known API-key shapes.", + "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-----)'", + 'SECRET_HITS="$(git ls-files -z | xargs -0 grep -lEa "$SECRET_REGEX" 2>/dev/null || true)"', + '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', + ' printf "%s\\n" "$SECRET_HITS" >&2', + " exit 1", + "fi", + "", + "# 10. Commit and push.", + 'git -c user.email=spawn-export@openrouter.ai -c user.name="spawn export" commit -q -m "spawn export"', + "", + '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"', + "", + ].join("\n"); +} + +/** Single-quote a string for safe inclusion in a bash script. */ +function shSingleQuote(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} + +/** Pick one record from a list of claude spawns. */ +async function pickOne(records: SpawnRecord[]): Promise { + const options = records.map((r) => ({ + value: r.id ?? r.timestamp, + label: buildRecordLabel(r), + hint: buildRecordSubtitle(r, null), + })); + const choice = await p.select({ + message: "Which claude spawn do you want to export?", + options, + }); + if (p.isCancel(choice)) { + return null; + } + return records.find((r) => (r.id ?? r.timestamp) === choice) ?? null; +} + +/** Options for cmdExport — injectable for testing. */ +export interface ExportOptions { + /** Override the runner construction (test injection). */ + makeRunner?: ( + ip: string, + user: string, + keyOpts: string[], + ) => { + runServer: (cmd: string, timeoutSecs?: number) => Promise; + downloadFile: (remotePath: string, localPath: string) => Promise; + uploadFile: (localPath: string, remotePath: string) => Promise; + }; + /** Override visibility. If omitted, the user is prompted interactively + * with a "make public?" confirm that defaults to no (i.e. private). */ + visibility?: "private" | "public"; + /** Inject the candidate records directly (test seam to skip filterHistory). */ + records?: SpawnRecord[]; +} + +/** Top-level command: `spawn export [target]`. */ +export async function cmdExport(target: string | undefined, options?: ExportOptions): Promise { + const all = options?.records ?? filterHistory(); + const exportable = exportableClaudeRecords(all); + if (exportable.length === 0) { + p.log.info("No claude spawns available to export."); + p.log.info(`Run ${pc.cyan("spawn claude ")} first, then export the result.`); + process.exit(1); + } + + let picked: SpawnRecord | null = null; + if (target) { + picked = matchTarget(exportable, target); + if (!picked) { + p.log.error(`No claude spawn matches ${pc.bold(target)}.`); + p.log.info(`Run ${pc.cyan("spawn list -a claude")} to see available targets.`); + process.exit(1); + } + } else if (exportable.length === 1) { + picked = exportable[0] ?? null; + } else { + picked = await pickOne(exportable); + if (!picked) { + handleCancel(); // never returns + } + } + if (!picked) { + // Defensive: the branches above either assign or exit, so this should + // be unreachable. The explicit check keeps TypeScript narrowing happy + // without an `!` non-null assertion. + handleCancel(); + } + const r: SpawnRecord = picked; + const conn = r.connection; + if (!conn) { + // exportableClaudeRecords guarantees connection is present — a missing + // connection here means state was mutated between filter and use. + p.log.error("Internal error: selected spawn has no connection info."); + process.exit(1); + } + + p.log.step(`Exporting ${pc.bold(buildRecordLabel(r))} ${pc.dim(`(${buildRecordSubtitle(r, null)})`)}`); + + // Visibility: private by default. If the caller didn't force one (tests do), + // ask the user whether to make the exported repo public. A private repo is + // the safe default — the secret scan is a backstop, not a guarantee. + let visibility: "private" | "public"; + if (options?.visibility) { + visibility = options.visibility; + } else { + const makePublic = await p.confirm({ + message: "Make the exported repo public on GitHub?", + initialValue: false, + }); + if (p.isCancel(makePublic)) { + handleCancel(); + } + visibility = makePublic === true ? "public" : "private"; + } + const steps = resolveSteps(r); + const script = buildExportScript({ + spawnMd: buildSpawnMd(r), + readmeTemplate: buildReadmeTemplate(), + gitignore: buildGitignore(), + cloud: r.cloud, + steps, + visibility, + resultPath: REMOTE_RESULT_PATH, + }); + + // SSH runner + const keyOpts = options?.makeRunner ? [] : getSshKeyOpts(await ensureSshKeys()); + const runner = options?.makeRunner + ? options.makeRunner(conn.ip, conn.user, keyOpts) + : makeSshRunner(conn.ip, conn.user, keyOpts); + + // Run the export script. 10-min timeout — large repos take time to push. + p.log.step("Running export on the VM (claude is naming the repo)..."); + const runResult = await asyncTryCatch(() => runner.runServer(script, 600)); + if (!runResult.ok) { + p.log.error(`Export failed: ${getErrorMessage(runResult.error)}`); + p.log.info("Check that `gh` is authenticated on the VM (`gh auth status`)."); + 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)); + if (!dlResult.ok) { + rmSync(localTmp, { + recursive: true, + force: true, + }); + p.log.error(`Could not read export result: ${getErrorMessage(dlResult.error)}`); + process.exit(1); + } + const text = readFileSync(localResult, "utf8"); + rmSync(localTmp, { + recursive: true, + force: true, + }); + const parsed = parseJsonWith(text, ResultSchema); + if (!parsed) { + 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(); +} diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 067101a8e..39ace40f1 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -44,6 +44,8 @@ function getHelpUsageSection(): string { spawn link Register an existing VM by IP (alias: reconnect) spawn link --agent Specify the agent running on the VM spawn link --cloud Specify the cloud provider + spawn export Export a claude spawn to a github repo (re-spawn via --repo) + spawn export Export a specific spawn by name or ID spawn last Instantly rerun the most recent spawn (alias: rerun) spawn matrix Full availability matrix (alias: m) spawn agents List all agents with descriptions diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 309a56362..e28883954 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -2,6 +2,16 @@ // delete.ts — cmdDelete, cascadeDelete export { cascadeDelete, cmdDelete } from "./delete.js"; +// export.ts — cmdExport (capture a claude spawn into a redistributable github repo) +export { + buildExportScript, + buildGitignore, + buildReadmeTemplate, + buildSpawnMd, + cmdExport, + parseStepsFromLaunchCmd, + resolveSteps, +} from "./export.js"; // feedback.ts — cmdFeedback export { cmdFeedback } from "./feedback.js"; // fix.ts — cmdFix, fixSpawn diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0892969e7..2d7bdded6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,7 @@ import { cmdCloudInfo, cmdClouds, cmdDelete, + cmdExport, cmdFeedback, cmdFix, cmdHelp, @@ -819,6 +820,15 @@ async function dispatchCommand( await cmdLink(filteredArgs); return; } + if (cmd === "export") { + if (hasTrailingHelpFlag(filteredArgs)) { + cmdHelp(); + return; + } + const targetArg = filteredArgs[1] && !filteredArgs[1].startsWith("-") ? filteredArgs[1] : undefined; + await cmdExport(targetArg); + return; + } if (VERB_ALIASES.has(cmd)) { await dispatchVerbAlias(cmd, filteredArgs, prompt, dryRun, debug, headless, outputFormat); return;