@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
@@ -1,48 +1,31 @@
1
1
  // @ts-check
2
- import chalk from 'chalk';
3
- import { InvalidArgumentError } from 'commander';
4
- import { configExists } from '../../lib/config.js';
5
- import { setJsonMode, classifyError, printErr } from '../../lib/output.js';
2
+ import { classifyError } from '../../lib/output.js';
3
+ import { error as errorStyle } from '../../lib/format.js';
6
4
 
7
- export function ensureConfigured() {
8
- if (!configExists()) {
9
- printErr('Not configured. Run thinr without parameters to set up.', {
10
- code: 'not_configured',
11
- });
12
- }
13
- }
14
-
15
- export function applyJsonFlag(opts) {
16
- if (opts.json) setJsonMode(true);
17
- }
18
-
19
- // Walks up to the root program so subcommands can read `--user` regardless
20
- // of nesting depth.
21
- export function getGlobalUser(cmd) {
22
- let root = cmd;
23
- while (root.parent) root = root.parent;
24
- return root.opts().user || null;
25
- }
26
-
27
- // Collect repeatable `-i key=value` pairs into a single object. Uses
28
- // Commander's InvalidArgumentError so a bad value prints a clean
29
- // "error: option … argument is invalid" line instead of a stack trace.
30
- export function collectInput(value, previous = {}) {
31
- const idx = value.indexOf('=');
32
- if (idx === -1) throw new InvalidArgumentError('must be key=value');
33
- return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
34
- }
5
+ export {
6
+ ensureConfigured,
7
+ applyJsonFlag,
8
+ getGlobalUser,
9
+ parsePositiveInt,
10
+ collectKeyValue,
11
+ collectInput,
12
+ collectVar,
13
+ extractField,
14
+ ProgressSpinner,
15
+ runDeviceCommand,
16
+ } from '../_shared.js';
35
17
 
36
18
  // Wrapper for the proxy / console handlers, which return a Promise that
37
19
  // resolves with an exit code or rejects with a tagged Error (instead of
38
- // calling process.exit themselves).
20
+ // calling process.exit themselves). Kept local to `device/` because only
21
+ // the interactive device subcommands use it.
39
22
  export const runInteractive = async (fn) => {
40
23
  try {
41
24
  const exitCode = await fn();
42
25
  process.exit(exitCode ?? 0);
43
26
  } catch (error) {
44
27
  const { message, code } = classifyError(error);
45
- console.error(chalk.red(`Error [${code}]: ${message}`));
28
+ console.error(errorStyle(`Error [${code}]: ${message}`));
46
29
  process.exit(1);
47
30
  }
48
31
  };
