@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,218 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import { createDeviceAPI } from '../../lib/device-api.js';
|
|
4
|
+
import { runProductFanOut } from '../../lib/product-orchestrator.js';
|
|
5
|
+
import {
|
|
6
|
+
label,
|
|
7
|
+
muted,
|
|
8
|
+
hint,
|
|
9
|
+
success,
|
|
10
|
+
error as errorStyle,
|
|
11
|
+
warning,
|
|
12
|
+
} from '../../lib/format.js';
|
|
13
|
+
import {
|
|
14
|
+
isJsonMode,
|
|
15
|
+
printOk,
|
|
16
|
+
printErr,
|
|
17
|
+
createSpinner,
|
|
18
|
+
classifyError,
|
|
19
|
+
} from '../../lib/output.js';
|
|
20
|
+
import {
|
|
21
|
+
applyJsonFlag,
|
|
22
|
+
ensureConfigured,
|
|
23
|
+
getGlobalUser,
|
|
24
|
+
parsePositiveInt,
|
|
25
|
+
ProgressSpinner,
|
|
26
|
+
} from '../_shared.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Factory for product-level fan-out filesystem subcommands.
|
|
30
|
+
* Captures the repetitive boilerplate (fetch product devices,
|
|
31
|
+
* optionally filter offline, fan out through the shared orchestrator,
|
|
32
|
+
* render the same cli-table3 summary) so each verb only has to
|
|
33
|
+
* describe its per-device operation.
|
|
34
|
+
*/
|
|
35
|
+
function registerProductFsCommand(product, { name, description, args, build, perDeviceLabel }) {
|
|
36
|
+
const cmd = product
|
|
37
|
+
.command(`${name} ${args}`)
|
|
38
|
+
.helpGroup('Filesystem:')
|
|
39
|
+
.description(description)
|
|
40
|
+
.option('-j, --json', 'Output as JSON')
|
|
41
|
+
.option('-g, --group <group>', 'Filter devices by asset group')
|
|
42
|
+
.option(
|
|
43
|
+
'-c, --concurrency <n>',
|
|
44
|
+
'Max parallel operations (default: 10)',
|
|
45
|
+
parsePositiveInt('concurrency'),
|
|
46
|
+
10,
|
|
47
|
+
)
|
|
48
|
+
.option('--fail-fast', 'Stop dequeueing new devices on the first failure')
|
|
49
|
+
.option('-a, --all', 'Include offline devices (by default, only active)');
|
|
50
|
+
|
|
51
|
+
cmd.action(async (...commandArgs) => {
|
|
52
|
+
const opts = commandArgs[commandArgs.length - 2];
|
|
53
|
+
const commanderCmd = commandArgs[commandArgs.length - 1];
|
|
54
|
+
const positionalArgs = commandArgs.slice(0, -2);
|
|
55
|
+
const [productId, ...rest] = positionalArgs;
|
|
56
|
+
|
|
57
|
+
applyJsonFlag(opts);
|
|
58
|
+
ensureConfigured();
|
|
59
|
+
const user = getGlobalUser(commanderCmd);
|
|
60
|
+
|
|
61
|
+
const fetchSpinner = createSpinner(
|
|
62
|
+
`Retrieving devices for product ${productId}...`,
|
|
63
|
+
).start();
|
|
64
|
+
/** @type {ProgressSpinner | null} */
|
|
65
|
+
let progress = null;
|
|
66
|
+
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await runProductFanOut({
|
|
70
|
+
product: productId,
|
|
71
|
+
group: opts.group,
|
|
72
|
+
includeOffline: !!opts.all,
|
|
73
|
+
user,
|
|
74
|
+
concurrency: opts.concurrency,
|
|
75
|
+
failFast: !!opts.failFast,
|
|
76
|
+
worker: async (device) => {
|
|
77
|
+
const api = createDeviceAPI(device.device, { user });
|
|
78
|
+
const perStart = Date.now();
|
|
79
|
+
try {
|
|
80
|
+
await build(api, rest, opts);
|
|
81
|
+
return {
|
|
82
|
+
device: device.device,
|
|
83
|
+
ok: true,
|
|
84
|
+
durationMs: Date.now() - perStart,
|
|
85
|
+
};
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
device: device.device,
|
|
89
|
+
ok: false,
|
|
90
|
+
error: err instanceof Error ? err.message : String(err),
|
|
91
|
+
durationMs: Date.now() - perStart,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
skipped: (device, firstFailure) => ({
|
|
96
|
+
device: device.device,
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `skipped (fail-fast after ${firstFailure})`,
|
|
99
|
+
durationMs: 0,
|
|
100
|
+
}),
|
|
101
|
+
isFailure: (entry) => !entry.ok,
|
|
102
|
+
onDevicesResolved: (devices) => {
|
|
103
|
+
fetchSpinner.succeed(
|
|
104
|
+
`Found ${devices.length} device(s) in product ${productId}`,
|
|
105
|
+
);
|
|
106
|
+
if (devices.length > 0 && !isJsonMode()) {
|
|
107
|
+
progress = new ProgressSpinner(
|
|
108
|
+
createSpinner('').start(),
|
|
109
|
+
devices.length,
|
|
110
|
+
({ done, total, inFlight }) =>
|
|
111
|
+
`${perDeviceLabel(rest)} on ${total} device(s) — ` +
|
|
112
|
+
`${done}/${total} done · ${inFlight} in flight` +
|
|
113
|
+
(opts.failFast ? ' · fail-fast' : ''),
|
|
114
|
+
);
|
|
115
|
+
progress.render();
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
onItemStart: () => progress?.startItem(),
|
|
119
|
+
onItemFinish: () => progress?.finishItem(),
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
fetchSpinner.fail(`Failed to retrieve devices for product ${productId}`);
|
|
123
|
+
const { message, code } = classifyError(error);
|
|
124
|
+
printErr(message, { code });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
progress?.stop();
|
|
128
|
+
|
|
129
|
+
const { devices, entries, durationMs } = result;
|
|
130
|
+
|
|
131
|
+
if (devices.length === 0) {
|
|
132
|
+
if (isJsonMode()) {
|
|
133
|
+
printOk({
|
|
134
|
+
product: productId,
|
|
135
|
+
summary: { total: 0, ok: 0, failed: 0 },
|
|
136
|
+
results: [],
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
console.log(warning('No devices to target.'));
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const okCount = entries.filter((e) => e.ok).length;
|
|
145
|
+
const failCount = entries.length - okCount;
|
|
146
|
+
|
|
147
|
+
if (isJsonMode()) {
|
|
148
|
+
printOk({
|
|
149
|
+
product: productId,
|
|
150
|
+
summary: {
|
|
151
|
+
total: entries.length,
|
|
152
|
+
ok: okCount,
|
|
153
|
+
failed: failCount,
|
|
154
|
+
durationMs,
|
|
155
|
+
},
|
|
156
|
+
results: entries,
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const table = new Table({
|
|
162
|
+
head: ['Device', 'Status', 'Detail', 'Duration'].map((h) => label(h)),
|
|
163
|
+
style: { head: [], border: ['gray'] },
|
|
164
|
+
colAligns: ['left', 'left', 'left', 'right'],
|
|
165
|
+
});
|
|
166
|
+
for (const e of entries) {
|
|
167
|
+
table.push([
|
|
168
|
+
e.device,
|
|
169
|
+
e.ok ? success('ok') : errorStyle('fail'),
|
|
170
|
+
e.ok ? muted('—') : e.error || '',
|
|
171
|
+
`${e.durationMs}ms`,
|
|
172
|
+
]);
|
|
173
|
+
}
|
|
174
|
+
console.log(table.toString());
|
|
175
|
+
console.log(
|
|
176
|
+
hint(`${entries.length} total · ${okCount} ok · ${failCount} failed · ${durationMs}ms`),
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return cmd;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function registerProductFsCommands(product) {
|
|
184
|
+
registerProductFsCommand(product, {
|
|
185
|
+
name: 'mkdir',
|
|
186
|
+
description: 'Create a directory on every active device of the product',
|
|
187
|
+
args: '<productId> <path>',
|
|
188
|
+
perDeviceLabel: ([path]) => `mkdir ${path}`,
|
|
189
|
+
build: async (api, [path]) => {
|
|
190
|
+
await api.mkdir(path);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
registerProductFsCommand(product, {
|
|
195
|
+
name: 'rm',
|
|
196
|
+
description:
|
|
197
|
+
'Delete a file or directory on every active device of the product (DESTRUCTIVE — double-check the path).',
|
|
198
|
+
args: '<productId> <path>',
|
|
199
|
+
perDeviceLabel: ([path]) => `rm ${path}`,
|
|
200
|
+
build: async (api, [path], opts) => {
|
|
201
|
+
await api.delete(path, opts.recursive !== false);
|
|
202
|
+
},
|
|
203
|
+
}).option(
|
|
204
|
+
'-r, --recursive',
|
|
205
|
+
'Recursively delete non-empty directories (default: true)',
|
|
206
|
+
true,
|
|
207
|
+
).option('--no-recursive', 'Fail on non-empty directories');
|
|
208
|
+
|
|
209
|
+
registerProductFsCommand(product, {
|
|
210
|
+
name: 'mv',
|
|
211
|
+
description: 'Move or rename a path on every active device of the product',
|
|
212
|
+
args: '<productId> <source> <destination>',
|
|
213
|
+
perDeviceLabel: ([src, dst]) => `mv ${src} → ${dst}`,
|
|
214
|
+
build: async (api, [src, dst], opts) => {
|
|
215
|
+
await api.move(src, dst, !!opts.force);
|
|
216
|
+
},
|
|
217
|
+
}).option('-f, --force', 'Overwrite destination if it already exists');
|
|
218
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { registerProductListCommand } from './list.js';
|
|
3
|
+
import { registerProductPropertyCommand } from './property.js';
|
|
4
|
+
import { registerProductPropertyAdminCommands } from './props.js';
|
|
5
|
+
import { registerProductMetricCommands } from './metric.js';
|
|
6
|
+
import { registerProductResourceCommand } from './resource.js';
|
|
7
|
+
import { registerProductExecCommand } from './exec.js';
|
|
8
|
+
import { registerProductStatusCommand } from './status.js';
|
|
9
|
+
import { registerProductFsCommands } from './fs.js';
|
|
10
|
+
import { registerProductPushCommand } from './push.js';
|
|
11
|
+
import { registerProductPlaybookCommand } from './playbook.js';
|
|
12
|
+
import { registerProductLogsCommand } from './logs.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `thinr product <subcommand> <productId> …` — fan-out commands across
|
|
16
|
+
* every device that belongs to a product.
|
|
17
|
+
*/
|
|
18
|
+
export function productCommand(program) {
|
|
19
|
+
const product = program
|
|
20
|
+
.command('product')
|
|
21
|
+
.helpGroup('Operations:')
|
|
22
|
+
.description('Product commands (subcommand-first: thinr product <action> <productId>)');
|
|
23
|
+
|
|
24
|
+
registerProductListCommand(product);
|
|
25
|
+
registerProductStatusCommand(product);
|
|
26
|
+
registerProductPropertyCommand(product);
|
|
27
|
+
registerProductPropertyAdminCommands(product);
|
|
28
|
+
registerProductMetricCommands(product);
|
|
29
|
+
registerProductResourceCommand(product);
|
|
30
|
+
registerProductExecCommand(product);
|
|
31
|
+
registerProductFsCommands(product);
|
|
32
|
+
registerProductPushCommand(product);
|
|
33
|
+
registerProductPlaybookCommand(product);
|
|
34
|
+
registerProductLogsCommand(product);
|
|
35
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getProducts, getProductApi } from '../../lib/product.js';
|
|
3
|
+
import { runPool } from '../../lib/concurrency.js';
|
|
4
|
+
import { label, hint, success } from '../../lib/format.js';
|
|
5
|
+
import {
|
|
6
|
+
isJsonMode,
|
|
7
|
+
printOk,
|
|
8
|
+
printErr,
|
|
9
|
+
createSpinner,
|
|
10
|
+
classifyError,
|
|
11
|
+
} from '../../lib/output.js';
|
|
12
|
+
import {
|
|
13
|
+
applyJsonFlag,
|
|
14
|
+
ensureConfigured,
|
|
15
|
+
getGlobalUser,
|
|
16
|
+
parsePositiveInt,
|
|
17
|
+
} from '../_shared.js';
|
|
18
|
+
|
|
19
|
+
export function registerProductListCommand(product) {
|
|
20
|
+
product
|
|
21
|
+
.command('list')
|
|
22
|
+
.helpGroup('Discovery:')
|
|
23
|
+
.description('List every product configured on the server')
|
|
24
|
+
.option('-j, --json', 'Output as JSON')
|
|
25
|
+
.option(
|
|
26
|
+
'-c, --concurrency <n>',
|
|
27
|
+
'Max parallel api-resource lookups (default: 10)',
|
|
28
|
+
parsePositiveInt('concurrency'),
|
|
29
|
+
10,
|
|
30
|
+
)
|
|
31
|
+
.action(async (opts, cmd) => {
|
|
32
|
+
applyJsonFlag(opts);
|
|
33
|
+
ensureConfigured();
|
|
34
|
+
const user = getGlobalUser(cmd);
|
|
35
|
+
|
|
36
|
+
const spinner = createSpinner('Fetching products...').start();
|
|
37
|
+
let products;
|
|
38
|
+
try {
|
|
39
|
+
products = await getProducts(user);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
spinner.fail('Failed to fetch products');
|
|
42
|
+
const { message, code } = classifyError(error);
|
|
43
|
+
printErr(message, { code });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Script counts come from each product's api resources map.
|
|
48
|
+
// Parallelise so a large catalogue doesn't crawl serially.
|
|
49
|
+
const scriptResults = await runPool(products, opts.concurrency, async (p) => {
|
|
50
|
+
try {
|
|
51
|
+
const apis = await getProductApi(p.product, user);
|
|
52
|
+
return Object.keys(apis || {}).length;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const rows = products.map((p, i) => ({
|
|
58
|
+
product: p.product,
|
|
59
|
+
name: p.name && p.name !== p.product ? p.name : null,
|
|
60
|
+
enabled: !!p.enabled,
|
|
61
|
+
scripts: scriptResults[i]?.ok ? scriptResults[i].value : null,
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
spinner.succeed(`Found ${rows.length} product(s)`);
|
|
65
|
+
|
|
66
|
+
if (isJsonMode()) {
|
|
67
|
+
printOk({ products: rows });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (rows.length === 0) return;
|
|
72
|
+
|
|
73
|
+
// Pad the product id column so the trailing name/scripts
|
|
74
|
+
// bits align even when ids have very different widths.
|
|
75
|
+
const idWidth = rows.reduce((w, r) => Math.max(w, r.product.length), 0);
|
|
76
|
+
for (const r of rows) {
|
|
77
|
+
const flag = r.enabled ? success('enabled ') : hint('disabled');
|
|
78
|
+
const id = label(r.product.padEnd(idWidth));
|
|
79
|
+
const name = r.name ? hint(`(${r.name})`) : '';
|
|
80
|
+
const scripts =
|
|
81
|
+
r.scripts != null && r.scripts > 0 ? hint(`${r.scripts} script(s)`) : '';
|
|
82
|
+
const extras = [name, scripts].filter(Boolean).join(' ');
|
|
83
|
+
console.log(` ${flag} ${id}${extras ? ' ' + extras : ''}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import {
|
|
4
|
+
addLogSource,
|
|
5
|
+
getProductLogs,
|
|
6
|
+
listPresets,
|
|
7
|
+
removeLogSource,
|
|
8
|
+
setDefaultLogSource,
|
|
9
|
+
} from '../../lib/product/logs.js';
|
|
10
|
+
import { hint, info, label, muted, success } from '../../lib/format.js';
|
|
11
|
+
import { classifyError, isJsonMode, printErr, printOk } from '../../lib/output.js';
|
|
12
|
+
import { applyJsonFlag, ensureConfigured, getGlobalUser } from '../_shared.js';
|
|
13
|
+
|
|
14
|
+
function truncate(value, max = 60) {
|
|
15
|
+
if (typeof value !== 'string') return '';
|
|
16
|
+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function registerList(logs) {
|
|
20
|
+
logs
|
|
21
|
+
.command('list <productId>')
|
|
22
|
+
.helpGroup('Log sources:')
|
|
23
|
+
.description('List the log sources configured on a product')
|
|
24
|
+
.option('-j, --json', 'Output as JSON')
|
|
25
|
+
.action(async (productId, opts, cmd) => {
|
|
26
|
+
applyJsonFlag(opts);
|
|
27
|
+
ensureConfigured();
|
|
28
|
+
const user = getGlobalUser(cmd);
|
|
29
|
+
try {
|
|
30
|
+
const cfg = await getProductLogs(productId, user);
|
|
31
|
+
const fallback = !!(/** @type {any} */ (cfg).__fallback);
|
|
32
|
+
const def = cfg.default || cfg.sources[0]?.name || null;
|
|
33
|
+
|
|
34
|
+
if (isJsonMode()) {
|
|
35
|
+
printOk({
|
|
36
|
+
product: productId,
|
|
37
|
+
fallback,
|
|
38
|
+
default: def,
|
|
39
|
+
sources: cfg.sources,
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (fallback) {
|
|
45
|
+
console.log(
|
|
46
|
+
`${muted('No logs property set on')} ${info(productId)}${muted('. Showing the synthetic fallback:')}`,
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(`${cfg.sources.length} source(s) on ${info(productId)}:`);
|
|
50
|
+
}
|
|
51
|
+
const table = new Table({
|
|
52
|
+
head: ['Name', 'Command', 'Pattern', 'Default'].map((h) => label(h)),
|
|
53
|
+
style: { head: [], border: ['gray'] },
|
|
54
|
+
});
|
|
55
|
+
for (const s of cfg.sources) {
|
|
56
|
+
const pat = s.preset
|
|
57
|
+
? muted(`preset:${s.preset}`)
|
|
58
|
+
: s.pattern
|
|
59
|
+
? truncate(s.pattern, 30)
|
|
60
|
+
: muted('—');
|
|
61
|
+
table.push([
|
|
62
|
+
s.name,
|
|
63
|
+
truncate(s.command),
|
|
64
|
+
pat,
|
|
65
|
+
s.name === def ? success('✓') : muted('—'),
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
console.log(table.toString());
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const { message, code } = classifyError(err);
|
|
71
|
+
printErr(message, { code });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function registerAdd(logs) {
|
|
77
|
+
logs
|
|
78
|
+
.command('add <productId> <name>')
|
|
79
|
+
.helpGroup('Log sources:')
|
|
80
|
+
.description('Add or replace a log source on a product')
|
|
81
|
+
.requiredOption('-c, --command <cmd>', 'Shell command the agent should exec-stream for this source')
|
|
82
|
+
.option('--default', 'Mark this source as the active default')
|
|
83
|
+
.option(
|
|
84
|
+
'--pattern <regex>',
|
|
85
|
+
'Custom regex with named groups (time, level, msg) for structured rendering',
|
|
86
|
+
)
|
|
87
|
+
.option(
|
|
88
|
+
'--preset <name>',
|
|
89
|
+
'Named preset for structured rendering (see `thinr product logs presets`)',
|
|
90
|
+
)
|
|
91
|
+
.option('-j, --json', 'Output as JSON')
|
|
92
|
+
.action(async (productId, name, opts, cmd) => {
|
|
93
|
+
applyJsonFlag(opts);
|
|
94
|
+
ensureConfigured();
|
|
95
|
+
const user = getGlobalUser(cmd);
|
|
96
|
+
try {
|
|
97
|
+
const { config, action } = await addLogSource(
|
|
98
|
+
productId,
|
|
99
|
+
{
|
|
100
|
+
name,
|
|
101
|
+
command: opts.command,
|
|
102
|
+
makeDefault: !!opts.default,
|
|
103
|
+
pattern: opts.pattern,
|
|
104
|
+
preset: opts.preset,
|
|
105
|
+
},
|
|
106
|
+
user,
|
|
107
|
+
);
|
|
108
|
+
if (isJsonMode()) {
|
|
109
|
+
printOk({ product: productId, action, name, config });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.log(
|
|
113
|
+
`${success(action === 'added' ? 'Added' : 'Updated')} source ${info(name)} on product ${info(productId)}` +
|
|
114
|
+
(opts.default ? ` ${muted('(default)')}` : ''),
|
|
115
|
+
);
|
|
116
|
+
console.log(` ${muted('command:')} ${truncate(opts.command, 80)}`);
|
|
117
|
+
if (opts.preset) console.log(` ${muted('preset:')} ${opts.preset}`);
|
|
118
|
+
if (opts.pattern) console.log(` ${muted('pattern:')} ${truncate(opts.pattern, 80)}`);
|
|
119
|
+
console.log(
|
|
120
|
+
` ${muted('total sources:')} ${config.sources.length}${
|
|
121
|
+
config.default ? ` ${muted('default:')} ${config.default}` : ''
|
|
122
|
+
}`,
|
|
123
|
+
);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const { message, code } = classifyError(err);
|
|
126
|
+
printErr(message, { code: code === 'error' ? 'input_error' : code });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function registerRemove(logs) {
|
|
132
|
+
logs
|
|
133
|
+
.command('remove <productId> <name>')
|
|
134
|
+
.alias('rm')
|
|
135
|
+
.helpGroup('Log sources:')
|
|
136
|
+
.description('Remove a log source from a product (idempotent)')
|
|
137
|
+
.option('-j, --json', 'Output as JSON')
|
|
138
|
+
.action(async (productId, name, opts, cmd) => {
|
|
139
|
+
applyJsonFlag(opts);
|
|
140
|
+
ensureConfigured();
|
|
141
|
+
const user = getGlobalUser(cmd);
|
|
142
|
+
try {
|
|
143
|
+
const { removed, config } = await removeLogSource(productId, name, user);
|
|
144
|
+
if (isJsonMode()) {
|
|
145
|
+
printOk({
|
|
146
|
+
product: productId,
|
|
147
|
+
name,
|
|
148
|
+
removed,
|
|
149
|
+
config: config || null,
|
|
150
|
+
cleared: removed && config === null,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (!removed) {
|
|
155
|
+
console.log(
|
|
156
|
+
`Source ${info(name)} was not configured on ${info(productId)}.`,
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (config === null) {
|
|
161
|
+
console.log(
|
|
162
|
+
`${success('Removed')} source ${info(name)} (last one). ${muted('Logs property cleared — falling back to system journal.')}`,
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.log(
|
|
167
|
+
`${success('Removed')} source ${info(name)}. ${muted(`${config.sources.length} source(s) remain`)}` +
|
|
168
|
+
(config.default ? ` ${muted(`· default: ${config.default}`)}` : ''),
|
|
169
|
+
);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const { message, code } = classifyError(err);
|
|
172
|
+
printErr(message, { code: code === 'error' ? 'input_error' : code });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function registerSetDefault(logs) {
|
|
178
|
+
logs
|
|
179
|
+
.command('set-default <productId> <name>')
|
|
180
|
+
.helpGroup('Log sources:')
|
|
181
|
+
.description('Set the active default source on a product')
|
|
182
|
+
.option('-j, --json', 'Output as JSON')
|
|
183
|
+
.action(async (productId, name, opts, cmd) => {
|
|
184
|
+
applyJsonFlag(opts);
|
|
185
|
+
ensureConfigured();
|
|
186
|
+
const user = getGlobalUser(cmd);
|
|
187
|
+
try {
|
|
188
|
+
const config = await setDefaultLogSource(productId, name, user);
|
|
189
|
+
if (isJsonMode()) {
|
|
190
|
+
printOk({ product: productId, default: config.default, config });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
console.log(
|
|
194
|
+
`${success('Default set')}: ${info(name)} on product ${info(productId)}.`,
|
|
195
|
+
);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
const { message, code } = classifyError(err);
|
|
198
|
+
printErr(message, { code: code === 'error' ? 'input_error' : code });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function registerShow(logs) {
|
|
204
|
+
logs
|
|
205
|
+
.command('show <productId> <name>')
|
|
206
|
+
.helpGroup('Log sources:')
|
|
207
|
+
.description('Show a single log source (full command, untruncated)')
|
|
208
|
+
.option('-j, --json', 'Output as JSON')
|
|
209
|
+
.action(async (productId, name, opts, cmd) => {
|
|
210
|
+
applyJsonFlag(opts);
|
|
211
|
+
ensureConfigured();
|
|
212
|
+
const user = getGlobalUser(cmd);
|
|
213
|
+
try {
|
|
214
|
+
const cfg = await getProductLogs(productId, user);
|
|
215
|
+
const source = cfg.sources.find((s) => s.name === name);
|
|
216
|
+
if (!source) {
|
|
217
|
+
const available = cfg.sources.map((s) => s.name).join(', ') || '(none)';
|
|
218
|
+
printErr(
|
|
219
|
+
`Source "${name}" is not configured on product "${productId}". Available: ${available}`,
|
|
220
|
+
{ code: 'not_found' },
|
|
221
|
+
);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const fallback = !!(/** @type {any} */ (cfg).__fallback);
|
|
225
|
+
const isDefault = (cfg.default || cfg.sources[0]?.name) === name;
|
|
226
|
+
if (isJsonMode()) {
|
|
227
|
+
printOk({
|
|
228
|
+
product: productId,
|
|
229
|
+
fallback,
|
|
230
|
+
name: source.name,
|
|
231
|
+
command: source.command,
|
|
232
|
+
pattern: source.pattern || null,
|
|
233
|
+
preset: source.preset || null,
|
|
234
|
+
default: isDefault,
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
console.log(`${label('Source')}: ${info(source.name)}${isDefault ? ` ${muted('(default)')}` : ''}`);
|
|
239
|
+
console.log(`${label('Command')}: ${source.command}`);
|
|
240
|
+
if (source.preset) console.log(`${label('Preset')}: ${source.preset}`);
|
|
241
|
+
if (source.pattern) console.log(`${label('Pattern')}: ${source.pattern}`);
|
|
242
|
+
if (fallback) console.log(hint('(synthetic fallback — no logs property set)'));
|
|
243
|
+
} catch (err) {
|
|
244
|
+
const { message, code } = classifyError(err);
|
|
245
|
+
printErr(message, { code });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function registerPresets(logs) {
|
|
251
|
+
logs
|
|
252
|
+
.command('presets')
|
|
253
|
+
.helpGroup('Log sources:')
|
|
254
|
+
.description('List the named log line patterns shipped with the CLI')
|
|
255
|
+
.option('-j, --json', 'Output as JSON')
|
|
256
|
+
.action(async (opts) => {
|
|
257
|
+
applyJsonFlag(opts);
|
|
258
|
+
const presets = listPresets();
|
|
259
|
+
if (isJsonMode()) {
|
|
260
|
+
printOk({ presets });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const table = new Table({
|
|
264
|
+
head: ['Name', 'Description', 'Pattern'].map((h) => label(h)),
|
|
265
|
+
style: { head: [], border: ['gray'] },
|
|
266
|
+
});
|
|
267
|
+
for (const p of presets) {
|
|
268
|
+
table.push([p.name, p.description, truncate(p.pattern, 60)]);
|
|
269
|
+
}
|
|
270
|
+
console.log(`${presets.length} preset(s) available:`);
|
|
271
|
+
console.log(table.toString());
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function registerProductLogsCommand(product) {
|
|
276
|
+
const logs = product
|
|
277
|
+
.command('logs')
|
|
278
|
+
.helpGroup('Log sources:')
|
|
279
|
+
.description(
|
|
280
|
+
`Manage log sources stored on a product. ${hint('Subcommands: list, add, remove, set-default, show, presets.')}`,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
registerList(logs);
|
|
284
|
+
registerAdd(logs);
|
|
285
|
+
registerRemove(logs);
|
|
286
|
+
registerSetDefault(logs);
|
|
287
|
+
registerShow(logs);
|
|
288
|
+
registerPresets(logs);
|
|
289
|
+
}
|