@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -1
- package/README.md +235 -30
- package/bin/thinr.js +43 -25
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +68 -20
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +213 -0
- package/lib/config.js +5 -3
- package/lib/console.js +19 -5
- package/lib/constants.js +83 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +131 -148
- package/lib/devices.js +18 -21
- package/lib/errors.js +12 -0
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1226 -105
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +14 -3
- package/commands/product.js +0 -407
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { createDeviceAPI } from '../../lib/device-api.js';
|
|
3
|
+
import { getDevice } from '../../lib/devices.js';
|
|
4
|
+
import { getProductLogs } from '../../lib/product/logs.js';
|
|
5
|
+
import { inputError } from '../../lib/errors.js';
|
|
6
|
+
import { isJsonMode, printOk, printErr, classifyError } from '../../lib/output.js';
|
|
7
|
+
import { error as errorStyle, muted, warning } from '../../lib/format.js';
|
|
8
|
+
import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
|
|
9
|
+
|
|
10
|
+
// Single-quote for /bin/sh: wrap in '…' and escape embedded single quotes.
|
|
11
|
+
function shq(value) {
|
|
12
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildCommand({ follow, tail, unit, since, path }) {
|
|
16
|
+
const n = Number.isFinite(tail) && tail >= 0 ? tail : 100;
|
|
17
|
+
if (path) {
|
|
18
|
+
const follower = follow ? '-F' : '';
|
|
19
|
+
return `tail -n ${n} ${follower} -- ${shq(path)}`.trim();
|
|
20
|
+
}
|
|
21
|
+
const parts = ['journalctl', '--no-pager', '--output=short', `-n ${n}`];
|
|
22
|
+
if (follow) parts.push('-f');
|
|
23
|
+
if (unit) parts.push(`-u ${shq(unit)}`);
|
|
24
|
+
if (since) parts.push(`--since ${shq(since)}`);
|
|
25
|
+
return parts.join(' ');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the command to stream when --source is in play. Looks up the
|
|
30
|
+
* device's product, reads its logs config (with the synthetic fallback
|
|
31
|
+
* when the property is unset), and picks either the requested source
|
|
32
|
+
* or the default. Returns `{ command, source, fallback }` so the
|
|
33
|
+
* caller can surface what it picked.
|
|
34
|
+
*/
|
|
35
|
+
async function resolveProductSource({ deviceId, sourceName, user }) {
|
|
36
|
+
const device = await getDevice(deviceId, user);
|
|
37
|
+
const productId = device?.product;
|
|
38
|
+
if (!productId) {
|
|
39
|
+
throw inputError(
|
|
40
|
+
`Device "${deviceId}" is not bound to a product — --source needs a product to look up the log source on.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const cfg = await getProductLogs(productId, user);
|
|
44
|
+
const fallback = !!(/** @type {any} */ (cfg).__fallback);
|
|
45
|
+
const picked = sourceName
|
|
46
|
+
? cfg.sources.find((s) => s.name === sourceName)
|
|
47
|
+
: cfg.sources.find((s) => s.name === cfg.default) || cfg.sources[0];
|
|
48
|
+
if (!picked) {
|
|
49
|
+
const available = cfg.sources.map((s) => s.name).join(', ') || '(none)';
|
|
50
|
+
const err = /** @type {Error & { code?: string }} */ (
|
|
51
|
+
new Error(
|
|
52
|
+
sourceName
|
|
53
|
+
? `Source "${sourceName}" is not configured on product "${productId}". Available: ${available}`
|
|
54
|
+
: `No log sources configured on product "${productId}".`,
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
err.code = 'not_found';
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
return { command: picked.command, source: picked.name, product: productId, fallback };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function registerLogsCommand(device) {
|
|
64
|
+
device
|
|
65
|
+
.command('logs <deviceId>')
|
|
66
|
+
.helpGroup('Observability:')
|
|
67
|
+
.description('Stream or tail device logs (journalctl by default, or a file path)')
|
|
68
|
+
.option('-f, --follow', 'Keep streaming until interrupted (Ctrl+C)')
|
|
69
|
+
.option('-n, --tail <n>', 'Number of initial lines to show', (v) => parseInt(v, 10), 100)
|
|
70
|
+
.option('--unit <name>', 'Filter by systemd unit (journalctl only)')
|
|
71
|
+
.option('--since <when>', 'Show entries since a time expression (journalctl only)')
|
|
72
|
+
.option('--path <file>', 'Follow a file instead of journalctl')
|
|
73
|
+
.option(
|
|
74
|
+
'--source [name]',
|
|
75
|
+
'Stream a log source configured on the device\'s product (omit name to use the default)',
|
|
76
|
+
)
|
|
77
|
+
.option('-j, --json', 'Buffer output and emit a single JSON envelope (not allowed with --follow)')
|
|
78
|
+
.action(async (deviceId, opts, cmd) => {
|
|
79
|
+
applyJsonFlag(opts);
|
|
80
|
+
ensureConfigured();
|
|
81
|
+
|
|
82
|
+
if (opts.follow && opts.json) {
|
|
83
|
+
printErr('--json and --follow cannot be combined', { code: 'invalid_args', exitCode: 2 });
|
|
84
|
+
}
|
|
85
|
+
if (opts.path && (opts.unit || opts.since)) {
|
|
86
|
+
printErr('--unit and --since only apply to journalctl mode', { code: 'invalid_args', exitCode: 2 });
|
|
87
|
+
}
|
|
88
|
+
if (opts.source !== undefined && (opts.path || opts.unit || opts.since)) {
|
|
89
|
+
printErr(
|
|
90
|
+
'--source is mutually exclusive with --path, --unit and --since (the source command is sent verbatim)',
|
|
91
|
+
{ code: 'invalid_args', exitCode: 2 },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const user = getGlobalUser(cmd);
|
|
96
|
+
let commandStr;
|
|
97
|
+
if (opts.source !== undefined) {
|
|
98
|
+
const sourceName = typeof opts.source === 'string' ? opts.source : null;
|
|
99
|
+
let resolved;
|
|
100
|
+
try {
|
|
101
|
+
resolved = await resolveProductSource({
|
|
102
|
+
deviceId,
|
|
103
|
+
sourceName,
|
|
104
|
+
user,
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const { message, code } = classifyError(err);
|
|
108
|
+
printErr(message, { code });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
commandStr = resolved.command;
|
|
112
|
+
if (!isJsonMode()) {
|
|
113
|
+
const tag = resolved.fallback ? ' (fallback)' : '';
|
|
114
|
+
process.stderr.write(
|
|
115
|
+
muted(`[source: ${resolved.source} on ${resolved.product}${tag}]\n`),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
commandStr = buildCommand({
|
|
120
|
+
follow: !!opts.follow,
|
|
121
|
+
tail: opts.tail,
|
|
122
|
+
unit: opts.unit,
|
|
123
|
+
since: opts.since,
|
|
124
|
+
path: opts.path,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const api = createDeviceAPI(deviceId, { user });
|
|
130
|
+
if (isJsonMode()) {
|
|
131
|
+
let stdout = '';
|
|
132
|
+
let stderr = '';
|
|
133
|
+
const { exitCode, timedOut, cancelled } = await api.execStream(commandStr, {
|
|
134
|
+
onStdout: (s) => {
|
|
135
|
+
stdout += s;
|
|
136
|
+
},
|
|
137
|
+
onStderr: (s) => {
|
|
138
|
+
stderr += s;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
if (cancelled) printErr('Interrupted', { code: 'cancelled', exitCode: 130 });
|
|
142
|
+
if (timedOut) printErr('Command timed out on the device', { code: 'timeout' });
|
|
143
|
+
printOk({ command: commandStr, stdout, stderr, exitCode: exitCode ?? null });
|
|
144
|
+
process.exit(exitCode ?? 1);
|
|
145
|
+
}
|
|
146
|
+
let cancelFn = null;
|
|
147
|
+
const onSigint = () => cancelFn?.();
|
|
148
|
+
process.on('SIGINT', onSigint);
|
|
149
|
+
const { exitCode, timedOut, cancelled } = await api.execStream(commandStr, {
|
|
150
|
+
onStdout: (s) => process.stdout.write(s),
|
|
151
|
+
onStderr: (s) => process.stderr.write(s),
|
|
152
|
+
onCancel: (fn) => {
|
|
153
|
+
cancelFn = fn;
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
process.off('SIGINT', onSigint);
|
|
157
|
+
if (timedOut) {
|
|
158
|
+
console.error(errorStyle('\nError: log stream timed out on the device.'));
|
|
159
|
+
}
|
|
160
|
+
if (cancelled) {
|
|
161
|
+
// Follow mode always ends via Ctrl+C — suppress the
|
|
162
|
+
// noisy [interrupted] banner in that case.
|
|
163
|
+
if (!opts.follow) console.error(warning('\n[interrupted]'));
|
|
164
|
+
process.exit(130);
|
|
165
|
+
}
|
|
166
|
+
process.exit(exitCode ?? 0);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const { message, code } = classifyError(error);
|
|
169
|
+
if (isJsonMode()) printErr(message, { code });
|
|
170
|
+
console.error(errorStyle(`Error [${code}]: ${message}`));
|
|
171
|
+
if (error.response) {
|
|
172
|
+
console.error(errorStyle(`Status: ${error.response.status}`));
|
|
173
|
+
console.error(errorStyle(`Body: ${JSON.stringify(error.response.data)}`));
|
|
174
|
+
}
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -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
|
+
}
|