@sandbar-sdk/cli 0.0.0 → 0.3.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 +124 -2
- package/dist/main.js +2335 -0
- package/package.json +33 -7
package/dist/main.js
ADDED
|
@@ -0,0 +1,2335 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
// package.json
|
|
6
|
+
var package_default = {
|
|
7
|
+
name: "@sandbar-sdk/cli",
|
|
8
|
+
version: "0.3.0",
|
|
9
|
+
private: false,
|
|
10
|
+
type: "module",
|
|
11
|
+
description: "Agent-first command line for creating and managing Sandbar sandboxes.",
|
|
12
|
+
bin: {
|
|
13
|
+
sandbar: "./dist/main.js"
|
|
14
|
+
},
|
|
15
|
+
files: [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
engines: {
|
|
21
|
+
node: ">=20"
|
|
22
|
+
},
|
|
23
|
+
scripts: {
|
|
24
|
+
check: "tsc -p tsconfig.json",
|
|
25
|
+
build: "rm -rf dist && bun build src/main.ts --target node --format esm --outdir dist --external sandbar-sdk --external @sandbar-sdk/adapter --external zod"
|
|
26
|
+
},
|
|
27
|
+
dependencies: {
|
|
28
|
+
"@sandbar-sdk/adapter": "workspace:*",
|
|
29
|
+
"sandbar-sdk": "workspace:*",
|
|
30
|
+
zod: "4.6.5"
|
|
31
|
+
},
|
|
32
|
+
devDependencies: {
|
|
33
|
+
"@sandbar/provider-fake": "workspace:*"
|
|
34
|
+
},
|
|
35
|
+
license: "Apache-2.0",
|
|
36
|
+
repository: {
|
|
37
|
+
type: "git",
|
|
38
|
+
url: "git+https://github.com/pandemicsyn/sandbar.git",
|
|
39
|
+
directory: "packages/cli"
|
|
40
|
+
},
|
|
41
|
+
bugs: {
|
|
42
|
+
url: "https://github.com/pandemicsyn/sandbar/issues"
|
|
43
|
+
},
|
|
44
|
+
publishConfig: {
|
|
45
|
+
access: "public"
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// src/commands.ts
|
|
50
|
+
import { readFile as readFile4, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
|
|
51
|
+
import { basename, join as join4, resolve as resolve3 } from "node:path";
|
|
52
|
+
import {
|
|
53
|
+
AdapterSandbox,
|
|
54
|
+
Image,
|
|
55
|
+
NonzeroExitError,
|
|
56
|
+
ResourceReference as ResourceReference2,
|
|
57
|
+
SandbarError as SandbarError2
|
|
58
|
+
} from "sandbar-sdk";
|
|
59
|
+
import { z as z4 } from "zod";
|
|
60
|
+
|
|
61
|
+
// src/config.ts
|
|
62
|
+
import { readFile } from "node:fs/promises";
|
|
63
|
+
import { homedir } from "node:os";
|
|
64
|
+
import { dirname, join, resolve } from "node:path";
|
|
65
|
+
import { z } from "zod";
|
|
66
|
+
|
|
67
|
+
// src/output.ts
|
|
68
|
+
import {
|
|
69
|
+
OutcomeUnknownError,
|
|
70
|
+
SandbarError
|
|
71
|
+
} from "sandbar-sdk";
|
|
72
|
+
|
|
73
|
+
class CliError extends Error {
|
|
74
|
+
code;
|
|
75
|
+
hint;
|
|
76
|
+
known;
|
|
77
|
+
outcome;
|
|
78
|
+
details;
|
|
79
|
+
constructor(code, message, hint, known, outcome = code === "OUTCOME_UNKNOWN" ? "unknown" : "rejected", details) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.code = code;
|
|
82
|
+
this.hint = hint;
|
|
83
|
+
this.known = known;
|
|
84
|
+
this.outcome = outcome;
|
|
85
|
+
this.details = details;
|
|
86
|
+
this.name = "CliError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function usage(message, hint) {
|
|
90
|
+
return new CliError("USAGE", message, hint);
|
|
91
|
+
}
|
|
92
|
+
var retryableCodes = new Set(["RATE_LIMIT", "CAPACITY", "UNAVAILABLE"]);
|
|
93
|
+
var uncertainEffects = new Set(["possible", "unknown", "partial", "applied"]);
|
|
94
|
+
function describeError(cause) {
|
|
95
|
+
if (cause instanceof CliError)
|
|
96
|
+
return {
|
|
97
|
+
code: cause.code,
|
|
98
|
+
message: cause.message,
|
|
99
|
+
retryable: false,
|
|
100
|
+
outcome: cause.outcome,
|
|
101
|
+
hint: cause.hint,
|
|
102
|
+
known: cause.known,
|
|
103
|
+
details: cause.details
|
|
104
|
+
};
|
|
105
|
+
if (cause instanceof SandbarError) {
|
|
106
|
+
const partial = cause.outcome?.status === "partial";
|
|
107
|
+
let known;
|
|
108
|
+
if (cause.outcome && "sandbox" in cause.outcome && cause.outcome.sandbox)
|
|
109
|
+
known = { reference: cause.outcome.sandbox };
|
|
110
|
+
if (cause.reference)
|
|
111
|
+
known = { ...known, recovery: cause.reference };
|
|
112
|
+
const outcome = partial ? "partial" : cause instanceof OutcomeUnknownError || uncertainEffects.has(cause.effect) ? "unknown" : "rejected";
|
|
113
|
+
return {
|
|
114
|
+
code: partial ? "PARTIAL" : cause.code,
|
|
115
|
+
message: cause.message,
|
|
116
|
+
retryable: outcome === "rejected" && retryableCodes.has(cause.code),
|
|
117
|
+
outcome,
|
|
118
|
+
known
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const local = cause instanceof Error && "syscall" in cause && "code" in cause;
|
|
122
|
+
if (local)
|
|
123
|
+
return {
|
|
124
|
+
code: "LOCAL_IO",
|
|
125
|
+
message: cause.message,
|
|
126
|
+
retryable: false,
|
|
127
|
+
outcome: "rejected"
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
code: "INTERNAL",
|
|
131
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
132
|
+
retryable: false,
|
|
133
|
+
outcome: "unknown"
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
var managementExitCodes = new Map([
|
|
137
|
+
["USAGE", 2],
|
|
138
|
+
["LOCAL_IO", 2],
|
|
139
|
+
["INVALID_ARGUMENT", 2],
|
|
140
|
+
["NOT_FOUND", 3],
|
|
141
|
+
["UNSUPPORTED", 4],
|
|
142
|
+
["ADAPTER_NOT_INSTALLED", 4],
|
|
143
|
+
["LIMIT_REACHED", 4],
|
|
144
|
+
["SECRET_TOOL_MISSING", 4],
|
|
145
|
+
["SECRET_UNAVAILABLE", 5],
|
|
146
|
+
["UNAUTHENTICATED", 5],
|
|
147
|
+
["FORBIDDEN", 5]
|
|
148
|
+
]);
|
|
149
|
+
function managementExitCode(report) {
|
|
150
|
+
if (report.outcome === "partial")
|
|
151
|
+
return 8;
|
|
152
|
+
if (report.outcome === "unknown")
|
|
153
|
+
return report.code === "INTERNAL" ? 1 : 6;
|
|
154
|
+
if (report.retryable)
|
|
155
|
+
return 7;
|
|
156
|
+
return managementExitCodes.get(report.code) ?? 1;
|
|
157
|
+
}
|
|
158
|
+
function commandExitCode() {
|
|
159
|
+
return 125;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
class Output {
|
|
163
|
+
json;
|
|
164
|
+
streams;
|
|
165
|
+
constructor(json, streams) {
|
|
166
|
+
this.json = json;
|
|
167
|
+
this.streams = streams;
|
|
168
|
+
}
|
|
169
|
+
success(data, human) {
|
|
170
|
+
if (this.json)
|
|
171
|
+
this.streams.stdout(`${JSON.stringify({ ok: true, data })}
|
|
172
|
+
`);
|
|
173
|
+
else if (human)
|
|
174
|
+
this.streams.stdout(human.endsWith(`
|
|
175
|
+
`) ? human : `${human}
|
|
176
|
+
`);
|
|
177
|
+
}
|
|
178
|
+
failure(report) {
|
|
179
|
+
if (this.json) {
|
|
180
|
+
this.streams.stdout(`${JSON.stringify({ ok: false, error: report })}
|
|
181
|
+
`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.streams.stderr(`sandbar: ${report.message}
|
|
185
|
+
`);
|
|
186
|
+
if (report.hint)
|
|
187
|
+
this.streams.stderr(`hint: ${report.hint}
|
|
188
|
+
`);
|
|
189
|
+
if (report.outcome === "unknown" && report.code !== "INTERNAL")
|
|
190
|
+
this.streams.stderr(`The operation may have taken effect; inspect before retrying (sandbar ls, sandbar inspect).
|
|
191
|
+
`);
|
|
192
|
+
if (report.known?.reference)
|
|
193
|
+
this.streams.stderr(`known sandbox reference: ${JSON.stringify(report.known.reference)}
|
|
194
|
+
`);
|
|
195
|
+
else if (report.known?.recovery)
|
|
196
|
+
this.streams.stderr(`operation recovery reference: ${JSON.stringify(report.known.recovery)}
|
|
197
|
+
`);
|
|
198
|
+
for (const item of report.details?.removed ?? [])
|
|
199
|
+
this.streams.stdout(`${item.name}
|
|
200
|
+
`);
|
|
201
|
+
for (const item of report.details?.skipped ?? [])
|
|
202
|
+
this.streams.stderr(`skipped ${item.name}: ${item.reason ?? item.result}
|
|
203
|
+
`);
|
|
204
|
+
for (const item of report.details?.failed ?? [])
|
|
205
|
+
this.streams.stderr(`failed ${item.name}: ${item.code} ${item.reason ?? ""}
|
|
206
|
+
`);
|
|
207
|
+
}
|
|
208
|
+
note(text) {
|
|
209
|
+
this.streams.stderr(`${text}
|
|
210
|
+
`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/config.ts
|
|
215
|
+
var ProfileConfig = z.strictObject({
|
|
216
|
+
provider: z.string().regex(/^[a-z0-9-]{1,64}$/).optional(),
|
|
217
|
+
module: z.string().min(1).max(1024).optional(),
|
|
218
|
+
export: z.string().min(1).max(128).optional(),
|
|
219
|
+
config: z.record(z.string(), z.json()).optional(),
|
|
220
|
+
credentials: z.record(z.string(), z.string()).optional()
|
|
221
|
+
}).refine((profile) => profile.provider || profile.module, {
|
|
222
|
+
message: "A profile needs a provider or a module"
|
|
223
|
+
});
|
|
224
|
+
var CliConfig = z.strictObject({
|
|
225
|
+
default: z.string().min(1).optional(),
|
|
226
|
+
profiles: z.record(z.string(), ProfileConfig).optional(),
|
|
227
|
+
state: z.strictObject({ kind: z.literal("fs"), dir: z.string().min(1).optional() }).optional(),
|
|
228
|
+
limits: z.strictObject({ maxSandboxes: z.number().int().positive().nullable().optional() }).optional(),
|
|
229
|
+
images: z.strictObject({ autoBuild: z.boolean().optional() }).optional(),
|
|
230
|
+
adapters: z.strictObject({ searchProject: z.boolean().optional() }).optional()
|
|
231
|
+
});
|
|
232
|
+
function configHome(env) {
|
|
233
|
+
return join(env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config"), "sandbar");
|
|
234
|
+
}
|
|
235
|
+
function expandHome(path, env) {
|
|
236
|
+
return path === "~" || path.startsWith("~/") ? join(env.HOME || homedir(), path.slice(1)) : path;
|
|
237
|
+
}
|
|
238
|
+
async function loadConfig(explicitPath, env) {
|
|
239
|
+
const chosen = explicitPath ?? env.SANDBAR_CONFIG;
|
|
240
|
+
const path = resolve(expandHome(chosen ?? join(configHome(env), "sandbar.json"), env));
|
|
241
|
+
let text;
|
|
242
|
+
try {
|
|
243
|
+
text = await readFile(path, "utf8");
|
|
244
|
+
} catch (cause) {
|
|
245
|
+
if (!chosen && cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
|
246
|
+
return { path, dir: dirname(path), exists: false, value: {} };
|
|
247
|
+
throw usage(`Cannot read config ${path}: ${cause instanceof Error ? cause.message : cause}`);
|
|
248
|
+
}
|
|
249
|
+
let json;
|
|
250
|
+
try {
|
|
251
|
+
json = z.json().parse(JSON.parse(text));
|
|
252
|
+
} catch {
|
|
253
|
+
throw usage(`Config ${path} is not valid JSON`);
|
|
254
|
+
}
|
|
255
|
+
const parsed = CliConfig.safeParse(json);
|
|
256
|
+
if (!parsed.success)
|
|
257
|
+
throw usage(`Config ${path} is invalid: ${z.prettifyError(parsed.error).replaceAll(`
|
|
258
|
+
`, "; ")}`);
|
|
259
|
+
return { path, dir: dirname(path), exists: true, value: parsed.data };
|
|
260
|
+
}
|
|
261
|
+
function stateDir(flag, config, env, cwd) {
|
|
262
|
+
const given = flag ?? env.SANDBAR_STATE_DIR;
|
|
263
|
+
if (given)
|
|
264
|
+
return resolve(cwd, expandHome(given, env));
|
|
265
|
+
const configured = config.value.state?.dir;
|
|
266
|
+
if (configured)
|
|
267
|
+
return resolve(config.dir, expandHome(configured, env));
|
|
268
|
+
return join(configHome(env), "state");
|
|
269
|
+
}
|
|
270
|
+
function searchProjectAdapters(config, env) {
|
|
271
|
+
const fromEnv = env.SANDBAR_PROJECT_ADAPTERS;
|
|
272
|
+
if (fromEnv !== undefined && fromEnv !== "")
|
|
273
|
+
return fromEnv === "1" || fromEnv === "true";
|
|
274
|
+
return config.value.adapters?.searchProject ?? false;
|
|
275
|
+
}
|
|
276
|
+
function maxSandboxes(config, env) {
|
|
277
|
+
const fromEnv = env.SANDBAR_MAX_SANDBOXES;
|
|
278
|
+
if (fromEnv !== undefined && fromEnv !== "") {
|
|
279
|
+
const parsed = z.coerce.number().int().positive().safeParse(fromEnv);
|
|
280
|
+
if (!parsed.success)
|
|
281
|
+
throw usage("SANDBAR_MAX_SANDBOXES must be a positive integer");
|
|
282
|
+
return parsed.data;
|
|
283
|
+
}
|
|
284
|
+
return config.value.limits?.maxSandboxes ?? null;
|
|
285
|
+
}
|
|
286
|
+
var EnvReference = z.string().regex(/^env:[A-Za-z_][A-Za-z0-9_]*$/);
|
|
287
|
+
function valueAvailable(value, env) {
|
|
288
|
+
const reference = EnvReference.safeParse(value);
|
|
289
|
+
return !reference.success || Boolean(env[reference.data.slice(4)]);
|
|
290
|
+
}
|
|
291
|
+
function resolveEnvReferences(value, env, where) {
|
|
292
|
+
const reference = EnvReference.safeParse(value);
|
|
293
|
+
if (reference.success) {
|
|
294
|
+
const name = reference.data.slice(4);
|
|
295
|
+
const found = env[name];
|
|
296
|
+
if (found === undefined || found === "")
|
|
297
|
+
throw new CliError("USAGE", `${where} needs environment variable ${name}`);
|
|
298
|
+
return found;
|
|
299
|
+
}
|
|
300
|
+
if (Array.isArray(value))
|
|
301
|
+
return value.map((item) => resolveEnvReferences(item, env, where));
|
|
302
|
+
const record = z.record(z.string(), z.json()).safeParse(value);
|
|
303
|
+
if (record.success)
|
|
304
|
+
return Object.fromEntries(Object.entries(record.data).map(([key, item]) => [
|
|
305
|
+
key,
|
|
306
|
+
resolveEnvReferences(item, env, `${where}.${key}`)
|
|
307
|
+
]));
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/providers.ts
|
|
312
|
+
import { access, readFile as readFile2 } from "node:fs/promises";
|
|
313
|
+
import { dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
|
|
314
|
+
import { pathToFileURL } from "node:url";
|
|
315
|
+
import {
|
|
316
|
+
bindAdapter
|
|
317
|
+
} from "@sandbar-sdk/adapter";
|
|
318
|
+
import { Sandbar } from "sandbar-sdk";
|
|
319
|
+
import { z as z2 } from "zod";
|
|
320
|
+
var providers = [
|
|
321
|
+
{
|
|
322
|
+
id: "daytona",
|
|
323
|
+
module: "@sandbar-sdk/daytona",
|
|
324
|
+
export: "daytonaAdapter",
|
|
325
|
+
experimental: false,
|
|
326
|
+
credentials: { apiKey: "DAYTONA_API_KEY" },
|
|
327
|
+
config: { target: { env: "DAYTONA_TARGET", required: true } }
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: "e2b",
|
|
331
|
+
module: "@sandbar-sdk/e2b",
|
|
332
|
+
export: "e2bAdapter",
|
|
333
|
+
experimental: false,
|
|
334
|
+
credentials: { apiKey: "E2B_API_KEY" },
|
|
335
|
+
config: {}
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
id: "boxd",
|
|
339
|
+
module: "@sandbar-sdk/boxd",
|
|
340
|
+
export: "boxdAdapter",
|
|
341
|
+
experimental: true,
|
|
342
|
+
credentials: { apiKey: "BOXD_API_KEY" },
|
|
343
|
+
config: {
|
|
344
|
+
org: { env: "BOXD_ORG", required: true },
|
|
345
|
+
network: { env: "BOXD_NETWORK", required: true }
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: "vercel",
|
|
350
|
+
module: "@sandbar-sdk/vercel",
|
|
351
|
+
export: "vercel",
|
|
352
|
+
experimental: false,
|
|
353
|
+
credentials: { token: "VERCEL_TOKEN" },
|
|
354
|
+
config: {
|
|
355
|
+
teamId: { env: "VERCEL_TEAM_ID", required: true },
|
|
356
|
+
projectId: { env: "VERCEL_PROJECT_ID", required: true }
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: "modal",
|
|
361
|
+
module: "@sandbar-sdk/modal",
|
|
362
|
+
export: "modalAdapter",
|
|
363
|
+
experimental: true,
|
|
364
|
+
credentials: { tokenId: "MODAL_TOKEN_ID", tokenSecret: "MODAL_TOKEN_SECRET" },
|
|
365
|
+
config: {
|
|
366
|
+
appName: { env: "MODAL_APP_NAME", required: true },
|
|
367
|
+
environment: { env: "MODAL_ENVIRONMENT", required: true }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
];
|
|
371
|
+
function descriptor(id) {
|
|
372
|
+
return providers.find((provider) => provider.id === id);
|
|
373
|
+
}
|
|
374
|
+
function missingEnv(provider, env, covered = new Set) {
|
|
375
|
+
const needed = [
|
|
376
|
+
...Object.entries(provider.credentials),
|
|
377
|
+
...Object.entries(provider.config).flatMap(([key, entry]) => entry.required ? [[key, entry.env]] : [])
|
|
378
|
+
];
|
|
379
|
+
return needed.flatMap(([key, name]) => covered.has(key) || env[name] ? [] : [name]);
|
|
380
|
+
}
|
|
381
|
+
function resolveProfile(flags, loaded, env) {
|
|
382
|
+
const profiles = loaded.value.profiles ?? {};
|
|
383
|
+
const profileName = flags.profile ?? (flags.provider ? undefined : env.SANDBAR_PROFILE);
|
|
384
|
+
if (profileName) {
|
|
385
|
+
const profile = profiles[profileName];
|
|
386
|
+
if (!profile)
|
|
387
|
+
throw usage(`Profile ${profileName} is not defined in ${loaded.path}`, Object.keys(profiles).length ? `known profiles: ${Object.keys(profiles).join(", ")}` : 'add it under "profiles" in sandbar.json');
|
|
388
|
+
return fromProfile(profileName, profile, loaded, env);
|
|
389
|
+
}
|
|
390
|
+
const providerId = flags.provider ?? env.SANDBAR_PROVIDER;
|
|
391
|
+
if (providerId)
|
|
392
|
+
return fromProfile(providerId, { provider: providerId }, loaded, env);
|
|
393
|
+
const fallback = loaded.value.default;
|
|
394
|
+
if (fallback) {
|
|
395
|
+
const profile = profiles[fallback];
|
|
396
|
+
if (!profile)
|
|
397
|
+
throw usage(`Default profile ${fallback} is not defined in ${loaded.path}`);
|
|
398
|
+
return fromProfile(fallback, profile, loaded, env);
|
|
399
|
+
}
|
|
400
|
+
const ready = providers.filter((provider) => !missingEnv(provider, env).length);
|
|
401
|
+
throw usage("No provider selected", ready.length ? `credentials found for: ${ready.map((provider) => provider.id).join(", ")}; set SANDBAR_PROVIDER or pass --provider` : "set SANDBAR_PROVIDER and the provider's credentials (see `sandbar providers`), or add a profile to sandbar.json");
|
|
402
|
+
}
|
|
403
|
+
function fromProfile(name, profile, loaded, env) {
|
|
404
|
+
const official = profile.provider ? descriptor(profile.provider) : undefined;
|
|
405
|
+
if (profile.provider && !official && !profile.module)
|
|
406
|
+
throw usage(`Unknown provider ${profile.provider}`, `official providers: ${providers.map((provider) => provider.id).join(", ")}; use "module" for other adapters`);
|
|
407
|
+
const where = `profile ${name}`;
|
|
408
|
+
const credentials = Object.fromEntries([
|
|
409
|
+
...Object.entries(official?.credentials ?? {}).flatMap(([key, variable]) => {
|
|
410
|
+
const value = env[variable];
|
|
411
|
+
return value ? [[key, value]] : [];
|
|
412
|
+
}),
|
|
413
|
+
...Object.entries(profile.credentials ?? {}).map(([key, value]) => [
|
|
414
|
+
key,
|
|
415
|
+
z2.string().parse(resolveEnvReferences(value, env, `${where}.credentials.${key}`))
|
|
416
|
+
])
|
|
417
|
+
]);
|
|
418
|
+
const config = Object.fromEntries([
|
|
419
|
+
...Object.entries(official?.config ?? {}).flatMap(([key, entry]) => {
|
|
420
|
+
const value = env[entry.env];
|
|
421
|
+
return value ? [[key, value]] : [];
|
|
422
|
+
}),
|
|
423
|
+
...Object.entries(profile.config ?? {}).map(([key, value]) => [key, resolveEnvReferences(value, env, `${where}.config.${key}`)])
|
|
424
|
+
]);
|
|
425
|
+
if (official) {
|
|
426
|
+
const missing = [
|
|
427
|
+
...Object.entries(official.credentials).filter(([key]) => !credentials[key]).map(([, variable]) => variable),
|
|
428
|
+
...Object.entries(official.config).filter(([key, entry]) => entry.required && config[key] === undefined).map(([, entry]) => entry.env)
|
|
429
|
+
];
|
|
430
|
+
if (missing.length)
|
|
431
|
+
throw usage(`${where} (${official.id}) is missing ${missing.join(", ")}`, "set the environment variables, or put the values in the profile in sandbar.json");
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
name,
|
|
435
|
+
provider: official?.id ?? profile.provider ?? name,
|
|
436
|
+
module: profile.module ?? official?.module ?? "",
|
|
437
|
+
export: profile.export ?? official?.export ?? "default",
|
|
438
|
+
baseDir: loaded.dir,
|
|
439
|
+
searchProject: searchProjectAdapters(loaded, env),
|
|
440
|
+
config,
|
|
441
|
+
credentials
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
var ConditionalEntry = z2.object({
|
|
445
|
+
import: z2.string().optional(),
|
|
446
|
+
default: z2.string().optional()
|
|
447
|
+
});
|
|
448
|
+
var PackageManifest = z2.object({
|
|
449
|
+
main: z2.string().optional(),
|
|
450
|
+
exports: z2.union([
|
|
451
|
+
z2.string(),
|
|
452
|
+
z2.object({ ".": z2.union([z2.string(), ConditionalEntry]).optional() }),
|
|
453
|
+
ConditionalEntry
|
|
454
|
+
]).optional()
|
|
455
|
+
});
|
|
456
|
+
async function exists(path) {
|
|
457
|
+
try {
|
|
458
|
+
await access(path);
|
|
459
|
+
return true;
|
|
460
|
+
} catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function rootEntry(manifest) {
|
|
465
|
+
const field = manifest.exports;
|
|
466
|
+
const plain = z2.string().safeParse(field);
|
|
467
|
+
if (plain.success)
|
|
468
|
+
return plain.data;
|
|
469
|
+
const dotted = z2.object({ ".": z2.union([z2.string(), ConditionalEntry]) }).safeParse(field);
|
|
470
|
+
const target = dotted.success ? dotted.data["."] : field;
|
|
471
|
+
const direct = z2.string().safeParse(target);
|
|
472
|
+
if (direct.success)
|
|
473
|
+
return direct.data;
|
|
474
|
+
const conditional = ConditionalEntry.safeParse(target);
|
|
475
|
+
return conditional.success && (conditional.data.import ?? conditional.data.default) || manifest.main || "index.js";
|
|
476
|
+
}
|
|
477
|
+
async function entryFor(packageDir) {
|
|
478
|
+
const manifestPath = join2(packageDir, "package.json");
|
|
479
|
+
if (!await exists(manifestPath))
|
|
480
|
+
return null;
|
|
481
|
+
const manifest = PackageManifest.safeParse(JSON.parse(await readFile2(manifestPath, "utf8")));
|
|
482
|
+
if (!manifest.success)
|
|
483
|
+
return null;
|
|
484
|
+
const entry = resolve2(packageDir, rootEntry(manifest.data));
|
|
485
|
+
const inside = relative(packageDir, entry);
|
|
486
|
+
if (!inside || inside.startsWith("..") || isAbsolute(inside))
|
|
487
|
+
throw new CliError("ADAPTER_NOT_INSTALLED", `${manifestPath} points its entry outside the package (${rootEntry(manifest.data)})`);
|
|
488
|
+
return entry;
|
|
489
|
+
}
|
|
490
|
+
var PackageName = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
491
|
+
async function resolveAdapterModule(profile, cwd, env) {
|
|
492
|
+
const spec = profile.module;
|
|
493
|
+
if (spec.startsWith(".") || isAbsolute(spec))
|
|
494
|
+
return pathToFileURL(resolve2(profile.baseDir, spec)).href;
|
|
495
|
+
if (!PackageName.test(spec))
|
|
496
|
+
throw usage(`Adapter module ${spec} is not a package name or a path`);
|
|
497
|
+
const roots = [join2(configHome(env), "adapters", "node_modules")];
|
|
498
|
+
if (profile.searchProject)
|
|
499
|
+
for (let dir = resolve2(cwd);; dir = dirname2(dir)) {
|
|
500
|
+
roots.push(join2(dir, "node_modules"));
|
|
501
|
+
if (dirname2(dir) === dir)
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
for (const root of roots) {
|
|
505
|
+
const entry = await entryFor(join2(root, spec));
|
|
506
|
+
if (entry)
|
|
507
|
+
return pathToFileURL(entry).href;
|
|
508
|
+
}
|
|
509
|
+
return spec;
|
|
510
|
+
}
|
|
511
|
+
function isAdapter(value) {
|
|
512
|
+
return value instanceof Object && "name" in value && "connect" in value && "config" in value;
|
|
513
|
+
}
|
|
514
|
+
var AdapterExport = z2.custom(isAdapter);
|
|
515
|
+
async function loadAdapter(profile, cwd, env, secrets) {
|
|
516
|
+
const target = await resolveAdapterModule(profile, cwd, env);
|
|
517
|
+
const schema = z2.object({ [profile.export]: AdapterExport });
|
|
518
|
+
let parsed;
|
|
519
|
+
try {
|
|
520
|
+
parsed = schema.safeParse(await import(target));
|
|
521
|
+
} catch (cause) {
|
|
522
|
+
const official = descriptor(profile.provider);
|
|
523
|
+
throw new CliError("ADAPTER_NOT_INSTALLED", `Cannot load adapter ${profile.module} for profile ${profile.name}: ${cause instanceof Error ? cause.message : cause}`, official ? `install it into ${join2(configHome(env), "adapters")} (cd there && npm install ${official.module}); project installs need adapters.searchProject or SANDBAR_PROJECT_ADAPTERS=1` : "check the profile's module path");
|
|
524
|
+
}
|
|
525
|
+
if (!parsed.success)
|
|
526
|
+
throw new CliError("ADAPTER_NOT_INSTALLED", `${profile.module} has no adapter export named ${profile.export}`);
|
|
527
|
+
const exported = parsed.data[profile.export];
|
|
528
|
+
if (!exported)
|
|
529
|
+
throw new CliError("ADAPTER_NOT_INSTALLED", `${profile.module} export is empty`);
|
|
530
|
+
if ("bound" in exported)
|
|
531
|
+
return exported;
|
|
532
|
+
const where = `profile ${profile.name}`;
|
|
533
|
+
const config = await secrets.resolve(profile.config, `${where}.config`);
|
|
534
|
+
const credentials = z2.record(z2.string(), z2.string()).parse(await secrets.resolve(profile.credentials, `${where}.credentials`));
|
|
535
|
+
return bindAdapter(exported, config, credentials);
|
|
536
|
+
}
|
|
537
|
+
async function connect(profile, cwd, env, secrets) {
|
|
538
|
+
return Sandbar.connect(await loadAdapter(profile, cwd, env, secrets));
|
|
539
|
+
}
|
|
540
|
+
|
|
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
|
+
// src/units.ts
|
|
720
|
+
var durationUnits = new Map([
|
|
721
|
+
["", 1],
|
|
722
|
+
["s", 1],
|
|
723
|
+
["m", 60],
|
|
724
|
+
["h", 3600]
|
|
725
|
+
]);
|
|
726
|
+
function parseDuration(value, flag) {
|
|
727
|
+
const match = /^(\d+)(s|m|h)?$/.exec(value.trim());
|
|
728
|
+
const seconds = match ? Number(match[1]) * (durationUnits.get(match[2] ?? "") ?? 1) : NaN;
|
|
729
|
+
if (!Number.isSafeInteger(seconds) || seconds <= 0)
|
|
730
|
+
throw usage(`--${flag} must be a duration like 90s, 30m or 2h`);
|
|
731
|
+
return seconds;
|
|
732
|
+
}
|
|
733
|
+
var sizeUnits = new Map([
|
|
734
|
+
["", 1],
|
|
735
|
+
["b", 1],
|
|
736
|
+
["k", 1024],
|
|
737
|
+
["m", 1024 ** 2],
|
|
738
|
+
["g", 1024 ** 3]
|
|
739
|
+
]);
|
|
740
|
+
function parseSize(value, flag) {
|
|
741
|
+
const match = /^(\d+)(b|k|m|g)?$/i.exec(value.trim());
|
|
742
|
+
const bytes = match ? Number(match[1]) * (sizeUnits.get((match[2] ?? "").toLowerCase()) ?? 1) : NaN;
|
|
743
|
+
if (!Number.isSafeInteger(bytes) || bytes <= 0)
|
|
744
|
+
throw usage(`--${flag} must be a size like 4096, 512k or 1m`);
|
|
745
|
+
return bytes;
|
|
746
|
+
}
|
|
747
|
+
function parsePairs(values, flag) {
|
|
748
|
+
const pairs = values.map((value) => {
|
|
749
|
+
const index = value.indexOf("=");
|
|
750
|
+
if (index <= 0)
|
|
751
|
+
throw usage(`--${flag} expects KEY=VALUE, got ${value}`);
|
|
752
|
+
return [value.slice(0, index), value.slice(index + 1)];
|
|
753
|
+
});
|
|
754
|
+
return Object.fromEntries(pairs);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/commands.ts
|
|
758
|
+
class Flags {
|
|
759
|
+
values;
|
|
760
|
+
positionals;
|
|
761
|
+
rest;
|
|
762
|
+
constructor(values, positionals, rest) {
|
|
763
|
+
this.values = values;
|
|
764
|
+
this.positionals = positionals;
|
|
765
|
+
this.rest = rest;
|
|
766
|
+
}
|
|
767
|
+
string(name) {
|
|
768
|
+
return z4.string().optional().parse(this.values[name]);
|
|
769
|
+
}
|
|
770
|
+
strings(name) {
|
|
771
|
+
return z4.array(z4.string()).optional().parse(this.values[name]) ?? [];
|
|
772
|
+
}
|
|
773
|
+
bool(name) {
|
|
774
|
+
return z4.boolean().optional().parse(this.values[name]) ?? false;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function createRequest(flags) {
|
|
778
|
+
if (flags.string("image") && flags.string("prepared"))
|
|
779
|
+
throw usage("Pass either --image or --prepared, not both");
|
|
780
|
+
if (flags.bool("build"))
|
|
781
|
+
throw usage("--build is not available yet", "build images in a later CLI release; for now use a prepared image or a provider that boots OCI images directly");
|
|
782
|
+
return {
|
|
783
|
+
name: flags.string("name"),
|
|
784
|
+
ifNotExists: flags.bool("if-not-exists"),
|
|
785
|
+
image: flags.string("image"),
|
|
786
|
+
prepared: flags.string("prepared"),
|
|
787
|
+
network: flags.string("network"),
|
|
788
|
+
labels: parsePairs(flags.strings("label"), "label")
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
function createInput(request) {
|
|
792
|
+
return {
|
|
793
|
+
environment: request.image ? Image.oci(request.image) : request.prepared ? Image.prepared(request.prepared) : undefined,
|
|
794
|
+
network: request.network,
|
|
795
|
+
labels: Object.keys(request.labels).length ? request.labels : undefined
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
async function claimName(store, record, requested, ifNotExists = false) {
|
|
799
|
+
for (let attempt = 0;; attempt++) {
|
|
800
|
+
const name = requested ? checkName(requested) : generatedName();
|
|
801
|
+
const full = { ...record, name };
|
|
802
|
+
try {
|
|
803
|
+
const { version } = await store.create("sandbox", name, full);
|
|
804
|
+
return { record: full, version };
|
|
805
|
+
} catch (cause) {
|
|
806
|
+
if (!(cause instanceof CliError) || cause.code !== "CONFLICT" || attempt >= 8)
|
|
807
|
+
throw cause;
|
|
808
|
+
if (requested)
|
|
809
|
+
throw new CliError("CONFLICT", cause.message, ifNotExists ? "its record is not a confirmed sandbox (still creating, or outcome unknown); check `sandbar inspect`, or drop it with `sandbar rm --force`" : "pass --if-not-exists to reuse it, or choose another --name");
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async function liveCount(store) {
|
|
814
|
+
return (await listAll(store, "sandbox")).length;
|
|
815
|
+
}
|
|
816
|
+
async function createSandbox(session, request) {
|
|
817
|
+
const store = await session.store();
|
|
818
|
+
const profile = await session.profile();
|
|
819
|
+
if (request.name && request.ifNotExists) {
|
|
820
|
+
const current = await store.get("sandbox", checkName(request.name));
|
|
821
|
+
if (current?.record.status === "created" && current.record.reference) {
|
|
822
|
+
const client2 = await session.connect(await session.profileNamed(current.record.profile));
|
|
823
|
+
try {
|
|
824
|
+
const box2 = await session.reopen(current.record, client2);
|
|
825
|
+
return { client: client2, box: box2, ...current, existing: true };
|
|
826
|
+
} catch (cause) {
|
|
827
|
+
if (!(cause instanceof SandbarError2) || cause.code !== "NOT_FOUND")
|
|
828
|
+
throw cause;
|
|
829
|
+
await store.remove("sandbox", current.record.name, current.version);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const limit = await session.limit();
|
|
834
|
+
if (limit !== null && await liveCount(store) >= limit)
|
|
835
|
+
throw new CliError("LIMIT_REACHED", `The store already holds ${limit} sandboxes (limits.maxSandboxes)`, "remove some with `sandbar rm`, or raise limits.maxSandboxes in sandbar.json");
|
|
836
|
+
const claimed = await claimName(store, {
|
|
837
|
+
format: 1,
|
|
838
|
+
kind: "sandbox",
|
|
839
|
+
status: "creating",
|
|
840
|
+
profile: profile.name,
|
|
841
|
+
provider: profile.provider,
|
|
842
|
+
createdAt: new Date().toISOString(),
|
|
843
|
+
labels: request.labels,
|
|
844
|
+
image: request.image ?? request.prepared,
|
|
845
|
+
reference: null
|
|
846
|
+
}, request.name, request.ifNotExists);
|
|
847
|
+
let client;
|
|
848
|
+
const input = createInput(request);
|
|
849
|
+
try {
|
|
850
|
+
client = await session.connect(profile);
|
|
851
|
+
if (input.environment) {
|
|
852
|
+
const plan = await client.sandboxes.checkCreate(input);
|
|
853
|
+
if (plan.status === "unsupported" || plan.status === "unavailable")
|
|
854
|
+
throw new CliError("UNSUPPORTED", `${profile.provider} cannot create this sandbox: ${plan.reason}`, request.image ? "this provider may need the image built first (sandbar image build arrives in a later release), or use --prepared with a provider image" : undefined);
|
|
855
|
+
}
|
|
856
|
+
} catch (cause) {
|
|
857
|
+
await store.remove("sandbox", claimed.record.name, claimed.version);
|
|
858
|
+
throw cause;
|
|
859
|
+
}
|
|
860
|
+
let box;
|
|
861
|
+
try {
|
|
862
|
+
box = await client.sandboxes.create(input, { signal: session.ctx.signal });
|
|
863
|
+
} catch (cause) {
|
|
864
|
+
const report = describeError(cause);
|
|
865
|
+
if (report.outcome === "rejected") {
|
|
866
|
+
await store.remove("sandbox", claimed.record.name, claimed.version);
|
|
867
|
+
if (report.code === "INVALID_ARGUMENT" && !input.environment)
|
|
868
|
+
throw new CliError("INVALID_ARGUMENT", `${profile.provider} needs an image for this sandbox: ${report.message}`, "pass --prepared <id> (a provider template or snapshot) or --image <oci-ref>");
|
|
869
|
+
throw cause;
|
|
870
|
+
}
|
|
871
|
+
const reference = report.known?.reference ?? null;
|
|
872
|
+
const recovery = report.known?.recovery;
|
|
873
|
+
let kept = `the store keeps ${claimed.record.name} so it can be inspected or removed`;
|
|
874
|
+
try {
|
|
875
|
+
await store.update("sandbox", claimed.record.name, { ...claimed.record, status: "unknown", reference, recovery }, claimed.version);
|
|
876
|
+
} catch (storeCause) {
|
|
877
|
+
kept = `the store could not record it (${describeError(storeCause).message}); keep the reference below`;
|
|
878
|
+
}
|
|
879
|
+
throw new CliError(report.outcome === "partial" ? "PARTIAL" : "OUTCOME_UNKNOWN", report.message, kept, { sandbox: claimed.record.name, reference, recovery }, report.outcome);
|
|
880
|
+
}
|
|
881
|
+
const record = { ...claimed.record, status: "created", reference: box.reference };
|
|
882
|
+
if (!box.reference)
|
|
883
|
+
session.ctx.out.note(`warning: ${profile.provider} returned no reopenable reference; later commands cannot reach ${record.name}`);
|
|
884
|
+
try {
|
|
885
|
+
await store.update("sandbox", record.name, record, claimed.version);
|
|
886
|
+
} catch (cause) {
|
|
887
|
+
const unrecorded = cause instanceof Error ? cause : new Error(String(cause));
|
|
888
|
+
return { client, box, record, existing: false, unrecorded };
|
|
889
|
+
}
|
|
890
|
+
return { client, box, record, existing: false };
|
|
891
|
+
}
|
|
892
|
+
function unrecordedError(created) {
|
|
893
|
+
return new CliError("STORE_WRITE_FAILED", `Created ${created.record.name} but could not record it in the store: ${created.unrecorded?.message}`, "save the reference below to a file and use `sandbar rm @<file>` to destroy it", { sandbox: created.record.name, reference: created.record.reference }, "partial");
|
|
894
|
+
}
|
|
895
|
+
function sandboxSummary(record) {
|
|
896
|
+
return {
|
|
897
|
+
name: record.name,
|
|
898
|
+
id: record.reference?.nativeId ?? null,
|
|
899
|
+
provider: record.provider,
|
|
900
|
+
profile: record.profile,
|
|
901
|
+
status: record.status,
|
|
902
|
+
createdAt: record.createdAt,
|
|
903
|
+
labels: record.labels,
|
|
904
|
+
image: record.image ?? null
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
async function findTarget(session, target) {
|
|
908
|
+
const store = await session.store();
|
|
909
|
+
if (target.startsWith("@")) {
|
|
910
|
+
const path = resolve3(session.ctx.cwd, target.slice(1));
|
|
911
|
+
const parsed = ResourceReference2.safeParse(JSON.parse(await readFile4(path, "utf8")));
|
|
912
|
+
if (!parsed.success || parsed.data.kind !== "sandbox")
|
|
913
|
+
throw usage(`${path} does not hold a sandbox reference`);
|
|
914
|
+
const profile = await session.profile();
|
|
915
|
+
return {
|
|
916
|
+
record: {
|
|
917
|
+
format: 1,
|
|
918
|
+
kind: "sandbox",
|
|
919
|
+
name: "from-file",
|
|
920
|
+
status: "created",
|
|
921
|
+
profile: profile.name,
|
|
922
|
+
provider: parsed.data.provider,
|
|
923
|
+
createdAt: new Date().toISOString(),
|
|
924
|
+
labels: {},
|
|
925
|
+
reference: { ...parsed.data, kind: "sandbox" }
|
|
926
|
+
},
|
|
927
|
+
version: "",
|
|
928
|
+
fromFile: true
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
if (RecordName.safeParse(target).success) {
|
|
932
|
+
const entry = await store.get("sandbox", target);
|
|
933
|
+
if (entry)
|
|
934
|
+
return entry;
|
|
935
|
+
}
|
|
936
|
+
const byId = (await listAll(store, "sandbox")).find((entry) => entry.record.reference?.nativeId === target);
|
|
937
|
+
if (byId)
|
|
938
|
+
return byId;
|
|
939
|
+
throw new CliError("NOT_FOUND", `No sandbox named ${target} in the store`, "list known sandboxes with `sandbar ls`");
|
|
940
|
+
}
|
|
941
|
+
async function createCommand(session, flags) {
|
|
942
|
+
const created = await createSandbox(session, createRequest(flags));
|
|
943
|
+
if (created.unrecorded)
|
|
944
|
+
throw unrecordedError(created);
|
|
945
|
+
session.ctx.out.success({
|
|
946
|
+
...sandboxSummary(created.record),
|
|
947
|
+
existing: created.existing,
|
|
948
|
+
reference: created.record.reference
|
|
949
|
+
}, created.record.name);
|
|
950
|
+
return 0;
|
|
951
|
+
}
|
|
952
|
+
async function stateOf(session, record, client) {
|
|
953
|
+
if (!record.reference || !client)
|
|
954
|
+
return record.status === "created" ? "unreachable" : record.status;
|
|
955
|
+
try {
|
|
956
|
+
const box = await session.reopen(record, client);
|
|
957
|
+
return (await box.inspect({ signal: session.ctx.signal })).state;
|
|
958
|
+
} catch (cause) {
|
|
959
|
+
if (cause instanceof SandbarError2 && cause.code === "NOT_FOUND")
|
|
960
|
+
return "gone";
|
|
961
|
+
if (cause instanceof SandbarError2 && cause.code === "UNSUPPORTED")
|
|
962
|
+
return "unknown";
|
|
963
|
+
return "unreachable";
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
async function lsCommand(session, flags) {
|
|
967
|
+
const entries = await listAll(await session.store(), "sandbox", (warning) => session.ctx.out.note(warning));
|
|
968
|
+
const offline = flags.bool("offline");
|
|
969
|
+
const clients = new Map;
|
|
970
|
+
if (!offline)
|
|
971
|
+
for (const profile of new Set(entries.map((entry) => entry.record.profile))) {
|
|
972
|
+
try {
|
|
973
|
+
clients.set(profile, await session.connect(await session.profileNamed(profile)));
|
|
974
|
+
} catch {
|
|
975
|
+
clients.set(profile, null);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
const rows = await Promise.all(entries.map(async ({ record }) => ({
|
|
979
|
+
...sandboxSummary(record),
|
|
980
|
+
state: offline ? null : await stateOf(session, record, clients.get(record.profile) ?? null)
|
|
981
|
+
})));
|
|
982
|
+
const table = [
|
|
983
|
+
["NAME", "STATE", "PROVIDER", "PROFILE", "ID", "CREATED"],
|
|
984
|
+
...rows.map((row) => [
|
|
985
|
+
row.name,
|
|
986
|
+
row.state ?? row.status,
|
|
987
|
+
row.provider,
|
|
988
|
+
row.profile,
|
|
989
|
+
row.id ?? "-",
|
|
990
|
+
row.createdAt
|
|
991
|
+
])
|
|
992
|
+
];
|
|
993
|
+
const widths = table[0].map((_, column) => Math.max(...table.map((row) => row[column].length)));
|
|
994
|
+
session.ctx.out.success({ sandboxes: rows }, rows.length ? table.map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()).join(`
|
|
995
|
+
`) : "");
|
|
996
|
+
return 0;
|
|
997
|
+
}
|
|
998
|
+
function onePositional(flags, what) {
|
|
999
|
+
if (flags.positionals.length !== 1)
|
|
1000
|
+
throw usage(`Expected exactly one ${what}`);
|
|
1001
|
+
return flags.positionals[0];
|
|
1002
|
+
}
|
|
1003
|
+
async function inspectCommand(session, flags) {
|
|
1004
|
+
const { record } = await recoverCreate(session, await session.store(), await findTarget(session, onePositional(flags, "sandbox")));
|
|
1005
|
+
const box = await session.reopen(record);
|
|
1006
|
+
const info = await box.inspect({ signal: session.ctx.signal });
|
|
1007
|
+
const lines = [
|
|
1008
|
+
`name ${record.name}`,
|
|
1009
|
+
`id ${box.id}`,
|
|
1010
|
+
`provider ${record.provider} (profile ${record.profile})`,
|
|
1011
|
+
`state ${info.state}${info.nativeState ? ` (${info.nativeState})` : ""}`,
|
|
1012
|
+
`expires ${info.expires.status === "known" ? `${info.expires.at} (${info.expires.action})` : info.expires.status}`,
|
|
1013
|
+
`observed ${info.observedAt}`
|
|
1014
|
+
];
|
|
1015
|
+
session.ctx.out.success({
|
|
1016
|
+
...sandboxSummary(record),
|
|
1017
|
+
info: { ...info, reference: undefined },
|
|
1018
|
+
reference: record.reference
|
|
1019
|
+
}, lines.join(`
|
|
1020
|
+
`));
|
|
1021
|
+
return 0;
|
|
1022
|
+
}
|
|
1023
|
+
function refusal(record) {
|
|
1024
|
+
if (!record.reference)
|
|
1025
|
+
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 });
|
|
1026
|
+
if (record.reference.ownership !== "verified-created")
|
|
1027
|
+
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 });
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
function sameSandbox(a, b) {
|
|
1031
|
+
if (a.createdAt !== b.createdAt || a.provider !== b.provider || a.profile !== b.profile)
|
|
1032
|
+
return false;
|
|
1033
|
+
return !a.reference || !b.reference || a.reference.nativeId === b.reference.nativeId;
|
|
1034
|
+
}
|
|
1035
|
+
async function forget(store, target) {
|
|
1036
|
+
if (target.fromFile)
|
|
1037
|
+
return;
|
|
1038
|
+
for (let attempt = 0;; attempt++) {
|
|
1039
|
+
const current = await store.get("sandbox", target.record.name);
|
|
1040
|
+
if (!current || !sameSandbox(current.record, target.record))
|
|
1041
|
+
return;
|
|
1042
|
+
try {
|
|
1043
|
+
await store.remove("sandbox", target.record.name, current.version);
|
|
1044
|
+
return;
|
|
1045
|
+
} catch (cause) {
|
|
1046
|
+
if (!(cause instanceof CliError) || cause.code !== "CONFLICT" || attempt === 2)
|
|
1047
|
+
throw cause;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
async function recoverCreate(session, store, target) {
|
|
1052
|
+
const { record } = target;
|
|
1053
|
+
if (target.fromFile || record.status !== "unknown" || record.reference || !record.recovery)
|
|
1054
|
+
return target;
|
|
1055
|
+
if (record.recovery.kind !== "create")
|
|
1056
|
+
return target;
|
|
1057
|
+
const client = await session.connect(await session.profileNamed(record.profile));
|
|
1058
|
+
let value;
|
|
1059
|
+
try {
|
|
1060
|
+
value = await (await client.recover(record.recovery)).observe();
|
|
1061
|
+
} catch (cause) {
|
|
1062
|
+
if (cause instanceof SandbarError2 && cause.effect !== "none")
|
|
1063
|
+
return target;
|
|
1064
|
+
throw cause;
|
|
1065
|
+
}
|
|
1066
|
+
if (!(value instanceof AdapterSandbox))
|
|
1067
|
+
return target;
|
|
1068
|
+
const { recovery: _recovery, ...rest } = record;
|
|
1069
|
+
const recovered = { ...rest, status: "created", reference: value.reference };
|
|
1070
|
+
const { version } = await store.update("sandbox", record.name, recovered, target.version);
|
|
1071
|
+
session.ctx.out.note(`${record.name}: recovered the uncertain create as ${value.id}`);
|
|
1072
|
+
return { record: recovered, version };
|
|
1073
|
+
}
|
|
1074
|
+
async function removeOne(session, store, found, force, all) {
|
|
1075
|
+
const target = await recoverCreate(session, store, found).catch((cause) => {
|
|
1076
|
+
if (force)
|
|
1077
|
+
return found;
|
|
1078
|
+
throw cause;
|
|
1079
|
+
});
|
|
1080
|
+
const { record } = target;
|
|
1081
|
+
const refused = refusal(record);
|
|
1082
|
+
if (refused && !force) {
|
|
1083
|
+
if (all)
|
|
1084
|
+
return {
|
|
1085
|
+
kind: "skipped",
|
|
1086
|
+
item: { name: record.name, result: "skipped", reason: refused.message }
|
|
1087
|
+
};
|
|
1088
|
+
throw refused;
|
|
1089
|
+
}
|
|
1090
|
+
if (!record.reference) {
|
|
1091
|
+
await forget(store, target);
|
|
1092
|
+
return {
|
|
1093
|
+
kind: "removed",
|
|
1094
|
+
item: { name: record.name, result: "forgotten", retainedResources: [] }
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
let box;
|
|
1098
|
+
try {
|
|
1099
|
+
box = await session.reopen(record);
|
|
1100
|
+
} catch (cause) {
|
|
1101
|
+
if (!(cause instanceof SandbarError2) || cause.code !== "NOT_FOUND")
|
|
1102
|
+
throw cause;
|
|
1103
|
+
await forget(store, target);
|
|
1104
|
+
return { kind: "removed", item: { name: record.name, result: "gone", retainedResources: [] } };
|
|
1105
|
+
}
|
|
1106
|
+
const destroyed = await box.destroy({ signal: session.ctx.signal });
|
|
1107
|
+
await forget(store, target);
|
|
1108
|
+
return {
|
|
1109
|
+
kind: "removed",
|
|
1110
|
+
item: {
|
|
1111
|
+
name: record.name,
|
|
1112
|
+
result: "destroyed",
|
|
1113
|
+
retainedResources: destroyed.retainedResources
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
async function rmCommand(session, flags) {
|
|
1118
|
+
const store = await session.store();
|
|
1119
|
+
const all = flags.bool("all");
|
|
1120
|
+
const force = flags.bool("force");
|
|
1121
|
+
const { out } = session.ctx;
|
|
1122
|
+
if (all === Boolean(flags.positionals.length))
|
|
1123
|
+
throw usage("Name sandboxes to remove, or pass --all");
|
|
1124
|
+
const targets = all ? (await listAll(store, "sandbox", (warning) => out.note(warning))).map((entry) => ({
|
|
1125
|
+
name: entry.record.name,
|
|
1126
|
+
find: async () => entry
|
|
1127
|
+
})) : flags.positionals.map((target) => ({
|
|
1128
|
+
name: target,
|
|
1129
|
+
find: () => findTarget(session, target)
|
|
1130
|
+
}));
|
|
1131
|
+
const details = {
|
|
1132
|
+
removed: [],
|
|
1133
|
+
skipped: [],
|
|
1134
|
+
failed: []
|
|
1135
|
+
};
|
|
1136
|
+
let lastCause;
|
|
1137
|
+
for (const target of targets) {
|
|
1138
|
+
try {
|
|
1139
|
+
const result = await removeOne(session, store, await target.find(), force, all);
|
|
1140
|
+
if (result.kind === "skipped") {
|
|
1141
|
+
details.skipped.push(result.item);
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
details.removed.push(result.item);
|
|
1145
|
+
if (result.item.result === "gone")
|
|
1146
|
+
out.note(`${result.item.name}: already gone`);
|
|
1147
|
+
if (result.item.retainedResources.length)
|
|
1148
|
+
out.note(`${result.item.name}: retained ${result.item.retainedResources.join(", ")}`);
|
|
1149
|
+
} catch (cause) {
|
|
1150
|
+
const report = describeError(cause);
|
|
1151
|
+
lastCause = cause;
|
|
1152
|
+
details.failed.push({
|
|
1153
|
+
name: target.name,
|
|
1154
|
+
result: "failed",
|
|
1155
|
+
code: report.code,
|
|
1156
|
+
reason: report.message
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (details.failed.length === 1 && targets.length === 1)
|
|
1161
|
+
throw lastCause;
|
|
1162
|
+
if (details.failed.length)
|
|
1163
|
+
throw new CliError("PARTIAL", `Removed ${details.removed.length} of ${targets.length} sandboxes; ${details.failed.length} failed`, "see error.details; retry the failed names once the cause is fixed", undefined, "partial", details);
|
|
1164
|
+
for (const item of details.skipped)
|
|
1165
|
+
out.note(`skipped ${item.name}: ${item.reason}`);
|
|
1166
|
+
out.success({ removed: details.removed, skipped: details.skipped }, details.removed.map((entry) => entry.name).join(`
|
|
1167
|
+
`));
|
|
1168
|
+
return 0;
|
|
1169
|
+
}
|
|
1170
|
+
async function execInput(session, flags, command) {
|
|
1171
|
+
if (flags.bool("stdin") && flags.string("stdin-file"))
|
|
1172
|
+
throw usage("Pass either --stdin or --stdin-file");
|
|
1173
|
+
const stdinFile = flags.string("stdin-file");
|
|
1174
|
+
const stdin = flags.bool("stdin") ? await session.ctx.readStdin() : stdinFile ? new Uint8Array(await readFile4(resolve3(session.ctx.cwd, stdinFile))) : undefined;
|
|
1175
|
+
const timeout = flags.string("timeout");
|
|
1176
|
+
const maxOutput = flags.string("max-output");
|
|
1177
|
+
const env = parsePairs(flags.strings("env"), "env");
|
|
1178
|
+
return {
|
|
1179
|
+
command,
|
|
1180
|
+
cwd: flags.string("cwd"),
|
|
1181
|
+
env: Object.keys(env).length ? env : undefined,
|
|
1182
|
+
stdin,
|
|
1183
|
+
deadlineSeconds: timeout ? parseDuration(timeout, "timeout") : undefined,
|
|
1184
|
+
maxOutputBytes: maxOutput ? parseSize(maxOutput, "max-output") : undefined
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
function argvCommand(flags) {
|
|
1188
|
+
if (!flags.rest?.length)
|
|
1189
|
+
throw usage("Give the command after --, e.g. sandbar exec web -- ls -la");
|
|
1190
|
+
return { kind: "argv", argv: [...flags.rest] };
|
|
1191
|
+
}
|
|
1192
|
+
async function execute(session, box, input) {
|
|
1193
|
+
try {
|
|
1194
|
+
return await box.exec(input, { signal: session.ctx.signal });
|
|
1195
|
+
} catch (cause) {
|
|
1196
|
+
if (!(cause instanceof NonzeroExitError))
|
|
1197
|
+
throw cause;
|
|
1198
|
+
return cause.result;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
function execSummary(result) {
|
|
1202
|
+
return {
|
|
1203
|
+
exitCode: result.exitCode,
|
|
1204
|
+
stdout: result.stdoutText({ full: true }),
|
|
1205
|
+
stderr: result.stderrText({ full: true }),
|
|
1206
|
+
truncated: result.truncated
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
function printStreams(out, result) {
|
|
1210
|
+
if (out.json)
|
|
1211
|
+
return;
|
|
1212
|
+
if (result.stdout.byteLength)
|
|
1213
|
+
out.streams.stdout(result.stdout);
|
|
1214
|
+
if (result.stderr.byteLength)
|
|
1215
|
+
out.streams.stderr(result.stderr);
|
|
1216
|
+
if (result.truncated)
|
|
1217
|
+
out.note("sandbar: output was truncated; raise --max-output to capture more");
|
|
1218
|
+
}
|
|
1219
|
+
function finishExec(out, result, sandbox) {
|
|
1220
|
+
printStreams(out, result);
|
|
1221
|
+
if (result.exitCode === null)
|
|
1222
|
+
throw new CliError("OUTCOME_UNKNOWN", "The command ran but the provider reported no exit status", "the captured output is in error.known.exec", { exec: execSummary(result) });
|
|
1223
|
+
if (out.json)
|
|
1224
|
+
out.success({ ...execSummary(result), sandbox }, "");
|
|
1225
|
+
return result.exitCode;
|
|
1226
|
+
}
|
|
1227
|
+
async function execCommand(session, flags) {
|
|
1228
|
+
const { record } = await findTarget(session, onePositional(flags, "sandbox"));
|
|
1229
|
+
const input = await execInput(session, flags, argvCommand(flags));
|
|
1230
|
+
const box = await session.reopen(record);
|
|
1231
|
+
return finishExec(session.ctx.out, await execute(session, box, input));
|
|
1232
|
+
}
|
|
1233
|
+
function shScript(flags) {
|
|
1234
|
+
const [target, inline] = flags.positionals;
|
|
1235
|
+
if (flags.rest && flags.positionals.length === 1 && flags.rest.length === 1)
|
|
1236
|
+
return { target, script: flags.rest[0] };
|
|
1237
|
+
if (!flags.rest && flags.positionals.length === 2)
|
|
1238
|
+
return { target, script: inline };
|
|
1239
|
+
throw usage("Usage: sandbar sh <sandbox> '<script>' (or put the script after --, as one argument)");
|
|
1240
|
+
}
|
|
1241
|
+
async function shCommand(session, flags) {
|
|
1242
|
+
const { target, script } = shScript(flags);
|
|
1243
|
+
const { record } = await findTarget(session, target);
|
|
1244
|
+
const input = await execInput(session, flags, { kind: "shell", script });
|
|
1245
|
+
const box = await session.reopen(record);
|
|
1246
|
+
return finishExec(session.ctx.out, await execute(session, box, input));
|
|
1247
|
+
}
|
|
1248
|
+
async function runCommand(session, flags) {
|
|
1249
|
+
if (flags.positionals.length)
|
|
1250
|
+
throw usage("run takes no positional arguments; put the command after --");
|
|
1251
|
+
const command = argvCommand(flags);
|
|
1252
|
+
const request = createRequest(flags);
|
|
1253
|
+
const input = await execInput(session, flags, command);
|
|
1254
|
+
const keep = flags.bool("keep");
|
|
1255
|
+
const created = await createSandbox(session, { ...request, ifNotExists: false });
|
|
1256
|
+
const store = await session.store();
|
|
1257
|
+
const { out, signal } = session.ctx;
|
|
1258
|
+
let result;
|
|
1259
|
+
let failure;
|
|
1260
|
+
const sandbox = (kept) => ({
|
|
1261
|
+
name: created.record.name,
|
|
1262
|
+
id: created.record.reference?.nativeId ?? null,
|
|
1263
|
+
kept
|
|
1264
|
+
});
|
|
1265
|
+
if (created.unrecorded)
|
|
1266
|
+
failure = unrecordedError(created);
|
|
1267
|
+
else
|
|
1268
|
+
try {
|
|
1269
|
+
result = await execute(session, created.box, input);
|
|
1270
|
+
} catch (cause) {
|
|
1271
|
+
failure = cause;
|
|
1272
|
+
}
|
|
1273
|
+
if (keep) {
|
|
1274
|
+
out.note(`kept sandbox ${created.record.name}`);
|
|
1275
|
+
if (failure)
|
|
1276
|
+
throw failure;
|
|
1277
|
+
return finishExec(out, result, sandbox(true));
|
|
1278
|
+
}
|
|
1279
|
+
try {
|
|
1280
|
+
await created.box.destroy();
|
|
1281
|
+
await store.remove("sandbox", created.record.name);
|
|
1282
|
+
} catch (cause) {
|
|
1283
|
+
const report = describeError(cause);
|
|
1284
|
+
if (result)
|
|
1285
|
+
printStreams(out, result);
|
|
1286
|
+
throw new CliError("CLEANUP_FAILED", `Cleanup of ${created.record.name} failed: ${report.message}${failure ? `; the command also failed: ${describeError(failure).message}` : ""}`, `remove it with \`sandbar rm ${created.record.name}\``, {
|
|
1287
|
+
sandbox: created.record.name,
|
|
1288
|
+
reference: created.record.reference,
|
|
1289
|
+
exec: result ? execSummary(result) : undefined
|
|
1290
|
+
}, "partial");
|
|
1291
|
+
}
|
|
1292
|
+
if (failure && signal.aborted)
|
|
1293
|
+
throw new CliError("INTERRUPTED", `Interrupted; sandbox ${created.record.name} was destroyed`, "the command may have partly run before the sandbox was destroyed", { sandbox: created.record.name, reference: created.record.reference }, "partial");
|
|
1294
|
+
if (failure) {
|
|
1295
|
+
if (failure instanceof CliError && failure.code === "STORE_WRITE_FAILED")
|
|
1296
|
+
throw new CliError("STORE_WRITE_FAILED", `${failure.message}; the sandbox was destroyed, so nothing was run`);
|
|
1297
|
+
throw failure;
|
|
1298
|
+
}
|
|
1299
|
+
return finishExec(out, result, sandbox(false));
|
|
1300
|
+
}
|
|
1301
|
+
var RemotePath = /^([a-z0-9][a-z0-9-]{0,62}):(\/.*)$/;
|
|
1302
|
+
function remote(spec) {
|
|
1303
|
+
const match = RemotePath.exec(spec);
|
|
1304
|
+
return match ? { target: match[1], path: match[2] } : null;
|
|
1305
|
+
}
|
|
1306
|
+
async function cpCommand(session, flags) {
|
|
1307
|
+
if (flags.positionals.length !== 2)
|
|
1308
|
+
throw usage("Usage: sandbar cp <src> <dst> (one side is <sandbox>:/path)");
|
|
1309
|
+
if (flags.bool("recursive"))
|
|
1310
|
+
throw usage("Recursive copy is not available yet; copy files one at a time");
|
|
1311
|
+
const [source, destination] = [flags.positionals[0], flags.positionals[1]];
|
|
1312
|
+
const from = remote(source);
|
|
1313
|
+
const to = remote(destination);
|
|
1314
|
+
if (Boolean(from) === Boolean(to))
|
|
1315
|
+
throw usage("Exactly one side of cp must be <sandbox>:/path");
|
|
1316
|
+
if (to) {
|
|
1317
|
+
const local2 = resolve3(session.ctx.cwd, source);
|
|
1318
|
+
if ((await stat2(local2)).isDirectory())
|
|
1319
|
+
throw usage(`${source} is a directory; recursive copy is not available yet`);
|
|
1320
|
+
const bytes2 = new Uint8Array(await readFile4(local2));
|
|
1321
|
+
const { record: record2 } = await findTarget(session, to.target);
|
|
1322
|
+
const box2 = await session.reopen(record2);
|
|
1323
|
+
await box2.writeFile(to.path, bytes2, {
|
|
1324
|
+
overwrite: !flags.bool("no-clobber"),
|
|
1325
|
+
signal: session.ctx.signal
|
|
1326
|
+
});
|
|
1327
|
+
session.ctx.out.success({ sandbox: record2.name, path: to.path, bytes: bytes2.byteLength }, "");
|
|
1328
|
+
return 0;
|
|
1329
|
+
}
|
|
1330
|
+
const { record } = await findTarget(session, from.target);
|
|
1331
|
+
const box = await session.reopen(record);
|
|
1332
|
+
const bytes = await box.readFile(from.path, { signal: session.ctx.signal });
|
|
1333
|
+
let local = resolve3(session.ctx.cwd, destination);
|
|
1334
|
+
try {
|
|
1335
|
+
if ((await stat2(local)).isDirectory())
|
|
1336
|
+
local = join4(local, basename(from.path));
|
|
1337
|
+
} catch {}
|
|
1338
|
+
try {
|
|
1339
|
+
await writeFile2(local, bytes, { flag: flags.bool("no-clobber") ? "wx" : "w" });
|
|
1340
|
+
} catch (cause) {
|
|
1341
|
+
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
|
1342
|
+
throw new CliError("CONFLICT", `${local} already exists`, "drop --no-clobber to overwrite it");
|
|
1343
|
+
throw cause;
|
|
1344
|
+
}
|
|
1345
|
+
session.ctx.out.success({ sandbox: record.name, path: local, bytes: bytes.byteLength }, "");
|
|
1346
|
+
return 0;
|
|
1347
|
+
}
|
|
1348
|
+
async function catCommand(session, flags) {
|
|
1349
|
+
const location = remote(onePositional(flags, "<sandbox>:/path"));
|
|
1350
|
+
if (!location)
|
|
1351
|
+
throw usage("Usage: sandbar cat <sandbox>:/path");
|
|
1352
|
+
const { record } = await findTarget(session, location.target);
|
|
1353
|
+
const box = await session.reopen(record);
|
|
1354
|
+
const bytes = await box.readFile(location.path, { signal: session.ctx.signal });
|
|
1355
|
+
const { out } = session.ctx;
|
|
1356
|
+
if (!out.json) {
|
|
1357
|
+
out.streams.stdout(bytes);
|
|
1358
|
+
return 0;
|
|
1359
|
+
}
|
|
1360
|
+
let text = null;
|
|
1361
|
+
try {
|
|
1362
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1363
|
+
} catch {}
|
|
1364
|
+
out.success(text === null ? {
|
|
1365
|
+
path: location.path,
|
|
1366
|
+
bytes: bytes.byteLength,
|
|
1367
|
+
base64: Buffer.from(bytes).toString("base64")
|
|
1368
|
+
} : { path: location.path, bytes: bytes.byteLength, text }, "");
|
|
1369
|
+
return 0;
|
|
1370
|
+
}
|
|
1371
|
+
async function doctorCommand(session) {
|
|
1372
|
+
const loaded = await session.config();
|
|
1373
|
+
const profile = await session.profile();
|
|
1374
|
+
const client = await session.connect(profile);
|
|
1375
|
+
const capabilities = await client.capabilities();
|
|
1376
|
+
let plan;
|
|
1377
|
+
try {
|
|
1378
|
+
plan = await client.sandboxes.checkCreate();
|
|
1379
|
+
} catch (cause) {
|
|
1380
|
+
plan = { status: "unavailable", reason: describeError(cause).message };
|
|
1381
|
+
}
|
|
1382
|
+
const store = await session.store();
|
|
1383
|
+
const count = (await listAll(store, "sandbox")).length;
|
|
1384
|
+
session.ctx.out.success({
|
|
1385
|
+
config: { path: loaded.path, exists: loaded.exists },
|
|
1386
|
+
profile: profile.name,
|
|
1387
|
+
provider: profile.provider,
|
|
1388
|
+
connected: true,
|
|
1389
|
+
create: plan,
|
|
1390
|
+
sandboxesInStore: count,
|
|
1391
|
+
capabilities
|
|
1392
|
+
}, [
|
|
1393
|
+
`config ${loaded.path}${loaded.exists ? "" : " (not found, using defaults)"}`,
|
|
1394
|
+
`profile ${profile.name} (${profile.provider})`,
|
|
1395
|
+
"connect ok",
|
|
1396
|
+
`create ${plan.status}${plan.status === "supported" ? "" : `: ${plan.reason}`}`,
|
|
1397
|
+
`store ${count} sandbox record(s)`
|
|
1398
|
+
].join(`
|
|
1399
|
+
`));
|
|
1400
|
+
return 0;
|
|
1401
|
+
}
|
|
1402
|
+
function profileKeys(loaded, provider, env) {
|
|
1403
|
+
return new Set(Object.values(loaded.value.profiles ?? {}).filter((profile) => profile.provider === provider).flatMap((profile) => [
|
|
1404
|
+
...Object.entries(profile.config ?? {}),
|
|
1405
|
+
...Object.entries(profile.credentials ?? {})
|
|
1406
|
+
].flatMap(([key, value]) => valueAvailable(value, env) ? [key] : [])));
|
|
1407
|
+
}
|
|
1408
|
+
async function providersCommand(session) {
|
|
1409
|
+
const { env, cwd } = session.ctx;
|
|
1410
|
+
const loaded = await session.config();
|
|
1411
|
+
const rows = await Promise.all(providers.map(async (provider) => {
|
|
1412
|
+
const resolved = await resolveAdapterModule({
|
|
1413
|
+
name: provider.id,
|
|
1414
|
+
provider: provider.id,
|
|
1415
|
+
module: provider.module,
|
|
1416
|
+
export: provider.export,
|
|
1417
|
+
baseDir: cwd,
|
|
1418
|
+
searchProject: searchProjectAdapters(loaded, env),
|
|
1419
|
+
config: {},
|
|
1420
|
+
credentials: {}
|
|
1421
|
+
}, cwd, env);
|
|
1422
|
+
let installed = resolved !== provider.module;
|
|
1423
|
+
if (!installed)
|
|
1424
|
+
try {
|
|
1425
|
+
import.meta.resolve(provider.module);
|
|
1426
|
+
installed = true;
|
|
1427
|
+
} catch {
|
|
1428
|
+
installed = false;
|
|
1429
|
+
}
|
|
1430
|
+
return {
|
|
1431
|
+
id: provider.id,
|
|
1432
|
+
module: provider.module,
|
|
1433
|
+
experimental: provider.experimental,
|
|
1434
|
+
installed,
|
|
1435
|
+
missingEnv: missingEnv(provider, env, profileKeys(loaded, provider.id, env))
|
|
1436
|
+
};
|
|
1437
|
+
}));
|
|
1438
|
+
session.ctx.out.success({ providers: rows }, rows.map((row) => `${row.id.padEnd(8)} ${row.installed ? "installed" : "not installed"} ${row.missingEnv.length ? `needs ${row.missingEnv.join(", ")}` : "credentials set"}${row.experimental ? " (experimental)" : ""}`).join(`
|
|
1439
|
+
`));
|
|
1440
|
+
return 0;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// src/secrets.ts
|
|
1444
|
+
import { execFile } from "node:child_process";
|
|
1445
|
+
import { z as z5 } from "zod";
|
|
1446
|
+
var BwsSecret = z5.object({ value: z5.string() });
|
|
1447
|
+
var backends = [
|
|
1448
|
+
{
|
|
1449
|
+
name: "1Password",
|
|
1450
|
+
pattern: /^op:\/\/[^/\s]+\/[^/\s]+(\/[^/\s]+){1,2}(\?\S*)?$/,
|
|
1451
|
+
command: "op",
|
|
1452
|
+
args: (reference) => ["read", "--no-newline", reference],
|
|
1453
|
+
value: (stdout) => stdout,
|
|
1454
|
+
install: "install the 1Password CLI (https://developer.1password.com/docs/cli) and sign in",
|
|
1455
|
+
check: "check `op whoami` and that the vault, item and field exist"
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
name: "Bitwarden Secrets Manager",
|
|
1459
|
+
pattern: /^bws:\/\/[0-9a-fA-F-]{36}$/,
|
|
1460
|
+
command: "bws",
|
|
1461
|
+
args: (reference) => ["secret", "get", reference.slice("bws://".length), "--output", "json"],
|
|
1462
|
+
value: (stdout) => BwsSecret.parse(JSON.parse(stdout)).value,
|
|
1463
|
+
install: "install the Bitwarden Secrets Manager CLI (https://bitwarden.com/help/secrets-manager-cli/) and set BWS_ACCESS_TOKEN",
|
|
1464
|
+
check: "check BWS_ACCESS_TOKEN and that the machine account can read the secret"
|
|
1465
|
+
},
|
|
1466
|
+
{
|
|
1467
|
+
name: "pass",
|
|
1468
|
+
pattern: /^pass:\/\/(?!.*(^|\/)\.\.(\/|$))[\w@+.-]+(\/[\w@+.-]+)*$/,
|
|
1469
|
+
command: "pass",
|
|
1470
|
+
args: (reference) => ["show", reference.slice("pass://".length)],
|
|
1471
|
+
value: (stdout) => stdout.split(`
|
|
1472
|
+
`)[0] ?? "",
|
|
1473
|
+
install: "install pass (https://www.passwordstore.org) and initialise your store",
|
|
1474
|
+
check: "check that `pass show <path>` works, including the GPG agent"
|
|
1475
|
+
}
|
|
1476
|
+
];
|
|
1477
|
+
function backendFor(value) {
|
|
1478
|
+
return backends.find((backend) => backend.pattern.test(value));
|
|
1479
|
+
}
|
|
1480
|
+
function run(command, args, env, signal) {
|
|
1481
|
+
return new Promise((done) => {
|
|
1482
|
+
execFile(command, args, { env: { ...env }, signal, timeout: 60000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => done({
|
|
1483
|
+
code: error ? error.code ?? "FAILED" : 0,
|
|
1484
|
+
stdout: String(stdout),
|
|
1485
|
+
stderr: String(stderr)
|
|
1486
|
+
}));
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
class SecretReader {
|
|
1491
|
+
env;
|
|
1492
|
+
signal;
|
|
1493
|
+
cache = new Map;
|
|
1494
|
+
constructor(env, signal) {
|
|
1495
|
+
this.env = env;
|
|
1496
|
+
this.signal = signal;
|
|
1497
|
+
}
|
|
1498
|
+
read(backend, reference, where) {
|
|
1499
|
+
let pending = this.cache.get(reference);
|
|
1500
|
+
if (!pending) {
|
|
1501
|
+
pending = this.fetch(backend, reference, where);
|
|
1502
|
+
this.cache.set(reference, pending);
|
|
1503
|
+
}
|
|
1504
|
+
return pending;
|
|
1505
|
+
}
|
|
1506
|
+
async fetch(backend, reference, where) {
|
|
1507
|
+
const result = await run(backend.command, backend.args(reference), this.env, this.signal);
|
|
1508
|
+
if (result.code === "ENOENT")
|
|
1509
|
+
throw new CliError("SECRET_TOOL_MISSING", `${where} is a ${backend.name} reference, but \`${backend.command}\` is not installed`, backend.install);
|
|
1510
|
+
if (result.code !== 0)
|
|
1511
|
+
throw new CliError("SECRET_UNAVAILABLE", `Cannot read ${where} from ${backend.name} (${reference}): ${result.stderr.trim() || `${backend.command} exited with ${result.code}`}`, backend.check);
|
|
1512
|
+
try {
|
|
1513
|
+
return backend.value(result.stdout);
|
|
1514
|
+
} catch {
|
|
1515
|
+
throw new CliError("SECRET_UNAVAILABLE", `${backend.name} returned an unreadable answer for ${where} (${reference})`, backend.check);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
async resolve(value, where) {
|
|
1519
|
+
const text = z5.string().safeParse(value);
|
|
1520
|
+
if (text.success) {
|
|
1521
|
+
const backend = backendFor(text.data);
|
|
1522
|
+
return backend ? this.read(backend, text.data, where) : value;
|
|
1523
|
+
}
|
|
1524
|
+
if (Array.isArray(value))
|
|
1525
|
+
return Promise.all(value.map((item, index) => this.resolve(item, `${where}[${index}]`)));
|
|
1526
|
+
const record = z5.record(z5.string(), z5.json()).safeParse(value);
|
|
1527
|
+
if (record.success)
|
|
1528
|
+
return Object.fromEntries(await Promise.all(Object.entries(record.data).map(async ([key, item]) => [key, await this.resolve(item, `${where}.${key}`)])));
|
|
1529
|
+
return value;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// src/session.ts
|
|
1534
|
+
class Session {
|
|
1535
|
+
ctx;
|
|
1536
|
+
loaded;
|
|
1537
|
+
opened = [];
|
|
1538
|
+
secrets;
|
|
1539
|
+
constructor(ctx) {
|
|
1540
|
+
this.ctx = ctx;
|
|
1541
|
+
this.secrets = new SecretReader(ctx.env, ctx.signal);
|
|
1542
|
+
}
|
|
1543
|
+
async config() {
|
|
1544
|
+
this.loaded ??= await loadConfig(this.ctx.flags.config, this.ctx.env);
|
|
1545
|
+
return this.loaded;
|
|
1546
|
+
}
|
|
1547
|
+
async store() {
|
|
1548
|
+
const { flags, env, cwd } = this.ctx;
|
|
1549
|
+
return new FsStateStore(stateDir(flags.stateDir, await this.config(), env, cwd));
|
|
1550
|
+
}
|
|
1551
|
+
async limit() {
|
|
1552
|
+
return maxSandboxes(await this.config(), this.ctx.env);
|
|
1553
|
+
}
|
|
1554
|
+
async profile() {
|
|
1555
|
+
return resolveProfile(this.ctx.flags, await this.config(), this.ctx.env);
|
|
1556
|
+
}
|
|
1557
|
+
async profileNamed(name) {
|
|
1558
|
+
const loaded = await this.config();
|
|
1559
|
+
if (loaded.value.profiles?.[name])
|
|
1560
|
+
return resolveProfile({ profile: name }, loaded, this.ctx.env);
|
|
1561
|
+
if (descriptor(name))
|
|
1562
|
+
return resolveProfile({ provider: name }, loaded, this.ctx.env);
|
|
1563
|
+
throw new CliError("USAGE", `Profile ${name} is no longer defined`, `restore it in ${loaded.path}; the sandbox was created with it`);
|
|
1564
|
+
}
|
|
1565
|
+
async connect(profile) {
|
|
1566
|
+
const client = await connect(profile, this.ctx.cwd, this.ctx.env, this.secrets);
|
|
1567
|
+
this.opened.push(client);
|
|
1568
|
+
return client;
|
|
1569
|
+
}
|
|
1570
|
+
async reopen(record, client) {
|
|
1571
|
+
if (!record.reference)
|
|
1572
|
+
throw new CliError("UNSUPPORTED", `Sandbox ${record.name} has no reopenable reference (status ${record.status})`, "inspect it with the provider's own tools; `sandbar rm --force` drops the record");
|
|
1573
|
+
const connected = client ?? await this.connect(await this.profileNamed(record.profile));
|
|
1574
|
+
return connected.sandboxes.get(record.reference, { signal: this.ctx.signal });
|
|
1575
|
+
}
|
|
1576
|
+
async close() {
|
|
1577
|
+
await Promise.allSettled(this.opened.splice(0).map((client) => client.close()));
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// src/skill.ts
|
|
1582
|
+
import { access as access2, mkdir as mkdir2, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
|
|
1583
|
+
import { homedir as homedir2 } from "node:os";
|
|
1584
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
1585
|
+
|
|
1586
|
+
// ../../skills/sandbar-cli/SKILL.md
|
|
1587
|
+
var SKILL_default = "---\nname: sandbar-cli\ndescription: Create, use and clean up cloud sandboxes from the shell with the `sandbar` CLI (Daytona, E2B, boxd, Vercel, Modal). Use when you need an isolated machine to run untrusted or generated code, try a build or test in a clean environment, or manage sandboxes someone created with sandbar; prefer it over provider-specific CLIs.\n---\n\n# Use sandboxes with the sandbar CLI\n\n`sandbar` creates and manages sandboxes on any Sandbar provider. It is built for agents: it never prompts, every command has a stable `--json` form, and exit codes tell you what to do next.\n\n## Check the setup first\n\n```sh\nsandbar doctor --json # config, credentials, provider connection; creates nothing\nsandbar providers --json # which adapters are installed and which credentials are missing\n```\n\n- If `sandbar` is not on PATH, stop and tell the user; do not install anything yourself.\n- The provider comes from `--profile`, `SANDBAR_PROFILE`, `--provider`, `SANDBAR_PROVIDER`, then `default` in `~/.config/sandbar/sandbar.json`. Do not edit that file or credentials unless the user asks.\n- A missing adapter (exit 4, `ADAPTER_NOT_INSTALLED`) or missing credential (exit 2) is the user's to fix. Report the `hint` from the error.\n\n**Sandboxes cost money.** Create only what the task needs and destroy it when done.\n\n## Always use JSON\n\nPass `--json` (or set `SANDBAR_JSON=1`). stdout then holds exactly one document:\n\n```json\n{\"ok\": true, \"data\": {...}}\n{\"ok\": false, \"error\": {\"code\": \"...\", \"message\": \"...\", \"retryable\": false, \"outcome\": \"rejected\", \"hint\": \"...\", \"known\": {...}}}\n```\n\nDiagnostics go to stderr. Read `error.hint` before deciding what to do.\n\n## One-shot work: `run`\n\n`run` creates a sandbox, runs one command and always destroys it. Prefer it whenever you do not need the sandbox afterwards.\n\n```sh\nsandbar run --json -- python3 -c 'print(2 + 2)'\nsandbar run --json --prepared base --timeout 5m -- sh -c 'pip install -q requests && python3 app.py'\n```\n\nPass the command after `--` as separate arguments. Its data is `{exitCode, stdout, stderr, truncated, sandbox}`, where `sandbox` names the sandbox it created and destroyed.\n\n## Longer work: create, use, remove\n\n```sh\nsandbar create --json --name job-1 --prepared base # prints the name; reuse it everywhere\nsandbar cp ./app.py job-1:/home/user/app.py\nsandbar exec --json job-1 --cwd /home/user -- python3 app.py\nsandbar sh --json job-1 'cd /home/user && ls -la | head'\nsandbar cat job-1:/home/user/out.txt\nsandbar rm --json job-1\n```\n\n- Give sandboxes a `--name` so you can find them again; names are lowercase letters, digits and dashes. `--if-not-exists` reuses a live sandbox with that name instead of failing.\n- `exec` takes an argv after `--`; no shell runs. Use `sh` only when you need pipes, redirects or `&&`. For a script that starts with `-`, put it after `--`: `sandbar sh job-1 -- '-n ...'`.\n- `--env K=V` (repeatable), `--cwd`, `--stdin` or `--stdin-file <path>`, `--timeout 30s|5m|1h`, `--max-output 1m`.\n- `cp` copies one file; one side is `<sandbox>:/absolute/path`. `--no-clobber` refuses to overwrite.\n- `--image <oci-ref>` boots an OCI image where the provider supports it; `--prepared <id>` uses a provider template or snapshot. On providers that need an image built first, `--image` fails with exit 4 before creating anything.\n- `sandbar ls --json` lists known sandboxes with their live state (`running`, `gone`, `unreachable`, ...). `sandbar inspect --json <name>` shows state, expiry and the saved reference.\n\nAlways `rm` what you created before finishing, even after failures. `rm` only destroys sandboxes this CLI created; never pass `--force` or `rm --all` unless the user asked.\n\n## Exit codes\n\n`exec`, `sh` and `run` exit with the **remote command's** exit code. Their own failures always exit **125**, with the error in the JSON document. A command that ran but reported no exit status is 125 with `OUTCOME_UNKNOWN`; its output is in `error.known.exec`. It is never success.\n\nOther commands:\n\n| Code | Meaning | What to do |\n| ---- | ----------------------------------------------------------------- | ---------------------------------------------------- |\n| 0 | success | |\n| 1 | other failure, including a name already taken | read the message |\n| 2 | usage or local error | fix the command; `sandbar <command> --help` |\n| 3 | not found | `sandbar ls` |\n| 4 | unsupported, adapter or tool not installed, limit reached | tell the user; do not work around it |\n| 5 | authentication, permission, or unreadable password-manager secret | tell the user |\n| 6 | **outcome unknown**: the effect may have happened | **do not retry**; run `sandbar ls` / `inspect` first |\n| 7 | retryable (rate limit, capacity, unavailable) before any effect | wait, then retry once or twice |\n| 8 | partial: some steps happened | read `error.known` and `error.details` |\n\n## When things go wrong\n\n- **Never repeat a create after exit 6 or `outcome: \"unknown\"`.** The store keeps the name in status `unknown`; check `sandbar ls --json`. `sandbar rm <name>` first asks the provider how that create ended (never resending it) and destroys the sandbox if one exists. If it still exits 4 because the outcome is unknown, and the user agrees it is gone, `sandbar rm --force <name>` drops the record.\n- **`run` exits 125 with `CLEANUP_FAILED`**: the command result is in `error.known.exec`, but the sandbox is still running. Tell the user and run the `rm` from the hint.\n- **`rm` exits 8**: `error.details` lists what was `removed`, `skipped` and `failed`. Retry only the failed names once the cause is fixed.\n- **`STORE_WRITE_FAILED`**: the sandbox exists but was not recorded. The reference is in `error.known.reference`; save it to a file and destroy with `sandbar rm @file.json`.\n- Interrupting `run` (Ctrl-C, SIGTERM) still destroys its sandbox and exits 130 (143 for SIGTERM) with `INTERRUPTED`. A second signal exits immediately and may leave it running.\n\n## Discover more\n\n`sandbar schema` prints every command, flag and exit code as JSON. `sandbar <command> --help` shows usage and examples.\n";
|
|
1588
|
+
|
|
1589
|
+
// src/skill.ts
|
|
1590
|
+
var skillName = "sandbar-cli";
|
|
1591
|
+
var marker = "<!-- Installed by `sandbar skill install`; rerun it to update this file. -->";
|
|
1592
|
+
async function exists2(path) {
|
|
1593
|
+
try {
|
|
1594
|
+
await access2(path);
|
|
1595
|
+
return true;
|
|
1596
|
+
} catch {
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
async function skillRoots(base) {
|
|
1601
|
+
const roots = [join5(base, ".agents", "skills")];
|
|
1602
|
+
if (await exists2(join5(base, ".claude")))
|
|
1603
|
+
roots.push(join5(base, ".claude", "skills"));
|
|
1604
|
+
return roots;
|
|
1605
|
+
}
|
|
1606
|
+
async function installAt(root, content) {
|
|
1607
|
+
const path = join5(root, skillName, "SKILL.md");
|
|
1608
|
+
let current = null;
|
|
1609
|
+
try {
|
|
1610
|
+
current = await readFile5(path, "utf8");
|
|
1611
|
+
} catch (cause) {
|
|
1612
|
+
if (!(cause instanceof Error && ("code" in cause) && cause.code === "ENOENT"))
|
|
1613
|
+
throw cause;
|
|
1614
|
+
}
|
|
1615
|
+
if (current === content)
|
|
1616
|
+
return { path, result: "unchanged" };
|
|
1617
|
+
if (current !== null && !current.includes(marker))
|
|
1618
|
+
return {
|
|
1619
|
+
path,
|
|
1620
|
+
result: "skipped",
|
|
1621
|
+
reason: "a copy installed another way is already there; remove it to let sandbar manage it"
|
|
1622
|
+
};
|
|
1623
|
+
await mkdir2(dirname3(path), { recursive: true });
|
|
1624
|
+
await writeFile3(path, content);
|
|
1625
|
+
return { path, result: current === null ? "written" : "updated" };
|
|
1626
|
+
}
|
|
1627
|
+
async function skillCommand(session, flags) {
|
|
1628
|
+
const [action, ...extra] = flags.positionals;
|
|
1629
|
+
const { out, env, cwd } = session.ctx;
|
|
1630
|
+
if (extra.length || action !== undefined && action !== "install")
|
|
1631
|
+
throw usage("Usage: sandbar skill [install [--project]]");
|
|
1632
|
+
if (action === undefined) {
|
|
1633
|
+
if (flags.bool("project"))
|
|
1634
|
+
throw usage("--project only applies to `sandbar skill install`");
|
|
1635
|
+
if (out.json)
|
|
1636
|
+
out.success({ name: skillName, content: SKILL_default }, "");
|
|
1637
|
+
else
|
|
1638
|
+
out.streams.stdout(SKILL_default);
|
|
1639
|
+
return 0;
|
|
1640
|
+
}
|
|
1641
|
+
const content = `${SKILL_default.trimEnd()}
|
|
1642
|
+
|
|
1643
|
+
${marker}
|
|
1644
|
+
`;
|
|
1645
|
+
const base = flags.bool("project") ? cwd : env.HOME || homedir2();
|
|
1646
|
+
const results = await Promise.all((await skillRoots(base)).map((root) => installAt(root, content)));
|
|
1647
|
+
if (results.every((entry) => entry.result === "skipped"))
|
|
1648
|
+
throw new CliError("CONFLICT", `The ${skillName} skill is already installed another way: ${results.map((entry) => entry.path).join(", ")}`, "remove those files and rerun, or update them with the tool that installed them");
|
|
1649
|
+
for (const entry of results)
|
|
1650
|
+
if (entry.reason)
|
|
1651
|
+
out.note(`skipped ${entry.path}: ${entry.reason}`);
|
|
1652
|
+
out.success({ name: skillName, installed: results }, results.filter((entry) => entry.result !== "skipped").map((entry) => `${entry.result.padEnd(9)} ${entry.path}`).join(`
|
|
1653
|
+
`));
|
|
1654
|
+
return 0;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// src/completion.ts
|
|
1658
|
+
var NoSpace = 1;
|
|
1659
|
+
var Files = 2;
|
|
1660
|
+
var fileFlags = new Set(["config", "state-dir", "stdin-file"]);
|
|
1661
|
+
var sandboxArguments = new Map([
|
|
1662
|
+
["inspect", 1],
|
|
1663
|
+
["exec", 1],
|
|
1664
|
+
["sh", 1],
|
|
1665
|
+
["rm", Infinity]
|
|
1666
|
+
]);
|
|
1667
|
+
async function sandboxNames(session) {
|
|
1668
|
+
const entries = await listAll(await session.store(), "sandbox");
|
|
1669
|
+
return entries.map(({ record }) => ({
|
|
1670
|
+
value: record.name,
|
|
1671
|
+
description: `${record.provider}, ${record.status}`
|
|
1672
|
+
}));
|
|
1673
|
+
}
|
|
1674
|
+
async function profileNames(session) {
|
|
1675
|
+
const loaded = await session.config();
|
|
1676
|
+
return Object.keys(loaded.value.profiles ?? {}).map((name) => ({ value: name }));
|
|
1677
|
+
}
|
|
1678
|
+
function flagCandidates(flags, globals) {
|
|
1679
|
+
return Object.entries({ ...globals, ...flags }).filter(([name]) => name !== "help").map(([name, spec]) => ({ value: `--${name}`, description: spec.description }));
|
|
1680
|
+
}
|
|
1681
|
+
async function complete(words, commands, globals, session) {
|
|
1682
|
+
const current = words.at(-1) ?? "";
|
|
1683
|
+
const before = words.slice(0, -1);
|
|
1684
|
+
const none = { candidates: [], directive: 0 };
|
|
1685
|
+
if (before.includes("--"))
|
|
1686
|
+
return { candidates: [], directive: Files };
|
|
1687
|
+
let command;
|
|
1688
|
+
const positionals = [];
|
|
1689
|
+
for (let index = 0;index < before.length; index++) {
|
|
1690
|
+
const word = before[index];
|
|
1691
|
+
if (word.startsWith("--")) {
|
|
1692
|
+
const spec = { ...globals, ...command?.flags }[word.slice(2)];
|
|
1693
|
+
if (spec?.type === "string" && !word.includes("="))
|
|
1694
|
+
index++;
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
if (!command)
|
|
1698
|
+
command = commands.find((candidate) => candidate.name === word);
|
|
1699
|
+
else
|
|
1700
|
+
positionals.push(word);
|
|
1701
|
+
}
|
|
1702
|
+
const previous = before.at(-1);
|
|
1703
|
+
const valueOf = previous?.startsWith("--") ? { ...globals, ...command?.flags }[previous.slice(2)] : undefined;
|
|
1704
|
+
if (previous && valueOf?.type === "string") {
|
|
1705
|
+
const flag = previous.slice(2);
|
|
1706
|
+
if (fileFlags.has(flag))
|
|
1707
|
+
return { candidates: [], directive: Files };
|
|
1708
|
+
if (flag === "profile")
|
|
1709
|
+
return { candidates: await profileNames(session), directive: 0 };
|
|
1710
|
+
if (flag === "provider")
|
|
1711
|
+
return {
|
|
1712
|
+
candidates: providers.map((provider) => ({ value: provider.id })),
|
|
1713
|
+
directive: 0
|
|
1714
|
+
};
|
|
1715
|
+
return none;
|
|
1716
|
+
}
|
|
1717
|
+
if (current.startsWith("-"))
|
|
1718
|
+
return { candidates: flagCandidates(command?.flags ?? {}, globals), directive: 0 };
|
|
1719
|
+
if (!command)
|
|
1720
|
+
return {
|
|
1721
|
+
candidates: commands.map((candidate) => ({
|
|
1722
|
+
value: candidate.name,
|
|
1723
|
+
description: candidate.summary
|
|
1724
|
+
})),
|
|
1725
|
+
directive: 0
|
|
1726
|
+
};
|
|
1727
|
+
const limit = sandboxArguments.get(command.name);
|
|
1728
|
+
if (limit !== undefined)
|
|
1729
|
+
return positionals.length < limit ? { candidates: await sandboxNames(session), directive: 0 } : none;
|
|
1730
|
+
if (command.name === "cp" || command.name === "cat") {
|
|
1731
|
+
if (current.includes(":"))
|
|
1732
|
+
return none;
|
|
1733
|
+
const remote2 = (await sandboxNames(session)).map((entry) => ({
|
|
1734
|
+
...entry,
|
|
1735
|
+
value: `${entry.value}:`
|
|
1736
|
+
}));
|
|
1737
|
+
return { candidates: remote2, directive: NoSpace | (command.name === "cp" ? Files : 0) };
|
|
1738
|
+
}
|
|
1739
|
+
if (command.name === "skill" && positionals.length === 0)
|
|
1740
|
+
return {
|
|
1741
|
+
candidates: [{ value: "install", description: "Install the skill for your agents" }],
|
|
1742
|
+
directive: 0
|
|
1743
|
+
};
|
|
1744
|
+
if (command.name === "completion" && positionals.length === 0)
|
|
1745
|
+
return { candidates: shells.map((shell) => ({ value: shell })), directive: 0 };
|
|
1746
|
+
return none;
|
|
1747
|
+
}
|
|
1748
|
+
function formatCompletion(completion) {
|
|
1749
|
+
const lines = completion.candidates.map((candidate) => candidate.description ? `${candidate.value} ${candidate.description.replaceAll(/\s+/g, " ")}` : candidate.value);
|
|
1750
|
+
return `${[...lines, `:${completion.directive}`].join(`
|
|
1751
|
+
`)}
|
|
1752
|
+
`;
|
|
1753
|
+
}
|
|
1754
|
+
var zsh = `#compdef sandbar
|
|
1755
|
+
# sandbar completion for zsh. Enable with: source <(sandbar completion zsh)
|
|
1756
|
+
|
|
1757
|
+
_sandbar() {
|
|
1758
|
+
local -a lines described plain
|
|
1759
|
+
local line value description directive
|
|
1760
|
+
lines=("\${(@f)$(command sandbar __complete -- "\${(@)words[2,CURRENT]}" 2>/dev/null)}")
|
|
1761
|
+
directive=\${lines[-1]#:}
|
|
1762
|
+
lines=("\${(@)lines[1,-2]}")
|
|
1763
|
+
|
|
1764
|
+
for line in "\${lines[@]}"; do
|
|
1765
|
+
[[ -z $line ]] && continue
|
|
1766
|
+
value=\${line%%$'\\t'*}
|
|
1767
|
+
if [[ $line == *$'\\t'* ]]; then
|
|
1768
|
+
description=\${line#*$'\\t'}
|
|
1769
|
+
described+=("\${value//:/\\\\:}:$description")
|
|
1770
|
+
else
|
|
1771
|
+
plain+=("$value")
|
|
1772
|
+
fi
|
|
1773
|
+
done
|
|
1774
|
+
|
|
1775
|
+
local -a suffix
|
|
1776
|
+
(( directive & 1 )) && suffix=(-S '')
|
|
1777
|
+
(( \${#described} )) && _describe -t sandbar sandbar described "\${suffix[@]}"
|
|
1778
|
+
(( \${#plain} )) && compadd "\${suffix[@]}" -a plain
|
|
1779
|
+
(( directive & 2 )) && _files
|
|
1780
|
+
return 0
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
|
|
1784
|
+
_sandbar "$@"
|
|
1785
|
+
else
|
|
1786
|
+
compdef _sandbar sandbar
|
|
1787
|
+
fi
|
|
1788
|
+
`;
|
|
1789
|
+
var bash = `# sandbar completion for bash. Enable with: source <(sandbar completion bash)
|
|
1790
|
+
|
|
1791
|
+
_sandbar() {
|
|
1792
|
+
local line current output directive
|
|
1793
|
+
local -a words candidates
|
|
1794
|
+
# Split the line ourselves so "name:/path" stays one word whatever COMP_WORDBREAKS says.
|
|
1795
|
+
line=\${COMP_LINE:0:COMP_POINT}
|
|
1796
|
+
read -ra words <<< "$line"
|
|
1797
|
+
[[ $line == *" " ]] && words+=("")
|
|
1798
|
+
current=\${words[-1]}
|
|
1799
|
+
output=$(command sandbar __complete -- "\${words[@]:1}" 2>/dev/null) || return 0
|
|
1800
|
+
directive=\${output##*:}
|
|
1801
|
+
output=\${output%:*}
|
|
1802
|
+
|
|
1803
|
+
while IFS= read -r candidate; do
|
|
1804
|
+
[[ -z $candidate ]] && continue
|
|
1805
|
+
candidate=\${candidate%%$'\\t'*}
|
|
1806
|
+
[[ $candidate == "$current"* ]] && candidates+=("$candidate")
|
|
1807
|
+
done <<< "$output"
|
|
1808
|
+
|
|
1809
|
+
if (( directive & 2 )); then
|
|
1810
|
+
local file
|
|
1811
|
+
while IFS= read -r file; do candidates+=("$file"); done < <(compgen -f -- "$current")
|
|
1812
|
+
fi
|
|
1813
|
+
|
|
1814
|
+
# bash replaces only the text after the last ':' when ':' is a word break.
|
|
1815
|
+
if [[ $current == *:* && $COMP_WORDBREAKS == *:* ]]; then
|
|
1816
|
+
local prefix=\${current%:*}:
|
|
1817
|
+
candidates=("\${candidates[@]#"$prefix"}")
|
|
1818
|
+
fi
|
|
1819
|
+
|
|
1820
|
+
COMPREPLY=("\${candidates[@]}")
|
|
1821
|
+
(( directive & 1 )) && compopt -o nospace 2>/dev/null
|
|
1822
|
+
return 0
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
complete -F _sandbar sandbar
|
|
1826
|
+
`;
|
|
1827
|
+
var fish = `# sandbar completion for fish. Enable with: sandbar completion fish | source
|
|
1828
|
+
|
|
1829
|
+
function __sandbar_complete
|
|
1830
|
+
set -l words (commandline -opc)[2..-1] (commandline -ct)
|
|
1831
|
+
set -l output (command sandbar __complete -- $words 2>/dev/null)
|
|
1832
|
+
set -l directive (string replace ':' '' -- $output[-1])
|
|
1833
|
+
string join \\n -- $output[1..-2]
|
|
1834
|
+
if test (math "bitand($directive, 2)") -ne 0
|
|
1835
|
+
__fish_complete_path (commandline -ct)
|
|
1836
|
+
end
|
|
1837
|
+
end
|
|
1838
|
+
|
|
1839
|
+
complete -c sandbar -f -a '(__sandbar_complete)'
|
|
1840
|
+
`;
|
|
1841
|
+
var scripts = { zsh, bash, fish };
|
|
1842
|
+
var shells = Object.keys(scripts);
|
|
1843
|
+
async function completionCommand(session, flags) {
|
|
1844
|
+
const shell = flags.positionals[0];
|
|
1845
|
+
const script = Object.entries(scripts).find(([name]) => name === shell)?.[1];
|
|
1846
|
+
if (flags.positionals.length !== 1 || !script)
|
|
1847
|
+
throw usage(`Usage: sandbar completion <${shells.join("|")}>`, "e.g. source <(sandbar completion zsh)");
|
|
1848
|
+
session.ctx.out.streams.stdout(script);
|
|
1849
|
+
return 0;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// src/cli.ts
|
|
1853
|
+
var globalFlags = {
|
|
1854
|
+
json: { type: "boolean", description: "Print one JSON document on stdout (also SANDBAR_JSON=1)" },
|
|
1855
|
+
profile: {
|
|
1856
|
+
type: "string",
|
|
1857
|
+
value: "name",
|
|
1858
|
+
description: "Profile from sandbar.json (also SANDBAR_PROFILE)"
|
|
1859
|
+
},
|
|
1860
|
+
provider: {
|
|
1861
|
+
type: "string",
|
|
1862
|
+
value: "id",
|
|
1863
|
+
description: "Official provider from environment credentials (also SANDBAR_PROVIDER)"
|
|
1864
|
+
},
|
|
1865
|
+
config: {
|
|
1866
|
+
type: "string",
|
|
1867
|
+
value: "path",
|
|
1868
|
+
description: "Config file (default ~/.config/sandbar/sandbar.json, also SANDBAR_CONFIG)"
|
|
1869
|
+
},
|
|
1870
|
+
"state-dir": {
|
|
1871
|
+
type: "string",
|
|
1872
|
+
value: "dir",
|
|
1873
|
+
description: "State store folder (default ~/.config/sandbar/state, also SANDBAR_STATE_DIR)"
|
|
1874
|
+
},
|
|
1875
|
+
help: { type: "boolean", short: "h", description: "Show help" },
|
|
1876
|
+
version: { type: "boolean", short: "V", description: "Print the CLI version" }
|
|
1877
|
+
};
|
|
1878
|
+
var createFlags = {
|
|
1879
|
+
name: {
|
|
1880
|
+
type: "string",
|
|
1881
|
+
value: "name",
|
|
1882
|
+
description: "Store name (default: generated, like sb-1a2b3)"
|
|
1883
|
+
},
|
|
1884
|
+
image: {
|
|
1885
|
+
type: "string",
|
|
1886
|
+
value: "oci-ref",
|
|
1887
|
+
description: "OCI image, e.g. python:3.12; default is the profile's image"
|
|
1888
|
+
},
|
|
1889
|
+
prepared: {
|
|
1890
|
+
type: "string",
|
|
1891
|
+
value: "id",
|
|
1892
|
+
description: "Provider-prepared image id (template, snapshot name)"
|
|
1893
|
+
},
|
|
1894
|
+
network: {
|
|
1895
|
+
type: "string",
|
|
1896
|
+
value: "policy",
|
|
1897
|
+
description: "blocked or internet; default is the profile's policy"
|
|
1898
|
+
},
|
|
1899
|
+
label: { type: "string", multiple: true, value: "k=v", description: "Label to set (repeatable)" },
|
|
1900
|
+
build: {
|
|
1901
|
+
type: "boolean",
|
|
1902
|
+
description: "Build --image first when the provider needs it (not available yet)"
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
var execFlags = {
|
|
1906
|
+
cwd: { type: "string", value: "dir", description: "Working directory in the sandbox" },
|
|
1907
|
+
env: {
|
|
1908
|
+
type: "string",
|
|
1909
|
+
multiple: true,
|
|
1910
|
+
value: "K=V",
|
|
1911
|
+
description: "Environment variable (repeatable)"
|
|
1912
|
+
},
|
|
1913
|
+
stdin: { type: "boolean", description: "Send this process's stdin to the command" },
|
|
1914
|
+
"stdin-file": {
|
|
1915
|
+
type: "string",
|
|
1916
|
+
value: "path",
|
|
1917
|
+
description: "Send a local file as the command's stdin"
|
|
1918
|
+
},
|
|
1919
|
+
timeout: {
|
|
1920
|
+
type: "string",
|
|
1921
|
+
value: "duration",
|
|
1922
|
+
description: "Execution deadline, e.g. 90s or 10m (default 300s)"
|
|
1923
|
+
},
|
|
1924
|
+
"max-output": { type: "string", value: "size", description: "Captured output limit, e.g. 1m" }
|
|
1925
|
+
};
|
|
1926
|
+
var commands = [
|
|
1927
|
+
{
|
|
1928
|
+
name: "create",
|
|
1929
|
+
summary: "Create a sandbox and print its name",
|
|
1930
|
+
usage: "sandbar create [--name <name>] [--image <oci-ref> | --prepared <id>] [--network <policy>]",
|
|
1931
|
+
flags: {
|
|
1932
|
+
...createFlags,
|
|
1933
|
+
"if-not-exists": {
|
|
1934
|
+
type: "boolean",
|
|
1935
|
+
description: "Return the named sandbox if it is still alive instead of failing"
|
|
1936
|
+
}
|
|
1937
|
+
},
|
|
1938
|
+
passthrough: false,
|
|
1939
|
+
remoteExit: false,
|
|
1940
|
+
examples: [
|
|
1941
|
+
"sandbar create --name web --network internet",
|
|
1942
|
+
"sandbar create --name job-42 --if-not-exists --json"
|
|
1943
|
+
],
|
|
1944
|
+
run: createCommand
|
|
1945
|
+
},
|
|
1946
|
+
{
|
|
1947
|
+
name: "ls",
|
|
1948
|
+
summary: "List sandboxes in the store with their current state",
|
|
1949
|
+
usage: "sandbar ls [--offline]",
|
|
1950
|
+
flags: {
|
|
1951
|
+
offline: { type: "boolean", description: "Show stored records without asking providers" }
|
|
1952
|
+
},
|
|
1953
|
+
passthrough: false,
|
|
1954
|
+
remoteExit: false,
|
|
1955
|
+
examples: ["sandbar ls", "sandbar ls --json"],
|
|
1956
|
+
run: lsCommand
|
|
1957
|
+
},
|
|
1958
|
+
{
|
|
1959
|
+
name: "inspect",
|
|
1960
|
+
summary: "Show a sandbox's state, expiry and reference",
|
|
1961
|
+
usage: "sandbar inspect <sandbox>",
|
|
1962
|
+
flags: {},
|
|
1963
|
+
passthrough: false,
|
|
1964
|
+
remoteExit: false,
|
|
1965
|
+
examples: ["sandbar inspect web --json"],
|
|
1966
|
+
run: inspectCommand
|
|
1967
|
+
},
|
|
1968
|
+
{
|
|
1969
|
+
name: "rm",
|
|
1970
|
+
summary: "Destroy sandboxes and remove their records",
|
|
1971
|
+
usage: "sandbar rm <sandbox>... | --all [--force]",
|
|
1972
|
+
flags: {
|
|
1973
|
+
all: { type: "boolean", description: "Every sandbox in this store" },
|
|
1974
|
+
force: {
|
|
1975
|
+
type: "boolean",
|
|
1976
|
+
description: "Also destroy sandboxes this CLI did not create, or drop unreachable records"
|
|
1977
|
+
}
|
|
1978
|
+
},
|
|
1979
|
+
passthrough: false,
|
|
1980
|
+
remoteExit: false,
|
|
1981
|
+
examples: ["sandbar rm web", "sandbar rm --all"],
|
|
1982
|
+
run: rmCommand
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
name: "exec",
|
|
1986
|
+
summary: "Run a command in a sandbox; exits with the command's exit code",
|
|
1987
|
+
usage: "sandbar exec <sandbox> [--cwd <dir>] [--env K=V]... [--stdin] -- <command> [args...]",
|
|
1988
|
+
flags: execFlags,
|
|
1989
|
+
passthrough: true,
|
|
1990
|
+
remoteExit: true,
|
|
1991
|
+
examples: [
|
|
1992
|
+
"sandbar exec web -- ls -la /",
|
|
1993
|
+
"sandbar exec web --cwd /work --env CI=1 -- npm test"
|
|
1994
|
+
],
|
|
1995
|
+
run: execCommand
|
|
1996
|
+
},
|
|
1997
|
+
{
|
|
1998
|
+
name: "sh",
|
|
1999
|
+
summary: "Run a shell script in a sandbox; exits with its exit code",
|
|
2000
|
+
usage: "sandbar sh <sandbox> '<script>' | sandbar sh <sandbox> -- '<script>'",
|
|
2001
|
+
flags: execFlags,
|
|
2002
|
+
passthrough: true,
|
|
2003
|
+
remoteExit: true,
|
|
2004
|
+
examples: ["sandbar sh web 'cd /work && make test'", "sandbar sh web -- '-n is fine here'"],
|
|
2005
|
+
run: shCommand
|
|
2006
|
+
},
|
|
2007
|
+
{
|
|
2008
|
+
name: "run",
|
|
2009
|
+
summary: "Create a sandbox, run one command, and always destroy it",
|
|
2010
|
+
usage: "sandbar run [create flags] [exec flags] [--keep] -- <command> [args...]",
|
|
2011
|
+
flags: {
|
|
2012
|
+
...createFlags,
|
|
2013
|
+
...execFlags,
|
|
2014
|
+
keep: { type: "boolean", description: "Keep the sandbox and print its name on stderr" }
|
|
2015
|
+
},
|
|
2016
|
+
passthrough: true,
|
|
2017
|
+
remoteExit: true,
|
|
2018
|
+
examples: [
|
|
2019
|
+
"sandbar run -- python3 -c 'print(2 + 2)'",
|
|
2020
|
+
"sandbar run --network internet -- curl -sI https://example.com"
|
|
2021
|
+
],
|
|
2022
|
+
run: runCommand
|
|
2023
|
+
},
|
|
2024
|
+
{
|
|
2025
|
+
name: "cp",
|
|
2026
|
+
summary: "Copy one file to or from a sandbox",
|
|
2027
|
+
usage: "sandbar cp <src> <dst> (one side is <sandbox>:/path)",
|
|
2028
|
+
flags: {
|
|
2029
|
+
"no-clobber": {
|
|
2030
|
+
type: "boolean",
|
|
2031
|
+
description: "Fail instead of overwriting a file in the sandbox"
|
|
2032
|
+
},
|
|
2033
|
+
recursive: {
|
|
2034
|
+
type: "boolean",
|
|
2035
|
+
short: "r",
|
|
2036
|
+
description: "Copy directories (not available yet)"
|
|
2037
|
+
}
|
|
2038
|
+
},
|
|
2039
|
+
passthrough: false,
|
|
2040
|
+
remoteExit: false,
|
|
2041
|
+
examples: ["sandbar cp ./app.py web:/work/app.py", "sandbar cp web:/work/out.json ./out.json"],
|
|
2042
|
+
run: cpCommand
|
|
2043
|
+
},
|
|
2044
|
+
{
|
|
2045
|
+
name: "cat",
|
|
2046
|
+
summary: "Print a file from a sandbox",
|
|
2047
|
+
usage: "sandbar cat <sandbox>:/path",
|
|
2048
|
+
flags: {},
|
|
2049
|
+
passthrough: false,
|
|
2050
|
+
remoteExit: false,
|
|
2051
|
+
examples: ["sandbar cat web:/etc/os-release"],
|
|
2052
|
+
run: catCommand
|
|
2053
|
+
},
|
|
2054
|
+
{
|
|
2055
|
+
name: "doctor",
|
|
2056
|
+
summary: "Check config, credentials and the provider connection without creating anything",
|
|
2057
|
+
usage: "sandbar doctor",
|
|
2058
|
+
flags: {},
|
|
2059
|
+
passthrough: false,
|
|
2060
|
+
remoteExit: false,
|
|
2061
|
+
examples: ["sandbar doctor --provider e2b"],
|
|
2062
|
+
run: doctorCommand
|
|
2063
|
+
},
|
|
2064
|
+
{
|
|
2065
|
+
name: "providers",
|
|
2066
|
+
summary: "List official providers, whether their adapter is installed and their credentials set",
|
|
2067
|
+
usage: "sandbar providers",
|
|
2068
|
+
flags: {},
|
|
2069
|
+
passthrough: false,
|
|
2070
|
+
remoteExit: false,
|
|
2071
|
+
examples: ["sandbar providers --json"],
|
|
2072
|
+
run: providersCommand
|
|
2073
|
+
},
|
|
2074
|
+
{
|
|
2075
|
+
name: "skill",
|
|
2076
|
+
summary: "Print the agent skill for this CLI, or install it for your coding agents",
|
|
2077
|
+
usage: "sandbar skill [install [--project]]",
|
|
2078
|
+
flags: {
|
|
2079
|
+
project: {
|
|
2080
|
+
type: "boolean",
|
|
2081
|
+
description: "Install into this folder's .agents/skills (and .claude/skills) instead of your home"
|
|
2082
|
+
}
|
|
2083
|
+
},
|
|
2084
|
+
passthrough: false,
|
|
2085
|
+
remoteExit: false,
|
|
2086
|
+
examples: ["sandbar skill", "sandbar skill install", "sandbar skill install --project"],
|
|
2087
|
+
run: skillCommand
|
|
2088
|
+
},
|
|
2089
|
+
{
|
|
2090
|
+
name: "completion",
|
|
2091
|
+
summary: "Print a shell completion script for zsh, bash or fish",
|
|
2092
|
+
usage: "sandbar completion <zsh|bash|fish>",
|
|
2093
|
+
flags: {},
|
|
2094
|
+
passthrough: false,
|
|
2095
|
+
remoteExit: false,
|
|
2096
|
+
examples: [
|
|
2097
|
+
"source <(sandbar completion zsh) # in ~/.zshrc",
|
|
2098
|
+
"source <(sandbar completion bash) # in ~/.bashrc",
|
|
2099
|
+
"sandbar completion fish > ~/.config/fish/completions/sandbar.fish"
|
|
2100
|
+
],
|
|
2101
|
+
run: completionCommand
|
|
2102
|
+
}
|
|
2103
|
+
];
|
|
2104
|
+
var exitCodes = [
|
|
2105
|
+
{ code: 0, meaning: "success" },
|
|
2106
|
+
{ code: 1, meaning: "other failure" },
|
|
2107
|
+
{ code: 2, meaning: "usage error; fix the command" },
|
|
2108
|
+
{ code: 3, meaning: "not found" },
|
|
2109
|
+
{
|
|
2110
|
+
code: 4,
|
|
2111
|
+
meaning: "unsupported by this provider or profile, adapter or password-manager CLI not installed, or store limit reached"
|
|
2112
|
+
},
|
|
2113
|
+
{
|
|
2114
|
+
code: 5,
|
|
2115
|
+
meaning: "authentication or permission failure, or an unreadable password-manager secret"
|
|
2116
|
+
},
|
|
2117
|
+
{ code: 6, meaning: "outcome unknown: an effect may have happened, inspect before retrying" },
|
|
2118
|
+
{ code: 7, meaning: "retryable: rate limit, capacity or unavailable before any effect" },
|
|
2119
|
+
{ code: 8, meaning: "partial: some steps were confirmed; read error.known" },
|
|
2120
|
+
{ code: 125, meaning: "exec/run: the CLI or provider failed; details in the error" },
|
|
2121
|
+
{
|
|
2122
|
+
code: 130,
|
|
2123
|
+
meaning: "interrupted by SIGINT (129 for SIGHUP, 143 for SIGTERM); an interrupted run still destroys its sandbox"
|
|
2124
|
+
}
|
|
2125
|
+
];
|
|
2126
|
+
function schema() {
|
|
2127
|
+
return {
|
|
2128
|
+
version: 1,
|
|
2129
|
+
cliVersion: package_default.version,
|
|
2130
|
+
globalFlags,
|
|
2131
|
+
commands: commands.map(({ run: _run, ...spec }) => spec),
|
|
2132
|
+
exitCodes,
|
|
2133
|
+
output: {
|
|
2134
|
+
json: "With --json, stdout holds exactly one document: {ok:true,data} or {ok:false,error:{code,message,retryable,outcome,hint?,known?,details?}}",
|
|
2135
|
+
exec: "exec, sh and run exit with the remote command's exit code; their JSON data is {exitCode,stdout,stderr,truncated}, and run adds sandbox {name,id,kept}"
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
function flagLines(flags) {
|
|
2140
|
+
return Object.entries(flags).map(([name, spec]) => {
|
|
2141
|
+
const label = `${spec.short ? `-${spec.short}, ` : ""}--${name}${spec.value ? ` <${spec.value}>` : ""}`;
|
|
2142
|
+
return ` ${label.padEnd(26)} ${spec.description}`;
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
function topHelp() {
|
|
2146
|
+
return [
|
|
2147
|
+
"sandbar: create and manage sandboxes on any Sandbar provider",
|
|
2148
|
+
"",
|
|
2149
|
+
"Usage: sandbar <command> [flags]",
|
|
2150
|
+
"",
|
|
2151
|
+
"Commands:",
|
|
2152
|
+
...commands.map((command) => ` ${command.name.padEnd(10)} ${command.summary}`),
|
|
2153
|
+
" schema Print a machine-readable description of every command",
|
|
2154
|
+
"",
|
|
2155
|
+
"Global flags:",
|
|
2156
|
+
...flagLines(globalFlags),
|
|
2157
|
+
"",
|
|
2158
|
+
"Quick start:",
|
|
2159
|
+
" export SANDBAR_PROVIDER=e2b E2B_API_KEY=...",
|
|
2160
|
+
" sandbar create --name web",
|
|
2161
|
+
" sandbar exec web -- uname -a",
|
|
2162
|
+
" sandbar rm web",
|
|
2163
|
+
" sandbar run -- python3 -c 'print(2 + 2)' # create, run, destroy",
|
|
2164
|
+
"",
|
|
2165
|
+
"Run `sandbar <command> --help` for details, `sandbar schema` for JSON."
|
|
2166
|
+
].join(`
|
|
2167
|
+
`);
|
|
2168
|
+
}
|
|
2169
|
+
function commandHelp(command) {
|
|
2170
|
+
return [
|
|
2171
|
+
command.summary,
|
|
2172
|
+
"",
|
|
2173
|
+
`Usage: ${command.usage}`,
|
|
2174
|
+
...Object.keys(command.flags).length ? ["", "Flags:", ...flagLines(command.flags)] : [],
|
|
2175
|
+
"",
|
|
2176
|
+
"Examples:",
|
|
2177
|
+
...command.examples.map((example) => ` ${example}`)
|
|
2178
|
+
].join(`
|
|
2179
|
+
`);
|
|
2180
|
+
}
|
|
2181
|
+
function parseOption(spec) {
|
|
2182
|
+
const option = { type: spec.type };
|
|
2183
|
+
if (spec.multiple)
|
|
2184
|
+
option.multiple = true;
|
|
2185
|
+
if (spec.short)
|
|
2186
|
+
option.short = spec.short;
|
|
2187
|
+
return option;
|
|
2188
|
+
}
|
|
2189
|
+
function commandIndex(head) {
|
|
2190
|
+
for (let index = 0;index < head.length; index++) {
|
|
2191
|
+
const arg = head[index];
|
|
2192
|
+
if (!arg.startsWith("-"))
|
|
2193
|
+
return index;
|
|
2194
|
+
const spec = Object.entries(globalFlags).find(([flag]) => flag === arg.slice(2))?.[1];
|
|
2195
|
+
if (spec?.type === "string" && !arg.includes("="))
|
|
2196
|
+
index++;
|
|
2197
|
+
}
|
|
2198
|
+
return -1;
|
|
2199
|
+
}
|
|
2200
|
+
async function completeWords(words, io) {
|
|
2201
|
+
const flagValue = (name) => {
|
|
2202
|
+
const index = words.indexOf(`--${name}`);
|
|
2203
|
+
return index === -1 ? undefined : words[index + 1];
|
|
2204
|
+
};
|
|
2205
|
+
const session = new Session({
|
|
2206
|
+
env: io.env,
|
|
2207
|
+
cwd: io.cwd,
|
|
2208
|
+
out: new Output(false, io.streams),
|
|
2209
|
+
signal: io.signal,
|
|
2210
|
+
readStdin: io.readStdin,
|
|
2211
|
+
flags: { json: false, config: flagValue("config"), stateDir: flagValue("state-dir") }
|
|
2212
|
+
});
|
|
2213
|
+
try {
|
|
2214
|
+
io.streams.stdout(formatCompletion(await complete(words, commands, globalFlags, session)));
|
|
2215
|
+
} catch {
|
|
2216
|
+
io.streams.stdout(`:0
|
|
2217
|
+
`);
|
|
2218
|
+
}
|
|
2219
|
+
return 0;
|
|
2220
|
+
}
|
|
2221
|
+
async function main(argv, io) {
|
|
2222
|
+
if (argv[0] === "__complete")
|
|
2223
|
+
return completeWords(argv.slice(argv[1] === "--" ? 2 : 1), io);
|
|
2224
|
+
const separator = argv.indexOf("--");
|
|
2225
|
+
const head = separator === -1 ? argv : argv.slice(0, separator);
|
|
2226
|
+
const rest = separator === -1 ? null : argv.slice(separator + 1);
|
|
2227
|
+
const nameIndex = commandIndex(head);
|
|
2228
|
+
const name = nameIndex === -1 ? undefined : head[nameIndex];
|
|
2229
|
+
const jsonFromEnv = io.env.SANDBAR_JSON === "1" || io.env.SANDBAR_JSON === "true";
|
|
2230
|
+
const out = new Output(jsonFromEnv || head.includes("--json"), io.streams);
|
|
2231
|
+
const command = commands.find((candidate) => candidate.name === name);
|
|
2232
|
+
try {
|
|
2233
|
+
if (!name && (head.includes("--version") || head.includes("-V"))) {
|
|
2234
|
+
out.success({ version: package_default.version }, package_default.version);
|
|
2235
|
+
return 0;
|
|
2236
|
+
}
|
|
2237
|
+
if (!name) {
|
|
2238
|
+
out.streams.stdout(`${topHelp()}
|
|
2239
|
+
`);
|
|
2240
|
+
return head.includes("--help") || head.includes("-h") ? 0 : 2;
|
|
2241
|
+
}
|
|
2242
|
+
if (name === "schema") {
|
|
2243
|
+
out.streams.stdout(`${JSON.stringify(schema(), null, 2)}
|
|
2244
|
+
`);
|
|
2245
|
+
return 0;
|
|
2246
|
+
}
|
|
2247
|
+
if (name === "help") {
|
|
2248
|
+
const topic = commands.find((candidate) => candidate.name === head[1]);
|
|
2249
|
+
out.streams.stdout(`${topic ? commandHelp(topic) : topHelp()}
|
|
2250
|
+
`);
|
|
2251
|
+
return 0;
|
|
2252
|
+
}
|
|
2253
|
+
if (!command) {
|
|
2254
|
+
const close = commands.find((candidate) => candidate.name.startsWith(name.slice(0, 2)));
|
|
2255
|
+
throw usage(`Unknown command ${name}`, close ? `did you mean \`sandbar ${close.name}\`?` : "run `sandbar --help`");
|
|
2256
|
+
}
|
|
2257
|
+
if (rest && !command.passthrough)
|
|
2258
|
+
throw usage(`${command.name} does not take a command after --`);
|
|
2259
|
+
const options = { ...globalFlags, ...command.flags };
|
|
2260
|
+
let parsed;
|
|
2261
|
+
try {
|
|
2262
|
+
parsed = parseArgs({
|
|
2263
|
+
args: head.filter((_, index) => index !== nameIndex),
|
|
2264
|
+
options: Object.fromEntries(Object.entries(options).map(([flag, spec]) => [flag, parseOption(spec)])),
|
|
2265
|
+
allowPositionals: true,
|
|
2266
|
+
strict: true
|
|
2267
|
+
});
|
|
2268
|
+
} catch (cause) {
|
|
2269
|
+
const known = Object.keys(options).map((flag) => `--${flag}`);
|
|
2270
|
+
throw usage(cause instanceof Error ? cause.message.split(`
|
|
2271
|
+
`)[0] : String(cause), `${command.name} accepts: ${known.join(" ")}`);
|
|
2272
|
+
}
|
|
2273
|
+
const flags = new Flags(parsed.values, parsed.positionals, rest);
|
|
2274
|
+
if (flags.bool("help")) {
|
|
2275
|
+
out.streams.stdout(`${commandHelp(command)}
|
|
2276
|
+
`);
|
|
2277
|
+
return 0;
|
|
2278
|
+
}
|
|
2279
|
+
const context = {
|
|
2280
|
+
env: io.env,
|
|
2281
|
+
cwd: io.cwd,
|
|
2282
|
+
out,
|
|
2283
|
+
signal: io.signal,
|
|
2284
|
+
readStdin: io.readStdin,
|
|
2285
|
+
flags: {
|
|
2286
|
+
json: out.json,
|
|
2287
|
+
profile: flags.string("profile"),
|
|
2288
|
+
provider: flags.string("provider"),
|
|
2289
|
+
config: flags.string("config"),
|
|
2290
|
+
stateDir: flags.string("state-dir")
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
2293
|
+
const session = new Session(context);
|
|
2294
|
+
try {
|
|
2295
|
+
return await command.run(session, flags);
|
|
2296
|
+
} finally {
|
|
2297
|
+
await session.close();
|
|
2298
|
+
}
|
|
2299
|
+
} catch (cause) {
|
|
2300
|
+
const report = describeError(cause);
|
|
2301
|
+
out.failure(report);
|
|
2302
|
+
return command?.remoteExit ? commandExitCode() : managementExitCode(report);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// src/main.ts
|
|
2307
|
+
var controller = new AbortController;
|
|
2308
|
+
var interrupted = false;
|
|
2309
|
+
var signalExitCodes = { SIGINT: 130, SIGHUP: 129, SIGTERM: 143 };
|
|
2310
|
+
var exitCode = 130;
|
|
2311
|
+
for (const [name, code] of Object.entries(signalExitCodes))
|
|
2312
|
+
process.on(name, () => {
|
|
2313
|
+
if (interrupted)
|
|
2314
|
+
process.exit(code);
|
|
2315
|
+
interrupted = true;
|
|
2316
|
+
exitCode = code;
|
|
2317
|
+
controller.abort(new Error(`Interrupted by ${name}`));
|
|
2318
|
+
});
|
|
2319
|
+
async function readStdin() {
|
|
2320
|
+
const chunks = [];
|
|
2321
|
+
for await (const chunk of process.stdin)
|
|
2322
|
+
chunks.push(new Uint8Array(chunk));
|
|
2323
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
2324
|
+
}
|
|
2325
|
+
var code = await main(process.argv.slice(2), {
|
|
2326
|
+
env: process.env,
|
|
2327
|
+
cwd: process.cwd(),
|
|
2328
|
+
signal: controller.signal,
|
|
2329
|
+
readStdin,
|
|
2330
|
+
streams: {
|
|
2331
|
+
stdout: (text) => process.stdout.write(text),
|
|
2332
|
+
stderr: (text) => process.stderr.write(text)
|
|
2333
|
+
}
|
|
2334
|
+
});
|
|
2335
|
+
process.exitCode = interrupted ? exitCode : code;
|