diff --git a/packages/cli/package.json b/packages/cli/package.json index 91ccbe5a..948fee4d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.16.5", + "version": "0.16.6", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/orchestrate.test.ts b/packages/cli/src/__tests__/orchestrate.test.ts index 9af84ffd..6e34e88b 100644 --- a/packages/cli/src/__tests__/orchestrate.test.ts +++ b/packages/cli/src/__tests__/orchestrate.test.ts @@ -117,8 +117,9 @@ describe("runOrchestration", () => { process.env.SPAWN_HOME = testDir; // Skip GitHub auth prompts during tests process.env.SPAWN_SKIP_GITHUB_AUTH = "1"; - // Ensure no stale SPAWN_ENABLED_STEPS leaks between tests + // Ensure no stale env leaks between tests delete process.env.SPAWN_ENABLED_STEPS; + delete process.env.SPAWN_BETA; stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); exitSpy = spyOn(process, "exit").mockImplementation((code) => { capturedExitCode = isNumber(code) ? code : 0; @@ -524,7 +525,8 @@ describe("runOrchestration", () => { // ── Tarball install ────────────────────────────────────────────────── - it("attempts tarball install before agent.install on non-local clouds", async () => { + it("attempts tarball install when --beta=tarball is set on non-local clouds", async () => { + process.env.SPAWN_BETA = "tarball"; const install = mock(() => Promise.resolve()); const cloud = createMockCloud({ cloudName: "digitalocean", @@ -543,7 +545,25 @@ describe("runOrchestration", () => { exitSpy.mockRestore(); }); + it("skips tarball install by default (no --beta flag)", async () => { + const install = mock(() => Promise.resolve()); + const cloud = createMockCloud({ + cloudName: "digitalocean", + }); + const agent = createMockAgent({ + install, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + expect(mockTryTarballInstall).not.toHaveBeenCalled(); + expect(install).toHaveBeenCalledTimes(1); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + it("skips agent.install when tarball succeeds", async () => { + process.env.SPAWN_BETA = "tarball"; mockTryTarballInstall.mockImplementation(() => Promise.resolve(true)); const install = mock(() => Promise.resolve()); const cloud = createMockCloud({ @@ -561,7 +581,8 @@ describe("runOrchestration", () => { exitSpy.mockRestore(); }); - it("skips tarball install for local cloud", async () => { + it("skips tarball install for local cloud even with --beta=tarball", async () => { + process.env.SPAWN_BETA = "tarball"; const install = mock(() => Promise.resolve()); const cloud = createMockCloud({ cloudName: "local", @@ -579,6 +600,7 @@ describe("runOrchestration", () => { }); it("skips tarball install when agent has skipTarball set", async () => { + process.env.SPAWN_BETA = "tarball"; const install = mock(() => Promise.resolve()); const cloud = createMockCloud({ cloudName: "digitalocean", diff --git a/packages/cli/src/flags.ts b/packages/cli/src/flags.ts index a445a00f..82d30601 100644 --- a/packages/cli/src/flags.ts +++ b/packages/cli/src/flags.ts @@ -30,6 +30,7 @@ export const KNOWN_FLAGS = new Set([ "--size", "--prune", "--json", + "--beta", ]); /** Return the first unknown flag in args, or null if all are known/positional */ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7571f2bd..b455bcda 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -76,6 +76,23 @@ function extractFlagValue( ]; } +/** Extract all occurrences of a repeatable flag, mutating args in place. */ +function extractAllFlagValues(args: string[], flag: string, usageHint: string): string[] { + const values: string[] = []; + let idx = args.indexOf(flag); + while (idx !== -1) { + if (!args[idx + 1] || args[idx + 1].startsWith("-")) { + console.error(pc.red(`Error: ${pc.bold(flag)} requires a value`)); + console.error(`\nUsage: ${pc.cyan(usageHint)}`); + process.exit(1); + } + values.push(args[idx + 1]); + args.splice(idx, 2); + idx = args.indexOf(flag); + } + return values; +} + const HELP_FLAGS = [ "--help", "-h", @@ -100,6 +117,7 @@ function checkUnknownFlags(args: string[]): void { console.error(` ${pc.cyan("--size, --machine-type")} Set instance size (e.g. e2-standard-4, s-2vcpu-2gb)`); console.error(` ${pc.cyan("--name")} Set the spawn/resource name`); console.error(` ${pc.cyan("--reauth")} Force re-prompting for cloud credentials`); + console.error(` ${pc.cyan("--beta tarball")} Use pre-built tarball for agent install (repeatable)`); console.error(` ${pc.cyan("--help, -h")} Show help information`); console.error(` ${pc.cyan("--version, -v")} Show version`); console.error(); @@ -761,6 +779,23 @@ async function main(): Promise { process.env.SPAWN_REAUTH = "1"; } + // Extract all --beta flags (repeatable, opt-in to experimental features) + const VALID_BETA_FEATURES = new Set([ + "tarball", + ]); + const betaFeatures = extractAllFlagValues(filteredArgs, "--beta", "spawn --beta tarball"); + for (const flag of betaFeatures) { + if (!VALID_BETA_FEATURES.has(flag)) { + console.error(pc.red(`Unknown beta feature: ${pc.bold(flag)}`)); + console.error("\nAvailable beta features:"); + console.error(` ${pc.cyan("tarball")} Use pre-built tarball for agent installation`); + process.exit(1); + } + } + if (betaFeatures.length > 0) { + process.env.SPAWN_BETA = betaFeatures.join(","); + } + // Extract --output flag const [outputFormat, outputFilteredArgs] = extractFlagValue( filteredArgs, diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index bf8cd873..2734e967 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -153,7 +153,8 @@ export async function runOrchestration( logInfo("Snapshot boot — skipping agent install"); } else { let installedFromTarball = false; - if (cloud.cloudName !== "local" && !agent.skipTarball) { + const betaFeatures = new Set((process.env.SPAWN_BETA ?? "").split(",").filter(Boolean)); + if (cloud.cloudName !== "local" && !agent.skipTarball && betaFeatures.has("tarball")) { const tarball = options?.tryTarball ?? tryTarballInstall; installedFromTarball = await tarball(cloud.runner, agentName); }