fix(export): support sprite spawns (drop sprite-console exclusion) (#3382)

The export filter was excluding records with ip === "sprite-console" on
the assumption they weren't reachable. Sprite has its own exec channel
(sprite exec / sprite cp), so they ARE reachable — we just weren't
wiring it up.

- exportableClaudeRecords no longer drops sprite-console records.
- New buildRunnerForRecord() branches on record.cloud:
    sprite -> sprite.runSprite / uploadFileSprite / downloadFileSprite
    else   -> makeSshRunner with the connection's ip/user
- sprite/sprite.ts gains setSpriteName(name) so the export flow can
  point the sprite module at an existing sprite by name (the runner
  functions key off the module's _state.name, which previously was
  only set during the createSprite() flow).

Bumps CLI 1.0.32 -> 1.0.33.
This commit is contained in:
Ahmed Abushagur 2026-05-02 00:13:47 -07:00 committed by GitHub
parent 3e99bdd604
commit 46bbeea751
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 73 additions and 15 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.32",
"version": "1.0.33",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -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 () => {

View file

@ -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<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) => ({
@ -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)...");

View file

@ -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");
}