@shardflux/cli 0.4.1 → 0.5.1

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
@@ -1,31 +1,40 @@
1
1
  /**
2
- * Command implementations. Everything goes through @shardflux/sdk (the same
3
- * client, retries, idempotency keys, operation polling and tool-token
4
- * management as SDK users get); `cloud.request()` covers the two endpoints
5
- * the SDK has no typed method for yet (GET /v1/operations/{id}, usage summary).
2
+ * Command implementations of the project-key commands (workspaces, files, operations, secrets, templates, usage).
3
+ * Everything goes through @shardflux/sdk (the same client, retries, idempotency keys, operation polling and
4
+ * tool-token management as SDK users get); `cloud.request()` covers the two endpoints the SDK has no typed method
5
+ * for yet (GET /v1/operations/{id}, usage summary). The account plane (0.5.0) is in account-commands.ts; handlers.ts
6
+ * joins both tables.
6
7
  */
7
8
  import { randomUUID } from 'node:crypto';
8
9
  import { access, readFile, writeFile } from 'node:fs/promises';
9
10
  import { basename, dirname, resolve as resolvePath } from 'node:path';
10
- import { SDK_VERSION, ShardfluxApiError } from '@shardflux/sdk';
11
+ import { EXECUTION_ID, FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH, NotSupportedForModeError, SDK_VERSION, ShardfluxApiError, newExecutionId } from '@shardflux/sdk';
11
12
  import { parse as parseYaml } from 'yaml';
12
13
  import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
13
- import { InterruptedError, NotFoundError, UsageError } from "./errors.js";
14
+ import { ExecutionFailedError, ExecutionPendingError, InterruptedError, NotFoundError, UsageError } from "./errors.js";
15
+ import { updateCheckDisabled } from "./update-check.js";
14
16
  import { initTemplateYaml, recipeYaml } from "./template-yaml.js";
15
17
  import { BuildTimer, buildTimingJson, buildTimingText, progressLine } from "./timing.js";
16
18
  import { timingJson } from "./timing.js";
17
- import { bindingsText, buildTable, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, fileTable, languageTable, json, kv, operationDetail, operationTable, packageTable, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, templateDetailText, templateEntryText, templateFileTable, templateTable, usageText, workspaceDetail, workspaceTable, } from "./format.js";
19
+ import { bindingsText, buildTable, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, executionJson, executionSummary, fileInfoText, fileTable, languageTable, json, kv, modeText, operationDetail, pairs, operationTable, packageTable, searchText, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, suspendRequestText, suspendWhenIdleText, templateDetailText, templateEntryText, templateFileTable, templateTable, usageEstimateText, usageSeriesText, usageText, workspaceDetail, workspaceTable, } from "./format.js";
18
20
  const DEFAULT_WAIT_MS = 300_000;
19
21
  const MAX_JSON_OUTPUT = 16 * 1024 * 1024;
20
22
  const MAX_STDIN = 1024 * 1024;
21
- const str = (v) => (typeof v === 'string' ? v : undefined);
23
+ /** `--output-limit` of a file-first execution: 1 byte to 16 MiB per stream (cell-api.yaml output_limit_bytes). */
24
+ const MAX_OUTPUT_LIMIT = 16 * 1024 * 1024;
25
+ export const str = (v) => (typeof v === 'string' ? v : undefined);
22
26
  /**
23
27
  * The workspace's cell client as this invocation configures it: the CLI's agent label, wake on use unless turned off,
24
28
  * and the wake timeout (an OperationTimeoutError past it exits 5).
25
29
  */
26
30
  const cellOf = (ctx, ws) => ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs, ...(ctx.wake ? {} : { wake: null }) });
27
- const strs = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : []);
31
+ export const strs = (v) => (Array.isArray(v) ? v : typeof v === 'string' ? [v] : []);
28
32
  const MAX_SECRET_VALUE_BYTES = 65_536;
33
+ /** `files patch --content-file`: a patch request is at most 7 MiB (contracts §26.2); larger files go through `files write`. */
34
+ const MAX_PATCH_CONTENT = 7 * 1024 * 1024;
35
+ const STOPPED_AT = { max_matches: '--max matches', budget: 'its 10 s budget', max_bytes: '4 MiB of results' };
36
+ /** A file revision (SHA-256 of the content) or `absent` (contracts §26.2). */
37
+ const REVISION = /^([0-9a-f]{64}|absent)$/;
29
38
  const TOOLS = ['exec', 'files', 'pty', 'process', 'git', 'browser'];
30
39
  function secretNames(names, option) {
31
40
  for (const n of names)
@@ -41,7 +50,7 @@ function withTiming(ctx, machine) {
41
50
  return machine;
42
51
  return { ...machine, timing: timingJson(ctx.traces.last) };
43
52
  }
44
- function out(ctx, human, machine) {
53
+ export function out(ctx, human, machine) {
45
54
  ctx.io.stdout.write(ctx.json ? json(withTiming(ctx, machine)) : human);
46
55
  }
47
56
  function caps(values) {
@@ -57,7 +66,17 @@ function caps(values) {
57
66
  c.disk_gib = parsePositiveInt(disk, 'disk-gib', 1_000_000);
58
67
  return Object.keys(c).length ? c : undefined;
59
68
  }
60
- function waitTimeout(values) {
69
+ /** `--when-idle <duration>`: whole seconds from 30 to 3600 (the API's after_seconds bounds, contracts §20.6). */
70
+ export function whenIdleSeconds(text) {
71
+ const ms = parseDuration(text, 'when-idle');
72
+ if (ms % 1000 !== 0)
73
+ throw new UsageError('--when-idle must be a whole number of seconds (e.g. 30s, 90s, 2m)');
74
+ const s = ms / 1000;
75
+ if (s < 30 || s > 3600)
76
+ throw new UsageError(`--when-idle must be between 30s and 1h (got "${text}")`);
77
+ return s;
78
+ }
79
+ export function waitTimeout(values) {
61
80
  const t = str(values.timeout);
62
81
  return t === undefined ? DEFAULT_WAIT_MS : parseDuration(t, 'timeout');
63
82
  }
@@ -96,7 +115,7 @@ const PURPOSE_FILTERS = ['standard', 'template_draft', 'template_test', 'any'];
96
115
  const DIFF_CHANGES = ['added', 'removed', 'changed', 'type_changed', 'metadata'];
97
116
  const OWNERS = ['platform', 'organization'];
98
117
  const DEFAULT_BUILD_WAIT_MS = 1_800_000;
99
- function oneOf(values, option, allowed) {
118
+ export function oneOf(values, option, allowed) {
100
119
  const v = str(values[option]);
101
120
  if (v === undefined)
102
121
  return undefined;
@@ -104,7 +123,7 @@ function oneOf(values, option, allowed) {
104
123
  throw new UsageError(`--${option} must be one of ${allowed.join(', ')} (got "${v}")`);
105
124
  return v;
106
125
  }
107
- function listLimit(values, max) {
126
+ export function listLimit(values, max) {
108
127
  const t = str(values.limit);
109
128
  return t === undefined ? undefined : parsePositiveInt(t, 'limit', max);
110
129
  }
@@ -160,7 +179,7 @@ function parseVersion(text, what) {
160
179
  }
161
180
  const SLUG = /^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9])?$/;
162
181
  /** `<slug>@<version>`. */
163
- function parseSlugVersion(text, what) {
182
+ export function parseSlugVersion(text, what) {
164
183
  const at = text.lastIndexOf('@');
165
184
  if (at <= 0)
166
185
  throw new UsageError(`${what} must be <slug>@<version>, e.g. acme-dev@3 (got "${text}")`);
@@ -230,7 +249,7 @@ const BUILD_STATES = ['queued', 'building', 'testing', 'publishing', 'published'
230
249
  function buildExitCode(b) {
231
250
  return b.state === 'failed' || b.state === 'canceled' || b.registration.state === 'failed' ? 6 : 0;
232
251
  }
233
- async function fileExists(path) {
252
+ export async function fileExists(path) {
234
253
  return access(path).then(() => true, () => false);
235
254
  }
236
255
  /** Writes `text` to `path`, refusing to overwrite an existing file without --force. */
@@ -283,7 +302,7 @@ async function readAll(stream, max, what) {
283
302
  }
284
303
  return new Uint8Array(Buffer.concat(chunks));
285
304
  }
286
- async function readInput(ctx, source, max, what) {
305
+ export async function readInput(ctx, source, max, what) {
287
306
  if (source === '-') {
288
307
  if (ctx.io.stdin.isTTY)
289
308
  throw new UsageError(`${what}: standard input is a terminal; pipe data in or pass a file`);
@@ -296,7 +315,7 @@ async function readInput(ctx, source, max, what) {
296
315
  throw new UsageError(`${what} exceeds ${max} bytes`);
297
316
  return new Uint8Array(data);
298
317
  }
299
- async function principal(ctx) {
318
+ export async function principal(ctx) {
300
319
  const me = await ctx.cloud().me();
301
320
  if (me.type !== 'api_key' || !me.api_key)
302
321
  throw new UsageError('SHARDFLUX_API_KEY did not authenticate as an API key');
@@ -317,6 +336,72 @@ function principalText(p) {
317
336
  ['tools', p.tool_permissions.length ? p.tool_permissions.join(', ') : '(none: read-only key)'],
318
337
  ]);
319
338
  }
339
+ /**
340
+ * Who sends the feedback: the project API key when one is configured (SHARDFLUX_API_KEY or saved by shard setup),
341
+ * else the signed-in session (SHARDFLUX_SESSION_TOKEN or saved by shard auth login), so an agent that is still setting
342
+ * up can report what got in its way. With neither, the key's own error (its hint names both ways, plus email).
343
+ */
344
+ function feedbackSender(ctx) {
345
+ const keyConfigured = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || ctx.store.profile(ctx.apiUrl).api_key !== undefined;
346
+ const sessionConfigured = Boolean(ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim()) || ctx.store.profile(ctx.apiUrl).session !== undefined;
347
+ return keyConfigured || !sessionConfigured ? ctx.cloud() : ctx.account();
348
+ }
349
+ /** Contract bounds of the feedback context fields shard sends (characters). */
350
+ const FEEDBACK_CONTEXT_MAX = { workspace: 200, 'request-id': 200, 'error-code': 100, command: 2000 };
351
+ /**
352
+ * The feedback message: the positional words joined with spaces, or --from-file (a file, or - for standard input),
353
+ * trimmed, 1-8000 characters (the API's bound, counted in UTF-16 units so nothing the API would refuse is sent).
354
+ */
355
+ async function feedbackMessage(ctx, values, words) {
356
+ const file = str(values['from-file']);
357
+ if (file !== undefined && words.length > 0)
358
+ throw new UsageError('give the message as arguments or with --from-file, not both');
359
+ let text;
360
+ if (file !== undefined) {
361
+ const bytes = await readInput(ctx, file, MAX_STDIN, '--from-file');
362
+ try {
363
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
364
+ }
365
+ catch {
366
+ throw new UsageError('--from-file: the message must be UTF-8 text');
367
+ }
368
+ }
369
+ else if (words.length > 0) {
370
+ text = words.join(' ');
371
+ }
372
+ else {
373
+ throw new UsageError('missing <message> for "shard feedback": shard feedback "what happened and what you expected" (or --from-file <file|->)');
374
+ }
375
+ text = text.trim();
376
+ if (text.length === 0)
377
+ throw new UsageError('the feedback message is empty');
378
+ if (text.length > FEEDBACK_MESSAGE_MAX_LENGTH) {
379
+ throw new UsageError(`the feedback message is ${text.length} characters; the limit is ${FEEDBACK_MESSAGE_MAX_LENGTH} (keep the relevant part, e.g. tail -n 100 of a log, or send it in two messages)`);
380
+ }
381
+ return text;
382
+ }
383
+ /** The context shard sends: its own client, the agent label when one is set, and the given references. */
384
+ function feedbackContext(ctx, values) {
385
+ const c = { client: `shard-cli/${CLI_VERSION}` };
386
+ if (ctx.agentLabel !== 'cli')
387
+ c.agent = ctx.agentLabel;
388
+ for (const [option, max] of Object.entries(FEEDBACK_CONTEXT_MAX)) {
389
+ const v = str(values[option])?.trim();
390
+ if (v === undefined)
391
+ continue;
392
+ if (v.length === 0 || v.length > max)
393
+ throw new UsageError(`--${option} must be 1-${max} characters`);
394
+ if (option === 'workspace')
395
+ c.workspace = v;
396
+ else if (option === 'request-id')
397
+ c.requestId = v;
398
+ else if (option === 'error-code')
399
+ c.errorCode = v;
400
+ else
401
+ c.command = v;
402
+ }
403
+ return c;
404
+ }
320
405
  /** The exit code `shard exec` returns for a finished command. */
321
406
  export function execExitCode(r) {
322
407
  if (r.timedOut)
@@ -330,14 +415,95 @@ export function execExitCode(r) {
330
415
  async function bestEffortCancel(cell, sessionId) {
331
416
  await Promise.race([cell.exec.cancel(sessionId).catch(() => undefined), new Promise((r) => setTimeout(r, 3_000).unref())]);
332
417
  }
418
+ // ---- File-first workspaces (0.5.0; contracts §29) ----------------------------------------------------------------------
419
+ const MODES = { processful: 'processful', 'file-first': 'file_first', file_first: 'file_first' };
420
+ /** `--mode processful|file-first` (`file_first` too). */
421
+ function modeFlag(values) {
422
+ const v = str(values.mode);
423
+ if (v === undefined)
424
+ return undefined;
425
+ const m = MODES[v];
426
+ if (!m)
427
+ throw new UsageError(`--mode must be processful or file-first (got "${v}")`);
428
+ return m;
429
+ }
430
+ const EXECUTION_ID_TEXT = '8-128 characters of A-Z a-z 0-9 . _ : -, starting with a letter or digit';
431
+ /** `--if-revision <n>`: a tree revision, a non-negative integer (0 is the empty tree a file-first workspace starts with). */
432
+ function ifRevision(values) {
433
+ const t = str(values['if-revision']);
434
+ if (t === undefined)
435
+ return undefined;
436
+ if (!/^\d{1,15}$/.test(t.trim()))
437
+ throw new UsageError(`--if-revision must be a tree revision, a non-negative integer (got "${t}")`);
438
+ return Number(t.trim());
439
+ }
440
+ /** Options that exist only for file-first workspaces are a usage error on a processful one (known once resolved). */
441
+ function fileFirstOnly(ws, flags, note = '') {
442
+ if (flags.length === 0 || ws.mode === 'file_first')
443
+ return;
444
+ throw new UsageError(`${flags.join(' and ')} ${flags.length > 1 ? 'apply' : 'applies'} to file-first workspaces only; ${ws.key} is ${modeText(ws.mode)}${note}`);
445
+ }
446
+ /** The tree revision after a mutating files call on a file-first workspace (the newest its responses reported); else null. */
447
+ function treeRevisionAfter(ws, cell) {
448
+ if (ws.mode !== 'file_first')
449
+ return null;
450
+ return cell.treeRevision ?? ws.treeRevision;
451
+ }
452
+ /**
453
+ * A lifecycle call a file-first workspace does not have (contracts §29.7: suspend, resume, fork, reset,
454
+ * save-as-template), refused before any request as the SDK's workspace handle does: NotSupportedForModeError, printed
455
+ * with its hint like the API's own refusal.
456
+ */
457
+ function requireProcessful(ws, operation, what) {
458
+ if (ws.mode !== 'file_first')
459
+ return;
460
+ throw NotSupportedForModeError.local('file_first', operation, 'api', `${what} is not available for a file-first workspace: it has no VM between executions and is never suspended; its state is its file tree (revision ${ws.treeRevision ?? 0}).`);
461
+ }
462
+ /**
463
+ * Prints an execution's result and returns the exit code. Human: the command's stdout and stderr bytes as they are
464
+ * (binary safe), a note when output was cut at the output limit, and with `summary` what ran (stderr; see
465
+ * executionSummary). --json: one document (executionJson). The exit code is the command's once it succeeded (like a
466
+ * processful exec: 124 timed out, 128+N killed by signal N); a failed or lost execution throws ExecutionFailedError
467
+ * (exit 6), one still queued or running ExecutionPendingError (exit 5, naming the command that waits for it).
468
+ */
469
+ function reportExecution(ctx, ref, r, opts) {
470
+ if (ctx.json)
471
+ ctx.io.stdout.write(json(withTiming(ctx, executionJson(r))));
472
+ else if (!r.pending) {
473
+ if (r.stdout.length)
474
+ ctx.io.stdout.write(r.stdout);
475
+ if (r.stderr.length)
476
+ ctx.io.stderr.write(r.stderr);
477
+ const cut = [r.stdoutTruncated ? 'stdout' : '', r.stderrTruncated ? 'stderr' : ''].filter(Boolean).join(' and ');
478
+ if (opts.summary)
479
+ ctx.io.stderr.write(executionSummary(r, undefined, opts.fetched ? { replayed: false } : {}));
480
+ else {
481
+ if (cut)
482
+ ctx.io.stderr.write(`truncated: ${cut} reached the output limit (--output-limit, default 1 MiB, at most 16 MiB)\n`);
483
+ if (opts.timings && Object.keys(r.timings).length)
484
+ ctx.io.stderr.write(`execution ${r.executionId} timings: ${pairs(r.timings)}\n`);
485
+ }
486
+ }
487
+ if (r.pending) {
488
+ const when = opts.waited ? `still ${r.state} after ${opts.waited}` : `${r.state} (on tree revision ${r.baseRevision})`;
489
+ throw new ExecutionPendingError(`Execution ${r.executionId} is ${when}; it continues server side.`, r, `wait for its result with: shard executions get ${ref} ${r.executionId} --wait 10m`);
490
+ }
491
+ if (r.state === 'failed' || r.state === 'lost')
492
+ throw new ExecutionFailedError(r);
493
+ return execExitCode(r);
494
+ }
333
495
  function lifecycleText(verb, w, op) {
334
496
  const finished = op.state === 'succeeded' || op.state === 'failed' || op.state === 'canceled';
335
497
  return `${verb} ${w.key} (${w.id}): operation ${op.id} ${op.kind} ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''}\n${finished ? '' : `wait with: shard operations wait ${op.id}\n`}`;
336
498
  }
337
- export const HANDLERS = {
499
+ /** The project-key commands; handlers.ts adds the account plane's. */
500
+ export const KEY_HANDLERS = {
338
501
  async login(ctx) {
339
502
  const p = await principal(ctx);
340
- out(ctx, `Authenticated.\n${principalText(p)}shard stores nothing; it reads SHARDFLUX_API_KEY on every run.\n`, p);
503
+ const where = ctx.apiKey().source === 'env'
504
+ ? 'The key comes from SHARDFLUX_API_KEY; shard stores nothing for it.'
505
+ : `The key is the one saved in ${ctx.store.file} (shard setup).`;
506
+ out(ctx, `Authenticated.\n${principalText(p)}${where} To sign in to your account instead: shard auth login.\n`, p);
341
507
  return 0;
342
508
  },
343
509
  async whoami(ctx) {
@@ -345,14 +511,54 @@ export const HANDLERS = {
345
511
  out(ctx, principalText(p), p);
346
512
  return 0;
347
513
  },
348
- version(ctx) {
349
- out(ctx, `shard ${CLI_VERSION} (@shardflux/sdk ${SDK_VERSION})\n`, { cli: CLI_VERSION, sdk: SDK_VERSION });
350
- return Promise.resolve(0);
514
+ async version(ctx, values) {
515
+ const update = await ctx.versionStatus(values.check === true);
516
+ const line = update === null && updateCheckDisabled(ctx.io.env) ? 'update check: off (SHARDFLUX_NO_UPDATE_CHECK or NO_UPDATE_NOTIFIER; shard version --check asks anyway)\n' : updateLine(update, ctx.distribution);
517
+ out(ctx, `shard ${CLI_VERSION} (@shardflux/sdk ${SDK_VERSION})\n${line}`, { cli: CLI_VERSION, sdk: SDK_VERSION, update });
518
+ return 0;
519
+ },
520
+ // Feedback (0.5.0+): POST /v1/feedback, emailed to the founder. Validated here first so a bad call costs no request.
521
+ async feedback(ctx, values, words) {
522
+ const category = oneOf(values, 'category', FEEDBACK_CATEGORIES);
523
+ const context = feedbackContext(ctx, values);
524
+ const message = await feedbackMessage(ctx, values, words);
525
+ const r = await feedbackSender(ctx).sendFeedback({ message, ...(category ? { category } : {}), context });
526
+ const human = r.duplicate
527
+ ? `Already received: the same message was sent in the last 24 hours (feedback ${r.id}); not emailed again.\n`
528
+ : `Sent to the Shardflux founder (feedback ${r.id}). Thank you.\n`;
529
+ out(ctx, human, { id: r.id, received_at: r.receivedAt, duplicate: r.duplicate });
530
+ return 0;
351
531
  },
352
532
  async usage(ctx, values) {
533
+ const views = [values.estimate === true, values.series === true, str(values.workspace) !== undefined].filter(Boolean).length;
534
+ if (views > 1)
535
+ throw new UsageError('use one of --estimate, --series and --workspace');
536
+ const seriesOnly = ['from', 'to', 'granularity', 'meter'].filter((k) => values[k] !== undefined);
537
+ if (seriesOnly.length > 0 && values.series !== true && str(values.workspace) === undefined) {
538
+ throw new UsageError(`--${seriesOnly[0]} needs --series or --workspace`);
539
+ }
540
+ const granularity = values.granularity === undefined ? undefined : oneOf(values, 'granularity', ['hour', 'day']);
541
+ const range = { from: str(values.from), to: str(values.to), granularity, meter: str(values.meter) };
542
+ const workspaceRef = str(values.workspace);
543
+ if (workspaceRef !== undefined) {
544
+ const ws = await resolveWorkspace(ctx, workspaceRef);
545
+ const series = await ctx.cloud().usage.workspace(ws.id, range);
546
+ out(ctx, usageSeriesText(series), series);
547
+ return 0;
548
+ }
353
549
  const org = str(values.org) ?? (await principal(ctx)).organization_id;
354
550
  if (!UUID.test(org))
355
551
  throw new UsageError(`--org must be an organization id (UUID)`);
552
+ if (values.estimate === true) {
553
+ const estimate = await ctx.cloud().usage.estimate(org);
554
+ out(ctx, usageEstimateText(estimate), estimate);
555
+ return 0;
556
+ }
557
+ if (values.series === true) {
558
+ const series = await ctx.cloud().usage.series(org, range);
559
+ out(ctx, usageSeriesText(series), series);
560
+ return 0;
561
+ }
356
562
  const summary = await ctx.cloud().request('GET', `/v1/organizations/${encodeURIComponent(org)}/usage/summary`);
357
563
  out(ctx, usageText(summary), summary);
358
564
  return 0;
@@ -364,6 +570,9 @@ export const HANDLERS = {
364
570
  const c = caps(values);
365
571
  const bind = secretNames(strs(values.secret), '--secret');
366
572
  const lifetime = oneOf(values, 'lifetime', LIFETIMES);
573
+ const mode = modeFlag(values);
574
+ if (mode === 'file_first' && lifetime === 'session')
575
+ throw new UsageError('a file-first workspace is persistent: --lifetime session is for processful workspaces');
367
576
  const inputs = await inputsFrom(ctx, values);
368
577
  const timeoutMs = waitTimeout(values);
369
578
  const cloud = ctx.cloud();
@@ -376,6 +585,7 @@ export const HANDLERS = {
376
585
  ...(bind.length ? { secrets: bind } : {}),
377
586
  ...(inputs ? { inputs } : {}),
378
587
  ...(lifetime ? { lifetime } : {}),
588
+ ...(mode ? { mode } : {}),
379
589
  agentLabel: ctx.agentLabel,
380
590
  wait: values.wait === false ? false : { timeoutMs, signal: ctx.signal },
381
591
  }));
@@ -438,16 +648,49 @@ export const HANDLERS = {
438
648
  const env = parseEnvPairs(values.env ?? []);
439
649
  const timeout = str(values.timeout);
440
650
  const timeoutMs = timeout === undefined ? undefined : parseDuration(timeout, 'timeout');
651
+ const executionId = str(values['execution-id']);
652
+ if (executionId !== undefined && !EXECUTION_ID.test(executionId))
653
+ throw new UsageError(`--execution-id must be ${EXECUTION_ID_TEXT} (got "${executionId}")`);
654
+ const limitText = str(values['output-limit']);
655
+ const outputLimit = limitText === undefined ? undefined : parsePositiveInt(limitText, 'output-limit', MAX_OUTPUT_LIMIT);
441
656
  const stdinSource = str(values.stdin);
442
657
  const stdin = stdinSource === undefined ? undefined : await readInput(ctx, stdinSource, MAX_STDIN, '--stdin');
443
658
  const ws = await resolveWorkspace(ctx, ref);
444
659
  const cell = cellOf(ctx, ws);
660
+ const cwd = str(values.cwd);
661
+ if (ws.mode === 'file_first') {
662
+ // An execution (contracts §29.8): answered when the command ended. The id is ours, so Ctrl-C can name it.
663
+ const id = executionId ?? newExecutionId();
664
+ let r;
665
+ try {
666
+ r = await cell.executions.run(argv, {
667
+ executionId: id,
668
+ ...(cwd !== undefined ? { cwd } : {}),
669
+ ...(Object.keys(env).length ? { env } : {}),
670
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
671
+ ...(stdin !== undefined ? { stdin } : {}),
672
+ ...(outputLimit !== undefined ? { outputLimitBytes: outputLimit } : {}),
673
+ signal: ctx.signal,
674
+ });
675
+ }
676
+ catch (err) {
677
+ if (ctx.signal.aborted) {
678
+ throw new InterruptedError(`Interrupted; execution ${id} keeps running server side if it started (an execution cannot be canceled).`, undefined, {
679
+ executionId: id,
680
+ hint: `fetch its result later with: shard executions get ${ref} ${id}`,
681
+ });
682
+ }
683
+ throw err;
684
+ }
685
+ return reportExecution(ctx, ref, r, { summary: values.verbose === true, timings: ctx.timing });
686
+ }
687
+ fileFirstOnly(ws, [...(executionId !== undefined ? ['--execution-id'] : []), ...(outputLimit !== undefined ? ['--output-limit'] : [])]);
445
688
  const sessionId = `cli-${randomUUID().replace(/-/g, '').slice(0, 24)}`;
446
689
  let r;
447
690
  try {
448
691
  r = await cell.exec.run(argv, {
449
692
  sessionId,
450
- ...(str(values.cwd) !== undefined ? { cwd: str(values.cwd) } : {}),
693
+ ...(cwd !== undefined ? { cwd } : {}),
451
694
  ...(Object.keys(env).length ? { env } : {}),
452
695
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
453
696
  ...(stdin !== undefined ? { stdin } : {}),
@@ -464,8 +707,30 @@ export const HANDLERS = {
464
707
  throw err;
465
708
  }
466
709
  const code = execExitCode(r);
710
+ // No exit code, no timeout, no signal: the session ended without the command's own ending (e.g. lost when its host
711
+ // restarted). Say so rather than exit 1 in silence (0.5.1); a command that could not start threw ExecStartError.
712
+ const unexplained = r.exitCode === null && !r.timedOut && !(r.termSignal !== null && r.termSignal > 0);
713
+ if (values.verbose === true && !ctx.json) {
714
+ const status = r.timedOut ? 'timed out' : r.canceled ? 'canceled' : r.termSignal ? `killed by signal ${r.termSignal}` : unexplained ? r.session.state : `exit code ${r.exitCode ?? '-'}`;
715
+ ctx.io.stderr.write(kv([['session', r.sessionId], ['status', status], ['output', `${r.stdoutBytes} bytes stdout, ${r.stderrBytes} bytes stderr`], ['reconnects', String(r.reconnects)]]));
716
+ }
467
717
  if (ctx.json) {
468
- 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 })));
718
+ ctx.io.stdout.write(json(withTiming(ctx, {
719
+ session_id: r.sessionId,
720
+ state: r.session.state,
721
+ exit_code: r.exitCode,
722
+ term_signal: r.termSignal,
723
+ timed_out: r.timedOut,
724
+ canceled: r.canceled,
725
+ stdout: r.stdout,
726
+ stderr: r.stderr,
727
+ truncated: r.truncated,
728
+ reconnects: r.reconnects,
729
+ ...(r.session.error ? { error: r.session.error } : {}),
730
+ })));
731
+ }
732
+ else if (unexplained) {
733
+ ctx.io.stderr.write(`exec session ${r.sessionId} ended ${r.session.state} without an exit code${r.session.error ? `: ${r.session.error}` : ''}\n`);
469
734
  }
470
735
  return code;
471
736
  },
@@ -486,10 +751,15 @@ export const HANDLERS = {
486
751
  return 0;
487
752
  },
488
753
  async 'files write'(ctx, values, [ref, path]) {
754
+ const ifRev = ifRevision(values);
489
755
  const data = await readInput(ctx, str(values.from) ?? '-', 1024 * 1024 * 1024, 'input');
490
756
  const ws = await resolveWorkspace(ctx, ref);
491
- const r = await cellOf(ctx, ws).files.write(path, data, { append: values.append === true, createParents: values.parents !== false });
492
- out(ctx, `wrote ${r.bytes_written} bytes to ${r.path}${r.sha256 ? ` (sha256 ${r.sha256})` : ''}\n`, r);
757
+ fileFirstOnly(ws, ifRev !== undefined ? ['--if-revision'] : []);
758
+ const cell = cellOf(ctx, ws);
759
+ const r = await cell.files.write(path, data, { append: values.append === true, createParents: values.parents !== false, ...(ifRev !== undefined ? { ifTreeRevision: ifRev } : {}) });
760
+ // File-first: the tree revision the write published (or the current one when nothing changed), to chain --if-revision.
761
+ const tree = treeRevisionAfter(ws, cell);
762
+ out(ctx, `wrote ${r.bytes_written} bytes to ${r.path}${r.sha256 ? ` (sha256 ${r.sha256})` : ''}${tree !== null ? `, tree revision ${tree}` : ''}\n`, tree !== null ? { ...r, tree_revision: tree } : r);
493
763
  return 0;
494
764
  },
495
765
  async 'files ls'(ctx, values, [ref, path]) {
@@ -499,11 +769,113 @@ export const HANDLERS = {
499
769
  out(ctx, `${fileTable(list)}${list.truncated ? '(truncated)\n' : ''}`, list);
500
770
  return 0;
501
771
  },
772
+ async 'files stat'(ctx, values, [ref, path]) {
773
+ const ws = await resolveWorkspace(ctx, ref);
774
+ const info = await cellOf(ctx, ws).files.stat(path, values.revision === true ? { revision: true } : {});
775
+ out(ctx, fileInfoText(info), info);
776
+ return 0;
777
+ },
778
+ async 'files search'(ctx, values, [ref, path, pattern]) {
779
+ const maxText = str(values.max);
780
+ const include = strs(values.include);
781
+ const exclude = strs(values.exclude);
782
+ for (const g of [...include, ...exclude])
783
+ if (g.length === 0 || g.length > 256)
784
+ throw new UsageError('--include/--exclude globs must be 1-256 characters');
785
+ if (include.length > 32 || exclude.length > 32)
786
+ throw new UsageError('at most 32 --include and 32 --exclude globs');
787
+ if (pattern.length === 0 || pattern.length > 1000)
788
+ throw new UsageError('<pattern> must be 1-1000 characters');
789
+ const maxMatches = maxText === undefined ? undefined : parsePositiveInt(maxText, 'max', 5000);
790
+ const ws = await resolveWorkspace(ctx, ref);
791
+ const r = await cellOf(ctx, ws).files.search(path, pattern, {
792
+ ...(values.regex === true ? { regex: true } : {}),
793
+ ...(values['ignore-case'] === true ? { caseInsensitive: true } : {}),
794
+ ...(include.length ? { include } : {}),
795
+ ...(exclude.length ? { exclude } : {}),
796
+ ...(maxMatches !== undefined ? { maxMatches } : {}),
797
+ signal: ctx.signal,
798
+ });
799
+ out(ctx, searchText(r), r);
800
+ if (!ctx.json && r.truncated) {
801
+ const at = r.stop_reason ? ` at ${STOPPED_AT[r.stop_reason] ?? r.stop_reason}` : '';
802
+ const raise = r.stop_reason === undefined || r.stop_reason === 'max_matches' ? ' or raise --max' : '';
803
+ ctx.io.stderr.write(`truncated: the search stopped${at} after ${r.matches.length} matches (${r.files_scanned} files scanned); narrow it${raise}\n`);
804
+ }
805
+ if (!ctx.json && r.served_from === 'disk')
806
+ ctx.io.stderr.write('note: searched the suspended workspace’s disk (its state at suspension)\n');
807
+ return 0;
808
+ },
809
+ async 'files patch'(ctx, values, [ref, path]) {
810
+ const olds = strs(values.old);
811
+ const news = strs(values.new);
812
+ const contentFile = str(values['content-file']);
813
+ const expected = str(values['expected-revision']);
814
+ if (contentFile !== undefined && (olds.length || news.length))
815
+ throw new UsageError('use either --old/--new or --content-file, not both');
816
+ if (contentFile === undefined && olds.length === 0)
817
+ throw new UsageError('give --old <text> --new <text> (repeatable), or --content-file <file|->');
818
+ if (olds.length !== news.length)
819
+ throw new UsageError(`every --old needs a --new (got ${olds.length} --old and ${news.length} --new)`);
820
+ if (olds.some((o) => o.length === 0))
821
+ throw new UsageError('--old must not be empty');
822
+ if (olds.length > 100)
823
+ throw new UsageError('at most 100 --old/--new pairs');
824
+ if (contentFile !== undefined && values['replace-all'] === true)
825
+ throw new UsageError('--replace-all applies to --old/--new, not --content-file');
826
+ if (expected !== undefined && !REVISION.test(expected))
827
+ throw new UsageError('--expected-revision must be a 64-character lowercase SHA-256 hex revision or "absent"');
828
+ const ifRev = ifRevision(values);
829
+ let content;
830
+ if (contentFile !== undefined) {
831
+ const bytes = await readInput(ctx, contentFile, MAX_PATCH_CONTENT, '--content-file');
832
+ try {
833
+ content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
834
+ }
835
+ catch {
836
+ throw new UsageError('--content-file must be UTF-8 text (files patch edits text; use files write for binary data)');
837
+ }
838
+ }
839
+ const ws = await resolveWorkspace(ctx, ref);
840
+ fileFirstOnly(ws, ifRev !== undefined ? ['--if-revision'] : [], ' (--expected-revision checks the file’s own revision in any workspace)');
841
+ const cell = cellOf(ctx, ws);
842
+ const common = { path: path, ...(expected !== undefined ? { expectedRevision: expected } : {}) };
843
+ const opts = { signal: ctx.signal, ...(ifRev !== undefined ? { ifTreeRevision: ifRev } : {}) };
844
+ const r = content !== undefined
845
+ ? await cell.files.patch({ ...common, content }, opts)
846
+ : await cell.files.patch({ ...common, edits: olds.map((o, i) => ({ oldText: o, newText: news[i], ...(values['replace-all'] === true ? { replaceAll: true } : {}) })) }, opts);
847
+ const what = r.replacements !== undefined ? `${r.replacements} replacement${r.replacements === 1 ? '' : 's'}` : `${r.bytes_written} bytes`;
848
+ const tree = treeRevisionAfter(ws, cell);
849
+ out(ctx, `patched ${r.path}: ${what}, revision ${r.revision}${tree !== null ? `, tree revision ${tree}` : ''}\n`, tree !== null ? { ...r, tree_revision: tree } : r);
850
+ return 0;
851
+ },
502
852
  async 'workspaces suspend'(ctx, values, [ref]) {
503
- return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => ctx.cloud().workspaces.suspend(ws.id, o));
853
+ const whenIdle = str(values['when-idle']);
854
+ const cancel = values['cancel-when-idle'] === true;
855
+ if (whenIdle === undefined && !cancel)
856
+ return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => ctx.cloud().workspaces.suspend(ws.id, o), ['suspend', 'Suspend']);
857
+ if (whenIdle !== undefined && cancel)
858
+ throw new UsageError('--when-idle and --cancel-when-idle cannot be combined');
859
+ const flag = cancel ? '--cancel-when-idle' : '--when-idle';
860
+ if (values.wait === true || str(values.timeout) !== undefined)
861
+ throw new UsageError(`${flag} cannot be combined with --wait or --timeout (nothing is suspended now)`);
862
+ if (cancel) {
863
+ const ws = await resolveWorkspace(ctx, ref);
864
+ const pending = ws.suspendRequest;
865
+ const after = await ctx.cloud().workspaces.cancelSuspendWhenIdle(ws.id);
866
+ out(ctx, pending ? `Canceled the pending suspend-when-idle request of ${ws.key} (${ws.id}): ${suspendRequestText(pending)}.\n` : `No suspend-when-idle request was pending for ${ws.key} (${ws.id}).\n`, after.data);
867
+ return 0;
868
+ }
869
+ const afterSeconds = whenIdleSeconds(whenIdle);
870
+ const ws = await resolveWorkspace(ctx, ref);
871
+ requireProcessful(ws, 'suspend_when_idle', 'Suspend when idle');
872
+ const r = await ctx.cloud().workspaces.suspendWhenIdle(ws.id, { afterSeconds });
873
+ out(ctx, suspendWhenIdleText(ws, { suspend_request: r.suspendRequest, operation: r.operation }), { workspace: r.workspace.data, operation: r.operation, suspend_request: r.suspendRequest });
874
+ return 0;
504
875
  },
505
876
  async 'workspaces resume'(ctx, values, [ref]) {
506
- return lifecycle(ctx, values, ref, 'Resuming', (ws, o) => ctx.cloud().workspaces.resume(ws.id, o));
877
+ // With --wait the resume is held until the workspace runs (contracts §22.6); its tool token is the CLI's label.
878
+ return lifecycle(ctx, values, ref, 'Resuming', (ws, o) => ctx.cloud().workspaces.resume(ws.id, { ...o, agentLabel: ctx.agentLabel }), ['resume', 'Resume']);
507
879
  },
508
880
  async 'workspaces delete'(ctx, values, [ref]) {
509
881
  if (values.yes !== true)
@@ -516,7 +888,7 @@ export const HANDLERS = {
516
888
  async 'workspaces reset'(ctx, values, [ref]) {
517
889
  if (values.yes !== true)
518
890
  throw new UsageError('refusing to reset without --yes (reset wipes every change in the workspace and restarts it on its template)');
519
- return lifecycle(ctx, values, ref, 'Resetting', (ws, o) => ctx.cloud().workspaces.reset(ws.id, o));
891
+ return lifecycle(ctx, values, ref, 'Resetting', (ws, o) => ctx.cloud().workspaces.reset(ws.id, o), ['reset', 'Reset']);
520
892
  },
521
893
  async 'workspaces save-as-template'(ctx, values, [ref]) {
522
894
  const template = str(values.template);
@@ -528,6 +900,7 @@ export const HANDLERS = {
528
900
  const defaults = saveDefaults(values);
529
901
  const ack = acknowledged(values);
530
902
  const ws = await resolveWorkspace(ctx, ref);
903
+ requireProcessful(ws, 'save_as_template', 'Saving as a template');
531
904
  const res = await ctx.cloud().workspaces.saveAsTemplate(ws.id, {
532
905
  templateSlug: template,
533
906
  ...(str(values['display-name']) !== undefined ? { displayName: str(values['display-name']) } : {}),
@@ -1004,6 +1377,7 @@ export const HANDLERS = {
1004
1377
  const c = caps(values);
1005
1378
  const timeoutMs = waitTimeout(values);
1006
1379
  const source = await resolveWorkspace(ctx, ref);
1380
+ requireProcessful(source, 'fork', 'A fork');
1007
1381
  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)));
1008
1382
  const op = res.operation;
1009
1383
  out(ctx, lifecycleText(`Forking ${source.key} into`, res.workspace, op), { operation: op, workspace: res.workspace.data });
@@ -1154,7 +1528,41 @@ export const HANDLERS = {
1154
1528
  out(ctx, operationDetail(op), op);
1155
1529
  return 0;
1156
1530
  },
1531
+ async 'executions get'(ctx, values, [ref, id]) {
1532
+ if (!EXECUTION_ID.test(id))
1533
+ throw new UsageError(`<execution-id> must be ${EXECUTION_ID_TEXT} (got "${id}")`);
1534
+ const waitText = str(values.wait);
1535
+ const waitMs = waitText === undefined ? 0 : parseDuration(waitText, 'wait');
1536
+ const ws = await resolveWorkspace(ctx, ref);
1537
+ let r;
1538
+ try {
1539
+ r = await cellOf(ctx, ws).executions.get(id, { waitMs, signal: ctx.signal });
1540
+ }
1541
+ catch (err) {
1542
+ if (ctx.signal.aborted)
1543
+ throw new InterruptedError(`Interrupted; execution ${id} continues server side.`, undefined, { executionId: id, hint: `fetch its result later with: shard executions get ${ref} ${id}` });
1544
+ throw err;
1545
+ }
1546
+ return reportExecution(ctx, ref, r, { summary: true, timings: false, fetched: true, ...(waitText !== undefined ? { waited: waitText } : {}) });
1547
+ },
1157
1548
  };
1549
+ /** The update line of `shard version`. */
1550
+ function updateLine(u, d) {
1551
+ const pkg = d.package === '@shardflux/cli' ? '' : ` (${d.package} ${d.version})`;
1552
+ if (!u)
1553
+ return `update check: not done yet${pkg} (shard version --check asks now)\n`;
1554
+ const when = u.checked_at ? `, checked ${u.checked_at.slice(0, 19).replace('T', ' ')}Z` : '';
1555
+ switch (u.status) {
1556
+ case 'current':
1557
+ return `up to date${pkg}: latest ${u.latest ?? '-'}${when}\n`;
1558
+ case 'outdated':
1559
+ return `update available${pkg}: ${u.latest} (you have ${u.current}). Update: ${u.upgrade_command ?? '-'}\n`;
1560
+ case 'unsupported':
1561
+ return `no longer supported${pkg}: the API needs ${u.minimum_supported} or later (latest ${u.latest ?? '-'}). Update: ${u.upgrade_command ?? '-'}\n`;
1562
+ default:
1563
+ return `update status unknown${pkg}${when} (the API could not be asked, or has no release of ${u.package} yet)\n`;
1564
+ }
1565
+ }
1158
1566
  /** `--org <id>`: organization scope; null: the API key's project. */
1159
1567
  function scopeFlag(values) {
1160
1568
  const org = str(values.org);
@@ -1262,9 +1670,11 @@ async function resolveSecret(ctx, values, ref) {
1262
1670
  function lifecycleOptions(ctx, values, timeoutMs) {
1263
1671
  return values.wait === true ? { wait: { timeoutMs, signal: ctx.signal } } : {};
1264
1672
  }
1265
- async function lifecycle(ctx, values, ref, verb, start) {
1673
+ async function lifecycle(ctx, values, ref, verb, start, processfulOnly) {
1266
1674
  const timeoutMs = waitTimeout(values);
1267
1675
  const ws = await resolveWorkspace(ctx, ref);
1676
+ if (processfulOnly)
1677
+ requireProcessful(ws, processfulOnly[0], processfulOnly[1]);
1268
1678
  const op = await waited(ctx, 'the operation continues server side.', () => start(ws, lifecycleOptions(ctx, values, timeoutMs)));
1269
1679
  out(ctx, lifecycleText(verb, ws, op), op);
1270
1680
  return 0;