@thinremote/thinr-cli 1.2.0-alpha.1 → 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.
Files changed (91) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +192 -16
  3. package/bin/thinr.js +23 -24
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +30 -17
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +199 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +12 -4
  51. package/lib/constants.js +76 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +109 -147
  57. package/lib/devices.js +18 -21
  58. package/lib/fleet-upgrade.js +106 -0
  59. package/lib/format.js +114 -0
  60. package/lib/mcp/registry.js +14 -0
  61. package/lib/mcp/tools-alarms.js +510 -0
  62. package/lib/mcp/tools-buckets.js +103 -0
  63. package/lib/mcp/tools-devices.js +33 -3
  64. package/lib/mcp/tools-exec.js +30 -17
  65. package/lib/mcp/tools-filesystem.js +102 -6
  66. package/lib/mcp/tools-install.js +153 -0
  67. package/lib/mcp/tools-logs.js +225 -0
  68. package/lib/mcp/tools-playbook.js +211 -0
  69. package/lib/mcp/tools-products.js +1226 -105
  70. package/lib/mcp/tools-profile.js +634 -0
  71. package/lib/mcp/tools-tokens.js +371 -0
  72. package/lib/monitoring.js +26 -1
  73. package/lib/playbook/actions.js +447 -0
  74. package/lib/playbook/expression.js +302 -0
  75. package/lib/playbook/fleet.js +175 -0
  76. package/lib/playbook/loader.js +247 -0
  77. package/lib/playbook/runner.js +300 -0
  78. package/lib/playbook/schema.js +209 -0
  79. package/lib/playbook/vars.js +209 -0
  80. package/lib/product/log-presets.js +196 -0
  81. package/lib/product/logs.js +485 -0
  82. package/lib/product-orchestrator.js +124 -0
  83. package/lib/product.js +523 -20
  84. package/lib/profile.js +665 -0
  85. package/lib/property.js +7 -40
  86. package/lib/proxy.js +9 -8
  87. package/lib/resource.js +10 -14
  88. package/lib/status.js +78 -37
  89. package/lib/tokens.js +374 -0
  90. package/package.json +14 -3
  91. package/commands/product.js +0 -407
