@thinremote/thinr-cli 1.2.0-alpha.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +235 -30
  3. package/bin/thinr.js +43 -25
  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 +68 -20
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +213 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +19 -5
  51. package/lib/constants.js +83 -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 +131 -148
  57. package/lib/devices.js +18 -21
  58. package/lib/errors.js +12 -0
  59. package/lib/fleet-upgrade.js +106 -0
  60. package/lib/format.js +114 -0
  61. package/lib/mcp/registry.js +14 -0
  62. package/lib/mcp/tools-alarms.js +510 -0
  63. package/lib/mcp/tools-buckets.js +103 -0
  64. package/lib/mcp/tools-devices.js +33 -3
  65. package/lib/mcp/tools-exec.js +30 -17
  66. package/lib/mcp/tools-filesystem.js +102 -6
  67. package/lib/mcp/tools-install.js +153 -0
  68. package/lib/mcp/tools-logs.js +225 -0
  69. package/lib/mcp/tools-playbook.js +211 -0
  70. package/lib/mcp/tools-products.js +1226 -105
  71. package/lib/mcp/tools-profile.js +634 -0
  72. package/lib/mcp/tools-tokens.js +371 -0
  73. package/lib/monitoring.js +26 -1
  74. package/lib/playbook/actions.js +447 -0
  75. package/lib/playbook/expression.js +302 -0
  76. package/lib/playbook/fleet.js +175 -0
  77. package/lib/playbook/loader.js +247 -0
  78. package/lib/playbook/runner.js +300 -0
  79. package/lib/playbook/schema.js +209 -0
  80. package/lib/playbook/vars.js +209 -0
  81. package/lib/product/log-presets.js +196 -0
  82. package/lib/product/logs.js +485 -0
  83. package/lib/product-orchestrator.js +124 -0
  84. package/lib/product.js +523 -20
  85. package/lib/profile.js +665 -0
  86. package/lib/property.js +7 -40
  87. package/lib/proxy.js +9 -8
  88. package/lib/resource.js +10 -14
  89. package/lib/status.js +78 -37
  90. package/lib/tokens.js +374 -0
  91. package/package.json +14 -3
  92. package/commands/product.js +0 -407
