@shardflux/cli 0.10.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 CHANGED
@@ -3,7 +3,24 @@
3
3
  Every command and option the README shows is available from the version named here. Breaking changes ship in minor
4
4
  releases and are marked **Breaking**.
5
5
 
6
- ## 0.10.0 (not yet published)
6
+ ## 0.11.0 (not yet published)
7
+
8
+ - Add opt-in persistent workspace idle deletion retention, workspace/project setters and projected deletion times.
9
+
10
+ - Accelerate reverse tunnels with a bounded, acknowledged binary WebSocket stream, 64 KiB frames and batched credits. Select `auto`, `pty` or `exec` transport; reconnect preserves byte offsets. Opt-in binary PTY output accepts larger blocks for file transfers.
11
+
12
+ - Add opt-in resize controls to grow named resources only and return the held server answer without polling a pending operation.
13
+
14
+ - `shard ws computer stream` reuses a private cached link by API/workspace/credential and kind with more than 60 seconds left. `--fresh` remints; `--stop` clears cached links.
15
+ - `SHARDFLUX_BASE_URL` aliases `SHARDFLUX_API_URL` (the latter wins). Canceled exec results exit 130; timeout, normal exit and signal statuses follow the shared POSIX status helper.
16
+ - Shared SDK proxy support, missing-file classification and computer batch screenshot deduplication.
17
+ - Embed live desktop viewers and port previews in a web app with an explicit HTTPS origin allowlist and partitioned browser sessions.
18
+
19
+ - Add foreground `shard ws tunnel <key> --remote <port> --to <host:port> [--bind 127.0.0.1|0.0.0.0]`. Ctrl-C closes the reverse tunnel and its guest process.
20
+
21
+ - Add `shard ws upgrade <key> [--at next-resume]` with operation waiting and JSON output.
22
+
23
+ ## 0.10.0 — 2026-10-04
7
24
 
8
25
  Needs `@shardflux/sdk` 0.15.0 or later.
9
26
 
@@ -30,12 +47,10 @@ Additive (contracts §46), with `@shardflux/sdk` 0.15.0.
30
47
  - `shard ws computer enable|disable|inherit <key>` and `shard ws open --computer-use on|off|inherit`: the workspace's
31
48
  computer use switch; `shard templates computer-use <slug> on|off`: the switch for every workspace of a template.
32
49
 
33
- ## 0.9.0 — 2026-10-04 (not yet published)
50
+ ## 0.9.0 — 2026-10-04
34
51
 
35
52
  - `shard ws hint <workspace>` explicitly stages a parked workspace ahead of a VM tool call, without waiting for restore or resuming a suspended workspace.
36
53
 
37
- ## 0.9.0 (not yet published)
38
-
39
54
  Needs `@shardflux/sdk` 0.14.0 or later.
40
55
 
41
56
  ### Sign up from an agent
@@ -115,7 +130,7 @@ workspace.
115
130
  API decides (with elastic-by-default enabled for the organization a new workspace is elastic; otherwise
116
131
  `requires_elastic`). With `--allocation-mode fixed` it stays a usage error.
117
132
 
118
- ## 0.8.1 (not yet published)
133
+ ## 0.8.1 — 2026-10-03
119
134
 
120
135
  Needs `@shardflux/sdk` 0.13.1 or later.
121
136
 
@@ -133,7 +148,7 @@ When the machine a workspace runs on fails, the workspace is suspended, and the
133
148
  `--timing` prints the SDK's `restored checkpoint <id> (host_lost; state as of <time>)`.
134
149
  - `hostLostNotice()` is exported next to `coldBootNotice()`.
135
150
 
136
- ## 0.8.0
151
+ ## 0.8.0 — 2026-10-02
137
152
 
138
153
  Needs `@shardflux/sdk` 0.13.0 or later.
139
154
 
@@ -206,7 +221,7 @@ newest published version.
206
221
  - **Breaking:** `--json` output no longer carries `update_policy`: the API removed the update policy (it was always
207
222
  `pinned`).
208
223
 
209
- ## 0.6.2
224
+ ## 0.6.2 — 2026-09-30
210
225
 
211
226
  Needs `@shardflux/sdk` 0.11.1 or later.
