@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/commands.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command implementations. Everything goes through @shardflux/sdk (the same
|
|
3
|
+
* client, retries, idempotency keys, operation polling and tool-token
|
|
4
|
+
* management as SDK users get); `cloud.request()` covers the two endpoints
|
|
5
|
+
* the SDK has no typed method for yet (GET /v1/operations/{id}, usage summary).
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { SDK_VERSION, ShardfluxApiError } from '@shardflux/sdk';
|
|
10
|
+
import { CLI_VERSION, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
|
|
11
|
+
import { InterruptedError, NotFoundError, UsageError } from "./errors.js";
|
|
12
|
+
import { fileTable, json, kv, operationDetail, operationTable, sessionTable, usageText, workspaceDetail, workspaceTable } from "./format.js";
|
|
13
|
+
const DEFAULT_WAIT_MS = 300_000;
|
|
14
|
+
const MAX_JSON_OUTPUT = 16 * 1024 * 1024;
|
|
15
|
+
const MAX_STDIN = 1024 * 1024;
|
|
16
|
+
const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
17
|
+
function out(ctx, human, machine) {
|
|
18
|
+
ctx.io.stdout.write(ctx.json ? json(machine) : human);
|
|
19
|
+
}
|
|
20
|
+
function caps(values) {
|
|
21
|
+
const c = {};
|
|
22
|
+
const cpu = str(values['cpu-millis']);
|
|
23
|
+
const mem = str(values['memory-mib']);
|
|
24
|
+
const disk = str(values['disk-gib']);
|
|
25
|
+
if (cpu !== undefined)
|
|
26
|
+
c.cpu_millis = parsePositiveInt(cpu, 'cpu-millis', 1_000_000);
|
|
27
|
+
if (mem !== undefined)
|
|
28
|
+
c.memory_mib = parsePositiveInt(mem, 'memory-mib', 16_777_216);
|
|
29
|
+
if (disk !== undefined)
|
|
30
|
+
c.disk_gib = parsePositiveInt(disk, 'disk-gib', 1_000_000);
|
|
31
|
+
return Object.keys(c).length ? c : undefined;
|
|
32
|
+
}
|
|
33
|
+
function waitTimeout(values) {
|
|
34
|
+
const t = str(values.timeout);
|
|
35
|
+
return t === undefined ? DEFAULT_WAIT_MS : parseDuration(t, 'timeout');
|
|
36
|
+
}
|
|
37
|
+
/** `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key. */
|
|
38
|
+
export async function resolveWorkspace(ctx, ref) {
|
|
39
|
+
const cloud = ctx.cloud();
|
|
40
|
+
if (UUID.test(ref)) {
|
|
41
|
+
try {
|
|
42
|
+
return await cloud.workspaces.get(ref);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (!(err instanceof ShardfluxApiError && err.status === 404))
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for await (const w of cloud.workspaces.listAll({ keyPrefix: ref, includeDeleted: true, limit: 200 })) {
|
|
50
|
+
if (w.key === ref)
|
|
51
|
+
return w;
|
|
52
|
+
if (ctx.signal.aborted)
|
|
53
|
+
throw new InterruptedError('interrupted');
|
|
54
|
+
}
|
|
55
|
+
throw new NotFoundError(`No workspace with id or key "${ref}" in this project.`);
|
|
56
|
+
}
|
|
57
|
+
/** Waits with the SDK's backoff; Ctrl-C reports the operation that keeps running. */
|
|
58
|
+
async function waitOperation(ctx, operationId, timeoutMs) {
|
|
59
|
+
try {
|
|
60
|
+
return await ctx.cloud().workspaces.waitForOperation(operationId, { timeoutMs, signal: ctx.signal });
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (ctx.signal.aborted)
|
|
64
|
+
throw new InterruptedError(`Interrupted; operation ${operationId} continues server side.`, operationId);
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function getOperation(ctx, operationId) {
|
|
69
|
+
if (!UUID.test(operationId))
|
|
70
|
+
throw new UsageError(`<operation-id> must be a UUID (got "${operationId}")`);
|
|
71
|
+
const body = await ctx.cloud().request('GET', `/v1/operations/${encodeURIComponent(operationId)}`);
|
|
72
|
+
return body.operation;
|
|
73
|
+
}
|
|
74
|
+
async function readAll(stream, max, what) {
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let n = 0;
|
|
77
|
+
for await (const c of stream) {
|
|
78
|
+
const b = typeof c === 'string' ? Buffer.from(c) : c;
|
|
79
|
+
n += b.length;
|
|
80
|
+
if (n > max)
|
|
81
|
+
throw new UsageError(`${what} exceeds ${max} bytes`);
|
|
82
|
+
chunks.push(b);
|
|
83
|
+
}
|
|
84
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
85
|
+
}
|
|
86
|
+
async function readInput(ctx, source, max, what) {
|
|
87
|
+
if (source === '-') {
|
|
88
|
+
if (ctx.io.stdin.isTTY)
|
|
89
|
+
throw new UsageError(`${what}: standard input is a terminal; pipe data in or pass a file`);
|
|
90
|
+
return readAll(ctx.io.stdin, max, what);
|
|
91
|
+
}
|
|
92
|
+
const data = await readFile(source).catch((e) => {
|
|
93
|
+
throw new UsageError(`${what}: cannot read "${source}" (${e.code ?? e.message})`);
|
|
94
|
+
});
|
|
95
|
+
if (data.length > max)
|
|
96
|
+
throw new UsageError(`${what} exceeds ${max} bytes`);
|
|
97
|
+
return new Uint8Array(data);
|
|
98
|
+
}
|
|
99
|
+
async function principal(ctx) {
|
|
100
|
+
const me = await ctx.cloud().me();
|
|
101
|
+
if (me.type !== 'api_key' || !me.api_key)
|
|
102
|
+
throw new UsageError('SHARDFLUX_API_KEY did not authenticate as an API key');
|
|
103
|
+
return {
|
|
104
|
+
api_url: ctx.apiUrl,
|
|
105
|
+
organization_id: me.api_key.organization_id,
|
|
106
|
+
project_id: me.api_key.project_id,
|
|
107
|
+
api_key: { id: me.api_key.id, key_id: me.api_key.key_id },
|
|
108
|
+
tool_permissions: me.api_key.tool_permissions,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function principalText(p) {
|
|
112
|
+
return kv([
|
|
113
|
+
['api', p.api_url],
|
|
114
|
+
['organization', p.organization_id],
|
|
115
|
+
['project', p.project_id],
|
|
116
|
+
['api key', `${p.api_key.key_id} (${p.api_key.id})`],
|
|
117
|
+
['tools', p.tool_permissions.length ? p.tool_permissions.join(', ') : '(none: read-only key)'],
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
/** The exit code `shard exec` returns for a finished command. */
|
|
121
|
+
export function execExitCode(r) {
|
|
122
|
+
if (r.timedOut)
|
|
123
|
+
return 124;
|
|
124
|
+
if (typeof r.exitCode === 'number' && r.exitCode >= 0)
|
|
125
|
+
return Math.min(r.exitCode, 255);
|
|
126
|
+
if (typeof r.termSignal === 'number' && r.termSignal > 0)
|
|
127
|
+
return 128 + r.termSignal;
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
async function bestEffortCancel(cell, sessionId) {
|
|
131
|
+
await Promise.race([cell.exec.cancel(sessionId).catch(() => undefined), new Promise((r) => setTimeout(r, 3_000).unref())]);
|
|
132
|
+
}
|
|
133
|
+
function lifecycleText(verb, w, op) {
|
|
134
|
+
const finished = op.state === 'succeeded' || op.state === 'failed' || op.state === 'canceled';
|
|
135
|
+
return `${verb} ${w.key} (${w.id}): operation ${op.id} ${op.kind} ${op.state}${op.state_reason ? ` (${op.state_reason})` : ''}\n${finished ? '' : `wait with: shard operations wait ${op.id}\n`}`;
|
|
136
|
+
}
|
|
137
|
+
export const HANDLERS = {
|
|
138
|
+
async login(ctx) {
|
|
139
|
+
const p = await principal(ctx);
|
|
140
|
+
out(ctx, `Authenticated.\n${principalText(p)}shard stores nothing; it reads SHARDFLUX_API_KEY on every run.\n`, p);
|
|
141
|
+
return 0;
|
|
142
|
+
},
|
|
143
|
+
async whoami(ctx) {
|
|
144
|
+
const p = await principal(ctx);
|
|
145
|
+
out(ctx, principalText(p), p);
|
|
146
|
+
return 0;
|
|
147
|
+
},
|
|
148
|
+
version(ctx) {
|
|
149
|
+
out(ctx, `shard ${CLI_VERSION} (@shardflux/sdk ${SDK_VERSION})\n`, { cli: CLI_VERSION, sdk: SDK_VERSION });
|
|
150
|
+
return Promise.resolve(0);
|
|
151
|
+
},
|
|
152
|
+
async usage(ctx, values) {
|
|
153
|
+
const org = str(values.org) ?? (await principal(ctx)).organization_id;
|
|
154
|
+
if (!UUID.test(org))
|
|
155
|
+
throw new UsageError(`--org must be an organization id (UUID)`);
|
|
156
|
+
const summary = await ctx.cloud().request('GET', `/v1/organizations/${encodeURIComponent(org)}/usage/summary`);
|
|
157
|
+
out(ctx, usageText(summary), summary);
|
|
158
|
+
return 0;
|
|
159
|
+
},
|
|
160
|
+
async 'workspaces open'(ctx, values, [key]) {
|
|
161
|
+
const template = str(values.template);
|
|
162
|
+
if (!template)
|
|
163
|
+
throw new UsageError('--template <slug> is required for "shard workspaces open"');
|
|
164
|
+
const c = caps(values);
|
|
165
|
+
const timeoutMs = waitTimeout(values);
|
|
166
|
+
const cloud = ctx.cloud();
|
|
167
|
+
let ws = await cloud.workspaces.open({ key: key, template, ...(c ? { caps: c } : {}), agentLabel: ctx.agentLabel, wait: false });
|
|
168
|
+
const op = ws.activeOperation;
|
|
169
|
+
if (values.wait !== false && !ws.ready && op) {
|
|
170
|
+
await waitOperation(ctx, op.id, timeoutMs);
|
|
171
|
+
ws = await cloud.workspaces.get(ws.id);
|
|
172
|
+
}
|
|
173
|
+
const view = ws.data;
|
|
174
|
+
const human = ws.ready
|
|
175
|
+
? `Workspace ${ws.key} (${ws.id}) is ready.\n${workspaceDetail(view)}`
|
|
176
|
+
: `Workspace ${ws.key} (${ws.id}) is ${ws.state}${op ? `; operation ${op.id} ${op.kind} ${op.state}` : ''}.\n${op ? `wait with: shard operations wait ${op.id}\n` : ''}`;
|
|
177
|
+
out(ctx, human, view);
|
|
178
|
+
return 0;
|
|
179
|
+
},
|
|
180
|
+
async 'workspaces list'(ctx, values) {
|
|
181
|
+
const cloud = ctx.cloud();
|
|
182
|
+
const state = str(values.state);
|
|
183
|
+
const prefix = str(values.prefix);
|
|
184
|
+
const limitText = str(values.limit);
|
|
185
|
+
const params = {
|
|
186
|
+
...(state ? { state: state } : {}),
|
|
187
|
+
...(prefix ? { keyPrefix: prefix } : {}),
|
|
188
|
+
...(values['include-deleted'] === true ? { includeDeleted: true } : {}),
|
|
189
|
+
...(limitText ? { limit: parsePositiveInt(limitText, 'limit', 200) } : {}),
|
|
190
|
+
};
|
|
191
|
+
let views = [];
|
|
192
|
+
let next = null;
|
|
193
|
+
if (values.all === true) {
|
|
194
|
+
for await (const w of cloud.workspaces.listAll(params)) {
|
|
195
|
+
views.push(w.data);
|
|
196
|
+
if (ctx.signal.aborted)
|
|
197
|
+
throw new InterruptedError('Interrupted.');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
const cursor = str(values.cursor);
|
|
202
|
+
const page = await cloud.workspaces.list({ ...params, ...(cursor ? { cursor } : {}) });
|
|
203
|
+
views = page.data.map((w) => w.data);
|
|
204
|
+
next = page.nextCursor;
|
|
205
|
+
}
|
|
206
|
+
out(ctx, views.length ? workspaceTable(views) : 'No workspaces.\n', { data: views, next_cursor: next });
|
|
207
|
+
if (!ctx.json && next)
|
|
208
|
+
ctx.io.stderr.write(`more: shard workspaces list --cursor ${next}\n`);
|
|
209
|
+
return 0;
|
|
210
|
+
},
|
|
211
|
+
async 'workspaces get'(ctx, _values, [ref]) {
|
|
212
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
213
|
+
out(ctx, workspaceDetail(ws.data), ws.data);
|
|
214
|
+
return 0;
|
|
215
|
+
},
|
|
216
|
+
async 'workspaces exec'(ctx, values, [ref, ...argv]) {
|
|
217
|
+
const env = parseEnvPairs(values.env ?? []);
|
|
218
|
+
const timeout = str(values.timeout);
|
|
219
|
+
const timeoutMs = timeout === undefined ? undefined : parseDuration(timeout, 'timeout');
|
|
220
|
+
const stdinSource = str(values.stdin);
|
|
221
|
+
const stdin = stdinSource === undefined ? undefined : await readInput(ctx, stdinSource, MAX_STDIN, '--stdin');
|
|
222
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
223
|
+
const cell = ws.cell({ agentLabel: ctx.agentLabel });
|
|
224
|
+
const sessionId = `cli-${randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
225
|
+
let r;
|
|
226
|
+
try {
|
|
227
|
+
r = await cell.exec.run(argv, {
|
|
228
|
+
sessionId,
|
|
229
|
+
...(str(values.cwd) !== undefined ? { cwd: str(values.cwd) } : {}),
|
|
230
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
231
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
232
|
+
...(stdin !== undefined ? { stdin } : {}),
|
|
233
|
+
maxOutputBytes: ctx.json ? MAX_JSON_OUTPUT : 0,
|
|
234
|
+
...(ctx.json ? {} : { onOutput: (stream, chunk) => void (stream === 'stderr' ? ctx.io.stderr : ctx.io.stdout).write(chunk) }),
|
|
235
|
+
signal: ctx.signal,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
if (ctx.signal.aborted) {
|
|
240
|
+
await bestEffortCancel(cell, sessionId);
|
|
241
|
+
throw new InterruptedError(`Interrupted; sent cancel to exec session ${sessionId}.`);
|
|
242
|
+
}
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
const code = execExitCode(r);
|
|
246
|
+
if (ctx.json) {
|
|
247
|
+
ctx.io.stdout.write(json({ session_id: r.sessionId, exit_code: r.exitCode, term_signal: r.termSignal, timed_out: r.timedOut, canceled: r.canceled, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated, reconnects: r.reconnects }));
|
|
248
|
+
}
|
|
249
|
+
return code;
|
|
250
|
+
},
|
|
251
|
+
async 'files read'(ctx, values, [ref, path]) {
|
|
252
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
253
|
+
const bytes = await ws.cell({ agentLabel: ctx.agentLabel }).files.read(path);
|
|
254
|
+
const dest = str(values.out);
|
|
255
|
+
if (dest !== undefined) {
|
|
256
|
+
await writeFile(dest, bytes);
|
|
257
|
+
out(ctx, `wrote ${bytes.length} bytes to ${dest}\n`, { path, bytes: bytes.length, out: dest });
|
|
258
|
+
}
|
|
259
|
+
else if (ctx.json) {
|
|
260
|
+
ctx.io.stdout.write(json({ path, bytes: bytes.length, content_base64: Buffer.from(bytes).toString('base64') }));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
ctx.io.stdout.write(bytes);
|
|
264
|
+
}
|
|
265
|
+
return 0;
|
|
266
|
+
},
|
|
267
|
+
async 'files write'(ctx, values, [ref, path]) {
|
|
268
|
+
const data = await readInput(ctx, str(values.from) ?? '-', 1024 * 1024 * 1024, 'input');
|
|
269
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
270
|
+
const r = await ws.cell({ agentLabel: ctx.agentLabel }).files.write(path, data, { append: values.append === true, createParents: values.parents !== false });
|
|
271
|
+
out(ctx, `wrote ${r.bytes_written} bytes to ${r.path}${r.sha256 ? ` (sha256 ${r.sha256})` : ''}\n`, r);
|
|
272
|
+
return 0;
|
|
273
|
+
},
|
|
274
|
+
async 'files ls'(ctx, values, [ref, path]) {
|
|
275
|
+
const limitText = str(values.limit);
|
|
276
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
277
|
+
const list = await ws.cell({ agentLabel: ctx.agentLabel }).files.list(path, { limit: limitText ? parsePositiveInt(limitText, 'limit', 10_000) : 1000 });
|
|
278
|
+
out(ctx, `${fileTable(list)}${list.truncated ? '(truncated)\n' : ''}`, list);
|
|
279
|
+
return 0;
|
|
280
|
+
},
|
|
281
|
+
async 'workspaces suspend'(ctx, values, [ref]) {
|
|
282
|
+
return lifecycle(ctx, values, ref, 'Suspending', (ws) => ctx.cloud().workspaces.suspend(ws.id));
|
|
283
|
+
},
|
|
284
|
+
async 'workspaces resume'(ctx, values, [ref]) {
|
|
285
|
+
return lifecycle(ctx, values, ref, 'Resuming', (ws) => ctx.cloud().workspaces.resume(ws.id));
|
|
286
|
+
},
|
|
287
|
+
async 'workspaces delete'(ctx, values, [ref]) {
|
|
288
|
+
if (values.yes !== true)
|
|
289
|
+
throw new UsageError('refusing to delete without --yes (deletion revokes tool access immediately and cannot be undone)');
|
|
290
|
+
return lifecycle(ctx, values, ref, 'Deleting', (ws) => ctx.cloud().workspaces.delete(ws.id));
|
|
291
|
+
},
|
|
292
|
+
async 'workspaces fork'(ctx, values, [ref, newKey]) {
|
|
293
|
+
const c = caps(values);
|
|
294
|
+
const timeoutMs = waitTimeout(values);
|
|
295
|
+
const source = await resolveWorkspace(ctx, ref);
|
|
296
|
+
const res = await ctx.cloud().workspaces.fork(source.id, { key: newKey, ...(c ? { caps: c } : {}) });
|
|
297
|
+
let op = res.operation;
|
|
298
|
+
if (values.wait === true)
|
|
299
|
+
op = await waitOperation(ctx, op.id, timeoutMs);
|
|
300
|
+
out(ctx, lifecycleText(`Forking ${source.key} into`, res.workspace, op), { operation: op, workspace: res.workspace.data });
|
|
301
|
+
return 0;
|
|
302
|
+
},
|
|
303
|
+
async 'workspaces sessions'(ctx, _values, [ref]) {
|
|
304
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
305
|
+
const sessions = [];
|
|
306
|
+
let cursor;
|
|
307
|
+
do {
|
|
308
|
+
const page = await ctx.cloud().workspaces.agentSessions(ws.id, { limit: 200, ...(cursor ? { cursor } : {}) });
|
|
309
|
+
sessions.push(...page.data);
|
|
310
|
+
cursor = page.nextCursor ?? undefined;
|
|
311
|
+
} while (cursor);
|
|
312
|
+
out(ctx, sessions.length ? sessionTable(sessions) : 'No agent sessions (no tool token was issued for this workspace yet).\n', { data: sessions });
|
|
313
|
+
return 0;
|
|
314
|
+
},
|
|
315
|
+
async 'operations list'(ctx, values, [ref]) {
|
|
316
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
317
|
+
const limitText = str(values.limit);
|
|
318
|
+
const state = str(values.state);
|
|
319
|
+
const kind = str(values.kind);
|
|
320
|
+
const page = await ctx.cloud().workspaces.operations(ws.id, {
|
|
321
|
+
...(limitText ? { limit: parsePositiveInt(limitText, 'limit', 200) } : {}),
|
|
322
|
+
...(state ? { state: state } : {}),
|
|
323
|
+
...(kind ? { kind: kind } : {}),
|
|
324
|
+
});
|
|
325
|
+
out(ctx, page.data.length ? operationTable(page.data) : 'No operations.\n', { data: page.data, next_cursor: page.nextCursor });
|
|
326
|
+
return 0;
|
|
327
|
+
},
|
|
328
|
+
async 'operations get'(ctx, _values, [id]) {
|
|
329
|
+
const op = await getOperation(ctx, id);
|
|
330
|
+
out(ctx, operationDetail(op), op);
|
|
331
|
+
return 0;
|
|
332
|
+
},
|
|
333
|
+
async 'operations wait'(ctx, values, [id]) {
|
|
334
|
+
if (!UUID.test(id))
|
|
335
|
+
throw new UsageError(`<operation-id> must be a UUID (got "${id}")`);
|
|
336
|
+
const op = await waitOperation(ctx, id, waitTimeout(values));
|
|
337
|
+
out(ctx, operationDetail(op), op);
|
|
338
|
+
return 0;
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
async function lifecycle(ctx, values, ref, verb, start) {
|
|
342
|
+
const timeoutMs = waitTimeout(values);
|
|
343
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
344
|
+
let op = await start(ws);
|
|
345
|
+
if (values.wait === true)
|
|
346
|
+
op = await waitOperation(ctx, op.id, timeoutMs);
|
|
347
|
+
out(ctx, lifecycleText(verb, ws, op), op);
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export declare const EXIT: {
|
|
2
|
+
readonly ok: 0;
|
|
3
|
+
/** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
|
|
4
|
+
readonly error: 1;
|
|
5
|
+
/** Bad command line, missing/invalid arguments, or input the API rejected as invalid (422). */
|
|
6
|
+
readonly usage: 2;
|
|
7
|
+
/** The workspace, operation or route does not exist in this project. */
|
|
8
|
+
readonly notFound: 3;
|
|
9
|
+
/** Missing, malformed, revoked or insufficient API key. */
|
|
10
|
+
readonly auth: 4;
|
|
11
|
+
/** Waiting ran out of time (the operation continues server side), or a request timed out. */
|
|
12
|
+
readonly timeout: 5;
|
|
13
|
+
/** The awaited operation ended `failed` or `canceled`. */
|
|
14
|
+
readonly operationFailed: 6;
|
|
15
|
+
/** SIGINT (Ctrl-C). */
|
|
16
|
+
readonly interrupted: 130;
|
|
17
|
+
};
|
|
18
|
+
export declare class UsageError extends Error {
|
|
19
|
+
name: string;
|
|
20
|
+
}
|
|
21
|
+
/** SHARDFLUX_API_KEY missing or malformed (never carries the key). */
|
|
22
|
+
export declare class AuthConfigError extends Error {
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
/** A workspace key that no workspace of this project has. */
|
|
26
|
+
export declare class NotFoundError extends Error {
|
|
27
|
+
name: string;
|
|
28
|
+
}
|
|
29
|
+
/** The user pressed Ctrl-C; `operationId` is set when a server-side operation keeps running. */
|
|
30
|
+
export declare class InterruptedError extends Error {
|
|
31
|
+
name: string;
|
|
32
|
+
readonly operationId: string | undefined;
|
|
33
|
+
constructor(message: string, operationId?: string);
|
|
34
|
+
}
|
|
35
|
+
export interface ErrorInfo {
|
|
36
|
+
code: string;
|
|
37
|
+
message: string;
|
|
38
|
+
status?: number;
|
|
39
|
+
request_id?: string;
|
|
40
|
+
retryable?: boolean;
|
|
41
|
+
operation_id?: string;
|
|
42
|
+
details?: Record<string, unknown>;
|
|
43
|
+
source?: 'api' | 'cell';
|
|
44
|
+
last_state?: string;
|
|
45
|
+
operation?: unknown;
|
|
46
|
+
}
|
|
47
|
+
/** Removes anything shaped like a Shardflux API key (and the configured key itself) from text. */
|
|
48
|
+
export declare function redact(text: string, key?: string): string;
|
|
49
|
+
export declare function describeError(err: unknown): ErrorInfo;
|
|
50
|
+
/** Maps an error to the documented exit code. */
|
|
51
|
+
export declare function exitCodeFor(err: unknown, interrupted?: boolean): number;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes and error description for `shard`. Every failure is reported on
|
|
3
|
+
* stderr with the API's error code, message and request id; the process exit
|
|
4
|
+
* code tells scripts what kind of failure it was (README "Exit codes").
|
|
5
|
+
*/
|
|
6
|
+
import { OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError } from '@shardflux/sdk';
|
|
7
|
+
export const EXIT = {
|
|
8
|
+
ok: 0,
|
|
9
|
+
/** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
|
|
10
|
+
error: 1,
|
|
11
|
+
/** Bad command line, missing/invalid arguments, or input the API rejected as invalid (422). */
|
|
12
|
+
usage: 2,
|
|
13
|
+
/** The workspace, operation or route does not exist in this project. */
|
|
14
|
+
notFound: 3,
|
|
15
|
+
/** Missing, malformed, revoked or insufficient API key. */
|
|
16
|
+
auth: 4,
|
|
17
|
+
/** Waiting ran out of time (the operation continues server side), or a request timed out. */
|
|
18
|
+
timeout: 5,
|
|
19
|
+
/** The awaited operation ended `failed` or `canceled`. */
|
|
20
|
+
operationFailed: 6,
|
|
21
|
+
/** SIGINT (Ctrl-C). */
|
|
22
|
+
interrupted: 130,
|
|
23
|
+
};
|
|
24
|
+
export class UsageError extends Error {
|
|
25
|
+
name = 'UsageError';
|
|
26
|
+
}
|
|
27
|
+
/** SHARDFLUX_API_KEY missing or malformed (never carries the key). */
|
|
28
|
+
export class AuthConfigError extends Error {
|
|
29
|
+
name = 'AuthConfigError';
|
|
30
|
+
}
|
|
31
|
+
/** A workspace key that no workspace of this project has. */
|
|
32
|
+
export class NotFoundError extends Error {
|
|
33
|
+
name = 'NotFoundError';
|
|
34
|
+
}
|
|
35
|
+
/** The user pressed Ctrl-C; `operationId` is set when a server-side operation keeps running. */
|
|
36
|
+
export class InterruptedError extends Error {
|
|
37
|
+
name = 'InterruptedError';
|
|
38
|
+
operationId;
|
|
39
|
+
constructor(message, operationId) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.operationId = operationId;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed']);
|
|
45
|
+
const KEY_PATTERN = /sfk_[A-Za-z0-9]{8,}_[A-Za-z0-9]+/g;
|
|
46
|
+
/** Removes anything shaped like a Shardflux API key (and the configured key itself) from text. */
|
|
47
|
+
export function redact(text, key) {
|
|
48
|
+
let out = text.replace(KEY_PATTERN, 'sfk_[redacted]');
|
|
49
|
+
if (key && key.length >= 8)
|
|
50
|
+
out = out.split(key).join('[redacted]');
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function isNamed(err, name) {
|
|
54
|
+
return typeof err === 'object' && err !== null && err.name === name;
|
|
55
|
+
}
|
|
56
|
+
export function describeError(err) {
|
|
57
|
+
if (err instanceof ShardfluxApiError) {
|
|
58
|
+
return {
|
|
59
|
+
code: err.code,
|
|
60
|
+
message: err.message,
|
|
61
|
+
status: err.status,
|
|
62
|
+
request_id: err.requestId,
|
|
63
|
+
retryable: err.retryable,
|
|
64
|
+
...(err.operationId ? { operation_id: err.operationId } : {}),
|
|
65
|
+
...(err.details ? { details: err.details } : {}),
|
|
66
|
+
source: err.source,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (err instanceof OperationTimeoutError)
|
|
70
|
+
return { code: 'timeout', message: err.message, operation_id: err.operationId, last_state: err.lastState };
|
|
71
|
+
if (err instanceof OperationFailedError) {
|
|
72
|
+
return { code: 'operation_failed', message: err.message, operation_id: err.operationId, ...(err.errorCode ? { details: { error_code: err.errorCode } } : {}), operation: err.operation };
|
|
73
|
+
}
|
|
74
|
+
if (err instanceof UsageError)
|
|
75
|
+
return { code: 'usage', message: err.message };
|
|
76
|
+
if (err instanceof AuthConfigError)
|
|
77
|
+
return { code: 'unauthenticated', message: err.message };
|
|
78
|
+
if (err instanceof NotFoundError)
|
|
79
|
+
return { code: 'not_found', message: err.message };
|
|
80
|
+
if (err instanceof InterruptedError)
|
|
81
|
+
return { code: 'interrupted', message: err.message, ...(err.operationId ? { operation_id: err.operationId } : {}) };
|
|
82
|
+
if (err instanceof ShardfluxProtocolError)
|
|
83
|
+
return { code: 'protocol_error', message: err.message, status: err.status };
|
|
84
|
+
if (isNamed(err, 'TimeoutError'))
|
|
85
|
+
return { code: 'timeout', message: 'The request timed out.' };
|
|
86
|
+
if (isNamed(err, 'AbortError'))
|
|
87
|
+
return { code: 'interrupted', message: 'Interrupted.' };
|
|
88
|
+
if (err instanceof TypeError && err.message === 'fetch failed') {
|
|
89
|
+
const cause = err.cause;
|
|
90
|
+
return { code: 'network_error', message: `Cannot reach the Shardflux API (${cause?.code ?? cause?.message ?? 'network error'}).` };
|
|
91
|
+
}
|
|
92
|
+
return { code: 'internal_error', message: err instanceof Error ? err.message : String(err) };
|
|
93
|
+
}
|
|
94
|
+
/** Maps an error to the documented exit code. */
|
|
95
|
+
export function exitCodeFor(err, interrupted = false) {
|
|
96
|
+
if (interrupted || err instanceof InterruptedError)
|
|
97
|
+
return EXIT.interrupted;
|
|
98
|
+
if (err instanceof UsageError)
|
|
99
|
+
return EXIT.usage;
|
|
100
|
+
if (err instanceof AuthConfigError)
|
|
101
|
+
return EXIT.auth;
|
|
102
|
+
if (err instanceof NotFoundError)
|
|
103
|
+
return EXIT.notFound;
|
|
104
|
+
if (err instanceof OperationTimeoutError || isNamed(err, 'TimeoutError'))
|
|
105
|
+
return EXIT.timeout;
|
|
106
|
+
if (err instanceof OperationFailedError)
|
|
107
|
+
return EXIT.operationFailed;
|
|
108
|
+
if (err instanceof ShardfluxApiError) {
|
|
109
|
+
if (AUTH_CODES.has(err.code) || err.status === 401)
|
|
110
|
+
return EXIT.auth;
|
|
111
|
+
if (err.code === 'not_found' || err.status === 404)
|
|
112
|
+
return EXIT.notFound;
|
|
113
|
+
if (err.code === 'validation_failed')
|
|
114
|
+
return EXIT.usage;
|
|
115
|
+
}
|
|
116
|
+
return EXIT.error;
|
|
117
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable output (aligned tables, key/value blocks) and the error line.
|
|
3
|
+
* `--json` output is the API's own objects (see README "JSON output"), so
|
|
4
|
+
* these functions only shape what people read.
|
|
5
|
+
*/
|
|
6
|
+
import type { AgentSession, FileList, Operation, WorkspaceView } from '@shardflux/sdk';
|
|
7
|
+
import type { ErrorInfo } from './errors.js';
|
|
8
|
+
export declare function json(value: unknown): string;
|
|
9
|
+
export declare function table(headers: readonly string[], rows: readonly (readonly string[])[]): string;
|
|
10
|
+
export declare function kv(pairs: readonly (readonly [string, string])[]): string;
|
|
11
|
+
/** ISO timestamp to second precision ("2026-09-26 12:00:00Z"), "-" when absent. */
|
|
12
|
+
export declare function time(iso: string | null | undefined): string;
|
|
13
|
+
/** { a: 1, b: 2 } -> "a=1 b=2" */
|
|
14
|
+
export declare function pairs(obj: Record<string, unknown> | null | undefined): string;
|
|
15
|
+
export declare function operationSummary(op: Operation | null | undefined): string;
|
|
16
|
+
export declare const WORKSPACE_HEADERS: readonly ["ID", "KEY", "STATE", "DESIRED", "TEMPLATE", "OPERATION", "CREATED"];
|
|
17
|
+
export declare function workspaceRow(w: WorkspaceView): string[];
|
|
18
|
+
export declare function workspaceTable(ws: readonly WorkspaceView[]): string;
|
|
19
|
+
export declare function isReady(w: WorkspaceView): boolean;
|
|
20
|
+
export declare function workspaceDetail(w: WorkspaceView): string;
|
|
21
|
+
export declare const OPERATION_HEADERS: readonly ["ID", "KIND", "STATE", "REASON", "CREATED", "COMPLETED"];
|
|
22
|
+
export declare function operationTable(ops: readonly Operation[]): string;
|
|
23
|
+
export declare function operationDetail(op: Operation): string;
|
|
24
|
+
export declare function sessionTable(sessions: readonly AgentSession[]): string;
|
|
25
|
+
export declare function fileTable(list: FileList): string;
|
|
26
|
+
/**
|
|
27
|
+
* The usage summary (GET /v1/organizations/{id}/usage/summary): plan, period, measurement
|
|
28
|
+
* freshness, then meters and allowances as tables. Columns absent from the response are omitted.
|
|
29
|
+
*/
|
|
30
|
+
export declare function usageText(summary: unknown): string;
|
|
31
|
+
/** One stderr line: `error: <message> [code=... status=... request_id=...]` plus a hint when there is one. */
|
|
32
|
+
export declare function errorText(e: ErrorInfo, hint?: string): string;
|