@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.
Files changed (3) hide show
  1. package/README.md +26 -5
  2. package/dist/main.js +567 -228
  3. 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`, and sandbox names for `exec`, `sh`, `inspect`, `rm`, `cp` and `cat`. Sandbox names come from the local store, so pressing tab never calls a provider.
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`, image builds and records, snapshots and volumes, `sandbar adapter add`, an S3-compatible store, KeePassXC and system-keyring references, and MCP mode. `--image` is passed to the adapter as an OCI image; providers that need a build first reject it before creating anything.
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.3.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 SandbarError2
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 readFile2 } from "node:fs/promises";
313
- import { dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
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 z2 } from "zod";
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
- z2.string().parse(resolveEnvReferences(value, env, `${where}.credentials.${key}`))
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 ConditionalEntry = z2.object({
445
- import: z2.string().optional(),
446
- default: z2.string().optional()
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
- function rootEntry(manifest) {
465
- const field = manifest.exports;
466
- const plain = z2.string().safeParse(field);
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 dotted = z2.object({ ".": z2.union([z2.string(), ConditionalEntry]) }).safeParse(field);
470
- const target = dotted.success ? dotted.data["."] : field;
471
- const direct = z2.string().safeParse(target);
472
- if (direct.success)
473
- return direct.data;
474
- const conditional = ConditionalEntry.safeParse(target);
475
- return conditional.success && (conditional.data.import ?? conditional.data.default) || manifest.main || "index.js";
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 = join2(packageDir, "package.json");
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 readFile2(manifestPath, "utf8")));
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 = [join2(configHome(env), "adapters", "node_modules")];
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(join2(dir, "node_modules"));
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(join2(root, spec));
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 = z2.custom(isAdapter);
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 = z2.object({ [profile.export]: AdapterExport });
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
- throw new CliError("ADAPTER_NOT_INSTALLED", `Cannot load adapter ${profile.module} for profile ${profile.name}: ${cause instanceof Error ? cause.message : cause}`, official ? `install it into ${join2(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");
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 = z2.record(z2.string(), z2.string()).parse(await secrets.resolve(profile.credentials, `${where}.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 is not available yet", "build images in a later CLI release; for now use a prepared image or a provider that boots OCI images directly");
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.image ? Image.oci(request.image) : request.prepared ? Image.prepared(request.prepared) : undefined,
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 SandbarError2) || cause.code !== "NOT_FOUND")
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 ? "this provider may need the image built first (sandbar image build arrives in a later release), or use --prepared with a provider image" : undefined);
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 SandbarError2 && cause.code === "NOT_FOUND")
1261
+ if (cause instanceof SandbarError3 && cause.code === "NOT_FOUND")
960
1262
  return "gone";
961
- if (cause instanceof SandbarError2 && cause.code === "UNSUPPORTED")
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 SandbarError2 && cause.effect !== "none")
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 SandbarError2) || cause.code !== "NOT_FOUND")
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 an image built first, `--image` fails with exit 4 before creating anything.\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";
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 first when the provider needs it (not available yet)"
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.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.3.0",
24
- "sandbar-sdk": "0.3.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",