@lizard-build/cli 4.0.45 → 4.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/commands/add.js +2 -7
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/agents.js +21 -2
  4. package/dist/commands/agents.js.map +1 -1
  5. package/dist/commands/domain.js +32 -2
  6. package/dist/commands/domain.js.map +1 -1
  7. package/dist/commands/init.js +15 -0
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/metrics.js +11 -2
  10. package/dist/commands/metrics.js.map +1 -1
  11. package/dist/commands/redeploy.js +1 -1
  12. package/dist/commands/redeploy.js.map +1 -1
  13. package/dist/commands/s3.d.ts +15 -0
  14. package/dist/commands/s3.js +59 -0
  15. package/dist/commands/s3.js.map +1 -1
  16. package/dist/commands/sandbox.js +13 -5
  17. package/dist/commands/sandbox.js.map +1 -1
  18. package/dist/commands/up.js +23 -6
  19. package/dist/commands/up.js.map +1 -1
  20. package/dist/commands/upgrade.js +20 -5
  21. package/dist/commands/upgrade.js.map +1 -1
  22. package/dist/commands/volume.js +38 -4
  23. package/dist/commands/volume.js.map +1 -1
  24. package/dist/commands/workspace.js +4 -7
  25. package/dist/commands/workspace.js.map +1 -1
  26. package/dist/lib/api.js +21 -6
  27. package/dist/lib/api.js.map +1 -1
  28. package/dist/lib/config.d.ts +7 -0
  29. package/dist/lib/config.js +10 -1
  30. package/dist/lib/config.js.map +1 -1
  31. package/dist/lib/default-project.js +7 -2
  32. package/dist/lib/default-project.js.map +1 -1
  33. package/dist/lib/skills-data.generated.js +4 -4
  34. package/dist/lib/skills-data.generated.js.map +1 -1
  35. package/dist/lib/updater.d.ts +1 -1
  36. package/dist/lib/updater.js +1 -1
  37. package/package.json +1 -1
  38. package/skill-data/core/SKILL.md +98 -74
  39. package/skill-data/core/references/deploys.md +41 -30
  40. package/skill-data/core/references/sandboxes.md +43 -18
  41. package/skill-data/core/references/sdk.md +33 -21
  42. package/src/commands/add.ts +2 -6
  43. package/src/commands/agents.ts +20 -4
  44. package/src/commands/domain.ts +36 -2
  45. package/src/commands/init.ts +18 -0
  46. package/src/commands/metrics.ts +11 -2
  47. package/src/commands/redeploy.ts +1 -1
  48. package/src/commands/s3.ts +65 -0
  49. package/src/commands/sandbox.ts +15 -5
  50. package/src/commands/up.ts +20 -6
  51. package/src/commands/upgrade.ts +17 -4
  52. package/src/commands/volume.ts +35 -4
  53. package/src/commands/workspace.ts +4 -9
  54. package/src/lib/api.ts +21 -6
  55. package/src/lib/config.ts +11 -1
  56. package/src/lib/default-project.ts +5 -2
  57. package/src/lib/updater.ts +1 -1
  58. package/test/unit/default-project.test.ts +8 -2
  59. package/test/unit/s3-presign.test.ts +32 -0
  60. package/test/unit/sandbox-transport.test.ts +5 -1
  61. package/test/unit/sandbox.test.ts +1 -0
@@ -21,15 +21,24 @@ The details behind the Sandboxes section of the core guide. Read the part the ta
21
21
  | `-t` | What is inside |
22
22
  |---|---|
23
23
  | `base` (default) | Python 3.11 on Alpine Linux, nothing else |
