@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.
- package/CHANGELOG.md +71 -0
- package/README.md +230 -16
- package/bin/thinr.js +23 -24
- 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 +30 -17
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +199 -0
- package/lib/concurrency.js +52 -0
- package/lib/config.js +5 -3
- package/lib/console.js +12 -4
- package/lib/constants.js +76 -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 +109 -147
- package/lib/devices.js +18 -21
- 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 +1343 -10
- 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 +15 -3
- package/commands/product.js +0 -167
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import { createDeviceAPI } from '../../lib/device-api.js';
|
|
4
|
+
import { runProductFanOut } from '../../lib/product-orchestrator.js';
|
|
5
|
+
import {
|
|
6
|
+
label,
|
|
7
|
+
muted,
|
|
8
|
+
hint,
|
|
9
|
+
success,
|
|
10
|
+
error as errorStyle,
|
|
11
|
+
warning,
|
|
12
|
+
} from '../../lib/format.js';
|
|
13
|
+
import {
|
|
14
|
+
isJsonMode,
|
|
15
|
+
printOk,
|
|
16
|
+
printErr,
|
|
17
|
+
createSpinner,
|
|
18
|
+
classifyError,
|
|
19
|
+
} from '../../lib/output.js';
|
|
20
|
+
import {
|
|
21
|
+
applyJsonFlag,
|
|
22
|
+
ensureConfigured,
|
|
23
|
+
getGlobalUser,
|
|
24
|
+
parsePositiveInt,
|
|
25
|
+
ProgressSpinner,
|
|
26
|
+
} from '../_shared.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Factory for product-level fan-out filesystem subcommands.
|
|
30
|
+
* Captures the repetitive boilerplate (fetch product devices,
|
|
31
|
+
* optionally filter offline, fan out through the shared orchestrator,
|
|
32
|
+
* render the same cli-table3 summary) so each verb only has to
|
|
33
|
+
* describe its per-device operation.
|
|
34
|
+
*/
|
|
35
|
+
function registerProductFsCommand(product, { name, description, args, build, perDeviceLabel }) {
|
|
36
|
+
const cmd = product
|
|
37
|
+
.command(`${name} ${args}`)
|
|
38
|
+
.helpGroup('Filesystem:')
|
|
39
|
+
.description(description)
|
|
40
|
+
.option('-j, --json', 'Output as JSON')
|
|
41
|
+
.option('-g, --group <group>', 'Filter devices by asset group')
|
|
42
|
+
.option(
|
|
43
|
+
'-c, --concurrency <n>',
|
|
44
|
+
'Max parallel operations (default: 10)',
|
|
45
|
+
parsePositiveInt('concurrency'),
|
|
46
|
+
10,
|
|
47
|
+
)
|
|
48
|
+
.option('--fail-fast', 'Stop dequeueing new devices on the first failure')
|
|
49
|
+
.option('-a, --all', 'Include offline devices (by default, only active)');
|
|
50
|
+
|
|
51
|
+
cmd.action(async (...commandArgs) => {
|
|
52
|
+
const opts = commandArgs[commandArgs.length - 2];
|
|
53
|
+
const commanderCmd = commandArgs[commandArgs.length - 1];
|
|
54
|
+
const positionalArgs = commandArgs.slice(0, -2);
|
|
55
|
+
const [productId, ...rest] = positionalArgs;
|
|
56
|
+
|
|
57
|
+
applyJsonFlag(opts);
|
|
58
|
+
ensureConfigured();
|
|
59
|
+
const user = getGlobalUser(commanderCmd);
|
|
60
|
+
|
|
61
|
+
const fetchSpinner = createSpinner(
|
|
62
|
+
`Retrieving devices for product ${productId}...`,
|
|
63
|
+
).start();
|
|
64
|
+
/** @type {ProgressSpinner | null} */
|
|
65
|
+
let progress = null;
|
|
66
|
+
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await runProductFanOut({
|
|
70
|
+
product: productId,
|
|
71
|
+
group: opts.group,
|
|
72
|
+
includeOffline: !!opts.all,
|
|
73
|
+
user,
|
|
74
|
+
concurrency: opts.concurrency,
|
|
75
|
+
failFast: !!opts.failFast,
|
|
76
|
+
worker: async (device) => {
|
|
77
|
+
const api = createDeviceAPI(device.device, { user });
|
|
78
|
+
const perStart = Date.now();
|
|
79
|
+
try {
|
|
80
|
+
await build(api, rest, opts);
|
|
81
|
+
return {
|
|
82
|
+
device: device.device,
|
|
83
|
+
ok: true,
|
|
84
|
+
durationMs: Date.now() - perStart,
|
|
85
|
+
};
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
device: device.device,
|
|
89
|
+
ok: false,
|
|
90
|
+
error: err instanceof Error ? err.message : String(err),
|
|
91
|
+
durationMs: Date.now() - perStart,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
skipped: (device, firstFailure) => ({
|
|
96
|
+
device: device.device,
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `skipped (fail-fast after ${firstFailure})`,
|
|
99
|
+
durationMs: 0,
|
|
100
|
+
}),
|
|
101
|
+
isFailure: (entry) => !entry.ok,
|
|
102
|
+
onDevicesResolved: (devices) => {
|
|
103
|
+
fetchSpinner.succeed(
|
|
104
|
+
`Found ${devices.length} device(s) in product ${productId}`,
|
|
105
|
+
);
|
|
106
|
+
if (devices.length > 0 && !isJsonMode()) {
|
|
107
|
+
progress = new ProgressSpinner(
|
|
108
|
+
createSpinner('').start(),
|
|
109
|
+
devices.length,
|
|
110
|
+
({ done, total, inFlight }) =>
|
|
111
|
+
`${perDeviceLabel(rest)} on ${total} device(s) — ` +
|
|
112
|
+
`${done}/${total} done · ${inFlight} in flight` +
|
|
113
|
+
(opts.failFast ? ' · fail-fast' : ''),
|
|
114
|
+
);
|
|
115
|
+
progress.render();
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
onItemStart: () => progress?.startItem(),
|
|
119
|
+
onItemFinish: () => progress?.finishItem(),
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
fetchSpinner.fail(`Failed to retrieve devices for product ${productId}`);
|
|
123
|
+
const { message, code } = classifyError(error);
|
|
124
|
+
printErr(message, { code });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
progress?.stop();
|
|
128
|
+
|
|
129
|
+
const { devices, entries, durationMs } = result;
|
|
130
|
+
|
|
131
|
+
if (devices.length === 0) {
|
|
132
|
+
if (isJsonMode()) {
|
|
133
|
+
printOk({
|
|
134
|
+
product: productId,
|
|
135
|
+
summary: { total: 0, ok: 0, failed: 0 },
|
|
136
|
+
results: [],
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
console.log(warning('No devices to target.'));
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const okCount = entries.filter((e) => e.ok).length;
|
|
145
|
+
const failCount = entries.length - okCount;
|
|
146
|
+
|
|
147
|
+
if (isJsonMode()) {
|
|
148
|
+
printOk({
|
|
149
|
+
product: productId,
|
|
150
|
+
summary: {
|
|
151
|
+
total: entries.length,
|
|
152
|
+
ok: okCount,
|
|
153
|
+
failed: failCount,
|
|
154
|
+
durationMs,
|
|
155
|
+
},
|
|
156
|
+
results: entries,
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const table = new Table({
|
|
162
|
+
head: ['Device', 'Status', 'Detail', 'Duration'].map((h) => label(h)),
|
|
163
|
+
style: { head: [], border: ['gray'] },
|
|
164
|
+
colAligns: ['left', 'left', 'left', 'right'],
|
|
165
|
+
});
|
|
166
|
+
for (const e of entries) {
|
|
167
|
+
table.push([
|
|
168
|
+
e.device,
|
|
169
|
+
e.ok ? success('ok') : errorStyle('fail'),
|
|
170
|
+
e.ok ? muted('—') : e.error || '',
|
|
171
|
+
`${e.durationMs}ms`,
|
|
172
|
+
]);
|
|
173
|
+
}
|
|
174
|
+
console.log(table.toString());
|
|
175
|
+
console.log(
|
|
176
|
+
hint(`${entries.length} total · ${okCount} ok · ${failCount} failed · ${durationMs}ms`),
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return cmd;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function registerProductFsCommands(product) {
|
|
184
|
+
registerProductFsCommand(product, {
|
|
185
|
+
name: 'mkdir',
|
|
186
|
+
description: 'Create a directory on every active device of the product',
|
|
187
|
+
args: '<productId> <path>',
|
|
188
|
+
perDeviceLabel: ([path]) => `mkdir ${path}`,
|
|
189
|
+
build: async (api, [path]) => {
|
|
190
|
+
await api.mkdir(path);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
registerProductFsCommand(product, {
|
|
195
|
+
name: 'rm',
|
|
196
|
+
description:
|
|
197
|
+
'Delete a file or directory on every active device of the product (DESTRUCTIVE — double-check the path).',
|
|
198
|
+
args: '<productId> <path>',
|
|
199
|
+
perDeviceLabel: ([path]) => `rm ${path}`,
|
|
200
|
+
build: async (api, [path], opts) => {
|
|
201
|
+
await api.delete(path, opts.recursive !== false);
|
|
202
|
+
},
|
|
203
|
+
}).option(
|
|
204
|
+
'-r, --recursive',
|
|
205
|
+
'Recursively delete non-empty directories (default: true)',
|
|
206
|
+
true,
|
|
207
|
+
).option('--no-recursive', 'Fail on non-empty directories');
|
|
208
|
+
|
|
209
|
+
registerProductFsCommand(product, {
|
|
210
|
+
name: 'mv',
|
|
211
|
+
description: 'Move or rename a path on every active device of the product',
|
|
212
|
+
args: '<productId> <source> <destination>',
|
|
213
|
+
perDeviceLabel: ([src, dst]) => `mv ${src} → ${dst}`,
|
|
214
|
+
build: async (api, [src, dst], opts) => {
|
|
215
|
+
await api.move(src, dst, !!opts.force);
|
|
216
|
+
},
|
|
217
|
+
}).option('-f, --force', 'Overwrite destination if it already exists');
|
|
218
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { registerProductListCommand } from './list.js';
|
|
3
|
+
import { registerProductPropertyCommand } from './property.js';
|
|
4
|
+
import { registerProductPropertyAdminCommands } from './props.js';
|
|
5
|
+
import { registerProductMetricCommands } from './metric.js';
|
|
6
|
+
import { registerProductResourceCommand } from './resource.js';
|
|
7
|
+
import { registerProductExecCommand } from './exec.js';
|
|
8
|
+
import { registerProductStatusCommand } from './status.js';
|
|
9
|
+
import { registerProductFsCommands } from './fs.js';
|
|
10
|
+
import { registerProductPushCommand } from './push.js';
|
|
11
|
+
import { registerProductPlaybookCommand } from './playbook.js';
|
|
12
|
+
import { registerProductLogsCommand } from './logs.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `thinr product <subcommand> <productId> …` — fan-out commands across
|
|
16
|
+
* every device that belongs to a product.
|
|
17
|
+
*/
|
|
18
|
+
export function productCommand(program) {
|
|
19
|
+
const product = program
|
|
20
|
+
.command('product')
|
|
21
|
+
.helpGroup('Operations:')
|
|
22
|
+
.description('Product commands (subcommand-first: thinr product <action> <productId>)');
|
|
23
|
+
|
|
24
|
+
registerProductListCommand(product);
|
|
25
|
+
registerProductStatusCommand(product);
|
|
26
|
+
registerProductPropertyCommand(product);
|
|
27
|
+
registerProductPropertyAdminCommands(product);
|
|
28
|
+
registerProductMetricCommands(product);
|
|
29
|
+
registerProductResourceCommand(product);
|
|
30
|
+
registerProductExecCommand(product);
|
|
31
|
+
registerProductFsCommands(product);
|
|
32
|
+
registerProductPushCommand(product);
|
|
33
|
+
registerProductPlaybookCommand(product);
|
|
34
|
+
registerProductLogsCommand(product);
|
|
35
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getProducts, getProductApi } from '../../lib/product.js';
|
|
3
|
+
import { runPool } from '../../lib/concurrency.js';
|
|
4
|
+
import { label, hint, success } from '../../lib/format.js';
|
|
5
|
+
import {
|
|
6
|
+
isJsonMode,
|
|
7
|
+
printOk,
|
|
8
|
+
printErr,
|
|
9
|
+
createSpinner,
|
|
10
|
+
classifyError,
|
|
11
|
+
} from '../../lib/output.js';
|
|
12
|
+
import {
|
|
13
|
+
applyJsonFlag,
|
|
14
|
+
ensureConfigured,
|
|
15
|
+
getGlobalUser,
|
|
16
|
+
parsePositiveInt,
|
|
17
|
+
} from '../_shared.js';
|
|
18
|
+
|
|
19
|
+
export function registerProductListCommand(product) {
|
|
20
|
+
product
|
|
21
|
+
.command('list')
|
|
22
|
+
.helpGroup('Discovery:')
|
|
23
|
+
.description('List every product configured on the server')
|
|
24
|
+
.option('-j, --json', 'Output as JSON')
|
|
25
|
+
.option(
|
|
26
|
+
'-c, --concurrency <n>',
|
|
27
|
+
'Max parallel api-resource lookups (default: 10)',
|
|
28
|
+
parsePositiveInt('concurrency'),
|
|
29
|
+
10,
|
|
30
|
+
)
|
|
31
|
+
.action(async (opts, cmd) => {
|
|
32
|
+
applyJsonFlag(opts);
|
|
33
|
+
ensureConfigured();
|
|
34
|
+
const user = getGlobalUser(cmd);
|
|
35
|
+
|
|
36
|
+
const spinner = createSpinner('Fetching products...').start();
|
|
37
|
+
let products;
|
|
38
|
+
try {
|
|
39
|
+
products = await getProducts(user);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
spinner.fail('Failed to fetch products');
|
|
42
|
+
const { message, code } = classifyError(error);
|
|
43
|
+
printErr(message, { code });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Script counts come from each product's api resources map.
|
|
48
|
+
// Parallelise so a large catalogue doesn't crawl serially.
|
|
49
|
+
const scriptResults = await runPool(products, opts.concurrency, async (p) => {
|
|
50
|
+
try {
|
|
51
|
+
const apis = await getProductApi(p.product, user);
|
|
52
|
+
return Object.keys(apis || {}).length;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const rows = products.map((p, i) => ({
|
|
58
|
+
product: p.product,
|
|
59
|
+
name: p.name && p.name !== p.product ? p.name : null,
|
|
60
|
+
enabled: !!p.enabled,
|
|
61
|
+
scripts: scriptResults[i]?.ok ? scriptResults[i].value : null,
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
spinner.succeed(`Found ${rows.length} product(s)`);
|
|
65
|
+
|
|
66
|
+
if (isJsonMode()) {
|
|
67
|
+
printOk({ products: rows });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (rows.length === 0) return;
|
|
72
|
+
|
|
73
|
+
// Pad the product id column so the trailing name/scripts
|
|
74
|
+
// bits align even when ids have very different widths.
|
|
75
|
+
const idWidth = rows.reduce((w, r) => Math.max(w, r.product.length), 0);
|
|
76
|
+
for (const r of rows) {
|
|
77
|
+
const flag = r.enabled ? success('enabled ') : hint('disabled');
|
|
78
|
+
const id = label(r.product.padEnd(idWidth));
|
|
79
|
+
const name = r.name ? hint(`(${r.name})`) : '';
|
|
80
|
+
const scripts =
|
|
81
|
+
r.scripts != null && r.scripts > 0 ? hint(`${r.scripts} script(s)`) : '';
|
|
82
|
+
const extras = [name, scripts].filter(Boolean).join(' ');
|
|
83
|
+
console.log(` ${flag} ${id}${extras ? ' ' + extras : ''}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|