@shardflux/cli 0.1.0 → 0.2.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/dist/commands.js CHANGED
@@ -7,13 +7,29 @@
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import { readFile, writeFile } from 'node:fs/promises';
9
9
  import { SDK_VERSION, ShardfluxApiError } from '@shardflux/sdk';
10
- import { CLI_VERSION, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
10
+ import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
11
11
  import { InterruptedError, NotFoundError, UsageError } from "./errors.js";
12
- import { fileTable, json, kv, operationDetail, operationTable, sessionTable, usageText, workspaceDetail, workspaceTable } from "./format.js";
12
+ import { bindingsText, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, fileTable, json, kv, operationDetail, operationTable, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, templateEntryText, templateFileTable, templateTable, usageText, workspaceDetail, workspaceTable, } from "./format.js";
13
13
  const DEFAULT_WAIT_MS = 300_000;
14
14
  const MAX_JSON_OUTPUT = 16 * 1024 * 1024;
15
15
  const MAX_STDIN = 1024 * 1024;
16
16
  const str = (v) => (typeof v === 'string' ? v : undefined);
17
+ /**
18
+ * The workspace's cell client as this invocation configures it: the CLI's agent label, wake on use unless turned off,
19
+ * and the wake timeout (an OperationTimeoutError past it exits 5).
20
+ */
21
+ const cellOf = (ctx, ws) => ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs, ...(ctx.wake ? {} : { wake: null }) });
22
+ const strs = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : []);
23
+ const MAX_SECRET_VALUE_BYTES = 65_536;
24
+ const TOOLS = ['exec', 'files', 'pty', 'process', 'git', 'browser'];
25
+ function secretNames(names, option) {
26
+ for (const n of names)
27
+ if (!SECRET_NAME.test(n))
28
+ throw new UsageError(`${option} expects a secret name like OPENAI_API_KEY (^[A-Z_][A-Z0-9_]*$), got "${n}"`);
29
+ if (new Set(names).size !== names.length)
30
+ throw new UsageError(`${option} names a secret twice`);
31
+ return [...names];
32
+ }
17
33
  function out(ctx, human, machine) {
18
34
  ctx.io.stdout.write(ctx.json ? json(machine) : human);
19
35
  }
@@ -34,7 +50,11 @@ function waitTimeout(values) {
34
50
  const t = str(values.timeout);
35
51
  return t === undefined ? DEFAULT_WAIT_MS : parseDuration(t, 'timeout');
36
52
  }
