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

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-beta.2",
3
+ "version": "0.11.0-beta.3",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
@@ -21,6 +21,9 @@ import {
21
21
  getRegistryAddonConfig,
22
22
  listAvailableAddonIds,
23
23
  getAddonServiceNames,
24
+ getAddonProfiles,
25
+ getAddonProfileSelection,
26
+ setAddonProfileSelection,
24
27
  enableAddon,
25
28
  disableAddonByName,
26
29
  setAddonEnabled,
@@ -391,6 +394,67 @@ describe("materialized registry catalog", () => {
391
394
  expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
392
395
  });
393
396
 
397
+ it("parses compose profiles + openpalm.profile.* labels per addon", () => {
398
+ const sourceRoot = join(tmpDir, 'repo');
399
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
400
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
401
+
402
+ mkdirSync(addonDir, { recursive: true });
403
+ mkdirSync(automationsDir, { recursive: true });
404
+ writeFileSync(
405
+ join(addonDir, 'compose.yml'),
406
+ [
407
+ 'services:',
408
+ ' voice:',
409
+ ' profiles: [cpu]',
410
+ ' image: openpalm/voice:cpu',
411
+ ' labels:',
412
+ ' openpalm.profile.label: CPU',
413
+ ' openpalm.profile.default: "true"',
414
+ ' voice-cuda:',
415
+ ' profiles: [cuda]',
416
+ ' image: openpalm/voice:cuda',
417
+ ' labels:',
418
+ ' openpalm.profile.label: NVIDIA',
419
+ ' openpalm.profile.requires: nvidia-container-toolkit',
420
+ '',
421
+ ].join('\n'),
422
+ );
423
+ writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
424
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
425
+
426
+ materializeRegistryCatalog(sourceRoot);
427
+
428
+ const profiles = getAddonProfiles(process.env.OP_HOME!, 'voice');
429
+ expect(profiles).toEqual([
430
+ { id: 'cpu', services: ['voice'], label: 'CPU', default: true },
431
+ { id: 'cuda', services: ['voice-cuda'], label: 'NVIDIA', requires: 'nvidia-container-toolkit' },
432
+ ]);
433
+ });
434
+
435
+ it("round-trips addon profile selection through stack.env", () => {
436
+ const sourceRoot = join(tmpDir, 'repo');
437
+ const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
438
+ const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
439
+
440
+ mkdirSync(addonDir, { recursive: true });
441
+ mkdirSync(automationsDir, { recursive: true });
442
+ writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: [cpu]\n image: x\n');
443
+ writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
444
+ writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
445
+
446
+ materializeRegistryCatalog(sourceRoot);
447
+
448
+ const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
449
+ mkdirSync(stackDir, { recursive: true });
450
+ writeFileSync(join(stackDir, 'stack.env'), '');
451
+
452
+ expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
453
+ setAddonProfileSelection(stackDir, 'voice', 'cuda');
454
+ expect(getAddonProfileSelection(stackDir, 'voice')).toBe('cuda');
455
+ expect(readFileSync(join(stackDir, 'stack.env'), 'utf-8')).toContain('OP_VOICE_PROFILE=cuda');
456
+ });
457
+
394
458
  it("installs and uninstalls automations through stash/tasks", () => {
395
459
  const sourceRoot = join(tmpDir, 'repo');
396
460
  const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
@@ -12,6 +12,7 @@ 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,
@@ -339,6 +340,118 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
339
340
  return [];
340
341
  }
341
342
 
343
+ export type AddonProfile = {
344
+ id: string;
345
+ services: string[];
346
+ label?: string;
347
+ requires?: string;
348
+ default?: boolean;
349
+ };
350
+
351
+ function readAddonProfiles(composePath: string): AddonProfile[] {
352
+ if (!existsSync(composePath)) return [];
353
+
354
+ let parsed: unknown;
355
+ try {
356
+ parsed = parseYaml(readFileSync(composePath, "utf-8"));
357
+ } catch (error) {
358
+ logger.warn("failed to parse addon compose profiles", {
359
+ composePath,
360
+ error: error instanceof Error ? error.message : String(error),
361
+ });
362
+ return [];
363
+ }
364
+
365
+ const services = parsed && typeof parsed === "object"
366
+ ? (parsed as { services?: unknown }).services
367
+ : undefined;
368
+ if (!services || typeof services !== "object" || Array.isArray(services)) return [];
369
+
370
+ const byProfile = new Map<string, AddonProfile>();
371
+ for (const [svcName, svcRaw] of Object.entries(services as Record<string, unknown>)) {
372
+ if (!svcRaw || typeof svcRaw !== "object") continue;
373
+ const svc = svcRaw as { profiles?: unknown; labels?: unknown };
374
+ if (!Array.isArray(svc.profiles)) continue;
375
+ const profileIds = svc.profiles.filter((p): p is string => typeof p === "string");
376
+ if (profileIds.length === 0) continue;
377
+
378
+ const labels = readServiceLabels(svc.labels);
379
+ const label = labels["openpalm.profile.label"];
380
+ const requires = labels["openpalm.profile.requires"];
381
+ const isDefault = labels["openpalm.profile.default"] === "true";
382
+
383
+ for (const id of profileIds) {
384
+ const existing = byProfile.get(id);
385
+ if (existing) {
386
+ existing.services.push(svcName);
387
+ if (!existing.label && label) existing.label = label;
388
+ if (!existing.requires && requires) existing.requires = requires;
389
+ if (!existing.default && isDefault) existing.default = true;
390
+ } else {
391
+ const profile: AddonProfile = { id, services: [svcName] };
392
+ if (label) profile.label = label;
393
+ if (requires) profile.requires = requires;
394
+ if (isDefault) profile.default = true;
395
+ byProfile.set(id, profile);
396
+ }
397
+ }
398
+ }
399
+
400
+ return [...byProfile.values()];
401
+ }
402
+
403
+ function readServiceLabels(raw: unknown): Record<string, string> {
404
+ if (!raw) return {};
405
+ const out: Record<string, string> = {};
406
+ if (Array.isArray(raw)) {
407
+ for (const entry of raw) {
408
+ if (typeof entry !== "string") continue;
409
+ const eq = entry.indexOf("=");
410
+ if (eq < 0) continue;
411
+ out[entry.slice(0, eq)] = entry.slice(eq + 1);
412
+ }
413
+ } else if (typeof raw === "object") {
414
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
415
+ if (v == null) continue;
416
+ out[k] = String(v);
417
+ }
418
+ }
419
+ return out;
420
+ }
421
+
422
+ export function getAddonProfiles(homeDir: string, name: string): AddonProfile[] {
423
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
424
+
425
+ const composeCandidates = [
426
+ join(homeDir, "config", "stack", "addons", name, "compose.yml"),
427
+ join(homeDir, "state", "registry", "addons", name, "compose.yml"),
428
+ ];
429
+
430
+ for (const composePath of composeCandidates) {
431
+ const profiles = readAddonProfiles(composePath);
432
+ if (profiles.length > 0) return profiles;
433
+ }
434
+
435
+ return [];
436
+ }
437
+
438
+ function profileEnvKey(name: string): string {
439
+ if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
440
+ return `OP_${name.replace(/-/g, '_').toUpperCase()}_PROFILE`;
441
+ }
442
+
443
+ export function getAddonProfileSelection(stackDir: string, name: string): string | null {
444
+ const env = readStackEnv(stackDir);
445
+ const value = env[profileEnvKey(name)];
446
+ return value && value.trim() ? value.trim() : null;
447
+ }
448
+
449
+ export function setAddonProfileSelection(stackDir: string, name: string, profile: string): void {
450
+ const trimmed = profile.trim();
451
+ if (!trimmed) throw new Error('Profile id cannot be empty');
452
+ patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
453
+ }
454
+
342
455
  export function enableAddon(homeDir: string, name: string): MutationResult {
343
456
  try {
344
457
  copyAddonFromRegistry(homeDir, name);
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  // ── Registry Catalog ─────────────────────────────────────────────────────
44
44
  export type {
45
45
  AddonMutationResult,
46
+ AddonProfile,
46
47
  RegistryAutomationEntry,
47
48
  RegistryComponentEntry,
48
49
  RegistryAddonConfig,
@@ -57,6 +58,9 @@ export {
57
58
  getRegistryAutomation,
58
59
  getRegistryAddonConfig,
59
60
  getAddonServiceNames,
61
+ getAddonProfiles,
62
+ getAddonProfileSelection,
63
+ setAddonProfileSelection,
60
64
  listAvailableAddonIds,
61
65
  listEnabledAddonIds,
62
66
  enableAddon,