@sandbar-sdk/cli 0.3.0 → 0.5.0
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/README.md +26 -5
- package/dist/main.js +567 -228
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ The adapter package must be installed where the CLI can find it: in `~/.config/s
|
|
|
29
29
|
| `exec <sandbox> [--cwd] [--env K=V] [--stdin \| --stdin-file] [--timeout] [--max-output] -- cmd...` | Run argv; exit code passes through |
|
|
30
30
|
| `sh <sandbox> '<script>'` (or `-- '<script>'`) | Run a shell script; same flags as exec |
|
|
31
31
|
| `run [create and exec flags] [--keep] -- cmd...` | Create, run one command, destroy |
|
|
32
|
+
| `image build <oci-ref> [--name] [--refresh]` | Build an OCI image on a provider that needs a build first, and record it |
|
|
33
|
+
| `image ls`, `image inspect <name>`, `image forget <name>` | List, show or drop image records; `forget` lists what the provider still holds |
|
|
32
34
|
| `cp <src> <dst>` | Copy one file; one side is `<sandbox>:/path` |
|
|
33
35
|
| `cat <sandbox>:/path` | Print a file |
|
|
34
36
|
| `doctor` | Check config, credentials and the connection; creates nothing |
|
|
@@ -49,7 +51,7 @@ With `--json` (or `SANDBAR_JSON=1`) stdout holds exactly one document: `{"ok":tr
|
|
|
49
51
|
| 1 | other failure (including a taken `--name` or an existing `cp --no-clobber` target) |
|
|
50
52
|
| 2 | usage error |
|
|
51
53
|
| 3 | not found |
|
|
52
|
-
| 4 | unsupported, adapter or password-manager CLI not installed, or `limits.maxSandboxes` reached |
|
|
54
|
+
| 4 | unsupported, adapter missing (`ADAPTER_NOT_INSTALLED`) or failing to load (`ADAPTER_LOAD_FAILED`), password-manager CLI not installed, or `limits.maxSandboxes` reached |
|
|
53
55
|
| 5 | authentication or permission, including a password-manager secret that cannot be read |
|
|
54
56
|
| 6 | outcome unknown: an effect may have happened, inspect before retrying |
|
|
55
57
|
| 7 | retryable before any effect (rate limit, capacity, unavailable) |
|
|
@@ -67,7 +69,7 @@ source <(sandbar completion bash) # add to ~/.bashrc
|
|
|
67
69
|
sandbar completion fish > ~/.config/fish/completions/sandbar.fish
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
Completion covers commands, flags, `--provider` ids, `--profile` names from `sandbar.json`,
|
|
72
|
+
Completion covers commands, flags, `--provider` ids, `--profile` names from `sandbar.json`, sandbox names for `exec`, `sh`, `inspect`, `rm`, `cp` and `cat`, and image record names for `image inspect` and `image forget`. Sandbox names come from the local store, so pressing tab never calls a provider.
|
|
71
73
|
|
|
72
74
|
## Agent skill
|
|
73
75
|
|
|
@@ -91,7 +93,7 @@ The CLI carries an agent skill that matches its own version. `sandbar skill` pri
|
|
|
91
93
|
|
|
92
94
|
- Profile selection: `--profile`, `SANDBAR_PROFILE`, `--provider`, `SANDBAR_PROVIDER`, then `default`.
|
|
93
95
|
- `provider` names an official adapter (`@sandbar-sdk/<provider>`); `config` and `credentials` are that adapter's own settings. `module` and `export` load any adapter, either a definition or a bound adapter.
|
|
94
|
-
- Credentials can be written in the profile directly, as `env:NAME` references, or as password-manager references (below). Keep the file private (`chmod 600`) if it holds keys. Env-only providers read `DAYTONA_API_KEY` and `DAYTONA_TARGET`; `E2B_API_KEY`; `BOXD_API_KEY`, `BOXD_ORG` and `BOXD_NETWORK=internet` (boxd always allows egress, so its adapter requires choosing it); `VERCEL_TOKEN`, `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID`; `MODAL_TOKEN_ID`, `MODAL_TOKEN_SECRET`, `MODAL_APP_NAME` and `MODAL_ENVIRONMENT`.
|
|
96
|
+
- Credentials can be written in the profile directly, as `env:NAME` references, or as password-manager references (below). Keep the file private (`chmod 600`) if it holds keys. Env-only providers read `DAYTONA_API_KEY` and `DAYTONA_TARGET`; `E2B_API_KEY`; `BOXD_API_KEY`, `BOXD_ORG` and `BOXD_NETWORK=internet` (boxd always allows egress, so its adapter requires choosing it); `BOAT_API_KEY` and `BOAT_NETWORK=internet` (likewise for Boat); `VERCEL_TOKEN`, `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID`; `MODAL_TOKEN_ID`, `MODAL_TOKEN_SECRET`, `MODAL_APP_NAME` and `MODAL_ENVIRONMENT`.
|
|
95
97
|
- `limits.maxSandboxes` (or `SANDBAR_MAX_SANDBOXES`) caps how many sandboxes the store may hold. Unset means uncapped.
|
|
96
98
|
|
|
97
99
|
## Password managers
|
|
@@ -110,13 +112,30 @@ Any `config` or `credentials` value (including one that comes from a provider's
|
|
|
110
112
|
|
|
111
113
|
If the tool is not installed the command exits 4 (`SECRET_TOOL_MISSING`); if it cannot read the secret it exits 5 (`SECRET_UNAVAILABLE`) with the tool's own message. Each reference is read at most once per command.
|
|
112
114
|
|
|
115
|
+
## Images
|
|
116
|
+
|
|
117
|
+
`--image` always takes an OCI reference, such as `python:3.12` or `ghcr.io/org/tool@sha256:...`. A provider-specific template or snapshot goes in the profile's `image` setting or `--prepared <id>`.
|
|
118
|
+
|
|
119
|
+
- boxd and Modal boot the OCI image directly. Nothing is built or kept.
|
|
120
|
+
- E2B and Daytona must build it into a prepared image first. A build is slow, may cost money, and leaves a provider-side template or snapshot behind that destroying a sandbox does not delete. So `create --image` and `run --image` only use a build already recorded for the same reference and profile; otherwise they fail with exit 4 before creating anything, naming the command to run:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
sandbar image build python:3.12 --provider e2b # once
|
|
124
|
+
sandbar run --image python:3.12 --provider e2b -- python3 --version
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Pass `--build` to build a missing image during `create` or `run`, or set `"images": { "autoBuild": true }` in `sandbar.json` to always do so. Each reference is built once per profile either way.
|
|
128
|
+
- Vercel takes neither and rejects `--image`.
|
|
129
|
+
|
|
130
|
+
`sandbar image build` records the reference as typed, the profile, the prepared image (bound to the provider account that built it) and the provider-side resources it left. Running it again for a recorded reference does nothing unless you pass `--refresh`, which builds again, for example after a mutable tag such as `python:3.12` moved; pin a digest for an exact image. `image forget` removes the record and prints the resources the provider still holds; the CLI cannot delete them yet, so remove them with the provider's own tools when you no longer need them. A build whose outcome is unknown (exit 6) keeps its record, and `image inspect` asks the provider again how it ended. Providers do not report a resolved digest, so records do not have one.
|
|
131
|
+
|
|
113
132
|
## State store
|
|
114
133
|
|
|
115
|
-
Sandbox references are kept as one JSON file per sandbox in `~/.config/sandbar/state/sandboxes/<name>.json` (override with `--state-dir` or `SANDBAR_STATE_DIR`, relative to the working directory, or `state.dir`, relative to the config file). Records hold references only, never credentials. A name is claimed before the provider call, so a crash or uncertain create leaves a record that `ls` shows. An uncertain create also keeps the SDK's recovery reference: `rm` and `inspect` use it to ask how the original request ended (never resending it) and `rm` destroys the sandbox if one was made; otherwise `rm --force` drops the record. Records are written whole (a temporary file linked or renamed into place) and updates take a short per-record lock, so concurrent CLI processes on one machine never see half-written files; `ls` skips an unreadable record with a warning. The `maxSandboxes` check is best effort under concurrent creates. The store sits behind a small per-record interface (create-if-absent, compare-and-swap update, paged list) so object storage can back it later.
|
|
134
|
+
Sandbox references are kept as one JSON file per sandbox in `~/.config/sandbar/state/sandboxes/<name>.json`, and image records in `images/<name>.json` next to it (override with `--state-dir` or `SANDBAR_STATE_DIR`, relative to the working directory, or `state.dir`, relative to the config file). Records hold references only, never credentials. A name is claimed before the provider call, so a crash or uncertain create leaves a record that `ls` shows. An uncertain create also keeps the SDK's recovery reference: `rm` and `inspect` use it to ask how the original request ended (never resending it) and `rm` destroys the sandbox if one was made; otherwise `rm --force` drops the record. Records are written whole (a temporary file linked or renamed into place) and updates take a short per-record lock, so concurrent CLI processes on one machine never see half-written files; `ls` skips an unreadable record with a warning. The `maxSandboxes` check is best effort under concurrent creates. The store sits behind a small per-record interface (create-if-absent, compare-and-swap update, paged list) so object storage can back it later.
|
|
116
135
|
|
|
117
136
|
## Not yet
|
|
118
137
|
|
|
119
|
-
Planned for later steps: `--lifetime`/`--idle`/`--port` create flags, recursive `cp`, detached processes and logs, preview URLs, suspend/resume/renew, `gc`,
|
|
138
|
+
Planned for later steps: `--lifetime`/`--idle`/`--port` create flags, recursive `cp`, detached processes and logs, preview URLs, suspend/resume/renew, `gc`, deleting built images (`image rm`), snapshots and volumes, `sandbar adapter add`, an S3-compatible store, KeePassXC and system-keyring references, and MCP mode.
|
|
120
139
|
|
|
121
140
|
## Development
|
|
122
141
|
|
|
@@ -125,3 +144,5 @@ Tests run the real CLI as a subprocess against the fake provider; they make no p
|
|
|
125
144
|
```sh
|
|
126
145
|
bun test packages/cli
|
|
127
146
|
```
|
|
147
|
+
|
|
148
|
+
Inside this repository the official adapters are development dependencies of the CLI, so `bun packages/cli/src/main.ts` finds them from any directory without an adapters install.
|
package/dist/main.js
CHANGED
|
@@ -5,7 +5,7 @@ import { parseArgs } from "node:util";
|
|
|
5
5
|
// package.json
|
|
6
6
|
var package_default = {
|
|
7
7
|
name: "@sandbar-sdk/cli",
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.5.0",
|
|
9
9
|
private: false,
|
|
10
10
|
type: "module",
|
|
11
11
|
description: "Agent-first command line for creating and managing Sandbar sandboxes.",
|
|
@@ -30,6 +30,12 @@ var package_default = {
|
|
|
30
30
|
zod: "4.6.5"
|
|
31
31
|
},
|
|
32
32
|
devDependencies: {
|
|
33
|
+
"@sandbar-sdk/boat": "workspace:*",
|
|
34
|
+
"@sandbar-sdk/boxd": "workspace:*",
|
|
35
|
+
"@sandbar-sdk/daytona": "workspace:*",
|
|
36
|
+
"@sandbar-sdk/e2b": "workspace:*",
|
|
37
|
+
"@sandbar-sdk/modal": "workspace:*",
|
|
38
|
+
"@sandbar-sdk/vercel": "workspace:*",
|
|
33
39
|
"@sandbar/provider-fake": "workspace:*"
|
|
34
40
|
},
|
|
35
41
|
license: "Apache-2.0",
|
|
@@ -51,10 +57,10 @@ import { readFile as readFile4, stat as stat2, writeFile as writeFile2 } from "n
|
|
|
51
57
|
import { basename, join as join4, resolve as resolve3 } from "node:path";
|
|
52
58
|
import {
|
|
53
59
|
AdapterSandbox,
|
|
54
|
-
Image,
|
|
60
|
+
Image as Image2,
|
|
55
61
|
NonzeroExitError,
|
|
56
62
|
ResourceReference as ResourceReference2,
|
|
57
|
-
SandbarError as
|
|
63
|
+
SandbarError as SandbarError3
|
|
58
64
|
} from "sandbar-sdk";
|
|
59
65
|
import { z as z4 } from "zod";
|
|
60
66
|
|
|
@@ -140,6 +146,7 @@ var managementExitCodes = new Map([
|
|
|
140
146
|
["NOT_FOUND", 3],
|
|
141
147
|
["UNSUPPORTED", 4],
|
|
142
148
|
["ADAPTER_NOT_INSTALLED", 4],
|
|
149
|
+
["ADAPTER_LOAD_FAILED", 4],
|
|
143
150
|
["LIMIT_REACHED", 4],
|
|
144
151
|
["SECRET_TOOL_MISSING", 4],
|
|
145
152
|
["SECRET_UNAVAILABLE", 5],
|
|
@@ -308,15 +315,454 @@ function resolveEnvReferences(value, env, where) {
|
|
|
308
315
|
return value;
|
|
309
316
|
}
|
|
310
317
|
|
|
318
|
+
// src/images.ts
|
|
319
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
320
|
+
import {
|
|
321
|
+
Image,
|
|
322
|
+
SandbarError as SandbarError2
|
|
323
|
+
} from "sandbar-sdk";
|
|
324
|
+
|
|
325
|
+
// src/store.ts
|
|
326
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
327
|
+
import { link, mkdir, readFile as readFile2, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
328
|
+
import { join as join2 } from "node:path";
|
|
329
|
+
import { ResourceReference } from "sandbar-sdk";
|
|
330
|
+
import { z as z2 } from "zod";
|
|
331
|
+
var RecordName = z2.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
332
|
+
var SandboxRecord = z2.strictObject({
|
|
333
|
+
format: z2.literal(1),
|
|
334
|
+
kind: z2.literal("sandbox"),
|
|
335
|
+
name: RecordName,
|
|
336
|
+
status: z2.enum(["creating", "created", "unknown"]),
|
|
337
|
+
profile: z2.string().min(1),
|
|
338
|
+
provider: z2.string().min(1),
|
|
339
|
+
createdAt: z2.iso.datetime({ offset: true }),
|
|
340
|
+
labels: z2.record(z2.string(), z2.string()),
|
|
341
|
+
image: z2.string().optional(),
|
|
342
|
+
reference: ResourceReference.extend({ kind: z2.literal("sandbox") }).nullable(),
|
|
343
|
+
recovery: z2.custom((value) => z2.record(z2.string(), z2.unknown()).safeParse(value).success).optional()
|
|
344
|
+
});
|
|
345
|
+
var RetainedResource = z2.strictObject({
|
|
346
|
+
kind: z2.string().min(1),
|
|
347
|
+
id: z2.string().min(1),
|
|
348
|
+
ownership: z2.enum(["verified", "unknown"]),
|
|
349
|
+
cleanup: z2.enum(["manual", "provider_expiry", "none_known"])
|
|
350
|
+
});
|
|
351
|
+
var ImageRecord = z2.strictObject({
|
|
352
|
+
format: z2.literal(1),
|
|
353
|
+
kind: z2.literal("image"),
|
|
354
|
+
name: RecordName,
|
|
355
|
+
status: z2.enum(["building", "built", "unknown"]),
|
|
356
|
+
profile: z2.string().min(1),
|
|
357
|
+
provider: z2.string().min(1),
|
|
358
|
+
createdAt: z2.iso.datetime({ offset: true }),
|
|
359
|
+
source: z2.string().min(1),
|
|
360
|
+
prepared: z2.strictObject({
|
|
361
|
+
kind: z2.literal("prepared"),
|
|
362
|
+
value: z2.string().min(1),
|
|
363
|
+
provider: z2.string().min(1),
|
|
364
|
+
scope: ResourceReference.shape.scope
|
|
365
|
+
}).nullable(),
|
|
366
|
+
retainedResources: z2.array(RetainedResource),
|
|
367
|
+
recovery: z2.custom((value) => z2.record(z2.string(), z2.unknown()).safeParse(value).success).optional()
|
|
368
|
+
});
|
|
369
|
+
var schemas = { sandbox: SandboxRecord, image: ImageRecord };
|
|
370
|
+
var folders = { sandbox: "sandboxes", image: "images" };
|
|
371
|
+
function errorCode(cause) {
|
|
372
|
+
return cause instanceof Error && "code" in cause ? String(cause.code) : undefined;
|
|
373
|
+
}
|
|
374
|
+
function versionOf(text) {
|
|
375
|
+
return createHash("sha256").update(text).digest("hex");
|
|
376
|
+
}
|
|
377
|
+
function encode(kind, record) {
|
|
378
|
+
return `${JSON.stringify(schemas[kind].parse(record), null, 2)}
|
|
379
|
+
`;
|
|
380
|
+
}
|
|
381
|
+
var STALE_LOCK_MS = 30000;
|
|
382
|
+
var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
383
|
+
|
|
384
|
+
class FsStateStore {
|
|
385
|
+
dir;
|
|
386
|
+
constructor(dir) {
|
|
387
|
+
this.dir = dir;
|
|
388
|
+
}
|
|
389
|
+
folder(kind) {
|
|
390
|
+
return join2(this.dir, folders[kind]);
|
|
391
|
+
}
|
|
392
|
+
path(kind, name) {
|
|
393
|
+
return join2(this.folder(kind), `${RecordName.parse(name)}.json`);
|
|
394
|
+
}
|
|
395
|
+
async temporary(kind, name, text) {
|
|
396
|
+
await mkdir(this.folder(kind), { recursive: true });
|
|
397
|
+
const path = `${this.path(kind, name)}.${randomBytes(6).toString("hex")}.tmp`;
|
|
398
|
+
await writeFile(path, text, { flag: "wx" });
|
|
399
|
+
return path;
|
|
400
|
+
}
|
|
401
|
+
async locked(kind, name, action) {
|
|
402
|
+
const lock = `${this.path(kind, name)}.lock`;
|
|
403
|
+
await mkdir(this.folder(kind), { recursive: true });
|
|
404
|
+
for (let attempt = 0;; attempt++) {
|
|
405
|
+
try {
|
|
406
|
+
await writeFile(lock, String(process.pid), { flag: "wx" });
|
|
407
|
+
break;
|
|
408
|
+
} catch (cause) {
|
|
409
|
+
if (errorCode(cause) !== "EEXIST")
|
|
410
|
+
throw cause;
|
|
411
|
+
const held = await stat(lock).catch(() => null);
|
|
412
|
+
if (held && Date.now() - held.mtimeMs > STALE_LOCK_MS)
|
|
413
|
+
await unlink(lock).catch(() => {});
|
|
414
|
+
else if (attempt >= 100)
|
|
415
|
+
throw new CliError("CONFLICT", `Store record ${name} is locked by another process`);
|
|
416
|
+
else
|
|
417
|
+
await sleep(20);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
return await action();
|
|
422
|
+
} finally {
|
|
423
|
+
await unlink(lock).catch(() => {});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async get(kind, name) {
|
|
427
|
+
let text;
|
|
428
|
+
try {
|
|
429
|
+
text = await readFile2(this.path(kind, name), "utf8");
|
|
430
|
+
} catch (cause) {
|
|
431
|
+
if (errorCode(cause) === "ENOENT")
|
|
432
|
+
return null;
|
|
433
|
+
throw cause;
|
|
434
|
+
}
|
|
435
|
+
let json;
|
|
436
|
+
try {
|
|
437
|
+
json = z2.json().parse(JSON.parse(text));
|
|
438
|
+
} catch {
|
|
439
|
+
throw new CliError("STORE_INVALID", `Store record ${this.path(kind, name)} is not valid JSON`);
|
|
440
|
+
}
|
|
441
|
+
const parsed = schemas[kind].safeParse(json);
|
|
442
|
+
if (!parsed.success)
|
|
443
|
+
throw new CliError("STORE_INVALID", `Store record ${this.path(kind, name)} is invalid`);
|
|
444
|
+
return { record: parsed.data, version: versionOf(text) };
|
|
445
|
+
}
|
|
446
|
+
async create(kind, name, record) {
|
|
447
|
+
const text = encode(kind, record);
|
|
448
|
+
const temporary = await this.temporary(kind, name, text);
|
|
449
|
+
try {
|
|
450
|
+
await link(temporary, this.path(kind, name));
|
|
451
|
+
} catch (cause) {
|
|
452
|
+
if (errorCode(cause) === "EEXIST")
|
|
453
|
+
throw new CliError("CONFLICT", `Name ${name} is already in use`);
|
|
454
|
+
throw cause;
|
|
455
|
+
} finally {
|
|
456
|
+
await unlink(temporary).catch(() => {});
|
|
457
|
+
}
|
|
458
|
+
return { version: versionOf(text) };
|
|
459
|
+
}
|
|
460
|
+
async update(kind, name, record, version) {
|
|
461
|
+
return this.locked(kind, name, async () => {
|
|
462
|
+
const current = await this.get(kind, name);
|
|
463
|
+
if (!current || current.version !== version)
|
|
464
|
+
throw new CliError("CONFLICT", `Store record ${name} changed concurrently`);
|
|
465
|
+
const text = encode(kind, record);
|
|
466
|
+
await rename(await this.temporary(kind, name, text), this.path(kind, name));
|
|
467
|
+
return { version: versionOf(text) };
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
async remove(kind, name, version) {
|
|
471
|
+
await this.locked(kind, name, async () => {
|
|
472
|
+
if (version !== undefined) {
|
|
473
|
+
const current = await this.get(kind, name);
|
|
474
|
+
if (current && current.version !== version)
|
|
475
|
+
throw new CliError("CONFLICT", `Store record ${name} changed concurrently`);
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
await unlink(this.path(kind, name));
|
|
479
|
+
} catch (cause) {
|
|
480
|
+
if (errorCode(cause) !== "ENOENT")
|
|
481
|
+
throw cause;
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
async list(kind, cursor, limit = 1000) {
|
|
486
|
+
let files;
|
|
487
|
+
try {
|
|
488
|
+
files = await readdir(this.folder(kind));
|
|
489
|
+
} catch (cause) {
|
|
490
|
+
if (errorCode(cause) === "ENOENT")
|
|
491
|
+
return { names: [] };
|
|
492
|
+
throw cause;
|
|
493
|
+
}
|
|
494
|
+
const names = files.filter((file) => file.endsWith(".json")).map((file) => file.slice(0, -5)).filter((name) => RecordName.safeParse(name).success && (!cursor || name > cursor)).sort();
|
|
495
|
+
const page = names.slice(0, limit);
|
|
496
|
+
return { names: page, cursor: names.length > limit ? page.at(-1) : undefined };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function listAll(store, kind, onInvalid) {
|
|
500
|
+
const found = [];
|
|
501
|
+
let cursor;
|
|
502
|
+
do {
|
|
503
|
+
const page = await store.list(kind, cursor);
|
|
504
|
+
for (const name of page.names) {
|
|
505
|
+
try {
|
|
506
|
+
const entry = await store.get(kind, name);
|
|
507
|
+
if (entry)
|
|
508
|
+
found.push(entry);
|
|
509
|
+
} catch (cause) {
|
|
510
|
+
if (!(cause instanceof CliError) || cause.code !== "STORE_INVALID")
|
|
511
|
+
throw cause;
|
|
512
|
+
onInvalid?.(`warning: ${cause.message}; skipped`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
cursor = page.cursor;
|
|
516
|
+
} while (cursor);
|
|
517
|
+
return found;
|
|
518
|
+
}
|
|
519
|
+
function checkName(name) {
|
|
520
|
+
const parsed = RecordName.safeParse(name);
|
|
521
|
+
if (!parsed.success)
|
|
522
|
+
throw usage(`Invalid name ${name}`, "use 1-63 lowercase letters, digits and dashes, starting with a letter or digit");
|
|
523
|
+
return parsed.data;
|
|
524
|
+
}
|
|
525
|
+
function generatedName() {
|
|
526
|
+
return `sb-${randomBytes(3).toString("hex").slice(0, 5)}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/images.ts
|
|
530
|
+
var BuildResult = ImageRecord.pick({ prepared: true, retainedResources: true }).extend({
|
|
531
|
+
prepared: ImageRecord.shape.prepared.unwrap()
|
|
532
|
+
});
|
|
533
|
+
function derivedName(source) {
|
|
534
|
+
const base = (source.split("/").at(-1) ?? source).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 57).replace(/^-+|-+$/g, "");
|
|
535
|
+
return RecordName.safeParse(base).success ? base : "image";
|
|
536
|
+
}
|
|
537
|
+
async function findBuild(store, profile, source) {
|
|
538
|
+
const entries = await listAll(store, "image");
|
|
539
|
+
return entries.find(({ record }) => record.profile === profile && record.source === source && record.prepared);
|
|
540
|
+
}
|
|
541
|
+
function mergeRetained(previous, next) {
|
|
542
|
+
const seen = new Set(previous.map((item) => `${item.kind}/${item.id}`));
|
|
543
|
+
return [...previous, ...next.filter((item) => !seen.has(`${item.kind}/${item.id}`))];
|
|
544
|
+
}
|
|
545
|
+
async function claim(store, record, requested) {
|
|
546
|
+
const base = requested ? checkName(requested) : derivedName(record.source);
|
|
547
|
+
for (let attempt = 0;; attempt++) {
|
|
548
|
+
const name = attempt === 0 ? base : `${base}-${randomBytes2(2).toString("hex")}`;
|
|
549
|
+
const full = { ...record, name };
|
|
550
|
+
try {
|
|
551
|
+
const { version } = await store.create("image", name, full);
|
|
552
|
+
return { record: full, version };
|
|
553
|
+
} catch (cause) {
|
|
554
|
+
if (!(cause instanceof CliError) || cause.code !== "CONFLICT" || attempt >= 8)
|
|
555
|
+
throw cause;
|
|
556
|
+
if (requested)
|
|
557
|
+
throw new CliError("CONFLICT", `Image name ${name} is already in use`, "choose another --name, or `sandbar image forget` the old record");
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function buildImage(session, client, profile, source, options) {
|
|
562
|
+
const store = await session.store();
|
|
563
|
+
const existing = await findBuild(store, profile.name, source);
|
|
564
|
+
if (existing && options.name && existing.record.name !== checkName(options.name))
|
|
565
|
+
throw new CliError("CONFLICT", `${source} is already built for profile ${profile.name} as ${existing.record.name}`, "use that name, or `sandbar image forget` it first");
|
|
566
|
+
if (existing && !options.refresh)
|
|
567
|
+
return { entry: existing, built: false };
|
|
568
|
+
if (!(await client.capabilities()).imageBuild)
|
|
569
|
+
throw new CliError("UNSUPPORTED", `${profile.provider} does not build images`, "providers that boot OCI images directly take --image without a build");
|
|
570
|
+
const claimed = existing ?? await claim(store, {
|
|
571
|
+
format: 1,
|
|
572
|
+
kind: "image",
|
|
573
|
+
status: "building",
|
|
574
|
+
profile: profile.name,
|
|
575
|
+
provider: profile.provider,
|
|
576
|
+
createdAt: new Date().toISOString(),
|
|
577
|
+
source,
|
|
578
|
+
prepared: null,
|
|
579
|
+
retainedResources: []
|
|
580
|
+
}, options.name);
|
|
581
|
+
session.ctx.out.note(`building ${source} on ${profile.provider}; this can take a few minutes`);
|
|
582
|
+
let result;
|
|
583
|
+
try {
|
|
584
|
+
result = await client.images.build({ source: { kind: "oci", value: source } }, { signal: session.ctx.signal });
|
|
585
|
+
} catch (cause) {
|
|
586
|
+
const report = describeError(cause);
|
|
587
|
+
if (report.outcome === "rejected") {
|
|
588
|
+
if (!existing)
|
|
589
|
+
await store.remove("image", claimed.record.name, claimed.version);
|
|
590
|
+
throw cause;
|
|
591
|
+
}
|
|
592
|
+
const recovery = report.known?.recovery;
|
|
593
|
+
let kept = `the store keeps ${claimed.record.name}; \`sandbar image inspect ${claimed.record.name}\` checks the build again`;
|
|
594
|
+
if (existing)
|
|
595
|
+
kept = `${existing.record.name} still holds the previous build; the new one may exist on the provider`;
|
|
596
|
+
else
|
|
597
|
+
try {
|
|
598
|
+
await store.update("image", claimed.record.name, { ...claimed.record, status: "unknown", recovery }, claimed.version);
|
|
599
|
+
} catch (storeCause) {
|
|
600
|
+
kept = `the store could not record it (${describeError(storeCause).message}); keep the reference below`;
|
|
601
|
+
}
|
|
602
|
+
throw new CliError("OUTCOME_UNKNOWN", report.message, kept, {
|
|
603
|
+
image: claimed.record.name,
|
|
604
|
+
recovery
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
const { recovery: _recovery, ...rest } = claimed.record;
|
|
608
|
+
const record = {
|
|
609
|
+
...rest,
|
|
610
|
+
status: "built",
|
|
611
|
+
createdAt: new Date().toISOString(),
|
|
612
|
+
prepared: result.prepared,
|
|
613
|
+
retainedResources: mergeRetained(claimed.record.retainedResources, result.retainedResources)
|
|
614
|
+
};
|
|
615
|
+
try {
|
|
616
|
+
const { version } = await store.update("image", record.name, record, claimed.version);
|
|
617
|
+
return { entry: { record, version }, built: true };
|
|
618
|
+
} catch (cause) {
|
|
619
|
+
throw new CliError("STORE_WRITE_FAILED", `Built ${source} as ${result.prepared.value} but could not record it: ${describeError(cause).message}`, "the provider keeps the image; pass it with --prepared, or rebuild once the store is writable", { image: record.name }, "partial");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async function imageEnvironment(session, client, profile, source, build) {
|
|
623
|
+
if (!(await client.capabilities()).imageBuild)
|
|
624
|
+
return Image.oci(source);
|
|
625
|
+
const store = await session.store();
|
|
626
|
+
const found = await findBuild(store, profile.name, source);
|
|
627
|
+
if (found?.record.prepared)
|
|
628
|
+
return Image.prepared(found.record.prepared);
|
|
629
|
+
const autoBuild = (await session.config()).value.images?.autoBuild === true;
|
|
630
|
+
if (!build && !autoBuild)
|
|
631
|
+
throw new CliError("UNSUPPORTED", `image ${source} is not built for profile ${profile.name}; run: sandbar image build ${source}`, `pass --build to build it during this command, or set images.autoBuild in sandbar.json`);
|
|
632
|
+
const { entry } = await buildImage(session, client, profile, source, {});
|
|
633
|
+
if (!entry.record.prepared)
|
|
634
|
+
throw new Error(`Image record ${entry.record.name} has no build`);
|
|
635
|
+
return Image.prepared(entry.record.prepared);
|
|
636
|
+
}
|
|
637
|
+
function summary(record) {
|
|
638
|
+
return {
|
|
639
|
+
name: record.name,
|
|
640
|
+
source: record.source,
|
|
641
|
+
profile: record.profile,
|
|
642
|
+
provider: record.provider,
|
|
643
|
+
status: record.status,
|
|
644
|
+
createdAt: record.createdAt,
|
|
645
|
+
prepared: record.prepared?.value ?? null,
|
|
646
|
+
retainedResources: record.retainedResources
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function retainedLines(record) {
|
|
650
|
+
return record.retainedResources.map((item) => ` ${item.kind} ${item.id} (cleanup ${item.cleanup}, ownership ${item.ownership})`);
|
|
651
|
+
}
|
|
652
|
+
async function findImage(store, name) {
|
|
653
|
+
const found = await store.get("image", checkName(name));
|
|
654
|
+
if (!found)
|
|
655
|
+
throw new CliError("NOT_FOUND", `No image record named ${name}`, "list them with `sandbar image ls`");
|
|
656
|
+
return found;
|
|
657
|
+
}
|
|
658
|
+
async function recoverBuild(session, store, entry) {
|
|
659
|
+
const { record } = entry;
|
|
660
|
+
if (record.status !== "unknown" || record.recovery?.kind !== "image_build")
|
|
661
|
+
return entry;
|
|
662
|
+
const client = await session.connect(await session.profileNamed(record.profile));
|
|
663
|
+
let value;
|
|
664
|
+
try {
|
|
665
|
+
value = await (await client.recover(record.recovery)).observe();
|
|
666
|
+
} catch (cause) {
|
|
667
|
+
if (cause instanceof SandbarError2 && cause.effect !== "none")
|
|
668
|
+
return entry;
|
|
669
|
+
throw cause;
|
|
670
|
+
}
|
|
671
|
+
const parsed = BuildResult.safeParse(value);
|
|
672
|
+
if (!parsed.success)
|
|
673
|
+
return entry;
|
|
674
|
+
const { recovery: _recovery, ...rest } = record;
|
|
675
|
+
const recovered = { ...rest, status: "built", ...parsed.data };
|
|
676
|
+
const { version } = await store.update("image", record.name, recovered, entry.version);
|
|
677
|
+
session.ctx.out.note(`${record.name}: recovered the uncertain build as ${parsed.data.prepared.value}`);
|
|
678
|
+
return { record: recovered, version };
|
|
679
|
+
}
|
|
680
|
+
async function buildCommand(session, flags, args) {
|
|
681
|
+
if (args.length !== 1)
|
|
682
|
+
throw usage("Expected exactly one OCI image reference", "e.g. sandbar image build python:3.12");
|
|
683
|
+
const profile = await session.profile();
|
|
684
|
+
const client = await session.connect(profile);
|
|
685
|
+
const { entry, built } = await buildImage(session, client, profile, args[0], {
|
|
686
|
+
name: flags.string("name"),
|
|
687
|
+
refresh: flags.bool("refresh")
|
|
688
|
+
});
|
|
689
|
+
const { record } = entry;
|
|
690
|
+
session.ctx.out.success({ ...summary(record), built }, built ? record.name : `${record.name} (already built; pass --refresh to build ${record.source} again)`);
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
async function lsImages(session) {
|
|
694
|
+
const store = await session.store();
|
|
695
|
+
const entries = await listAll(store, "image", (message) => session.ctx.out.note(message));
|
|
696
|
+
const rows = entries.map(({ record }) => summary(record));
|
|
697
|
+
const lines = rows.map((row) => [row.name.padEnd(20), row.profile.padEnd(12), row.status.padEnd(9), row.source].join(" "));
|
|
698
|
+
session.ctx.out.success({ images: rows }, rows.length ? [
|
|
699
|
+
`${"NAME".padEnd(20)} ${"PROFILE".padEnd(12)} ${"STATUS".padEnd(9)} SOURCE`,
|
|
700
|
+
...lines
|
|
701
|
+
].join(`
|
|
702
|
+
`) : "no image records; build one with `sandbar image build <oci-ref>`");
|
|
703
|
+
return 0;
|
|
704
|
+
}
|
|
705
|
+
async function inspectImage(session, args) {
|
|
706
|
+
if (args.length !== 1)
|
|
707
|
+
throw usage("Expected exactly one image name");
|
|
708
|
+
const store = await session.store();
|
|
709
|
+
const { record } = await recoverBuild(session, store, await findImage(store, args[0]));
|
|
710
|
+
const lines = [
|
|
711
|
+
`name ${record.name}`,
|
|
712
|
+
`source ${record.source}`,
|
|
713
|
+
`provider ${record.provider} (profile ${record.profile})`,
|
|
714
|
+
`status ${record.status}`,
|
|
715
|
+
`prepared ${record.prepared?.value ?? "none"}`,
|
|
716
|
+
`built ${record.createdAt}`,
|
|
717
|
+
...record.retainedResources.length ? ["retained", ...retainedLines(record)] : []
|
|
718
|
+
];
|
|
719
|
+
session.ctx.out.success({ ...summary(record), preparedImage: record.prepared, recovery: record.recovery ?? null }, lines.join(`
|
|
720
|
+
`));
|
|
721
|
+
return 0;
|
|
722
|
+
}
|
|
723
|
+
async function forgetImage(session, args) {
|
|
724
|
+
if (args.length !== 1)
|
|
725
|
+
throw usage("Expected exactly one image name");
|
|
726
|
+
const store = await session.store();
|
|
727
|
+
const { record, version } = await findImage(store, args[0]);
|
|
728
|
+
await store.remove("image", record.name, version);
|
|
729
|
+
const lines = [`forgot ${record.name}`];
|
|
730
|
+
if (record.retainedResources.length)
|
|
731
|
+
lines.push(`${record.provider} still holds these; delete them with the provider's own tools if you no longer need them:`, ...retainedLines(record));
|
|
732
|
+
session.ctx.out.success({ name: record.name, result: "forgotten", retainedResources: record.retainedResources }, lines.join(`
|
|
733
|
+
`));
|
|
734
|
+
return 0;
|
|
735
|
+
}
|
|
736
|
+
var imageActions = ["build", "ls", "inspect", "forget"];
|
|
737
|
+
async function imageCommand(session, flags) {
|
|
738
|
+
const [action, ...args] = flags.positionals;
|
|
739
|
+
if ((flags.string("name") || flags.bool("refresh")) && action !== "build")
|
|
740
|
+
throw usage("--name and --refresh only apply to `sandbar image build`");
|
|
741
|
+
switch (action) {
|
|
742
|
+
case "build":
|
|
743
|
+
return buildCommand(session, flags, args);
|
|
744
|
+
case "ls":
|
|
745
|
+
if (args.length)
|
|
746
|
+
throw usage("image ls takes no arguments");
|
|
747
|
+
return lsImages(session);
|
|
748
|
+
case "inspect":
|
|
749
|
+
return inspectImage(session, args);
|
|
750
|
+
case "forget":
|
|
751
|
+
return forgetImage(session, args);
|
|
752
|
+
default:
|
|
753
|
+
throw usage(action ? `Unknown image command ${action}` : "Expected an image command", `use one of: ${imageActions.join(", ")}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
311
757
|
// src/providers.ts
|
|
312
|
-
import { access, readFile as
|
|
313
|
-
import { dirname as dirname2, isAbsolute, join as
|
|
314
|
-
import { pathToFileURL } from "node:url";
|
|
758
|
+
import { access, readFile as readFile3 } from "node:fs/promises";
|
|
759
|
+
import { dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2 } from "node:path";
|
|
760
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
315
761
|
import {
|
|
316
762
|
bindAdapter
|
|
317
763
|
} from "@sandbar-sdk/adapter";
|
|
318
764
|
import { Sandbar } from "sandbar-sdk";
|
|
319
|
-
import { z as
|
|
765
|
+
import { z as z3 } from "zod";
|
|
320
766
|
var providers = [
|
|
321
767
|
{
|
|
322
768
|
id: "daytona",
|
|
@@ -345,6 +791,16 @@ var providers = [
|
|
|
345
791
|
network: { env: "BOXD_NETWORK", required: true }
|
|
346
792
|
}
|
|
347
793
|
},
|
|
794
|
+
{
|
|
795
|
+
id: "boat",
|
|
796
|
+
module: "@sandbar-sdk/boat",
|
|
797
|
+
export: "boatAdapter",
|
|
798
|
+
experimental: true,
|
|
799
|
+
credentials: { apiKey: "BOAT_API_KEY" },
|
|
800
|
+
config: {
|
|
801
|
+
network: { env: "BOAT_NETWORK", required: true }
|
|
802
|
+
}
|
|
803
|
+
},
|
|
348
804
|
{
|
|
349
805
|
id: "vercel",
|
|
350
806
|
module: "@sandbar-sdk/vercel",
|
|
@@ -412,7 +868,7 @@ function fromProfile(name, profile, loaded, env) {
|
|
|
412
868
|
}),
|
|
413
869
|
...Object.entries(profile.credentials ?? {}).map(([key, value]) => [
|
|
414
870
|
key,
|
|
415
|
-
|
|
871
|
+
z3.string().parse(resolveEnvReferences(value, env, `${where}.credentials.${key}`))
|
|
416
872
|
])
|
|
417
873
|
]);
|
|
418
874
|
const config = Object.fromEntries([
|
|
@@ -441,17 +897,9 @@ function fromProfile(name, profile, loaded, env) {
|
|
|
441
897
|
credentials
|
|
442
898
|
};
|
|
443
899
|
}
|
|
444
|
-
var
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
});
|
|
448
|
-
var PackageManifest = z2.object({
|
|
449
|
-
main: z2.string().optional(),
|
|
450
|
-
exports: z2.union([
|
|
451
|
-
z2.string(),
|
|
452
|
-
z2.object({ ".": z2.union([z2.string(), ConditionalEntry]).optional() }),
|
|
453
|
-
ConditionalEntry
|
|
454
|
-
]).optional()
|
|
900
|
+
var PackageManifest = z3.object({
|
|
901
|
+
main: z3.string().optional(),
|
|
902
|
+
exports: z3.json().optional()
|
|
455
903
|
});
|
|
456
904
|
async function exists(path) {
|
|
457
905
|
try {
|
|
@@ -461,24 +909,45 @@ async function exists(path) {
|
|
|
461
909
|
return false;
|
|
462
910
|
}
|
|
463
911
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const plain =
|
|
912
|
+
var importConditions = new Set(["import", "node", "default"]);
|
|
913
|
+
function exportTarget(value) {
|
|
914
|
+
const plain = z3.string().safeParse(value);
|
|
467
915
|
if (plain.success)
|
|
468
916
|
return plain.data;
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
917
|
+
const list = z3.array(z3.json()).safeParse(value);
|
|
918
|
+
if (list.success) {
|
|
919
|
+
for (const item of list.data) {
|
|
920
|
+
const target = exportTarget(item);
|
|
921
|
+
if (target)
|
|
922
|
+
return target;
|
|
923
|
+
}
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
const conditions = z3.record(z3.string(), z3.json()).safeParse(value);
|
|
927
|
+
if (!conditions.success)
|
|
928
|
+
return null;
|
|
929
|
+
for (const [condition, nested] of Object.entries(conditions.data)) {
|
|
930
|
+
if (!importConditions.has(condition))
|
|
931
|
+
continue;
|
|
932
|
+
const target = exportTarget(nested);
|
|
933
|
+
if (target)
|
|
934
|
+
return target;
|
|
935
|
+
}
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
function rootEntry(manifest) {
|
|
939
|
+
const exports = manifest.exports;
|
|
940
|
+
if (exports === undefined || exports === null)
|
|
941
|
+
return manifest.main || "index.js";
|
|
942
|
+
const subpaths = z3.record(z3.string(), z3.json()).safeParse(exports);
|
|
943
|
+
const root = subpaths.success && Object.keys(subpaths.data).some((key) => key.startsWith(".")) ? subpaths.data["."] : exports;
|
|
944
|
+
return (root === undefined ? null : exportTarget(root)) ?? manifest.main ?? "index.js";
|
|
476
945
|
}
|
|
477
946
|
async function entryFor(packageDir) {
|
|
478
|
-
const manifestPath =
|
|
947
|
+
const manifestPath = join3(packageDir, "package.json");
|
|
479
948
|
if (!await exists(manifestPath))
|
|
480
949
|
return null;
|
|
481
|
-
const manifest = PackageManifest.safeParse(JSON.parse(await
|
|
950
|
+
const manifest = PackageManifest.safeParse(JSON.parse(await readFile3(manifestPath, "utf8")));
|
|
482
951
|
if (!manifest.success)
|
|
483
952
|
return null;
|
|
484
953
|
const entry = resolve2(packageDir, rootEntry(manifest.data));
|
|
@@ -494,15 +963,15 @@ async function resolveAdapterModule(profile, cwd, env) {
|
|
|
494
963
|
return pathToFileURL(resolve2(profile.baseDir, spec)).href;
|
|
495
964
|
if (!PackageName.test(spec))
|
|
496
965
|
throw usage(`Adapter module ${spec} is not a package name or a path`);
|
|
497
|
-
const roots = [
|
|
966
|
+
const roots = [join3(configHome(env), "adapters", "node_modules")];
|
|
498
967
|
if (profile.searchProject)
|
|
499
968
|
for (let dir = resolve2(cwd);; dir = dirname2(dir)) {
|
|
500
|
-
roots.push(
|
|
969
|
+
roots.push(join3(dir, "node_modules"));
|
|
501
970
|
if (dirname2(dir) === dir)
|
|
502
971
|
break;
|
|
503
972
|
}
|
|
504
973
|
for (const root of roots) {
|
|
505
|
-
const entry = await entryFor(
|
|
974
|
+
const entry = await entryFor(join3(root, spec));
|
|
506
975
|
if (entry)
|
|
507
976
|
return pathToFileURL(entry).href;
|
|
508
977
|
}
|
|
@@ -511,16 +980,24 @@ async function resolveAdapterModule(profile, cwd, env) {
|
|
|
511
980
|
function isAdapter(value) {
|
|
512
981
|
return value instanceof Object && "name" in value && "connect" in value && "config" in value;
|
|
513
982
|
}
|
|
514
|
-
var AdapterExport =
|
|
983
|
+
var AdapterExport = z3.custom(isAdapter);
|
|
984
|
+
async function adapterMissing(target, spec, message) {
|
|
985
|
+
if (target.startsWith("file:"))
|
|
986
|
+
return !await exists(fileURLToPath(target));
|
|
987
|
+
return message.includes(`'${spec}'`) || message.includes(`"${spec}"`);
|
|
988
|
+
}
|
|
515
989
|
async function loadAdapter(profile, cwd, env, secrets) {
|
|
516
990
|
const target = await resolveAdapterModule(profile, cwd, env);
|
|
517
|
-
const schema =
|
|
991
|
+
const schema = z3.object({ [profile.export]: AdapterExport });
|
|
518
992
|
let parsed;
|
|
519
993
|
try {
|
|
520
994
|
parsed = schema.safeParse(await import(target));
|
|
521
995
|
} catch (cause) {
|
|
996
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
522
997
|
const official = descriptor(profile.provider);
|
|
523
|
-
|
|
998
|
+
if (await adapterMissing(target, profile.module, message))
|
|
999
|
+
throw new CliError("ADAPTER_NOT_INSTALLED", `Cannot load adapter ${profile.module} for profile ${profile.name}: ${message}`, official ? `install it into ${join3(configHome(env), "adapters")} (cd there && npm install ${official.module}); project installs need adapters.searchProject or SANDBAR_PROJECT_ADAPTERS=1` : "check the profile's module path");
|
|
1000
|
+
throw new CliError("ADAPTER_LOAD_FAILED", `Adapter ${profile.module} for profile ${profile.name} is installed but failed to load: ${message}`, "reinstall the adapter and its dependencies; the message above comes from the adapter package");
|
|
524
1001
|
}
|
|
525
1002
|
if (!parsed.success)
|
|
526
1003
|
throw new CliError("ADAPTER_NOT_INSTALLED", `${profile.module} has no adapter export named ${profile.export}`);
|
|
@@ -531,191 +1008,13 @@ async function loadAdapter(profile, cwd, env, secrets) {
|
|
|
531
1008
|
return exported;
|
|
532
1009
|
const where = `profile ${profile.name}`;
|
|
533
1010
|
const config = await secrets.resolve(profile.config, `${where}.config`);
|
|
534
|
-
const credentials =
|
|
1011
|
+
const credentials = z3.record(z3.string(), z3.string()).parse(await secrets.resolve(profile.credentials, `${where}.credentials`));
|
|
535
1012
|
return bindAdapter(exported, config, credentials);
|
|
536
1013
|
}
|
|
537
1014
|
async function connect(profile, cwd, env, secrets) {
|
|
538
1015
|
return Sandbar.connect(await loadAdapter(profile, cwd, env, secrets));
|
|
539
1016
|
}
|
|
540
1017
|
|
|
541
|
-
// src/store.ts
|
|
542
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
543
|
-
import { link, mkdir, readFile as readFile3, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
544
|
-
import { join as join3 } from "node:path";
|
|
545
|
-
import { ResourceReference } from "sandbar-sdk";
|
|
546
|
-
import { z as z3 } from "zod";
|
|
547
|
-
var RecordName = z3.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
548
|
-
var SandboxRecord = z3.strictObject({
|
|
549
|
-
format: z3.literal(1),
|
|
550
|
-
kind: z3.literal("sandbox"),
|
|
551
|
-
name: RecordName,
|
|
552
|
-
status: z3.enum(["creating", "created", "unknown"]),
|
|
553
|
-
profile: z3.string().min(1),
|
|
554
|
-
provider: z3.string().min(1),
|
|
555
|
-
createdAt: z3.iso.datetime({ offset: true }),
|
|
556
|
-
labels: z3.record(z3.string(), z3.string()),
|
|
557
|
-
image: z3.string().optional(),
|
|
558
|
-
reference: ResourceReference.extend({ kind: z3.literal("sandbox") }).nullable(),
|
|
559
|
-
recovery: z3.custom((value) => z3.record(z3.string(), z3.unknown()).safeParse(value).success).optional()
|
|
560
|
-
});
|
|
561
|
-
function errorCode(cause) {
|
|
562
|
-
return cause instanceof Error && "code" in cause ? String(cause.code) : undefined;
|
|
563
|
-
}
|
|
564
|
-
function versionOf(text) {
|
|
565
|
-
return createHash("sha256").update(text).digest("hex");
|
|
566
|
-
}
|
|
567
|
-
function encode(record) {
|
|
568
|
-
return `${JSON.stringify(SandboxRecord.parse(record), null, 2)}
|
|
569
|
-
`;
|
|
570
|
-
}
|
|
571
|
-
var STALE_LOCK_MS = 30000;
|
|
572
|
-
var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
573
|
-
|
|
574
|
-
class FsStateStore {
|
|
575
|
-
dir;
|
|
576
|
-
constructor(dir) {
|
|
577
|
-
this.dir = dir;
|
|
578
|
-
}
|
|
579
|
-
folder(kind) {
|
|
580
|
-
return join3(this.dir, `${kind}es`);
|
|
581
|
-
}
|
|
582
|
-
path(kind, name) {
|
|
583
|
-
return join3(this.folder(kind), `${RecordName.parse(name)}.json`);
|
|
584
|
-
}
|
|
585
|
-
async temporary(kind, name, text) {
|
|
586
|
-
await mkdir(this.folder(kind), { recursive: true });
|
|
587
|
-
const path = `${this.path(kind, name)}.${randomBytes(6).toString("hex")}.tmp`;
|
|
588
|
-
await writeFile(path, text, { flag: "wx" });
|
|
589
|
-
return path;
|
|
590
|
-
}
|
|
591
|
-
async locked(kind, name, action) {
|
|
592
|
-
const lock = `${this.path(kind, name)}.lock`;
|
|
593
|
-
await mkdir(this.folder(kind), { recursive: true });
|
|
594
|
-
for (let attempt = 0;; attempt++) {
|
|
595
|
-
try {
|
|
596
|
-
await writeFile(lock, String(process.pid), { flag: "wx" });
|
|
597
|
-
break;
|
|
598
|
-
} catch (cause) {
|
|
599
|
-
if (errorCode(cause) !== "EEXIST")
|
|
600
|
-
throw cause;
|
|
601
|
-
const held = await stat(lock).catch(() => null);
|
|
602
|
-
if (held && Date.now() - held.mtimeMs > STALE_LOCK_MS)
|
|
603
|
-
await unlink(lock).catch(() => {});
|
|
604
|
-
else if (attempt >= 100)
|
|
605
|
-
throw new CliError("CONFLICT", `Store record ${name} is locked by another process`);
|
|
606
|
-
else
|
|
607
|
-
await sleep(20);
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
try {
|
|
611
|
-
return await action();
|
|
612
|
-
} finally {
|
|
613
|
-
await unlink(lock).catch(() => {});
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
async get(kind, name) {
|
|
617
|
-
let text;
|
|
618
|
-
try {
|
|
619
|
-
text = await readFile3(this.path(kind, name), "utf8");
|
|
620
|
-
} catch (cause) {
|
|
621
|
-
if (errorCode(cause) === "ENOENT")
|
|
622
|
-
return null;
|
|
623
|
-
throw cause;
|
|
624
|
-
}
|
|
625
|
-
let json;
|
|
626
|
-
try {
|
|
627
|
-
json = z3.json().parse(JSON.parse(text));
|
|
628
|
-
} catch {
|
|
629
|
-
throw new CliError("STORE_INVALID", `Store record ${this.path(kind, name)} is not valid JSON`);
|
|
630
|
-
}
|
|
631
|
-
const parsed = SandboxRecord.safeParse(json);
|
|
632
|
-
if (!parsed.success)
|
|
633
|
-
throw new CliError("STORE_INVALID", `Store record ${this.path(kind, name)} is invalid`);
|
|
634
|
-
return { record: parsed.data, version: versionOf(text) };
|
|
635
|
-
}
|
|
636
|
-
async create(kind, name, record) {
|
|
637
|
-
const text = encode(record);
|
|
638
|
-
const temporary = await this.temporary(kind, name, text);
|
|
639
|
-
try {
|
|
640
|
-
await link(temporary, this.path(kind, name));
|
|
641
|
-
} catch (cause) {
|
|
642
|
-
if (errorCode(cause) === "EEXIST")
|
|
643
|
-
throw new CliError("CONFLICT", `Name ${name} is already in use`);
|
|
644
|
-
throw cause;
|
|
645
|
-
} finally {
|
|
646
|
-
await unlink(temporary).catch(() => {});
|
|
647
|
-
}
|
|
648
|
-
return { version: versionOf(text) };
|
|
649
|
-
}
|
|
650
|
-
async update(kind, name, record, version) {
|
|
651
|
-
return this.locked(kind, name, async () => {
|
|
652
|
-
const current = await this.get(kind, name);
|
|
653
|
-
if (!current || current.version !== version)
|
|
654
|
-
throw new CliError("CONFLICT", `Store record ${name} changed concurrently`);
|
|
655
|
-
const text = encode(record);
|
|
656
|
-
await rename(await this.temporary(kind, name, text), this.path(kind, name));
|
|
657
|
-
return { version: versionOf(text) };
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
async remove(kind, name, version) {
|
|
661
|
-
await this.locked(kind, name, async () => {
|
|
662
|
-
if (version !== undefined) {
|
|
663
|
-
const current = await this.get(kind, name);
|
|
664
|
-
if (current && current.version !== version)
|
|
665
|
-
throw new CliError("CONFLICT", `Store record ${name} changed concurrently`);
|
|
666
|
-
}
|
|
667
|
-
try {
|
|
668
|
-
await unlink(this.path(kind, name));
|
|
669
|
-
} catch (cause) {
|
|
670
|
-
if (errorCode(cause) !== "ENOENT")
|
|
671
|
-
throw cause;
|
|
672
|
-
}
|
|
673
|
-
});
|
|
674
|
-
}
|
|
675
|
-
async list(kind, cursor, limit = 1000) {
|
|
676
|
-
let files;
|
|
677
|
-
try {
|
|
678
|
-
files = await readdir(this.folder(kind));
|
|
679
|
-
} catch (cause) {
|
|
680
|
-
if (errorCode(cause) === "ENOENT")
|
|
681
|
-
return { names: [] };
|
|
682
|
-
throw cause;
|
|
683
|
-
}
|
|
684
|
-
const names = files.filter((file) => file.endsWith(".json")).map((file) => file.slice(0, -5)).filter((name) => RecordName.safeParse(name).success && (!cursor || name > cursor)).sort();
|
|
685
|
-
const page = names.slice(0, limit);
|
|
686
|
-
return { names: page, cursor: names.length > limit ? page.at(-1) : undefined };
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
async function listAll(store, kind, onInvalid) {
|
|
690
|
-
const found = [];
|
|
691
|
-
let cursor;
|
|
692
|
-
do {
|
|
693
|
-
const page = await store.list(kind, cursor);
|
|
694
|
-
for (const name of page.names) {
|
|
695
|
-
try {
|
|
696
|
-
const entry = await store.get(kind, name);
|
|
697
|
-
if (entry)
|
|
698
|
-
found.push(entry);
|
|
699
|
-
} catch (cause) {
|
|
700
|
-
if (!(cause instanceof CliError) || cause.code !== "STORE_INVALID")
|
|
701
|
-
throw cause;
|
|
702
|
-
onInvalid?.(`warning: ${cause.message}; skipped`);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
cursor = page.cursor;
|
|
706
|
-
} while (cursor);
|
|
707
|
-
return found;
|
|
708
|
-
}
|
|
709
|
-
function checkName(name) {
|
|
710
|
-
const parsed = RecordName.safeParse(name);
|
|
711
|
-
if (!parsed.success)
|
|
712
|
-
throw usage(`Invalid name ${name}`, "use 1-63 lowercase letters, digits and dashes, starting with a letter or digit");
|
|
713
|
-
return parsed.data;
|
|
714
|
-
}
|
|
715
|
-
function generatedName() {
|
|
716
|
-
return `sb-${randomBytes(3).toString("hex").slice(0, 5)}`;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
1018
|
// src/units.ts
|
|
720
1019
|
var durationUnits = new Map([
|
|
721
1020
|
["", 1],
|
|
@@ -777,9 +1076,10 @@ class Flags {
|
|
|
777
1076
|
function createRequest(flags) {
|
|
778
1077
|
if (flags.string("image") && flags.string("prepared"))
|
|
779
1078
|
throw usage("Pass either --image or --prepared, not both");
|
|
780
|
-
if (flags.bool("build"))
|
|
781
|
-
throw usage("--build
|
|
1079
|
+
if (flags.bool("build") && !flags.string("image"))
|
|
1080
|
+
throw usage("--build needs --image");
|
|
782
1081
|
return {
|
|
1082
|
+
build: flags.bool("build"),
|
|
783
1083
|
name: flags.string("name"),
|
|
784
1084
|
ifNotExists: flags.bool("if-not-exists"),
|
|
785
1085
|
image: flags.string("image"),
|
|
@@ -790,7 +1090,7 @@ function createRequest(flags) {
|
|
|
790
1090
|
}
|
|
791
1091
|
function createInput(request) {
|
|
792
1092
|
return {
|
|
793
|
-
environment: request.
|
|
1093
|
+
environment: request.prepared ? Image2.prepared(request.prepared) : undefined,
|
|
794
1094
|
network: request.network,
|
|
795
1095
|
labels: Object.keys(request.labels).length ? request.labels : undefined
|
|
796
1096
|
};
|
|
@@ -824,7 +1124,7 @@ async function createSandbox(session, request) {
|
|
|
824
1124
|
const box2 = await session.reopen(current.record, client2);
|
|
825
1125
|
return { client: client2, box: box2, ...current, existing: true };
|
|
826
1126
|
} catch (cause) {
|
|
827
|
-
if (!(cause instanceof
|
|
1127
|
+
if (!(cause instanceof SandbarError3) || cause.code !== "NOT_FOUND")
|
|
828
1128
|
throw cause;
|
|
829
1129
|
await store.remove("sandbox", current.record.name, current.version);
|
|
830
1130
|
}
|
|
@@ -848,10 +1148,12 @@ async function createSandbox(session, request) {
|
|
|
848
1148
|
const input = createInput(request);
|
|
849
1149
|
try {
|
|
850
1150
|
client = await session.connect(profile);
|
|
1151
|
+
if (request.image)
|
|
1152
|
+
input.environment = await imageEnvironment(session, client, profile, request.image, request.build);
|
|
851
1153
|
if (input.environment) {
|
|
852
1154
|
const plan = await client.sandboxes.checkCreate(input);
|
|
853
1155
|
if (plan.status === "unsupported" || plan.status === "unavailable")
|
|
854
|
-
throw new CliError("UNSUPPORTED", `${profile.provider} cannot create this sandbox: ${plan.reason}`, request.image ? "
|
|
1156
|
+
throw new CliError("UNSUPPORTED", `${profile.provider} cannot create this sandbox: ${plan.reason}`, request.image ? "use --prepared with a provider image instead" : undefined);
|
|
855
1157
|
}
|
|
856
1158
|
} catch (cause) {
|
|
857
1159
|
await store.remove("sandbox", claimed.record.name, claimed.version);
|
|
@@ -956,9 +1258,9 @@ async function stateOf(session, record, client) {
|
|
|
956
1258
|
const box = await session.reopen(record, client);
|
|
957
1259
|
return (await box.inspect({ signal: session.ctx.signal })).state;
|
|
958
1260
|
} catch (cause) {
|
|
959
|
-
if (cause instanceof
|
|
1261
|
+
if (cause instanceof SandbarError3 && cause.code === "NOT_FOUND")
|
|
960
1262
|
return "gone";
|
|
961
|
-
if (cause instanceof
|
|
1263
|
+
if (cause instanceof SandbarError3 && cause.code === "UNSUPPORTED")
|
|
962
1264
|
return "unknown";
|
|
963
1265
|
return "unreachable";
|
|
964
1266
|
}
|
|
@@ -1059,7 +1361,7 @@ async function recoverCreate(session, store, target) {
|
|
|
1059
1361
|
try {
|
|
1060
1362
|
value = await (await client.recover(record.recovery)).observe();
|
|
1061
1363
|
} catch (cause) {
|
|
1062
|
-
if (cause instanceof
|
|
1364
|
+
if (cause instanceof SandbarError3 && cause.effect !== "none")
|
|
1063
1365
|
return target;
|
|
1064
1366
|
throw cause;
|
|
1065
1367
|
}
|
|
@@ -1098,7 +1400,7 @@ async function removeOne(session, store, found, force, all) {
|
|
|
1098
1400
|
try {
|
|
1099
1401
|
box = await session.reopen(record);
|
|
1100
1402
|
} catch (cause) {
|
|
1101
|
-
if (!(cause instanceof
|
|
1403
|
+
if (!(cause instanceof SandbarError3) || cause.code !== "NOT_FOUND")
|
|
1102
1404
|
throw cause;
|
|
1103
1405
|
await forget(store, target);
|
|
1104
1406
|
return { kind: "removed", item: { name: record.name, result: "gone", retainedResources: [] } };
|
|
@@ -1584,7 +1886,7 @@ import { homedir as homedir2 } from "node:os";
|
|
|
1584
1886
|
import { dirname as dirname3, join as join5 } from "node:path";
|
|
1585
1887
|
|
|
1586
1888
|
// ../../skills/sandbar-cli/SKILL.md
|
|
1587
|
-
var SKILL_default = "---\nname: sandbar-cli\ndescription: Create, use and clean up cloud sandboxes from the shell with the `sandbar` CLI (Daytona, E2B, boxd, Vercel, Modal). Use when you need an isolated machine to run untrusted or generated code, try a build or test in a clean environment, or manage sandboxes someone created with sandbar; prefer it over provider-specific CLIs.\n---\n\n# Use sandboxes with the sandbar CLI\n\n`sandbar` creates and manages sandboxes on any Sandbar provider. It is built for agents: it never prompts, every command has a stable `--json` form, and exit codes tell you what to do next.\n\n## Check the setup first\n\n```sh\nsandbar doctor --json # config, credentials, provider connection; creates nothing\nsandbar providers --json # which adapters are installed and which credentials are missing\n```\n\n- If `sandbar` is not on PATH, stop and tell the user; do not install anything yourself.\n- The provider comes from `--profile`, `SANDBAR_PROFILE`, `--provider`, `SANDBAR_PROVIDER`, then `default` in `~/.config/sandbar/sandbar.json`. Do not edit that file or credentials unless the user asks.\n- A missing adapter (exit 4, `ADAPTER_NOT_INSTALLED`) or missing credential (exit 2) is the user's to fix. Report the `hint` from the error.\n\n**Sandboxes cost money.** Create only what the task needs and destroy it when done.\n\n## Always use JSON\n\nPass `--json` (or set `SANDBAR_JSON=1`). stdout then holds exactly one document:\n\n```json\n{\"ok\": true, \"data\": {...}}\n{\"ok\": false, \"error\": {\"code\": \"...\", \"message\": \"...\", \"retryable\": false, \"outcome\": \"rejected\", \"hint\": \"...\", \"known\": {...}}}\n```\n\nDiagnostics go to stderr. Read `error.hint` before deciding what to do.\n\n## One-shot work: `run`\n\n`run` creates a sandbox, runs one command and always destroys it. Prefer it whenever you do not need the sandbox afterwards.\n\n```sh\nsandbar run --json -- python3 -c 'print(2 + 2)'\nsandbar run --json --prepared base --timeout 5m -- sh -c 'pip install -q requests && python3 app.py'\n```\n\nPass the command after `--` as separate arguments. Its data is `{exitCode, stdout, stderr, truncated, sandbox}`, where `sandbox` names the sandbox it created and destroyed.\n\n## Longer work: create, use, remove\n\n```sh\nsandbar create --json --name job-1 --prepared base # prints the name; reuse it everywhere\nsandbar cp ./app.py job-1:/home/user/app.py\nsandbar exec --json job-1 --cwd /home/user -- python3 app.py\nsandbar sh --json job-1 'cd /home/user && ls -la | head'\nsandbar cat job-1:/home/user/out.txt\nsandbar rm --json job-1\n```\n\n- Give sandboxes a `--name` so you can find them again; names are lowercase letters, digits and dashes. `--if-not-exists` reuses a live sandbox with that name instead of failing.\n- `exec` takes an argv after `--`; no shell runs. Use `sh` only when you need pipes, redirects or `&&`. For a script that starts with `-`, put it after `--`: `sandbar sh job-1 -- '-n ...'`.\n- `--env K=V` (repeatable), `--cwd`, `--stdin` or `--stdin-file <path>`, `--timeout 30s|5m|1h`, `--max-output 1m`.\n- `cp` copies one file; one side is `<sandbox>:/absolute/path`. `--no-clobber` refuses to overwrite.\n- `--image <oci-ref>` boots an OCI image where the provider supports it; `--prepared <id>` uses a provider template or snapshot. On providers that need
|
|
1889
|
+
var SKILL_default = "---\nname: sandbar-cli\ndescription: Create, use and clean up cloud sandboxes from the shell with the `sandbar` CLI (Daytona, E2B, boxd, Boat, Vercel, Modal). Use when you need an isolated machine to run untrusted or generated code, try a build or test in a clean environment, or manage sandboxes someone created with sandbar; prefer it over provider-specific CLIs.\n---\n\n# Use sandboxes with the sandbar CLI\n\n`sandbar` creates and manages sandboxes on any Sandbar provider. It is built for agents: it never prompts, every command has a stable `--json` form, and exit codes tell you what to do next.\n\n## Check the setup first\n\n```sh\nsandbar doctor --json # config, credentials, provider connection; creates nothing\nsandbar providers --json # which adapters are installed and which credentials are missing\n```\n\n- If `sandbar` is not on PATH, stop and tell the user; do not install anything yourself.\n- The provider comes from `--profile`, `SANDBAR_PROFILE`, `--provider`, `SANDBAR_PROVIDER`, then `default` in `~/.config/sandbar/sandbar.json`. Do not edit that file or credentials unless the user asks.\n- A missing adapter (exit 4, `ADAPTER_NOT_INSTALLED`), an installed adapter that fails to load (exit 4, `ADAPTER_LOAD_FAILED`) or a missing credential (exit 2) is the user's to fix. Report the `hint` from the error.\n\n**Sandboxes cost money.** Create only what the task needs and destroy it when done.\n\n## Always use JSON\n\nPass `--json` (or set `SANDBAR_JSON=1`). stdout then holds exactly one document:\n\n```json\n{\"ok\": true, \"data\": {...}}\n{\"ok\": false, \"error\": {\"code\": \"...\", \"message\": \"...\", \"retryable\": false, \"outcome\": \"rejected\", \"hint\": \"...\", \"known\": {...}}}\n```\n\nDiagnostics go to stderr. Read `error.hint` before deciding what to do.\n\n## One-shot work: `run`\n\n`run` creates a sandbox, runs one command and always destroys it. Prefer it whenever you do not need the sandbox afterwards.\n\n```sh\nsandbar run --json -- python3 -c 'print(2 + 2)'\nsandbar run --json --prepared base --timeout 5m -- sh -c 'pip install -q requests && python3 app.py'\n```\n\nPass the command after `--` as separate arguments. Its data is `{exitCode, stdout, stderr, truncated, sandbox}`, where `sandbox` names the sandbox it created and destroyed.\n\n## Longer work: create, use, remove\n\n```sh\nsandbar create --json --name job-1 --prepared base # prints the name; reuse it everywhere\nsandbar cp ./app.py job-1:/home/user/app.py\nsandbar exec --json job-1 --cwd /home/user -- python3 app.py\nsandbar sh --json job-1 'cd /home/user && ls -la | head'\nsandbar cat job-1:/home/user/out.txt\nsandbar rm --json job-1\n```\n\n- Give sandboxes a `--name` so you can find them again; names are lowercase letters, digits and dashes. `--if-not-exists` reuses a live sandbox with that name instead of failing.\n- `exec` takes an argv after `--`; no shell runs. Use `sh` only when you need pipes, redirects or `&&`. For a script that starts with `-`, put it after `--`: `sandbar sh job-1 -- '-n ...'`.\n- `--env K=V` (repeatable), `--cwd`, `--stdin` or `--stdin-file <path>`, `--timeout 30s|5m|1h`, `--max-output 1m`.\n- `cp` copies one file; one side is `<sandbox>:/absolute/path`. `--no-clobber` refuses to overwrite.\n- `--image <oci-ref>` boots an OCI image where the provider supports it; `--prepared <id>` uses a provider template or snapshot. On providers that need a build first (E2B, Daytona), `--image` fails with exit 4 and `UNSUPPORTED` before creating anything until `sandbar image build <oci-ref>` has run once for that profile; later creates reuse the build. Build once rather than passing `--build` in a loop: a build is slow, may cost money and leaves a provider-side resource. `sandbar image ls` shows the builds; `sandbar image forget <name>` drops a record and lists what the provider still holds.\n- `sandbar ls --json` lists known sandboxes with their live state (`running`, `gone`, `unreachable`, ...). `sandbar inspect --json <name>` shows state, expiry and the saved reference.\n\nAlways `rm` what you created before finishing, even after failures. `rm` only destroys sandboxes this CLI created; never pass `--force` or `rm --all` unless the user asked.\n\n## Exit codes\n\n`exec`, `sh` and `run` exit with the **remote command's** exit code. Their own failures always exit **125**, with the error in the JSON document. A command that ran but reported no exit status is 125 with `OUTCOME_UNKNOWN`; its output is in `error.known.exec`. It is never success.\n\nOther commands:\n\n| Code | Meaning | What to do |\n| ---- | ----------------------------------------------------------------- | ---------------------------------------------------- |\n| 0 | success | |\n| 1 | other failure, including a name already taken | read the message |\n| 2 | usage or local error | fix the command; `sandbar <command> --help` |\n| 3 | not found | `sandbar ls` |\n| 4 | unsupported, adapter or tool not installed, limit reached | tell the user; do not work around it |\n| 5 | authentication, permission, or unreadable password-manager secret | tell the user |\n| 6 | **outcome unknown**: the effect may have happened | **do not retry**; run `sandbar ls` / `inspect` first |\n| 7 | retryable (rate limit, capacity, unavailable) before any effect | wait, then retry once or twice |\n| 8 | partial: some steps happened | read `error.known` and `error.details` |\n\n## When things go wrong\n\n- **Never repeat a create after exit 6 or `outcome: \"unknown\"`.** The store keeps the name in status `unknown`; check `sandbar ls --json`. `sandbar rm <name>` first asks the provider how that create ended (never resending it) and destroys the sandbox if one exists. If it still exits 4 because the outcome is unknown, and the user agrees it is gone, `sandbar rm --force <name>` drops the record.\n- **`run` exits 125 with `CLEANUP_FAILED`**: the command result is in `error.known.exec`, but the sandbox is still running. Tell the user and run the `rm` from the hint.\n- **`rm` exits 8**: `error.details` lists what was `removed`, `skipped` and `failed`. Retry only the failed names once the cause is fixed.\n- **`STORE_WRITE_FAILED`**: the sandbox exists but was not recorded. The reference is in `error.known.reference`; save it to a file and destroy with `sandbar rm @file.json`.\n- Interrupting `run` (Ctrl-C, SIGTERM) still destroys its sandbox and exits 130 (143 for SIGTERM) with `INTERRUPTED`. A second signal exits immediately and may leave it running.\n\n## Discover more\n\n`sandbar schema` prints every command, flag and exit code as JSON. `sandbar <command> --help` shows usage and examples.\n";
|
|
1588
1890
|
|
|
1589
1891
|
// src/skill.ts
|
|
1590
1892
|
var skillName = "sandbar-cli";
|
|
@@ -1741,6 +2043,21 @@ async function complete(words, commands, globals, session) {
|
|
|
1741
2043
|
candidates: [{ value: "install", description: "Install the skill for your agents" }],
|
|
1742
2044
|
directive: 0
|
|
1743
2045
|
};
|
|
2046
|
+
if (command.name === "image") {
|
|
2047
|
+
if (positionals.length === 0)
|
|
2048
|
+
return { candidates: imageActions.map((action) => ({ value: action })), directive: 0 };
|
|
2049
|
+
if (positionals.length === 1 && (positionals[0] === "inspect" || positionals[0] === "forget")) {
|
|
2050
|
+
const entries = await listAll(await session.store(), "image");
|
|
2051
|
+
return {
|
|
2052
|
+
candidates: entries.map(({ record }) => ({
|
|
2053
|
+
value: record.name,
|
|
2054
|
+
description: `${record.source} on ${record.profile}`
|
|
2055
|
+
})),
|
|
2056
|
+
directive: 0
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
return none;
|
|
2060
|
+
}
|
|
1744
2061
|
if (command.name === "completion" && positionals.length === 0)
|
|
1745
2062
|
return { candidates: shells.map((shell) => ({ value: shell })), directive: 0 };
|
|
1746
2063
|
return none;
|
|
@@ -1899,7 +2216,7 @@ var createFlags = {
|
|
|
1899
2216
|
label: { type: "string", multiple: true, value: "k=v", description: "Label to set (repeatable)" },
|
|
1900
2217
|
build: {
|
|
1901
2218
|
type: "boolean",
|
|
1902
|
-
description: "Build --image
|
|
2219
|
+
description: "Build --image now if this provider needs a build and none is recorded"
|
|
1903
2220
|
}
|
|
1904
2221
|
};
|
|
1905
2222
|
var execFlags = {
|
|
@@ -2021,6 +2338,28 @@ var commands = [
|
|
|
2021
2338
|
],
|
|
2022
2339
|
run: runCommand
|
|
2023
2340
|
},
|
|
2341
|
+
{
|
|
2342
|
+
name: "image",
|
|
2343
|
+
summary: "Build OCI images for providers that need it, and list or forget the builds",
|
|
2344
|
+
usage: "sandbar image build <oci-ref> [--name <name>] [--refresh] | ls | inspect <name> | forget <name>",
|
|
2345
|
+
flags: {
|
|
2346
|
+
name: {
|
|
2347
|
+
type: "string",
|
|
2348
|
+
value: "name",
|
|
2349
|
+
description: "Record name for a build (default: from the reference, like python-3-12)"
|
|
2350
|
+
},
|
|
2351
|
+
refresh: { type: "boolean", description: "Build again even if this reference is recorded" }
|
|
2352
|
+
},
|
|
2353
|
+
passthrough: false,
|
|
2354
|
+
remoteExit: false,
|
|
2355
|
+
examples: [
|
|
2356
|
+
"sandbar image build python:3.12 --provider e2b",
|
|
2357
|
+
"sandbar create --image python:3.12 --provider e2b",
|
|
2358
|
+
"sandbar image ls",
|
|
2359
|
+
"sandbar image forget python-3-12"
|
|
2360
|
+
],
|
|
2361
|
+
run: imageCommand
|
|
2362
|
+
},
|
|
2024
2363
|
{
|
|
2025
2364
|
name: "cp",
|
|
2026
2365
|
summary: "Copy one file to or from a sandbox",
|
|
@@ -2108,7 +2447,7 @@ var exitCodes = [
|
|
|
2108
2447
|
{ code: 3, meaning: "not found" },
|
|
2109
2448
|
{
|
|
2110
2449
|
code: 4,
|
|
2111
|
-
meaning: "unsupported by this provider or profile, adapter or password-manager CLI not installed, or store limit reached"
|
|
2450
|
+
meaning: "unsupported by this provider or profile, adapter missing or broken, password-manager CLI not installed, or store limit reached"
|
|
2112
2451
|
},
|
|
2113
2452
|
{
|
|
2114
2453
|
code: 5,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandbar-sdk/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Agent-first command line for creating and managing Sandbar sandboxes.",
|
|
@@ -20,11 +20,17 @@
|
|
|
20
20
|
"build": "rm -rf dist && bun build src/main.ts --target node --format esm --outdir dist --external sandbar-sdk --external @sandbar-sdk/adapter --external zod"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@sandbar-sdk/adapter": "0.
|
|
24
|
-
"sandbar-sdk": "0.
|
|
23
|
+
"@sandbar-sdk/adapter": "0.5.0",
|
|
24
|
+
"sandbar-sdk": "0.5.0",
|
|
25
25
|
"zod": "4.6.5"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
+
"@sandbar-sdk/boat": "0.5.0",
|
|
29
|
+
"@sandbar-sdk/boxd": "0.5.0",
|
|
30
|
+
"@sandbar-sdk/daytona": "0.5.0",
|
|
31
|
+
"@sandbar-sdk/e2b": "0.5.0",
|
|
32
|
+
"@sandbar-sdk/modal": "0.0.0",
|
|
33
|
+
"@sandbar-sdk/vercel": "0.5.0",
|
|
28
34
|
"@sandbar/provider-fake": "0.0.0"
|
|
29
35
|
},
|
|
30
36
|
"license": "Apache-2.0",
|