@thinremote/thinr-cli 1.2.0-alpha.1 → 1.2.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +192 -16
  3. package/bin/thinr.js +23 -24
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +30 -17
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +199 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +12 -4
  51. package/lib/constants.js +76 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +109 -147
  57. package/lib/devices.js +18 -21
  58. package/lib/fleet-upgrade.js +106 -0
  59. package/lib/format.js +114 -0
  60. package/lib/mcp/registry.js +14 -0
  61. package/lib/mcp/tools-alarms.js +510 -0
  62. package/lib/mcp/tools-buckets.js +103 -0
  63. package/lib/mcp/tools-devices.js +33 -3
  64. package/lib/mcp/tools-exec.js +30 -17
  65. package/lib/mcp/tools-filesystem.js +102 -6
  66. package/lib/mcp/tools-install.js +153 -0
  67. package/lib/mcp/tools-logs.js +225 -0
  68. package/lib/mcp/tools-playbook.js +211 -0
  69. package/lib/mcp/tools-products.js +1226 -105
  70. package/lib/mcp/tools-profile.js +634 -0
  71. package/lib/mcp/tools-tokens.js +371 -0
  72. package/lib/monitoring.js +26 -1
  73. package/lib/playbook/actions.js +447 -0
  74. package/lib/playbook/expression.js +302 -0
  75. package/lib/playbook/fleet.js +175 -0
  76. package/lib/playbook/loader.js +247 -0
  77. package/lib/playbook/runner.js +300 -0
  78. package/lib/playbook/schema.js +209 -0
  79. package/lib/playbook/vars.js +209 -0
  80. package/lib/product/log-presets.js +196 -0
  81. package/lib/product/logs.js +485 -0
  82. package/lib/product-orchestrator.js +124 -0
  83. package/lib/product.js +523 -20
  84. package/lib/profile.js +665 -0
  85. package/lib/property.js +7 -40
  86. package/lib/proxy.js +9 -8
  87. package/lib/resource.js +10 -14
  88. package/lib/status.js +78 -37
  89. package/lib/tokens.js +374 -0
  90. package/package.json +14 -3
  91. package/commands/product.js +0 -407
