@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
package/bin/thinr.js
CHANGED
|
@@ -3,22 +3,10 @@
|
|
|
3
3
|
import { readFileSync } from 'fs';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { dirname, resolve } from 'path';
|
|
6
|
-
import figlet from 'figlet';
|
|
7
6
|
import { defineIdentity } from '@thinger-io/cli-core/identity';
|
|
8
7
|
import { runCli } from '@thinger-io/cli-core/cli';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { accent, info } from '@thinger-io/cli-core/format';
|
|
12
|
-
import { mcpInstructions, thinrIdentity } from '../lib/identity.js';
|
|
13
|
-
import { tools as mcpTools } from '../lib/mcp/registry.js';
|
|
14
|
-
import { resourceHooks } from '../lib/mcp/resource-hooks.js';
|
|
15
|
-
import { resourceActions, resourceTypes } from '../lib/mcp/resource-types.js';
|
|
16
|
-
import { guides } from '../lib/guides.js';
|
|
17
|
-
import { registerDeviceCommands } from '../commands/device/index.js';
|
|
18
|
-
import { registerProductCommands } from '../commands/product/index.js';
|
|
19
|
-
import { fleetCommand } from '../commands/fleet/index.js';
|
|
20
|
-
import { playbookCommand } from '../commands/playbook/index.js';
|
|
21
|
-
import { runDashboard } from '../commands/dashboard.js';
|
|
8
|
+
import { thinrIdentity } from '../lib/identity.js';
|
|
9
|
+
import { cliOptions } from '../lib/cli.js';
|
|
22
10
|
|
|
23
11
|
// Single source of truth for the version: package.json. Avoids the drift
|
|
24
12
|
// between `package.json#version` and a hardcoded string in this file.
|
|
@@ -26,49 +14,6 @@ const pkg = JSON.parse(
|
|
|
26
14
|
readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'),
|
|
27
15
|
);
|
|
28
16
|
|
|
29
|
-
function displayBanner() {
|
|
30
|
-
console.log(accent(figlet.textSync('thinr-cli', { horizontalLayout: 'full' })));
|
|
31
|
-
console.log(info('Thin Remote CLI - Remote management for IoT devices'));
|
|
32
|
-
console.log();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
17
|
defineIdentity(thinrIdentity);
|
|
36
18
|
|
|
37
|
-
await runCli({
|
|
38
|
-
version: pkg.version,
|
|
39
|
-
mcpTools,
|
|
40
|
-
resourceHooks,
|
|
41
|
-
resourceTypes,
|
|
42
|
-
resourceActions,
|
|
43
|
-
docs: guides,
|
|
44
|
-
mcpInstructions,
|
|
45
|
-
register(program, groups) {
|
|
46
|
-
registerDeviceCommands(groups.device);
|
|
47
|
-
registerProductCommands(groups.product);
|
|
48
|
-
fleetCommand(program);
|
|
49
|
-
playbookCommand(program);
|
|
50
|
-
|
|
51
|
-
const originalHelp = program.help;
|
|
52
|
-
program.help = function (cb) {
|
|
53
|
-
displayBanner();
|
|
54
|
-
return originalHelp.call(this, cb);
|
|
55
|
-
};
|
|
56
|
-
},
|
|
57
|
-
// Just `thinr`: launch the dashboard when running interactively (signing
|
|
58
|
-
// in first if there is no profile yet). Non-TTY contexts (pipes,
|
|
59
|
-
// scripts, `thinr | cat`) fall back to `--help` so they get something
|
|
60
|
-
// useful instead of an Ink crash.
|
|
61
|
-
async onNoCommand(program) {
|
|
62
|
-
if (!process.stdout.isTTY) program.help();
|
|
63
|
-
if (!configExists()) {
|
|
64
|
-
displayBanner();
|
|
65
|
-
try {
|
|
66
|
-
await authenticate();
|
|
67
|
-
} catch (error) {
|
|
68
|
-
console.error(`Configuration error: ${error.message}`);
|
|
69
|
-
process.exit(1);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
await runDashboard();
|
|
73
|
-
},
|
|
74
|
-
});
|
|
19
|
+
await runCli({ version: pkg.version, ...cliOptions });
|
package/commands/_shared.js
CHANGED
|
@@ -9,12 +9,17 @@ import {
|
|
|
9
9
|
} from '@thinger-io/cli-core/output';
|
|
10
10
|
import { label as labelText, accent } from '@thinger-io/cli-core/format';
|
|
11
11
|
import { TIMEOUTS } from '../../lib/constants.js';
|
|
12
|
-
import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Ask the agent of one device to check for a release (`check`) or to
|
|
15
|
+
* install it (`apply`), through its `update` resource.
|
|
16
|
+
*
|
|
17
|
+
* @param {'check' | 'apply'} action
|
|
18
|
+
* @param {string} deviceId
|
|
19
|
+
* @param {{ channel?: string }} opts
|
|
20
|
+
* @param {string | null} user
|
|
21
|
+
*/
|
|
22
|
+
export async function runDeviceUpdate(action, deviceId, opts, user) {
|
|
18
23
|
const channel = opts.channel || 'latest';
|
|
19
24
|
const phase = action === 'check' ? 'Checking for updates' : 'Applying update';
|
|
20
25
|
const spinner = createSpinner(`${phase} on ${deviceId}...`).start();
|
|
@@ -40,25 +45,4 @@ const runUpdate = async (action, deviceId, opts, cmd) => {
|
|
|
40
45
|
const { message, code } = classifyError(error);
|
|
41
46
|
printErr(message, { code });
|
|
42
47
|
}
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export function registerUpdateCommand(device) {
|
|
46
|
-
const update = device
|
|
47
|
-
.command('update')
|
|
48
|
-
.helpGroup('Execution:')
|
|
49
|
-
.description('Check for or apply agent updates');
|
|
50
|
-
|
|
51
|
-
update
|
|
52
|
-
.command('check <deviceId>')
|
|
53
|
-
.description('Check if an agent update is available')
|
|
54
|
-
.option('-j, --json', 'Output as JSON')
|
|
55
|
-
.option('--channel <name>', 'Update channel (default: latest)')
|
|
56
|
-
.action((deviceId, opts, cmd) => runUpdate('check', deviceId, opts, cmd));
|
|
57
|
-
|
|
58
|
-
update
|
|
59
|
-
.command('apply <deviceId>')
|
|
60
|
-
.description('Apply an agent update')
|
|
61
|
-
.option('-j, --json', 'Output as JSON')
|
|
62
|
-
.option('--channel <name>', 'Update channel (default: latest)')
|
|
63
|
-
.action((deviceId, opts, cmd) => runUpdate('apply', deviceId, opts, cmd));
|
|
64
48
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `thinr agent update --all`: update the agent on every outdated device of
|
|
27
|
+
* the fleet (or of a product, or an asset group), a canary first, then in
|
|
28
|
+
* batches, after a confirmation unless `-y`.
|
|
29
|
+
*
|
|
30
|
+
* @param {Record<string, any>} opts The options of `agent update`.
|
|
31
|
+
* @param {string | null} user
|
|
32
|
+
*/
|
|
33
|
+
export async function runFleetUpdate(opts, user) {
|
|
34
|
+
// ── 1. Target version ──────────────────────────────────────
|
|
35
|
+
const latestSpinner = createSpinner(`Resolving ${opts.channel} release…`).start();
|
|
36
|
+
let target;
|
|
37
|
+
try {
|
|
38
|
+
target = await fetchLatestAgentVersion({ channel: opts.channel });
|
|
39
|
+
latestSpinner.succeed(`Channel ${accent(opts.channel)} → ${success(target)}`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
latestSpinner.fail(`Failed to resolve channel ${opts.channel}`);
|
|
42
|
+
const { message, code } = classifyError(err);
|
|
43
|
+
printErr(message, { code });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── 2. Fleet inventory ─────────────────────────────────────
|
|
48
|
+
const listSpinner = createSpinner('Listing fleet…').start();
|
|
49
|
+
let devices;
|
|
50
|
+
try {
|
|
51
|
+
const filter = {};
|
|
52
|
+
if (opts.product) filter.product = opts.product;
|
|
53
|
+
if (opts.group) filter.asset_group = opts.group;
|
|
54
|
+
devices = await getDevices(filter, user);
|
|
55
|
+
listSpinner.succeed(`Fleet size: ${label(devices.length)} device(s)`);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
listSpinner.fail('Failed to list devices');
|
|
58
|
+
const { message, code } = classifyError(err);
|
|
59
|
+
printErr(message, { code });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (devices.length === 0) {
|
|
63
|
+
if (isJsonMode()) {
|
|
64
|
+
printOk({ target, outdated: [], results: [] });
|
|
65
|
+
} else {
|
|
66
|
+
console.log(hint('No devices match the filter.'));
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── 3. Current versions ────────────────────────────────────
|
|
72
|
+
// Fleet-wide monitoring snapshot trimmed to `agent.version`.
|
|
73
|
+
// The backend may return several rows per device (history
|
|
74
|
+
// tail); with `sort=desc` the first row we see per device is
|
|
75
|
+
// the most recent one, so we ignore later repeats. We also
|
|
76
|
+
// scope to the filtered fleet so monitoring data from devices
|
|
77
|
+
// outside the product/group filter doesn't leak into the count.
|
|
78
|
+
const versionsSpinner = createSpinner('Reading agent versions…').start();
|
|
79
|
+
const fleetIds = new Set(devices.map((d) => d.device));
|
|
80
|
+
const deviceVersions = {};
|
|
81
|
+
for (const d of devices) deviceVersions[d.device] = null;
|
|
82
|
+
try {
|
|
83
|
+
const rows = await getMonitoringData({
|
|
84
|
+
user,
|
|
85
|
+
items: Math.max(500, devices.length),
|
|
86
|
+
sort: 'desc',
|
|
87
|
+
fields: 'agent.version',
|
|
88
|
+
});
|
|
89
|
+
if (Array.isArray(rows)) {
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
const id = row?.device;
|
|
92
|
+
const v = row?.agent?.version;
|
|
93
|
+
if (!id || !v || !fleetIds.has(id)) continue;
|
|
94
|
+
if (deviceVersions[id]) continue;
|
|
95
|
+
deviceVersions[id] = v;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const known = Object.values(deviceVersions).filter(Boolean).length;
|
|
99
|
+
versionsSpinner.succeed(`Read versions for ${label(known)} of ${devices.length} device(s)`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
versionsSpinner.fail('Failed to read monitoring data');
|
|
102
|
+
const { message, code } = classifyError(err);
|
|
103
|
+
printErr(message, { code });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { outdated, current, unknown } = classifyAgainst(target, deviceVersions);
|
|
108
|
+
|
|
109
|
+
// ── 4. Render summary ──────────────────────────────────────
|
|
110
|
+
const versionCounts = countVersions(deviceVersions);
|
|
111
|
+
if (!isJsonMode()) {
|
|
112
|
+
console.log();
|
|
113
|
+
console.log(label('Agent versions in fleet:'));
|
|
114
|
+
for (const [v, c] of versionCounts) {
|
|
115
|
+
const tag = v === target ? success('●') : warning('●');
|
|
116
|
+
console.log(` ${tag} ${v.padEnd(12)} ${muted(String(c))}`);
|
|
117
|
+
}
|
|
118
|
+
if (unknown.length) {
|
|
119
|
+
console.log(` ${muted('?')} ${'unknown'.padEnd(12)} ${muted(String(unknown.length))}`);
|
|
120
|
+
}
|
|
121
|
+
console.log();
|
|
122
|
+
console.log(
|
|
123
|
+
`Target: ${success(target)} · ` +
|
|
124
|
+
`outdated: ${label(outdated.length)} · ` +
|
|
125
|
+
`current: ${muted(current.length)}` +
|
|
126
|
+
(unknown.length ? ` · unknown: ${muted(unknown.length)}` : ''),
|
|
127
|
+
);
|
|
128
|
+
console.log();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (outdated.length === 0) {
|
|
132
|
+
if (isJsonMode()) {
|
|
133
|
+
printOk({
|
|
134
|
+
target,
|
|
135
|
+
summary: { outdated: 0, current: current.length, unknown: unknown.length },
|
|
136
|
+
results: [],
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
console.log(success('All devices are already on the target version.'));
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── 5. Dry-run ─────────────────────────────────────────────
|
|
145
|
+
if (opts.dryRun) {
|
|
146
|
+
if (isJsonMode()) {
|
|
147
|
+
printOk({
|
|
148
|
+
target,
|
|
149
|
+
dryRun: true,
|
|
150
|
+
outdated,
|
|
151
|
+
current,
|
|
152
|
+
unknown,
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
console.log(label(`Would upgrade ${outdated.length} device(s):`));
|
|
157
|
+
for (const id of outdated) {
|
|
158
|
+
console.log(
|
|
159
|
+
` - ${id} ${muted(`(${normalizeAgentVersion(deviceVersions[id]) || '?'})`)}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 6. Confirmation ────────────────────────────────────────
|
|
166
|
+
if (!opts.yes && !isJsonMode()) {
|
|
167
|
+
if (!process.stdin.isTTY) {
|
|
168
|
+
printErr('Refusing to upgrade without --yes in a non-interactive shell', {
|
|
169
|
+
code: 'needs_confirm',
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const plan = opts.canary ? 'canary first, then batches' : 'all in batches';
|
|
174
|
+
const failure = opts.continueOnError ? 'continue on failure' : 'abort on failure';
|
|
175
|
+
const ok = await confirm({
|
|
176
|
+
message: `Upgrade ${outdated.length} device(s) to ${target}? (${plan} · ${failure})`,
|
|
177
|
+
default: false,
|
|
178
|
+
});
|
|
179
|
+
if (!ok) {
|
|
180
|
+
console.log(hint('Cancelled.'));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── 7. Rollout ─────────────────────────────────────────────
|
|
186
|
+
const runSpinner = createSpinner('').start();
|
|
187
|
+
const setRunText = (done, total) => {
|
|
188
|
+
runSpinner.text = `Upgrading · ${done}/${total} device(s) processed`;
|
|
189
|
+
};
|
|
190
|
+
setRunText(0, outdated.length);
|
|
191
|
+
|
|
192
|
+
const perDevice = [];
|
|
193
|
+
const { results, aborted, reason } = await runFleetUpgrade({
|
|
194
|
+
deviceIds: outdated,
|
|
195
|
+
channel: opts.channel,
|
|
196
|
+
user,
|
|
197
|
+
canary: opts.canary,
|
|
198
|
+
abortOnFailure: !opts.continueOnError,
|
|
199
|
+
batchSize: opts.batchSize,
|
|
200
|
+
onProgress: ({ done, total }) => setRunText(done, total),
|
|
201
|
+
onDeviceResult: (r) => {
|
|
202
|
+
perDevice.push(r);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
runSpinner.stop();
|
|
206
|
+
|
|
207
|
+
// ── 8. Results ─────────────────────────────────────────────
|
|
208
|
+
const sum = summarize(results);
|
|
209
|
+
if (isJsonMode()) {
|
|
210
|
+
printOk({
|
|
211
|
+
target,
|
|
212
|
+
channel: opts.channel,
|
|
213
|
+
aborted,
|
|
214
|
+
reason: aborted ? reason : undefined,
|
|
215
|
+
summary: sum,
|
|
216
|
+
results,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const r of results) {
|
|
222
|
+
if (r.ok) {
|
|
223
|
+
console.log(` ${success('✓')} ${r.deviceId}`);
|
|
224
|
+
} else {
|
|
225
|
+
console.log(` ${errorStyle('✗')} ${r.deviceId} ${muted(r.error)}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log();
|
|
230
|
+
const verdict = aborted
|
|
231
|
+
? `${warning('aborted')} (${reason}) · ${sum.ok} ok · ${sum.failed} failed`
|
|
232
|
+
: `${success('done')} · ${sum.ok} ok · ${sum.failed} failed`;
|
|
233
|
+
console.log(label(`Rollout ${verdict}`));
|
|
234
|
+
|
|
235
|
+
if (aborted || sum.failed > 0) {
|
|
236
|
+
// Scripts can use the exit code to gate follow-up work — a
|
|
237
|
+
// partial rollout is a "soft failure" worth surfacing.
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function countVersions(deviceVersions) {
|
|
243
|
+
const counts = new Map();
|
|
244
|
+
for (const raw of Object.values(deviceVersions)) {
|
|
245
|
+
const v = normalizeAgentVersion(raw);
|
|
246
|
+
if (!v) continue;
|
|
247
|
+
counts.set(v, (counts.get(v) || 0) + 1);
|
|
248
|
+
}
|
|
249
|
+
// Newest first by semver — reuse the comparator via dynamic import
|
|
250
|
+
// would be overkill here; the caller only needs a stable human order.
|
|
251
|
+
return [...counts.entries()].sort((a, b) =>
|
|
252
|
+
b[0].localeCompare(a[0], undefined, { numeric: true }),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { printErr } from '@thinger-io/cli-core/output';
|
|
3
|
+
import { applyJsonFlag, ensureConfigured, getGlobalUser, parsePositiveInt } from '../_shared.js';
|
|
4
|
+
import { runDeviceUpdate } from './device.js';
|
|
5
|
+
import { runFleetUpdate } from './fleet.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `thinr agent check|update` — the ThinRemote agent on the devices: check
|
|
9
|
+
* whether a device has a newer release, and update one device or, with
|
|
10
|
+
* `--all`, every outdated device of the fleet (narrowed by product or asset
|
|
11
|
+
* group), a canary first and then in batches.
|
|
12
|
+
*/
|
|
13
|
+
export function agentCommand(program) {
|
|
14
|
+
const agent = program
|
|
15
|
+
.command('agent')
|
|
16
|
+
.helpGroup('Operations:')
|
|
17
|
+
.description('The ThinRemote agent of the devices: check for and apply its updates');
|
|
18
|
+
|
|
19
|
+
agent
|
|
20
|
+
.command('check <device>')
|
|
21
|
+
.helpGroup('Updates:')
|
|
22
|
+
.description('Check whether a newer agent release is available for a device')
|
|
23
|
+
.option('--channel <name>', 'Release channel', 'latest')
|
|
24
|
+
.option('-j, --json', 'Output as JSON')
|
|
25
|
+
.action(async (device, opts, cmd) => {
|
|
26
|
+
applyJsonFlag(opts);
|
|
27
|
+
ensureConfigured();
|
|
28
|
+
await runDeviceUpdate('check', device, opts, getGlobalUser(cmd));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
agent
|
|
32
|
+
.command('update [device]')
|
|
33
|
+
.helpGroup('Updates:')
|
|
34
|
+
.description(
|
|
35
|
+
'Update the agent of a device, or with --all of every outdated device of the fleet',
|
|
36
|
+
)
|
|
37
|
+
.option('--channel <name>', 'Release channel', 'latest')
|
|
38
|
+
.option('--all', 'Every outdated device of the fleet (narrow it with -p and -g)')
|
|
39
|
+
.option('-p, --product <id>', 'With --all: only the devices of this product')
|
|
40
|
+
.option('-g, --group <id>', 'With --all: only the devices of this asset group')
|
|
41
|
+
.option(
|
|
42
|
+
'--batch-size <n>',
|
|
43
|
+
'With --all: devices updated in parallel once the canary passes',
|
|
44
|
+
parsePositiveInt('batch-size'),
|
|
45
|
+
5,
|
|
46
|
+
)
|
|
47
|
+
.option('--no-canary', 'With --all: no canary phase (everything in batches from the start)')
|
|
48
|
+
.option('--continue-on-error', 'With --all: keep rolling after a batch failure')
|
|
49
|
+
.option('--dry-run', 'With --all: list what would be updated, without updating it')
|
|
50
|
+
.option('-y, --yes', 'With --all: no confirmation (required in non-interactive shells)')
|
|
51
|
+
.option('-j, --json', 'Output as JSON')
|
|
52
|
+
.action(async (device, opts, cmd) => {
|
|
53
|
+
applyJsonFlag(opts);
|
|
54
|
+
ensureConfigured();
|
|
55
|
+
const user = getGlobalUser(cmd);
|
|
56
|
+
if (device && opts.all) {
|
|
57
|
+
printErr('give a device or --all, not both', { code: 'input_error' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!device && !opts.all) {
|
|
61
|
+
printErr('give a device, or --all for every outdated device of the fleet', {
|
|
62
|
+
code: 'input_error',
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (opts.all) return runFleetUpdate(opts, user);
|
|
67
|
+
const fleetOnly = ['product', 'group', 'dryRun', 'continueOnError'].filter(
|
|
68
|
+
(o) => opts[o] !== undefined,
|
|
69
|
+
);
|
|
70
|
+
if (fleetOnly.length || opts.canary === false) {
|
|
71
|
+
printErr(
|
|
72
|
+
`--product, --group, --dry-run, --continue-on-error and --no-canary go with --all (to check one device: agent check ${device})`,
|
|
73
|
+
{ code: 'input_error' },
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await runDeviceUpdate('apply', device, opts, user);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -4,7 +4,7 @@ import { ensureConfigured, runInteractive } from './_shared.js';
|
|
|
4
4
|
|
|
5
5
|
export function registerConsoleCommand(device) {
|
|
6
6
|
device
|
|
7
|
-
.command('console <
|
|
7
|
+
.command('console <device>')
|
|
8
8
|
.helpGroup('Remote access:')
|
|
9
9
|
.description('Open an interactive terminal on the device')
|
|
10
10
|
.action((deviceId) =>
|
package/commands/device/exec.js
CHANGED
|
@@ -6,7 +6,7 @@ import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
|
|
|
6
6
|
|
|
7
7
|
export function registerExecCommand(device) {
|
|
8
8
|
device
|
|
9
|
-
.command('exec <
|
|
9
|
+
.command('exec <device> <command...>')
|
|
10
10
|
.helpGroup('Execution:')
|
|
11
11
|
.description('Execute a shell command on the device')
|
|
12
12
|
.option('-j, --json', 'Buffer stdout/stderr and emit a single JSON envelope')
|
package/commands/device/fs.js
CHANGED
|
@@ -13,7 +13,7 @@ import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export function registerFsCommands(device) {
|
|
15
15
|
device
|
|
16
|
-
.command('ls <
|
|
16
|
+
.command('ls <device> [path]')
|
|
17
17
|
.helpGroup('Filesystem:')
|
|
18
18
|
.description('List a directory on the device')
|
|
19
19
|
.option('-j, --json', 'Output as JSON')
|
|
@@ -46,7 +46,7 @@ export function registerFsCommands(device) {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
device
|
|
49
|
-
.command('read <
|
|
49
|
+
.command('read <device> <path>')
|
|
50
50
|
.alias('cat')
|
|
51
51
|
.helpGroup('Filesystem:')
|
|
52
52
|
.description('Print the contents of a remote file to stdout')
|
|
@@ -72,7 +72,7 @@ export function registerFsCommands(device) {
|
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
device
|
|
75
|
-
.command('write <
|
|
75
|
+
.command('write <device> <path> [content]')
|
|
76
76
|
.helpGroup('Filesystem:')
|
|
77
77
|
.description(
|
|
78
78
|
'Write inline content to a remote file (use `push` for large local files). Reads from stdin when <content> is omitted.',
|
|
@@ -111,7 +111,7 @@ export function registerFsCommands(device) {
|
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
device
|
|
114
|
-
.command('mkdir <
|
|
114
|
+
.command('mkdir <device> <path>')
|
|
115
115
|
.helpGroup('Filesystem:')
|
|
116
116
|
.description('Create a directory on the device')
|
|
117
117
|
.option('-j, --json', 'Output as JSON')
|
|
@@ -133,7 +133,7 @@ export function registerFsCommands(device) {
|
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
device
|
|
136
|
-
.command('rm <
|
|
136
|
+
.command('rm <device> <path>')
|
|
137
137
|
.helpGroup('Filesystem:')
|
|
138
138
|
.description(
|
|
139
139
|
'Delete a file or directory on the device (pass --recursive for directories with contents)',
|
|
@@ -159,7 +159,7 @@ export function registerFsCommands(device) {
|
|
|
159
159
|
});
|
|
160
160
|
|
|
161
161
|
device
|
|
162
|
-
.command('mv <
|
|
162
|
+
.command('mv <device> <source> <destination>')
|
|
163
163
|
.helpGroup('Filesystem:')
|
|
164
164
|
.description('Move or rename a file or directory on the device')
|
|
165
165
|
.option('-j, --json', 'Output as JSON')
|
package/commands/device/index.js
CHANGED
|
@@ -3,23 +3,21 @@ import { registerProxyCommands } from './proxy.js';
|
|
|
3
3
|
import { registerConsoleCommand } from './console.js';
|
|
4
4
|
import { registerStatusCommand } from './status.js';
|
|
5
5
|
import { registerExecCommand } from './exec.js';
|
|
6
|
-
import { registerUpdateCommand } from './update.js';
|
|
7
6
|
import { registerTransferCommands } from './transfer.js';
|
|
8
7
|
import { registerFsCommands } from './fs.js';
|
|
9
8
|
import { registerLogsCommand } from './logs.js';
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
|
-
* `thinr device <
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* `thinr device <verb> <device> …` — the agent-backed actions (console,
|
|
12
|
+
* proxies, exec, files, logs, status), added to the generic `device` group
|
|
13
|
+
* of @thinger-io/cli-core, which holds its records, properties, resources
|
|
14
|
+
* and children. Agent updates are `thinr agent`.
|
|
16
15
|
*/
|
|
17
16
|
export function registerDeviceCommands(device) {
|
|
18
17
|
registerProxyCommands(device);
|
|
19
18
|
registerConsoleCommand(device);
|
|
20
19
|
registerStatusCommand(device);
|
|
21
20
|
registerExecCommand(device);
|
|
22
|
-
registerUpdateCommand(device);
|
|
23
21
|
registerTransferCommands(device);
|
|
24
22
|
registerFsCommands(device);
|
|
25
23
|
registerLogsCommand(device);
|
package/commands/device/logs.js
CHANGED
|
@@ -62,7 +62,7 @@ async function resolveProductSource({ deviceId, sourceName, user }) {
|
|
|
62
62
|
|
|
63
63
|
export function registerLogsCommand(device) {
|
|
64
64
|
device
|
|
65
|
-
.command('logs <
|
|
65
|
+
.command('logs <device>')
|
|
66
66
|
.helpGroup('Observability:')
|
|
67
67
|
.description('Stream or tail device logs (journalctl by default, or a file path)')
|
|
68
68
|
.option('-f, --follow', 'Keep streaming until interrupted (Ctrl+C)')
|
package/commands/device/proxy.js
CHANGED
|
@@ -4,10 +4,13 @@ import { ensureConfigured, runInteractive } from './_shared.js';
|
|
|
4
4
|
|
|
5
5
|
export function registerProxyCommands(device) {
|
|
6
6
|
device
|
|
7
|
-
.command('tcp <
|
|
7
|
+
.command('tcp <device> [target]')
|
|
8
8
|
.helpGroup('Remote access:')
|
|
9
9
|
.description('Create a TCP proxy (no TLS). Default target: 22.')
|
|
10
|
-
.option(
|
|
10
|
+
.option(
|
|
11
|
+
'-p, --port <port>',
|
|
12
|
+
'Port of the relay on the server (default: random, 50000-51000)',
|
|
13
|
+
)
|
|
11
14
|
.action((deviceId, target, opts) =>
|
|
12
15
|
runInteractive(() => {
|
|
13
16
|
ensureConfigured();
|
|
@@ -16,10 +19,13 @@ export function registerProxyCommands(device) {
|
|
|
16
19
|
);
|
|
17
20
|
|
|
18
21
|
device
|
|
19
|
-
.command('tls <
|
|
22
|
+
.command('tls <device> [target]')
|
|
20
23
|
.helpGroup('Remote access:')
|
|
21
24
|
.description('Create a TLS proxy. Default target: 443.')
|
|
22
|
-
.option(
|
|
25
|
+
.option(
|
|
26
|
+
'-p, --port <port>',
|
|
27
|
+
'Port of the relay on the server (default: random, 50000-51000)',
|
|
28
|
+
)
|
|
23
29
|
.action((deviceId, target, opts) =>
|
|
24
30
|
runInteractive(() => {
|
|
25
31
|
ensureConfigured();
|
|
@@ -28,10 +34,13 @@ export function registerProxyCommands(device) {
|
|
|
28
34
|
);
|
|
29
35
|
|
|
30
36
|
device
|
|
31
|
-
.command('http <
|
|
37
|
+
.command('http <device> [target]')
|
|
32
38
|
.helpGroup('Remote access:')
|
|
33
39
|
.description('Create an HTTP proxy. Default target: 80.')
|
|
34
|
-
.option(
|
|
40
|
+
.option(
|
|
41
|
+
'-p, --port <port>',
|
|
42
|
+
'Port of the relay on the server (default: random, 50000-51000)',
|
|
43
|
+
)
|
|
35
44
|
.option('--no-open', 'Do not open the browser')
|
|
36
45
|
.action((deviceId, target, opts) =>
|
|
37
46
|
runInteractive(() => {
|
|
@@ -6,7 +6,7 @@ import { applyJsonFlag, ensureConfigured, runDeviceCommand } from './_shared.js'
|
|
|
6
6
|
|
|
7
7
|
export function registerStatusCommand(device) {
|
|
8
8
|
device
|
|
9
|
-
.command('status <
|
|
9
|
+
.command('status <device>')
|
|
10
10
|
.helpGroup('Discovery:')
|
|
11
11
|
.description('Check device status, data transfer and the latest monitoring sample')
|
|
12
12
|
.option('-j, --json', 'Output as JSON')
|
|
@@ -88,7 +88,7 @@ function makeTransferBar(totalBytes) {
|
|
|
88
88
|
*/
|
|
89
89
|
export function registerTransferCommands(device) {
|
|
90
90
|
device
|
|
91
|
-
.command('push <
|
|
91
|
+
.command('push <device> <local-path> <remote-path>')
|
|
92
92
|
.helpGroup('Filesystem:')
|
|
93
93
|
.description('Upload a local file to the device (use trailing "/" on remote to keep the source basename)')
|
|
94
94
|
.option('-j, --json', 'Output as JSON')
|
|
@@ -167,7 +167,7 @@ export function registerTransferCommands(device) {
|
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
device
|
|
170
|
-
.command('pull <
|
|
170
|
+
.command('pull <device> <remote-path> <local-path>')
|
|
171
171
|
.helpGroup('Filesystem:')
|
|
172
172
|
.description('Download a remote file from the device to local disk')
|
|
173
173
|
.option('-j, --json', 'Output as JSON')
|
package/commands/product/exec.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
|
|
29
29
|
export function registerProductExecCommand(product) {
|
|
30
30
|
product
|
|
31
|
-
.command('exec <
|
|
31
|
+
.command('exec <product> <command...>')
|
|
32
32
|
.helpGroup('Fan-out:')
|
|
33
33
|
.description(
|
|
34
34
|
'Execute a shell command in parallel on every active device of the product',
|