Skip to content
Merged
22 changes: 22 additions & 0 deletions .changeset/init-commands-on-every-box.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ box create --no-repl \
| `--name <name>` | Human-readable name | |
| `--size <size>` | Resource size | `small` |
| `--keep-alive` | Keep the box running instead of pausing when idle | |
| `--init-command <cmd>` | Startup script, for keep-alive boxes | |
| `--init-command <cmd>` | Startup script, run once each time the box starts | |
| `--browser` | Provision a headless Chromium | |
| `--clone-repo <repo>` | Clone this repository after creating the box | |
| `--agent-model <model>` | Agent model identifier | |
Expand Down
20 changes: 11 additions & 9 deletions packages/cli/src/__tests__/commands/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ program
.option("--name <name>", "Human-readable name for the box")
.option("--size <size>", "Resource size (small, medium, large)")
.option("--keep-alive", "Keep the box running instead of pausing when idle")
.option("--init-command <command>", "Startup script, for keep-alive boxes")
.option("--init-command <command>", "Startup script, run once each time the box starts")
.option("--browser", "Provision a headless Chromium in the box")
.option("--clone-repo <repo>", "Clone this repository into the box after creating it")
.option(
Expand Down
5 changes: 0 additions & 5 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,6 @@ export async function createCommand(flags: CreateFlags): Promise<void> {
);
}

// 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...");
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/public-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/repl/commands/public-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export async function* handlePublicUrl(box: Box, args: string): AsyncGenerator<B
// when the command that launched it finishes, and the URL then 502s.
yield {
type: "log",
message: "Start the server detached — ( npm run dev & ) — or it stops with the command.",
message: "Start the server detached, ( npm run dev & ), or it stops with the command.",
};
}
}
Expand Down
3 changes: 3 additions & 0 deletions packages/python-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to `upstash-box` (Python) are documented here.

## Unreleased

- Init commands now work on every box, not only keep-alive ones. `create(init_command=...)`
no longer requires `keep_alive=True`, and `get_init_command()`, `set_init_command()`
and `delete_init_command()` no longer raise on a non-keep-alive box.
- Add Claude Opus 5.5 model constants for Claude Code, OpenRouter, Vercel AI
Gateway, OpenCode/Zen, and Cursor, mirroring `@upstash/box`.
- Fix Vercel AI Gateway Grok identifiers: the gateway moved xAI models from the
Expand Down
2 changes: 2 additions & 0 deletions packages/python-sdk/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ statics `create`, `from_snapshot`, `get_by_name`, `delete_boxes`,
| Browser `schema` = Pydantic model or raw dict (Python) vs Zod (JS) | Same `ResponseSchema` contract as `agent.run`; raw dicts skip client-side validation. |
| `screenshot` `type: "png"\|"base64"` (JS) → `encoding: "bytes"\|"base64"` (Python) | Python returns native `bytes`; `encoding` matches `files.read` naming. |
| `browser` on `from_snapshot` (Python) | Python's shared create-body builder forwards `browser=True` on `from_snapshot`; JS `fromSnapshot` currently omits it (JS gap). |
| `PublicURLListItem` type (JS) | JS types `listPublicURLs()` as a distinct list item (`id`, `created_at`, `basic_auth`, `bearer_token`, no secrets); Python reuses `PublicURL` for create and list, so the list-only fields arrive through `extra="allow"` untyped (Python gap). |

## Behavioral quirks mirrored exactly

Expand All @@ -108,6 +109,7 @@ statics `create`, `from_snapshot`, `get_by_name`, `delete_boxes`,
- `Run.cancel()`: swallows endpoint errors, always sets `cancelled`.
- `files.download` destination: `./{basename}` | `./workspace` | `./{basename(cwd)}`.
- 3-mode run request: file paths → multipart, base64 objects → JSON `files`, else plain JSON.
- Init commands on any box (not just keep-alive): both SDKs dropped the keep-alive guard together.

## Test mapping (JS unit file → Python)

Expand Down
11 changes: 8 additions & 3 deletions packages/python-sdk/tests/_async/test_box_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,14 @@ async def test_create_requires_api_key(monkeypatch):
await AsyncBox.create(base_url=TEST_BASE_URL)


async def test_init_command_requires_keep_alive():
with pytest.raises(BoxError, match="init_command requires keep_alive"):
await AsyncBox.create(init_command="x", **_opts())
@respx.mock
async def test_create_init_command_without_keep_alive():
route = respx.post(CREATE_URL).mock(return_value=httpx.Response(200, json=TEST_BOX_DATA))
box = await AsyncBox.create(init_command="npm run dev", **_opts())
body = last_json_body(route)
assert body["init_command"] == "npm run dev"
assert "keep_alive" not in body
await box.aclose()


@respx.mock
Expand Down
30 changes: 23 additions & 7 deletions packages/python-sdk/tests/_async/test_box_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,27 +147,43 @@ async def test_keep_alive_box_cannot_pause():


@respx.mock
async def test_init_command_requires_keep_alive():
box = await make_async_box(respx.mock)
with pytest.raises(BoxError, match="only available for keep-alive"):
await box.get_init_command()
async def test_init_command_crud():
box = await make_async_box(respx.mock, {"keep_alive": True})
respx.get(f"{BASE}/startup").mock(
return_value=httpx.Response(200, json={"init_command": "npm run dev"})
)
respx.put(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={}))
respx.delete(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={}))
assert await box.get_init_command() == "npm run dev"
await box.set_init_command("npm start")
await box.delete_init_command()
await box.aclose()


@respx.mock
async def test_init_command_crud():
box = await make_async_box(respx.mock, {"keep_alive": True})
async def test_init_command_crud_without_keep_alive():
box = await make_async_box(respx.mock)
respx.get(f"{BASE}/startup").mock(
return_value=httpx.Response(200, json={"init_command": "npm run dev"})
)
respx.put(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={}))
put = respx.put(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={}))
respx.delete(f"{BASE}/startup").mock(return_value=httpx.Response(200, json={}))
assert box.keep_alive is False
assert await box.get_init_command() == "npm run dev"
await box.set_init_command("npm start")
assert last_json_body(put) == {"init_command": "npm start"}
await box.delete_init_command()
await box.aclose()


@respx.mock
async def test_set_init_command_requires_a_command():
box = await make_async_box(respx.mock)
with pytest.raises(BoxError, match="init_command is required"):
await box.set_init_command("")
await box.aclose()


# ---------- logs / list_runs ----------


Expand Down
9 changes: 0 additions & 9 deletions packages/python-sdk/upstash_box/_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -882,10 +882,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))
Expand Down Expand Up @@ -1521,20 +1517,17 @@ 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(
"PUT", f"/v2/box/{self.id}/startup", body={"init_command": init_command}
)

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:
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 0 additions & 9 deletions packages/python-sdk/upstash_box/_sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 8 additions & 7 deletions packages/sdk/src/__tests__/box-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
42 changes: 31 additions & 11 deletions packages/sdk/src/__tests__/box-instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

Expand Down
Loading
Loading