@thinremote/thinr-cli 1.3.4 → 1.4.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 (98) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +117 -123
  3. package/bin/thinr.js +43 -105
  4. package/commands/_shared.js +10 -90
  5. package/commands/device/_shared.js +2 -2
  6. package/commands/device/exec.js +2 -2
  7. package/commands/device/fs.js +2 -2
  8. package/commands/device/index.js +5 -19
  9. package/commands/device/logs.js +4 -4
  10. package/commands/device/status.js +1 -1
  11. package/commands/device/transfer.js +2 -2
  12. package/commands/device/update.js +8 -2
  13. package/commands/fleet/upgrade.js +3 -3
  14. package/commands/playbook/run.js +2 -2
  15. package/commands/playbook/validate.js +2 -2
  16. package/commands/product/_shared.js +1 -1
  17. package/commands/product/exec.js +2 -2
  18. package/commands/product/fs.js +2 -2
  19. package/commands/product/index.js +4 -11
  20. package/commands/product/logs.js +2 -2
  21. package/commands/product/metric.js +2 -2
  22. package/commands/product/playbook.js +5 -5
  23. package/commands/product/property.js +4 -4
  24. package/commands/product/push.js +2 -2
  25. package/commands/product/resource.js +4 -4
  26. package/commands/product/status.js +3 -3
  27. package/dist/dashboard.js +12 -12
  28. package/docs/guides/thinremote-metrics-logs.md +59 -0
  29. package/docs/guides/thinremote-playbooks.md +133 -0
  30. package/docs/guides/thinremote-scripts.md +115 -0
  31. package/docs/guides/thinremote.md +116 -0
  32. package/lib/command-stream.js +3 -2
  33. package/lib/console.js +6 -6
  34. package/lib/constants.js +1 -19
  35. package/lib/dashboard/event-stream.js +5 -5
  36. package/lib/device-api.js +4 -4
  37. package/lib/guides.js +41 -0
  38. package/lib/identity.js +75 -0
  39. package/lib/local-files.js +54 -0
  40. package/lib/mcp/{tools-install.js → agent-install.js} +12 -61
  41. package/lib/mcp/helpers.js +5 -25
  42. package/lib/mcp/playbook-ops.js +393 -0
  43. package/lib/mcp/registry.js +9 -43
  44. package/lib/mcp/resource-hooks.js +53 -0
  45. package/lib/mcp/resource-types.js +857 -0
  46. package/lib/mcp/tools-exec.js +217 -177
  47. package/lib/monitoring.js +3 -3
  48. package/lib/playbook/actions.js +30 -18
  49. package/lib/playbook/expression.js +1 -4
  50. package/lib/playbook/fleet.js +4 -1
  51. package/lib/playbook/loader.js +16 -8
  52. package/lib/playbook/runner.js +40 -8
  53. package/lib/playbook/schema.js +108 -34
  54. package/lib/product/log-presets.js +1 -1
  55. package/lib/product/logs.js +2 -2
  56. package/lib/product-orchestrator.js +14 -9
  57. package/lib/product.js +29 -227
  58. package/lib/proxy.js +4 -4
  59. package/lib/status.js +13 -5
  60. package/package.json +4 -2
  61. package/commands/device/delete.js +0 -53
  62. package/commands/device/list.js +0 -55
  63. package/commands/device/property.js +0 -52
  64. package/commands/device/resource.js +0 -52
  65. package/commands/logout.js +0 -47
  66. package/commands/product/list.js +0 -86
  67. package/commands/product/props.js +0 -125
  68. package/commands/profile.js +0 -173
  69. package/lib/alarms.js +0 -223
  70. package/lib/api.js +0 -89
  71. package/lib/auth.js +0 -428
  72. package/lib/bucket.js +0 -82
  73. package/lib/concurrency.js +0 -52
  74. package/lib/config.js +0 -230
  75. package/lib/devices.js +0 -60
  76. package/lib/errors.js +0 -65
  77. package/lib/format.js +0 -114
  78. package/lib/mcp/server.js +0 -67
  79. package/lib/mcp/tools-alarms.js +0 -507
  80. package/lib/mcp/tools-buckets.js +0 -99
  81. package/lib/mcp/tools-devices.js +0 -235
  82. package/lib/mcp/tools-filesystem.js +0 -281
  83. package/lib/mcp/tools-logs.js +0 -225
  84. package/lib/mcp/tools-playbook.js +0 -210
  85. package/lib/mcp/tools-products.js +0 -1557
  86. package/lib/mcp/tools-profile.js +0 -635
  87. package/lib/mcp/tools-profiles.js +0 -30
  88. package/lib/mcp/tools-properties.js +0 -79
  89. package/lib/mcp/tools-resources.js +0 -78
  90. package/lib/mcp/tools-scripts.js +0 -212
  91. package/lib/mcp/tools-tokens.js +0 -369
  92. package/lib/output.js +0 -114
  93. package/lib/paths.js +0 -32
  94. package/lib/profile.js +0 -657
  95. package/lib/property.js +0 -75
  96. package/lib/resource.js +0 -151
  97. package/lib/storage.js +0 -117
  98. package/lib/tokens.js +0 -366
