@shardflux/cli 0.9.0 → 0.11.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
@@ -5,10 +5,11 @@
5
5
  * for yet (GET /v1/operations/{id}, usage summary). The account plane (0.5.0) is in account-commands.ts; handlers.ts
6
6
  * joins both tables.
7
7
  */
8
+ import { computerStreamCache } from "./computer-stream-cache.js";
8
9
  import { randomUUID } from 'node:crypto';
9
10
  import { access, readFile, writeFile } from 'node:fs/promises';
10
11
  import { basename, dirname, resolve as resolvePath } from 'node:path';
11
- import { EXECUTION_ID, ExecStartError, FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH, NotSupportedForModeError, SDK_VERSION, ShardfluxApiError, ShardfluxProtocolError, newExecutionId } from '@shardflux/sdk';
12
+ import { EXECUTION_ID, ExecStartError, FEEDBACK_CATEGORIES, FEEDBACK_MESSAGE_MAX_LENGTH, NotSupportedForModeError, SDK_VERSION, ShardfluxApiError, ShardfluxProtocolError, newExecutionId, exitCodePosix } from '@shardflux/sdk';
12
13
  import { parse as parseYaml } from 'yaml';
13
14
  import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
14
15
  import { ExecSessionRunningError, ExecutionFailedError, ExecutionPendingError, InterruptedError, NotFoundError, UsageError } from "./errors.js";
@@ -94,6 +95,10 @@ function resizeParams(values) {
94
95
  p.diskGib = parsePositiveInt(disk, 'disk', 1_000_000);
95
96
  if (Object.keys(p).length === 0)
96
97
  throw new UsageError('nothing to resize: give --memory, --held, --fixed, --elastic, --cpu or --disk');
98
+ if (values['at-least'] === true)
99
+ p.atLeast = true;
100
+ if (values.wait === false)
101
+ p.wait = false;
97
102
  return p;
98
103
  }
99
104
  /**
@@ -346,6 +351,22 @@ async function waitOperation(ctx, operationId, timeoutMs) {
346
351
  * Runs an SDK call that waits itself (a waited open or lifecycle call). Ctrl-C reports the operation it was waiting
347
352
  * for, as the SDK observed it; a held open interrupted before the server answered has none yet (`unnamed`).
348
353
  */
