@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
package/lib/product.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  writeStorageFile,
12
12
  deleteStorageFile,
13
13
  } from './storage.js';
14
+ import { parsePlaybook } from './playbook/loader.js';
14
15
 
15
16
  /**
16
17
  * Helpers for ThinRemote products and the product-script workflow.
@@ -76,6 +77,18 @@ export async function createProduct(
76
77
  user,
77
78
  { icon = 'fab fa-linux', name, description } = {},
78
79
  ) {
80
+ // Icons accept either a Font Awesome class (string, legacy short form)
81
+ // or a structured object { type: 'fa'|'svg', source, color, background }.
82
+ // The structured form lets callers ship inline SVG markup directly.
83
+ const iconSpec =
84
+ typeof icon === 'object' && icon !== null
85
+ ? {
86
+ type: icon.type === 'svg' ? 'svg' : 'fa',
87
+ source: icon.source,
88
+ color: icon.color || '#000000',
89
+ background: icon.background || '#ffffff',
90
+ }
91
+ : { type: 'fa', source: icon, color: '#000000', background: '#ffffff' };
79
92
  try {
80
93
  await api.post(`${v1(user)}/products`, {
81
94
  product: productId,
@@ -87,7 +100,7 @@ export async function createProduct(
87
100
  icons: [
88
101
  {
89
102
  conditions: [],
90
- icon: { type: 'fa', source: icon, color: '#000000', background: '#ffffff' },
103
+ icon: iconSpec,
91
104
  },
92
105
  ],
93
106
  },
@@ -188,6 +201,58 @@ export async function setProductCode(productId, user, payload) {
188
201
  }
189
202
  }
190
203
 
204
+ // ─── Low-level: product properties (/v1/.../products/<id>/properties) ─
205
+ //
206
+ // Product-level properties are structured JSON values attached to the
207
+ // product itself (not to any single device). Typical uses: dashboard
208
+ // configuration, shared feature flags, a list of metrics to stream.
209
+ // Endpoint mirrors device properties but lives under the v1 product
210
+ // namespace on the server.
211
+
212
+ export async function getProductProperties(productId, user) {
213
+ try {
214
+ const res = await api.get(`${v1(user)}/products/${productId}/properties`);
215
+ return res.data || [];
216
+ } catch (e) {
217
+ throw apiError(e, { notFound: `Product not found: ${productId}` });
218
+ }
219
+ }
220
+
221
+ export async function getProductProperty(productId, propertyId, user) {
222
+ try {
223
+ const res = await api.get(
224
+ `${v1(user)}/products/${productId}/properties/${propertyId}`,
225
+ );
226
+ return res.data?.value;
227
+ } catch (e) {
228
+ throw apiError(e, {
229
+ notFound: `Property not found: ${propertyId} on product ${productId}`,
230
+ });
231
+ }
232
+ }
233
+
234
+ export async function setProductProperty(productId, propertyId, value, user) {
235
+ try {
236
+ const res = await api.put(
237
+ `${v1(user)}/products/${productId}/properties/${propertyId}`,
238
+ { value },
239
+ );
240
+ return res.data;
241
+ } catch (e) {
242
+ throw apiError(e, { notFound: `Product not found: ${productId}` });
243
+ }
244
+ }
245
+
246
+ export async function deleteProductProperty(productId, propertyId, user) {
247
+ try {
248
+ await api.delete(`${v1(user)}/products/${productId}/properties/${propertyId}`);
249
+ return true;
250
+ } catch (e) {
251
+ if (e.response?.status === 404) return false;
252
+ throw apiError(e);
253
+ }
254
+ }
255
+
191
256
  // ─── Low-level: device ↔ product binding ─────────────────────────────
192
257
 
193
258
  export async function setDeviceProduct(deviceId, productId, user) {
@@ -214,12 +279,36 @@ const PRODUCT_SCRIPT_INDEX_JS = `function script(ev) {
214
279
  );
215
280
  if (!match) throw new Error("script not found: " + ev.resource);
216
281
  const body = fs.readFileSync(path.join(dir, match), "utf8");
282
+ // Input can arrive in three shapes depending on payload_type:
283
+ // - template_payload: ev.input (envelope from buildInputTemplate)
284
+ // - source_event: ev.payload.input or ev.payload (free-form)
285
+ // - source_payload: ev (already the payload)
286
+ const inp = ev.input !== undefined ? ev.input
287
+ : (ev.payload && ev.payload.input !== undefined) ? ev.payload.input
288
+ : ev.payload;
217
289
  return {
218
290
  cmd: body, mode: "api", timeout: 30,
219
- stdin: ev.payload && ev.payload.input !== undefined
220
- ? JSON.stringify(ev.payload.input) : undefined
291
+ stdin: inp !== undefined && inp !== null ? JSON.stringify(inp) : undefined
221
292
  };
222
293
  }
294
+
295
+ function parse_stdout(ev) {
296
+ const p = (ev && ev.payload) || {};
297
+ if (p.retcode !== 0) {
298
+ const msg = (p.stderr && String(p.stderr).trim()) ||
299
+ ("script exited with code " + p.retcode);
300
+ return { error: msg, retcode: p.retcode };
301
+ }
302
+ try {
303
+ return JSON.parse(p.stdout);
304
+ } catch (e) {
305
+ return {
306
+ error: "invalid JSON in stdout",
307
+ detail: e.message,
308
+ stdout: p.stdout
309
+ };
310
+ }
311
+ }
223
312
  `;
224
313
 
225
314
  /** Drop a product (and, by default, its companion file storage). */
