diff --git a/packages/cli/package.json b/packages/cli/package.json index c137b229b..9c78f68f3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.32", + "version": "1.0.33", "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 5bfc088ab..60a84a9f9 100644 --- a/packages/cli/src/__tests__/export.test.ts +++ b/packages/cli/src/__tests__/export.test.ts @@ -317,22 +317,44 @@ describe("cmdExport", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); - it("filters out sprite-console connections", async () => { - const spriteConsole: SpawnRecord = { + it("includes sprite-console connections (sprite has its own runner)", async () => { + const spriteRecord: SpawnRecord = { ...baseRecord, + cloud: "sprite", connection: { ...baseRecord.connection!, + cloud: "sprite", ip: "sprite-console", }, }; + // The injected runner short-circuits the sprite-module import, so we just + // need cmdExport to attempt the export rather than filtering the record out. + const ranWith: { + script?: string; + } = {}; + const stubRunner = { + runServer: async (cmd: string) => { + ranWith.script = cmd; + }, + uploadFile: async () => {}, + // downloadFile must succeed so the parser sees a result file. Make it + // throw a recognisable error and assert we got past the filter step. + downloadFile: async () => { + throw new Error("__downloadFile_called__"); + }, + }; await expect( cmdExport(undefined, { records: [ - spriteConsole, + spriteRecord, ], + visibility: "private", + makeRunner: () => stubRunner, }), ).rejects.toThrow("__exit__"); - expect(exitSpy).toHaveBeenCalledWith(1); + // The script ran (record passed the filter), then the download stub threw, + // which cmdExport surfaces as exit(1). What matters: ranWith.script is set. + expect(ranWith.script).toBeDefined(); }); it("errors with a target hint when the named spawn doesn't exist", async () => { diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 63756dd15..64485683a 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -77,8 +77,8 @@ const ResultSchema = v.union([ }), ]); -/** Filter to records the export can actually drive: claude, with a live SSH - * connection, not deleted, not sprite-console. */ +/** Filter to records the export can actually drive: claude, with a live + * connection (SSH or sprite-console), not deleted. */ function exportableClaudeRecords(records: SpawnRecord[]): SpawnRecord[] { return records.filter((r) => { if (r.agent !== CLAUDE_AGENT) { @@ -91,9 +91,6 @@ function exportableClaudeRecords(records: SpawnRecord[]): SpawnRecord[] { if (c.deleted) { return false; } - if (c.ip === "sprite-console") { - return false; - } return true; }); } @@ -322,6 +319,37 @@ function shSingleQuote(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; } +interface ExportRunner { + runServer: (cmd: string, timeoutSecs?: number) => Promise; + uploadFile: (localPath: string, remotePath: string) => Promise; + downloadFile: (remotePath: string, localPath: string) => Promise; +} + +/** Build the runner for a specific spawn record. Sprite has its own exec + * channel (`sprite exec`, etc.); everything else uses SSH. */ +async function buildRunnerForRecord(record: SpawnRecord): Promise { + const conn = record.connection; + if (!conn) { + throw new Error("Cannot build runner: spawn has no connection info."); + } + if (record.cloud === "sprite") { + if (!conn.server_name) { + throw new Error("Cannot export sprite: connection is missing server_name."); + } + const sprite = await import("../sprite/sprite.js"); + await sprite.ensureSpriteCli(); + await sprite.ensureSpriteAuthenticated(); + sprite.setSpriteName(conn.server_name); + return { + runServer: sprite.runSprite, + uploadFile: sprite.uploadFileSprite, + downloadFile: sprite.downloadFileSprite, + }; + } + const keyOpts = getSshKeyOpts(await ensureSshKeys()); + return makeSshRunner(conn.ip, conn.user, keyOpts); +} + /** Pick one record from a list of claude spawns. */ async function pickOne(records: SpawnRecord[]): Promise { const options = records.map((r) => ({ @@ -428,11 +456,9 @@ export async function cmdExport(target: string | undefined, options?: ExportOpti 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); + // 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. p.log.step("Running export on the VM (claude is naming the repo)..."); diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index c9107ff9d..d69bba4a2 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -270,6 +270,16 @@ function orgFlags(): string[] { // ─── Server Name ───────────────────────────────────────────────────────────── +/** Set the active sprite name for subsequent runSprite/uploadFileSprite/ + * downloadFileSprite calls. Used by reconnect-style flows (e.g. spawn export) + * that operate on an existing sprite without going through createSprite. */ +export function setSpriteName(name: string): void { + if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) { + throw new Error("setSpriteName: name must be non-empty and match [a-zA-Z0-9_.-]+"); + } + _state.name = name; +} + export async function promptSpawnName(): Promise { return promptSpawnNameShared("Sprite"); }