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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +192 -16
  3. package/bin/thinr.js +23 -24
  4. package/commands/_shared.js +154 -0
  5. package/commands/dashboard.js +17 -0
  6. package/commands/device/_shared.js +17 -34
  7. package/commands/device/console.js +1 -0
  8. package/commands/device/delete.js +53 -0
  9. package/commands/device/exec.js +7 -6
  10. package/commands/device/fs.js +193 -0
  11. package/commands/device/index.js +9 -0
  12. package/commands/device/list.js +35 -23
  13. package/commands/device/logs.js +178 -0
  14. package/commands/device/property.js +30 -28
  15. package/commands/device/proxy.js +3 -0
  16. package/commands/device/resource.js +30 -29
  17. package/commands/device/status.js +35 -14
  18. package/commands/device/transfer.js +236 -0
  19. package/commands/device/update.js +15 -10
  20. package/commands/fleet/index.js +17 -0
  21. package/commands/fleet/upgrade.js +275 -0
  22. package/commands/logout.js +10 -11
  23. package/commands/playbook/_shared.js +12 -0
  24. package/commands/playbook/index.js +18 -0
  25. package/commands/playbook/run.js +302 -0
  26. package/commands/playbook/validate.js +38 -0
  27. package/commands/product/_shared.js +8 -0
  28. package/commands/product/exec.js +243 -0
  29. package/commands/product/fs.js +218 -0
  30. package/commands/product/index.js +35 -0
  31. package/commands/product/list.js +86 -0
  32. package/commands/product/logs.js +289 -0
  33. package/commands/product/metric.js +124 -0
  34. package/commands/product/playbook.js +911 -0
  35. package/commands/product/property.js +76 -0
  36. package/commands/product/props.js +125 -0
  37. package/commands/product/push.js +184 -0
  38. package/commands/product/resource.js +76 -0
  39. package/commands/product/status.js +213 -0
  40. package/commands/profile.js +76 -8
  41. package/dist/dashboard.js +5644 -0
  42. package/lib/agent-releases.js +59 -0
  43. package/lib/agent-versions.js +73 -0
  44. package/lib/alarms.js +252 -0
  45. package/lib/api.js +8 -2
  46. package/lib/auth.js +30 -17
  47. package/lib/bucket.js +87 -0
  48. package/lib/command-stream.js +199 -0
  49. package/lib/config.js +5 -3
  50. package/lib/console.js +12 -4
  51. package/lib/constants.js +76 -0
  52. package/lib/dashboard/event-stream.js +239 -0
  53. package/lib/dashboard/metric-aggregate.js +64 -0
  54. package/lib/dashboard/metric-filter.js +39 -0
  55. package/lib/debug-log.js +93 -0
  56. package/lib/device-api.js +109 -147
  57. package/lib/devices.js +18 -21
  58. package/lib/fleet-upgrade.js +106 -0
  59. package/lib/format.js +114 -0
  60. package/lib/mcp/registry.js +14 -0
  61. package/lib/mcp/tools-alarms.js +510 -0
  62. package/lib/mcp/tools-buckets.js +103 -0
  63. package/lib/mcp/tools-devices.js +33 -3
  64. package/lib/mcp/tools-exec.js +30 -17
  65. package/lib/mcp/tools-filesystem.js +102 -6
  66. package/lib/mcp/tools-install.js +153 -0
  67. package/lib/mcp/tools-logs.js +225 -0
  68. package/lib/mcp/tools-playbook.js +211 -0
  69. package/lib/mcp/tools-products.js +1226 -105
  70. package/lib/mcp/tools-profile.js +634 -0
  71. package/lib/mcp/tools-tokens.js +371 -0
  72. package/lib/monitoring.js +26 -1
  73. package/lib/playbook/actions.js +447 -0
  74. package/lib/playbook/expression.js +302 -0
  75. package/lib/playbook/fleet.js +175 -0
  76. package/lib/playbook/loader.js +247 -0
  77. package/lib/playbook/runner.js +300 -0
  78. package/lib/playbook/schema.js +209 -0
  79. package/lib/playbook/vars.js +209 -0
  80. package/lib/product/log-presets.js +196 -0
  81. package/lib/product/logs.js +485 -0
  82. package/lib/product-orchestrator.js +124 -0
  83. package/lib/product.js +523 -20
  84. package/lib/profile.js +665 -0
  85. package/lib/property.js +7 -40
  86. package/lib/proxy.js +9 -8
  87. package/lib/resource.js +10 -14
  88. package/lib/status.js +78 -37
  89. package/lib/tokens.js +374 -0
  90. package/package.json +14 -3
  91. package/commands/product.js +0 -407
