@thinremote/thinr-cli 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -0
- package/README.md +167 -120
- package/bin/thinr.js +3 -58
- package/commands/_shared.js +1 -0
- package/commands/{device/update.js → agent/device.js} +10 -26
- package/commands/agent/fleet.js +254 -0
- package/commands/agent/index.js +79 -0
- package/commands/device/console.js +1 -1
- package/commands/device/exec.js +1 -1
- package/commands/device/fs.js +6 -6
- package/commands/device/index.js +4 -6
- package/commands/device/logs.js +1 -1
- package/commands/device/proxy.js +15 -6
- package/commands/device/status.js +1 -1
- package/commands/device/transfer.js +2 -2
- package/commands/product/exec.js +1 -1
- package/commands/product/fs.js +3 -3
- package/commands/product/index.js +5 -4
- package/commands/product/logs.js +35 -36
- package/commands/product/metric.js +24 -14
- package/commands/product/playbook.js +10 -5
- package/commands/product/property.js +4 -14
- package/commands/product/push.js +1 -1
- package/commands/product/resource.js +56 -56
- package/commands/product/status.js +1 -1
- package/docs/guides/thinremote-metrics-logs.md +21 -22
- package/docs/guides/thinremote-playbooks.md +15 -11
- package/docs/guides/thinremote-scripts.md +11 -10
- package/docs/guides/thinremote.md +53 -42
- package/lib/agent-releases.js +1 -1
- package/lib/cli.js +172 -0
- package/lib/constants.js +1 -1
- package/lib/guides.js +1 -1
- package/lib/identity.js +8 -6
- package/lib/mcp/{tools-exec.js → device-exec.js} +84 -55
- package/lib/mcp/helpers.js +2 -2
- package/lib/mcp/resource-hooks.js +104 -6
- package/lib/mcp/resource-types.js +114 -227
- package/lib/playbook/actions.js +1 -1
- package/lib/playbook/schema.js +1 -1
- package/lib/product/log-presets.js +1 -1
- package/lib/product.js +26 -0
- package/package.json +2 -2
- package/commands/fleet/index.js +0 -17
- package/commands/fleet/upgrade.js +0 -275
- package/lib/mcp/registry.js +0 -12
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
import { confirm } from '@inquirer/prompts';
|
|
3
|
-
import { getDevices } from '@thinger-io/cli-core/devices';
|
|
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 '@thinger-io/cli-core/output';
|
|
15
|
-
import {
|
|
16
|
-
success,
|
|
17
|
-
error as errorStyle,
|
|
18
|
-
warning,
|
|
19
|
-
hint,
|
|
20
|
-
label,
|
|
21
|
-
muted,
|
|
22
|
-
accent,
|
|
23
|
-
} from '@thinger-io/cli-core/format';
|
|
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
|
-
}
|
package/lib/mcp/registry.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
import { tools as execTools } from './tools-exec.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* ThinRemote's own MCP tools: only `device_exec`, the command on one device
|
|
6
|
-
* or a fleet. Everything else ThinRemote adds is served by the generic
|
|
7
|
-
* `resource_*` tools of @thinger-io/cli-core: its resource types (a
|
|
8
|
-
* device's files and scripts; a product's scripts, metrics, log sources
|
|
9
|
-
* and playbooks) and its actions on devices, in resource-types.js, and
|
|
10
|
-
* what it adds to the server's types, in resource-hooks.js.
|
|
11
|
-
*/
|
|
12
|
-
export const tools = [...execTools];
|