@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18

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 (66) 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 +69 -30
  11. package/src/control-plane/compose-args.ts +62 -8
  12. package/src/control-plane/config-persistence.ts +102 -136
  13. package/src/control-plane/core-assets.ts +45 -60
  14. package/src/control-plane/defaults.ts +16 -0
  15. package/src/control-plane/docker.ts +15 -14
  16. package/src/control-plane/env.test.ts +10 -10
  17. package/src/control-plane/env.ts +16 -1
  18. package/src/control-plane/extends-support.test.ts +8 -8
  19. package/src/control-plane/fs-atomic.ts +15 -0
  20. package/src/control-plane/home.ts +34 -46
  21. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  22. package/src/control-plane/host-akm-sharing.ts +129 -0
  23. package/src/control-plane/host-opencode.test.ts +82 -10
  24. package/src/control-plane/host-opencode.ts +42 -13
  25. package/src/control-plane/install-edge-cases.test.ts +100 -136
  26. package/src/control-plane/install-lock.ts +7 -7
  27. package/src/control-plane/lifecycle.ts +45 -40
  28. package/src/control-plane/markdown-task.ts +30 -50
  29. package/src/control-plane/migrations.test.ts +272 -0
  30. package/src/control-plane/migrations.ts +423 -0
  31. package/src/control-plane/opencode-client.ts +1 -1
  32. package/src/control-plane/paths.ts +61 -46
  33. package/src/control-plane/profile-ids.ts +21 -0
  34. package/src/control-plane/provider-models.ts +3 -3
  35. package/src/control-plane/registry.test.ts +107 -90
  36. package/src/control-plane/registry.ts +301 -110
  37. package/src/control-plane/rollback.ts +8 -38
  38. package/src/control-plane/scheduler.ts +10 -7
  39. package/src/control-plane/secret-audit.test.ts +159 -0
  40. package/src/control-plane/secret-audit.ts +255 -0
  41. package/src/control-plane/secret-mappings.ts +2 -2
  42. package/src/control-plane/secrets-files.test.ts +99 -0
  43. package/src/control-plane/secrets-files.ts +113 -0
  44. package/src/control-plane/secrets.ts +113 -86
  45. package/src/control-plane/setup-config.schema.json +1 -1
  46. package/src/control-plane/setup-status.ts +6 -11
  47. package/src/control-plane/setup.test.ts +137 -61
  48. package/src/control-plane/setup.ts +82 -63
  49. package/src/control-plane/skeleton-guardrail.test.ts +66 -56
  50. package/src/control-plane/spec-to-env.test.ts +63 -26
  51. package/src/control-plane/spec-to-env.ts +51 -14
  52. package/src/control-plane/task-files.test.ts +45 -0
  53. package/src/control-plane/task-files.ts +51 -0
  54. package/src/control-plane/types.ts +2 -4
  55. package/src/control-plane/ui-assets.test.ts +333 -0
  56. package/src/control-plane/ui-assets.ts +290 -142
  57. package/src/control-plane/validate.ts +13 -15
  58. package/src/index.ts +96 -26
  59. package/src/control-plane/akm-vault.test.ts +0 -105
  60. package/src/control-plane/akm-vault.ts +0 -311
  61. package/src/control-plane/core-assets.test.ts +0 -104
  62. package/src/control-plane/migrate-0110.test.ts +0 -177
  63. package/src/control-plane/migrate-0110.ts +0 -99
  64. package/src/control-plane/registry-components.test.ts +0 -391
  65. package/src/control-plane/stack-spec.test.ts +0 -94
  66. package/src/control-plane/stack-spec.ts +0 -67
@@ -3,58 +3,54 @@
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 "./defaults.js";
18
+ import { CURRENT_LAYOUT_VERSION } from "./migrations.js";
16
19
 
17
20
  import {
18
21
  readCoreCompose,
22
+ readBundledStackAsset,
19
23
  } from "./core-assets.js";
20
24
  export { sha256, randomHex } from "./crypto.js";
21
25
  import { sha256, randomHex } from "./crypto.js";
22
26
 
23
- const DEFAULT_IMAGE_TAG = process.env.OP_IMAGE_TAG ?? "latest";
27
+ const DEFAULT_IMAGE_TAG = "latest";
24
28
 
25
29
  // ── Env File Management ──────────────────────────────────────────────
26
30
 
