@openpalm/lib 0.11.0-beta.2 → 0.11.0-beta.6

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.
@@ -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 ─────────────────────────────────────────────────
@@ -306,10 +310,11 @@ describe("materialized registry catalog", () => {
306
310
 
307
311
  materializeRegistryCatalog(sourceRoot);
308
312
 
309
- expect(enableAddon(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
313
+ const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
314
+ expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', true)).toMatchObject({ ok: true });
310
315
  expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
311
316
 
312
- expect(disableAddonByName(process.env.OP_HOME!, 'chat')).toEqual({ ok: true });
317
+ expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', false)).toMatchObject({ ok: true });
313
318
  expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
314
319
  });
315
320
 
@@ -391,6 +396,67 @@ describe("materialized registry catalog", () => {
391
396
  expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
392
397
  });
393
398
 
399
+ it("parses compose profiles + openpalm.profile.* labels per addon", () => {
400
+ const sourceRoot = join(tmpDir, 'repo');
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
+
394
460
  it("installs and uninstalls automations through stash/tasks", () => {
395
461
  const sourceRoot = join(tmpDir, 'repo');
396
462
  const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
@@ -414,3 +480,131 @@ describe("materialized registry catalog", () => {
414
480
  expect(existsSync(join(stashDir, 'tasks', 'cleanup.md'))).toBe(false);
415
481
  });
416
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);
609
+ });
610
+ });
@@ -5,13 +5,14 @@
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,
@@ -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 }
@@ -339,7 +340,335 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
339
340
  return [];
340
341
  }
341
342
 
342
- 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 {
343
672
  try {
344
673
  copyAddonFromRegistry(homeDir, name);
345
674
  // Pre-create the addon services directory so Docker doesn't create it as root
@@ -350,7 +679,7 @@ export function enableAddon(homeDir: string, name: string): MutationResult {
350
679
  }
351
680
  }
352
681
 
353
- export function disableAddonByName(homeDir: string, name: string): MutationResult {
682
+ function disableAddonByName(homeDir: string, name: string): MutationResult {
354
683
  try {
355
684
  removeEnabledAddon(homeDir, name);
356
685
  return { ok: true };
@@ -14,8 +14,8 @@ const logger = createLogger("secrets");
14
14
  /** Keys whose values are shown unmasked in the UI (not secrets). */
15
15
  export const PLAIN_CONFIG_KEYS = new Set([
16
16
  "OPENAI_BASE_URL",
17
- "OWNER_NAME",
18
- "OWNER_EMAIL",
17
+ "OP_OWNER_NAME",
18
+ "OP_OWNER_EMAIL",
19
19
  ]);
20
20
 
21
21
 
@@ -91,8 +91,8 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
91
91
  "LMSTUDIO_API_KEY=",
92
92
  "",
93
93
  "# ── Owner ────────────────────────────────────────────────────────────",
94
- `OWNER_NAME=${process.env.OWNER_NAME ?? ""}`,
95
- `OWNER_EMAIL=${process.env.OWNER_EMAIL ?? ""}`,
94
+ `OP_OWNER_NAME=${process.env.OP_OWNER_NAME ?? ""}`,
95
+ `OP_OWNER_EMAIL=${process.env.OP_OWNER_EMAIL ?? ""}`,
96
96
  "",
97
97
  ].join("\n");
98
98
  const content = mergeEnvContent(header, updates);
@@ -218,15 +218,15 @@ describe("buildSecretsFromSetup", () => {
218
218
  it("sets owner info when provided", () => {
219
219
  const spec = makeValidSpec();
220
220
  const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
221
- expect(secrets.OWNER_NAME).toBe("Test User");
222
- expect(secrets.OWNER_EMAIL).toBe("test@example.com");
221
+ expect(secrets.OP_OWNER_NAME).toBe("Test User");
222
+ expect(secrets.OP_OWNER_EMAIL).toBe("test@example.com");
223
223
  });
224
224
 
225
225
  it("omits owner info when empty", () => {
226
226
  const spec = makeValidSpec({ owner: { name: "", email: "" } });
227
227
  const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
228
- expect(secrets.OWNER_NAME).toBeUndefined();
229
- expect(secrets.OWNER_EMAIL).toBeUndefined();
228
+ expect(secrets.OP_OWNER_NAME).toBeUndefined();
229
+ expect(secrets.OP_OWNER_EMAIL).toBeUndefined();
230
230
  });
231
231
 
232
232
  it("does NOT include provider API keys in stack.env updates", () => {
@@ -367,8 +367,8 @@ describe("performSetup", () => {
367
367
  "GROQ_API_KEY=",
368
368
  "MISTRAL_API_KEY=",
369
369
  "GOOGLE_API_KEY=",
370
- "OWNER_NAME=",
371
- "OWNER_EMAIL=",
370
+ "OP_OWNER_NAME=",
371
+ "OP_OWNER_EMAIL=",
372
372
  "",
373
373
  ].join("\n")
374
374
  );