@thinremote/thinr-cli 1.0.2-alpha.2 → 1.1.0-alpha.1
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 +90 -0
- package/README.md +362 -87
- package/bin/thinr.js +51 -11
- package/commands/device/_shared.js +48 -0
- package/commands/device/console.js +15 -0
- package/commands/device/exec.js +76 -0
- package/commands/device/index.js +31 -0
- package/commands/device/list.js +43 -0
- package/commands/device/property.js +50 -0
- package/commands/device/proxy.js +42 -0
- package/commands/device/resource.js +51 -0
- package/commands/device/status.js +26 -0
- package/commands/device/update.js +53 -0
- package/commands/logout.js +12 -5
- package/commands/product.js +146 -177
- package/commands/profile.js +105 -0
- package/lib/api.js +54 -31
- package/lib/auth.js +225 -309
- package/lib/config.js +176 -45
- package/lib/console.js +87 -108
- package/lib/device-api.js +257 -0
- package/lib/devices.js +69 -0
- package/lib/errors.js +53 -0
- package/lib/mcp/helpers.js +34 -0
- package/lib/mcp/registry.js +32 -0
- package/lib/mcp/server.js +63 -0
- package/lib/mcp/tools-devices.js +194 -0
- package/lib/mcp/tools-exec.js +213 -0
- package/lib/mcp/tools-filesystem.js +163 -0
- package/lib/mcp/tools-products.js +232 -0
- package/lib/mcp/tools-profiles.js +30 -0
- package/lib/mcp/tools-properties.js +79 -0
- package/lib/mcp/tools-resources.js +76 -0
- package/lib/mcp/tools-scripts.js +172 -0
- package/lib/monitoring.js +72 -0
- package/lib/output.js +114 -0
- package/lib/product.js +373 -0
- package/lib/property.js +41 -57
- package/lib/proxy.js +58 -90
- package/lib/resource.js +106 -72
- package/lib/status.js +7 -33
- package/lib/storage.js +128 -0
- package/package.json +68 -37
- package/.github/workflows/publish.yml +0 -79
- package/commands/device.js +0 -268
- package/lib/device.js +0 -76
package/bin/thinr.js
CHANGED
|
@@ -1,23 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, resolve } from 'path';
|
|
3
6
|
import { Command } from 'commander';
|
|
4
7
|
import chalk from 'chalk';
|
|
5
8
|
import figlet from 'figlet';
|
|
6
9
|
import ora from 'ora';
|
|
7
|
-
import { configExists, readConfig } from '../lib/config.js';
|
|
10
|
+
import { configExists, readConfig, setActiveProfile } from '../lib/config.js';
|
|
11
|
+
import { detectJsonModeFromArgv } from '../lib/output.js';
|
|
12
|
+
|
|
13
|
+
// Single source of truth for the version: package.json. Avoids the drift
|
|
14
|
+
// between `package.json#version` and a hardcoded string in this file.
|
|
15
|
+
const pkg = JSON.parse(
|
|
16
|
+
readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// Pick up --profile early so subsequent config reads use the right one.
|
|
20
|
+
// Commander parses options later, but the config is touched before that.
|
|
21
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
22
|
+
const a = process.argv[i];
|
|
23
|
+
if (a === '--profile' && process.argv[i + 1]) {
|
|
24
|
+
setActiveProfile(process.argv[i + 1]);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
if (a.startsWith('--profile=')) {
|
|
28
|
+
setActiveProfile(a.slice('--profile='.length));
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Set JSON mode before any command runs so spinners/logs can opt out.
|
|
34
|
+
detectJsonModeFromArgv();
|
|
8
35
|
import { authenticate } from '../lib/auth.js';
|
|
9
|
-
import { deviceCommand } from '../commands/device.js';
|
|
36
|
+
import { deviceCommand } from '../commands/device/index.js';
|
|
10
37
|
import { logoutCommand } from '../commands/logout.js';
|
|
11
|
-
import { productCommand } from
|
|
38
|
+
import { productCommand } from '../commands/product.js';
|
|
39
|
+
import { profileCommand } from '../commands/profile.js';
|
|
12
40
|
import { setBaseURL } from '../lib/api.js';
|
|
41
|
+
import { startMCPServer } from '../lib/mcp/server.js';
|
|
13
42
|
|
|
14
43
|
// Function to display the banner
|
|
15
44
|
function displayBanner() {
|
|
16
|
-
console.log(
|
|
17
|
-
chalk.cyan(
|
|
18
|
-
figlet.textSync('thinr-cli', { horizontalLayout: 'full' })
|
|
19
|
-
)
|
|
20
|
-
);
|
|
45
|
+
console.log(chalk.cyan(figlet.textSync('thinr-cli', { horizontalLayout: 'full' })));
|
|
21
46
|
console.log(chalk.blue('Thin Remote CLI - Remote management for IoT devices'));
|
|
22
47
|
console.log();
|
|
23
48
|
}
|
|
@@ -28,16 +53,31 @@ const program = new Command();
|
|
|
28
53
|
program
|
|
29
54
|
.name('thinr')
|
|
30
55
|
.description('CLI for ThinRemote - Remote management for IoT devices')
|
|
31
|
-
.version(
|
|
56
|
+
.version(pkg.version)
|
|
57
|
+
.option('-u, --user <username>', 'API user override (admin impersonation)')
|
|
58
|
+
.option('--profile <name>', 'Configuration profile to use (defaults to the saved default)');
|
|
32
59
|
|
|
33
60
|
// Register commands
|
|
34
61
|
deviceCommand(program);
|
|
35
62
|
productCommand(program);
|
|
63
|
+
profileCommand(program);
|
|
36
64
|
logoutCommand(program);
|
|
37
65
|
|
|
66
|
+
program
|
|
67
|
+
.command('mcp')
|
|
68
|
+
.description('Start the MCP server (stdio) for AI tool integration')
|
|
69
|
+
.action(async () => {
|
|
70
|
+
try {
|
|
71
|
+
await startMCPServer();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`Error: ${error.message}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
38
78
|
// Handle help
|
|
39
79
|
const originalHelp = program.help;
|
|
40
|
-
program.help = function(cb) {
|
|
80
|
+
program.help = function (cb) {
|
|
41
81
|
displayBanner();
|
|
42
82
|
return originalHelp.call(this, cb);
|
|
43
83
|
};
|
|
@@ -75,4 +115,4 @@ if (process.argv.length <= 2) {
|
|
|
75
115
|
} else {
|
|
76
116
|
// Parse arguments - don't show banner for regular commands
|
|
77
117
|
program.parse(process.argv);
|
|
78
|
-
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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';
|
|
6
|
+
|
|
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
|
+
}
|
|
35
|
+
|
|
36
|
+
// Wrapper for the proxy / console handlers, which return a Promise that
|
|
37
|
+
// resolves with an exit code or rejects with a tagged Error (instead of
|
|
38
|
+
// calling process.exit themselves).
|
|
39
|
+
export const runInteractive = async (fn) => {
|
|
40
|
+
try {
|
|
41
|
+
const exitCode = await fn();
|
|
42
|
+
process.exit(exitCode ?? 0);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const { message, code } = classifyError(error);
|
|
45
|
+
console.error(chalk.red(`Error [${code}]: ${message}`));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { connectToDeviceConsole } from '../../lib/console.js';
|
|
3
|
+
import { ensureConfigured, runInteractive } from './_shared.js';
|
|
4
|
+
|
|
5
|
+
export function registerConsoleCommand(device) {
|
|
6
|
+
device
|
|
7
|
+
.command('console <deviceId>')
|
|
8
|
+
.description('Open an interactive terminal on the device')
|
|
9
|
+
.action((deviceId) =>
|
|
10
|
+
runInteractive(() => {
|
|
11
|
+
ensureConfigured();
|
|
12
|
+
return connectToDeviceConsole(deviceId);
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { createDeviceAPI } from '../../lib/device-api.js';
|
|
4
|
+
import { isJsonMode, printOk, printErr, classifyError } from '../../lib/output.js';
|
|
5
|
+
import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
|
|
6
|
+
|
|
7
|
+
export function registerExecCommand(device) {
|
|
8
|
+
device
|
|
9
|
+
.command('exec <deviceId> <command...>')
|
|
10
|
+
.description('Execute a shell command on the device')
|
|
11
|
+
.option('-j, --json', 'Buffer stdout/stderr and emit a single JSON envelope')
|
|
12
|
+
.option('--legacy', 'Use the non-streaming one-shot API (older agents)')
|
|
13
|
+
.action(async (deviceId, commandParts, opts, cmd) => {
|
|
14
|
+
applyJsonFlag(opts);
|
|
15
|
+
ensureConfigured();
|
|
16
|
+
const user = getGlobalUser(cmd);
|
|
17
|
+
const commandStr = commandParts.join(' ');
|
|
18
|
+
try {
|
|
19
|
+
const api = createDeviceAPI(deviceId, { user });
|
|
20
|
+
if (isJsonMode()) {
|
|
21
|
+
let stdout = '';
|
|
22
|
+
let stderr = '';
|
|
23
|
+
const { exitCode, timedOut, cancelled } = await api.execStream(commandStr, {
|
|
24
|
+
onStdout: (s) => {
|
|
25
|
+
stdout += s;
|
|
26
|
+
},
|
|
27
|
+
onStderr: (s) => {
|
|
28
|
+
stderr += s;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
if (cancelled) printErr('Interrupted', { code: 'cancelled', exitCode: 130 });
|
|
32
|
+
if (timedOut) printErr('Command timed out on the device', { code: 'timeout' });
|
|
33
|
+
printOk({ stdout, stderr, exitCode: exitCode ?? null });
|
|
34
|
+
process.exit(exitCode ?? 1);
|
|
35
|
+
}
|
|
36
|
+
if (opts.legacy) {
|
|
37
|
+
const result = await api.exec(commandStr, 120);
|
|
38
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
39
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
40
|
+
process.exit(result.retcode || 0);
|
|
41
|
+
}
|
|
42
|
+
let cancelFn = null;
|
|
43
|
+
const onSigint = () => cancelFn?.();
|
|
44
|
+
process.on('SIGINT', onSigint);
|
|
45
|
+
const { exitCode, timedOut, cancelled } = await api.execStream(commandStr, {
|
|
46
|
+
onStdout: (s) => process.stdout.write(s),
|
|
47
|
+
onStderr: (s) => process.stderr.write(s),
|
|
48
|
+
onCancel: (fn) => {
|
|
49
|
+
cancelFn = fn;
|
|
50
|
+
},
|
|
51
|
+
stdin: process.stdin,
|
|
52
|
+
});
|
|
53
|
+
process.off('SIGINT', onSigint);
|
|
54
|
+
if (timedOut) {
|
|
55
|
+
console.error(chalk.red('\nError: command timed out on the device.'));
|
|
56
|
+
}
|
|
57
|
+
if (cancelled) {
|
|
58
|
+
console.error(chalk.yellow('\n[interrupted]'));
|
|
59
|
+
process.exit(130);
|
|
60
|
+
}
|
|
61
|
+
process.exit(exitCode ?? 1);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const { message, code } = classifyError(error);
|
|
64
|
+
if (isJsonMode()) printErr(message, { code });
|
|
65
|
+
// Human path: prefix `[code]` matches the MCP error format
|
|
66
|
+
// and the rest of the CLI; the response dump only surfaces
|
|
67
|
+
// when the failure carries an HTTP body.
|
|
68
|
+
console.error(chalk.red(`Error [${code}]: ${message}`));
|
|
69
|
+
if (error.response) {
|
|
70
|
+
console.error(chalk.red(`Status: ${error.response.status}`));
|
|
71
|
+
console.error(chalk.red(`Body: ${JSON.stringify(error.response.data)}`));
|
|
72
|
+
}
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { registerListCommand } from './list.js';
|
|
3
|
+
import { registerProxyCommands } from './proxy.js';
|
|
4
|
+
import { registerConsoleCommand } from './console.js';
|
|
5
|
+
import { registerStatusCommand } from './status.js';
|
|
6
|
+
import { registerPropertyCommand } from './property.js';
|
|
7
|
+
import { registerResourceCommand } from './resource.js';
|
|
8
|
+
import { registerExecCommand } from './exec.js';
|
|
9
|
+
import { registerUpdateCommand } from './update.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `thinr device <subcommand> [<deviceId>] …` — all device-scoped actions
|
|
13
|
+
* as proper Commander subcommands. Most subcommands declare `<deviceId>`
|
|
14
|
+
* as their first positional so help, option parsing and validation are
|
|
15
|
+
* autogenerated per-action; `list` is the exception (it operates on the
|
|
16
|
+
* fleet, not a single device).
|
|
17
|
+
*/
|
|
18
|
+
export function deviceCommand(program) {
|
|
19
|
+
const device = program
|
|
20
|
+
.command('device')
|
|
21
|
+
.description('Device commands (subcommand-first: thinr device <action> <deviceId>)');
|
|
22
|
+
|
|
23
|
+
registerListCommand(device);
|
|
24
|
+
registerProxyCommands(device);
|
|
25
|
+
registerConsoleCommand(device);
|
|
26
|
+
registerStatusCommand(device);
|
|
27
|
+
registerPropertyCommand(device);
|
|
28
|
+
registerResourceCommand(device);
|
|
29
|
+
registerExecCommand(device);
|
|
30
|
+
registerUpdateCommand(device);
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
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';
|
|
6
|
+
|
|
7
|
+
export function registerListCommand(device) {
|
|
8
|
+
device
|
|
9
|
+
.command('list [pattern]')
|
|
10
|
+
.description(
|
|
11
|
+
'List devices, optionally filtered by name/id pattern (case-insensitive regex)',
|
|
12
|
+
)
|
|
13
|
+
.option('-j, --json', 'Output as JSON')
|
|
14
|
+
.action(async (pattern, opts, cmd) => {
|
|
15
|
+
applyJsonFlag(opts);
|
|
16
|
+
ensureConfigured();
|
|
17
|
+
const user = getGlobalUser(cmd);
|
|
18
|
+
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 {
|
|
29
|
+
for (const d of devices) {
|
|
30
|
+
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}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
spinner.fail('Failed to list devices');
|
|
39
|
+
const { message, code } = classifyError(error);
|
|
40
|
+
printErr(message, { code });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import {
|
|
3
|
+
formatDeviceProperty,
|
|
4
|
+
getDeviceProperty,
|
|
5
|
+
getDeviceProperties,
|
|
6
|
+
formatDeviceProperties,
|
|
7
|
+
} from '../../lib/property.js';
|
|
8
|
+
import { isJsonMode, printOk, printErr, createSpinner, classifyError } from '../../lib/output.js';
|
|
9
|
+
import { applyJsonFlag, ensureConfigured } from './_shared.js';
|
|
10
|
+
|
|
11
|
+
export function registerPropertyCommand(device) {
|
|
12
|
+
device
|
|
13
|
+
.command('property <deviceId> [propertyId]')
|
|
14
|
+
.description('List device properties, or read one by ID')
|
|
15
|
+
.option('-j, --json', 'Output as JSON')
|
|
16
|
+
.option('-f, --field <field>', 'Extract a sub-field (dot path, e.g. data.value)')
|
|
17
|
+
.action(async (deviceId, propertyId, opts) => {
|
|
18
|
+
applyJsonFlag(opts);
|
|
19
|
+
ensureConfigured();
|
|
20
|
+
if (!propertyId) {
|
|
21
|
+
const spinner = createSpinner(`Getting properties for ${deviceId}...`).start();
|
|
22
|
+
try {
|
|
23
|
+
const result = await getDeviceProperties(deviceId);
|
|
24
|
+
spinner.succeed(`Properties for ${deviceId} retrieved successfully`);
|
|
25
|
+
if (isJsonMode()) printOk(result);
|
|
26
|
+
else console.log(formatDeviceProperties(deviceId, result));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
spinner.fail('Property retrieval failed');
|
|
29
|
+
const { message, code } = classifyError(error);
|
|
30
|
+
printErr(message, { code });
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const spinner = createSpinner(`Retrieving property for ${deviceId}...`).start();
|
|
35
|
+
try {
|
|
36
|
+
const property = await getDeviceProperty(deviceId, propertyId);
|
|
37
|
+
spinner.succeed(`Property ${propertyId} for ${deviceId} found`);
|
|
38
|
+
const value = opts.field
|
|
39
|
+
? opts.field.split('.').reduce((obj, key) => obj && obj[key], property)
|
|
40
|
+
: property;
|
|
41
|
+
if (isJsonMode()) printOk(value);
|
|
42
|
+
else if (opts.field) console.log(value);
|
|
43
|
+
else console.log(formatDeviceProperty(deviceId, propertyId, property));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
spinner.fail('Property retrieval failed');
|
|
46
|
+
const { message, code } = classifyError(error);
|
|
47
|
+
printErr(message, { code });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { handleProxyAction } from '../../lib/proxy.js';
|
|
3
|
+
import { ensureConfigured, runInteractive } from './_shared.js';
|
|
4
|
+
|
|
5
|
+
export function registerProxyCommands(device) {
|
|
6
|
+
device
|
|
7
|
+
.command('tcp <deviceId> [target]')
|
|
8
|
+
.description('Create a TCP proxy (no TLS). Default target: 22.')
|
|
9
|
+
.option('-p, --port <port>', 'Local port to use (default: random)')
|
|
10
|
+
.action((deviceId, target, opts) =>
|
|
11
|
+
runInteractive(() => {
|
|
12
|
+
ensureConfigured();
|
|
13
|
+
return handleProxyAction(deviceId, 'tcp', target, { port: opts.port });
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
device
|
|
18
|
+
.command('tls <deviceId> [target]')
|
|
19
|
+
.description('Create a TLS proxy. Default target: 443.')
|
|
20
|
+
.option('-p, --port <port>', 'Local port to use (default: random)')
|
|
21
|
+
.action((deviceId, target, opts) =>
|
|
22
|
+
runInteractive(() => {
|
|
23
|
+
ensureConfigured();
|
|
24
|
+
return handleProxyAction(deviceId, 'tls', target, { port: opts.port });
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
device
|
|
29
|
+
.command('http <deviceId> [target]')
|
|
30
|
+
.description('Create an HTTP proxy. Default target: 80.')
|
|
31
|
+
.option('-p, --port <port>', 'Local port to use (default: random)')
|
|
32
|
+
.option('--no-open', 'Do not open the browser')
|
|
33
|
+
.action((deviceId, target, opts) =>
|
|
34
|
+
runInteractive(() => {
|
|
35
|
+
ensureConfigured();
|
|
36
|
+
return handleProxyAction(deviceId, 'http', target, {
|
|
37
|
+
port: opts.port,
|
|
38
|
+
openBrowser: opts.open,
|
|
39
|
+
});
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import {
|
|
3
|
+
callDeviceResource,
|
|
4
|
+
listDeviceResourcesWithSchemas,
|
|
5
|
+
formatDeviceResourcesWithSchemas,
|
|
6
|
+
} from '../../lib/resource.js';
|
|
7
|
+
import { isJsonMode, printOk, printErr, createSpinner, classifyError } from '../../lib/output.js';
|
|
8
|
+
import { applyJsonFlag, collectInput, ensureConfigured } from './_shared.js';
|
|
9
|
+
|
|
10
|
+
export function registerResourceCommand(device) {
|
|
11
|
+
device
|
|
12
|
+
.command('resource <deviceId> [resource]')
|
|
13
|
+
.description('List device resources, or call one by name')
|
|
14
|
+
.option('-j, --json', 'Output as JSON')
|
|
15
|
+
.option('-f, --field <field>', 'Extract a sub-field from the result (dot path)')
|
|
16
|
+
.option('-i, --input <key=value>', 'Resource input (repeatable)', collectInput, {})
|
|
17
|
+
.action(async (deviceId, resource, opts) => {
|
|
18
|
+
applyJsonFlag(opts);
|
|
19
|
+
ensureConfigured();
|
|
20
|
+
if (!resource) {
|
|
21
|
+
const spinner = createSpinner(`Getting resources for ${deviceId}...`).start();
|
|
22
|
+
try {
|
|
23
|
+
const entries = await listDeviceResourcesWithSchemas(deviceId);
|
|
24
|
+
spinner.succeed(`Successfully retrieved resources for ${deviceId}`);
|
|
25
|
+
if (isJsonMode()) printOk(entries);
|
|
26
|
+
else console.log(formatDeviceResourcesWithSchemas(deviceId, entries));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
spinner.fail('Resource retrieval failed');
|
|
29
|
+
const { message, code } = classifyError(error);
|
|
30
|
+
printErr(message, { code });
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const spinner = createSpinner(
|
|
35
|
+
`Calling resource ${resource} for ${deviceId}...`,
|
|
36
|
+
).start();
|
|
37
|
+
try {
|
|
38
|
+
const result = await callDeviceResource(deviceId, resource, opts.input);
|
|
39
|
+
spinner.succeed(`Resource ${resource} returned successfully for ${deviceId}`);
|
|
40
|
+
const value = opts.field
|
|
41
|
+
? opts.field.split('.').reduce((obj, key) => obj && obj[key], result)
|
|
42
|
+
: result;
|
|
43
|
+
if (isJsonMode()) printOk(value);
|
|
44
|
+
else console.log(value);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
spinner.fail('Resource call failed');
|
|
47
|
+
const { message, code } = classifyError(error);
|
|
48
|
+
printErr(message, { code });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getDeviceStatus, formatDeviceStatus } from '../../lib/status.js';
|
|
3
|
+
import { isJsonMode, printOk, printErr, createSpinner, classifyError } from '../../lib/output.js';
|
|
4
|
+
import { applyJsonFlag, ensureConfigured } from './_shared.js';
|
|
5
|
+
|
|
6
|
+
export function registerStatusCommand(device) {
|
|
7
|
+
device
|
|
8
|
+
.command('status <deviceId>')
|
|
9
|
+
.description('Check device status and stats')
|
|
10
|
+
.option('-j, --json', 'Output as JSON')
|
|
11
|
+
.action(async (deviceId, opts) => {
|
|
12
|
+
applyJsonFlag(opts);
|
|
13
|
+
ensureConfigured();
|
|
14
|
+
const spinner = createSpinner(`Checking status of ${deviceId}...`).start();
|
|
15
|
+
try {
|
|
16
|
+
const status = await getDeviceStatus(deviceId);
|
|
17
|
+
spinner.succeed(`Device ${deviceId} found`);
|
|
18
|
+
if (isJsonMode()) printOk(status);
|
|
19
|
+
else console.log(formatDeviceStatus(deviceId, status));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
spinner.fail('Status check failed');
|
|
22
|
+
const { message, code } = classifyError(error);
|
|
23
|
+
printErr(message, { code });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { createDeviceAPI } from '../../lib/device-api.js';
|
|
4
|
+
import { isJsonMode, printOk, printErr, createSpinner, classifyError } from '../../lib/output.js';
|
|
5
|
+
import { applyJsonFlag, ensureConfigured, getGlobalUser } from './_shared.js';
|
|
6
|
+
|
|
7
|
+
const runUpdate = async (action, deviceId, opts, cmd) => {
|
|
8
|
+
applyJsonFlag(opts);
|
|
9
|
+
ensureConfigured();
|
|
10
|
+
const user = getGlobalUser(cmd);
|
|
11
|
+
const channel = opts.channel || 'latest';
|
|
12
|
+
const label = action === 'check' ? 'Checking for updates' : 'Applying update';
|
|
13
|
+
const spinner = createSpinner(`${label} on ${deviceId}...`).start();
|
|
14
|
+
try {
|
|
15
|
+
const api = createDeviceAPI(deviceId, { user });
|
|
16
|
+
const timeout = action === 'apply' ? 300000 : 30000;
|
|
17
|
+
const result = await api.callResource('update', { action, channel }, { timeout });
|
|
18
|
+
spinner.succeed(`${label} finished`);
|
|
19
|
+
if (isJsonMode()) {
|
|
20
|
+
printOk(result);
|
|
21
|
+
} else if (result && typeof result === 'object') {
|
|
22
|
+
if (result.current) console.log(` current: ${chalk.bold(result.current)}`);
|
|
23
|
+
if (result.latest) console.log(` latest: ${chalk.bold(result.latest)}`);
|
|
24
|
+
if (result.arch) console.log(` arch: ${result.arch}`);
|
|
25
|
+
if (result.status) console.log(` status: ${chalk.cyan(result.status)}`);
|
|
26
|
+
if (result.message) console.log(` message: ${result.message}`);
|
|
27
|
+
} else {
|
|
28
|
+
console.log(result);
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
spinner.fail(`${label} failed`);
|
|
32
|
+
const { message, code } = classifyError(error);
|
|
33
|
+
printErr(message, { code });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function registerUpdateCommand(device) {
|
|
38
|
+
const update = device.command('update').description('Check for or apply agent updates');
|
|
39
|
+
|
|
40
|
+
update
|
|
41
|
+
.command('check <deviceId>')
|
|
42
|
+
.description('Check if an agent update is available')
|
|
43
|
+
.option('-j, --json', 'Output as JSON')
|
|
44
|
+
.option('--channel <name>', 'Update channel (default: latest)')
|
|
45
|
+
.action((deviceId, opts, cmd) => runUpdate('check', deviceId, opts, cmd));
|
|
46
|
+
|
|
47
|
+
update
|
|
48
|
+
.command('apply <deviceId>')
|
|
49
|
+
.description('Apply an agent update')
|
|
50
|
+
.option('-j, --json', 'Output as JSON')
|
|
51
|
+
.option('--channel <name>', 'Update channel (default: latest)')
|
|
52
|
+
.action((deviceId, opts, cmd) => runUpdate('apply', deviceId, opts, cmd));
|
|
53
|
+
}
|
package/commands/logout.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import { deleteConfig, configExists } from '../lib/config.js';
|
|
4
|
-
import { deleteToken } from '../lib/auth.js';
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Register the logout command
|
|
@@ -29,13 +28,21 @@ export function logoutCommand(program) {
|
|
|
29
28
|
spinner.succeed('Logged out successfully. Configuration removed.');
|
|
30
29
|
} else {
|
|
31
30
|
spinner.fail('Failed to remove configuration.');
|
|
32
|
-
console.error(
|
|
33
|
-
|
|
31
|
+
console.error(
|
|
32
|
+
chalk.red(
|
|
33
|
+
'Error: Unable to delete configuration file. You may need to remove it manually.',
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
console.error(
|
|
37
|
+
chalk.gray('Configuration is stored in ~/.config/thinr-cli/config.json'),
|
|
38
|
+
);
|
|
34
39
|
}
|
|
35
40
|
} catch (error) {
|
|
36
41
|
spinner.fail('Failed to remove configuration.');
|
|
37
42
|
console.error(chalk.red(`Error: ${error.message}`));
|
|
38
|
-
console.error(
|
|
43
|
+
console.error(
|
|
44
|
+
chalk.gray('Configuration is stored in ~/.config/thinr-cli/config.json'),
|
|
45
|
+
);
|
|
39
46
|
}
|
|
40
47
|
});
|
|
41
|
-
}
|
|
48
|
+
}
|