@openpalm/lib 0.11.0-rc.3 → 0.11.0

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.
@@ -0,0 +1,423 @@
1
+ /**
2
+ * On-disk layout migration harness.
3
+ *
4
+ * `stack.env` carries `OP_LAYOUT_VERSION` — the authoritative marker of the
5
+ * home-directory layout schema. `ensureMigrated()` runs BEFORE any state
6
+ * validation (createState/resolveRuntimeFiles assume the current layout), so it
7
+ * must resolve its own paths rather than take a built ControlPlaneState.
8
+ *
9
+ * Contract (the fail-safe invariant):
10
+ * - Fast path: if the layout is already current, return immediately — no lock,
11
+ * no backup, zero overhead on routine updates.
12
+ * - Otherwise: acquire the install lock, take a FULL-HOME backup first, and
13
+ * abort the whole upgrade if the backup fails (never migrate without a
14
+ * verified safety copy).
15
+ * - Migrations are COPY-ONLY / additive (never delete the old layout), so a
16
+ * mid-run failure leaves the home fully recoverable.
17
+ * - The OP_LAYOUT_VERSION bump is the LAST step (the commit point); a crash
18
+ * before it just re-runs next time (idempotent).
19
+ * - On failure, throw MigrationError carrying the backup path + recovery
20
+ * guidance for the CLI/UI to surface.
21
+ */
22
+ import {
23
+ existsSync, mkdirSync, readFileSync, writeFileSync,
24
+ readdirSync, statSync, chmodSync, cpSync,
25
+ } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { parse as yamlParse } from "yaml";
28
+ import {
29
+ resolveOpenPalmHome, resolveDataDir, resolveStackDir, resolveStashDir, resolveConfigDir,
30
+ } from "./home.js";
31
+ import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
32
+ import { backupOpenPalmHome } from "./backup.js";
33
+ import { upsertEnvValue } from "./env.js";
34
+
35
+ export const LAYOUT_VERSION_KEY = "OP_LAYOUT_VERSION";
36
+ /** Bump when the on-disk layout changes and add a Migration to MIGRATIONS. */
37
+ export const CURRENT_LAYOUT_VERSION = 1;
38
+
39
+ const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
40
+
41
+ export interface MigrationReport {
42
+ migrated: boolean;
43
+ from: number;
44
+ to: number;
45
+ applied: string[];
46
+ backupDir: string | null;
47
+ notes: string[];
48
+ }
49
+
50
+ export class MigrationError extends Error {
51
+ constructor(
52
+ message: string,
53
+ readonly guidance: string,
54
+ readonly backupDir: string | null,
55
+ ) {
56
+ super(message);
57
+ this.name = "MigrationError";
58
+ }
59
+ }
60
+
61
+ interface MigrationCtx {
62
+ homeDir: string;
63
+ dataDir: string;
64
+ stackDir: string;
65
+ stashDir: string;
66
+ configDir: string;
67
+ dryRun: boolean;
68
+ log: (m: string) => void;
69
+ notes: string[];
70
+ }
71
+
72
+ interface Migration {
73
+ from: number;
74
+ to: number;
75
+ describe: string;
76
+ apply(ctx: MigrationCtx): void;
77
+ verify(ctx: MigrationCtx): void;
78
+ }
79
+
80
+ // ── Layout-version read/write (stack.env is the single source of truth) ───────
81
+
82
+ function stackEnvFile(stashDir: string): string {
83
+ return join(stashDir, "env", "stack.env");
84
+ }
85
+
86
+ /**
87
+ * Resolve the current on-disk layout version.
88
+ * - explicit OP_LAYOUT_VERSION in knowledge/env/stack.env wins
89
+ * - else a top-level `vault/` directory ⇒ 0.10.x layout (version 0)
90
+ * - else assume the current layout (a pre-marker 0.11 install) — caller stamps it
91
+ */
92
+ function readLayoutVersion(ctx: { homeDir: string; stashDir: string }): number {
93
+ const envPath = stackEnvFile(ctx.stashDir);
94
+ if (existsSync(envPath)) {
95
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
96
+ const m = line.match(/^OP_LAYOUT_VERSION=(\d+)\s*$/);
97
+ if (m) return Number(m[1]);
98
+ }
99
+ }
100
+ if (existsSync(join(ctx.homeDir, "vault"))) return 0;
101
+ return CURRENT_LAYOUT_VERSION;
102
+ }
103
+
104
+ function stampLayoutVersion(stashDir: string, version: number): void {
105
+ const envPath = stackEnvFile(stashDir);
106
+ if (!existsSync(envPath)) return; // nothing to stamp; not a usable install
107
+ const next = upsertEnvValue(readFileSync(envPath, "utf-8"), LAYOUT_VERSION_KEY, String(version));
108
+ writeFileSync(envPath, next);
109
+ }
110
+
111
+ // ── helpers (non-destructive: copy, never delete the source) ──────────────────
112
+
113
+ function ensureDir(ctx: MigrationCtx, dir: string): void {
114
+ if (ctx.dryRun) { ctx.log(`[dry-run] mkdir ${rel(ctx, dir)}`); return; }
115
+ mkdirSync(dir, { recursive: true });
116
+ try { chmodSync(dir, 0o700); } catch { /* Windows / best-effort */ }
117
+ }
118
+
119
+ function rel(ctx: MigrationCtx, p: string): string {
120
+ return p.startsWith(ctx.homeDir) ? p.slice(ctx.homeDir.length + 1) : p;
121
+ }
122
+
123
+ /** Copy src→dest; skip if dest exists; chmod 600. Returns true if copied. */
124
+ function copyIfAbsent(ctx: MigrationCtx, src: string, dest: string): boolean {
125
+ if (!existsSync(src)) return false;
126
+ if (existsSync(dest)) { ctx.log(`skip (exists): ${rel(ctx, dest)}`); return false; }
127
+ if (ctx.dryRun) { ctx.log(`[dry-run] copy ${rel(ctx, src)} -> ${rel(ctx, dest)}`); return true; }
128
+ cpSync(src, dest, { recursive: true });
129
+ try { if (statSync(dest).isFile()) chmodSync(dest, 0o600); } catch { /* best-effort */ }
130
+ ctx.log(`copied: ${rel(ctx, src)} -> ${rel(ctx, dest)}`);
131
+ return true;
132
+ }
133
+
134
+ function writeFile600(ctx: MigrationCtx, path: string, content: string): void {
135
+ if (ctx.dryRun) { ctx.log(`[dry-run] write ${rel(ctx, path)}`); return; }
136
+ writeFileSync(path, content);
137
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
138
+ }
139
+
140
+ // ── Migration 0 → 1: 0.10.x `vault/` layout → 0.11.0 knowledge/ layout ────────
141
+
142
+ const SECRET_KEY_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD)$/;
143
+ const CONFIG_KEY_RE = /^(OP_CAP_|SYSTEM_LLM_|EMBEDDING_)/;
144
+
145
+ function migrate010to011(ctx: MigrationCtx): void {
146
+ const vault = join(ctx.homeDir, "vault");
147
+ const newEnv = join(ctx.stashDir, "env");
148
+ const newSecrets = join(ctx.stashDir, "secrets");
149
+ ensureDir(ctx, newEnv);
150
+ ensureDir(ctx, newSecrets);
151
+
152
+ // user.env → knowledge/env/user.env
153
+ copyIfAbsent(ctx, join(vault, "user", "user.env"), join(newEnv, "user.env"));
154
+
155
+ // stack.env transform → knowledge/env/stack.env
156
+ const srcStack = join(vault, "stack", "stack.env");
157
+ const destStack = join(newEnv, "stack.env");
158
+ if (existsSync(srcStack) && !existsSync(destStack)) {
159
+ const kept: string[] = [];
160
+ const removed: string[] = [];
161
+ for (const line of readFileSync(srcStack, "utf-8").split("\n")) {
162
+ if (line === "" || line.startsWith("#")) { kept.push(line); continue; }
163
+ const eq = line.indexOf("=");
164
+ const key = eq >= 0 ? line.slice(0, eq) : line;
165
+ const val = eq >= 0 ? line.slice(eq + 1) : "";
166
+ if (key === "OP_UI_LOGIN_PASSWORD") {
167
+ writeFile600(ctx, join(newSecrets, "op_ui_login_password"), val + "\n");
168
+ ctx.log("extracted OP_UI_LOGIN_PASSWORD -> knowledge/secrets/op_ui_login_password");
169
+ } else if (key === "OP_ADMIN_PORT") {
170
+ kept.push(`OP_HOST_UI_PORT=${val}`);
171
+ ctx.log("renamed OP_ADMIN_PORT -> OP_HOST_UI_PORT");
172
+ } else if (key === "OP_ADMIN_OPENCODE_PORT" || key === "OP_GUARDIAN_PORT") {
173
+ ctx.log(`dropped removed var: ${key}`);
174
+ } else if (key.startsWith("TTS_") || key.startsWith("STT_")) {
175
+ kept.push(`OP_${key}=${val}`);
176
+ ctx.log(`renamed ${key} -> OP_${key}`);
177
+ } else if (CONFIG_KEY_RE.test(key) || SECRET_KEY_RE.test(key)) {
178
+ removed.push(line);
179
+ ctx.log(`quarantined: ${key}`);
180
+ } else {
181
+ kept.push(line);
182
+ }
183
+ }
184
+ writeFile600(ctx, destStack, kept.join("\n") + "\n");
185
+ if (removed.length > 0) {
186
+ writeFile600(ctx, join(newEnv, "stack.env.removed-secrets.bak"), removed.join("\n") + "\n");
187
+ ctx.notes.push(
188
+ "Secret/capability keys were removed from stack.env (saved to knowledge/env/stack.env.removed-secrets.bak) — re-enter provider keys via the Connections tab and LLM config via config/akm/config.json; do not put them back in stack.env.",
189
+ );
190
+ }
191
+ }
192
+
193
+ // provider creds (best-effort) + service secrets
194
+ if (copyIfAbsent(ctx, join(vault, "stack", "auth.json"), join(newSecrets, "auth.json"))) {
195
+ ctx.notes.push("Copied auth.json best-effort — verify providers in the Connections tab and re-add any that are missing (the OpenCode auth format changed).");
196
+ }
197
+ const servicesDir = join(vault, "stack", "services");
198
+ if (existsSync(servicesDir)) {
199
+ for (const name of readdirSync(servicesDir)) {
200
+ copyIfAbsent(ctx, join(servicesDir, name), join(newSecrets, name));
201
+ }
202
+ }
203
+
204
+ // channel HMAC secrets: split CHANNEL_<NAME>_SECRET into per-secret files
205
+ const guardianEnv = join(vault, "stack", "guardian.env");
206
+ if (existsSync(guardianEnv)) {
207
+ for (const line of readFileSync(guardianEnv, "utf-8").split("\n")) {
208
+ const m = line.match(/^CHANNEL_(.+)_SECRET=(.*)$/);
209
+ if (!m) continue;
210
+ const name = m[1].toLowerCase();
211
+ if (!ADDON_NAME_RE.test(name)) continue;
212
+ const dest = join(newSecrets, `channel_${name}_secret`);
213
+ if (existsSync(dest)) { ctx.log(`skip (exists): knowledge/secrets/channel_${name}_secret`); continue; }
214
+ writeFile600(ctx, dest, m[2] + "\n");
215
+ ctx.log(`channel secret: ${m[1]} -> knowledge/secrets/channel_${name}_secret`);
216
+ }
217
+ }
218
+
219
+ // user credential files mounted into the assistant at /etc/openpalm
220
+ for (const n of ["apprise.yaml", "apprise.conf", "gcloud-credentials.json"]) {
221
+ copyIfAbsent(ctx, join(vault, "user", n), join(newSecrets, n));
222
+ }
223
+ for (const d of [".gws", ".gcloud", ".mgc"]) {
224
+ copyIfAbsent(ctx, join(vault, "user", d), join(newSecrets, d));
225
+ }
226
+
227
+ // Leave a README in the retained legacy vault/ explaining that it is now a
228
+ // recovery copy and how to remove it safely once 0.11.x is confirmed working.
229
+ writeVaultReadme(ctx, vault);
230
+
231
+ // Addon enablement: stack.yml is removed in 0.11.0. Convert any addons[] from
232
+ // a legacy stack.yml (config/stack.yml or config/stack/stack.yml) into
233
+ // OP_ENABLED_ADDONS in stack.env. Do NOT create stack.yml.
234
+ const addons = readLegacyStackYmlAddons(ctx);
235
+ if (addons.length > 0) {
236
+ const envPath = stackEnvFile(ctx.stashDir);
237
+ if (ctx.dryRun) {
238
+ ctx.log(`[dry-run] set OP_ENABLED_ADDONS=${addons.join(",")}`);
239
+ } else if (existsSync(envPath)) {
240
+ writeFile600(ctx, envPath, upsertEnvValue(readFileSync(envPath, "utf-8"), "OP_ENABLED_ADDONS", addons.join(",")));
241
+ ctx.log(`set OP_ENABLED_ADDONS=${addons.join(",")}`);
242
+ }
243
+ }
244
+ }
245
+
246
+ const VAULT_README = `# This \`vault/\` directory is from OpenPalm 0.10.x — it is now a RECOVERY COPY
247
+
248
+ OpenPalm 0.11.0 changed the on-disk layout. The upgrade **copied** everything out
249
+ of this \`vault/\` directory into the new locations and left these originals here,
250
+ untouched, as a safety net:
251
+
252
+ - \`vault/user/user.env\` → \`knowledge/env/user.env\`
253
+ - \`vault/stack/stack.env\` → \`knowledge/env/stack.env\` (transformed)
254
+ - \`vault/stack/guardian.env\` → \`knowledge/secrets/channel_<name>_secret\`
255
+ - \`vault/stack/services/*\` → \`knowledge/secrets/\`
256
+ - \`vault/stack/auth.json\` → \`knowledge/secrets/auth.json\` (best-effort)
257
+ - other user credential files → \`knowledge/secrets/\`
258
+
259
+ A full backup of your home was also taken under \`data/backups/\` before migrating.
260
+
261
+ Nothing in 0.11.x reads this directory anymore. You can delete it once you have
262
+ confirmed the new version works.
263
+
264
+ ## How to remove it safely
265
+
266
+ 1. Confirm 0.11.x is healthy: the stack starts (\`openpalm status\`), you can sign
267
+ in to the UI, your providers are connected (Connections tab), and your
268
+ channels still work.
269
+ 2. Spot-check that your data is in the new layout:
270
+ - \`knowledge/env/stack.env\` and \`knowledge/env/user.env\` look right
271
+ - your secrets are under \`knowledge/secrets/\` (login password, channel
272
+ secrets, \`auth.json\`, etc.)
273
+ - if \`knowledge/env/stack.env.removed-secrets.bak\` exists, re-enter those
274
+ provider keys (Connections) and LLM config (\`config/akm/config.json\`) — do
275
+ not put secrets back into \`stack.env\`.
276
+ 3. Only then remove this directory. Prefer your OS trash (reversible):
277
+ - Linux: \`gio trash ~/.openpalm/vault\` (or \`trash-put ~/.openpalm/vault\`)
278
+ - macOS: \`trash ~/.openpalm/vault\`
279
+ - Windows: delete \`%USERPROFILE%\\.openpalm\\vault\` (sends to Recycle Bin)
280
+ - Last resort (irreversible): \`rm -rf ~/.openpalm/vault\`
281
+
282
+ If anything looks wrong, do NOT delete this directory — restore from it or from
283
+ \`data/backups/\`. Full guide: docs/operations/upgrade-0.10-to-0.11.md
284
+ `;
285
+
286
+ /** Drop a safe-removal README into the retained legacy vault/ (skip if present). */
287
+ function writeVaultReadme(ctx: MigrationCtx, vault: string): void {
288
+ const dest = join(vault, "README.md");
289
+ if (existsSync(dest)) { ctx.log("skip (exists): vault/README.md"); return; }
290
+ if (ctx.dryRun) { ctx.log("[dry-run] write vault/README.md (safe-removal guide)"); return; }
291
+ writeFileSync(dest, VAULT_README);
292
+ ctx.log("wrote vault/README.md (safe-removal guide)");
293
+ }
294
+
295
+ /** Extract a validated addons[] list from any legacy stack.yml, or []. */
296
+ function readLegacyStackYmlAddons(ctx: MigrationCtx): string[] {
297
+ for (const p of [join(ctx.configDir, "stack.yml"), join(ctx.stackDir, "stack.yml")]) {
298
+ if (!existsSync(p)) continue;
299
+ try {
300
+ const raw = yamlParse(readFileSync(p, "utf-8")) as { addons?: unknown };
301
+ if (Array.isArray(raw?.addons)) {
302
+ return [...new Set(raw.addons.filter((v): v is string => typeof v === "string" && ADDON_NAME_RE.test(v)))].sort();
303
+ }
304
+ } catch { /* ignore unparseable */ }
305
+ }
306
+ return [];
307
+ }
308
+
309
+ const MIGRATIONS: Migration[] = [
310
+ {
311
+ from: 0,
312
+ to: 1,
313
+ describe: "0.10.x vault/ layout → 0.11.0 knowledge/ layout",
314
+ apply: migrate010to011,
315
+ verify(ctx) {
316
+ // The migration must have produced a usable 0.11 stack.env (unless a
317
+ // dry-run, where nothing was written).
318
+ if (ctx.dryRun) return;
319
+ if (!existsSync(stackEnvFile(ctx.stashDir))) {
320
+ throw new Error("post-migration check failed: knowledge/env/stack.env is missing");
321
+ }
322
+ },
323
+ },
324
+ ];
325
+
326
+ // ── Public entry point ────────────────────────────────────────────────────────
327
+
328
+ const RECOVERY_GUIDANCE =
329
+ "Your original files were left untouched and a full backup was taken first. " +
330
+ "To recover, restore the backup (see docs/operations/backup-restore.md) or run " +
331
+ "the standalone migrator with --dry-run (scripts/migrate-0.10-to-0.11.sh / .ps1). " +
332
+ "Full guide: docs/operations/upgrade-0.10-to-0.11.md";
333
+
334
+ /**
335
+ * Ensure the home directory is migrated to the current layout. Safe to call at
336
+ * the top of any upgrade/install entry point. Resolves its own paths (must run
337
+ * before createState, which assumes the current layout).
338
+ */
339
+ export function ensureMigrated(opts: { homeDir?: string; dryRun?: boolean; log?: (m: string) => void } = {}): MigrationReport {
340
+ const homeDir = opts.homeDir ?? resolveOpenPalmHome();
341
+ const dryRun = opts.dryRun ?? false;
342
+ const log = opts.log ?? (() => {});
343
+ const stashDir = resolveStashDir();
344
+ const ctxBase = {
345
+ homeDir,
346
+ dataDir: resolveDataDir(),
347
+ stackDir: resolveStackDir(),
348
+ stashDir,
349
+ configDir: resolveConfigDir(),
350
+ dryRun,
351
+ log,
352
+ notes: [] as string[],
353
+ };
354
+
355
+ const from = readLayoutVersion(ctxBase);
356
+ const empty: MigrationReport = { migrated: false, from, to: from, applied: [], backupDir: null, notes: [] };
357
+
358
+ // Fast path: already current → just ensure the marker is stamped, no lock/backup.
359
+ if (from >= CURRENT_LAYOUT_VERSION) {
360
+ if (!dryRun) stampLayoutVersion(stashDir, CURRENT_LAYOUT_VERSION);
361
+ return { ...empty, to: CURRENT_LAYOUT_VERSION };
362
+ }
363
+
364
+ const pending = MIGRATIONS
365
+ .filter((m) => m.from >= from && m.to <= CURRENT_LAYOUT_VERSION)
366
+ .sort((a, b) => a.from - b.from);
367
+ if (pending.length === 0) {
368
+ if (!dryRun) stampLayoutVersion(stashDir, CURRENT_LAYOUT_VERSION);
369
+ return { ...empty, to: CURRENT_LAYOUT_VERSION };
370
+ }
371
+
372
+ let lock: ReturnType<typeof acquireInstallLock> = null;
373
+ let backupDir: string | null = null;
374
+ try {
375
+ // Mutual exclusion + backup gate: never migrate without a verified safety
376
+ // copy. Any failure here aborts with no changes made.
377
+ if (!dryRun) {
378
+ try {
379
+ mkdirSync(ctxBase.dataDir, { recursive: true });
380
+ } catch (e) {
381
+ throw new MigrationError(`Could not prepare the data directory: ${e instanceof Error ? e.message : String(e)}`, RECOVERY_GUIDANCE, null);
382
+ }
383
+ lock = acquireInstallLock(ctxBase.dataDir);
384
+ if (!lock) {
385
+ throw new MigrationError("Another install/upgrade is in progress.", RECOVERY_GUIDANCE, null);
386
+ }
387
+ log("Taking a full backup before migrating…");
388
+ try {
389
+ backupDir = backupOpenPalmHome(homeDir);
390
+ } catch (e) {
391
+ throw new MigrationError(`Could not create a safety backup; upgrade aborted (no changes made): ${e instanceof Error ? e.message : String(e)}`, RECOVERY_GUIDANCE, null);
392
+ }
393
+ if (!backupDir) {
394
+ throw new MigrationError("Could not create a safety backup; upgrade aborted (no changes made).", RECOVERY_GUIDANCE, null);
395
+ }
396
+ log(`Backup: ${backupDir}`);
397
+ }
398
+
399
+ const applied: string[] = [];
400
+ const notes: string[] = [];
401
+ for (const m of pending) {
402
+ const ctx: MigrationCtx = { ...ctxBase, notes };
403
+ log(`Migrating layout ${m.from} → ${m.to}: ${m.describe}`);
404
+ m.apply(ctx);
405
+ m.verify(ctx);
406
+ applied.push(`${m.from}->${m.to}`);
407
+ }
408
+
409
+ // Commit point: bump the layout version LAST.
410
+ if (!dryRun) stampLayoutVersion(stashDir, CURRENT_LAYOUT_VERSION);
411
+
412
+ return { migrated: true, from, to: CURRENT_LAYOUT_VERSION, applied, backupDir, notes };
413
+ } catch (e) {
414
+ if (e instanceof MigrationError) throw e;
415
+ throw new MigrationError(
416
+ `Migration failed: ${e instanceof Error ? e.message : String(e)}`,
417
+ RECOVERY_GUIDANCE,
418
+ backupDir,
419
+ );
420
+ } finally {
421
+ releaseInstallLock(lock);
422
+ }
423
+ }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Layout:
8
8
  * config/ — user-editable config + system config files (akm/)
