@thinremote/thinr-cli 1.1.0 → 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 (92) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +230 -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/concurrency.js +52 -0
  50. package/lib/config.js +5 -3
  51. package/lib/console.js +12 -4
  52. package/lib/constants.js +76 -0
  53. package/lib/dashboard/event-stream.js +239 -0
  54. package/lib/dashboard/metric-aggregate.js +64 -0
  55. package/lib/dashboard/metric-filter.js +39 -0
  56. package/lib/debug-log.js +93 -0
  57. package/lib/device-api.js +109 -147
  58. package/lib/devices.js +18 -21
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1343 -10
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +15 -3
  92. package/commands/product.js +0 -167
@@ -0,0 +1,275 @@
1
+ // @ts-check
2
+ import { confirm } from '@inquirer/prompts';
3
+ import { getDevices } from '../../lib/devices.js';
4
+ import { getMonitoringData } from '../../lib/monitoring.js';
5
+ import { fetchLatestAgentVersion } from '../../lib/agent-releases.js';
6
+ import { runFleetUpgrade, summarize } from '../../lib/fleet-upgrade.js';
7
+ import { classifyAgainst, normalizeAgentVersion } from '../../lib/agent-versions.js';
8
+ import {
9
+ isJsonMode,
10
+ printOk,
11
+ printErr,
12
+ createSpinner,
13
+ classifyError,
14
+ } from '../../lib/output.js';
15
+ import {
16
+ success,
17
+ error as errorStyle,
18
+ warning,
19
+ hint,
20
+ label,
21
+ muted,
22
+ accent,
23
+ } from '../../lib/format.js';
24
+ import {
25
+ applyJsonFlag,
26
+ ensureConfigured,
27
+ getGlobalUser,
28
+ parsePositiveInt,
29
+ } from '../_shared.js';
30
+
31
+ export function registerUpgradeCommand(fleet) {
32
+ fleet
33
+ .command('upgrade')
34
+ .helpGroup('Fleet:')
35
+ .description('Apply an agent update across every outdated device in the fleet')
36
+ .option('-j, --json', 'Output as JSON')
37
+ .option('--channel <name>', 'Release channel to target', 'latest')
38
+ .option('-p, --product <id>', 'Restrict to devices belonging to this product')
39
+ .option('-g, --group <id>', 'Restrict to devices in this asset group')
40
+ .option(
41
+ '--batch-size <n>',
42
+ 'Devices updated in parallel once the canary passes',
43
+ parsePositiveInt('batch-size'),
44
+ 5,
45
+ )
46
+ .option('--no-canary', 'Skip the canary phase (upgrade everything in batches from the start)')
47
+ .option('--continue-on-error', 'Keep rolling after a batch failure instead of aborting')
48
+ .option('--dry-run', 'List what would be upgraded without sending any update request')
49
+ .option('-y, --yes', 'Skip the confirmation prompt (required in non-interactive shells)')
50
+ .action(async (opts, cmd) => {
51
+ applyJsonFlag(opts);
52
+ ensureConfigured();
53
+ const user = getGlobalUser(cmd);
54
+
55
+ // ── 1. Target version ──────────────────────────────────────
56
+ const latestSpinner = createSpinner(
57
+ `Resolving ${opts.channel} release…`,
58
+ ).start();
59
+ let target;
60
+ try {
61
+ target = await fetchLatestAgentVersion({ channel: opts.channel });
62
+ latestSpinner.succeed(`Channel ${accent(opts.channel)} → ${success(target)}`);
63
+ } catch (err) {
64
+ latestSpinner.fail(`Failed to resolve channel ${opts.channel}`);
65
+ const { message, code } = classifyError(err);
66
+ printErr(message, { code });
67
+ return;
68
+ }
69
+
70
+ // ── 2. Fleet inventory ─────────────────────────────────────
71
+ const listSpinner = createSpinner('Listing fleet…').start();
72
+ let devices;
73
+ try {
74
+ const filter = {};
75
+ if (opts.product) filter.product = opts.product;
76
+ if (opts.group) filter.asset_group = opts.group;
77
+ devices = await getDevices(filter, user);
78
+ listSpinner.succeed(`Fleet size: ${label(devices.length)} device(s)`);
79
+ } catch (err) {
80
+ listSpinner.fail('Failed to list devices');
81
+ const { message, code } = classifyError(err);
82
+ printErr(message, { code });
83
+ return;
84
+ }
85
+ if (devices.length === 0) {
86
+ if (isJsonMode()) {
87
+ printOk({ target, outdated: [], results: [] });
88
+ } else {
89
+ console.log(hint('No devices match the filter.'));
90
+ }
91
+ return;
92
+ }
93
+
94
+ // ── 3. Current versions ────────────────────────────────────
95
+ // Fleet-wide monitoring snapshot trimmed to `agent.version`.
96
+ // The backend may return several rows per device (history
97
+ // tail); with `sort=desc` the first row we see per device is
98
+ // the most recent one, so we ignore later repeats. We also
99
+ // scope to the filtered fleet so monitoring data from devices
100
+ // outside the product/group filter doesn't leak into the count.
101
+ const versionsSpinner = createSpinner('Reading agent versions…').start();
102
+ const fleetIds = new Set(devices.map((d) => d.device));
103
+ const deviceVersions = {};
104
+ for (const d of devices) deviceVersions[d.device] = null;
105
+ try {
106
+ const rows = await getMonitoringData({
107
+ user,
108
+ items: Math.max(500, devices.length),
109
+ sort: 'desc',
110
+ fields: 'agent.version',
111
+ });
112
+ if (Array.isArray(rows)) {
113
+ for (const row of rows) {
114
+ const id = row?.device;
115
+ const v = row?.agent?.version;
116
+ if (!id || !v || !fleetIds.has(id)) continue;
117
+ if (deviceVersions[id]) continue;
118
+ deviceVersions[id] = v;
119
+ }
120
+ }
121
+ const known = Object.values(deviceVersions).filter(Boolean).length;
122
+ versionsSpinner.succeed(`Read versions for ${label(known)} of ${devices.length} device(s)`);
123
+ } catch (err) {
124
+ versionsSpinner.fail('Failed to read monitoring data');
125
+ const { message, code } = classifyError(err);
126
+ printErr(message, { code });
127
+ return;
128
+ }
129
+
130
+ const { outdated, current, unknown } = classifyAgainst(target, deviceVersions);
131
+
132
+ // ── 4. Render summary ──────────────────────────────────────
133
+ const versionCounts = countVersions(deviceVersions);
134
+ if (!isJsonMode()) {
135
+ console.log();
136
+ console.log(label('Agent versions in fleet:'));
137
+ for (const [v, c] of versionCounts) {
138
+ const tag = v === target ? success('●') : warning('●');
139
+ console.log(` ${tag} ${v.padEnd(12)} ${muted(String(c))}`);
140
+ }
141
+ if (unknown.length) {
142
+ console.log(` ${muted('?')} ${'unknown'.padEnd(12)} ${muted(String(unknown.length))}`);
143
+ }
144
+ console.log();
145
+ console.log(
146
+ `Target: ${success(target)} · ` +
147
+ `outdated: ${label(outdated.length)} · ` +
148
+ `current: ${muted(current.length)}` +
149
+ (unknown.length ? ` · unknown: ${muted(unknown.length)}` : ''),
150
+ );
151
+ console.log();
152
+ }
153
+
154
+ if (outdated.length === 0) {
155
+ if (isJsonMode()) {
156
+ printOk({
157
+ target,
158
+ summary: { outdated: 0, current: current.length, unknown: unknown.length },
159
+ results: [],
160
+ });
161
+ } else {
162
+ console.log(success('All devices are already on the target version.'));
163
+ }
164
+ return;
165
+ }
166
+
167
+ // ── 5. Dry-run ─────────────────────────────────────────────
168
+ if (opts.dryRun) {
169
+ if (isJsonMode()) {
170
+ printOk({
171
+ target,
172
+ dryRun: true,
173
+ outdated,
174
+ current,
175
+ unknown,
176
+ });
177
+ return;
178
+ }
179
+ console.log(label(`Would upgrade ${outdated.length} device(s):`));
180
+ for (const id of outdated) {
181
+ console.log(` - ${id} ${muted(`(${normalizeAgentVersion(deviceVersions[id]) || '?'})`)}`);
182
+ }
183
+ return;
184
+ }
185
+
186
+ // ── 6. Confirmation ────────────────────────────────────────
187
+ if (!opts.yes && !isJsonMode()) {
188
+ if (!process.stdin.isTTY) {
189
+ printErr(
190
+ 'Refusing to upgrade without --yes in a non-interactive shell',
191
+ { code: 'needs_confirm' },
192
+ );
193
+ return;
194
+ }
195
+ const plan = opts.canary ? 'canary first, then batches' : 'all in batches';
196
+ const failure = opts.continueOnError ? 'continue on failure' : 'abort on failure';
197
+ const ok = await confirm({
198
+ message: `Upgrade ${outdated.length} device(s) to ${target}? (${plan} · ${failure})`,
199
+ default: false,
200
+ });
201
+ if (!ok) {
202
+ console.log(hint('Cancelled.'));
203
+ return;
204
+ }
205
+ }
206
+
207
+ // ── 7. Rollout ─────────────────────────────────────────────
208
+ const runSpinner = createSpinner('').start();
209
+ const setRunText = (done, total) => {
210
+ runSpinner.text = `Upgrading · ${done}/${total} device(s) processed`;
211
+ };
212
+ setRunText(0, outdated.length);
213
+
214
+ const perDevice = [];
215
+ const { results, aborted, reason } = await runFleetUpgrade({
216
+ deviceIds: outdated,
217
+ channel: opts.channel,
218
+ user,
219
+ canary: opts.canary,
220
+ abortOnFailure: !opts.continueOnError,
221
+ batchSize: opts.batchSize,
222
+ onProgress: ({ done, total }) => setRunText(done, total),
223
+ onDeviceResult: (r) => {
224
+ perDevice.push(r);
225
+ },
226
+ });
227
+ runSpinner.stop();
228
+
229
+ // ── 8. Results ─────────────────────────────────────────────
230
+ const sum = summarize(results);
231
+ if (isJsonMode()) {
232
+ printOk({
233
+ target,
234
+ channel: opts.channel,
235
+ aborted,
236
+ reason: aborted ? reason : undefined,
237
+ summary: sum,
238
+ results,
239
+ });
240
+ return;
241
+ }
242
+
243
+ for (const r of results) {
244
+ if (r.ok) {
245
+ console.log(` ${success('✓')} ${r.deviceId}`);
246
+ } else {
247
+ console.log(` ${errorStyle('✗')} ${r.deviceId} ${muted(r.error)}`);
248
+ }
249
+ }
250
+
251
+ console.log();
252
+ const verdict = aborted
253
+ ? `${warning('aborted')} (${reason}) · ${sum.ok} ok · ${sum.failed} failed`
254
+ : `${success('done')} · ${sum.ok} ok · ${sum.failed} failed`;
255
+ console.log(label(`Rollout ${verdict}`));
256
+
257
+ if (aborted || sum.failed > 0) {
258
+ // Scripts can use the exit code to gate follow-up work — a
259
+ // partial rollout is a "soft failure" worth surfacing.
260
+ process.exitCode = 1;
261
+ }
262
+ });
263
+ }
264
+
265
+ function countVersions(deviceVersions) {
266
+ const counts = new Map();
267
+ for (const raw of Object.values(deviceVersions)) {
268
+ const v = normalizeAgentVersion(raw);
269
+ if (!v) continue;
270
+ counts.set(v, (counts.get(v) || 0) + 1);
271
+ }
272
+ // Newest first by semver — reuse the comparator via dynamic import
273
+ // would be overkill here; the caller only needs a stable human order.
274
+ return [...counts.entries()].sort((a, b) => b[0].localeCompare(a[0], undefined, { numeric: true }));
275
+ }
@@ -1,6 +1,6 @@
1
- import chalk from 'chalk';
2
1
  import ora from 'ora';