@@ -0,0 +1,225 @@
1
+ // @ts-check
2
+ import {
3
+ addLogSource,
4
+ getProductLogs,
5
+ listPresets,
6
+ removeLogSource,
7
+ setDefaultLogSource,
8
+ } from '../product/logs.js';
9
+ import { inputError } from '../errors.js';
10
+
11
+ function truncate(value, max = 80) {
12
+ if (typeof value !== 'string') return '';
13
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
14
+ }
15
+
16
+ async function toolProductLogsList(args) {
17
+ if (!args.product) throw inputError('product is required');
18
+ const cfg = await getProductLogs(args.product, args.user);
19
+ const fallback = !!(/** @type {any} */ (cfg).__fallback);
20
+ const def = cfg.default || cfg.sources[0]?.name || null;
21
+ const lines = cfg.sources.map((s) => {
22
+ const tag = s.name === def ? '★' : ' ';
23
+ return ` ${tag} ${s.name} → ${truncate(s.command)}`;
24
+ });
25
+ const header = fallback
26
+ ? `Product "${args.product}" has no logs property — showing the synthetic fallback (${cfg.sources.length} source):`
27
+ : `${cfg.sources.length} log source(s) on "${args.product}" (default: ${def}):`;
28
+ return {
29
+ content: [{ type: 'text', text: [header, ...lines].join('\n') }],
30
+ isError: false,
31
+ };
32
+ }
33
+
34
+ async function toolProductLogsAdd(args) {
35
+ if (!args.product) throw inputError('product is required');
36
+ if (!args.name) throw inputError('name is required');
37
+ if (typeof args.command !== 'string' || args.command.trim() === '') {
38
+ throw inputError('command is required (non-empty string)');
39
+ }
40
+ const { config, action } = await addLogSource(
41
+ args.product,
42
+ {
43
+ name: args.name,
44
+ command: args.command,
45
+ makeDefault: !!args.default,
46
+ pattern: args.pattern,
47
+ preset: args.preset,
48
+ },
49
+ args.user,
50
+ );
51
+ const def = config.default || config.sources[0]?.name || null;
52
+ const lines = [
53
+ `${action === 'added' ? 'Added' : 'Updated'} source "${args.name}" on product "${args.product}"${
54
+ args.default ? ' (default)' : ''
55
+ }.`,
56
+ `Command: ${truncate(args.command, 120)}`,
57
+ ];
58
+ if (args.preset) lines.push(`Preset: ${args.preset}`);
59
+ if (args.pattern) lines.push(`Pattern: ${truncate(args.pattern, 120)}`);
60
+ lines.push(`Sources: ${config.sources.length}${def ? `, default: ${def}` : ''}`);
61
+ return {
62
+ content: [{ type: 'text', text: lines.join('\n') }],
63
+ isError: false,
64
+ };
65
+ }
66
+
67
+ async function toolProductLogsPresets() {
68
+ const presets = listPresets();
69
+ const lines = [
70
+ `${presets.length} log line preset(s) shipped with the CLI:`,
71
+ ...presets.map((p) => ` · ${p.name} — ${p.description}`),
72
+ ];
73
+ return {
74
+ content: [{ type: 'text', text: lines.join('\n') }],
75
+ isError: false,
76
+ };
77
+ }
78
+
79
+ async function toolProductLogsRemove(args) {
80
+ if (!args.product) throw inputError('product is required');
81
+ if (!args.name) throw inputError('name is required');
82
+ const { removed, config } = await removeLogSource(args.product, args.name, args.user);
83
+ if (!removed) {
84
+ return {
85
+ content: [
86
+ {
87
+ type: 'text',
88
+ text: `Source "${args.name}" was not configured on product "${args.product}".`,
89
+ },
90
+ ],
91
+ isError: false,
92
+ };
93
+ }
94
+ if (config === null) {
95
+ return {
96
+ content: [
97
+ {
98
+ type: 'text',
99
+ text: `Removed last source "${args.name}" from product "${args.product}". Logs property cleared — falls back to the system journal.`,
100
+ },
101
+ ],
102
+ isError: false,
103
+ };
104
+ }
105
+ const def = config.default || config.sources[0]?.name || null;
106
+ return {
107
+ content: [
108
+ {
109
+ type: 'text',
110
+ text: `Removed source "${args.name}" from product "${args.product}". ${config.sources.length} source(s) remain${def ? `, default: ${def}` : ''}.`,
111
+ },
112
+ ],
113
+ isError: false,
114
+ };
115
+ }
116
+
117
+ async function toolProductLogsSetDefault(args) {
118
+ if (!args.product) throw inputError('product is required');
119
+ if (!args.name) throw inputError('name is required');
120
+ const config = await setDefaultLogSource(args.product, args.name, args.user);
121
+ return {
122
+ content: [
123
+ {
124
+ type: 'text',
125
+ text: `Default log source set to "${config.default}" on product "${args.product}".`,
126
+ },
127
+ ],
128
+ isError: false,
129
+ };
130
+ }
131
+
132
+ export const tools = [
133
+ {
134
+ name: 'thinr_product_logs_list',
135
+ description: `List the log sources configured on a product. Each source pairs a slug-ish \`name\` (visible to operators) with a \`command\` the agent will exec-stream when that source is selected. The default source — used when callers do not pick explicitly — is highlighted with a ★. When the product has no \`logs\` property yet, returns a synthetic fallback with a single \`system\` source running journalctl, matching the dashboard's pre-feature behaviour. Pair with thinr_product_logs_add to populate, thinr_product_logs_set_default to pick which one comes up first, or thinr_product_logs_remove to drop entries.`,
136
+ inputSchema: {
137
+ type: 'object',
138
+ properties: {
139
+ product: { type: 'string', description: 'Product ID.' },
140
+ user: { type: 'string', description: 'API user' },
141
+ },
142
+ required: ['product'],
143
+ },
144
+ handler: toolProductLogsList,
145
+ },
146
+ {
147
+ name: 'thinr_product_logs_add',
148
+ description: `Upsert a log source on a product: adds a new entry if \`name\` is unused, replaces the command in place when it already exists. The command is an opaque shell string the agent will exec-stream as-is when the dashboard or CLI selects this source — there is no protocol indirection, no special log "kind". Use it to expose journalctl unit tails, container log streams (\`docker logs -f thinger\`), application log files (\`tail -F /var/log/app.log\`), or any other follow-style command relevant to the device fleet.
149
+
150
+ Set \`default: true\` to mark this source as the active default; the previous default is preserved otherwise (or, on a fresh product, no default is recorded so consumers fall back to the first source). Names are slug-ish (letters, digits, underscore, dash; ≤ 32 chars). Up to 32 sources per product. The product does not need to exist on the agent — the configuration lives entirely server-side.
151
+
152
+ Optionally attach a \`pattern\` (regex with named groups \`time\`, \`level\`, \`msg\`) or a \`preset\` (named pattern shipped with the CLI; see thinr_product_logs_presets) to enable structured rendering in the dashboard. When the pattern captures a \`level\` group, the operator can filter the panel by severity (info+/warn+/error+). \`pattern\` and \`preset\` are mutually exclusive; omit both to keep the source rendered raw.`,
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ product: { type: 'string', description: 'Product ID.' },
157
+ name: {
158
+ type: 'string',
159
+ description:
160
+ 'Stable identifier for the source (letters, digits, underscore, dash; ≤ 32 chars). Reusing the name updates the existing entry.',
161
+ },
162
+ command: {
163
+ type: 'string',
164
+ description:
165
+ 'Shell command the agent should exec-stream when this source is selected (e.g. "journalctl --no-pager --output=short -f", "docker logs -f thinger", "tail -F /var/log/nginx/access.log"). Stored verbatim.',
166
+ },
167
+ default: {
168
+ type: 'boolean',
169
+ description:
170
+ 'Mark this source as the active default (default: false). When false the previous default is preserved.',
171
+ },
172
+ pattern: {
173
+ type: 'string',
174
+ description:
175
+ 'Custom regex with named groups (time, level, msg) for structured rendering in the dashboard. Mutually exclusive with `preset`. Validated to compile at config time.',
176
+ },
177
+ preset: {
178
+ type: 'string',
179
+ description:
180
+ 'Named preset for structured rendering (e.g. "spdlog", "journalctl", "nginx-error"). Use thinr_product_logs_presets to list available names. Mutually exclusive with `pattern`.',
181
+ },
182
+ user: { type: 'string', description: 'API user' },
183
+ },
184
+ required: ['product', 'name', 'command'],
185
+ },
186
+ handler: toolProductLogsAdd,
187
+ },
188
+ {
189
+ name: 'thinr_product_logs_remove',
190
+ description: `Remove a log source by name from a product. When the removed entry was the default, the default is dropped (consumers will fall back to the first remaining source). When the last source is removed, the entire \`logs\` property is deleted so the product reverts to the synthetic fallback. Idempotent — calling it on a missing source reports success without erroring.`,
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ product: { type: 'string', description: 'Product ID.' },
195
+ name: { type: 'string', description: 'Source name previously registered.' },
196
+ user: { type: 'string', description: 'API user' },
197
+ },
198
+ required: ['product', 'name'],
199
+ },
200
+ handler: toolProductLogsRemove,
201
+ },
202
+ {
203
+ name: 'thinr_product_logs_presets',
204
+ description: `List the named log line patterns shipped with the CLI. Each preset bundles a regex with named groups (\`time\`, \`level\`, \`msg\`) tuned for a common log format (journalctl short, spdlog, nginx access/error). Use the preset name with thinr_product_logs_add via the \`preset\` argument to enable structured rendering and level-based filtering on a source without typing the regex by hand.`,
205
+ inputSchema: {
206
+ type: 'object',
207
+ properties: {},
208
+ },
209
+ handler: toolProductLogsPresets,
210
+ },
211
+ {
212
+ name: 'thinr_product_logs_set_default',
213
+ description: `Set the active default log source on a product. The named source must already exist (use thinr_product_logs_add first). The default is what the dashboard surfaces first when an operator opens a device, and what \`thinr device logs <id>\` resolves to when no \`--source\` flag is passed. To clear the default, remove the matching source with thinr_product_logs_remove.`,
214
+ inputSchema: {
215
+ type: 'object',
216
+ properties: {
217
+ product: { type: 'string', description: 'Product ID.' },
218
+ name: { type: 'string', description: 'Source name to mark as default.' },
219
+ user: { type: 'string', description: 'API user' },
220
+ },
221
+ required: ['product', 'name'],
222
+ },
223
+ handler: toolProductLogsSetDefault,
224
+ },
225
+ ];
@@ -0,0 +1,211 @@
1
+ // @ts-check
2
+ import { parsePlaybook } from '../playbook/loader.js';
3
+ import { buildDryRunPlan, resolveTargets, runPlaybook } from '../playbook/runner.js';
4
+ import { playbookSchema } from '../playbook/schema.js';
5
+ import { inputError } from '../errors.js';
6
+
7
+ async function toolPlaybookSchema() {
8
+ const schema = playbookSchema();
9
+ return {
10
+ content: [{ type: 'text', text: JSON.stringify(schema, null, 2) }],
11
+ isError: false,
12
+ };
13
+ }
14
+
15
+ async function toolPlaybookValidate(args) {
16
+ if (typeof args.content !== 'string' || !args.content.trim()) {
17
+ throw inputError('content (YAML playbook source) is required');
18
+ }
19
+ try {
20
+ const pb = parsePlaybook(args.content);
21
+ const summary = {
22
+ valid: true,
23
+ name: pb.name,
24
+ description: pb.description,
25
+ target: pb.target,
26
+ steps: pb.steps.map((s) => ({ name: s.name, action: s.action })),
27
+ };
28
+ return {
29
+ content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
30
+ isError: false,
31
+ };
32
+ } catch (err) {
33
+ const msg = err instanceof Error ? err.message : String(err);
34
+ return {
35
+ content: [{ type: 'text', text: msg }],
36
+ isError: true,
37
+ _meta: { code: 'input_error', message: msg },
38
+ };
39
+ }
40
+ }
41
+
42
+ async function toolPlaybookRun(args) {
43
+ if (typeof args.content !== 'string' || !args.content.trim()) {
44
+ throw inputError('content (YAML playbook source) is required');
45
+ }
46
+ const pb = parsePlaybook(args.content);
47
+ if (args.target_product) pb.target.product = args.target_product;
48
+ if (args.target_group) pb.target.group = args.target_group;
49
+ if (Array.isArray(args.target_devices) && args.target_devices.length) {
50
+ pb.target.devices = args.target_devices;
51
+ }
52
+ if (Number.isInteger(args.concurrency) && args.concurrency > 0) {
53
+ pb.target.concurrency = args.concurrency;
54
+ }
55
+ if (typeof args.fail_fast === 'boolean') pb.target.fail_fast = args.fail_fast;
56
+ if (args.vars && typeof args.vars === 'object') {
57
+ pb.vars = { ...pb.vars, ...args.vars };
58
+ }
59
+
60
+ if (args.dry_run) {
61
+ const plan = buildDryRunPlan(pb);
62
+ const out = {
63
+ dry_run: true,
64
+ name: pb.name,
65
+ target: pb.target,
66
+ vars: pb.vars,
67
+ steps: plan,
68
+ };
69
+ return {
70
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
71
+ isError: false,
72
+ };
73
+ }
74
+
75
+ const devices = await resolveTargets(pb, { user: args.user });
76
+ if (devices.length === 0) {
77
+ return {
78
+ content: [
79
+ {
80
+ type: 'text',
81
+ text: `No devices match the playbook target (product="${pb.target.product}"${pb.target.group ? `, group="${pb.target.group}"` : ''}).`,
82
+ },
83
+ ],
84
+ isError: false,
85
+ };
86
+ }
87
+
88
+ const checkMode = !!args.check;
89
+ const results = await runPlaybook(pb, devices, {
90
+ user: args.user,
91
+ concurrency: pb.target.concurrency,
92
+ failFast: pb.target.fail_fast,
93
+ checkMode,
94
+ continueOnError: !!args.continue_on_error,
95
+ });
96
+
97
+ const okCount = results.filter((r) => r.ok).length;
98
+ let verdicts;
99
+ if (checkMode) {
100
+ verdicts = { changed: 0, unchanged: 0, unknown: 0 };
101
+ for (const r of results) {
102
+ for (const s of r.steps) {
103
+ if (s.verdict && s.verdict in verdicts) verdicts[s.verdict] += 1;
104
+ }
105
+ }
106
+ }
107
+ const summary = {
108
+ name: pb.name,
109
+ target: pb.target,
110
+ mode: checkMode ? 'check' : 'apply',
111
+ total: results.length,
112
+ ok: okCount,
113
+ failed: results.length - okCount,
114
+ ...(verdicts ? { verdicts } : {}),
115
+ results,
116
+ };
117
+ return {
118
+ content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
119
+ isError: okCount < results.length,
120
+ };
121
+ }
122
+
123
+ export const tools = [
124
+ {
125
+ name: 'thinr_playbook_schema',
126
+ description: `Return the JSON schema a ThinR playbook document must follow: accepted top-level fields, the target block, the \`vars\` block (plain and extended forms with \`default\` / \`description\` / \`type\` / \`overridable\` / \`required\`), the variable substitution syntax, and the full catalogue of step actions with their parameters. Call this ONCE at the start of any authoring session — whether the user is asking for a one-shot local playbook (\`thinr_playbook_run\`) or a persistent product playbook (\`thinr_product_playbook_write\`) — so the generated YAML validates on the first try.`,
127
+ inputSchema: {
128
+ type: 'object',
129
+ properties: {},
130
+ required: [],
131
+ },
132
+ handler: toolPlaybookSchema,
133
+ },
134
+ {
135
+ name: 'thinr_playbook_validate',
136
+ description: `Parse and validate a playbook document without running it or uploading it. Returns a structured summary (name, description, target, step list) on success, or a consolidated error message listing every issue. Use this while drafting YAML from a natural-language request: write, call validate, fix the reported errors, repeat — then hand the final content to thinr_product_playbook_write (persistent) or thinr_playbook_run (one-shot). Never upload YAML that hasn't validated cleanly; thinr_product_playbook_write will re-validate and reject invalid documents, but running validate during iteration keeps the loop tight without touching product storage.`,
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ content: {
141
+ type: 'string',
142
+ description: 'Full YAML source of the playbook.',
143
+ },
144
+ },
145
+ required: ['content'],
146
+ },
147
+ handler: toolPlaybookValidate,
148
+ },
149
+ {
150
+ name: 'thinr_playbook_run',
151
+ description: `Run a playbook (YAML source passed inline) against the product / devices declared in its target block. Optional overrides let you retarget a saved playbook without editing it. Set \`dry_run: true\` to get the execution plan without touching any device.
152
+
153
+ Response carries a consolidated report: devices visited, ok / failed counts, and per-device step outcomes (summary, duration, error message if any). \`isError\` is true whenever any device didn't complete every step cleanly, so clients can branch on a single flag.`,
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ content: {
158
+ type: 'string',
159
+ description: 'Full YAML source of the playbook.',
160
+ },
161
+ dry_run: {
162
+ type: 'boolean',
163
+ description:
164
+ 'Return the static plan without contacting devices (default: false).',
165
+ },
166
+ check: {
167
+ type: 'boolean',
168
+ description:
169
+ 'Contact devices read-only and report what each step would change, without writing. Each step verdict is one of "changed" / "unchanged" / "unknown". Actions that can\'t be predicted safely (exec, resource) always return "unknown".',
170
+ },
171
+ target_product: {
172
+ type: 'string',
173
+ description: 'Override the playbook\'s target.product.',
174
+ },
175
+ target_group: {
176
+ type: 'string',
177
+ description: 'Override the playbook\'s target.group.',
178
+ },
179
+ target_devices: {
180
+ type: 'array',
181
+ description: 'Override the playbook\'s target.devices with an explicit list.',
182
+ },
183
+ concurrency: {
184
+ type: 'number',
185
+ description: 'Override max parallel devices.',
186
+ },
187
+ fail_fast: {
188
+ type: 'boolean',
189
+ description:
190
+ 'Stop dequeueing new devices on the first failure (default: playbook\'s own value).',
191
+ },
192
+ continue_on_error: {
193
+ type: 'boolean',
194
+ description:
195
+ 'Keep running subsequent steps on a device even if one step fails (default: false — a failing step stops that device).',
196
+ },
197
+ vars: {
198
+ type: 'object',
199
+ description:
200
+ 'Merge into the playbook\'s `vars` block before substitution.',
201
+ },
202
+ user: {
203
+ type: 'string',
204
+ description: 'API user (admin impersonation).',
205
+ },
206
+ },
207
+ required: ['content'],
208
+ },
209
+ handler: toolPlaybookRun,
210
+ },
211
+ ];