@@ -243,6 +332,29 @@ export async function deleteProductWithStorage(productId, user, { keepStorage =
243
332
  return { steps };
244
333
  }
245
334
 
335
+ function buildInputTemplate(input) {
336
+ const pairs = Object.entries(input).map(([k, v]) => {
337
+ if (typeof v === 'string') {
338
+ return ` "${k}": "{{payload.${k}="${v}"}}"`;
339
+ }
340
+ if (typeof v === 'number' || typeof v === 'boolean') {
341
+ return ` "${k}": {{payload.${k}=${v}}}`;
342
+ }
343
+ return ` "${k}": ${JSON.stringify(v)}`;
344
+ });
345
+ // Keep {{resource}} in the envelope so the wrapper can still discover
346
+ // which script to dispatch — template_payload strips the event context
347
+ // away and only hands the rendered object to the payload_function.
348
+ return [
349
+ '{',
350
+ ' "resource": "{{resource}}",',
351
+ ' "input": {',
352
+ pairs.join(',\n'),
353
+ ' }',
354
+ '}',
355
+ ].join('\n');
356
+ }
357
+
246
358
  /**
247
359
  * Install (create or update) a product script. Ensures the product
248
360
  * exists, the storage exists, the index.js wrapper is present, the
@@ -250,7 +362,7 @@ export async function deleteProductWithStorage(productId, user, { keepStorage =
250
362
  * storage, and the API resource named after the script's stem is
251
363
  * registered. Idempotent.
252
364
  */
253
- export async function installProductScript({ product, name, content, user, icon } = {}) {
365
+ export async function installProductScript({ product, name, content, user, input, output } = {}) {
254
366
  if (!product) throw inputError('product is required');
255
367
  if (!name) throw inputError('name is required');
256
368
  if (typeof content !== 'string') throw inputError('content is required (string)');
@@ -259,16 +371,32 @@ export async function installProductScript({ product, name, content, user, icon
259
371
  `Invalid script name "${name}" (allowed: letters, digits, dot, dash, underscore)`,
260
372
  );
261
373
  }
374
+ if (input !== undefined && input !== null) {
375
+ if (typeof input !== 'object' || Array.isArray(input)) {
376
+ throw inputError('input must be a plain object mapping field names to default values');
377
+ }
378
+ for (const k of Object.keys(input)) {
379
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
380
+ throw inputError(`Invalid input field name "${k}"`);
381
+ }
382
+ }
383
+ }
262
384
 
263
385
  const storage = product;
264
386
  const stem = name.replace(/\.[^.]+$/, '');
265
387
  const steps = [];
266
388
 
267
- // 1. Ensure product exists
389
+ // 1. Refuse to silently create the product. The agent flow is
390
+ // list → create → write-script: if the product is missing, the
391
+ // caller has to create it explicitly with `thinr_product_create`
392
+ // so its name, description and icon are set deliberately rather
393
+ // than picking up defaults from a side effect.
268
394
  if (!(await productExists(product, user))) {
269
- const usedIcon = icon || 'fab fa-linux';
270
- await createProduct(product, user, { icon: usedIcon });
271
- steps.push(`created product "${product}" (icon: ${usedIcon})`);
395
+ const err = new Error(
396
+ `Product "${product}" does not exist. Create it first with thinr_product_create.`,
397
+ );
398
+ err.code = 'not_found';
399
+ throw err;
272
400
  }
273
401
 
274
402
  // 2. Ensure storage exists
@@ -277,8 +405,19 @@ export async function installProductScript({ product, name, content, user, icon
277
405
  steps.push(`created storage "${storage}"`);
278
406
  }
279
407
 
280
- // 3. Ensure the generic index.js wrapper is in place
281
- if (!(await storageFileExists(storage, 'index.js', user))) {
408
+ // 3. Ensure the generic index.js wrapper is in place and up to date.
409
+ // Rewrite only when the contents differ, so hand-edited wrappers are
410
+ // preserved if the user tweaked theirs and still match.
411
+ let needsWrapper = true;
412
+ if (await storageFileExists(storage, 'index.js', user)) {
413
+ try {
414
+ const current = await readStorageFile(storage, 'index.js', user);
415
+ if (current === PRODUCT_SCRIPT_INDEX_JS) needsWrapper = false;
416
+ } catch {
417
+ // unreadable → just overwrite.
418
+ }
419
+ }
420
+ if (needsWrapper) {
282
421
  await writeStorageFile(storage, 'index.js', PRODUCT_SCRIPT_INDEX_JS, user);
283
422
  steps.push('wrote index.js wrapper');
284
423
  }
@@ -301,18 +440,39 @@ export async function installProductScript({ product, name, content, user, icon
301
440
  steps.push('enabled product');
302
441
  }
303
442
 
304
- // 7. Create/update the API resource bound to the script stem
443
+ // 7. Create/update the API resource bound to the script stem.
444
+ // With an input schema, register a template_payload so the UI can
445
+ // render typed fields; without one, fall back to source_event so any
446
+ // object the caller sends flows straight through.
447
+ const requestData = {
448
+ target: 'resource',
449
+ resource: 'cmd',
450
+ payload_function: 'script',
451
+ };
452
+ if (input && Object.keys(input).length > 0) {
453
+ // The frontend encodes "Template Payload" as an empty payload_type
454
+ // string (see profile-source-configurator.html), and the backend
455
+ // defaults to template_payload when the value is unrecognised.
456
+ requestData.payload_type = '';
457
+ requestData.payload = buildInputTemplate(input);
458
+ } else {
459
+ requestData.payload_type = 'source_event';
460
+ }
461
+ // When the caller explicitly opts out of the response (output: false)
462
+ // the resource becomes fire-and-forget: no parse_stdout, no body back.
463
+ // Combined with no input this classifies as a "run" resource.
464
+ const wantOutput = output !== false;
465
+ const responseData = wantOutput
466
+ ? {
467
+ source: 'request_response',
468
+ payload_type: 'source_event',
469
+ payload_function: 'parse_stdout',
470
+ }
471
+ : { payload_type: 'none' };
305
472
  await setProductApiResource(product, stem, user, {
306
473
  enabled: true,
307
- request: {
308
- data: {
309
- target: 'resource',
310
- resource: 'cmd',
311
- payload_type: 'source_event',
312
- payload_function: 'script',
313
- },
314
- },
315
- response: { data: { source: 'request_response', payload_type: 'source_payload' } },
474
+ request: { data: requestData },
475
+ response: { data: responseData },
316
476
  });
317
477
  steps.push(`upserted API resource "${stem}"`);
318
478
 
@@ -371,3 +531,346 @@ export async function readProductScript(productId, name, user) {
371
531
  }
372
532
  return readStorageFile(productId, `scripts/${name}`, user);
373
533
  }
534
+
535
+ // ─── Orchestrators: dashboard metrics ────────────────────────────────
536
+ //
537
+ // The dashboard reads its metric panels from a single product property
538
+ // (`dashboard_metrics`) whose value is an array of metric entries. These
539
+ // helpers read/modify/write that list so callers don't have to care
540
+ // about the encoding — each metric is addressed by its stable `name`
541
+ // key, idempotently upserted or removed.
542
+
543
+ export const DASHBOARD_METRICS_PROPERTY = 'dashboard_metrics';
544
+
545
+ const METRIC_VISUALIZATIONS = new Set(['kpi', 'bar', 'sparkline', 'list']);
546
+ const METRIC_AGGREGATIONS = new Set([
547
+ 'avg',
548
+ 'count',
549
+ 'distribution',
550
+ 'max',
551
+ 'min',
552
+ 'none',
553
+ 'sum',
554
+ 'top',
555
+ ]);
556
+
557
+ function validateMetric(m) {
558
+ if (!m || typeof m !== 'object') throw inputError('metric must be an object');
559
+ if (!m.name || typeof m.name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(m.name)) {
560
+ throw inputError('metric.name is required (letters, digits, underscore, dash)');
561
+ }
562
+ if (!m.resource || typeof m.resource !== 'string') {
563
+ throw inputError('metric.resource is required (product API resource name)');
564
+ }
565
+ if (m.aggregation && !METRIC_AGGREGATIONS.has(m.aggregation)) {
566
+ throw inputError(
567
+ `metric.aggregation must be one of: ${[...METRIC_AGGREGATIONS].join(', ')}`,
568
+ );
569
+ }
570
+ if (m.visualization && !METRIC_VISUALIZATIONS.has(m.visualization)) {
571
+ throw inputError(
572
+ `metric.visualization must be one of: ${[...METRIC_VISUALIZATIONS].join(', ')}`,
573
+ );
574
+ }
575
+ if (m.interval != null) {
576
+ const n = Number(m.interval);
577
+ if (!Number.isFinite(n) || n < 1) {
578
+ throw inputError('metric.interval must be a positive number of seconds');
579
+ }
580
+ }
581
+ }
582
+
583
+ async function loadMetricList(productId, user) {
584
+ try {
585
+ const value = await getProductProperty(productId, DASHBOARD_METRICS_PROPERTY, user);
586
+ if (Array.isArray(value)) return value;
587
+ if (Array.isArray(value?.metrics)) return value.metrics;
588
+ return [];
589
+ } catch (e) {
590
+ if (/Property not found/.test(e.message || '')) return [];
591
+ throw e;
592
+ }
593
+ }
594
+
595
+ export async function listDashboardMetrics(productId, user) {
596
+ return loadMetricList(productId, user);
597
+ }
598
+
599
+ export async function upsertDashboardMetric(productId, metric, user) {
600
+ validateMetric(metric);
601
+ const list = await loadMetricList(productId, user);
602
+ const idx = list.findIndex((m) => m.name === metric.name);
603
+ const next = [...list];
604
+ const action = idx >= 0 ? 'updated' : 'added';
605
+ if (idx >= 0) next[idx] = { ...list[idx], ...metric };
606
+ else next.push(metric);
607
+ await setProductProperty(productId, DASHBOARD_METRICS_PROPERTY, next, user);
608
+ return { action, metric, count: next.length };
609
+ }
610
+
611
+ export async function removeDashboardMetric(productId, name, user) {
612
+ if (!name || typeof name !== 'string') throw inputError('metric name is required');
613
+ const list = await loadMetricList(productId, user);
614
+ const next = list.filter((m) => m.name !== name);
615
+ if (next.length === list.length) return { action: 'noop', count: next.length };
616
+ if (next.length === 0) {
617
+ await deleteProductProperty(productId, DASHBOARD_METRICS_PROPERTY, user);
618
+ } else {
619
+ await setProductProperty(productId, DASHBOARD_METRICS_PROPERTY, next, user);
620
+ }
621
+ return { action: 'removed', count: next.length };
622
+ }
623
+
624
+ // ─── Orchestrators: product playbooks ────────────────────────────────
625
+ //
626
+ // Playbooks live as YAML files inside the product's file storage under
627
+ // a reserved `playbooks/` folder. A lightweight index kept in the
628
+ // product property `playbooks` mirrors the available entries with just
629
+ // name, description and storage path, so listing is a single property
630
+ // read instead of a storage scan. Defaults for playbook variables
631
+ // travel inside the YAML itself (see lib/playbook/loader.js) — the
632
+ // execution tasks consume them without a separate metadata file.
633
+
634
+ export const PLAYBOOK_INDEX_PROPERTY = 'playbooks';
635
+ export const PLAYBOOK_NAME_RE = /^[A-Za-z0-9_-]+$/;
636
+
637
+ export function playbookStoragePath(name) {
638
+ return `playbooks/${name}.yaml`;
639
+ }
640
+
641
+ function assertPlaybookName(name) {
642
+ if (!name || typeof name !== 'string') {
643
+ throw inputError('playbook name is required');
644
+ }
645
+ if (!PLAYBOOK_NAME_RE.test(name)) {
646
+ throw inputError(
647
+ `Invalid playbook name "${name}" (allowed: letters, digits, underscore, dash)`,
648
+ );
649
+ }
650
+ }
651
+
652
+ async function loadPlaybookIndex(productId, user) {
653
+ try {
654
+ const value = await getProductProperty(productId, PLAYBOOK_INDEX_PROPERTY, user);
655
+ if (Array.isArray(value)) return value;
656
+ if (Array.isArray(value?.playbooks)) return value.playbooks;
657
+ return [];
658
+ } catch (e) {
659
+ if (/Property not found/.test(e.message || '')) return [];
660
+ throw e;
661
+ }
662
+ }
663
+
664
+ async function savePlaybookIndex(productId, list, user) {
665
+ if (!list || list.length === 0) {
666
+ await deleteProductProperty(productId, PLAYBOOK_INDEX_PROPERTY, user);
667
+ return;
668
+ }
669
+ await setProductProperty(productId, PLAYBOOK_INDEX_PROPERTY, list, user);
670
+ }
671
+
672
+ export async function listProductPlaybooks(productId, user) {
673
+ const list = await loadPlaybookIndex(productId, user);
674
+ return list.map((e) => ({
675
+ name: e.name,
676
+ description: typeof e.description === 'string' ? e.description : '',
677
+ path: e.path || playbookStoragePath(e.name),
678
+ }));
679
+ }
680
+
681
+ export async function findProductPlaybook(productId, name, user) {
682
+ assertPlaybookName(name);
683
+ const list = await loadPlaybookIndex(productId, user);
684
+ const entry = list.find((e) => e.name === name);
685
+ if (!entry) return null;
686
+ return {
687
+ name: entry.name,
688
+ description: typeof entry.description === 'string' ? entry.description : '',
689
+ path: entry.path || playbookStoragePath(entry.name),
690
+ };
691
+ }
692
+
693
+ export async function readProductPlaybook(productId, name, user) {
694
+ assertPlaybookName(name);
695
+ const entry = await findProductPlaybook(productId, name, user);
696
+ const path = entry?.path || playbookStoragePath(name);
697
+ return readStorageFile(productId, path, user);
698
+ }
699
+
700
+ /**
701
+ * Upload a playbook YAML to a product. Validates the name, optionally
702
+ * validates the YAML against the playbook schema, ensures the storage
703
+ * exists, writes the file, and only then registers the entry in the
704
+ * index property. If the index update fails the file is rolled back
705
+ * so no orphans are left behind. Returns `{ action, entry, replaced }`.
706
+ *
707
+ * @param {{
708
+ * product?: string,
709
+ * name?: string,
710
+ * content?: unknown,
711
+ * description?: string,
712
+ * user?: string,
713
+ * skipValidation?: boolean,
714
+ * }} [opts]
715
+ */
716
+ export async function uploadProductPlaybook({
717
+ product,
718
+ name,
719
+ content,
720
+ description,
721
+ user,
722
+ skipValidation = false,
723
+ } = {}) {
724
+ if (!product) throw inputError('product is required');
725
+ assertPlaybookName(name);
726
+ if (typeof content !== 'string') throw inputError('content is required (string)');
727
+
728
+ let parsedDescription = null;
729
+ if (!skipValidation) {
730
+ const pb = parsePlaybook(content, { sourcePath: `${name}.yaml` });
731
+ parsedDescription = pb.description;
732
+ }
733
+
734
+ const storage = product;
735
+ const path = playbookStoragePath(name);
736
+ const steps = [];
737
+
738
+ if (!(await storageExists(storage, user))) {
739
+ await createStorage(storage, user, { description: `Product storage for ${product}` });
740
+ steps.push(`created storage "${storage}"`);
741
+ }
742
+
743
+ const existing = await loadPlaybookIndex(product, user);
744
+ const priorIdx = existing.findIndex((e) => e.name === name);
745
+ const replaced = priorIdx >= 0;
746
+
747
+ await writeStorageFile(storage, path, content, user);
748
+ steps.push(`${replaced ? 'overwrote' : 'wrote'} ${path} (${content.length} bytes)`);
749
+
750
+ const entry = {
751
+ name,
752
+ description:
753
+ typeof description === 'string'
754
+ ? description
755
+ : typeof parsedDescription === 'string'
756
+ ? parsedDescription
757
+ : '',
758
+ path,
759
+ };
760
+ const next = [...existing];
761
+ if (replaced) next[priorIdx] = entry;
762
+ else next.push(entry);
763
+
764
+ try {
765
+ await savePlaybookIndex(product, next, user);
766
+ steps.push(`${replaced ? 'updated' : 'registered'} index entry "${name}"`);
767
+ } catch (indexErr) {
768
+ // Rollback the file so the user doesn't end up with an orphan
769
+ // that `list` can't see. If rollback itself fails, surface that
770
+ // alongside the original error so the user can clean up.
771
+ let rollbackNote;
772
+ try {
773
+ if (replaced) {
774
+ // The file existed before — we overwrote it. Leaving it
775
+ // in its new shape is the least destructive option.
776
+ rollbackNote = ' (overwritten file kept, previous content lost)';
777
+ } else {
778
+ await deleteStorageFile(storage, path, user);
779
+ rollbackNote = ' (uploaded file rolled back)';
780
+ }
781
+ } catch (rollbackErr) {
782
+ rollbackNote = ` (rollback failed: ${rollbackErr.message || rollbackErr})`;
783
+ }
784
+ const err = new Error(
785
+ `Failed to update playbook index for "${name}"${rollbackNote}: ${indexErr.message || indexErr}`,
786
+ );
787
+ err.code = indexErr.code || 'error';
788
+ throw err;
789
+ }
790
+
791
+ return { action: replaced ? 'updated' : 'created', entry, replaced, steps };
792
+ }
793
+
794
+ /**
795
+ * Persist a fleet-run report in the product's file storage under
796
+ * `playbooks/runs/`. Returns `{ ok, path, reason }`. Best-effort: the
797
+ * caller decides whether to surface failures as warnings instead of
798
+ * aborting, since the rollout has already run by the time this is
799
+ * called.
800
+ *
801
+ * @param {{
802
+ * product: string,
803
+ * report: object,
804
+ * user?: string | null,
805
+ * filename?: string,
806
+ * }} opts
807
+ */
808
+ export async function uploadFleetRunReport({ product, report, user, filename } = {}) {
809
+ if (!product) throw inputError('product is required');
810
+ if (!report || typeof report !== 'object') throw inputError('report is required');
811
+
812
+ if (!(await storageExists(product, user))) {
813
+ return { ok: false, reason: 'storage-missing', path: null };
814
+ }
815
+ const path = `playbooks/runs/${filename || defaultRunReportFilename(report)}`;
816
+ try {
817
+ await writeStorageFile(product, path, JSON.stringify(report, null, 2), user);
818
+ return { ok: true, path, reason: null };
819
+ } catch (err) {
820
+ return {
821
+ ok: false,
822
+ reason: err instanceof Error ? err.message : String(err),
823
+ path: null,
824
+ };
825
+ }
826
+ }
827
+
828
+ function defaultRunReportFilename(report) {
829
+ const name = typeof report.name === 'string' && PLAYBOOK_NAME_RE.test(report.name)
830
+ ? report.name
831
+ : 'run';
832
+ const user = typeof report.user === 'string' && /^[A-Za-z0-9_.-]+$/.test(report.user)
833
+ ? report.user
834
+ : 'unknown';
835
+ const ts = typeof report.startedAt === 'string' ? report.startedAt : new Date().toISOString();
836
+ const stamp = ts.replace(/[:.]/g, '').replace(/[^0-9TZ]/g, '');
837
+ return `${stamp}-${name}-${user}.json`;
838
+ }
839
+
840
+ export async function deleteProductPlaybook({ product, name, user } = {}) {
841
+ if (!product) throw inputError('product is required');
842
+ assertPlaybookName(name);
843
+
844
+ const steps = [];
845
+ const list = await loadPlaybookIndex(product, user);
846
+ const idx = list.findIndex((e) => e.name === name);
847
+ const entry = idx >= 0 ? list[idx] : null;
848
+ const path = entry?.path || playbookStoragePath(name);
849
+
850
+ if (idx >= 0) {
851
+ const next = list.filter((_, i) => i !== idx);
852
+ await savePlaybookIndex(product, next, user);
853
+ steps.push(`removed index entry "${name}"`);
854
+ } else {
855
+ steps.push(`index entry "${name}" already absent`);
856
+ }
857
+
858
+ let fileRemoved = false;
859
+ try {
860
+ fileRemoved = await deleteStorageFile(product, path, user);
861
+ steps.push(fileRemoved ? `deleted ${path}` : `${path} already absent`);
862
+ } catch (e) {
863
+ // Index is already clean at this point. Surface the file error
864
+ // as a warning step rather than failing the whole op, since the
865
+ // user now just has an orphan to clean up manually.
866
+ steps.push(`warning: failed to delete ${path}: ${e.message || e}`);
867
+ }
868
+
869
+ return {
870
+ removed: idx >= 0 || fileRemoved,
871
+ indexRemoved: idx >= 0,
872
+ fileRemoved,
873
+ entry,
874
+ steps,
875
+ };
876
+ }