@rolling-design-sync/agent-cli 0.0.0-stage → 0.13.1

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/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@rolling-design-sync/agent-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.13.1",
4
+ "license": "Apache-2.0",
5
+ "description": "Thin zero-dependency CLI over the v3 agent API; commands generated from its OpenAPI document.",
6
+ "keywords": [
7
+ "figma",
8
+ "design-sync",
9
+ "cli",
10
+ "agent",
11
+ "design-to-code"
12
+ ],
13
+ "homepage": "https://github.com/deven-bryant/rolling-design-sync/tree/main/scripts#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/deven-bryant/rolling-design-sync/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/deven-bryant/rolling-design-sync.git",
20
+ "directory": "scripts"
21
+ },
22
+ "bin": {
23
+ "rds-agent": "rds-agent.js"
24
+ },
25
+ "files": [
26
+ "rds-agent.js",
27
+ "commands.generated.json",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "test": "node --test tests/*.test.js",
33
+ "build": "node --check rds-agent.js && node --check gen-commands.js",
34
+ "gen": "node gen-commands.js",
35
+ "gen:check": "node gen-commands.js --check"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "provenance": true
43
+ }
44
+ }
package/rds-agent.js ADDED
@@ -0,0 +1,562 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * rds-agent — thin CLI over the rolling-design-sync v3 agent API.
6
+ *
7
+ * Commands, flags and requests come from commands.generated.json, generated from
8
+ * the API's OpenAPI document (`npm run gen`; docs/api-v3-phase2.md, "8. Generated
9
+ * MCP tools and CLI commands"). Hand-written here: Figma `--url` addressing,
10
+ * `1-2` node ids, `--all` paging, `--format`, bulk-clear input, output and error
11
+ * formatting, exit codes.
12
+ *
13
+ * Env: RDS_API_BASE_URL, RDS_AGENT_TOKEN, RDS_WORKER. Zero deps (Node >= 20 built-in fetch).
14
+ * Exits 0 on 2xx (and for --help), 1 on API/usage errors.
15
+ */
16
+
17
+ const SPEC = require('./commands.generated.json');
18
+ const { version: VERSION } = require('./package.json');
19
+
20
+ const CLIENT = `cli/${VERSION}`;
21
+ /** @type {Map<string, any>} */
22
+ const COMMANDS = new Map(SPEC.commands.map(c => [c.command, c]));
23
+ const LOCAL_FORMATS = ['json', 'table'];
24
+ /** Parameters that hold node ids, normalized from Figma's `1-2` form. */
25
+ const NODE_ID_PARAMS = new Set(['nodeId', 'under', 'component', 'nodeIds']);
26
+
27
+ class CliError extends Error {
28
+ /**
29
+ * @param {string} message
30
+ * @param {{ exitCode?: number, body?: unknown }} [options]
31
+ */
32
+ constructor(message, { exitCode = 1, body } = {}) {
33
+ super(message);
34
+ this.exitCode = exitCode;
35
+ this.body = body;
36
+ }
37
+ }
38
+
39
+ // --- env -----------------------------------------------------------------------
40
+ const warnedVars = new Set();
41
+ const LEGACY_VAR = /^FDS_([A-Z0-9_]+)$/;
42
+
43
+ /**
44
+ * Warns once per variable (per `warned` set) for each non-empty pre-0.10
45
+ * `FDS_<name>` in `env`, naming its `RDS_<name>` replacement. Their values are
46
+ * never read.
47
+ */
48
+ function warnLegacyEnv(env, { warn = message => process.stderr.write(`${message}\n`), warned = warnedVars } = {}) {
49
+ for (const key of Object.keys(env).sort()) {
50
+ const m = LEGACY_VAR.exec(key);
51
+ if (!m || !env[key] || warned.has(key)) continue;
52
+ warned.add(key);
53
+ warn(`rds-agent: ${key} is no longer read; set RDS_${m[1]} instead`);
54
+ }
55
+ }
56
+
57
+ /** `RDS_<name>` from `env`; empty counts as unset. */
58
+ const envVar = (env, name) => env[`RDS_${name}`] || undefined;
59
+
60
+ // --- help ------------------------------------------------------------------------
61
+ const pad = (rows, gap = 2) => {
62
+ const width = Math.max(...rows.map(r => r[0].length));
63
+ return rows.map(([a, b]) => (b ? `${a.padEnd(width + gap)}${b}` : a));
64
+ };
65
+
66
+ function usage() {
67
+ return [
68
+ 'Usage: rds-agent <command> [arguments] [flags]',
69
+ '',
70
+ 'Commands:',
71
+ ...pad([...COMMANDS.values()].map(c => [` ${c.command}${argsOf(c)}`, c.summary || ''])),
72
+ '',
73
+ "Run 'rds-agent <command> --help' for a command's flags.",
74
+ '',
75
+ 'File-scoped commands take --file-key <key> or --url <figma link> (the link supplies the file key and node id).',
76
+ 'Node ids take 1:2 or 1-2. Global: --format json|table (ndjson|md where the API streams them), --all on paged listings.',
77
+ '',
78
+ 'Env: RDS_API_BASE_URL (required), RDS_AGENT_TOKEN (required), RDS_WORKER (default for --worker)',
79
+ ].join('\n');
80
+ }
81
+
82
+ function argsOf(cmd) {
83
+ const parts = (cmd.positionals || []).map(p => ` <${p.name}>`);
84
+ if (cmd.items) parts.push(` <${cmd.items.keys[0]}>=<${cmd.items.keys[1]}>… | -`);
85
+ if (!parts.length && linkArgument(cmd)) parts.push(' [<figma link>]');
86
+ return parts.join('');
87
+ }
88
+
89
+ const serverFormats = cmd => (cmd.flags.find(f => f.name === 'format' && f.in === 'query') || {}).enum || [];
90
+ const formatsOf = cmd => [...new Set([...LOCAL_FORMATS, ...serverFormats(cmd)])];
91
+
92
+ function flagLine(cmd, f) {
93
+ const value = f.type === 'boolean' ? '' : ` <${f.enum ? f.enum.join('|') : f.type === 'array' ? `${f.items},…` : f.type}>`;
94
+ const notes = [];
95
+ if (f.name === 'fileKey' && hasUrl(cmd)) notes.push('required unless --url');
96
+ else if (f.required) notes.push(f.name === 'worker' && f.in === 'body' ? 'required; default $RDS_WORKER' : 'required');
97
+ if (f.default !== undefined) notes.push(`default ${JSON.stringify(f.default)}`);
98
+ return [` --${f.flag}${value}`, `${notes.length ? `(${notes.join('; ')}) ` : ''}${f.description || ''}`];
99
+ }
100
+
101
+ function commandHelp(cmd) {
102
+ const flags = cmd.flags.filter(f => f.name !== 'format').map(f => flagLine(cmd, f));
103
+ const outline = serverFormats(cmd).includes('outline');
104
+ flags.push([` --format <${formatsOf(cmd).join('|')}>`, `(default "json") table: aligned columns${serverFormats(cmd).length ? '; ndjson, md: streamed by the API' : ''}${outline ? '; outline: every line unless --limit' : ''}`]);
105
+ if (cmd.paging) flags.push([' --all', `Follow ${cmd.paging.next}${outline ? ' (outline: X-RDS-Next-Cursor)' : ''} and print every page as one response`]);
106
+ const lines = [`Usage: rds-agent ${cmd.command}${argsOf(cmd)} [flags]`, '', `${cmd.method} ${cmd.path}`];
107
+ if (cmd.summary) lines.push('', cmd.summary);
108
+ if (cmd.description) lines.push('', cmd.description);
109
+ for (const p of cmd.positionals || []) lines.push('', `<${p.name}>: ${p.description || ''}${p.name === 'nodeId' && hasUrl(cmd) ? ' Optional with --url when the link has a node-id.' : ''}`);
110
+ if (cmd.items) lines.push('', `<${cmd.items.keys[0]}>=<${cmd.items.keys[1]}>: ${cmd.items.description || cmd.items.name}; '-' reads them from stdin (whitespace-separated, # comments).`);
111
+ if (!(cmd.positionals || []).length && !cmd.items && linkArgument(cmd)) lines.push('', '[<figma link>]: stands in for --url.');
112
+ lines.push('', 'Flags:', ...pad(flags));
113
+ return lines.join('\n');
114
+ }
115
+
116
+ // --- parsing ---------------------------------------------------------------------
117
+ const hasUrl = cmd => cmd.flags.some(f => f.name === 'url' && f.in === 'query');
118
+ /** A command without positional arguments takes the Figma link as its argument. */
119
+ const linkArgument = cmd => hasUrl(cmd) && !(cmd.positionals || []).length && !cmd.items;
120
+ const booleanFlags = cmd => new Set(['help', ...(cmd && cmd.paging ? ['all'] : []), ...(cmd ? cmd.flags.filter(f => f.type === 'boolean').map(f => f.flag) : [])]);
121
+
122
+ /** The command named by the leading words of argv (`nodes ls`, `claim`, …), or a CliError. */
123
+ function commandOf(words) {
124
+ const [first, second] = words;
125
+ if (first === undefined) throw new CliError(usage(), { exitCode: 1 });
126
+ if (COMMANDS.has(first)) return { cmd: COMMANDS.get(first), used: 1 };
127
+ const subs = [...COMMANDS.keys()].filter(k => k.startsWith(`${first} `)).map(k => k.slice(first.length + 1));
128
+ if (!subs.length) throw new CliError(`Unknown command: ${first}\n\n${usage()}`);
129
+ if (second === undefined || second.startsWith('-')) {
130
+ if (second === '--help' || second === '-h') throw new CliError(`'${first}' subcommands: ${subs.join(', ')}\n\n${usage()}`, { exitCode: 0 });
131
+ throw new CliError(`'${first}' needs a subcommand: ${subs.join(' or ')}\n\n${usage()}`);
132
+ }
133
+ if (!subs.includes(second)) throw new CliError(`Unknown subcommand: ${first} ${second} (valid: ${subs.join(', ')})\n\n${usage()}`);
134
+ return { cmd: COMMANDS.get(`${first} ${second}`), used: 2 };
135
+ }
136
+
137
+ /** Parse argv (after node + script) into { command, options, args }. */
138
+ function parseArgs(argv) {
139
+ const [head, ...tail] = argv;
140
+ if (head === 'help' || head === '--help' || head === '-h') {
141
+ if (!tail.length) throw new CliError(usage(), { exitCode: 0 });
142
+ throw new CliError(commandHelp(commandOf(tail).cmd), { exitCode: 0 });
143
+ }
144
+ const { cmd, used } = commandOf(argv);
145
+ const rest = argv.slice(used);
146
+ const booleans = booleanFlags(cmd);
147
+ const options = {};
148
+ const args = [];
149
+ for (let i = 0; i < rest.length; i++) {
150
+ const arg = rest[i];
151
+ if (arg === '-h') { options.help = true; continue; }
152
+ if (!arg.startsWith('--')) { args.push(arg); continue; }
153
+ let key = arg.slice(2);
154
+ let value;
155
+ const eq = key.indexOf('=');
156
+ if (eq !== -1) { value = key.slice(eq + 1); key = key.slice(0, eq); }
157
+ if (booleans.has(key)) {
158
+ options[key] = value === undefined ? true : value !== 'false';
159
+ continue;
160
+ }
161
+ if (value === undefined) {
162
+ if (i + 1 >= rest.length) throw new CliError(`Missing value for --${key}\n\n${commandHelp(cmd)}`);
163
+ value = rest[++i];
164
+ }
165
+ if (options[key] !== undefined) throw new CliError(`Pass --${key} once`);
166
+ options[key] = value;
167
+ }
168
+ return { command: cmd.command, options, args };
169
+ }
170
+
171
+ // --- request building ------------------------------------------------------------
172
+ function splitList(value) {
173
+ return String(value).split(',').map(s => s.trim()).filter(Boolean);
174
+ }
175
+
176
+ /** Figma links write node ids as `1-2`; the API stores `1:2`. Other ids pass through. */
177
+ const normalizeNodeId = id => (/^\d+-\d+$/.test(id) ? id.replace('-', ':') : id);
178
+
179
+ function integerOf(f, value) {
180
+ const s = String(value).trim();
181
+ const n = Number(s);
182
+ const min = f.minimum ?? -Infinity;
183
+ const max = f.maximum ?? Infinity;
184
+ if (!/^-?\d+$/.test(s) || n < min || n > max) {
185
+ const range = [Number.isFinite(min) ? `>= ${min}` : '', Number.isFinite(max) ? `<= ${max}` : ''].filter(Boolean).join(' and ');
186
+ throw new CliError(`--${f.flag} must be an integer${range ? ` ${range}` : ''}`);
187
+ }
188
+ return n;
189
+ }
190
+
191
+ /** A flag's value as the API expects it (query values stay strings). */
192
+ function valueOf(f, raw) {
193
+ if (f.type === 'boolean') return f.in === 'query' ? String(!!raw) : !!raw;
194
+ if (typeof raw !== 'string' || !raw.trim()) throw new CliError(`--${f.flag} needs a value`);
195
+ const text = raw.trim();
196
+ if (f.type === 'integer' || f.type === 'number') {
197
+ const n = f.type === 'integer' ? integerOf(f, text) : Number(text);
198
+ if (Number.isNaN(n)) throw new CliError(`--${f.flag} must be a number`);
199
+ return f.in === 'query' ? String(n) : n;
200
+ }
201
+ if (f.type === 'array') {
202
+ const list = splitList(text).map(v => (NODE_ID_PARAMS.has(f.name) ? normalizeNodeId(v) : v));
203
+ if (!list.length) throw new CliError(`--${f.flag} needs at least one value`);
204
+ return f.in === 'query' ? list.join(',') : list.map(v => (f.items === 'integer' ? integerOf(f, v) : v));
205
+ }
206
+ if (f.enum && !f.enum.includes(text)) throw new CliError(`--${f.flag} must be one of: ${f.enum.join(', ')}`);
207
+ if (NODE_ID_PARAMS.has(f.name)) return splitList(text).map(normalizeNodeId).join(',');
208
+ return text;
209
+ }
210
+
211
+ /** `a=b` tokens (arguments or stdin lines; `#` starts a comment) → body items with the command's two keys. */
212
+ function parseItems(cmd, tokens) {
213
+ const [a, b] = cmd.items.keys;
214
+ return tokens.map(token => {
215
+ const eq = token.indexOf('=');
216
+ if (eq <= 0 || eq === token.length - 1) throw new CliError(`Expected <${a}>=<${b}>, got '${token}'`);
217
+ const first = token.slice(0, eq).trim();
218
+ return { [a]: NODE_ID_PARAMS.has(a) ? normalizeNodeId(first) : first, [b]: token.slice(eq + 1).trim() };
219
+ });
220
+ }
221
+
222
+ const stdinTokens = text => String(text || '').split(/\r?\n/)
223
+ .map(line => line.replace(/#.*/, '').trim())
224
+ .filter(Boolean)
225
+ .flatMap(line => line.split(/\s+/));
226
+
227
+ /** Unknown flags are an error: a typo would otherwise silently widen a filter or a claim. */
228
+ function checkFlags(cmd, options) {
229
+ const allowed = [...cmd.flags.map(f => f.flag), ...(cmd.flags.some(f => f.flag === 'format') ? [] : ['format']), ...(cmd.paging ? ['all'] : []), 'help'];
230
+ const unknown = Object.keys(options).filter(k => !allowed.includes(k));
231
+ if (unknown.length) {
232
+ throw new CliError(`Unknown option${unknown.length > 1 ? 's' : ''} for '${cmd.command}': ${unknown.map(k => `--${k}`).join(', ')}. Valid: ${allowed.map(k => `--${k}`).join(' ')}`);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Build a request description for a parsed command:
238
+ * { command, method, path, query, body?, format, all, paging?, chunk? }.
239
+ */
240
+ /**
241
+ * @param {any} parsed
242
+ * @param {{ env?: Record<string, string | undefined>, stdinText?: string }} [options]
243
+ */
244
+ function buildRequest(parsed, { env = {}, stdinText } = {}) {
245
+ const cmd = COMMANDS.get(parsed.command);
246
+ if (!cmd) throw new CliError(`Unknown command: ${parsed.command}\n\n${usage()}`);
247
+ const options = { ...parsed.options };
248
+ const args = [...(parsed.args || [])];
249
+ if (options.help) throw new CliError(commandHelp(cmd), { exitCode: 0 });
250
+ checkFlags(cmd, options);
251
+
252
+ const format = options.format === undefined ? 'json' : String(options.format).trim();
253
+ if (!formatsOf(cmd).includes(format)) throw new CliError(`--format must be one of: ${formatsOf(cmd).join(', ')}`);
254
+ delete options.format;
255
+
256
+ if (linkArgument(cmd) && args.length) {
257
+ if (options.url !== undefined) throw new CliError(`Pass the Figma link as an argument or --url, not both`);
258
+ options.url = args.shift();
259
+ }
260
+ const byUrl = options.url !== undefined && String(options.url).trim() !== '';
261
+
262
+ const pathValues = {};
263
+ for (const p of cmd.positionals || []) {
264
+ const raw = args.length ? String(args.shift()).trim() : '';
265
+ if (raw) pathValues[p.name] = NODE_ID_PARAMS.has(p.name) ? normalizeNodeId(raw) : raw;
266
+ else if (p.name === 'nodeId' && byUrl) pathValues[p.name] = '-';
267
+ else throw new CliError(`'${cmd.command}' needs a <${p.name}>${p.name === 'nodeId' && hasUrl(cmd) ? ' (or --url with a node-id)' : ''}\n\n${commandHelp(cmd)}`);
268
+ }
269
+
270
+ let items;
271
+ if (cmd.items) {
272
+ const tokens = args.splice(0).flatMap(a => (a === '-' ? stdinTokens(stdinText) : [a]));
273
+ if (!tokens.length && cmd.items.required) {
274
+ throw new CliError(`'${cmd.command}' needs <${cmd.items.keys[0]}>=<${cmd.items.keys[1]}> items (arguments, or '-' to read lines from stdin)\n\n${commandHelp(cmd)}`);
275
+ }
276
+ if (tokens.length) items = parseItems(cmd, tokens);
277
+ }
278
+ if (args.length) throw new CliError(`Unexpected argument for '${cmd.command}': ${args.join(' ')}\n\n${commandHelp(cmd)}`);
279
+
280
+ const query = {};
281
+ const body = cmd.body ? {} : undefined;
282
+ /** Body flags and items only exist on commands that take a body. */
283
+ const bodyFields = /** @type {Record<string, any>} */ (body);
284
+ for (const f of cmd.flags) {
285
+ let raw = options[f.flag];
286
+ if (raw === undefined && f.name === 'worker' && f.in === 'body') raw = envVar(env, 'WORKER');
287
+ if (raw === undefined && f.in === 'path' && f.name === 'fileKey' && byUrl) raw = '-';
288
+ if (raw === undefined) {
289
+ if (!f.required) continue;
290
+ if (f.name === 'fileKey' && hasUrl(cmd)) throw new CliError(`--file-key or --url is required\n\n${commandHelp(cmd)}`);
291
+ if (f.name === 'worker' && f.in === 'body') throw new CliError(`--worker is required (or set RDS_WORKER)\n\n${commandHelp(cmd)}`);
292
+ throw new CliError(`--${f.flag} is required\n\n${commandHelp(cmd)}`);
293
+ }
294
+ const value = valueOf(f, raw);
295
+ if (f.in === 'path') pathValues[f.name] = value;
296
+ else if (f.in === 'query') query[f.name] = value;
297
+ else if (f.object) (bodyFields[f.object] = bodyFields[f.object] || {})[f.name] = value;
298
+ else bodyFields[f.name] = value;
299
+ }
300
+ if (items) bodyFields[cmd.items.name] = items;
301
+ if (serverFormats(cmd).includes(format) && format !== 'json') query.format = format;
302
+
303
+ const path = cmd.path.replace(/\{(\w+)\}/g, (_, name) => encodeURIComponent(pathValues[name]));
304
+ const request = { command: cmd.command, method: cmd.method, path, query, format, all: !!options.all };
305
+ if (body !== undefined) request.body = body;
306
+ if (cmd.paging) request.paging = cmd.paging;
307
+ if (cmd.items && cmd.items.maxItems) request.chunk = { name: cmd.items.name, size: cmd.items.maxItems };
308
+ return request;
309
+ }
310
+
311
+ // --- execution -------------------------------------------------------------------
312
+ function readEnv(env) {
313
+ const baseUrl = (envVar(env, 'API_BASE_URL') || '').trim().replace(/\/+$/, '');
314
+ const token = (envVar(env, 'AGENT_TOKEN') || '').trim();
315
+ if (!baseUrl) throw new CliError('RDS_API_BASE_URL is not set');
316
+ if (!token) throw new CliError('RDS_AGENT_TOKEN is not set');
317
+ return { baseUrl, token };
318
+ }
319
+
320
+ function buildUrl(baseUrl, { path, query }) {
321
+ const url = new URL(baseUrl + path);
322
+ for (const [k, v] of Object.entries(query || {})) url.searchParams.set(k, v);
323
+ return url.toString();
324
+ }
325
+
326
+ /** An RFC 9457 problem body (every v3 error). */
327
+ const isProblem = data => !!data && typeof data === 'object' && typeof data.title === 'string' && typeof data.detail === 'string';
328
+
329
+ /** Seconds until a `Retry-After` value (delta-seconds or an HTTP date); null when absent or unreadable. */
330
+ function retrySeconds(value, now = Date.now()) {
331
+ if (value === null || value === undefined || value === '') return null;
332
+ const text = String(value).trim();
333
+ if (/^\d+$/.test(text)) return Number(text);
334
+ const at = Date.parse(text);
335
+ return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - now) / 1000));
336
+ }
337
+
338
+ /** A 429: `error: rate limited (HTTP 429) — retry in N s. <detail>` and the API's hint. */
339
+ function rateLimitMessage(res, data) {
340
+ const header = res.headers && typeof res.headers.get === 'function' ? res.headers.get('retry-after') : null;
341
+ const problem = data && typeof data === 'object' ? data : {};
342
+ const seconds = retrySeconds(header) ?? retrySeconds(problem.retryAfter);
343
+ const when = seconds === null ? 'retry later' : `retry in ${seconds} s`;
344
+ const detail = problem.detail || (typeof data === 'string' ? data.trim() : '');
345
+ return `error: rate limited (HTTP 429) — ${when}.${detail ? ` ${detail}` : ''}${problem.hint ? `\nhint: ${problem.hint}` : ''}`;
346
+ }
347
+
348
+ /** `error: <title> — <detail>` and, when the API gives one, `hint: …`. */
349
+ function problemMessage(problem) {
350
+ return `error: ${problem.title} — ${problem.detail}${problem.hint ? `\nhint: ${problem.hint}` : ''}`;
351
+ }
352
+
353
+ const ACCEPT = { ndjson: 'application/x-ndjson', md: 'text/markdown', outline: 'text/plain' };
354
+ const streamed = request => request.query && request.query.format !== undefined && request.query.format !== 'json';
355
+
356
+ /**
357
+ * Execute a built request; streamed formats return the response text (with
358
+ * `withCursor`, `{ text, next }`: next is its X-RDS-Next-Cursor, else null).
359
+ * `fetchImpl` is injectable for tests.
360
+ */
361
+ async function execute(request, { env = process.env, fetchImpl = globalThis.fetch, withCursor = false } = {}) {
362
+ const { baseUrl, token } = readEnv(env);
363
+ const init = {
364
+ method: request.method,
365
+ headers: { authorization: `Bearer ${token}`, accept: ACCEPT[request.query && request.query.format] || 'application/json', 'x-rds-client': CLIENT },
366
+ };
367
+ if (request.body !== undefined) {
368
+ init.headers['content-type'] = 'application/json';
369
+ init.body = JSON.stringify(request.body);
370
+ }
371
+ const res = await fetchImpl(buildUrl(baseUrl, request), init);
372
+ const text = await res.text();
373
+ if (res.ok && streamed(request)) {
374
+ if (!withCursor) return text;
375
+ return { text, next: (res.headers && typeof res.headers.get === 'function' && res.headers.get('x-rds-next-cursor')) || null };
376
+ }
377
+ let data;
378
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
379
+ if (!res.ok) {
380
+ if (res.status === 429) throw new CliError(rateLimitMessage(res, data));
381
+ if (isProblem(data)) throw new CliError(problemMessage(data));
382
+ const detail = data && typeof data === 'object' ? `${data.error || 'error'}: ${data.message || ''}`.trim() : String(data || '');
383
+ throw new CliError(`HTTP ${res.status} ${request.method} ${request.path}${detail ? ` — ${detail}` : ''}`, { body: data });
384
+ }
385
+ return data;
386
+ }
387
+
388
+ /** `--all`: follow the page token until it runs out (or stops moving), as one response. */
389
+ async function allPages(request, options) {
390
+ const { param, next, items } = request.paging;
391
+ const first = await execute(request, options);
392
+ const rows = [...first[items]];
393
+ let token = first[next];
394
+ let previous = request.query[param];
395
+ let last = first;
396
+ while (token !== null && token !== undefined && String(token) !== String(previous) && last[items].length) {
397
+ last = await execute({ ...request, query: { ...request.query, [param]: String(token) } }, options);
398
+ rows.push(...last[items]);
399
+ previous = token;
400
+ token = last[next];
401
+ }
402
+ return { ...first, [items]: rows, [next]: token };
403
+ }
404
+
405
+ /** `--all` with format=outline: follow X-RDS-Next-Cursor, joining the pages' text. */
406
+ async function outlinePages(request, options) {
407
+ let text = '';
408
+ let cursor = request.query.cursor;
409
+ for (;;) {
410
+ const page = await execute({ ...request, query: { ...request.query, ...(cursor === undefined ? {} : { cursor }) } }, { ...options, withCursor: true });
411
+ text += page.text;
412
+ if (!page.next || page.next === cursor) return text;
413
+ cursor = page.next;
414
+ }
415
+ }
416
+
417
+ /** Bulk writes larger than the API's batch limit go in batches; results concatenate and totals add up. */
418
+ async function inBatches(request, options) {
419
+ const { name, size } = request.chunk;
420
+ const all = request.body[name];
421
+ let merged;
422
+ for (let i = 0; i < all.length; i += size) {
423
+ const part = await execute({ ...request, body: { ...request.body, [name]: all.slice(i, i + size) } }, options);
424
+ if (!merged) { merged = part; continue; }
425
+ for (const [k, v] of Object.entries(part)) {
426
+ if (Array.isArray(v)) merged[k] = [...merged[k], ...v];
427
+ else if (v && typeof v === 'object') for (const [t, n] of Object.entries(v)) merged[k][t] = (merged[k][t] || 0) + n;
428
+ else merged[k] = v;
429
+ }
430
+ }
431
+ return merged;
432
+ }
433
+
434
+ /** Execute a request, following pages for `--all` and splitting bulk items into API-sized batches. */
435
+ async function perform(request, options) {
436
+ if (request.all && request.paging && request.query.format === 'outline') return outlinePages(request, options);
437
+ if (request.all && request.paging && !streamed(request)) return allPages(request, options);
438
+ if (request.chunk && request.body[request.chunk.name] && request.body[request.chunk.name].length > request.chunk.size) return inBatches(request, options);
439
+ return execute(request, options);
440
+ }
441
+
442
+ // --- table output ----------------------------------------------------------------
443
+ const cell = v => {
444
+ if (v === undefined || v === null || v === '') return '-';
445
+ if (typeof v === 'object') return JSON.stringify(v);
446
+ return String(v);
447
+ };
448
+
449
+ /** Left-aligned columns separated by two spaces; the header is upper-cased. */
450
+ function table(headers, rows) {
451
+ const all = [headers.map(h => h.toUpperCase()), ...rows.map(r => r.map(cell))];
452
+ const widths = headers.map((_, i) => Math.max(...all.map(r => r[i].length)));
453
+ return all.map(r => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(widths[i]))).join(' ').trimEnd()).join('\n') + '\n';
454
+ }
455
+
456
+ function categoriesOf(node) {
457
+ if (node.changes === undefined) return undefined;
458
+ if (node.changes === null) return '?';
459
+ const categories = [...new Set(Object.values(node.changes).map(c => c && c.category).filter(Boolean))].sort();
460
+ return categories.join(',');
461
+ }
462
+
463
+ function claimedOf(node) {
464
+ if (node.claim === undefined) return undefined;
465
+ if (!node.claim) return '';
466
+ return node.claim.label ? `${node.claim.worker} (${node.claim.label})` : node.claim.worker;
467
+ }
468
+
469
+ const NODE_COLUMNS = ['node', 'name', 'type', 'change', 'categories', 'claimed'];
470
+ const nodeRow = n => [n.nodeId, n.name, n.type, n.changeType || (n.state === 'dirty' ? undefined : n.state), categoriesOf(n), claimedOf(n)];
471
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
472
+
473
+ const claimTable = data => `claim ${data.claimId} worker ${data.worker}${data.label ? ` (${data.label})` : ''} expires ${data.expiresAt}\n`
474
+ + (data.nodes.length ? table(NODE_COLUMNS, data.nodes.map(nodeRow)) : '') + `${plural(data.nodes.length, 'node')} claimed\n`;
475
+
476
+ /** An array of objects as a table of the first row's keys. */
477
+ function rowsTable(rows) {
478
+ if (!rows.length) return '';
479
+ const keys = Object.keys(rows[0]);
480
+ return table(keys, rows.map(r => keys.map(k => r[k])));
481
+ }
482
+
483
+ /** Any other response: its one array of objects as a table, or its fields as KEY / VALUE. */
484
+ function genericTable(data) {
485
+ if (!data || typeof data !== 'object') return `${cell(data)}\n`;
486
+ const arrays = Object.keys(data).filter(k => Array.isArray(data[k]) && data[k].every(r => r && typeof r === 'object' && !Array.isArray(r)));
487
+ if (arrays.length === 1) {
488
+ const [key] = arrays;
489
+ const rest = Object.keys(data).filter(k => k !== key && data[k] !== null && data[k] !== undefined);
490
+ return rowsTable(data[key]) + [plural(data[key].length, key.replace(/s$/, '')), ...rest.map(k => `${k} ${cell(data[k])}`)].join(' ') + '\n';
491
+ }
492
+ return table(['key', 'value'], Object.entries(data).map(([k, v]) => [k, v]));
493
+ }
494
+
495
+ const RENDERERS = {
496
+ 'nodes ls': data => {
497
+ const shown = `${data.nodes.length}${typeof data.total === 'number' ? ` of ${data.total}` : ''} node${data.total === 1 ? '' : 's'}`;
498
+ const next = data.nextCursor ? ` (more: --cursor ${data.nextCursor}, or --all)` : '';
499
+ return (data.nodes.length ? table(NODE_COLUMNS, data.nodes.map(nodeRow)) : '') + `${shown}${next}\n`;
500
+ },
501
+ 'nodes show': data => table(NODE_COLUMNS, [nodeRow(data)]),
502
+ claim: claimTable,
503
+ renew: claimTable,
504
+ clear: data => table(['node', 'status', 'contentHash'], data.results.map(r => [r.nodeId, r.status, r.contentHash]))
505
+ + `cleared ${data.totals.cleared}, stale ${data.totals.stale}, not found ${data.totals.notFound}\n`,
506
+ release: data => `released claim ${data.claimId} worker ${data.worker}${data.label ? ` (${data.label})` : ''}: ${plural(data.released.length, 'node')}\n`
507
+ + (data.released.length ? table(['node'], data.released.map(id => [id])) : ''),
508
+ resolve: data => table(['fileKey', 'nodeId'], [[data.fileKey, data.nodeId]]),
509
+ block: data => table(NODE_COLUMNS, [nodeRow(data.node)]) + `blocked ${data.node.nodeId}${data.node.blocked && data.node.blocked.reason ? `: ${data.node.blocked.reason}` : ''}\n`,
510
+ unblock: data => table(NODE_COLUMNS, [nodeRow(data.node)]) + `${data.unblocked ? 'unblocked' : 'was not blocked:'} ${data.node.nodeId}\n`,
511
+ };
512
+
513
+ /**
514
+ * stderr notes for bulk-clear items that were not cleared (the request itself succeeded).
515
+ * @param {import('../apps/api/src/v3/types').ClearReply} data
516
+ */
517
+ function clearNotes(data) {
518
+ return (data.results ?? []).filter(r => r.status !== 'cleared').map(r => (r.status === 'stale'
519
+ ? `stale: ${r.nodeId} changed since it was read; current hash ${r.contentHash}. Re-read it (nodes show ${r.nodeId}) and clear against the new hash.\n`
520
+ : `not found: ${r.nodeId} is not in this file.\n`)).join('');
521
+ }
522
+
523
+ function render(request, data) {
524
+ if (typeof data === 'string' && streamed(request)) return data.endsWith('\n') || !data ? data : `${data}\n`;
525
+ if (request.format === 'table') return (RENDERERS[request.command] || genericTable)(data);
526
+ return JSON.stringify(data, null, 2) + '\n';
527
+ }
528
+
529
+ async function readAll(stream) {
530
+ if (typeof stream === 'string') return stream;
531
+ let text = '';
532
+ for await (const chunk of stream) text += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
533
+ return text;
534
+ }
535
+
536
+ async function run(argv, { env = process.env, fetchImpl = globalThis.fetch, stdout = process.stdout, stderr = process.stderr, stdin = process.stdin } = {}) {
537
+ try {
538
+ warnLegacyEnv(env, { warn: message => stderr.write(`${message}\n`) });
539
+ const parsed = parseArgs(argv);
540
+ const cmd = COMMANDS.get(parsed.command);
541
+ const stdinText = cmd.items && parsed.args.includes('-') ? await readAll(stdin) : undefined;
542
+ const request = buildRequest(parsed, { env, stdinText });
543
+ const data = await perform(request, { env, fetchImpl });
544
+ stdout.write(render(request, data));
545
+ if (request.command === 'clear' && data && Array.isArray(data.results)) stderr.write(clearNotes(data));
546
+ return 0;
547
+ } catch (err) {
548
+ if (err instanceof CliError) {
549
+ (err.exitCode === 0 ? stdout : stderr).write(err.message + '\n');
550
+ if (err.body !== undefined && err.body !== null) stderr.write(JSON.stringify(err.body, null, 2) + '\n');
551
+ return err.exitCode;
552
+ }
553
+ stderr.write(`${err && err.message ? err.message : err}\n`);
554
+ return 1;
555
+ }
556
+ }
557
+
558
+ if (require.main === module) {
559
+ run(process.argv.slice(2)).then(code => { process.exitCode = code; });
560
+ }
561
+
562
+ module.exports = { envVar, warnLegacyEnv, retrySeconds, parseArgs, buildRequest, buildUrl, execute, perform, run, normalizeNodeId, usage, commandHelp, CliError, CLIENT, COMMANDS };