@sandbar-sdk/cli 0.5.0 → 0.6.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 +4 -4
- package/dist/main.js +460 -51
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ The adapter package must be installed where the CLI can find it: in `~/.config/s
|
|
|
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
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; `
|
|
33
|
+
| `image ls [--remote]`, `image inspect <name>`, `image rm <name> [--force]`, `image forget <name>` | List, show, delete or drop image records; `ls --remote` lists the provider's images, `rm` deletes a built image on the provider (only builds this CLI created unless `--force`), `forget` only drops the record |
|
|
34
34
|
| `cp <src> <dst>` | Copy one file; one side is `<sandbox>:/path` |
|
|
35
35
|
| `cat <sandbox>:/path` | Print a file |
|
|
36
36
|
| `doctor` | Check config, credentials and the connection; creates nothing |
|
|
@@ -69,7 +69,7 @@ source <(sandbar completion bash) # add to ~/.bashrc
|
|
|
69
69
|
sandbar completion fish > ~/.config/fish/completions/sandbar.fish
|
|
70
70
|
```
|
|
71
71
|
|
|
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.
|
|
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`, `image rm` and `image forget`. Sandbox names come from the local store, so pressing tab never calls a provider.
|
|
73
73
|
|
|
74
74
|
## Agent skill
|
|
75
75
|
|
|
@@ -127,7 +127,7 @@ If the tool is not installed the command exits 4 (`SECRET_TOOL_MISSING`); if it
|
|
|
127
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
128
|
- Vercel takes neither and rejects `--image`.
|
|
129
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
|
|
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 rm <name>` deletes a built image on the provider through `images.delete` and then drops the record; it works where the provider supports image deletion (E2B and Daytona) and exits 4 with `UNSUPPORTED` elsewhere. Without `--force` it only deletes builds this CLI verifiably created, so a build recovered from an older record or of unknown ownership needs `--force`. It refuses, with no effect, a record without a confirmed build, a record that shares the same prepared image, and an image the provider reports as public, shared or still in use. An image that is already gone counts as deleted. If the delete's outcome is unknown, `rm` exits 6, keeps the record in a `deleting` state with its recovery reference, and the next `image rm` asks the provider how the original request ended instead of sending it again. If the provider gives no operation to check, the record stays built and `rm` says so; check the provider before running it again, which sends a new delete. If that check itself fails (for example the profile now reaches another account), the record stays `deleting` with its recovery reference, so a later `image rm` from the original account can still check it. `image forget` removes the record without deleting anything and prints the resources the provider still holds. `image ls --remote` lists the images the provider reports for the profile (up to 1,000), marks the ones with a local record, and says how complete the provider's listing is; a listed image is not proof that you own it. 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
131
|
|
|
132
132
|
## State store
|
|
133
133
|
|
|
@@ -135,7 +135,7 @@ Sandbox references are kept as one JSON file per sandbox in `~/.config/sandbar/s
|
|
|
135
135
|
|
|
136
136
|
## Not yet
|
|
137
137
|
|
|
138
|
-
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`, snapshots and volumes, `sandbar adapter add`, an S3-compatible store, KeePassXC and system-keyring references, and MCP mode.
|
|
139
139
|
|
|
140
140
|
## Development
|
|
141
141
|
|
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.6.0",
|
|
9
9
|
private: false,
|
|
10
10
|
type: "module",
|
|
11
11
|
description: "Agent-first command line for creating and managing Sandbar sandboxes.",
|
|
@@ -60,9 +60,10 @@ import {
|
|
|
60
60
|
Image as Image2,
|
|
61
61
|
NonzeroExitError,
|
|
62
62
|
ResourceReference as ResourceReference2,
|
|
63
|
-
SandbarError as SandbarError3
|
|
63
|
+
SandbarError as SandbarError3,
|
|
64
|
+
assertResourceScope
|
|
64
65
|
} from "sandbar-sdk";
|
|
65
|
-
import { z as
|
|
66
|
+
import { z as z5 } from "zod";
|
|
66
67
|
|
|
67
68
|
// src/config.ts
|
|
68
69
|
import { readFile } from "node:fs/promises";
|
|
@@ -317,6 +318,7 @@ function resolveEnvReferences(value, env, where) {
|
|
|
317
318
|
|
|
318
319
|
// src/images.ts
|
|
319
320
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
321
|
+
import { z as z3 } from "zod";
|
|
320
322
|
import {
|
|
321
323
|
Image,
|
|
322
324
|
SandbarError as SandbarError2
|
|
@@ -340,6 +342,7 @@ var SandboxRecord = z2.strictObject({
|
|
|
340
342
|
labels: z2.record(z2.string(), z2.string()),
|
|
341
343
|
image: z2.string().optional(),
|
|
342
344
|
reference: ResourceReference.extend({ kind: z2.literal("sandbox") }).nullable(),
|
|
345
|
+
adopted: z2.literal(true).optional(),
|
|
343
346
|
recovery: z2.custom((value) => z2.record(z2.string(), z2.unknown()).safeParse(value).success).optional()
|
|
344
347
|
});
|
|
345
348
|
var RetainedResource = z2.strictObject({
|
|
@@ -352,7 +355,7 @@ var ImageRecord = z2.strictObject({
|
|
|
352
355
|
format: z2.literal(1),
|
|
353
356
|
kind: z2.literal("image"),
|
|
354
357
|
name: RecordName,
|
|
355
|
-
status: z2.enum(["building", "built", "unknown"]),
|
|
358
|
+
status: z2.enum(["building", "built", "unknown", "deleting"]),
|
|
356
359
|
profile: z2.string().min(1),
|
|
357
360
|
provider: z2.string().min(1),
|
|
358
361
|
createdAt: z2.iso.datetime({ offset: true }),
|
|
@@ -364,6 +367,7 @@ var ImageRecord = z2.strictObject({
|
|
|
364
367
|
scope: ResourceReference.shape.scope
|
|
365
368
|
}).nullable(),
|
|
366
369
|
retainedResources: z2.array(RetainedResource),
|
|
370
|
+
reference: ResourceReference.extend({ kind: z2.literal("image") }).optional(),
|
|
367
371
|
recovery: z2.custom((value) => z2.record(z2.string(), z2.unknown()).safeParse(value).success).optional()
|
|
368
372
|
});
|
|
369
373
|
var schemas = { sandbox: SandboxRecord, image: ImageRecord };
|
|
@@ -527,16 +531,21 @@ function generatedName() {
|
|
|
527
531
|
}
|
|
528
532
|
|
|
529
533
|
// src/images.ts
|
|
530
|
-
var BuildResult = ImageRecord.pick({
|
|
534
|
+
var BuildResult = ImageRecord.pick({
|
|
535
|
+
prepared: true,
|
|
536
|
+
retainedResources: true,
|
|
537
|
+
reference: true
|
|
538
|
+
}).extend({
|
|
531
539
|
prepared: ImageRecord.shape.prepared.unwrap()
|
|
532
540
|
});
|
|
541
|
+
var DeleteResult = z3.looseObject({ deleted: z3.literal(true) });
|
|
533
542
|
function derivedName(source) {
|
|
534
543
|
const base = (source.split("/").at(-1) ?? source).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 57).replace(/^-+|-+$/g, "");
|
|
535
544
|
return RecordName.safeParse(base).success ? base : "image";
|
|
536
545
|
}
|
|
537
546
|
async function findBuild(store, profile, source) {
|
|
538
547
|
const entries = await listAll(store, "image");
|
|
539
|
-
return entries.find(({ record }) => record.profile === profile && record.source === source && record.prepared);
|
|
548
|
+
return entries.find(({ record }) => record.profile === profile && record.source === source && record.prepared && record.status !== "deleting");
|
|
540
549
|
}
|
|
541
550
|
function mergeRetained(previous, next) {
|
|
542
551
|
const seen = new Set(previous.map((item) => `${item.kind}/${item.id}`));
|
|
@@ -610,7 +619,8 @@ async function buildImage(session, client, profile, source, options) {
|
|
|
610
619
|
status: "built",
|
|
611
620
|
createdAt: new Date().toISOString(),
|
|
612
621
|
prepared: result.prepared,
|
|
613
|
-
retainedResources: mergeRetained(claimed.record.retainedResources, result.retainedResources)
|
|
622
|
+
retainedResources: mergeRetained(claimed.record.retainedResources, result.retainedResources),
|
|
623
|
+
reference: result.reference
|
|
614
624
|
};
|
|
615
625
|
try {
|
|
616
626
|
const { version } = await store.update("image", record.name, record, claimed.version);
|
|
@@ -716,7 +726,12 @@ async function inspectImage(session, args) {
|
|
|
716
726
|
`built ${record.createdAt}`,
|
|
717
727
|
...record.retainedResources.length ? ["retained", ...retainedLines(record)] : []
|
|
718
728
|
];
|
|
719
|
-
session.ctx.out.success({
|
|
729
|
+
session.ctx.out.success({
|
|
730
|
+
...summary(record),
|
|
731
|
+
preparedImage: record.prepared,
|
|
732
|
+
reference: record.reference ?? null,
|
|
733
|
+
recovery: record.recovery ?? null
|
|
734
|
+
}, lines.join(`
|
|
720
735
|
`));
|
|
721
736
|
return 0;
|
|
722
737
|
}
|
|
@@ -728,25 +743,171 @@ async function forgetImage(session, args) {
|
|
|
728
743
|
await store.remove("image", record.name, version);
|
|
729
744
|
const lines = [`forgot ${record.name}`];
|
|
730
745
|
if (record.retainedResources.length)
|
|
731
|
-
lines.push(`${record.provider} still holds these;
|
|
746
|
+
lines.push(`${record.provider} still holds these; forget never deletes them (\`sandbar image rm\` does, where the provider supports it):`, ...retainedLines(record));
|
|
732
747
|
session.ctx.out.success({ name: record.name, result: "forgotten", retainedResources: record.retainedResources }, lines.join(`
|
|
733
748
|
`));
|
|
734
749
|
return 0;
|
|
735
750
|
}
|
|
736
|
-
|
|
751
|
+
function recordReference(record) {
|
|
752
|
+
if (record.reference)
|
|
753
|
+
return record.reference;
|
|
754
|
+
if (!record.prepared)
|
|
755
|
+
return null;
|
|
756
|
+
return {
|
|
757
|
+
version: 1,
|
|
758
|
+
kind: "image",
|
|
759
|
+
provider: record.prepared.provider,
|
|
760
|
+
scope: structuredClone(record.prepared.scope),
|
|
761
|
+
nativeId: record.prepared.value,
|
|
762
|
+
ownership: "unknown"
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
async function forgetDeleted(store, entry) {
|
|
766
|
+
const current = await store.get("image", entry.record.name);
|
|
767
|
+
if (current?.version === entry.version)
|
|
768
|
+
await store.remove("image", entry.record.name, entry.version);
|
|
769
|
+
}
|
|
770
|
+
function deletedOutput(session, record, result) {
|
|
771
|
+
const prepared = record.prepared?.value ?? null;
|
|
772
|
+
const others = record.retainedResources.filter((item) => item.id !== prepared);
|
|
773
|
+
const lines = [result === "gone" ? `${record.name} was already gone` : `deleted ${record.name}`];
|
|
774
|
+
if (others.length)
|
|
775
|
+
lines.push(`${record.provider} still holds these from earlier builds:`, ...retainedLines({ ...record, retainedResources: others }));
|
|
776
|
+
session.ctx.out.success({ name: record.name, result, prepared, retainedResources: others }, lines.join(`
|
|
777
|
+
`));
|
|
778
|
+
return 0;
|
|
779
|
+
}
|
|
780
|
+
async function rmImage(session, args, force) {
|
|
781
|
+
if (args.length !== 1)
|
|
782
|
+
throw usage("Expected exactly one image name");
|
|
783
|
+
const store = await session.store();
|
|
784
|
+
const entry = await recoverBuild(session, store, await findImage(store, args[0]));
|
|
785
|
+
const { record } = entry;
|
|
786
|
+
const client = await session.connect(await session.profileNamed(record.profile));
|
|
787
|
+
if (record.status === "deleting") {
|
|
788
|
+
if (record.recovery?.kind !== "image_delete") {
|
|
789
|
+
const { recovery: _recovery, ...rest } = record;
|
|
790
|
+
await store.update("image", record.name, { ...rest, status: "built" }, entry.version);
|
|
791
|
+
throw new CliError("OUTCOME_UNKNOWN", `${record.name} has no saved delete to check`, `the store keeps ${record.name} as built; check the provider (\`sandbar image ls --remote\`) before running \`sandbar image rm ${record.name}\` again, which sends a new delete`, { image: record.name });
|
|
792
|
+
}
|
|
793
|
+
let value;
|
|
794
|
+
try {
|
|
795
|
+
value = await (await client.recover(record.recovery)).observe();
|
|
796
|
+
} catch (cause) {
|
|
797
|
+
if (!(cause instanceof SandbarError2))
|
|
798
|
+
throw cause;
|
|
799
|
+
if (cause.effect === "none")
|
|
800
|
+
throw new CliError(cause.code, cause.message, `the store keeps ${record.name} deleting; run \`sandbar image rm ${record.name}\` again with profile ${record.profile} connected to the account the delete was sent from`, { image: record.name, recovery: record.recovery });
|
|
801
|
+
}
|
|
802
|
+
if (!DeleteResult.safeParse(value).success)
|
|
803
|
+
throw new CliError("OUTCOME_UNKNOWN", `The delete of ${record.name} is still unconfirmed`, `the store keeps ${record.name}; run \`sandbar image rm ${record.name}\` again later`, { image: record.name, recovery: record.recovery });
|
|
804
|
+
await forgetDeleted(store, entry);
|
|
805
|
+
return deletedOutput(session, record, "deleted");
|
|
806
|
+
}
|
|
807
|
+
const reference = recordReference(record);
|
|
808
|
+
if (record.status !== "built" || !reference)
|
|
809
|
+
throw new CliError("CONFLICT", `${record.name} has no confirmed build to delete`, "`sandbar image inspect` checks an uncertain build; `sandbar image forget` drops the record");
|
|
810
|
+
if (reference.ownership !== "verified-created" && !force)
|
|
811
|
+
throw new CliError("FORBIDDEN", `${record.name} was not created by this CLI (ownership ${reference.ownership})`, "pass --force to delete it anyway", { image: record.name });
|
|
812
|
+
const capabilities = await client.capabilities();
|
|
813
|
+
if (capabilities.imageManagement.delete.status !== "supported")
|
|
814
|
+
throw new CliError("UNSUPPORTED", `${record.provider} cannot delete images through Sandbar`, "`sandbar image forget` drops the record; delete the image with the provider's own tools");
|
|
815
|
+
const shared = (await listAll(store, "image")).find((other) => other.record.name !== record.name && other.record.profile === record.profile && other.record.prepared?.value === reference.nativeId);
|
|
816
|
+
if (shared)
|
|
817
|
+
throw new CliError("CONFLICT", `${record.name} and ${shared.record.name} record the same provider image`, "forget one record first, so the other is not left pointing at a deleted image");
|
|
818
|
+
try {
|
|
819
|
+
await client.images.delete(reference, { signal: session.ctx.signal });
|
|
820
|
+
} catch (cause) {
|
|
821
|
+
const report = describeError(cause);
|
|
822
|
+
if (report.outcome === "rejected" && report.code === "NOT_FOUND") {
|
|
823
|
+
await forgetDeleted(store, entry);
|
|
824
|
+
return deletedOutput(session, record, "gone");
|
|
825
|
+
}
|
|
826
|
+
if (report.outcome === "rejected")
|
|
827
|
+
throw cause;
|
|
828
|
+
const recovery = report.known?.recovery;
|
|
829
|
+
if (!recovery)
|
|
830
|
+
throw new CliError("OUTCOME_UNKNOWN", report.message, `the store keeps ${record.name} as built and there is no saved delete to check; check the provider (\`sandbar image ls --remote\`) before running \`sandbar image rm ${record.name}\` again, which sends a new delete`, { image: record.name });
|
|
831
|
+
let kept = `the store keeps ${record.name}; run \`sandbar image rm ${record.name}\` again to check`;
|
|
832
|
+
try {
|
|
833
|
+
await store.update("image", record.name, { ...record, status: "deleting", recovery }, entry.version);
|
|
834
|
+
} catch (storeCause) {
|
|
835
|
+
kept = `the store could not record it (${describeError(storeCause).message}); keep the reference below`;
|
|
836
|
+
}
|
|
837
|
+
throw new CliError("OUTCOME_UNKNOWN", report.message, kept, {
|
|
838
|
+
image: record.name,
|
|
839
|
+
recovery
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
await forgetDeleted(store, entry);
|
|
843
|
+
return deletedOutput(session, record, "deleted");
|
|
844
|
+
}
|
|
845
|
+
function remoteRow(info, records) {
|
|
846
|
+
const recorded = records.find(({ record }) => record.prepared?.value === info.reference.nativeId)?.record.name;
|
|
847
|
+
return {
|
|
848
|
+
id: info.reference.nativeId,
|
|
849
|
+
state: info.state,
|
|
850
|
+
visibility: info.visibility,
|
|
851
|
+
source: info.source?.value ?? null,
|
|
852
|
+
createdAt: info.createdAt,
|
|
853
|
+
record: recorded ?? null,
|
|
854
|
+
reference: info.reference
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
async function lsRemote(session) {
|
|
858
|
+
const profile = await session.profile();
|
|
859
|
+
const client = await session.connect(profile);
|
|
860
|
+
const capabilities = await client.capabilities();
|
|
861
|
+
if (capabilities.imageManagement.list.status !== "supported")
|
|
862
|
+
throw new CliError("UNSUPPORTED", `${profile.provider} cannot list images through Sandbar`, "`sandbar image ls` shows the builds this store recorded");
|
|
863
|
+
const records = (await listAll(await session.store(), "image")).filter(({ record }) => record.profile === profile.name);
|
|
864
|
+
const rows = [];
|
|
865
|
+
let cursor;
|
|
866
|
+
let coverage = capabilities.imageManagement.list.value.coverage;
|
|
867
|
+
for (let page = 0;page < 10; page++) {
|
|
868
|
+
const result = await client.images.list({ limit: 100, cursor });
|
|
869
|
+
coverage = result.coverage;
|
|
870
|
+
rows.push(...result.items.map((info) => remoteRow(info, records)));
|
|
871
|
+
cursor = result.nextCursor;
|
|
872
|
+
if (!cursor)
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
if (cursor)
|
|
876
|
+
session.ctx.out.note(`showing the first ${rows.length} images; more exist`);
|
|
877
|
+
const lines = rows.map((row) => [
|
|
878
|
+
row.id.padEnd(28),
|
|
879
|
+
row.state.padEnd(9),
|
|
880
|
+
row.visibility.padEnd(10),
|
|
881
|
+
(row.record ?? "-").padEnd(20),
|
|
882
|
+
row.source ?? "-"
|
|
883
|
+
].join(" "));
|
|
884
|
+
session.ctx.out.success({ profile: profile.name, coverage, images: rows, truncated: !!cursor }, rows.length ? [
|
|
885
|
+
`${"ID".padEnd(28)} ${"STATE".padEnd(9)} ${"VISIBILITY".padEnd(10)} ${"RECORD".padEnd(20)} SOURCE`,
|
|
886
|
+
...lines
|
|
887
|
+
].join(`
|
|
888
|
+
`) : `no images on ${profile.provider} for profile ${profile.name}`);
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
var imageActions = ["build", "ls", "inspect", "rm", "forget"];
|
|
737
892
|
async function imageCommand(session, flags) {
|
|
738
893
|
const [action, ...args] = flags.positionals;
|
|
739
894
|
if ((flags.string("name") || flags.bool("refresh")) && action !== "build")
|
|
740
895
|
throw usage("--name and --refresh only apply to `sandbar image build`");
|
|
896
|
+
if (flags.bool("remote") && action !== "ls")
|
|
897
|
+
throw usage("--remote only applies to `sandbar image ls`");
|
|
898
|
+
if (flags.bool("force") && action !== "rm")
|
|
899
|
+
throw usage("--force only applies to `sandbar image rm`");
|
|
741
900
|
switch (action) {
|
|
742
901
|
case "build":
|
|
743
902
|
return buildCommand(session, flags, args);
|
|
744
903
|
case "ls":
|
|
745
904
|
if (args.length)
|
|
746
905
|
throw usage("image ls takes no arguments");
|
|
747
|
-
return lsImages(session);
|
|
906
|
+
return flags.bool("remote") ? lsRemote(session) : lsImages(session);
|
|
748
907
|
case "inspect":
|
|
749
908
|
return inspectImage(session, args);
|
|
909
|
+
case "rm":
|
|
910
|
+
return rmImage(session, args, flags.bool("force"));
|
|
750
911
|
case "forget":
|
|
751
912
|
return forgetImage(session, args);
|
|
752
913
|
default:
|
|
@@ -762,7 +923,7 @@ import {
|
|
|
762
923
|
bindAdapter
|
|
763
924
|
} from "@sandbar-sdk/adapter";
|
|
764
925
|
import { Sandbar } from "sandbar-sdk";
|
|
765
|
-
import { z as
|
|
926
|
+
import { z as z4 } from "zod";
|
|
766
927
|
var providers = [
|
|
767
928
|
{
|
|
768
929
|
id: "daytona",
|
|
@@ -868,7 +1029,7 @@ function fromProfile(name, profile, loaded, env) {
|
|
|
868
1029
|
}),
|
|
869
1030
|
...Object.entries(profile.credentials ?? {}).map(([key, value]) => [
|
|
870
1031
|
key,
|
|
871
|
-
|
|
1032
|
+
z4.string().parse(resolveEnvReferences(value, env, `${where}.credentials.${key}`))
|
|
872
1033
|
])
|
|
873
1034
|
]);
|
|
874
1035
|
const config = Object.fromEntries([
|
|
@@ -897,9 +1058,9 @@ function fromProfile(name, profile, loaded, env) {
|
|
|
897
1058
|
credentials
|
|
898
1059
|
};
|
|
899
1060
|
}
|
|
900
|
-
var PackageManifest =
|
|
901
|
-
main:
|
|
902
|
-
exports:
|
|
1061
|
+
var PackageManifest = z4.object({
|
|
1062
|
+
main: z4.string().optional(),
|
|
1063
|
+
exports: z4.json().optional()
|
|
903
1064
|
});
|
|
904
1065
|
async function exists(path) {
|
|
905
1066
|
try {
|
|
@@ -911,10 +1072,10 @@ async function exists(path) {
|
|
|
911
1072
|
}
|
|
912
1073
|
var importConditions = new Set(["import", "node", "default"]);
|
|
913
1074
|
function exportTarget(value) {
|
|
914
|
-
const plain =
|
|
1075
|
+
const plain = z4.string().safeParse(value);
|
|
915
1076
|
if (plain.success)
|
|
916
1077
|
return plain.data;
|
|
917
|
-
const list =
|
|
1078
|
+
const list = z4.array(z4.json()).safeParse(value);
|
|
918
1079
|
if (list.success) {
|
|
919
1080
|
for (const item of list.data) {
|
|
920
1081
|
const target = exportTarget(item);
|
|
@@ -923,7 +1084,7 @@ function exportTarget(value) {
|
|
|
923
1084
|
}
|
|
924
1085
|
return null;
|
|
925
1086
|
}
|
|
926
|
-
const conditions =
|
|
1087
|
+
const conditions = z4.record(z4.string(), z4.json()).safeParse(value);
|
|
927
1088
|
if (!conditions.success)
|
|
928
1089
|
return null;
|
|
929
1090
|
for (const [condition, nested] of Object.entries(conditions.data)) {
|
|
@@ -939,7 +1100,7 @@ function rootEntry(manifest) {
|
|
|
939
1100
|
const exports = manifest.exports;
|
|
940
1101
|
if (exports === undefined || exports === null)
|
|
941
1102
|
return manifest.main || "index.js";
|
|
942
|
-
const subpaths =
|
|
1103
|
+
const subpaths = z4.record(z4.string(), z4.json()).safeParse(exports);
|
|
943
1104
|
const root = subpaths.success && Object.keys(subpaths.data).some((key) => key.startsWith(".")) ? subpaths.data["."] : exports;
|
|
944
1105
|
return (root === undefined ? null : exportTarget(root)) ?? manifest.main ?? "index.js";
|
|
945
1106
|
}
|
|
@@ -980,7 +1141,7 @@ async function resolveAdapterModule(profile, cwd, env) {
|
|
|
980
1141
|
function isAdapter(value) {
|
|
981
1142
|
return value instanceof Object && "name" in value && "connect" in value && "config" in value;
|
|
982
1143
|
}
|
|
983
|
-
var AdapterExport =
|
|
1144
|
+
var AdapterExport = z4.custom(isAdapter);
|
|
984
1145
|
async function adapterMissing(target, spec, message) {
|
|
985
1146
|
if (target.startsWith("file:"))
|
|
986
1147
|
return !await exists(fileURLToPath(target));
|
|
@@ -988,7 +1149,7 @@ async function adapterMissing(target, spec, message) {
|
|
|
988
1149
|
}
|
|
989
1150
|
async function loadAdapter(profile, cwd, env, secrets) {
|
|
990
1151
|
const target = await resolveAdapterModule(profile, cwd, env);
|
|
991
|
-
const schema =
|
|
1152
|
+
const schema = z4.object({ [profile.export]: AdapterExport });
|
|
992
1153
|
let parsed;
|
|
993
1154
|
try {
|
|
994
1155
|
parsed = schema.safeParse(await import(target));
|
|
@@ -1008,7 +1169,7 @@ async function loadAdapter(profile, cwd, env, secrets) {
|
|
|
1008
1169
|
return exported;
|
|
1009
1170
|
const where = `profile ${profile.name}`;
|
|
1010
1171
|
const config = await secrets.resolve(profile.config, `${where}.config`);
|
|
1011
|
-
const credentials =
|
|
1172
|
+
const credentials = z4.record(z4.string(), z4.string()).parse(await secrets.resolve(profile.credentials, `${where}.credentials`));
|
|
1012
1173
|
return bindAdapter(exported, config, credentials);
|
|
1013
1174
|
}
|
|
1014
1175
|
async function connect(profile, cwd, env, secrets) {
|
|
@@ -1064,16 +1225,16 @@ class Flags {
|
|
|
1064
1225
|
this.rest = rest;
|
|
1065
1226
|
}
|
|
1066
1227
|
string(name) {
|
|
1067
|
-
return
|
|
1228
|
+
return z5.string().optional().parse(this.values[name]);
|
|
1068
1229
|
}
|
|
1069
1230
|
strings(name) {
|
|
1070
|
-
return
|
|
1231
|
+
return z5.array(z5.string()).optional().parse(this.values[name]) ?? [];
|
|
1071
1232
|
}
|
|
1072
1233
|
bool(name) {
|
|
1073
|
-
return
|
|
1234
|
+
return z5.boolean().optional().parse(this.values[name]) ?? false;
|
|
1074
1235
|
}
|
|
1075
1236
|
}
|
|
1076
|
-
function createRequest(flags) {
|
|
1237
|
+
function createRequest(flags, env = {}) {
|
|
1077
1238
|
if (flags.string("image") && flags.string("prepared"))
|
|
1078
1239
|
throw usage("Pass either --image or --prepared, not both");
|
|
1079
1240
|
if (flags.bool("build") && !flags.string("image"))
|
|
@@ -1085,14 +1246,89 @@ function createRequest(flags) {
|
|
|
1085
1246
|
image: flags.string("image"),
|
|
1086
1247
|
prepared: flags.string("prepared"),
|
|
1087
1248
|
network: flags.string("network"),
|
|
1088
|
-
labels: parsePairs(flags.strings("label"), "label")
|
|
1249
|
+
labels: parsePairs(flags.strings("label"), "label"),
|
|
1250
|
+
env
|
|
1089
1251
|
};
|
|
1090
1252
|
}
|
|
1253
|
+
var EnvName = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1254
|
+
var escapes = new Map([
|
|
1255
|
+
["n", `
|
|
1256
|
+
`],
|
|
1257
|
+
['"', '"'],
|
|
1258
|
+
["\\", "\\"]
|
|
1259
|
+
]);
|
|
1260
|
+
function envFileValue(text) {
|
|
1261
|
+
const value = text.trimStart();
|
|
1262
|
+
const quote = value[0];
|
|
1263
|
+
if (quote !== '"' && quote !== "'")
|
|
1264
|
+
return { value: text.replace(/\s#.*$/s, "").trim() };
|
|
1265
|
+
let parsed = "";
|
|
1266
|
+
let end = -1;
|
|
1267
|
+
for (let at = 1;at < value.length; at++) {
|
|
1268
|
+
const char = value[at];
|
|
1269
|
+
if (char === quote) {
|
|
1270
|
+
end = at;
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
if (quote === '"' && char === "\\" && at + 1 < value.length) {
|
|
1274
|
+
const next = value[++at];
|
|
1275
|
+
parsed += escapes.get(next) ?? `\\${next}`;
|
|
1276
|
+
} else
|
|
1277
|
+
parsed += char;
|
|
1278
|
+
}
|
|
1279
|
+
if (end < 0)
|
|
1280
|
+
return { error: "has an unterminated quoted value; multi-line values are not supported" };
|
|
1281
|
+
if (!/^\s*(#.*)?$/s.test(value.slice(end + 1)))
|
|
1282
|
+
return { error: "has text after the closing quote" };
|
|
1283
|
+
return { value: parsed };
|
|
1284
|
+
}
|
|
1285
|
+
async function envFlags(session, flags) {
|
|
1286
|
+
const env = {};
|
|
1287
|
+
for (const file of flags.strings("env-file")) {
|
|
1288
|
+
const path = resolve3(session.ctx.cwd, file);
|
|
1289
|
+
let text;
|
|
1290
|
+
try {
|
|
1291
|
+
text = await readFile4(path, "utf8");
|
|
1292
|
+
} catch {
|
|
1293
|
+
throw usage(`--env-file ${file} could not be read`);
|
|
1294
|
+
}
|
|
1295
|
+
for (const [index, raw] of text.split(/\r?\n/).entries()) {
|
|
1296
|
+
const line = raw.trim();
|
|
1297
|
+
if (!line || line.startsWith("#"))
|
|
1298
|
+
continue;
|
|
1299
|
+
const assignment = line.startsWith("export ") ? line.slice(7).trimStart() : line;
|
|
1300
|
+
const split = assignment.indexOf("=");
|
|
1301
|
+
const name = split > 0 ? assignment.slice(0, split).trim() : "";
|
|
1302
|
+
if (!EnvName.test(name))
|
|
1303
|
+
throw usage(`--env-file ${file}:${index + 1} must be NAME=VALUE with a valid NAME`);
|
|
1304
|
+
const parsed = envFileValue(assignment.slice(split + 1));
|
|
1305
|
+
if ("error" in parsed)
|
|
1306
|
+
throw usage(`--env-file ${file}:${index + 1} ${parsed.error}`);
|
|
1307
|
+
env[name] = parsed.value;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
for (const entry of flags.strings("env")) {
|
|
1311
|
+
const split = entry.indexOf("=");
|
|
1312
|
+
const name = split < 0 ? entry : entry.slice(0, split);
|
|
1313
|
+
if (!EnvName.test(name))
|
|
1314
|
+
throw usage("--env expects NAME=VALUE or NAME, with NAME like [A-Za-z_][A-Za-z0-9_]*");
|
|
1315
|
+
if (split >= 0)
|
|
1316
|
+
env[name] = entry.slice(split + 1);
|
|
1317
|
+
else {
|
|
1318
|
+
const inherited = session.ctx.env[name];
|
|
1319
|
+
if (inherited === undefined)
|
|
1320
|
+
throw usage(`--env ${name} is not set in this environment`);
|
|
1321
|
+
env[name] = inherited;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return env;
|
|
1325
|
+
}
|
|
1091
1326
|
function createInput(request) {
|
|
1092
1327
|
return {
|
|
1093
1328
|
environment: request.prepared ? Image2.prepared(request.prepared) : undefined,
|
|
1094
1329
|
network: request.network,
|
|
1095
|
-
labels: Object.keys(request.labels).length ? request.labels : undefined
|
|
1330
|
+
labels: Object.keys(request.labels).length ? request.labels : undefined,
|
|
1331
|
+
env: Object.keys(request.env).length ? request.env : undefined
|
|
1096
1332
|
};
|
|
1097
1333
|
}
|
|
1098
1334
|
async function claimName(store, record, requested, ifNotExists = false) {
|
|
@@ -1203,7 +1439,8 @@ function sandboxSummary(record) {
|
|
|
1203
1439
|
status: record.status,
|
|
1204
1440
|
createdAt: record.createdAt,
|
|
1205
1441
|
labels: record.labels,
|
|
1206
|
-
image: record.image ?? null
|
|
1442
|
+
image: record.image ?? null,
|
|
1443
|
+
adopted: record.adopted ?? false
|
|
1207
1444
|
};
|
|
1208
1445
|
}
|
|
1209
1446
|
async function findTarget(session, target) {
|
|
@@ -1235,13 +1472,15 @@ async function findTarget(session, target) {
|
|
|
1235
1472
|
if (entry)
|
|
1236
1473
|
return entry;
|
|
1237
1474
|
}
|
|
1238
|
-
const byId = (await listAll(store, "sandbox")).
|
|
1239
|
-
if (byId)
|
|
1240
|
-
|
|
1475
|
+
const byId = (await listAll(store, "sandbox")).filter((entry) => entry.record.reference?.nativeId === target);
|
|
1476
|
+
if (byId.length > 1)
|
|
1477
|
+
throw new CliError("CONFLICT", `${target} names more than one stored sandbox: ${byId.map((entry) => entry.record.name).join(", ")}`, "use the store name instead of the id");
|
|
1478
|
+
if (byId[0])
|
|
1479
|
+
return byId[0];
|
|
1241
1480
|
throw new CliError("NOT_FOUND", `No sandbox named ${target} in the store`, "list known sandboxes with `sandbar ls`");
|
|
1242
1481
|
}
|
|
1243
1482
|
async function createCommand(session, flags) {
|
|
1244
|
-
const created = await createSandbox(session, createRequest(flags));
|
|
1483
|
+
const created = await createSandbox(session, createRequest(flags, await envFlags(session, flags)));
|
|
1245
1484
|
if (created.unrecorded)
|
|
1246
1485
|
throw unrecordedError(created);
|
|
1247
1486
|
session.ctx.out.success({
|
|
@@ -1265,7 +1504,108 @@ async function stateOf(session, record, client) {
|
|
|
1265
1504
|
return "unreachable";
|
|
1266
1505
|
}
|
|
1267
1506
|
}
|
|
1507
|
+
var REMOTE_PAGES = 20;
|
|
1508
|
+
async function remoteSandboxes(client, until) {
|
|
1509
|
+
const items = [];
|
|
1510
|
+
let cursor;
|
|
1511
|
+
let coverage = "provider-scope";
|
|
1512
|
+
for (let page = 0;page < REMOTE_PAGES; page++) {
|
|
1513
|
+
const result = await client.sandboxes.list({ cursor, limit: 100 });
|
|
1514
|
+
coverage = result.coverage;
|
|
1515
|
+
items.push(...result.items);
|
|
1516
|
+
cursor = result.nextCursor;
|
|
1517
|
+
if (!cursor || until && result.items.some(until))
|
|
1518
|
+
return { items, coverage, complete: true };
|
|
1519
|
+
}
|
|
1520
|
+
return { items, coverage, complete: false };
|
|
1521
|
+
}
|
|
1522
|
+
var coverageNote = {
|
|
1523
|
+
"provider-scope": "every sandbox this profile's account and scope can see",
|
|
1524
|
+
"sandbar-managed": "only sandboxes created through Sandbar in this profile's scope"
|
|
1525
|
+
};
|
|
1526
|
+
function renderTable(rows) {
|
|
1527
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
|
|
1528
|
+
return rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()).join(`
|
|
1529
|
+
`);
|
|
1530
|
+
}
|
|
1531
|
+
async function remoteLs(session) {
|
|
1532
|
+
const { out } = session.ctx;
|
|
1533
|
+
const profile = await session.profile();
|
|
1534
|
+
const entries = await listAll(await session.store(), "sandbox", (warning) => out.note(warning));
|
|
1535
|
+
const client = await session.connect(profile);
|
|
1536
|
+
const listing = await remoteSandboxes(client);
|
|
1537
|
+
const nameOf = (item) => entries.find(({ record }) => storesSandbox(record, profile, client, item))?.record.name ?? null;
|
|
1538
|
+
const rows = listing.items.map((item) => ({
|
|
1539
|
+
id: item.id,
|
|
1540
|
+
state: item.state,
|
|
1541
|
+
name: nameOf(item),
|
|
1542
|
+
reopenable: item.reference !== null,
|
|
1543
|
+
ownership: item.reference?.ownership ?? null,
|
|
1544
|
+
reference: item.reference
|
|
1545
|
+
}));
|
|
1546
|
+
out.note(`${profile.provider} lists ${coverageNote[listing.coverage]}`);
|
|
1547
|
+
if (!listing.complete)
|
|
1548
|
+
out.note(`warning: showing the first ${rows.length} sandboxes; the provider has more`);
|
|
1549
|
+
out.success({
|
|
1550
|
+
provider: profile.provider,
|
|
1551
|
+
profile: profile.name,
|
|
1552
|
+
coverage: listing.coverage,
|
|
1553
|
+
complete: listing.complete,
|
|
1554
|
+
sandboxes: rows
|
|
1555
|
+
}, rows.length ? renderTable([
|
|
1556
|
+
["ID", "STATE", "NAME", "OWNERSHIP"],
|
|
1557
|
+
...rows.map((row) => [
|
|
1558
|
+
row.id,
|
|
1559
|
+
row.state,
|
|
1560
|
+
row.name ?? "-",
|
|
1561
|
+
row.ownership ?? "not reopenable"
|
|
1562
|
+
])
|
|
1563
|
+
]) : "");
|
|
1564
|
+
return 0;
|
|
1565
|
+
}
|
|
1566
|
+
async function adoptCommand(session, flags) {
|
|
1567
|
+
const id = onePositional(flags, "sandbox id");
|
|
1568
|
+
const requested = flags.string("name");
|
|
1569
|
+
const store = await session.store();
|
|
1570
|
+
const profile = await session.profile();
|
|
1571
|
+
const client = await session.connect(profile);
|
|
1572
|
+
const listing = await remoteSandboxes(client, (item) => item.id === id);
|
|
1573
|
+
const listed = listing.items.find((item) => item.id === id);
|
|
1574
|
+
const known = listed && (await listAll(store, "sandbox")).find(({ record: record2 }) => storesSandbox(record2, profile, client, listed));
|
|
1575
|
+
if (known)
|
|
1576
|
+
throw new CliError("CONFLICT", `${id} is already in the store as ${known.record.name}`, `use \`sandbar inspect ${known.record.name}\``);
|
|
1577
|
+
if (!listed)
|
|
1578
|
+
throw new CliError("NOT_FOUND", `${profile.provider} does not list a sandbox ${id} for profile ${profile.name}`, listing.complete ? "list what it does show with `sandbar ls --remote`" : "the listing was cut short; check the provider's own tools");
|
|
1579
|
+
if (!listed.reference)
|
|
1580
|
+
throw new CliError("UNSUPPORTED", `${profile.provider} lists ${id} without a reopenable reference`, "this provider cannot verify that sandbox's identity, so the CLI cannot reach it");
|
|
1581
|
+
const box = await client.sandboxes.get(listed.reference, { signal: session.ctx.signal });
|
|
1582
|
+
const record = {
|
|
1583
|
+
format: 1,
|
|
1584
|
+
kind: "sandbox",
|
|
1585
|
+
status: "created",
|
|
1586
|
+
profile: profile.name,
|
|
1587
|
+
provider: profile.provider,
|
|
1588
|
+
createdAt: new Date().toISOString(),
|
|
1589
|
+
labels: {},
|
|
1590
|
+
reference: box.reference ?? listed.reference,
|
|
1591
|
+
adopted: true
|
|
1592
|
+
};
|
|
1593
|
+
let claimed;
|
|
1594
|
+
try {
|
|
1595
|
+
claimed = await claimName(store, record, requested);
|
|
1596
|
+
} catch (cause) {
|
|
1597
|
+
if (cause instanceof CliError && cause.code === "CONFLICT")
|
|
1598
|
+
throw new CliError("CONFLICT", cause.message, "choose another --name");
|
|
1599
|
+
throw cause;
|
|
1600
|
+
}
|
|
1601
|
+
session.ctx.out.success({ ...sandboxSummary(claimed.record), id: box.id, reference: claimed.record.reference }, claimed.record.name);
|
|
1602
|
+
return 0;
|
|
1603
|
+
}
|
|
1268
1604
|
async function lsCommand(session, flags) {
|
|
1605
|
+
if (flags.bool("remote") && flags.bool("offline"))
|
|
1606
|
+
throw usage("Pass either --remote or --offline, not both");
|
|
1607
|
+
if (flags.bool("remote"))
|
|
1608
|
+
return remoteLs(session);
|
|
1269
1609
|
const entries = await listAll(await session.store(), "sandbox", (warning) => session.ctx.out.note(warning));
|
|
1270
1610
|
const offline = flags.bool("offline");
|
|
1271
1611
|
const clients = new Map;
|
|
@@ -1325,10 +1665,30 @@ async function inspectCommand(session, flags) {
|
|
|
1325
1665
|
function refusal(record) {
|
|
1326
1666
|
if (!record.reference)
|
|
1327
1667
|
return new CliError("UNSUPPORTED", `${record.name} has no reopenable reference (status ${record.status}); it cannot be destroyed from here`, "check the provider for it, then `sandbar rm --force` to drop the record", { sandbox: record.name });
|
|
1668
|
+
if (record.adopted)
|
|
1669
|
+
return new CliError("FORBIDDEN", `${record.name} was adopted from a provider listing, not created by this CLI`, "pass --force to destroy it anyway", { sandbox: record.name });
|
|
1328
1670
|
if (record.reference.ownership !== "verified-created")
|
|
1329
1671
|
return new CliError("FORBIDDEN", `${record.name} was not created by this CLI (ownership ${record.reference.ownership})`, "pass --force to destroy it anyway", { sandbox: record.name });
|
|
1330
1672
|
return null;
|
|
1331
1673
|
}
|
|
1674
|
+
function storesSandbox(record, profile, client, item) {
|
|
1675
|
+
const stored = record.reference;
|
|
1676
|
+
if (!stored || record.provider !== profile.provider || stored.nativeId !== item.id)
|
|
1677
|
+
return false;
|
|
1678
|
+
if (item.reference?.generation !== undefined && stored.generation !== undefined && stored.generation !== item.reference.generation)
|
|
1679
|
+
return false;
|
|
1680
|
+
try {
|
|
1681
|
+
assertResourceScope(stored, {
|
|
1682
|
+
provider: client.provider,
|
|
1683
|
+
scope: item.reference?.scope ?? client.scope
|
|
1684
|
+
});
|
|
1685
|
+
return true;
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
if (error instanceof Error && "code" in error && error.code === "CONFLICT")
|
|
1688
|
+
return false;
|
|
1689
|
+
throw error;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1332
1692
|
function sameSandbox(a, b) {
|
|
1333
1693
|
if (a.createdAt !== b.createdAt || a.provider !== b.provider || a.profile !== b.profile)
|
|
1334
1694
|
return false;
|
|
@@ -1476,7 +1836,7 @@ async function execInput(session, flags, command) {
|
|
|
1476
1836
|
const stdin = flags.bool("stdin") ? await session.ctx.readStdin() : stdinFile ? new Uint8Array(await readFile4(resolve3(session.ctx.cwd, stdinFile))) : undefined;
|
|
1477
1837
|
const timeout = flags.string("timeout");
|
|
1478
1838
|
const maxOutput = flags.string("max-output");
|
|
1479
|
-
const env =
|
|
1839
|
+
const env = await envFlags(session, flags);
|
|
1480
1840
|
return {
|
|
1481
1841
|
command,
|
|
1482
1842
|
cwd: flags.string("cwd"),
|
|
@@ -1744,8 +2104,8 @@ async function providersCommand(session) {
|
|
|
1744
2104
|
|
|
1745
2105
|
// src/secrets.ts
|
|
1746
2106
|
import { execFile } from "node:child_process";
|
|
1747
|
-
import { z as
|
|
1748
|
-
var BwsSecret =
|
|
2107
|
+
import { z as z6 } from "zod";
|
|
2108
|
+
var BwsSecret = z6.object({ value: z6.string() });
|
|
1749
2109
|
var backends = [
|
|
1750
2110
|
{
|
|
1751
2111
|
name: "1Password",
|
|
@@ -1818,14 +2178,14 @@ class SecretReader {
|
|
|
1818
2178
|
}
|
|
1819
2179
|
}
|
|
1820
2180
|
async resolve(value, where) {
|
|
1821
|
-
const text =
|
|
2181
|
+
const text = z6.string().safeParse(value);
|
|
1822
2182
|
if (text.success) {
|
|
1823
2183
|
const backend = backendFor(text.data);
|
|
1824
2184
|
return backend ? this.read(backend, text.data, where) : value;
|
|
1825
2185
|
}
|
|
1826
2186
|
if (Array.isArray(value))
|
|
1827
2187
|
return Promise.all(value.map((item, index) => this.resolve(item, `${where}[${index}]`)));
|
|
1828
|
-
const record =
|
|
2188
|
+
const record = z6.record(z6.string(), z6.json()).safeParse(value);
|
|
1829
2189
|
if (record.success)
|
|
1830
2190
|
return Object.fromEntries(await Promise.all(Object.entries(record.data).map(async ([key, item]) => [key, await this.resolve(item, `${where}.${key}`)])));
|
|
1831
2191
|
return value;
|
|
@@ -1886,7 +2246,7 @@ import { homedir as homedir2 } from "node:os";
|
|
|
1886
2246
|
import { dirname as dirname3, join as join5 } from "node:path";
|
|
1887
2247
|
|
|
1888
2248
|
// ../../skills/sandbar-cli/SKILL.md
|
|
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
|
|
2249
|
+
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), `--env-file <path>`, `--cwd`, `--stdin` or `--stdin-file <path>`, `--timeout 30s|5m|1h`, `--max-output 1m`.\n- On `create`, `--env K=V` and `--env-file <path>` set variables for every later `exec`/`sh` in that sandbox; a command's own `--env` wins. For a secret, pass `--env NAME` (no value) to copy it from your environment instead of typing it. Values are never stored or printed. Providers that cannot keep sandbox variables (boxd) fail with exit 4 and `UNSUPPORTED` before creating anything; pass `--env` on each `exec` there. On `run`, `--env` applies to its one command and works everywhere.\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 (`--remote` lists the provider's images instead); `sandbar image rm <name>` deletes a build on the provider and drops its record; `sandbar image forget <name>` only drops the record. Only `image rm` images you built for this task; it refuses builds this CLI did not verifiably create, and never pass `--force` unless the user asked. Exit 6 from `image rm` means the outcome is unknown: run `sandbar image rm <name>` again later to check, which never resends the delete.\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- `sandbar ls --remote --json` asks the provider instead of the store. `data.coverage` says whether it shows every sandbox in the account scope (`provider-scope`) or only Sandbar-created ones (`sandbar-managed`); `name` is the store name, or null for sandboxes this store does not know. `sandbar adopt <id> [--name <name>]` records a listed sandbox so `exec`, `cp` and `inspect` can reach it. Adopted sandboxes are not the CLI's own: `rm --all` skips them and `rm <name>` needs `--force`. Do not adopt or remove sandboxes the user did not ask about.\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";
|
|
1890
2250
|
|
|
1891
2251
|
// src/skill.ts
|
|
1892
2252
|
var skillName = "sandbar-cli";
|
|
@@ -1959,7 +2319,7 @@ ${marker}
|
|
|
1959
2319
|
// src/completion.ts
|
|
1960
2320
|
var NoSpace = 1;
|
|
1961
2321
|
var Files = 2;
|
|
1962
|
-
var fileFlags = new Set(["config", "state-dir", "stdin-file"]);
|
|
2322
|
+
var fileFlags = new Set(["config", "state-dir", "stdin-file", "env-file"]);
|
|
1963
2323
|
var sandboxArguments = new Map([
|
|
1964
2324
|
["inspect", 1],
|
|
1965
2325
|
["exec", 1],
|
|
@@ -2046,7 +2406,7 @@ async function complete(words, commands, globals, session) {
|
|
|
2046
2406
|
if (command.name === "image") {
|
|
2047
2407
|
if (positionals.length === 0)
|
|
2048
2408
|
return { candidates: imageActions.map((action) => ({ value: action })), directive: 0 };
|
|
2049
|
-
if (positionals.length === 1 &&
|
|
2409
|
+
if (positionals.length === 1 && ["inspect", "forget", "rm"].includes(positionals[0])) {
|
|
2050
2410
|
const entries = await listAll(await session.store(), "image");
|
|
2051
2411
|
return {
|
|
2052
2412
|
candidates: entries.map(({ record }) => ({
|
|
@@ -2214,6 +2574,18 @@ var createFlags = {
|
|
|
2214
2574
|
description: "blocked or internet; default is the profile's policy"
|
|
2215
2575
|
},
|
|
2216
2576
|
label: { type: "string", multiple: true, value: "k=v", description: "Label to set (repeatable)" },
|
|
2577
|
+
env: {
|
|
2578
|
+
type: "string",
|
|
2579
|
+
multiple: true,
|
|
2580
|
+
value: "K=V",
|
|
2581
|
+
description: "Environment variable for every command in the sandbox (repeatable; K alone copies it from here)"
|
|
2582
|
+
},
|
|
2583
|
+
"env-file": {
|
|
2584
|
+
type: "string",
|
|
2585
|
+
multiple: true,
|
|
2586
|
+
value: "path",
|
|
2587
|
+
description: "Read K=V lines into the sandbox environment (repeatable; --env wins)"
|
|
2588
|
+
},
|
|
2217
2589
|
build: {
|
|
2218
2590
|
type: "boolean",
|
|
2219
2591
|
description: "Build --image now if this provider needs a build and none is recorded"
|
|
@@ -2225,7 +2597,13 @@ var execFlags = {
|
|
|
2225
2597
|
type: "string",
|
|
2226
2598
|
multiple: true,
|
|
2227
2599
|
value: "K=V",
|
|
2228
|
-
description: "Environment variable (repeatable)"
|
|
2600
|
+
description: "Environment variable for this command (repeatable; K alone copies it from here)"
|
|
2601
|
+
},
|
|
2602
|
+
"env-file": {
|
|
2603
|
+
type: "string",
|
|
2604
|
+
multiple: true,
|
|
2605
|
+
value: "path",
|
|
2606
|
+
description: "Read K=V lines into this command's environment (repeatable; --env wins)"
|
|
2229
2607
|
},
|
|
2230
2608
|
stdin: { type: "boolean", description: "Send this process's stdin to the command" },
|
|
2231
2609
|
"stdin-file": {
|
|
@@ -2244,7 +2622,7 @@ var commands = [
|
|
|
2244
2622
|
{
|
|
2245
2623
|
name: "create",
|
|
2246
2624
|
summary: "Create a sandbox and print its name",
|
|
2247
|
-
usage: "sandbar create [--name <name>] [--image <oci-ref> | --prepared <id>] [--network <policy>]",
|
|
2625
|
+
usage: "sandbar create [--name <name>] [--image <oci-ref> | --prepared <id>] [--network <policy>] [--env K=V]...",
|
|
2248
2626
|
flags: {
|
|
2249
2627
|
...createFlags,
|
|
2250
2628
|
"if-not-exists": {
|
|
@@ -2256,22 +2634,43 @@ var commands = [
|
|
|
2256
2634
|
remoteExit: false,
|
|
2257
2635
|
examples: [
|
|
2258
2636
|
"sandbar create --name web --network internet",
|
|
2259
|
-
"sandbar create --name job-42 --if-not-exists --json"
|
|
2637
|
+
"sandbar create --name job-42 --if-not-exists --json",
|
|
2638
|
+
"sandbar create --name ci --env-file .env --env API_TOKEN"
|
|
2260
2639
|
],
|
|
2261
2640
|
run: createCommand
|
|
2262
2641
|
},
|
|
2263
2642
|
{
|
|
2264
2643
|
name: "ls",
|
|
2265
2644
|
summary: "List sandboxes in the store with their current state",
|
|
2266
|
-
usage: "sandbar ls [--offline]",
|
|
2645
|
+
usage: "sandbar ls [--offline | --remote]",
|
|
2267
2646
|
flags: {
|
|
2268
|
-
offline: { type: "boolean", description: "Show stored records without asking providers" }
|
|
2647
|
+
offline: { type: "boolean", description: "Show stored records without asking providers" },
|
|
2648
|
+
remote: {
|
|
2649
|
+
type: "boolean",
|
|
2650
|
+
description: "List the profile's sandboxes from the provider instead of the store"
|
|
2651
|
+
}
|
|
2269
2652
|
},
|
|
2270
2653
|
passthrough: false,
|
|
2271
2654
|
remoteExit: false,
|
|
2272
|
-
examples: ["sandbar ls", "sandbar ls --json"],
|
|
2655
|
+
examples: ["sandbar ls", "sandbar ls --json", "sandbar ls --remote --provider daytona"],
|
|
2273
2656
|
run: lsCommand
|
|
2274
2657
|
},
|
|
2658
|
+
{
|
|
2659
|
+
name: "adopt",
|
|
2660
|
+
summary: "Add a sandbox from the provider's listing to the store so other commands reach it",
|
|
2661
|
+
usage: "sandbar adopt <id> [--name <name>]",
|
|
2662
|
+
flags: {
|
|
2663
|
+
name: {
|
|
2664
|
+
type: "string",
|
|
2665
|
+
value: "name",
|
|
2666
|
+
description: "Store name (default: generated, like sb-1a2b3)"
|
|
2667
|
+
}
|
|
2668
|
+
},
|
|
2669
|
+
passthrough: false,
|
|
2670
|
+
remoteExit: false,
|
|
2671
|
+
examples: ["sandbar ls --remote", "sandbar adopt 4f1c2a --name worker"],
|
|
2672
|
+
run: adoptCommand
|
|
2673
|
+
},
|
|
2275
2674
|
{
|
|
2276
2675
|
name: "inspect",
|
|
2277
2676
|
summary: "Show a sandbox's state, expiry and reference",
|
|
@@ -2340,15 +2739,23 @@ var commands = [
|
|
|
2340
2739
|
},
|
|
2341
2740
|
{
|
|
2342
2741
|
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>",
|
|
2742
|
+
summary: "Build OCI images for providers that need it, and list, delete or forget the builds",
|
|
2743
|
+
usage: "sandbar image build <oci-ref> [--name <name>] [--refresh] | ls [--remote] | inspect <name> | rm <name> [--force] | forget <name>",
|
|
2345
2744
|
flags: {
|
|
2346
2745
|
name: {
|
|
2347
2746
|
type: "string",
|
|
2348
2747
|
value: "name",
|
|
2349
2748
|
description: "Record name for a build (default: from the reference, like python-3-12)"
|
|
2350
2749
|
},
|
|
2351
|
-
refresh: { type: "boolean", description: "Build again even if this reference is recorded" }
|
|
2750
|
+
refresh: { type: "boolean", description: "Build again even if this reference is recorded" },
|
|
2751
|
+
remote: {
|
|
2752
|
+
type: "boolean",
|
|
2753
|
+
description: "List the images on the provider for this profile, not the local records"
|
|
2754
|
+
},
|
|
2755
|
+
force: {
|
|
2756
|
+
type: "boolean",
|
|
2757
|
+
description: "Delete a recorded image this CLI did not verifiably create"
|
|
2758
|
+
}
|
|
2352
2759
|
},
|
|
2353
2760
|
passthrough: false,
|
|
2354
2761
|
remoteExit: false,
|
|
@@ -2356,6 +2763,8 @@ var commands = [
|
|
|
2356
2763
|
"sandbar image build python:3.12 --provider e2b",
|
|
2357
2764
|
"sandbar create --image python:3.12 --provider e2b",
|
|
2358
2765
|
"sandbar image ls",
|
|
2766
|
+
"sandbar image ls --remote --provider e2b",
|
|
2767
|
+
"sandbar image rm python-3-12",
|
|
2359
2768
|
"sandbar image forget python-3-12"
|
|
2360
2769
|
],
|
|
2361
2770
|
run: imageCommand
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandbar-sdk/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Agent-first command line for creating and managing Sandbar sandboxes.",
|
|
@@ -20,17 +20,17 @@
|
|
|
20
20
|
"build": "rm -rf dist && bun build src/main.ts --target node --format esm --outdir dist --external sandbar-sdk --external @sandbar-sdk/adapter --external zod"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@sandbar-sdk/adapter": "0.
|
|
24
|
-
"sandbar-sdk": "0.
|
|
23
|
+
"@sandbar-sdk/adapter": "0.6.0",
|
|
24
|
+
"sandbar-sdk": "0.6.0",
|
|
25
25
|
"zod": "4.6.5"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@sandbar-sdk/boat": "0.
|
|
29
|
-
"@sandbar-sdk/boxd": "0.
|
|
30
|
-
"@sandbar-sdk/daytona": "0.
|
|
31
|
-
"@sandbar-sdk/e2b": "0.
|
|
28
|
+
"@sandbar-sdk/boat": "0.6.0",
|
|
29
|
+
"@sandbar-sdk/boxd": "0.6.0",
|
|
30
|
+
"@sandbar-sdk/daytona": "0.6.0",
|
|
31
|
+
"@sandbar-sdk/e2b": "0.6.0",
|
|
32
32
|
"@sandbar-sdk/modal": "0.0.0",
|
|
33
|
-
"@sandbar-sdk/vercel": "0.
|
|
33
|
+
"@sandbar-sdk/vercel": "0.6.0",
|
|
34
34
|
"@sandbar/provider-fake": "0.0.0"
|
|
35
35
|
},
|
|
36
36
|
"license": "Apache-2.0",
|