27
31
  /**
28
32
  * Return the env files used for docker compose --env-file args.
29
- * These are the live vault env files.
30
33
  *
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).
34
+ * Only `knowledge/env/stack.env` (non-secret system config). Secret values
35
+ * live in `knowledge/secrets/<ENV_KEY>` and are granted to services as Compose
36
+ * file secrets. The user env (`knowledge/env/user.env`) is NOT a compose
37
+ * env_file it is sourced by the assistant entrypoint at container startup.
40
38
  */
41
39
  export function buildEnvFiles(state: ControlPlaneState): string[] {
42
40
  return [
43
- `${state.stackDir}/stack.env`,
44
- `${state.stackDir}/guardian.env`,
41
+ `${state.stashDir}/env/stack.env`,
45
42
  ].filter(existsSync);
46
43
  }
47
44
 
48
45
  /**
49
- * Write system-managed values to config/stack/stack.env.
46
+ * Write system-managed values to knowledge/env/stack.env.
50
47
  *
51
- * Channel HMAC secrets are NOT written here — they belong in guardian.env.
52
- * Use writeChannelSecrets() for channel secrets.
48
+ * Secret-like keys are NOT written here — they belong in knowledge/secrets/.
49
+ * Use ensureChannelSecret() for channel secrets.
53
50
  */
