@openpalm/lib 0.11.0-beta.8 → 0.11.0-rc.1

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 +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +67 -30
  11. package/src/control-plane/compose-args.ts +63 -8
  12. package/src/control-plane/config-persistence.ts +95 -136
  13. package/src/control-plane/core-assets.ts +21 -44
  14. package/src/control-plane/docker.ts +15 -14
  15. package/src/control-plane/env.test.ts +10 -10
  16. package/src/control-plane/env.ts +1 -1
  17. package/src/control-plane/extends-support.test.ts +8 -8
  18. package/src/control-plane/fs-atomic.ts +15 -0
  19. package/src/control-plane/home.ts +34 -46
  20. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  21. package/src/control-plane/host-akm-sharing.ts +129 -0
  22. package/src/control-plane/host-opencode.test.ts +82 -10
  23. package/src/control-plane/host-opencode.ts +42 -13
  24. package/src/control-plane/install-edge-cases.test.ts +98 -105
  25. package/src/control-plane/install-lock.ts +7 -7
  26. package/src/control-plane/lifecycle.ts +37 -36
  27. package/src/control-plane/markdown-task.ts +30 -50
  28. package/src/control-plane/opencode-client.ts +1 -1
  29. package/src/control-plane/paths.ts +61 -46
  30. package/src/control-plane/profile-ids.ts +21 -0
  31. package/src/control-plane/provider-models.ts +3 -3
  32. package/src/control-plane/registry.test.ts +107 -90
  33. package/src/control-plane/registry.ts +288 -109
  34. package/src/control-plane/rollback.ts +8 -38
  35. package/src/control-plane/scheduler.ts +10 -7
  36. package/src/control-plane/secret-audit.test.ts +159 -0
  37. package/src/control-plane/secret-audit.ts +255 -0
  38. package/src/control-plane/secret-mappings.ts +2 -2
  39. package/src/control-plane/secrets-files.test.ts +99 -0
  40. package/src/control-plane/secrets-files.ts +113 -0
  41. package/src/control-plane/secrets.ts +113 -86
  42. package/src/control-plane/setup-config.schema.json +1 -1
  43. package/src/control-plane/setup-status.ts +6 -11
  44. package/src/control-plane/setup.test.ts +140 -44
  45. package/src/control-plane/setup.ts +85 -62
  46. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  47. package/src/control-plane/spec-to-env.test.ts +63 -26
  48. package/src/control-plane/spec-to-env.ts +49 -12
  49. package/src/control-plane/stack-spec.test.ts +15 -11
  50. package/src/control-plane/stack-spec.ts +31 -10
  51. package/src/control-plane/task-files.test.ts +45 -0
  52. package/src/control-plane/task-files.ts +51 -0
  53. package/src/control-plane/types.ts +2 -4
  54. package/src/control-plane/ui-assets.test.ts +130 -0
  55. package/src/control-plane/ui-assets.ts +132 -57
  56. package/src/control-plane/validate.ts +13 -15
  57. package/src/index.ts +86 -16
  58. package/src/control-plane/akm-vault.test.ts +0 -105
  59. package/src/control-plane/akm-vault.ts +0 -311
  60. package/src/control-plane/core-assets.test.ts +0 -104
  61. package/src/control-plane/migrate-0110.test.ts +0 -177
  62. package/src/control-plane/migrate-0110.ts +0 -99
  63. package/src/control-plane/registry-components.test.ts +0 -391
