@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +230 -16
  3. package/bin/thinr.js +23 -24
  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 +30 -17
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +199 -0
  49. package/lib/concurrency.js +52 -0
  50. package/lib/config.js +5 -3
  51. package/lib/console.js +12 -4
  52. package/lib/constants.js +76 -0
  53. package/lib/dashboard/event-stream.js +239 -0
  54. package/lib/dashboard/metric-aggregate.js +64 -0
  55. package/lib/dashboard/metric-filter.js +39 -0
  56. package/lib/debug-log.js +93 -0
  57. package/lib/device-api.js +109 -147
  58. package/lib/devices.js +18 -21
  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 +1343 -10
  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 +15 -3
  92. 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, 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
+ }
@@ -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 label = action === 'check' ? 'Checking for updates' : 'Applying update';
13
- const spinner = createSpinner(`${label} on ${deviceId}...`).start();
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 = action === 'apply' ? 300000 : 30000;
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(`${label} finished`);
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: ${chalk.bold(result.current)}`);
23
- if (result.latest) console.log(` latest: ${chalk.bold(result.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: ${chalk.cyan(result.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(`${label} failed`);
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.command('update').description('Check for or apply agent updates');
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
+ }