@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.
- package/CHANGELOG.md +63 -1
- package/README.md +235 -30
- package/bin/thinr.js +43 -25
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +68 -20
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +213 -0
- package/lib/config.js +5 -3
- package/lib/console.js +19 -5
- package/lib/constants.js +83 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +131 -148
- package/lib/devices.js +18 -21
- package/lib/errors.js +12 -0
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1226 -105
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +14 -3
- 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
|
|
3
|
-
import {
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
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
|
+
}
|
package/commands/device/exec.js
CHANGED
|
@@ -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(
|
|
56
|
+
console.error(errorStyle('\nError: command timed out on the device.'));
|
|
56
57
|
}
|
|
57
58
|
if (cancelled) {
|
|
58
|
-
console.error(
|
|
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(
|
|
69
|
+
console.error(errorStyle(`Error [${code}]: ${message}`));
|
|
69
70
|
if (error.response) {
|
|
70
|
-
console.error(
|
|
71
|
-
console.error(
|
|
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
|
+
}
|
package/commands/device/index.js
CHANGED
|
@@ -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
|
}
|
package/commands/device/list.js
CHANGED
|
@@ -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
|
|
5
|
-
import {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
?
|
|
32
|
-
:
|
|
33
|
-
const name = d.name ?
|
|
34
|
-
|
|
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
|
-
}
|
|
38
|
-
spinner.fail('Failed to list devices');
|
|
39
|
-
const { message, code } = classifyError(error);
|
|
40
|
-
printErr(message, { code });
|
|
41
|
-
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
42
54
|
});
|
|
43
55
|
}
|