mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-17 04:11:23 +00:00
feat(cli): spawn export — capture a claude session into a github repo (#3377)
* feat(cli): spawn export — capture a claude session into a github repo
Adds `spawn export [name|id]` as a top-level subcommand. Captures a
running claude spawn into a redistributable github repo whose README
contains the canonical re-spawn command — the symmetric inverse of
`--repo`.
What gets exported:
- `~/project/` working tree (with aggressive .gitignore)
- `~/.claude/` sanitized agent system dir: skills, commands,
hooks, CLAUDE.md, AGENTS.md, settings.json
(with token-shaped fields stripped)
- `spawn.md` generated re-spawn metadata
- `README.md` generated; renders a re-auth checklist on github
The export runs over the existing SshRunner. v1 is claude-only; non-claude
agents return a clear "not yet supported" error. Bumps CLI 1.0.27 -> 1.1.0
because this is a real new surface, not a fix.
Followups (not in v1):
- claude introspects its own session (MCP servers, OAuth providers) and
writes them into spawn.md's setup steps
- local cloud target uses a direct branch (currently routed through SSH)
- in-session `:export` slash command
* fix(cli): use patch bump (1.0.28) to match team versioning cadence
The update-check tests hardcode 1.0.x patch-bump scenarios as test fixtures
(e.g. mocking "latest" as 1.0.99 to verify patch auto-install policy). A
1.1.0 bump made those mocked versions look like downgrades and failed CI.
Realign with the team's recent patch-bump cadence — every recent feature
PR has shipped under 1.0.x.
* feat(export): list picker, claude picks the slug, pre-commit secrets scan
Three changes per review feedback:
1. **Picker.** When the user has multiple exportable claude spawns and
no positional target arg, show a clack `select` listing them. Auto-pick
on a single match. Filter out non-claude / no-connection / deleted /
sprite-console records up front.
2. **Claude decides the repo name.** No more slug prompt. The on-VM script
runs `claude -p` with a name-suggestion prompt asking for a kebab-case
project name (max 40 chars, [a-z0-9-]). Falls back to basename(~/project)
then a timestamp slug if claude is unavailable or returns garbage.
`gh api user --jq .login` provides the username; missing gh auth aborts
with a structured JSON failure the CLI surfaces verbatim.
3. **Pre-commit secrets scan.** After `git add`, scan all staged files for
known API-key shapes — Anthropic (sk-ant-api...), OpenRouter (sk-or-v1-),
OpenAI (sk-proj-), GitHub PAT/OAuth/server (gh[ops]_), AWS (AKIA...),
Hetzner (hcloud_), DigitalOcean (dop_v1_), and PEM private keys. Any
match aborts the export with `{"ok":false,"error":"..."}` to the result
file. The settings.json scrubber now recurses; previously it only
stripped top-level + env keys.
Also expands the .gitignore deny-list with .spawnrc, .bash_history,
.aws/, .config/spawn/, .config/gcloud/, .gnupg/, *.token, *.credentials.
Bumps CLI 1.0.28 -> 1.0.29.
* feat(export): default to public visibility
Exports are share-friendly artifacts — public by default makes the
'spawn link' (the printed re-spawn command) usable by anyone the user
hands it to. Override path stays via options.visibility for callers
that need private.
Bumps CLI 1.0.29 -> 1.0.30.
* feat(export): bake --steps into the spawn link for zero-prompt respawn
The printed spawn link is now:
spawn claude <cloud> --repo <slug> --steps <list>
Source of the steps list:
1. Parse `--steps <value>` (or `--steps=<value>`) out of the original
record.connection.launch_cmd.
2. Fall back to 'github,auto-update,security-scan' when the launch_cmd
doesn't carry it (older spawns, or interactive launches that didn't
pass the flag).
The respawn consumer reads SPAWN_ENABLED_STEPS from --steps and skips
the interactive setup picker entirely, so handing someone the spawn
link is a true zero-choice replay.
Adds parseStepsFromLaunchCmd + resolveSteps helpers, both exported
from the barrel for testability. README template grows a __STEPS__
placeholder; bash sed adds it to the substitution pass.
Bumps CLI 1.0.30 -> 1.0.31.
* fix(export): safe defaults and tighter flag parsing
Review follow-ups to #3377:
- Visibility: default private; interactive "make public?" confirm when
the caller doesn't force one. Prior default-public + one-shot `gh repo
create --push` was a public-leak footgun when the secret regex missed.
- `parseStepsFromLaunchCmd`: anchor both regexes to start-or-whitespace
so `--no-steps=foo` no longer over-matches and returns `foo`.
- `--exclude=.git` on the claude/{skills,commands,hooks} rsync so a
nested git checkout inside a skill doesn't leak its history.
- Replace `record!` / `conn!` non-null assertions with explicit
narrowing — matches the project's type-safety rule (no `as`, no `!`).
- Tests: lock in private default, the .git exclude, and the negative
`--no-steps=` regex case (14 new expects, 32/32 pass).
- Bump CLI to 1.0.32.
---------
Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
parent
83ecccf4ad
commit
3e99bdd604
6 changed files with 849 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "1.0.27",
|
||||
"version": "1.0.32",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
348
packages/cli/src/__tests__/export.test.ts
Normal file
348
packages/cli/src/__tests__/export.test.ts
Normal file
|
|
@ -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<typeof spyOn>;
|
||||
let stdoutSpy: ReturnType<typeof spyOn>;
|
||||
let exitSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
478
packages/cli/src/commands/export.ts
Normal file
478
packages/cli/src/commands/export.ts
Normal file
|
|
@ -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 <cloud> --repo user/<slug>` 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 <value>` (or `--steps=<value>`) 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<SpawnRecord | null> {
|
||||
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<void>;
|
||||
downloadFile: (remotePath: string, localPath: string) => Promise<void>;
|
||||
uploadFile: (localPath: string, remotePath: string) => Promise<void>;
|
||||
};
|
||||
/** 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<void> {
|
||||
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 <cloud>")} 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();
|
||||
}
|
||||
|
|
@ -44,6 +44,8 @@ function getHelpUsageSection(): string {
|
|||
spawn link <ip> Register an existing VM by IP (alias: reconnect)
|
||||
spawn link <ip> --agent <agent> Specify the agent running on the VM
|
||||
spawn link <ip> --cloud <cloud> Specify the cloud provider
|
||||
spawn export Export a claude spawn to a github repo (re-spawn via --repo)
|
||||
spawn export <name> 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue