@shardflux/cli 0.4.1 → 0.5.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
@@ -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, 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,7 @@ 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
+ export function waitTimeout(values) {
61
70
  const t = str(values.timeout);
62
71
  return t === undefined ? DEFAULT_WAIT_MS : parseDuration(t, 'timeout');
63
72
  }
@@ -96,7 +105,7 @@ const PURPOSE_FILTERS = ['standard', 'template_draft', 'template_test', 'any'];
96
105
  const DIFF_CHANGES = ['added', 'removed', 'changed', 'type_changed', 'metadata'];
97
106
  const OWNERS = ['platform', 'organization'];
98
107
  const DEFAULT_BUILD_WAIT_MS = 1_800_000;
99
- function oneOf(values, option, allowed) {
108
+ export function oneOf(values, option, allowed) {
100
109
  const v = str(values[option]);
101
110
  if (v === undefined)
102
111
  return undefined;
@@ -104,7 +113,7 @@ function oneOf(values, option, allowed) {
104
113
  throw new UsageError(`--${option} must be one of ${allowed.join(', ')} (got "${v}")`);
105
114
  return v;
106
115
  }
107
- function listLimit(values, max) {
116
+ export function listLimit(values, max) {
108
117
  const t = str(values.limit);
109
118
  return t === undefined ? undefined : parsePositiveInt(t, 'limit', max);
110
119
  }
@@ -160,7 +169,7 @@ function parseVersion(text, what) {
160
169
  }
161
170
  const SLUG = /^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9])?$/;
162
171
  /** `<slug>@<version>`. */
163
- function parseSlugVersion(text, what) {
172
+ export function parseSlugVersion(text, what) {
164
173
  const at = text.lastIndexOf('@');
165
174
  if (at <= 0)
166
175
  throw new UsageError(`${what} must be <slug>@<version>, e.g. acme-dev@3 (got "${text}")`);
@@ -230,7 +239,7 @@ const BUILD_STATES = ['queued', 'building', 'testing', 'publishing', 'published'
230
239
  function buildExitCode(b) {
231
240
  return b.state === 'failed' || b.state === 'canceled' || b.registration.state === 'failed' ? 6 : 0;
232
241
  }
233
- async function fileExists(path) {
242
+ export async function fileExists(path) {
234
243
  return access(path).then(() => true, () => false);
235
244
  }
236
245
  /** Writes `text` to `path`, refusing to overwrite an existing file without --force. */
@@ -283,7 +292,7 @@ async function readAll(stream, max, what) {
283
292
  }
284
293
  return new Uint8Array(Buffer.concat(chunks));
285
294
  }
286
- async function readInput(ctx, source, max, what) {
295
+ export async function readInput(ctx, source, max, what) {
287
296
  if (source === '-') {
288
297
  if (ctx.io.stdin.isTTY)
289
298
  throw new UsageError(`${what}: standard input is a terminal; pipe data in or pass a file`);
@@ -296,7 +305,7 @@ async function readInput(ctx, source, max, what) {
296
305
  throw new UsageError(`${what} exceeds ${max} bytes`);
297
306
  return new Uint8Array(data);
298
307
  }
299
- async function principal(ctx) {
308
+ export async function principal(ctx) {
300
309
  const me = await ctx.cloud().me();
301
310
  if (me.type !== 'api_key' || !me.api_key)
302
311
  throw new UsageError('SHARDFLUX_API_KEY did not authenticate as an API key');
@@ -317,6 +326,72 @@ function principalText(p) {
317
326
  ['tools', p.tool_permissions.length ? p.tool_permissions.join(', ') : '(none: read-only key)'],
318
327
  ]);
319
328
  }
329
+ /**
330
+ * Who sends the feedback: the project API key when one is configured (SHARDFLUX_API_KEY or saved by shard setup),
331
+ * else the signed-in session (SHARDFLUX_SESSION_TOKEN or saved by shard auth login), so an agent that is still setting
332
+ * up can report what got in its way. With neither, the key's own error (its hint names both ways, plus email).
333
+ */
334
+ function feedbackSender(ctx) {
335
+ const keyConfigured = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || ctx.store.profile(ctx.apiUrl).api_key !== undefined;
336
+ const sessionConfigured = Boolean(ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim()) || ctx.store.profile(ctx.apiUrl).session !== undefined;
337
+ return keyConfigured || !sessionConfigured ? ctx.cloud() : ctx.account();
338
+ }
339
+ /** Contract bounds of the feedback context fields shard sends (characters). */
340
+ const FEEDBACK_CONTEXT_MAX = { workspace: 200, 'request-id': 200, 'error-code': 100, command: 2000 };
341
+ /**
342
+ * The feedback message: the positional words joined with spaces, or --from-file (a file, or - for standard input),
343
+ * trimmed, 1-8000 characters (the API's bound, counted in UTF-16 units so nothing the API would refuse is sent).
344
+ */
345
+ async function feedbackMessage(ctx, values, words) {
346
+ const file = str(values['from-file']);
347
+ if (file !== undefined && words.length > 0)
348
+ throw new UsageError('give the message as arguments or with --from-file, not both');
349
+ let text;
350
+ if (file !== undefined) {
351
+ const bytes = await readInput(ctx, file, MAX_STDIN, '--from-file');
352
+ try {
353
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
354
+ }
355
+ catch {
356
+ throw new UsageError('--from-file: the message must be UTF-8 text');
357
+ }
358
+ }
359
+ else if (words.length > 0) {
360
+ text = words.join(' ');
361
+ }
362
+ else {
363
+ throw new UsageError('missing <message> for "shard feedback": shard feedback "what happened and what you expected" (or --from-file <file|->)');
364
+ }
365
+ text = text.trim();
366
+ if (text.length === 0)
367
+ throw new UsageError('the feedback message is empty');
368
+ if (text.length > FEEDBACK_MESSAGE_MAX_LENGTH) {
369
+ 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)`);
370
+ }
371
+ return text;
372
+ }
373
+ /** The context shard sends: its own client, the agent label when one is set, and the given references. */
374
+ function feedbackContext(ctx, values) {
375
+ const c = { client: `shard-cli/${CLI_VERSION}` };
376
+ if (ctx.agentLabel !== 'cli')
377
+ c.agent = ctx.agentLabel;
378
+ for (const [option, max] of Object.entries(FEEDBACK_CONTEXT_MAX)) {
379
+ const v = str(values[option])?.trim();
380
+ if (v === undefined)
381
+ continue;
382
+ if (v.length === 0 || v.length > max)
383
+ throw new UsageError(`--${option} must be 1-${max} characters`);
384
+ if (option === 'workspace')
385
+ c.workspace = v;
386
+ else if (option === 'request-id')
387
+ c.requestId = v;
388
+ else if (option === 'error-code')
389
+ c.errorCode = v;
390
+ else
391
+ c.command = v;
392
+ }
393
+ return c;
394
+ }
320
395
  /** The exit code `shard exec` returns for a finished command. */
321
396
  export function execExitCode(r) {
322
397
  if (r.timedOut)
@@ -330,14 +405,95 @@ export function execExitCode(r) {
330
405
  async function bestEffortCancel(cell, sessionId) {
331
406
  await Promise.race([cell.exec.cancel(sessionId).catch(() => undefined), new Promise((r) => setTimeout(r, 3_000).unref())]);
332
407
  }
408
+ // ---- File-first workspaces (0.5.0; contracts §29) ----------------------------------------------------------------------
409
+ const MODES = { processful: 'processful', 'file-first': 'file_first', file_first: 'file_first' };
410
+ /** `--mode processful|file-first` (`file_first` too). */
411
+ function modeFlag(values) {
412
+ const v = str(values.mode);
413
+ if (v === undefined)
414
+ return undefined;
415
+ const m = MODES[v];
416
+ if (!m)
417
+ throw new UsageError(`--mode must be processful or file-first (got "${v}")`);
418
+ return m;
419
+ }
420
+ const EXECUTION_ID_TEXT = '8-128 characters of A-Z a-z 0-9 . _ : -, starting with a letter or digit';
421
+ /** `--if-revision <n>`: a tree revision, a non-negative integer (0 is the empty tree a file-first workspace starts with). */
422
+ function ifRevision(values) {
423
+ const t = str(values['if-revision']);
424
+ if (t === undefined)
425
+ return undefined;
426
+ if (!/^\d{1,15}$/.test(t.trim()))
427
+ throw new UsageError(`--if-revision must be a tree revision, a non-negative integer (got "${t}")`);
428
+ return Number(t.trim());
429
+ }
430
+ /** Options that exist only for file-first workspaces are a usage error on a processful one (known once resolved). */
431
+ function fileFirstOnly(ws, flags, note = '') {
432
+ if (flags.length === 0 || ws.mode === 'file_first')
433
+ return;
434
+ throw new UsageError(`${flags.join(' and ')} ${flags.length > 1 ? 'apply' : 'applies'} to file-first workspaces only; ${ws.key} is ${modeText(ws.mode)}${note}`);
435
+ }
436
+ /** The tree revision after a mutating files call on a file-first workspace (the newest its responses reported); else null. */
437
+ function treeRevisionAfter(ws, cell) {
438
+ if (ws.mode !== 'file_first')
439
+ return null;
440
+ return cell.treeRevision ?? ws.treeRevision;
441
+ }
442
+ /**
443
+ * A lifecycle call a file-first workspace does not have (contracts §29.7: suspend, resume, fork, reset,
444
+ * save-as-template), refused before any request as the SDK's workspace handle does: NotSupportedForModeError, printed
445
+ * with its hint like the API's own refusal.
446
+ */
447
+ function requireProcessful(ws, operation, what) {
448
+ if (ws.mode !== 'file_first')
449
+ return;
450
+ 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}).`);
451
+ }
452
+ /**
453
+ * Prints an execution's result and returns the exit code. Human: the command's stdout and stderr bytes as they are
454
+ * (binary safe), a note when output was cut at the output limit, and with `summary` what ran (stderr; see
455
+ * executionSummary). --json: one document (executionJson). The exit code is the command's once it succeeded (like a
456
+ * processful exec: 124 timed out, 128+N killed by signal N); a failed or lost execution throws ExecutionFailedError
457
+ * (exit 6), one still queued or running ExecutionPendingError (exit 5, naming the command that waits for it).
458
+ */
459
+ function reportExecution(ctx, ref, r, opts) {
460
+ if (ctx.json)
461
+ ctx.io.stdout.write(json(withTiming(ctx, executionJson(r))));
462
+ else if (!r.pending) {
463
+ if (r.stdout.length)
464
+ ctx.io.stdout.write(r.stdout);
465
+ if (r.stderr.length)
466
+ ctx.io.stderr.write(r.stderr);
467
+ const cut = [r.stdoutTruncated ? 'stdout' : '', r.stderrTruncated ? 'stderr' : ''].filter(Boolean).join(' and ');
468
+ if (opts.summary)
469
+ ctx.io.stderr.write(executionSummary(r, undefined, opts.fetched ? { replayed: false } : {}));
470
+ else {
471
+ if (cut)
472
+ ctx.io.stderr.write(`truncated: ${cut} reached the output limit (--output-limit, default 1 MiB, at most 16 MiB)\n`);
473
+ if (opts.timings && Object.keys(r.timings).length)
474
+ ctx.io.stderr.write(`execution ${r.executionId} timings: ${pairs(r.timings)}\n`);
475
+ }
476
+ }
477
+ if (r.pending) {
478
+ const when = opts.waited ? `still ${r.state} after ${opts.waited}` : `${r.state} (on tree revision ${r.baseRevision})`;
479
+ 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`);
480
+ }
481
+ if (r.state === 'failed' || r.state === 'lost')
482
+ throw new ExecutionFailedError(r);
483
+ return execExitCode(r);
484
+ }
333
485
  function lifecycleText(verb, w, op) {
334
486
  const finished = op.state === 'succeeded' || op.state === 'failed' || op.state === 'canceled';
335
487
  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
488
  }
337
- export const HANDLERS = {
489
+ /** The project-key commands; handlers.ts adds the account plane's. */
490
+ export const KEY_HANDLERS = {
338
491
  async login(ctx) {
339
492
  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);
493
+ const where = ctx.apiKey().source === 'env'
494
+ ? 'The key comes from SHARDFLUX_API_KEY; shard stores nothing for it.'
495
+ : `The key is the one saved in ${ctx.store.file} (shard setup).`;
496
+ out(ctx, `Authenticated.\n${principalText(p)}${where} To sign in to your account instead: shard auth login.\n`, p);
341
497
  return 0;
342
498
  },
343
499
  async whoami(ctx) {
@@ -345,14 +501,54 @@ export const HANDLERS = {
345
501
  out(ctx, principalText(p), p);
346
502
  return 0;
347
503
  },
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);
504
+ async version(ctx, values) {
505
+ const update = await ctx.versionStatus(values.check === true);
506
+ 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);
507
+ out(ctx, `shard ${CLI_VERSION} (@shardflux/sdk ${SDK_VERSION})\n${line}`, { cli: CLI_VERSION, sdk: SDK_VERSION, update });
508
+ return 0;
509
+ },
510
+ // Feedback (0.5.0+): POST /v1/feedback, emailed to the founder. Validated here first so a bad call costs no request.
511
+ async feedback(ctx, values, words) {
512
+ const category = oneOf(values, 'category', FEEDBACK_CATEGORIES);
513
+ const context = feedbackContext(ctx, values);
514
+ const message = await feedbackMessage(ctx, values, words);
515
+ const r = await feedbackSender(ctx).sendFeedback({ message, ...(category ? { category } : {}), context });
516
+ const human = r.duplicate
517
+ ? `Already received: the same message was sent in the last 24 hours (feedback ${r.id}); not emailed again.\n`
518
+ : `Sent to the Shardflux founder (feedback ${r.id}). Thank you.\n`;
519
+ out(ctx, human, { id: r.id, received_at: r.receivedAt, duplicate: r.duplicate });
520
+ return 0;
351
521
  },
352
522
  async usage(ctx, values) {
523
+ const views = [values.estimate === true, values.series === true, str(values.workspace) !== undefined].filter(Boolean).length;
524
+ if (views > 1)
525
+ throw new UsageError('use one of --estimate, --series and --workspace');
526
+ const seriesOnly = ['from', 'to', 'granularity', 'meter'].filter((k) => values[k] !== undefined);
527
+ if (seriesOnly.length > 0 && values.series !== true && str(values.workspace) === undefined) {
528
+ throw new UsageError(`--${seriesOnly[0]} needs --series or --workspace`);
529
+ }
530
+ const granularity = values.granularity === undefined ? undefined : oneOf(values, 'granularity', ['hour', 'day']);
531
+ const range = { from: str(values.from), to: str(values.to), granularity, meter: str(values.meter) };
532
+ const workspaceRef = str(values.workspace);
533
+ if (workspaceRef !== undefined) {
534
+ const ws = await resolveWorkspace(ctx, workspaceRef);
535
+ const series = await ctx.cloud().usage.workspace(ws.id, range);
536
+ out(ctx, usageSeriesText(series), series);
537
+ return 0;
538
+ }
353
539
  const org = str(values.org) ?? (await principal(ctx)).organization_id;
354
540
  if (!UUID.test(org))
355
541
  throw new UsageError(`--org must be an organization id (UUID)`);
542
+ if (values.estimate === true) {
543
+ const estimate = await ctx.cloud().usage.estimate(org);
544
+ out(ctx, usageEstimateText(estimate), estimate);
545
+ return 0;
546
+ }
547
+ if (values.series === true) {
548
+ const series = await ctx.cloud().usage.series(org, range);
549
+ out(ctx, usageSeriesText(series), series);
550
+ return 0;
551
+ }
356
552
  const summary = await ctx.cloud().request('GET', `/v1/organizations/${encodeURIComponent(org)}/usage/summary`);
357
553
  out(ctx, usageText(summary), summary);
358
554
  return 0;
@@ -364,6 +560,9 @@ export const HANDLERS = {
364
560
  const c = caps(values);
365
561
  const bind = secretNames(strs(values.secret), '--secret');
366
562
  const lifetime = oneOf(values, 'lifetime', LIFETIMES);
563
+ const mode = modeFlag(values);
564
+ if (mode === 'file_first' && lifetime === 'session')
565
+ throw new UsageError('a file-first workspace is persistent: --lifetime session is for processful workspaces');
367
566
  const inputs = await inputsFrom(ctx, values);
368
567
  const timeoutMs = waitTimeout(values);
369
568
  const cloud = ctx.cloud();
@@ -376,6 +575,7 @@ export const HANDLERS = {
376
575
  ...(bind.length ? { secrets: bind } : {}),
377
576
  ...(inputs ? { inputs } : {}),
378
577
  ...(lifetime ? { lifetime } : {}),
578
+ ...(mode ? { mode } : {}),
379
579
  agentLabel: ctx.agentLabel,
380
580
  wait: values.wait === false ? false : { timeoutMs, signal: ctx.signal },
381
581
  }));
@@ -438,16 +638,49 @@ export const HANDLERS = {
438
638
  const env = parseEnvPairs(values.env ?? []);
439
639
  const timeout = str(values.timeout);
440
640
  const timeoutMs = timeout === undefined ? undefined : parseDuration(timeout, 'timeout');
641
+ const executionId = str(values['execution-id']);
642
+ if (executionId !== undefined && !EXECUTION_ID.test(executionId))
643
+ throw new UsageError(`--execution-id must be ${EXECUTION_ID_TEXT} (got "${executionId}")`);
644
+ const limitText = str(values['output-limit']);
645
+ const outputLimit = limitText === undefined ? undefined : parsePositiveInt(limitText, 'output-limit', MAX_OUTPUT_LIMIT);
441
646
  const stdinSource = str(values.stdin);
442
647
  const stdin = stdinSource === undefined ? undefined : await readInput(ctx, stdinSource, MAX_STDIN, '--stdin');
443
648
  const ws = await resolveWorkspace(ctx, ref);
444
649
  const cell = cellOf(ctx, ws);
650
+ const cwd = str(values.cwd);
651
+ if (ws.mode === 'file_first') {
652
+ // An execution (contracts §29.8): answered when the command ended. The id is ours, so Ctrl-C can name it.
653
+ const id = executionId ?? newExecutionId();
654
+ let r;
655
+ try {
656
+ r = await cell.executions.run(argv, {
657
+ executionId: id,
658
+ ...(cwd !== undefined ? { cwd } : {}),
659
+ ...(Object.keys(env).length ? { env } : {}),
660
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
661
+ ...(stdin !== undefined ? { stdin } : {}),
662
+ ...(outputLimit !== undefined ? { outputLimitBytes: outputLimit } : {}),
663
+ signal: ctx.signal,
664
+ });
665
+ }
666
+ catch (err) {
667
+ if (ctx.signal.aborted) {
668
+ throw new InterruptedError(`Interrupted; execution ${id} keeps running server side if it started (an execution cannot be canceled).`, undefined, {
669
+ executionId: id,
670
+ hint: `fetch its result later with: shard executions get ${ref} ${id}`,
671
+ });
672
+ }
673
+ throw err;
674
+ }
675
+ return reportExecution(ctx, ref, r, { summary: values.verbose === true, timings: ctx.timing });
676
+ }
677
+ fileFirstOnly(ws, [...(executionId !== undefined ? ['--execution-id'] : []), ...(outputLimit !== undefined ? ['--output-limit'] : [])]);
445
678
  const sessionId = `cli-${randomUUID().replace(/-/g, '').slice(0, 24)}`;
446
679
  let r;
447
680
  try {
448
681
  r = await cell.exec.run(argv, {
449
682
  sessionId,
450
- ...(str(values.cwd) !== undefined ? { cwd: str(values.cwd) } : {}),
683
+ ...(cwd !== undefined ? { cwd } : {}),
451
684
  ...(Object.keys(env).length ? { env } : {}),
452
685
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
453
686
  ...(stdin !== undefined ? { stdin } : {}),
@@ -464,6 +697,10 @@ export const HANDLERS = {
464
697
  throw err;
465
698
  }
466
699
  const code = execExitCode(r);
700
+ if (values.verbose === true && !ctx.json) {
701
+ const status = r.timedOut ? 'timed out' : r.canceled ? 'canceled' : r.termSignal ? `killed by signal ${r.termSignal}` : `exit code ${r.exitCode ?? '-'}`;
702
+ ctx.io.stderr.write(kv([['session', r.sessionId], ['status', status], ['output', `${r.stdoutBytes} bytes stdout, ${r.stderrBytes} bytes stderr`], ['reconnects', String(r.reconnects)]]));
703
+ }
467
704
  if (ctx.json) {
468
705
  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 })));
469
706
  }
@@ -486,10 +723,15 @@ export const HANDLERS = {
486
723
  return 0;
487
724
  },
488
725
  async 'files write'(ctx, values, [ref, path]) {
726
+ const ifRev = ifRevision(values);
489
727
  const data = await readInput(ctx, str(values.from) ?? '-', 1024 * 1024 * 1024, 'input');
490
728
  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);
729
+ fileFirstOnly(ws, ifRev !== undefined ? ['--if-revision'] : []);
730
+ const cell = cellOf(ctx, ws);
731
+ const r = await cell.files.write(path, data, { append: values.append === true, createParents: values.parents !== false, ...(ifRev !== undefined ? { ifTreeRevision: ifRev } : {}) });
732
+ // File-first: the tree revision the write published (or the current one when nothing changed), to chain --if-revision.
733
+ const tree = treeRevisionAfter(ws, cell);
734
+ 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
735
  return 0;
494
736
  },
495
737
  async 'files ls'(ctx, values, [ref, path]) {
@@ -499,11 +741,92 @@ export const HANDLERS = {
499
741
  out(ctx, `${fileTable(list)}${list.truncated ? '(truncated)\n' : ''}`, list);
500
742
  return 0;
501
743
  },
744
+ async 'files stat'(ctx, values, [ref, path]) {
745
+ const ws = await resolveWorkspace(ctx, ref);
746
+ const info = await cellOf(ctx, ws).files.stat(path, values.revision === true ? { revision: true } : {});
747
+ out(ctx, fileInfoText(info), info);
748
+ return 0;
749
+ },
750
+ async 'files search'(ctx, values, [ref, path, pattern]) {
751
+ const maxText = str(values.max);
752
+ const include = strs(values.include);
753
+ const exclude = strs(values.exclude);
754
+ for (const g of [...include, ...exclude])
755
+ if (g.length === 0 || g.length > 256)
756
+ throw new UsageError('--include/--exclude globs must be 1-256 characters');
757
+ if (include.length > 32 || exclude.length > 32)
758
+ throw new UsageError('at most 32 --include and 32 --exclude globs');
759
+ if (pattern.length === 0 || pattern.length > 1000)
760
+ throw new UsageError('<pattern> must be 1-1000 characters');
761
+ const maxMatches = maxText === undefined ? undefined : parsePositiveInt(maxText, 'max', 5000);
762
+ const ws = await resolveWorkspace(ctx, ref);
763
+ const r = await cellOf(ctx, ws).files.search(path, pattern, {
764
+ ...(values.regex === true ? { regex: true } : {}),
765
+ ...(values['ignore-case'] === true ? { caseInsensitive: true } : {}),
766
+ ...(include.length ? { include } : {}),
767
+ ...(exclude.length ? { exclude } : {}),
768
+ ...(maxMatches !== undefined ? { maxMatches } : {}),
769
+ signal: ctx.signal,
770
+ });
771
+ out(ctx, searchText(r), r);
772
+ if (!ctx.json && r.truncated) {
773
+ const at = r.stop_reason ? ` at ${STOPPED_AT[r.stop_reason] ?? r.stop_reason}` : '';
774
+ const raise = r.stop_reason === undefined || r.stop_reason === 'max_matches' ? ' or raise --max' : '';
775
+ ctx.io.stderr.write(`truncated: the search stopped${at} after ${r.matches.length} matches (${r.files_scanned} files scanned); narrow it${raise}\n`);
776
+ }
777
+ if (!ctx.json && r.served_from === 'disk')
778
+ ctx.io.stderr.write('note: searched the suspended workspace’s disk (its state at suspension)\n');
779
+ return 0;
780
+ },
781
+ async 'files patch'(ctx, values, [ref, path]) {
782
+ const olds = strs(values.old);
783
+ const news = strs(values.new);
784
+ const contentFile = str(values['content-file']);
785
+ const expected = str(values['expected-revision']);
786
+ if (contentFile !== undefined && (olds.length || news.length))
787
+ throw new UsageError('use either --old/--new or --content-file, not both');
788
+ if (contentFile === undefined && olds.length === 0)
789
+ throw new UsageError('give --old <text> --new <text> (repeatable), or --content-file <file|->');
790
+ if (olds.length !== news.length)
791
+ throw new UsageError(`every --old needs a --new (got ${olds.length} --old and ${news.length} --new)`);
792
+ if (olds.some((o) => o.length === 0))
793
+ throw new UsageError('--old must not be empty');
794
+ if (olds.length > 100)
795
+ throw new UsageError('at most 100 --old/--new pairs');
796
+ if (contentFile !== undefined && values['replace-all'] === true)
797
+ throw new UsageError('--replace-all applies to --old/--new, not --content-file');
798
+ if (expected !== undefined && !REVISION.test(expected))
799
+ throw new UsageError('--expected-revision must be a 64-character lowercase SHA-256 hex revision or "absent"');
800
+ const ifRev = ifRevision(values);
801
+ let content;
802
+ if (contentFile !== undefined) {
803
+ const bytes = await readInput(ctx, contentFile, MAX_PATCH_CONTENT, '--content-file');
804
+ try {
805
+ content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
806
+ }
807
+ catch {
808
+ throw new UsageError('--content-file must be UTF-8 text (files patch edits text; use files write for binary data)');
809
+ }
810
+ }
811
+ const ws = await resolveWorkspace(ctx, ref);
812
+ fileFirstOnly(ws, ifRev !== undefined ? ['--if-revision'] : [], ' (--expected-revision checks the file’s own revision in any workspace)');
813
+ const cell = cellOf(ctx, ws);
814
+ const common = { path: path, ...(expected !== undefined ? { expectedRevision: expected } : {}) };
815
+ const opts = { signal: ctx.signal, ...(ifRev !== undefined ? { ifTreeRevision: ifRev } : {}) };
816
+ const r = content !== undefined
817
+ ? await cell.files.patch({ ...common, content }, opts)
818
+ : await cell.files.patch({ ...common, edits: olds.map((o, i) => ({ oldText: o, newText: news[i], ...(values['replace-all'] === true ? { replaceAll: true } : {}) })) }, opts);
819
+ const what = r.replacements !== undefined ? `${r.replacements} replacement${r.replacements === 1 ? '' : 's'}` : `${r.bytes_written} bytes`;
820
+ const tree = treeRevisionAfter(ws, cell);
821
+ out(ctx, `patched ${r.path}: ${what}, revision ${r.revision}${tree !== null ? `, tree revision ${tree}` : ''}\n`, tree !== null ? { ...r, tree_revision: tree } : r);
822
+ return 0;
823
+ },
502
824
  async 'workspaces suspend'(ctx, values, [ref]) {
503
- return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => ctx.cloud().workspaces.suspend(ws.id, o));
825
+ return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => ctx.cloud().workspaces.suspend(ws.id, o), ['suspend', 'Suspend']);
504
826
  },
505
827
  async 'workspaces resume'(ctx, values, [ref]) {
506
- return lifecycle(ctx, values, ref, 'Resuming', (ws, o) => ctx.cloud().workspaces.resume(ws.id, o));
828
+ // With --wait the resume is held until the workspace runs (contracts §22.6); its tool token is the CLI's label.
829
+ return lifecycle(ctx, values, ref, 'Resuming', (ws, o) => ctx.cloud().workspaces.resume(ws.id, { ...o, agentLabel: ctx.agentLabel }), ['resume', 'Resume']);
507
830
  },
508
831
  async 'workspaces delete'(ctx, values, [ref]) {
509
832
  if (values.yes !== true)
@@ -516,7 +839,7 @@ export const HANDLERS = {
516
839
  async 'workspaces reset'(ctx, values, [ref]) {
517
840
  if (values.yes !== true)
518
841
  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));
842
+ return lifecycle(ctx, values, ref, 'Resetting', (ws, o) => ctx.cloud().workspaces.reset(ws.id, o), ['reset', 'Reset']);
520
843
  },
521
844
  async 'workspaces save-as-template'(ctx, values, [ref]) {
522
845
  const template = str(values.template);
@@ -528,6 +851,7 @@ export const HANDLERS = {
528
851
  const defaults = saveDefaults(values);
529
852
  const ack = acknowledged(values);
530
853
  const ws = await resolveWorkspace(ctx, ref);
854
+ requireProcessful(ws, 'save_as_template', 'Saving as a template');
531
855
  const res = await ctx.cloud().workspaces.saveAsTemplate(ws.id, {
532
856
  templateSlug: template,
533
857
  ...(str(values['display-name']) !== undefined ? { displayName: str(values['display-name']) } : {}),
@@ -1004,6 +1328,7 @@ export const HANDLERS = {
1004
1328
  const c = caps(values);
1005
1329
  const timeoutMs = waitTimeout(values);
1006
1330
  const source = await resolveWorkspace(ctx, ref);
1331
+ requireProcessful(source, 'fork', 'A fork');
1007
1332
  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
1333
  const op = res.operation;
1009
1334
  out(ctx, lifecycleText(`Forking ${source.key} into`, res.workspace, op), { operation: op, workspace: res.workspace.data });
@@ -1154,7 +1479,41 @@ export const HANDLERS = {
1154
1479
  out(ctx, operationDetail(op), op);
1155
1480
  return 0;
1156
1481
  },
1482
+ async 'executions get'(ctx, values, [ref, id]) {
1483
+ if (!EXECUTION_ID.test(id))
1484
+ throw new UsageError(`<execution-id> must be ${EXECUTION_ID_TEXT} (got "${id}")`);
1485
+ const waitText = str(values.wait);
1486
+ const waitMs = waitText === undefined ? 0 : parseDuration(waitText, 'wait');
1487
+ const ws = await resolveWorkspace(ctx, ref);
1488
+ let r;
1489
+ try {
1490
+ r = await cellOf(ctx, ws).executions.get(id, { waitMs, signal: ctx.signal });
1491
+ }
1492
+ catch (err) {
1493
+ if (ctx.signal.aborted)
1494
+ throw new InterruptedError(`Interrupted; execution ${id} continues server side.`, undefined, { executionId: id, hint: `fetch its result later with: shard executions get ${ref} ${id}` });
1495
+ throw err;
1496
+ }
1497
+ return reportExecution(ctx, ref, r, { summary: true, timings: false, fetched: true, ...(waitText !== undefined ? { waited: waitText } : {}) });
1498
+ },
1157
1499
  };
1500
+ /** The update line of `shard version`. */
1501
+ function updateLine(u, d) {
1502
+ const pkg = d.package === '@shardflux/cli' ? '' : ` (${d.package} ${d.version})`;
1503
+ if (!u)
1504
+ return `update check: not done yet${pkg} (shard version --check asks now)\n`;
1505
+ const when = u.checked_at ? `, checked ${u.checked_at.slice(0, 19).replace('T', ' ')}Z` : '';
1506
+ switch (u.status) {
1507
+ case 'current':
1508
+ return `up to date${pkg}: latest ${u.latest ?? '-'}${when}\n`;
1509
+ case 'outdated':
1510
+ return `update available${pkg}: ${u.latest} (you have ${u.current}). Update: ${u.upgrade_command ?? '-'}\n`;
1511
+ case 'unsupported':
1512
+ return `no longer supported${pkg}: the API needs ${u.minimum_supported} or later (latest ${u.latest ?? '-'}). Update: ${u.upgrade_command ?? '-'}\n`;
1513
+ default:
1514
+ return `update status unknown${pkg}${when} (the API could not be asked, or has no release of ${u.package} yet)\n`;
1515
+ }
1516
+ }
1158
1517
  /** `--org <id>`: organization scope; null: the API key's project. */
1159
1518
  function scopeFlag(values) {
1160
1519
  const org = str(values.org);
@@ -1262,9 +1621,11 @@ async function resolveSecret(ctx, values, ref) {
1262
1621
  function lifecycleOptions(ctx, values, timeoutMs) {
1263
1622
  return values.wait === true ? { wait: { timeoutMs, signal: ctx.signal } } : {};
1264
1623
  }
1265
- async function lifecycle(ctx, values, ref, verb, start) {
1624
+ async function lifecycle(ctx, values, ref, verb, start, processfulOnly) {
1266
1625
  const timeoutMs = waitTimeout(values);
1267
1626
  const ws = await resolveWorkspace(ctx, ref);
1627
+ if (processfulOnly)
1628
+ requireProcessful(ws, processfulOnly[0], processfulOnly[1]);
1268
1629
  const op = await waited(ctx, 'the operation continues server side.', () => start(ws, lifecycleOptions(ctx, values, timeoutMs)));
1269
1630
  out(ctx, lifecycleText(verb, ws, op), op);
1270
1631
  return 0;