212
227
 
@@ -241,14 +256,12 @@ New organizations can start on a trial: 60 days at Developer limits, no card. Th
241
256
  - An interrupted file-first `ws exec` says `(an execution runs to completion)`; `mode_not_available` drops "yet";
242
257
  `--open` no longer says "best effort"; `templates init --help` no longer names an internal document.
243
258
 
244
- ## 0.7.0 (release candidate)
259
+ ## 0.7.0 — 2026-09-30
245
260
 
246
261
  Completed exec output streams are drained before releasing their HTTP connections, with a bounded cleanup if a peer does not close.
247
262
 
248
263
  Uses SDK 0.12.0 and its pooled transport. Adds --labels/--idle-policy to open, --labels to list, ws labels, ws idle, ws exec-start --stdin-open and ws exec-input --offset. Protocol-error JSON includes source. Resume recovers failed workspaces; completed deletion permits key reuse.
249
264
 
250
- Requires the QM integration backend release for labels, failed recovery, key reuse and pipe stdin. No production deployment has occurred from this branch.
251
-
252
265
  ### Instant suspend: durable storage
253
266
 
254
267
  - `shard ws suspend --wait` still returns when the suspend succeeds (sealed on its host); a finished suspend or fork
@@ -264,7 +277,7 @@ Requires the QM integration backend release for labels, failed recovery, key reu
264
277
  - A suspend-when-idle canceled `workspace_active` says the workspace was in use and keeps running.
265
278
  - The `ws suspend` summary no longer says "durable checkpoint".
266
279
 
267
- ## 0.6.1
280
+ ## 0.6.1 — 2026-09-30
268
281
 
269
282
  Needs `@shardflux/sdk` 0.11.0 or later.
270
283
 
@@ -276,7 +289,7 @@ flush grace after its last work), and a running command, an attached stream or a
276
289
  it ends. The human output says `is suspended as soon as it is idle`, and `ws get` shows `as soon as idle`. Out of
277
290
  range is still exit 2 before any request: `--when-idle must be between 0s and 1h`.
278
291
 
279
- ## 0.6.0
292
+ ## 0.6.0 — 2026-09-29
280
293
 
281
294
  Needs `@shardflux/sdk` 0.11.0 or later.
282
295
 
@@ -296,17 +309,17 @@ suspend; every process was restarted.
296
309
  processes restarted (runtime_changed)` (the SDK's `formatTiming()`).
297
310
  - `coldBootNotice()` is exported next to `TimingRecorder`, which records the call as `coldBoot`.
298
311
 
299
- ## 0.5.3
312
+ ## 0.5.3 — 2026-09-29
300
313
 
301
314
  Fix: `shard --version`, the User-Agent and the update check reports 0.5.3. 0.5.2 was published reporting 0.5.1, its previous version; the build now
302
315
  fails when `CLI_VERSION` differs from package.json.
303
316
 
304
- ## 0.5.2
317
+ ## 0.5.2 — 2026-09-29
305
318
 
306
319
  Public support: package metadata and the README now link to the [shared bug tracker](https://github.com/shardfluxdev/community/issues),
307
320
  feature requests, and private support/security reporting.
308
321
 
309
- ## 0.5.1
322
+ ## 0.5.1 — 2026-09-29
310
323
 
311
324
  ### A command that could not start is never a silent exit
312
325
 
@@ -356,7 +369,7 @@ cell had ended the session `failed_to_start` (`working directory "app" is not a
356
369
  `--cancel-when-idle` cancels it. `--json` prints `{workspace, operation, suspend_request}`.
357
370
  - `shard workspaces get` shows a pending request (`suspend when idle after N s idle, not before ...`).
358
371
 
359
- ## 0.5.0
372
+ ## 0.5.0 — 2026-09-29
360
373
 
361
374
  Needs `@shardflux/sdk` 0.9.0 or later.
362
375
 
@@ -529,13 +542,12 @@ unchanged and still stores nothing.
529
542
  edit that does not match, a mode the workspace lacks, a reused execution id), and `shard feedback` itself.
530
543
  - The positional usage of `shard feedback` is shown as `<message...>`; `--agent-label` also names the reporting agent.