9
- * config/stack/ — compose runtime + stack config (stack.env, stack.yml, auth.json, fixed compose files)
9
+ * config/stack/ — fixed compose files only (stack.env, secrets, auth.json live under knowledge/; no stack.yml)
10
10
  * data/ — persistent service data, logs, backups, rollback
11
11
  * knowledge/ — akm knowledge (skills, env, secrets, agents)
12
12
  * workspace/ — shared work area
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Built-in addon/profile discovery and legacy registry helpers.
3
3
  *
4
- * Runtime addon enablement is recorded in stack.yml and resolved to Compose
5
- * profiles. The fixed compose files under config/stack are the runtime source
6
- * of truth.
4
+ * Runtime addon enablement is recorded as OP_ENABLED_ADDONS in stack.env and
5
+ * resolved to Compose profiles. The fixed compose files under config/stack are
6
+ * the runtime source of truth.
7
7
  */
8
8
  import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { execFile, execFileSync } from 'node:child_process';
@@ -16,7 +16,7 @@ import { ensureChannelSecret } from './config-persistence.js';
16
16
  import { patchSecretsEnvFile, readStackEnv } from './secrets.js';
17
17
  import { readBundledStackAsset } from './core-assets.js';
18
18
  import { canonicalAddonProfileSelection, resolveHardwareProfileVariant } from './profile-ids.js';