@@ -0,0 +1,167 @@
1
+ /// <reference types="bun-types" />
2
+ /**
3
+ * akm `env:user` helpers.
4
+ *
5
+ * The user-managed environment file lives at `${OP_HOME}/knowledge/env/user.env`
6
+ * and is the canonical home for user-managed configuration (LLM provider keys,
7
+ * owner info, and any other user-set values). It maps to the akm `env` asset
8
+ * type (ref `env:user`): a whole `.env` file that akm loads wholesale via
9
+ * `akm env run env:user` / `akm env path env:user`. The assistant entrypoint
10
+ * sources this file directly at startup.
11
+ *
12
+ * akm (>= 0.8.0) no longer manages individual env entries — the file owner edits
13
+ * it and akm loads it as a unit. OpenPalm therefore owns the file directly:
14
+ * writes/deletes are plain atomic .env edits (mode 0600), no akm subprocess.
15
+ * Values are shell-quoted on write so the entrypoint can `source` the file
16
+ * safely; `parseEnvFile` (dotenv) unquotes them on read.
17
+ *
18
+ * `stack.env` and `knowledge/secrets/` are operator-managed and NOT part of
19
+ * this file; service secrets are granted as Compose secret files.
20
+ *
21
+ * Layout:
22
+ * knowledge/ — AKM_STASH_DIR: asset content (skills, env, secrets, agents)
23
+ * data/akm/ — akm operational cache and data
24
+ */
25
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
26
+ import { dirname } from "node:path";
27
+ import { parseEnvFile, upsertEnvValue, removeEnvKey } from "./env.js";
28
+ import type { ControlPlaneState } from "./types.js";
29
+
30
+ /**
31
+ * Quote a value so the written line is interpreted IDENTICALLY by a POSIX shell
32
+ * `source` (the assistant entrypoint does `set -a; . user.env`) and by dotenv
33
+ * (akm `env run` / OpenPalm's `parseEnvFile`).
34
+ *
35
+ * The shared `quoteEnvValue` (env.ts) is tuned for dotenv/compose only: it
36
+ * leaves values with internal spaces bare (`OWNER=Ada Lovelace`) and uses
37
+ * double-quote+backslash escaping — both of which a shell `source` mis-parses
38
+ * (word-splitting, `&`/`$` interpretation). POSIX single-quoting is the one
39
+ * encoding both agree on: everything inside `'...'` is literal in shell AND in
40
+ * dotenv. Simple token-shaped values are written bare for readability; anything
41
+ * else is single-quoted, with embedded single quotes closed/escaped/reopened
42
+ * the POSIX way (`'\''`).
43
+ */
44
+ function quoteForUserEnv(value: string): string {
45
+ if (value === "") return "";
46
+ // Bare-safe: characters that need no quoting in either shell or dotenv.
47
+ if (/^[A-Za-z0-9_./:@%+,=-]+$/.test(value)) return value;
48
+ return `'${value.replace(/'/g, `'\\''`)}'`;
49
+ }
50
+
51
+ /** akm ref for the user-managed environment file. */
52
+ export const AKM_USER_ENV_REF = "env:user";
53
+
54
+ const ENV_DIR_MODE = 0o700;
55
+ const ENV_FILE_MODE = 0o600;
56
+
57
+ /**
58
+ * Build the env that points akm at the shared OpenPalm stash. We mirror the
59
+ * layout that the assistant container uses (see
60
+ * `.openpalm/config/stack/core.compose.yml`) so host-side and container-side
61
+ * runs resolve to the same files.
62
+ *
63
+ * Host-side runs use the same explicit directories as the assistant container:
64
+ * config in config/akm, cache in data/akm/cache, and durable data in
65
+ * data/akm/data. Used by automation execution (`executeAutomation`).
66
+ */
67
+ export function buildAkmEnv(state: ControlPlaneState): NodeJS.ProcessEnv {
68
+ return {
69
+ ...process.env,
70
+ AKM_STASH_DIR: state.stashDir,
71
+ AKM_CONFIG_DIR: `${state.configDir}/akm`,
72
+ AKM_CACHE_DIR: `${state.dataDir}/akm/cache`,
73
+ AKM_DATA_DIR: `${state.dataDir}/akm/data`,
74
+ };
75
+ }
76
+
77
+ /** The four XDG-base akm env vars that MUST be set together (akm 0.8.0). */
78
+ export const AKM_ENV_KEYS = ["AKM_STASH_DIR", "AKM_CONFIG_DIR", "AKM_CACHE_DIR", "AKM_DATA_DIR"] as const;
79
+
80
+ /**
81
+ * Guard (I-6): every OpenPalm-internal `akm` spawn MUST set all four AKM_* dirs
82
+ * explicitly. Partially overriding them lets akm fall back to the operator's
83
+ * GLOBAL ~/.config/akm / ~/.local/share/akm for the unset families — the
84
+ * documented forensic hazard (akm setup writing the global config regardless of
85
+ * AKM_STASH_DIR). We check the keys are present as OWN properties of the env
86
+ * object passed to akm, not merely inherited from process.env (process.env may
87
+ * carry the operator's global AKM_STASH_DIR, which is exactly what must NOT be
88
+ * relied upon). `buildAkmEnv` satisfies this by construction.
89
+ */
90
+ export function assertAkmEnvComplete(env: NodeJS.ProcessEnv): void {
91
+ const missing = AKM_ENV_KEYS.filter((k) => !env[k] || !String(env[k]).trim());
92
+ if (missing.length > 0) {
93
+ throw new Error(
94
+ `Refusing to spawn akm without all four AKM_* dirs set: missing ${missing.join(", ")}. ` +
95
+ `Use buildAkmEnv(state) — a partial set lets akm write the operator's global config.`,
96
+ );
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Canonical akm `env:user` file path for a control-plane state.
102
+ *
103
+ * Deterministic: akm (>= 0.8.0) materializes env files at
104
+ * `${AKM_STASH_DIR}/env/<name>.env`, and `state.stashDir` is the stash root.
105
+ * Returns the path regardless of whether the file currently exists.
106
+ */
107
+ export function userEnvPathSync(state: ControlPlaneState): string {
108
+ return `${state.stashDir}/env/user.env`;
109
+ }
110
+
111
+ /**
112
+ * Ensure the user env file exists and return its absolute path.
113
+ *
114
+ * Pure filesystem — no akm subprocess. Returns immediately when the file is
115
+ * already provisioned (the steady state — read paths pay no extra syscalls).
116
+ * Otherwise creates `knowledge/env/` (0700) and an empty `user.env` (0600).
117
+ */
118
+ export function ensureAkmUserEnv(state: ControlPlaneState): string {
119
+ const envPath = userEnvPathSync(state);
120
+ if (existsSync(envPath)) return envPath;
121
+
122
+ mkdirSync(dirname(envPath), { recursive: true, mode: ENV_DIR_MODE });
123
+ writeFileSync(envPath, "", { mode: ENV_FILE_MODE });
124
+ chmodSync(envPath, ENV_FILE_MODE);
125
+ return envPath;
126
+ }
127
+
128
+ /**
129
+ * Write a single key/value into the user env file (`env:user`).
130
+ *
131
+ * The value is shell-quoted before it is written so the assistant entrypoint
132
+ * can `source` the file without word-splitting on spaces or special
133
+ * characters. `ensureAkmUserEnv` guarantees the file exists; `chmodSync`
134
+ * keeps it 0600. Throws on filesystem errors so callers can surface the error.
135
+ */
136
+ export function writeUserEnvKey(state: ControlPlaneState, key: string, value: string): void {
137
+ const path = ensureAkmUserEnv(state);
138
+ writeFileSync(path, upsertEnvValue(readFileSync(path, "utf-8"), key, quoteForUserEnv(value)));
139
+ chmodSync(path, ENV_FILE_MODE);
140
+ }
141
+
142
+ /**
143
+ * Remove a key from the user env file (`env:user`). Idempotent: removing an
144
+ * absent key rewrites the file unchanged. Throws on filesystem errors.
145
+ */
146
+ export function deleteUserEnvKey(state: ControlPlaneState, key: string): void {
147
+ const path = ensureAkmUserEnv(state);
148
+ writeFileSync(path, removeEnvKey(readFileSync(path, "utf-8"), key));
149
+ chmodSync(path, ENV_FILE_MODE);
150
+ }
151
+
152
+ /**
153
+ * Read the user-managed env namespace. Returns `{}` when the file does not
154
+ * exist yet. Pure sync — no subprocess.
155
+ */
156
+ export function readUserEnvSync(state: ControlPlaneState): Record<string, string> {
157
+ return readUserEnvFile(userEnvPathSync(state));
158
+ }
159
+
160
+ /**
161
+ * Return the parsed contents of a user env file (public API used by the admin
162
+ * UI list endpoint). `parseEnvFile` returns `{}` for a missing or unreadable
163
+ * file (it backs up corrupt files internally), so no extra guards are needed.
164
+ */
165
+ export function readUserEnvFile(envPath: string): Record<string, string> {
166
+ return parseEnvFile(envPath);
167
+ }
@@ -8,20 +8,29 @@ function timestampDirName(now = new Date()): string {
8
8
  /**
9
9
  * Create a durable backup snapshot of the current OP_HOME contents.
10
10
  *
11
- * The backup is written under OP_HOME/backups/<timestamp>/ and excludes the
12
- * backups directory itself to avoid recursive copies.
11
+ * The backup is written under OP_HOME/data/backups/<timestamp>/ and excludes
12
+ * existing backups to avoid recursive copies.
13
13
  */
14
14
  export function backupOpenPalmHome(homeDir: string): string | null {
15
15
  if (!existsSync(homeDir)) return null;
16
16
 
17
- const backupDir = join(homeDir, "backups", timestampDirName());
17
+ const backupDir = join(homeDir, "data", "backups", timestampDirName());
18
18
  mkdirSync(backupDir, { recursive: true });
19
19
 
20
20
  let copiedAny = false;
21
21
  for (const entry of readdirSync(homeDir, { withFileTypes: true })) {
22
- if (entry.name === "backups") continue;
23
-
24
22
  const sourcePath = join(homeDir, entry.name);
23
+ if (entry.name === "data") {
24
+ const dataTarget = join(backupDir, entry.name);
25
+ mkdirSync(dataTarget, { recursive: true });
26
+ for (const dataEntry of readdirSync(sourcePath, { withFileTypes: true })) {
27
+ if (dataEntry.name === "backups") continue;
28
+ cpSync(join(sourcePath, dataEntry.name), join(dataTarget, dataEntry.name), { recursive: true });
29
+ copiedAny = true;
30
+ }
31
+ continue;
32
+ }
33
+
25
34
  const targetPath = join(backupDir, entry.name);
26
35
  cpSync(sourcePath, targetPath, { recursive: true });
27
36
  copiedAny = true;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Channel validation, discovery, and allowlist checks for the OpenPalm control plane.
3
3
  */
4
- import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { existsSync, readFileSync } from "node:fs";
5
5
  import { dirname } from "node:path";
6
6
  import { parse as yamlParse } from "yaml";
7
7
  import type { ChannelInfo } from "./types.js";
@@ -16,6 +16,43 @@ function isValidChannelName(name: string): boolean {
16
16
  return CHANNEL_NAME_RE.test(name);
17
17
  }
18
18
 
19
+ function addonComposePaths(homeDir: string): string[] {
20
+ const paths: string[] = [];
21
+
22
+ for (const name of ['channels.compose.yml', 'services.compose.yml', 'custom.compose.yml']) {
23
+ const composePath = `${homeDir}/config/stack/${name}`;
24
+ if (existsSync(composePath)) paths.push(composePath);
25
+ }
26
+
27
+ return paths;
28
+ }
29
+
30
+ function channelNamesFromCompose(composePath: string): string[] {
31
+ try {
32
+ const content = readFileSync(composePath, "utf-8");
33
+ const doc = yamlParse(content);
34
+ if (typeof doc !== "object" || doc === null) return [];
35
+ const services = (doc as Record<string, unknown>).services;
36
+ if (typeof services !== "object" || services === null) return [];
37
+
38
+ const names: string[] = [];
39
+ for (const [svcName, svcDef] of Object.entries(services as Record<string, unknown>)) {
40
+ if (typeof svcDef !== "object" || svcDef === null) continue;
41
+ const env = (svcDef as Record<string, unknown>).environment;
42
+ if (typeof env === "object" && env !== null) {
43
+ if (Array.isArray(env)) {
44
+ if (env.some((e: unknown) => typeof e === "string" && e.startsWith("CHANNEL_NAME="))) names.push(svcName);
45
+ } else if ("CHANNEL_NAME" in (env as Record<string, unknown>)) {
46
+ names.push(svcName);
47
+ }
48
+ }
49
+ }
50
+ return names;
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
19
56
  // ── Channel Discovery ─────────────────────────────────────────────────
20
57
 
21
58
  /**
@@ -51,7 +88,8 @@ export function isChannelAddon(composePath: string): boolean {
51
88
  }
52
89
 
53
90
  /**
54
- * Discover installed channels by scanning stack/addons/ for channel addons.
91
+ * Discover installed channels from explicit first-party addon state plus
92
+ * custom stack/addons/ overlays.
55
93
  * A channel addon is identified by compose-derived truth: its compose.yml
56
94
  * defines services with a CHANNEL_NAME environment variable.
57
95
  *
@@ -62,20 +100,8 @@ export function isChannelAddon(composePath: string): boolean {
62
100
  */
63
101
  export function discoverChannels(configDir: string): ChannelInfo[] {
64
102
  const homeDir = dirname(configDir);
65
- const addonsDir = `${homeDir}/config/stack/addons`;
66
- if (!existsSync(addonsDir)) return [];
67
-
68
- const entries = readdirSync(addonsDir, { withFileTypes: true });
69
- return entries
70
- .filter((entry) => {
71
- if (!entry.isDirectory()) return false;
72
- const composePath = `${addonsDir}/${entry.name}/compose.yml`;
73
- return existsSync(composePath) && isChannelAddon(composePath);
74
- })
75
- .map((entry) => ({
76
- name: entry.name,
77
- ymlPath: `${addonsDir}/${entry.name}/compose.yml`,
78
- }))
103
+ return addonComposePaths(homeDir)
104
+ .flatMap((composePath) => channelNamesFromCompose(composePath).map((name) => ({ name, ymlPath: composePath })))
79
105
  .filter((ch) => isValidChannelName(ch.name));
80
106
  }
81
107
 
@@ -84,8 +110,8 @@ export function discoverChannels(configDir: string): ChannelInfo[] {
84
110
  /**
85
111
  * Check if a service name is allowed. Core services are always allowed.
86
112
  * Addon services are allowed if they appear as a compose service defined in
87
- * any addon compose file under stack/addons/. This is compose-derived: the
88
- * actual compose content is checked, not directory naming conventions.
113
+ * any active addon compose file. This is compose-derived: the actual compose
114
+ * content is checked, not directory naming conventions.
89
115
  */
90
116
  export function isAllowedService(value: string, configDir?: string): boolean {
91
117
  if (!value || !value.trim() || value !== value.toLowerCase()) return false;
@@ -93,14 +119,8 @@ export function isAllowedService(value: string, configDir?: string): boolean {
93
119
 
94
120
  if (configDir) {
95
121
  const homeDir = dirname(configDir);
96
- const addonsDir = `${homeDir}/config/stack/addons`;
97
- if (!existsSync(addonsDir)) return false;
98
-
99
- // Check if any addon compose.yml defines this service name (YAML-parsed)
100
- for (const entry of readdirSync(addonsDir, { withFileTypes: true })) {
101
- if (!entry.isDirectory()) continue;
102
- const composePath = `${addonsDir}/${entry.name}/compose.yml`;
103
- if (!existsSync(composePath)) continue;
122
+ // Check if any active addon compose.yml defines this service name (YAML-parsed)
123
+ for (const composePath of addonComposePaths(homeDir)) {
104
124
  try {
105
125
  const content = readFileSync(composePath, "utf-8");
106
126
  const doc = yamlParse(content);
@@ -120,14 +140,13 @@ export function isAllowedService(value: string, configDir?: string): boolean {
120
140
 
121
141
  /**
122
142
  * Check if a channel name is valid and installed.
123
- * Accepts any channel with a compose.yml in stack/addons/<name>/.
143
+ * Accepts enabled first-party channels and custom channel overlays.
124
144
  */
125
145
  export function isValidChannel(value: string, configDir?: string): boolean {
126
146
  if (!value || !value.trim()) return false;
127
147
  if (!isValidChannelName(value)) return false;
128
148
  if (configDir) {
129
- const homeDir = dirname(configDir);
130
- return existsSync(`${homeDir}/config/stack/addons/${value}/compose.yml`);
149
+ return discoverChannels(configDir).some((channel) => channel.name === value);
131
150
  }
132
151
  return false;
133
152
  }
@@ -112,7 +112,7 @@ describe("guardrail: compose preflight before mutation", () => {
112
112
  // Verify composePreflight is imported
113
113
  expect(lifecycleTs).toContain("composePreflight");
114
114
  // Verify preflight appears BEFORE snapshot in the source
115
- const preflightIdx = lifecycleTs.indexOf("composePreflight({ files, envFiles })");
115
+ const preflightIdx = lifecycleTs.indexOf("composePreflight({ files, envFiles, profiles })");
116
116
  const snapshotIdx = lifecycleTs.indexOf("snapshotCurrentState(state)");
117
117
  expect(preflightIdx).toBeGreaterThan(0);
118
118
  expect(snapshotIdx).toBeGreaterThan(0);
@@ -18,10 +18,9 @@ function makeState(overrides: Partial<ControlPlaneState> = {}): ControlPlaneStat
18
18
  return {
19
19
  homeDir: tempDir,
20
20
  configDir,
21
- stashDir: join(tempDir, "stash"),
21
+ stashDir: join(tempDir, "knowledge"),
22
22
  workspaceDir: join(tempDir, "workspace"),
23
- cacheDir: join(tempDir, "cache"),
24
- stateDir: join(tempDir, "state"),
23
+ dataDir: join(tempDir, "data"),
25
24
  stackDir: join(configDir, "stack"),
26
25
  services: {},
27
26
  artifacts: { compose: "" },
@@ -36,26 +35,24 @@ function seedCoreCompose(): void {
36
35
  writeFileSync(join(stackDir, "core.compose.yml"), "services: {}");
37
36
  }
38
37
 
39
- function seedEnvFiles(files: { stack?: boolean; guardian?: boolean } = {}): void {
40
- const stackDir = join(tempDir, "config", "stack");
38
+ function seedEnvFiles(files: { stack?: boolean } = {}): void {
41
39
  if (files.stack) {
42
- mkdirSync(stackDir, { recursive: true });
43
- writeFileSync(join(stackDir, "stack.env"), "KEY=val");
44
- }
45
- if (files.guardian) {
46
- mkdirSync(stackDir, { recursive: true });
47
- writeFileSync(join(stackDir, "guardian.env"), "CHANNEL_CHAT_SECRET=abc");
40
+ const envDir = join(tempDir, "knowledge", "env");
41
+ mkdirSync(envDir, { recursive: true });
42
+ writeFileSync(join(envDir, "stack.env"), "KEY=val");
48
43
  }
49
44
  }
50
45
 
51
46
  function seedAddon(name: string): void {
52
- const addonDir = join(tempDir, "config", "stack", "addons", name);
53
- mkdirSync(addonDir, { recursive: true });
54
- writeFileSync(join(addonDir, "compose.yml"), "services: {}");
47
+ const stackDir = join(tempDir, "config", "stack");
48
+ mkdirSync(stackDir, { recursive: true });
49
+ writeFileSync(join(stackDir, "channels.compose.yml"), `services:\n ${name}:\n profiles: [\"addon.${name}\"]\n image: test\n`);
50
+ writeFileSync(join(stackDir, "stack.yml"), `version: 2\naddons:\n - ${name}\n`);
55
51
  }
56
52
 
57
53
  beforeEach(() => {
58
54
  tempDir = mkdtempSync(join(tmpdir(), "compose-args-test-"));
55
+ process.env.OP_HOME = tempDir;
59
56
  });
60
57
 
61
58
  afterEach(() => {
@@ -73,27 +70,37 @@ describe("buildComposeOptions", () => {
73
70
  expect(opts.files[0]).toContain("core.compose.yml");
74
71
  });
75
72
 
76
- it("includes addon overlays when compose files are present in stack/addons", () => {
73
+ it("includes fixed channel compose and profile from stack.yml", () => {
77
74
  seedCoreCompose();
78
75
  seedAddon("chat");
79
76
 
80
77
  const state = makeState();
81
78
  const opts = buildComposeOptions(state);
82
79
  expect(opts.files).toHaveLength(2);
83
- expect(opts.files[1]).toContain("chat");
80
+ expect(opts.files[1]).toContain("channels.compose.yml");
81
+ expect(opts.profiles).toContain("addon.chat");
82
+ });
83
+
84
+ it("includes the user custom compose file", () => {
85
+ seedCoreCompose();
86
+ const stackDir = join(tempDir, "config", "stack");
87
+ writeFileSync(join(stackDir, "custom.compose.yml"), "services: {}");
88
+
89
+ const state = makeState();
90
+ const opts = buildComposeOptions(state);
91
+ expect(opts.files).toHaveLength(2);
92
+ expect(opts.files[1]).toContain("custom.compose.yml");
84
93
  });
85
94
 
86
95
  it("returns env files in correct order", () => {
87
- // Note: vault/user/user.env is no longer a
88
- // compose env_file. The runtime env file list is: stack.env, guardian.env.
89
- // Even when a legacy user.env is present on disk, it is intentionally
90
- // excluded from the compose args.
91
- seedEnvFiles({ stack: true, guardian: true });
96
+ // The runtime --env-file list is knowledge/env/stack.env only. The user env
97
+ // (knowledge/env/user.env) is sourced by the assistant entrypoint, not a
98
+ // compose env_file.
99
+ seedEnvFiles({ stack: true });
92
100
  const state = makeState();
93
101
  const opts = buildComposeOptions(state);
94
- expect(opts.envFiles).toHaveLength(2);
102
+ expect(opts.envFiles).toHaveLength(1);
95
103
  expect(opts.envFiles[0]).toContain("stack.env");
96
- expect(opts.envFiles[1]).toContain("guardian.env");
97
104
  });
98
105
 
99
106
  it("excludes missing env files", () => {
@@ -115,6 +122,38 @@ describe("buildComposeCliArgs", () => {
115
122
  expect(args[1]).toBe("openpalm");
116
123
  });
117
124
 
125
+ it("uses OP_PROJECT_NAME from stack.env", () => {
126
+ seedCoreCompose();
127
+ seedEnvFiles({ stack: true });
128
+ writeFileSync(join(tempDir, "knowledge", "env", "stack.env"), "OP_PROJECT_NAME=openpalm-test\n");
129
+ const state = makeState();
130
+ const args = buildComposeCliArgs(state);
131
+ expect(args[0]).toBe("--project-name");
132
+ expect(args[1]).toBe("openpalm-test");
133
+ });
134
+
135
+ it("uses canonical voice and ollama profile ids", () => {
136
+ seedCoreCompose();
137
+ seedEnvFiles({ stack: true });
138
+ writeFileSync(join(tempDir, "knowledge", "env", "stack.env"), "OP_VOICE_PROFILE=addon.voice.cuda\nOP_OLLAMA_PROFILE=addon.ollama.cpu\n");
139
+ const state = makeState();
140
+ const args = buildComposeCliArgs(state);
141
+ expect(args).toContain("addon.voice.cuda");
142
+ expect(args).toContain("addon.ollama.cpu");
143
+ });
144
+
145
+ it("ignores non-canonical addon profile ids", () => {
146
+ seedCoreCompose();
147
+ seedEnvFiles({ stack: true });
148
+ writeFileSync(join(tempDir, "knowledge", "env", "stack.env"), "OP_VOICE_PROFILE=not-canonical\nOP_OLLAMA_PROFILE=also-not-canonical\n");
149
+ const state = makeState();
150
+ const args = buildComposeCliArgs(state);
151
+ expect(args).not.toContain("not-canonical");
152
+ expect(args).not.toContain("also-not-canonical");
153
+ expect(args).not.toContain("addon.voice.cuda");
154
+ expect(args).not.toContain("addon.ollama.cpu");
155
+ });
156
+
118
157
  it("includes -f flags for compose files", () => {
119
158
  seedCoreCompose();
120
159
  const state = makeState();
@@ -125,18 +164,16 @@ describe("buildComposeCliArgs", () => {
125
164
  });
126
165
 
127
166
  it("includes --env-file flags for env files that exist", () => {
128
- // Note: vault/user/user.env is no longer
129
- // listed in the compose env_file set. Only stack.env and guardian.env
130
- // (when present) are passed via --env-file.
167
+ // Only knowledge/env/stack.env is passed via --env-file.
131
168
  seedCoreCompose();
132
- seedEnvFiles({ stack: true, guardian: true });
169
+ seedEnvFiles({ stack: true });
133
170
  const state = makeState();
134
171
  const args = buildComposeCliArgs(state);
135
172
  const envFileIndices = args.reduce<number[]>((acc, arg, i) => {
136
173
  if (arg === "--env-file") acc.push(i);
137
174
  return acc;
138
175
  }, []);
139
- expect(envFileIndices).toHaveLength(2);
176
+ expect(envFileIndices).toHaveLength(1);
140
177
  });
141
178
 
142
179
  it("does not include --env-file for missing files", () => {
@@ -146,7 +183,7 @@ describe("buildComposeCliArgs", () => {
146
183
  expect(args).not.toContain("--env-file");
147
184
  });
148
185
 
149
- it("includes addon overlays in -f flags", () => {
186
+ it("includes fixed channel compose in -f flags", () => {
150
187
  seedCoreCompose();
151
188
  seedAddon("chat");
152
189
 
@@ -157,6 +194,6 @@ describe("buildComposeCliArgs", () => {
157
194
  return acc;
158
195
  }, []);
159
196
  expect(fFlags).toHaveLength(2);
160
- expect(fFlags[1]).toContain("chat");
197
+ expect(fFlags[1]).toContain("channels.compose.yml");
161
198
  });
162
199
  });
@@ -10,44 +10,99 @@ import type { ControlPlaneState } from "./types.js";
10
10
  import { buildComposeFileList } from "./lifecycle.js";
11
11
  import { buildEnvFiles } from "./config-persistence.js";
12
12
  import { resolveComposeProjectName } from "./docker.js";
13
+ import { parseEnvFile } from "./env.js";
14
+ import { canonicalAddonProfileSelection } from "./profile-ids.js";
15
+ import { listStackSpecAddons } from "./stack-spec.js";
13
16
 
14
17
  // ── Types ────────────────────────────────────────────────────────────────
15
18
 
16
19
  export type ComposeOptions = {
17
20
  files: string[];
18
21
  envFiles: string[];
22
+ profiles: string[];
19
23
  };
20
24
 
25
+ // ── Profile Resolution ───────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Resolve active Docker Compose profiles from the stack env.
29
+ * Reads OP_VOICE_PROFILE and OP_OLLAMA_PROFILE (addon hardware profiles).
30
+ * Returns deduplicated, non-empty profile strings.
31
+ */
32
+ export function resolveActiveProfiles(state: ControlPlaneState): string[] {
33
+ const profiles: string[] = [];
34
+ const stackEnvPath = `${state.stashDir}/env/stack.env`;
35
+ let env: Record<string, string> = {};
36
+ if (existsSync(stackEnvPath)) {
37
+ env = parseEnvFile(stackEnvPath);
38
+ for (const profile of (env.COMPOSE_PROFILES ?? '').split(',')) {
39
+ const trimmed = profile.trim();
40
+ if (trimmed) profiles.push(trimmed);
41
+ }
42
+ const voiceProfile = canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '');
43
+ if (voiceProfile) profiles.push(voiceProfile);
44
+ const ollamaProfile = canonicalAddonProfileSelection('ollama', env.OP_OLLAMA_PROFILE ?? '');
45
+ if (ollamaProfile) profiles.push(ollamaProfile);
46
+ }
47
+
48
+ for (const addon of listStackSpecAddons(state.stackDir)) {
49
+ if (addon === 'voice') {
50
+ profiles.push(canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '') || 'addon.voice.cpu');
51
+ } else if (addon === 'ollama') {
52
+ profiles.push(canonicalAddonProfileSelection('ollama', env.OP_OLLAMA_PROFILE ?? '') || 'addon.ollama.cpu');
53
+ } else {
54
+ profiles.push(`addon.${addon}`);
55
+ }
56
+ }
57
+
58
+ return [...new Set(profiles)];
59
+ }
60
+
21
61
  // ── Builders ─────────────────────────────────────────────────────────────
22
62
 
23
63
  /**
24
- * Build the compose file and env file lists for a given state.
25
- * Returns the resolved files and env files for use with docker.ts functions.
26
- *
27
- * Note: env files are already filtered to only existing paths by
28
- * `buildEnvFiles()` in config-persistence.ts.
64
+ * Build the compose file, env file, and profile lists for a given state.
65
+ * Returns the resolved values for use with docker.ts functions.
29
66
  */
30
67
  export function buildComposeOptions(state: ControlPlaneState): ComposeOptions {
31
68
  return {
32
69
  files: buildComposeFileList(state),
33
70
  envFiles: buildEnvFiles(state),
71
+ profiles: resolveActiveProfiles(state),
34
72
  };
35
73
  }
36
74
 
37
75
  /**
38
76
  * Build the full docker compose CLI argument array for a given state.
39
77
  *
40
- * Returns: ['--project-name', 'openpalm', '-f', file1, '-f', file2, '--env-file', env1, ...]
78
+ * Returns: ['--project-name', 'openpalm', '-f', file1, '-f', file2, '--env-file', env1, '--profile', addon.voice.cpu, ...]
41
79
  *
42
80
  * Only includes env files that exist on disk.
43
81
  */
44
82
  export function buildComposeCliArgs(state: ControlPlaneState): string[] {
45
- const { files, envFiles } = buildComposeOptions(state);
83
+ const { files, envFiles, profiles } = buildComposeOptions(state);
46
84
 
47
85
  return [
48
86
  "--project-name",
49
- resolveComposeProjectName(),
87
+ resolveComposeProjectName(collectEnvOverrides(envFiles)),
50
88
  ...files.flatMap((f) => ["-f", f]),
51
89
  ...envFiles.filter((f) => existsSync(f)).flatMap((f) => ["--env-file", f]),
90
+ ...profiles.flatMap((p) => ["--profile", p]),
52
91
  ];
53
92
  }
93
+
94
+ // ── Run Script ───────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Convert an absolute path to a ${OP_HOME}-relative shell expression.
98
+ * E.g. "/home/user/.openpalm/config/stack/core.compose.yml"
99
+ * → "${OP_HOME}/config/stack/core.compose.yml"
100
+ */
101
+ function collectEnvOverrides(envFiles: string[]): Record<string, string> {
102
+ const overrides: Record<string, string> = {};
103
+ for (const envFile of envFiles) {
104
+ Object.assign(overrides, parseEnvFile(envFile));
105
+ }
106
+ return overrides;
107
+ }
108
+