@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/CHANGELOG.md +63 -24
- package/README.md +103 -7
- package/dist/args.d.ts +1 -2
- package/dist/args.js +242 -3
- package/dist/commands.d.ts +4 -2
- package/dist/commands.js +323 -12
- package/dist/computer-stream-cache.d.ts +8 -0
- package/dist/computer-stream-cache.js +34 -0
- package/dist/format.js +3 -0
- package/dist/main.js +3 -2
- package/package.json +3 -3
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
47
|
+
"@shardflux/sdk": "^0.16.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@eslint/js": "10.0.1",
|