3
2
  import { deleteConfig, configExists } from '../lib/config.js';
3
+ import { warning, error, hint } from '../lib/format.js';
4
4
 
5
5
  /**
6
6
  * Register the logout command
@@ -9,11 +9,12 @@ import { deleteConfig, configExists } from '../lib/config.js';
9
9
  export function logoutCommand(program) {
10
10
  program
11
11
  .command('logout')
12
+ .helpGroup('Configuration:')
12
13
  .description('Remove stored credentials and configuration')
13
14
  .action(async () => {
14
15
  // Check if configured
15
16
  if (!configExists()) {
16
- console.log(chalk.yellow('No configuration found. Already logged out.'));
17
+ console.log(warning('No configuration found. Already logged out.'));
17
18
  return;
18
19
  }
19
20
 
@@ -22,27 +23,25 @@ export function logoutCommand(program) {
22
23
  try {
23
24
  // Delete local configuration
24
25
  spinner.text = 'Removing local configuration...';
25
- const success = deleteConfig();
26
+ const ok = deleteConfig();
26
27
 
27
- if (success) {
28
+ if (ok) {
28
29
  spinner.succeed('Logged out successfully. Configuration removed.');
29
30
  } else {
30
31
  spinner.fail('Failed to remove configuration.');
31
32
  console.error(
32
- chalk.red(
33
+ error(
33
34
  'Error: Unable to delete configuration file. You may need to remove it manually.',
34
35
  ),
35
36
  );
36
37
  console.error(
37
- chalk.gray('Configuration is stored in ~/.config/thinr-cli/config.json'),
38
+ hint('Configuration is stored in ~/.config/thinr-cli/config.json'),
38
39
  );
39
40
  }
40
- } catch (error) {
41
+ } catch (err) {
41
42
  spinner.fail('Failed to remove configuration.');
42
- console.error(chalk.red(`Error: ${error.message}`));
43
- console.error(
44
- chalk.gray('Configuration is stored in ~/.config/thinr-cli/config.json'),
45
- );
43
+ console.error(error(`Error: ${err.message}`));
44
+ console.error(hint('Configuration is stored in ~/.config/thinr-cli/config.json'));
46
45
  }
47
46
  });
48
47
  }
@@ -0,0 +1,12 @@
1
+ // @ts-check
2
+ export {
3
+ ensureConfigured,
4
+ applyJsonFlag,
5
+ getGlobalUser,
6
+ parsePositiveInt,
7
+ collectKeyValue,
8
+ collectVar,
9
+ collectInput,
10
+ extractField,
11
+ ProgressSpinner,
12
+ } from '../_shared.js';
@@ -0,0 +1,18 @@
1
+ // @ts-check
2
+ import { registerRunCommand } from './run.js';
3
+ import { registerValidateCommand } from './validate.js';
4
+
5
+ /**
6
+ * `thinr playbook <subcommand>` — declarative, repeatable sequences of
7
+ * agent actions over a fleet. See `lib/playbook/schema.js` for the
8
+ * accepted format.
9
+ */
10
+ export function playbookCommand(program) {
11
+ const playbook = program
12
+ .command('playbook')
13
+ .helpGroup('Operations:')
14
+ .description('Run declarative YAML playbooks against the fleet');
15
+
16
+ registerRunCommand(playbook);
17
+ registerValidateCommand(playbook);
18
+ }
@@ -0,0 +1,302 @@
1
+ // @ts-check
2
+ import { dirname, resolve } from 'path';
3
+ import { loadPlaybookFile } from '../../lib/playbook/loader.js';
4
+ import { buildDryRunPlan, resolveTargets, runPlaybook } from '../../lib/playbook/runner.js';
5
+ import {
6
+ isJsonMode,
7
+ printOk,
8
+ printErr,
9
+ createSpinner,
10
+ classifyError,
11
+ } from '../../lib/output.js';
12
+ import { success, error as errorStyle, warning, hint, label, muted } from '../../lib/format.js';
13
+ import {
14
+ applyJsonFlag,
15
+ ensureConfigured,
16
+ getGlobalUser,
17
+ parsePositiveInt,
18
+ collectVar,
19
+ } from './_shared.js';
20
+
21
+ export function registerRunCommand(playbook) {
22
+ playbook
23
+ .command('run <file>')
24
+ .helpGroup('Playbook:')
25
+ .description('Run a playbook against the product / devices declared in its target block')
26
+ .option('-j, --json', 'Output as JSON')
27
+ .option('--dry-run', 'Print the static plan without contacting devices')
28
+ .option('--check', 'Contact devices read-only and report what would change (no writes)')
29
+ .option('--target <product>', 'Override the target product declared in the file')
30
+ .option('--group <group>', 'Override the asset group filter')
31
+ .option(
32
+ '-c, --concurrency <n>',
33
+ 'Override max parallel devices',
34
+ parsePositiveInt('concurrency'),
35
+ )
36
+ .option('--fail-fast', 'Stop dequeueing new devices on the first failure')
37
+ .option('--continue-on-error', 'Keep running subsequent steps on a device even if one fails')
38
+ .option('-v, --var <key=value>', 'Override a playbook variable (repeatable)', collectVar, {})
39
+ .action(async (file, opts, cmd) => {
40
+ applyJsonFlag(opts);
41
+ ensureConfigured();
42
+ const user = getGlobalUser(cmd);
43
+
44
+ let pb;
45
+ try {
46
+ pb = loadPlaybookFile(file);
47
+ } catch (err) {
48
+ const { message, code } = classifyError(err);
49
+ printErr(message, { code: code === 'error' ? 'input_error' : code });
50
+ return;
51
+ }
52
+
53
+ // CLI overrides merge into the parsed document before we
54
+ // resolve targets, so overrides affect device selection too.
55
+ if (opts.target) pb.target.product = opts.target;
56
+ if (opts.group) pb.target.group = opts.group;
57
+ if (opts.concurrency) pb.target.concurrency = opts.concurrency;
58
+ if (opts.failFast) pb.target.fail_fast = true;
59
+ if (opts.var && Object.keys(opts.var).length) {
60
+ pb.vars = { ...pb.vars, ...opts.var };
61
+ }
62
+
63
+ // ── Dry-run path ──────────────────────────────────────────
64
+ if (opts.dryRun) {
65
+ const plan = buildDryRunPlan(pb);
66
+ if (isJsonMode()) {
67
+ printOk({
68
+ name: pb.name,
69
+ target: pb.target,
70
+ vars: pb.vars,
71
+ steps: plan,
72
+ });
73
+ return;
74
+ }
75
+ if (pb.name) console.log(label(pb.name));
76
+ console.log(
77
+ hint(
78
+ `target: ${pb.target.product || '(explicit)'}` +
79
+ (pb.target.group ? ` · group=${pb.target.group}` : '') +
80
+ ` · concurrency=${pb.target.concurrency}` +
81
+ (pb.target.fail_fast ? ' · fail-fast' : ''),
82
+ ),
83
+ );
84
+ console.log(label(`\nPlan (${plan.length} step${plan.length === 1 ? '' : 's'}):`));
85
+ for (const s of plan) {
86
+ const pause = s.pause_after ? muted(` then pause ${s.pause_after}s`) : '';
87
+ console.log(
88
+ ` ${hint(String(s.index + 1).padStart(2) + '.')} ` +
89
+ `${label(s.name)} ${muted(`(${s.action})`)}${pause}`,
90
+ );
91
+ }
92
+ return;
93
+ }
94
+
95
+ // ── Resolve targets ──────────────────────────────────────
96
+ const resolveSpinner = createSpinner('Resolving targets...').start();
97
+ let devices;
98
+ try {
99
+ devices = await resolveTargets(pb, { user });
100
+ resolveSpinner.succeed(`Running on ${devices.length} device(s)`);
101
+ } catch (err) {
102
+ resolveSpinner.fail('Failed to resolve targets');
103
+ const { message, code } = classifyError(err);
104
+ printErr(message, { code });
105
+ return;
106
+ }
107
+
108
+ if (devices.length === 0) {
109
+ if (isJsonMode()) {
110
+ printOk({ name: pb.name, devices: [], results: [] });
111
+ } else {
112
+ console.log(warning('No devices to run on.'));
113
+ }
114
+ return;
115
+ }
116
+
117
+ // ── Execute ──────────────────────────────────────────────
118
+ const runSpinner = createSpinner('').start();
119
+ let done = 0;
120
+ const verb = opts.check ? 'Checking' : 'Executing';
121
+ const updateSpinner = () => {
122
+ runSpinner.text = `${verb} · ${done}/${devices.length} device(s) finished`;
123
+ };
124
+ updateSpinner();
125
+
126
+ const results = await runPlaybook(pb, devices, {
127
+ user,
128
+ concurrency: pb.target.concurrency,
129
+ failFast: pb.target.fail_fast,
130
+ baseDir: dirname(resolve(file)),
131
+ checkMode: !!opts.check,
132
+ continueOnError: !!opts.continueOnError,
133
+ onStepEnd: () => {
134
+ // bump the spinner whenever any device finishes its
135
+ // last step. Cheaper than a per-step counter when
136
+ // devices are running in parallel.
137
+ },
138
+ });
139
+ // Count devices whose outcome is known (all settled).
140
+ done = results.length;
141
+ runSpinner.stop();
142
+
143
+ // ── Render ───────────────────────────────────────────────
144
+ const verdictTotals = aggregateVerdicts(results);
145
+
146
+ if (isJsonMode()) {
147
+ const okCount = results.filter((r) => r.ok).length;
148
+ printOk({
149
+ name: pb.name,
150
+ target: pb.target,
151
+ mode: opts.check ? 'check' : 'apply',
152
+ summary: {
153
+ total: results.length,
154
+ ok: okCount,
155
+ failed: results.length - okCount,
156
+ ...(opts.check ? { verdicts: verdictTotals } : {}),
157
+ },
158
+ results,
159
+ });
160
+ return;
161
+ }
162
+
163
+ const devicesWithFailures = [];
164
+ const deviceColWidth = Math.max(
165
+ ...results.map((r) => r.device.length),
166
+ 12,
167
+ );
168
+ for (const r of results) {
169
+ const fails = r.steps.filter((s) => !s.ok && !s.skipped);
170
+ const statusTag = r.ok ? success('success') : errorStyle('failed ');
171
+ let durationMs = 0;
172
+ for (const s of r.steps) durationMs += s.durationMs || 0;
173
+ const duration = muted(`${durationMs}ms`.padStart(6));
174
+ const counts = opts.check
175
+ ? perDeviceCheckCounts(r)
176
+ : perDeviceApplyCounts(r);
177
+ let extra = '';
178
+ if (fails.length === 1) {
179
+ extra = ` ${fails[0].name} · ${muted(fails[0].summary)}`;
180
+ } else if (fails.length > 1) {
181
+ devicesWithFailures.push({ device: r.device, fails });
182
+ }
183
+ console.log(
184
+ ` ${statusTag} ${r.device.padEnd(deviceColWidth)} ${duration} ${counts}${extra}`,
185
+ );
186
+ }
187
+ const okCount = results.filter((r) => r.ok).length;
188
+ console.log(
189
+ hint(
190
+ `${results.length} total · ${okCount} ok · ${results.length - okCount} failed`,
191
+ ),
192
+ );
193
+ // Always surface per-step failure detail when any device had
194
+ // multiple failures (continue-on-error path) or we want to
195
+ // group by step for a quick "which steps broke, on which
196
+ // devices" view.
197
+ if (devicesWithFailures.length) {
198
+ const failedStepIndices = new Set();
199
+ for (const r of results) {
200
+ for (const s of r.steps) {
201
+ if (!s.ok && !s.skipped) failedStepIndices.add(s.index);
202
+ }
203
+ }
204
+ const sortedIndices = [...failedStepIndices].sort((a, b) => a - b);
205
+ console.log(label('\nFailed steps:'));
206
+ for (const idx of sortedIndices) {
207
+ const header = `${String(idx + 1).padStart(2)}. ${pb.steps[idx].name}`;
208
+ console.log(` ${label(header)}`);
209
+ for (const r of results) {
210
+ const s = r.steps.find((x) => x.index === idx);
211
+ if (!s || s.ok || s.skipped) continue;
212
+ console.log(
213
+ ` ${errorStyle('!')} ${r.device.padEnd(20)} ${muted(s.summary)}`,
214
+ );
215
+ }
216
+ }
217
+ }
218
+ if (opts.check) {
219
+ console.log(label('\nCheck details:'));
220
+ const stepCount = pb.steps.length;
221
+ for (let idx = 0; idx < stepCount; idx++) {
222
+ const header = `${String(idx + 1).padStart(2)}. ${pb.steps[idx].name}`;
223
+ console.log(` ${label(header)}`);
224
+ for (const r of results) {
225
+ const s = r.steps.find((x) => x.index === idx);
226
+ if (!s) continue;
227
+ const tag = verdictTag(s);
228
+ console.log(
229
+ ` ${tag} ${r.device.padEnd(20)} ${muted(s.summary)}`,
230
+ );
231
+ }
232
+ }
233
+ console.log(
234
+ hint(
235
+ `\nSummary: +${verdictTotals.changed} ` +
236
+ `=${verdictTotals.unchanged} ` +
237
+ `?${verdictTotals.unknown}`,
238
+ ),
239
+ );
240
+ }
241
+ });
242
+ }
243
+
244
+ function perDeviceCheckCounts(r) {
245
+ const counts = { changed: 0, unchanged: 0, unknown: 0, failed: 0, skipped: 0 };
246
+ for (const s of r.steps) {
247
+ if (!s.ok) counts.failed += 1;
248
+ else if (s.skipped) counts.skipped += 1;
249
+ else if (s.verdict && s.verdict in counts) counts[s.verdict] += 1;
250
+ }
251
+ const parts = [
252
+ `${warning('+' + counts.changed)}`,
253
+ `${muted('=' + counts.unchanged)}`,
254
+ `${hint('?' + counts.unknown)}`,
255
+ ];
256
+ if (counts.failed) parts.push(errorStyle(`!${counts.failed}`));
257
+ if (counts.skipped) parts.push(muted(`·${counts.skipped}`));
258
+ return parts.join(' ');
259
+ }
260
+
261
+ function perDeviceApplyCounts(r) {
262
+ let changed = 0;
263
+ let unchanged = 0;
264
+ let failed = 0;
265
+ let skipped = 0;
266
+ for (const s of r.steps) {
267
+ if (!s.ok) failed += 1;
268
+ else if (s.skipped) skipped += 1;
269
+ else if (s.verdict === 'unchanged') unchanged += 1;
270
+ else changed += 1;
271
+ }
272
+ const parts = [warning(`+${changed}`), muted(`=${unchanged}`)];
273
+ if (failed) parts.push(errorStyle(`!${failed}`));
274
+ if (skipped) parts.push(muted(`·${skipped}`));
275
+ return parts.join(' ');
276
+ }
277
+
278
+ function aggregateVerdicts(results) {
279
+ const totals = { changed: 0, unchanged: 0, unknown: 0 };
280
+ for (const r of results) {
281
+ for (const s of r.steps) {
282
+ if (!s.verdict) continue;
283
+ if (s.verdict in totals) totals[s.verdict] += 1;
284
+ }
285
+ }
286
+ return totals;
287
+ }
288
+
289
+ function verdictTag(step) {
290
+ if (!step.ok) return errorStyle('!');
291
+ if (step.skipped) return muted('·');
292
+ switch (step.verdict) {
293
+ case 'changed':
294
+ return warning('+');
295
+ case 'unchanged':
296
+ return muted('=');
297
+ case 'unknown':
298
+ return hint('?');
299
+ default:
300
+ return muted('·');
301
+ }
302
+ }