37
- /** `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key. */
53
+ /**
54
+ * `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key, across every
55
+ * lifetime and purpose (sessions, drafts and test instances are hidden from the default list), preferring the live
56
+ * workspace over tombstones of ended sessions that had the same key (contracts §19.11).
57
+ */
38
58
  export async function resolveWorkspace(ctx, ref) {
39
59
  const cloud = ctx.cloud();
40
60
  if (UUID.test(ref)) {
@@ -46,14 +66,87 @@ export async function resolveWorkspace(ctx, ref) {
46
66
  throw err;
47
67
  }
48
68
  }
49
- for await (const w of cloud.workspaces.listAll({ keyPrefix: ref, includeDeleted: true, limit: 200 })) {
50
- if (w.key === ref)
51
- return w;
69
+ let found;
70
+ try {
71
+ found = await cloud.workspaces.findByKey(ref, { signal: ctx.signal });
72
+ }
73
+ catch (err) {
52
74
  if (ctx.signal.aborted)
53
75
  throw new InterruptedError('interrupted');
76
+ throw err;
54
77
  }
78
+ if (found)
79
+ return found;
55
80
  throw new NotFoundError(`No workspace with id or key "${ref}" in this project.`);
56
81
  }
82
+ const LIFETIMES = ['persistent', 'session'];
83
+ const LIFETIME_FILTERS = ['persistent', 'session', 'any'];
84
+ const PURPOSE_FILTERS = ['standard', 'template_draft', 'template_test', 'any'];
85
+ const DIFF_CHANGES = ['added', 'removed', 'changed', 'type_changed', 'metadata'];
86
+ const OWNERS = ['platform', 'organization'];
87
+ const DEFAULT_BUILD_WAIT_MS = 1_800_000;
88
+ function oneOf(values, option, allowed) {
89
+ const v = str(values[option]);
90
+ if (v === undefined)
91
+ return undefined;
92
+ if (!allowed.includes(v))
93
+ throw new UsageError(`--${option} must be one of ${allowed.join(', ')} (got "${v}")`);
94
+ return v;
95
+ }
96
+ function listLimit(values, max) {
97
+ const t = str(values.limit);
98
+ return t === undefined ? undefined : parsePositiveInt(t, 'limit', max);
99
+ }
100
+ /** `--default-lifetime` / `--default-idle-timeout` as the new version's `defaults` (undefined when neither is given). */
101
+ function saveDefaults(values) {
102
+ const lifetime = oneOf(values, 'default-lifetime', LIFETIMES);
103
+ const idle = str(values['default-idle-timeout']);
104
+ if (lifetime === undefined && idle === undefined)
105
+ return undefined;
106
+ const d = {};
107
+ if (lifetime !== undefined)
108
+ d.lifetime = lifetime;
109
+ if (idle !== undefined) {
110
+ const seconds = Math.round(parseDuration(idle, 'default-idle-timeout') / 1000);
111
+ if (seconds < 60 || seconds > 86_400)
112
+ throw new UsageError('--default-idle-timeout must be between 60s and 24h');
113
+ d.idle_timeout_seconds = seconds;
114
+ }
115
+ return d;
116
+ }
117
+ function acknowledged(values) {
118
+ const paths = strs(values.acknowledge);
119
+ for (const p of paths)
120
+ if (!p.startsWith('/'))
121
+ throw new UsageError(`--acknowledge expects an absolute path, got "${p}"`);
122
+ if (paths.length > 200)
123
+ throw new UsageError('--acknowledge takes at most 200 paths');
124
+ return paths.length ? paths : undefined;
125
+ }
126
+ /** Prints a save-as-template / draft publish result; `--wait` follows the build until it settles. */
127
+ async function reportSave(ctx, values, res, what) {
128
+ let build = res.build;
129
+ if (values.wait === true) {
130
+ const t = str(values.timeout);
131
+ const timeoutMs = t === undefined ? DEFAULT_BUILD_WAIT_MS : parseDuration(t, 'timeout');
132
+ try {
133
+ build = await ctx.cloud().templates.builds.waitForBuild(build.organization_id, build.id, { timeoutMs, signal: ctx.signal });
134
+ }
135
+ catch (err) {
136
+ if (ctx.signal.aborted)
137
+ throw new InterruptedError(`Interrupted; build ${build.id} continues server side.`);
138
+ throw err;
139
+ }
140
+ }
141
+ const capture = res.operation ? `capture: operation ${res.operation.id} ${res.operation.kind} ${res.operation.state}\n` : '';
142
+ out(ctx, `${what}\n${capture}${buildText(build)}`, { operation: res.operation, build });
143
+ return build.state === 'failed' || build.state === 'canceled' || build.registration.state === 'failed' ? 6 : 0;
144
+ }
145
+ function parseVersion(text, what) {
146
+ if (!/^\d+$/.test(text) || Number(text) < 1)
147
+ throw new UsageError(`${what} must be a version number (got "${text}")`);
148
+ return Number(text);
149
+ }
57
150
  /** Waits with the SDK's backoff; Ctrl-C reports the operation that keeps running. */
58
151
  async function waitOperation(ctx, operationId, timeoutMs) {
59
152
  try {
@@ -162,9 +255,19 @@ export const HANDLERS = {
162
255
  if (!template)
163
256
  throw new UsageError('--template <slug> is required for "shard workspaces open"');
164
257
  const c = caps(values);
258
+ const bind = secretNames(strs(values.secret), '--secret');
259
+ const lifetime = oneOf(values, 'lifetime', LIFETIMES);
165
260
  const timeoutMs = waitTimeout(values);
166
261
  const cloud = ctx.cloud();
167
- let ws = await cloud.workspaces.open({ key: key, template, ...(c ? { caps: c } : {}), agentLabel: ctx.agentLabel, wait: false });
262
+ let ws = await cloud.workspaces.open({
263
+ key: key,
264
+ template,
265
+ ...(c ? { caps: c } : {}),
266
+ ...(bind.length ? { secrets: bind } : {}),
267
+ ...(lifetime ? { lifetime } : {}),
268
+ agentLabel: ctx.agentLabel,
269
+ wait: false,
270
+ });
168
271
  const op = ws.activeOperation;
169
272
  if (values.wait !== false && !ws.ready && op) {
170
273
  await waitOperation(ctx, op.id, timeoutMs);
@@ -182,9 +285,13 @@ export const HANDLERS = {
182
285
  const state = str(values.state);
183
286
  const prefix = str(values.prefix);
184
287
  const limitText = str(values.limit);
288
+ const lifetime = oneOf(values, 'lifetime', LIFETIME_FILTERS);
289
+ const purpose = oneOf(values, 'purpose', PURPOSE_FILTERS);
185
290
  const params = {
186
291
  ...(state ? { state: state } : {}),
187
292
  ...(prefix ? { keyPrefix: prefix } : {}),
293
+ ...(lifetime ? { lifetime } : {}),
294
+ ...(purpose ? { purpose } : {}),
188
295
  ...(values['include-deleted'] === true ? { includeDeleted: true } : {}),
189
296
  ...(limitText ? { limit: parsePositiveInt(limitText, 'limit', 200) } : {}),
190
297
  };
@@ -220,7 +327,7 @@ export const HANDLERS = {
220
327
  const stdinSource = str(values.stdin);
221
328
  const stdin = stdinSource === undefined ? undefined : await readInput(ctx, stdinSource, MAX_STDIN, '--stdin');
222
329
  const ws = await resolveWorkspace(ctx, ref);
223
- const cell = ws.cell({ agentLabel: ctx.agentLabel });
330
+ const cell = cellOf(ctx, ws);
224
331
  const sessionId = `cli-${randomUUID().replace(/-/g, '').slice(0, 24)}`;
225
332
  let r;
226
333
  try {
@@ -250,7 +357,7 @@ export const HANDLERS = {
250
357
  },
251
358
  async 'files read'(ctx, values, [ref, path]) {
252
359
  const ws = await resolveWorkspace(ctx, ref);
253
- const bytes = await ws.cell({ agentLabel: ctx.agentLabel }).files.read(path);
360
+ const bytes = await cellOf(ctx, ws).files.read(path);
254
361
  const dest = str(values.out);
255
362
  if (dest !== undefined) {
256
363
  await writeFile(dest, bytes);
@@ -267,14 +374,14 @@ export const HANDLERS = {
267
374
  async 'files write'(ctx, values, [ref, path]) {
268
375
  const data = await readInput(ctx, str(values.from) ?? '-', 1024 * 1024 * 1024, 'input');
269
376
  const ws = await resolveWorkspace(ctx, ref);
270
- const r = await ws.cell({ agentLabel: ctx.agentLabel }).files.write(path, data, { append: values.append === true, createParents: values.parents !== false });
377
+ const r = await cellOf(ctx, ws).files.write(path, data, { append: values.append === true, createParents: values.parents !== false });
271
378
  out(ctx, `wrote ${r.bytes_written} bytes to ${r.path}${r.sha256 ? ` (sha256 ${r.sha256})` : ''}\n`, r);
272
379
  return 0;
273
380
  },
274
381
  async 'files ls'(ctx, values, [ref, path]) {
275
382
  const limitText = str(values.limit);
276
383
  const ws = await resolveWorkspace(ctx, ref);
277
- const list = await ws.cell({ agentLabel: ctx.agentLabel }).files.list(path, { limit: limitText ? parsePositiveInt(limitText, 'limit', 10_000) : 1000 });
384
+ const list = await cellOf(ctx, ws).files.list(path, { limit: limitText ? parsePositiveInt(limitText, 'limit', 10_000) : 1000 });
278
385
  out(ctx, `${fileTable(list)}${list.truncated ? '(truncated)\n' : ''}`, list);
279
386
  return 0;
280
387
  },
@@ -289,6 +396,262 @@ export const HANDLERS = {
289
396
  throw new UsageError('refusing to delete without --yes (deletion revokes tool access immediately and cannot be undone)');
290
397
  return lifecycle(ctx, values, ref, 'Deleting', (ws) => ctx.cloud().workspaces.delete(ws.id));
291
398
  },
399
+ async 'workspaces close'(ctx, values, [ref]) {
400
+ return lifecycle(ctx, values, ref, 'Closing', (ws) => ctx.cloud().workspaces.close(ws.id));
401
+ },
402
+ async 'workspaces reset'(ctx, values, [ref]) {
403
+ if (values.yes !== true)
404
+ throw new UsageError('refusing to reset without --yes (reset wipes every change in the workspace and restarts it on its template)');
405
+ return lifecycle(ctx, values, ref, 'Resetting', (ws) => ctx.cloud().workspaces.reset(ws.id));
406
+ },
407
+ async 'workspaces save-as-template'(ctx, values, [ref]) {
408
+ const template = str(values.template);
409
+ if (!template)
410
+ throw new UsageError('--template <slug> is required for "shard workspaces save-as-template"');
411
+ const checkpoint = str(values.checkpoint);
412
+ if (checkpoint !== undefined && !UUID.test(checkpoint))
413
+ throw new UsageError('--checkpoint must be a checkpoint id (UUID)');
414
+ const defaults = saveDefaults(values);
415
+ const ack = acknowledged(values);
416
+ const ws = await resolveWorkspace(ctx, ref);
417
+ const res = await ctx.cloud().workspaces.saveAsTemplate(ws.id, {
418
+ templateSlug: template,
419
+ ...(str(values['display-name']) !== undefined ? { displayName: str(values['display-name']) } : {}),
420
+ ...(str(values.description) !== undefined ? { description: str(values.description) } : {}),
421
+ ...(defaults ? { defaults } : {}),
422
+ ...(checkpoint ? { checkpointId: checkpoint } : {}),
423
+ ...(values['auto-publish'] === false ? { autoPublish: false } : {}),
424
+ ...(ack ? { acknowledgedScanFindings: ack } : {}),
425
+ });
426
+ return reportSave(ctx, values, res, `Saving ${ws.key} (${ws.id}) as ${template} v${res.build.target_version ?? '?'}.`);
427
+ },
428
+ async 'workspaces changes'(ctx, values, [ref]) {
429
+ const path = str(values.path);
430
+ if (path !== undefined && !path.startsWith('/'))
431
+ throw new UsageError('--path must be an absolute path');
432
+ const limit = listLimit(values, 1000);
433
+ const ws = await resolveWorkspace(ctx, ref);
434
+ const cell = cellOf(ctx, ws);
435
+ const base = { ...(path ? { pathPrefix: path } : {}), ...(values.hash === true ? { hash: true } : {}), ...(limit ? { limit } : {}) };
436
+ let rows = [];
437
+ let next = null;
438
+ let summary;
439
+ if (values.all === true) {
440
+ let cursor;
441
+ do {
442
+ const page = await cell.changes({ ...base, ...(cursor ? { cursor } : {}), ...(cursor === undefined && values.summary === true ? { summary: true } : {}) });
443
+ if (page.summary)
444
+ summary = page.summary;
445
+ rows.push(...page.data);
446
+ cursor = page.next_cursor ?? undefined;
447
+ if (ctx.signal.aborted)
448
+ throw new InterruptedError('Interrupted.');
449
+ } while (cursor);
450
+ }
451
+ else {
452
+ const cursor = str(values.cursor);
453
+ const page = await cell.changes({ ...base, ...(cursor ? { cursor } : {}), ...(values.summary === true ? { summary: true } : {}) });
454
+ rows = page.data;
455
+ next = page.next_cursor;
456
+ summary = page.summary;
457
+ }
458
+ const s = summary;
459
+ const head = s ? `added ${s.added}, modified ${s.modified}, deleted ${s.deleted}, replaced ${s.replaced} (${s.bytes} bytes)\n` : '';
460
+ out(ctx, `${head}${rows.length ? changesTable(rows) : 'No changes against the template.\n'}`, { data: rows, next_cursor: next, ...(summary ? { summary } : {}) });
461
+ if (!ctx.json && next)
462
+ ctx.io.stderr.write(`more: shard workspaces changes ${ref} --cursor ${next}\n`);
463
+ return 0;
464
+ },
465
+ async 'templates list'(ctx, values) {
466
+ const owner = oneOf(values, 'owner', OWNERS);
467
+ const limit = listLimit(values, 200);
468
+ const params = { ...(owner ? { owner } : {}), ...(values['include-archived'] === true ? { includeArchived: true } : {}) };
469
+ const templates = ctx.cloud().templates;
470
+ let rows = [];
471
+ let next = null;
472
+ if (values.all === true) {
473
+ for await (const t of templates.listAll(params))
474
+ rows.push(t);
475
+ }
476
+ else {
477
+ const cursor = str(values.cursor);
478
+ const page = await templates.list({ ...params, ...(limit ? { limit } : {}), ...(cursor ? { cursor } : {}) });
479
+ rows = page.data;
480
+ next = page.nextCursor;
481
+ }
482
+ out(ctx, rows.length ? templateTable(rows) : 'No templates.\n', { data: rows, next_cursor: next });
483
+ if (!ctx.json && next)
484
+ ctx.io.stderr.write(`more: shard templates list --cursor ${next}\n`);
485
+ return 0;
486
+ },
487
+ async 'templates files'(ctx, values, [slug, versionText, path]) {
488
+ const version = parseVersion(versionText, '<version>');
489
+ const owner = oneOf(values, 'owner', OWNERS);
490
+ const templates = ctx.cloud().templates;
491
+ const dir = path ?? '/';
492
+ if (values.stat === true) {
493
+ const entry = await templates.fileEntry(slug, version, dir, owner ? { owner } : {});
494
+ out(ctx, templateEntryText(entry), entry);
495
+ return 0;
496
+ }
497
+ const limit = listLimit(values, 1000);
498
+ let rows = [];
499
+ let next = null;
500
+ if (values.all === true) {
501
+ for await (const e of templates.filesAll(slug, version, { path: dir, ...(owner ? { owner } : {}), ...(limit ? { limit } : {}) })) {
502
+ rows.push(e);
503
+ if (ctx.signal.aborted)
504
+ throw new InterruptedError('Interrupted.');
505
+ }
506
+ }
507
+ else {
508
+ const cursor = str(values.cursor);
509
+ const page = await templates.files(slug, version, { path: dir, ...(owner ? { owner } : {}), ...(limit ? { limit } : {}), ...(cursor ? { cursor } : {}) });
510
+ rows = page.data;
511
+ next = page.next_cursor;
512
+ }
513
+ out(ctx, rows.length ? templateFileTable(rows) : `${dir} is empty.\n`, { path: dir, data: rows, next_cursor: next });
514
+ if (!ctx.json && next)
515
+ ctx.io.stderr.write(`more: shard templates files ${slug} ${version} ${dir} --cursor ${next}\n`);
516
+ return 0;
517
+ },
518
+ async 'templates diff'(ctx, values, [slug]) {
519
+ const fromText = str(values.from);
520
+ const toText = str(values.to);
521
+ if (fromText === undefined || toText === undefined)
522
+ throw new UsageError('--from <version|base> and --to <version> are required for "shard templates diff"');
523
+ const from = fromText === 'base' ? 'base' : parseVersion(fromText, '--from');
524
+ const to = parseVersion(toText, '--to');
525
+ const change = oneOf(values, 'change', DIFF_CHANGES);
526
+ const owner = oneOf(values, 'owner', OWNERS);
527
+ const prefix = str(values.prefix);
528
+ if (prefix !== undefined && !prefix.startsWith('/'))
529
+ throw new UsageError('--prefix must be an absolute path');
530
+ const limit = listLimit(values, 1000);
531
+ const base = { from, to, ...(prefix ? { pathPrefix: prefix } : {}), ...(change ? { change } : {}), ...(owner ? { owner } : {}), ...(limit ? { limit } : {}) };
532
+ const templates = ctx.cloud().templates;
533
+ let rows = [];
534
+ let next = null;
535
+ let summary = null;
536
+ if (values.all === true) {
537
+ let cursor;
538
+ do {
539
+ const page = await templates.diff(slug, { ...base, ...(cursor ? { cursor } : {}) });
540
+ if (page.summary)
541
+ summary = page.summary;
542
+ rows.push(...page.data);
543
+ cursor = page.next_cursor ?? undefined;
544
+ if (ctx.signal.aborted)
545
+ throw new InterruptedError('Interrupted.');
546
+ } while (cursor);
547
+ }
548
+ else {
549
+ const cursor = str(values.cursor);
550
+ const page = await templates.diff(slug, { ...base, ...(cursor ? { cursor } : {}) });
551
+ rows = page.data;
552
+ next = page.next_cursor;
553
+ summary = page.summary;
554
+ }
555
+ out(ctx, `${diffSummaryText(summary)}${rows.length ? diffTable(rows) : 'No differences.\n'}`, { data: rows, next_cursor: next, summary });
556
+ if (!ctx.json && next)
557
+ ctx.io.stderr.write(`more: shard templates diff ${slug} --from ${fromText} --to ${toText} --cursor ${next}\n`);
558
+ return 0;
559
+ },
560
+ async 'templates draft open'(ctx, values, [slug]) {
561
+ const c = caps(values);
562
+ const timeoutMs = waitTimeout(values);
563
+ const base = str(values.base);
564
+ const cloud = ctx.cloud();
565
+ const opened = await cloud.templates.draft(slug).create({ ...(base ? { base } : {}), ...(c ? { caps: c } : {}), agentLabel: ctx.agentLabel, wait: false });
566
+ let ws = opened.workspace;
567
+ const op = opened.operation;
568
+ if (values.wait !== false && !ws.ready && op) {
569
+ await waitOperation(ctx, op.id, timeoutMs);
570
+ ws = await cloud.workspaces.get(ws.id);
571
+ }
572
+ const human = ws.ready
573
+ ? `Draft of ${slug} is ready: workspace ${ws.key} (${ws.id}) on ${opened.draft.base_version.slug} v${opened.draft.base_version.version}.\nedit it with: shard ws exec ${ws.key} -- <command>\n`
574
+ : `Draft of ${slug}: workspace ${ws.key} (${ws.id}) is ${ws.state}${op ? `; operation ${op.id} ${op.kind} ${op.state}` : ''}.\n${op ? `wait with: shard operations wait ${op.id}\n` : ''}`;
575
+ out(ctx, human, { draft: { ...opened.draft, workspace: ws.data }, operation: op });
576
+ return 0;
577
+ },
578
+ async 'templates draft status'(ctx, _values, [slug]) {
579
+ const draft = await ctx.cloud().templates.draft(slug).get();
580
+ out(ctx, draftText(draft), draft);
581
+ return 0;
582
+ },
583
+ async 'templates draft state'(ctx, values, [slug]) {
584
+ const draft = ctx.cloud().templates.draft(slug);
585
+ if (values.list === true) {
586
+ const states = [];
587
+ for await (const st of draft.statesAll({ limit: 200 }))
588
+ states.push(st);
589
+ out(ctx, states.length ? draftStateTable(states) : 'No states captured yet.\n', { data: states });
590
+ return 0;
591
+ }
592
+ const label = str(values.label);
593
+ let op = await draft.captureState(label === undefined ? {} : { label });
594
+ if (values.wait === true)
595
+ op = await waitOperation(ctx, op.id, waitTimeout(values));
596
+ const checkpoint = typeof op.result?.checkpoint_id === 'string' ? `state ${op.result.checkpoint_id}\n` : '';
597
+ out(ctx, `Capturing a state of the ${slug} draft: operation ${op.id} ${op.kind} ${op.state}\n${checkpoint}${op.state === 'succeeded' ? '' : `wait with: shard operations wait ${op.id}\n`}`, op);
598
+ return 0;
599
+ },
600
+ async 'templates draft test'(ctx, values, [slug]) {
601
+ const draft = ctx.cloud().templates.draft(slug);
602
+ if (values.list === true) {
603
+ const views = [];
604
+ let cursor;
605
+ do {
606
+ const page = await draft.testInstances({ limit: 200, ...(values['include-ended'] === true ? { includeEnded: true } : {}), ...(cursor ? { cursor } : {}) });
607
+ views.push(...page.data.map((w) => w.data));
608
+ cursor = page.nextCursor ?? undefined;
609
+ } while (cursor);
610
+ out(ctx, views.length ? workspaceTable(views) : 'No test instances.\n', { data: views });
611
+ return 0;
612
+ }
613
+ const state = str(values.state);
614
+ if (state !== undefined && !UUID.test(state))
615
+ throw new UsageError('--state must be a draft state id (UUID)');
616
+ const c = caps(values);
617
+ const timeoutMs = waitTimeout(values);
618
+ const instanceKey = str(values['instance-key']);
619
+ let ws = await draft.openTestInstance({ ...(state ? { stateId: state } : {}), ...(instanceKey ? { key: instanceKey } : {}), ...(c ? { caps: c } : {}), agentLabel: ctx.agentLabel, wait: false });
620
+ const op = ws.activeOperation;
621
+ if (values.wait !== false && !ws.ready && op) {
622
+ await waitOperation(ctx, op.id, timeoutMs);
623
+ ws = await ctx.cloud().workspaces.get(ws.id);
624
+ }
625
+ const human = ws.ready
626
+ ? `Test instance ${ws.key} (${ws.id}) is ready. It ends with: shard ws close ${ws.key}\n`
627
+ : `Test instance ${ws.key} (${ws.id}) is ${ws.state}${op ? `; operation ${op.id} ${op.kind} ${op.state}` : ''}.\n${op ? `wait with: shard operations wait ${op.id}\n` : ''}`;
628
+ out(ctx, human, ws.data);
629
+ return 0;
630
+ },
631
+ async 'templates draft publish'(ctx, values, [slug]) {
632
+ const state = str(values.state);
633
+ if (state !== undefined && !UUID.test(state))
634
+ throw new UsageError('--state must be a draft state id (UUID)');
635
+ const defaults = saveDefaults(values);
636
+ const ack = acknowledged(values);
637
+ const res = await ctx.cloud().templates.draft(slug).publish({
638
+ ...(state ? { stateId: state } : {}),
639
+ ...(str(values.description) !== undefined ? { description: str(values.description) } : {}),
640
+ ...(defaults ? { defaults } : {}),
641
+ ...(values['auto-publish'] === false ? { autoPublish: false } : {}),
642
+ ...(ack ? { acknowledgedScanFindings: ack } : {}),
643
+ });
644
+ return reportSave(ctx, values, res, `Publishing the ${slug} draft as v${res.build.target_version ?? '?'}.`);
645
+ },
646
+ async 'templates draft discard'(ctx, values, [slug]) {
647
+ if (values.yes !== true)
648
+ throw new UsageError('refusing to discard without --yes (the draft is deleted and its live test instances end)');
649
+ let op = await ctx.cloud().templates.draft(slug).discard();
650
+ if (values.wait === true)
651
+ op = await waitOperation(ctx, op.id, waitTimeout(values));
652
+ out(ctx, `Discarding the ${slug} draft: operation ${op.id} ${op.kind} ${op.state}\n`, op);
653
+ return 0;
654
+ },
292
655
  async 'workspaces fork'(ctx, values, [ref, newKey]) {
293
656
  const c = caps(values);
294
657
  const timeoutMs = waitTimeout(values);
@@ -312,6 +675,114 @@ export const HANDLERS = {
312
675
  out(ctx, sessions.length ? sessionTable(sessions) : 'No agent sessions (no tool token was issued for this workspace yet).\n', { data: sessions });
313
676
  return 0;
314
677
  },
678
+ async 'workspaces secrets'(ctx, values, [ref]) {
679
+ const set = secretNames(strs(values.set), '--set');
680
+ const clear = values.clear === true;
681
+ if (clear && set.length > 0)
682
+ throw new UsageError('use either --set NAME... or --clear, not both');
683
+ const ws = await resolveWorkspace(ctx, ref);
684
+ const b = clear || set.length > 0 ? await ws.secrets.set(clear ? [] : set) : await ws.secrets.get();
685
+ out(ctx, bindingsText(b), b);
686
+ return 0;
687
+ },
688
+ async 'secrets create'(ctx, values, [name]) {
689
+ if (!SECRET_NAME.test(name))
690
+ throw new UsageError(`<name> must be an environment variable name like OPENAI_API_KEY (^[A-Z_][A-Z0-9_]*$), got "${name}"`);
691
+ const scope = scopeFlag(values);
692
+ const perms = permissionParams(values, scope === null ? 'project' : 'organization', 'create');
693
+ const description = str(values.description);
694
+ const value = await readSecretValue(ctx, values);
695
+ const cloud = ctx.cloud();
696
+ const base = { name: name, value, ...(description !== undefined ? { description } : {}) };
697
+ const s = scope === null
698
+ ? await cloud.secrets.create((await principal(ctx)).project_id, { ...base, ...perms })
699
+ : await cloud.secrets.createOrganization(scope, { ...base, ...perms });
700
+ out(ctx, `Created ${s.scope} secret ${s.name} (${s.id}), version ${s.current_version}. The value is stored encrypted and is never shown.\n${secretDetail(s)}`, s);
701
+ return 0;
702
+ },
703
+ async 'secrets list'(ctx, values) {
704
+ const cloud = ctx.cloud();
705
+ const org = scopeFlag(values);
706
+ const project = org === null ? (await principal(ctx)).project_id : null;
707
+ const limitText = str(values.limit);
708
+ const opts = { ...(values['include-deleted'] === true ? { includeDeleted: true } : {}), ...(limitText ? { limit: parsePositiveInt(limitText, 'limit', 200) } : {}) };
709
+ const page = (cursor) => (project !== null ? cloud.secrets.list(project, { ...opts, ...(cursor ? { cursor } : {}) }) : cloud.secrets.listOrganization(org, { ...opts, ...(cursor ? { cursor } : {}) }));
710
+ let rows = [];
711
+ let next = null;
712
+ if (values.all === true) {
713
+ let cursor;
714
+ do {
715
+ const p = await page(cursor);
716
+ rows.push(...p.data);
717
+ cursor = p.nextCursor ?? undefined;
718
+ } while (cursor);
719
+ }
720
+ else {
721
+ const p = await page(str(values.cursor));
722
+ rows = p.data;
723
+ next = p.nextCursor;
724
+ }
725
+ out(ctx, rows.length ? secretTable(rows) : 'No secrets.\n', { data: rows, next_cursor: next });
726
+ if (!ctx.json && next)
727
+ ctx.io.stderr.write(`more: shard secrets list${org ? ` --org ${org}` : ''} --cursor ${next}\n`);
728
+ return 0;
729
+ },
730
+ async 'secrets get'(ctx, values, [ref]) {
731
+ const s = await resolveSecret(ctx, values, ref);
732
+ out(ctx, secretDetail(s), s);
733
+ return 0;
734
+ },
735
+ async 'secrets update'(ctx, values, [ref]) {
736
+ const s = await resolveSecret(ctx, values, ref);
737
+ const params = { ...permissionParams(values, s.scope, 'update') };
738
+ const description = str(values.description);
739
+ if (description !== undefined)
740
+ params.description = description;
741
+ if (Object.keys(params).length === 0)
742
+ throw new UsageError('nothing to update: pass --description, --tool, --allow-workspace, --any-workspace, --allow-project, --all-projects or --clear-projects');
743
+ const updated = await ctx.cloud().secrets.update(s.id, params);
744
+ out(ctx, `Updated ${updated.name}; applies from the next session start.\n${secretDetail(updated)}`, updated);
745
+ return 0;
746
+ },
747
+ async 'secrets rotate'(ctx, values, [ref]) {
748
+ const value = await readSecretValue(ctx, values);
749
+ const s = await resolveSecret(ctx, values, ref);
750
+ const r = await ctx.cloud().secrets.rotate(s.id, value);
751
+ out(ctx, `Rotated ${r.secret.name} to version ${r.version.version}; older values were erased. Processes that already read the old value keep it.\n`, r);
752
+ return 0;
753
+ },
754
+ async 'secrets versions'(ctx, values, [ref]) {
755
+ const s = await resolveSecret(ctx, values, ref);
756
+ const limitText = str(values.limit);
757
+ const page = await ctx.cloud().secrets.versions(s.id, limitText ? { limit: parsePositiveInt(limitText, 'limit', 200) } : {});
758
+ out(ctx, secretVersionTable(page.data), { data: page.data, next_cursor: page.nextCursor });
759
+ return 0;
760
+ },
761
+ async 'secrets delete'(ctx, values, [ref]) {
762
+ if (values.yes !== true)
763
+ throw new UsageError('refusing to delete without --yes (the values are erased and the name is removed from every workspace binding)');
764
+ const s = await resolveSecret(ctx, values, ref);
765
+ await ctx.cloud().secrets.delete(s.id);
766
+ out(ctx, `Deleted ${s.scope} secret ${s.name} (${s.id}): its values were erased and it was removed from every workspace binding.\n`, { deleted: true, id: s.id, name: s.name, scope: s.scope });
767
+ return 0;
768
+ },
769
+ async 'secrets access-log'(ctx, values, [ref]) {
770
+ const outcome = str(values.outcome);
771
+ if (outcome !== undefined && !['granted', 'denied', 'failed'].includes(outcome))
772
+ throw new UsageError('--outcome must be granted, denied or failed');
773
+ const workspace = str(values.workspace);
774
+ if (workspace !== undefined && !UUID.test(workspace))
775
+ throw new UsageError('--workspace must be a workspace id (UUID)');
776
+ const limitText = str(values.limit);
777
+ const s = await resolveSecret(ctx, values, ref);
778
+ const page = await ctx.cloud().secrets.accessEvents(s.id, {
779
+ ...(outcome ? { outcome: outcome } : {}),
780
+ ...(workspace ? { workspaceId: workspace } : {}),
781
+ ...(limitText ? { limit: parsePositiveInt(limitText, 'limit', 200) } : {}),
782
+ });
783
+ out(ctx, page.data.length ? secretAccessTable(page.data) : 'No session has resolved this secret yet.\n', { data: page.data, next_cursor: page.nextCursor });
784
+ return 0;
785
+ },
315
786
  async 'operations list'(ctx, values, [ref]) {
316
787
  const ws = await resolveWorkspace(ctx, ref);
317
788
  const limitText = str(values.limit);
@@ -338,6 +809,109 @@ export const HANDLERS = {
338
809
  return 0;
339
810
  },
340
811
  };
812
+ /** `--org <id>`: organization scope; null: the API key's project. */
813
+ function scopeFlag(values) {
814
+ const org = str(values.org);
815
+ if (org === undefined)
816
+ return null;
817
+ if (!UUID.test(org))
818
+ throw new UsageError('--org must be an organization id (UUID)');
819
+ return org;
820
+ }
821
+ /** Usage permissions from the flags (create: defaults left to the API; update: only what was passed). */
822
+ function permissionParams(values, scope, mode) {
823
+ const out = {};
824
+ const tools = strs(values.tool);
825
+ for (const t of tools)
826
+ if (!TOOLS.includes(t))
827
+ throw new UsageError(`--tool must be one of ${TOOLS.join(', ')} (got "${t}")`);
828
+ if (tools.length)
829
+ out.allowedTools = [...new Set(tools)];
830
+ const workspaces = strs(values['allow-workspace']);
831
+ for (const w of workspaces)
832
+ if (!UUID.test(w))
833
+ throw new UsageError(`--allow-workspace expects a workspace id (UUID), got "${w}"`);
834
+ if (workspaces.length && values['any-workspace'] === true)
835
+ throw new UsageError('use either --allow-workspace or --any-workspace');
836
+ if (workspaces.length)
837
+ out.allowedWorkspaceIds = workspaces;
838
+ if (mode === 'update' && values['any-workspace'] === true)
839
+ out.allowedWorkspaceIds = null;
840
+ const projects = strs(values['allow-project']);
841
+ const all = values['all-projects'] === true;
842
+ const none = mode === 'update' && values['clear-projects'] === true;
843
+ if ((projects.length ? 1 : 0) + (all ? 1 : 0) + (none ? 1 : 0) > 1)
844
+ throw new UsageError('use only one of --allow-project, --all-projects, --clear-projects');
845
+ if ((projects.length || all || none) && scope === 'project')
846
+ throw new UsageError('--allow-project/--all-projects/--clear-projects apply to organization secrets only (--org)');
847
+ for (const p of projects)
848
+ if (!UUID.test(p))
849
+ throw new UsageError(`--allow-project expects a project id (UUID), got "${p}"`);
850
+ if (projects.length)
851
+ out.allowedProjectIds = projects;
852
+ if (all)
853
+ out.allowedProjectIds = null;
854
+ if (none)
855
+ out.allowedProjectIds = [];
856
+ return out;
857
+ }
858
+ /**
859
+ * The secret value from --from-file or standard input (never from arguments): UTF-8 text, one trailing
860
+ * newline removed, 1..65536 bytes. Never echoed.
861
+ */
862
+ async function readSecretValue(ctx, values) {
863
+ const file = str(values['from-file']);
864
+ const bytes = await readInput(ctx, file ?? '-', MAX_SECRET_VALUE_BYTES + 2, file === undefined ? 'secret value (standard input)' : '--from-file');
865
+ let text;
866
+ try {
867
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
868
+ }
869
+ catch {
870
+ throw new UsageError('the secret value must be UTF-8 text');
871
+ }
872
+ finally {
873
+ bytes.fill(0);
874
+ }
875
+ if (text.endsWith('\r\n'))
876
+ text = text.slice(0, -2);
877
+ else if (text.endsWith('\n'))
878
+ text = text.slice(0, -1);
879
+ if (text.length === 0)
880
+ throw new UsageError('the secret value is empty: pipe it on standard input or pass --from-file <file>');
881
+ if (Buffer.byteLength(text, 'utf8') > MAX_SECRET_VALUE_BYTES)
882
+ throw new UsageError(`the secret value exceeds ${MAX_SECRET_VALUE_BYTES} bytes`);
883
+ if (text.includes('\u0000'))
884
+ throw new UsageError('the secret value must not contain NUL characters');
885
+ return text;
886
+ }
887
+ /** `<id|name>`: a UUID is tried as an id first; otherwise the live secret of that name in the scope (project, or --org). */
888
+ async function resolveSecret(ctx, values, ref) {
889
+ const cloud = ctx.cloud();
890
+ if (UUID.test(ref)) {
891
+ try {
892
+ return await cloud.secrets.get(ref);
893
+ }
894
+ catch (err) {
895
+ if (!(err instanceof ShardfluxApiError && err.status === 404))
896
+ throw err;
897
+ }
898
+ }
899
+ const org = scopeFlag(values);
900
+ if (!SECRET_NAME.test(ref))
901
+ throw new NotFoundError(`No secret with id or name "${ref}".`);
902
+ const project = org === null ? (await principal(ctx)).project_id : null;
903
+ let cursor;
904
+ do {
905
+ const page = project !== null ? await cloud.secrets.list(project, { limit: 200, ...(cursor ? { cursor } : {}) }) : await cloud.secrets.listOrganization(org, { limit: 200, ...(cursor ? { cursor } : {}) });
906
+ const hit = page.data.find((s) => s.name === ref && s.deleted_at === null);
907
+ if (hit)
908
+ return hit;
909
+ cursor = page.nextCursor ?? undefined;
910
+ if (ctx.signal.aborted)
911
+ throw new InterruptedError('Interrupted.');
912
+ } while (cursor);
913
+ throw new NotFoundError(project !== null ? `No secret named "${ref}" in this project (organization-wide secrets: add --org <organization-id>).` : `No organization secret named "${ref}".`);
914
+ }
341
915
  async function lifecycle(ctx, values, ref, verb, start) {
342
916
  const timeoutMs = waitTimeout(values);
343
917
  const ws = await resolveWorkspace(ctx, ref);