@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,75 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Identity of the `thinr` CLI on top of @thinger-io/cli-core: its names,
5
+ * where its profiles live, the OAuth client registered for it on the
6
+ * ThinRemote servers, and the shape every ThinRemote product starts with.
7
+ *
8
+ * Profiles stay apart from the `thinger` CLI's (~/.config/thinger-cli,
9
+ * THINGER_*), so neither MCP server operates on the other's servers.
10
+ *
11
+ * @type {import('@thinger-io/cli-core/identity').CliIdentity}
12
+ */
13
+ export const thinrIdentity = {
14
+ name: 'thinr',
15
+ displayName: 'ThinRemote',
16
+ description: 'CLI for ThinRemote - Remote management for IoT devices',
17
+ configDir: 'thinr-cli',
18
+ envPrefix: 'THINR',
19
+ token: { id: 'ThinRemote', name: 'ThinRemote' },
20
+ oauth: { clientId: '3b40164d28730e416cbd' },
21
+ defaultServer: 'console.thinr.io',
22
+ mcp: { serverName: 'thinr', toolPrefix: 'thinr' },
23
+ // ThinRemote products: `type: thinremote`, a Linux icon, and a
24
+ // `monitoring` bucket fed every 60 s from the agent's `monitoring`
25
+ // resource.
26
+ productTemplate: {
27
+ description: 'ThinRemote product',
28
+ config: {
29
+ type: 'thinremote',
30
+ icons: [
31
+ {
32
+ conditions: [],
33
+ icon: {
34
+ type: 'fa',
35
+ source: 'fab fa-linux',
36
+ color: '#000000',
37
+ background: '#ffffff',
38
+ },
39
+ },
40
+ ],
41
+ },
42
+ profile: {
43
+ buckets: {
44
+ monitoring: {
45
+ name: 'Monitoring Data',
46
+ description: 'ThinRemote device metrics',
47
+ enabled: true,
48
+ backend: 'mongodb',
49
+ data: {
50
+ source: 'resource',
51
+ resource: 'monitoring',
52
+ update: 'interval',
53
+ interval: 60,
54
+ magnitude: 'second',
55
+ payload: '{{payload}}',
56
+ payload_function: '',
57
+ payload_type: 'source_payload',
58
+ },
59
+ retention: { period: 3, unit: 'months' },
60
+ tags: [],
61
+ },
62
+ },
63
+ },
64
+ },
65
+ };
66
+
67
+ /** Server-level guidance sent to MCP clients on `initialize`. */
68
+ export const mcpInstructions = `Tools to operate ThinRemote fleets: machines running thinr-agent on a Thinger.io server, with the permissions of the token stored in the CLI profile.
69
+
70
+ - Read the guide first: thinr_doc_read id "thinremote" (the agent, products, which tool for each need, recipes); thinr_doc_read without id lists the rest (scripts, playbooks, metrics and logs) and the server's own docs.
71
+ - Almost everything is a resource type behind thinr_resource_list / _get / _create / _update / _delete / _call, described by thinr_resource_describe: the server's (device, product, bucket, token, alarm…) and ThinRemote's: device_fs (a device's files and folders, parent = the device), device_script, product_script, product_metric, product_log_source and product_playbook (parent = the product; actions validate, run and rollout). Actions on devices: agent_update, agent_install (the command that installs the agent).
72
+ - Shell commands: thinr_device_exec, on one device or on several (devices, product, group, all).
73
+ - Monitoring: live with thinr_device_resource_call resource "monitoring"; history with thinr_bucket_data_read bucket "monitoring" (device for one), stored only for devices of products that have that bucket (ThinRemote products do).
74
+ - List before acting to discover ids; never guess them. Fleet changes (thinr_device_exec on several devices, playbook rollouts) and deletes act immediately: confirm with the user first.
75
+ - Every tool takes an optional \`profile\` (another configured server, see thinr_profile_list) and most take \`user\` (another account, admin only).`;
@@ -0,0 +1,54 @@
1
+ // @ts-check
2
+ import { existsSync, lstatSync, realpathSync } from 'fs';
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'path';
4
+ import { inputError } from '@thinger-io/cli-core/errors';
5
+
6
+ /**
7
+ * Files of the machine running the MCP server, as a model names them for a
8
+ * push or a pull: kept inside one folder, so a prompt injected through a
9
+ * device (a log line, a file) cannot have the model send ~/.ssh away or
10
+ * overwrite a shell profile. The folder is THINR_MCP_LOCAL_DIR, or the
11
+ * folder the server was started in.
12
+ */
13
+ export function mcpLocalRoot() {
14
+ return resolve(process.env.THINR_MCP_LOCAL_DIR || process.cwd());
15
+ }
16
+
17
+ const inside = (root, path) => {
18
+ const rel = relative(root, path);
19
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.startsWith(`..${sep}`));
20
+ };
21
+
22
+ const outside = (path, root) =>
23
+ inputError(
24
+ `${path} is outside ${root}, the only folder of this machine the MCP server reads and writes (THINR_MCP_LOCAL_DIR changes it)`,
25
+ );
26
+
27
+ /** A file to read (push): it must exist inside the root, links resolved. */
28
+ export function localFileToRead(path, root = mcpLocalRoot()) {
29
+ const realRoot = realpathSync(root);
30
+ const wanted = resolve(realRoot, path);
31
+ if (!existsSync(wanted)) throw inputError(`No local file ${wanted}`);
32
+ const real = realpathSync(wanted);
33
+ if (!inside(realRoot, real)) throw outside(real, realRoot);
34
+ return real;
35
+ }
36
+
37
+ /**
38
+ * A file to write (pull): its folder inside the root, links resolved; an
39
+ * existing file only replaced when asked, and never through a link.
40
+ */
41
+ export function localFileToWrite(path, root = mcpLocalRoot(), { overwrite = false } = {}) {
42
+ const realRoot = realpathSync(root);
43
+ const wanted = resolve(realRoot, path);
44
+ const dir = dirname(wanted);
45
+ if (!existsSync(dir)) throw inputError(`No local folder ${dir}`);
46
+ const target = resolve(realpathSync(dir), wanted.slice(dir.length + 1));
47
+ if (!inside(realRoot, target)) throw outside(target, realRoot);
48
+ if (existsSync(target)) {
49
+ if (lstatSync(target).isSymbolicLink())
50
+ throw inputError(`${target} is a link: not written`);
51
+ if (!overwrite) throw inputError(`${target} exists: pass overwrite to replace it`);
52
+ }
53
+ return target;
54
+ }
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
- import { requireConfig } from '../config.js';
3
- import { inputError } from '../errors.js';
2
+ import { requireConfig } from '@thinger-io/cli-core/config';
3
+ import { inputError } from '@thinger-io/cli-core/errors';
4
4
 
