@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
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Registry catalog discovery and refresh.
3
3
  *
4
- * `OP_HOME/registry` is the only persistent catalog location.
4
+ * `OP_HOME/state/registry` is the only persistent catalog location.
5
5
  * Install seeds it once; refresh replaces it explicitly.
6
6
  */
7
7
  import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
- import { execFileSync } from 'node:child_process';
8
+ import { execFile, execFileSync } from 'node:child_process';
9
9
  import { join } from 'node:path';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { parse as parseYaml } from 'yaml';
12
12
  import { createLogger } from '../logger.js';
13
13
  import { isChannelAddon } from './channels.js';
14
14
  import { randomHex, writeChannelSecrets } from './config-persistence.js';
15
+ import { patchSecretsEnvFile, readStackEnv } from './secrets.js';
15
16
  import {
16
17
  resolveRegistryAddonsDir,
17
18
  resolveRegistryAutomationsDir,
@@ -46,10 +47,10 @@ export function isValidComponentName(name: string): boolean {
46
47
 
47
48
  const DEFAULT_REPO = 'itlackey/openpalm';
48
49
 
49
- export interface RegistryConfig {
50
+ export type RegistryConfig = {
50
51
  repoUrl: string;
51
52
  branch: string;
52
- }
53
+ };
53
54
 
54
55
  export function getRegistryConfig(): RegistryConfig {
55
56
  return {
@@ -63,7 +64,7 @@ export type RegistryAutomationEntry = {
63
64
  type: 'automation';
64
65
  description: string;
65
66
  schedule: string;
66
- ymlContent: string;
67
+ content: string;
67
68
  };
68
69
 
69
70
  export type RegistryComponentEntry = {
@@ -83,7 +84,7 @@ export type RegistryCatalogVerification = {
83
84
  automationCount: number;
84
85
  };
85
86
 
86
- export type MutationResult = { ok: true } | { ok: false; error: string };
87
+ type MutationResult = { ok: true } | { ok: false; error: string };
87
88
  export type AddonMutationResult = (
88
89
  | { ok: true; enabled: boolean; changed: boolean; services: string[] }
89
90
  | { ok: false; error: string }
@@ -95,7 +96,10 @@ function countValidAddons(rootDir: string): number {
95
96
  return readdirSync(addonsDir, { withFileTypes: true }).filter((entry) => {
96
97
  if (!entry.isDirectory() || !isValidComponentName(entry.name)) return false;
97
98
  const addonDir = join(addonsDir, entry.name);
98
- return existsSync(join(addonDir, 'compose.yml')) && existsSync(join(addonDir, '.env.schema'));
99
+ // An addon is valid if it has a compose.yml. Overlay-only addons that only
100
+ // patch existing services (ports, env, volumes) do not need an .env.schema;
101
+ // full addons that introduce services and env vars do.
102
+ return existsSync(join(addonDir, 'compose.yml'));
99
103
  }).length;
100
104
  }
101
105
 
@@ -103,8 +107,8 @@ function countValidAutomations(rootDir: string): number {
103
107
  const automationsDir = join(rootDir, 'automations');
104
108
  if (!existsSync(automationsDir)) return 0;
105
109
  return readdirSync(automationsDir).filter((file) => {
106
- if (!file.endsWith('.yml')) return false;
107
- return isValidComponentName(file.replace(/\.yml$/, ''));
110
+ if (!file.endsWith('.md')) return false;
111
+ return isValidComponentName(file.replace(/\.md$/, ''));
108
112
  }).length;
109
113
  }
110
114
 
@@ -123,8 +127,8 @@ export function verifyRegistryCatalog(rootDir = resolveRegistryDir()): RegistryC
123
127
  }
124
128
 
125
129
  export function materializeRegistryCatalog(sourceRoot: string): string {
126
- const sourceAddonsDir = join(sourceRoot, '.openpalm', 'registry', 'addons');
127
- const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
130
+ const sourceAddonsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons');
131
+ const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
128
132
  const tempRoot = mkdtempSync(join(tmpdir(), 'openpalm-registry-materialize-'));
129
133
 
130
134
  try {
@@ -184,37 +188,46 @@ export function discoverRegistryComponents(): Record<string, RegistryComponentEn
184
188
  if (!entry.isDirectory() || !VALID_NAME_RE.test(entry.name)) continue;
185
189
  const addonDir = join(addonsDir, entry.name);
186
190
  const composeFile = join(addonDir, 'compose.yml');
191
+ if (!existsSync(composeFile)) continue;
192
+
193
+ // .env.schema is optional: overlay-only addons (e.g. a port toggle) do
194
+ // not introduce new env vars, so they ship just compose.yml.
187
195
  const schemaFile = join(addonDir, '.env.schema');
188
- if (!existsSync(composeFile) || !existsSync(schemaFile)) continue;
196
+ const schema = existsSync(schemaFile) ? readFileSync(schemaFile, 'utf-8') : '';
189
197
 
190
198
  result[entry.name] = {
191
199
  compose: readFileSync(composeFile, 'utf-8'),
192
- schema: readFileSync(schemaFile, 'utf-8'),
200
+ schema,
193
201
  };
194
202
  }
195
203
 
196
204
  return result;
197
205
  }
198
206
 
199
- export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
207
+ export function discoverRegistryAutomations(stashDir: string): RegistryAutomationEntry[] {
200
208
  const automationsDir = resolveRegistryAutomationsDir();
201
209
  if (!existsSync(automationsDir)) return [];
202
210
 
203
211
  return readdirSync(automationsDir)
204
- .filter((file) => file.endsWith('.yml'))
212
+ .filter((file) => file.endsWith('.md'))
205
213
  .map((file) => {
206
- const name = file.replace(/\.yml$/, '');
214
+ const name = file.replace(/\.md$/, '');
207
215
  if (!VALID_NAME_RE.test(name)) return null;
208
216
 
209
- const ymlContent = readFileSync(join(automationsDir, file), 'utf-8');
217
+ const content = readFileSync(join(automationsDir, file), 'utf-8');
210
218
  let description = '';
211
219
  let schedule = '';
212
220
 
221
+ // Extract frontmatter metadata (between --- delimiters)
213
222
  try {
214
- const parsed = parseYaml(ymlContent);
215
- if (parsed && typeof parsed === 'object') {
216
- description = parsed.description ?? '';
217
- schedule = parsed.schedule ?? '';
223
+ const after = content.startsWith('---') ? content.slice(3) : '';
224
+ const end = after.indexOf('\n---');
225
+ if (end !== -1) {
226
+ const parsed = parseYaml(after.slice(0, end));
227
+ if (parsed && typeof parsed === 'object') {
228
+ description = (parsed as Record<string, unknown>).description as string ?? '';
229
+ schedule = (parsed as Record<string, unknown>).schedule as string ?? '';
230
+ }
218
231
  }
219
232
  } catch {
220
233
  // best-effort metadata extraction
@@ -225,7 +238,7 @@ export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
225
238
  type: 'automation' as const,
226
239
  description,
227
240
  schedule,
228
- ymlContent,
241
+ content,
229
242
  };
230
243
  })
231
244
  .filter((entry): entry is RegistryAutomationEntry => entry !== null);
@@ -233,9 +246,9 @@ export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
233
246
 
234
247
  export function getRegistryAutomation(name: string): string | null {
235
248
  if (!VALID_NAME_RE.test(name)) return null;
236
- const ymlPath = join(resolveRegistryAutomationsDir(), `${name}.yml`);
237
- if (!existsSync(ymlPath)) return null;
238
- return readFileSync(ymlPath, 'utf-8');
249
+ const mdPath = join(resolveRegistryAutomationsDir(), `${name}.md`);
250
+ if (!existsSync(mdPath)) return null;
251
+ return readFileSync(mdPath, 'utf-8');
239
252
  }
240
253
 
241
254
  export function getRegistryAddonConfig(homeDir: string, name: string): RegistryAddonConfig {
@@ -243,11 +256,14 @@ export function getRegistryAddonConfig(homeDir: string, name: string): RegistryA
243
256
  throw new Error(`Invalid addon name: ${name}`);
244
257
  }
245
258
 
246
- const schemaPath = `registry/addons/${name}/.env.schema`;
259
+ // Overlay-only addons (compose.yml only, no .env.schema) have no env vars
260
+ // to render, so the schema reads as an empty string.
261
+ const schemaPath = `state/registry/addons/${name}/.env.schema`;
262
+ const schemaFile = join(homeDir, schemaPath);
247
263
  return {
248
264
  schemaPath,
249
- userEnvPath: 'vault/user/user.env',
250
- envSchema: readFileSync(join(homeDir, schemaPath), 'utf-8'),
265
+ userEnvPath: 'config/stack/stack.env',
266
+ envSchema: existsSync(schemaFile) ? readFileSync(schemaFile, 'utf-8') : '',
251
267
  };
252
268
  }
253
269
 
@@ -261,7 +277,7 @@ export function listAvailableAddonIds(): string[] {
261
277
  }
262
278
 
263
279
  export function listEnabledAddonIds(homeDir: string): string[] {
264
- const addonsDir = join(homeDir, 'stack', 'addons');
280
+ const addonsDir = join(homeDir, 'config', 'stack', 'addons');
265
281
  if (!existsSync(addonsDir)) return [];
266
282
 
267
283
  return readdirSync(addonsDir, { withFileTypes: true })
@@ -274,19 +290,21 @@ function copyAddonFromRegistry(homeDir: string, name: string): void {
274
290
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
275
291
 
276
292
  const sourceDir = join(resolveRegistryAddonsDir(), name);
277
- if (!existsSync(join(sourceDir, 'compose.yml')) || !existsSync(join(sourceDir, '.env.schema'))) {
293
+ // compose.yml is the only required file. Overlay-only addons may omit
294
+ // .env.schema entirely.
295
+ if (!existsSync(join(sourceDir, 'compose.yml'))) {
278
296
  throw new Error(`Addon "${name}" not found in registry`);
279
297
  }
280
298
 
281
- const targetDir = join(homeDir, 'stack', 'addons', name);
299
+ const targetDir = join(homeDir, 'config', 'stack', 'addons', name);
282
300
  rmSync(targetDir, { recursive: true, force: true });
283
- mkdirSync(join(homeDir, 'stack', 'addons'), { recursive: true });
301
+ mkdirSync(join(homeDir, 'config', 'stack', 'addons'), { recursive: true });
284
302
  cpSync(sourceDir, targetDir, { recursive: true });
285
303
  }
286
304
 
287
305
  function removeEnabledAddon(homeDir: string, name: string): void {
288
306
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
289
- rmSync(join(homeDir, 'stack', 'addons', name), { recursive: true, force: true });
307
+ rmSync(join(homeDir, 'config', 'stack', 'addons', name), { recursive: true, force: true });
290
308
  }
291
309
 
292
310
  function readAddonServiceNames(composePath: string): string[] {
@@ -310,8 +328,8 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
310
328
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
311
329
 
312
330
  const composeCandidates = [
313
- join(homeDir, "stack", "addons", name, "compose.yml"),
314
- join(homeDir, "registry", "addons", name, "compose.yml"),
331
+ join(homeDir, "config", "stack", "addons", name, "compose.yml"),
332
+ join(homeDir, "state", "registry", "addons", name, "compose.yml"),
315
333
  ];
316
334
 
317
335
  for (const composePath of composeCandidates) {
@@ -322,18 +340,346 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
322
340
  return [];
323
341
  }
324
342
 
325
- export function enableAddon(homeDir: string, name: string): MutationResult {
343
+ export type AddonProfile = {
344
+ id: string;
345
+ services: string[];
346
+ label?: string;
347
+ requires?: string;
348
+ default?: boolean;
349
+ /**
350
+ * Whether the host can run this profile.
351
+ *
352
+ * Populated by `getAddonProfileAvailability()`. When the value is missing
353
+ * (e.g. older catalogs), callers should treat the profile as available.
354
+ */
355
+ available?: boolean;
356
+ /** Human-readable reason when `available === false`. */
357
+ reason?: string;
358
+ };
359
+
360
+ // ── Host capability probes ─────────────────────────────────────────────
361
+
362
+ export type AddonProfileAvailability = { available: boolean; reason?: string };
363
+
364
+ const HOST_PROBE_TIMEOUT_MS = 2_000;
365
+
366
+ // Process-lifetime cache. Hardware presence does not change while the UI
367
+ // server is running, so probing once is enough.
368
+ const availabilityCache = new Map<string, AddonProfileAvailability>();
369
+
370
+ /**
371
+ * Reset the host-capability cache. Test-only — not exported.
372
+ */
373
+ function _resetAvailabilityCacheForTests(): void {
374
+ availabilityCache.clear();
375
+ }
376
+
377
+ // Exported under a deliberately ugly name so test files can reach it.
378
+ export const __addonAvailabilityTestHooks = {
379
+ reset: _resetAvailabilityCacheForTests,
380
+ /**
381
+ * Test-only: exposes the internal exec wrapper so tests can verify
382
+ * ENOENT (missing binary) is surfaced as actionable stderr that the
383
+ * docker-error translator can recognise.
384
+ */
385
+ execFileNoThrow: (cmd: string, args: string[], timeoutMs: number) =>
386
+ execFileNoThrow(cmd, args, timeoutMs),
387
+ };
388
+
389
+ function execFileNoThrow(
390
+ cmd: string,
391
+ args: string[],
392
+ timeoutMs: number,
393
+ ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
394
+ return new Promise((resolve) => {
395
+ execFile(cmd, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
396
+ // ENOENT (binary missing) surfaces here with no stderr — child_process
397
+ // never gets to exec the program. Inject a synthetic stderr that
398
+ // matches the translateDockerError ENOENT regex so callers get
399
+ // actionable copy instead of "unknown error (no stderr)".
400
+ let mergedStderr = stderr?.toString() ?? '';
401
+ const code = (error as NodeJS.ErrnoException | null)?.code;
402
+ if (code && !mergedStderr) {
403
+ if (code === 'ENOENT') {
404
+ mergedStderr = `spawn ${cmd} ENOENT: command not found`;
405
+ } else {
406
+ mergedStderr = `spawn ${cmd} ${code}`;
407
+ }
408
+ }
409
+ resolve({
410
+ ok: !error,
411
+ stdout: stdout?.toString() ?? '',
412
+ stderr: mergedStderr,
413
+ });
414
+ });
415
+ });
416
+ }
417
+
418
+ /**
419
+ * Compute the openpalm/voice image ref for a given GPU variant, matching
420
+ * the substitution chain in the addon compose file:
421
+ * ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-${OP_IMAGE_TAG:-v0.11.0}-<variant>}
422
+ */
423
+ function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
424
+ const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
425
+ const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
426
+ if (explicit) return `${namespace}/voice:${explicit}`;
427
+ const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'v0.11.0';
428
+ return `${namespace}/voice:${baseTag}-${variant}`;
429
+ }
430
+
431
+ /**
432
+ * `docker manifest inspect <ref>` returns 0 only when the registry can
433
+ * resolve a manifest for that ref. We use it as the cheap "is this image
434
+ * actually published?" check — no pull required. The retry handles
435
+ * transient registry hiccups. Timeout is short because the manifest blob
436
+ * is a few KB.
437
+ */
438
+ async function dockerManifestExists(imageRef: string): Promise<boolean> {
439
+ for (let attempt = 0; attempt < 2; attempt++) {
440
+ const res = await execFileNoThrow(
441
+ 'docker',
442
+ ['manifest', 'inspect', imageRef],
443
+ 5_000,
444
+ );
445
+ if (res.ok) return true;
446
+ // If docker itself is missing (ENOENT), retrying won't help.
447
+ if (/ENOENT/.test(res.stderr)) return false;
448
+ }
449
+ return false;
450
+ }
451
+
452
+ async function probeCuda(): Promise<AddonProfileAvailability> {
453
+ // Two acceptance signals:
454
+ // 1. `docker info` reports an `nvidia` runtime (toolkit installed +
455
+ // `nvidia-ctk runtime configure --runtime=docker` was run).
456
+ // 2. `/etc/cdi/nvidia.yaml` exists (CDI-mode daemon with a generated
457
+ // spec). We don't require the runtime in this case — the route's
458
+ // CDI fallback can switch the compose to driver:cdi.
459
+ try {
460
+ if (existsSync('/etc/cdi/nvidia.yaml')) return { available: true };
461
+ } catch {
462
+ // existsSync only throws on path-syntax issues; ignore and probe docker.
463
+ }
464
+
465
+ const result = await execFileNoThrow(
466
+ 'docker',
467
+ ['info', '--format', '{{json .Runtimes}}'],
468
+ HOST_PROBE_TIMEOUT_MS,
469
+ );
470
+ if (result.ok && result.stdout.includes('"nvidia"')) {
471
+ return { available: true };
472
+ }
473
+ return {
474
+ available: false,
475
+ reason: 'NVIDIA runtime not registered. Install nvidia-container-toolkit or enable CDI.',
476
+ };
477
+ }
478
+
479
+ async function probeRocm(): Promise<AddonProfileAvailability> {
480
+ // Hardware gate: ROCm needs both the KFD char device and the GPU DRI nodes.
481
+ let devicesPresent = false;
482
+ try {
483
+ devicesPresent = existsSync('/dev/kfd') && existsSync('/dev/dri');
484
+ } catch {
485
+ devicesPresent = false;
486
+ }
487
+ if (!devicesPresent) {
488
+ return {
489
+ available: false,
490
+ reason: 'AMD ROCm devices not present on this host.',
491
+ };
492
+ }
493
+
494
+ // Image gate: the openpalm/voice:*-rocm6 image isn't published yet, so
495
+ // even on a fully-functional ROCm host the compose-up would fail with a
496
+ // manifest-unknown pull error. Refuse the profile until the image lands.
497
+ const imageRef = voiceImageRef('rocm6');
498
+ const published = await dockerManifestExists(imageRef);
499
+ if (!published) {
500
+ return {
501
+ available: false,
502
+ reason: 'AMD ROCm image not published yet. Check back in a future release or use the CPU profile.',
503
+ };
504
+ }
505
+ return { available: true };
506
+ }
507
+
508
+ /**
509
+ * Probe the host for the capabilities required by an addon profile.
510
+ *
511
+ * Results are cached for the lifetime of the process — hardware doesn't
512
+ * change while the UI server runs. All probes use execFile (no shell)
513
+ * and never throw: errors collapse to `{ available: false, reason }`.
514
+ *
515
+ * Unknown profile ids default to `available: true` so unrelated addons
516
+ * (e.g. a future "high-mem" profile that doesn't probe hardware) keep
517
+ * working without code changes here.
518
+ */
519
+ export async function getAddonProfileAvailability(
520
+ profile: Pick<AddonProfile, 'id'>,
521
+ ): Promise<AddonProfileAvailability> {
522
+ const cacheKey = profile.id;
523
+ const cached = availabilityCache.get(cacheKey);
524
+ if (cached) return cached;
525
+
526
+ let result: AddonProfileAvailability;
527
+ try {
528
+ if (profile.id === 'cpu') {
529
+ result = { available: true };
530
+ } else if (profile.id === 'cuda') {
531
+ result = await probeCuda();
532
+ } else if (profile.id === 'rocm') {
533
+ result = await probeRocm();
534
+ } else {
535
+ // Unknown profile id — assume available; caller is responsible for
536
+ // labelling profiles that need host capability gating.
537
+ result = { available: true };
538
+ }
539
+ } catch (err) {
540
+ // Belt-and-braces: any unexpected throw collapses to unavailable.
541
+ const reason = err instanceof Error ? err.message : String(err);
542
+ result = { available: false, reason: `probe failed: ${reason}` };
543
+ }
544
+
545
+ availabilityCache.set(cacheKey, result);
546
+ return result;
547
+ }
548
+
549
+ /**
550
+ * Decorate a list of profiles with `available`/`reason` based on the host
551
+ * capability probes. Returns a fresh array; does not mutate inputs.
552
+ */
553
+ export async function annotateAddonProfileAvailability(
554
+ profiles: AddonProfile[],
555
+ ): Promise<AddonProfile[]> {
556
+ const results = await Promise.all(
557
+ profiles.map(async (p) => {
558
+ const a = await getAddonProfileAvailability(p);
559
+ const annotated: AddonProfile = { ...p, available: a.available };
560
+ if (a.reason) annotated.reason = a.reason;
561
+ return annotated;
562
+ }),
563
+ );
564
+ return results;
565
+ }
566
+
567
+ function readAddonProfiles(composePath: string): AddonProfile[] {
568
+ if (!existsSync(composePath)) return [];
569
+
570
+ let parsed: unknown;
571
+ try {
572
+ parsed = parseYaml(readFileSync(composePath, "utf-8"));
573
+ } catch (error) {
574
+ logger.warn("failed to parse addon compose profiles", {
575
+ composePath,
576
+ error: error instanceof Error ? error.message : String(error),
577
+ });
578
+ return [];
579
+ }
580
+
581
+ const services = parsed && typeof parsed === "object"
582
+ ? (parsed as { services?: unknown }).services
583
+ : undefined;
584
+ if (!services || typeof services !== "object" || Array.isArray(services)) return [];
585
+
586
+ const byProfile = new Map<string, AddonProfile>();
587
+ for (const [svcName, svcRaw] of Object.entries(services as Record<string, unknown>)) {
588
+ if (!svcRaw || typeof svcRaw !== "object") continue;
589
+ const svc = svcRaw as { profiles?: unknown; labels?: unknown };
590
+ if (!Array.isArray(svc.profiles)) continue;
591
+ const profileIds = svc.profiles.filter((p): p is string => typeof p === "string");
592
+ if (profileIds.length === 0) continue;
593
+
594
+ const labels = readServiceLabels(svc.labels);
595
+ const label = labels["openpalm.profile.label"];
596
+ const requires = labels["openpalm.profile.requires"];
597
+ const isDefault = labels["openpalm.profile.default"] === "true";
598
+
599
+ for (const id of profileIds) {
600
+ const existing = byProfile.get(id);
601
+ if (existing) {
602
+ existing.services.push(svcName);
603
+ if (!existing.label && label) existing.label = label;
604
+ if (!existing.requires && requires) existing.requires = requires;
605
+ if (!existing.default && isDefault) existing.default = true;
606
+ } else {
607
+ const profile: AddonProfile = { id, services: [svcName] };
608
+ if (label) profile.label = label;
609
+ if (requires) profile.requires = requires;
610
+ if (isDefault) profile.default = true;
611
+ byProfile.set(id, profile);
612
+ }
613
+ }
614
+ }
615
+
616
+ return [...byProfile.values()];
617
+ }
618
+
619
+ function readServiceLabels(raw: unknown): Record<string, string> {
620
+ if (!raw) return {};
621
+ const out: Record<string, string> = {};
622
+ if (Array.isArray(raw)) {
623
+ for (const entry of raw) {
624
+ if (typeof entry !== "string") continue;
625
+ const eq = entry.indexOf("=");
626
+ if (eq < 0) continue;
627
+ out[entry.slice(0, eq)] = entry.slice(eq + 1);
628
+ }
629
+ } else if (typeof raw === "object") {
630
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
631
+ if (v == null) continue;
632
+ out[k] = String(v);
633
+ }
634
+ }
635
+ return out;
636
+ }
637
+
638
+ export function getAddonProfiles(homeDir: string, name: string): AddonProfile[] {
639
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
640
+
641
+ const composeCandidates = [
642
+ join(homeDir, "config", "stack", "addons", name, "compose.yml"),
643
+ join(homeDir, "state", "registry", "addons", name, "compose.yml"),
644
+ ];
645
+
646
+ for (const composePath of composeCandidates) {
647
+ const profiles = readAddonProfiles(composePath);
648
+ if (profiles.length > 0) return profiles;
649
+ }
650
+
651
+ return [];
652
+ }
653
+
654
+ function profileEnvKey(name: string): string {
655
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
656
+ return `OP_${name.replace(/-/g, '_').toUpperCase()}_PROFILE`;
657
+ }
658
+
659
+ export function getAddonProfileSelection(stackDir: string, name: string): string | null {
660
+ const env = readStackEnv(stackDir);
661
+ const value = env[profileEnvKey(name)];
662
+ return value && value.trim() ? value.trim() : null;
663
+ }
664
+
665
+ export function setAddonProfileSelection(stackDir: string, name: string, profile: string): void {
666
+ const trimmed = profile.trim();
667
+ if (!trimmed) throw new Error('Profile id cannot be empty');
668
+ patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
669
+ }
670
+
671
+ function enableAddon(homeDir: string, name: string): MutationResult {
326
672
  try {
327
673
  copyAddonFromRegistry(homeDir, name);
328
- // Pre-create the addon data directory so Docker doesn't create it as root
329
- mkdirSync(join(homeDir, 'data', name), { recursive: true });
674
+ // Pre-create the addon services directory so Docker doesn't create it as root
675
+ mkdirSync(join(homeDir, 'services', name), { recursive: true });
330
676
  return { ok: true };
331
677
  } catch (error) {
332
678
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
333
679
  }
334
680
  }
335
681
 
336
- export function disableAddonByName(homeDir: string, name: string): MutationResult {
682
+ function disableAddonByName(homeDir: string, name: string): MutationResult {
337
683
  try {
338
684
  removeEnabledAddon(homeDir, name);
339
685
  return { ok: true };
@@ -342,7 +688,7 @@ export function disableAddonByName(homeDir: string, name: string): MutationResul
342
688
  }
343
689
  }
344
690
 
345
- export function setAddonEnabled(homeDir: string, vaultDir: string, name: string, enabled: boolean): AddonMutationResult {
691
+ export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean): AddonMutationResult {
346
692
  if (!VALID_NAME_RE.test(name)) {
347
693
  return { ok: false, error: `Invalid addon name: ${name}` };
348
694
  }
@@ -367,9 +713,9 @@ export function setAddonEnabled(homeDir: string, vaultDir: string, name: string,
367
713
  if (!mutation.ok) return mutation;
368
714
 
369
715
  if (enabled) {
370
- const composePath = join(homeDir, "stack", "addons", name, "compose.yml");
716
+ const composePath = join(homeDir, "config", "stack", "addons", name, "compose.yml");
371
717
  if (isChannelAddon(composePath)) {
372
- writeChannelSecrets(vaultDir, { [name]: randomHex(16) });
718
+ writeChannelSecrets(stackDir, { [name]: randomHex(16) });
373
719
  }
374
720
  }
375
721
 
@@ -381,38 +727,42 @@ export function setAddonEnabled(homeDir: string, vaultDir: string, name: string,
381
727
  };
382
728
  }
383
729
 
384
- export function installAutomationFromRegistry(name: string, configDir: string): MutationResult {
730
+ export function installAutomationFromRegistry(name: string, stashDir: string): MutationResult {
385
731
  if (!VALID_NAME_RE.test(name)) {
386
732
  return { ok: false, error: `Invalid automation name: ${name}` };
387
733
  }
388
734
 
389
- const automationYml = getRegistryAutomation(name);
390
- if (!automationYml) {
735
+ const markdownContent = getRegistryAutomation(name);
736
+ if (!markdownContent) {
391
737
  return { ok: false, error: `Automation "${name}" not found in registry` };
392
738
  }
393
739
 
394
- const automationsDir = join(configDir, 'automations');
395
- mkdirSync(automationsDir, { recursive: true });
740
+ const tasksDir = join(stashDir, 'tasks');
741
+ mkdirSync(tasksDir, { recursive: true });
396
742
 
397
- const ymlPath = join(automationsDir, `${name}.yml`);
398
- if (existsSync(ymlPath)) {
743
+ const mdPath = join(tasksDir, `${name}.md`);
744
+ if (existsSync(mdPath)) {
399
745
  return { ok: false, error: `Automation "${name}" is already installed` };
400
746
  }
401
747
 
402
- writeFileSync(ymlPath, automationYml);
748
+ writeFileSync(mdPath, markdownContent);
749
+ // The assistant container's 60-second akm tasks sync loop picks up the new
750
+ // file from the shared stash mount and registers it with OS cron.
403
751
  return { ok: true };
404
752
  }
405
753
 
406
- export function uninstallAutomation(name: string, configDir: string): MutationResult {
754
+ export function uninstallAutomation(name: string, stashDir: string): MutationResult {
407
755
  if (!VALID_NAME_RE.test(name)) {
408
756
  return { ok: false, error: `Invalid automation name: ${name}` };
409
757
  }
410
758
 
411
- const ymlPath = join(configDir, 'automations', `${name}.yml`);
412
- if (!existsSync(ymlPath)) {
759
+ const mdPath = join(stashDir, 'tasks', `${name}.md`);
760
+ if (!existsSync(mdPath)) {
413
761
  return { ok: false, error: `Automation "${name}" is not installed` };
414
762
  }
415
763
 
416
- rmSync(ymlPath, { force: true });
764
+ rmSync(mdPath, { force: true });
765
+ // The assistant container's 60-second akm tasks sync will notice the file
766
+ // is gone and deregister it from OS cron on next sync.
417
767
  return { ok: true };
418
768
  }