@sandbar-sdk/cli 0.3.0 → 0.4.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 +25 -4
- package/dist/main.js +556 -228
- package/package.json +8 -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
|
|
|
@@ -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.4.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,11 @@ var package_default = {
|
|
|
30
30
|
zod: "4.6.5"
|
|
31
31
|
},
|
|
32
32
|
devDependencies: {
|
|
33
|
+
"@sandbar-sdk/boxd": "workspace:*",
|
|
34
|
+
"@sandbar-sdk/daytona": "workspace:*",
|
|
35
|
+
"@sandbar-sdk/e2b": "workspace:*",
|
|
36
|
+
"@sandbar-sdk/modal": "workspace:*",
|
|
37
|
+
"@sandbar-sdk/vercel": "workspace:*",
|
|
33
38
|
"@sandbar/provider-fake": "workspace:*"
|
|
34
39
|
},
|
|
35
40
|
license: "Apache-2.0",
|
|
@@ -51,10 +56,10 @@ import { readFile as readFile4, stat as stat2, writeFile as writeFile2 } from "n
|
|
|
51
56
|
import { basename, join as join4, resolve as resolve3 } from "node:path";
|
|
52
57
|
import {
|
|
53
58
|
AdapterSandbox,
|
|
54
|
-
Image,
|
|
59
|
+
Image as Image2,
|
|
55
60
|
NonzeroExitError,
|
|
56
61
|
ResourceReference as ResourceReference2,
|
|
57
|
-
SandbarError as
|
|
62
|
+
SandbarError as SandbarError3
|
|
58
63
|
} from "sandbar-sdk";
|
|
59
64
|
import { z as z4 } from "zod";
|
|
60
65
|
|
|
@@ -140,6 +145,7 @@ var managementExitCodes = new Map([
|
|
|
140
145
|
["NOT_FOUND", 3],
|
|
141
146
|
["UNSUPPORTED", 4],
|
|
142
147
|
["ADAPTER_NOT_INSTALLED", 4],
|
|
148
|
+
["ADAPTER_LOAD_FAILED", 4],
|
|
143
149
|
["LIMIT_REACHED", 4],
|
|
144
150
|
["SECRET_TOOL_MISSING", 4],
|
|
145
151
|
["SECRET_UNAVAILABLE", 5],
|
|
@@ -308,15 +314,454 @@ function resolveEnvReferences(value, env, where) {
|
|
|
308
314
|
return value;
|
|
309
315
|
}
|
|
310
316
|
|
|
317
|
+
// src/images.ts
|
|
318
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
319
|
+
import {
|
|
320
|
+
Image,
|
|
321
|
+
SandbarError as SandbarError2
|
|
322
|
+
} from "sandbar-sdk";
|
|
323
|
+
|
|
324
|
+
// src/store.ts
|
|
325
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
326
|
+
import { link, mkdir, readFile as readFile2, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
327
|
+
import { join as join2 } from "node:path";
|
|
328
|
+
import { ResourceReference } from "sandbar-sdk";
|
|
329
|
+
import { z as z2 } from "zod";
|
|
330
|
+
var RecordName = z2.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
331
|
+
var SandboxRecord = z2.strictObject({
|
|
332
|
+
format: z2.literal(1),
|
|
333
|
+
kind: z2.literal("sandbox"),
|
|
334
|
+
name: RecordName,
|
|
335
|
+
status: z2.enum(["creating", "created", "unknown"]),
|
|
336
|
+
profile: z2.string().min(1),
|
|
337
|
+
provider: z2.string().min(1),
|
|
338
|
+
createdAt: z2.iso.datetime({ offset: true }),
|
|
339
|
+
labels: z2.record(z2.string(), z2.string()),
|
|
340
|
+
image: z2.string().optional(),
|
|
341
|
+
reference: ResourceReference.extend({ kind: z2.literal("sandbox") }).nullable(),
|
|
342
|
+
recovery: z2.custom((value) => z2.record(z2.string(), z2.unknown()).safeParse(value).success).optional()
|
|
343
|
+
});
|
|
344
|
+
var RetainedResource = z2.strictObject({
|
|
345
|
+
kind: z2.string().min(1),
|
|
346
|
+
id: z2.string().min(1),
|
|
347
|
+
ownership: z2.enum(["verified", "unknown"]),
|
|
348
|
+
cleanup: z2.enum(["manual", "provider_expiry", "none_known"])
|
|
349
|
+
});
|
|
350
|
+
var ImageRecord = z2.strictObject({
|
|
351
|
+
format: z2.literal(1),
|
|
352
|
+
kind: z2.literal("image"),
|
|
353
|
+
name: RecordName,
|
|
354
|
+
status: z2.enum(["building", "built", "unknown"]),
|
|
355
|
+
profile: z2.string().min(1),
|
|
356
|
+
provider: z2.string().min(1),
|
|
357
|
+
createdAt: z2.iso.datetime({ offset: true }),
|
|
358
|
+
source: z2.string().min(1),
|
|
359
|
+
prepared: z2.strictObject({
|
|
360
|
+
kind: z2.literal("prepared"),
|
|
361
|
+
value: z2.string().min(1),
|
|
362
|
+
provider: z2.string().min(1),
|
|
363
|
+
scope: ResourceReference.shape.scope
|
|
364
|
+
}).nullable(),
|
|
365
|
+
retainedResources: z2.array(RetainedResource),
|
|
366
|
+
recovery: z2.custom((value) => z2.record(z2.string(), z2.unknown()).safeParse(value).success).optional()
|
|
367
|
+
});
|
|
368
|
+
var schemas = { sandbox: SandboxRecord, image: ImageRecord };
|
|
369
|
+
var folders = { sandbox: "sandboxes", image: "images" };
|
|
370
|
+
function errorCode(cause) {
|
|
371
|
+
return cause instanceof Error && "code" in cause ? String(cause.code) : undefined;
|
|
372
|
+
}
|
|
373
|
+
function versionOf(text) {
|
|
374
|
+
return createHash("sha256").update(text).digest("hex");
|
|
375
|
+
}
|
|
376
|
+
function encode(kind, record) {
|
|
377
|
+
return `${JSON.stringify(schemas[kind].parse(record), null, 2)}
|
|
378
|
+
`;
|
|
379
|
+
}
|
|
380
|
+
var STALE_LOCK_MS = 30000;
|
|
381
|
+
var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
382
|
+
|
|
383
|
+
class FsStateStore {
|
|
384
|
+
dir;
|
|
385
|
+
constructor(dir) {
|
|
386
|
+
this.dir = dir;
|
|
387
|
+
}
|
|
388
|
+
folder(kind) {
|
|
389
|
+
return join2(this.dir, folders[kind]);
|
|
390
|
+
}
|
|
391
|
+
path(kind, name) {
|
|
392
|
+
return join2(this.folder(kind), `${RecordName.parse(name)}.json`);
|
|
393
|
+
}
|
|
394
|
+
async temporary(kind, name, text) {
|
|
395
|
+
await mkdir(this.folder(kind), { recursive: true });
|
|
396
|
+
const path = `${this.path(kind, name)}.${randomBytes(6).toString("hex")}.tmp`;
|
|
397
|
+
await writeFile(path, text, { flag: "wx" });
|
|
398
|
+
return path;
|
|
399
|
+
}
|
|
400
|
+
async locked(kind, name, action) {
|
|
401
|
+
const lock = `${this.path(kind, name)}.lock`;
|
|
402
|
+
await mkdir(this.folder(kind), { recursive: true });
|
|
403
|
+
for (let attempt = 0;; attempt++) {
|
|
404
|
+
try {
|
|
405
|
+
await writeFile(lock, String(process.pid), { flag: "wx" });
|
|
406
|
+
break;
|
|
407
|
+
} catch (cause) {
|
|
408
|
+
if (errorCode(cause) !== "EEXIST")
|
|
409
|
+
throw cause;
|
|
410
|
+
const held = await stat(lock).catch(() => null);
|
|
411
|
+
if (held && Date.now() - held.mtimeMs > STALE_LOCK_MS)
|
|
412
|
+
await unlink(lock).catch(() => {});
|
|
413
|
+
else if (attempt >= 100)
|
|
414
|
+
throw new CliError("CONFLICT", `Store record ${name} is locked by another process`);
|
|
415
|
+
else
|
|
416
|
+
await sleep(20);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
return await action();
|
|
421
|
+
} finally {
|
|
422
|
+
await unlink(lock).catch(() => {});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async get(kind, name) {
|
|
426
|
+
let text;
|
|
427
|
+
try {
|
|
428
|
+
text = await readFile2(this.path(kind, name), "utf8");
|
|
429
|
+
} catch (cause) {
|
|
430
|
+
if (errorCode(cause) === "ENOENT")
|
|
431
|
+
return null;
|
|
432
|
+
throw cause;
|
|
433
|
+
}
|
|
434
|
+
let json;
|
|
435
|
+
try {
|
|
436
|
+
json = z2.json().parse(JSON.parse(text));
|
|
437
|
+
} catch {
|
|
438
|
+
throw new CliError("STORE_INVALID", `Store record ${this.path(kind, name)} is not valid JSON`);
|
|
439
|
+
}
|
|
440
|
+
const parsed = schemas[kind].safeParse(json);
|
|
441
|
+
if (!parsed.success)
|
|
442
|
+
throw new CliError("STORE_INVALID", `Store record ${this.path(kind, name)} is invalid`);
|
|
443
|
+
return { record: parsed.data, version: versionOf(text) };
|
|
444
|
+
}
|
|
445
|
+
async create(kind, name, record) {
|
|
446
|
+
const text = encode(kind, record);
|
|
447
|
+
const temporary = await this.temporary(kind, name, text);
|
|
448
|
+
try {
|
|
449
|
+
await link(temporary, this.path(kind, name));
|
|
450
|
+
} catch (cause) {
|
|
451
|
+
if (errorCode(cause) === "EEXIST")
|
|
452
|
+
throw new CliError("CONFLICT", `Name ${name} is already in use`);
|
|
453
|
+
throw cause;
|
|
454
|
+
} finally {
|
|
455
|
+
await unlink(temporary).catch(() => {});
|
|
456
|
+
}
|
|
457
|
+
return { version: versionOf(text) };
|
|
458
|
+
}
|
|
459
|
+
async update(kind, name, record, version) {
|
|
460
|
+
return this.locked(kind, name, async () => {
|
|
461
|
+
const current = await this.get(kind, name);
|
|
462
|
+
if (!current || current.version !== version)
|
|
463
|
+
throw new CliError("CONFLICT", `Store record ${name} changed concurrently`);
|
|
464
|
+
const text = encode(kind, record);
|
|
465
|
+
await rename(await this.temporary(kind, name, text), this.path(kind, name));
|
|
466
|
+
return { version: versionOf(text) };
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
async remove(kind, name, version) {
|
|
470
|
+
await this.locked(kind, name, async () => {
|
|
471
|
+
if (version !== undefined) {
|
|
472
|
+
const current = await this.get(kind, name);
|
|
473
|
+
if (current && current.version !== version)
|
|
474
|
+
throw new CliError("CONFLICT", `Store record ${name} changed concurrently`);
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
await unlink(this.path(kind, name));
|
|
478
|
+
} catch (cause) {
|
|
479
|
+
if (errorCode(cause) !== "ENOENT")
|
|
480
|
+
throw cause;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
async list(kind, cursor, limit = 1000) {
|
|
485
|
+
let files;
|
|
486
|
+
try {
|
|
487
|
+
files = await readdir(this.folder(kind));
|
|
488
|
+
} catch (cause) {
|
|
489
|
+
if (errorCode(cause) === "ENOENT")
|
|
490
|
+
return { names: [] };
|
|
491
|
+
throw cause;
|
|
492
|
+
}
|
|
493
|
+
const names = files.filter((file) => file.endsWith(".json")).map((file) => file.slice(0, -5)).filter((name) => RecordName.safeParse(name).success && (!cursor || name > cursor)).sort();
|
|
494
|
+
const page = names.slice(0, limit);
|
|
495
|
+
return { names: page, cursor: names.length > limit ? page.at(-1) : undefined };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function listAll(store, kind, onInvalid) {
|
|
499
|
+
const found = [];
|
|
500
|
+
let cursor;
|
|
501
|
+
do {
|
|
502
|
+
const page = await store.list(kind, cursor);
|
|
503
|
+
for (const name of page.names) {
|
|
504
|
+
try {
|
|
505
|
+
const entry = await store.get(kind, name);
|
|
506
|
+
if (entry)
|
|
507
|
+
found.push(entry);
|
|
508
|
+
} catch (cause) {
|
|
509
|
+
if (!(cause instanceof CliError) || cause.code !== "STORE_INVALID")
|
|
510
|
+
throw cause;
|
|
511
|
+
onInvalid?.(`warning: ${cause.message}; skipped`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
cursor = page.cursor;
|
|
515
|
+
} while (cursor);
|
|
516
|
+
return found;
|
|
517
|
+
}
|
|
518
|
+
function checkName(name) {
|
|
519
|
+
const parsed = RecordName.safeParse(name);
|
|
520
|
+
if (!parsed.success)
|
|
521
|
+
throw usage(`Invalid name ${name}`, "use 1-63 lowercase letters, digits and dashes, starting with a letter or digit");
|
|
522
|
+
return parsed.data;
|
|
523
|
+
}
|
|
524
|
+
function generatedName() {
|
|
525
|
+
return `sb-${randomBytes(3).toString("hex").slice(0, 5)}`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/images.ts
|
|
529
|
+
var BuildResult = ImageRecord.pick({ prepared: true, retainedResources: true }).extend({
|
|
530
|
+
prepared: ImageRecord.shape.prepared.unwrap()
|
|
531
|
+
});
|
|
532
|
+
function derivedName(source) {
|
|
533
|
+
const base = (source.split("/").at(-1) ?? source).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 57).replace(/^-+|-+$/g, "");
|
|
534
|
+
return RecordName.safeParse(base).success ? base : "image";
|
|
535
|
+
}
|
|
536
|
+
async function findBuild(store, profile, source) {
|
|
537
|
+
const entries = await listAll(store, "image");
|
|
538
|
+
return entries.find(({ record }) => record.profile === profile && record.source === source && record.prepared);
|
|
539
|
+
}
|
|
540
|
+
function mergeRetained(previous, next) {
|
|
541
|
+
const seen = new Set(previous.map((item) => `${item.kind}/${item.id}`));
|
|
542
|
+
return [...previous, ...next.filter((item) => !seen.has(`${item.kind}/${item.id}`))];
|
|
543
|
+
}
|
|
544
|
+
async function claim(store, record, requested) {
|
|
545
|
+
const base = requested ? checkName(requested) : derivedName(record.source);
|
|
546
|
+
for (let attempt = 0;; attempt++) {
|
|
547
|
+
const name = attempt === 0 ? base : `${base}-${randomBytes2(2).toString("hex")}`;
|
|
548
|
+
const full = { ...record, name };
|
|
549
|
+
try {
|
|
550
|
+
const { version } = await store.create("image", name, full);
|
|
551
|
+
return { record: full, version };
|
|
552
|
+
} catch (cause) {
|
|
553
|
+
if (!(cause instanceof CliError) || cause.code !== "CONFLICT" || attempt >= 8)
|
|
554
|
+
throw cause;
|
|
555
|
+
if (requested)
|
|
556
|
+
throw new CliError("CONFLICT", `Image name ${name} is already in use`, "choose another --name, or `sandbar image forget` the old record");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async function buildImage(session, client, profile, source, options) {
|
|
561
|
+
const store = await session.store();
|
|
562
|
+
const existing = await findBuild(store, profile.name, source);
|
|
563
|
+
if (existing && options.name && existing.record.name !== checkName(options.name))
|
|
564
|
+
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");
|
|
565
|
+
if (existing && !options.refresh)
|
|
566
|
+
return { entry: existing, built: false };
|
|
567
|
+
if (!(await client.capabilities()).imageBuild)
|
|
568
|
+
throw new CliError("UNSUPPORTED", `${profile.provider} does not build images`, "providers that boot OCI images directly take --image without a build");
|
|
569
|
+
const claimed = existing ?? await claim(store, {
|
|
570
|
+
format: 1,
|
|
571
|
+
kind: "image",
|
|
572
|
+
status: "building",
|
|
573
|
+
profile: profile.name,
|
|
574
|
+
provider: profile.provider,
|
|
575
|
+
createdAt: new Date().toISOString(),
|
|
576
|
+
source,
|
|
577
|
+
prepared: null,
|
|
578
|
+
retainedResources: []
|
|
579
|
+
}, options.name);
|
|
580
|
+
session.ctx.out.note(`building ${source} on ${profile.provider}; this can take a few minutes`);
|
|
581
|
+
let result;
|
|
582
|
+
try {
|
|
583
|
+
result = await client.images.build({ source: { kind: "oci", value: source } }, { signal: session.ctx.signal });
|
|
584
|
+
} catch (cause) {
|
|
585
|
+
const report = describeError(cause);
|
|
586
|
+
if (report.outcome === "rejected") {
|
|
587
|
+
if (!existing)
|
|
588
|
+
await store.remove("image", claimed.record.name, claimed.version);
|
|
589
|
+
throw cause;
|
|
590
|
+
}
|
|
591
|
+
const recovery = report.known?.recovery;
|
|
592
|
+
let kept = `the store keeps ${claimed.record.name}; \`sandbar image inspect ${claimed.record.name}\` checks the build again`;
|
|
593
|
+
if (existing)
|
|
594
|
+
kept = `${existing.record.name} still holds the previous build; the new one may exist on the provider`;
|
|
595
|
+
else
|
|
596
|
+
try {
|
|
597
|
+
await store.update("image", claimed.record.name, { ...claimed.record, status: "unknown", recovery }, claimed.version);
|
|
598
|
+
} catch (storeCause) {
|
|
599
|
+
kept = `the store could not record it (${describeError(storeCause).message}); keep the reference below`;
|
|
600
|
+
}
|
|
601
|
+
throw new CliError("OUTCOME_UNKNOWN", report.message, kept, {
|
|
602
|
+
image: claimed.record.name,
|
|
603
|
+
recovery
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const { recovery: _recovery, ...rest } = claimed.record;
|
|
607
|
+
const record = {
|
|
608
|
+
...rest,
|
|
609
|
+
status: "built",
|
|
610
|
+
createdAt: new Date().toISOString(),
|
|
611
|
+
prepared: result.prepared,
|
|
612
|
+
retainedResources: mergeRetained(claimed.record.retainedResources, result.retainedResources)
|
|
613
|
+
};
|
|
614
|
+
try {
|
|
615
|
+
const { version } = await store.update("image", record.name, record, claimed.version);
|
|
616
|
+
return { entry: { record, version }, built: true };
|
|
617
|
+
} catch (cause) {
|
|
618
|
+
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");
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
async function imageEnvironment(session, client, profile, source, build) {
|
|
622
|
+
if (!(await client.capabilities()).imageBuild)
|
|
623
|
+
return Image.oci(source);
|
|
624
|
+
const store = await session.store();
|
|
625
|
+
const found = await findBuild(store, profile.name, source);
|
|
626
|
+
if (found?.record.prepared)
|
|
627
|
+
return Image.prepared(found.record.prepared);
|
|
628
|
+
const autoBuild = (await session.config()).value.images?.autoBuild === true;
|
|
629
|
+
if (!build && !autoBuild)
|
|
630
|
+
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`);
|
|
631
|
+
const { entry } = await buildImage(session, client, profile, source, {});
|
|
632
|
+
if (!entry.record.prepared)
|
|
633
|
+
throw new Error(`Image record ${entry.record.name} has no build`);
|
|
634
|
+
return Image.prepared(entry.record.prepared);
|
|
635
|
+
}
|
|
636
|
+
function summary(record) {
|
|
637
|
+
return {
|
|
638
|
+
name: record.name,
|
|
639
|
+
source: record.source,
|
|
640
|
+
profile: record.profile,
|
|
641
|
+
provider: record.provider,
|
|
642
|
+
status: record.status,
|
|
643
|
+
createdAt: record.createdAt,
|
|
644
|
+
prepared: record.prepared?.value ?? null,
|
|
645
|
+
retainedResources: record.retainedResources
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function retainedLines(record) {
|
|
649
|
+
return record.retainedResources.map((item) => ` ${item.kind} ${item.id} (cleanup ${item.cleanup}, ownership ${item.ownership})`);
|
|
650
|
+
}
|
|
651
|
+
async function findImage(store, name) {
|
|
652
|
+
const found = await store.get("image", checkName(name));
|
|
653
|
+
if (!found)
|
|
654
|
+
throw new CliError("NOT_FOUND", `No image record named ${name}`, "list them with `sandbar image ls`");
|
|
655
|
+
return found;
|
|
656
|
+
}
|
|
657
|
+
async function recoverBuild(session, store, entry) {
|
|
658
|
+
const { record } = entry;
|
|
659
|
+
if (record.status !== "unknown" || record.recovery?.kind !== "image_build")
|
|
660
|
+
return entry;
|
|
661
|
+
const client = await session.connect(await session.profileNamed(record.profile));
|
|
662
|
+
let value;
|
|
663
|
+
try {
|
|
664
|
+
value = await (await client.recover(record.recovery)).observe();
|
|
665
|
+
} catch (cause) {
|
|
666
|
+
if (cause instanceof SandbarError2 && cause.effect !== "none")
|
|
667
|
+
return entry;
|
|
668
|
+
throw cause;
|
|
669
|
+
}
|
|
670
|
+
const parsed = BuildResult.safeParse(value);
|
|
671
|
+
if (!parsed.success)
|
|
672
|
+
return entry;
|
|
673
|
+
const { recovery: _recovery, ...rest } = record;
|
|
674
|
+
const recovered = { ...rest, status: "built", ...parsed.data };
|
|
675
|
+
const { version } = await store.update("image", record.name, recovered, entry.version);
|
|
676
|
+
session.ctx.out.note(`${record.name}: recovered the uncertain build as ${parsed.data.prepared.value}`);
|
|
677
|
+
return { record: recovered, version };
|
|
678
|
+
}
|
|
679
|
+
async function buildCommand(session, flags, args) {
|
|
680
|
+
if (args.length !== 1)
|
|
681
|
+
throw usage("Expected exactly one OCI image reference", "e.g. sandbar image build python:3.12");
|
|
682
|
+
const profile = await session.profile();
|
|
683
|
+
const client = await session.connect(profile);
|
|
684
|
+
const { entry, built } = await buildImage(session, client, profile, args[0], {
|
|
685
|
+
name: flags.string("name"),
|
|
686
|
+
refresh: flags.bool("refresh")
|
|
687
|
+
});
|
|
688
|
+
const { record } = entry;
|
|
689
|
+
session.ctx.out.success({ ...summary(record), built }, built ? record.name : `${record.name} (already built; pass --refresh to build ${record.source} again)`);
|
|
690
|
+
return 0;
|
|
691
|
+
}
|
|
692
|
+
async function lsImages(session) {
|
|
693
|
+
const store = await session.store();
|
|
694
|
+
const entries = await listAll(store, "image", (message) => session.ctx.out.note(message));
|
|
695
|
+
const rows = entries.map(({ record }) => summary(record));
|
|
696
|
+
const lines = rows.map((row) => [row.name.padEnd(20), row.profile.padEnd(12), row.status.padEnd(9), row.source].join(" "));
|
|
697
|
+
session.ctx.out.success({ images: rows }, rows.length ? [
|
|
698
|
+
`${"NAME".padEnd(20)} ${"PROFILE".padEnd(12)} ${"STATUS".padEnd(9)} SOURCE`,
|
|
699
|
+
...lines
|
|
700
|
+
].join(`
|
|
701
|
+
`) : "no image records; build one with `sandbar image build <oci-ref>`");
|
|
702
|
+
return 0;
|
|
703
|
+
}
|
|
704
|
+
async function inspectImage(session, args) {
|
|
705
|
+
if (args.length !== 1)
|
|
706
|
+
throw usage("Expected exactly one image name");
|
|
707
|
+
const store = await session.store();
|
|
708
|
+
const { record } = await recoverBuild(session, store, await findImage(store, args[0]));
|
|
709
|
+
const lines = [
|
|
710
|
+
`name ${record.name}`,
|
|
711
|
+
`source ${record.source}`,
|
|
712
|
+
`provider ${record.provider} (profile ${record.profile})`,
|
|
713
|
+
`status ${record.status}`,
|
|
714
|
+
`prepared ${record.prepared?.value ?? "none"}`,
|
|
715
|
+
`built ${record.createdAt}`,
|
|
716
|
+
...record.retainedResources.length ? ["retained", ...retainedLines(record)] : []
|
|
717
|
+
];
|
|
718
|
+
session.ctx.out.success({ ...summary(record), preparedImage: record.prepared, recovery: record.recovery ?? null }, lines.join(`
|
|
719
|
+
`));
|
|
720
|
+
return 0;
|
|
721
|
+
}
|
|
722
|
+
async function forgetImage(session, args) {
|
|
723
|
+
if (args.length !== 1)
|
|
724
|
+
throw usage("Expected exactly one image name");
|
|
725
|
+
const store = await session.store();
|
|
726
|
+
const { record, version } = await findImage(store, args[0]);
|
|
727
|
+
await store.remove("image", record.name, version);
|
|
728
|
+
const lines = [`forgot ${record.name}`];
|
|
729
|
+
if (record.retainedResources.length)
|
|
730
|
+
lines.push(`${record.provider} still holds these; delete them with the provider's own tools if you no longer need them:`, ...retainedLines(record));
|
|
731
|
+
session.ctx.out.success({ name: record.name, result: "forgotten", retainedResources: record.retainedResources }, lines.join(`
|
|
732
|
+
`));
|
|
733
|
+
return 0;
|
|
734
|
+
}
|
|
735
|
+
var imageActions = ["build", "ls", "inspect", "forget"];
|
|
736
|
+
async function imageCommand(session, flags) {
|
|
737
|
+
const [action, ...args] = flags.positionals;
|
|
738
|
+
if ((flags.string("name") || flags.bool("refresh")) && action !== "build")
|
|
739
|
+
throw usage("--name and --refresh only apply to `sandbar image build`");
|
|
740
|
+
switch (action) {
|
|
741
|
+
case "build":
|
|
742
|
+
return buildCommand(session, flags, args);
|
|
743
|
+
case "ls":
|
|
744
|
+
if (args.length)
|
|
745
|
+
throw usage("image ls takes no arguments");
|
|
746
|
+
return lsImages(session);
|
|
747
|
+
case "inspect":
|
|
748
|
+
return inspectImage(session, args);
|
|
749
|
+
case "forget":
|
|
750
|
+
return forgetImage(session, args);
|
|
751
|
+
default:
|
|
752
|
+
throw usage(action ? `Unknown image command ${action}` : "Expected an image command", `use one of: ${imageActions.join(", ")}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
311
756
|
// src/providers.ts
|
|
312
|
-
import { access, readFile as
|
|
313
|
-
import { dirname as dirname2, isAbsolute, join as
|
|
314
|
-
import { pathToFileURL } from "node:url";
|
|
757
|
+
import { access, readFile as readFile3 } from "node:fs/promises";
|
|
758
|
+
import { dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2 } from "node:path";
|
|
759
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
315
760
|
import {
|
|
316
761
|
bindAdapter
|
|
317
762
|
} from "@sandbar-sdk/adapter";
|
|
318
763
|
import { Sandbar } from "sandbar-sdk";
|
|
319
|
-
import { z as
|
|
764
|
+
import { z as z3 } from "zod";
|
|
320
765
|
var providers = [
|
|
321
766
|
{
|
|
322
767
|
id: "daytona",
|
|
@@ -412,7 +857,7 @@ function fromProfile(name, profile, loaded, env) {
|
|
|
412
857
|
}),
|
|
413
858
|
...Object.entries(profile.credentials ?? {}).map(([key, value]) => [
|
|
414
859
|
key,
|
|
415
|
-
|
|
860
|
+
z3.string().parse(resolveEnvReferences(value, env, `${where}.credentials.${key}`))
|
|
416
861
|
])
|
|
417
862
|
]);
|
|
418
863
|
const config = Object.fromEntries([
|
|
@@ -441,17 +886,9 @@ function fromProfile(name, profile, loaded, env) {
|
|
|
441
886
|
credentials
|
|
442
887
|
};
|
|
443
888
|
}
|
|
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()
|
|
889
|
+
var PackageManifest = z3.object({
|
|
890
|
+
main: z3.string().optional(),
|
|
891
|
+
exports: z3.json().optional()
|
|
455
892
|
});
|
|
456
893
|
async function exists(path) {
|
|
457
894
|
try {
|
|
@@ -461,24 +898,45 @@ async function exists(path) {
|
|
|
461
898
|
return false;
|
|
462
899
|
}
|
|
463
900
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const plain =
|
|
901
|
+
var importConditions = new Set(["import", "node", "default"]);
|
|
902
|
+
function exportTarget(value) {
|
|
903
|
+
const plain = z3.string().safeParse(value);
|
|
467
904
|
if (plain.success)
|
|
468
905
|
return plain.data;
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
906
|
+
const list = z3.array(z3.json()).safeParse(value);
|
|
907
|
+
if (list.success) {
|
|
908
|
+
for (const item of list.data) {
|
|
909
|
+
const target = exportTarget(item);
|
|
910
|
+
if (target)
|
|
911
|
+
return target;
|
|
912
|
+
}
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
const conditions = z3.record(z3.string(), z3.json()).safeParse(value);
|
|
916
|
+
if (!conditions.success)
|
|
917
|
+
return null;
|
|
918
|
+
for (const [condition, nested] of Object.entries(conditions.data)) {
|
|
919
|
+
if (!importConditions.has(condition))
|
|
920
|
+
continue;
|
|
921
|
+
const target = exportTarget(nested);
|
|
922
|
+
if (target)
|
|
923
|
+
return target;
|
|
924
|
+
}
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
function rootEntry(manifest) {
|
|
928
|
+
const exports = manifest.exports;
|
|
929
|
+
if (exports === undefined || exports === null)
|
|
930
|
+
return manifest.main || "index.js";
|
|
931
|
+
const subpaths = z3.record(z3.string(), z3.json()).safeParse(exports);
|
|
932
|
+
const root = subpaths.success && Object.keys(subpaths.data).some((key) => key.startsWith(".")) ? subpaths.data["."] : exports;
|
|
933
|
+
return (root === undefined ? null : exportTarget(root)) ?? manifest.main ?? "index.js";
|
|
476
934
|
}
|
|
477
935
|
async function entryFor(packageDir) {
|
|
478
|
-
const manifestPath =
|
|
936
|
+
const manifestPath = join3(packageDir, "package.json");
|
|
479
937
|
if (!await exists(manifestPath))
|
|
480
938
|
return null;
|
|
481
|
-
const manifest = PackageManifest.safeParse(JSON.parse(await
|
|
939
|
+
const manifest = PackageManifest.safeParse(JSON.parse(await readFile3(manifestPath, "utf8")));
|
|
482
940
|
if (!manifest.success)
|
|
483
941
|
return null;
|
|
484
942
|
const entry = resolve2(packageDir, rootEntry(manifest.data));
|
|
@@ -494,15 +952,15 @@ async function resolveAdapterModule(profile, cwd, env) {
|
|
|
494
952
|
return pathToFileURL(resolve2(profile.baseDir, spec)).href;
|
|
495
953
|
if (!PackageName.test(spec))
|
|
496
954
|
throw usage(`Adapter module ${spec} is not a package name or a path`);
|
|
497
|
-
const roots = [
|
|
955
|
+
const roots = [join3(configHome(env), "adapters", "node_modules")];
|
|
498
956
|
if (profile.searchProject)
|
|
499
957
|
for (let dir = resolve2(cwd);; dir = dirname2(dir)) {
|
|
500
|
-
roots.push(
|
|
958
|
+
roots.push(join3(dir, "node_modules"));
|
|
501
959
|
if (dirname2(dir) === dir)
|
|
502
960
|
break;
|
|
503
961
|
}
|
|
504
962
|
for (const root of roots) {
|
|
505
|
-
const entry = await entryFor(
|
|
963
|
+
const entry = await entryFor(join3(root, spec));
|
|
506
964
|
if (entry)
|
|
507
965
|
return pathToFileURL(entry).href;
|
|
508
966
|
}
|
|
@@ -511,16 +969,24 @@ async function resolveAdapterModule(profile, cwd, env) {
|
|
|
511
969
|
function isAdapter(value) {
|
|
512
970
|
return value instanceof Object && "name" in value && "connect" in value && "config" in value;
|
|
513
971
|
}
|
|
514
|
-
var AdapterExport =
|
|
972
|
+
var AdapterExport = z3.custom(isAdapter);
|
|
973
|
+
async function adapterMissing(target, spec, message) {
|
|
974
|
+
if (target.startsWith("file:"))
|
|
975
|
+
return !await exists(fileURLToPath(target));
|
|
976
|
+
return message.includes(`'${spec}'`) || message.includes(`"${spec}"`);
|
|
977
|
+
}
|
|
515
978
|
async function loadAdapter(profile, cwd, env, secrets) {
|
|
516
979
|
const target = await resolveAdapterModule(profile, cwd, env);
|
|
517
|
-
const schema =
|
|
980
|
+
const schema = z3.object({ [profile.export]: AdapterExport });
|
|
518
981
|
let parsed;
|
|
519
982
|
try {
|
|
520
983
|
parsed = schema.safeParse(await import(target));
|
|
521
984
|
} catch (cause) {
|
|
985
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
522
986
|
const official = descriptor(profile.provider);
|
|
523
|
-
|
|
987
|
+
if (await adapterMissing(target, profile.module, message))
|
|
988
|
+
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");
|
|
989
|
+
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
990
|
}
|
|
525
991
|
if (!parsed.success)
|
|
526
992
|
throw new CliError("ADAPTER_NOT_INSTALLED", `${profile.module} has no adapter export named ${profile.export}`);
|
|
@@ -531,191 +997,13 @@ async function loadAdapter(profile, cwd, env, secrets) {
|
|
|
531
997
|
return exported;
|
|
532
998
|
const where = `profile ${profile.name}`;
|
|
533
999
|
const config = await secrets.resolve(profile.config, `${where}.config`);
|
|
534
|
-
const credentials =
|
|
1000
|
+
const credentials = z3.record(z3.string(), z3.string()).parse(await secrets.resolve(profile.credentials, `${where}.credentials`));
|
|
535
1001
|
return bindAdapter(exported, config, credentials);
|
|
536
1002
|
}
|
|
537
1003
|
async function connect(profile, cwd, env, secrets) {
|
|
538
1004
|
return Sandbar.connect(await loadAdapter(profile, cwd, env, secrets));
|
|
539
1005
|
}
|
|
540
1006
|
|
|
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
1007
|
// src/units.ts
|
|
720
1008
|
var durationUnits = new Map([
|
|
721
1009
|
["", 1],
|
|
@@ -777,9 +1065,10 @@ class Flags {
|
|
|
777
1065
|
function createRequest(flags) {
|
|
778
1066
|
if (flags.string("image") && flags.string("prepared"))
|
|
779
1067
|
throw usage("Pass either --image or --prepared, not both");
|
|
780
|
-
if (flags.bool("build"))
|
|
781
|
-
throw usage("--build
|
|
1068
|
+
if (flags.bool("build") && !flags.string("image"))
|
|
1069
|
+
throw usage("--build needs --image");
|
|
782
1070
|
return {
|
|
1071
|
+
build: flags.bool("build"),
|
|
783
1072
|
name: flags.string("name"),
|
|
784
1073
|
ifNotExists: flags.bool("if-not-exists"),
|
|
785
1074
|
image: flags.string("image"),
|
|
@@ -790,7 +1079,7 @@ function createRequest(flags) {
|
|
|
790
1079
|
}
|
|
791
1080
|
function createInput(request) {
|
|
792
1081
|
return {
|
|
793
|
-
environment: request.
|
|
1082
|
+
environment: request.prepared ? Image2.prepared(request.prepared) : undefined,
|
|
794
1083
|
network: request.network,
|
|
795
1084
|
labels: Object.keys(request.labels).length ? request.labels : undefined
|
|
796
1085
|
};
|
|
@@ -824,7 +1113,7 @@ async function createSandbox(session, request) {
|
|
|
824
1113
|
const box2 = await session.reopen(current.record, client2);
|
|
825
1114
|
return { client: client2, box: box2, ...current, existing: true };
|
|
826
1115
|
} catch (cause) {
|
|
827
|
-
if (!(cause instanceof
|
|
1116
|
+
if (!(cause instanceof SandbarError3) || cause.code !== "NOT_FOUND")
|
|
828
1117
|
throw cause;
|
|
829
1118
|
await store.remove("sandbox", current.record.name, current.version);
|
|
830
1119
|
}
|
|
@@ -848,10 +1137,12 @@ async function createSandbox(session, request) {
|
|
|
848
1137
|
const input = createInput(request);
|
|
849
1138
|
try {
|
|
850
1139
|
client = await session.connect(profile);
|
|
1140
|
+
if (request.image)
|
|
1141
|
+
input.environment = await imageEnvironment(session, client, profile, request.image, request.build);
|
|
851
1142
|
if (input.environment) {
|
|
852
1143
|
const plan = await client.sandboxes.checkCreate(input);
|
|
853
1144
|
if (plan.status === "unsupported" || plan.status === "unavailable")
|
|
854
|
-
throw new CliError("UNSUPPORTED", `${profile.provider} cannot create this sandbox: ${plan.reason}`, request.image ? "
|
|
1145
|
+
throw new CliError("UNSUPPORTED", `${profile.provider} cannot create this sandbox: ${plan.reason}`, request.image ? "use --prepared with a provider image instead" : undefined);
|
|
855
1146
|
}
|
|
856
1147
|
} catch (cause) {
|
|
857
1148
|
await store.remove("sandbox", claimed.record.name, claimed.version);
|
|
@@ -956,9 +1247,9 @@ async function stateOf(session, record, client) {
|
|
|
956
1247
|
const box = await session.reopen(record, client);
|
|
957
1248
|
return (await box.inspect({ signal: session.ctx.signal })).state;
|
|
958
1249
|
} catch (cause) {
|
|
959
|
-
if (cause instanceof
|
|
1250
|
+
if (cause instanceof SandbarError3 && cause.code === "NOT_FOUND")
|
|
960
1251
|
return "gone";
|
|
961
|
-
if (cause instanceof
|
|
1252
|
+
if (cause instanceof SandbarError3 && cause.code === "UNSUPPORTED")
|
|
962
1253
|
return "unknown";
|
|
963
1254
|
return "unreachable";
|
|
964
1255
|
}
|
|
@@ -1059,7 +1350,7 @@ async function recoverCreate(session, store, target) {
|
|
|
1059
1350
|
try {
|
|
1060
1351
|
value = await (await client.recover(record.recovery)).observe();
|
|
1061
1352
|
} catch (cause) {
|
|
1062
|
-
if (cause instanceof
|
|
1353
|
+
if (cause instanceof SandbarError3 && cause.effect !== "none")
|
|
1063
1354
|
return target;
|
|
1064
1355
|
throw cause;
|
|
1065
1356
|
}
|
|
@@ -1098,7 +1389,7 @@ async function removeOne(session, store, found, force, all) {
|
|
|
1098
1389
|
try {
|
|
1099
1390
|
box = await session.reopen(record);
|
|
1100
1391
|
} catch (cause) {
|
|
1101
|
-
if (!(cause instanceof
|
|
1392
|
+
if (!(cause instanceof SandbarError3) || cause.code !== "NOT_FOUND")
|
|
1102
1393
|
throw cause;
|
|
1103
1394
|
await forget(store, target);
|
|
1104
1395
|
return { kind: "removed", item: { name: record.name, result: "gone", retainedResources: [] } };
|
|
@@ -1584,7 +1875,7 @@ import { homedir as homedir2 } from "node:os";
|
|
|
1584
1875
|
import { dirname as dirname3, join as join5 } from "node:path";
|
|
1585
1876
|
|
|
1586
1877
|
// ../../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
|
|
1878
|
+
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`), 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
1879
|
|
|
1589
1880
|
// src/skill.ts
|
|
1590
1881
|
var skillName = "sandbar-cli";
|
|
@@ -1741,6 +2032,21 @@ async function complete(words, commands, globals, session) {
|
|
|
1741
2032
|
candidates: [{ value: "install", description: "Install the skill for your agents" }],
|
|
1742
2033
|
directive: 0
|
|
1743
2034
|
};
|
|
2035
|
+
if (command.name === "image") {
|
|
2036
|
+
if (positionals.length === 0)
|
|
2037
|
+
return { candidates: imageActions.map((action) => ({ value: action })), directive: 0 };
|
|
2038
|
+
if (positionals.length === 1 && (positionals[0] === "inspect" || positionals[0] === "forget")) {
|
|
2039
|
+
const entries = await listAll(await session.store(), "image");
|
|
2040
|
+
return {
|
|
2041
|
+
candidates: entries.map(({ record }) => ({
|
|
2042
|
+
value: record.name,
|
|
2043
|
+
description: `${record.source} on ${record.profile}`
|
|
2044
|
+
})),
|
|
2045
|
+
directive: 0
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
return none;
|
|
2049
|
+
}
|
|
1744
2050
|
if (command.name === "completion" && positionals.length === 0)
|
|
1745
2051
|
return { candidates: shells.map((shell) => ({ value: shell })), directive: 0 };
|
|
1746
2052
|
return none;
|
|
@@ -1899,7 +2205,7 @@ var createFlags = {
|
|
|
1899
2205
|
label: { type: "string", multiple: true, value: "k=v", description: "Label to set (repeatable)" },
|
|
1900
2206
|
build: {
|
|
1901
2207
|
type: "boolean",
|
|
1902
|
-
description: "Build --image
|
|
2208
|
+
description: "Build --image now if this provider needs a build and none is recorded"
|
|
1903
2209
|
}
|
|
1904
2210
|
};
|
|
1905
2211
|
var execFlags = {
|
|
@@ -2021,6 +2327,28 @@ var commands = [
|
|
|
2021
2327
|
],
|
|
2022
2328
|
run: runCommand
|
|
2023
2329
|
},
|
|
2330
|
+
{
|
|
2331
|
+
name: "image",
|
|
2332
|
+
summary: "Build OCI images for providers that need it, and list or forget the builds",
|
|
2333
|
+
usage: "sandbar image build <oci-ref> [--name <name>] [--refresh] | ls | inspect <name> | forget <name>",
|
|
2334
|
+
flags: {
|
|
2335
|
+
name: {
|
|
2336
|
+
type: "string",
|
|
2337
|
+
value: "name",
|
|
2338
|
+
description: "Record name for a build (default: from the reference, like python-3-12)"
|
|
2339
|
+
},
|
|
2340
|
+
refresh: { type: "boolean", description: "Build again even if this reference is recorded" }
|
|
2341
|
+
},
|
|
2342
|
+
passthrough: false,
|
|
2343
|
+
remoteExit: false,
|
|
2344
|
+
examples: [
|
|
2345
|
+
"sandbar image build python:3.12 --provider e2b",
|
|
2346
|
+
"sandbar create --image python:3.12 --provider e2b",
|
|
2347
|
+
"sandbar image ls",
|
|
2348
|
+
"sandbar image forget python-3-12"
|
|
2349
|
+
],
|
|
2350
|
+
run: imageCommand
|
|
2351
|
+
},
|
|
2024
2352
|
{
|
|
2025
2353
|
name: "cp",
|
|
2026
2354
|
summary: "Copy one file to or from a sandbox",
|
|
@@ -2108,7 +2436,7 @@ var exitCodes = [
|
|
|
2108
2436
|
{ code: 3, meaning: "not found" },
|
|
2109
2437
|
{
|
|
2110
2438
|
code: 4,
|
|
2111
|
-
meaning: "unsupported by this provider or profile, adapter or password-manager CLI not installed, or store limit reached"
|
|
2439
|
+
meaning: "unsupported by this provider or profile, adapter missing or broken, password-manager CLI not installed, or store limit reached"
|
|
2112
2440
|
},
|
|
2113
2441
|
{
|
|
2114
2442
|
code: 5,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandbar-sdk/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,16 @@
|
|
|
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.4.0",
|
|
24
|
+
"sandbar-sdk": "0.4.0",
|
|
25
25
|
"zod": "4.6.5"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
+
"@sandbar-sdk/boxd": "0.4.0",
|
|
29
|
+
"@sandbar-sdk/daytona": "0.4.0",
|
|
30
|
+
"@sandbar-sdk/e2b": "0.4.0",
|
|
31
|
+
"@sandbar-sdk/modal": "0.0.0",
|
|
32
|
+
"@sandbar-sdk/vercel": "0.4.0",
|
|
28
33
|
"@sandbar/provider-fake": "0.0.0"
|
|
29
34
|
},
|
|
30
35
|
"license": "Apache-2.0",
|