@@ -0,0 +1,302 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Minimal boolean-expression evaluator for playbook `when:` clauses.
5
+ * Deliberately a tiny subset — no Jinja, no filters, no arithmetic —
6
+ * just enough to express "skip this step when X" reliably:
7
+ *
8
+ * - literals: strings (single or double quoted), numbers, `true`,
9
+ * `false`, `null`
10
+ * - path access: `agent.version`, `result.data[0]` is NOT supported
11
+ * (only named dotted paths; index access is future work)
12
+ * - comparators: `==`, `!=`, `>`, `<`, `>=`, `<=`
13
+ * - logicals: `and`, `or`, `not`
14
+ * - parentheses
15
+ *
16
+ * The evaluator is deliberately strict: unknown identifiers throw so
17
+ * typos don't silently turn into "falsy skip". Callers pre-seed every
18
+ * expected variable (playbook `vars` + registered step results + the
19
+ * implicit `device`) into the scope.
20
+ *
21
+ * @param {string} source The `when` expression as written in YAML.
22
+ * @param {Record<string, unknown>} scope
23
+ * @returns {boolean}
24
+ */
25
+ export function evaluateCondition(source, scope) {
26
+ const value = evaluateExpression(source, scope);
27
+ return !!value;
28
+ }
29
+
30
+ /**
31
+ * Same as evaluateCondition but returns the raw value (useful for
32
+ * tests and future `register` expressions).
33
+ *
34
+ * @param {string} source
35
+ * @param {Record<string, unknown>} scope
36
+ */
37
+ export function evaluateExpression(source, scope) {
38
+ const parser = new Parser(source);
39
+ const ast = parser.parseExpression();
40
+ parser.expectEof();
41
+ return evalNode(ast, scope);
42
+ }
43
+
44
+ // ── Tokeniser ───────────────────────────────────────────────────────
45
+
46
+ const KEYWORDS = new Set(['and', 'or', 'not', 'true', 'false', 'null']);
47
+
48
+ /**
49
+ * @typedef {{ type: 'num', value: number }
50
+ * | { type: 'str', value: string }
51
+ * | { type: 'ident', value: string }
52
+ * | { type: 'op', value: string }} Token
53
+ */
54
+
55
+ /**
56
+ * @param {string} src
57
+ * @returns {Token[]}
58
+ */
59
+ function tokenize(src) {
60
+ /** @type {Token[]} */
61
+ const out = [];
62
+ let i = 0;
63
+ while (i < src.length) {
64
+ const c = src[i];
65
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
66
+ i++;
67
+ continue;
68
+ }
69
+ if (c === '(' || c === ')') {
70
+ out.push({ type: 'op', value: c });
71
+ i++;
72
+ continue;
73
+ }
74
+ if (c === '=' || c === '!' || c === '<' || c === '>') {
75
+ if (src[i + 1] === '=') {
76
+ out.push({ type: 'op', value: c + '=' });
77
+ i += 2;
78
+ continue;
79
+ }
80
+ if (c === '<' || c === '>') {
81
+ out.push({ type: 'op', value: c });
82
+ i++;
83
+ continue;
84
+ }
85
+ throw new Error(`Unexpected character '${c}' at ${i}`);
86
+ }
87
+ if (c === '"' || c === "'") {
88
+ const quote = c;
89
+ let j = i + 1;
90
+ let buf = '';
91
+ while (j < src.length && src[j] !== quote) {
92
+ if (src[j] === '\\' && j + 1 < src.length) {
93
+ buf += src[j + 1];
94
+ j += 2;
95
+ } else {
96
+ buf += src[j];
97
+ j++;
98
+ }
99
+ }
100
+ if (j >= src.length) throw new Error(`Unterminated string at ${i}`);
101
+ out.push({ type: 'str', value: buf });
102
+ i = j + 1;
103
+ continue;
104
+ }
105
+ if ((c >= '0' && c <= '9') || (c === '-' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
106
+ let j = i + 1;
107
+ while (
108
+ j < src.length &&
109
+ ((src[j] >= '0' && src[j] <= '9') || src[j] === '.')
110
+ ) {
111
+ j++;
112
+ }
113
+ const n = Number(src.slice(i, j));
114
+ if (Number.isNaN(n)) throw new Error(`Invalid number at ${i}`);
115
+ out.push({ type: 'num', value: n });
116
+ i = j;
117
+ continue;
118
+ }
119
+ if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_') {
120
+ let j = i + 1;
121
+ while (
122
+ j < src.length &&
123
+ ((src[j] >= 'a' && src[j] <= 'z') ||
124
+ (src[j] >= 'A' && src[j] <= 'Z') ||
125
+ (src[j] >= '0' && src[j] <= '9') ||
126
+ src[j] === '_' ||
127
+ src[j] === '.')
128
+ ) {
129
+ j++;
130
+ }
131
+ const word = src.slice(i, j);
132
+ out.push({ type: 'ident', value: word });
133
+ i = j;
134
+ continue;
135
+ }
136
+ throw new Error(`Unexpected character '${c}' at ${i}`);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // ── Parser (recursive descent) ──────────────────────────────────────
142
+
143
+ class Parser {
144
+ /** @param {string} source */
145
+ constructor(source) {
146
+ this.tokens = tokenize(source);
147
+ this.pos = 0;
148
+ }
149
+
150
+ peek() {
151
+ return this.tokens[this.pos];
152
+ }
153
+
154
+ advance() {
155
+ return this.tokens[this.pos++];
156
+ }
157
+
158
+ expectEof() {
159
+ if (this.pos < this.tokens.length) {
160
+ const t = this.tokens[this.pos];
161
+ throw new Error(`Unexpected trailing token: ${formatToken(t)}`);
162
+ }
163
+ }
164
+
165
+ parseExpression() {
166
+ return this.parseOr();
167
+ }
168
+
169
+ parseOr() {
170
+ let left = this.parseAnd();
171
+ while (this.matchKeyword('or')) {
172
+ const right = this.parseAnd();
173
+ left = { type: 'or', left, right };
174
+ }
175
+ return left;
176
+ }
177
+
178
+ parseAnd() {
179
+ let left = this.parseNot();
180
+ while (this.matchKeyword('and')) {
181
+ const right = this.parseNot();
182
+ left = { type: 'and', left, right };
183
+ }
184
+ return left;
185
+ }
186
+
187
+ parseNot() {
188
+ if (this.matchKeyword('not')) {
189
+ return { type: 'not', value: this.parseNot() };
190
+ }
191
+ return this.parseCompare();
192
+ }
193
+
194
+ parseCompare() {
195
+ const left = this.parsePrimary();
196
+ const t = this.peek();
197
+ if (t && t.type === 'op' && ['==', '!=', '>', '<', '>=', '<='].includes(t.value)) {
198
+ this.advance();
199
+ const right = this.parsePrimary();
200
+ return { type: 'cmp', op: t.value, left, right };
201
+ }
202
+ return left;
203
+ }
204
+
205
+ parsePrimary() {
206
+ const t = this.peek();
207
+ if (!t) throw new Error('Unexpected end of expression');
208
+ if (t.type === 'op' && t.value === '(') {
209
+ this.advance();
210
+ const inner = this.parseExpression();
211
+ const close = this.advance();
212
+ if (!close || close.type !== 'op' || close.value !== ')') {
213
+ throw new Error('Missing closing parenthesis');
214
+ }
215
+ return inner;
216
+ }
217
+ if (t.type === 'str') {
218
+ this.advance();
219
+ return { type: 'lit', value: t.value };
220
+ }
221
+ if (t.type === 'num') {
222
+ this.advance();
223
+ return { type: 'lit', value: t.value };
224
+ }
225
+ if (t.type === 'ident') {
226
+ this.advance();
227
+ if (t.value === 'true') return { type: 'lit', value: true };
228
+ if (t.value === 'false') return { type: 'lit', value: false };
229
+ if (t.value === 'null') return { type: 'lit', value: null };
230
+ if (KEYWORDS.has(t.value)) {
231
+ throw new Error(`Unexpected keyword '${t.value}' in expression`);
232
+ }
233
+ return { type: 'path', parts: t.value.split('.') };
234
+ }
235
+ throw new Error(`Unexpected token: ${formatToken(t)}`);
236
+ }
237
+
238
+ matchKeyword(kw) {
239
+ const t = this.peek();
240
+ if (t && t.type === 'ident' && t.value === kw) {
241
+ this.advance();
242
+ return true;
243
+ }
244
+ return false;
245
+ }
246
+ }
247
+
248
+ function formatToken(t) {
249
+ if (!t) return '<eof>';
250
+ return `${t.type}:${t.value}`;
251
+ }
252
+
253
+ // ── Evaluator ───────────────────────────────────────────────────────
254
+
255
+ function evalNode(node, scope) {
256
+ switch (node.type) {
257
+ case 'lit':
258
+ return node.value;
259
+ case 'not':
260
+ return !evalNode(node.value, scope);
261
+ case 'and':
262
+ return !!(evalNode(node.left, scope) && evalNode(node.right, scope));
263
+ case 'or':
264
+ return !!(evalNode(node.left, scope) || evalNode(node.right, scope));
265
+ case 'cmp': {
266
+ const l = evalNode(node.left, scope);
267
+ const r = evalNode(node.right, scope);
268
+ switch (node.op) {
269
+ case '==':
270
+ return l === r || (l != null && r != null && String(l) === String(r));
271
+ case '!=':
272
+ return !(l === r || (l != null && r != null && String(l) === String(r)));
273
+ case '>':
274
+ return Number(l) > Number(r);
275
+ case '<':
276
+ return Number(l) < Number(r);
277
+ case '>=':
278
+ return Number(l) >= Number(r);
279
+ case '<=':
280
+ return Number(l) <= Number(r);
281
+ default:
282
+ throw new Error(`Unknown comparator: ${node.op}`);
283
+ }
284
+ }
285
+ case 'path':
286
+ return resolvePath(node.parts, scope);
287
+ default:
288
+ throw new Error(`Unknown expression node: ${node.type}`);
289
+ }
290
+ }
291
+
292
+ function resolvePath(parts, scope) {
293
+ if (!(parts[0] in scope)) {
294
+ throw new Error(`Undefined playbook variable: ${parts[0]}`);
295
+ }
296
+ let cur = scope[parts[0]];
297
+ for (let i = 1; i < parts.length; i++) {
298
+ if (cur == null) return undefined;
299
+ cur = cur[parts[i]];
300
+ }
301
+ return cur;
302
+ }
@@ -0,0 +1,175 @@
1
+ // @ts-check
2
+ import { runPlaybook } from './runner.js';
3
+
4
+ export const DEFAULT_FAILURE_THRESHOLD = 10;
5
+
6
+ /**
7
+ * Roll a product playbook across a fleet in discrete batches. Each
8
+ * batch runs in parallel up to `batchSize`; the next batch only starts
9
+ * once every device in the current one has finished. After every
10
+ * batch the cumulative failure rate is compared against
11
+ * `failureThreshold` (a percentage of the devices attempted so far);
12
+ * crossing it aborts the rollout before any further batch runs.
13
+ *
14
+ * The orchestrator reuses the single-device runner under the hood, so
15
+ * behaviour per device (idempotency, `when` gates, `register`,
16
+ * check/apply modes, variable scope) stays identical to
17
+ * `thinr product playbook run --device <id>`. Fleet-specific concerns
18
+ * live here: partitioning, batch barriers, threshold bookkeeping, and
19
+ * rollout-level progress hooks.
20
+ *
21
+ * Caller shape — the returned `results` array is parallel to `devices`
22
+ * after both the successful and skipped paths fill in. Skipped devices
23
+ * have `ok: false, skipped: true` with the abort reason in `error`.
24
+ *
25
+ * @param {any} pb Parsed playbook.
26
+ * @param {Array<{ device: string, connection?: { active: boolean } }>} devices
27
+ * @param {{
28
+ * user?: string | null,
29
+ * batchSize?: number,
30
+ * failureThreshold?: number,
31
+ * overrides?: Record<string, unknown>,
32
+ * checkMode?: boolean,
33
+ * baseDir?: string,
34
+ * signal?: AbortSignal,
35
+ * onBatchStart?: (info: { index: number, size: number, firstIndex: number, deviceIds: string[] }) => void,
36
+ * onBatchEnd?: (info: { index: number, size: number, firstIndex: number, deviceIds: string[], results: any[], cumulative: { attempted: number, succeeded: number, failed: number, failureRate: number } }) => void,
37
+ * onDeviceResult?: (result: any) => void,
38
+ * runBatch?: (pb: any, devices: any[], opts: any) => Promise<any[]>,
39
+ * }} [opts]
40
+ * @returns {Promise<{
41
+ * results: any[],
42
+ * aborted: boolean,
43
+ * reason: 'failure-threshold' | 'cancelled' | null,
44
+ * attempted: number,
45
+ * succeeded: number,
46
+ * failed: number,
47
+ * failureRate: number,
48
+ * batches: Array<{ index: number, size: number, firstIndex: number, deviceIds: string[], succeeded: number, failed: number }>,
49
+ * }>}
50
+ */
51
+ export async function runFleetPlaybook(pb, devices, opts = {}) {
52
+ const batchSize = Math.max(1, opts.batchSize ?? 5);
53
+ const failureThreshold = clampThreshold(opts.failureThreshold);
54
+ const user = opts.user ?? null;
55
+ const batchRunner = opts.runBatch || runPlaybook;
56
+
57
+ /** @type {any[]} */
58
+ const results = new Array(devices.length);
59
+ const batches = [];
60
+ let attempted = 0;
61
+ let succeeded = 0;
62
+ let failed = 0;
63
+ let aborted = false;
64
+ /** @type {'failure-threshold' | 'cancelled' | null} */
65
+ let reason = null;
66
+
67
+ for (let start = 0; start < devices.length; start += batchSize) {
68
+ if (opts.signal?.aborted) {
69
+ aborted = true;
70
+ reason = 'cancelled';
71
+ break;
72
+ }
73
+
74
+ const batchDevices = devices.slice(start, start + batchSize);
75
+ const batchIndex = batches.length;
76
+ const deviceIds = batchDevices.map((d) => d.device);
77
+
78
+ opts.onBatchStart?.({
79
+ index: batchIndex,
80
+ size: batchDevices.length,
81
+ firstIndex: start,
82
+ deviceIds,
83
+ });
84
+
85
+ const batchResults = await batchRunner(pb, batchDevices, {
86
+ user,
87
+ concurrency: batchDevices.length,
88
+ failFast: false,
89
+ checkMode: !!opts.checkMode,
90
+ baseDir: opts.baseDir,
91
+ overrides: opts.overrides,
92
+ });
93
+
94
+ let batchSucceeded = 0;
95
+ let batchFailed = 0;
96
+ for (let i = 0; i < batchResults.length; i++) {
97
+ const r = batchResults[i];
98
+ const absIndex = start + i;
99
+ results[absIndex] = r;
100
+ attempted += 1;
101
+ if (r && r.ok) {
102
+ succeeded += 1;
103
+ batchSucceeded += 1;
104
+ } else {
105
+ failed += 1;
106
+ batchFailed += 1;
107
+ }
108
+ opts.onDeviceResult?.(r);
109
+ }
110
+
111
+ const cumulative = {
112
+ attempted,
113
+ succeeded,
114
+ failed,
115
+ failureRate: attempted === 0 ? 0 : (failed / attempted) * 100,
116
+ };
117
+ batches.push({
118
+ index: batchIndex,
119
+ size: batchDevices.length,
120
+ firstIndex: start,
121
+ deviceIds,
122
+ succeeded: batchSucceeded,
123
+ failed: batchFailed,
124
+ });
125
+ opts.onBatchEnd?.({
126
+ index: batchIndex,
127
+ size: batchDevices.length,
128
+ firstIndex: start,
129
+ deviceIds,
130
+ results: batchResults,
131
+ cumulative,
132
+ });
133
+
134
+ if (failed > 0 && cumulative.failureRate >= failureThreshold) {
135
+ aborted = true;
136
+ reason = 'failure-threshold';
137
+ break;
138
+ }
139
+ }
140
+
141
+ if (aborted) {
142
+ const skipReason = reason === 'cancelled' ? 'cancelled' : 'aborted (failure threshold reached)';
143
+ for (let i = 0; i < devices.length; i++) {
144
+ if (results[i] === undefined) {
145
+ results[i] = {
146
+ device: devices[i].device,
147
+ ok: false,
148
+ skipped: true,
149
+ steps: [],
150
+ error: skipReason,
151
+ };
152
+ }
153
+ }
154
+ }
155
+
156
+ return {
157
+ results,
158
+ aborted,
159
+ reason,
160
+ attempted,
161
+ succeeded,
162
+ failed,
163
+ failureRate: attempted === 0 ? 0 : (failed / attempted) * 100,
164
+ batches,
165
+ };
166
+ }
167
+
168
+ function clampThreshold(value) {
169
+ if (value === undefined || value === null) return DEFAULT_FAILURE_THRESHOLD;
170
+ const n = Number(value);
171
+ if (!Number.isFinite(n)) return DEFAULT_FAILURE_THRESHOLD;
172
+ if (n < 0) return 0;
173
+ if (n > 100) return 100;
174
+ return n;
175
+ }