@@ -5,6 +5,7 @@ import { ensureConfigured, runInteractive } from './_shared.js';
5
5
  export function registerConsoleCommand(device) {
6
6
  device
7
7
  .command('console <deviceId>')
8
+ .helpGroup('Remote access:')
8
9
  .description('Open an interactive terminal on the device')
9
10
  .action((deviceId) =>
10
11
  runInteractive(() => {
@@ -0,0 +1,53 @@
1
+ // @ts-check
2
+ import { confirm } from '@inquirer/prompts';
3
+ import { deleteDevice } from '../../lib/devices.js';
4
+ import { isJsonMode, printOk, printErr, classifyError } from '../../lib/output.js';
5
+ import { hint, info } from '../../lib/format.js';
6
+ import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
7
+
8
+ export function registerDeleteCommand(device) {
9
+ device
10
+ .command('delete <deviceId>')
11
+ .helpGroup('State & resources:')
12
+ .description('Delete a device record from the platform (irreversible)')
13
+ .option('-y, --yes', 'Skip the interactive confirmation prompt')
14
+ .option('-j, --json', 'Output as JSON')
15
+ .action(async (deviceId, opts, cmd) => {
16
+ applyJsonFlag(opts);
17
+ ensureConfigured();
18
+ const user = getGlobalUser(cmd);
19
+
20
+ if (!opts.yes && !isJsonMode()) {
21
+ if (!process.stdin.isTTY) {
22
+ printErr('Refusing to delete without --yes in a non-interactive shell', {
23
+ code: 'needs_confirm',
24
+ });
25
+ return;
26
+ }
27
+ const ok = await confirm({
28
+ message: `Delete device ${deviceId}? This cannot be undone.`,
29
+ default: false,
30
+ });
31
+ if (!ok) {
32
+ console.log(hint('Cancelled.'));
33
+ return;
34
+ }
35
+ }
36
+
37
+ try {
38
+ const removed = await deleteDevice(deviceId, user);
39
+ if (isJsonMode()) {
40
+ printOk({ device: deviceId, removed });
41
+ return;
42
+ }
43
+ console.log(
44
+ removed
45
+ ? `Deleted ${info(deviceId)}`
46
+ : `Device ${info(deviceId)} did not exist`,
47
+ );
48
+ } catch (error) {
49
+ const { message, code } = classifyError(error);
50
+ printErr(message, { code });
51
+ }
52
+ });
53
+ }
@@ -1,12 +1,13 @@
1
1
  // @ts-check
2
- import chalk from 'chalk';
3
2
  import { createDeviceAPI } from '../../lib/device-api.js';
4
3
  import { isJsonMode, printOk, printErr, classifyError } from '../../lib/output.js';
4
+ import { error as errorStyle, warning } from '../../lib/format.js';
5
5
  import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
6
6
 
7
7
  export function registerExecCommand(device) {
8
8
  device
9
9
  .command('exec <deviceId> <command...>')
10
+ .helpGroup('Execution:')
10
11
  .description('Execute a shell command on the device')
11
12
  .option('-j, --json', 'Buffer stdout/stderr and emit a single JSON envelope')
12
13
  .option('--legacy', 'Use the non-streaming one-shot API (older agents)')
@@ -52,10 +53,10 @@ export function registerExecCommand(device) {
52
53
  });
53
54
  process.off('SIGINT', onSigint);
54
55
  if (timedOut) {
55
- console.error(chalk.red('\nError: command timed out on the device.'));
56
+ console.error(errorStyle('\nError: command timed out on the device.'));
56
57
  }
57
58
  if (cancelled) {
58
- console.error(chalk.yellow('\n[interrupted]'));
59
+ console.error(warning('\n[interrupted]'));
59
60
  process.exit(130);
60
61
  }
61
62
  process.exit(exitCode ?? 1);
@@ -65,10 +66,10 @@ export function registerExecCommand(device) {
65
66
  // Human path: prefix `[code]` matches the MCP error format
66
67
  // and the rest of the CLI; the response dump only surfaces
67
68
  // when the failure carries an HTTP body.
68
- console.error(chalk.red(`Error [${code}]: ${message}`));
69
+ console.error(errorStyle(`Error [${code}]: ${message}`));
69
70
  if (error.response) {
70
- console.error(chalk.red(`Status: ${error.response.status}`));
71
- console.error(chalk.red(`Body: ${JSON.stringify(error.response.data)}`));
71
+ console.error(errorStyle(`Status: ${error.response.status}`));
72
+ console.error(errorStyle(`Body: ${JSON.stringify(error.response.data)}`));
72
73
  }
73
74
  process.exit(1);
74
75
  }
@@ -0,0 +1,193 @@
1
+ // @ts-check
2
+ import { createDeviceAPI } from '../../lib/device-api.js';
3
+ import { isJsonMode, printOk, printErr, createSpinner, classifyError } from '../../lib/output.js';
4
+ import { success, hint, muted, label } from '../../lib/format.js';
5
+ import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
6
+
7
+ /**
8
+ * Plain filesystem operations on a single device, mirroring the
9
+ * MCP filesystem tools with CLI-native verbs. Two semantic families:
10
+ * · read / write — inline content (strings, small files)
11
+ * · push / pull — file transfer with progress (in transfer.js)
12
+ * Plus ls, mkdir, rm, mv for directory housekeeping.
13
+ */
14
+ export function registerFsCommands(device) {
15
+ device
16
+ .command('ls <deviceId> [path]')
17
+ .helpGroup('Filesystem:')
18
+ .description('List a directory on the device')
19
+ .option('-j, --json', 'Output as JSON')
20
+ .option('-a, --all', 'Include hidden entries (dotfiles)')
21
+ .action(async (deviceId, path, opts, cmd) => {
22
+ applyJsonFlag(opts);
23
+ ensureConfigured();
24
+ const user = getGlobalUser(cmd);
25
+ const target = path || '/';
26
+ const spinner = createSpinner(`Listing ${deviceId}:${target}...`).start();
27
+ try {
28
+ const api = createDeviceAPI(deviceId, { user });
29
+ const entries = await api.listDir(target, !!opts.all);
30
+ spinner.succeed(`${deviceId}:${target} (${entries.length} entries)`);
31
+ if (isJsonMode()) {
32
+ printOk(entries);
33
+ return;
34
+ }
35
+ for (const e of entries) {
36
+ const kind = e.type === 'directory' ? label('d') : muted('-');
37
+ const mode = e.mode || 'rwxr-xr-x';
38
+ const size = (e.size || 0).toString().padStart(10);
39
+ console.log(` ${kind}${mode} ${hint(size)} ${e.name}`);
40
+ }
41
+ } catch (err) {
42
+ spinner.fail('List failed');
43
+ const { message, code } = classifyError(err);
44
+ printErr(message, { code });
45
+ }
46
+ });
47
+
48
+ device
49
+ .command('read <deviceId> <path>')
50
+ .alias('cat')
51
+ .helpGroup('Filesystem:')
52
+ .description('Print the contents of a remote file to stdout')
53
+ .option('-j, --json', 'Output as JSON')
54
+ .action(async (deviceId, path, opts, cmd) => {
55
+ applyJsonFlag(opts);
56
+ ensureConfigured();
57
+ const user = getGlobalUser(cmd);
58
+ try {
59
+ const api = createDeviceAPI(deviceId, { user });
60
+ const buf = await api.readFile(path);
61
+ const text = buf.toString('utf8');
62
+ if (isJsonMode()) {
63
+ printOk({ device: deviceId, path, bytes: buf.byteLength, content: text });
64
+ return;
65
+ }
66
+ process.stdout.write(text);
67
+ if (text && !text.endsWith('\n')) process.stdout.write('\n');
68
+ } catch (err) {
69
+ const { message, code } = classifyError(err);
70
+ printErr(message, { code });
71
+ }
72
+ });
73
+
74
+ device
75
+ .command('write <deviceId> <path> [content]')
76
+ .helpGroup('Filesystem:')
77
+ .description(
78
+ 'Write inline content to a remote file (use `push` for large local files). Reads from stdin when <content> is omitted.',
79
+ )
80
+ .option('-j, --json', 'Output as JSON')
81
+ .action(async (deviceId, path, content, opts, cmd) => {
82
+ applyJsonFlag(opts);
83
+ ensureConfigured();
84
+ const user = getGlobalUser(cmd);
85
+ const spinner = createSpinner(`Writing ${deviceId}:${path}...`).start();
86
+ try {
87
+ let payload;
88
+ if (typeof content === 'string') {
89
+ payload = Buffer.from(content, 'utf8');
90
+ } else if (!process.stdin.isTTY) {
91
+ payload = await readAllStdin();
92
+ } else {
93
+ spinner.stop();
94
+ printErr(
95
+ 'No content given. Pass it as the third argument or pipe it on stdin.',
96
+ { code: 'input_error' },
97
+ );
98
+ return;
99
+ }
100
+ const api = createDeviceAPI(deviceId, { user });
101
+ await api.writeFile(path, payload);
102
+ spinner.succeed(success(`Wrote ${payload.byteLength} bytes to ${deviceId}:${path}`));
103
+ if (isJsonMode()) {
104
+ printOk({ device: deviceId, path, bytes: payload.byteLength });
105
+ }
106
+ } catch (err) {
107
+ spinner.fail('write failed');
108
+ const { message, code } = classifyError(err);
109
+ printErr(message, { code });
110
+ }
111
+ });
112
+
113
+ device
114
+ .command('mkdir <deviceId> <path>')
115
+ .helpGroup('Filesystem:')
116
+ .description('Create a directory on the device')
117
+ .option('-j, --json', 'Output as JSON')
118
+ .action(async (deviceId, path, opts, cmd) => {
119
+ applyJsonFlag(opts);
120
+ ensureConfigured();
121
+ const user = getGlobalUser(cmd);
122
+ const spinner = createSpinner(`Creating ${deviceId}:${path}...`).start();
123
+ try {
124
+ const api = createDeviceAPI(deviceId, { user });
125
+ await api.mkdir(path);
126
+ spinner.succeed(success(`Created ${deviceId}:${path}`));
127
+ if (isJsonMode()) printOk({ device: deviceId, path });
128
+ } catch (err) {
129
+ spinner.fail('mkdir failed');
130
+ const { message, code } = classifyError(err);
131
+ printErr(message, { code });
132
+ }
133
+ });
134
+
135
+ device
136
+ .command('rm <deviceId> <path>')
137
+ .helpGroup('Filesystem:')
138
+ .description(
139
+ 'Delete a file or directory on the device (pass --recursive for directories with contents)',
140
+ )
141
+ .option('-j, --json', 'Output as JSON')
142
+ .option('-r, --recursive', 'Recursively delete non-empty directories (default: true)', true)
143
+ .option('--no-recursive', 'Fail on non-empty directories')
144
+ .action(async (deviceId, path, opts, cmd) => {
145
+ applyJsonFlag(opts);
146
+ ensureConfigured();
147
+ const user = getGlobalUser(cmd);
148
+ const spinner = createSpinner(`Deleting ${deviceId}:${path}...`).start();
149
+ try {
150
+ const api = createDeviceAPI(deviceId, { user });
151
+ await api.delete(path, opts.recursive !== false);
152
+ spinner.succeed(success(`Deleted ${deviceId}:${path}`));
153
+ if (isJsonMode()) printOk({ device: deviceId, path });
154
+ } catch (err) {
155
+ spinner.fail('rm failed');
156
+ const { message, code } = classifyError(err);
157
+ printErr(message, { code });
158
+ }
159
+ });
160
+
161
+ device
162
+ .command('mv <deviceId> <source> <destination>')
163
+ .helpGroup('Filesystem:')
164
+ .description('Move or rename a file or directory on the device')
165
+ .option('-j, --json', 'Output as JSON')
166
+ .option('-f, --force', 'Overwrite destination if it already exists')
167
+ .action(async (deviceId, source, destination, opts, cmd) => {
168
+ applyJsonFlag(opts);
169
+ ensureConfigured();
170
+ const user = getGlobalUser(cmd);
171
+ const spinner = createSpinner(
172
+ `Moving ${deviceId}:${source} → ${destination}...`,
173
+ ).start();
174
+ try {
175
+ const api = createDeviceAPI(deviceId, { user });
176
+ await api.move(source, destination, !!opts.force);
177
+ spinner.succeed(success(`Moved ${deviceId}:${source} → ${destination}`));
178
+ if (isJsonMode()) printOk({ device: deviceId, source, destination });
179
+ } catch (err) {
180
+ spinner.fail('mv failed');
181
+ const { message, code } = classifyError(err);
182
+ printErr(message, { code });
183
+ }
184
+ });
185
+ }
186
+
187
+ async function readAllStdin() {
188
+ const chunks = [];
189
+ for await (const chunk of process.stdin) {
190
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
191
+ }
192
+ return Buffer.concat(chunks);
193
+ }
@@ -7,6 +7,10 @@ import { registerPropertyCommand } from './property.js';
7
7
  import { registerResourceCommand } from './resource.js';
8
8
  import { registerExecCommand } from './exec.js';
9
9
  import { registerUpdateCommand } from './update.js';
10
+ import { registerTransferCommands } from './transfer.js';
11
+ import { registerFsCommands } from './fs.js';
12
+ import { registerLogsCommand } from './logs.js';
13
+ import { registerDeleteCommand } from './delete.js';
10
14
 
11
15
  /**
12
16
  * `thinr device <subcommand> [<deviceId>] …` — all device-scoped actions
@@ -18,6 +22,7 @@ import { registerUpdateCommand } from './update.js';
18
22
  export function deviceCommand(program) {
19
23
  const device = program
20
24
  .command('device')
25
+ .helpGroup('Operations:')
21
26
  .description('Device commands (subcommand-first: thinr device <action> <deviceId>)');
22
27
 
23
28
  registerListCommand(device);
@@ -28,4 +33,8 @@ export function deviceCommand(program) {
28
33
  registerResourceCommand(device);
29
34
  registerExecCommand(device);
30
35
  registerUpdateCommand(device);
36
+ registerTransferCommands(device);
37
+ registerFsCommands(device);
38
+ registerLogsCommand(device);
39
+ registerDeleteCommand(device);
31
40
  }
@@ -1,12 +1,18 @@
1
1
  // @ts-check
2
- import chalk from 'chalk';
3
2
  import { getDevices } from '../../lib/devices.js';
4
- import { isJsonMode, printOk, printErr, createSpinner, classifyError } from '../../lib/output.js';
5
- import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
3
+ import { isJsonMode, printOk } from '../../lib/output.js';
4
+ import { success, hint, label } from '../../lib/format.js';
5
+ import {
6
+ applyJsonFlag,
7
+ ensureConfigured,
8
+ getGlobalUser,
9
+ runDeviceCommand,
10
+ } from './_shared.js';
6
11
 
7
12
  export function registerListCommand(device) {
8
13
  device
9
14
  .command('list [pattern]')
15
+ .helpGroup('Discovery:')
10
16
  .description(
11
17
  'List devices, optionally filtered by name/id pattern (case-insensitive regex)',
12
18
  )
@@ -16,28 +22,34 @@ export function registerListCommand(device) {
16
22
  ensureConfigured();
17
23
  const user = getGlobalUser(cmd);
18
24
  const filter = pattern ? { name: pattern } : {};
19
- const label = pattern
20
- ? `Searching devices matching "${pattern}"...`
21
- : 'Fetching devices...';
22
- const spinner = createSpinner(label).start();
23
- try {
24
- const devices = await getDevices(filter, user);
25
- spinner.succeed(`Found ${devices.length} device(s)`);
26
- if (isJsonMode()) {
27
- printOk(devices);
28
- } else {
25
+ await runDeviceCommand({
26
+ start: pattern
27
+ ? `Searching devices matching "${pattern}"...`
28
+ : 'Fetching devices...',
29
+ fn: () => getDevices(filter, user),
30
+ success: (devices) => `Found ${devices.length} device(s)`,
31
+ failure: 'Failed to list devices',
32
+ onSuccess: (devices) => {
33
+ if (isJsonMode()) {
34
+ printOk(devices);
35
+ return;
36
+ }
37
+ // Pad the device id column so trailing names line up
38
+ // even when ids have very different widths — same
39
+ // treatment as thinr product list.
40
+ const idWidth = devices.reduce(
41
+ (w, d) => Math.max(w, d.device.length),
42
+ 0,
43
+ );
29
44
  for (const d of devices) {
30
45
  const online = d.connection?.active
31
- ? chalk.green('online ')
32
- : chalk.gray('offline');
33
- const name = d.name ? chalk.gray(` (${d.name})`) : '';
34
- console.log(` ${online} ${chalk.bold(d.device)}${name}`);
46
+ ? success('online ')
47
+ : hint('offline');
48
+ const name = d.name ? hint(`(${d.name})`) : '';
49
+ const id = label(d.device.padEnd(idWidth));
50
+ console.log(` ${online} ${id}${name ? ' ' + name : ''}`);
35
51
  }
36
- }
37
- } catch (error) {
38
- spinner.fail('Failed to list devices');
39
- const { message, code } = classifyError(error);
40
- printErr(message, { code });
41
- }
52
+ },
53
+ });
42
54
  });
43
55
  }
@@ -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
+ }