@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,76 @@
1
+ // @ts-check
2
+ import { formatDeviceProperty, getDeviceProperty } from '../../lib/property.js';
3
+ import { filterActiveDevices } from '../../lib/devices.js';
4
+ import { error as errorStyle, info } from '../../lib/format.js';
5
+ import {
6
+ isJsonMode,
7
+ printOk,
8
+ printErr,
9
+ createSpinner,
10
+ classifyError,
11
+ } from '../../lib/output.js';
12
+ import {
13
+ applyJsonFlag,
14
+ ensureConfigured,
15
+ extractField,
16
+ getGlobalUser,
17
+ } from '../_shared.js';
18
+ import { fetchProductDevices } from './_shared.js';
19
+
20
+ export function registerProductPropertyCommand(product) {
21
+ product
22
+ .command('property <productId> <propertyId>')
23
+ .helpGroup('Fan-out:')
24
+ .description('Read a property on every device of the product')
25
+ .option('-j, --json', 'Output as JSON')
26
+ .option('-f, --field <field>', 'Extract a sub-field from each property (dot path)')
27
+ .option('-a, --all', 'Include offline devices (default: only active)')
28
+ .option('-g, --group <group>', 'Filter devices by asset group')
29
+ .action(async (productId, propertyId, opts, cmd) => {
30
+ applyJsonFlag(opts);
31
+ ensureConfigured();
32
+ const user = getGlobalUser(cmd);
33
+ const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
34
+ let devices;
35
+ try {
36
+ devices = await fetchProductDevices(productId, opts.group, user);
37
+ spinner.succeed(`Devices for product ${productId} retrieved successfully`);
38
+ } catch (error) {
39
+ spinner.fail(`Failed to retrieve devices for product ${productId}`);
40
+ const { message, code } = classifyError(error);
41
+ printErr(message, { code });
42
+ return;
43
+ }
44
+ if (!opts.all) devices = filterActiveDevices(devices);
45
+
46
+ const results = [];
47
+ for (const device of devices) {
48
+ try {
49
+ const property = await getDeviceProperty(device.device, propertyId);
50
+ const value = extractField(property, opts.field);
51
+ results.push({ device: device.device, ok: true, data: value });
52
+ if (!isJsonMode()) {
53
+ console.log(
54
+ 'Device',
55
+ info(device.device),
56
+ 'property',
57
+ info(propertyId),
58
+ );
59
+ if (opts.field) console.log(value);
60
+ else console.log(formatDeviceProperty(device.device, propertyId, property));
61
+ }
62
+ } catch (error) {
63
+ const { message, code } = classifyError(error);
64
+ results.push({ device: device.device, ok: false, error: { message, code } });
65
+ if (!isJsonMode()) {
66
+ console.error(
67
+ errorStyle(
68
+ `Error retrieving property ${propertyId} for device ${device.device}: ${message}`,
69
+ ),
70
+ );
71
+ }
72
+ }
73
+ }
74
+ if (isJsonMode()) printOk({ product: productId, property: propertyId, results });
75
+ });
76
+ }
@@ -0,0 +1,125 @@
1
+ // @ts-check
2
+ import {
3
+ getProductProperties,
4
+ getProductProperty,
5
+ setProductProperty,
6
+ deleteProductProperty,
7
+ } from '../../lib/product.js';
8
+ import { info, success } from '../../lib/format.js';
9
+ import { isJsonMode, printOk, printErr, classifyError } from '../../lib/output.js';
10
+ import { applyJsonFlag, ensureConfigured, getGlobalUser } from '../_shared.js';
11
+
12
+ function parseJsonValue(raw) {
13
+ if (raw == null) return raw;
14
+ try {
15
+ return JSON.parse(raw);
16
+ } catch {
17
+ // Fall back to raw string so `thinr product property-set p x foo` still works
18
+ // for trivial scalar writes without shell-escaping quotes.
19
+ return raw;
20
+ }
21
+ }
22
+
23
+ export function registerProductPropertyAdminCommands(product) {
24
+ product
25
+ .command('property-list <productId>')
26
+ .helpGroup('Product config:')
27
+ .description('List the names of properties attached to a product')
28
+ .option('-j, --json', 'Output as JSON')
29
+ .action(async (productId, opts, cmd) => {
30
+ applyJsonFlag(opts);
31
+ ensureConfigured();
32
+ const user = getGlobalUser(cmd);
33
+ try {
34
+ const list = await getProductProperties(productId, user);
35
+ const names = Array.isArray(list) ? list.map((p) => p.property).filter(Boolean) : [];
36
+ if (isJsonMode()) {
37
+ printOk({ product: productId, properties: names });
38
+ return;
39
+ }
40
+ if (names.length === 0) {
41
+ console.log(`No properties on product ${info(productId)}`);
42
+ return;
43
+ }
44
+ console.log(`Properties of product ${info(productId)}:`);
45
+ for (const n of names) console.log(`- ${n}`);
46
+ } catch (error) {
47
+ const { message, code } = classifyError(error);
48
+ printErr(message, { code });
49
+ }
50
+ });
51
+
52
+ product
53
+ .command('property-get <productId> <propertyId>')
54
+ .helpGroup('Product config:')
55
+ .description('Read a property attached to a product')
56
+ .option('-j, --json', 'Output as JSON')
57
+ .action(async (productId, propertyId, opts, cmd) => {
58
+ applyJsonFlag(opts);
59
+ ensureConfigured();
60
+ const user = getGlobalUser(cmd);
61
+ try {
62
+ const value = await getProductProperty(productId, propertyId, user);
63
+ if (isJsonMode()) {
64
+ printOk({ product: productId, property: propertyId, value });
65
+ return;
66
+ }
67
+ console.log(`Value of ${info(propertyId)} on product ${info(productId)}:`);
68
+ console.log(JSON.stringify(value, null, 2));
69
+ } catch (error) {
70
+ const { message, code } = classifyError(error);
71
+ printErr(message, { code });
72
+ }
73
+ });
74
+
75
+ product
76
+ .command('property-set <productId> <propertyId> <value>')
77
+ .helpGroup('Product config:')
78
+ .description('Create or overwrite a product property (value parsed as JSON)')
79
+ .option('-j, --json', 'Output as JSON')
80
+ .action(async (productId, propertyId, value, opts, cmd) => {
81
+ applyJsonFlag(opts);
82
+ ensureConfigured();
83
+ const user = getGlobalUser(cmd);
84
+ const parsed = parseJsonValue(value);
85
+ try {
86
+ const saved = await setProductProperty(productId, propertyId, parsed, user);
87
+ if (isJsonMode()) {
88
+ printOk({ product: productId, property: propertyId, value: saved.value });
89
+ return;
90
+ }
91
+ console.log(
92
+ `Saved property ${info(propertyId)} on product ${info(productId)} → ${success(JSON.stringify(saved.value))}`,
93
+ );
94
+ } catch (error) {
95
+ const { message, code } = classifyError(error);
96
+ printErr(message, { code });
97
+ }
98
+ });
99
+
100
+ product
101
+ .command('property-delete <productId> <propertyId>')
102
+ .helpGroup('Product config:')
103
+ .description('Delete a product property (idempotent)')
104
+ .option('-j, --json', 'Output as JSON')
105
+ .action(async (productId, propertyId, opts, cmd) => {
106
+ applyJsonFlag(opts);
107
+ ensureConfigured();
108
+ const user = getGlobalUser(cmd);
109
+ try {
110
+ const removed = await deleteProductProperty(productId, propertyId, user);
111
+ if (isJsonMode()) {
112
+ printOk({ product: productId, property: propertyId, removed });
113
+ return;
114
+ }
115
+ console.log(
116
+ removed
117
+ ? `Deleted ${info(propertyId)} from ${info(productId)}`
118
+ : `Property ${info(propertyId)} was not set on ${info(productId)}`,
119
+ );
120
+ } catch (error) {
121
+ const { message, code } = classifyError(error);
122
+ printErr(message, { code });
123
+ }
124
+ });
125
+ }
@@ -0,0 +1,184 @@
1
+ // @ts-check
2
+ import { readFile as readLocalFile } from 'fs/promises';
3
+ import Table from 'cli-table3';
4
+ import { createDeviceAPI } from '../../lib/device-api.js';
5
+ import { runProductFanOut } from '../../lib/product-orchestrator.js';
6
+ import {
7
+ label,
8
+ muted,
9
+ hint,
10
+ success,
11
+ error as errorStyle,
12
+ warning,
13
+ } from '../../lib/format.js';
14
+ import {
15
+ isJsonMode,
16
+ printOk,
17
+ printErr,
18
+ createSpinner,
19
+ classifyError,
20
+ } from '../../lib/output.js';
21
+ import {
22
+ applyJsonFlag,
23
+ ensureConfigured,
24
+ getGlobalUser,
25
+ parsePositiveInt,
26
+ ProgressSpinner,
27
+ } from '../_shared.js';
28
+
29
+ export function registerProductPushCommand(product) {
30
+ product
31
+ .command('push <productId> <localPath> <remotePath>')
32
+ .helpGroup('Filesystem:')
33
+ .description('Upload a local file to every active device of a product, in parallel')
34
+ .option('-j, --json', 'Output as JSON')
35
+ .option('-g, --group <group>', 'Filter devices by asset group')
36
+ .option(
37
+ '-c, --concurrency <n>',
38
+ 'Max parallel uploads (default: 10)',
39
+ parsePositiveInt('concurrency'),
40
+ 10,
41
+ )
42
+ .option('--fail-fast', 'Stop dequeueing new devices on the first failure')
43
+ .option('-a, --all', 'Include offline devices (by default, only active)')
44
+ .action(async (productId, localPath, remotePath, opts, cmd) => {
45
+ applyJsonFlag(opts);
46
+ ensureConfigured();
47
+ const user = getGlobalUser(cmd);
48
+
49
+ let content;
50
+ try {
51
+ content = await readLocalFile(localPath);
52
+ } catch (err) {
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ printErr(`Cannot read local file: ${msg}`, { code: 'input_error' });
55
+ return;
56
+ }
57
+
58
+ const fetchSpinner = createSpinner(
59
+ `Retrieving devices for product ${productId}...`,
60
+ ).start();
61
+ /** @type {ProgressSpinner | null} */
62
+ let progress = null;
63
+
64
+ let result;
65
+ try {
66
+ result = await runProductFanOut({
67
+ product: productId,
68
+ group: opts.group,
69
+ includeOffline: !!opts.all,
70
+ user,
71
+ concurrency: opts.concurrency,
72
+ failFast: !!opts.failFast,
73
+ worker: async (device) => {
74
+ const api = createDeviceAPI(device.device, { user });
75
+ const perStart = Date.now();
76
+ try {
77
+ await api.writeFile(remotePath, content);
78
+ return {
79
+ device: device.device,
80
+ ok: true,
81
+ bytes: content.byteLength,
82
+ durationMs: Date.now() - perStart,
83
+ };
84
+ } catch (err) {
85
+ return {
86
+ device: device.device,
87
+ ok: false,
88
+ error: err instanceof Error ? err.message : String(err),
89
+ durationMs: Date.now() - perStart,
90
+ };
91
+ }
92
+ },
93
+ skipped: (device, firstFailure) => ({
94
+ device: device.device,
95
+ ok: false,
96
+ error: `skipped (fail-fast after ${firstFailure})`,
97
+ durationMs: 0,
98
+ }),
99
+ isFailure: (entry) => !entry.ok,
100
+ onDevicesResolved: (devices) => {
101
+ fetchSpinner.succeed(
102
+ `Found ${devices.length} device(s) in product ${productId}`,
103
+ );
104
+ if (devices.length > 0 && !isJsonMode()) {
105
+ progress = new ProgressSpinner(
106
+ createSpinner('').start(),
107
+ devices.length,
108
+ ({ done, total, inFlight }) =>
109
+ `Uploading ${localPath} → ${remotePath} on ${total} device(s) — ` +
110
+ `${done}/${total} done · ${inFlight} in flight` +
111
+ (opts.failFast ? ' · fail-fast' : ''),
112
+ );
113
+ progress.render();
114
+ }
115
+ },
116
+ onItemStart: () => progress?.startItem(),
117
+ onItemFinish: () => progress?.finishItem(),
118
+ });
119
+ } catch (error) {
120
+ fetchSpinner.fail(`Failed to retrieve devices for product ${productId}`);
121
+ const { message, code } = classifyError(error);
122
+ printErr(message, { code });
123
+ return;
124
+ }
125
+ progress?.stop();
126
+
127
+ const { devices, entries, durationMs } = result;
128
+
129
+ if (devices.length === 0) {
130
+ if (isJsonMode()) {
131
+ printOk({
132
+ product: productId,
133
+ local: localPath,
134
+ remote: remotePath,
135
+ summary: { total: 0, ok: 0, failed: 0 },
136
+ results: [],
137
+ });
138
+ } else {
139
+ console.log(warning('No devices to upload to.'));
140
+ }
141
+ return;
142
+ }
143
+
144
+ const okCount = entries.filter((e) => e.ok).length;
145
+ const failCount = entries.length - okCount;
146
+
147
+ if (isJsonMode()) {
148
+ printOk({
149
+ product: productId,
150
+ local: localPath,
151
+ remote: remotePath,
152
+ summary: {
153
+ total: entries.length,
154
+ ok: okCount,
155
+ failed: failCount,
156
+ bytes: content.byteLength,
157
+ durationMs,
158
+ },
159
+ results: entries,
160
+ });
161
+ return;
162
+ }
163
+
164
+ const table = new Table({
165
+ head: ['Device', 'Status', 'Detail', 'Duration'].map((h) => label(h)),
166
+ style: { head: [], border: ['gray'] },
167
+ colAligns: ['left', 'left', 'left', 'right'],
168
+ });
169
+ for (const e of entries) {
170
+ table.push([
171
+ e.device,
172
+ e.ok ? success('ok') : errorStyle('fail'),
173
+ e.ok ? muted(`${e.bytes} bytes`) : e.error || '',
174
+ `${e.durationMs}ms`,
175
+ ]);
176
+ }
177
+ console.log(table.toString());
178
+ console.log(
179
+ hint(
180
+ `${entries.length} total · ${okCount} ok · ${failCount} failed · ${content.byteLength} bytes · ${durationMs}ms`,
181
+ ),
182
+ );
183
+ });
184
+ }
@@ -0,0 +1,76 @@
1
+ // @ts-check
2
+ import { callDeviceResource } from '../../lib/resource.js';
3
+ import { filterActiveDevices } from '../../lib/devices.js';
4
+ import { error as errorStyle, info } from '../../lib/format.js';
5
+ import {
6
+ isJsonMode,
7
+ printOk,
8
+ printErr,
9
+ createSpinner,
10
+ classifyError,
11
+ } from '../../lib/output.js';
12
+ import {
13
+ applyJsonFlag,
14
+ collectInput,
15
+ ensureConfigured,
16
+ extractField,
17
+ getGlobalUser,
18
+ } from '../_shared.js';
19
+ import { fetchProductDevices } from './_shared.js';
20
+
21
+ export function registerProductResourceCommand(product) {
22
+ product
23
+ .command('resource <productId> <resource>')
24
+ .helpGroup('Fan-out:')
25
+ .description('Call a resource on every active device of the product')
26
+ .option('-j, --json', 'Output as JSON')
27
+ .option('-f, --field <field>', 'Extract a sub-field from each result (dot path)')
28
+ .option('-g, --group <group>', 'Filter devices by asset group')
29
+ .option('-i, --input <key=value>', 'Resource input (repeatable)', collectInput, {})
30
+ .action(async (productId, resource, opts, cmd) => {
31
+ applyJsonFlag(opts);
32
+ ensureConfigured();
33
+ const user = getGlobalUser(cmd);
34
+ const spinner = createSpinner(`Retrieving devices for product ${productId}...`).start();
35
+ let devices;
36
+ try {
37
+ devices = await fetchProductDevices(productId, opts.group, user);
38
+ spinner.succeed(`Devices for product ${productId} retrieved successfully`);
39
+ } catch (error) {
40
+ spinner.fail(`Failed to retrieve devices for product ${productId}`);
41
+ const { message, code } = classifyError(error);
42
+ printErr(message, { code });
43
+ return;
44
+ }
45
+ devices = filterActiveDevices(devices);
46
+
47
+ const results = [];
48
+ for (const device of devices) {
49
+ try {
50
+ const result = await callDeviceResource(device.device, resource, opts.input);
51
+ const value = extractField(result, opts.field);
52
+ results.push({ device: device.device, ok: true, data: value });
53
+ if (!isJsonMode()) {
54
+ console.log(
55
+ 'Device',
56
+ info(device.device),
57
+ 'resource',
58
+ info(resource),
59
+ );
60
+ console.log(value);
61
+ }
62
+ } catch (error) {
63
+ const { message, code } = classifyError(error);
64
+ results.push({ device: device.device, ok: false, error: { message, code } });
65
+ if (!isJsonMode()) {
66
+ console.error(
67
+ errorStyle(
68
+ `Error executing resource ${resource} for device ${device.device}: ${message}`,
69
+ ),
70
+ );
71
+ }
72
+ }
73
+ }
74
+ if (isJsonMode()) printOk({ product: productId, resource, results });
75
+ });
76
+ }
@@ -0,0 +1,213 @@
1
+ // @ts-check
2
+ import Table from 'cli-table3';
3
+ import { InvalidArgumentError } from 'commander';
4
+ import { runPool } from '../../lib/concurrency.js';
5
+ import { getMonitoringData } from '../../lib/monitoring.js';
6
+ import {
7
+ formatUptime,
8
+ colorPct,
9
+ label,
10
+ muted,
11
+ hint,
12
+ success,
13
+ error as errorStyle,
14
+ } from '../../lib/format.js';
15
+ import {
16
+ isJsonMode,
17
+ printOk,
18
+ printErr,
19
+ createSpinner,
20
+ classifyError,
21
+ } from '../../lib/output.js';
22
+ import {
23
+ applyJsonFlag,
24
+ ensureConfigured,
25
+ getGlobalUser,
26
+ parsePositiveInt,
27
+ } from '../_shared.js';
28
+ import { fetchProductDevices } from './_shared.js';
29
+
30
+ export function registerProductStatusCommand(product) {
31
+ product
32
+ .command('status <productId>')
33
+ .helpGroup('Discovery:')
34
+ .description('Snapshot of every device in the product (status + monitoring)')
35
+ .option('-j, --json', 'Output as JSON')
36
+ .option('-g, --group <group>', 'Filter devices by asset group')
37
+ .option(
38
+ '-c, --concurrency <n>',
39
+ 'Max parallel monitoring queries (default: 10)',
40
+ parsePositiveInt('concurrency'),
41
+ 10,
42
+ )
43
+ .option(
44
+ '-w, --watch [seconds]',
45
+ 'Refresh continuously every N seconds (default: 5)',
46
+ )
47
+ .action(async (productId, opts, cmd) => {
48
+ applyJsonFlag(opts);
49
+ ensureConfigured();
50
+ const user = getGlobalUser(cmd);
51
+
52
+ // Parse the optional value of --watch. Commander delivers `true`
53
+ // when the flag is present without a value, the string value
54
+ // when one is given, and `undefined` when absent.
55
+ let watchSeconds = 0;
56
+ if (opts.watch !== undefined) {
57
+ if (opts.watch === true) watchSeconds = 5;
58
+ else {
59
+ const n = Number(opts.watch);
60
+ if (!Number.isInteger(n) || n <= 0) {
61
+ throw new InvalidArgumentError(
62
+ 'watch interval must be a positive integer (seconds)',
63
+ );
64
+ }
65
+ watchSeconds = n;
66
+ }
67
+ }
68
+
69
+ // Snapshot a single pass: fetch devices, pull latest monitoring
70
+ // for each, and return parallel arrays the renderer can consume.
71
+ const collect = async () => {
72
+ const devices = await fetchProductDevices(productId, opts.group, user);
73
+ const monResults = await runPool(devices, opts.concurrency, async (device) => {
74
+ if (!device.connection?.active) return null;
75
+ try {
76
+ const data = await getMonitoringData({
77
+ device: device.device,
78
+ user,
79
+ items: 1,
80
+ sort: 'desc',
81
+ });
82
+ return Array.isArray(data) && data.length > 0 ? data[0] : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ });
87
+ return devices.map((device, i) => {
88
+ const mon = monResults[i] && monResults[i].ok ? monResults[i].value : null;
89
+ return { device, mon };
90
+ });
91
+ };
92
+
93
+ const render = (rows) => {
94
+ if (isJsonMode()) {
95
+ printOk({
96
+ product: productId,
97
+ ts: Date.now(),
98
+ devices: rows.map(({ device, mon }) => ({
99
+ device: device.device,
100
+ name: device.name || null,
101
+ online: !!device.connection?.active,
102
+ ip: device.connection?.ip_address || null,
103
+ monitoring: mon,
104
+ })),
105
+ });
106
+ return;
107
+ }
108
+ const table = new Table({
109
+ head: ['Device', 'Status', 'CPU', 'Mem', 'Disk', 'Load', 'Uptime'].map((h) =>
110
+ label(h),
111
+ ),
112
+ style: { head: [], border: ['gray'] },
113
+ colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right'],
114
+ });
115
+ for (const { device, mon } of rows) {
116
+ const online = !!device.connection?.active;
117
+ const status = online ? success('online') : muted('offline');
118
+ if (online && mon) {
119
+ table.push([
120
+ device.device,
121
+ status,
122
+ colorPct(mon.cpu?.usage),
123
+ colorPct(mon.memory?.usage),
124
+ colorPct(mon.disk?.root?.usage),
125
+ mon.load?.['1m'] != null
126
+ ? mon.load['1m'].toFixed(2)
127
+ : muted('—'),
128
+ formatUptime(mon.uptime),
129
+ ]);
130
+ } else {
131
+ const dim = muted('—');
132
+ table.push([device.device, status, dim, dim, dim, dim, dim]);
133
+ }
134
+ }
135
+ const header = watchSeconds
136
+ ? hint(
137
+ `${productId} · refreshed ${new Date().toLocaleTimeString()} · every ${watchSeconds}s · Ctrl+C to stop`,
138
+ )
139
+ : hint(`${productId} · ${new Date().toLocaleTimeString()}`);
140
+ console.log(header);
141
+ console.log(table.toString());
142
+ const onlineCount = rows.filter((r) => r.device.connection?.active).length;
143
+ console.log(
144
+ hint(`${rows.length} total · ${onlineCount} online · ${rows.length - onlineCount} offline`),
145
+ );
146
+ };
147
+
148
+ // In watch mode + TTY, switch to the terminal's alternate
149
+ // screen buffer (`\x1b[?1049h`) so updates redraw in place
150
+ // without piling up in the scrollback. On exit we restore
151
+ // the main buffer (`\x1b[?1049l`) and the user's prompt
152
+ // reappears with their previous history intact — same
153
+ // pattern `htop`, `vim` and `less` use.
154
+ const isTty = !!process.stdout.isTTY;
155
+ const useAltScreen = !!watchSeconds && isTty && !isJsonMode();
156
+ const enterAltScreen = () => process.stdout.write('\x1b[?1049h\x1b[H');
157
+ const exitAltScreen = () => process.stdout.write('\x1b[?1049l');
158
+ const clearScreen = () => process.stdout.write('\x1b[H\x1b[2J');
159
+
160
+ // First pass — always. The spinner runs in the main buffer so
161
+ // the user sees progress while we fetch devices + monitoring
162
+ // in parallel; once data is ready, we (optionally) switch to
163
+ // the alternate screen and draw the table from there.
164
+ let rows;
165
+ const spinner = createSpinner(
166
+ `Fetching status for devices in product ${productId}...`,
167
+ ).start();
168
+ try {
169
+ rows = await collect();
170
+ spinner.stop();
171
+ } catch (error) {
172
+ spinner.fail(`Failed to fetch status for product ${productId}`);
173
+ const { message, code } = classifyError(error);
174
+ printErr(message, { code });
175
+ return;
176
+ }
177
+
178
+ if (useAltScreen) {
179
+ enterAltScreen();
180
+ // Restore the main screen on any path out, including
181
+ // uncaught errors. `once` avoids double-emitting the
182
+ // escape sequence if the process exits multiple times.
183
+ process.once('exit', exitAltScreen);
184
+ }
185
+
186
+ render(rows);
187
+
188
+ if (!watchSeconds) return;
189
+
190
+ // Watch loop. Redraw on each tick; clear in-buffer when
191
+ // we're on the alternate screen, and just append when
192
+ // stdout isn't a TTY (piped) — useful for logging.
193
+ let stopped = false;
194
+ process.on('SIGINT', () => {
195
+ stopped = true;
196
+ if (useAltScreen) exitAltScreen();
197
+ console.log('Watch stopped.');
198
+ process.exit(0);
199
+ });
200
+ while (!stopped) {
201
+ await new Promise((r) => setTimeout(r, watchSeconds * 1000));
202
+ if (stopped) break;
203
+ try {
204
+ const rows = await collect();
205
+ if (useAltScreen) clearScreen();
206
+ render(rows);
207
+ } catch (error) {
208
+ const { message, code } = classifyError(error);
209
+ console.error(errorStyle(`[watch] ${code}: ${message}`));
210
+ }
211
+ }
212
+ });
213
+ }