@openpalm/lib 0.10.2 → 0.11.0-beta.10

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 (63) hide show
  1. package/README.md +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. package/src/control-plane/spec-validator.ts +0 -159
@@ -21,11 +21,15 @@ import {
21
21
  getRegistryAddonConfig,
22
22
  listAvailableAddonIds,
23
23
  getAddonServiceNames,
24
- enableAddon,
25
- disableAddonByName,
24
+ getAddonProfiles,
25
+ getAddonProfileSelection,
26
+ setAddonProfileSelection,
26
27
  setAddonEnabled,
27
28
  installAutomationFromRegistry,
28
29
  uninstallAutomation,
30
+ getAddonProfileAvailability,
31
+ annotateAddonProfileAvailability,
32
+ __addonAvailabilityTestHooks,
29
33
  } from "./registry.js";
30
34
 
31
35
  // ── Validation Tests ─────────────────────────────────────────────────
@@ -201,75 +205,76 @@ describe("materialized registry catalog", () => {
201
205
 
202
206
  it("materializes addons and automations into OP_HOME/registry", () => {
203
207
  const sourceRoot = join(tmpDir, 'repo');
204
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
205
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
208
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
209
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
206
210
 
207
211
  mkdirSync(addonDir, { recursive: true });
208
212
  mkdirSync(automationsDir, { recursive: true });
209
213
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
210
214
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
211
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
215
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
212
216
 
213
217
  const root = materializeRegistryCatalog(sourceRoot);
214
218
 
215
- expect(root).toBe(join(process.env.OP_HOME!, 'registry'));
219
+ expect(root).toBe(join(process.env.OP_HOME!, 'state', 'registry'));
216
220
  expect(existsSync(join(root, 'addons', 'chat', 'compose.yml'))).toBe(true);
217
221
  expect(existsSync(join(root, 'addons', 'chat', '.env.schema'))).toBe(true);
218
- expect(readFileSync(join(root, 'automations', 'cleanup.yml'), 'utf-8')).toContain('Cleanup');
222
+ expect(readFileSync(join(root, 'automations', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
219
223
  });
220
224
 
221
225
  it("discovers materialized registry entries", () => {
222
226
  const sourceRoot = join(tmpDir, 'repo');
223
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
224
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
227
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
228
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
225
229
 
226
230
  mkdirSync(addonDir, { recursive: true });
227
231
  mkdirSync(automationsDir, { recursive: true });
228
232
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
229
233
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
230
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
234
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
231
235
 
232
236
  materializeRegistryCatalog(sourceRoot);
233
237
 
234
238
  const components = discoverRegistryComponents();
235
- const automations = discoverRegistryAutomations();
239
+ const stashDir = join(process.env.OP_HOME!, 'stash');
240
+ const automations = discoverRegistryAutomations(stashDir);
236
241
 
237
242
  expect(Object.keys(components)).toEqual(['chat']);
238
243
  expect(components.chat?.schema).toContain('CHANNEL_CHAT_SECRET');
239
244
  expect(automations.map((entry) => entry.name)).toEqual(['cleanup']);
240
- expect(getRegistryAutomation('cleanup')).toContain('schedule: daily');
245
+ expect(getRegistryAutomation('cleanup')).toContain('schedule: "0 3 * * *"');
241
246
  });
242
247
 
243
248
  it("returns addon config metadata from the materialized registry", () => {
244
249
  const sourceRoot = join(tmpDir, 'repo');
245
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
246
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
250
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
251
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
247
252
 
248
253
  mkdirSync(addonDir, { recursive: true });
249
254
  mkdirSync(automationsDir, { recursive: true });
250
255
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
251
256
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
252
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
257
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
253
258
 
254
259
  materializeRegistryCatalog(sourceRoot);
255
260
 
256
261
  expect(getRegistryAddonConfig(process.env.OP_HOME!, 'chat')).toEqual({
257
- schemaPath: 'registry/addons/chat/.env.schema',
258
- userEnvPath: 'vault/user/user.env',
262
+ schemaPath: 'state/registry/addons/chat/.env.schema',
263
+ userEnvPath: 'config/stack/stack.env',
259
264
  envSchema: 'CHANNEL_CHAT_SECRET=\n',
260
265
  });
261
266
  });
262
267
 
263
268
  it("verifies the materialized registry and returns counts", () => {
264
269
  const sourceRoot = join(tmpDir, 'repo');
265
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
266
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
270
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
271
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
267
272
 
268
273
  mkdirSync(addonDir, { recursive: true });
269
274
  mkdirSync(automationsDir, { recursive: true });
270
275
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
271
276
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
272
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
277
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
273
278
 
274
279
  const root = materializeRegistryCatalog(sourceRoot);
275
280
 
@@ -286,77 +291,78 @@ describe("materialized registry catalog", () => {
286
291
 
287
292
  it("fails when source catalog is incomplete", () => {
288
293
  const sourceRoot = join(tmpDir, 'repo');
289
- mkdirSync(join(sourceRoot, '.openpalm', 'registry', 'addons'), { recursive: true });
290
- mkdirSync(join(sourceRoot, '.openpalm', 'registry', 'automations'), { recursive: true });
294
+ mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'addons'), { recursive: true });
295
+ mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'automations'), { recursive: true });
291
296
 
292
297
  expect(() => materializeRegistryCatalog(sourceRoot)).toThrow('Registry catalog is incomplete');
293
298
  });
294
299
 
295
300
  it("enables and disables addons through the runtime stack directory", () => {
296
301
  const sourceRoot = join(tmpDir, 'repo');
297
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
298
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
302
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
303
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
299
304
 
300
305
  mkdirSync(addonDir, { recursive: true });
301
306
  mkdirSync(automationsDir, { recursive: true });
302
307
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
303
308
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
304
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
309
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
305
310
 
306
311
  materializeRegistryCatalog(sourceRoot);
307
312
 
308
- expect(enableAddon(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
309
- expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
313
+ const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
314
+ expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', true)).toMatchObject({ ok: true });
315
+ expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
310
316
 
311
- expect(disableAddonByName(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
312
- expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat'))).toBe(false);
317
+ expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', false)).toMatchObject({ ok: true });
318
+ expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
313
319
  });
314
320
 
315
321
  it("returns addon service names from stack or registry compose files", () => {
316
322
  const sourceRoot = join(tmpDir, 'repo');
317
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'admin');
318
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
323
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'proxy-test');
324
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
319
325
 
320
326
  mkdirSync(addonDir, { recursive: true });
321
327
  mkdirSync(automationsDir, { recursive: true });
322
- writeFileSync(join(addonDir, 'compose.yml'), 'services:\n docker-socket-proxy:\n image: proxy\n admin:\n image: admin\n');
323
- writeFileSync(join(addonDir, '.env.schema'), 'OP_ADMIN_TOKEN=\n');
324
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
328
+ writeFileSync(join(addonDir, 'compose.yml'), 'services:\n svc-a:\n image: image-a\n svc-b:\n image: image-b\n');
329
+ writeFileSync(join(addonDir, '.env.schema'), 'PROXY_TOKEN=\n');
330
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
325
331
 
326
332
  materializeRegistryCatalog(sourceRoot);
327
333
 
328
- expect(getAddonServiceNames(process.env.OP_HOME!, 'admin')).toEqual(['docker-socket-proxy', 'admin']);
334
+ expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['svc-a', 'svc-b']);
329
335
  });
330
336
 
331
337
  it("toggles addons and generates channel secrets when enabling channel addons", () => {
332
338
  const sourceRoot = join(tmpDir, 'repo');
333
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
334
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
339
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
340
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
335
341
 
336
342
  mkdirSync(addonDir, { recursive: true });
337
343
  mkdirSync(automationsDir, { recursive: true });
338
344
  writeFileSync(join(addonDir, 'compose.yml'), 'services:\n chat:\n image: test\n environment:\n CHANNEL_NAME: "Chat"\n CHANNEL_ID: "chat"\n');
339
345
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
340
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
346
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
341
347
 
342
348
  materializeRegistryCatalog(sourceRoot);
343
349
 
344
- expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'vault'), 'chat', true)).toEqual({
350
+ expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', true)).toEqual({
345
351
  ok: true,
346
352
  enabled: true,
347
353
  changed: true,
348
354
  services: ['chat'],
349
355
  });
350
- expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
351
- expect(readFileSync(join(process.env.OP_HOME!, 'vault', 'stack', 'guardian.env'), 'utf-8')).toMatch(/CHANNEL_CHAT_SECRET=/);
356
+ expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
357
+ expect(readFileSync(join(process.env.OP_HOME!, 'config', 'stack', 'guardian.env'), 'utf-8')).toMatch(/CHANNEL_CHAT_SECRET=/);
352
358
 
353
- expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'vault'), 'chat', false)).toEqual({
359
+ expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', false)).toEqual({
354
360
  ok: true,
355
361
  enabled: false,
356
362
  changed: true,
357
363
  services: ['chat'],
358
364
  });
359
- expect(existsSync(join(process.env.OP_HOME!, 'stack', 'addons', 'chat'))).toBe(false);
365
+ expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
360
366
  });
