Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.32",
"version": "1.0.33",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
30 changes: 26 additions & 4 deletions packages/cli/src/__tests__/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
46 changes: 36 additions & 10 deletions packages/cli/src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -91,9 +91,6 @@ function exportableClaudeRecords(records: SpawnRecord[]): SpawnRecord[] {
if (c.deleted) {
return false;
}
if (c.ip === "sprite-console") {
return false;
}
return true;
});
}
Expand Down Expand Up @@ -322,6 +319,37 @@ function shSingleQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}

interface ExportRunner {
runServer: (cmd: string, timeoutSecs?: number) => Promise<void>;
uploadFile: (localPath: string, remotePath: string) => Promise<void>;
downloadFile: (remotePath: string, localPath: string) => Promise<void>;
}

/** 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<ExportRunner> {
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<SpawnRecord | null> {
const options = records.map((r) => ({
Expand Down Expand Up @@ -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)...");
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/sprite/sprite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return promptSpawnNameShared("Sprite");
}
Expand Down
Loading