@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
@@ -3,58 +3,53 @@
3
3
  *
4
4
  * Writes and derives live runtime files (compose, env, schemas).
5
5
  * Files are validated in-place before writing; rollback is handled by
6
- * the rollback module (snapshot to ~/.cache/openpalm/rollback/).
6
+ * the rollback module (snapshot to OP_HOME/data/rollback/).
7
7
  */
8
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, chmodSync } from "node:fs";
9
- import { dirname } from "node:path";
8
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
9
+ import { dirname, resolve as resolvePath } from "node:path";
10
10
  import { parse as yamlParse } from "yaml";
11
- import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
11
+ import { parseEnvContent, parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
12
+ import { assertNoSecretLikeStackEnvKeys, isSecretLikeStackEnvKey } from './secrets.js';
13
+ import { ensureSecret } from './secrets-files.js';
12
14
  import type { ControlPlaneState, ArtifactMeta } from "./types.js";
13
- import { isChannelAddon } from "./channels.js";
14
15
  import { listEnabledAddonIds } from "./registry.js";
15
16
  import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
17
+ import { SPEC_DEFAULTS } from "./stack-spec.js";
16
18
 
17
19
  import {
18
20
  readCoreCompose,
21
+ readBundledStackAsset,
19
22
  } from "./core-assets.js";
20
23
  export { sha256, randomHex } from "./crypto.js";
21
24
  import { sha256, randomHex } from "./crypto.js";
22
25
 
23
- const DEFAULT_IMAGE_TAG = process.env.OP_IMAGE_TAG ?? "latest";
26
+ const DEFAULT_IMAGE_TAG = "latest";
24
27
 
25
28
  // ── Env File Management ──────────────────────────────────────────────
26
29
 
27
30
  /**
28
31
  * Return the env files used for docker compose --env-file args.
29
- * These are the live vault env files.
30
32
  *
31
- * Order: stack.env -> guardian.env
32
- *
33
- * Note: `vault/user/user.env` is no longer a
34
- * compose env_file. User-managed env secrets live in the akm
35
- * `vault:user` store and are sourced by the assistant entrypoint at
36
- * container startup. The legacy file is migrated into akm and deleted
37
- * on upgrade; subsequent `docker compose` invocations must not reference
38
- * it (compose interpolates `${VAR}` against the merged --env-file
39
- * contents, and a stale user.env would shadow the akm-sourced values).
33
+ * Only `knowledge/env/stack.env` (non-secret system config). Secret values
34
+ * live in `knowledge/secrets/<ENV_KEY>` and are granted to services as Compose
35
+ * file secrets. The user env (`knowledge/env/user.env`) is NOT a compose
36
+ * env_file it is sourced by the assistant entrypoint at container startup.
40
37
  */
41
38
  export function buildEnvFiles(state: ControlPlaneState): string[] {
42
39
  return [
43
- `${state.stackDir}/stack.env`,
44
- `${state.stackDir}/guardian.env`,
40
+ `${state.stashDir}/env/stack.env`,
45
41
  ].filter(existsSync);
46
42
  }
47
43
 
48
44
  /**
49
- * Write system-managed values to config/stack/stack.env.
45
+ * Write system-managed values to knowledge/env/stack.env.
50
46
  *
51
- * Channel HMAC secrets are NOT written here — they belong in guardian.env.
52
- * Use writeChannelSecrets() for channel secrets.
47
+ * Secret-like keys are NOT written here — they belong in knowledge/secrets/.
48
+ * Use ensureChannelSecret() for channel secrets.
53
49
  */
54
50
  export function writeSystemEnv(state: ControlPlaneState): void {
55
- mkdirSync(state.stackDir, { recursive: true });
56
-
57
- const systemEnvPath = `${state.stackDir}/stack.env`;
51
+ const systemEnvPath = `${state.stashDir}/env/stack.env`;
52
+ mkdirSync(`${state.stashDir}/env`, { recursive: true, mode: 0o700 });
58
53
 
59
54
  let base = "";
60
55
  if (existsSync(systemEnvPath)) {
@@ -63,11 +58,12 @@ export function writeSystemEnv(state: ControlPlaneState): void {
63
58
  base = generateFallbackSystemEnv(state);
64
59
  }
65
60
 
66
- // Preserve existing OP_SETUP_COMPLETE=true
67
- const alreadyComplete = /^OP_SETUP_COMPLETE=true$/mi.test(base);
68
-
61
+ // Preserve the existing OP_SETUP_COMPLETE flag as-is.
62
+ // Only the wizard completion path (buildSystemSecretsFromSetup) writes "true".
63
+ // Defaulting to "false" here ensures a fresh install always shows the wizard.
64
+ const parsed = parseEnvFile(systemEnvPath);
69
65
  const adminManaged: Record<string, string> = {
70
- OP_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
66
+ OP_SETUP_COMPLETE: parsed.OP_SETUP_COMPLETE === "true" ? "true" : "false",
71
67
  };
72
68
 
73
69
  // Backfill OP_UID/OP_GID when the existing stack.env was written by an
@@ -76,13 +72,20 @@ export function writeSystemEnv(state: ControlPlaneState): void {
76
72
  // missing or zero — an operator who manually set OP_UID=2000 (e.g.
77
73
  // because they're running on a host with a non-1000 service account)
78
74
  // must not be silently changed.
79
- const parsed = parseEnvFile(systemEnvPath);
80
75
  const ids = resolveOperatorIds(state.homeDir);
81
76
  if (ids) {
82
77
  if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
83
78
  if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
84
79
  }
85
80
 
81
+ // Backfill OP_HOME when missing — compose files reference ${OP_HOME}
82
+ // for all volume mounts. Without this, Docker Compose defaults to blank.
83
+ if (!parsed.OP_HOME) adminManaged.OP_HOME = state.homeDir;
84
+
85
+ base = stripSecretLikeEnvKeys(base);
86
+ assertNoSecretLikeStackEnvKeys(parseEnvContent(base));
87
+ assertNoSecretLikeStackEnvKeys(adminManaged);
88
+
86
89
  const content = mergeEnvContent(base, adminManaged, {
87
90
  sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
88
91
  });
@@ -91,6 +94,19 @@ export function writeSystemEnv(state: ControlPlaneState): void {
91
94
  chmodSync(systemEnvPath, 0o600);
92
95
  }
93
96
 
97
+ function stripSecretLikeEnvKeys(content: string): string {
98
+ return content
99
+ .split('\n')
100
+ .filter((line) => {
101
+ let trimmed = line.trim();
102
+ if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7).trimStart();
103
+ const eq = trimmed.indexOf('=');
104
+ if (eq <= 0) return true;
105
+ return !isSecretLikeStackEnvKey(trimmed.slice(0, eq).trim());
106
+ })
107
+ .join('\n');
108
+ }
109
+
94
110
  function generateFallbackSystemEnv(state: ControlPlaneState): string {
95
111
  // Operator UID/GID — auto-detect from OP_HOME owner (or process UID).
96
112
  // Skipped on Windows where containers run in WSL2 and OP_UID has no
@@ -104,12 +120,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
104
120
  "# OpenPalm — System Configuration (managed by CLI/admin)",
105
121
  "# Auto-generated fallback.",
106
122
  "",
107
- "# ── Authentication ──────────────────────────────────────────────────",
108
- `OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
109
- "",
110
- "# ── Service Auth ─────────────────────────────────────────────────────",
111
- "OP_OPENCODE_PASSWORD=",
112
- "",
113
123
  "# ── Paths ──────────────────────────────────────────────────────────",
114
124
  `OP_HOME=${state.homeDir}`,
115
125
  ...idLines,
@@ -121,9 +131,8 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
121
131
  "# ── Ports (38XX range) ──────────────────────────────────────────────",
122
132
  "# Guardian is network-only (no host port) — channels reach it via",
123
133
  "# http://guardian:8080 over the channel_lan Docker network.",
124
- `OP_ASSISTANT_PORT=3800`,
125
- `OP_ADMIN_PORT=3880`,
126
- `OP_ADMIN_OPENCODE_PORT=3881`,
134
+ `OP_ASSISTANT_PORT=${SPEC_DEFAULTS.ports.assistant}`,
135
+ `OP_HOST_UI_PORT=${SPEC_DEFAULTS.ports.hostUi}`,
127
136
  ""
128
137
  ].join("\n");
129
138
  }
@@ -131,37 +140,25 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
131
140
  // ── Stack Overlay Discovery ────────────────────────────────────────────
132
141
 
133
142
  /**
134
- * Discover compose overlays from the stack directory.
135
- * Returns full paths: [stack/core.compose.yml, stack/addons/{name}/compose.yml].
143
+ * Discover active compose overlays.
144
+ * Returns the fixed compose stack: core, services, channels, and custom.
145
+ * First-party services are profile-gated inside services.compose.yml and
146
+ * channels.compose.yml.
147
+ *
148
+ * Host AKM sharing is NOT a compose overlay: the assistant always mounts
149
+ * `/host-stash` (core.compose.yml, with an empty-dir fallback), and "sharing"
150
+ * is purely a writable secondary source entry in config/akm/config.json. No
151
+ * conditional overlay file is involved.
136
152
  */
137
- export function discoverStackOverlays(stackDir: string): string[] {
153
+ export function discoverStackOverlays(stackDir: string, _homeDir?: string): string[] {
138
154
  const files: string[] = [];
139
155
 
140
156
  const coreYml = `${stackDir}/core.compose.yml`;
141
157
  if (existsSync(coreYml)) files.push(coreYml);
142
158
 
143
- const addonsDir = `${stackDir}/addons`;
144
- if (existsSync(addonsDir)) {
145
- const entries = readdirSync(addonsDir, { withFileTypes: true })
146
- .filter((e) => e.isDirectory())
147
- .sort((a, b) => a.name.localeCompare(b.name));
148
- for (const entry of entries) {
149
- const dir = `${addonsDir}/${entry.name}`;
150
- // Pick up compose.yml plus any compose.<variant>.yml sibling
151
- // overlays (e.g. compose.cdi.yml generated by /admin/voice on
152
- // CDI hosts). Stable sort: compose.yml first, then siblings
153
- // alphabetically, so the base file's keys are the defaults and
154
- // overlays merge on top in deterministic order.
155
- const overlays = readdirSync(dir, { withFileTypes: true })
156
- .filter((e) => e.isFile() && /^compose(\.[A-Za-z0-9_-]+)?\.ya?ml$/.test(e.name))
157
- .map((e) => e.name)
158
- .sort((a, b) => {
159
- if (a === "compose.yml" || a === "compose.yaml") return -1;
160
- if (b === "compose.yml" || b === "compose.yaml") return 1;
161
- return a.localeCompare(b);
162
- });
163
- for (const name of overlays) files.push(`${dir}/${name}`);
164
- }
159
+ for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
160
+ const composePath = `${stackDir}/${name}`;
161
+ if (existsSync(composePath)) files.push(composePath);
165
162
  }
166
163
 
167
164
  return files;
@@ -192,79 +189,38 @@ export function buildRuntimeFileMeta(artifacts: {
192
189
  }
193
190
 
194
191
  // ── Channel Secrets ────────────────────────────────────────────────────
195
- // Channel HMAC secrets live exclusively in vault/stack/guardian.env.
196
-
197
- const CHANNEL_SECRET_RE = /^CHANNEL_([A-Z0-9_]+)_SECRET$/;
198
-
199
- /** Extract channel secrets from parsed env entries. */
200
- function extractChannelSecrets(parsed: Record<string, string>): Record<string, string> {
201
- const result: Record<string, string> = {};
202
- for (const [key, value] of Object.entries(parsed)) {
203
- const match = key.match(CHANNEL_SECRET_RE);
204
- if (match?.[1] && value) result[match[1].toLowerCase()] = value;
205
- }
206
- return result;
207
- }
208
192
 
209
- /**
210
- * Read channel HMAC secrets from config/stack/guardian.env.
211
- */
212
- export function readChannelSecrets(stackDir: string): Record<string, string> {
213
- return extractChannelSecrets(parseEnvFile(`${stackDir}/guardian.env`));
193
+ export function channelSecretName(addon: string): string {
194
+ return `channel_${addon.replace(/-/g, '_')}_secret`;
214
195
  }
215
196
 
216
- /**
217
- * Write channel HMAC secrets to state/guardian.env.
218
- * Merges with existing content; does not overwrite unrelated entries.
219
- */
220
- export function writeChannelSecrets(stackDir: string, secrets: Record<string, string>): void {
221
- const guardianPath = `${stackDir}/guardian.env`;
222
- mkdirSync(stackDir, { recursive: true });
223
-
224
- let base = "";
225
- if (existsSync(guardianPath)) {
226
- base = readFileSync(guardianPath, "utf-8");
227
- } else {
228
- base = "# Guardian channel HMAC secrets — managed by openpalm\n";
229
- }
230
-
231
- const updates: Record<string, string> = {};
232
- for (const [ch, secret] of Object.entries(secrets)) {
233
- updates[`CHANNEL_${ch.toUpperCase()}_SECRET`] = secret;
234
- }
235
-
236
- const content = mergeEnvContent(base, updates);
237
- writeFileSync(guardianPath, content, { mode: 0o600 });
238
- // Ensure correct permissions even if file already existed with wrong mode
239
- chmodSync(guardianPath, 0o600);
197
+ export function ensureChannelSecret(stackDir: string, addon: string): string {
198
+ return ensureSecret(stackDir, channelSecretName(addon), () => randomHex(16));
240
199
  }
241
200
 
242
201
  // ── Volume Mount Targets ───────────────────────────────────────────────
243
202
 
244
203
  /**
245
- * Parse all enabled compose files and pre-create every host-side volume
246
- * mount target as the current user. This prevents Docker from creating
247
- * them as root-owned, which causes EACCES inside non-root containers.
248
- *
249
- * For file mounts (basename contains a `.`), creates an empty file.
250
- * For directory mounts (basename has no `.`), creates the directory.
251
- *
252
- * Heuristic: a basename containing a `.` is treated as a file. This
253
- * intentionally includes leading-dot files (e.g. `.env`) because Docker
254
- * bind mounts to them must be regular files. Bare directory names like
255
- * `stack` or `addons` lack extensions and are created as directories.
204
+ * Parse enabled compose files and pre-create host-side volume mount
205
+ * targets under OP_HOME as the current user. This prevents Docker from
206
+ * creating them as root-owned, which causes EACCES inside non-root
207
+ * containers.
256
208
  *
257
209
  * Only mount sources under `state.homeDir` are touched; external paths
258
210
  * (e.g. `/var/run/docker.sock`) are left alone.
211
+ *
212
+ * The file-vs-directory distinction is best-effort and only applies to
213
+ * explicit OP_HOME paths.
259
214
  */
260
215
  export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
261
- const composeFiles = discoverStackOverlays(state.stackDir);
216
+ const composeFiles = discoverStackOverlays(state.stackDir, state.homeDir);
262
217
  if (composeFiles.length === 0) return;
263
218
 
264
219
  const envVars: Record<string, string> = {
265
220
  ...(process.env as Record<string, string>),
266
- ...parseEnvFile(`${state.stackDir}/stack.env`),
221
+ ...parseEnvFile(`${state.stashDir}/env/stack.env`),
267
222
  };
223
+ const homeRoot = resolvePath(state.homeDir);
268
224
 
269
225
  for (const file of composeFiles) {
270
226
  let doc: Record<string, unknown>;
@@ -291,18 +247,20 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
291
247
 
292
248
  const hostPath = expandEnvVars(rawSource, envVars);
293
249
  if (!hostPath || !hostPath.startsWith('/')) continue;
294
- if (existsSync(hostPath)) continue;
250
+ const resolvedHostPath = resolvePath(hostPath);
251
+ if (!resolvedHostPath.startsWith(`${homeRoot}/`) && resolvedHostPath !== homeRoot) continue;
252
+ if (existsSync(resolvedHostPath)) continue;
295
253
 
296
- // A basename containing a `.` (anywhere, including leading) is a file.
297
- // Bare names like `stack` or `data` are directories.
298
- const basename = hostPath.split('/').pop() ?? '';
254
+ // Only create mounts under OP_HOME. For now, treat existing explicit
255
+ // file paths as files and directory paths as directories.
256
+ const basename = resolvedHostPath.split('/').pop() ?? '';
299
257
  const isFile = basename.includes('.');
300
258
 
301
259
  if (isFile) {
302
- mkdirSync(dirname(hostPath), { recursive: true });
303
- writeFileSync(hostPath, '');
260
+ mkdirSync(dirname(resolvedHostPath), { recursive: true });
261
+ writeFileSync(resolvedHostPath, '');
304
262
  } else {
305
- mkdirSync(hostPath, { recursive: true });
263
+ mkdirSync(resolvedHostPath, { recursive: true });
306
264
  }
307
265
  }
308
266
  }
@@ -322,24 +280,25 @@ export function writeRuntimeFiles(
322
280
  writeFileSync(composePath, state.artifacts.compose);
323
281
  }
324
282
 
325
- // Load persisted channel HMAC secrets from guardian.env,
326
- // then generate new ones for new channel addons.
327
- const channelSecrets = readChannelSecrets(state.stackDir);
283
+ for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
284
+ const path = `${state.stackDir}/${name}`;
285
+ if (!existsSync(path)) writeFileSync(path, readBundledStackAsset(name));
286
+ }
287
+
328
288
  for (const addon of listEnabledAddonIds(state.homeDir)) {
329
- const composePath = `${state.stackDir}/addons/${addon}/compose.yml`;
330
- if (isChannelAddon(composePath) && !channelSecrets[addon]) {
331
- channelSecrets[addon] = randomHex(16);
289
+ if (['api', 'chat', 'discord', 'slack'].includes(addon)) {
290
+ for (const channel of ['api', 'chat', 'discord', 'slack']) {
291
+ ensureChannelSecret(state.stackDir, channel);
292
+ }
293
+ break;
332
294
  }
333
295
  }
334
296
 
335
- // Write channel secrets to guardian.env (the canonical source)
336
- writeChannelSecrets(state.stackDir, channelSecrets);
337
-
338
- // Write system.env (no channel secrets — those live in guardian.env)
297
+ // Write stack.env (no secrets those live in knowledge/secrets/)
339
298
  writeSystemEnv(state);
340
299
 
341
300
  // Ensure state directory exists
342
- mkdirSync(state.stateDir, { recursive: true });
301
+ mkdirSync(state.dataDir, { recursive: true });
343
302
 
344
303
  state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
345
304
  }
@@ -5,19 +5,24 @@
5
5
  * stack/ — compose runtime assets (core.compose.yml)
6
6
  *
7
7
  * This module manages runtime-owned core files only.
8
- * Registry catalog refresh is handled separately in registry.ts.
8
+ * Addon compose bundle generation and registry catalog refresh are handled
9
+ * separately in registry.ts.
9
10
  * Env validation has moved to `akm vault` + the in-house redactor — the
10
11
  * historical `.env.schema` files (varlock format) were retired in #391.
11
12
  */
12
13
  import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
13
- import { dirname, join, resolve, sep } from "node:path";
14
+ import { dirname, join } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
- import { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
16
+ import { resolveDataDir, resolveOpenPalmHome, resolveBackupsDir } from "./home.js";
16
17
  import { createLogger } from "../logger.js";
17
18
  import { sha256 } from "./crypto.js";
18
19
 
19
20
  const logger = createLogger("core-assets");
20
21
 
22
+ function bundledAssetPath(relPath: string): string {
23
+ return join(dirname(fileURLToPath(import.meta.url)), '../../../../.openpalm', relPath);
24
+ }
25
+
21
26
  // ── Core Compose (stack/) ─────────────────────────────────────────────
22
27
 
23
28
  export function ensureCoreCompose(): string {
@@ -27,57 +32,26 @@ export function ensureCoreCompose(): string {
27
32
  }
28
33
 
29
34
  export function readCoreCompose(): string {
30
- return readFileSync(`${resolveOpenPalmHome()}/config/stack/core.compose.yml`, "utf-8");
35
+ const livePath = `${resolveOpenPalmHome()}/config/stack/core.compose.yml`;
36
+ if (existsSync(livePath)) {
37
+ return readFileSync(livePath, 'utf-8');
38
+ }
39
+ return readFileSync(bundledAssetPath('config/stack/core.compose.yml'), 'utf-8');
40
+ }
41
+
42
+ export function readBundledStackAsset(name: string): string {
43
+ return readFileSync(bundledAssetPath(`config/stack/${name}`), 'utf-8');
31
44
  }
32
45
 
33
46
  // ── OpenCode System Config ──────────────────────────────────────────
34
47
 
35
48
  export function ensureOpenCodeSystemConfig(): void {
36
- const dir = `${resolveStateDir()}/assistant`;
49
+ const dir = `${resolveDataDir()}/assistant`;
37
50
  mkdirSync(dir, { recursive: true });
38
51
  }
39
52
 
40
53
  // ── Shared akm stash (skills / commands / agents) ────────────────────
41
54
 
42
- /**
43
- * Seed the shared akm stash with built-in skills / commands / agents.
44
- *
45
- * Idempotent: **never overwrites** an existing file — user edits to a
46
- * seeded asset always win, which preserves the same "config doesn't
47
- * overwrite user edits" contract that governs the rest of OP_HOME.
48
- *
49
- * Returns the list of stash-relative paths that were actually written
50
- * (empty on re-run when every seed already exists on disk).
51
- *
52
- * `seeds` is a map of stash-relative path → file content. Keys MUST be
53
- * forward-slash relative paths that stay inside `data/stash/`; any key
54
- * that escapes the stash directory after canonicalization throws,
55
- * preventing a malicious caller from writing arbitrary files. Source of
56
- * truth for the seeded files lives at `.openpalm/stash/` in the
57
- * repo; the CLI embeds them at build time and passes the embedded
58
- * record directly.
59
- */
60
- export function seedStashAssets(seeds: Record<string, string>): string[] {
61
- const stashDir = resolveStashDir();
62
- const normalizedStash = resolve(stashDir);
63
- const written: string[] = [];
64
- for (const [relPath, content] of Object.entries(seeds)) {
65
- const targetPath = join(stashDir, relPath);
66
- const normalizedTarget = resolve(targetPath);
67
- if (
68
- normalizedTarget !== normalizedStash &&
69
- !normalizedTarget.startsWith(normalizedStash + sep)
70
- ) {
71
- throw new Error(`Seed path escapes stash dir: ${relPath}`);
72
- }
73
- if (existsSync(targetPath)) continue;
74
- mkdirSync(dirname(targetPath), { recursive: true });
75
- writeFileSync(targetPath, content);
76
- written.push(relPath);
77
- }
78
- return written;
79
- }
80
-
81
55
  // ── Asset Refresh (GitHub download) ──────────────────────────────────
82
56
 
83
57
  const REPO = "itlackey/openpalm";
@@ -100,12 +74,15 @@ const VERSION = resolveAssetVersion();
100
74
  // overwritten) via seedOpenPalmDir (skipExisting) or SEEDED_ASSETS below.
101
75
  const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
102
76
  { relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
77
+ { relPath: "config/stack/services.compose.yml", githubFilename: ".openpalm/config/stack/services.compose.yml" },
78
+ { relPath: "config/stack/channels.compose.yml", githubFilename: ".openpalm/config/stack/channels.compose.yml" },
103
79
  ];
104
80
 
105
81
  // Seeded once — written only when the file does not exist yet.
106
82
  // User edits always win; upgrade never touches these files.
107
83
  const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
108
84
  { relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
85
+ { relPath: "config/stack/custom.compose.yml", githubFilename: ".openpalm/config/stack/custom.compose.yml" },
109
86
  ];
110
87
 
111
88
  async function downloadAsset(filename: string): Promise<string> {
@@ -39,10 +39,12 @@ function run(
39
39
 
40
40
  /**
41
41
  * Resolve the Docker Compose project name.
42
- * Honors COMPOSE_PROJECT_NAME (Docker standard) and OP_PROJECT_NAME (legacy).
42
+ * Honors OP_PROJECT_NAME first for OpenPalm stacks, then COMPOSE_PROJECT_NAME.
43
43
  */
44
- export function resolveComposeProjectName(): string {
44
+ export function resolveComposeProjectName(envOverrides: Record<string, string> = {}): string {
45
45
  return (
46
+ envOverrides.OP_PROJECT_NAME?.trim() ||
47
+ envOverrides.COMPOSE_PROJECT_NAME?.trim() ||
46
48
  process.env.OP_PROJECT_NAME?.trim() ||
47
49
  process.env.COMPOSE_PROJECT_NAME?.trim() ||
48
50
  "openpalm"
@@ -87,12 +89,14 @@ export async function checkDockerCompose(): Promise<DockerResult> {
87
89
  });
88
90
  }
89
91
 
90
- /** Build common prefix: compose -f ... --project-name ... --env-file ... */
91
- function buildComposeArgs(options: { files: string[]; envFiles?: string[] }): string[] {
92
- const args = ["compose", ...options.files.flatMap((f) => ["-f", f]), "--project-name", resolveComposeProjectName()];
92
+ /** Build common prefix: compose -f ... --project-name ... --env-file ... --profile ... */
93
+ function buildComposeArgs(options: { files: string[]; envFiles?: string[]; profiles?: string[] }): string[] {
94
+ const envOverrides = collectEnvOverrides(options.envFiles);
95
+ const args = ["compose", ...options.files.flatMap((f) => ["-f", f]), "--project-name", resolveComposeProjectName(envOverrides)];
93
96
  for (const ef of options.envFiles ?? []) {
94
97
  if (existsSync(ef)) args.push("--env-file", ef);
95
98
  }
99
+ for (const p of options.profiles ?? []) args.push("--profile", p);
96
100
  return args;
97
101
  }
98
102
 
@@ -108,7 +112,7 @@ function collectEnvOverrides(envFiles?: string[]): Record<string, string> {
108
112
  * Must be called before any lifecycle mutation (install/apply/update).
109
113
  */
110
114
  export async function composePreflight(
111
- options: { files: string[]; envFiles?: string[] }
115
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
112
116
  ): Promise<DockerResult> {
113
117
  const args = buildComposeArgs(options);
114
118
  args.push("config", "--quiet");
@@ -119,22 +123,23 @@ export async function composePreflight(
119
123
  * Run compose config preflight validation before any mutation.
120
124
  * Skipped when OP_SKIP_COMPOSE_PREFLIGHT is set (tests, CI).
121
125
  */
122
- async function runPreflight(options: { files: string[]; envFiles?: string[] }): Promise<void> {
126
+ async function runPreflight(options: { files: string[]; envFiles?: string[]; profiles?: string[] }): Promise<void> {
123
127
  if (options.files.length === 0 || process.env.OP_SKIP_COMPOSE_PREFLIGHT) return;
124
128
  const result = await composePreflight(options);
125
129
  if (!result.ok) {
126
- const project = resolveComposeProjectName();
130
+ const project = resolveComposeProjectName(collectEnvOverrides(options.envFiles));
127
131
  const fileArgs = options.files.map((f) => `-f ${f}`).join(" ");
128
132
  const envArgs = (options.envFiles ?? []).map((f) => `--env-file ${f}`).join(" ");
133
+ const profileArgs = (options.profiles ?? []).map((p) => `--profile ${p}`).join(" ");
129
134
  throw new Error(
130
135
  `Compose preflight failed: ${result.stderr}\n` +
131
- `Resolved command: docker compose ${fileArgs} --project-name ${project} ${envArgs} config --quiet`
136
+ `Resolved command: docker compose ${fileArgs} --project-name ${project} ${envArgs} ${profileArgs} config --quiet`
132
137
  );
133
138
  }
134
139
  }
135
140
 
136
141
  export async function composeConfigServices(
137
- options: { files: string[]; envFiles?: string[] }
142
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
138
143
  ): Promise<{ ok: boolean; services: string[] }> {
139
144
  const args = buildComposeArgs(options);
140
145
  args.push("config", "--services");
@@ -163,7 +168,6 @@ export async function composeUp(
163
168
  return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
164
169
  }
165
170
  const args = buildComposeArgs(options);
166
- for (const p of options.profiles ?? []) args.push("--profile", p);
167
171
  args.push("up", "-d");
168
172
  if (options.forceRecreate) args.push("--force-recreate");
169
173
  if (options.removeOrphans) args.push("--remove-orphans");
@@ -187,7 +191,6 @@ export async function composeDown(
187
191
  return { ok: false, stdout: "", stderr: "Compose file not found", code: 1 };
188
192
  }
189
193
  const args = buildComposeArgs(options);
190
- for (const p of options.profiles ?? []) args.push("--profile", p);
191
194
  args.push("down");
192
195
  if (options.removeVolumes) args.push("-v");
193
196
  return run(args, undefined);
@@ -313,7 +316,6 @@ export async function composePullService(
313
316
  ): Promise<DockerResult> {
314
317
  await runPreflight(options);
315
318
  const args = buildComposeArgs(options);
316
- for (const p of options.profiles ?? []) args.push("--profile", p);
317
319
  args.push("pull", service);
318
320
  return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
319
321
  }
@@ -323,7 +325,6 @@ export async function composePull(
323
325
  ): Promise<DockerResult> {
324
326
  await runPreflight(options);
325
327
  const args = buildComposeArgs(options);
326
- for (const p of options.profiles ?? []) args.push("--profile", p);
327
328
  args.push("pull");
328
329
  return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
329
330
  }
@@ -86,25 +86,25 @@ describe("quoteEnvValue quoting strategy (via mergeEnvContent)", () => {
86
86
 
87
87
  describe("mergeEnvContent updates existing keys with special char values", () => {
88
88
  it("updates an existing key to a value with =", () => {
89
- const input = "export ADMIN_TOKEN=old_value\n";
90
- const result = mergeEnvContent(input, { ADMIN_TOKEN: "new=value=here" });
89
+ const input = "export TEST_VALUE=old_value\n";
90
+ const result = mergeEnvContent(input, { TEST_VALUE: "new=value=here" });
91
91
  const parsed = parseEnvContent(result);
92
- expect(parsed.ADMIN_TOKEN).toBe("new=value=here");
92
+ expect(parsed.TEST_VALUE).toBe("new=value=here");
93
93
  });
94
94
 
95
95
  it("updates an existing key to a value with $", () => {
96
- const input = "export ADMIN_TOKEN=old_value\n";
97
- const result = mergeEnvContent(input, { ADMIN_TOKEN: "tok$en" });
96
+ const input = "export TEST_VALUE=old_value\n";
97
+ const result = mergeEnvContent(input, { TEST_VALUE: "tok$en" });
98
98
  const parsed = parseEnvContent(result);
99
- expect(parsed.ADMIN_TOKEN).toBe("tok$en");
99
+ expect(parsed.TEST_VALUE).toBe("tok$en");
100
100
  });
101
101
 
102
102
  it("preserves export prefix when updating with special chars", () => {
103
- const input = "export ADMIN_TOKEN=old_value\n";
104
- const result = mergeEnvContent(input, { ADMIN_TOKEN: "new#value" });
105
- expect(result).toMatch(/^export ADMIN_TOKEN=/m);
103
+ const input = "export TEST_VALUE=old_value\n";
104
+ const result = mergeEnvContent(input, { TEST_VALUE: "new#value" });
105
+ expect(result).toMatch(/^export TEST_VALUE=/m);
106
106
  const parsed = parseEnvContent(result);
107
- expect(parsed.ADMIN_TOKEN).toBe("new#value");
107
+ expect(parsed.TEST_VALUE).toBe("new#value");
108
108
  });
109
109
  });
110
110
 
@@ -26,7 +26,7 @@ export function expandEnvVars(input: string, vars: Record<string, string>): stri
26
26
  return input.replace(/\$\{([^}:]+)(?::-([^}]*))?\}/g, (_, name, def) => vars[name] ?? def ?? '');
27
27
  }
28
28
 
29
- function quoteEnvValue(value: string): string {
29
+ export function quoteEnvValue(value: string): string {
30
30
  if (value.length === 0) return '';
31
31
  const needsQuoting = /[#"'\\\n\r$]/.test(value) || value !== value.trim();
32
32
  if (!needsQuoting) return value;