361
367
 
362
368
  it("backs up OP_HOME without recursively copying backups", () => {
@@ -390,10 +396,71 @@ describe("materialized registry catalog", () => {
390
396
  expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
391
397
  });
392
398
 
393
- it("installs and uninstalls automations through config/automations", () => {
399
+ it("parses compose profiles + openpalm.profile.* labels per addon", () => {
394
400
  const sourceRoot = join(tmpDir, 'repo');
395
- const addonDir = join(sourceRoot, '.openpalm', 'registry', 'addons', 'chat');
396
- const automationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
401
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
402
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
403
+
404
+ mkdirSync(addonDir, { recursive: true });
405
+ mkdirSync(automationsDir, { recursive: true });
406
+ writeFileSync(
407
+ join(addonDir, 'compose.yml'),
408
+ [
409
+ 'services:',
410
+ ' voice:',
411
+ ' profiles: [cpu]',
412
+ ' image: openpalm/voice:cpu',
413
+ ' labels:',
414
+ ' openpalm.profile.label: CPU',
415
+ ' openpalm.profile.default: "true"',
416
+ ' voice-cuda:',
417
+ ' profiles: [cuda]',
418
+ ' image: openpalm/voice:cuda',
419
+ ' labels:',
420
+ ' openpalm.profile.label: NVIDIA',
421
+ ' openpalm.profile.requires: nvidia-container-toolkit',
422
+ '',
423
+ ].join('\n'),
424
+ );
425
+ writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
426
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
427
+
428
+ materializeRegistryCatalog(sourceRoot);
429
+
430
+ const profiles = getAddonProfiles(process.env.OP_HOME!, 'voice');
431
+ expect(profiles).toEqual([
432
+ { id: 'cpu', services: ['voice'], label: 'CPU', default: true },
433
+ { id: 'cuda', services: ['voice-cuda'], label: 'NVIDIA', requires: 'nvidia-container-toolkit' },
434
+ ]);
435
+ });
436
+
437
+ it("round-trips addon profile selection through stack.env", () => {
438
+ const sourceRoot = join(tmpDir, 'repo');
439
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
440
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
441
+
442
+ mkdirSync(addonDir, { recursive: true });
443
+ mkdirSync(automationsDir, { recursive: true });
444
+ writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: [cpu]\n image: x\n');
445
+ writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
446
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
447
+
448
+ materializeRegistryCatalog(sourceRoot);
449
+
450
+ const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
451
+ mkdirSync(stackDir, { recursive: true });
452
+ writeFileSync(join(stackDir, 'stack.env'), '');
453
+
454
+ expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
455
+ setAddonProfileSelection(stackDir, 'voice', 'cuda');
456
+ expect(getAddonProfileSelection(stackDir, 'voice')).toBe('cuda');
457
+ expect(readFileSync(join(stackDir, 'stack.env'), 'utf-8')).toContain('OP_VOICE_PROFILE=cuda');
458
+ });
459
+
460
+ it("installs and uninstalls automations through stash/tasks", () => {
461
+ const sourceRoot = join(tmpDir, 'repo');
462
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
463
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
397
464
  const configDir = join(process.env.OP_HOME!, 'config');
398
465
 
399
466
  mkdirSync(addonDir, { recursive: true });
@@ -401,14 +468,143 @@ describe("materialized registry catalog", () => {
401
468
  mkdirSync(configDir, { recursive: true });
402
469
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
403
470
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
404
- writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: daily\n');
471
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
405
472
 
406
473
  materializeRegistryCatalog(sourceRoot);
407
474
 
408
- expect(installAutomationFromRegistry('cleanup', configDir)).toEqual({ ok: true });
409
- expect(readFileSync(join(configDir, 'automations', 'cleanup.yml'), 'utf-8')).toContain('Cleanup');
475
+ const stashDir = join(process.env.OP_HOME!, 'stash');
476
+ expect(installAutomationFromRegistry('cleanup', stashDir)).toEqual({ ok: true });
477
+ expect(readFileSync(join(stashDir, 'tasks', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
410
478
 
411
- expect(uninstallAutomation('cleanup', configDir)).toEqual({ ok: true });
412
- expect(existsSync(join(configDir, 'automations', 'cleanup.yml'))).toBe(false);
479
+ expect(uninstallAutomation('cleanup', stashDir)).toEqual({ ok: true });
480
+ expect(existsSync(join(stashDir, 'tasks', 'cleanup.md'))).toBe(false);
481
+ });
482
+ });
483
+
484
+ // ── Host capability probes ───────────────────────────────────────────
485
+
486
+ describe("getAddonProfileAvailability", () => {
487
+ beforeEach(() => {
488
+ __addonAvailabilityTestHooks.reset();
489
+ });
490
+
491
+ afterEach(() => {
492
+ __addonAvailabilityTestHooks.reset();
493
+ });
494
+
495
+ it("returns available:true for the cpu profile (no host requirements)", async () => {
496
+ const result = await getAddonProfileAvailability({ id: 'cpu' });
497
+ expect(result.available).toBe(true);
498
+ expect(result.reason).toBeUndefined();
499
+ });
500
+
501
+ it("returns available:true for unknown profile ids (no host-side gating)", async () => {
502
+ const result = await getAddonProfileAvailability({ id: 'something-else' });
503
+ expect(result.available).toBe(true);
504
+ });
505
+
506
+ it("caches the result across calls (probe runs only once)", async () => {
507
+ const a = await getAddonProfileAvailability({ id: 'cpu' });
508
+ const b = await getAddonProfileAvailability({ id: 'cpu' });
509
+ expect(a).toBe(b); // same reference — cached
510
+ });
511
+
512
+ it("probes cuda: returns available:false on a host with no NVIDIA runtime / CDI", async () => {
513
+ // This test runs on CI/dev machines without GPUs. We don't mock execFile;
514
+ // we just assert the contract: when neither signal is present, the
515
+ // reason mentions nvidia-container-toolkit. If a future GPU host runs
516
+ // this test, the assertion still tolerates the success case.
517
+ const result = await getAddonProfileAvailability({ id: 'cuda' });
518
+ if (!result.available) {
519
+ expect(result.reason).toContain('NVIDIA');
520
+ } else {
521
+ // Host genuinely has the runtime registered — accept it.
522
+ expect(result.reason).toBeUndefined();
523
+ }
524
+ });
525
+
526
+ it("probes rocm: returns available:false when /dev/kfd is missing", async () => {
527
+ const result = await getAddonProfileAvailability({ id: 'rocm' });
528
+ if (!result.available) {
529
+ expect(result.reason).toContain('ROCm');
530
+ } else {
531
+ expect(result.reason).toBeUndefined();
532
+ }
533
+ });
534
+
535
+ it("probes rocm: when devices exist, reports unpublished image distinctly from missing-device case", async () => {
536
+ // On a host without /dev/kfd, we hit the device-missing branch and
537
+ // get the "devices not present" copy. On a ROCm host, we'd fall
538
+ // through to the manifest-inspect probe and (until 0.11.0-rocm6
539
+ // ships) get the "image not published yet" copy. Both must mention
540
+ // ROCm so operator-facing copy stays consistent.
541
+ const result = await getAddonProfileAvailability({ id: 'rocm' });
542
+ if (!result.available && existsSync('/dev/kfd') && existsSync('/dev/dri')) {
543
+ expect(result.reason).toMatch(/image not published|CPU profile/i);
544
+ }
545
+ if (!result.available && !(existsSync('/dev/kfd') && existsSync('/dev/dri'))) {
546
+ expect(result.reason).toMatch(/devices not present/i);
547
+ }
548
+ });
549
+ });
550
+
551
+ describe("execFileNoThrow (ENOENT capture)", () => {
552
+ it("captures ENOENT for a missing binary as 'spawn <cmd> ENOENT' stderr", async () => {
553
+ const result = await __addonAvailabilityTestHooks.execFileNoThrow(
554
+ '/nonexistent/path/to/openpalm-test-no-such-binary-zzz',
555
+ ['--help'],
556
+ 2_000,
557
+ );
558
+ expect(result.ok).toBe(false);
559
+ expect(result.stderr).toMatch(/ENOENT/);
560
+ // When the binary is "docker", the synthetic stderr becomes
561
+ // `spawn docker ENOENT: command not found` — that string matches the
562
+ // translateDockerError regex `/spawn .*docker.*ENOENT/i` so the
563
+ // operator gets actionable copy instead of "unknown error (no stderr)".
564
+ expect(result.stderr).toMatch(/spawn\s+\S*\s*ENOENT/);
565
+ });
566
+
567
+ it("formats ENOENT for `docker` so translateDockerError can match it", async () => {
568
+ // Use an absolute path that we know doesn't exist so the test is
569
+ // deterministic regardless of whether docker is installed on the host.
570
+ const result = await __addonAvailabilityTestHooks.execFileNoThrow(
571
+ 'docker-not-installed-zzz',
572
+ ['info'],
573
+ 2_000,
574
+ );
575
+ expect(result.ok).toBe(false);
576
+ expect(result.stderr).toBe('spawn docker-not-installed-zzz ENOENT: command not found');
577
+ });
578
+ });
579
+
580
+ describe("annotateAddonProfileAvailability", () => {
581
+ beforeEach(() => {
582
+ __addonAvailabilityTestHooks.reset();
583
+ });
584
+
585
+ afterEach(() => {
586
+ __addonAvailabilityTestHooks.reset();
587
+ });
588
+
589
+ it("decorates each profile with available + optional reason", async () => {
590
+ const out = await annotateAddonProfileAvailability([
591
+ { id: 'cpu', services: ['voice'], label: 'CPU', default: true },
592
+ { id: 'rocm', services: ['voice-rocm'], label: 'AMD' },
593
+ ]);
594
+ expect(out).toHaveLength(2);
595
+ expect(out[0]?.id).toBe('cpu');
596
+ expect(out[0]?.available).toBe(true);
597
+ // Preserves original fields.
598
+ expect(out[0]?.label).toBe('CPU');
599
+ expect(out[0]?.default).toBe(true);
600
+ expect(out[1]?.id).toBe('rocm');
601
+ expect(typeof out[1]?.available).toBe('boolean');
602
+ });
603
+
604
+ it("does not mutate the input array", async () => {
605
+ const input = [{ id: 'cpu', services: ['voice'] }];
606
+ const before = JSON.parse(JSON.stringify(input));
607
+ await annotateAddonProfileAvailability(input);
608
+ expect(input).toEqual(before);
413
609
  });
414
610
  });