@shardflux/cli 0.1.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/LICENSE +202 -0
- package/README.md +110 -0
- package/dist/args.d.ts +63 -0
- package/dist/args.js +390 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +19 -0
- package/dist/commands.d.ts +27 -0
- package/dist/commands.js +349 -0
- package/dist/errors.d.ts +51 -0
- package/dist/errors.js +117 -0
- package/dist/format.d.ts +32 -0
- package/dist/format.js +177 -0
- package/dist/http.d.ts +13 -0
- package/dist/http.js +21 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +120 -0
- package/package.json +58 -0
package/dist/args.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command table and argument parsing for `shard` (node:util parseArgs, no
|
|
3
|
+
* dependencies). Pure: no SDK, no I/O, so it is unit-tested directly.
|
|
4
|
+
*
|
|
5
|
+
* shard [global options] <command> [options] [arguments]
|
|
6
|
+
*
|
|
7
|
+
* `workspaces` may be written `ws`; `operations` may be written `ops`; the
|
|
8
|
+
* `files` and `operations` groups are also reachable under `workspaces`
|
|
9
|
+
* (`shard workspaces files read ...` == `shard files read ...`).
|
|
10
|
+
*/
|
|
11
|
+
import { parseArgs } from 'node:util';
|
|
12
|
+
import { UsageError } from "./errors.js";
|
|
13
|
+
export const CLI_VERSION = '0.1.0';
|
|
14
|
+
export const GLOBAL_OPTIONS = {
|
|
15
|
+
'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
|
|
16
|
+
json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
|
|
17
|
+
'agent-label': { type: 'string', value: '<label>', description: 'Attribution label for workspace tool tokens (default $SHARDFLUX_AGENT_LABEL, else "cli").' },
|
|
18
|
+
help: { type: 'boolean', short: 'h', description: 'Show help.' },
|
|
19
|
+
version: { type: 'boolean', short: 'V', description: 'Print the version.' },
|
|
20
|
+
};
|
|
21
|
+
const CAPS = {
|
|
22
|
+
'cpu-millis': { type: 'string', value: '<n>', description: 'CPU cap in millicores.' },
|
|
23
|
+
'memory-mib': { type: 'string', value: '<n>', description: 'Memory cap in MiB.' },
|
|
24
|
+
'disk-gib': { type: 'string', value: '<n>', description: 'Disk cap in GiB.' },
|
|
25
|
+
};
|
|
26
|
+
const WAIT = {
|
|
27
|
+
wait: { type: 'boolean', description: 'Wait until the operation finishes.' },
|
|
28
|
+
timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (e.g. 90s, 5m; default 5m). The operation continues server side.' },
|
|
29
|
+
};
|
|
30
|
+
const REF = { name: 'id|key', required: true };
|
|
31
|
+
export const COMMANDS = [
|
|
32
|
+
{
|
|
33
|
+
path: ['login'],
|
|
34
|
+
summary: 'Verify SHARDFLUX_API_KEY and show its organization, project and tool permissions',
|
|
35
|
+
description: 'shard never stores the key: it is read from SHARDFLUX_API_KEY on every run.',
|
|
36
|
+
positionals: [],
|
|
37
|
+
options: {},
|
|
38
|
+
},
|
|
39
|
+
{ path: ['whoami'], summary: 'Show the principal behind SHARDFLUX_API_KEY', positionals: [], options: {} },
|
|
40
|
+
{ path: ['version'], summary: 'Print the CLI and SDK versions', positionals: [], options: {} },
|
|
41
|
+
{
|
|
42
|
+
path: ['usage'],
|
|
43
|
+
summary: 'Show the organization usage summary for the current period',
|
|
44
|
+
positionals: [],
|
|
45
|
+
options: { org: { type: 'string', value: '<organization-id>', description: 'Organization (default: the API key’s organization).' } },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
path: ['workspaces', 'open'],
|
|
49
|
+
summary: 'Open a workspace by key (create on first use, reconnect or resume afterwards)',
|
|
50
|
+
description: 'Waits until the workspace is ready unless --no-wait. Reopening never resets an existing workspace.',
|
|
51
|
+
positionals: [{ name: 'key', required: true }],
|
|
52
|
+
options: {
|
|
53
|
+
template: { type: 'string', value: '<slug>', description: 'Template slug (required), e.g. python-node-browser.' },
|
|
54
|
+
...CAPS,
|
|
55
|
+
wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
|
|
56
|
+
timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m). The start continues server side.' },
|
|
57
|
+
},
|
|
58
|
+
examples: ['shard workspaces open acme/demo --template python-node-browser', 'shard ws open acme/demo --template python-node-browser --no-wait --json'],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
path: ['workspaces', 'list'],
|
|
62
|
+
summary: 'List the project’s workspaces',
|
|
63
|
+
positionals: [],
|
|
64
|
+
options: {
|
|
65
|
+
state: { type: 'string', value: '<state>', description: 'Observed state filter (running, suspended, creating, ...).' },
|
|
66
|
+
prefix: { type: 'string', value: '<key-prefix>', description: 'Only keys starting with this prefix.' },
|
|
67
|
+
'include-deleted': { type: 'boolean', description: 'Include deleted workspaces (tombstones).' },
|
|
68
|
+
all: { type: 'boolean', description: 'Fetch every page.' },
|
|
69
|
+
limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 50).' },
|
|
70
|
+
cursor: { type: 'string', value: '<cursor>', description: 'Continue from a previous page.' },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{ path: ['workspaces', 'get'], summary: 'Show one workspace', positionals: [REF], options: {} },
|
|
74
|
+
{
|
|
75
|
+
path: ['workspaces', 'exec'],
|
|
76
|
+
summary: 'Run a command in a workspace; streams its output and exits with its exit code',
|
|
77
|
+
description: 'Runs argv directly (no shell; use `-- bash -lc "..."` for shell syntax). Output is streamed as it arrives and resumes from byte offsets after dropped connections; Ctrl-C stops waiting and cancels the command.',
|
|
78
|
+
positionals: [REF, { name: 'command', required: true, variadic: true }],
|
|
79
|
+
options: {
|
|
80
|
+
cwd: { type: 'string', value: '<dir>', description: 'Working directory (absolute).' },
|
|
81
|
+
env: { type: 'string', multiple: true, value: '<K=V>', description: 'Environment variable (repeatable).' },
|
|
82
|
+
timeout: { type: 'string', value: '<duration>', description: 'Kill the command after this long (exit code 124).' },
|
|
83
|
+
stdin: { type: 'string', value: '<file|->', description: 'Send this file (or - for standard input) to the command’s stdin (max 1 MiB).' },
|
|
84
|
+
},
|
|
85
|
+
examples: ['shard ws exec acme/demo -- python3 -V', 'shard ws exec acme/demo --cwd /home/user/app --env CI=1 -- bash -lc "npm test"'],
|
|
86
|
+
},
|
|
87
|
+
{ path: ['workspaces', 'suspend'], summary: 'Suspend a running workspace (durable checkpoint)', positionals: [REF], options: { ...WAIT } },
|
|
88
|
+
{ path: ['workspaces', 'resume'], summary: 'Resume a suspended workspace', positionals: [REF], options: { ...WAIT } },
|
|
89
|
+
{
|
|
90
|
+
path: ['workspaces', 'fork'],
|
|
91
|
+
summary: 'Fork a workspace into a new key',
|
|
92
|
+
positionals: [REF, { name: 'new-key', required: true }],
|
|
93
|
+
options: { ...CAPS, ...WAIT },
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
path: ['workspaces', 'delete'],
|
|
97
|
+
summary: 'Delete a workspace (tool access ends now; storage is cleaned up asynchronously)',
|
|
98
|
+
positionals: [REF],
|
|
99
|
+
options: { yes: { type: 'boolean', description: 'Required: confirm the deletion.' }, ...WAIT },
|
|
100
|
+
},
|
|
101
|
+
{ path: ['workspaces', 'sessions'], summary: 'List the attributed agent sessions (tool-token principals and labels) of a workspace', positionals: [REF], options: {} },
|
|
102
|
+
{
|
|
103
|
+
path: ['files', 'read'],
|
|
104
|
+
summary: 'Print (or save) a file from a workspace',
|
|
105
|
+
positionals: [REF, { name: 'path', required: true }],
|
|
106
|
+
options: { out: { type: 'string', value: '<file>', description: 'Write to this local file instead of stdout.' } },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
path: ['files', 'write'],
|
|
110
|
+
summary: 'Write a file in a workspace (atomic; durable once acknowledged)',
|
|
111
|
+
positionals: [REF, { name: 'path', required: true }],
|
|
112
|
+
options: {
|
|
113
|
+
from: { type: 'string', value: '<file|->', description: 'Local file to upload, or - for standard input (default -).' },
|
|
114
|
+
append: { type: 'boolean', description: 'Append instead of replacing.' },
|
|
115
|
+
parents: { type: 'boolean', description: 'Create missing parent directories (default; --no-parents to refuse).' },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
path: ['files', 'ls'],
|
|
120
|
+
summary: 'List a directory in a workspace',
|
|
121
|
+
positionals: [REF, { name: 'path', required: true }],
|
|
122
|
+
options: { limit: { type: 'string', value: '<n>', description: 'Maximum entries (default 1000).' } },
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
path: ['operations', 'list'],
|
|
126
|
+
summary: 'List a workspace’s operations, newest first',
|
|
127
|
+
positionals: [REF],
|
|
128
|
+
options: {
|
|
129
|
+
state: { type: 'string', value: '<state>', description: 'queued, capacity_pending, running, succeeded, failed or canceled.' },
|
|
130
|
+
kind: { type: 'string', value: '<kind>', description: 'open, suspend, resume, fork, snapshot, delete, ...' },
|
|
131
|
+
limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 50).' },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{ path: ['operations', 'get'], summary: 'Show one operation', positionals: [{ name: 'operation-id', required: true }], options: {} },
|
|
135
|
+
{
|
|
136
|
+
path: ['operations', 'wait'],
|
|
137
|
+
summary: 'Wait for an operation to finish (exit 0 succeeded, 6 failed/canceled, 5 timeout)',
|
|
138
|
+
positionals: [{ name: 'operation-id', required: true }],
|
|
139
|
+
options: { timeout: { type: 'string', value: '<duration>', description: 'Give up after this long (default 5m).' } },
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
const GROUPS = {
|
|
143
|
+
workspaces: 'Open, inspect and manage workspaces',
|
|
144
|
+
files: 'Read, write and list files in a workspace',
|
|
145
|
+
operations: 'Inspect and wait for lifecycle operations',
|
|
146
|
+
};
|
|
147
|
+
const ALIASES = { ws: 'workspaces', workspace: 'workspaces', ops: 'operations', operation: 'operations', file: 'files' };
|
|
148
|
+
/** Canonical command words: aliases resolved, `workspaces files|operations` folded to the top-level group. */
|
|
149
|
+
export function normalizeWords(words) {
|
|
150
|
+
const w = words.map((x) => ALIASES[x] ?? x);
|
|
151
|
+
if (w[0] === 'workspaces' && (w[1] === 'files' || w[1] === 'operations'))
|
|
152
|
+
return w.slice(1);
|
|
153
|
+
return w;
|
|
154
|
+
}
|
|
155
|
+
const key = (path) => path.join(' ');
|
|
156
|
+
const BY_PATH = new Map(COMMANDS.map((c) => [key(c.path), c]));
|
|
157
|
+
function isPrefix(words) {
|
|
158
|
+
const n = normalizeWords(words);
|
|
159
|
+
if (n.length === 0)
|
|
160
|
+
return true;
|
|
161
|
+
return COMMANDS.some((c) => n.length <= c.path.length && n.every((x, i) => c.path[i] === x));
|
|
162
|
+
}
|
|
163
|
+
export function findCommand(words) {
|
|
164
|
+
return BY_PATH.get(key(normalizeWords(words)));
|
|
165
|
+
}
|
|
166
|
+
// ---- values --------------------------------------------------------------------------------
|
|
167
|
+
const DURATION = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/;
|
|
168
|
+
const MAX_DURATION_MS = 7 * 24 * 3600 * 1000;
|
|
169
|
+
/** "90s", "5m", "1.5h", "250ms"; a bare number is seconds. */
|
|
170
|
+
export function parseDuration(text, option = 'duration') {
|
|
171
|
+
const m = DURATION.exec(text.trim());
|
|
172
|
+
if (!m)
|
|
173
|
+
throw new UsageError(`--${option} must be a duration like 30s, 5m, 1h or 500ms (got "${text}")`);
|
|
174
|
+
const n = Number(m[1]);
|
|
175
|
+
const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[(m[2] ?? 's')];
|
|
176
|
+
const ms = Math.round(n * mult);
|
|
177
|
+
if (ms <= 0)
|
|
178
|
+
throw new UsageError(`--${option} must be greater than zero`);
|
|
179
|
+
if (ms > MAX_DURATION_MS)
|
|
180
|
+
throw new UsageError(`--${option} must be at most 7 days`);
|
|
181
|
+
return ms;
|
|
182
|
+
}
|
|
183
|
+
export function parsePositiveInt(text, option, max = Number.MAX_SAFE_INTEGER) {
|
|
184
|
+
if (!/^\d+$/.test(text.trim()))
|
|
185
|
+
throw new UsageError(`--${option} must be a positive integer (got "${text}")`);
|
|
186
|
+
const n = Number(text);
|
|
187
|
+
if (n < 1 || n > max)
|
|
188
|
+
throw new UsageError(`--${option} must be between 1 and ${max}`);
|
|
189
|
+
return n;
|
|
190
|
+
}
|
|
191
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
192
|
+
/** ["A=1", "B=x=y"] -> { A: "1", B: "x=y" }. */
|
|
193
|
+
export function parseEnvPairs(pairs) {
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const p of pairs) {
|
|
196
|
+
const eq = p.indexOf('=');
|
|
197
|
+
const name = eq < 0 ? p : p.slice(0, eq);
|
|
198
|
+
if (eq < 0 || !ENV_NAME.test(name))
|
|
199
|
+
throw new UsageError(`--env expects NAME=value with a valid variable name (got "${p}")`);
|
|
200
|
+
out[name] = p.slice(eq + 1);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
205
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
206
|
+
/** Validates the API base URL: https, or http only to a loopback address (the key is a bearer credential). */
|
|
207
|
+
export function resolveApiUrl(flag, env) {
|
|
208
|
+
const raw = (flag ?? env ?? '').trim() || 'https://api.shardflux.dev';
|
|
209
|
+
let u;
|
|
210
|
+
try {
|
|
211
|
+
u = new URL(raw);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new UsageError(`API URL is not a valid URL: "${raw}"`);
|
|
215
|
+
}
|
|
216
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && (LOOPBACK.has(u.hostname) || u.hostname.endsWith('.localhost')))) {
|
|
217
|
+
throw new UsageError(`API URL must use https (plain http is allowed only for localhost): "${raw}"`);
|
|
218
|
+
}
|
|
219
|
+
if (u.username || u.password || u.search || u.hash)
|
|
220
|
+
throw new UsageError('API URL must not contain credentials, a query or a fragment');
|
|
221
|
+
return u.toString().replace(/\/+$/, '');
|
|
222
|
+
}
|
|
223
|
+
const FORBIDDEN_KEY_FLAG = /^--?(api-?key|apikey|key|token|secret)(=.*)?$/i;
|
|
224
|
+
const GLOBAL_VALUE_FLAGS = new Set(['--api-url', '--agent-label']);
|
|
225
|
+
function toParseArgsOptions(opts) {
|
|
226
|
+
const out = {};
|
|
227
|
+
for (const [name, o] of Object.entries(opts))
|
|
228
|
+
out[name] = { type: o.type, ...(o.short ? { short: o.short } : {}), ...(o.multiple ? { multiple: true } : {}) };
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Parses argv (without node/script). Throws UsageError for anything invalid;
|
|
233
|
+
* returns help text for --help, bare groups and a bare `shard`.
|
|
234
|
+
*/
|
|
235
|
+
export function parseCommandLine(argv) {
|
|
236
|
+
const terminator = argv.indexOf('--');
|
|
237
|
+
const head = terminator < 0 ? argv : argv.slice(0, terminator);
|
|
238
|
+
for (const a of head) {
|
|
239
|
+
if (FORBIDDEN_KEY_FLAG.test(a)) {
|
|
240
|
+
throw new UsageError('shard reads the API key only from the SHARDFLUX_API_KEY environment variable; never pass it on the command line (shell history and process listings would expose it)');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Pick the command words (the leading non-option words that form a known command path).
|
|
244
|
+
const words = [];
|
|
245
|
+
const rest = [];
|
|
246
|
+
let sawPositional = false;
|
|
247
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
248
|
+
const a = argv[i];
|
|
249
|
+
if (a === '--') {
|
|
250
|
+
rest.push(...argv.slice(i));
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
if (a.startsWith('-') && a !== '-') {
|
|
254
|
+
rest.push(a);
|
|
255
|
+
if (GLOBAL_VALUE_FLAGS.has(a) && i + 1 < argv.length)
|
|
256
|
+
rest.push(argv[(i += 1)]);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!sawPositional && !findCommand(words) && isPrefix([...words, a])) {
|
|
260
|
+
words.push(a);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
sawPositional = true;
|
|
264
|
+
rest.push(a);
|
|
265
|
+
}
|
|
266
|
+
const wantsHelp = head.includes('--help') || head.includes('-h');
|
|
267
|
+
const command = findCommand(words);
|
|
268
|
+
if (!command) {
|
|
269
|
+
if (words.length === 0) {
|
|
270
|
+
if (head.includes('--version') || head.includes('-V'))
|
|
271
|
+
return { kind: 'version' };
|
|
272
|
+
const stray = rest.find((r) => !r.startsWith('-'));
|
|
273
|
+
if (stray !== undefined)
|
|
274
|
+
throw new UsageError(`unknown command "${stray}"`);
|
|
275
|
+
if (rest.length > 0 && !wantsHelp) {
|
|
276
|
+
const unknown = rest.find((r) => !(r in { '--json': 1, '--help': 1, '-h': 1 }));
|
|
277
|
+
if (unknown !== undefined)
|
|
278
|
+
throw new UsageError(`unknown option "${unknown}"`);
|
|
279
|
+
}
|
|
280
|
+
return { kind: 'help', text: topHelp() };
|
|
281
|
+
}
|
|
282
|
+
const group = normalizeWords(words)[0];
|
|
283
|
+
const stray = rest.find((r) => !r.startsWith('-'));
|
|
284
|
+
if (stray !== undefined)
|
|
285
|
+
throw new UsageError(`unknown command "${[...normalizeWords(words), stray].join(' ')}"`);
|
|
286
|
+
return { kind: 'help', text: groupHelp(group) };
|
|
287
|
+
}
|
|
288
|
+
let parsed;
|
|
289
|
+
try {
|
|
290
|
+
parsed = parseArgs({
|
|
291
|
+
args: rest,
|
|
292
|
+
options: toParseArgsOptions({ ...GLOBAL_OPTIONS, ...command.options }),
|
|
293
|
+
allowPositionals: true,
|
|
294
|
+
allowNegative: true,
|
|
295
|
+
strict: true,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
300
|
+
}
|
|
301
|
+
const values = parsed.values;
|
|
302
|
+
if (values.help === true)
|
|
303
|
+
return { kind: 'help', text: commandHelp(command) };
|
|
304
|
+
if (values.version === true)
|
|
305
|
+
return { kind: 'version' };
|
|
306
|
+
const positionals = parsed.positionals;
|
|
307
|
+
const specs = command.positionals;
|
|
308
|
+
const variadic = specs.at(-1)?.variadic === true;
|
|
309
|
+
const required = specs.filter((s) => s.required && !s.variadic).length;
|
|
310
|
+
if (positionals.length < required || (variadic && positionals.length < specs.length)) {
|
|
311
|
+
const missing = specs[Math.min(positionals.length, specs.length - 1)];
|
|
312
|
+
throw new UsageError(`missing <${missing.name}> for "shard ${command.path.join(' ')}"`);
|
|
313
|
+
}
|
|
314
|
+
if (!variadic && positionals.length > specs.length) {
|
|
315
|
+
throw new UsageError(`unexpected argument "${positionals[specs.length]}" for "shard ${command.path.join(' ')}"`);
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
kind: 'run',
|
|
319
|
+
command,
|
|
320
|
+
values,
|
|
321
|
+
positionals,
|
|
322
|
+
global: {
|
|
323
|
+
apiUrl: typeof values['api-url'] === 'string' ? values['api-url'] : undefined,
|
|
324
|
+
json: values.json === true,
|
|
325
|
+
agentLabel: typeof values['agent-label'] === 'string' ? values['agent-label'] : undefined,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
// ---- help ----------------------------------------------------------------------------------
|
|
330
|
+
function optionLines(opts) {
|
|
331
|
+
const rows = Object.entries(opts).map(([name, o]) => [`${o.short ? `-${o.short}, ` : ' '}--${name}${o.value ? ` ${o.value}` : ''}`, o.description]);
|
|
332
|
+
const w = Math.max(0, ...rows.map((r) => r[0].length));
|
|
333
|
+
return rows.map(([l, d]) => ` ${l.padEnd(w)} ${d}`);
|
|
334
|
+
}
|
|
335
|
+
export function usageLine(c) {
|
|
336
|
+
const pos = c.positionals.map((p) => (p.variadic ? `-- <${p.name}> [args...]` : p.required ? `<${p.name}>` : `[${p.name}]`));
|
|
337
|
+
const opts = Object.keys(c.options).length > 0 ? ' [options]' : '';
|
|
338
|
+
return `shard ${c.path.join(' ')}${opts}${pos.length ? ` ${pos.join(' ')}` : ''}`;
|
|
339
|
+
}
|
|
340
|
+
export function commandHelp(c) {
|
|
341
|
+
const lines = [`Usage: ${usageLine(c)}`, '', c.summary + '.'];
|
|
342
|
+
if (c.description)
|
|
343
|
+
lines.push('', c.description);
|
|
344
|
+
if (Object.keys(c.options).length > 0)
|
|
345
|
+
lines.push('', 'Options:', ...optionLines(c.options));
|
|
346
|
+
lines.push('', 'Global options:', ...optionLines(GLOBAL_OPTIONS));
|
|
347
|
+
if (c.examples?.length)
|
|
348
|
+
lines.push('', 'Examples:', ...c.examples.map((e) => ` ${e}`));
|
|
349
|
+
return lines.join('\n') + '\n';
|
|
350
|
+
}
|
|
351
|
+
export function groupHelp(group) {
|
|
352
|
+
const cmds = COMMANDS.filter((c) => c.path[0] === group);
|
|
353
|
+
const w = Math.max(...cmds.map((c) => usageLine(c).length));
|
|
354
|
+
return [`Usage: shard ${group} <command> [options]`, '', `${GROUPS[group] ?? ''}.`, '', 'Commands:', ...cmds.map((c) => ` ${usageLine(c).padEnd(w)} ${c.summary}`), ...(group === 'workspaces' ? ['', 'Also: shard workspaces files <read|write|ls> ... and shard workspaces operations <list|get|wait> ...'] : []), '', `Run "shard ${group} <command> --help" for details.`].join('\n') + '\n';
|
|
355
|
+
}
|
|
356
|
+
export const EXIT_CODE_HELP = [
|
|
357
|
+
' 0 success',
|
|
358
|
+
' 1 error (API refusal such as conflict or quota, network, protocol)',
|
|
359
|
+
' 2 usage error (bad arguments, or input the API rejected as invalid)',
|
|
360
|
+
' 3 not found (workspace, operation or route)',
|
|
361
|
+
' 4 authentication/authorization (missing, malformed, revoked or insufficient key)',
|
|
362
|
+
' 5 timeout (waiting gave up; the operation continues server side)',
|
|
363
|
+
' 6 operation failed or canceled',
|
|
364
|
+
' 130 interrupted (Ctrl-C)',
|
|
365
|
+
' exec: the command’s own exit code (124 if it timed out, 128+N if killed by signal N)',
|
|
366
|
+
];
|
|
367
|
+
export function topHelp() {
|
|
368
|
+
const w = Math.max(...COMMANDS.map((c) => usageLine(c).length));
|
|
369
|
+
return [
|
|
370
|
+
`shard ${CLI_VERSION} — the Shardflux command line (built on @shardflux/sdk)`,
|
|
371
|
+
'',
|
|
372
|
+
'Usage: shard [global options] <command> [options] [arguments]',
|
|
373
|
+
'',
|
|
374
|
+
'Commands:',
|
|
375
|
+
...COMMANDS.map((c) => ` ${usageLine(c).padEnd(w)} ${c.summary}`),
|
|
376
|
+
'',
|
|
377
|
+
'Aliases: ws = workspaces, ops = operations; "shard workspaces files|operations ..." also works.',
|
|
378
|
+
'',
|
|
379
|
+
'Global options:',
|
|
380
|
+
...optionLines(GLOBAL_OPTIONS),
|
|
381
|
+
'',
|
|
382
|
+
'Environment:',
|
|
383
|
+
' SHARDFLUX_API_KEY project API key (sfk_...), required; never pass it as an argument',
|
|
384
|
+
' SHARDFLUX_API_URL API base URL (default https://api.shardflux.dev)',
|
|
385
|
+
' SHARDFLUX_AGENT_LABEL attribution label for tool tokens (default cli)',
|
|
386
|
+
'',
|
|
387
|
+
'Exit codes:',
|
|
388
|
+
...EXIT_CODE_HELP,
|
|
389
|
+
].join('\n') + '\n';
|
|
390
|
+
}
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `shard` executable. Ctrl-C aborts in-flight waits/streams cleanly (exit
|
|
4
|
+
* 130); a second Ctrl-C exits at once. Output is flushed before exiting
|
|
5
|
+
* (stdout/stderr pipes are asynchronous on macOS).
|
|
6
|
+
*/
|
|
7
|
+
import { run } from "./main.js";
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
let interrupts = 0;
|
|
10
|
+
process.on('SIGINT', () => {
|
|
11
|
+
interrupts += 1;
|
|
12
|
+
if (interrupts > 1)
|
|
13
|
+
process.exit(130);
|
|
14
|
+
controller.abort(new Error('interrupted'));
|
|
15
|
+
});
|
|
16
|
+
const flush = (stream) => new Promise((resolve) => stream.write('', () => resolve()));
|
|
17
|
+
const code = await run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr, stdin: process.stdin, env: process.env }, { signal: controller.signal });
|
|
18
|
+
await Promise.all([flush(process.stdout), flush(process.stderr)]);
|
|
19
|
+
process.exit(code);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RunResult, Shardflux, Workspace } from '@shardflux/sdk';
|
|
2
|
+
import type { Values } from './args.js';
|
|
3
|
+
export interface Writer {
|
|
4
|
+
write(chunk: string | Uint8Array): boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface Io {
|
|
7
|
+
stdout: Writer;
|
|
8
|
+
stderr: Writer;
|
|
9
|
+
stdin: AsyncIterable<Buffer | string> & {
|
|
10
|
+
isTTY?: boolean;
|
|
11
|
+
};
|
|
12
|
+
env: Record<string, string | undefined>;
|
|
13
|
+
}
|
|
14
|
+
export interface Ctx {
|
|
15
|
+
io: Io;
|
|
16
|
+
json: boolean;
|
|
17
|
+
apiUrl: string;
|
|
18
|
+
agentLabel: string;
|
|
19
|
+
signal: AbortSignal;
|
|
20
|
+
cloud(): Shardflux;
|
|
21
|
+
}
|
|
22
|
+
export type Handler = (ctx: Ctx, values: Values, positionals: string[]) => Promise<number>;
|
|
23
|
+
/** `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key. */
|
|
24
|
+
export declare function resolveWorkspace(ctx: Ctx, ref: string): Promise<Workspace>;
|
|
25
|
+
/** The exit code `shard exec` returns for a finished command. */
|
|
26
|
+
export declare function execExitCode(r: Pick<RunResult, 'exitCode' | 'termSignal' | 'timedOut'>): number;
|
|
27
|
+
export declare const HANDLERS: Record<string, Handler>;
|