54
51
  export function writeSystemEnv(state: ControlPlaneState): void {
55
- mkdirSync(state.stackDir, { recursive: true });
56
-
57
- const systemEnvPath = `${state.stackDir}/stack.env`;
52
+ const systemEnvPath = `${state.stashDir}/env/stack.env`;
53
+ mkdirSync(`${state.stashDir}/env`, { recursive: true, mode: 0o700 });
58
54
 
59
55
  let base = "";
60
56
  if (existsSync(systemEnvPath)) {
@@ -63,11 +59,12 @@ export function writeSystemEnv(state: ControlPlaneState): void {
63
59
  base = generateFallbackSystemEnv(state);
64
60
  }
65
61
 
66
- // Preserve existing OP_SETUP_COMPLETE=true
67
- const alreadyComplete = /^OP_SETUP_COMPLETE=true$/mi.test(base);
68
-
62
+ // Preserve the existing OP_SETUP_COMPLETE flag as-is.
63
+ // Only the wizard completion path (buildSystemSecretsFromSetup) writes "true".
64
+ // Defaulting to "false" here ensures a fresh install always shows the wizard.
65
+ const parsed = parseEnvFile(systemEnvPath);
69
66
  const adminManaged: Record<string, string> = {
70
- OP_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
67
+ OP_SETUP_COMPLETE: parsed.OP_SETUP_COMPLETE === "true" ? "true" : "false",
71
68
  };
72
69
 
73
70
  // Backfill OP_UID/OP_GID when the existing stack.env was written by an
@@ -76,13 +73,20 @@ export function writeSystemEnv(state: ControlPlaneState): void {
76
73
  // missing or zero — an operator who manually set OP_UID=2000 (e.g.
77
74
  // because they're running on a host with a non-1000 service account)
78
75
  // must not be silently changed.
79
- const parsed = parseEnvFile(systemEnvPath);
80
76
  const ids = resolveOperatorIds(state.homeDir);
81
77
  if (ids) {
82
78
  if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
83
79
  if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
84
80
  }
85
81
 
82
+ // Backfill OP_HOME when missing — compose files reference ${OP_HOME}
83
+ // for all volume mounts. Without this, Docker Compose defaults to blank.
84
+ if (!parsed.OP_HOME) adminManaged.OP_HOME = state.homeDir;
85
+
86
+ base = stripSecretLikeEnvKeys(base);
87
+ assertNoSecretLikeStackEnvKeys(parseEnvContent(base));
88
+ assertNoSecretLikeStackEnvKeys(adminManaged);
89
+
86
90
  const content = mergeEnvContent(base, adminManaged, {
87
91
  sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
88
92
  });
@@ -91,6 +95,19 @@ export function writeSystemEnv(state: ControlPlaneState): void {
91
95
  chmodSync(systemEnvPath, 0o600);
92
96
  }
93
97
 
98
+ function stripSecretLikeEnvKeys(content: string): string {
99
+ return content
100
+ .split('\n')
101
+ .filter((line) => {
102
+ let trimmed = line.trim();
103
+ if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7).trimStart();
104
+ const eq = trimmed.indexOf('=');
105
+ if (eq <= 0) return true;
106
+ return !isSecretLikeStackEnvKey(trimmed.slice(0, eq).trim());
107
+ })
108
+ .join('\n');
109
+ }
110
+
94
111
  function generateFallbackSystemEnv(state: ControlPlaneState): string {
95
112
  // Operator UID/GID — auto-detect from OP_HOME owner (or process UID).
96
113
  // Skipped on Windows where containers run in WSL2 and OP_UID has no
@@ -104,12 +121,6 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
104
121
  "# OpenPalm — System Configuration (managed by CLI/admin)",
105
122
  "# Auto-generated fallback.",
106
123
  "",
107
- "# ── Authentication ──────────────────────────────────────────────────",
108
- `OP_UI_LOGIN_PASSWORD=\${OP_UI_LOGIN_PASSWORD}`,
109
- "",
110
- "# ── Service Auth ─────────────────────────────────────────────────────",
111
- "OP_OPENCODE_PASSWORD=",
112
- "",
113
124
  "# ── Paths ──────────────────────────────────────────────────────────",
114
125
  `OP_HOME=${state.homeDir}`,
115
126
  ...idLines,
@@ -118,12 +129,17 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
118
129
  `OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
119
130
  `OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
120
131
  "",
132
+ "# ── Layout (on-disk schema version; managed by the migration harness) ──",
133
+ `OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`,
134
+ "",
135
+ "# ── Enabled addons (comma-separated; managed via the Add-ons UI / CLI) ──",
136
+ "OP_ENABLED_ADDONS=",
137
+ "",
121
138
  "# ── Ports (38XX range) ──────────────────────────────────────────────",
122
139
  "# Guardian is network-only (no host port) — channels reach it via",
123
140
  "# 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`,
141
+ `OP_ASSISTANT_PORT=${SPEC_DEFAULTS.ports.assistant}`,
142
+ `OP_HOST_UI_PORT=${SPEC_DEFAULTS.ports.hostUi}`,
127
143
  ""
128
144
  ].join("\n");
129
145
  }
@@ -131,37 +147,25 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
131
147
  // ── Stack Overlay Discovery ────────────────────────────────────────────
132
148
 
133
149
  /**
134
- * Discover compose overlays from the stack directory.
135
- * Returns full paths: [stack/core.compose.yml, stack/addons/{name}/compose.yml].
150
+ * Discover active compose overlays.
151
+ * Returns the fixed compose stack: core, services, channels, and custom.
152
+ * First-party services are profile-gated inside services.compose.yml and
153
+ * channels.compose.yml.
154
+ *
155
+ * Host AKM sharing is NOT a compose overlay: the assistant always mounts
156
+ * `/host-stash` (core.compose.yml, with an empty-dir fallback), and "sharing"
157
+ * is purely a writable secondary source entry in config/akm/config.json. No
158
+ * conditional overlay file is involved.
136
159
  */
137
- export function discoverStackOverlays(stackDir: string): string[] {
160
+ export function discoverStackOverlays(stackDir: string, _homeDir?: string): string[] {
138
161
  const files: string[] = [];
139
162
 
140
163
  const coreYml = `${stackDir}/core.compose.yml`;
141
164
  if (existsSync(coreYml)) files.push(coreYml);
142
165
 
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
- }
166
+ for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
167
+ const composePath = `${stackDir}/${name}`;
168
+ if (existsSync(composePath)) files.push(composePath);
165
169
  }
166
170
 
167
171
  return files;
@@ -192,79 +196,38 @@ export function buildRuntimeFileMeta(artifacts: {
192
196
  }
193
197
 
194
198
  // ── 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
199
 
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`));
200
+ export function channelSecretName(addon: string): string {
201
+ return `channel_${addon.replace(/-/g, '_')}_secret`;
214
202
  }
215
203
 
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);
204
+ export function ensureChannelSecret(stackDir: string, addon: string): string {
205
+ return ensureSecret(stackDir, channelSecretName(addon), () => randomHex(16));
240
206
  }
241
207
 
242
208
  // ── Volume Mount Targets ───────────────────────────────────────────────
243
209
 
244
210
  /**
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.
211
+ * Parse enabled compose files and pre-create host-side volume mount
212
+ * targets under OP_HOME as the current user. This prevents Docker from
213
+ * creating them as root-owned, which causes EACCES inside non-root
214
+ * containers.
256
215
  *
257
216
  * Only mount sources under `state.homeDir` are touched; external paths
258
217
  * (e.g. `/var/run/docker.sock`) are left alone.
218
+ *
219
+ * The file-vs-directory distinction is best-effort and only applies to
220
+ * explicit OP_HOME paths.
259
221
  */
260
222
  export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
261
- const composeFiles = discoverStackOverlays(state.stackDir);
223
+ const composeFiles = discoverStackOverlays(state.stackDir, state.homeDir);
262
224
  if (composeFiles.length === 0) return;
263
225
 
264
226
  const envVars: Record<string, string> = {
265
227
  ...(process.env as Record<string, string>),
266
- ...parseEnvFile(`${state.stackDir}/stack.env`),
228
+ ...parseEnvFile(`${state.stashDir}/env/stack.env`),
267
229
  };
230
+ const homeRoot = resolvePath(state.homeDir);
268
231
 
269
232
  for (const file of composeFiles) {
270
233
  let doc: Record<string, unknown>;
@@ -291,18 +254,20 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
291
254
 
292
255
  const hostPath = expandEnvVars(rawSource, envVars);
293
256
  if (!hostPath || !hostPath.startsWith('/')) continue;
294
- if (existsSync(hostPath)) continue;
257
+ const resolvedHostPath = resolvePath(hostPath);
258
+ if (!resolvedHostPath.startsWith(`${homeRoot}/`) && resolvedHostPath !== homeRoot) continue;
259
+ if (existsSync(resolvedHostPath)) continue;
295
260
 
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() ?? '';
261
+ // Only create mounts under OP_HOME. For now, treat existing explicit
262
+ // file paths as files and directory paths as directories.
263
+ const basename = resolvedHostPath.split('/').pop() ?? '';
299
264
  const isFile = basename.includes('.');
300
265
 
301
266
  if (isFile) {
302
- mkdirSync(dirname(hostPath), { recursive: true });
303
- writeFileSync(hostPath, '');
267
+ mkdirSync(dirname(resolvedHostPath), { recursive: true });
268
+ writeFileSync(resolvedHostPath, '');
304
269
  } else {
305
- mkdirSync(hostPath, { recursive: true });
270
+ mkdirSync(resolvedHostPath, { recursive: true });
306
271
  }
307
272
  }
308
273
  }
@@ -322,24 +287,25 @@ export function writeRuntimeFiles(
322
287
  writeFileSync(composePath, state.artifacts.compose);
323
288
  }
324
289
 
325
- // Load persisted channel HMAC secrets from guardian.env,
326
- // then generate new ones for new channel addons.
327
- const channelSecrets = readChannelSecrets(state.stackDir);
290
+ for (const name of ['services.compose.yml', 'channels.compose.yml', 'custom.compose.yml']) {
291
+ const path = `${state.stackDir}/${name}`;
292
+ if (!existsSync(path)) writeFileSync(path, readBundledStackAsset(name));
293
+ }
294
+
328
295
  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);
296
+ if (['api', 'chat', 'discord', 'slack'].includes(addon)) {
297
+ for (const channel of ['api', 'chat', 'discord', 'slack']) {
298
+ ensureChannelSecret(state.stackDir, channel);
299
+ }
300
+ break;
332
301
  }
333
302
  }
334
303
 
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)
304
+ // Write stack.env (no secrets those live in knowledge/secrets/)
339
305
  writeSystemEnv(state);
340
306
 
341
307
  // Ensure state directory exists
342
- mkdirSync(state.stateDir, { recursive: true });
308
+ mkdirSync(state.dataDir, { recursive: true });
343
309
 
344
310
  state.artifactMeta = buildRuntimeFileMeta(state.artifacts);
345
311
  }
@@ -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,90 +32,69 @@ 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";
84
58
 
85
- function resolveAssetVersion(): string {
86
- if (process.env.OP_ASSET_VERSION) return process.env.OP_ASSET_VERSION;
87
- try {
88
- const pkgJson = JSON.parse(
89
- readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf-8")
59
+ // The version to download assets for is ALWAYS passed in by the caller (the
60
+ // upgrade flow resolves the canonical platform tag — the newest published
61
+ // `openpalm/assistant` Docker tag, e.g. "v0.11.0-rc.6" — and threads it here).
62
+ // This module intentionally does NOT resolve the version itself: no env var, no
63
+ // `import.meta.url` package.json read (which breaks when the lib is bundled into
64
+ // the UI/electron), and never a silent "main" fallback (main's asset layout can
65
+ // differ from a released install). Bundler-agnostic by construction.
66
+
67
+ function normalizeAssetRef(version: string): string {
68
+ const v = version.trim();
69
+ if (!v) {
70
+ throw new Error(
71
+ "Cannot download OpenPalm stack assets: no version provided. " +
72
+ "The caller must pass the target release tag (e.g. \"v0.11.0-rc.6\")."
90
73
  );
91
- return `v${pkgJson.version}`;
92
- } catch {
93
- return "main";
94
74
  }
75
+ // GitHub release/raw refs are `vX.Y.Z`; accept a bare semver and add the `v`.
76
+ return /^\d/.test(v) ? `v${v}` : v;
95
77
  }
96
- const VERSION = resolveAssetVersion();
97
78
 
98
79
  // Persona files (openpalm.md, system.md), stash seeds, and user-editable config
99
80
  // files are intentionally NOT in this list. They are seeded once (never
100
81
  // overwritten) via seedOpenPalmDir (skipExisting) or SEEDED_ASSETS below.
101
82
  const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
102
83
  { relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
84
+ { relPath: "config/stack/services.compose.yml", githubFilename: ".openpalm/config/stack/services.compose.yml" },
85
+ { relPath: "config/stack/channels.compose.yml", githubFilename: ".openpalm/config/stack/channels.compose.yml" },
103
86
  ];
104
87
 
105
88
  // Seeded once — written only when the file does not exist yet.
106
89
  // User edits always win; upgrade never touches these files.
107
90
  const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
108
91
  { relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
92
+ { relPath: "config/stack/custom.compose.yml", githubFilename: ".openpalm/config/stack/custom.compose.yml" },
109
93
  ];
110
94
 
111
- async function downloadAsset(filename: string): Promise<string> {
112
- const releaseUrl = `https://github.com/${REPO}/releases/download/${VERSION}/${filename}`;
113
- const rawUrl = `https://raw.githubusercontent.com/${REPO}/${VERSION}/${filename}`;
95
+ async function downloadAsset(filename: string, version: string): Promise<string> {
96
+ const releaseUrl = `https://github.com/${REPO}/releases/download/${version}/${filename}`;
97
+ const rawUrl = `https://raw.githubusercontent.com/${REPO}/${version}/${filename}`;
114
98
 
115
99
  for (const url of [releaseUrl, rawUrl]) {
116
100
  try {
@@ -120,19 +104,20 @@ async function downloadAsset(filename: string): Promise<string> {
120
104
  // try next URL
121
105
  }
122
106
  }
123
- throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${VERSION}")`);
107
+ throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${version}")`);
124
108
  }
125
109
 
126
- export async function refreshCoreAssets(): Promise<{
110
+ export async function refreshCoreAssets(version: string): Promise<{
127
111
  backupDir: string | null;
128
112
  updated: string[];
129
113
  }> {
114
+ const ref = normalizeAssetRef(version);
130
115
  const homeDir = resolveOpenPalmHome();
131
116
  const updated: string[] = [];
132
117
  let backupDir: string | null = null;
133
118
 
134
119
  for (const asset of MANAGED_ASSETS) {
135
- const freshContent = await downloadAsset(asset.githubFilename);
120
+ const freshContent = await downloadAsset(asset.githubFilename, ref);
136
121
  const targetPath = join(homeDir, asset.relPath);
137
122
 
138
123
  if (existsSync(targetPath)) {
@@ -158,7 +143,7 @@ export async function refreshCoreAssets(): Promise<{
158
143
  for (const asset of SEEDED_ASSETS) {
159
144
  const targetPath = join(homeDir, asset.relPath);
160
145
  if (existsSync(targetPath)) continue;
161
- const freshContent = await downloadAsset(asset.githubFilename);
146
+ const freshContent = await downloadAsset(asset.githubFilename, ref);
162
147
  mkdirSync(dirname(targetPath), { recursive: true });
163
148
  writeFileSync(targetPath, freshContent);
164
149
  updated.push(asset.relPath);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Stack defaults (ports + image). Formerly in stack-spec.ts; kept after the
3
+ * stack.yml removal because these are the canonical fallback values used when a
4
+ * key is absent from stack.env.
5
+ */
6
+ export const SPEC_DEFAULTS = {
7
+ ports: {
8
+ assistant: 3800,
9
+ hostUi: 3880,
10
+ assistantSsh: 2222,
11
+ },
12
+ image: {
13
+ namespace: "openpalm",
14
+ tag: "latest",
15
+ },
16
+ } as const;