diff --git a/.changeset/init-commands-on-every-box.md b/.changeset/init-commands-on-every-box.md new file mode 100644 index 00000000..6cf99f70 --- /dev/null +++ b/.changeset/init-commands-on-every-box.md @@ -0,0 +1,22 @@ +--- +"@upstash/box": patch +"@upstash/box-cli": patch +--- + +Allow init commands on every box, and correct the public URL list type. + +Init commands are no longer restricted to keep-alive boxes. `Box.create` accepts +`initCommand` without `keepAlive`, and `getInitCommand`, `setInitCommand` and +`deleteInitCommand` work on any box, including a paused one, where the change is +stored and applied on the next resume. The CLI no longer rejects +`--init-command` without `--keep-alive`. + +This matters because a public URL now resumes a paused box on any request and +holds the request until the app's port is listening. The init command is what +restarts the app when that happens, so the two go together. + +`listPublicURLs()` now returns `PublicURLListItem[]` rather than `PublicURL[]`. +The previous type was wrong: the list endpoint returns `id`, `created_at`, +`basic_auth` and `bearer_token`, and never returns the `token`, `username` or +`password` fields the old type advertised, since those are only returned once at +creation. diff --git a/packages/cli/README.md b/packages/cli/README.md index f9edc358..8cd2c6e2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -217,7 +217,7 @@ box create --no-repl \ | `--name ` | Human-readable name | | | `--size ` | Resource size | `small` | | `--keep-alive` | Keep the box running instead of pausing when idle | | -| `--init-command ` | Startup script, for keep-alive boxes | | +| `--init-command ` | Startup script, run once each time the box starts | | | `--browser` | Provision a headless Chromium | | | `--clone-repo ` | Clone this repository after creating the box | | | `--agent-model ` | Agent model identifier | | diff --git a/packages/cli/src/__tests__/commands/create.test.ts b/packages/cli/src/__tests__/commands/create.test.ts index 0ad95934..1db5128a 100644 --- a/packages/cli/src/__tests__/commands/create.test.ts +++ b/packages/cli/src/__tests__/commands/create.test.ts @@ -170,15 +170,17 @@ describe("createCommand", () => { if (key !== undefined) process.env.UPSTASH_BOX_API_KEY = key; }); - it("rejects --init-command without --keep-alive, which the backend would 400", async () => { - await expect( - createCommand({ - token: "key", - initCommand: "npm start", - repl: false, - }), - ).rejects.toThrow(/keep-alive/); - expect(Box.create).not.toHaveBeenCalled(); + it("accepts --init-command without --keep-alive", async () => { + const mockBox = { id: "box-1" }; + vi.mocked(Box.create).mockResolvedValueOnce(mockBox as any); + + await createCommand({ + token: "key", + initCommand: "npm start", + repl: false, + }); + + expect(Box.create).toHaveBeenCalledWith(expect.objectContaining({ initCommand: "npm start" })); }); it("passes runtime, git token, and env vars", async () => { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d268e226..11492a57 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -526,7 +526,7 @@ program .option("--name ", "Human-readable name for the box") .option("--size ", "Resource size (small, medium, large)") .option("--keep-alive", "Keep the box running instead of pausing when idle") - .option("--init-command ", "Startup script, for keep-alive boxes") + .option("--init-command ", "Startup script, run once each time the box starts") .option("--browser", "Provision a headless Chromium in the box") .option("--clone-repo ", "Clone this repository into the box after creating it") .option( diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 301191b5..c2fcbd0a 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -262,11 +262,6 @@ export async function createCommand(flags: CreateFlags): Promise { ); } - // The backend rejects a startup script on a box that is allowed to pause. - if (flags.initCommand !== undefined && !flags.keepAlive) { - throw new CliError("--init-command only applies to a keep-alive box; add --keep-alive"); - } - // In headless mode stdout carries the box id and nothing else, so progress // goes to stderr. if (headless) note("Creating box..."); diff --git a/packages/cli/src/commands/public-url.ts b/packages/cli/src/commands/public-url.ts index 4c1be8cd..113355d0 100644 --- a/packages/cli/src/commands/public-url.ts +++ b/packages/cli/src/commands/public-url.ts @@ -47,7 +47,7 @@ export async function publicUrlCommand(portArg: string, flags: PublicUrlFlags): emit(created, lines, flags); // A server started as a plain background job is reaped when the command that // launched it finishes, and the URL then 502s. - note("Start the server detached — ( npm run dev & ) — or it stops with the command."); + note("Start the server detached, ( npm run dev & ), or it stops with the command."); } /** List the box's public URLs. */ diff --git a/packages/cli/src/repl/commands/public-url.ts b/packages/cli/src/repl/commands/public-url.ts index 141ebe7a..e1fdfbfe 100644 --- a/packages/cli/src/repl/commands/public-url.ts +++ b/packages/cli/src/repl/commands/public-url.ts @@ -65,7 +65,7 @@ export async function* handlePublicUrl(box: Box, args: string): AsyncGenerator None: "No agent configured. Pass an `agent` option to create() to use box.agent.run()." ) - def _require_keep_alive(self, feature: str) -> None: - if not self.keep_alive: - raise BoxError(f"{feature} is only available for keep-alive boxes") - def _log(self, *args: Any) -> None: if self._debug: _logger.debug("[Box] %s", " ".join(str(a) for a in args)) @@ -1521,12 +1517,10 @@ async def update_network_policy(self, policy: NetworkPolicy) -> None: self._network_policy = policy async def get_init_command(self) -> str: - self._require_keep_alive("Init command") data = await self._request("GET", f"/v2/box/{self.id}/startup") return data.get("init_command", "") async def set_init_command(self, init_command: str) -> None: - self._require_keep_alive("Init command") if not init_command: raise BoxError("init_command is required") await self._request( @@ -1534,7 +1528,6 @@ async def set_init_command(self, init_command: str) -> None: ) async def delete_init_command(self) -> None: - self._require_keep_alive("Init command") await self._request("DELETE", f"/v2/box/{self.id}/startup") async def pause(self) -> None: @@ -1963,8 +1956,6 @@ async def create(cls, **config: Unpack[BoxConfig]) -> "AsyncBox": agent = config.get("agent") if agent: common.resolve_agent_model(agent) - if config.get("init_command") is not None and not config.get("keep_alive"): - raise BoxError("init_command requires keep_alive=True") base_url = common.resolve_base_url(config.get("base_url")) headers = common.build_headers(api_key) timeout = config.get("timeout", _DEFAULT_TIMEOUT_MS) diff --git a/packages/python-sdk/upstash_box/_sync/client.py b/packages/python-sdk/upstash_box/_sync/client.py index 998fb6a4..b8de30fa 100644 --- a/packages/python-sdk/upstash_box/_sync/client.py +++ b/packages/python-sdk/upstash_box/_sync/client.py @@ -873,10 +873,6 @@ def _require_agent(self) -> None: "No agent configured. Pass an `agent` option to create() to use box.agent.run()." ) - def _require_keep_alive(self, feature: str) -> None: - if not self.keep_alive: - raise BoxError(f"{feature} is only available for keep-alive boxes") - def _log(self, *args: Any) -> None: if self._debug: _logger.debug("[Box] %s", " ".join(str(a) for a in args)) @@ -1510,18 +1506,15 @@ def update_network_policy(self, policy: NetworkPolicy) -> None: self._network_policy = policy def get_init_command(self) -> str: - self._require_keep_alive("Init command") data = self._request("GET", f"/v2/box/{self.id}/startup") return data.get("init_command", "") def set_init_command(self, init_command: str) -> None: - self._require_keep_alive("Init command") if not init_command: raise BoxError("init_command is required") self._request("PUT", f"/v2/box/{self.id}/startup", body={"init_command": init_command}) def delete_init_command(self) -> None: - self._require_keep_alive("Init command") self._request("DELETE", f"/v2/box/{self.id}/startup") def pause(self) -> None: @@ -1940,8 +1933,6 @@ def create(cls, **config: Unpack[BoxConfig]) -> "Box": agent = config.get("agent") if agent: common.resolve_agent_model(agent) - if config.get("init_command") is not None and not config.get("keep_alive"): - raise BoxError("init_command requires keep_alive=True") base_url = common.resolve_base_url(config.get("base_url")) headers = common.build_headers(api_key) timeout = config.get("timeout", _DEFAULT_TIMEOUT_MS) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 48df8867..06ba61f4 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -326,7 +326,7 @@ await box.delete(); // Permanent delete const { status } = await box.getStatus(); ``` -Keep-alive boxes also support init-command management: +Init commands can be read, set and removed on any box, including a paused one: ```ts const script = await box.getInitCommand(); diff --git a/packages/sdk/src/__tests__/box-create.test.ts b/packages/sdk/src/__tests__/box-create.test.ts index 2cfc5efa..2f8934c2 100644 --- a/packages/sdk/src/__tests__/box-create.test.ts +++ b/packages/sdk/src/__tests__/box-create.test.ts @@ -314,13 +314,14 @@ describe("Box.create", () => { expect(box.keepAlive).toBe(true); }); - it("throws when initCommand is provided without keepAlive", async () => { - await expect( - Box.create({ - ...TEST_CONFIG, - initCommand: "echo hi", - }), - ).rejects.toThrow("initCommand requires keepAlive: true"); + it("sends initCommand without keepAlive", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ ...TEST_BOX_DATA, status: "running" })); + + await Box.create({ ...TEST_CONFIG, initCommand: "echo hi" }); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string); + expect(body.init_command).toBe("echo hi"); + expect(body.keep_alive).toBeUndefined(); }); it("sends skills and mcpServers in body", async () => { diff --git a/packages/sdk/src/__tests__/box-instance.test.ts b/packages/sdk/src/__tests__/box-instance.test.ts index bd8a49c0..1eee3d81 100644 --- a/packages/sdk/src/__tests__/box-instance.test.ts +++ b/packages/sdk/src/__tests__/box-instance.test.ts @@ -380,20 +380,40 @@ describe("Box instance methods", () => { await expect(box.setInitCommand("")).rejects.toThrow("initCommand is required"); }); - it("throws for non-keep-alive boxes", async () => { + // All three used to reject a box that was not keep-alive, so all three are + // covered here: one of them could otherwise regress unnoticed. + it("reads the init command on a box that is not keep-alive", async () => { const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ init_command: "npm run dev" })); - await expect(box.getInitCommand()).rejects.toThrow( - "Init command is only available for keep-alive boxes", - ); - await expect(box.setInitCommand("echo hi")).rejects.toThrow( - "Init command is only available for keep-alive boxes", - ); - await expect(box.deleteInitCommand()).rejects.toThrow( - "Init command is only available for keep-alive boxes", - ); + await expect(box.getInitCommand()).resolves.toBe("npm run dev"); - expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123/startup"); + expect(init?.method).toBe("GET"); + }); + + it("sets the init command on a box that is not keep-alive", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ message: "startup script saved" })); + + await expect(box.setInitCommand("npm run dev")).resolves.toBeUndefined(); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123/startup"); + expect(init?.method).toBe("PUT"); + expect(JSON.parse(init?.body as string)).toEqual({ init_command: "npm run dev" }); + }); + + it("deletes the init command on a box that is not keep-alive", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ message: "startup script deleted" })); + + await expect(box.deleteInitCommand()).resolves.toBeUndefined(); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123/startup"); + expect(init?.method).toBe("DELETE"); }); }); diff --git a/packages/sdk/src/__tests__/integration/preview.integration.test.ts b/packages/sdk/src/__tests__/integration/preview.integration.test.ts index 666cd8f5..1c867404 100644 --- a/packages/sdk/src/__tests__/integration/preview.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/preview.integration.test.ts @@ -97,3 +97,46 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("public URLs", () => { expect(res.publicURLs.every((p) => p.port !== 3000)).toBe(true); }); }); + +const WAKE_SECRET = "box-paused-url-integration-secret-7"; + +describe.skipIf(!UPSTASH_BOX_API_KEY)("public URLs: paused box", () => { + let box: Box; + + beforeAll(async () => { + // The init command is what restarts the server after a resume, so it is + // part of the behaviour under test rather than setup convenience. + box = await Box.create({ + apiKey: UPSTASH_BOX_API_KEY!, + runtime: "node", + initCommand: `node -e 'require("http").createServer((_,r)=>r.end("${WAKE_SECRET}")).listen(3000)' &`, + }); + await new Promise((r) => setTimeout(r, 4000)); + }, 180000); + + afterAll(async () => { + try { + await box?.delete(); + } catch { + // cleanup best-effort + } + }, 30000); + + it("a request to the URL resumes the box and serves the app's own response", async () => { + const created = await box.getPublicURL(3000); + + const awake = await fetch(created.url, { signal: AbortSignal.timeout(60_000) }); + expect(await awake.text()).toBe(WAKE_SECRET); + + await box.pause(); + expect((await box.getStatus()).status).toBe("paused"); + + // The wait is bounded at 30s server-side, so allow more than that here. + const woken = await fetch(created.url, { signal: AbortSignal.timeout(90_000) }); + expect(woken.status).toBe(200); + expect(await woken.text()).toBe(WAKE_SECRET); + expect((await box.getStatus()).status).not.toBe("paused"); + + await box.deletePublicURL(3000); + }, 240000); +}); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index a9bd9ab5..7109a249 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -40,6 +40,7 @@ import { type UploadFileEntry, type Snapshot, type Preview, + type PublicURLListItem, type PublicURL, type EphemeralBoxConfig, type EphemeralBoxData, @@ -973,9 +974,6 @@ export class Box { ); } if (config?.agent) resolveAgentModel(config.agent); - if (config?.initCommand !== undefined && !config.keepAlive) { - throw new BoxError("initCommand requires keepAlive: true"); - } const baseUrl = ( config?.baseUrl ?? process.env.UPSTASH_BOX_BASE_URL ?? @@ -2514,10 +2512,9 @@ export class Box { } /** - * Read the current init command for a keep-alive box. + * Read the current init command. */ async getInitCommand(): Promise { - this._requireKeepAlive("Init command"); const data = await this._request<{ init_command?: string }>( "GET", `/v2/box/${this.id}/startup`, @@ -2526,10 +2523,10 @@ export class Box { } /** - * Set or replace the init command for a keep-alive box. + * Set or replace the init command. On a paused box the change is stored and + * applied on the next resume. */ async setInitCommand(initCommand: string): Promise { - this._requireKeepAlive("Init command"); if (!initCommand) { throw new BoxError("initCommand is required"); } @@ -2539,10 +2536,9 @@ export class Box { } /** - * Delete the init command for a keep-alive box. + * Delete the init command. */ async deleteInitCommand(): Promise { - this._requireKeepAlive("Init command"); await this._request("DELETE", `/v2/box/${this.id}/startup`); } @@ -2728,12 +2724,6 @@ export class Box { if (this._debug) console.log("[Box]", ...args); } - private _requireKeepAlive(feature: string): void { - if (!this.keepAlive) { - throw new BoxError(`${feature} is only available for keep-alive boxes`); - } - } - private async _browserCreateTab(url: string, options?: BrowserTabCreateOptions): Promise { const operationTimeout = options?.timeout === 0 ? 2_147_000_000 : options?.timeout; const resp = await this._request<{ id: string; url?: string; title?: string }>( @@ -3207,6 +3197,10 @@ export class Box { // ==================== Public URLs ==================== + /** + * Expose a port on a public URL. A request to the URL resumes the box if it + * is paused and is held until the port is listening. + */ async getPublicURL( port: number, options?: { bearerToken?: boolean; basicAuth?: boolean }, @@ -3220,8 +3214,8 @@ export class Box { }); } - async listPublicURLs(): Promise<{ publicURLs: PublicURL[] }> { - const data = await this._request<{ previews: PublicURL[] }>( + async listPublicURLs(): Promise<{ publicURLs: PublicURLListItem[] }> { + const data = await this._request<{ previews: PublicURLListItem[] }>( "GET", `/v2/box/${this.id}/preview`, ); @@ -3241,7 +3235,7 @@ export class Box { } /** @deprecated Use `listPublicURLs` instead. */ - async listPreviews(): Promise<{ previews: Preview[] }> { + async listPreviews(): Promise<{ previews: PublicURLListItem[] }> { const data = await this.listPublicURLs(); return { previews: data.publicURLs }; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b1089cbe..10e1fa68 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -74,6 +74,7 @@ export type { ErrorResponse, BoxRunData, PublicURL, + PublicURLListItem, Preview, NetworkPolicy, EphemeralBoxConfig, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f6e79566..f7411472 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -492,7 +492,11 @@ export interface BoxConfig extends BoxConnectionOptions { browser?: boolean; /** Keep the box alive instead of allowing pause-based idle lifecycle. */ keepAlive?: boolean; - /** Optional startup script for keep-alive boxes. */ + /** + * Startup script run once per container start: after create and on every resume. + * A box restored from a snapshot does not inherit it; pass `initCommand` to + * `fromSnapshot` to set one there. + */ initCommand?: string; agent?: AgentConfig; git?: { @@ -1257,6 +1261,22 @@ export interface PublicURL { password?: string; } +/** One entry returned by `listPublicURLs()`. Secrets are only returned at creation time. */ +export interface PublicURLListItem { + /** Preview id, `{boxId}-{port}` */ + id: string; + /** Port number exposed */ + port: number; + /** Public URL to access the exposed port */ + url: string; + /** Unix seconds */ + created_at: number; + /** Whether the URL is protected by basic auth */ + basic_auth: boolean; + /** Whether the URL is protected by a bearer token */ + bearer_token: boolean; +} + /** @deprecated Use `PublicURL` instead. */ export type Preview = PublicURL;