@shardflux/cli 0.3.1 → 0.4.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 +29 -1
- package/README.md +58 -3
- package/dist/args.d.ts +1 -1
- package/dist/args.js +121 -4
- package/dist/commands.js +330 -2
- package/dist/errors.js +11 -2
- package/dist/format.d.ts +15 -1
- package/dist/format.js +79 -5
- package/dist/template-yaml.d.ts +7 -0
- package/dist/template-yaml.js +62 -0
- package/dist/timing.d.ts +29 -1
- package/dist/timing.js +96 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,7 +3,35 @@
|
|
|
3
3
|
Every command and option the README shows is available from the version named here. Below 1.0, a minor release may
|
|
4
4
|
break compatibility; breaking changes are marked **Breaking**.
|
|
5
5
|
|
|
6
|
-
## 0.
|
|
6
|
+
## 0.4.0 (not yet published; npm `latest` is 0.3.1)
|
|
7
|
+
|
|
8
|
+
Needs `@shardflux/sdk` 0.7.0 or later. New dependency: `yaml` (template.yaml).
|
|
9
|
+
|
|
10
|
+
### Template editor (contracts §24)
|
|
11
|
+
|
|
12
|
+
- `shard templates build <file> [--slug] [--display-name] [--description] [--publish] [--acknowledge PATH]... [--wait]
|
|
13
|
+
[--timeout] [--timing]`: builds a template from template.yaml. Local `from` files and folders are uploaded (folders
|
|
14
|
+
as the reproducible tar the SDKs pack; bytes the organization has are not sent again), then the recipe v2 build is
|
|
15
|
+
created. Progress on stderr; `--wait` follows the build (exit 6 failed or canceled, 5 timeout); `--timing` prints
|
|
16
|
+
packing, uploads, the request, the wait and the build's queued and run time (a `timing` field with `--json`). The
|
|
17
|
+
slug defaults to the directory holding the file. The version stays unpublished unless `--publish`.
|
|
18
|
+
- `shard templates init --base <slug[@version]> [--out] [--force]`: a starter template.yaml.
|
|
19
|
+
- `shard templates export <slug>@<version> [--out]`: the template.yaml a version was built from (exit 3 for a version
|
|
20
|
+
without a recipe).
|
|
21
|
+
- `shard templates get <slug>`: versions, what open resolves to, the open version's settings.
|
|
22
|
+
- `shard templates test <slug>@<version> [--input NAME=value]...`: a test instance of a version, published or not.
|
|
23
|
+
- `shard templates builds list|get|log|cancel`.
|
|
24
|
+
- `shard templates languages --base <slug>@<version>` and `shard templates packages search <apt|pip|npm> <query>`.
|
|
25
|
+
- `shard workspaces open --input NAME=value` (repeatable) and `--input-file` (NAME=value lines or a JSON object);
|
|
26
|
+
`shard workspaces inputs <ws>`.
|
|
27
|
+
- `shard workspaces get` (and the ready output of `open`) shows the startup of the template's start commands and
|
|
28
|
+
services; a failed step shows its exit code, reason and output tail.
|
|
29
|
+
- A template file `shard` cannot read or pack is a usage error (exit 2); a storage refusal of an upload is
|
|
30
|
+
`upload_failed` (exit 1); a build wait that runs out is `timeout` (exit 5).
|
|
31
|
+
- `buildText` names a recipe v2 build's recipe sha256, its build network and denied hosts, and points at
|
|
32
|
+
`shard templates builds get`.
|
|
33
|
+
|
|
34
|
+
## 0.3.1 (2026-09-28)
|
|
7
35
|
|
|
8
36
|
Needs `@shardflux/sdk` 0.6.2 or later.
|
|
9
37
|
|
package/README.md
CHANGED
|
@@ -56,9 +56,10 @@ every run.
|
|
|
56
56
|
| `login`, `whoami` | Show the principal behind the key. |
|
|
57
57
|
| `version` | Print the CLI and SDK versions. |
|
|
58
58
|
| `usage [--org <id>]` | Usage summary for the current period: plan, meters and allowances. |
|
|
59
|
-
| `workspaces open <key> --template <slug> [--secret NAME]... [--lifetime persistent\|session] [--cpu-millis N --memory-mib N --disk-gib N] [--no-wait] [--timeout 5m] [--timing]` | Open by key. Creates the workspace on first use and reconnects or resumes it afterwards, never resetting it. Waits until it is ready unless you pass `--no-wait`. `--secret` binds secret names (replacing the binding of an existing workspace). `--lifetime session`: the workspace is discarded when the session ends (`workspaces close` or the idle timeout), and the key then opens a new workspace. |
|
|
59
|
+
| `workspaces open <key> --template <slug> [--secret NAME]... [--input NAME=value]... [--input-file F] [--lifetime persistent\|session] [--cpu-millis N --memory-mib N --disk-gib N] [--no-wait] [--timeout 5m] [--timing]` | Open by key. Creates the workspace on first use and reconnects or resumes it afterwards, never resetting it. Waits until it is ready (the template's start commands and services included) unless you pass `--no-wait`. `--secret` binds secret names (replacing the binding of an existing workspace). `--input` (0.4.0+) passes the template's text inputs (replacing those of an existing workspace); `--input-file` reads `NAME=value` lines or a JSON object. `--lifetime session`: the workspace is discarded when the session ends (`workspaces close` or the idle timeout), and the key then opens a new workspace. |
|
|
60
60
|
| `workspaces list [--state S] [--prefix P] [--lifetime persistent\|session\|any] [--purpose standard\|template_draft\|template_test\|any] [--include-deleted] [--all] [--limit N] [--cursor C]` | One page, or every page with `--all`. The next cursor is printed on stderr. By default only persistent standard workspaces are listed. |
|
|
61
|
-
| `workspaces get <id\|key>` | One workspace (deleted ones included). |
|
|
61
|
+
| `workspaces get <id\|key>` | One workspace (deleted ones included), with the startup of its template's start commands and services (0.4.0+): a failed step shows its exit code, reason and output tail. |
|
|
62
|
+
| `workspaces inputs <id\|key>` | The workspace's text inputs (0.4.0+). |
|
|
62
63
|
| `workspaces exec <id\|key> [--cwd D] [--env K=V]... [--timeout T] [--stdin F\|-] -- <cmd> [args...]` | Runs argv with no shell. Output is streamed and resumes from byte offsets after dropped connections. `shard` exits with the command's exit code. Ctrl-C cancels the command. |
|
|
63
64
|
| `files read <id\|key> <path> [--out F]` | Raw bytes to stdout, or to a local file. |
|
|
64
65
|
| `files write <id\|key> <path> [--from F\|-] [--append] [--no-parents]` | Atomic write. The default source is stdin. |
|
|
@@ -79,6 +80,14 @@ every run.
|
|
|
79
80
|
| `secrets versions <id\|NAME>`, `secrets delete <id\|NAME> --yes`, `secrets access-log <id\|NAME>` | Versions, deletion (also removes the name from every workspace binding), and the access log. |
|
|
80
81
|
| `operations list <id\|key> [--state S] [--kind K]`, `operations get <op>`, `operations wait <op> [--timeout T] [--timing]` | Operations. |
|
|
81
82
|
| `templates list [--owner platform\|organization] [--include-archived] [--all]` | Templates the project can open, with their open version, layouts, file list state and draft. |
|
|
83
|
+
| `templates get <slug> [--owner O] [--include-archived]` | One template (0.4.0+): its versions, what open resolves to, and the open version's settings (env, inputs, start commands, services, defaults). |
|
|
84
|
+
| `templates init --base <slug[@version]> [--out F] [--force]` | Write a starter `template.yaml` on a base (0.4.0+). |
|
|
85
|
+
| `templates build <file> [--slug S] [--display-name N] [--description T] [--publish] [--acknowledge PATH]... [--wait] [--timeout 30m] [--timing]` | Build a template from `template.yaml` (0.4.0+): local files and folders are uploaded, then the build is created. See [Build a template](#build-a-template-from-templateyaml). |
|
|
86
|
+
| `templates export <slug>@<version> [--out F] [--force]` | The `template.yaml` a version was built from, ready to build again (0.4.0+). |
|
|
87
|
+
| `templates test <slug>@<version> [--input NAME=value]... [--instance-key K] [caps] [--no-wait] [--timing]` | A test instance of a version, published or not: a session workspace that runs its start commands and services (0.4.0+). |
|
|
88
|
+
| `templates builds list [--template S] [--state S] [--all]`, `templates builds get\|log\|cancel <build-id>` | The organization's template builds, one build, its full log (`--out F`), or cancel it (0.4.0+). |
|
|
89
|
+
| `templates languages --base <slug>@<version>` | The languages and versions a base offers `build.languages` (0.4.0+). |
|
|
90
|
+
| `templates packages search <apt\|pip\|npm> <query> [--base slug@version] [--limit N]` | Package names for `build.packages` (0.4.0+). apt searches the base's index and needs `--base`. |
|
|
82
91
|
| `templates files <slug> <version> [path] [--stat] [--owner O] [--all] [--limit N]` | One directory of a version's file tree, or one entry with `--stat`. |
|
|
83
92
|
| `templates diff <slug> --from <version\|base> --to <version> [--prefix P] [--change K] [--all]` | Diff two versions: added, removed, changed, type_changed, metadata. The totals come first. |
|
|
84
93
|
| `templates draft open <slug> [--base slug@version] [caps] [--no-wait]` | Open the template's draft, a layered workspace you edit with `workspaces exec`/`files`. |
|
|
@@ -94,6 +103,51 @@ is a UUID. Otherwise, or if no workspace has that id, it is matched as an exact
|
|
|
94
103
|
and purpose, so sessions, drafts and test instances resolve too. The live workspace wins over tombstones that ended
|
|
95
104
|
sessions left with the same key.
|
|
96
105
|
|
|
106
|
+
## Build a template from template.yaml
|
|
107
|
+
|
|
108
|
+
(0.4.0+) A template is a recipe v2: a base, what the build adds (languages, packages, files, build steps) and the
|
|
109
|
+
settings a workspace gets when it opens (environment, inputs, start commands, services). `template.yaml` is that
|
|
110
|
+
document; a file entry may name a local `from` path (relative to the file), which `shard` uploads.
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
shard templates init --base ubuntu-24.04 # writes template.yaml on the base's current version
|
|
114
|
+
$EDITOR template.yaml
|
|
115
|
+
shard templates languages --base ubuntu-24.04@1 # what build.languages offers there
|
|
116
|
+
shard templates packages search apt ffmpeg --base ubuntu-24.04@1
|
|
117
|
+
shard templates build template.yaml --slug acme-dev --wait --timing
|
|
118
|
+
shard templates test acme-dev@1 --input PROJECT_NAME=demo
|
|
119
|
+
shard templates build template.yaml --slug acme-dev --publish --wait
|
|
120
|
+
shard ws open acme/main --template acme-dev --input PROJECT_NAME=acme
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
base: ubuntu-24.04@1
|
|
125
|
+
build:
|
|
126
|
+
languages: [{ id: python }]
|
|
127
|
+
packages: { apt: [jq], pip: { packages: [pandas==2.3.2] } }
|
|
128
|
+
files:
|
|
129
|
+
- { from: ./app, to: /home/user/app, owner: user } # a folder: uploaded as a tar
|
|
130
|
+
- { from: ./settings.toml, to: /home/user/.config/acme/settings.toml, owner: user, mode: "0600" }
|
|
131
|
+
steps:
|
|
132
|
+
- { name: install, run: pip install -e /home/user/app, user: user }
|
|
133
|
+
settings:
|
|
134
|
+
env: { APP_ENV: development }
|
|
135
|
+
inputs: { PROJECT_NAME: { kind: text, required: true } }
|
|
136
|
+
start: [{ name: seed, when: create, run: python -m app.seed, user: user, cwd: /home/user/app }]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- Folders are packed as a reproducible tar (the same bytes as the SDKs pack), so the same folder gives the same
|
|
140
|
+
upload and the same `recipe_sha256`. Bytes the organization already has are not sent again. Progress lines go to
|
|
141
|
+
stderr (`packed`, `uploaded` or `already uploaded`, then the build's states with `--wait`).
|
|
142
|
+
- The version stays unpublished unless you pass `--publish`: try it with `shard templates test <slug>@<version>`.
|
|
143
|
+
Without `--slug` the template slug is the name of the directory holding the file.
|
|
144
|
+
- `--wait` follows the build until it is registered or fails (exit 6 when it failed or was canceled, 5 when
|
|
145
|
+
`--timeout` ran out; the build continues). A build whose network refused a host lists it under `denied hosts`: add
|
|
146
|
+
it to `build.network.extra_hosts` and build again.
|
|
147
|
+
- `--timing` prints where the time went: packing, uploads, the request, the wait, and the build's queued and run time.
|
|
148
|
+
- A file `shard` cannot read or pack (a symlink that leaves the folder, a device) is refused before any request (exit
|
|
149
|
+
2). The API's refusals (`upload_missing`, `language_unavailable`, `invalid_settings`, ...) exit 2 with the reason.
|
|
150
|
+
|
|
97
151
|
## Suspended workspaces
|
|
98
152
|
|
|
99
153
|
`workspaces exec`, `files read|write|ls` and `workspaces changes` wake a suspended workspace: they
|
|
@@ -154,7 +208,8 @@ Here the time went to waiting for a host with capacity; the network and the VM s
|
|
|
154
208
|
|
|
155
209
|
Which call is timed:
|
|
156
210
|
|
|
157
|
-
- `workspaces open` and `templates draft open|test`: the open, including the wait unless `--no-wait`.
|
|
211
|
+
- `workspaces open`, `templates test` and `templates draft open|test`: the open, including the wait unless `--no-wait`.
|
|
212
|
+
- `templates build` (0.4.0+): packing, uploads, the request and the wait, then the build's own queued and run time.
|
|
158
213
|
- `workspaces suspend|resume|fork|delete|close|reset` and `templates draft state|discard`: with `--wait`, the request
|
|
159
214
|
and the whole wait; without it, the request.
|
|
160
215
|
- `operations wait`: the wait.
|
package/dist/args.d.ts
CHANGED
package/dist/args.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { parseArgs } from 'node:util';
|
|
12
12
|
import { UsageError } from "./errors.js";
|
|
13
|
-
export const CLI_VERSION = '0.
|
|
13
|
+
export const CLI_VERSION = '0.4.0';
|
|
14
14
|
export const GLOBAL_OPTIONS = {
|
|
15
15
|
'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
|
|
16
16
|
json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
|
|
@@ -58,6 +58,17 @@ const SAVE = {
|
|
|
58
58
|
wait: { type: 'boolean', description: 'Wait until the build is published and registered (or fails).' },
|
|
59
59
|
timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 30m). The build continues server side.' },
|
|
60
60
|
};
|
|
61
|
+
/** Open-time text inputs of a template version (0.4.0; contracts §24.3). */
|
|
62
|
+
const INPUTS = {
|
|
63
|
+
input: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
multiple: true,
|
|
66
|
+
value: '<NAME=value>',
|
|
67
|
+
description: 'Text input of the template version (repeatable): in the environment of every exec, terminal, start command and service. Replaces the inputs of an existing workspace; omit to leave them unchanged. Not for credentials: those are secret inputs (stored secrets).',
|
|
68
|
+
},
|
|
69
|
+
'input-file': { type: 'string', value: '<file>', description: 'Read text inputs from a file: NAME=value lines (# comments), or a JSON object. --input wins for a name in both.' },
|
|
70
|
+
};
|
|
71
|
+
const BUILD_ID = { name: 'build-id', required: true };
|
|
61
72
|
const LIFETIME_FILTER = {
|
|
62
73
|
lifetime: { type: 'string', value: '<persistent|session|any>', description: 'Lifetime filter (default persistent: sessions are hidden).' },
|
|
63
74
|
purpose: { type: 'string', value: '<standard|template_draft|template_test|any>', description: 'Purpose filter (default standard: template drafts and test instances are hidden).' },
|
|
@@ -109,14 +120,16 @@ export const COMMANDS = [
|
|
|
109
120
|
value: '<persistent|session>',
|
|
110
121
|
description: 'session: the workspace is discarded when the session ends ("shard ws close" or the idle timeout) and the key then opens a new one. Default: the template’s default, else persistent.',
|
|
111
122
|
},
|
|
123
|
+
...INPUTS,
|
|
112
124
|
...CAPS,
|
|
113
|
-
wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
|
|
125
|
+
wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once). Ready includes the template’s start commands and services.' },
|
|
114
126
|
timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m). The start continues server side; one waiting for capacity gives up 15 minutes after it began (capacity_unavailable, retryable).' },
|
|
115
127
|
...TIMING,
|
|
116
128
|
},
|
|
117
129
|
examples: [
|
|
118
130
|
'shard workspaces open acme/demo --template python-node-browser',
|
|
119
131
|
'shard ws open job-42 --template python-node-browser --lifetime session',
|
|
132
|
+
'shard ws open acme/demo --template acme-notebook --input PROJECT_NAME=demo --input LOG_LEVEL=debug',
|
|
120
133
|
'shard ws open acme/demo --template python-node-browser --secret OPENAI_API_KEY --secret DATABASE_URL',
|
|
121
134
|
'shard ws open acme/demo --template python-node-browser --no-wait --json',
|
|
122
135
|
'shard ws open acme/demo --template python-node-browser --timing',
|
|
@@ -136,7 +149,8 @@ export const COMMANDS = [
|
|
|
136
149
|
cursor: { type: 'string', value: '<cursor>', description: 'Continue from a previous page.' },
|
|
137
150
|
},
|
|
138
151
|
},
|
|
139
|
-
{ path: ['workspaces', 'get'], summary: 'Show one workspace', positionals: [REF], options: {} },
|
|
152
|
+
{ path: ['workspaces', 'get'], summary: 'Show one workspace (state, template, startup)', positionals: [REF], options: {} },
|
|
153
|
+
{ path: ['workspaces', 'inputs'], summary: 'Show the text inputs of a workspace (secret inputs are bound secrets: shard ws secrets)', positionals: [REF], options: {} },
|
|
140
154
|
{
|
|
141
155
|
path: ['workspaces', 'exec'],
|
|
142
156
|
summary: 'Run a command in a workspace; streams its output and exits with its exit code',
|
|
@@ -326,6 +340,109 @@ export const COMMANDS = [
|
|
|
326
340
|
limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 50).' },
|
|
327
341
|
},
|
|
328
342
|
},
|
|
343
|
+
{
|
|
344
|
+
path: ['templates', 'get'],
|
|
345
|
+
summary: 'Show a template: its versions, what open resolves to, and the settings of its open version',
|
|
346
|
+
positionals: [SLUG],
|
|
347
|
+
options: {
|
|
348
|
+
owner: { type: 'string', value: '<platform|organization>', description: 'Pick the platform template even when an organization template shadows its slug.' },
|
|
349
|
+
'include-archived': { type: 'boolean', description: 'Include archived versions.' },
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
path: ['templates', 'init'],
|
|
354
|
+
summary: 'Write a template.yaml that starts from a base template',
|
|
355
|
+
description: 'The file is the recipe v2 document (contracts §24.1): languages, packages, files (local "from" paths are uploaded by "shard templates build"), build steps, and the settings a workspace gets when it opens (env, inputs, start commands, services, defaults). Edit it, then build it.',
|
|
356
|
+
positionals: [],
|
|
357
|
+
options: {
|
|
358
|
+
base: { type: 'string', value: '<slug[@version]>', description: 'Base template (required), e.g. ubuntu-24.04; without @version, the version open resolves to now.' },
|
|
359
|
+
out: { type: 'string', value: '<file>', description: 'Where to write it (default template.yaml).' },
|
|
360
|
+
force: { type: 'boolean', description: 'Overwrite an existing file.' },
|
|
361
|
+
},
|
|
362
|
+
examples: ['shard templates init --base ubuntu-24.04', 'shard templates init --base python-node-browser@7 --out acme/template.yaml'],
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
path: ['templates', 'build'],
|
|
366
|
+
summary: 'Build a template from template.yaml: upload its local files and folders, then build it',
|
|
367
|
+
description: 'Folders named by "from" are packed as a reproducible tar and uploaded with files unless the organization already has the same bytes. The version stays unpublished (test it with "shard templates test") unless --publish. Progress goes to stderr.',
|
|
368
|
+
positionals: [{ name: 'file', required: true }],
|
|
369
|
+
options: {
|
|
370
|
+
slug: { type: 'string', value: '<slug>', description: 'Organization template to build into (default: the name of the directory holding the file). Created by the first build.' },
|
|
371
|
+
'display-name': { type: 'string', value: '<name>', description: 'Template name when this build creates the template.' },
|
|
372
|
+
description: { type: 'string', value: '<text>', description: 'Version description.' },
|
|
373
|
+
publish: { type: 'boolean', description: 'Publish the version once it is registered (new workspaces of the slug then use it). Default: leave it unpublished.' },
|
|
374
|
+
acknowledge: { type: 'string', multiple: true, value: '<path>', description: 'Absolute path the credential scan may report without failing the build (repeatable, up to 200).' },
|
|
375
|
+
wait: { type: 'boolean', description: 'Wait until the build is registered (or fails).' },
|
|
376
|
+
timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 30m). The build continues server side.' },
|
|
377
|
+
timing: { type: 'boolean', description: 'Print where the time went on stderr: packing, uploads, the request, the wait, and the build’s queued and run time (with --json: a "timing" field).' },
|
|
378
|
+
},
|
|
379
|
+
examples: ['shard templates build template.yaml --wait', 'shard templates build acme/template.yaml --slug acme-dev --publish --wait --timing'],
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
path: ['templates', 'export'],
|
|
383
|
+
summary: 'Print (or save) the template.yaml a version was built from, ready to build again',
|
|
384
|
+
description: 'Versions saved from a workspace (or a draft) and platform versions have no recipe: change their settings through a draft publish instead.',
|
|
385
|
+
positionals: [{ name: 'slug@version', required: true }],
|
|
386
|
+
options: {
|
|
387
|
+
out: { type: 'string', value: '<file>', description: 'Write to this file instead of stdout.' },
|
|
388
|
+
force: { type: 'boolean', description: 'Overwrite an existing --out file.' },
|
|
389
|
+
owner: { type: 'string', value: '<platform|organization>', description: 'Pick the platform template even when an organization template shadows its slug.' },
|
|
390
|
+
},
|
|
391
|
+
examples: ['shard templates export acme-dev@3 --out template.yaml'],
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
path: ['templates', 'test'],
|
|
395
|
+
summary: 'Open a test instance of a version, published or not (a disposable session workspace)',
|
|
396
|
+
description: 'Runs the version’s start commands and services like a new workspace. It ends with "shard ws close <key>" or when idle. Owners, admins and API keys with a tool permission.',
|
|
397
|
+
positionals: [{ name: 'slug@version', required: true }],
|
|
398
|
+
options: {
|
|
399
|
+
'instance-key': { type: 'string', value: '<key>', description: 'Workspace key of the test instance (default sf:test:<slug>:<random>).' },
|
|
400
|
+
...INPUTS,
|
|
401
|
+
...CAPS,
|
|
402
|
+
wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
|
|
403
|
+
timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m).' },
|
|
404
|
+
...TIMING,
|
|
405
|
+
},
|
|
406
|
+
examples: ['shard templates test acme-dev@4 --input PROJECT_NAME=demo'],
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
path: ['templates', 'builds', 'list'],
|
|
410
|
+
summary: 'List the organization’s template builds, newest first',
|
|
411
|
+
positionals: [],
|
|
412
|
+
options: {
|
|
413
|
+
template: { type: 'string', value: '<slug>', description: 'Only builds of this template.' },
|
|
414
|
+
state: { type: 'string', value: '<state>', description: 'queued, building, testing, publishing, published, failed or canceled.' },
|
|
415
|
+
...PAGE,
|
|
416
|
+
limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 50).' },
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
{ path: ['templates', 'builds', 'get'], summary: 'Show one template build (state, registration, denied hosts, failure)', positionals: [BUILD_ID], options: {} },
|
|
420
|
+
{
|
|
421
|
+
path: ['templates', 'builds', 'log'],
|
|
422
|
+
summary: 'Print (or save) a build’s full log',
|
|
423
|
+
positionals: [BUILD_ID],
|
|
424
|
+
options: { out: { type: 'string', value: '<file>', description: 'Write to this file instead of stdout.' } },
|
|
425
|
+
},
|
|
426
|
+
{ path: ['templates', 'builds', 'cancel'], summary: 'Cancel a template build (queued: at once; running: honoured until publishing starts)', positionals: [BUILD_ID], options: {} },
|
|
427
|
+
{
|
|
428
|
+
path: ['templates', 'languages'],
|
|
429
|
+
summary: 'List the languages (and versions) a base offers a template.yaml',
|
|
430
|
+
description: 'included: the base already has that version (nothing is installed). A version the base has another version of is left out. HOSTS is what an auto build network allows for it.',
|
|
431
|
+
positionals: [],
|
|
432
|
+
options: { base: { type: 'string', value: '<slug@version>', description: 'The base (required), e.g. ubuntu-24.04@1.' } },
|
|
433
|
+
examples: ['shard templates languages --base ubuntu-24.04@1'],
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
path: ['templates', 'packages', 'search'],
|
|
437
|
+
summary: 'Search apt, pip or npm package names for a template.yaml',
|
|
438
|
+
description: 'apt searches the package index of a base (--base, required); pip returns names only; npm searches the registry.',
|
|
439
|
+
positionals: [{ name: 'ecosystem', required: true }, { name: 'query', required: true }],
|
|
440
|
+
options: {
|
|
441
|
+
base: { type: 'string', value: '<slug@version>', description: 'The base whose apt index to search (apt only).' },
|
|
442
|
+
limit: { type: 'string', value: '<n>', description: 'Results, 1-50 (default 20).' },
|
|
443
|
+
},
|
|
444
|
+
examples: ['shard templates packages search apt jq --base ubuntu-24.04@1', 'shard templates packages search npm typescript'],
|
|
445
|
+
},
|
|
329
446
|
{
|
|
330
447
|
path: ['templates', 'files'],
|
|
331
448
|
summary: 'List one directory of a template version’s file tree (or show one entry with --stat)',
|
|
@@ -420,7 +537,7 @@ const GROUPS = {
|
|
|
420
537
|
files: 'Read, write and list files in a workspace',
|
|
421
538
|
operations: 'Inspect and wait for lifecycle operations',
|
|
422
539
|
secrets: 'Manage secrets (values are write-only) and bind them to workspaces',
|
|
423
|
-
templates: '
|
|
540
|
+
templates: 'Build templates from template.yaml, test and export versions, browse templates (file tree, diff) and develop them in a draft',
|
|
424
541
|
};
|
|
425
542
|
const ALIASES = { ws: 'workspaces', workspace: 'workspaces', ops: 'operations', operation: 'operations', file: 'files', secret: 'secrets', template: 'templates' };
|
|
426
543
|
/** Canonical command words: aliases resolved, `workspaces files|operations` folded to the top-level group. */
|
package/dist/commands.js
CHANGED
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
* the SDK has no typed method for yet (GET /v1/operations/{id}, usage summary).
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { basename, dirname, resolve as resolvePath } from 'node:path';
|
|
9
10
|
import { SDK_VERSION, ShardfluxApiError } from '@shardflux/sdk';
|
|
11
|
+
import { parse as parseYaml } from 'yaml';
|
|
10
12
|
import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
|
|
11
13
|
import { InterruptedError, NotFoundError, UsageError } from "./errors.js";
|
|
14
|
+
import { initTemplateYaml, recipeYaml } from "./template-yaml.js";
|
|
15
|
+
import { BuildTimer, buildTimingJson, buildTimingText, progressLine } from "./timing.js";
|
|
12
16
|
import { timingJson } from "./timing.js";
|
|
13
|
-
import { bindingsText, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, fileTable, json, kv, operationDetail, operationTable, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, templateEntryText, templateFileTable, templateTable, usageText, workspaceDetail, workspaceTable, } from "./format.js";
|
|
17
|
+
import { bindingsText, buildTable, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, fileTable, languageTable, json, kv, operationDetail, operationTable, packageTable, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, templateDetailText, templateEntryText, templateFileTable, templateTable, usageText, workspaceDetail, workspaceTable, } from "./format.js";
|
|
14
18
|
const DEFAULT_WAIT_MS = 300_000;
|
|
15
19
|
const MAX_JSON_OUTPUT = 16 * 1024 * 1024;
|
|
16
20
|
const MAX_STDIN = 1024 * 1024;
|
|
@@ -154,6 +158,87 @@ function parseVersion(text, what) {
|
|
|
154
158
|
throw new UsageError(`${what} must be a version number (got "${text}")`);
|
|
155
159
|
return Number(text);
|
|
156
160
|
}
|
|
161
|
+
const SLUG = /^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9])?$/;
|
|
162
|
+
/** `<slug>@<version>`. */
|
|
163
|
+
function parseSlugVersion(text, what) {
|
|
164
|
+
const at = text.lastIndexOf('@');
|
|
165
|
+
if (at <= 0)
|
|
166
|
+
throw new UsageError(`${what} must be <slug>@<version>, e.g. acme-dev@3 (got "${text}")`);
|
|
167
|
+
const slug = text.slice(0, at);
|
|
168
|
+
if (!SLUG.test(slug))
|
|
169
|
+
throw new UsageError(`${what}: "${slug}" is not a template slug`);
|
|
170
|
+
return { slug, version: parseVersion(text.slice(at + 1), what) };
|
|
171
|
+
}
|
|
172
|
+
const INPUT_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
173
|
+
/**
|
|
174
|
+
* Open-time text inputs (contracts §24.3) from --input-file (NAME=value lines with # comments, or a JSON object of
|
|
175
|
+
* strings), then each --input NAME=value (a later one wins). Undefined when neither option is given.
|
|
176
|
+
*/
|
|
177
|
+
async function inputsFrom(ctx, values) {
|
|
178
|
+
const file = str(values['input-file']);
|
|
179
|
+
const pairs = strs(values.input);
|
|
180
|
+
if (file === undefined && pairs.length === 0)
|
|
181
|
+
return undefined;
|
|
182
|
+
const out = {};
|
|
183
|
+
const put = (name, value, where) => {
|
|
184
|
+
if (!INPUT_NAME.test(name))
|
|
185
|
+
throw new UsageError(`${where}: "${name}" is not an input name (^[A-Za-z_][A-Za-z0-9_]*$)`);
|
|
186
|
+
if (/[\r\n\0]/.test(value))
|
|
187
|
+
throw new UsageError(`${where}: the value of ${name} must be one line without NUL`);
|
|
188
|
+
out[name] = value;
|
|
189
|
+
};
|
|
190
|
+
if (file !== undefined) {
|
|
191
|
+
const text = Buffer.from(await readInput(ctx, file, 1024 * 1024, '--input-file')).toString('utf8');
|
|
192
|
+
if (text.trimStart().startsWith('{')) {
|
|
193
|
+
let doc;
|
|
194
|
+
try {
|
|
195
|
+
doc = JSON.parse(text);
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
throw new UsageError(`--input-file: not valid JSON (${e instanceof Error ? e.message : String(e)})`);
|
|
199
|
+
}
|
|
200
|
+
if (typeof doc !== 'object' || doc === null || Array.isArray(doc))
|
|
201
|
+
throw new UsageError('--input-file: expected a JSON object of NAME: "value"');
|
|
202
|
+
for (const [k, v] of Object.entries(doc)) {
|
|
203
|
+
if (typeof v !== 'string')
|
|
204
|
+
throw new UsageError(`--input-file: the value of ${k} must be a string`);
|
|
205
|
+
put(k, v, '--input-file');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
for (const [i, raw] of text.split(/\r?\n/).entries()) {
|
|
210
|
+
const line = raw.trim();
|
|
211
|
+
if (line === '' || line.startsWith('#'))
|
|
212
|
+
continue;
|
|
213
|
+
const eq = line.indexOf('=');
|
|
214
|
+
if (eq <= 0)
|
|
215
|
+
throw new UsageError(`--input-file line ${i + 1}: expected NAME=value`);
|
|
216
|
+
put(line.slice(0, eq).trim(), line.slice(eq + 1), `--input-file line ${i + 1}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const p of pairs) {
|
|
221
|
+
const eq = p.indexOf('=');
|
|
222
|
+
if (eq <= 0)
|
|
223
|
+
throw new UsageError(`--input expects NAME=value (got "${p}")`);
|
|
224
|
+
put(p.slice(0, eq), p.slice(eq + 1), '--input');
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
const BUILD_STATES = ['queued', 'building', 'testing', 'publishing', 'published', 'succeeded', 'failed', 'canceled'];
|
|
229
|
+
/** The exit code of a build the command followed: 6 when it failed, was canceled or could not be registered. */
|
|
230
|
+
function buildExitCode(b) {
|
|
231
|
+
return b.state === 'failed' || b.state === 'canceled' || b.registration.state === 'failed' ? 6 : 0;
|
|
232
|
+
}
|
|
233
|
+
async function fileExists(path) {
|
|
234
|
+
return access(path).then(() => true, () => false);
|
|
235
|
+
}
|
|
236
|
+
/** Writes `text` to `path`, refusing to overwrite an existing file without --force. */
|
|
237
|
+
async function writeNew(path, text, force) {
|
|
238
|
+
if (!force && (await fileExists(path)))
|
|
239
|
+
throw new UsageError(`${path} exists; pass --force to overwrite it`);
|
|
240
|
+
await writeFile(path, text);
|
|
241
|
+
}
|
|
157
242
|
/** Waits with the SDK's backoff; Ctrl-C reports the operation that keeps running. */
|
|
158
243
|
async function waitOperation(ctx, operationId, timeoutMs) {
|
|
159
244
|
try {
|
|
@@ -279,6 +364,7 @@ export const HANDLERS = {
|
|
|
279
364
|
const c = caps(values);
|
|
280
365
|
const bind = secretNames(strs(values.secret), '--secret');
|
|
281
366
|
const lifetime = oneOf(values, 'lifetime', LIFETIMES);
|
|
367
|
+
const inputs = await inputsFrom(ctx, values);
|
|
282
368
|
const timeoutMs = waitTimeout(values);
|
|
283
369
|
const cloud = ctx.cloud();
|
|
284
370
|
// The SDK's waited open: the server holds the request until the workspace is ready (Prefer: wait), then the SDK
|
|
@@ -288,6 +374,7 @@ export const HANDLERS = {
|
|
|
288
374
|
template,
|
|
289
375
|
...(c ? { caps: c } : {}),
|
|
290
376
|
...(bind.length ? { secrets: bind } : {}),
|
|
377
|
+
...(inputs ? { inputs } : {}),
|
|
291
378
|
...(lifetime ? { lifetime } : {}),
|
|
292
379
|
agentLabel: ctx.agentLabel,
|
|
293
380
|
wait: values.wait === false ? false : { timeoutMs, signal: ctx.signal },
|
|
@@ -340,6 +427,13 @@ export const HANDLERS = {
|
|
|
340
427
|
out(ctx, workspaceDetail(ws.data), ws.data);
|
|
341
428
|
return 0;
|
|
342
429
|
},
|
|
430
|
+
async 'workspaces inputs'(ctx, _values, [ref]) {
|
|
431
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
432
|
+
const inputs = await ws.inputs();
|
|
433
|
+
const names = Object.keys(inputs);
|
|
434
|
+
out(ctx, names.length ? kv(names.map((n) => [n, inputs[n]])) : 'No text inputs (secret inputs are bound secrets: shard ws secrets).\n', { inputs });
|
|
435
|
+
return 0;
|
|
436
|
+
},
|
|
343
437
|
async 'workspaces exec'(ctx, values, [ref, ...argv]) {
|
|
344
438
|
const env = parseEnvPairs(values.env ?? []);
|
|
345
439
|
const timeout = str(values.timeout);
|
|
@@ -504,6 +598,240 @@ export const HANDLERS = {
|
|
|
504
598
|
ctx.io.stderr.write(`more: shard templates list --cursor ${next}\n`);
|
|
505
599
|
return 0;
|
|
506
600
|
},
|
|
601
|
+
async 'templates get'(ctx, values, [slug]) {
|
|
602
|
+
const owner = oneOf(values, 'owner', OWNERS);
|
|
603
|
+
const detail = await ctx.cloud().templates.get(slug, { ...(owner ? { owner } : {}), ...(values['include-archived'] === true ? { includeArchived: true } : {}) });
|
|
604
|
+
out(ctx, templateDetailText(detail), detail);
|
|
605
|
+
return 0;
|
|
606
|
+
},
|
|
607
|
+
async 'templates init'(ctx, values) {
|
|
608
|
+
const baseText = str(values.base);
|
|
609
|
+
if (!baseText)
|
|
610
|
+
throw new UsageError('--base <slug[@version]> is required for "shard templates init", e.g. --base ubuntu-24.04');
|
|
611
|
+
const dest = str(values.out) ?? 'template.yaml';
|
|
612
|
+
if (values.force !== true && (await fileExists(dest)))
|
|
613
|
+
throw new UsageError(`${dest} exists; pass --force to overwrite it`);
|
|
614
|
+
let base;
|
|
615
|
+
if (baseText.includes('@')) {
|
|
616
|
+
const { slug, version } = parseSlugVersion(baseText, '--base');
|
|
617
|
+
base = `${slug}@${version}`;
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
if (!SLUG.test(baseText))
|
|
621
|
+
throw new UsageError(`--base: "${baseText}" is not a template slug`);
|
|
622
|
+
const t = await ctx.cloud().templates.get(baseText);
|
|
623
|
+
const v = t.open_resolves_to?.version ?? t.open_version?.version;
|
|
624
|
+
if (v === undefined)
|
|
625
|
+
throw new NotFoundError(`${baseText} has no published version to start from; name one with --base ${baseText}@<version>`);
|
|
626
|
+
base = `${baseText}@${v}`;
|
|
627
|
+
}
|
|
628
|
+
await writeNew(dest, initTemplateYaml(base), values.force === true);
|
|
629
|
+
const dir = basename(dirname(resolvePath(dest)));
|
|
630
|
+
const slugHint = SLUG.test(dir.toLowerCase()) ? '' : ' --slug <slug>';
|
|
631
|
+
out(ctx, `Wrote ${dest} (base ${base}).\nEdit it, then build it with: shard templates build ${dest}${slugHint} --wait\n`, { path: dest, base });
|
|
632
|
+
return 0;
|
|
633
|
+
},
|
|
634
|
+
async 'templates build'(ctx, values, [file]) {
|
|
635
|
+
const path = resolvePath(file);
|
|
636
|
+
const given = str(values.slug);
|
|
637
|
+
const dirName = basename(dirname(path));
|
|
638
|
+
const slug = given ?? dirName.toLowerCase();
|
|
639
|
+
if (!SLUG.test(slug))
|
|
640
|
+
throw new UsageError(given !== undefined ? `--slug "${given}" is not a template slug (lower-case letters, digits, ".", "_" and "-")` : `the directory name "${dirName}" is not a template slug; pass --slug <slug>`);
|
|
641
|
+
const ack = acknowledged(values);
|
|
642
|
+
const t = str(values.timeout);
|
|
643
|
+
const timeoutMs = t === undefined ? DEFAULT_BUILD_WAIT_MS : parseDuration(t, 'timeout');
|
|
644
|
+
const displayName = str(values['display-name']);
|
|
645
|
+
const description = str(values.description);
|
|
646
|
+
const timer = new BuildTimer();
|
|
647
|
+
const progress = (e) => {
|
|
648
|
+
timer.record(e);
|
|
649
|
+
if (!ctx.json)
|
|
650
|
+
ctx.io.stderr.write(progressLine(e));
|
|
651
|
+
};
|
|
652
|
+
if (!ctx.json)
|
|
653
|
+
ctx.io.stderr.write(`Building ${slug} from ${file}${given === undefined ? ' (template slug from the directory name; --slug to choose)' : ''}\n`);
|
|
654
|
+
let res;
|
|
655
|
+
try {
|
|
656
|
+
res = await ctx.cloud().templates.buildFromFile(path, {
|
|
657
|
+
templateSlug: slug,
|
|
658
|
+
...(displayName !== undefined ? { displayName } : {}),
|
|
659
|
+
...(description !== undefined ? { description } : {}),
|
|
660
|
+
autoPublish: values.publish === true,
|
|
661
|
+
...(ack ? { acknowledgedScanFindings: ack } : {}),
|
|
662
|
+
parseYaml: (text) => parseYaml(text),
|
|
663
|
+
onProgress: progress,
|
|
664
|
+
...(values.wait === true ? { wait: { timeoutMs, signal: ctx.signal } } : {}),
|
|
665
|
+
signal: ctx.signal,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
timer.finish(null);
|
|
670
|
+
if (ctx.timing && !ctx.json)
|
|
671
|
+
ctx.io.stderr.write(buildTimingText(timer));
|
|
672
|
+
if (ctx.signal.aborted)
|
|
673
|
+
throw new InterruptedError(timer.buildId ? `Interrupted; build ${timer.buildId} continues server side.` : 'Interrupted.');
|
|
674
|
+
throw err;
|
|
675
|
+
}
|
|
676
|
+
timer.finish(res.build);
|
|
677
|
+
if (ctx.timing && !ctx.json)
|
|
678
|
+
ctx.io.stderr.write(buildTimingText(timer));
|
|
679
|
+
const b = res.build;
|
|
680
|
+
const next = b.template_version && !b.template_version.published ? `test it with: shard templates test ${b.template.slug}@${b.template_version.version}\n` : '';
|
|
681
|
+
const machine = { build: b, uploads: res.uploads, ...(ctx.timing ? { timing: buildTimingJson(timer) } : {}) };
|
|
682
|
+
ctx.io.stdout.write(ctx.json ? json(machine) : `${buildText(b)}${next}`);
|
|
683
|
+
return values.wait === true ? buildExitCode(b) : 0;
|
|
684
|
+
},
|
|
685
|
+
async 'templates export'(ctx, values, [ref]) {
|
|
686
|
+
const { slug, version } = parseSlugVersion(ref, '<slug@version>');
|
|
687
|
+
const owner = oneOf(values, 'owner', OWNERS);
|
|
688
|
+
const exp = await ctx.cloud().templates.versions.recipe(slug, version, owner ? { owner } : {});
|
|
689
|
+
if (ctx.json) {
|
|
690
|
+
ctx.io.stdout.write(json(exp));
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
if (exp.recipe === null) {
|
|
694
|
+
throw new NotFoundError(`${slug} v${version} has no recipe to export (${exp.source_kind === 'workspace' ? 'it was saved from a workspace or a draft' : 'a platform version or a version without a recipe'}); its settings: shard templates get ${slug}`);
|
|
695
|
+
}
|
|
696
|
+
const text = recipeYaml(exp.recipe, `${slug} v${version}${exp.source_kind ? ` (${exp.source_kind})` : ''}. Build it again with: shard templates build <this file> --slug ${slug}`);
|
|
697
|
+
const dest = str(values.out);
|
|
698
|
+
if (dest === undefined)
|
|
699
|
+
ctx.io.stdout.write(text);
|
|
700
|
+
else {
|
|
701
|
+
await writeNew(dest, text, values.force === true);
|
|
702
|
+
ctx.io.stderr.write(`wrote ${dest}\n`);
|
|
703
|
+
}
|
|
704
|
+
return 0;
|
|
705
|
+
},
|
|
706
|
+
async 'templates test'(ctx, values, [ref]) {
|
|
707
|
+
const { slug, version } = parseSlugVersion(ref, '<slug@version>');
|
|
708
|
+
const c = caps(values);
|
|
709
|
+
const inputs = await inputsFrom(ctx, values);
|
|
710
|
+
const timeoutMs = waitTimeout(values);
|
|
711
|
+
const instanceKey = str(values['instance-key']);
|
|
712
|
+
let ws = await ctx.cloud().templates.versionTestInstances.create(slug, version, {
|
|
713
|
+
...(instanceKey ? { key: instanceKey } : {}),
|
|
714
|
+
...(c ? { caps: c } : {}),
|
|
715
|
+
...(inputs ? { inputs } : {}),
|
|
716
|
+
agentLabel: ctx.agentLabel,
|
|
717
|
+
wait: false,
|
|
718
|
+
});
|
|
719
|
+
const op = ws.activeOperation;
|
|
720
|
+
if (values.wait !== false && !ws.ready && op) {
|
|
721
|
+
await waitOperation(ctx, op.id, timeoutMs);
|
|
722
|
+
ws = await ctx.cloud().workspaces.get(ws.id);
|
|
723
|
+
}
|
|
724
|
+
const human = ws.ready
|
|
725
|
+
? `Test instance ${ws.key} (${ws.id}) of ${slug} v${version} is ready. It ends with: shard ws close ${ws.key}\n${workspaceDetail(ws.data)}`
|
|
726
|
+
: `Test instance ${ws.key} (${ws.id}) of ${slug} v${version} is ${ws.state}${op ? `; operation ${op.id} ${op.kind} ${op.state}` : ''}.\n${op ? `wait with: shard operations wait ${op.id}\n` : ''}`;
|
|
727
|
+
out(ctx, human, ws.data);
|
|
728
|
+
return 0;
|
|
729
|
+
},
|
|
730
|
+
async 'templates builds list'(ctx, values) {
|
|
731
|
+
const cloud = ctx.cloud();
|
|
732
|
+
const org = (await principal(ctx)).organization_id;
|
|
733
|
+
const state = oneOf(values, 'state', BUILD_STATES);
|
|
734
|
+
const template = str(values.template);
|
|
735
|
+
const limit = listLimit(values, 200);
|
|
736
|
+
const params = { ...(state ? { state } : {}), ...(template ? { template } : {}) };
|
|
737
|
+
let rows = [];
|
|
738
|
+
let next = null;
|
|
739
|
+
if (values.all === true) {
|
|
740
|
+
let cursor;
|
|
741
|
+
do {
|
|
742
|
+
const page = await cloud.templates.builds.list(org, { ...params, limit: limit ?? 200, ...(cursor ? { cursor } : {}) });
|
|
743
|
+
rows.push(...page.data);
|
|
744
|
+
cursor = page.nextCursor ?? undefined;
|
|
745
|
+
if (ctx.signal.aborted)
|
|
746
|
+
throw new InterruptedError('Interrupted.');
|
|
747
|
+
} while (cursor);
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
const cursor = str(values.cursor);
|
|
751
|
+
const page = await cloud.templates.builds.list(org, { ...params, ...(limit ? { limit } : {}), ...(cursor ? { cursor } : {}) });
|
|
752
|
+
rows = page.data;
|
|
753
|
+
next = page.nextCursor;
|
|
754
|
+
}
|
|
755
|
+
out(ctx, rows.length ? buildTable(rows) : 'No template builds.\n', { data: rows, next_cursor: next });
|
|
756
|
+
if (!ctx.json && next)
|
|
757
|
+
ctx.io.stderr.write(`more: shard templates builds list --cursor ${next}\n`);
|
|
758
|
+
return 0;
|
|
759
|
+
},
|
|
760
|
+
async 'templates builds get'(ctx, _values, [id]) {
|
|
761
|
+
if (!UUID.test(id))
|
|
762
|
+
throw new UsageError(`<build-id> must be a UUID (got "${id}")`);
|
|
763
|
+
const org = (await principal(ctx)).organization_id;
|
|
764
|
+
const b = await ctx.cloud().templates.builds.get(org, id);
|
|
765
|
+
out(ctx, buildText(b), b);
|
|
766
|
+
return 0;
|
|
767
|
+
},
|
|
768
|
+
async 'templates builds log'(ctx, values, [id]) {
|
|
769
|
+
if (!UUID.test(id))
|
|
770
|
+
throw new UsageError(`<build-id> must be a UUID (got "${id}")`);
|
|
771
|
+
const org = (await principal(ctx)).organization_id;
|
|
772
|
+
const cloud = ctx.cloud();
|
|
773
|
+
let text;
|
|
774
|
+
try {
|
|
775
|
+
const link = await cloud.templates.builds.logUrl(org, id);
|
|
776
|
+
const res = await fetch(link.url, { signal: ctx.signal });
|
|
777
|
+
if (!res.ok)
|
|
778
|
+
throw new Error(`the log download answered HTTP ${res.status}`);
|
|
779
|
+
text = new Uint8Array(await res.arrayBuffer());
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
// Not recorded yet (or expired): the build view's tail is the most there is.
|
|
783
|
+
if (!(err instanceof ShardfluxApiError && err.status === 404))
|
|
784
|
+
throw err;
|
|
785
|
+
const b = await cloud.templates.builds.get(org, id);
|
|
786
|
+
if (!b.log.tail)
|
|
787
|
+
throw err;
|
|
788
|
+
ctx.io.stderr.write(`the full log is not available (${typeof err.details?.reason === 'string' ? err.details.reason : err.code}); the build's log tail follows\n`);
|
|
789
|
+
text = new TextEncoder().encode(b.log.tail.endsWith('\n') ? b.log.tail : `${b.log.tail}\n`);
|
|
790
|
+
}
|
|
791
|
+
const dest = str(values.out);
|
|
792
|
+
if (dest !== undefined) {
|
|
793
|
+
await writeFile(dest, text);
|
|
794
|
+
out(ctx, `wrote ${text.length} bytes to ${dest}\n`, { build_id: id, bytes: text.length, out: dest });
|
|
795
|
+
}
|
|
796
|
+
else if (ctx.json)
|
|
797
|
+
ctx.io.stdout.write(json({ build_id: id, bytes: text.length, log: Buffer.from(text).toString('utf8') }));
|
|
798
|
+
else
|
|
799
|
+
ctx.io.stdout.write(text);
|
|
800
|
+
return 0;
|
|
801
|
+
},
|
|
802
|
+
async 'templates builds cancel'(ctx, _values, [id]) {
|
|
803
|
+
if (!UUID.test(id))
|
|
804
|
+
throw new UsageError(`<build-id> must be a UUID (got "${id}")`);
|
|
805
|
+
const org = (await principal(ctx)).organization_id;
|
|
806
|
+
const b = await ctx.cloud().templates.builds.cancel(org, id);
|
|
807
|
+
out(ctx, `${b.state === 'canceled' ? 'Canceled' : 'Cancellation requested for'} build ${b.id} (${b.template.slug} v${b.target_version ?? '?'}).\n${buildText(b)}`, b);
|
|
808
|
+
return 0;
|
|
809
|
+
},
|
|
810
|
+
async 'templates languages'(ctx, values) {
|
|
811
|
+
const base = str(values.base);
|
|
812
|
+
if (base === undefined)
|
|
813
|
+
throw new UsageError('--base <slug@version> is required for "shard templates languages", e.g. --base ubuntu-24.04@1');
|
|
814
|
+
parseSlugVersion(base, '--base');
|
|
815
|
+
const langs = await ctx.cloud().templates.languages(base);
|
|
816
|
+
out(ctx, `${languageTable(langs)}platform base ${langs.platform_base}; apt packages need ${langs.apt_hosts.join(', ') || '(no host)'}\n`, langs);
|
|
817
|
+
return 0;
|
|
818
|
+
},
|
|
819
|
+
async 'templates packages search'(ctx, values, [ecosystem, query]) {
|
|
820
|
+
if (ecosystem !== 'apt' && ecosystem !== 'pip' && ecosystem !== 'npm')
|
|
821
|
+
throw new UsageError(`<ecosystem> must be apt, pip or npm (got "${ecosystem}")`);
|
|
822
|
+
const base = str(values.base);
|
|
823
|
+
if (ecosystem === 'apt' && base === undefined)
|
|
824
|
+
throw new UsageError('apt needs --base <slug@version> (the base whose package index to search)');
|
|
825
|
+
if (base !== undefined)
|
|
826
|
+
parseSlugVersion(base, '--base');
|
|
827
|
+
if (query.length < 1 || query.length > 100)
|
|
828
|
+
throw new UsageError('<query> must be 1-100 characters');
|
|
829
|
+
const limit = listLimit(values, 50);
|
|
830
|
+
const page = await ctx.cloud().templates.packages.search(ecosystem, query, { ...(base ? { base } : {}), ...(limit ? { limit } : {}) });
|
|
831
|
+
const note = page.source === 'pypi_names' ? '(pip: names only; the build installs the latest version unless you pin one, e.g. pandas==2.3.2)\n' : '';
|
|
832
|
+
out(ctx, page.data.length ? `${packageTable(page)}${note}` : `No ${ecosystem} packages match "${query}".\n`, page);
|
|
833
|
+
return 0;
|
|
834
|
+
},
|
|
507
835
|
async 'templates files'(ctx, values, [slug, versionText, path]) {
|
|
508
836
|
const version = parseVersion(versionText, '<version>');
|
|
509
837
|
const owner = oneOf(values, 'owner', OWNERS);
|
package/dist/errors.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* stderr with the API's error code, message and request id; the process exit
|
|
4
4
|
* code tells scripts what kind of failure it was (README "Exit codes").
|
|
5
5
|
*/
|
|
6
|
-
import { OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError } from '@shardflux/sdk';
|
|
6
|
+
import { OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
|
|
7
7
|
export const EXIT = {
|
|
8
8
|
ok: 0,
|
|
9
9
|
/** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
|
|
@@ -78,6 +78,13 @@ export function describeError(err) {
|
|
|
78
78
|
}
|
|
79
79
|
if (err instanceof UsageError)
|
|
80
80
|
return { code: 'usage', message: err.message };
|
|
81
|
+
// Template builds (0.4.0): a template file that cannot be read or packed is a usage error; a wait that ran out names the build.
|
|
82
|
+
if (err instanceof TemplateFileError)
|
|
83
|
+
return { code: 'usage', message: err.message };
|
|
84
|
+
if (err instanceof TemplateBuildTimeoutError)
|
|
85
|
+
return { code: 'timeout', message: `${err.message}; follow it with: shard templates builds get ${err.build.id}`, last_state: err.build.state };
|
|
86
|
+
if (err instanceof TemplateUploadError)
|
|
87
|
+
return { code: 'upload_failed', message: err.message, ...(err.status ? { status: err.status } : {}), details: { sha256: err.sha256, ...(err.code ? { storage_code: err.code } : {}) } };
|
|
81
88
|
if (err instanceof AuthConfigError)
|
|
82
89
|
return { code: 'unauthenticated', message: err.message };
|
|
83
90
|
if (err instanceof NotFoundError)
|
|
@@ -100,10 +107,12 @@ export function describeError(err) {
|
|
|
100
107
|
export function exitCodeFor(err, interrupted = false) {
|
|
101
108
|
if (interrupted || err instanceof InterruptedError)
|
|
102
109
|
return EXIT.interrupted;
|
|
103
|
-
if (err instanceof UsageError)
|
|
110
|
+
if (err instanceof UsageError || err instanceof TemplateFileError)
|
|
104
111
|
return EXIT.usage;
|
|
105
112
|
if (err instanceof AuthConfigError)
|
|
106
113
|
return EXIT.auth;
|
|
114
|
+
if (err instanceof TemplateBuildTimeoutError)
|
|
115
|
+
return EXIT.timeout;
|
|
107
116
|
if (err instanceof NotFoundError)
|
|
108
117
|
return EXIT.notFound;
|
|
109
118
|
if (err instanceof OperationTimeoutError || isNamed(err, 'TimeoutError'))
|
package/dist/format.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `--json` output is the API's own objects (see README "JSON output"), so
|
|
4
4
|
* these functions only shape what people read.
|
|
5
5
|
*/
|
|
6
|
-
import type { AgentSession, DraftState, FileList, Operation, Secret, SecretAccessEvent, SecretVersion, TemplateBuild, TemplateDiffEntry, TemplateDraft, TemplateFileEntry, TemplateSummary, WorkspaceChange, WorkspaceSecretBindings, WorkspaceView } from '@shardflux/sdk';
|
|
6
|
+
import type { AgentSession, DraftState, FileList, Operation, Secret, SecretAccessEvent, SecretVersion, TemplateBuild, TemplateDetail, TemplateDiffEntry, TemplateDraft, TemplateFileEntry, TemplateLanguages, TemplatePackagePage, TemplateSettings, TemplateSummary, WorkspaceChange, WorkspaceSecretBindings, WorkspaceView } from '@shardflux/sdk';
|
|
7
7
|
import type { ErrorInfo } from './errors.js';
|
|
8
8
|
export declare function json(value: unknown): string;
|
|
9
9
|
export declare function table(headers: readonly string[], rows: readonly (readonly string[])[]): string;
|
|
@@ -19,6 +19,13 @@ export declare function workspaceState(w: WorkspaceView): string;
|
|
|
19
19
|
export declare function workspaceRow(w: WorkspaceView): string[];
|
|
20
20
|
export declare function workspaceTable(ws: readonly WorkspaceView[]): string;
|
|
21
21
|
export declare function isReady(w: WorkspaceView): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The startup of a workspace's template version (contracts §24.4) in one line: state, trigger, and for a failure the
|
|
24
|
+
* step or service, exit code and reason. Null when the version has no start commands or services.
|
|
25
|
+
*/
|
|
26
|
+
export declare function startupText(s: WorkspaceView['startup'] | undefined): string | null;
|
|
27
|
+
/** The failed step's output tail, indented, after the detail block (empty otherwise). */
|
|
28
|
+
export declare function startupTail(s: WorkspaceView['startup'] | undefined): string;
|
|
22
29
|
export declare function workspaceDetail(w: WorkspaceView): string;
|
|
23
30
|
export declare const OPERATION_HEADERS: readonly ["ID", "KIND", "STATE", "REASON", "CREATED", "COMPLETED"];
|
|
24
31
|
export declare function operationTable(ops: readonly Operation[]): string;
|
|
@@ -52,3 +59,10 @@ export declare function changesTable(rows: readonly WorkspaceChange[]): string;
|
|
|
52
59
|
export declare function draftText(d: TemplateDraft): string;
|
|
53
60
|
export declare function draftStateTable(rows: readonly DraftState[]): string;
|
|
54
61
|
export declare function buildText(b: TemplateBuild): string;
|
|
62
|
+
export declare function buildTable(rows: readonly TemplateBuild[]): string;
|
|
63
|
+
export declare function languageTable(l: TemplateLanguages): string;
|
|
64
|
+
export declare function packageTable(page: TemplatePackagePage): string;
|
|
65
|
+
/** A template: identity, what open resolves to, its versions, and the open version's settings. */
|
|
66
|
+
export declare function templateDetailText(t: TemplateDetail): string;
|
|
67
|
+
/** A version's settings (contracts §24.3), one line per part. */
|
|
68
|
+
export declare function settingsText(s: TemplateSettings): string;
|
package/dist/format.js
CHANGED
|
@@ -78,8 +78,32 @@ function originText(o) {
|
|
|
78
78
|
return `template ${o.template_id} version ${o.version}`;
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* The startup of a workspace's template version (contracts §24.4) in one line: state, trigger, and for a failure the
|
|
83
|
+
* step or service, exit code and reason. Null when the version has no start commands or services.
|
|
84
|
+
*/
|
|
85
|
+
export function startupText(s) {
|
|
86
|
+
if (!s)
|
|
87
|
+
return null;
|
|
88
|
+
if (s.state === 'pending')
|
|
89
|
+
return 'pending (a start is in progress)';
|
|
90
|
+
const where = s.service ? `service ${s.service}` : s.step ? `step ${s.step}` : null;
|
|
91
|
+
if (s.state === 'failed') {
|
|
92
|
+
const exit = s.exit_code !== null ? `, exit ${s.exit_code}` : '';
|
|
93
|
+
return `failed${where ? ` at ${where}` : ''}${exit}${s.reason ? ` (${s.reason})` : ''}${s.trigger ? `, trigger ${s.trigger}` : ''}; the next open runs it again`;
|
|
94
|
+
}
|
|
95
|
+
return `${s.state}${s.trigger ? ` (${s.trigger})` : ''}${s.state === 'running' && where ? `: ${where}` : ''}`;
|
|
96
|
+
}
|
|
97
|
+
/** The failed step's output tail, indented, after the detail block (empty otherwise). */
|
|
98
|
+
export function startupTail(s) {
|
|
99
|
+
if (!s || s.state !== 'failed' || !s.output_tail)
|
|
100
|
+
return '';
|
|
101
|
+
const lines = s.output_tail.replace(/\n$/, '').split('\n');
|
|
102
|
+
return `startup output (last ${Buffer.byteLength(s.output_tail)} bytes):\n${lines.map((l) => ` | ${l}`).join('\n')}\n`;
|
|
103
|
+
}
|
|
81
104
|
export function workspaceDetail(w) {
|
|
82
105
|
const c = w.ceilings;
|
|
106
|
+
const startup = startupText(w.startup);
|
|
83
107
|
return kv([
|
|
84
108
|
['id', w.id],
|
|
85
109
|
['key', w.workspace_key],
|
|
@@ -92,6 +116,7 @@ export function workspaceDetail(w) {
|
|
|
92
116
|
['caps', pairs(w.caps)],
|
|
93
117
|
['operation', w.active_operation ? `${operationSummary(w.active_operation)} ${w.active_operation.id}` : '-'],
|
|
94
118
|
['pending', w.pending_reason ?? '-'],
|
|
119
|
+
...(startup ? [['startup', startup]] : []),
|
|
95
120
|
['forked from', w.forked_from_workspace_id ?? '-'],
|
|
96
121
|
...(w.lifetime !== undefined
|
|
97
122
|
? [
|
|
@@ -104,7 +129,7 @@ export function workspaceDetail(w) {
|
|
|
104
129
|
: []),
|
|
105
130
|
['created', time(w.created_at)],
|
|
106
131
|
['deleted', time(w.deleted_at)],
|
|
107
|
-
]);
|
|
132
|
+
]) + startupTail(w.startup);
|
|
108
133
|
}
|
|
109
134
|
export const OPERATION_HEADERS = ['ID', 'KIND', 'STATE', 'REASON', 'CREATED', 'COMPLETED'];
|
|
110
135
|
export function operationTable(ops) {
|
|
@@ -312,13 +337,62 @@ export function draftText(d) {
|
|
|
312
337
|
export function draftStateTable(rows) {
|
|
313
338
|
return table(['STATE', 'LABEL', 'CAPTURED', 'BYTES', 'TEST INSTANCES'], rows.map((st) => [st.checkpoint_id, st.label ?? '-', time(st.captured_at), st.bytes === null ? '-' : String(st.bytes), String(st.test_instances)]));
|
|
314
339
|
}
|
|
340
|
+
function buildSource(b) {
|
|
341
|
+
if (b.source_kind === 'workspace')
|
|
342
|
+
return `workspace ${b.source_workspace_id ?? '-'}${b.source_checkpoint_id ? ` checkpoint ${b.source_checkpoint_id}` : ''}`;
|
|
343
|
+
const v2 = b.provenance.recipe_schema === 'shardflux.template-recipe.v2';
|
|
344
|
+
return `recipe${v2 ? ' v2' : ''}${b.provenance.recipe_sha256 ? ` (sha256 ${b.provenance.recipe_sha256.slice(0, 12)})` : ''}`;
|
|
345
|
+
}
|
|
315
346
|
export function buildText(b) {
|
|
316
|
-
|
|
347
|
+
const settled = b.state === 'published' || b.state === 'failed' || b.state === 'canceled' || b.state === 'succeeded';
|
|
348
|
+
const denied = b.denied_hosts ?? [];
|
|
349
|
+
return (kv([
|
|
317
350
|
['build', b.id],
|
|
318
351
|
['template', `${b.template.slug} v${b.target_version ?? '?'}`],
|
|
319
352
|
['state', `${b.state}${b.failure ? ` (${b.failure.code}: ${b.failure.message})` : ''}`],
|
|
320
|
-
['registration', b.registration.state],
|
|
321
|
-
['source', b
|
|
353
|
+
['registration', `${b.registration.state}${b.registration.error ? ` (${b.registration.error.code}: ${b.registration.error.message})` : ''}`],
|
|
354
|
+
['source', buildSource(b)],
|
|
355
|
+
['network', b.network.mode === 'none' ? 'none' : `allowlist ${b.network.allow_hosts.join(', ') || '(empty)'}`],
|
|
356
|
+
...(denied.length ? [['denied hosts', `${denied.join(', ')} (allow one: add it to build.network.extra_hosts and build again)`]] : []),
|
|
322
357
|
['version', b.template_version ? `${b.template_version.id}${b.template_version.published ? ' (published)' : ' (unpublished)'}` : '-'],
|
|
323
|
-
|
|
358
|
+
['created', time(b.created_at)],
|
|
359
|
+
]) + (settled ? '' : `the build continues server side (--wait follows it; shard templates builds get ${b.id} reports it)\n`));
|
|
360
|
+
}
|
|
361
|
+
export function buildTable(rows) {
|
|
362
|
+
return table(['ID', 'TEMPLATE', 'STATE', 'REGISTRATION', 'SOURCE', 'CREATED'], rows.map((b) => [b.id, `${b.template.slug} v${b.target_version ?? '?'}`, b.state, b.registration.state, b.source_kind === 'workspace' ? 'workspace' : b.provenance.recipe_schema === 'shardflux.template-recipe.v2' ? 'recipe v2' : 'recipe', time(b.created_at)]));
|
|
363
|
+
}
|
|
364
|
+
export function languageTable(l) {
|
|
365
|
+
return table(['ID', 'NAME', 'VERSION', 'DEFAULT', 'SOURCE', 'HOSTS'], l.data.map((x) => [x.id, x.name, x.version, x.default ? 'yes' : '-', x.included ? `included${x.base_version ? ` (${x.base_version})` : ''}` : x.apt.length ? `apt ${x.apt.join(' ')}` : 'download', x.hosts.join(', ') || '-']));
|
|
366
|
+
}
|
|
367
|
+
export function packageTable(page) {
|
|
368
|
+
return table(['NAME', 'VERSION', 'SUMMARY'], page.data.map((p) => [p.name, p.version ?? '-', p.summary ? (p.summary.length > 80 ? `${p.summary.slice(0, 79)}…` : p.summary) : '-']));
|
|
369
|
+
}
|
|
370
|
+
/** A template: identity, what open resolves to, its versions, and the open version's settings. */
|
|
371
|
+
export function templateDetailText(t) {
|
|
372
|
+
const open = t.open_version;
|
|
373
|
+
const head = kv([
|
|
374
|
+
['template', `${t.slug} (${t.name})`],
|
|
375
|
+
['owner', `${t.owner}${t.category ? `, ${t.category}` : ''}`],
|
|
376
|
+
['open resolves to', t.open_resolves_to ? `v${t.open_resolves_to.version} (${t.open_resolves_to.owner})` : '-'],
|
|
377
|
+
['draft', t.draft ? `${t.draft.workspace_key} (${t.draft.observed_state})` : '-'],
|
|
378
|
+
['archived', time(t.archived_at)],
|
|
379
|
+
]);
|
|
380
|
+
const versions = t.versions.length
|
|
381
|
+
? table(['VERSION', 'STATE', 'SOURCE', 'LAYOUTS', 'CREATED', 'DESCRIPTION'], t.versions.map((v) => [`v${v.version}${v.is_open_version ? ' *' : ''}`, v.state, v.source?.kind ?? '-', v.disk_layouts.join(',') || '-', time(v.created_at), v.description ?? '-']))
|
|
382
|
+
: 'No versions.\n';
|
|
383
|
+
return `${head}\n${versions}${open?.settings ? `\nsettings of v${open.version}:\n${settingsText(open.settings)}` : ''}`;
|
|
384
|
+
}
|
|
385
|
+
/** A version's settings (contracts §24.3), one line per part. */
|
|
386
|
+
export function settingsText(s) {
|
|
387
|
+
const inputs = Object.entries(s.inputs).map(([n, i]) => `${n} (${i.kind}${i.required ? ', required' : ''}${i.default !== null ? `, default ${JSON.stringify(i.default)}` : ''})`);
|
|
388
|
+
const d = s.defaults;
|
|
389
|
+
const egress = d.egress ? (d.egress.mode === 'none' ? 'none' : `allowlist ${d.egress.allow_hosts.join(', ')}`) : 'internet (the project policy)';
|
|
390
|
+
return kv([
|
|
391
|
+
['env', Object.keys(s.env).length ? Object.keys(s.env).join(', ') : '-'],
|
|
392
|
+
['inputs', inputs.length ? inputs.join('; ') : '-'],
|
|
393
|
+
['start', s.start.length ? s.start.map((c) => `${c.name} (${c.when})`).join(', ') : '-'],
|
|
394
|
+
['services', Object.keys(s.services).length ? Object.entries(s.services).map(([n, v]) => `${n}${v.ready ? ('port' in v.ready ? ` (ready: port ${v.ready.port})` : ' (ready: command)') : ''}`).join(', ') : '-'],
|
|
395
|
+
['lifetime', `${d.lifetime}${d.idle_timeout_seconds ? `, idle ${d.idle_timeout_seconds} s` : ''}`],
|
|
396
|
+
['egress', egress],
|
|
397
|
+
]).replace(/^/gm, ' ');
|
|
324
398
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A recipe as template.yaml, with `header` as the comment above the document. */
|
|
2
|
+
export declare function recipeYaml(recipe: unknown, header?: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The starter template.yaml on `base` (`<slug>@<version>`): every section present, with commented examples and one
|
|
5
|
+
* placeholder step (the API refuses a recipe that builds nothing). It parses to a valid recipe v2 and builds as it is.
|
|
6
|
+
*/
|
|
7
|
+
export declare function initTemplateYaml(base: string): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* template.yaml text (contracts §24.1: the recipe v2 document in YAML; only clients parse YAML): the starter file of
|
|
3
|
+
* `shard templates init` and the export of a version (`shard templates export`), written like the dashboard's
|
|
4
|
+
* Template file section (keys in the recipe's order, long scripts as block literals, no line folding).
|
|
5
|
+
*/
|
|
6
|
+
import { Document } from 'yaml';
|
|
7
|
+
/** A recipe as template.yaml, with `header` as the comment above the document. */
|
|
8
|
+
export function recipeYaml(recipe, header) {
|
|
9
|
+
const doc = new Document(recipe);
|
|
10
|
+
if (header)
|
|
11
|
+
doc.commentBefore = ` ${header}`;
|
|
12
|
+
return doc.toString({ lineWidth: 0, minContentWidth: 0, blockQuote: 'literal' });
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The starter template.yaml on `base` (`<slug>@<version>`): every section present, with commented examples and one
|
|
16
|
+
* placeholder step (the API refuses a recipe that builds nothing). It parses to a valid recipe v2 and builds as it is.
|
|
17
|
+
*/
|
|
18
|
+
export function initTemplateYaml(base) {
|
|
19
|
+
return `# A Shardflux template (recipe v2). Build it with: shard templates build template.yaml --wait
|
|
20
|
+
# build: what goes into the template (languages, packages, files, steps). settings: what a workspace gets when it opens.
|
|
21
|
+
schema: shardflux.template-recipe.v2
|
|
22
|
+
base: ${base}
|
|
23
|
+
build:
|
|
24
|
+
# Languages from the platform's table: python, node, go, rust, java (version omitted: the base's default).
|
|
25
|
+
languages: []
|
|
26
|
+
# - id: python
|
|
27
|
+
# - id: node
|
|
28
|
+
# version: "22"
|
|
29
|
+
packages:
|
|
30
|
+
apt: [] # e.g. [jq, ffmpeg]
|
|
31
|
+
pip:
|
|
32
|
+
packages: [] # installed into /opt/venv, e.g. [pandas==2.3.2]
|
|
33
|
+
requirements: [] # absolute paths of requirements files copied in by files, e.g. [/home/user/app/requirements.txt]
|
|
34
|
+
npm: [] # installed globally, e.g. [typescript@5.9.2]
|
|
35
|
+
# Local files and folders ("from", relative to this file) are uploaded by shard templates build; a folder is copied
|
|
36
|
+
# into "to" (kind tar), a file to the path "to" (kind file).
|
|
37
|
+
files: []
|
|
38
|
+
# - from: ./app
|
|
39
|
+
# to: /home/user/app
|
|
40
|
+
# owner: user
|
|
41
|
+
# - from: ./config/settings.toml
|
|
42
|
+
# to: /home/user/.config/app/settings.toml
|
|
43
|
+
# owner: user
|
|
44
|
+
# mode: "0600"
|
|
45
|
+
# Build steps run after languages, packages and files, in this order (as root unless user is set). A recipe must
|
|
46
|
+
# build something (a language, a package, a file or a step): replace this one with your own.
|
|
47
|
+
steps:
|
|
48
|
+
- name: hello
|
|
49
|
+
run: echo "built from template.yaml" > /etc/motd
|
|
50
|
+
# - name: install-deps
|
|
51
|
+
# run: npm ci
|
|
52
|
+
# user: user
|
|
53
|
+
# cwd: /home/user/app
|
|
54
|
+
network:
|
|
55
|
+
build: auto # auto: the hosts the recipe needs (plus extra_hosts); none; or allowlist with allow_hosts
|
|
56
|
+
settings:
|
|
57
|
+
env: {} # e.g. {APP_ENV: development}
|
|
58
|
+
inputs: {} # open-time inputs, e.g. {PROJECT_NAME: {kind: text, required: true}, OPENAI_API_KEY: {kind: secret}}
|
|
59
|
+
start: [] # start commands, e.g. [{name: seed, when: create, run: "python seed.py", user: user}]
|
|
60
|
+
services: {} # e.g. {web: {run: "npm start", user: user, cwd: /home/user/app, ready: {port: 3000}}}
|
|
61
|
+
`;
|
|
62
|
+
}
|
package/dist/timing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LifecycleTiming, ProgressListener } from '@shardflux/sdk';
|
|
1
|
+
import type { BuildFromFileEvent, LifecycleTiming, ProgressListener, TemplateBuild } from '@shardflux/sdk';
|
|
2
2
|
export declare class TimingRecorder {
|
|
3
3
|
/** The timing of the last traced call that ended (token fetches are not lifecycle calls and are skipped). */
|
|
4
4
|
last: LifecycleTiming | null;
|
|
@@ -15,3 +15,31 @@ export declare class TimingRecorder {
|
|
|
15
15
|
export declare function timingText(t: LifecycleTiming): string;
|
|
16
16
|
/** The `--json` form: the SDK's LifecycleTiming with the CLI's snake_case keys (the rest of its JSON is the API's). */
|
|
17
17
|
export declare function timingJson(t: LifecycleTiming | null): Record<string, unknown> | null;
|
|
18
|
+
/**
|
|
19
|
+
* Where a template build's time went, from the SDK's build-from-file progress events: reading and packing the local
|
|
20
|
+
* files, the uploads, the build request, and the wait; plus the build's own queued and run time from its view
|
|
21
|
+
* (created_at, started_at, completed_at).
|
|
22
|
+
*/
|
|
23
|
+
export declare class BuildTimer {
|
|
24
|
+
#private;
|
|
25
|
+
packMs: number;
|
|
26
|
+
uploadMs: number;
|
|
27
|
+
files: number;
|
|
28
|
+
sent: number;
|
|
29
|
+
bytesSent: number;
|
|
30
|
+
buildId: string | null;
|
|
31
|
+
record(e: BuildFromFileEvent): void;
|
|
32
|
+
finish(build: TemplateBuild | null): void;
|
|
33
|
+
get totalMs(): number;
|
|
34
|
+
/** From the last upload (or the start, reading the file) to the created build: the organization lookup and the POST. */
|
|
35
|
+
get requestMs(): number | null;
|
|
36
|
+
get waitMs(): number | null;
|
|
37
|
+
get server(): {
|
|
38
|
+
queuedMs: number | null;
|
|
39
|
+
runMs: number | null;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** One stderr line per build-from-file progress event. */
|
|
43
|
+
export declare function progressLine(e: BuildFromFileEvent): string;
|
|
44
|
+
export declare function buildTimingText(t: BuildTimer): string;
|
|
45
|
+
export declare function buildTimingJson(t: BuildTimer): Record<string, unknown>;
|
package/dist/timing.js
CHANGED
|
@@ -63,3 +63,99 @@ export function timingJson(t) {
|
|
|
63
63
|
outside_server_ms: t.outsideServerMs,
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
|
+
// ---- `shard templates build --timing` (0.4.0) ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* Where a template build's time went, from the SDK's build-from-file progress events: reading and packing the local
|
|
69
|
+
* files, the uploads, the build request, and the wait; plus the build's own queued and run time from its view
|
|
70
|
+
* (created_at, started_at, completed_at).
|
|
71
|
+
*/
|
|
72
|
+
export class BuildTimer {
|
|
73
|
+
#t0 = performance.now();
|
|
74
|
+
packMs = 0;
|
|
75
|
+
uploadMs = 0;
|
|
76
|
+
files = 0;
|
|
77
|
+
sent = 0;
|
|
78
|
+
bytesSent = 0;
|
|
79
|
+
buildId = null;
|
|
80
|
+
#requestDoneAt = null;
|
|
81
|
+
#prepareDoneAt = null;
|
|
82
|
+
#endAt = null;
|
|
83
|
+
#build = null;
|
|
84
|
+
record(e) {
|
|
85
|
+
const now = performance.now();
|
|
86
|
+
if (e.type === 'pack') {
|
|
87
|
+
this.packMs += e.ms;
|
|
88
|
+
this.files += 1;
|
|
89
|
+
this.#prepareDoneAt = now;
|
|
90
|
+
}
|
|
91
|
+
else if (e.type === 'upload') {
|
|
92
|
+
this.uploadMs += e.ms;
|
|
93
|
+
if (e.uploaded) {
|
|
94
|
+
this.sent += 1;
|
|
95
|
+
this.bytesSent += e.size;
|
|
96
|
+
}
|
|
97
|
+
this.#prepareDoneAt = now;
|
|
98
|
+
}
|
|
99
|
+
else if (this.#requestDoneAt === null) {
|
|
100
|
+
this.#requestDoneAt = now;
|
|
101
|
+
this.buildId = e.build.id;
|
|
102
|
+
this.#build = e.build;
|
|
103
|
+
}
|
|
104
|
+
else
|
|
105
|
+
this.#build = e.build;
|
|
106
|
+
}
|
|
107
|
+
finish(build) {
|
|
108
|
+
this.#endAt = performance.now();
|
|
109
|
+
if (build)
|
|
110
|
+
this.#build = build;
|
|
111
|
+
}
|
|
112
|
+
get totalMs() {
|
|
113
|
+
return (this.#endAt ?? performance.now()) - this.#t0;
|
|
114
|
+
}
|
|
115
|
+
/** From the last upload (or the start, reading the file) to the created build: the organization lookup and the POST. */
|
|
116
|
+
get requestMs() {
|
|
117
|
+
return this.#requestDoneAt === null ? null : this.#requestDoneAt - (this.#prepareDoneAt ?? this.#t0);
|
|
118
|
+
}
|
|
119
|
+
get waitMs() {
|
|
120
|
+
return this.#requestDoneAt === null || this.#endAt === null ? null : this.#endAt - this.#requestDoneAt;
|
|
121
|
+
}
|
|
122
|
+
get server() {
|
|
123
|
+
const b = this.#build;
|
|
124
|
+
const ms = (a, z) => (a && z ? Math.max(0, Date.parse(z) - Date.parse(a)) : null);
|
|
125
|
+
return { queuedMs: ms(b?.created_at, b?.started_at), runMs: ms(b?.started_at, b?.completed_at) };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const fmtMs = (ms) => (ms === null ? '-' : ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`);
|
|
129
|
+
const short = (sha) => sha.slice(0, 12);
|
|
130
|
+
const bytes = (n) => (n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KiB` : n < 1024 ** 3 ? `${(n / 1024 ** 2).toFixed(1)} MiB` : `${(n / 1024 ** 3).toFixed(2)} GiB`);
|
|
131
|
+
/** One stderr line per build-from-file progress event. */
|
|
132
|
+
export function progressLine(e) {
|
|
133
|
+
if (e.type === 'pack')
|
|
134
|
+
return e.kind === 'tar' && e.entries !== null ? `packed ${e.from} (${e.entries} entries, ${bytes(e.size)}, sha256 ${short(e.sha256)}) in ${fmtMs(e.ms)}\n` : `read ${e.from} (${bytes(e.size)}, sha256 ${short(e.sha256)}) in ${fmtMs(e.ms)}\n`;
|
|
135
|
+
if (e.type === 'upload')
|
|
136
|
+
return e.uploaded ? `uploaded sha256:${short(e.sha256)} (${bytes(e.size)}) in ${fmtMs(e.ms)}\n` : `sha256:${short(e.sha256)} is already uploaded\n`;
|
|
137
|
+
const b = e.build;
|
|
138
|
+
return `build ${b.id}: ${b.state}${b.state === 'published' ? `, registration ${b.registration.state}` : ''}${b.failure ? ` (${b.failure.code})` : ''}\n`;
|
|
139
|
+
}
|
|
140
|
+
export function buildTimingText(t) {
|
|
141
|
+
const s = t.server;
|
|
142
|
+
const uploads = t.files ? `, upload ${fmtMs(t.uploadMs)} (${t.sent} of ${t.files} sent, ${bytes(t.bytesSent)})` : '';
|
|
143
|
+
const lines = [`timing: templates build ${fmtMs(t.totalMs)}${t.buildId ? ` (build ${t.buildId})` : ''}`, ` pack ${fmtMs(t.packMs)}${uploads}, request ${fmtMs(t.requestMs)}${t.waitMs !== null && t.waitMs > 1 ? `, wait ${fmtMs(t.waitMs)}` : ''}`];
|
|
144
|
+
if (s.queuedMs !== null || s.runMs !== null)
|
|
145
|
+
lines.push(` server: queued ${fmtMs(s.queuedMs)}, run ${fmtMs(s.runMs)}`);
|
|
146
|
+
return `${lines.join('\n')}\n`;
|
|
147
|
+
}
|
|
148
|
+
export function buildTimingJson(t) {
|
|
149
|
+
const r = (v) => (v === null ? null : Math.round(v * 10) / 10);
|
|
150
|
+
return {
|
|
151
|
+
action: 'template_build',
|
|
152
|
+
build_id: t.buildId,
|
|
153
|
+
total_ms: r(t.totalMs),
|
|
154
|
+
pack_ms: r(t.packMs),
|
|
155
|
+
upload_ms: r(t.uploadMs),
|
|
156
|
+
request_ms: r(t.requestMs),
|
|
157
|
+
wait_ms: r(t.waitMs),
|
|
158
|
+
uploads: { files: t.files, sent: t.sent, bytes_sent: t.bytesSent },
|
|
159
|
+
server: { queued_ms: t.server.queuedMs, run_ms: t.server.runMs },
|
|
160
|
+
};
|
|
161
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "shard: the Shardflux command line. Open, run commands in, move files to, suspend, resume and fork persistent agent workspaces.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"
|
|
42
|
+
"yaml": "2.9.1",
|
|
43
|
+
"@shardflux/sdk": "^0.7.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@eslint/js": "10.0.1",
|