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