@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +235 -30
  3. package/bin/thinr.js +43 -25
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +68 -20
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +213 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +19 -5
  51. package/lib/constants.js +83 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +131 -148
  57. package/lib/devices.js +18 -21
  58. package/lib/errors.js +12 -0
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1226 -105
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +14 -3
  92. 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, printErr, createSpinner, classifyError } from '../../lib/output.js';
9
- import { applyJsonFlag, ensureConfigured } from './_shared.js';
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
- const spinner = createSpinner(`Getting properties for ${deviceId}...`).start();
22
- try {
23
- const result = await getDeviceProperties(deviceId);
24
- spinner.succeed(`Properties for ${deviceId} retrieved successfully`);
25
- if (isJsonMode()) printOk(result);
26
- else console.log(formatDeviceProperties(deviceId, result));
27
- } catch (error) {
28
- spinner.fail('Property retrieval failed');
29
- const { message, code } = classifyError(error);
30
- printErr(message, { code });
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
- const spinner = createSpinner(`Retrieving property for ${deviceId}...`).start();
35
- try {
36
- const property = await getDeviceProperty(deviceId, propertyId);
37
- spinner.succeed(`Property ${propertyId} for ${deviceId} found`);
38
- const value = opts.field
39
- ? opts.field.split('.').reduce((obj, key) => obj && obj[key], property)
40
- : property;
41
- if (isJsonMode()) printOk(value);
42
- else if (opts.field) console.log(value);
43
- else console.log(formatDeviceProperty(deviceId, propertyId, property));
44
- } catch (error) {
45
- spinner.fail('Property retrieval failed');
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
  }
@@ -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, printErr, createSpinner, classifyError } from '../../lib/output.js';
8
- import { applyJsonFlag, collectInput, ensureConfigured } from './_shared.js';
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
- const spinner = createSpinner(`Getting resources for ${deviceId}...`).start();
22
- try {
23
- const entries = await listDeviceResourcesWithSchemas(deviceId);
24
- spinner.succeed(`Successfully retrieved resources for ${deviceId}`);
25
- if (isJsonMode()) printOk(entries);
26
- else console.log(formatDeviceResourcesWithSchemas(deviceId, entries));
27
- } catch (error) {
28
- spinner.fail('Resource retrieval failed');
29
- const { message, code } = classifyError(error);
30
- printErr(message, { code });
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
- const spinner = createSpinner(
35
- `Calling resource ${resource} for ${deviceId}...`,
36
- ).start();
37
- try {
38
- const result = await callDeviceResource(deviceId, resource, opts.input);
39
- spinner.succeed(`Resource ${resource} returned successfully for ${deviceId}`);
40
- const value = opts.field
41
- ? opts.field.split('.').reduce((obj, key) => obj && obj[key], result)
42
- : result;
43
- if (isJsonMode()) printOk(value);
44
- else console.log(value);
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 { isJsonMode, printOk, printErr, createSpinner, classifyError } from '../../lib/output.js';
4
- import { applyJsonFlag, ensureConfigured } from './_shared.js';
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
- .description('Check device status and stats')
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
- const spinner = createSpinner(`Checking status of ${deviceId}...`).start();
15
- try {
16
- const status = await getDeviceStatus(deviceId);
17
- spinner.succeed(`Device ${deviceId} found`);
18
- if (isJsonMode()) printOk(status);
19
- else console.log(formatDeviceStatus(deviceId, status));
20
- } catch (error) {
21
- spinner.fail('Status check failed');
22
- const { message, code } = classifyError(error);
23
- printErr(message, { code });
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
+ }