531
544
 
532
-
533
- ## 0.4.1 (2026-09-28)
545
+ ## 0.4.1 — 2026-09-28
534
546
 
535
547
  Needs `@shardflux/sdk` 0.8.0 or later. No command changes: the SDK's 0.8.0 is a types-only release (its provider tool
536
548
  exports type-check without casts), and the CLI moves to it so `shardflux` installs one copy of the SDK.
537
549
 
538
- ## 0.4.0 (2026-09-28)
550
+ ## 0.4.0 — 2026-09-28
539
551
 
540
552
  Needs `@shardflux/sdk` 0.7.0 or later. New dependency: `yaml` (template.yaml).
541
553
 
@@ -563,7 +575,7 @@ Needs `@shardflux/sdk` 0.7.0 or later. New dependency: `yaml` (template.yaml).
563
575
  - `buildText` names a recipe v2 build's recipe sha256, its build network and denied hosts, and points at
564
576
  `shard templates builds get`.
565
577
 
566
- ## 0.3.1 (2026-09-28)
578
+ ## 0.3.1 — 2026-09-28
567
579
 
568
580
  Needs `@shardflux/sdk` 0.6.2 or later.
569
581
 
@@ -580,7 +592,7 @@ stays suspended).
580
592
  - `shard workspaces get` names the origin of a template version test instance (`template <id> version <n>`, contracts
581
593
  §24.6) instead of reading it as a draft state.
582
594
 
583
- ## 0.3.0 (2026-09-28)
595
+ ## 0.3.0 — 2026-09-28
584
596
 
585
597
  Needs `@shardflux/sdk` 0.6.0 or later.
586
598
 
@@ -609,13 +621,13 @@ Needs `@shardflux/sdk` 0.6.0 or later.
609
621
  - Ctrl-C ends a held open at once (exit 130); the open continues server side, and opening the key again keeps
610
622
  waiting.
611
623
 
612
- ## 0.2.0 (2026-09-28)
624
+ ## 0.2.0 — 2026-09-28
613
625
 
614
626
  Secrets (`secrets ...`, `workspaces open --secret`, `workspaces secrets`), session workspaces (`--lifetime session`,
615
627
  `workspaces close`), layered workspaces (`workspaces reset|save-as-template|changes`), templates
616
628
  (`templates list|files|diff|draft ...`), and waking suspended workspaces on use (`--no-wake`, `--wake-timeout`).
617
629
 
618
- ## 0.1.0 (2026-09-26)
630
+ ## 0.1.0 — 2026-09-26
619
631
 
620
632
  First release: `login`, `whoami`, `usage`, `workspaces open|list|get|exec|suspend|resume|fork|delete|sessions`,
621
633
  `files read|write|ls`, `operations list|get|wait`, `--json`, and documented exit codes.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @shardflux/cli: `shard`
2
2
 