@@ -0,0 +1,247 @@
1
+ // @ts-check
2
+ import { readFileSync } from 'fs';
3
+ import YAML from 'yaml';
4
+ import { ACTION_BY_NAME } from './schema.js';
5
+ import { VAR_TYPES, checkVarType, inferVarType } from './vars.js';
6
+
7
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
+ const VAR_META_KEYS = ['default', 'description', 'type', 'overridable', 'required'];
9
+ const VAR_META_KEY_SET = new Set(VAR_META_KEYS);
10
+
11
+ /**
12
+ * Parse and validate a playbook document. Returns the normalised shape
13
+ * ready to feed into the runner, or throws a descriptive Error listing
14
+ * every issue found (one per line).
15
+ *
16
+ * @param {string} source YAML source text.
17
+ * @param {{ sourcePath?: string }} [opts]
18
+ */
19
+ export function parsePlaybook(source, { sourcePath } = {}) {
20
+ let raw;
21
+ try {
22
+ raw = YAML.parse(source);
23
+ } catch (err) {
24
+ throw new Error(
25
+ `Invalid YAML${sourcePath ? ` in ${sourcePath}` : ''}: ${err.message}`,
26
+ { cause: err },
27
+ );
28
+ }
29
+
30
+ const errors = [];
31
+
32
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
33
+ throw new Error('Playbook root must be a YAML mapping with `target` and `steps`.');
34
+ }
35
+
36
+ const target = validateTarget(raw.target, errors);
37
+ const { vars, variables } = validateVars(raw.vars, errors);
38
+ const steps = validateSteps(raw.steps, errors);
39
+
40
+ if (errors.length) {
41
+ throw new Error(`Invalid playbook:\n - ${errors.join('\n - ')}`);
42
+ }
43
+
44
+ return {
45
+ name: typeof raw.name === 'string' ? raw.name : null,
46
+ description: typeof raw.description === 'string' ? raw.description : null,
47
+ target,
48
+ vars,
49
+ variables,
50
+ steps,
51
+ };
52
+ }
53
+
54
+ /** Read + parse a playbook from disk. */
55
+ export function loadPlaybookFile(path) {
56
+ const source = readFileSync(path, 'utf8');
57
+ return parsePlaybook(source, { sourcePath: path });
58
+ }
59
+
60
+ function validateTarget(t, errors) {
61
+ if (!t || typeof t !== 'object' || Array.isArray(t)) {
62
+ errors.push('`target` is required and must be a mapping.');
63
+ return { product: null, group: null, devices: null, concurrency: 10, fail_fast: false };
64
+ }
65
+ const hasProduct = typeof t.product === 'string' && t.product;
66
+ const hasDevices = Array.isArray(t.devices) && t.devices.length > 0;
67
+ if (!hasProduct && !hasDevices) {
68
+ errors.push('`target.product` or `target.devices` must be provided.');
69
+ }
70
+ if (t.devices !== undefined && !Array.isArray(t.devices)) {
71
+ errors.push('`target.devices` must be a list of device IDs.');
72
+ }
73
+ if (t.group !== undefined && typeof t.group !== 'string') {
74
+ errors.push('`target.group` must be a string.');
75
+ }
76
+ if (t.concurrency !== undefined && (!Number.isInteger(t.concurrency) || t.concurrency <= 0)) {
77
+ errors.push('`target.concurrency` must be a positive integer.');
78
+ }
79
+ if (t.fail_fast !== undefined && typeof t.fail_fast !== 'boolean') {
80
+ errors.push('`target.fail_fast` must be a boolean.');
81
+ }
82
+ return {
83
+ product: hasProduct ? t.product : null,
84
+ group: typeof t.group === 'string' ? t.group : null,
85
+ devices: hasDevices ? [...t.devices] : null,
86
+ concurrency: Number.isInteger(t.concurrency) && t.concurrency > 0 ? t.concurrency : 10,
87
+ fail_fast: !!t.fail_fast,
88
+ };
89
+ }
90
+
91
+ function validateVars(v, errors) {
92
+ if (v === undefined || v === null) return { vars: {}, variables: [] };
93
+ if (typeof v !== 'object' || Array.isArray(v)) {
94
+ errors.push('`vars` must be a mapping of name → value.');
95
+ return { vars: {}, variables: [] };
96
+ }
97
+ const vars = {};
98
+ const variables = [];
99
+ for (const [name, raw] of Object.entries(v)) {
100
+ if (!IDENT_RE.test(name)) {
101
+ errors.push(`\`vars.${name}\` is not a valid identifier (letters, digits, underscore; no leading digit).`);
102
+ continue;
103
+ }
104
+ const def = normaliseVarDef(name, raw, errors);
105
+ if (!def) continue;
106
+ variables.push(def);
107
+ if (def.hasDefault) vars[name] = def.default;
108
+ }
109
+ return { vars, variables };
110
+ }
111
+
112
+ function isExtendedVarValue(raw) {
113
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false;
114
+ for (const key of VAR_META_KEYS) {
115
+ if (key in raw) return true;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ function normaliseVarDef(name, raw, errors) {
121
+ if (!isExtendedVarValue(raw)) {
122
+ return {
123
+ name,
124
+ description: null,
125
+ type: inferVarType(raw),
126
+ default: raw,
127
+ hasDefault: true,
128
+ overridable: true,
129
+ required: false,
130
+ };
131
+ }
132
+
133
+ for (const key of Object.keys(raw)) {
134
+ if (!VAR_META_KEY_SET.has(key)) {
135
+ errors.push(`\`vars.${name}\`: unknown metadata key "${key}" (expected: ${VAR_META_KEYS.join(', ')}).`);
136
+ return null;
137
+ }
138
+ }
139
+
140
+ let type = raw.type;
141
+ if (type !== undefined) {
142
+ if (typeof type !== 'string' || !VAR_TYPES.includes(type)) {
143
+ errors.push(`\`vars.${name}.type\` must be one of: ${VAR_TYPES.join(', ')}.`);
144
+ return null;
145
+ }
146
+ } else {
147
+ type = 'default' in raw ? inferVarType(raw.default) : 'string';
148
+ }
149
+
150
+ if (raw.description !== undefined && typeof raw.description !== 'string') {
151
+ errors.push(`\`vars.${name}.description\` must be a string.`);
152
+ return null;
153
+ }
154
+ if (raw.overridable !== undefined && typeof raw.overridable !== 'boolean') {
155
+ errors.push(`\`vars.${name}.overridable\` must be a boolean.`);
156
+ return null;
157
+ }
158
+ if (raw.required !== undefined && typeof raw.required !== 'boolean') {
159
+ errors.push(`\`vars.${name}.required\` must be a boolean.`);
160
+ return null;
161
+ }
162
+
163
+ const hasDefault = 'default' in raw;
164
+ if (hasDefault && !checkVarType(raw.default, type)) {
165
+ errors.push(`\`vars.${name}.default\` does not match declared type ${type}.`);
166
+ return null;
167
+ }
168
+
169
+ return {
170
+ name,
171
+ description: typeof raw.description === 'string' ? raw.description : null,
172
+ type,
173
+ default: hasDefault ? raw.default : undefined,
174
+ hasDefault,
175
+ overridable: raw.overridable !== false,
176
+ required: raw.required === true,
177
+ };
178
+ }
179
+
180
+ function validateSteps(s, errors) {
181
+ if (!Array.isArray(s) || s.length === 0) {
182
+ errors.push('`steps` must be a non-empty list.');
183
+ return [];
184
+ }
185
+ const out = [];
186
+ s.forEach((raw, i) => {
187
+ const where = `step[${i}]${raw?.name ? ` "${raw.name}"` : ''}`;
188
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
189
+ errors.push(`${where}: must be a mapping.`);
190
+ return;
191
+ }
192
+ const { action, pause_after, when, register, label, ...rest } = raw;
193
+ if (typeof action !== 'string' || !action) {
194
+ errors.push(`${where}: \`action\` is required.`);
195
+ return;
196
+ }
197
+ const spec = ACTION_BY_NAME[action];
198
+ if (!spec) {
199
+ errors.push(`${where}: unknown action "${action}".`);
200
+ return;
201
+ }
202
+ // `name` is usually the step's human label, but actions like
203
+ // `script_install` / `script_delete` declare a `name` of their
204
+ // own. Resolve the clash transparently: if the action owns
205
+ // `name`, route it to params; otherwise treat it as the label.
206
+ // A top-level `label:` field is always honoured as an explicit
207
+ // override for the clashing case.
208
+ const actionOwnsName = spec.params.some((p) => p.name === 'name');
209
+ let stepLabel = null;
210
+ let params;
211
+ if (actionOwnsName) {
212
+ params = rest;
213
+ } else {
214
+ const { name: stepName, ...others } = rest;
215
+ params = others;
216
+ if (typeof stepName === 'string' && stepName) stepLabel = stepName;
217
+ }
218
+ if (typeof label === 'string' && label) stepLabel = label;
219
+
220
+ for (const p of spec.params) {
221
+ if (p.required && params[p.name] === undefined) {
222
+ errors.push(`${where}: \`${p.name}\` is required for action "${action}".`);
223
+ }
224
+ }
225
+ if (pause_after !== undefined && (!Number.isFinite(pause_after) || pause_after < 0)) {
226
+ errors.push(`${where}: \`pause_after\` must be a non-negative number of seconds.`);
227
+ return;
228
+ }
229
+ if (when !== undefined && typeof when !== 'string') {
230
+ errors.push(`${where}: \`when\` must be a string expression.`);
231
+ return;
232
+ }
233
+ if (register !== undefined && (typeof register !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(register))) {
234
+ errors.push(`${where}: \`register\` must be a valid identifier (letters, digits, underscore; no leading digit).`);
235
+ return;
236
+ }
237
+ out.push({
238
+ name: stepLabel || spec.summary(params),
239
+ action,
240
+ params,
241
+ pause_after: Number.isFinite(pause_after) ? pause_after : 0,
242
+ when: typeof when === 'string' ? when : null,
243
+ register: typeof register === 'string' ? register : null,
244
+ });
245
+ });
246
+ return out;
247
+ }
@@ -0,0 +1,300 @@
1
+ // @ts-check
2
+ import { filterActiveDevices, getDevices } from '../devices.js';
3
+ import { runPool } from '../concurrency.js';
4
+ import { HANDLERS, CHECKERS, createActionContext } from './actions.js';
5
+
6
+ /** Actions whose effect isn't derivable from observable state, so
7
+ * pre-checking before apply is either pointless or harmful (sleep
8
+ * would always skip; exec/resource have unpredictable side effects
9
+ * we must always trigger; pull writes to local disk — pre-check
10
+ * would hash the remote just to overwrite the local file anyway). */
11
+ const NON_IDEMPOTENT = new Set(['sleep', 'exec', 'resource', 'pull']);
12
+ import { ACTION_BY_NAME } from './schema.js';
13
+ import { interpolate, resolveVarScope } from './vars.js';
14
+ import { evaluateCondition } from './expression.js';
15
+
16
+ function buildScope(pb, opts, extras) {
17
+ if (opts && Object.hasOwn(opts, 'overrides')) {
18
+ return resolveVarScope(pb, opts.overrides || {}, extras);
19
+ }
20
+ return { ...(pb.vars || {}), ...extras };
21
+ }
22
+
23
+ /**
24
+ * Resolve the playbook's `target` block to a concrete device list.
25
+ * Honours optional CLI overrides (product / group / devices) that the
26
+ * caller may pass to tweak a saved playbook without editing it.
27
+ *
28
+ * @param {any} pb
29
+ * @param {{ user?: string | null, overrides?: { product?: string, group?: string, devices?: string[] } }} [opts]
30
+ */
31
+ export async function resolveTargets(pb, opts = {}) {
32
+ const { user, overrides = {} } = opts;
33
+ const target = { ...pb.target, ...overrides };
34
+ if (Array.isArray(target.devices) && target.devices.length > 0) {
35
+ // Explicit list — we still fetch the fleet so we can attach
36
+ // connection state, but fall back gracefully when a requested
37
+ // id isn't in the user's fleet (unlikely).
38
+ const all = await getDevices({}, user);
39
+ const byId = Object.fromEntries(all.map((d) => [d.device, d]));
40
+ return target.devices.map((id) => byId[id] || { device: id, connection: { active: false } });
41
+ }
42
+ if (!target.product) {
43
+ throw new Error('playbook target has no product/devices after overrides');
44
+ }
45
+ const filter = { product: target.product };
46
+ if (target.group) filter.asset_group = target.group;
47
+ const all = await getDevices(filter, user);
48
+ return filterActiveDevices(all);
49
+ }
50
+
51
+ /**
52
+ * Static "what would happen" summary — does no remote calls. Returns
53
+ * rows of `{ step, summary }` the caller can render as a plan.
54
+ *
55
+ * Pass `opts.overrides` to resolve variables with the new scope model
56
+ * (declared defaults + validated overrides, throws on unknown / non-
57
+ * overridable / wrong-type / missing-required vars). Omit it to fall
58
+ * back to the legacy behaviour that just spreads `pb.vars` into scope.
59
+ *
60
+ * @param {any} pb
61
+ * @param {{ overrides?: Record<string, unknown> }} [opts]
62
+ */
63
+ export function buildDryRunPlan(pb, opts) {
64
+ /** @type {Record<string, unknown>} */
65
+ const scope = buildScope(pb, opts, { device: '<device>' });
66
+ return pb.steps.map((s, i) => {
67
+ const spec = ACTION_BY_NAME[s.action];
68
+ const resolvedParams = interpolate(s.params, scope);
69
+ return {
70
+ index: i,
71
+ name: s.name,
72
+ action: s.action,
73
+ summary: spec ? spec.summary(resolvedParams) : s.action,
74
+ pause_after: s.pause_after,
75
+ };
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Run the playbook over `devices` with `concurrency` parallel workers.
81
+ * Each worker runs every step in order against its device and records
82
+ * the outcome per step. Handlers never throw past the runner — failures
83
+ * are captured into the results table.
84
+ *
85
+ * Set `checkMode: true` to route every step through CHECKERS instead
86
+ * of HANDLERS. Actions without a checker are reported as `unknown`
87
+ * and skipped without side effects.
88
+ *
89
+ * Pass `opts.overrides` to resolve variables with the new scope model
90
+ * (declared defaults + validated overrides). Omit it to fall back to
91
+ * the legacy behaviour that just spreads `pb.vars` into scope.
92
+ *
93
+ * @param {Object} pb Parsed playbook.
94
+ * @param {Object[]} devices Device records from resolveTargets().
95
+ * @param {{
96
+ * user?: string | null,
97
+ * concurrency?: number,
98
+ * failFast?: boolean,
99
+ * continueOnError?: boolean,
100
+ * baseDir?: string,
101
+ * checkMode?: boolean,
102
+ * overrides?: Record<string, unknown>,
103
+ * onStepStart?: (args: { deviceId: string, stepIndex: number, step: any }) => void,
104
+ * onStepEnd?: (args: { deviceId: string, stepIndex: number, step: any, ok: boolean, summary: string, error?: string, durationMs: number, verdict?: 'applied' | 'changed' | 'unchanged' | 'unknown', stdout?: string, stderr?: string, exitCode?: number }) => void,
105
+ * }} [opts]
106
+ */
107
+ export async function runPlaybook(pb, devices, opts = {}) {
108
+ const checkMode = !!opts.checkMode;
109
+ const concurrency = opts.concurrency ?? pb.target.concurrency ?? 10;
110
+ const failFast = opts.failFast ?? pb.target.fail_fast ?? false;
111
+ const continueOnError = opts.continueOnError ?? pb.target.continue_on_error ?? false;
112
+ const user = opts.user ?? null;
113
+ const baseDir = opts.baseDir;
114
+
115
+ /** @type {boolean} */
116
+ let aborted = false;
117
+
118
+ const results = await runPool(devices, concurrency, async (device) => {
119
+ const deviceId = device.device;
120
+ const ctx = createActionContext({ deviceId, user, baseDir });
121
+ /** @type {Record<string, unknown>} */
122
+ const scope = buildScope(pb, opts, { device: deviceId });
123
+ const steps = [];
124
+ let deviceOk = true;
125
+
126
+ for (let i = 0; i < pb.steps.length; i++) {
127
+ if (aborted) {
128
+ steps.push({ index: i, name: pb.steps[i].name, ok: false, skipped: true, summary: 'skipped (fail-fast)', durationMs: 0 });
129
+ continue;
130
+ }
131
+ const step = pb.steps[i];
132
+
133
+ // `when` gate. Evaluated against the live scope (playbook
134
+ // vars + `device` + every `register`-ed step result so far).
135
+ // A parse/eval error fails the step — less confusing than
136
+ // silently skipping and continuing.
137
+ if (step.when) {
138
+ let gate;
139
+ try {
140
+ gate = evaluateCondition(step.when, scope);
141
+ } catch (err) {
142
+ const durationMs = 0;
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ steps.push({
145
+ index: i,
146
+ name: step.name,
147
+ ok: false,
148
+ summary: `when-expression error: ${msg}`,
149
+ error: msg,
150
+ durationMs,
151
+ });
152
+ opts.onStepEnd?.({ deviceId, stepIndex: i, step, ok: false, summary: msg, error: msg, durationMs });
153
+ deviceOk = false;
154
+ if (failFast) aborted = true;
155
+ break;
156
+ }
157
+ if (!gate) {
158
+ steps.push({
159
+ index: i,
160
+ name: step.name,
161
+ ok: true,
162
+ skipped: true,
163
+ summary: `skipped (when: ${step.when})`,
164
+ durationMs: 0,
165
+ });
166
+ opts.onStepEnd?.({ deviceId, stepIndex: i, step, ok: true, summary: 'skipped', durationMs: 0 });
167
+ continue;
168
+ }
169
+ }
170
+
171
+ const handler = HANDLERS[step.action];
172
+ const checker = CHECKERS[step.action];
173
+ const resolvedParams = interpolate(step.params, scope);
174
+ opts.onStepStart?.({ deviceId, stepIndex: i, step });
175
+
176
+ const t0 = Date.now();
177
+ try {
178
+ let summary;
179
+ let verdict;
180
+ let stdout;
181
+ let stderr;
182
+ let exitCode;
183
+ if (checkMode) {
184
+ if (checker) {
185
+ const result = await checker(ctx, resolvedParams);
186
+ verdict = result.status;
187
+ summary = result.summary;
188
+ } else {
189
+ verdict = 'unknown';
190
+ summary = `check not supported for ${step.action}`;
191
+ }
192
+ } else {
193
+ let preCheck = null;
194
+ if (checker && !NON_IDEMPOTENT.has(step.action)) {
195
+ try {
196
+ preCheck = await checker(ctx, resolvedParams);
197
+ } catch {
198
+ preCheck = null;
199
+ }
200
+ }
201
+ if (preCheck && preCheck.status === 'unchanged') {
202
+ verdict = 'unchanged';
203
+ summary = preCheck.summary;
204
+ } else {
205
+ const out = await handler(ctx, resolvedParams);
206
+ if (typeof out === 'string') {
207
+ summary = out;
208
+ } else {
209
+ summary = out.summary;
210
+ stdout = out.stdout;
211
+ stderr = out.stderr;
212
+ exitCode = out.exitCode;
213
+ }
214
+ verdict = 'changed';
215
+ }
216
+ }
217
+ const durationMs = Date.now() - t0;
218
+ const entry = { index: i, name: step.name, ok: true, summary, durationMs, verdict };
219
+ if (stdout !== undefined) entry.stdout = stdout;
220
+ if (stderr !== undefined) entry.stderr = stderr;
221
+ if (exitCode !== undefined) entry.exitCode = exitCode;
222
+ steps.push(entry);
223
+ if (step.register) {
224
+ scope[step.register] = {
225
+ ok: true,
226
+ result: summary,
227
+ duration_ms: durationMs,
228
+ };
229
+ }
230
+ opts.onStepEnd?.({
231
+ deviceId,
232
+ stepIndex: i,
233
+ step,
234
+ ok: true,
235
+ summary,
236
+ durationMs,
237
+ verdict,
238
+ stdout,
239
+ stderr,
240
+ exitCode,
241
+ });
242
+ } catch (err) {
243
+ const durationMs = Date.now() - t0;
244
+ const msg = err instanceof Error ? err.message : String(err);
245
+ const stdout = err && typeof err === 'object' ? err.stdout : undefined;
246
+ const stderr = err && typeof err === 'object' ? err.stderr : undefined;
247
+ const exitCode = err && typeof err === 'object' ? err.exitCode : undefined;
248
+ const entry = { index: i, name: step.name, ok: false, summary: msg, error: msg, durationMs };
249
+ if (stdout !== undefined) entry.stdout = stdout;
250
+ if (stderr !== undefined) entry.stderr = stderr;
251
+ if (exitCode !== undefined) entry.exitCode = exitCode;
252
+ steps.push(entry);
253
+ if (step.register) {
254
+ scope[step.register] = {
255
+ ok: false,
256
+ error: msg,
257
+ duration_ms: durationMs,
258
+ };
259
+ }
260
+ opts.onStepEnd?.({
261
+ deviceId,
262
+ stepIndex: i,
263
+ step,
264
+ ok: false,
265
+ summary: msg,
266
+ error: msg,
267
+ durationMs,
268
+ stdout,
269
+ stderr,
270
+ exitCode,
271
+ });
272
+ deviceOk = false;
273
+ if (failFast) aborted = true;
274
+ if (continueOnError) continue;
275
+ break;
276
+ }
277
+
278
+ if (step.pause_after > 0) {
279
+ await new Promise((r) => setTimeout(r, step.pause_after * 1000));
280
+ }
281
+ }
282
+
283
+ return { device: deviceId, ok: deviceOk, steps };
284
+ });
285
+
286
+ return results.map((r, i) => {
287
+ if (r && r.ok) return r.value;
288
+ let msg = 'unknown error';
289
+ if (r && r.ok === false) {
290
+ const err = r.error;
291
+ msg = err instanceof Error ? err.message : String(err);
292
+ }
293
+ return {
294
+ device: devices[i].device,
295
+ ok: false,
296
+ steps: [],
297
+ error: msg,
298
+ };
299
+ });
300
+ }