19
- import { listStackSpecAddons, setStackSpecAddon } from './stack-spec.js';
19
+ import { parseEnabledAddons } from './env.js';
20
20
  import type { ControlPlaneState } from './types.js';
21
21
  import {
22
22
  resolveRegistryAddonsDir,
@@ -430,8 +430,8 @@ export function listAvailableAddonIds(): string[] {
430
430
  }
431
431
 
432
432
  export function listEnabledAddonIds(homeDir: string): string[] {
433
- const enabled = new Set(listStackSpecAddons(join(homeDir, 'config', 'stack')));
434
433
  const env = readStackEnv(join(homeDir, 'config', 'stack'));
434
+ const enabled = new Set(parseEnabledAddons(env.OP_ENABLED_ADDONS));
435
435
  const profiles = new Set((env.COMPOSE_PROFILES ?? '').split(',').map((p) => p.trim()).filter(Boolean));
436
436
  for (const key of ['OP_VOICE_PROFILE', 'OP_OLLAMA_PROFILE']) {
437
437
  const profile = env[key]?.trim();
@@ -574,14 +574,18 @@ function execFileNoThrow(
574
574
  /**
575
575
  * Compute the openpalm/voice image ref for a given GPU variant, matching
576
576
  * the substitution chain in the addon compose file:
577
- * ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-${OP_IMAGE_TAG:-latest}-<variant>}
577
+ * ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-latest-<variant>}
578
+ *
579
+ * Voice images are published OUT OF BAND (publish-voice.yml), decoupled from the
580
+ * platform OP_IMAGE_TAG — they are heavy and rarely change. So the default is
581
+ * the moving `latest-<variant>` voice tag; operators pin a specific build by
582
+ * setting OP_VOICE_IMAGE_TAG (e.g. `v1.0.0-cpu`).
578
583
  */
579
584
  function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
580
585
  const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
581
586
  const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
582
587
  if (explicit) return `${namespace}/voice:${explicit}`;
583
- const baseTag = process.env.OP_IMAGE_TAG?.trim() || 'latest';
584
- return `${namespace}/voice:${baseTag}-${variant}`;
588
+ return `${namespace}/voice:latest-${variant}`;
585
589
  }
586
590
 
587
591
  /**
@@ -843,10 +847,18 @@ export function setAddonProfileSelection(stackDir: string, name: string, profile
843
847
  patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
844
848
  }
845
849
 
850
+ /** Add/remove an addon id in the OP_ENABLED_ADDONS list in stack.env. */
851
+ function setEnabledAddonState(stackDir: string, name: string, enabled: boolean): void {
852
+ const current = new Set(parseEnabledAddons(readStackEnv(stackDir).OP_ENABLED_ADDONS));
853
+ if (enabled) current.add(name);
854
+ else current.delete(name);
855
+ patchSecretsEnvFile(stackDir, { OP_ENABLED_ADDONS: [...current].sort().join(',') });
856
+ }
857
+
846
858
  function enableAddon(homeDir: string, stackDir: string, name: string): MutationResult {
847
859
  try {
848
860
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
849
- setStackSpecAddon(stackDir, name, true);
861
+ setEnabledAddonState(stackDir, name, true);
850
862
  if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '1' });
851
863
  return { ok: true };
852
864
  } catch (error) {
@@ -857,7 +869,7 @@ function enableAddon(homeDir: string, stackDir: string, name: string): MutationR
857
869
  function disableAddonByName(homeDir: string, stackDir: string, name: string): MutationResult {
858
870
  try {
859
871
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
860
- setStackSpecAddon(stackDir, name, false);
872
+ setEnabledAddonState(stackDir, name, false);
861
873
  if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '0' });
862
874
  return { ok: true };
863
875
  } catch (error) {
@@ -10,7 +10,6 @@ import {
10
10
  performSetup,
11
11
  } from "./setup.js";
12
12
  import type { SetupSpec, SetupConnection } from "./setup.js";
13
- import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
14
13
  import { readSecret } from './secrets-files.js';
15
14
 
16
15
  // ── Helpers ──────────────────────────────────────────────────────────────
@@ -431,15 +430,6 @@ describe("performSetup", () => {
431
430
  expect(config.llm).toBeUndefined();
432
431
  });
433
432
 
434
- it("writes stack.yml v2 version marker", async () => {
435
- const result = await performSetup(makeValidSpec());
436
- expect(result.ok).toBe(true);
437
-
438
- const spec = readStackSpec(stackDir);
439
- expect(spec).not.toBeNull();
440
- expect(spec!.version).toBe(2);
441
- });
442
-
443
433
  it("writes core compose file to stack/", async () => {
444
434
  const result = await performSetup(makeValidSpec());
445
435
  expect(result.ok).toBe(true);
@@ -522,16 +512,10 @@ describe("performSetup", () => {
522
512
  }
523
513
  });
524
514
 
525
- it("writes stack.yml as version marker only", async () => {
515
+ it("does not create a stack.yml (addon state lives in stack.env)", async () => {
526
516
  const result = await performSetup(makeValidSpec());
527
517
  expect(result.ok).toBe(true);
528
-
529
- const specPath = join(stackDir, STACK_SPEC_FILENAME);
530
- expect(existsSync(specPath)).toBe(true);
531
-
532
- const spec = readStackSpec(stackDir);
533
- expect(spec).not.toBeNull();
534
- expect(spec!.version).toBe(2);
518
+ expect(existsSync(join(stackDir, "stack.yml"))).toBe(false);
535
519
  });
536
520
 
537
521
  it("completes setup with multiple connections", async () => {
@@ -545,10 +529,6 @@ describe("performSetup", () => {
545
529
  const result = await performSetup(input);
546
530
  expect(result.ok).toBe(true);
547
531
 
548
- const spec = readStackSpec(stackDir);
549
- expect(spec).not.toBeNull();
550
- expect(spec!.version).toBe(2);
551
-
552
532
  const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), 'utf-8');
553
533
  expect(stackEnv).not.toContain('OPENAI_API_KEY=');
554
534
  expect(readSecret(stackDir, 'openai_api_key')).toBeNull();
@@ -24,7 +24,6 @@ import {
24
24
  writeAuthJsonProviderKeys,
25
25
  } from "./secrets.js";
26
26
  import { createState } from "./lifecycle.js";
27
- import { readStackSpec, writeStackSpec } from "./stack-spec.js";
28
27
  import { writeVoiceVars } from "./spec-to-env.js";
29
28
  import type { ControlPlaneState } from "./types.js";
30
29
  import { validateSetupSpec } from "./setup-validation.js";
@@ -221,9 +220,6 @@ export async function performSetup(
221
220
  // single try/catch so that a disk-full or permission-denied mid-way returns a
222
221
  // clean error rather than leaving a broken half-installed ~/.openpalm/.
223
222
  try {
224
- // Preserve addon enablement while refreshing the stack schema marker.
225
- writeStackSpec(state.stackDir, readStackSpec(state.stackDir) ?? { version: 2 });
226
-
227
223
  // Write image tag and AKM mount paths to stack.env — atomic to avoid
228
224
  // partial writes if the process is interrupted mid-write.
229
225
  const systemEnvForAkm = existsSync(`${state.stashDir}/env/stack.env`)
@@ -57,12 +57,13 @@ describe("skeleton: helper scripts", () => {
57
57
  // ── config/ subdirectory ──────────────────────────────────────────────
58
58
 
59
59
  describe("skeleton: .openpalm/config/ structure", () => {
60
- test("config/stack/ exists with fixed compose files and stack.yml", () => {
60
+ test("config/stack/ exists with fixed compose files (no stack.yml)", () => {
61
61
  expect(existsSync(join(SKELETON_DIR, "config", "stack", "core.compose.yml"))).toBe(true);
62
62
  expect(existsSync(join(SKELETON_DIR, "config", "stack", "services.compose.yml"))).toBe(true);
63
63
  expect(existsSync(join(SKELETON_DIR, "config", "stack", "channels.compose.yml"))).toBe(true);
64
64
  expect(existsSync(join(SKELETON_DIR, "config", "stack", "custom.compose.yml"))).toBe(true);
65
- expect(existsSync(join(SKELETON_DIR, "config", "stack", "stack.yml"))).toBe(true);
65
+ // stack.yml removed in 0.11.0 — addon enablement lives in stack.env.
66
+ expect(existsSync(join(SKELETON_DIR, "config", "stack", "stack.yml"))).toBe(false);
66
67
  });
67
68
 
68
69
  test("config/stack/addons/ does not exist", () => {
@@ -5,7 +5,7 @@
5
5
  * Voice channel vars (TTS/STT) are written separately via writeVoiceVars.
6
6
  */
7
7
 
8
- import { SPEC_DEFAULTS } from "./stack-spec.js";
8
+ import { SPEC_DEFAULTS } from "./defaults.js";
9
9
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
10
  import { dirname } from "node:path";
11
11
  import { mergeEnvContent } from "./env.js";
@@ -14,7 +14,7 @@ import { assertNoSecretLikeStackEnvKeys } from './secrets.js';
14
14
  import { stackEnvPathFromStackDir } from './paths.js';
15
15
 
16
16
  /**
17
- * Derive the system.env key-value pairs from the StackSpec.
17
+ * Derive the system.env key-value pairs from the setup spec + defaults.
18
18
  * Secrets (tokens, API keys, HMAC) are NOT included — the caller merges them.
19
19
  */
20
20
  export function deriveSystemEnvFromSpec(homeDir: string): Record<string, string> {