@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.
- package/CHANGELOG.md +71 -0
- package/README.md +230 -16
- package/bin/thinr.js +23 -24
- 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 +30 -17
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +199 -0
- package/lib/concurrency.js +52 -0
- package/lib/config.js +5 -3
- package/lib/console.js +12 -4
- package/lib/constants.js +76 -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 +109 -147
- package/lib/devices.js +18 -21
- 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 +1343 -10
- 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 +15 -3
- package/commands/product.js +0 -167
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|