@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,154 @@
1
+ // @ts-check
2
+ import { InvalidArgumentError } from 'commander';
3
+ import { configExists } from '../lib/config.js';
4
+ import {
5
+ setJsonMode,
6
+ isJsonMode,
7
+ printOk,
8
+ printErr,
9
+ createSpinner,
10
+ classifyError,
11
+ } from '../lib/output.js';
12
+
13
+ export function ensureConfigured() {
14
+ if (!configExists()) {
15
+ printErr('Not configured. Run thinr without parameters to set up.', {
16
+ code: 'not_configured',
17
+ });
18
+ }
19
+ }
20
+
21
+ export function applyJsonFlag(opts) {
22
+ if (opts.json) setJsonMode(true);
23
+ }
24
+
25
+ // Walks up to the root program so subcommands can read `--user` regardless
26
+ // of nesting depth.
27
+ export function getGlobalUser(cmd) {
28
+ let root = cmd;
29
+ while (root.parent) root = root.parent;
30
+ return root.opts().user || null;
31
+ }
32
+
33
+ export function parsePositiveInt(label) {
34
+ return (value) => {
35
+ const n = Number(value);
36
+ if (!Number.isInteger(n) || n <= 0) {
37
+ throw new InvalidArgumentError(`${label} must be a positive integer`);
38
+ }
39
+ return n;
40
+ };
41
+ }
42
+
43
+ // Collect repeatable `-i key=value` / `-v key=value` pairs into a single
44
+ // object. Uses Commander's InvalidArgumentError so a bad value prints a
45
+ // clean "error: option … argument is invalid" line instead of a stack trace.
46
+ export function collectKeyValue(value, previous = {}) {
47
+ const idx = value.indexOf('=');
48
+ if (idx === -1) throw new InvalidArgumentError('must be key=value');
49
+ return { ...previous, [value.slice(0, idx)]: value.slice(idx + 1) };
50
+ }
51
+
52
+ // Historical aliases kept so existing subcommands don't need renaming at
53
+ // the call sites. Same function, different names where it reads better.
54
+ export const collectInput = collectKeyValue;
55
+ export const collectVar = collectKeyValue;
56
+
57
+ // Resolve a dot-path against a nested object. Returns `undefined` as soon
58
+ // as any intermediate hop is missing — matching the previous inline
59
+ // `reduce((o, k) => o && o[k], obj)` behaviour across the CLI.
60
+ export function extractField(obj, path) {
61
+ if (!path) return obj;
62
+ return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
63
+ }
64
+
65
+ /**
66
+ * Live counter for fan-out spinners. Wraps an ora spinner and rewrites
67
+ * its text as work enters/leaves the pool and completes. Use it to avoid
68
+ * reimplementing the `done/in flight` bookkeeping in every fan-out
69
+ * command (product exec, product fs, product push, playbook run…).
70
+ *
71
+ * const progress = new ProgressSpinner(spinner, devices.length,
72
+ * ({ done, total, inFlight }) =>
73
+ * `Running on ${total} devices — ${done}/${total} done · ${inFlight} in flight`);
74
+ * progress.render();
75
+ * // inside worker:
76
+ * progress.startItem();
77
+ * // …
78
+ * progress.finishItem();
79
+ */
80
+ export class ProgressSpinner {
81
+ constructor(spinner, total, formatText) {
82
+ this.spinner = spinner;
83
+ this.total = total;
84
+ this.formatText = formatText;
85
+ this.done = 0;
86
+ this.inFlight = 0;
87
+ }
88
+
89
+ render() {
90
+ this.spinner.text = this.formatText({
91
+ done: this.done,
92
+ total: this.total,
93
+ inFlight: this.inFlight,
94
+ });
95
+ }
96
+
97
+ startItem() {
98
+ this.inFlight++;
99
+ this.render();
100
+ }
101
+
102
+ finishItem() {
103
+ this.inFlight--;
104
+ this.done++;
105
+ this.render();
106
+ }
107
+
108
+ stop() {
109
+ this.spinner.stop();
110
+ }
111
+ }
112
+
113
+ /**
114
+ * One-shot command skeleton shared by read-only device subcommands
115
+ * (status, list, property, resource…). Handles the spinner start,
116
+ * try/catch, `classifyError`+`printErr` on failure, and the
117
+ * `isJsonMode() ? printOk : console.log` default for the success path.
118
+ *
119
+ * Shape:
120
+ * await runDeviceCommand({
121
+ * start: 'Fetching devices…', // spinner label
122
+ * fn: () => getDevices(filter), // async unit of work
123
+ * success: n => `Found ${n} device(s)`,// string | (result) => string | null
124
+ * failure: 'Failed to list devices', // string on failure
125
+ * onSuccess: (result) => { … }, // optional full render override
126
+ * });
127
+ *
128
+ * When `success` is `null` the spinner just stops (no checkmark), which
129
+ * matches the idiom used by commands whose output block already speaks
130
+ * for the "found" state. When `onSuccess` is omitted the result is
131
+ * printed verbatim (`printOk` in JSON mode, `console.log` otherwise).
132
+ */
133
+ export async function runDeviceCommand({ start, fn, success, failure, onSuccess }) {
134
+ const spinner = createSpinner(start).start();
135
+ try {
136
+ const result = await fn();
137
+ if (success === null) {
138
+ spinner.stop();
139
+ } else {
140
+ spinner.succeed(typeof success === 'function' ? success(result) : success);
141
+ }
142
+ if (onSuccess) {
143
+ await onSuccess(result);
144
+ } else if (isJsonMode()) {
145
+ printOk(result);
146
+ } else {
147
+ console.log(result);
148
+ }
149
+ } catch (error) {
150
+ spinner.fail(typeof failure === 'function' ? failure(error) : failure);
151
+ const { message, code } = classifyError(error);
152
+ printErr(message, { code });
153
+ }
154
+ }
@@ -0,0 +1,17 @@
1
+ // @ts-check
2
+ import { ensureConfigured } from './device/_shared.js';
3
+
4
+ export async function runDashboard() {
5
+ ensureConfigured();
6
+ // React's package entry switches between react.development.js and
7
+ // react.production.js based on NODE_ENV at first load. The dev
8
+ // build has a known leak under frequent setState (facebook/react
9
+ // #34770) — heap grows from ~100MB to >1GB in minutes when WS
10
+ // events drive the UI. Force production unless the user has
11
+ // explicitly asked for the dev build (e.g. while debugging).
12
+ // Set BEFORE the dynamic import so the bundle resolves React with
13
+ // the correct env baked in.
14
+ if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production';
15
+ const { run } = await import('../dist/dashboard.js');
16
+ await run();
17
+ }
@@ -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
  }