@@ -0,0 +1,857 @@
1
+ // @ts-check
2
+ import { readFile as readLocalFile, writeFile as writeLocalFile } from 'fs/promises';
3
+ import { basename, posix } from 'path';
4
+ import {
5
+ callDeviceResource,
6
+ getDeviceResources,
7
+ readDeviceResource,
8
+ } from '@thinger-io/cli-core/resource';
9
+ import { getDevices } from '@thinger-io/cli-core/devices';
10
+ import { inputError } from '@thinger-io/cli-core/errors';
11
+ import { toolName } from '@thinger-io/cli-core/mcp/naming';
12
+ import {
13
+ deleteProductPlaybook,
14
+ findProductPlaybook,
15
+ installProductScript,
16
+ listDashboardMetrics,
17
+ listProductPlaybooks,
18
+ listProductScripts,
19
+ readProductPlaybook,
20
+ readProductScript,
21
+ removeDashboardMetric,
22
+ removeProductScript,
23
+ upsertDashboardMetric,
24
+ uploadProductPlaybook,
25
+ } from '../product.js';
26
+ import { addLogSource, getProductLogs, listPresets, removeLogSource } from '../product/logs.js';
27
+ import { localFileToRead, localFileToWrite } from '../local-files.js';
28
+ import { getAPI } from './helpers.js';
29
+ import { agentInstallCommand } from './agent-install.js';
30
+ import {
31
+ DEFAULT_FAILURE_THRESHOLD,
32
+ playbookSchema,
33
+ toolPlaybookRun,
34
+ toolPlaybookValidate,
35
+ toolProductPlaybookRollout,
36
+ toolProductPlaybookRun,
37
+ } from './playbook-ops.js';
38
+
39
+ /**
40
+ * ThinRemote's own resources, served by the generic `resource_*` tools of
41
+ * cli-core next to the server's: a device's file system and scripts, and
42
+ * a product's scripts, dashboard metrics, log sources and playbooks. Plus
43
+ * two actions on the server's `device` type: updating the agent and the
44
+ * command that installs it.
45
+ *
46
+ * Create fails when the record is already there and update when it is
47
+ * not, as on the server; a file of `device_fs` is written either way.
48
+ */
49
+
50
+ /** @typedef {import('@thinger-io/cli-core/mcp/tools-resource').LocalType} LocalType */
51
+ /** @typedef {import('@thinger-io/cli-core/mcp/tools-resource').LocalAction} LocalAction */
52
+
53
+ /**
54
+ * One write at a time per product and kind: metrics, log sources and the
55
+ * playbook index are each one product property, read, changed and written
56
+ * back, and a client often sends several creates at once.
57
+ * @type {Map<string, Promise<unknown>>}
58
+ */
59
+ const queues = new Map();
60
+ function oneAtATime(ctx, kind, fn) {
61
+ const key = JSON.stringify([ctx.user ?? null, ctx.parent, kind]);
62
+ const run = (queues.get(key) || Promise.resolve()).then(fn, fn);
63
+ const settled = run.catch(() => {});
64
+ queues.set(key, settled);
65
+ settled.then(() => queues.get(key) === settled && queues.delete(key));
66
+ return run;
67
+ }
68
+
69
+ const exists = (what, id, where) =>
70
+ inputError(`${what} "${id}" already exists on ${where}: update it instead`);
71
+ const missing = (what, id, where) => {
72
+ const e = inputError(`No ${what} "${id}" on ${where}`);
73
+ return Object.assign(e, { code: 'not_found' });
74
+ };
75
+ const objectBody = (body) => {
76
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
77
+ throw inputError('body must be a JSON object');
78
+ }
79
+ return body;
80
+ };
81
+
82
+ // ─── device_fs ──────────────────────────────────────────────────────
83
+
84
+ /** Bytes that are not text: a NUL in the first few KB. */
85
+ const isBinary = (buf) => buf.subarray(0, 8192).includes(0);
86
+
87
+ /** @type {LocalType} */
88
+ const deviceFs = {
89
+ description:
90
+ 'Files and folders of a device with the agent: list a folder, read, write, delete; move, push and pull are actions.',
91
+ parent: 'device',
92
+ id: 'path',
93
+ schema: {
94
+ type: 'object',
95
+ properties: {
96
+ path: { type: 'string', description: 'Absolute path on the device (the id).' },
97
+ content: { type: 'string', description: 'Text to write (create, update).' },
98
+ type: {
99
+ type: 'string',
100
+ enum: ['file', 'directory'],
101
+ description: 'create only: "directory" creates a folder (default file).',
102
+ },
103
+ },
104
+ },
105
+ notes: 'Paths are absolute on the device and act as the agent\'s OS user (root for a system install; check before writing to /etc). resource_list takes filter {path: "<folder>", include_hidden?: true} (default "/", which means the agent\'s working directory, normally the root). resource_get reads a text file; binary files are copied with the pull action. resource_create writes a file (body {content}) or creates a folder (body {type: "directory"}); resource_update writes a file whether it exists or not; resource_delete deletes a file or a folder with all it holds, never "/" or a folder right under it (use device_exec for that). push and pull read and write files of the machine running this MCP server only inside its local folder (THINR_MCP_LOCAL_DIR, or where the server started), and pull replaces a file only with body.overwrite.',
106
+ async list(ctx) {
107
+ const { api } = getAPI(ctx.parent, ctx.user);
108
+ const asked = ctx.filter?.path || '/';
109
+ const entries = await api.listDir(asked, !!ctx.filter?.include_hidden);
110
+ // "/" alone is the agent's working directory, normally the root but
111
+ // not always: give each entry its real path.
112
+ let dir = asked;
113
+ if (posix.normalize(asked) === '/') {
114
+ dir = (await api.info('/').catch(() => null))?.path || '/';
115
+ }
116
+ return entries.map((e) => ({
117
+ name: e.name,
118
+ path: posix.join(dir, e.name),
119
+ type: e.type === 'directory' ? 'directory' : 'file',
120
+ size: e.size ?? 0,
121
+ ...(e.mode ? { mode: e.mode } : {}),
122
+ }));
123
+ },
124
+ async get(ctx) {
125
+ const { api } = getAPI(ctx.parent, ctx.user);
126
+ const buf = await api.readFile(ctx.id);
127
+ if (isBinary(buf)) {
128
+ return `Binary file of ${buf.byteLength} bytes: copy it to this machine with ${toolName('resource_call')} action "pull".`;
129
+ }
130
+ return buf.toString('utf8');
131
+ },
132
+ async create(body, ctx) {
133
+ const { api } = getAPI(ctx.parent, ctx.user);
134
+ if (body.type === 'directory') {
135
+ await api.mkdir(ctx.id);
136
+ return 'Folder created.';
137
+ }
138
+ if (typeof body.content !== 'string') {
139
+ throw inputError('body.content (text) is required, or type "directory" for a folder');
140
+ }
141
+ await api.writeFile(ctx.id, Buffer.from(body.content, 'utf8'));
142
+ return `Wrote ${Buffer.byteLength(body.content)} bytes.`;
143
+ },
144
+ async update(body, ctx) {
145
+ if (typeof body.content !== 'string') throw inputError('body.content (text) is required');
146
+ const { api } = getAPI(ctx.parent, ctx.user);
147
+ await api.writeFile(ctx.id, Buffer.from(body.content, 'utf8'));
148
+ return `Wrote ${Buffer.byteLength(body.content)} bytes.`;
149
+ },
150
+ async delete(ctx) {
151
+ // A slip of a path that empties a whole machine: not from here.
152
+ const path = posix.normalize(String(ctx.id));
153
+ if (!path.startsWith('/') || path.split('/').filter(Boolean).length < 2) {
154
+ throw inputError(
155
+ `Not deleting ${ctx.id}: only absolute paths at least two levels deep (/var/tmp/x). For more, ${toolName('device_exec')}.`,
156
+ );
157
+ }
158
+ const { api } = getAPI(ctx.parent, ctx.user);
159
+ await api.delete(path, true);
160
+ },
161
+ actions: {
162
+ move: {
163
+ description:
164
+ 'Move or rename the path `id` to body.destination; body.overwrite replaces what is there. Write to a temporary file and move it into place for a change that is never seen half done.',
165
+ body: {
166
+ type: 'object',
167
+ properties: { destination: { type: 'string' }, overwrite: { type: 'boolean' } },
168
+ required: ['destination'],
169
+ },
170
+ async run(body, ctx) {
171
+ const { destination, overwrite } = objectBody(body);
172
+ if (!destination) throw inputError('body.destination is required');
173
+ const { api } = getAPI(ctx.parent, ctx.user);
174
+ await api.move(ctx.id, destination, !!overwrite);
175
+ return `Moved ${ctx.id} → ${destination}.`;
176
+ },
177
+ },
178
+ push: {
179
+ description:
180
+ 'Copy a file of the machine running this MCP server (body.source, inside its local folder: see notes) to the device (body.destination; ending in "/" keeps the name). For binary or large files.',
181
+ on: 'collection',
182
+ body: {
183
+ type: 'object',
184
+ properties: { source: { type: 'string' }, destination: { type: 'string' } },
185
+ required: ['source', 'destination'],
186
+ },
187
+ async run(body, ctx) {
188
+ const { source, destination } = objectBody(body);
189
+ if (!source || !destination) {
190
+ throw inputError('body.source (local) and body.destination are required');
191
+ }
192
+ const local = localFileToRead(source);
193
+ const remote = destination.endsWith('/')
194
+ ? destination + basename(local)
195
+ : destination;
196
+ const content = await readLocalFile(local);
197
+ const { api } = getAPI(ctx.parent, ctx.user);
198
+ await api.writeFile(remote, content);
199
+ return `Pushed ${content.byteLength} bytes: ${local} → ${remote}.`;
200
+ },
201
+ },
202
+ pull: {
203
+ description:
204
+ 'Copy the file `id` of the device to the machine running this MCP server (body.destination, inside its local folder: see notes; ending in "/" keeps the name). An existing file is replaced only with body.overwrite.',
205
+ body: {
206
+ type: 'object',
207
+ properties: { destination: { type: 'string' }, overwrite: { type: 'boolean' } },
208
+ required: ['destination'],
209
+ },
210
+ async run(body, ctx) {
211
+ const { destination, overwrite } = objectBody(body);
212
+ if (!destination) throw inputError('body.destination (local) is required');
213
+ const local = localFileToWrite(
214
+ destination.endsWith('/') ? destination + basename(ctx.id) : destination,
215
+ undefined,
216
+ { overwrite: !!overwrite },
217
+ );
218
+ const { api } = getAPI(ctx.parent, ctx.user);
219
+ const buf = await api.readFile(ctx.id);
220
+ await writeLocalFile(local, buf);
221
+ return `Pulled ${buf.byteLength} bytes: ${ctx.id} → ${local}.`;
222
+ },
223
+ },
224
+ },
225
+ };
226
+
227
+ // ─── device_script ──────────────────────────────────────────────────
228
+
229
+ const SCRIPT_NAME = /^[A-Za-z0-9._-]+$/;
230
+
231
+ async function scriptsInfo(device, user) {
232
+ const info = await readDeviceResource(device, '$scripts/info', { user });
233
+ if (!info?.path) {
234
+ throw new Error('The agent did not return a scripts folder (is $scripts/info supported?)');
235
+ }
236
+ return { dir: info.path, scripts: Array.isArray(info.scripts) ? info.scripts : [] };
237
+ }
238
+
239
+ /** A script by the name the agent reports (`battery`) or its file (`battery.sh`). */
240
+ const findScript = (scripts, name) => {
241
+ const stem = name.replace(/\.[^./]+$/, '');
242
+ return scripts.find((s) => s.name === stem || s.path?.endsWith(`/${name}`));
243
+ };
244
+
245
+ async function installDeviceScript(body, ctx, { replace }) {
246
+ const name = ctx.id;
247
+ if (!SCRIPT_NAME.test(name)) {
248
+ throw inputError(`Invalid script name "${name}" (letters, digits, dot, dash, underscore)`);
249
+ }
250
+ if (typeof body.content !== 'string') throw inputError('body.content (the script) is required');
251
+ const { dir, scripts } = await scriptsInfo(ctx.parent, ctx.user);
252
+ const current = findScript(scripts, name);
253
+ if (current && current.path && basename(current.path) !== name) {
254
+ throw inputError(
255
+ `The script "${current.name}" is ${basename(current.path)}: use that id (delete it first to change its language)`,
256
+ );
257
+ }
258
+ if (current && !replace) throw exists('script', name, `device ${ctx.parent}`);
259
+ if (!current && replace) throw missing('script', name, `device ${ctx.parent}`);
260
+ const resource = name.replace(/\.[^./]+$/, '');
261
+ // The agent registers scripts at the root of the device's resources: a
262
+ // script named after a built-in one replaces it, and the device stops
263
+ // answering the real one.
264
+ if (!current && !body.force) {
265
+ const native = await getDeviceResources(ctx.parent, { user: ctx.user }).catch(() => ({}));
266
+ if (Object.prototype.hasOwnProperty.call(native, resource)) {
267
+ throw inputError(
268
+ `"${resource}" is already a resource of this device and the script would replace it: rename it, or pass force: true if that is the intent`,
269
+ );
270
+ }
271
+ }
272
+ const path = current?.path || `${dir}/${name}`;
273
+ const { api } = getAPI(ctx.parent, ctx.user);
274
+ await api.writeFile(path, body.content, true);
275
+ const chmod = await api.exec(`chmod +x ${JSON.stringify(path)}`, 10);
276
+ if (chmod.retcode !== 0) {
277
+ throw new Error(`chmod +x failed: ${chmod.stderr || `exit ${chmod.retcode}`}`);
278
+ }
279
+ const reloaded = await callDeviceResource(
280
+ ctx.parent,
281
+ '$scripts/reload',
282
+ {},
283
+ { user: ctx.user },
284
+ );
285
+ const found = (reloaded?.scripts || []).find((s) => s.name === resource);
286
+ return [
287
+ `Script at ${path}; resource "${resource}": call it with ${toolName('device_resource_call')}.`,
288
+ found?.describe
289
+ ? `Schema: ${JSON.stringify(found.describe)}`
290
+ : 'Schema: none (the script does not answer --describe): any input, raw output.',
291
+ ].join('\n');
292
+ }
293
+
294
+ /** @type {LocalType} */
295
+ const deviceScript = {
296
+ description:
297
+ 'Scripts installed on a device with the agent: each becomes a resource of the device (battery.sh → "battery").',
298
+ parent: 'device',
299
+ id: 'name',
300
+ schema: {
301
+ type: 'object',
302
+ properties: {
303
+ name: {
304
+ type: 'string',
305
+ description: 'File name (the id); without extension it names the resource.',
306
+ },
307
+ content: { type: 'string', description: 'Script source, shebang included.' },
308
+ force: {
309
+ type: 'boolean',
310
+ description:
311
+ 'Install even if it replaces a built-in resource (cmd, monitoring, update…).',
312
+ },
313
+ },
314
+ },
315
+ notes: 'The id is the file name with its extension ("uptime.sh"), in create, update and delete alike; the resource it becomes is named without it ("uptime"). Scripts run on whatever OS the device has: check it (resource_get of the device, agent.system_info) before relying on /proc or GNU tools. A script runs with the agent\'s privileges (root for a system install): installing one grants permanent remote execution of that code. Contract (--describe, JSON on stdin and stdout): {tool:doc_read} id "thinremote-scripts". For every device of a product use type "product_script" instead.',
316
+ async list(ctx) {
317
+ const { scripts } = await scriptsInfo(ctx.parent, ctx.user);
318
+ return scripts.map((s) => ({
319
+ name: s.name,
320
+ path: s.path,
321
+ ...(s.describe ? { describe: s.describe } : {}),
322
+ }));
323
+ },
324
+ async get(ctx) {
325
+ const { scripts } = await scriptsInfo(ctx.parent, ctx.user);
326
+ const script = findScript(scripts, ctx.id);
327
+ if (!script) throw missing('script', ctx.id, `device ${ctx.parent}`);
328
+ const { api } = getAPI(ctx.parent, ctx.user);
329
+ const content = (await api.readFile(script.path)).toString('utf8');
330
+ return { ...script, content };
331
+ },
332
+ create: (body, ctx) => installDeviceScript(body, ctx, { replace: false }),
333
+ update: (body, ctx) => installDeviceScript(body, ctx, { replace: true }),
334
+ async delete(ctx) {
335
+ const { scripts } = await scriptsInfo(ctx.parent, ctx.user);
336
+ const script = findScript(scripts, ctx.id);
337
+ if (!script) {
338
+ const names = scripts.map((s) => s.name).join(', ') || 'none';
339
+ throw missing('script', ctx.id, `device ${ctx.parent} (it has: ${names})`);
340
+ }
341
+ const { api } = getAPI(ctx.parent, ctx.user);
342
+ await api.delete(script.path, false);
343
+ await callDeviceResource(ctx.parent, '$scripts/reload', {}, { user: ctx.user });
344
+ return `Its resource "${script.name}" is gone.`;
345
+ },
346
+ };
347
+
348
+ // ─── product_script ─────────────────────────────────────────────────
349
+
350
+ async function writeProductScript(body, ctx, { replace }) {
351
+ // One API resource per stem: backup.sh and backup.py would share one.
352
+ const stem = String(ctx.id).replace(/\.[^.]+$/, '');
353
+ const same = (await listProductScripts(ctx.parent, ctx.user)).find((s) => s.stem === stem);
354
+ if (same && same.name !== ctx.id) {
355
+ throw inputError(
356
+ `The resource "${stem}" is already the script ${same.name}: use that id, or delete it first`,
357
+ );
358
+ }
359
+ if (same && !replace) throw exists('script', ctx.id, `product ${ctx.parent}`);
360
+ if (!same && replace) throw missing('script', ctx.id, `product ${ctx.parent}`);
361
+ const { steps } = await installProductScript({
362
+ product: ctx.parent,
363
+ name: ctx.id,
364
+ content: body.content,
365
+ input: body.input,
366
+ output: body.output,
367
+ user: ctx.user,
368
+ });
369
+ return [
370
+ ...steps.map((s) => `- ${s}`),
371
+ `Call it on any device of the product: ${toolName('device_resource_call')} resource "${stem}".`,
372
+ ].join('\n');
373
+ }
374
+
375
+ /** @type {LocalType} */
376
+ const productScript = {
377
+ description:
378
+ 'Scripts of a product: kept once in its storage, they run on any of its devices as a product API resource named after the file (backup.sh → "backup").',
379
+ parent: 'product',
380
+ id: 'name',
381
+ schema: {
382
+ type: 'object',
383
+ properties: {
384
+ name: { type: 'string', description: 'File name (the id), e.g. "backup.sh".' },
385
+ content: { type: 'string', description: 'Script source.' },
386
+ input: {
387
+ type: 'object',
388
+ description:
389
+ 'Input fields and their defaults ({"path": "/var"}); omit for a free-form payload.',
390
+ },
391
+ output: {
392
+ type: 'boolean',
393
+ description:
394
+ 'Whether it answers JSON on stdout (default true); false for fire-and-forget.',
395
+ },
396
+ },
397
+ },
398
+ notes: 'Writing one sets up all it needs (storage, wrapper, product code, API resource); the product must exist. Records listed with registered: false are files without their API resource. Contract and examples: {tool:doc_read} id "thinremote-scripts".',
399
+ list: (ctx) => listProductScripts(ctx.parent, ctx.user),
400
+ async get(ctx) {
401
+ return { name: ctx.id, content: await readProductScript(ctx.parent, ctx.id, ctx.user) };
402
+ },
403
+ create: (body, ctx) =>
404
+ oneAtATime(ctx, 'script', () => writeProductScript(body, ctx, { replace: false })),
405
+ update: (body, ctx) =>
406
+ oneAtATime(ctx, 'script', () => writeProductScript(body, ctx, { replace: true })),
407
+ async delete(ctx) {
408
+ const { steps } = await oneAtATime(ctx, 'script', () =>
409
+ removeProductScript({ product: ctx.parent, name: ctx.id, user: ctx.user }),
410
+ );
411
+ return steps.map((s) => `- ${s}`).join('\n');
412
+ },
413
+ };
414
+
415
+ // ─── product_metric ─────────────────────────────────────────────────
416
+
417
+ const METRIC_FIELDS = [
418
+ 'label',
419
+ 'resource',
420
+ 'field',
421
+ 'aggregation',
422
+ 'visualization',
423
+ 'interval',
424
+ 'unit',
425
+ ];
426
+
427
+ async function writeMetric(body, ctx, { replace }) {
428
+ const list = await listDashboardMetrics(ctx.parent, ctx.user);
429
+ const current = list.find((m) => m.name === ctx.id);
430
+ if (current && !replace) throw exists('metric', ctx.id, `product ${ctx.parent}`);
431
+ if (!current && replace) throw missing('metric', ctx.id, `product ${ctx.parent}`);
432
+ const metric = { ...(current || {}), name: ctx.id };
433
+ for (const k of METRIC_FIELDS) if (body[k] !== undefined) metric[k] = body[k];
434
+ const { count } = await upsertDashboardMetric(ctx.parent, metric, ctx.user);
435
+ return `The product has ${count} metric(s).`;
436
+ }
437
+
438
+ /** @type {LocalType} */
439
+ const productMetric = {
440
+ description:
441
+ "Metrics of thinr's terminal dashboard for a product (not a console dashboard): a value asked to every device and combined across the fleet.",
442
+ parent: 'product',
443
+ id: 'name',
444
+ schema: {
445
+ type: 'object',
446
+ properties: {
447
+ name: { type: 'string', description: 'Metric id.' },
448
+ label: { type: 'string', description: 'Shown in the panel.' },
449
+ resource: {
450
+ type: 'string',
451
+ description:
452
+ 'Product API resource asked to each device (usually a product script). Required.',
453
+ },
454
+ field: {
455
+ type: 'string',
456
+ description: 'Dot path to the value in the answer; omit for scalars.',
457
+ },
458
+ aggregation: {
459
+ type: 'string',
460
+ enum: ['sum', 'avg', 'max', 'min', 'count', 'top', 'none', 'distribution'],
461
+ description:
462
+ 'How the devices\' values combine; "distribution" counts devices per value (versions, kernels).',
463
+ },
464
+ visualization: {
465
+ type: 'string',
466
+ enum: ['kpi', 'bar', 'sparkline', 'list'],
467
+ description: 'kpi (default), sparkline (trend), list (pairs with distribution).',
468
+ },
469
+ interval: {
470
+ type: 'number',
471
+ description: 'Seconds between refreshes; omit to only relay what devices push.',
472
+ },
473
+ unit: { type: 'string', description: 'Suffix shown with the value ("%").' },
474
+ },
475
+ required: ['resource'],
476
+ },
477
+ notes: 'Kept in the product property "dashboard_metrics": change them here, not through the property. Recipe: {tool:doc_read} id "thinremote-metrics-logs".',
478
+ list: (ctx) => listDashboardMetrics(ctx.parent, ctx.user),
479
+ async get(ctx) {
480
+ const metric = (await listDashboardMetrics(ctx.parent, ctx.user)).find(
481
+ (m) => m.name === ctx.id,
482
+ );
483
+ if (!metric) throw missing('metric', ctx.id, `product ${ctx.parent}`);
484
+ return metric;
485
+ },
486
+ create: (body, ctx) =>
487
+ oneAtATime(ctx, 'metric', () => writeMetric(body, ctx, { replace: false })),
488
+ update: (body, ctx) =>
489
+ oneAtATime(ctx, 'metric', () => writeMetric(body, ctx, { replace: true })),
490
+ async delete(ctx) {
491
+ const { action } = await oneAtATime(ctx, 'metric', () =>
492
+ removeDashboardMetric(ctx.parent, ctx.id, ctx.user),
493
+ );
494
+ if (action !== 'removed') throw missing('metric', ctx.id, `product ${ctx.parent}`);
495
+ },
496
+ };
497
+
498
+ // ─── product_log_source ─────────────────────────────────────────────
499
+
500
+ async function logSources(product, user) {
501
+ const cfg = await getProductLogs(product, user);
502
+ const fallback = !!(/** @type {any} */ (cfg).__fallback);
503
+ const def = cfg.default || cfg.sources[0]?.name || null;
504
+ return {
505
+ fallback,
506
+ sources: cfg.sources.map((s) => ({
507
+ ...s,
508
+ default: s.name === def,
509
+ ...(fallback ? { fallback: true } : {}),
510
+ })),
511
+ };
512
+ }
513
+
514
+ async function writeLogSource(body, ctx, { replace }) {
515
+ const { sources, fallback } = await logSources(ctx.parent, ctx.user);
516
+ const current = fallback ? undefined : sources.find((s) => s.name === ctx.id);
517
+ if (current && !replace) throw exists('log source', ctx.id, `product ${ctx.parent}`);
518
+ if (!current && replace) throw missing('log source', ctx.id, `product ${ctx.parent}`);
519
+ const command = body.command ?? current?.command;
520
+ if (typeof command !== 'string' || !command.trim()) {
521
+ throw inputError('body.command is required');
522
+ }
523
+ const { config } = await addLogSource(
524
+ ctx.parent,
525
+ {
526
+ name: ctx.id,
527
+ command,
528
+ makeDefault: !!body.default,
529
+ // Giving one of them (even null) replaces both: they exclude each other.
530
+ ...('pattern' in body || 'preset' in body
531
+ ? { pattern: body.pattern ?? undefined, preset: body.preset ?? undefined }
532
+ : { pattern: current?.pattern, preset: current?.preset }),
533
+ },
534
+ ctx.user,
535
+ );
536
+ const def = config.default || config.sources[0]?.name;
537
+ return `The product has ${config.sources.length} log source(s); default: ${def}.`;
538
+ }
539
+
540
+ /** @type {LocalType} */
541
+ const productLogSource = {
542
+ description:
543
+ "Log sources of a product: named shell commands the agent streams when someone opens a device's logs in thinr.",
544
+ parent: 'product',
545
+ id: 'name',
546
+ schema: {
547
+ type: 'object',
548
+ properties: {
549
+ name: {
550
+ type: 'string',
551
+ description: 'Source id (letters, digits, _ and -; up to 32).',
552
+ },
553
+ command: {
554
+ type: 'string',
555
+ description:
556
+ 'What the agent runs and streams: "journalctl -f", "docker logs -f app", "tail -F <file>".',
557
+ },
558
+ default: {
559
+ type: 'boolean',
560
+ description: 'Shown first (and by `thinr device logs` without --source).',
561
+ },
562
+ preset: {
563
+ type: 'string',
564
+ description: 'Named line format for structured rendering (see presets below).',
565
+ },
566
+ pattern: {
567
+ type: 'string',
568
+ description: 'Or a regex with named groups time, level and msg (not with preset).',
569
+ },
570
+ },
571
+ required: ['command'],
572
+ },
573
+ notes: 'Without any source a product shows one `system` journal source, listed with fallback: true; removing the last one brings it back. Details: {tool:doc_read} id "thinremote-metrics-logs".',
574
+ describe: () => ({
575
+ presets: Object.fromEntries(listPresets().map((p) => [p.name, p.description])),
576
+ }),
577
+ list: async (ctx) => (await logSources(ctx.parent, ctx.user)).sources,
578
+ async get(ctx) {
579
+ const source = (await logSources(ctx.parent, ctx.user)).sources.find(
580
+ (s) => s.name === ctx.id,
581
+ );
582
+ if (!source) throw missing('log source', ctx.id, `product ${ctx.parent}`);
583
+ return source;
584
+ },
585
+ create: (body, ctx) =>
586
+ oneAtATime(ctx, 'logs', () => writeLogSource(body, ctx, { replace: false })),
587
+ update: (body, ctx) =>
588
+ oneAtATime(ctx, 'logs', () => writeLogSource(body, ctx, { replace: true })),
589
+ async delete(ctx) {
590
+ const { removed, config } = await oneAtATime(ctx, 'logs', () =>
591
+ removeLogSource(ctx.parent, ctx.id, ctx.user),
592
+ );
593
+ if (!removed) throw missing('log source', ctx.id, `product ${ctx.parent}`);
594
+ if (config === null)
595
+ return 'It was the last one: the product falls back to the system journal.';
596
+ },
597
+ };
598
+
599
+ // ─── product_playbook ───────────────────────────────────────────────
600
+
601
+ /** What resource_describe shows next to the format: fields at the top level. */
602
+ const PLAYBOOK_EXAMPLE = `name: app-config
603
+ description: Deploy app.conf and restart the service
604
+ target:
605
+ product: gateways
606
+ vars:
607
+ port: { default: 8080, type: number, description: Listening port }
608
+ steps:
609
+ - name: Write it to a temporary file
610
+ action: write
611
+ path: /tmp/app.conf.new
612
+ content: "port={{ port }}"
613
+ - name: Move it into place
614
+ action: move
615
+ source: /tmp/app.conf.new
616
+ destination: /etc/app.conf
617
+ overwrite: true
618
+ - name: Restart
619
+ action: exec
620
+ command: systemctl restart app
621
+ `;
622
+
623
+ async function writePlaybook(body, ctx, { replace }) {
624
+ const there = await findProductPlaybook(ctx.parent, ctx.id, ctx.user);
625
+ if (there && !replace) throw exists('playbook', ctx.id, `product ${ctx.parent}`);
626
+ if (!there && replace) throw missing('playbook', ctx.id, `product ${ctx.parent}`);
627
+ const content =
628
+ body.content ?? (there && (await readProductPlaybook(ctx.parent, ctx.id, ctx.user)));
629
+ if (typeof content !== 'string') throw inputError('body.content (the YAML) is required');
630
+ const result = await uploadProductPlaybook({
631
+ product: ctx.parent,
632
+ name: ctx.id,
633
+ content,
634
+ description: body.description ?? there?.description,
635
+ user: ctx.user,
636
+ });
637
+ return result.steps.map((s) => `- ${s}`).join('\n');
638
+ }
639
+
640
+ /** @type {LocalType} */
641
+ const productPlaybook = {
642
+ description:
643
+ 'Playbooks of a product: YAML steps the agent runs on each device (write a file, run a command, install a script…), validated, tried and rolled out in batches.',
644
+ parent: 'product',
645
+ id: 'name',
646
+ schema: {
647
+ type: 'object',
648
+ properties: {
649
+ name: { type: 'string', description: 'Playbook id (letters, digits, _ and -).' },
650
+ content: {
651
+ type: 'string',
652
+ description: 'The YAML, following `playbook_format` below.',
653
+ },
654
+ description: { type: 'string', description: "Defaults to the YAML's own description." },
655
+ },
656
+ },
657
+ notes: 'Flow: write the YAML following playbook_format, check it with the validate action, save it with resource_create (validated again: nothing is saved if invalid), try it with the run action on one device (dry_run, then check, then for real), then roll it out with the rollout action. The run and validate actions also take an unsaved playbook: no id, the YAML in body.content. Rollouts change real machines: confirm with the user and start with small batches. Guide: {tool:doc_read} id "thinremote-playbooks".',
658
+ describe: () => {
659
+ // The schema calls the top-level fields "document": said plainly,
660
+ // with an example, so nobody nests them under a `document:` key.
661
+ const { document, ...rest } = playbookSchema();
662
+ return {
663
+ playbook_format: { top_level_fields: document, ...rest },
664
+ playbook_example: PLAYBOOK_EXAMPLE,
665
+ };
666
+ },
667
+ list: (ctx) => listProductPlaybooks(ctx.parent, ctx.user),
668
+ async get(ctx) {
669
+ const entry = await findProductPlaybook(ctx.parent, ctx.id, ctx.user);
670
+ if (!entry) throw missing('playbook', ctx.id, `product ${ctx.parent}`);
671
+ return { ...entry, content: await readProductPlaybook(ctx.parent, ctx.id, ctx.user) };
672
+ },
673
+ create: (body, ctx) =>
674
+ oneAtATime(ctx, 'playbooks', () => writePlaybook(body, ctx, { replace: false })),
675
+ update: (body, ctx) =>
676
+ oneAtATime(ctx, 'playbooks', () => writePlaybook(body, ctx, { replace: true })),
677
+ async delete(ctx) {
678
+ const { removed } = await oneAtATime(ctx, 'playbooks', () =>
679
+ deleteProductPlaybook({ product: ctx.parent, name: ctx.id, user: ctx.user }),
680
+ );
681
+ if (!removed) throw missing('playbook', ctx.id, `product ${ctx.parent}`);
682
+ },
683
+ actions: {
684
+ validate: {
685
+ description:
686
+ 'Check a playbook without running or saving it: a summary, or every issue found. The saved one (`id`) or the YAML in body.content.',
687
+ on: 'either',
688
+ body: { type: 'object', properties: { content: { type: 'string' } } },
689
+ async run(body, ctx) {
690
+ const content = await playbookSource(body, ctx);
691
+ return toolPlaybookValidate({ content });
692
+ },
693
+ },
694
+ run: {
695
+ description:
696
+ 'Run a playbook now, on the target given: body.device (one device: the pre-flight before a rollout), body.devices (of this product), body.group, or body.all: true for every device of the product at once (no batches or failure threshold: for a fleet, prefer rollout). The playbook is the saved one (`id`) or the YAML in body.content. body.dry_run gives the plan without contacting devices; body.check contacts them read-only and says what each step would change. body.vars overrides its variables (checked against what it declares).',
697
+ on: 'either',
698
+ body: {
699
+ type: 'object',
700
+ properties: {
701
+ content: { type: 'string', description: 'YAML of an unsaved playbook.' },
702
+ device: { type: 'string' },
703
+ devices: { type: 'array', items: { type: 'string' } },
704
+ group: { type: 'string' },
705
+ all: { type: 'boolean', description: 'Every device of the product.' },
706
+ vars: { type: 'object', description: 'Variable overrides (never stored).' },
707
+ dry_run: { type: 'boolean' },
708
+ check: { type: 'boolean' },
709
+ concurrency: { type: 'number' },
710
+ fail_fast: { type: 'boolean' },
711
+ continue_on_error: { type: 'boolean' },
712
+ },
713
+ },
714
+ async run(body, ctx) {
715
+ const b = body && typeof body === 'object' ? body : {};
716
+ const listed = Array.isArray(b.devices) && b.devices.length > 0;
717
+ const targets = [b.device, listed, b.group, b.all === true].filter(Boolean);
718
+ if (targets.length !== 1) {
719
+ throw inputError(
720
+ 'give one target in the body: device (one device), devices, group, or all: true (every device of the product; for a fleet prefer the rollout action)',
721
+ );
722
+ }
723
+ if (listed) {
724
+ const ids = new Set(
725
+ (await getDevices({ product: ctx.parent }, ctx.user)).map((d) => d.device),
726
+ );
727
+ const foreign = b.devices.filter((d) => !ids.has(d));
728
+ if (foreign.length) {
729
+ throw inputError(`Not devices of ${ctx.parent}: ${foreign.join(', ')}`);
730
+ }
731
+ }
732
+ if (ctx.id && b.device && !b.content) {
733
+ return toolProductPlaybookRun({
734
+ product: ctx.parent,
735
+ name: ctx.id,
736
+ device_id: b.device,
737
+ vars: b.vars,
738
+ dry_run: b.dry_run,
739
+ check: b.check,
740
+ user: ctx.user,
741
+ });
742
+ }
743
+ return toolPlaybookRun({
744
+ content: await playbookSource(b, ctx),
745
+ target_product: ctx.parent,
746
+ target_group: b.group,
747
+ target_devices: b.device ? [b.device] : listed ? b.devices : undefined,
748
+ target_all: b.all === true || !!b.group,
749
+ vars: b.vars,
750
+ dry_run: b.dry_run,
751
+ check: b.check,
752
+ concurrency: b.concurrency,
753
+ fail_fast: b.fail_fast,
754
+ continue_on_error: b.continue_on_error,
755
+ user: ctx.user,
756
+ });
757
+ },
758
+ },
759
+ rollout: {
760
+ description: `Roll the saved playbook \`id\` out across the product's devices in batches of body.batch_size (default 5), stopping when the failure rate reaches body.failure_threshold (percent, default ${DEFAULT_FAILURE_THRESHOLD}). Narrow it with body.group or body.filter; offline devices are skipped unless body.include_offline. body.dry_run lists plan and devices; body.check says what would change. Writes a report to playbooks/runs/ in the product storage. Changes real machines: confirm with the user and try it with run on one device first.`,
761
+ body: {
762
+ type: 'object',
763
+ properties: {
764
+ batch_size: { type: 'number' },
765
+ failure_threshold: { type: 'number' },
766
+ group: { type: 'string' },
767
+ filter: { type: 'object' },
768
+ include_offline: { type: 'boolean' },
769
+ vars: { type: 'object' },
770
+ dry_run: { type: 'boolean' },
771
+ check: { type: 'boolean' },
772
+ },
773
+ },
774
+ run(body, ctx) {
775
+ const b = body && typeof body === 'object' ? body : {};
776
+ return toolProductPlaybookRollout({
777
+ ...b,
778
+ product: ctx.parent,
779
+ name: ctx.id,
780
+ user: ctx.user,
781
+ });
782
+ },
783
+ },
784
+ },
785
+ };
786
+
787
+ /** The YAML an action works on: body.content, or the saved playbook `id`. */
788
+ async function playbookSource(body, ctx) {
789
+ if (typeof body?.content === 'string' && body.content.trim()) return body.content;
790
+ if (!ctx.id)
791
+ throw inputError('give the playbook: `id` (saved on the product) or body.content (YAML)');
792
+ const entry = await findProductPlaybook(ctx.parent, ctx.id, ctx.user);
793
+ if (!entry) {
794
+ const names = (await listProductPlaybooks(ctx.parent, ctx.user)).map((e) => e.name);
795
+ throw missing(
796
+ 'playbook',
797
+ ctx.id,
798
+ `product ${ctx.parent} (it has: ${names.join(', ') || 'none'})`,
799
+ );
800
+ }
801
+ return readProductPlaybook(ctx.parent, ctx.id, ctx.user);
802
+ }
803
+
804
+ /** @type {Record<string, LocalType>} */
805
+ export const resourceTypes = {
806
+ device_fs: deviceFs,
807
+ device_script: deviceScript,
808
+ product_script: productScript,
809
+ product_metric: productMetric,
810
+ product_log_source: productLogSource,
811
+ product_playbook: productPlaybook,
812
+ };
813
+
814
+ /** @type {Record<string, Record<string, LocalAction>>} */
815
+ export const resourceActions = {
816
+ device: {
817
+ agent_update: {
818
+ description:
819
+ 'Check for a new version of the ThinRemote agent of device `id` (body {action: "check"}) or install it (body {action: "apply", channel?: "latest" | "main" | "develop"}).',
820
+ body: {
821
+ type: 'object',
822
+ properties: {
823
+ action: { type: 'string', enum: ['check', 'apply'] },
824
+ channel: { type: 'string' },
825
+ },
826
+ required: ['action'],
827
+ },
828
+ async run(body, ctx) {
829
+ const { action, channel } = objectBody(body);
830
+ if (action !== 'check' && action !== 'apply') {
831
+ throw inputError('body.action must be "check" or "apply"');
832
+ }
833
+ const { api } = getAPI(ctx.id, ctx.user);
834
+ return api.callResource('update', { action, channel: channel || 'latest' });
835
+ },
836
+ },
837
+ agent_install: {
838
+ description:
839
+ "The one-line shell command that installs the ThinRemote agent on a new machine and registers it as a device, with this profile's token (a credential: share it only through trusted channels). body: device (id; default the hostname), product, channel, sudo, overwrite, no_start, no_verify_ssl, http.",
840
+ on: 'collection',
841
+ body: {
842
+ type: 'object',
843
+ properties: {
844
+ device: { type: 'string' },
845
+ product: { type: 'string' },
846
+ channel: { type: 'string', enum: ['latest', 'main', 'develop'] },
847
+ sudo: { type: 'boolean' },
848
+ overwrite: { type: 'boolean' },
849
+ no_start: { type: 'boolean' },
850
+ no_verify_ssl: { type: 'boolean' },
851
+ http: { type: 'boolean' },
852
+ },
853
+ },
854
+ run: (body) => agentInstallCommand(body && typeof body === 'object' ? body : {}),
855
+ },
856
+ },
857
+ };