@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
|
@@ -5,12 +5,18 @@ import {
|
|
|
5
5
|
getDeviceProperties,
|
|
6
6
|
formatDeviceProperties,
|
|
7
7
|
} from '../../lib/property.js';
|
|
8
|
-
import { isJsonMode, printOk
|
|
9
|
-
import {
|
|
8
|
+
import { isJsonMode, printOk } from '../../lib/output.js';
|
|
9
|
+
import {
|
|
10
|
+
applyJsonFlag,
|
|
11
|
+
ensureConfigured,
|
|
12
|
+
extractField,
|
|
13
|
+
runDeviceCommand,
|
|
14
|
+
} from './_shared.js';
|
|
10
15
|
|
|
11
16
|
export function registerPropertyCommand(device) {
|
|
12
17
|
device
|
|
13
18
|
.command('property <deviceId> [propertyId]')
|
|
19
|
+
.helpGroup('State & resources:')
|
|
14
20
|
.description('List device properties, or read one by ID')
|
|
15
21
|
.option('-j, --json', 'Output as JSON')
|
|
16
22
|
.option('-f, --field <field>', 'Extract a sub-field (dot path, e.g. data.value)')
|
|
@@ -18,33 +24,29 @@ export function registerPropertyCommand(device) {
|
|
|
18
24
|
applyJsonFlag(opts);
|
|
19
25
|
ensureConfigured();
|
|
20
26
|
if (!propertyId) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
27
|
+
await runDeviceCommand({
|
|
28
|
+
start: `Getting properties for ${deviceId}...`,
|
|
29
|
+
fn: () => getDeviceProperties(deviceId),
|
|
30
|
+
success: `Properties for ${deviceId} retrieved successfully`,
|
|
31
|
+
failure: 'Property retrieval failed',
|
|
32
|
+
onSuccess: (result) => {
|
|
33
|
+
if (isJsonMode()) printOk(result);
|
|
34
|
+
else console.log(formatDeviceProperties(deviceId, result));
|
|
35
|
+
},
|
|
36
|
+
});
|
|
32
37
|
return;
|
|
33
38
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const { message, code } = classifyError(error);
|
|
47
|
-
printErr(message, { code });
|
|
48
|
-
}
|
|
39
|
+
await runDeviceCommand({
|
|
40
|
+
start: `Retrieving property for ${deviceId}...`,
|
|
41
|
+
fn: () => getDeviceProperty(deviceId, propertyId),
|
|
42
|
+
success: `Property ${propertyId} for ${deviceId} found`,
|
|
43
|
+
failure: 'Property retrieval failed',
|
|
44
|
+
onSuccess: (property) => {
|
|
45
|
+
const value = extractField(property, opts.field);
|
|
46
|
+
if (isJsonMode()) printOk(value);
|
|
47
|
+
else if (opts.field) console.log(value);
|
|
48
|
+
else console.log(formatDeviceProperty(deviceId, propertyId, property));
|
|
49
|
+
},
|
|
50
|
+
});
|
|
49
51
|
});
|
|
50
52
|
}
|
package/commands/device/proxy.js
CHANGED
|
@@ -5,6 +5,7 @@ import { ensureConfigured, runInteractive } from './_shared.js';
|
|
|
5
5
|
export function registerProxyCommands(device) {
|
|
6
6
|
device
|
|
7
7
|
.command('tcp <deviceId> [target]')
|
|
8
|
+
.helpGroup('Remote access:')
|
|
8
9
|
.description('Create a TCP proxy (no TLS). Default target: 22.')
|
|
9
10
|
.option('-p, --port <port>', 'Local port to use (default: random)')
|
|
10
11
|
.action((deviceId, target, opts) =>
|
|
@@ -16,6 +17,7 @@ export function registerProxyCommands(device) {
|
|
|
16
17
|
|
|
17
18
|
device
|
|
18
19
|
.command('tls <deviceId> [target]')
|
|
20
|
+
.helpGroup('Remote access:')
|
|
19
21
|
.description('Create a TLS proxy. Default target: 443.')
|
|
20
22
|
.option('-p, --port <port>', 'Local port to use (default: random)')
|
|
21
23
|
.action((deviceId, target, opts) =>
|
|
@@ -27,6 +29,7 @@ export function registerProxyCommands(device) {
|
|
|
27
29
|
|
|
28
30
|
device
|
|
29
31
|
.command('http <deviceId> [target]')
|
|
32
|
+
.helpGroup('Remote access:')
|
|
30
33
|
.description('Create an HTTP proxy. Default target: 80.')
|
|
31
34
|
.option('-p, --port <port>', 'Local port to use (default: random)')
|
|
32
35
|
.option('--no-open', 'Do not open the browser')
|
|
@@ -4,12 +4,19 @@ import {
|
|
|
4
4
|
listDeviceResourcesWithSchemas,
|
|
5
5
|
formatDeviceResourcesWithSchemas,
|
|
6
6
|
} from '../../lib/resource.js';
|
|
7
|
-
import { isJsonMode, printOk
|
|
8
|
-
import {
|
|
7
|
+
import { isJsonMode, printOk } from '../../lib/output.js';
|
|
8
|
+
import {
|
|
9
|
+
applyJsonFlag,
|
|
10
|
+
collectInput,
|
|
11
|
+
ensureConfigured,
|
|
12
|
+
extractField,
|
|
13
|
+
runDeviceCommand,
|
|
14
|
+
} from './_shared.js';
|
|
9
15
|
|
|
10
16
|
export function registerResourceCommand(device) {
|
|
11
17
|
device
|
|
12
18
|
.command('resource <deviceId> [resource]')
|
|
19
|
+
.helpGroup('State & resources:')
|
|
13
20
|
.description('List device resources, or call one by name')
|
|
14
21
|
.option('-j, --json', 'Output as JSON')
|
|
15
22
|
.option('-f, --field <field>', 'Extract a sub-field from the result (dot path)')
|
|
@@ -18,34 +25,28 @@ export function registerResourceCommand(device) {
|
|
|
18
25
|
applyJsonFlag(opts);
|
|
19
26
|
ensureConfigured();
|
|
20
27
|
if (!resource) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
28
|
+
await runDeviceCommand({
|
|
29
|
+
start: `Getting resources for ${deviceId}...`,
|
|
30
|
+
fn: () => listDeviceResourcesWithSchemas(deviceId),
|
|
31
|
+
success: `Successfully retrieved resources for ${deviceId}`,
|
|
32
|
+
failure: 'Resource retrieval failed',
|
|
33
|
+
onSuccess: (entries) => {
|
|
34
|
+
if (isJsonMode()) printOk(entries);
|
|
35
|
+
else console.log(formatDeviceResourcesWithSchemas(deviceId, entries));
|
|
36
|
+
},
|
|
37
|
+
});
|
|
32
38
|
return;
|
|
33
39
|
}
|
|
34
|
-
|
|
35
|
-
`Calling resource ${resource} for ${deviceId}...`,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
} catch (error) {
|
|
46
|
-
spinner.fail('Resource call failed');
|
|
47
|
-
const { message, code } = classifyError(error);
|
|
48
|
-
printErr(message, { code });
|
|
49
|
-
}
|
|
40
|
+
await runDeviceCommand({
|
|
41
|
+
start: `Calling resource ${resource} for ${deviceId}...`,
|
|
42
|
+
fn: () => callDeviceResource(deviceId, resource, opts.input),
|
|
43
|
+
success: `Resource ${resource} returned successfully for ${deviceId}`,
|
|
44
|
+
failure: 'Resource call failed',
|
|
45
|
+
onSuccess: (result) => {
|
|
46
|
+
const value = extractField(result, opts.field);
|
|
47
|
+
if (isJsonMode()) printOk(value);
|
|
48
|
+
else console.log(value);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
50
51
|
});
|
|
51
52
|
}
|
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { getDeviceStatus, formatDeviceStatus } from '../../lib/status.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { getMonitoringData } from '../../lib/monitoring.js';
|
|
4
|
+
import { isJsonMode, printOk } from '../../lib/output.js';
|
|
5
|
+
import { applyJsonFlag, ensureConfigured, runDeviceCommand } from './_shared.js';
|
|
5
6
|
|
|
6
7
|
export function registerStatusCommand(device) {
|
|
7
8
|
device
|
|
8
9
|
.command('status <deviceId>')
|
|
9
|
-
.
|
|
10
|
+
.helpGroup('Discovery:')
|
|
11
|
+
.description('Check device status, data transfer and the latest monitoring sample')
|
|
10
12
|
.option('-j, --json', 'Output as JSON')
|
|
11
13
|
.action(async (deviceId, opts) => {
|
|
12
14
|
applyJsonFlag(opts);
|
|
13
15
|
ensureConfigured();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
await runDeviceCommand({
|
|
17
|
+
start: `Checking status of ${deviceId}...`,
|
|
18
|
+
// Fetch the server-side stats and the latest monitoring sample
|
|
19
|
+
// in parallel — the monitoring call is best-effort, we still
|
|
20
|
+
// render the stats section if the bucket query fails or the
|
|
21
|
+
// device never published.
|
|
22
|
+
fn: async () => {
|
|
23
|
+
const [statusResult, monitoringResult] = await Promise.allSettled([
|
|
24
|
+
getDeviceStatus(deviceId),
|
|
25
|
+
getMonitoringData({ device: deviceId, items: 1, sort: 'desc' }),
|
|
26
|
+
]);
|
|
27
|
+
if (statusResult.status === 'rejected') throw statusResult.reason;
|
|
28
|
+
const monitoring =
|
|
29
|
+
monitoringResult.status === 'fulfilled' &&
|
|
30
|
+
Array.isArray(monitoringResult.value) &&
|
|
31
|
+
monitoringResult.value.length > 0
|
|
32
|
+
? monitoringResult.value[0]
|
|
33
|
+
: null;
|
|
34
|
+
return { status: statusResult.value, monitoring };
|
|
35
|
+
},
|
|
36
|
+
// Skip the success banner: the detail block below speaks
|
|
37
|
+
// for the "found" state and the failure path prints its
|
|
38
|
+
// own clear error.
|
|
39
|
+
success: null,
|
|
40
|
+
failure: 'Status check failed',
|
|
41
|
+
onSuccess: ({ status, monitoring }) => {
|
|
42
|
+
if (isJsonMode()) printOk({ ...status, monitoring });
|
|
43
|
+
else console.log(formatDeviceStatus(deviceId, status, monitoring));
|
|
44
|
+
},
|
|
45
|
+
});
|
|
25
46
|
});
|
|
26
47
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readFile as readLocalFile, writeFile as writeLocalFile, stat as statLocal } from 'fs/promises';
|
|
3
|
+
import { createReadStream } from 'fs';
|
|
4
|
+
import { Transform } from 'node:stream';
|
|
5
|
+
import { basename } from 'path';
|
|
6
|
+
import cliProgress from 'cli-progress';
|
|
7
|
+
import { createDeviceAPI } from '../../lib/device-api.js';
|
|
8
|
+
import { isJsonMode, printOk, printErr, classifyError } from '../../lib/output.js';
|
|
9
|
+
import { success, hint, formatBytes, formatETA } from '../../lib/format.js';
|
|
10
|
+
import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a single-line progress bar for a byte-counted transfer.
|
|
14
|
+
* Disabled when stdout isn't a TTY or when the caller asked for JSON
|
|
15
|
+
* output — in both cases the caller resorts to a simpler log line.
|
|
16
|
+
*/
|
|
17
|
+
function makeTransferBar(totalBytes) {
|
|
18
|
+
// cli-progress counts characters literally when sizing the line,
|
|
19
|
+
// so ANSI escape codes inside barChars / placeholders would make
|
|
20
|
+
// the whole thing truncate. Keep the bar and its columns plain;
|
|
21
|
+
// the theme lives on the surrounding "Uploading" / "Uploaded"
|
|
22
|
+
// lines instead.
|
|
23
|
+
const sizeW = 9;
|
|
24
|
+
const rateW = 12;
|
|
25
|
+
const etaW = 11;
|
|
26
|
+
const bar = new cliProgress.SingleBar({
|
|
27
|
+
// `{eta}` is reserved by cli-progress (plain seconds, no label);
|
|
28
|
+
// use a different placeholder name so our "ETA 12s" string
|
|
29
|
+
// actually shows up instead of being silently overridden.
|
|
30
|
+
format: ` {bar} {percentage}% {transferred}/{total_human} {rate} {countdown}`,
|
|
31
|
+
barCompleteChar: '█',
|
|
32
|
+
barIncompleteChar: '░',
|
|
33
|
+
barsize: 24,
|
|
34
|
+
hideCursor: true,
|
|
35
|
+
clearOnComplete: false,
|
|
36
|
+
stopOnComplete: false,
|
|
37
|
+
});
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
bar.start(totalBytes, 0, {
|
|
40
|
+
transferred: formatBytes(0).padStart(sizeW),
|
|
41
|
+
total_human: formatBytes(totalBytes).padEnd(sizeW),
|
|
42
|
+
rate: '—'.padStart(rateW),
|
|
43
|
+
countdown: 'ETA —'.padEnd(etaW),
|
|
44
|
+
});
|
|
45
|
+
const onProgress = (ev) => {
|
|
46
|
+
const loaded = ev?.loaded ?? 0;
|
|
47
|
+
const elapsed = (Date.now() - startedAt) / 1000;
|
|
48
|
+
const rate = elapsed > 0 ? loaded / elapsed : 0;
|
|
49
|
+
const remaining = Math.max(0, totalBytes - loaded);
|
|
50
|
+
const etaSeconds = rate > 0 ? remaining / rate : Infinity;
|
|
51
|
+
bar.update(loaded, {
|
|
52
|
+
transferred: formatBytes(loaded).padStart(sizeW),
|
|
53
|
+
total_human: formatBytes(totalBytes).padEnd(sizeW),
|
|
54
|
+
rate:
|
|
55
|
+
elapsed > 0.2
|
|
56
|
+
? `${formatBytes(rate)}/s`.padStart(rateW)
|
|
57
|
+
: '—'.padStart(rateW),
|
|
58
|
+
countdown:
|
|
59
|
+
elapsed > 0.5 && Number.isFinite(etaSeconds)
|
|
60
|
+
? `ETA ${formatETA(etaSeconds)}`.padEnd(etaW)
|
|
61
|
+
: 'ETA —'.padEnd(etaW),
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
const finish = () => {
|
|
65
|
+
const elapsed = (Date.now() - startedAt) / 1000;
|
|
66
|
+
const averageRate = elapsed > 0 ? totalBytes / elapsed : 0;
|
|
67
|
+
bar.update(totalBytes, {
|
|
68
|
+
transferred: formatBytes(totalBytes).padStart(sizeW),
|
|
69
|
+
total_human: formatBytes(totalBytes).padEnd(sizeW),
|
|
70
|
+
rate:
|
|
71
|
+
elapsed > 0
|
|
72
|
+
? `${formatBytes(averageRate)}/s`.padStart(rateW)
|
|
73
|
+
: '—'.padStart(rateW),
|
|
74
|
+
countdown: 'done'.padEnd(etaW),
|
|
75
|
+
});
|
|
76
|
+
bar.stop();
|
|
77
|
+
return { elapsed, averageRate };
|
|
78
|
+
};
|
|
79
|
+
return { onProgress, finish };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Split `push` and `pull` into two plain commands instead of the
|
|
84
|
+
* scp-style single `cp` with `<deviceId>:` prefixes. Intent is obvious
|
|
85
|
+
* from the verb and the argument order is always the same: device id,
|
|
86
|
+
* then source, then destination — no ambiguity about "which side is
|
|
87
|
+
* local".
|
|
88
|
+
*/
|
|
89
|
+
export function registerTransferCommands(device) {
|
|
90
|
+
device
|
|
91
|
+
.command('push <deviceId> <localPath> <remotePath>')
|
|
92
|
+
.helpGroup('Filesystem:')
|
|
93
|
+
.description('Upload a local file to the device (use trailing "/" on remote to keep the source basename)')
|
|
94
|
+
.option('-j, --json', 'Output as JSON')
|
|
95
|
+
.action(async (deviceId, localPath, remotePath, opts, cmd) => {
|
|
96
|
+
applyJsonFlag(opts);
|
|
97
|
+
ensureConfigured();
|
|
98
|
+
const user = getGlobalUser(cmd);
|
|
99
|
+
|
|
100
|
+
const finalRemote = remotePath.endsWith('/')
|
|
101
|
+
? remotePath + basename(localPath)
|
|
102
|
+
: remotePath;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const api = createDeviceAPI(deviceId, { user });
|
|
106
|
+
const fileStat = await statLocal(localPath);
|
|
107
|
+
const totalBytes = fileStat.size;
|
|
108
|
+
const useBar = !isJsonMode() && !!process.stdout.isTTY;
|
|
109
|
+
if (!useBar) {
|
|
110
|
+
// No progress bar needed — keep the simpler,
|
|
111
|
+
// whole-buffer path that other callers also use.
|
|
112
|
+
const content = await readLocalFile(localPath);
|
|
113
|
+
await api.writeFile(finalRemote, content);
|
|
114
|
+
if (isJsonMode()) {
|
|
115
|
+
printOk({
|
|
116
|
+
direction: 'push',
|
|
117
|
+
device: deviceId,
|
|
118
|
+
local: localPath,
|
|
119
|
+
remote: finalRemote,
|
|
120
|
+
bytes: content.byteLength,
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
console.log(
|
|
124
|
+
success(`Uploaded ${localPath} → ${deviceId}:${finalRemote}`) +
|
|
125
|
+
' ' +
|
|
126
|
+
hint(`(${formatBytes(content.byteLength)})`),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.log(
|
|
132
|
+
hint(`Uploading ${localPath} → ${deviceId}:${finalRemote}`),
|
|
133
|
+
);
|
|
134
|
+
const { onProgress, finish } = makeTransferBar(totalBytes);
|
|
135
|
+
// A Transform between the file stream and axios counts
|
|
136
|
+
// bytes only when the downstream (axios → HTTP socket)
|
|
137
|
+
// accepts them. Backpressure means the count rises at
|
|
138
|
+
// the speed of the slowest link, so the bar tracks
|
|
139
|
+
// actual network progress instead of the speed of the
|
|
140
|
+
// local disk read. axios' own onUploadProgress would
|
|
141
|
+
// otherwise report the buffer fill, not the wire.
|
|
142
|
+
const stream = createReadStream(localPath, { highWaterMark: 64 * 1024 });
|
|
143
|
+
let loaded = 0;
|
|
144
|
+
const meter = new Transform({
|
|
145
|
+
transform(chunk, _enc, cb) {
|
|
146
|
+
loaded += chunk.length;
|
|
147
|
+
onProgress({ loaded, total: totalBytes });
|
|
148
|
+
cb(null, chunk);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
stream.pipe(meter);
|
|
152
|
+
await api.writeFile(finalRemote, meter, true, {
|
|
153
|
+
contentLength: totalBytes,
|
|
154
|
+
});
|
|
155
|
+
const { elapsed, averageRate } = finish();
|
|
156
|
+
console.log(
|
|
157
|
+
success(`Uploaded ${localPath} → ${deviceId}:${finalRemote}`) +
|
|
158
|
+
' ' +
|
|
159
|
+
hint(
|
|
160
|
+
`(${formatBytes(totalBytes)} in ${elapsed.toFixed(1)}s, ${formatBytes(averageRate)}/s avg)`,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const { message, code } = classifyError(err);
|
|
165
|
+
printErr(message, { code });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
device
|
|
170
|
+
.command('pull <deviceId> <remotePath> <localPath>')
|
|
171
|
+
.helpGroup('Filesystem:')
|
|
172
|
+
.description('Download a remote file from the device to local disk')
|
|
173
|
+
.option('-j, --json', 'Output as JSON')
|
|
174
|
+
.action(async (deviceId, remotePath, localPath, opts, cmd) => {
|
|
175
|
+
applyJsonFlag(opts);
|
|
176
|
+
ensureConfigured();
|
|
177
|
+
const user = getGlobalUser(cmd);
|
|
178
|
+
|
|
179
|
+
const finalLocal = localPath.endsWith('/')
|
|
180
|
+
? localPath + basename(remotePath)
|
|
181
|
+
: localPath;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const api = createDeviceAPI(deviceId, { user });
|
|
185
|
+
const useBar = !isJsonMode() && !!process.stdout.isTTY;
|
|
186
|
+
if (!useBar) {
|
|
187
|
+
const buf = await api.readFile(remotePath);
|
|
188
|
+
await writeLocalFile(finalLocal, buf);
|
|
189
|
+
if (isJsonMode()) {
|
|
190
|
+
printOk({
|
|
191
|
+
direction: 'pull',
|
|
192
|
+
device: deviceId,
|
|
193
|
+
remote: remotePath,
|
|
194
|
+
local: finalLocal,
|
|
195
|
+
bytes: buf.byteLength,
|
|
196
|
+
});
|
|
197
|
+
} else {
|
|
198
|
+
console.log(
|
|
199
|
+
success(`Downloaded ${deviceId}:${remotePath} → ${finalLocal}`) +
|
|
200
|
+
' ' +
|
|
201
|
+
hint(`(${formatBytes(buf.byteLength)})`),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// First HEAD-like round-trip isn't available; axios
|
|
207
|
+
// surfaces `total` on the first onDownloadProgress
|
|
208
|
+
// frame if the server sent Content-Length. Start the
|
|
209
|
+
// bar deferred until we know the total.
|
|
210
|
+
console.log(
|
|
211
|
+
hint(`Downloading ${deviceId}:${remotePath} → ${finalLocal}`),
|
|
212
|
+
);
|
|
213
|
+
/** @type {ReturnType<typeof makeTransferBar> | null} */
|
|
214
|
+
let bar = null;
|
|
215
|
+
const buf = await api.readFile(remotePath, {
|
|
216
|
+
onProgress: (ev) => {
|
|
217
|
+
if (!bar && ev?.total) bar = makeTransferBar(ev.total);
|
|
218
|
+
bar?.onProgress(ev);
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
if (!bar) bar = makeTransferBar(buf.byteLength);
|
|
222
|
+
const { elapsed, averageRate } = bar.finish();
|
|
223
|
+
await writeLocalFile(finalLocal, buf);
|
|
224
|
+
console.log(
|
|
225
|
+
success(`Downloaded ${deviceId}:${remotePath} → ${finalLocal}`) +
|
|
226
|
+
' ' +
|
|
227
|
+
hint(
|
|
228
|
+
`(${formatBytes(buf.byteLength)} in ${elapsed.toFixed(1)}s, ${formatBytes(averageRate)}/s avg)`,
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
const { message, code } = classifyError(err);
|
|
233
|
+
printErr(message, { code });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import chalk from 'chalk';
|
|
3
2
|
import { createDeviceAPI } from '../../lib/device-api.js';
|
|
4
3
|
import { isJsonMode, printOk, printErr, createSpinner, classifyError } from '../../lib/output.js';
|
|
4
|
+
import { label as labelText, accent } from '../../lib/format.js';
|
|
5
|
+
import { TIMEOUTS } from '../../lib/constants.js';
|
|
5
6
|
import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
|
|
6
7
|
|
|
7
8
|
const runUpdate = async (action, deviceId, opts, cmd) => {
|
|
@@ -9,33 +10,37 @@ const runUpdate = async (action, deviceId, opts, cmd) => {
|
|
|
9
10
|
ensureConfigured();
|
|
10
11
|
const user = getGlobalUser(cmd);
|
|
11
12
|
const channel = opts.channel || 'latest';
|
|
12
|
-
const
|
|
13
|
-
const spinner = createSpinner(`${
|
|
13
|
+
const phase = action === 'check' ? 'Checking for updates' : 'Applying update';
|
|
14
|
+
const spinner = createSpinner(`${phase} on ${deviceId}...`).start();
|
|
14
15
|
try {
|
|
15
16
|
const api = createDeviceAPI(deviceId, { user });
|
|
16
|
-
const timeout =
|
|
17
|
+
const timeout =
|
|
18
|
+
action === 'apply' ? TIMEOUTS.DEVICE_UPDATE_APPLY_MS : TIMEOUTS.DEVICE_RESOURCE_CALL_MS;
|
|
17
19
|
const result = await api.callResource('update', { action, channel }, { timeout });
|
|
18
|
-
spinner.succeed(`${
|
|
20
|
+
spinner.succeed(`${phase} finished`);
|
|
19
21
|
if (isJsonMode()) {
|
|
20
22
|
printOk(result);
|
|
21
23
|
} else if (result && typeof result === 'object') {
|
|
22
|
-
if (result.current) console.log(` current: ${
|
|
23
|
-
if (result.latest) console.log(` latest: ${
|
|
24
|
+
if (result.current) console.log(` current: ${labelText(result.current)}`);
|
|
25
|
+
if (result.latest) console.log(` latest: ${labelText(result.latest)}`);
|
|
24
26
|
if (result.arch) console.log(` arch: ${result.arch}`);
|
|
25
|
-
if (result.status) console.log(` status: ${
|
|
27
|
+
if (result.status) console.log(` status: ${accent(result.status)}`);
|
|
26
28
|
if (result.message) console.log(` message: ${result.message}`);
|
|
27
29
|
} else {
|
|
28
30
|
console.log(result);
|
|
29
31
|
}
|
|
30
32
|
} catch (error) {
|
|
31
|
-
spinner.fail(`${
|
|
33
|
+
spinner.fail(`${phase} failed`);
|
|
32
34
|
const { message, code } = classifyError(error);
|
|
33
35
|
printErr(message, { code });
|
|
34
36
|
}
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
export function registerUpdateCommand(device) {
|
|
38
|
-
const update = device
|
|
40
|
+
const update = device
|
|
41
|
+
.command('update')
|
|
42
|
+
.helpGroup('Execution:')
|
|
43
|
+
.description('Check for or apply agent updates');
|
|
39
44
|
|
|
40
45
|
update
|
|
41
46
|
.command('check <deviceId>')
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { registerUpgradeCommand } from './upgrade.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `thinr fleet <subcommand>` — operations that treat the whole device
|
|
6
|
+
* fleet as the unit of work, not a single device. Today we only expose
|
|
7
|
+
* `upgrade`; future fan-out commands (status summary, health probe,
|
|
8
|
+
* rollback, etc.) would live alongside it.
|
|
9
|
+
*/
|
|
10
|
+
export function fleetCommand(program) {
|
|
11
|
+
const fleet = program
|
|
12
|
+
.command('fleet')
|
|
13
|
+
.helpGroup('Operations:')
|
|
14
|
+
.description('Fleet-wide commands (run once, touch many devices)');
|
|
15
|
+
|
|
16
|
+
registerUpgradeCommand(fleet);
|
|
17
|
+
}
|