24
- | `interpreter` | A Python code-interpreter environment |
25
- | `codex` | Coding agents: see [Coding agents](#coding-agents) |
26
- | `desktop` | A graphical desktop with Chromium: see [Desktop](#desktop-computer-use) |
24
+ | `interpreter` | Python 3.11 with numpy, pandas, matplotlib, scipy, requests, httpx and IPython; Node 22 and `lizard` |
25
+ | `codex`, `claude`, `opencode`, `pi`, `prime` | One coding agent each (Codex, Claude Code, OpenCode, pi, Prime Agent) on Node 22, with git, python3, ripgrep, curl, jq and `lizard`: see [Coding agents](#coding-agents) |
26
+ | `desktop` | A graphical desktop with Chromium, plus Codex and `lizard`: see [Desktop](#desktop-computer-use) |
27
27
 
28
- The platform validates the name; `lizard sandbox create --help --json` lists the templates this CLI knows.
28
+ An unknown name fails and lists the region's `availableTemplates`. `--with-token` on `sandbox create` signs in the sandbox's own `lizard` with a new key limited to the sandbox's project, deleted with the sandbox (only useful on templates that ship `lizard`).
29
29
 
30
30
  ## Running commands
31
31
 
32
- `lizard sandbox exec <id> -- <cmd> [args...]` runs the command through `sh -c` in the sandbox, streams stdout and stderr, and exits with the command's exit code. In JSON mode it prints the raw output without the `$ <cmd>` header. Each call is a new process, so `cd` and exported variables don't carry over: chain steps in one call, e.g. `lizard sandbox exec <id> -- sh -c 'cd /workspace && npm test'`, and keep results in files.
32
+ `lizard sandbox exec <id> -- <cmd> [args...]` runs the command in the sandbox, streams stdout and stderr, and exits with the command's exit code. Each argument stays one word: `exec <id> 'cd /workspace && npm test'` fails with `not found`, so put a shell line in `-- sh -c '…'`. Without a terminal it prints the raw output without the `$ <cmd>` header. Each call is a new process that starts in `/root`, so `cd` and exported variables don't carry over: chain steps in one call, e.g. `lizard sandbox exec <id> -- sh -c 'cd /workspace && npm test'`, and keep results in files.
33
+
34
+ A call gives up after 150 seconds (exit `4`, `EXEC_TIMEOUT`; the command may still be running) and keeps at most 512 KB of output. Run longer work in the background and poll a log:
35
+
36
+ ```
37
+ lizard sandbox exec <id> -- sh -c 'cd /workspace && nohup npm run build > build.log 2>&1 &'
38
+ lizard sandbox exec <id> -- tail -n 50 /workspace/build.log
39
+ ```
40
+
41
+ Ports 25, 465, 587 and 2525 are blocked for outbound traffic: sandboxes cannot send mail directly. Lizard's own hosts answer a sandbox only on ports 80 and 443, so the public URLs of managed Postgres and Redis (high ports on those hosts) time out from inside a sandbox; reach them from a deployed service or from the user's machine.
33
42
 
34
43
  ## Files
35
44
 
@@ -41,15 +50,20 @@ lizard sandbox files get <id> <remote> <local>
41
50
  lizard sandbox files rm <id> <path>
42
51
  ```
43
52
 
44
- `/workspace` is the working directory: `ssh` starts there and volumes mount there. `files put` and `files get` keep binary files intact.
53
+ `ssh` starts in `/workspace` and volumes mount there; `exec` starts in `/root`, so use absolute paths. `files put` and `files get` keep binary files intact. `files get` and `files cat` stop at 15 MiB (HTTP 413); `files put` takes about 110 MB.
45
54
 
46
55
  ## Ports
47
56
 
48
- `lizard sandbox expose <id> <port>` publishes a port inside the sandbox on an HTTPS URL; `lizard sandbox unexpose <id> <port>` removes it. The URL carries the sandbox's access token, so treat it like a password. The platform keeps one port for itself and refuses to expose it.
57
+ `lizard sandbox expose <id> <port> --json` publishes a port inside the sandbox on an HTTPS URL, `https://<id>-<port>.sandbox.<region>.onlizard.com/?lizard_token=…`; `lizard sandbox unexpose <id> <port>` removes it. One token opens every port of that sandbox, so treat the URL like a password. The server can listen on `127.0.0.1` or `0.0.0.0`.
58
+
59
+ - A browser opening the URL trades the token for a cookie and is redirected, so the link just works.
60
+ - `curl` gets an empty `302`, and `curl -L` without a cookie jar gets `401`. From a script send the header instead: `curl -H "X-Lizard-Access-Token: <accessToken>" https://<hostname>/` (both come in the `--json` output), or keep cookies: `curl -sL -c jar -b jar '<url>'`.
61
+ - A `POST` with `?lizard_token=` passes straight through, so webhooks work.
62
+ - Port `49983` is the platform's and can't be exposed.
49
63
 
50
64
  ## Lifetime
51
65
 
52
- `lizard sandbox create` sends a five-minute lifetime (`300000` milliseconds) by default. Pass `--timeout 0` to create a sandbox without expiration, or pass an integer up to `2147483647` milliseconds. This is a lifetime, not an idle timer: commands do not reset it. `lizard sandbox timeout <id> <ms>` sets a new lifetime of at least `1000` milliseconds. Pausing a sandbox freezes its remaining lifetime; resuming continues from it (see [private snapshots and pause/resume](#snapshots-and-pauseresume)). The CLI has no sandbox fork command. Use volumes for persistent files.
66
+ `lizard sandbox create` and `lizard sandbox restore` send a five-minute lifetime (`300000` milliseconds) by default. Pass `--timeout 0` to create a sandbox without a deadline (the VM itself still stops after 596 hours), or pass an integer up to `2147483647` milliseconds. This is a lifetime, not an idle timer: commands do not reset it. `lizard sandbox timeout <id> <ms>` sets a new lifetime of at least `1000` milliseconds. Pausing a sandbox freezes its remaining lifetime; resuming continues from it (see [private snapshots and pause/resume](#snapshots-and-pauseresume)). The CLI has no sandbox fork command. Use volumes for persistent files.
53
67
 
54
68
  ## Size and pricing
55
69
 
@@ -61,9 +75,11 @@ lizard sandbox files rm <id> <path>
61
75
  | `medium` (default) | 4 | 8 GiB | 20 GB | $0.0324/h |
62
76
  | `large` | 8 | 16 GiB | 50 GB | $0.0648/h |
63
77
 
64
- Prices are per hour, billed per second while the sandbox runs, flat by size: measured CPU/RAM is not charged, disk is included, and egress is included up to 2 TB per account over a rolling 30 days (past that, the account's sandboxes lose outbound network until the total drops). A paused sandbox costs nothing. Attached volumes bill separately. `--size` cannot be combined with `--snapshot`: a snapshot restores at the size it was captured at (see [private snapshots](#snapshots-and-pauseresume)).
78
+ Prices are per hour, billed per second while the sandbox runs, flat by size: measured CPU/RAM is not charged, and disk and egress are included. A paused sandbox costs nothing. Attached volumes bill separately. `--size` cannot be combined with `--snapshot`: a snapshot restores at the size it was captured at (see [private snapshots](#snapshots-and-pauseresume)).
65
79
 
66
- Sandboxes run as Firecracker microVMs, each with its own kernel. A sandbox that attaches an existing pod-backed volume, or restores an older pod snapshot, runs as a Kubernetes pod instead.
80
+ Sandboxes run as Firecracker microVMs, each with its own kernel. A sandbox that attaches an existing pod-backed volume, or restores an older pod snapshot, runs as a Kubernetes pod instead; a Firecracker start that fails without a volume also falls back to a pod. The create JSON's `runtime` says which.
81
+
82
+ An account runs at most 5 sandboxes at once on a trial and 100 after its first paid invoice (paused ones don't count), with limits on starts per minute, hour and day. Enterprise accounts have a cap per workspace instead (50 by default). Over a limit the API answers 429 `SANDBOX_LIMIT_REACHED` or `SANDBOX_RATE_LIMITED` (with `retryAfterS`).
67
83
 
68
84
  ## Interactive shell
69
85
 
@@ -75,19 +91,26 @@ exit code.
75
91
 
76
92
  ## Coding agents
77
93
 
78
- Run a coding agent inside a sandbox (use `-t codex`) on the user's own credentials:
94
+ Run a coding agent in a sandbox made from its template (`-t codex`, `-t claude`, `-t opencode`, `-t pi`, `-t prime`) on the user's own credentials. Give the sandbox a real lifetime (`--timeout 3600000`): at five minutes it is deleted with the agent mid-task. Another template with Node installs the agent at the first prompt, which is slow; one without Node can't run agents.
79
95
 
80
96
  - `lizard agents` — your account's credentials and which agents can run. Connect once (here or in the
81
97
  dashboard: Account settings › Agents); every sandbox and every agent that accepts it uses it.
82
98
  - `lizard agents login chatgpt` — ChatGPT subscription via device code.
83
99
  `lizard agents add claude` (token from `claude setup-token`), `add anthropic|openai|openrouter|llmgateway|deepseek` for API keys.
84
100
  - `lizard agents use <agent> --credential … --model … --effort …` — per-agent defaults.
85
- - `lizard sandbox prompt <id> --agent codex|claude|pi|opencode|prime "task"` — streams tool calls and
86
- the answer; `-c <conversation>` continues one; `--no-wait` returns at once.
101
+ - `lizard sandbox prompt <id> --agent codex|claude|pi|opencode|prime "task" --json` — one JSON event per
102
+ line: tool calls, then a `response` event whose `text` is the answer, then `finished` or `failed`.
103
+ `-c <conversation>` continues one; `--cwd` (default `/workspace`), `-m <model>`, `--effort
104
+ minimal|low|medium|high|xhigh`. `--no-wait` returns at once, and the CLI has no command to read the
105
+ answer later, so prefer waiting.
87
106
  - `lizard sandbox conversations <id>`, `lizard sandbox interrupt <id> [-c <conversation>]`.
88
107
 
89
108
  Codex and Claude Code use one credential at a time; pi, OpenCode and Prime use every connected one
90
- they accept. Sandboxes never receive a subscription's refresh token, only a short-lived access token.
109
+ they accept. A ChatGPT login reaches the sandbox only as a short-lived access token. API keys and the
110
+ Claude token go in as they are (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`), so any code in the
111
+ sandbox can read them. `lizard agents` needs a full sign-in: a key limited to projects gets 403
112
+ `ACCOUNT_SCOPE_REQUIRED`. A failed agent login is not always the user's credential: report the
113
+ error as it is.
91
114
 
92
115
  Use `sandbox exec` to read a log file written by your process.
93
116
 
@@ -117,7 +140,7 @@ lizard sandbox exec <id> -- lizard-desktop screenshot | grep -E '^[A-Za-z0-9+/=]
117
140
  lizard sandbox exec <id> -- lizard-desktop screenshot --path # writes the PNG to a file in the sandbox, prints its path
118
141
  ```
119
142
 
120
- `lizard-desktop screenshot` prints the PNG as base64 wrapped at 76 columns; the `grep` keeps only those lines (it drops the `$ <cmd>` header `sandbox exec` prints). Use `--path` to save the PNG, then download that path with `lizard sandbox files get <id> <remote-path> screen.png`. `files put` and `files get` preserve binary files.
143
+ `lizard-desktop screenshot` prints the PNG as base64 wrapped at 76 columns; the `grep` keeps only those lines (in a terminal it drops the `$ <cmd>` header `sandbox exec` prints). Use `--path` to save the PNG, then download that path with `lizard sandbox files get <id> <remote-path> screen.png`. `files put` and `files get` preserve binary files.
121
144
 
122
145
  ## Volumes
123
146
 
@@ -126,19 +149,21 @@ Use `lizard volume create <name> --size <gb> --project <project>` for persistent
126
149
  Persistent volumes for sandboxes. A volume's name is its key within the project; every command takes a name or ID.
127
150
 
128
151
  ```
129
- lizard volume create <name> [--size <gb>] [--region <code>] # default 5 GB; region must match the sandbox's
152
+ lizard volume create <name> [--size <gb>] [--region <code>] # default 5 GB; a sandbox with --volume starts in its region
130
153
  lizard volume list --json # name, sizeGb, status, attachedTo
131
154
  lizard volume resize <name> --size <gb> # grow in place
132
155
  lizard volume rm <name> [-y] # refused while attached; delete the sandbox first
133
156
  lizard sandbox create --volume <name> # attach at create time
134
157
  ```
135
158
 
159
+ A volume attaches to one sandbox at a time (409 `Volume is already attached to another sandbox`); a paused sandbox keeps its volume. A workspace holds 200 GB of volumes by default (`volume_quota_exceeded`). For a deployed service's volume, see `lizard volume create <name> --service <svc>` in the core guide.
160
+
136
161
  `volume resize` is online: an attached sandbox keeps running, and its `/workspace` shows the new size right away. A volume grown while detached gets the new size when a sandbox next mounts it. Firecracker volumes (the default) only grow; asking for less fails with `volume_shrink_unsupported`. A Kubernetes volume can also shrink if at least 10% of the new size stays free (`volume_too_full_to_shrink` otherwise). The size limit (`volume_too_large`) and the workspace's volume quota apply as at create. `The volume is not ready` (HTTP 409) right after create means retry in a few seconds.
137
162
 
138
163
  ## Snapshots and pause/resume
139
164
 
140
- - `lizard sandbox snapshot <sandbox-id> --name my-app` saves files and running memory privately. The snapshot is yours: by default it restores into any of your projects and workspaces; `--scope workspace` keeps it to the sandbox's workspace and `--scope project` to its project, and `sandbox snapshot-scope <id> <user|workspace|project>` changes that later. A Firecracker snapshot is ready as soon as the capture ends. A snapshot of a pod sandbox keeps **five** independent copies warm; `--warm 1..10` changes that count. It waits until ready; `--no-wait` returns the queued capture.
141
- - `lizard sandbox restore <snapshot-id> [--project <project>]` starts a new sandbox from a warm copy, in the snapshot's project unless `--project` names another. `--volume <volume>` mounts a volume at `/workspace` in the restored sandbox (Firecracker snapshots of a sandbox that had no volume attached; the volume must be in the snapshot's region). `sandbox create --snapshot <id> --project <project> [--volume <volume>]` also works. Restores stay in the snapshot's region. A Firecracker snapshot restores at its source's size and bills flat at that size; a pod snapshot runs on the machine it was captured on and bills by measured usage. `--size` with `--snapshot` is an error.
165
+ - `lizard sandbox snapshot <sandbox-id> --name my-app` saves files and running memory. You and the members of its project can list, restore and delete it; only you change its scope. By default it restores into any of your projects and workspaces; `--scope workspace` keeps it to the sandbox's workspace and `--scope project` to its project, and `sandbox snapshot-scope <id> <user|workspace|project>` changes that later. A Firecracker snapshot is ready as soon as the capture ends. A snapshot of a pod sandbox keeps **five** independent copies warm; `--warm 1..10` changes that count. It waits until ready; `--no-wait` returns the queued capture.
166
+ - `lizard sandbox restore <snapshot-id> [--project <project>] [--timeout <ms>]` starts a new sandbox from a warm copy (five-minute lifetime by default), in the snapshot's project unless `--project` names another. `--volume <volume>` mounts a volume at `/workspace` in the restored sandbox (Firecracker snapshots of a sandbox that had no volume attached; the volume must be in the snapshot's region). `sandbox create --snapshot <id> --project <project> [--volume <volume>]` also works. Restores stay in the snapshot's region. A Firecracker snapshot restores at its source's size and bills flat at that size; a pod snapshot runs on the machine it was captured on and bills by measured usage. `--size` with `--snapshot` is an error.
142
167
  - `lizard sandbox snapshots --project <project>` lists a project's snapshots; `--mine` lists every snapshot you took. `sandbox snapshot-warm <id> <count>` changes capacity. `sandbox snapshot-rm <id>` deletes the snapshot and free copies, preserving already running sandboxes.
143
168
  - `lizard sandbox pause <id>` snapshots and stops a Firecracker VM (a pod sandbox is checkpointed with CRIU), stops billing, and freezes its remaining lifetime. `sandbox resume <id>` restores the same sandbox ID, files, processes, memory, published ports, and remaining lifetime. Paused sandboxes keep no warm copies; resume includes a cold restore. Both support `--no-wait`.
144
169
  - Disconnect clients and terminals and stop workspace writes before capturing or pausing. On a pod sandbox, active TCP connections and unsupported CRIU process state cause a clear failure. To reuse one snapshot across sandboxes that each keep their own data, snapshot a sandbox with no volume attached and pass `--volume` on each restore; a snapshot taken with a volume attached cannot take a different one.
@@ -22,10 +22,12 @@ Use Lizard SDK when the user's own code creates and drives sandboxes or manages
22
22
 
23
23
  | SDK | Install | Requires |
24
24
  |---|---|---|
25
- | TypeScript | `npm install @lizard-build/sdk` | Node.js 18+ |
25
+ | TypeScript | `npm install @lizard-build/sdk` | Node.js 18.16+ |
26
26
  | Python | `pip install lizard-sdk` | Python 3.10+ |
27
27
 
28
- The SDK reads `LIZARD_API_KEY` and, optionally, `LIZARD_API_URL` (default `https://lizard.build`); `apiKey` / `api_key` and `apiUrl` / `api_url` override them. It does not load `.env` files. Create a key with `lizard keys create <name> --project <project> --json`: a key limited to one project is safest when the code runs somewhere else. The key is shown once.
28
+ This guide matches SDK 0.1.59 or later.
29
+
30
+ The SDK reads `LIZARD_API_KEY` and, optionally, `LIZARD_API_URL` (default `https://lizard.build`); `apiKey` / `api_key` and `apiUrl` / `api_url` override them. It does not load `.env` files. Create a key with `lizard keys create <name> --project <project> --json`: a key limited to one project is safest when the code runs somewhere else. The key is shown once; pass it to the program as `LIZARD_API_KEY`. The `lizard` CLI's own sign-in is not read by the SDK. Static methods (`Sandbox.connect`, `Sandbox.list`, `Sandbox.restore`, `Sandbox.deleteSnapshot`, `CodeSandbox.create`) read `LIZARD_API_KEY` too, not the key given to `new Lizard`: on a server holding several keys pass `apiKey` / `api_key` to them, or use `lizard.connect(id)`.
29
31
 
30
32
  Sandboxes and volumes need a project: `new Lizard({ project })` / `Lizard(project=...)` takes its ID, slug or unique name; use the exact ID when names are ambiguous. Platform APIs work without one.
31
33
 
@@ -34,15 +36,17 @@ Options for `create()` (TypeScript / Python):
34
36
  | Option | Purpose |
35
37
  |---|---|
36
38
  | `size` | `small` (2 vCPU / 4 GB, $0.0162/h), `medium` (4 vCPU / 8 GB, $0.0324/h, default), `large` (8 vCPU / 16 GB, $0.0648/h) |
37
- | `timeoutMs` / `timeout_ms` | Lifetime; default 300,000 ms (5 minutes); `0` means no limit |
38
- | `envs` | Environment variables for every command in the sandbox |
39
- | `metadata` | Your own string labels, returned by `getInfo()` / `get_info()` |
39
+ | `timeoutMs` / `timeout_ms` | Lifetime; default 300,000 ms (5 minutes); `0` means no deadline (the VM still stops after 596 hours) |
40
+ | `envs` | Environment variables for every command in the sandbox (up to 256) |
41
+ | `metadata` | Your own string labels, returned by `getInfo()` / `get_info()` (up to 64) |
40
42
  | `region` | Region ID; omit to use the platform default |
41
43
  | `volumeName` / `volume_name`, `volumeId` / `volume_id` | Attach a persistent volume at `/workspace` |
42
44
  | `snapshotId` / `snapshot_id` | Start from a saved snapshot |
43
- | `lizardToken` / `lizard_token` | A Lizard key for tools inside the sandbox; any code there can read it |
45
+ | `lizardToken` / `lizard_token` | One of your own keys: the sandbox's `lizard` gets a new key limited to its project, deleted with it. Someone else's key is skipped without an error |
46
+
47
+ Templates: `base` (default), `interpreter` (Python with data libraries, plus Node 22), `codex`, `claude`, `opencode`, `pi`, `prime` (one coding agent each), `desktop`. The SDK and CLI give a sandbox 5 minutes by default; the raw API and the dashboard give none. Commands do not reset the deadline. Pricing is flat by size, billed per second while the sandbox runs; a sandbox restored from a snapshot runs at the source's size and is billed at that size; volumes bill separately.
44
48
 
45
- Templates: `base` (default), `interpreter` (Python and common data libraries), `codex`, `desktop`. The SDK and CLI give a sandbox 5 minutes by default; the raw API and the dashboard give none. Commands do not reset the deadline. Pricing is flat by size, billed per second while the sandbox runs; a sandbox started from a snapshot is billed by measured usage, and volumes bill separately.
49
+ An account runs at most 5 sandboxes at once on a trial and 100 after its first paid invoice, with limits on starts per minute, hour and day: over a limit, a `LizardError` with status 429 and `code` `SANDBOX_LIMIT_REACHED` or `SANDBOX_RATE_LIMITED`. More than 32 commands at once in one sandbox gives `EXEC_LIMIT_REACHED`.
46
50
 
47
51
  ## Quickstart
48
52
 
@@ -80,14 +84,15 @@ Always kill the sandbox when done: `try/finally` in TypeScript, the `with` block
80
84
  `process.exec(cmd)` / `process.exec_(cmd)` returns stdout, stderr and the exit code. A nonzero exit code is a result to check; request failures throw or raise.
81
85
 
82
86
  - `onStdout` / `onStderr` (`on_stdout` / `on_stderr`) stream output while the call still waits for the end.
83
- - `timeoutMs` / `timeout_ms` bounds one command: 1–600 seconds.
87
+ - `timeoutMs` / `timeout_ms` bounds one command: 1–600 seconds, **60 seconds by default**. Past it the SDK gives up with a `DOMException` named `TimeoutError` (TypeScript) or `httpx.ReadTimeout` (Python), not a `LizardError`. Pass it for anything slower, such as `pip install`; run longer jobs with `nohup … &` and poll a log.
88
+ - Output keeps 512 KiB per stream, then `truncated` is set.
84
89
  - `envs`, `workdir` and `user` (e.g. `user: 'user'`, the template's unprivileged account) apply to that command only.
85
90
  - `onPid` / `on_pid` gives the pid before any output; `process.list()` lists commands and `process.kill(pid, 'SIGKILL')` signals the whole process tree.
86
91
  - An argv array runs the binary without a shell, so text from a model or a user cannot run commands.
87
92
 
88
93
  ## Files
89
94
 
90
- `fs.write`, `fs.read`, `fs.readBytes` / `fs.read_bytes`, `fs.list`, `fs.stat`, `fs.makeDir` / `fs.make_dir`, `fs.move`, `fs.remove`. Use absolute paths. Writes take text or bytes; bytes are stored exactly, so read binary files back with `readBytes()` / `read_bytes()`. A missing path raises `NotFoundError`. `list()` and `stat()` entries carry `type`, `size`, `mode`, `permissions`, `owner`, `group` and `modTime`. File watching is not available yet.
95
+ `fs.write`, `fs.read`, `fs.readBytes` / `fs.read_bytes`, `fs.list`, `fs.stat`, `fs.makeDir` / `fs.make_dir`, `fs.move`, `fs.remove`. Use absolute paths. Writes take text or bytes; bytes are stored exactly, so read binary files back with `readBytes()` / `read_bytes()`. A missing path raises `NotFoundError`. `list()` and `stat()` entries carry `type`, `size`, `mode`, `permissions`, `owner`, `group` and `modTime` (`mod_time` in Python). Reads stop at 15 MB (HTTP 413). File watching is not available yet.
91
96
 
92
97
  ## Stateful code (CodeSandbox)
93
98
 
@@ -99,8 +104,9 @@ import { CodeSandbox } from '@lizard-build/sdk'
99
104
  const sandbox = await CodeSandbox.create({ projectId })
100
105
  try {
101
106
  await sandbox.runCode('import pandas as pd\ndf = pd.DataFrame({"x": [1, 2, 3]})')
102
- const run = await sandbox.runCode('df.x.sum()')
103
- console.log(run.results[0].data) // "6"
107
+ const run = await sandbox.runCode('int(df.x.sum())')
108
+ const out = run.results[0]
109
+ if (out?.type === 'result') console.log(out.data) // "6"
104
110
  } finally {
105
111
  await sandbox.kill()
106
112
  }
@@ -111,10 +117,10 @@ from lizard import CodeSandbox
111
117
 
112
118
  with CodeSandbox.create(project_id=project_id) as sandbox:
113
119
  sandbox.run_code('import pandas as pd\ndf = pd.DataFrame({"x": [1, 2, 3]})')
114
- print(sandbox.run_code("df.x.sum()").results[0].data) # "6"
120
+ print(sandbox.run_code("int(df.x.sum())").results[0].data) # "6"
115
121
  ```
116
122
 
117
- An `Execution` has `stdout`, `stderr`, `results` (the last expression as text, charts and images as base64 PNG, HTML), `error` (`name`, `message`, `traceback`) and `executionCount`. A code error is a result, not an exception. `createContext()` / `create_context()` starts a separate kernel with its own state; `restartContext` clears one. `timeoutMs` (default 60 s) bounds one call: the kernel is interrupted, keeps its state, and `error.name` is `TimeoutError`. `bash` and `javascript` run as fresh processes with no state; `javascript` needs `node`, which `interpreter` does not ship.
123
+ An `Execution` has `stdout`, `stderr`, `results` (the last expression as text, charts and images as base64 PNG, HTML), `error` (`name`, `message`, `traceback`) and `executionCount`. A code error is a result, not an exception. `createContext()` / `create_context()` starts a separate kernel with its own state; `restartContext` clears one. `timeoutMs` (default 60 s) bounds one call: the kernel is interrupted, keeps its state, and `error.name` is `TimeoutError`. `bash` and `javascript` run as fresh processes with no state; `javascript` uses the template's Node 22. With numpy 2, a bare `df.x.sum()` displays as `np.int64(6)`: convert to `int` for plain output.
118
124
 
119
125
  ## Ports
120
126
 
@@ -148,7 +154,7 @@ lizard.volumes.get_or_create("agent-files", size_gb=10)
148
154
  sandbox = lizard.create("base", volume_name="agent-files")
149
155
  ```
150
156
 
151
- Names are unique within a project: `getOrCreate` returns the existing volume, `create` raises `ConflictError`. New volumes are 1–50 GB (default 5). `volumes.list()`, `volumes.get(nameOrId)` and `volumes.delete(nameOrId)` (deletes the files too). To grow a volume, use `lizard volume resize` (see the sandboxes reference).
157
+ Names are unique within a project: `getOrCreate` returns the existing volume, `create` raises `ConflictError`. New volumes are 1–50 GB (default 5). `volumes.list()`, `volumes.get(nameOrId)` and `volumes.delete(nameOrId)` (deletes the files too). Grow one with `lizard.volumes.resize('agent-files', 20)` / `lizard.volumes.resize("agent-files", 20)`; volumes only grow.
152
158
 
153
159
  ## Lifecycle, pause, snapshots and fork
154
160
 
@@ -162,7 +168,7 @@ Names are unique within a project: `getOrCreate` returns the existing volume, `c
162
168
  | Pause and resume (memory and processes kept, billing stops) | `pause()`, `resume()`, `waitForStatus(s)` | `pause()`, `resume()`, `wait_for_status(s)` |
163
169
  | Snapshot (about 2 s, the source keeps running) | `snapshot(name)` | `snapshot(name)` |
164
170
  | Start a sandbox from a snapshot (about 0.4 s) | `Sandbox.restore(id)` | `Sandbox.restore(id)` |
165
- | Live copies of a running sandbox (1–10) | `fork({ count })` | `fork(count=...)` |
171
+ | Live copies of a running sandbox (each counts toward the running limit) | `fork({ count })` | `fork(count=...)` |
166
172
 
167
173
  A snapshot belongs to the user who took it and restores into any project they can use, unless its scope is `workspace` or `project` (`snapshot(name, { scope })`, `Sandbox.setSnapshotScope(id, scope)`). To start many sandboxes that each keep their own files from one snapshot, take it without a volume and pass `volumeId` to each `restore`. A sandbox with a volume cannot be forked. `fork()` returns one entry per copy, with `sandbox` or `error` (for example the account's sandbox limit). Delete snapshots with `Sandbox.deleteSnapshot(id)`; sandboxes already restored keep running.
168
174
 
@@ -172,24 +178,30 @@ The `desktop` template has a graphical desktop with Chromium on `DISPLAY=:1`. `s
172
178
 
173
179
  ## Sandboxes for your own users
174
180
 
175
- Give each of your users a workspace, a project and a key scoped to that workspace. Run this on your trusted server:
181
+ Each sandbox is its own microVM, so running every customer's code in a sandbox of its own already keeps customers apart: one project, one key limited to it, a sandbox per customer or per task tagged with `metadata` (`{ customerId }`), killed when done. That is the default.
182
+
183
+ Add a workspace and a key per customer only when customers get Lizard access of their own (their code calls Lizard, or you hand them a key). Creating workspaces needs an unscoped key; a scoped key gets 403. Run this on your trusted server:
176
184
 
177
185
  ```ts
178
186
  const lizard = new Lizard({})
179
187
  const workspace = await lizard.workspaces.create({ name: 'user-123' })
180
- await lizard.projects.create({ workspaceId: workspace.id, name: 'default' })
188
+ const project = await lizard.projects.create({ workspaceId: workspace.id, name: 'default' })
181
189
  const key = await lizard.apiKeys.create({ name: 'user-123', workspaces: [workspace.id] })
182
- // Store key.key now: the API returns it only once.
190
+ // Store key.key and project.id now: the API returns the key only once.
191
+ const forUser = new Lizard({ apiKey: key.key, project: project.id })
183
192
  ```
184
193
 
185
194
  ```python
186
195
  lizard = Lizard()
187
196
  workspace = lizard.workspaces.create(name="user-123")
188
- lizard.projects.create(workspace_id=workspace.id, name="default")
197
+ project = lizard.projects.create(workspace_id=workspace.id, name="default")
189
198
  key = lizard.api_keys.create(name="user-123", workspaces=[workspace.id])
199
+ for_user = Lizard(api_key=key.key, project=project.id)
190
200
  ```
191
201
 
192
- A key without scopes reaches everything the account can; `workspaces` or `projects` limit it, and a scoped key cannot create a broader one. Scoped keys cannot see account billing (HTTP 403 `ACCOUNT_SCOPE_REQUIRED`). `workspaces.delete()` needs an empty workspace unless you pass `force`.
202
+ Keep `project.id`: every user's project is named `default`, so the name is ambiguous for your own key. Every user workspace bills to your account and counts toward your sandbox limits. A key without scopes reaches everything the account can; `workspaces` or `projects` limit it, and a scoped key cannot create a broader one. Scoped keys cannot see account billing (HTTP 403 `ACCOUNT_SCOPE_REQUIRED`).
203
+
204
+ `workspaces.delete()` needs an empty workspace unless you pass `force`, and `force` removes apps, addons and projects but leaves sandboxes running and volumes and snapshots in place: kill the workspace's sandboxes and delete its volumes and snapshots first.
193
205
 
194
206
  ## Platform APIs
195
207
 
@@ -204,7 +216,7 @@ The same client manages the rest of Lizard: `projects`, `services` (deploy, insp
204
216
  | Error | Meaning |
205
217
  |---|---|
206
218
  | `PaymentRequiredError` | HTTP 402: the account must start Pro, start it now, or pay an invoice. Show `message` as is and open `url`; retry after the user pays |
207
- | `AuthenticationError` | HTTP 401 or 403: bad key or no access |
219
+ | `AuthenticationError` | HTTP 401 or 403: bad key, no access, or a refusal such as a volume quota, a snapshot's scope or `ACCOUNT_SCOPE_REQUIRED`: check `code` |
208
220
  | `NotFoundError` | HTTP 404 |
209
221
  | `ConflictError` | HTTP 409, such as a volume name in use |
210
222
  | `TimeoutError` | HTTP 408 or 504 |
@@ -273,7 +273,6 @@ async function runAdd(input: AddInput): Promise<void> {
273
273
  if (opts.variables?.length) {
274
274
  info(chalk.yellow("Warning: --variables is ignored for managed addons"));
275
275
  }
276
- const isSingle = databases.length === 1;
277
276
  for (const db of databases) {
278
277
  const cat = CATALOG.find((c) => c.name === db)!;
279
278
  info(`Adding ${chalk.cyan(cat.label)}...`);
@@ -303,11 +302,8 @@ async function runAdd(input: AddInput): Promise<void> {
303
302
  }
304
303
  }
305
304
 
306
- if (isSingle) {
307
- try {
308
- updateProjectLink({ serviceId: addon.id, serviceName: addon.name });
309
- } catch {}
310
- }
305
+ // The folder keeps its service link: linking it to the addon sent the next
306
+ // `lizard up` / `secrets set` / `logs --build` to a database ("App not found").
311
307
  }
312
308
  return;
313
309
  }
@@ -3,7 +3,7 @@ import ora from "ora";
3
3
  import open from "open";
4
4
  import { Command } from "commander";
5
5
  import * as p from "@clack/prompts";
6
- import { api } from "../lib/api.js";
6
+ import { api, APIError } from "../lib/api.js";
7
7
  import { success, info, isJSONMode, printJSON, table, timeAgo } from "../lib/format.js";
8
8
 
9
9
  // Built-in coding agents (platform: server/src/routes/agents.ts), Boat-style.
@@ -185,6 +185,24 @@ function render(e: AgentEvent, verbose: boolean) {
185
185
  }
186
186
  }
187
187
 
188
+ /**
189
+ * One page of a conversation's events. The agent keeps working in the sandbox while we
190
+ * watch, so a dropped connection or a 5xx must not end the watch: retry for about two
191
+ * minutes before giving up.
192
+ */
193
+ async function pollEvents(id: string, conversationId: string, after: number) {
194
+ const path = `/api/sandboxes/${id}/events?after=${after}&conversationId=${conversationId}&limit=500`;
195
+ for (let attempt = 1; ; attempt++) {
196
+ try {
197
+ return await api.get<{ events: AgentEvent[]; next: number }>(path);
198
+ } catch (e) {
199
+ const transient = !(e instanceof APIError) || e.status === 429 || e.status >= 500;
200
+ if (!transient || attempt >= 24) throw e;
201
+ await sleep(Math.min(1000 * attempt, 5000));
202
+ }
203
+ }
204
+ }
205
+
188
206
  export function registerSandboxAgentCommands(sb: Command) {
189
207
  sb.command("prompt")
190
208
  .argument("<id>", "Sandbox ID")
@@ -223,9 +241,7 @@ Examples:
223
241
  });
224
242
  let after = 0;
225
243
  for (;;) {
226
- const r = await api.get<{ events: AgentEvent[]; next: number }>(
227
- `/api/sandboxes/${id}/events?after=${after}&conversationId=${started.conversationId}&limit=500`,
228
- );
244
+ const r = await pollEvents(id, started.conversationId, after);
229
245
  after = r.next ?? after;
230
246
  for (const e of r.events) {
231
247
  if (e.promptId !== started.promptId) continue;
@@ -4,7 +4,7 @@ import { Command } from "commander";
4
4
  import { api } from "../lib/api.js";
5
5
  import { resolveProjectId } from "../lib/config.js";
6
6
  import { getActiveService } from "../lib/resolve.js";
7
- import { success, isJSONMode, printJSON, table, isTTY } from "../lib/format.js";
7
+ import { success, isJSONMode, printJSON, table, isTTY, info } from "../lib/format.js";
8
8
 
9
9
  interface DomainResponse {
10
10
  ok?: boolean;
@@ -92,7 +92,14 @@ export function registerDomain(program: Command) {
92
92
  return;
93
93
  }
94
94
 
95
- // Attach custom hostname
95
+ // Attach custom hostname. Anything without a dot is a typo or a missing subcommand
96
+ // (`lizard domain list` once attached a domain literally named "list").
97
+ if (!/^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/i.test(hostname)) {
98
+ throw new Error(
99
+ `"${hostname}" is not a domain name. Attach a full hostname such as app.example.com; ` +
100
+ "`lizard domain list` shows the service's domains.",
101
+ );
102
+ }
96
103
  const result = await api
97
104
  .post<DomainResponse>(`/api/apps/${service.id}/domains`, {
98
105
  hostname,
@@ -250,6 +257,33 @@ export function registerDomain(program: Command) {
250
257
  }
251
258
  });
252
259
 
260
+ dom
261
+ .command("list")
262
+ .alias("ls")
263
+ .description("List the service's domains, and custom domains still waiting for DNS verification")
264
+ .action(async (_opts, sub) => {
265
+ const opts = sub.optsWithGlobals();
266
+ const projectId = await resolveProjectId(opts.project);
267
+ const service = await getActiveService(opts.service, projectId);
268
+ const r = await api.get<{
269
+ domains?: string[]; generatedDomain?: string | null; cnameTarget?: string;
270
+ pending?: { hostname: string; txtRecord: string; txtValue: string; cnameTarget: string }[];
271
+ }>(`/api/apps/${service.id}/domains`);
272
+ if (isJSONMode()) return printJSON({ service: service.name, ...r });
273
+ const active = r.domains ?? [];
274
+ if (!active.length && !(r.pending ?? []).length) {
275
+ info(`${service.name} has no domain yet. Generate one: lizard domain --service ${service.name}`);
276
+ return;
277
+ }
278
+ for (const d of active) console.log(`${chalk.green("●")} https://${d}`);
279
+ for (const p of r.pending ?? []) {
280
+ console.log(`${chalk.yellow("○")} ${p.hostname} ${chalk.dim("(waiting for DNS)")}`);
281
+ console.log(chalk.dim(` TXT ${p.txtRecord} ${p.txtValue}`));
282
+ console.log(chalk.dim(` CNAME ${p.hostname} ${p.cnameTarget}`));
283
+ console.log(chalk.dim(` then: lizard domain verify ${p.hostname} --service ${service.name}`));
284
+ }
285
+ });
286
+
253
287
  dom
254
288
  .command("verify")
255
289
  .argument("<hostname>", "Custom domain to verify")
@@ -83,6 +83,24 @@ export async function ensureLinked(opts: {
83
83
  }
84
84
  }
85
85
 
86
+ // A named project may live in any workspace. Look it up everywhere first: the flow
87
+ // below searches one workspace only and creates the project when that workspace has
88
+ // no match, which made a duplicate of a project in another workspace (and, for a key
89
+ // limited to that project, failed with 404 "Project not found").
90
+ if (opts.projectName && !opts.workspaceFlag) {
91
+ const lower = opts.projectName.toLowerCase();
92
+ const all = await api.get<Array<Project & { workspaceId?: string; workspaceName?: string }>>("/api/projects");
93
+ const matches = all.filter(
94
+ (pr) => pr.id.toLowerCase() === lower || pr.slug?.toLowerCase() === lower || pr.name.toLowerCase() === lower,
95
+ );
96
+ if (matches.length === 1) {
97
+ const m = matches[0];
98
+ const link: ProjectLink = { projectId: m.id, projectName: m.name, workspaceId: m.workspaceId, workspaceName: m.workspaceName };
99
+ setProjectLink(link);
100
+ return link;
101
+ }
102
+ }
103
+
86
104
  // 1. Workspace
87
105
  const workspaces = await fetchWorkspaces();
88
106
  const workspace = await pickWorkspace({
@@ -2,7 +2,7 @@ import chalk from "chalk";
2
2
  import { Command } from "commander";
3
3
  import { api, withScope, withQuery, APIError, type ResourceScope } from "../lib/api.js";
4
4
  import { getProjectLink } from "../lib/config.js";
5
- import { resolveProjectScope, getActiveServiceWithKind } from "../lib/resolve.js";
5
+ import { resolveProjectScope, getActiveServiceWithKind, lookupProjectWorkspace } from "../lib/resolve.js";
6
6
  import { info, fail, isJSONMode, printJSON, table, timeAgo } from "../lib/format.js";
7
7
 
8
8
  // Shapes returned by /api/apps/:id/metrics and /api/projects/:id/metrics.
@@ -584,6 +584,11 @@ function fmtPeriodDate(ms: number): string {
584
584
  }
585
585
 
586
586
  async function showCost(projectId: string, scope: ResourceScope) {
587
+ // Scopes for other commands skip the workspace lookup (their routes ignore it), so an
588
+ // unlinked folder or --project arrives here without one: fetch it, cost needs it.
589
+ if (!scope.workspaceId) {
590
+ scope = { ...scope, workspaceId: (await lookupProjectWorkspace(projectId))?.workspaceId ?? null };
591
+ }
587
592
  if (!scope.workspaceId) {
588
593
  fail("Could not resolve the workspace for this project. Run `lizard link` first.");
589
594
  }
@@ -599,7 +604,11 @@ async function showCost(projectId: string, scope: ResourceScope) {
599
604
  ]);
600
605
  } catch (e) {
601
606
  if (e instanceof APIError && e.status === 403) {
602
- fail("Billing is only visible to the workspace owner.", 2);
607
+ // A key limited to some projects can't read the workspace's billing at all; only
608
+ // a plain 403 means "not the owner".
609
+ fail(/API key/i.test(e.message)
610
+ ? `${e.message} Costs need a key that covers the whole workspace, or a full sign-in (lizard login).`
611
+ : "Billing is only visible to the workspace owner.", 2);
603
612
  }
604
613
  throw e;
605
614
  }
@@ -130,7 +130,7 @@ export function registerRedeploy(program: Command) {
130
130
  process.stdout.write(data + "\n");
131
131
  }
132
132
  return true;
133
- });
133
+ }, { reconnect: true }); // a dropped connection (ECONNRESET) must not end the wait
134
134
  }
135
135
 
136
136
  if (opts.wait) {
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { createHash, createHmac } from "node:crypto";
3
4
  import chalk from "chalk";
4
5
  import { Command } from "commander";
5
6
  import { api, getBaseURL, withScope, APIError, type ResourceScope } from "../lib/api.js";
@@ -12,6 +13,41 @@ interface AddonLite {
12
13
  name: string;
13
14
  type?: string;
14
15
  status?: string;
16
+ connection?: { endpoint?: string; accessKeyId?: string; secretKey?: string; region?: string } | null;
17
+ }
18
+
19
+ const rfc3986 = (v: string) => encodeURIComponent(v).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
20
+ const sha256hex = (v: string) => createHash("sha256").update(v).digest("hex");
21
+ const hmac = (key: Buffer | string, v: string) => createHmac("sha256", key).update(v).digest();
22
+
23
+ /**
24
+ * A SigV4 presigned GET for one object (path style, the way the S3 gateway expects it).
25
+ * The link works for `expiresIn` seconds without any credentials, so it is safe to hand
26
+ * to someone who must not get the addon's keys.
27
+ */
28
+ export function presignGet(o: {
29
+ endpoint: string; accessKeyId: string; secretKey: string; region: string;
30
+ bucket: string; key: string; expiresIn: number; now?: Date;
31
+ }): string {
32
+ const base = new URL(o.endpoint);
33
+ const segments = [...base.pathname.split("/").filter(Boolean), o.bucket, ...o.key.split("/")];
34
+ const canonicalUri = "/" + segments.map(rfc3986).join("/");
35
+ const iso = (o.now ?? new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
36
+ const date = iso.slice(0, 8);
37
+ const scope = `${date}/${o.region}/s3/aws4_request`;
38
+ const query: Record<string, string> = {
39
+ "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
40
+ "X-Amz-Credential": `${o.accessKeyId}/${scope}`,
41
+ "X-Amz-Date": iso,
42
+ "X-Amz-Expires": String(o.expiresIn),
43
+ "X-Amz-SignedHeaders": "host",
44
+ };
45
+ const canonicalQuery = Object.keys(query).sort().map((k) => `${rfc3986(k)}=${rfc3986(query[k])}`).join("&");
46
+ const canonicalRequest = ["GET", canonicalUri, canonicalQuery, `host:${base.host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n");
47
+ const toSign = ["AWS4-HMAC-SHA256", iso, scope, sha256hex(canonicalRequest)].join("\n");
48
+ const key = hmac(hmac(hmac(hmac("AWS4" + o.secretKey, date), o.region), "s3"), "aws4_request");
49
+ const signature = createHmac("sha256", key).update(toSign).digest("hex");
50
+ return `${base.protocol}//${base.host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
15
51
  }
16
52
 
17
53
  interface ServicesResponse {
@@ -177,6 +213,35 @@ export function registerS3(program: Command) {
177
213
  }
178
214
  });
179
215
 
216
+ s3.command("url")
217
+ .description("Print a temporary download link for an object (works without credentials until it expires)")
218
+ .argument("<key>", "Object key, e.g. report.txt")
219
+ .option("--addon <name>", "S3 addon name or ID (default: the project's only S3 addon)")
220
+ .option("--bucket <name>", "Bucket", "default")
221
+ .option("--expires <seconds>", "How long the link works, in seconds (max 604800 = 7 days)", "3600")
222
+ .option("-p, --project <id>", "Project name, slug, or ID")
223
+ .action(async (key: string, opts) => {
224
+ const expiresIn = Number(opts.expires);
225
+ if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > 604800) {
226
+ throw new Error(`--expires must be a whole number of seconds from 1 to 604800 (got ${opts.expires}).`);
227
+ }
228
+ const { projectId, scope } = await resolveProjectScope(opts.project);
229
+ const addon = await resolveS3AddonId(projectId, scope, opts.addon);
230
+ const data = await api.get<ServicesResponse>(withScope(`/api/projects/${projectId}/services`, scope));
231
+ const c = (data.addons ?? []).find((a) => a.id === addon.id)?.connection;
232
+ if (!c?.endpoint || !c.accessKeyId || !c.secretKey) {
233
+ throw new Error(`S3 addon ${addon.name} has no credentials yet; wait until it is running (lizard ps).`);
234
+ }
235
+ const url = presignGet({
236
+ endpoint: c.endpoint, accessKeyId: c.accessKeyId, secretKey: c.secretKey, region: c.region || "us-east-1",
237
+ bucket: opts.bucket, key, expiresIn,
238
+ });
239
+ const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
240
+ if (isJSONMode()) return printJSON({ url, key, bucket: opts.bucket, expiresIn, expiresAt });
241
+ console.log(url);
242
+ info(chalk.dim(`Works until ${expiresAt}. Anyone with the link can download ${key}.`));
243
+ });
244
+
180
245
  s3.command("list")
181
246
  .alias("ls")
182
247
  .description("List objects in an S3 addon bucket")