@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -1
- package/README.md +235 -30
- package/bin/thinr.js +43 -25
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +68 -20
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +213 -0
- package/lib/config.js +5 -3
- package/lib/console.js +19 -5
- package/lib/constants.js +83 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +131 -148
- package/lib/devices.js +18 -21
- package/lib/errors.js +12 -0
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1226 -105
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +14 -3
- package/commands/product.js +0 -407
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { loadPlaybookFile } from '../../lib/playbook/loader.js';
|
|
3
|
+
import { isJsonMode, printOk, printErr, classifyError } from '../../lib/output.js';
|
|
4
|
+
import { success, label, hint } from '../../lib/format.js';
|
|
5
|
+
import { applyJsonFlag } from './_shared.js';
|
|
6
|
+
|
|
7
|
+
export function registerValidateCommand(playbook) {
|
|
8
|
+
playbook
|
|
9
|
+
.command('validate <file>')
|
|
10
|
+
.helpGroup('Playbook:')
|
|
11
|
+
.description('Parse and validate a playbook without running it')
|
|
12
|
+
.option('-j, --json', 'Output as JSON')
|
|
13
|
+
.action((file, opts) => {
|
|
14
|
+
applyJsonFlag(opts);
|
|
15
|
+
try {
|
|
16
|
+
const pb = loadPlaybookFile(file);
|
|
17
|
+
if (isJsonMode()) {
|
|
18
|
+
printOk({
|
|
19
|
+
valid: true,
|
|
20
|
+
name: pb.name,
|
|
21
|
+
target: pb.target,
|
|
22
|
+
steps: pb.steps.map((s) => ({
|
|
23
|
+
name: s.name,
|
|
24
|
+
action: s.action,
|
|
25
|
+
})),
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
console.log(success('✔ Playbook is valid'));
|
|
30
|
+
if (pb.name) console.log(label(pb.name));
|
|
31
|
+
if (pb.description) console.log(hint(pb.description));
|
|
32
|
+
console.log(hint(`${pb.steps.length} step(s)`));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const { message, code } = classifyError(error);
|
|
35
|
+
printErr(message, { code: code === 'error' ? 'input_error' : code });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import { createDeviceAPI } from '../../lib/device-api.js';
|
|
4
|
+
import { TIMEOUTS } from '../../lib/constants.js';
|
|
5
|
+
import { runProductFanOut } from '../../lib/product-orchestrator.js';
|
|
6
|
+
import {
|
|
7
|
+
label,
|
|
8
|
+
muted,
|
|
9
|
+
hint,
|
|
10
|
+
success,
|
|
11
|
+
error as errorStyle,
|
|
12
|
+
warning,
|
|
13
|
+
} from '../../lib/format.js';
|
|
14
|
+
import {
|
|
15
|
+
isJsonMode,
|
|
16
|
+
printOk,
|
|
17
|
+
printErr,
|
|
18
|
+
createSpinner,
|
|
19
|
+
classifyError,
|
|
20
|
+
} from '../../lib/output.js';
|
|
21
|
+
import {
|
|
22
|
+
applyJsonFlag,
|
|
23
|
+
ensureConfigured,
|
|
24
|
+
getGlobalUser,
|
|
25
|
+
parsePositiveInt,
|
|
26
|
+
ProgressSpinner,
|
|
27
|
+
} from '../_shared.js';
|
|
28
|
+
|
|
29
|
+
export function registerProductExecCommand(product) {
|
|
30
|
+
product
|
|
31
|
+
.command('exec <productId> <command...>')
|
|
32
|
+
.helpGroup('Fan-out:')
|
|
33
|
+
.description(
|
|
34
|
+
'Execute a shell command in parallel on every active device of the product',
|
|
35
|
+
)
|
|
36
|
+
.option('-j, --json', 'Output as JSON')
|
|
37
|
+
.option('-g, --group <group>', 'Filter devices by asset group')
|
|
38
|
+
.option(
|
|
39
|
+
'-c, --concurrency <n>',
|
|
40
|
+
'Max parallel executions (default: 10)',
|
|
41
|
+
parsePositiveInt('concurrency'),
|
|
42
|
+
10,
|
|
43
|
+
)
|
|
44
|
+
.option(
|
|
45
|
+
'--timeout <seconds>',
|
|
46
|
+
`Per-device timeout in seconds (default: ${TIMEOUTS.DEFAULT_EXEC_SECONDS})`,
|
|
47
|
+
parsePositiveInt('timeout'),
|
|
48
|
+
TIMEOUTS.DEFAULT_EXEC_SECONDS,
|
|
49
|
+
)
|
|
50
|
+
.option('--fail-fast', 'Stop dequeueing new devices as soon as one fails')
|
|
51
|
+
.option('-a, --all', 'Include offline devices (by default, only active)')
|
|
52
|
+
.action(async (productId, commandParts, opts, cmd) => {
|
|
53
|
+
applyJsonFlag(opts);
|
|
54
|
+
ensureConfigured();
|
|
55
|
+
const user = getGlobalUser(cmd);
|
|
56
|
+
const commandStr = commandParts.join(' ');
|
|
57
|
+
|
|
58
|
+
const fetchSpinner = createSpinner(
|
|
59
|
+
`Retrieving devices for product ${productId}...`,
|
|
60
|
+
).start();
|
|
61
|
+
/** @type {ProgressSpinner | null} */
|
|
62
|
+
let progress = null;
|
|
63
|
+
|
|
64
|
+
let result;
|
|
65
|
+
try {
|
|
66
|
+
result = await runProductFanOut({
|
|
67
|
+
product: productId,
|
|
68
|
+
group: opts.group,
|
|
69
|
+
includeOffline: !!opts.all,
|
|
70
|
+
user,
|
|
71
|
+
concurrency: opts.concurrency,
|
|
72
|
+
failFast: !!opts.failFast,
|
|
73
|
+
worker: async (device) => {
|
|
74
|
+
const api = createDeviceAPI(device.device, { user });
|
|
75
|
+
const perStart = Date.now();
|
|
76
|
+
let stdout = '';
|
|
77
|
+
let stderr = '';
|
|
78
|
+
try {
|
|
79
|
+
const { exitCode, timedOut } = await api.execStream(commandStr, {
|
|
80
|
+
timeout: opts.timeout,
|
|
81
|
+
onStdout: (s) => {
|
|
82
|
+
stdout += s;
|
|
83
|
+
},
|
|
84
|
+
onStderr: (s) => {
|
|
85
|
+
stderr += s;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
device: device.device,
|
|
90
|
+
ok: !timedOut && exitCode === 0,
|
|
91
|
+
exitCode: exitCode ?? null,
|
|
92
|
+
timedOut: !!timedOut,
|
|
93
|
+
durationMs: Date.now() - perStart,
|
|
94
|
+
stdout,
|
|
95
|
+
stderr,
|
|
96
|
+
};
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
99
|
+
return {
|
|
100
|
+
device: device.device,
|
|
101
|
+
ok: false,
|
|
102
|
+
exitCode: null,
|
|
103
|
+
timedOut: false,
|
|
104
|
+
durationMs: Date.now() - perStart,
|
|
105
|
+
stdout,
|
|
106
|
+
stderr,
|
|
107
|
+
error: msg,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
skipped: (device, firstFailure) => ({
|
|
112
|
+
device: device.device,
|
|
113
|
+
ok: false,
|
|
114
|
+
exitCode: null,
|
|
115
|
+
timedOut: false,
|
|
116
|
+
durationMs: 0,
|
|
117
|
+
stdout: '',
|
|
118
|
+
stderr: '',
|
|
119
|
+
error: `skipped (fail-fast after ${firstFailure})`,
|
|
120
|
+
}),
|
|
121
|
+
isFailure: (entry) => !entry.ok,
|
|
122
|
+
onDevicesResolved: (devices) => {
|
|
123
|
+
fetchSpinner.succeed(
|
|
124
|
+
`Found ${devices.length} device(s) in product ${productId}`,
|
|
125
|
+
);
|
|
126
|
+
if (devices.length > 0 && !isJsonMode()) {
|
|
127
|
+
progress = new ProgressSpinner(
|
|
128
|
+
createSpinner('').start(),
|
|
129
|
+
devices.length,
|
|
130
|
+
({ done, total, inFlight }) =>
|
|
131
|
+
`Running \`${commandStr}\` on ${total} device(s) — ` +
|
|
132
|
+
`${done}/${total} done · ${inFlight} in flight` +
|
|
133
|
+
(opts.failFast ? ' · fail-fast' : ''),
|
|
134
|
+
);
|
|
135
|
+
progress.render();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
onItemStart: () => progress?.startItem(),
|
|
139
|
+
onItemFinish: () => progress?.finishItem(),
|
|
140
|
+
});
|
|
141
|
+
} catch (error) {
|
|
142
|
+
fetchSpinner.fail(`Failed to retrieve devices for product ${productId}`);
|
|
143
|
+
const { message, code } = classifyError(error);
|
|
144
|
+
printErr(message, { code });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
progress?.stop();
|
|
148
|
+
|
|
149
|
+
const { devices, entries, durationMs } = result;
|
|
150
|
+
|
|
151
|
+
if (devices.length === 0) {
|
|
152
|
+
if (isJsonMode()) {
|
|
153
|
+
printOk({
|
|
154
|
+
product: productId,
|
|
155
|
+
command: commandStr,
|
|
156
|
+
summary: { total: 0, ok: 0, failed: 0, durationMs: 0 },
|
|
157
|
+
results: [],
|
|
158
|
+
});
|
|
159
|
+
} else {
|
|
160
|
+
console.log(warning('No devices to run on.'));
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const okCount = entries.filter((e) => e.ok).length;
|
|
166
|
+
const timedOutCount = entries.filter((e) => e.timedOut).length;
|
|
167
|
+
const errorCount = entries.filter((e) => e.error).length;
|
|
168
|
+
const failCount = entries.length - okCount - timedOutCount - errorCount;
|
|
169
|
+
|
|
170
|
+
if (isJsonMode()) {
|
|
171
|
+
printOk({
|
|
172
|
+
product: productId,
|
|
173
|
+
command: commandStr,
|
|
174
|
+
summary: {
|
|
175
|
+
total: entries.length,
|
|
176
|
+
ok: okCount,
|
|
177
|
+
failed: failCount + timedOutCount + errorCount,
|
|
178
|
+
timedOut: timedOutCount,
|
|
179
|
+
errored: errorCount,
|
|
180
|
+
durationMs,
|
|
181
|
+
},
|
|
182
|
+
results: entries,
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const statusBadge = (e) => {
|
|
188
|
+
if (e.error) return errorStyle('error');
|
|
189
|
+
if (e.timedOut) return warning('timeout');
|
|
190
|
+
if (e.ok) return success('ok');
|
|
191
|
+
return errorStyle('fail');
|
|
192
|
+
};
|
|
193
|
+
const detail = (e) => {
|
|
194
|
+
if (e.error) return e.error;
|
|
195
|
+
if (e.timedOut) return `after ${opts.timeout}s`;
|
|
196
|
+
return `exit=${e.exitCode ?? 'null'}`;
|
|
197
|
+
};
|
|
198
|
+
const table = new Table({
|
|
199
|
+
head: ['Device', 'Status', 'Detail', 'Duration'].map((h) => label(h)),
|
|
200
|
+
style: { head: [], border: ['gray'] },
|
|
201
|
+
colAligns: ['left', 'left', 'left', 'right'],
|
|
202
|
+
});
|
|
203
|
+
for (const e of entries) {
|
|
204
|
+
table.push([
|
|
205
|
+
e.device,
|
|
206
|
+
statusBadge(e),
|
|
207
|
+
detail(e),
|
|
208
|
+
`${e.durationMs}ms`,
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
console.log(table.toString());
|
|
212
|
+
|
|
213
|
+
// Output section: only devices that produced stdout/stderr.
|
|
214
|
+
// Single-line outputs render inline; multi-line ones indent
|
|
215
|
+
// on their own block so grepping and copying still work.
|
|
216
|
+
const withOutput = entries.filter(
|
|
217
|
+
(e) => (e.stdout && e.stdout.trim()) || (e.stderr && e.stderr.trim()),
|
|
218
|
+
);
|
|
219
|
+
if (withOutput.length) {
|
|
220
|
+
console.log(label('\nOutput:'));
|
|
221
|
+
for (const e of withOutput) {
|
|
222
|
+
const body = [e.stdout, e.stderr]
|
|
223
|
+
.map((s) => (s ? s.replace(/\s+$/, '') : ''))
|
|
224
|
+
.filter(Boolean)
|
|
225
|
+
.join('\n');
|
|
226
|
+
const lines = body.split('\n');
|
|
227
|
+
if (lines.length === 1) {
|
|
228
|
+
console.log(` ${muted(e.device + ':')} ${lines[0]}`);
|
|
229
|
+
} else {
|
|
230
|
+
console.log(` ${muted(e.device + ':')}`);
|
|
231
|
+
for (const line of lines) console.log(` ${line}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
console.log(
|
|
237
|
+
'\n' +
|
|
238
|
+
hint(
|
|
239
|
+
`${entries.length} total — ${okCount} ok, ${failCount} fail, ${timedOutCount} timeout, ${errorCount} error · ${durationMs}ms`,
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
}
|