3
- The command line for [Shardflux](https://shardflux.dev): cloud computers for AI agents. Open a
3
+ The command line for [Shardflux](https://shardflux.dev): serverless VMs for AI agents. Open a
4
4
  persistent workspace by key, run commands in it, move files in and out, and suspend, resume, fork
5
5
  or delete it. From 0.5.0 it also does everything the web app does: create an account, sign in
6
6
  (with two-factor authentication), set up an organization, project and API key, manage members,
@@ -24,6 +24,20 @@ For usage, account, billing, or private support, email [shardflux@heliosone.fi](
24
24
  Report vulnerabilities through [private security reporting](https://github.com/shardfluxdev/community/security/advisories/new).
25
25
  See the [support guide](https://github.com/shardfluxdev/community/blob/main/SUPPORT.md) for all reporting options.
26
26
 
27
+ ## Reverse tunnels (0.11.0+)
28
+
29
+ Run a foreground TCP tunnel from a workspace to a service on this machine:
30
+
31
+ ```sh
32
+ shard ws tunnel customer-42/main --remote 8081 --to localhost:8081
33
+ ```
34
+
35
+ Commands in the workspace can then call `http://127.0.0.1:8081`. The target is dialed on the machine running
36
+ `shard`. Ctrl-C closes the sockets and guest process. `--bind 0.0.0.0` selects all guest interfaces; loopback is the
37
+ default. An open tunnel keeps the workspace awake, and persistent workspaces preserve it across explicit suspend/resume.
38
+
39
+ The tunnel selects a streaming transport automatically when `pty` permission is granted. Choose `transport: 'pty'` or `transport: 'exec'` in the SDK, or `--transport pty|exec` in the CLI, to select it explicitly. The handle reports `transport`.
40
+
27
41
  ## Install
28
42
 
29
43
  ```sh
@@ -1022,3 +1036,44 @@ Use the byte offset in each acknowledgement, including partial writes. `ws resum
1022
1036
  ### Prepare an imminent command (0.9.0+)
1023
1037
 
1024
1038
  Call `shard ws hint <workspace>` ahead of a tool call to stage a parked workspace while the input is prepared. It returns without waiting for restore. Resume a suspended workspace first. The hint is explicit; ordinary commands add no early hint.
1039
+
1040
+ Resize controls (0.11.0+): `shard ws resize <workspace> --memory 6144 --disk 2 --at-least --no-wait` grows only
1041
+ resources that need more capacity and returns the held server answer with the operation. The normal command waits
1042
+ for completion. Fixed memory without `--memory-mib` uses the template default.
1043
+
1044
+ ## New in 0.11.0
1045
+
1046
+ `shard ws computer stream <workspace>` reuses a private viewer link with more than 60 seconds left across CLI invocations. `--fresh` mints a new link, and `--stop` clears it. Canceled exec commands exit 130, timeouts exit 124, and signal exits use 128 + signal. `SHARDFLUX_BASE_URL` aliases `SHARDFLUX_API_URL`; the latter and `--api-url` take precedence. The SDK transport honors environment HTTP(S)_PROXY and NO_PROXY.
1047
+
1048
+ ## Embed a live viewer (0.11.0+)
1049
+
1050
+ Watch the desktop or a port preview inside your web app. Choose 1–10 distinct HTTPS origins, then use the returned URL as the iframe source.
1051
+
1052
+ ```bash
1053
+ shard ws computer stream acme/demo --embed-origin https://app.example.com
1054
+ shard ws port link acme/demo 3000 --embed-origin https://app.example.com --embed-origin https://admin.example.com
1055
+ ```
1056
+
1057
+ ```html
1058
+ <iframe src="RETURNED_LINK_URL" title="Watch the agent" style="width:100%;height:600px;border:0"></iframe>
1059
+ ```
1060
+
1061
+ The browser session is partitioned by the top-level site. Each origin is exactly a scheme and host with an optional port, such as `https://app.example.com:8443`. The viewer permits framing by those origins; interactive viewing uses the same allowlist.
1062
+
1063
+ ## Idle deletion retention (0.11.0+)
1064
+
1065
+ ```sh
1066
+ shard ws open chat/42 --template python-node-browser --delete-after-idle 14d
1067
+ shard ws retention chat/42 30d
1068
+ shard projects retention PROJECT_ID 14d --labels '{"app":"chat"}'
1069
+ shard projects retention PROJECT_ID
1070
+ shard ws retention chat/42 off
1071
+ ```
1072
+
1073
+ Use 1d through 3650d with persistent workspaces. `off` removes the workspace's own policy and follows its
1074
+ project default; `shard projects retention PROJECT_ID off` clears that default. All selector labels match
1075
+ exactly, and a workspace's own setting wins. With no policy, workspaces stay until you delete them.
1076
+
1077
+ ## Workspace upgrade (0.11.0+)
1078
+
1079
+ `shard ws upgrade <key>` keeps files and installed package files through one cold start. Memory and processes end. Use `--at next-resume` to schedule it without stopping the VM; `--json` returns the operation and its applied/scheduled result.
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.10.0";
1
+ export declare const CLI_VERSION = "0.11.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.10.0';
15
+ export const CLI_VERSION = '0.11.0';
16
16
  export const GLOBAL_OPTIONS = {
17
17
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
18
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -200,6 +200,17 @@ const KEY_COMMANDS = [
200
200
  },
201
201
  examples: ['shard usage', 'shard usage --estimate', 'shard usage --series --granularity day', 'shard usage --workspace acme/demo --meter cpu_seconds'],
202
202
  },
203
+ {
204
+ path: ['workspaces', 'retention'], summary: 'Set or clear persistent workspace idle deletion',
205
+ positionals: [{ name: 'key', required: true }, { name: 'policy', required: true }], options: {},
206
+ examples: ['shard ws retention acme/thread-42 14d', 'shard ws retention acme/thread-42 off'],
207
+ },
208
+ {
209
+ path: ['projects', 'retention'], summary: 'Read or set the project default idle deletion policy (project key)',
210
+ positionals: [{ name: 'project-id', required: true }, { name: 'policy', required: false }],
211
+ options: { labels: { type: 'string', value: '<json>', description: 'Exact-match label selectors.' } },
212
+ examples: ["shard projects retention <project-id> 14d --labels '{\"app\":\"chat\"}'"],
213
+ },
203
214
  {
204
215
  path: ['workspaces', 'open'],
205
216
  summary: 'Open a workspace by key (create on first use, reconnect or resume afterwards)',
@@ -208,6 +219,7 @@ const KEY_COMMANDS = [
208
219
  options: {
209
220
  template: { type: 'string', value: '<slug>', description: 'Template slug (required), e.g. python-node-browser.' },
210
221
  labels: { type: 'string', value: '<json>', description: 'Replace workspace labels with a JSON object of strings.' },
222
+ 'delete-after-idle': { type: 'string', value: '<days>d', description: 'Delete this persistent workspace after 1..3650 idle days (e.g. 14d).' },
211
223
  'idle-policy': { type: 'string', value: '<policy>', description: 'adaptive, never or fixed:<seconds>.' },
212
224
  secret: {
213
225
  type: 'string',
@@ -294,6 +306,17 @@ const KEY_COMMANDS = [
294
306
  },
295
307
  examples: ['shard ws exec-start acme/demo --session-id build-1 --timeout 2h -- bash -lc "make -C /home/user/app"', 'shard ws exec-read acme/demo build-1 --follow'],
296
308
  },
309
+ {
310
+ path: ['workspaces', 'tunnel'], summary: 'Reach this machine from a workspace TCP port',
311
+ description: 'Runs in the foreground. The guest listener defaults to loopback; --to is dialed on this machine. Ctrl-C closes the tunnel. An open tunnel keeps the workspace awake.',
312
+ positionals: [REF], options: {
313
+ transport: { type: 'string', value: '<auto|pty|exec>', description: 'Tunnel transport (default auto).' },
314
+ remote: { type: 'string', value: '<port>', description: 'Required guest listener port (1..65535).' },
315
+ to: { type: 'string', value: '<host:port>', description: 'Required TCP target on this machine (host:port or [IPv6]:port).' },
316
+ bind: { type: 'string', value: '<127.0.0.1|0.0.0.0>', description: 'Guest bind address (default 127.0.0.1).' },
317
+ },
318
+ examples: ['shard ws tunnel acme/demo --remote 8081 --to localhost:8081'],
319
+ },
297
320
  {
298
321
  path: ['workspaces', 'exec-read'],
299
322
  summary: 'Print a background exec session’s output and state; --follow streams it until the command exits',
@@ -387,6 +410,11 @@ const KEY_COMMANDS = [
387
410
  positionals: [REF, { name: 'new-key', required: true }],
388
411
  options: { ...CAPS, ...MEMORY_ALLOCATION, ...WAIT },
389
412
  },
413
+ {
414
+ path: ['workspaces', 'upgrade'], summary: 'Upgrade in place, keeping files and installed packages',
415
+ description: 'One cold start drops memory and processes. --at next-resume schedules it without stopping the VM.',
416
+ positionals: [REF], options: { ...WAIT, at: { type: 'string', value: '<now|next-resume>', description: 'When to cold start (default now).' } },
417
+ },
390
418
  {
391
419
  path: ['workspaces', 'resize'],
392
420
  summary: 'Change a workspace’s memory, CPU or disk, running or suspended, without a restart (0.9.0+)',
@@ -399,6 +427,8 @@ const KEY_COMMANDS = [
399
427
  elastic: { type: 'boolean', description: 'Switch to elastic memory: the workspace holds what its commands use and grows up to the memory size.' },
400
428
  cpu: { type: 'string', value: '<millis>', description: 'CPU in millicores (1000 = one vCPU).' },
401
429
  disk: { type: 'string', value: '<gib>', description: 'Disk in GiB (disks grow only).' },
430
+ 'at-least': { type: 'boolean', description: 'Only grow each named resource; smaller or equal values stay unchanged.' },
431
+ wait: { type: 'boolean', description: 'Wait for the operation (default); --no-wait returns the held server answer.' },
402
432
  timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m); the resize continues server side.' },
403
433
  ...TIMING,
404
434
  },
@@ -493,6 +523,7 @@ const KEY_COMMANDS = [
493
523
  description: 'Opening the link starts a browser session for the port (until it expires) and lands on --path. Anyone with the link can open the port until then: share it like a password. --json prints {url, expires_at}.',
494
524
  positionals: [REF, PORT],
495
525
  options: {
526
+ 'embed-origin': { type: 'string', multiple: true, value: '<https-origin>', description: 'Allow this HTTPS origin to embed the viewer (repeatable, 1–10 distinct origins).' },
496
527
  ttl: { type: 'string', value: '<duration>', description: 'Lifetime of the link and its browser session, 1m to 7d (default 24h).' },
497
528
  path: { type: 'string', value: '<path>', description: 'Where the link lands: a path starting with one / (query allowed; default /).' },
498
529
  },
@@ -660,7 +691,9 @@ const KEY_COMMANDS = [
660
691
  description: 'Starts the viewer in the workspace, exposes its port and signs a link to it (inbound ports). View only unless --interactive. Anyone with the link can open it until it expires: share it like a password. --stop ends every view instead. --json prints {url, expires_at, port, interactive}.',
661
692
  positionals: [REF],
662
693
  options: {
694
+ fresh: { type: 'boolean', description: 'Mint a new viewer link instead of reusing a link with more than 60 seconds left.' },
663
695
  interactive: { type: 'boolean', description: 'Let the viewer use the mouse and keyboard.' },
696
+ 'embed-origin': { type: 'string', multiple: true, value: '<https-origin>', description: 'Allow this HTTPS origin to embed the viewer (repeatable, 1–10 distinct origins).' },
664
697
  ttl: { type: 'string', value: '<duration>', description: 'Lifetime of the link, 1m to 7d (default 24h).' },
665
698
  stop: { type: 'boolean', description: 'End every view and close the viewer ports.' },
666
699
  },
@@ -99,7 +99,9 @@ export declare function principal(ctx: Ctx): Promise<{
99
99
  tool_permissions: ("git" | "exec" | "files" | "pty" | "process" | "browser" | "computer")[];
100
100
  }>;
101
101
  /** The exit code `shard exec` returns for a finished command. */
102
- export declare function execExitCode(r: Pick<RunResult, 'exitCode' | 'termSignal' | 'timedOut'>): number;
102
+ export declare function execExitCode(r: Pick<RunResult, 'exitCode' | 'termSignal' | 'timedOut'> & {
103
+ canceled?: boolean;
104
+ }): number;
103
105
  /**
104
106
  * A lifecycle call a file-first workspace does not have (suspend, resume, fork, reset,
105
107
  * save-as-template), refused before any request as the SDK's workspace handle does: NotSupportedForModeError, printed
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
  /**
@@ -493,13 +498,7 @@ function feedbackContext(ctx, values) {
493
498
  }
494
499
  /** The exit code `shard exec` returns for a finished command. */
495
500
  export function execExitCode(r) {
496
- if (r.timedOut)
497
- return 124;
498
- if (typeof r.exitCode === 'number' && r.exitCode >= 0)
499
- return Math.min(r.exitCode, 255);
500
- if (typeof r.termSignal === 'number' && r.termSignal > 0)
501
- return 128 + r.termSignal;
502
- return 1;
501
+ return Math.min(exitCodePosix(r), 255);
503
502
  }
504
503
  /** The longest cancel grace the workspace applies (it waits 5 s without one). */
505
504
  const MAX_CANCEL_GRACE_MS = 60_000;
@@ -860,6 +859,35 @@ export const KEY_HANDLERS = {
860
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`);
861
860
  return 0;
862
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
+ },
863
891
  async 'workspaces exec-read'(ctx, values, [ref, sessionId]) {
864
892
  const ws = await resolveWorkspace(ctx, ref);
865
893
  const cell = cellOf(ctx, ws);
@@ -904,7 +932,7 @@ export const KEY_HANDLERS = {
904
932
  if (s.state === 'starting' || s.state === 'running') {
905
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`);
906
934
  }
907
- 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 };
908
936
  if (!ctx.json && result.exitCode === null && !result.timedOut && !(result.termSignal !== null && result.termSignal > 0)) {
909
937
  ctx.io.stderr.write(`exec session ${s.session_id} ended ${s.state} without an exit code${s.error ? `: ${s.error}` : ''}\n`);
910
938
  }
@@ -931,6 +959,22 @@ export const KEY_HANDLERS = {
931
959
  out(ctx, JSON.stringify(ack) + '\n', ack);
932
960
  return 0;
933
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
+ },
934
978
  async 'workspaces open'(ctx, values, [key]) {
935
979
  const template = str(values.template);
936
980
  if (!template)
@@ -954,6 +998,7 @@ export const KEY_HANDLERS = {
954
998
  key: key,
955
999
  template,
956
1000
  ...(str(values.labels) !== undefined ? { labels: labelMap(str(values.labels)) } : {}),
1001
+ ...(str(values['delete-after-idle']) !== undefined ? { retention: openRetentionDays(str(values['delete-after-idle'])) } : {}),
957
1002
  ...(str(values['idle-policy']) ? { idlePolicy: str(values['idle-policy']) } : {}),
958
1003
  ...(c ? { caps: c } : {}),
959
1004
  ...(bind.length ? { secrets: bind } : {}),
@@ -1301,6 +1346,12 @@ export const KEY_HANDLERS = {
1301
1346
  async 'workspaces close'(ctx, values, [ref]) {
1302
1347
  return lifecycle(ctx, values, ref, 'Closing', (ws, o) => ctx.cloud().workspaces.close(ws.id, o));
1303
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
+ },
1304
1355
  async 'workspaces reset'(ctx, values, [ref]) {
1305
1356
  if (values.yes !== true)
1306
1357
  throw new UsageError('refusing to reset without --yes (reset wipes every change in the workspace and restarts it on its template)');
@@ -1860,7 +1911,7 @@ export const KEY_HANDLERS = {
1860
1911
  if (path !== undefined && !path.startsWith('/'))
1861
1912
  throw new UsageError(`--path must start with / (got "${path}")`);
1862
1913
  const ws = await portWorkspace(ctx, ref);
1863
- 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 } : {}) });
1864
1915
  out(ctx, `${l.url}\n`, { url: l.url, expires_at: l.expiresAt });
1865
1916
  if (!ctx.json)
1866
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`);
@@ -2004,13 +2055,20 @@ export const KEY_HANDLERS = {
2004
2055
  },
2005
2056
  async 'workspaces computer stream'(ctx, values, [ref]) {
2006
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);
2007
2060
  if (values.stop === true) {
2061
+ cache.clear();
2008
2062
  await ws.computer.stopStream();
2009
2063
  out(ctx, `Ended every view of the desktop of ${ws.key}.\n`, { workspace_id: ws.id, stopped: true });
2010
2064
  return 0;
2011
2065
  }
2012
2066
  const ttl = ttlSeconds(values, PORT_LINK_TTL);
2013
- const s = await ws.computer.stream({ interactive: values.interactive === true, ...(ttl !== undefined ? { ttlSeconds: 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);
2014
2072
  out(ctx, `${s.url}\n`, { url: s.url, expires_at: s.expiresAt, port: s.port, interactive: s.interactive });
2015
2073
  if (!ctx.json) {
2016
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`);
@@ -2334,3 +2392,18 @@ function labelMap(value) {
2334
2392
  throw new UsageError('labels must be a JSON object of strings');
2335
2393
  return parsed;
2336
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.10.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.15.0"
47
+ "@shardflux/sdk": "^0.16.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@eslint/js": "10.0.1",