@@ -0,0 +1,911 @@
1
+ // @ts-check
2
+ import { readFileSync, writeFileSync } from 'fs';
3
+ import Table from 'cli-table3';
4
+ import YAML from 'yaml';
5
+ import { confirm } from '@inquirer/prompts';
6
+ import {
7
+ deleteProductPlaybook,
8
+ findProductPlaybook,
9
+ listProductPlaybooks,
10
+ readProductPlaybook,
11
+ uploadFleetRunReport,
12
+ uploadProductPlaybook,
13
+ } from '../../lib/product.js';
14
+ import { parsePlaybook } from '../../lib/playbook/loader.js';
15
+ import { buildDryRunPlan, runPlaybook } from '../../lib/playbook/runner.js';
16
+ import { DEFAULT_FAILURE_THRESHOLD, runFleetPlaybook } from '../../lib/playbook/fleet.js';
17
+ import { coerceCliVarValue, listVariables, resolveVarScope } from '../../lib/playbook/vars.js';
18
+ import { filterActiveDevices, getDevices } from '../../lib/devices.js';
19
+ import { requireConfig } from '../../lib/config.js';
20
+ import { inputError } from '../../lib/errors.js';
21
+ import {
22
+ accent,
23
+ error as errorStyle,
24
+ hint,
25
+ info,
26
+ label,
27
+ muted,
28
+ success,
29
+ warning,
30
+ } from '../../lib/format.js';
31
+ import { classifyError, createSpinner, isJsonMode, printErr, printOk } from '../../lib/output.js';
32
+ import {
33
+ applyJsonFlag,
34
+ collectKeyValue,
35
+ ensureConfigured,
36
+ getGlobalUser,
37
+ parsePositiveInt,
38
+ } from '../_shared.js';
39
+
40
+ async function readAllStdin() {
41
+ const chunks = [];
42
+ for await (const chunk of process.stdin) {
43
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
44
+ }
45
+ return Buffer.concat(chunks).toString('utf8');
46
+ }
47
+
48
+ function registerList(playbook) {
49
+ playbook
50
+ .command('list <productId>')
51
+ .helpGroup('Playbooks:')
52
+ .description('List playbooks registered on a product')
53
+ .option('-j, --json', 'Output as JSON')
54
+ .action(async (productId, opts, cmd) => {
55
+ applyJsonFlag(opts);
56
+ ensureConfigured();
57
+ const user = getGlobalUser(cmd);
58
+ try {
59
+ const entries = await listProductPlaybooks(productId, user);
60
+ if (isJsonMode()) {
61
+ printOk({ product: productId, playbooks: entries });
62
+ return;
63
+ }
64
+ if (entries.length === 0) {
65
+ console.log(`No playbooks registered on ${info(productId)}`);
66
+ return;
67
+ }
68
+ const table = new Table({
69
+ head: ['Name', 'Description', 'Path'].map((h) => label(h)),
70
+ style: { head: [], border: ['gray'] },
71
+ });
72
+ for (const e of entries) {
73
+ table.push([e.name, e.description || muted('—'), muted(e.path)]);
74
+ }
75
+ console.log(`${entries.length} playbook(s) on ${info(productId)}:`);
76
+ console.log(table.toString());
77
+ } catch (err) {
78
+ const { message, code } = classifyError(err);
79
+ printErr(message, { code });
80
+ }
81
+ });
82
+ }
83
+
84
+ function registerUpload(playbook) {
85
+ playbook
86
+ .command('upload <productId> <name> [file]')
87
+ .helpGroup('Playbooks:')
88
+ .description(
89
+ 'Upload a playbook YAML to a product (reads stdin when <file> is omitted or "-")',
90
+ )
91
+ .option('-d, --description <text>', 'Override the description recorded in the index')
92
+ .option('--skip-validation', 'Skip playbook schema validation before uploading')
93
+ .option('-j, --json', 'Output as JSON')
94
+ .action(async (productId, name, file, opts, cmd) => {
95
+ applyJsonFlag(opts);
96
+ ensureConfigured();
97
+ const user = getGlobalUser(cmd);
98
+
99
+ let content;
100
+ try {
101
+ if (file && file !== '-') {
102
+ content = readFileSync(file, 'utf8');
103
+ } else if (!process.stdin.isTTY) {
104
+ content = await readAllStdin();
105
+ } else {
106
+ printErr(
107
+ 'No playbook given. Pass a file path or pipe the YAML on stdin.',
108
+ { code: 'input_error' },
109
+ );
110
+ return;
111
+ }
112
+ } catch (err) {
113
+ const { message, code } = classifyError(err);
114
+ printErr(message, { code: code === 'error' ? 'input_error' : code });
115
+ return;
116
+ }
117
+
118
+ const spinner = createSpinner(`Uploading playbook ${name} to ${productId}...`).start();
119
+ try {
120
+ const result = await uploadProductPlaybook({
121
+ product: productId,
122
+ name,
123
+ content,
124
+ description: opts.description,
125
+ user,
126
+ skipValidation: !!opts.skipValidation,
127
+ });
128
+ spinner.succeed(
129
+ `Playbook ${name} ${result.action} on ${productId}` +
130
+ (result.replaced ? ' (replaced existing entry)' : ''),
131
+ );
132
+ if (isJsonMode()) {
133
+ printOk({
134
+ product: productId,
135
+ action: result.action,
136
+ replaced: result.replaced,
137
+ entry: result.entry,
138
+ steps: result.steps,
139
+ });
140
+ return;
141
+ }
142
+ if (result.replaced) {
143
+ console.log(warning(`Replaced existing playbook "${name}".`));
144
+ }
145
+ for (const step of result.steps) console.log(` ${muted('·')} ${step}`);
146
+ } catch (err) {
147
+ spinner.fail(`Failed to upload playbook ${name}`);
148
+ const { message, code } = classifyError(err);
149
+ printErr(message, { code: code === 'error' ? 'input_error' : code });
150
+ }
151
+ });
152
+ }
153
+
154
+ function registerDownload(playbook) {
155
+ playbook
156
+ .command('download <productId> <name>')
157
+ .helpGroup('Playbooks:')
158
+ .description('Download a playbook YAML by name (writes to stdout by default)')
159
+ .option('-o, --output <file>', 'Write to a local file instead of stdout')
160
+ .option('-j, --json', 'Output as JSON (wraps the YAML in the envelope)')
161
+ .action(async (productId, name, opts, cmd) => {
162
+ applyJsonFlag(opts);
163
+ ensureConfigured();
164
+ const user = getGlobalUser(cmd);
165
+ try {
166
+ const content = await readProductPlaybook(productId, name, user);
167
+ if (opts.output) {
168
+ writeFileSync(opts.output, content, 'utf8');
169
+ if (isJsonMode()) {
170
+ printOk({
171
+ product: productId,
172
+ name,
173
+ bytes: Buffer.byteLength(content, 'utf8'),
174
+ output: opts.output,
175
+ });
176
+ return;
177
+ }
178
+ console.log(
179
+ success(`Wrote ${Buffer.byteLength(content, 'utf8')} bytes to ${opts.output}`),
180
+ );
181
+ return;
182
+ }
183
+ if (isJsonMode()) {
184
+ printOk({
185
+ product: productId,
186
+ name,
187
+ bytes: Buffer.byteLength(content, 'utf8'),
188
+ content,
189
+ });
190
+ return;
191
+ }
192
+ process.stdout.write(content);
193
+ if (content && !content.endsWith('\n')) process.stdout.write('\n');
194
+ } catch (err) {
195
+ const { message, code } = classifyError(err);
196
+ printErr(message, { code });
197
+ }
198
+ });
199
+ }
200
+
201
+ function registerDelete(playbook) {
202
+ playbook
203
+ .command('delete <productId> <name>')
204
+ .helpGroup('Playbooks:')
205
+ .description('Delete a playbook from a product (idempotent)')
206
+ .option('-j, --json', 'Output as JSON')
207
+ .action(async (productId, name, opts, cmd) => {
208
+ applyJsonFlag(opts);
209
+ ensureConfigured();
210
+ const user = getGlobalUser(cmd);
211
+ const spinner = createSpinner(`Deleting playbook ${name} from ${productId}...`).start();
212
+ try {
213
+ const result = await deleteProductPlaybook({ product: productId, name, user });
214
+ if (result.removed) {
215
+ spinner.succeed(`Deleted playbook ${name} from ${productId}`);
216
+ } else {
217
+ spinner.succeed(`Playbook ${name} was not registered on ${productId}`);
218
+ }
219
+ if (isJsonMode()) {
220
+ printOk({
221
+ product: productId,
222
+ name,
223
+ removed: result.removed,
224
+ indexRemoved: result.indexRemoved,
225
+ fileRemoved: result.fileRemoved,
226
+ steps: result.steps,
227
+ });
228
+ return;
229
+ }
230
+ for (const step of result.steps) console.log(` ${muted('·')} ${step}`);
231
+ } catch (err) {
232
+ spinner.fail(`Failed to delete playbook ${name}`);
233
+ const { message, code } = classifyError(err);
234
+ printErr(message, { code: code === 'error' ? 'input_error' : code });
235
+ }
236
+ });
237
+ }
238
+
239
+ function loadVarsFile(filePath) {
240
+ const raw = readFileSync(filePath, 'utf8');
241
+ const trimmed = raw.trim();
242
+ if (!trimmed) return {};
243
+ try {
244
+ const parsed = YAML.parse(raw);
245
+ if (parsed == null) return {};
246
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
247
+ throw inputError(
248
+ `--vars-file "${filePath}" must contain a mapping of variable names to values.`,
249
+ );
250
+ }
251
+ return parsed;
252
+ } catch (err) {
253
+ if (err.code === 'input_error') throw err;
254
+ throw inputError(`Failed to parse --vars-file "${filePath}": ${err.message}`);
255
+ }
256
+ }
257
+
258
+ function collectCliOverrides(pb, cliVarEntries, varsFilePath) {
259
+ const merged = varsFilePath ? { ...loadVarsFile(varsFilePath) } : {};
260
+ for (const [name, raw] of Object.entries(cliVarEntries || {})) {
261
+ merged[name] = coerceCliVarValue(pb, name, raw);
262
+ }
263
+ return merged;
264
+ }
265
+
266
+ function renderStepResult(step, stepIndex) {
267
+ const header = `${String(stepIndex + 1).padStart(2)}. ${step.name}`;
268
+ if (step.skipped) {
269
+ return ` ${muted('·')} ${label(header)} ${muted(step.summary)}`;
270
+ }
271
+ if (!step.ok) {
272
+ return ` ${errorStyle('!')} ${label(header)} ${errorStyle(step.summary)}`;
273
+ }
274
+ const verdict = step.verdict;
275
+ let tag = success('+');
276
+ if (verdict === 'unchanged') tag = muted('=');
277
+ else if (verdict === 'unknown') tag = hint('?');
278
+ const duration = muted(` (${step.durationMs}ms)`);
279
+ return ` ${tag} ${label(header)} ${muted(step.summary)}${duration}`;
280
+ }
281
+
282
+ async function maybeConfirm({ productId, name, deviceId, steps, skipPrompt }) {
283
+ if (skipPrompt) return true;
284
+ if (isJsonMode()) return true;
285
+ if (!process.stdin.isTTY) return true;
286
+ const message = `${accent('Run')} playbook "${name}" on product "${productId}" against device "${deviceId}" (${steps} step${steps === 1 ? '' : 's'})?`;
287
+ return confirm({ message, default: false });
288
+ }
289
+
290
+ async function maybeConfirmFleet({
291
+ productId,
292
+ name,
293
+ steps,
294
+ deviceCount,
295
+ batchSize,
296
+ failureThreshold,
297
+ skipPrompt,
298
+ }) {
299
+ if (skipPrompt) return true;
300
+ if (isJsonMode()) return true;
301
+ if (!process.stdin.isTTY) {
302
+ throw inputError(
303
+ 'Fleet rollout requires interactive confirmation. Re-run with --yes in non-interactive shells.',
304
+ );
305
+ }
306
+ const message =
307
+ `${accent('Roll out')} "${name}" on product "${productId}" to ${deviceCount} device(s) ` +
308
+ `in batches of ${batchSize} (abort at ${failureThreshold}% failure, ${steps} step${steps === 1 ? '' : 's'} each)?`;
309
+ return confirm({ message, default: false });
310
+ }
311
+
312
+ async function collectFleetDevices({ productId, group, filters, includeOffline, user }) {
313
+ const baseFilter = { product: productId };
314
+ if (group) baseFilter.asset_group = group;
315
+ for (const [k, v] of Object.entries(filters || {})) {
316
+ if (!/^[A-Za-z0-9_]+$/.test(k)) {
317
+ throw inputError(`Invalid filter key "${k}" (letters, digits, underscore only).`);
318
+ }
319
+ baseFilter[k] = v;
320
+ }
321
+ const all = await getDevices(baseFilter, user);
322
+ return includeOffline ? all : filterActiveDevices(all);
323
+ }
324
+
325
+ function renderFleetResultTable(results) {
326
+ const table = new Table({
327
+ head: ['Device', 'Status', 'Steps', 'Error'].map((h) => label(h)),
328
+ style: { head: [], border: ['gray'] },
329
+ });
330
+ for (const r of results) {
331
+ const stepCount = Array.isArray(r?.steps) ? r.steps.length : 0;
332
+ let status;
333
+ if (r?.skipped) status = warning('SKIPPED');
334
+ else if (r?.ok) status = success('OK');
335
+ else status = errorStyle('FAILED');
336
+ const err = r?.error ? muted(r.error) : muted('—');
337
+ table.push([r?.device || '?', status, String(stepCount), err]);
338
+ }
339
+ return table.toString();
340
+ }
341
+
342
+ function buildRunReport({
343
+ productId,
344
+ name,
345
+ playbook,
346
+ user,
347
+ startedAt,
348
+ finishedAt,
349
+ mode,
350
+ batchSize,
351
+ failureThreshold,
352
+ filters,
353
+ group,
354
+ includeOffline,
355
+ resolvedVars,
356
+ summary,
357
+ reason,
358
+ results,
359
+ batches,
360
+ }) {
361
+ return {
362
+ product: productId,
363
+ name,
364
+ user,
365
+ startedAt,
366
+ finishedAt,
367
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
368
+ mode,
369
+ playbook: {
370
+ name: playbook.name || null,
371
+ description: playbook.description || null,
372
+ steps: playbook.steps.length,
373
+ },
374
+ rollout: {
375
+ batchSize,
376
+ failureThreshold,
377
+ filters: filters && Object.keys(filters).length > 0 ? filters : null,
378
+ group: group || null,
379
+ includeOffline: !!includeOffline,
380
+ },
381
+ vars: resolvedVars,
382
+ summary,
383
+ aborted: !!reason,
384
+ reason: reason || null,
385
+ batches,
386
+ devices: results.map((r) => ({
387
+ device: r?.device,
388
+ ok: !!r?.ok,
389
+ skipped: !!r?.skipped,
390
+ error: r?.error || null,
391
+ steps: Array.isArray(r?.steps)
392
+ ? r.steps.map((s) => ({
393
+ index: s.index,
394
+ name: s.name,
395
+ ok: !!s.ok,
396
+ skipped: !!s.skipped,
397
+ verdict: s.verdict || null,
398
+ summary: s.summary,
399
+ durationMs: s.durationMs,
400
+ error: s.error || null,
401
+ }))
402
+ : [],
403
+ })),
404
+ };
405
+ }
406
+
407
+ async function loadProductPlaybook({ productId, name, user }) {
408
+ const entry = await findProductPlaybook(productId, name, user);
409
+ if (!entry) {
410
+ const available = (await listProductPlaybooks(productId, user))
411
+ .map((e) => e.name)
412
+ .join(', ') || '(none)';
413
+ const err = new Error(
414
+ `Playbook "${name}" is not registered on product "${productId}". Available: ${available}`,
415
+ );
416
+ err.code = 'not_found';
417
+ throw err;
418
+ }
419
+ const content = await readProductPlaybook(productId, name, user);
420
+ return parsePlaybook(content, { sourcePath: `${name}.yaml` });
421
+ }
422
+
423
+ async function runSingleDevice({ productId, name, pb, overrides, resolvedScope, user, opts }) {
424
+ pb.target.devices = [opts.device];
425
+ pb.target.product = productId;
426
+ pb.target.group = null;
427
+
428
+ if (opts.dryRun) {
429
+ const plan = buildDryRunPlan(pb, { overrides });
430
+ if (isJsonMode()) {
431
+ printOk({
432
+ product: productId,
433
+ name,
434
+ device: opts.device,
435
+ variables: listVariables(pb),
436
+ vars: resolvedScope,
437
+ steps: plan,
438
+ });
439
+ return;
440
+ }
441
+ console.log(`${label(pb.name || name)} ${muted(`(dry-run · ${opts.device})`)}`);
442
+ if (pb.description) console.log(muted(pb.description));
443
+ console.log(label(`\nPlan (${plan.length} step${plan.length === 1 ? '' : 's'}):`));
444
+ for (const s of plan) {
445
+ const pause = s.pause_after ? muted(` then pause ${s.pause_after}s`) : '';
446
+ console.log(
447
+ ` ${hint(String(s.index + 1).padStart(2) + '.')} ` +
448
+ `${label(s.name)} ${muted(`(${s.action})`)}${pause}`,
449
+ );
450
+ }
451
+ return;
452
+ }
453
+
454
+ const confirmed = await maybeConfirm({
455
+ productId,
456
+ name,
457
+ deviceId: opts.device,
458
+ steps: pb.steps.length,
459
+ skipPrompt: !!opts.yes || !!opts.check,
460
+ });
461
+ if (!confirmed) {
462
+ if (isJsonMode()) {
463
+ printOk({ product: productId, name, device: opts.device, cancelled: true });
464
+ } else {
465
+ console.log(warning('Cancelled.'));
466
+ }
467
+ return;
468
+ }
469
+
470
+ const all = await getDevices({ product: productId }, user);
471
+ const device =
472
+ all.find((d) => d.device === opts.device) || {
473
+ device: opts.device,
474
+ connection: { active: false },
475
+ };
476
+
477
+ const verb = opts.check ? 'Checking' : 'Running';
478
+ const spinner = createSpinner(`${verb} "${name}" on ${opts.device}...`).start();
479
+ let results;
480
+ try {
481
+ results = await runPlaybook(pb, [device], {
482
+ user,
483
+ concurrency: 1,
484
+ failFast: true,
485
+ checkMode: !!opts.check,
486
+ overrides,
487
+ });
488
+ spinner.stop();
489
+ } catch (err) {
490
+ spinner.fail(`${verb} failed`);
491
+ throw err;
492
+ }
493
+
494
+ const result = results[0];
495
+ if (isJsonMode()) {
496
+ printOk({
497
+ product: productId,
498
+ name,
499
+ device: opts.device,
500
+ mode: opts.check ? 'check' : 'apply',
501
+ ok: !!result?.ok,
502
+ result,
503
+ });
504
+ return;
505
+ }
506
+
507
+ const titleTag = result?.ok ? success('OK') : errorStyle('FAILED');
508
+ console.log(
509
+ `${label(pb.name || name)} ${muted(`(${opts.check ? 'check' : 'apply'} · ${opts.device})`)} ${titleTag}`,
510
+ );
511
+ if (Array.isArray(result?.steps)) {
512
+ for (const step of result.steps) {
513
+ console.log(renderStepResult(step, step.index));
514
+ }
515
+ }
516
+ if (!result?.ok && result?.error) {
517
+ console.log(errorStyle(`\n${result.error}`));
518
+ }
519
+ }
520
+
521
+ async function runFleet({ productId, name, pb, overrides, resolvedScope, user, opts }) {
522
+ const batchSize = opts.batchSize ?? 5;
523
+ const failureThreshold =
524
+ opts.failureThreshold === undefined
525
+ ? DEFAULT_FAILURE_THRESHOLD
526
+ : opts.failureThreshold;
527
+
528
+ pb.target.devices = null;
529
+ pb.target.product = productId;
530
+ pb.target.group = opts.group || null;
531
+
532
+ let devices;
533
+ try {
534
+ devices = await collectFleetDevices({
535
+ productId,
536
+ group: opts.group,
537
+ filters: opts.filter,
538
+ includeOffline: !!opts.includeOffline,
539
+ user,
540
+ });
541
+ } catch (err) {
542
+ throw err;
543
+ }
544
+
545
+ if (opts.dryRun) {
546
+ const plan = buildDryRunPlan(pb, { overrides });
547
+ if (isJsonMode()) {
548
+ printOk({
549
+ product: productId,
550
+ name,
551
+ fleet: true,
552
+ mode: opts.check ? 'check' : 'apply',
553
+ device_count: devices.length,
554
+ devices: devices.map((d) => d.device),
555
+ batch_size: batchSize,
556
+ failure_threshold: failureThreshold,
557
+ variables: listVariables(pb),
558
+ vars: resolvedScope,
559
+ steps: plan,
560
+ });
561
+ return;
562
+ }
563
+ console.log(
564
+ `${label(pb.name || name)} ${muted(`(dry-run · fleet · ${devices.length} device(s))`)}`,
565
+ );
566
+ if (pb.description) console.log(muted(pb.description));
567
+ console.log(
568
+ muted(
569
+ `Batch size: ${batchSize} · Abort at ${failureThreshold}% failures · ` +
570
+ `${opts.includeOffline ? 'including offline' : 'active only'}`,
571
+ ),
572
+ );
573
+ console.log(label(`\nPlan (${plan.length} step${plan.length === 1 ? '' : 's'}):`));
574
+ for (const s of plan) {
575
+ const pause = s.pause_after ? muted(` then pause ${s.pause_after}s`) : '';
576
+ console.log(
577
+ ` ${hint(String(s.index + 1).padStart(2) + '.')} ` +
578
+ `${label(s.name)} ${muted(`(${s.action})`)}${pause}`,
579
+ );
580
+ }
581
+ if (devices.length > 0) {
582
+ console.log(label('\nTargets:'));
583
+ for (const d of devices) console.log(` ${muted('·')} ${d.device}`);
584
+ }
585
+ return;
586
+ }
587
+
588
+ if (devices.length === 0) {
589
+ const filterHint = opts.filter && Object.keys(opts.filter).length
590
+ ? ` (filters: ${Object.entries(opts.filter).map(([k, v]) => `${k}=${v}`).join(', ')})`
591
+ : '';
592
+ if (isJsonMode()) {
593
+ printOk({
594
+ product: productId,
595
+ name,
596
+ fleet: true,
597
+ device_count: 0,
598
+ devices: [],
599
+ summary: { attempted: 0, succeeded: 0, failed: 0, failureRate: 0 },
600
+ aborted: false,
601
+ reason: null,
602
+ });
603
+ return;
604
+ }
605
+ console.log(warning(`No matching devices on product "${productId}"${filterHint}.`));
606
+ return;
607
+ }
608
+
609
+ const confirmed = await maybeConfirmFleet({
610
+ productId,
611
+ name,
612
+ steps: pb.steps.length,
613
+ deviceCount: devices.length,
614
+ batchSize,
615
+ failureThreshold,
616
+ skipPrompt: !!opts.yes || !!opts.check,
617
+ });
618
+ if (!confirmed) {
619
+ if (isJsonMode()) {
620
+ printOk({ product: productId, name, fleet: true, cancelled: true });
621
+ } else {
622
+ console.log(warning('Cancelled.'));
623
+ }
624
+ return;
625
+ }
626
+
627
+ const mode = opts.check ? 'check' : 'apply';
628
+ const startedAt = new Date().toISOString();
629
+ const spinner = isJsonMode()
630
+ ? null
631
+ : createSpinner(
632
+ `Rolling out "${name}" to ${devices.length} device(s) in batches of ${batchSize}...`,
633
+ ).start();
634
+
635
+ const renderBatchLabel = (info) => {
636
+ if (!spinner) return;
637
+ spinner.text =
638
+ `Batch ${info.index + 1} — ${info.deviceIds.length} device(s) ` +
639
+ `(${info.firstIndex + 1}-${info.firstIndex + info.deviceIds.length}/${devices.length})`;
640
+ };
641
+
642
+ const batchLines = [];
643
+
644
+ let outcome;
645
+ try {
646
+ outcome = await runFleetPlaybook(pb, devices, {
647
+ user,
648
+ batchSize,
649
+ failureThreshold,
650
+ overrides,
651
+ checkMode: !!opts.check,
652
+ onBatchStart: renderBatchLabel,
653
+ onBatchEnd: ({ index, deviceIds, results, cumulative }) => {
654
+ const ok = results.filter((r) => r?.ok).length;
655
+ const fail = results.length - ok;
656
+ const line =
657
+ ` ${muted('·')} Batch ${index + 1}: ` +
658
+ `${success(ok + ' ok')}, ${fail > 0 ? errorStyle(fail + ' failed') : muted('0 failed')}` +
659
+ ` ${muted(`[${cumulative.succeeded}/${cumulative.attempted} done · ${cumulative.failureRate.toFixed(1)}% failure]`)}`;
660
+ batchLines.push(line);
661
+ if (spinner) {
662
+ spinner.text =
663
+ `Batch ${index + 1} done — ${cumulative.succeeded}/${cumulative.attempted} ok ` +
664
+ `(${cumulative.failureRate.toFixed(1)}% failure)`;
665
+ }
666
+ },
667
+ });
668
+ spinner?.stop();
669
+ } catch (err) {
670
+ spinner?.fail('Rollout failed');
671
+ throw err;
672
+ }
673
+ const finishedAt = new Date().toISOString();
674
+
675
+ const summary = {
676
+ attempted: outcome.attempted,
677
+ succeeded: outcome.succeeded,
678
+ failed: outcome.failed,
679
+ failureRate: outcome.failureRate,
680
+ total: devices.length,
681
+ };
682
+
683
+ const report = buildRunReport({
684
+ productId,
685
+ name,
686
+ playbook: pb,
687
+ user: user || requireConfig().username,
688
+ startedAt,
689
+ finishedAt,
690
+ mode,
691
+ batchSize,
692
+ failureThreshold,
693
+ filters: opts.filter,
694
+ group: opts.group,
695
+ includeOffline: !!opts.includeOffline,
696
+ resolvedVars: resolvedScope,
697
+ summary,
698
+ reason: outcome.reason,
699
+ results: outcome.results,
700
+ batches: outcome.batches,
701
+ });
702
+
703
+ const upload = await uploadFleetRunReport({ product: productId, report, user });
704
+
705
+ if (isJsonMode()) {
706
+ printOk({
707
+ product: productId,
708
+ name,
709
+ fleet: true,
710
+ mode,
711
+ startedAt,
712
+ finishedAt,
713
+ batch_size: batchSize,
714
+ failure_threshold: failureThreshold,
715
+ device_count: devices.length,
716
+ batches: outcome.batches,
717
+ summary,
718
+ aborted: outcome.aborted,
719
+ reason: outcome.reason,
720
+ results: outcome.results,
721
+ report_uploaded: upload.ok,
722
+ report_path: upload.ok ? upload.path : null,
723
+ report_error: upload.ok ? null : upload.reason,
724
+ });
725
+ return;
726
+ }
727
+
728
+ console.log(
729
+ `\n${label(pb.name || name)} ${muted(`(${mode} · fleet · ${devices.length} device(s))`)}`,
730
+ );
731
+ for (const line of batchLines) console.log(line);
732
+
733
+ if (outcome.aborted) {
734
+ console.log(
735
+ errorStyle(
736
+ `\nRollout aborted: ${
737
+ outcome.reason === 'failure-threshold'
738
+ ? `failure threshold ${failureThreshold}% reached`
739
+ : outcome.reason
740
+ }`,
741
+ ),
742
+ );
743
+ } else {
744
+ console.log(success(`\nRollout complete.`));
745
+ }
746
+
747
+ console.log(
748
+ muted(
749
+ ` ${summary.succeeded}/${summary.attempted} ok, ${summary.failed} failed ` +
750
+ `(${summary.failureRate.toFixed(1)}% failure rate)` +
751
+ (devices.length > summary.attempted
752
+ ? `, ${devices.length - summary.attempted} skipped`
753
+ : ''),
754
+ ),
755
+ );
756
+
757
+ if (summary.failed > 0 || outcome.results.some((r) => r?.skipped)) {
758
+ console.log('');
759
+ console.log(renderFleetResultTable(outcome.results));
760
+ }
761
+
762
+ if (upload.ok) {
763
+ console.log(muted(`\nReport: ${productId}:${upload.path}`));
764
+ } else {
765
+ const note =
766
+ upload.reason === 'storage-missing'
767
+ ? 'product file storage not enabled — run report not persisted.'
768
+ : `run report upload failed: ${upload.reason}`;
769
+ console.log(warning(`\n${note}`));
770
+ }
771
+ }
772
+
773
+ function registerRun(playbook) {
774
+ playbook
775
+ .command('run <productId> <name>')
776
+ .helpGroup('Playbooks:')
777
+ .description('Run a product playbook against one device or the whole fleet (progressive rollout)')
778
+ .option('-d, --device <id>', 'Target device ID (single-device mode)')
779
+ .option(
780
+ '--fleet',
781
+ 'Fleet rollout: run progressively across every matching device of the product',
782
+ )
783
+ .option(
784
+ '--batch-size <n>',
785
+ 'Devices attempted in parallel per batch (fleet mode; default: 5)',
786
+ parsePositiveInt('batch-size'),
787
+ )
788
+ .option(
789
+ '--failure-threshold <p>',
790
+ `Abort rollout when cumulative failure rate ≥ P percent (fleet mode; default: ${DEFAULT_FAILURE_THRESHOLD})`,
791
+ (v) => {
792
+ const n = Number(v);
793
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
794
+ throw inputError('--failure-threshold must be a number between 0 and 100');
795
+ }
796
+ return n;
797
+ },
798
+ )
799
+ .option('-g, --group <id>', 'Restrict to devices in this asset group (fleet mode)')
800
+ .option(
801
+ '--filter <key=value>',
802
+ 'Extra server-side filter for device selection (repeatable, fleet mode)',
803
+ collectKeyValue,
804
+ {},
805
+ )
806
+ .option(
807
+ '--include-offline',
808
+ 'Include offline devices in the rollout (fleet mode; default: active only)',
809
+ )
810
+ .option(
811
+ '-v, --var <key=value>',
812
+ 'Override a playbook variable (repeatable, values coerced to the declared type)',
813
+ collectKeyValue,
814
+ {},
815
+ )
816
+ .option('--vars-file <path>', 'Load variable overrides from a YAML/JSON file')
817
+ .option('--dry-run', 'Print the resolved plan without contacting any device')
818
+ .option('--check', 'Contact devices read-only and report what each step would change')
819
+ .option('-y, --yes', 'Skip the interactive confirmation prompt')
820
+ .option('-j, --json', 'Output as JSON')
821
+ .action(async (productId, name, opts, cmd) => {
822
+ applyJsonFlag(opts);
823
+ ensureConfigured();
824
+ const user = getGlobalUser(cmd);
825
+
826
+ const fleetMode = !!opts.fleet;
827
+ if (!fleetMode && !opts.device) {
828
+ printErr('Pass either --device <id> or --fleet.', { code: 'input_error' });
829
+ return;
830
+ }
831
+ if (fleetMode && opts.device) {
832
+ printErr('--device and --fleet are mutually exclusive.', { code: 'input_error' });
833
+ return;
834
+ }
835
+ if (!fleetMode) {
836
+ const fleetOnly = [];
837
+ if (opts.batchSize !== undefined) fleetOnly.push('--batch-size');
838
+ if (opts.failureThreshold !== undefined) fleetOnly.push('--failure-threshold');
839
+ if (opts.group) fleetOnly.push('--group');
840
+ if (opts.filter && Object.keys(opts.filter).length) fleetOnly.push('--filter');
841
+ if (opts.includeOffline) fleetOnly.push('--include-offline');
842
+ if (fleetOnly.length > 0) {
843
+ printErr(
844
+ `${fleetOnly.join(', ')} ${fleetOnly.length === 1 ? 'is' : 'are'} only valid with --fleet.`,
845
+ { code: 'input_error' },
846
+ );
847
+ return;
848
+ }
849
+ }
850
+
851
+ let pb;
852
+ try {
853
+ pb = await loadProductPlaybook({ productId, name, user });
854
+ } catch (err) {
855
+ const { message, code } = classifyError(err);
856
+ printErr(message, { code: err.code || code });
857
+ return;
858
+ }
859
+
860
+ let overrides;
861
+ try {
862
+ overrides = collectCliOverrides(pb, opts.var, opts.varsFile);
863
+ } catch (err) {
864
+ const { message, code } = classifyError(err);
865
+ printErr(message, { code: code === 'error' ? 'input_error' : code });
866
+ return;
867
+ }
868
+
869
+ let resolvedScope;
870
+ try {
871
+ resolvedScope = resolveVarScope(pb, overrides);
872
+ } catch (err) {
873
+ printErr(err.message, { code: 'input_error' });
874
+ return;
875
+ }
876
+
877
+ try {
878
+ if (fleetMode) {
879
+ await runFleet({ productId, name, pb, overrides, resolvedScope, user, opts });
880
+ } else {
881
+ await runSingleDevice({
882
+ productId,
883
+ name,
884
+ pb,
885
+ overrides,
886
+ resolvedScope,
887
+ user,
888
+ opts,
889
+ });
890
+ }
891
+ } catch (err) {
892
+ const { message, code } = classifyError(err);
893
+ printErr(message, { code: err.code || code });
894
+ }
895
+ });
896
+ }
897
+
898
+ export function registerProductPlaybookCommand(product) {
899
+ const playbook = product
900
+ .command('playbook')
901
+ .helpGroup('Playbooks:')
902
+ .description(
903
+ `Manage playbooks stored on a product. ${hint('Subcommands: list, upload, download, delete, run.')}`,
904
+ );
905
+
906
+ registerList(playbook);
907
+ registerUpload(playbook);
908
+ registerDownload(playbook);
909
+ registerDelete(playbook);
910
+ registerRun(playbook);
911
+ }