354
+ /**
355
+ * The workspace of `ws exec` / `exec-start`: with `--template <slug>` (0.10.0+) the key is opened first (created on first
356
+ * use, resumed if suspended; stderr says when it was created), else `<id|key>` must name an existing workspace.
357
+ */
358
+ async function execTarget(ctx, values, ref) {
359
+ const template = str(values.template);
360
+ if (template === undefined)
361
+ return resolveWorkspace(ctx, ref);
362
+ const ws = await waited(ctx, 'the open continues server side (run the command again to keep waiting).', () => ctx
363
+ .cloud()
364
+ .workspace(ref, { template, agentLabel: ctx.agentLabel, wait: { timeoutMs: DEFAULT_WAIT_MS, signal: ctx.signal } })
365
+ .open());
366
+ if (ws.created === true && !ctx.json)
367
+ ctx.io.stderr.write(`created workspace ${ws.key} (template ${template})\n`);
368
+ return ws;
369
+ }
349
370
  async function waited(ctx, unnamed, call) {
350
371
  try {
351
372
  return await call();
@@ -477,13 +498,7 @@ function feedbackContext(ctx, values) {
477
498
  }
478
499
  /** The exit code `shard exec` returns for a finished command. */
479
500
  export function execExitCode(r) {
480
- if (r.timedOut)
481
- return 124;
482
- if (typeof r.exitCode === 'number' && r.exitCode >= 0)
483
- return Math.min(r.exitCode, 255);
484
- if (typeof r.termSignal === 'number' && r.termSignal > 0)
485
- return 128 + r.termSignal;
486
- return 1;
501
+ return Math.min(exitCodePosix(r), 255);
487
502
  }
488
503
  /** The longest cancel grace the workspace applies (it waits 5 s without one). */
489
504
  const MAX_CANCEL_GRACE_MS = 60_000;
@@ -623,6 +638,71 @@ async function portWorkspace(ctx, ref) {
623
638
  requireProcessful(ws, 'inbound_ports', 'Serving a port');
624
639
  return ws;
625
640
  }
641
+ /** The workspace of a computer command; a file-first workspace has no desktop (refused before any request). */
642
+ /** --format/--quality of a desktop image (0.10.0+). */
643
+ function imageOpts(values) {
644
+ const format = str(values.format);
645
+ if (format !== undefined && format !== 'png' && format !== 'jpeg')
646
+ throw new UsageError(`--format must be png or jpeg (got "${format}")`);
647
+ const quality = str(values.quality);
648
+ return { ...(format !== undefined ? { format } : {}), ...(quality !== undefined ? { quality: parsePositiveInt(quality, 'quality', 100) } : {}) };
649
+ }
650
+ /** --screenshot <file>, --settle and the image options: the screenshot after a computer action or batch. */
651
+ function lookOpts(values) {
652
+ const shotTo = str(values.screenshot);
653
+ const settle = str(values.settle);
654
+ if (settle !== undefined && (!/^\d+$/.test(settle.trim()) || Number(settle) > 10_000))
655
+ throw new UsageError(`--settle must be 0 to 10000 milliseconds (got "${settle}")`);
656
+ return { shotTo, req: { screenshot: shotTo !== undefined, ...(settle !== undefined ? { settle_ms: Number(settle) } : {}), ...imageOpts(values) } };
657
+ }
658
+ /** A screen coordinate argument (pixels from the top-left). */
659
+ function pixel(text, name) {
660
+ if (!/^\d+$/.test(text.trim()))
661
+ throw new UsageError(`<${name}> must be a whole number of pixels (got "${text}")`);
662
+ return Number(text);
663
+ }
664
+ /** <x> <y> as a coordinate; neither means at the pointer. */
665
+ function pointAt(x, y) {
666
+ if (x === undefined)
667
+ return {};
668
+ if (y === undefined)
669
+ throw new UsageError('pass both <x> and <y>, or neither to act at the pointer');
670
+ return { coordinate: [pixel(x, 'x'), pixel(y, 'y')] };
671
+ }
672
+ const modsOf = (values) => (str(values.mods) !== undefined ? { text: str(values.mods) } : {});
673
+ function secondsOf(text, name) {
674
+ if (!/^\d+(\.\d+)?$/.test(text.trim()) || Number(text) > 300)
675
+ throw new UsageError(`${name} must be 0 to 300 seconds (got "${text}")`);
676
+ return Number(text);
677
+ }
678
+ /** Runs computer actions as one batch and prints one line per action; exit 1 when one failed. */
679
+ async function runComputer(ctx, ref, actions, look) {
680
+ const ws = await computerWorkspace(ctx, ref);
681
+ const r = await cellOf(ctx, ws).computer.act({ actions, ...look.req });
682
+ if (look.shotTo !== undefined && r.screenshot)
683
+ await writeFile(look.shotTo, Buffer.from(r.screenshot.data, 'base64'));
684
+ const lines = r.results.map((x, i) => {
685
+ const state = x.skipped ? 'skipped' : x.ok ? 'ok' : `failed: ${x.error?.message ?? ''}`;
686
+ return `${String(i + 1).padStart(3)} ${x.action.padEnd(16)} ${state}${x.output ? ` ${x.output}` : ''}${x.image ? ' (image)' : ''} ${x.took_ms} ms`;
687
+ });
688
+ const tail = `cursor ${r.cursor[0]},${r.cursor[1]} on a ${r.display.width}x${r.display.height} screen${look.shotTo !== undefined && r.screenshot ? `; screenshot written to ${look.shotTo}` : ''}`;
689
+ out(ctx, `${lines.join('\n')}${lines.length ? '\n' : ''}${tail}\n`, r);
690
+ return r.results.every((x) => x.ok) ? 0 : 1;
691
+ }
692
+ async function computerWorkspace(ctx, ref) {
693
+ const ws = await resolveWorkspace(ctx, ref);
694
+ requireProcessful(ws, 'computer_use', 'Computer use');
695
+ return ws;
696
+ }
697
+ async function setComputerUse(ctx, ref, enabled) {
698
+ const ws = await computerWorkspace(ctx, ref);
699
+ await ws.setComputerUse(enabled);
700
+ const cu = ws.computerUse;
701
+ const why = cu.workspace === null ? `following its template (${cu.template ? 'on' : 'off'})` : 'set on the workspace';
702
+ const note = !cu.available ? ' Its template version cannot run a desktop.' : '';
703
+ out(ctx, `Computer use is ${cu.enabled ? 'on' : 'off'} for ${ws.key}, ${why}.${note}\n`, cu);
704
+ return 0;
705
+ }
626
706
  /** A port as the API sends it (`--json`). */
627
707
  const portJson = (p) => ({ port: p.port, url: p.url, created_at: p.createdAt, callback: p.callback ? { created_at: p.callback.createdAt } : null });
628
708
  function requireProcessful(ws, operation, what) {
@@ -768,7 +848,7 @@ export const KEY_HANDLERS = {
768
848
  const timeout = str(values.timeout);
769
849
  const timeoutMs = timeout === undefined ? undefined : parseDuration(timeout, 'timeout');
770
850
  const resourceHint = oneOf(values, 'resource-hint', RESOURCE_HINTS);
771
- const ws = await resolveWorkspace(ctx, ref);
851
+ const ws = await execTarget(ctx, values, ref);
772
852
  const session = await cellOf(ctx, ws).exec.start({ argv, stdin_open: values['stdin-open'] === true,
773
853
  ...(resourceHint !== undefined ? { resource_hint: resourceHint } : {}),
774
854
  ...(str(values['session-id']) ? { session_id: str(values['session-id']) } : {}), ...(str(values.cwd) ? { cwd: str(values.cwd) } : {}),
@@ -779,6 +859,35 @@ export const KEY_HANDLERS = {
779
859
  ctx.io.stderr.write(`read it with: shard ws exec-read ${ref} ${session.session_id} --follow; stop it with: shard ws exec-cancel ${ref} ${session.session_id}\n`);
780
860
  return 0;
781
861
  },
862
+ async 'workspaces tunnel'(ctx, values, [ref]) {
863
+ const remote = str(values.remote);
864
+ const target = str(values.to);
865
+ if (!remote || !target)
866
+ throw new UsageError('tunnel requires --remote <port> and --to <host:port>');
867
+ const remotePort = parsePositiveInt(remote, 'remote');
868
+ if (remotePort > 65535)
869
+ throw new UsageError('--remote must be 1..65535');
870
+ const bindAddress = oneOf(values, 'bind', ['127.0.0.1', '0.0.0.0']);
871
+ const transport = oneOf(values, 'transport', ['auto', 'pty', 'exec']);
872
+ const terminated = new AbortController();
873
+ const onTerm = () => terminated.abort(new Error('terminated'));
874
+ process.once('SIGTERM', onTerm);
875
+ try {
876
+ const ws = await resolveWorkspace(ctx, ref);
877
+ const tunnel = await ws.tunnels.reverse({ remotePort, target, ...(bindAddress ? { bindAddress } : {}), ...(transport ? { transport } : {}), signal: AbortSignal.any([ctx.signal, terminated.signal]) });
878
+ out(ctx, `${bindAddress ?? '127.0.0.1'}:${remotePort} in ${ws.key} → ${target} on this machine; Ctrl-C closes the tunnel\n`, { remote_port: remotePort, target, session_id: tunnel.sessionId, transport: tunnel.transport });
879
+ try {
880
+ await tunnel.closed;
881
+ }
882
+ finally {
883
+ await tunnel.close();
884
+ }
885
+ return terminated.signal.aborted ? 143 : ctx.signal.aborted ? 130 : 0;
886
+ }
887
+ finally {
888
+ process.removeListener('SIGTERM', onTerm);
889
+ }
890
+ },
782
891
  async 'workspaces exec-read'(ctx, values, [ref, sessionId]) {
783
892
  const ws = await resolveWorkspace(ctx, ref);
784
893
  const cell = cellOf(ctx, ws);
@@ -823,7 +932,7 @@ export const KEY_HANDLERS = {
823
932
  if (s.state === 'starting' || s.state === 'running') {
824
933
  throw new ExecSessionRunningError(`Exec session ${s.session_id} is still ${s.state}.`, s, `follow it to its end with: shard ws exec-read ${ref} ${s.session_id} --follow`);
825
934
  }
826
- const result = { exitCode: s.exit_code ?? null, termSignal: s.term_signal ?? null, timedOut: s.timed_out ?? false };
935
+ const result = { exitCode: s.exit_code ?? null, termSignal: s.term_signal ?? null, timedOut: s.timed_out ?? false, canceled: s.canceled ?? false };
827
936
  if (!ctx.json && result.exitCode === null && !result.timedOut && !(result.termSignal !== null && result.termSignal > 0)) {
828
937
  ctx.io.stderr.write(`exec session ${s.session_id} ended ${s.state} without an exit code${s.error ? `: ${s.error}` : ''}\n`);
829
938
  }
@@ -850,6 +959,22 @@ export const KEY_HANDLERS = {
850
959
  out(ctx, JSON.stringify(ack) + '\n', ack);
851
960
  return 0;
852
961
  },
962
+ async 'workspaces retention'(ctx, _values, [ref, policy]) {
963
+ const ws = await resolveWorkspace(ctx, ref);
964
+ await ws.setRetention(retentionDays(policy));
965
+ out(ctx, `${JSON.stringify(ws.retention)}\n`, ws.data);
966
+ return 0;
967
+ },
968
+ async 'projects retention'(ctx, values, [projectId, policy]) {
969
+ const api = ctx.cloud().projects;
970
+ if (policy === 'off' && values.labels !== undefined)
971
+ throw new UsageError('--labels cannot be combined with off');
972
+ if (policy === undefined && values.labels !== undefined)
973
+ throw new UsageError('--labels requires a retention policy');
974
+ const retention = policy === undefined ? await api.getRetention(projectId) : await api.setRetention(projectId, policy === 'off' ? null : { ...openRetentionDays(policy), ...(str(values.labels) === undefined ? {} : { labels: labelMap(str(values.labels)) }) });
975
+ out(ctx, `${JSON.stringify(retention)}\n`, retention);
976
+ return 0;
977
+ },
853
978
  async 'workspaces open'(ctx, values, [key]) {
854
979
  const template = str(values.template);
855
980
  if (!template)
@@ -858,6 +983,10 @@ export const KEY_HANDLERS = {
858
983
  const bind = secretNames(strs(values.secret), '--secret');
859
984
  const lifetime = oneOf(values, 'lifetime', LIFETIMES);
860
985
  const mode = modeFlag(values);
986
+ const cuText = str(values['computer-use']);
987
+ if (cuText !== undefined && !['on', 'off', 'inherit'].includes(cuText))
988
+ throw new UsageError(`--computer-use must be on, off or inherit (got "${cuText}")`);
989
+ const computerUse = cuText === undefined ? undefined : cuText === 'inherit' ? null : cuText === 'on';
861
990
  if (mode === 'file_first' && lifetime === 'session')
862
991
  throw new UsageError('a file-first workspace is persistent: --lifetime session is for processful workspaces');
863
992
  const inputs = await inputsFrom(ctx, values);
@@ -869,12 +998,14 @@ export const KEY_HANDLERS = {
869
998
  key: key,
870
999
  template,
871
1000
  ...(str(values.labels) !== undefined ? { labels: labelMap(str(values.labels)) } : {}),
1001
+ ...(str(values['delete-after-idle']) !== undefined ? { retention: openRetentionDays(str(values['delete-after-idle'])) } : {}),
872
1002
  ...(str(values['idle-policy']) ? { idlePolicy: str(values['idle-policy']) } : {}),
873
1003
  ...(c ? { caps: c } : {}),
874
1004
  ...(bind.length ? { secrets: bind } : {}),
875
1005
  ...(inputs ? { inputs } : {}),
876
1006
  ...(lifetime ? { lifetime } : {}),
877
1007
  ...(mode ? { mode } : {}),
1008
+ ...(computerUse !== undefined ? { computerUse } : {}),
878
1009
  agentLabel: ctx.agentLabel,
879
1010
  wait: values.wait === false ? false : { timeoutMs, signal: ctx.signal },
880
1011
  }));
@@ -947,7 +1078,7 @@ export const KEY_HANDLERS = {
947
1078
  const burst = burstOptions(values, stdinSource !== undefined);
948
1079
  const resourceHint = oneOf(values, 'resource-hint', RESOURCE_HINTS);
949
1080
  const stdin = stdinSource === undefined ? undefined : await readInput(ctx, stdinSource, MAX_STDIN, '--stdin');
950
- const ws = await resolveWorkspace(ctx, ref);
1081
+ const ws = await execTarget(ctx, values, ref);
951
1082
  const cell = cellOf(ctx, ws);
952
1083
  const cwd = str(values.cwd);
953
1084
  if (ws.mode === 'file_first') {
@@ -1215,6 +1346,12 @@ export const KEY_HANDLERS = {
1215
1346
  async 'workspaces close'(ctx, values, [ref]) {
1216
1347
  return lifecycle(ctx, values, ref, 'Closing', (ws, o) => ctx.cloud().workspaces.close(ws.id, o));
1217
1348
  },
1349
+ async 'workspaces upgrade'(ctx, values, [ref]) {
1350
+ const at = str(values.at) ?? 'now';
1351
+ if (at !== 'now' && at !== 'next-resume')
1352
+ throw new UsageError('--at must be now or next-resume');
1353
+ return lifecycle(ctx, values, ref, at === 'now' ? 'Upgrading' : 'Scheduling upgrade for', (ws, o) => ctx.cloud().workspaces.upgrade(ws.id, { ...o, at: at === 'next-resume' ? 'next_resume' : 'now' }), ['upgrade', 'Upgrade']);
1354
+ },
1218
1355
  async 'workspaces reset'(ctx, values, [ref]) {
1219
1356
  if (values.yes !== true)
1220
1357
  throw new UsageError('refusing to reset without --yes (reset wipes every change in the workspace and restarts it on its template)');
@@ -1774,7 +1911,7 @@ export const KEY_HANDLERS = {
1774
1911
  if (path !== undefined && !path.startsWith('/'))
1775
1912
  throw new UsageError(`--path must start with / (got "${path}")`);
1776
1913
  const ws = await portWorkspace(ctx, ref);
1777
- const l = await ws.ports.link(port, { ...(ttl !== undefined ? { ttlSeconds: ttl } : {}), ...(path !== undefined ? { path } : {}) });
1914
+ const l = await ws.ports.link(port, { ...(values['embed-origin'] !== undefined ? { embed: { origins: values['embed-origin'] } } : {}), ...(ttl !== undefined ? { ttlSeconds: ttl } : {}), ...(path !== undefined ? { path } : {}) });
1778
1915
  out(ctx, `${l.url}\n`, { url: l.url, expires_at: l.expiresAt });
1779
1916
  if (!ctx.json)
1780
1917
  ctx.io.stderr.write(`Opens port ${port} of ${ws.key} in a browser until ${time(l.expiresAt)}; anyone with the link can open it until then.\n`);
@@ -1795,6 +1932,165 @@ export const KEY_HANDLERS = {
1795
1932
  }
1796
1933
  return 0;
1797
1934
  },
1935
+ // ---- computer use (0.10.0; contracts §45) ----
1936
+ async 'workspaces computer status'(ctx, _values, [ref]) {
1937
+ const ws = await computerWorkspace(ctx, ref);
1938
+ const st = await cellOf(ctx, ws).computer.status();
1939
+ const views = [st.streams.view ? 'view only' : null, st.streams.interactive ? 'interactive' : null].filter(Boolean);
1940
+ out(ctx, st.running
1941
+ ? `Desktop of ${ws.key}: running ${st.width}x${st.height} since ${time(st.started_at ?? '')}; viewers: ${views.length ? views.join(', ') : 'none'}.\n`
1942
+ : `Desktop of ${ws.key}: not running (the first computer call starts it).\n`, st);
1943
+ return 0;
1944
+ },
1945
+ async 'workspaces computer screenshot'(ctx, values, [ref]) {
1946
+ const img = imageOpts(values);
1947
+ const dest = str(values.out) ?? (img.format === 'jpeg' ? 'screenshot.jpg' : 'screenshot.png');
1948
+ const ws = await computerWorkspace(ctx, ref);
1949
+ const r = await cellOf(ctx, ws).computer.act({ actions: [], screenshot: true, ...img });
1950
+ if (!r.screenshot)
1951
+ throw new ShardfluxProtocolError('the desktop returned no screenshot', 200, 'cell');
1952
+ const bytes = Buffer.from(r.screenshot.data, 'base64');
1953
+ await writeFile(dest, bytes);
1954
+ out(ctx, `Wrote a ${r.screenshot.width}x${r.screenshot.height} screenshot of ${ws.key} to ${dest}.\n`, {
1955
+ out: dest, format: r.screenshot.format, width: r.screenshot.width, height: r.screenshot.height, bytes: bytes.length,
1956
+ });
1957
+ return 0;
1958
+ },
1959
+ async 'workspaces computer act'(ctx, values, [ref, source]) {
1960
+ const text = source === '-' ? Buffer.from(await readInput(ctx, '-', 1 << 20, 'actions')).toString('utf8') : source;
1961
+ let actions;
1962
+ try {
1963
+ actions = JSON.parse(text);
1964
+ }
1965
+ catch {
1966
+ throw new UsageError('<actions-json> must be a JSON array of actions, e.g. [{"action":"screenshot"}]');
1967
+ }
1968
+ if (!Array.isArray(actions))
1969
+ throw new UsageError('<actions-json> must be a JSON array of actions');
1970
+ return runComputer(ctx, ref, actions, lookOpts(values));
1971
+ },
1972
+ async 'workspaces computer click'(ctx, values, [ref, x, y]) {
1973
+ const button = str(values.button) ?? 'left';
1974
+ if (button !== 'left' && button !== 'right' && button !== 'middle')
1975
+ throw new UsageError(`--button must be left, right or middle (got "${button}")`);
1976
+ const count = str(values.count) !== undefined ? parsePositiveInt(str(values.count), 'count', 3) : 1;
1977
+ if (count > 1 && button !== 'left')
1978
+ throw new UsageError('--count 2 or 3 is a double or triple click of the left button');
1979
+ const action = button === 'right' ? 'right_click' : button === 'middle' ? 'middle_click' : ['left_click', 'double_click', 'triple_click'][count - 1];
1980
+ return runComputer(ctx, ref, [{ action, ...pointAt(x, y), ...modsOf(values) }], lookOpts(values));
1981
+ },
1982
+ async 'workspaces computer type'(ctx, values, [ref, source]) {
1983
+ const text = source === '-' ? Buffer.from(await readInput(ctx, '-', 256 << 10, 'text')).toString('utf8') : source;
1984
+ if (text === '')
1985
+ throw new UsageError('<text> must not be empty');
1986
+ return runComputer(ctx, ref, [{ action: 'type', text }], lookOpts(values));
1987
+ },
1988
+ async 'workspaces computer key'(ctx, values, [ref, keys]) {
1989
+ const repeat = str(values.repeat) !== undefined ? parsePositiveInt(str(values.repeat), 'repeat', 100) : undefined;
1990
+ return runComputer(ctx, ref, [{ action: 'key', text: keys, ...(repeat !== undefined ? { repeat } : {}) }], lookOpts(values));
1991
+ },
1992
+ async 'workspaces computer scroll'(ctx, values, [ref, direction, x, y]) {
1993
+ if (direction !== 'up' && direction !== 'down' && direction !== 'left' && direction !== 'right') {
1994
+ throw new UsageError(`expected up, down, left or right (got "${direction}")`);
1995
+ }
1996
+ const amount = str(values.amount) !== undefined ? parsePositiveInt(str(values.amount), 'amount', 100) : 3;
1997
+ return runComputer(ctx, ref, [{ action: 'scroll', scroll_direction: direction, scroll_amount: amount, ...pointAt(x, y), ...modsOf(values) }], lookOpts(values));
1998
+ },
1999
+ async 'workspaces computer move'(ctx, values, [ref, x, y]) {
2000
+ return runComputer(ctx, ref, [{ action: 'mouse_move', ...pointAt(x, y) }], lookOpts(values));
2001
+ },
2002
+ async 'workspaces computer drag'(ctx, values, [ref, x1, y1, x2, y2]) {
2003
+ const start = pointAt(x1, y1).coordinate;
2004
+ return runComputer(ctx, ref, [{ action: 'left_click_drag', start_coordinate: start, ...pointAt(x2, y2), ...modsOf(values) }], lookOpts(values));
2005
+ },
2006
+ async 'workspaces computer mouse-down'(ctx, values, [ref]) {
2007
+ return runComputer(ctx, ref, [{ action: 'left_mouse_down' }], lookOpts(values));
2008
+ },
2009
+ async 'workspaces computer mouse-up'(ctx, values, [ref]) {
2010
+ return runComputer(ctx, ref, [{ action: 'left_mouse_up' }], lookOpts(values));
2011
+ },
2012
+ async 'workspaces computer hold-key'(ctx, values, [ref, keys, seconds]) {
2013
+ return runComputer(ctx, ref, [{ action: 'hold_key', text: keys, duration: secondsOf(seconds, '<seconds>') }], lookOpts(values));
2014
+ },
2015
+ async 'workspaces computer wait'(ctx, values, [ref, seconds]) {
2016
+ return runComputer(ctx, ref, [{ action: 'wait', duration: secondsOf(seconds, '<seconds>') }], lookOpts(values));
2017
+ },
2018
+ async 'workspaces computer zoom'(ctx, values, [ref, x0, y0, x1, y1]) {
2019
+ const img = imageOpts(values);
2020
+ const region = [pixel(x0, 'x0'), pixel(y0, 'y0'), pixel(x1, 'x1'), pixel(y1, 'y1')];
2021
+ const dest = str(values.out) ?? (img.format === 'jpeg' ? 'zoom.jpg' : 'zoom.png');
2022
+ const ws = await computerWorkspace(ctx, ref);
2023
+ const r = await cellOf(ctx, ws).computer.act({ actions: [{ action: 'zoom', region }], ...img });
2024
+ const res = r.results[0];
2025
+ if (!res?.ok || !res.image) {
2026
+ out(ctx, `zoom failed: ${res?.error?.message ?? 'no image'}\n`, r);
2027
+ return 1;
2028
+ }
2029
+ const bytes = Buffer.from(res.image.data, 'base64');
2030
+ await writeFile(dest, bytes);
2031
+ out(ctx, `Wrote a ${res.image.width}x${res.image.height} view of [${region.join(', ')}] on ${ws.key} to ${dest}.\n`, {
2032
+ out: dest, format: res.image.format, width: res.image.width, height: res.image.height, bytes: bytes.length,
2033
+ });
2034
+ return 0;
2035
+ },
2036
+ async 'workspaces computer cursor'(ctx, _values, [ref]) {
2037
+ const ws = await computerWorkspace(ctx, ref);
2038
+ const r = await cellOf(ctx, ws).computer.act({ actions: [{ action: 'cursor_position' }] });
2039
+ out(ctx, `${r.cursor[0]} ${r.cursor[1]}\n`, { x: r.cursor[0], y: r.cursor[1], display: r.display });
2040
+ return 0;
2041
+ },
2042
+ async 'workspaces computer start'(ctx, values, [ref]) {
2043
+ const width = str(values.width) !== undefined ? parsePositiveInt(str(values.width), '--width') : undefined;
2044
+ const height = str(values.height) !== undefined ? parsePositiveInt(str(values.height), '--height') : undefined;
2045
+ const ws = await computerWorkspace(ctx, ref);
2046
+ const st = await cellOf(ctx, ws).computer.start({ ...(width !== undefined ? { width } : {}), ...(height !== undefined ? { height } : {}) });
2047
+ out(ctx, `Desktop of ${ws.key}: running ${st.width}x${st.height}.\n`, st);
2048
+ return 0;
2049
+ },
2050
+ async 'workspaces computer stop'(ctx, _values, [ref]) {
2051
+ const ws = await computerWorkspace(ctx, ref);
2052
+ await cellOf(ctx, ws).computer.stop();
2053
+ out(ctx, `Stopped the desktop of ${ws.key}.\n`, { workspace_id: ws.id, stopped: true });
2054
+ return 0;
2055
+ },
2056
+ async 'workspaces computer stream'(ctx, values, [ref]) {
2057
+ const ws = await computerWorkspace(ctx, ref);
2058
+ const credential = ctx.io.env.SHARDFLUX_API_KEY?.trim() ?? ctx.store.profile(ctx.apiUrl).api_key?.token ?? '';
2059
+ const cache = computerStreamCache(ctx.io.env, ctx.apiUrl, ws.id, credential);
2060
+ if (values.stop === true) {
2061
+ cache.clear();
2062
+ await ws.computer.stopStream();
2063
+ out(ctx, `Ended every view of the desktop of ${ws.key}.\n`, { workspace_id: ws.id, stopped: true });
2064
+ return 0;
2065
+ }
2066
+ const ttl = ttlSeconds(values, PORT_LINK_TTL);
2067
+ const streamOptions = { fresh: values.fresh === true, interactive: values.interactive === true,
2068
+ ...(values['embed-origin'] !== undefined ? { embed: { origins: values['embed-origin'] } } : {}),
2069
+ ...(ttl !== undefined ? { ttlSeconds: ttl } : {}) };
2070
+ const s = (values.fresh !== true ? cache.get(values.interactive === true, streamOptions) : undefined) ?? await ws.computer.stream(streamOptions);
2071
+ cache.set(s, streamOptions);
2072
+ out(ctx, `${s.url}\n`, { url: s.url, expires_at: s.expiresAt, port: s.port, interactive: s.interactive });
2073
+ if (!ctx.json) {
2074
+ ctx.io.stderr.write(`${s.interactive ? 'Uses' : 'Watches'} the desktop of ${ws.key} until ${time(s.expiresAt)}; anyone with the link can open it until then.\n`);
2075
+ }
2076
+ return 0;
2077
+ },
2078
+ async 'workspaces computer enable'(ctx, _values, [ref]) {
2079
+ return setComputerUse(ctx, ref, true);
2080
+ },
2081
+ async 'workspaces computer disable'(ctx, _values, [ref]) {
2082
+ return setComputerUse(ctx, ref, false);
2083
+ },
2084
+ async 'workspaces computer inherit'(ctx, _values, [ref]) {
2085
+ return setComputerUse(ctx, ref, null);
2086
+ },
2087
+ async 'templates computer-use'(ctx, _values, [slug, state]) {
2088
+ if (state !== 'on' && state !== 'off')
2089
+ throw new UsageError(`expected on or off (got "${state}")`);
2090
+ const r = await ctx.cloud().templates.setComputerUse(slug, state === 'on');
2091
+ out(ctx, `Computer use is ${r.enabled ? 'on' : 'off'} for the workspaces of ${slug} (a workspace's own setting wins).\n`, r);
2092
+ return 0;
2093
+ },
1798
2094
  async 'workspaces secrets'(ctx, values, [ref]) {
1799
2095
  const set = secretNames(strs(values.set), '--set');
1800
2096
  const clear = values.clear === true;
@@ -2096,3 +2392,18 @@ function labelMap(value) {
2096
2392
  throw new UsageError('labels must be a JSON object of strings');
2097
2393
  return parsed;
2098
2394
  }
2395
+ function retentionDays(value) {
2396
+ if (value === 'off')
2397
+ return null;
2398
+ const match = /^([0-9]+)d$/.exec(value);
2399
+ const days = match ? Number(match[1]) : NaN;
2400
+ if (!Number.isInteger(days) || days < 1 || days > 3650)
2401
+ throw new UsageError('retention must be 1d..3650d or off');
2402
+ return { delete_after_idle_days: days };
2403
+ }
2404
+ function openRetentionDays(value) {
2405
+ const policy = retentionDays(value);
2406
+ if (policy === null)
2407
+ throw new UsageError('--delete-after-idle requires 1d..3650d; clear an own policy with shard ws retention KEY off');
2408
+ return policy;
2409
+ }
@@ -0,0 +1,8 @@
1
+ import type { ComputerStream, ComputerStreamOptions } from '@shardflux/sdk';
2
+ type StreamOptions = Pick<ComputerStreamOptions, 'embed' | 'ttlSeconds'>;
3
+ export declare function computerStreamCache(env: Record<string, string | undefined>, apiUrl: string, workspaceId: string, credential: string): {
4
+ get(interactive: boolean, options?: StreamOptions): ComputerStream | undefined;
5
+ set(link: ComputerStream, options?: StreamOptions): void;
6
+ clear(): void;
7
+ };
8
+ export {};
@@ -0,0 +1,34 @@
1
+ /** Viewer links survive CLI invocations in the private config directory, scoped to API/workspace/credential. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { configDir } from "./credentials.js";
6
+ export function computerStreamCache(env, apiUrl, workspaceId, credential) {
7
+ const digest = (text) => createHash('sha256').update(text).digest('hex');
8
+ const dir = join(configDir(env), 'computer-streams', digest(`${apiUrl}\0${workspaceId}`));
9
+ const path = (interactive, options = {}) => join(dir, `${digest(credential)}-${interactive ? 'interactive' : 'view'}-${digest(JSON.stringify({ ttlSeconds: options.ttlSeconds, embed: options.embed }))}.json`);
10
+ return {
11
+ get(interactive, options = {}) {
12
+ try {
13
+ const link = JSON.parse(readFileSync(path(interactive, options), 'utf8'));
14
+ if (typeof link.url === 'string' && typeof link.expiresAt === 'string' && Number.isInteger(link.port) && link.interactive === interactive && Date.parse(link.expiresAt) - Date.now() > 60_000)
15
+ return link;
16
+ }
17
+ catch { /* cache misses are ordinary; mint through the SDK */ }
18
+ return undefined;
19
+ },
20
+ set(link, options = {}) {
21
+ const temp = `${path(link.interactive, options)}.${randomUUID()}.tmp`;
22
+ try {
23
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
24
+ writeFileSync(temp, JSON.stringify(link), { mode: 0o600, flag: 'wx' });
25
+ renameSync(temp, path(link.interactive, options));
26
+ }
27
+ catch { /* caching is optional; the minted link remains usable */ }
28
+ finally {
29
+ rmSync(temp, { force: true });
30
+ }
31
+ },
32
+ clear() { rmSync(dir, { recursive: true, force: true }); },
33
+ };
34
+ }
package/dist/format.js CHANGED
@@ -155,6 +155,9 @@ const deferText = (r) => (r === null || r === 'suspended' || r === 'stopped' ? '
155
155
  * applied in part (requested above a limit, a partial grow, a shrink the guest did not finish).
156
156
  */
157
157
  export function resizeText(w, r) {
158
+ if (r.operation && !['succeeded', 'failed', 'canceled'].includes(r.operation.state)) {
159
+ return `Resize accepted for ${w.key} (${w.id}); operation ${r.operation.id} is ${r.operation.state}.\n`;
160
+ }
158
161
  const rows = [];
159
162
  const row = (name, unit, x, extra = '', converged = null) => {
160
163
  const notes = [];
package/dist/main.js CHANGED
@@ -415,7 +415,7 @@ export async function run(argv, io, opts = {}) {
415
415
  let ctx;
416
416
  let updates = null;
417
417
  try {
418
- const apiUrl = resolveApiUrl(parsed.global.apiUrl, io.env.SHARDFLUX_API_URL);
418
+ const apiUrl = resolveApiUrl(parsed.global.apiUrl, io.env.SHARDFLUX_API_URL ?? io.env.SHARDFLUX_BASE_URL);
419
419
  apiUrlForCleanup = apiUrl;
420
420
  const agentLabel = parsed.global.agentLabel ?? io.env.SHARDFLUX_AGENT_LABEL ?? 'cli';
421
421
  if (!isLabel(agentLabel))
@@ -609,7 +609,8 @@ export async function run(argv, io, opts = {}) {
609
609
  const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
610
610
  // On Ctrl-C give the command a moment to clean up (exec sends a cancel), then stop regardless.
611
611
  const interrupted = new Promise((resolve) => {
612
- const onAbort = () => setTimeout(() => resolve({ interrupted: true }), 4_000).unref();
612
+ const cleanupMs = parsed.command.path.join(' ') === 'workspaces tunnel' ? 90_000 : 4_000;
613
+ const onAbort = () => setTimeout(() => resolve({ interrupted: true }), cleanupMs).unref();
613
614
  if (signal.aborted)
614
615
  onAbort();
615
616
  else
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
- "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork persistent agent workspaces.",
5
+ "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
7
7
  "homepage": "https://shardflux.dev",
8
8
  "bugs": {
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "yaml": "2.9.1",
47
- "@shardflux/sdk": "^0.14.0"
47
+ "@shardflux/sdk": "^0.16.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@eslint/js": "10.0.1",