@lizard-build/cli 4.0.45 → 4.0.46
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.
- package/dist/commands/add.js +2 -7
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/agents.js +21 -2
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/domain.js +32 -2
- package/dist/commands/domain.js.map +1 -1
- package/dist/commands/init.js +15 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/metrics.js +11 -2
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/s3.d.ts +15 -0
- package/dist/commands/s3.js +59 -0
- package/dist/commands/s3.js.map +1 -1
- package/dist/commands/sandbox.js +13 -5
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/up.js +23 -6
- package/dist/commands/up.js.map +1 -1
- package/dist/commands/upgrade.js +17 -3
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/commands/volume.js +38 -4
- package/dist/commands/volume.js.map +1 -1
- package/dist/commands/workspace.js +4 -7
- package/dist/commands/workspace.js.map +1 -1
- package/dist/lib/api.js +21 -6
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.js +10 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/default-project.js +7 -2
- package/dist/lib/default-project.js.map +1 -1
- package/dist/lib/skills-data.generated.js +4 -4
- package/dist/lib/skills-data.generated.js.map +1 -1
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/package.json +1 -1
- package/skill-data/core/SKILL.md +95 -74
- package/skill-data/core/references/deploys.md +41 -30
- package/skill-data/core/references/sandboxes.md +43 -18
- package/skill-data/core/references/sdk.md +33 -21
- package/src/commands/add.ts +2 -6
- package/src/commands/agents.ts +20 -4
- package/src/commands/domain.ts +36 -2
- package/src/commands/init.ts +18 -0
- package/src/commands/metrics.ts +11 -2
- package/src/commands/s3.ts +65 -0
- package/src/commands/sandbox.ts +15 -5
- package/src/commands/up.ts +20 -6
- package/src/commands/upgrade.ts +14 -3
- package/src/commands/volume.ts +35 -4
- package/src/commands/workspace.ts +4 -9
- package/src/lib/api.ts +21 -6
- package/src/lib/config.ts +11 -1
- package/src/lib/default-project.ts +5 -2
- package/src/lib/updater.ts +1 -1
- package/test/unit/default-project.test.ts +8 -2
- package/test/unit/s3-presign.test.ts +32 -0
- package/test/unit/sandbox-transport.test.ts +5 -1
- 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` |
|
|
25
|
-
| `codex` |
|
|
26
|
-
| `desktop` | A graphical desktop with Chromium
|
|
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
|
-
|
|
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
|
|
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.
|
|
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`
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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"` —
|
|
86
|
-
|
|
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.
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
|
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` |
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`
|
|
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).
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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`).
|
|
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
|
|
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 |
|
package/src/commands/add.ts
CHANGED
|
@@ -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
|
-
|
|
307
|
-
|
|
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
|
}
|
package/src/commands/agents.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/commands/domain.ts
CHANGED
|
@@ -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")
|
package/src/commands/init.ts
CHANGED
|
@@ -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({
|
package/src/commands/metrics.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/commands/s3.ts
CHANGED
|
@@ -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")
|