@dench.com/cli 3.7.0-staging.387 → 3.8.1-staging.390

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.
@@ -0,0 +1,694 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ lstat,
4
+ mkdtemp,
5
+ readdir,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import type { ConvexHttpClient } from "convex/browser";
14
+ import { makeFunctionReference } from "convex/server";
15
+ import { CliArgError } from "./lib/cli-args";
16
+ import { fetchEnvironmentBytes, sha256 } from "./lib/environment-archive";
17
+ import {
18
+ integerOption,
19
+ isEnvironmentHelp,
20
+ parseEnvironmentOptions,
21
+ requireEnvironmentName,
22
+ } from "./lib/environment-cli-args";
23
+ import {
24
+ type EnvironmentRevision,
25
+ type EnvironmentSourceBundle,
26
+ isForbiddenEnvironmentSourcePath,
27
+ MAX_ENVIRONMENT_FILES,
28
+ MAX_ENVIRONMENT_SOURCE_BYTES,
29
+ } from "./lib/environment-contract";
30
+ import {
31
+ acquireEnvironmentWorkspaceLock,
32
+ activatedEnvironment,
33
+ EnvironmentOutputRedactor,
34
+ environmentPrivatePath,
35
+ environmentSecretMatches,
36
+ prepareEnvironment,
37
+ type ResolvedEnvironment,
38
+ runEnvironmentProcess,
39
+ } from "./lib/environment-execution";
40
+ import {
41
+ collectEnvironmentSource,
42
+ validateSourceBundle,
43
+ } from "./lib/environment-source";
44
+
45
+ const HELP = `dench env — verified setups reused across chats
46
+
47
+ dench env list [--limit N] [--cursor CURSOR] [--json]
48
+ dench env show NAME [--revision SHA256] [--json]
49
+ dench env save NAME --path DIRECTORY [--wait SECONDS | --no-wait] [--json]
50
+ dench env run NAME [--revision SHA256] [--state-key KEY] [--prepare-local] [--json] -- COMMAND [ARGS...]
51
+ dench env delete NAME [--json]
52
+
53
+ Save requires dench-env.json, SKILL.md, an explicit files allowlist and verify
54
+ commands. Credentials, caches and symlinks are excluded from source uploads.
55
+ Save returns an authoritative preparation receipt; default observation is 10s.
56
+ Ready runs reuse prepared dependencies. --prepare-local explicitly prepares a
57
+ separate local platform cache once; ordinary ready runs never reinstall.
58
+ State is job-local by default; choose an explicit state key to share it.
59
+ Example: dench env run openai-report -- node report.mjs`;
60
+ export function environmentHelpText(): string {
61
+ return HELP;
62
+ }
63
+
64
+ export function validateEnvironmentArgs(input: readonly string[]) {
65
+ if (isEnvironmentHelp(input)) return { verb: "help" as const };
66
+ const verb = input[0];
67
+ if (!verb || !["list", "show", "save", "run", "delete"].includes(verb))
68
+ throw new CliArgError(
69
+ "Use dench env list, show, save, run, or delete; see --help.",
70
+ );
71
+ const values =
72
+ verb === "list"
73
+ ? ["--limit", "--cursor"]
74
+ : verb === "show"
75
+ ? ["--revision"]
76
+ : verb === "save"
77
+ ? ["--path", "--wait"]
78
+ : verb === "run"
79
+ ? ["--revision", "--state-key"]
80
+ : [];
81
+ const booleans = [
82
+ "--json",
83
+ ...(verb === "save" ? ["--no-wait"] : []),
84
+ ...(verb === "run" ? ["--prepare-local"] : []),
85
+ ];
86
+ const parsed = parseEnvironmentOptions(input.slice(1), values, booleans);
87
+ if (
88
+ parsed.positional.length !== (verb === "list" ? 0 : 1) ||
89
+ (verb !== "run" && parsed.child.length)
90
+ )
91
+ throw new CliArgError(
92
+ `Unexpected or missing arguments for dench env ${verb}.`,
93
+ );
94
+ const name =
95
+ verb === "list" ? undefined : requireEnvironmentName(parsed.positional[0]);
96
+ const revision = parsed.flags["--revision"] as string | undefined;
97
+ if (revision !== undefined && !/^[a-f0-9]{64}$/.test(revision))
98
+ throw new CliArgError(
99
+ "--revision must be the SHA-256 revision from env show.",
100
+ );
101
+ const stateKey = parsed.flags["--state-key"] as string | undefined;
102
+ if (
103
+ stateKey !== undefined &&
104
+ !/^[a-zA-Z0-9][a-zA-Z0-9:_-]{0,159}$/.test(stateKey)
105
+ )
106
+ throw new CliArgError(
107
+ "--state-key must contain 1–160 letters, numbers, colons, underscores or hyphens, beginning with a letter or number.",
108
+ );
109
+ if (verb === "save" && !parsed.flags["--path"])
110
+ throw new CliArgError(
111
+ "env save requires --path DIRECTORY containing dench-env.json and SKILL.md.",
112
+ );
113
+ if (parsed.flags["--wait"] !== undefined && parsed.flags["--no-wait"])
114
+ throw new CliArgError("Use --wait or --no-wait, not both.");
115
+ if (verb === "run" && !parsed.child.length)
116
+ throw new CliArgError("env run requires -- COMMAND [ARGS...].");
117
+ return {
118
+ verb: verb as "list" | "show" | "save" | "run" | "delete",
119
+ name,
120
+ revision,
121
+ stateKey,
122
+ limit: integerOption(parsed.flags["--limit"], "--limit", 20, 1, 100),
123
+ cursor: parsed.flags["--cursor"] as string | undefined,
124
+ path: parsed.flags["--path"] as string | undefined,
125
+ wait: parsed.flags["--no-wait"]
126
+ ? 0
127
+ : integerOption(parsed.flags["--wait"], "--wait", 10, 0, 600),
128
+ prepareLocal: Boolean(parsed.flags["--prepare-local"]),
129
+ json: Boolean(parsed.flags["--json"]),
130
+ command: parsed.child,
131
+ };
132
+ }
133
+
134
+ type ClientOptions = {
135
+ convex: ConvexHttpClient;
136
+ args: string[];
137
+ sessionToken?: string;
138
+ apiUrl?: string;
139
+ execution?: { workspace?: string; cacheRoot?: string };
140
+ };
141
+ const queryRef = (name: string) =>
142
+ makeFunctionReference<"query">(`functions/environments:${name}`);
143
+ const mutationRef = (name: string) =>
144
+ makeFunctionReference<"mutation">(`functions/environments:${name}`);
145
+ const actionRef = (name: string) =>
146
+ makeFunctionReference<"action">(`functions/environmentsNode:${name}`);
147
+
148
+ async function uploadBundle(
149
+ opts: ClientOptions,
150
+ kind: "source" | "state",
151
+ bytes: Buffer,
152
+ ) {
153
+ const token = opts.sessionToken ? { sessionToken: opts.sessionToken } : {};
154
+ const hash = sha256(bytes);
155
+ const grant = (await opts.convex.mutation(mutationRef("requestUpload"), {
156
+ ...token,
157
+ kind,
158
+ size: bytes.length,
159
+ sha256: hash,
160
+ })) as { uploadId: string; uploadUrl: string; prefix: string };
161
+ const url = new URL(grant.uploadUrl);
162
+ if (
163
+ url.protocol !== "https:" &&
164
+ !(
165
+ url.protocol === "http:" &&
166
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
167
+ )
168
+ )
169
+ throw new Error("invalid_upload_url: Upload URL must use HTTPS.");
170
+ const response = await fetch(grant.uploadUrl, {
171
+ method: "POST",
172
+ headers: { "Content-Type": "application/octet-stream" },
173
+ body: new Uint8Array(
174
+ Buffer.concat([Buffer.from(grant.prefix, "utf8"), bytes]),
175
+ ),
176
+ redirect: "error",
177
+ signal: AbortSignal.timeout(120_000),
178
+ });
179
+ if (!response.ok)
180
+ throw new Error(
181
+ "environment_upload_failed: No environment or state was published.",
182
+ );
183
+ const result = (await response.json()) as { storageId?: string };
184
+ if (!result.storageId)
185
+ throw new Error(
186
+ "environment_upload_failed: Upload did not return a storage receipt.",
187
+ );
188
+ return {
189
+ uploadId: grant.uploadId,
190
+ storageId: result.storageId,
191
+ sha256: hash,
192
+ };
193
+ }
194
+
195
+ async function captureState(
196
+ environment: ResolvedEnvironment,
197
+ workspace: string,
198
+ secretValues: string[],
199
+ ): Promise<Buffer> {
200
+ const files: EnvironmentSourceBundle["files"] = [];
201
+ const seen = new Set<string>();
202
+ // Expand after command completion, once refreshed credential-file values
203
+ // have joined the bound values. Never upload known tokens as plaintext state.
204
+ const secretMatches = environmentSecretMatches(secretValues).map((value) =>
205
+ Buffer.from(value),
206
+ );
207
+ let bytes = 0;
208
+ const secretPaths = environment.manifest.secretFiles.map(
209
+ (secret) => secret.path,
210
+ );
211
+ const visit = async (path: string) => {
212
+ if (seen.has(path)) return;
213
+ seen.add(path);
214
+ if (
215
+ isForbiddenEnvironmentSourcePath(path) ||
216
+ secretPaths.some(
217
+ (secret) => path === secret || path.startsWith(`${secret}/`),
218
+ )
219
+ )
220
+ throw new Error(
221
+ "environment_state_rejected: Credential/cache paths cannot be saved as application state.",
222
+ );
223
+ const absolute = await environmentPrivatePath(workspace, path);
224
+ let stat: Awaited<ReturnType<typeof lstat>>;
225
+ try {
226
+ stat = await lstat(absolute);
227
+ } catch (error) {
228
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
229
+ throw error;
230
+ }
231
+ if (stat.isDirectory()) {
232
+ for (const child of (await readdir(absolute)).sort())
233
+ await visit(`${path}/${child}`);
234
+ return;
235
+ }
236
+ if (!stat.isFile() || stat.size > MAX_ENVIRONMENT_SOURCE_BYTES)
237
+ throw new Error(
238
+ "environment_state_rejected: State must contain bounded regular files.",
239
+ );
240
+ const value = await readFile(absolute);
241
+ if (secretMatches.some((secret) => value.includes(secret)))
242
+ throw new Error(
243
+ "environment_state_rejected: State contains a bound credential; use a mutable encrypted credential file instead.",
244
+ );
245
+ bytes += value.length;
246
+ if (
247
+ bytes > MAX_ENVIRONMENT_SOURCE_BYTES ||
248
+ files.length >= MAX_ENVIRONMENT_FILES
249
+ )
250
+ throw new Error(
251
+ "environment_state_rejected: State exceeds its file or byte limit.",
252
+ );
253
+ files.push({
254
+ path,
255
+ contentBase64: value.toString("base64"),
256
+ mode: stat.mode & 0o111 ? 493 : 420,
257
+ });
258
+ };
259
+ for (const path of environment.manifest.statePaths) await visit(path);
260
+ return Buffer.from(
261
+ JSON.stringify(validateSourceBundle({ version: 1, files })),
262
+ "utf8",
263
+ );
264
+ }
265
+
266
+ async function restoreState(
267
+ environment: ResolvedEnvironment,
268
+ workspace: string,
269
+ ) {
270
+ if (!environment.state?.url) return;
271
+ const payload = await fetchEnvironmentBytes(
272
+ environment.state.url,
273
+ MAX_ENVIRONMENT_SOURCE_BYTES + 512,
274
+ );
275
+ const prefix = Buffer.from(environment.state.prefix ?? "", "utf8");
276
+ const bytes = payload.subarray(prefix.length);
277
+ if (
278
+ !payload.subarray(0, prefix.length).equals(prefix) ||
279
+ !environment.state.sha256 ||
280
+ sha256(bytes) !== environment.state.sha256
281
+ )
282
+ throw new Error(
283
+ "invalid_environment_state: Saved state failed integrity verification.",
284
+ );
285
+ const bundle = validateSourceBundle(JSON.parse(bytes.toString("utf8")));
286
+ for (const file of bundle.files) {
287
+ if (
288
+ !environment.manifest.statePaths.some(
289
+ (path) => file.path === path || file.path.startsWith(`${path}/`),
290
+ ) ||
291
+ environment.manifest.secretFiles.some(
292
+ (secret) =>
293
+ file.path === secret.path || file.path.startsWith(`${secret.path}/`),
294
+ )
295
+ )
296
+ throw new Error(
297
+ "invalid_environment_state: State contains an undeclared or credential path.",
298
+ );
299
+ }
300
+ for (const relative of environment.manifest.statePaths)
301
+ await rm(await environmentPrivatePath(workspace, relative), {
302
+ recursive: true,
303
+ force: true,
304
+ });
305
+ for (const file of bundle.files)
306
+ await writeFile(
307
+ await environmentPrivatePath(workspace, file.path),
308
+ Buffer.from(file.contentBase64, "base64"),
309
+ { mode: file.mode, flag: "wx" },
310
+ );
311
+ }
312
+
313
+ async function executeSavedEnvironment(
314
+ opts: ClientOptions,
315
+ parsed: Exclude<ReturnType<typeof validateEnvironmentArgs>, { verb: "help" }>,
316
+ ) {
317
+ const token = opts.sessionToken ? { sessionToken: opts.sessionToken } : {};
318
+ const job =
319
+ process.env.DENCH_HARNESS_A_JOB_ID ?? process.env.DENCH_HARNESS_JOB_ID;
320
+ const stateKey =
321
+ parsed.stateKey ??
322
+ (job ? `job:${job}` : `local:${sha256(process.cwd()).slice(0, 32)}`);
323
+ const environment = (await opts.convex.action(actionRef("resolve"), {
324
+ ...token,
325
+ name: parsed.name,
326
+ ...(parsed.revision ? { revision: parsed.revision } : {}),
327
+ stateKey,
328
+ })) as ResolvedEnvironment;
329
+ const suppliedWorkspace =
330
+ opts.execution?.workspace ?? process.env.DENCH_ENVIRONMENT_WORKSPACE;
331
+ const workspace =
332
+ suppliedWorkspace ??
333
+ (job
334
+ ? join(
335
+ process.env.HOME ?? homedir(),
336
+ ".dench",
337
+ "environment-workspaces",
338
+ sha256(environment.organizationId).slice(0, 24),
339
+ environment.revision,
340
+ )
341
+ : await mkdtemp(join(tmpdir(), "dench-env-run-")));
342
+ const privatePaths: string[] = [];
343
+ const secretValues = [
344
+ ...Object.values(environment.credentialEnv).map((entry) => entry.value),
345
+ ...environment.credentialFiles.map((entry) => entry.value),
346
+ ];
347
+ const sanitizeError = (error: unknown) => {
348
+ const redactor = new EnvironmentOutputRedactor(secretValues);
349
+ const message = redactor.push(
350
+ error instanceof Error ? error.message : String(error),
351
+ true,
352
+ );
353
+ const data = (
354
+ error as {
355
+ data?: { code?: string; receiptId?: string; retryable?: boolean };
356
+ }
357
+ )?.data;
358
+ return Object.assign(new Error(message), {
359
+ data: {
360
+ message,
361
+ ...(data?.code ? { code: data.code } : {}),
362
+ ...(data?.receiptId ? { receiptId: data.receiptId } : {}),
363
+ ...(typeof data?.retryable === "boolean"
364
+ ? { retryable: data.retryable }
365
+ : {}),
366
+ },
367
+ });
368
+ };
369
+ let completedCommand:
370
+ | Awaited<ReturnType<typeof runEnvironmentProcess>>
371
+ | undefined;
372
+ let pendingState: Buffer | undefined;
373
+ let preserveWorkspace = false;
374
+ const releaseExecution =
375
+ environment.manifest.secretFiles.length ||
376
+ environment.manifest.statePaths.length
377
+ ? await acquireEnvironmentWorkspaceLock(workspace)
378
+ : undefined;
379
+ try {
380
+ if (releaseExecution) {
381
+ // Another command may have committed state or rotated credentials while
382
+ // this process waited. Read again under the lock, retaining this revision.
383
+ Object.assign(
384
+ environment,
385
+ await opts.convex.action(actionRef("resolve"), {
386
+ ...token,
387
+ name: environment.name,
388
+ revision: environment.revision,
389
+ stateKey,
390
+ }),
391
+ );
392
+ secretValues.push(
393
+ ...Object.values(environment.credentialEnv).map((entry) => entry.value),
394
+ ...environment.credentialFiles.map((entry) => entry.value),
395
+ );
396
+ }
397
+ const prepared = await prepareEnvironment(environment, {
398
+ workspace,
399
+ prepareLocal: parsed.prepareLocal,
400
+ cacheRoot: opts.execution?.cacheRoot,
401
+ report: (text) => process.stderr.write(`${text}\n`),
402
+ });
403
+ await restoreState(environment, prepared.workspace);
404
+ for (const secret of environment.credentialFiles) {
405
+ const path = await environmentPrivatePath(
406
+ prepared.workspace,
407
+ secret.path,
408
+ );
409
+ privatePaths.push(path);
410
+ const temporary = `${path}.dench-secret-${randomUUID()}`;
411
+ try {
412
+ await writeFile(temporary, secret.value, { mode: 0o600, flag: "wx" });
413
+ await rename(temporary, path);
414
+ } finally {
415
+ await rm(temporary, { force: true });
416
+ }
417
+ }
418
+ const result = await runEnvironmentProcess(parsed.command, {
419
+ cwd: prepared.workspace,
420
+ env: activatedEnvironment(prepared, environment, {
421
+ sessionToken: opts.sessionToken,
422
+ convexUrl: opts.convex.url,
423
+ apiUrl: opts.apiUrl,
424
+ }),
425
+ secretValues,
426
+ ...(parsed.json
427
+ ? {}
428
+ : {
429
+ stdout: (text: string) => process.stdout.write(text),
430
+ stderr: (text: string) => process.stderr.write(text),
431
+ }),
432
+ });
433
+ completedCommand = result;
434
+ const observations: {
435
+ state?: unknown;
436
+ credentialUpdates: unknown[];
437
+ conflicts: { kind: string; receipt?: unknown }[];
438
+ } = { credentialUpdates: [], conflicts: [] };
439
+ for (const secret of environment.credentialFiles.filter(
440
+ (file) => file.mutable,
441
+ )) {
442
+ const value = await readFile(
443
+ await environmentPrivatePath(prepared.workspace, secret.path),
444
+ "utf8",
445
+ );
446
+ if (value === secret.value) continue;
447
+ // New token values are also redacted from errors and never included in the result.
448
+ secretValues.push(value);
449
+ try {
450
+ const receipt = await opts.convex.action(
451
+ makeFunctionReference<"action">("functions/workspaceSecretsNode:set"),
452
+ {
453
+ ...token,
454
+ name: secret.secretName,
455
+ kind: "file",
456
+ value,
457
+ expectedVersion: secret.version,
458
+ },
459
+ );
460
+ observations.credentialUpdates.push({
461
+ name: secret.secretName,
462
+ version: (receipt as { version?: number }).version,
463
+ });
464
+ } catch (error) {
465
+ const data = (error as { data?: { receiptId?: string; code?: string } })
466
+ .data;
467
+ let localRecovery:
468
+ | { path: string; name: string; expectedVersion: number }
469
+ | undefined;
470
+ if (!data?.receiptId) {
471
+ const recoveryDirectory = await mkdtemp(
472
+ join(tmpdir(), "dench-secret-recovery-"),
473
+ );
474
+ const path = join(recoveryDirectory, "credential");
475
+ await writeFile(path, value, { mode: 0o600, flag: "wx" });
476
+ localRecovery = {
477
+ path,
478
+ name: secret.secretName,
479
+ expectedVersion: secret.version,
480
+ };
481
+ }
482
+ observations.conflicts.push({
483
+ kind: data?.code ?? "credential_state_unconfirmed",
484
+ ...(data?.receiptId
485
+ ? { receipt: { receiptId: data.receiptId } }
486
+ : { receipt: localRecovery }),
487
+ });
488
+ }
489
+ }
490
+ if (environment.manifest.statePaths.length) {
491
+ const state = await captureState(
492
+ environment,
493
+ prepared.workspace,
494
+ secretValues,
495
+ );
496
+ pendingState = state;
497
+ if (sha256(state) !== environment.state?.sha256) {
498
+ const upload = await uploadBundle(opts, "state", state);
499
+ try {
500
+ observations.state = await opts.convex.action(
501
+ actionRef("saveState"),
502
+ {
503
+ ...token,
504
+ name: environment.name,
505
+ revision: environment.revision,
506
+ stateKey: environment.state?.key ?? stateKey,
507
+ expectedVersion: environment.state?.version ?? 0,
508
+ uploadId: upload.uploadId,
509
+ storageId: upload.storageId,
510
+ },
511
+ );
512
+ } catch {
513
+ observations.conflicts.push({
514
+ kind: "state_commit_unconfirmed",
515
+ receipt: {
516
+ ...upload,
517
+ expectedVersion: environment.state?.version ?? 0,
518
+ stateKey,
519
+ },
520
+ });
521
+ }
522
+ }
523
+ }
524
+ const output = {
525
+ name: environment.name,
526
+ revision: environment.revision,
527
+ exitCode: result.exitCode,
528
+ signal: result.signal,
529
+ preparation: {
530
+ cacheHit: prepared.cacheHit,
531
+ source: prepared.preparation,
532
+ platform: prepared.platform,
533
+ },
534
+ ...observations,
535
+ ...(observations.conflicts.length
536
+ ? {
537
+ commandExecuted: true,
538
+ guidance:
539
+ "The business command already ran. Reconcile the saved receipts; do not rerun it to recover state.",
540
+ }
541
+ : {}),
542
+ ...(parsed.json
543
+ ? {
544
+ stdout: result.stdout,
545
+ stderr: result.stderr,
546
+ outputTruncated: result.outputTruncated,
547
+ }
548
+ : {}),
549
+ };
550
+ if (parsed.json) console.log(JSON.stringify(output, null, 2));
551
+ else if (observations.conflicts.length)
552
+ process.stderr.write(
553
+ `${JSON.stringify({ code: "environment_state_conflict", commandExitCode: result.exitCode, conflicts: observations.conflicts, guidance: "The business command already ran. Reconcile the saved receipts; do not rerun it to recover state." })}\n`,
554
+ );
555
+ process.exitCode = observations.conflicts.length ? 75 : result.exitCode;
556
+ return output;
557
+ } catch (error) {
558
+ if (completedCommand) {
559
+ preserveWorkspace = true;
560
+ // A post-execution storage failure must never look like a failed dispatch.
561
+ // A verified, nonsecret state candidate remains recoverable without running
562
+ // the business operation again, even if its upload failed before a receipt.
563
+ let recoveryPath: string | undefined;
564
+ if (pendingState) {
565
+ const recoveryDirectory = await mkdtemp(
566
+ join(tmpdir(), "dench-env-state-recovery-"),
567
+ );
568
+ recoveryPath = join(recoveryDirectory, "state.json");
569
+ await writeFile(recoveryPath, pendingState, {
570
+ mode: 0o600,
571
+ flag: "wx",
572
+ });
573
+ }
574
+ const output = {
575
+ name: environment.name,
576
+ revision: environment.revision,
577
+ code: "environment_state_unconfirmed",
578
+ commandExecuted: true,
579
+ exitCode: completedCommand.exitCode,
580
+ signal: completedCommand.signal,
581
+ error: sanitizeError(error).message,
582
+ recovery: {
583
+ path: recoveryPath ?? workspace,
584
+ verified: Boolean(recoveryPath),
585
+ expectedVersion: environment.state?.version ?? 0,
586
+ stateKey,
587
+ },
588
+ guidance:
589
+ "The business command already ran. Recover the saved state; do not rerun the business operation.",
590
+ ...(parsed.json
591
+ ? {
592
+ stdout: completedCommand.stdout,
593
+ stderr: completedCommand.stderr,
594
+ outputTruncated: completedCommand.outputTruncated,
595
+ }
596
+ : {}),
597
+ };
598
+ if (parsed.json) console.log(JSON.stringify(output, null, 2));
599
+ else process.stderr.write(`${JSON.stringify(output)}\n`);
600
+ process.exitCode = 75;
601
+ return output;
602
+ }
603
+ throw sanitizeError(error);
604
+ } finally {
605
+ try {
606
+ for (const path of privatePaths) await rm(path, { force: true });
607
+ } finally {
608
+ await releaseExecution?.();
609
+ }
610
+ if (!suppliedWorkspace && !job && !preserveWorkspace)
611
+ await rm(workspace, { recursive: true, force: true });
612
+ }
613
+ }
614
+
615
+ export async function runEnvironmentCommand(
616
+ opts: ClientOptions,
617
+ ): Promise<void> {
618
+ const parsed = validateEnvironmentArgs(opts.args);
619
+ if (parsed.verb === "help") {
620
+ console.log(HELP);
621
+ return;
622
+ }
623
+ const token = opts.sessionToken ? { sessionToken: opts.sessionToken } : {};
624
+ if (parsed.verb === "list") {
625
+ console.log(
626
+ JSON.stringify(
627
+ await opts.convex.query(queryRef("list"), {
628
+ ...token,
629
+ limit: parsed.limit,
630
+ ...(parsed.cursor ? { cursor: parsed.cursor } : {}),
631
+ }),
632
+ null,
633
+ 2,
634
+ ),
635
+ );
636
+ return;
637
+ }
638
+ if (parsed.verb === "show") {
639
+ console.log(
640
+ JSON.stringify(
641
+ await opts.convex.query(queryRef("get"), {
642
+ ...token,
643
+ name: parsed.name,
644
+ ...(parsed.revision ? { revision: parsed.revision } : {}),
645
+ }),
646
+ null,
647
+ 2,
648
+ ),
649
+ );
650
+ return;
651
+ }
652
+ if (parsed.verb === "delete") {
653
+ console.log(
654
+ JSON.stringify(
655
+ await opts.convex.mutation(mutationRef("remove"), {
656
+ ...token,
657
+ name: parsed.name,
658
+ }),
659
+ null,
660
+ 2,
661
+ ),
662
+ );
663
+ return;
664
+ }
665
+ if (parsed.verb === "run") {
666
+ await executeSavedEnvironment(opts, parsed);
667
+ return;
668
+ }
669
+ if (!parsed.path || !parsed.name)
670
+ throw new CliArgError("env save requires a name and --path.");
671
+ const source = await collectEnvironmentSource(parsed.path, parsed.name);
672
+ const uploaded = await uploadBundle(opts, "source", source.bytes);
673
+ let receipt = (await opts.convex.action(actionRef("save"), {
674
+ ...token,
675
+ name: parsed.name,
676
+ manifest: source.manifest,
677
+ ...uploaded,
678
+ })) as EnvironmentRevision;
679
+ const deadline = Date.now() + parsed.wait * 1000;
680
+ while (
681
+ (receipt.status === "pending" || receipt.status === "building") &&
682
+ Date.now() < deadline
683
+ ) {
684
+ await new Promise((done) =>
685
+ setTimeout(done, Math.min(1000, deadline - Date.now())),
686
+ );
687
+ receipt = (await opts.convex.query(queryRef("get"), {
688
+ ...token,
689
+ name: parsed.name,
690
+ revision: receipt.revision,
691
+ })) as EnvironmentRevision;
692
+ }
693
+ console.log(JSON.stringify(receipt, null, 2));
694
+ }