5
5
  const CHANNEL_SCRIPT = {
6
6
  latest: 'install.sh',
@@ -24,7 +24,14 @@ function decodeJwtClaims(token) {
24
24
  }
25
25
  }
26
26
 
27
- async function toolAgentInstallCommand(args) {
27
+ /**
28
+ * The command that installs the agent on a new machine: the `agent_install`
29
+ * action of the `device` type (see resource-types.js).
30
+ *
31
+ * @param {{ device?: string, product?: string, channel?: string, sudo?: boolean,
32
+ * overwrite?: boolean, no_start?: boolean, no_verify_ssl?: boolean, http?: boolean }} args
33
+ */
34
+ export async function agentInstallCommand(args) {
28
35
  // requireConfig throws `not_configured` if there's no active profile
29
36
  // with a token — classifyError in server.js surfaces that cleanly.
30
37
  const config = requireConfig();
@@ -41,7 +48,7 @@ async function toolAgentInstallCommand(args) {
41
48
  const url = `${scheme}://get.thinremote.io/${script}`;
42
49
 
43
50
  const agentArgs = ['install', '--token', shQuote(config.token)];
44
- if (args.device_id) agentArgs.push('--device', shQuote(args.device_id));
51
+ if (args.device) agentArgs.push('--device', shQuote(args.device));
45
52
  if (args.product) agentArgs.push('--product', shQuote(args.product));
46
53
  if (args.overwrite) agentArgs.push('--overwrite');
47
54
  if (args.no_start) agentArgs.push('--no-start');
@@ -92,61 +99,5 @@ async function toolAgentInstallCommand(args) {
92
99
  sections.push('', 'Warnings:', ...warnings.map((w) => `- ${w}`));
93
100
  }
94
101
 
95
- return {
96
- content: [{ type: 'text', text: sections.join('\n') }],
97
- isError: false,
98
- };
102
+ return sections.join('\n');
99
103
  }
100
-
101
- export const tools = [
102
- {
103
- name: 'thinr_agent_install_command',
104
- description:
105
- "Generate a one-liner shell command that installs thinr-agent on a remote host using the active profile's token for auto-provisioning. Meant to be run on the target machine (directly or piped through `ssh`). The token is embedded verbatim, so only share the output through trusted channels.",
106
- inputSchema: {
107
- type: 'object',
108
- properties: {
109
- device_id: {
110
- type: 'string',
111
- description:
112
- "Custom device identifier. Omit to let the agent default to the target host's hostname.",
113
- },
114
- product: {
115
- type: 'string',
116
- description:
117
- 'Product to associate the device with. Omit to auto-detect or create the default product.',
118
- },
119
- overwrite: {
120
- type: 'boolean',
121
- description:
122
- 'Auto-overwrite an existing device with the same id on the server.',
123
- },
124
- no_start: {
125
- type: 'boolean',
126
- description: 'Install the service but do not start it.',
127
- },
128
- no_verify_ssl: {
129
- type: 'boolean',
130
- description: 'Disable TLS certificate verification (for self-signed servers).',
131
- },
132
- channel: {
133
- type: 'string',
134
- enum: ['latest', 'main', 'develop'],
135
- description: 'Release channel. Defaults to `latest` (stable).',
136
- },
137
- sudo: {
138
- type: 'boolean',
139
- description:
140
- 'Pipe into `sudo sh` instead of `sh`. Needed on Linux hosts when running as a non-root user, since installing the systemd service writes under /etc.',
141
- },
142
- http: {
143
- type: 'boolean',
144
- description:
145
- 'Use http://get.thinremote.io instead of https (for devices without working HTTPS).',
146
- },
147
- },
148
- required: [],
149
- },
150
- handler: toolAgentInstallCommand,
151
- },
152
- ];
@@ -1,34 +1,14 @@
1
1
  // @ts-check
2
2
  import { createDeviceAPI } from '../device-api.js';
3
- import { getActiveProfile, getProfile, setActiveProfile } from '../config.js';
4
- import { setBaseURL } from '../api.js';
5
- import { inputError } from '../errors.js';
3
+ import { inputError } from '@thinger-io/cli-core/errors';
4
+ import { toolName } from '@thinger-io/cli-core/mcp/naming';
6
5
 
7
6
  /** Build a device-scoped API client. Tools must always pass `device`
8
7
  * explicitly — the server has no per-session default. */
9
8
  export function getAPI(device, user) {
10
9
  if (!device)
11
- throw inputError('device is required. Use thinr_devices to list available devices.');
10
+ throw inputError(
11
+ `device is required. Use ${toolName('resource_list')} (type "device") to list available devices.`,
12
+ );
12
13
  return { api: createDeviceAPI(device, { user: user || undefined }), device };
13
14
  }
14
-
15
- /** Run `fn` with the active profile temporarily switched to `profile`
16
- * (when supplied) so callers can target any configured environment per
17
- * tool call without persisting the change. */
18
- export async function withProfile(profile, fn) {
19
- if (!profile) return fn();
20
- const previous = getActiveProfile();
21
- const target = getProfile(profile);
22
- if (!target) {
23
- throw inputError(`Unknown profile: ${profile}. Use thinr_profiles to list available ones.`);
24
- }
25
- setActiveProfile(profile);
26
- if (target.server) setBaseURL(`https://${target.server}`);
27
- try {
28
- return await fn();
29
- } finally {
30
- setActiveProfile(previous);
31
- const restore = previous ? getProfile(previous) : null;
32
- if (restore?.server) setBaseURL(`https://${restore.server}`);
33
- }
34
- }
@@ -0,0 +1,393 @@
1
+ // @ts-check
2
+ import {
3
+ findProductPlaybook,
4
+ listProductPlaybooks,
5
+ readProductPlaybook,
6
+ uploadFleetRunReport,
7
+ } from '../product.js';
8
+ import { requireConfig } from '@thinger-io/cli-core/config';
9
+ import { filterActiveDevices, getDevices } from '@thinger-io/cli-core/devices';
10
+ import { inputError } from '@thinger-io/cli-core/errors';
11
+ import { parsePlaybook } from '../playbook/loader.js';
12
+ import { buildDryRunPlan, resolveTargets, runPlaybook } from '../playbook/runner.js';
13
+ import { DEFAULT_FAILURE_THRESHOLD, runFleetPlaybook } from '../playbook/fleet.js';
14
+ import { playbookSchema } from '../playbook/schema.js';
15
+ import { listVariables, resolveVarScope } from '../playbook/vars.js';
16
+ import { mcpLocalRoot } from '../local-files.js';
17
+
18
+ /**
19
+ * Validating, running and rolling out playbooks: the actions of the
20
+ * `product_playbook` resource type (see resource-types.js). Each takes the
21
+ * arguments of the tools they used to be and answers a whole tool result,
22
+ * flagged as an error when a device did not complete.
23
+ */
24
+
25
+ export { DEFAULT_FAILURE_THRESHOLD, playbookSchema };
26
+
27
+ export async function toolPlaybookValidate(args) {
28
+ if (typeof args.content !== 'string' || !args.content.trim()) {
29
+ throw inputError('content (YAML playbook source) is required');
30
+ }
31
+ try {
32
+ const pb = parsePlaybook(args.content);
33
+ const summary = {
34
+ valid: true,
35
+ name: pb.name,
36
+ description: pb.description,
37
+ target: pb.target,
38
+ steps: pb.steps.map((s) => ({ name: s.name, action: s.action })),
39
+ };
40
+ return {
41
+ content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
42
+ isError: false,
43
+ };
44
+ } catch (err) {
45
+ const msg = err instanceof Error ? err.message : String(err);
46
+ return {
47
+ content: [{ type: 'text', text: msg }],
48
+ isError: true,
49
+ _meta: { code: 'input_error', message: msg },
50
+ };
51
+ }
52
+ }
53
+
54
+ export async function toolPlaybookRun(args) {
55
+ if (typeof args.content !== 'string' || !args.content.trim()) {
56
+ throw inputError('content (YAML playbook source) is required');
57
+ }
58
+ const pb = parsePlaybook(args.content);
59
+ if (args.target_product) pb.target.product = args.target_product;
60
+ if (args.target_group) pb.target.group = args.target_group;
61
+ // The whole product (or one of its groups), whatever devices the YAML lists.
62
+ if (args.target_all) pb.target.devices = null;
63
+ if (Array.isArray(args.target_devices) && args.target_devices.length) {
64
+ pb.target.devices = args.target_devices;
65
+ pb.target.group = null;
66
+ }
67
+ if (Number.isInteger(args.concurrency) && args.concurrency > 0) {
68
+ pb.target.concurrency = args.concurrency;
69
+ }
70
+ if (typeof args.fail_fast === 'boolean') pb.target.fail_fast = args.fail_fast;
71
+ // Overrides are checked against what the playbook declares (unknown,
72
+ // not overridable, wrong type, required and missing) before anything
73
+ // runs, as for saved playbooks.
74
+ const rawVars =
75
+ args.vars && typeof args.vars === 'object' && !Array.isArray(args.vars) ? args.vars : {};
76
+ let resolvedScope;
77
+ try {
78
+ resolvedScope = resolveVarScope(pb, rawVars);
79
+ } catch (err) {
80
+ throw inputError(err.message);
81
+ }
82
+
83
+ if (args.dry_run) {
84
+ const plan = buildDryRunPlan(pb, { overrides: rawVars });
85
+ const out = {
86
+ dry_run: true,
87
+ name: pb.name,
88
+ target: pb.target,
89
+ vars: resolvedScope,
90
+ steps: plan,
91
+ };
92
+ return {
93
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
94
+ isError: false,
95
+ };
96
+ }
97
+
98
+ const devices = await resolveTargets(pb, { user: args.user });
99
+ if (devices.length === 0) {
100
+ return {
101
+ content: [
102
+ {
103
+ type: 'text',
104
+ text: `No devices match the playbook target (product="${pb.target.product}"${pb.target.group ? `, group="${pb.target.group}"` : ''}).`,
105
+ },
106
+ ],
107
+ isError: false,
108
+ };
109
+ }
110
+
111
+ const checkMode = !!args.check;
112
+ const results = await runPlaybook(pb, devices, {
113
+ user: args.user,
114
+ overrides: rawVars,
115
+ localRoot: mcpLocalRoot(),
116
+ concurrency: pb.target.concurrency,
117
+ failFast: pb.target.fail_fast,
118
+ checkMode,
119
+ continueOnError: !!args.continue_on_error,
120
+ });
121
+
122
+ const okCount = results.filter((r) => r.ok).length;
123
+ let verdicts;
124
+ if (checkMode) {
125
+ verdicts = { changed: 0, unchanged: 0, unknown: 0 };
126
+ for (const r of results) {
127
+ for (const s of r.steps) {
128
+ if (s.verdict && s.verdict in verdicts) verdicts[s.verdict] += 1;
129
+ }
130
+ }
131
+ }
132
+ const summary = {
133
+ name: pb.name,
134
+ target: pb.target,
135
+ mode: checkMode ? 'check' : 'apply',
136
+ total: results.length,
137
+ ok: okCount,
138
+ failed: results.length - okCount,
139
+ ...(verdicts ? { verdicts } : {}),
140
+ results,
141
+ };
142
+ return {
143
+ content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
144
+ isError: okCount < results.length,
145
+ };
146
+ }
147
+
148
+ export async function toolProductPlaybookRun(args) {
149
+ if (!args.product) throw inputError('product is required');
150
+ if (!args.name) throw inputError('name is required');
151
+ if (!args.device_id) throw inputError('device is required (single-device only)');
152
+
153
+ const entry = await findProductPlaybook(args.product, args.name, args.user);
154
+ if (!entry) {
155
+ const available = await listProductPlaybooks(args.product, args.user);
156
+ throw inputError(
157
+ `Playbook "${args.name}" is not registered on product "${args.product}". Available: ${available.map((e) => e.name).join(', ') || '(none)'}`,
158
+ );
159
+ }
160
+
161
+ const content = await readProductPlaybook(args.product, args.name, args.user);
162
+ const pb = parsePlaybook(content, { sourcePath: `${args.name}.yaml` });
163
+
164
+ const rawVars =
165
+ args.vars && typeof args.vars === 'object' && !Array.isArray(args.vars) ? args.vars : {};
166
+
167
+ let resolvedScope;
168
+ try {
169
+ resolvedScope = resolveVarScope(pb, rawVars);
170
+ } catch (err) {
171
+ throw inputError(err.message);
172
+ }
173
+
174
+ pb.target.devices = [args.device_id];
175
+ pb.target.product = args.product;
176
+ pb.target.group = null;
177
+
178
+ if (args.dry_run) {
179
+ const plan = buildDryRunPlan(pb, { overrides: rawVars });
180
+ const payload = {
181
+ dry_run: true,
182
+ product: args.product,
183
+ name: args.name,
184
+ device: args.device_id,
185
+ variables: listVariables(pb),
186
+ vars: resolvedScope,
187
+ steps: plan,
188
+ };
189
+ return {
190
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
191
+ isError: false,
192
+ };
193
+ }
194
+
195
+ const all = await getDevices({ product: args.product }, args.user);
196
+ const device = all.find((d) => d.device === args.device_id) || {
197
+ device: args.device_id,
198
+ connection: { active: false },
199
+ };
200
+
201
+ const results = await runPlaybook(pb, [device], {
202
+ localRoot: mcpLocalRoot(),
203
+ user: args.user,
204
+ concurrency: 1,
205
+ failFast: true,
206
+ checkMode: !!args.check,
207
+ overrides: rawVars,
208
+ });
209
+ const result = results[0];
210
+ const payload = {
211
+ product: args.product,
212
+ name: args.name,
213
+ device: args.device_id,
214
+ mode: args.check ? 'check' : 'apply',
215
+ ok: !!result?.ok,
216
+ result,
217
+ };
218
+ return {
219
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
220
+ isError: !result?.ok,
221
+ };
222
+ }
223
+
224
+ export async function toolProductPlaybookRollout(args) {
225
+ if (!args.product) throw inputError('product is required');
226
+ if (!args.name) throw inputError('name is required');
227
+
228
+ const batchSize =
229
+ Number.isFinite(args.batch_size) && args.batch_size > 0 ? Math.floor(args.batch_size) : 5;
230
+ const failureThreshold =
231
+ args.failure_threshold === undefined || args.failure_threshold === null
232
+ ? DEFAULT_FAILURE_THRESHOLD
233
+ : Number(args.failure_threshold);
234
+ if (!Number.isFinite(failureThreshold) || failureThreshold < 0 || failureThreshold > 100) {
235
+ throw inputError('failure_threshold must be a number between 0 and 100');
236
+ }
237
+
238
+ const entry = await findProductPlaybook(args.product, args.name, args.user);
239
+ if (!entry) {
240
+ const available = await listProductPlaybooks(args.product, args.user);
241
+ throw inputError(
242
+ `Playbook "${args.name}" is not registered on product "${args.product}". Available: ${available.map((e) => e.name).join(', ') || '(none)'}`,
243
+ );
244
+ }
245
+
246
+ const content = await readProductPlaybook(args.product, args.name, args.user);
247
+ const pb = parsePlaybook(content, { sourcePath: `${args.name}.yaml` });
248
+
249
+ const rawVars =
250
+ args.vars && typeof args.vars === 'object' && !Array.isArray(args.vars) ? args.vars : {};
251
+
252
+ let resolvedScope;
253
+ try {
254
+ resolvedScope = resolveVarScope(pb, rawVars);
255
+ } catch (err) {
256
+ throw inputError(err.message);
257
+ }
258
+
259
+ const filters = {};
260
+ if (args.filter && typeof args.filter === 'object' && !Array.isArray(args.filter)) {
261
+ for (const [k, v] of Object.entries(args.filter)) {
262
+ if (!/^[A-Za-z0-9_]+$/.test(k)) {
263
+ throw inputError(`Invalid filter key "${k}"`);
264
+ }
265
+ filters[k] = v;
266
+ }
267
+ }
268
+
269
+ const baseFilter = { product: args.product };
270
+ if (args.group) baseFilter.asset_group = args.group;
271
+ Object.assign(baseFilter, filters);
272
+ let devices = await getDevices(baseFilter, args.user);
273
+ if (!args.include_offline) devices = filterActiveDevices(devices);
274
+
275
+ pb.target.devices = null;
276
+ pb.target.product = args.product;
277
+ pb.target.group = args.group || null;
278
+
279
+ if (args.dry_run) {
280
+ const plan = buildDryRunPlan(pb, { overrides: rawVars });
281
+ const payload = {
282
+ dry_run: true,
283
+ product: args.product,
284
+ name: args.name,
285
+ fleet: true,
286
+ device_count: devices.length,
287
+ devices: devices.map((d) => d.device),
288
+ batch_size: batchSize,
289
+ failure_threshold: failureThreshold,
290
+ variables: listVariables(pb),
291
+ vars: resolvedScope,
292
+ steps: plan,
293
+ };
294
+ return {
295
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
296
+ isError: false,
297
+ };
298
+ }
299
+
300
+ if (devices.length === 0) {
301
+ const payload = {
302
+ product: args.product,
303
+ name: args.name,
304
+ fleet: true,
305
+ device_count: 0,
306
+ aborted: false,
307
+ summary: { attempted: 0, succeeded: 0, failed: 0, failureRate: 0 },
308
+ message: 'no matching devices',
309
+ };
310
+ return {
311
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
312
+ isError: false,
313
+ };
314
+ }
315
+
316
+ const startedAt = new Date().toISOString();
317
+ const outcome = await runFleetPlaybook(pb, devices, {
318
+ localRoot: mcpLocalRoot(),
319
+ user: args.user,
320
+ batchSize,
321
+ failureThreshold,
322
+ overrides: rawVars,
323
+ checkMode: !!args.check,
324
+ });
325
+ const finishedAt = new Date().toISOString();
326
+
327
+ const summary = {
328
+ attempted: outcome.attempted,
329
+ succeeded: outcome.succeeded,
330
+ failed: outcome.failed,
331
+ failureRate: outcome.failureRate,
332
+ total: devices.length,
333
+ };
334
+
335
+ const report = {
336
+ product: args.product,
337
+ name: args.name,
338
+ user: args.user || requireConfig().username,
339
+ startedAt,
340
+ finishedAt,
341
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
342
+ mode: args.check ? 'check' : 'apply',
343
+ playbook: {
344
+ name: pb.name || null,
345
+ description: pb.description || null,
346
+ steps: pb.steps.length,
347
+ },
348
+ rollout: {
349
+ batchSize,
350
+ failureThreshold,
351
+ filters: Object.keys(filters).length > 0 ? filters : null,
352
+ group: args.group || null,
353
+ includeOffline: !!args.include_offline,
354
+ },
355
+ vars: resolvedScope,
356
+ summary,
357
+ aborted: outcome.aborted,
358
+ reason: outcome.reason,
359
+ batches: outcome.batches,
360
+ devices: outcome.results.map((r) => ({
361
+ device: r?.device,
362
+ ok: !!r?.ok,
363
+ skipped: !!r?.skipped,
364
+ error: r?.error || null,
365
+ })),
366
+ };
367
+
368
+ const upload = await uploadFleetRunReport({ product: args.product, report, user: args.user });
369
+
370
+ const payload = {
371
+ product: args.product,
372
+ name: args.name,
373
+ fleet: true,
374
+ mode: args.check ? 'check' : 'apply',
375
+ startedAt,
376
+ finishedAt,
377
+ batch_size: batchSize,
378
+ failure_threshold: failureThreshold,
379
+ device_count: devices.length,
380
+ batches: outcome.batches,
381
+ summary,
382
+ aborted: outcome.aborted,
383
+ reason: outcome.reason,
384
+ results: outcome.results,
385
+ report_uploaded: upload.ok,
386
+ report_path: upload.ok ? upload.path : null,
387
+ report_error: upload.ok ? null : upload.reason,
388
+ };
389
+ return {
390
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
391
+ isError: outcome.aborted || summary.failed > 0,
392
+ };
393
+ }