@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-rc.3",
3
+ "version": "0.11.0",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
@@ -47,7 +47,9 @@ function seedAddon(name: string): void {
47
47
  const stackDir = join(tempDir, "config", "stack");
48
48
  mkdirSync(stackDir, { recursive: true });
49
49
  writeFileSync(join(stackDir, "channels.compose.yml"), `services:\n ${name}:\n profiles: [\"addon.${name}\"]\n image: test\n`);
50
- writeFileSync(join(stackDir, "stack.yml"), `version: 2\naddons:\n - ${name}\n`);
50
+ const envDir = join(tempDir, "knowledge", "env");
51
+ mkdirSync(envDir, { recursive: true });
52
+ writeFileSync(join(envDir, "stack.env"), `OP_ENABLED_ADDONS=${name}\n`);
51
53
  }
52
54
 
53
55
  beforeEach(() => {
@@ -70,7 +72,7 @@ describe("buildComposeOptions", () => {
70
72
  expect(opts.files[0]).toContain("core.compose.yml");
71
73
  });
72
74
 
73
- it("includes fixed channel compose and profile from stack.yml", () => {
75
+ it("includes fixed channel compose and profile from OP_ENABLED_ADDONS", () => {
74
76
  seedCoreCompose();
75
77
  seedAddon("chat");
76
78
 
@@ -10,9 +10,8 @@ import type { ControlPlaneState } from "./types.js";
10
10
  import { buildComposeFileList } from "./lifecycle.js";
11
11
  import { buildEnvFiles } from "./config-persistence.js";
12
12
  import { resolveComposeProjectName } from "./docker.js";
13
- import { parseEnvFile } from "./env.js";
13
+ import { parseEnvFile, parseEnabledAddons } from "./env.js";
14
14
  import { canonicalAddonProfileSelection } from "./profile-ids.js";
15
- import { listStackSpecAddons } from "./stack-spec.js";
16
15
 
17
16
  // ── Types ────────────────────────────────────────────────────────────────
18
17
 
@@ -45,7 +44,7 @@ export function resolveActiveProfiles(state: ControlPlaneState): string[] {
45
44
  if (ollamaProfile) profiles.push(ollamaProfile);
46
45
  }
47
46
 
48
- for (const addon of listStackSpecAddons(state.stackDir)) {
47
+ for (const addon of parseEnabledAddons(env.OP_ENABLED_ADDONS)) {
49
48
  if (addon === 'voice') {
50
49
  profiles.push(canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '') || 'addon.voice.cpu');
51
50
  } else if (addon === 'ollama') {
@@ -14,7 +14,8 @@ import { ensureSecret } from './secrets-files.js';
14
14
  import type { ControlPlaneState, ArtifactMeta } from "./types.js";
15
15
  import { listEnabledAddonIds } from "./registry.js";
16
16
  import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
17
- import { SPEC_DEFAULTS } from "./stack-spec.js";
17
+ import { SPEC_DEFAULTS } from "./defaults.js";
18
+ import { CURRENT_LAYOUT_VERSION } from "./migrations.js";
18
19
 
19
20
  import {
20
21
  readCoreCompose,
@@ -128,6 +129,12 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
128
129
  `OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
129
130
  `OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
130
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
+ "",
131
138
  "# ── Ports (38XX range) ──────────────────────────────────────────────",
132
139
  "# Guardian is network-only (no host port) — channels reach it via",
133
140
  "# http://guardian:8080 over the channel_lan Docker network.",
@@ -56,18 +56,25 @@ export function ensureOpenCodeSystemConfig(): void {
56
56
 
57
57
  const REPO = "itlackey/openpalm";
58
58
 
59
- function resolveAssetVersion(): string {
60
- if (process.env.OP_ASSET_VERSION) return process.env.OP_ASSET_VERSION;
61
- try {
62
- const pkgJson = JSON.parse(
63
- 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\")."
64
73
  );
65
- return `v${pkgJson.version}`;
66
- } catch {
67
- return "main";
68
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;
69
77
  }
70
- const VERSION = resolveAssetVersion();
71
78
 
72
79
  // Persona files (openpalm.md, system.md), stash seeds, and user-editable config
73
80
  // files are intentionally NOT in this list. They are seeded once (never
@@ -85,9 +92,9 @@ const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
85
92
  { relPath: "config/stack/custom.compose.yml", githubFilename: ".openpalm/config/stack/custom.compose.yml" },
86
93
  ];
87
94
 
88
- async function downloadAsset(filename: string): Promise<string> {
89
- const releaseUrl = `https://github.com/${REPO}/releases/download/${VERSION}/${filename}`;
90
- 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}`;
91
98
 
92
99
  for (const url of [releaseUrl, rawUrl]) {
93
100
  try {
@@ -97,19 +104,20 @@ async function downloadAsset(filename: string): Promise<string> {
97
104
  // try next URL
98
105
  }
99
106
  }
100
- 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}")`);
101
108
  }
102
109
 
103
- export async function refreshCoreAssets(): Promise<{
110
+ export async function refreshCoreAssets(version: string): Promise<{
104
111
  backupDir: string | null;
105
112
  updated: string[];
106
113
  }> {
114
+ const ref = normalizeAssetRef(version);
107
115
  const homeDir = resolveOpenPalmHome();
108
116
  const updated: string[] = [];
109
117
  let backupDir: string | null = null;
110
118
 
111
119
  for (const asset of MANAGED_ASSETS) {
112
- const freshContent = await downloadAsset(asset.githubFilename);
120
+ const freshContent = await downloadAsset(asset.githubFilename, ref);
113
121
  const targetPath = join(homeDir, asset.relPath);
114
122
 
115
123
  if (existsSync(targetPath)) {
@@ -135,7 +143,7 @@ export async function refreshCoreAssets(): Promise<{
135
143
  for (const asset of SEEDED_ASSETS) {
136
144
  const targetPath = join(homeDir, asset.relPath);
137
145
  if (existsSync(targetPath)) continue;
138
- const freshContent = await downloadAsset(asset.githubFilename);
146
+ const freshContent = await downloadAsset(asset.githubFilename, ref);
139
147
  mkdirSync(dirname(targetPath), { recursive: true });
140
148
  writeFileSync(targetPath, freshContent);
141
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;
@@ -82,6 +82,21 @@ export function upsertEnvValue(content: string, key: string, value: string): str
82
82
  return `${content}${suffix}${line}\n`;
83
83
  }
84
84
 
85
+ /** Addon name shape (matches the former stack.yml validation). */
86
+ export const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
87
+
88
+ /**
89
+ * Parse the `OP_ENABLED_ADDONS` stack.env value (comma-separated) into a
90
+ * validated, de-duplicated, sorted list of addon ids. Replaces the former
91
+ * stack.yml `addons[]` array as the authoritative enabled-addon record.
92
+ */
93
+ export function parseEnabledAddons(value: string | undefined): string[] {
94
+ if (!value) return [];
95
+ return [...new Set(
96
+ value.split(',').map((v) => v.trim()).filter((v) => ADDON_NAME_RE.test(v)),
97
+ )].sort();
98
+ }
99
+
85
100
  export const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
86
101
 
87
102
  /**
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Single ~/.openpalm/ root:
5
5
  * config/ — user-editable config + system config files (akm/)
6
- * config/stack/ — compose runtime + stack config (stack.env, stack.yml, auth.json, fixed compose files)
6
+ * config/stack/ — fixed compose files (no stack.env/secrets/stack.yml)
7
7
  * data/ — persistent service data, logs, backups, rollback
8
- * knowledge/ — akm knowledge (env, secrets, tasks)
8
+ * knowledge/ — akm knowledge (env, secrets, tasks); env/stack.env is the
9
+ * authoritative stack composition + versions record
9
10
  * workspace/ — shared assistant work area
10
- * config/stack/ — compose runtime assets + stack config (stack.env, stack.yml)
11
11
  */
12
12
  import { mkdirSync } from "node:fs";
13
13
  import { homedir, tmpdir } from "node:os";
@@ -26,7 +26,6 @@ import {
26
26
  } from "./setup.js";
27
27
  import type { SetupSpec, SetupConnection } from "./setup.js";
28
28
  import type { ControlPlaneState } from "./types.js";
29
- import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
30
29
  import { readSecret } from './secrets-files.js';
31
30
 
32
31
  // ── Helpers ──────────────────────────────────────────────────────────────
@@ -313,11 +312,6 @@ describe("Existing Install", () => {
313
312
  })
314
313
  );
315
314
 
316
- // stack.yml is just a version marker now
317
- const specAfterSecond = readStackSpec(stackDir);
318
- expect(specAfterSecond).not.toBeNull();
319
- expect(specAfterSecond!.version).toBe(2);
320
-
321
315
  const auth = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), "utf-8"));
322
316
  expect(auth.groq.key).toBe("gsk-test-key-456");
323
317
  });
@@ -426,12 +420,6 @@ describe("Broken/Corrupt State", () => {
426
420
  }
427
421
  });
428
422
 
429
- // Scenario 13: Missing stack.yml returns null
430
- it("readStackSpec returns null when stack.yml missing", () => {
431
- const spec = readStackSpec(stackDir);
432
- expect(spec).toBeNull();
433
- });
434
-
435
423
  // Scenario 14: knowledge/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
436
424
  it("performSetup creates missing subdirectories", async () => {
437
425
  // Seed the minimal env files first
@@ -453,16 +441,6 @@ describe("Broken/Corrupt State", () => {
453
441
  expect(existsSync(join(homeDir, "knowledge", "tasks"))).toBe(true);
454
442
  });
455
443
 
456
- // Scenario 15: openpalm.yaml with old version
457
- it("readStackSpec returns null for version 1 spec", () => {
458
- writeFileSync(
459
- join(stackDir, STACK_SPEC_FILENAME),
460
- "version: 1\nconnections: []\n"
461
- );
462
-
463
- const spec = readStackSpec(stackDir);
464
- expect(spec).toBeNull();
465
- });
466
444
  });
467
445
 
468
446
  // =====================================================================
@@ -562,10 +540,6 @@ describe("Setup Input Variations", () => {
562
540
 
563
541
  const result = await performSetup(input);
564
542
  expect(result.ok).toBe(true);
565
-
566
- const spec = readStackSpec(stackDir);
567
- expect(spec).not.toBeNull();
568
- expect(spec!.version).toBe(2);
569
543
  });
570
544
 
571
545
  // Scenario 21: Multiple providers map to correct env vars
@@ -626,12 +600,9 @@ describe("performSetup end-to-end artifacts", () => {
626
600
  rmSync(homeDir, { recursive: true, force: true });
627
601
  });
628
602
 
629
- it("writes stack.yml and readStackSpec returns v2", async () => {
603
+ it("does not create a stack.yml (addon state lives in stack.env)", async () => {
630
604
  await performSetup(makeValidSpec());
631
-
632
- const spec = readStackSpec(stackDir);
633
- expect(spec).not.toBeNull();
634
- expect(spec!.version).toBe(2);
605
+ expect(existsSync(join(stackDir, "stack.yml"))).toBe(false);
635
606
  });
636
607
 
637
608
  it("writes akm config with embedding dims from setup spec", async () => {
@@ -216,7 +216,9 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
216
216
  }
217
217
 
218
218
  export async function applyUpgrade(
219
- state: ControlPlaneState
219
+ state: ControlPlaneState,
220
+ /** Release tag whose stack assets to fetch (e.g. "v0.11.0-rc.6"). Caller-supplied. */
221
+ version: string
220
222
  ): Promise<{
221
223
  backupDir: string | null;
222
224
  updated: string[];
@@ -225,7 +227,7 @@ export async function applyUpgrade(
225
227
  const lock = acquireInstallLock(state.dataDir);
226
228
  if (!lock) throw new Error("Another install is already in progress");
227
229
  try {
228
- const { backupDir, updated } = await refreshCoreAssets();
230
+ const { backupDir, updated } = await refreshCoreAssets(version);
229
231
  const restarted = await reconcileCore(state, {});
230
232
  return { backupDir, updated, restarted };
231
233
  } finally {
@@ -269,7 +271,9 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
269
271
  const tagResult = await updateStackEnvToLatestImageTag(state);
270
272
  imageTag = tagResult.tag;
271
273
  namespace = tagResult.namespace;
272
- upgradeResult = await applyUpgrade(state);
274
+ // The resolved platform tag IS the version whose stack assets we fetch —
275
+ // keeps compose files and images in lockstep.
276
+ upgradeResult = await applyUpgrade(state, imageTag);
273
277
  } catch (e) {
274
278
  // Restore stack.env on failure
275
279
  if (originalStackEnv !== null) {
@@ -308,7 +312,7 @@ export async function applyTagChange(state: ControlPlaneState, tag: string): Pro
308
312
  const stackEnvPath = `${state.stashDir}/env/stack.env`;
309
313
  const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
310
314
  writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
311
- const upgradeResult = await applyUpgrade(state);
315
+ const upgradeResult = await applyUpgrade(state, tag);
312
316
  return {
313
317
  imageTag: tag,
314
318
  namespace: "openpalm",
@@ -0,0 +1,272 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ensureMigrated, MigrationError, CURRENT_LAYOUT_VERSION } from "./migrations.js";
6
+
7
+ // The harness resolves all paths from OP_HOME; point it at a synthetic 0.10 home.
8
+ let home: string;
9
+ let prevOpHome: string | undefined;
10
+
11
+ function seed010(h: string): void {
12
+ mkdirSync(join(h, "vault", "user"), { recursive: true });
13
+ mkdirSync(join(h, "vault", "stack", "services"), { recursive: true });
14
+ mkdirSync(join(h, "config"), { recursive: true });
15
+ mkdirSync(join(h, "data"), { recursive: true });
16
+ writeFileSync(join(h, "vault", "user", "user.env"), "MY_PREF=hello\n");
17
+ writeFileSync(
18
+ join(h, "vault", "stack", "stack.env"),
19
+ [
20
+ "# system env",
21
+ "OP_HOME=/x/.openpalm",
22
+ "OP_ADMIN_PORT=9000",
23
+ "OPENAI_API_KEY=sk-secret123",
24
+ "OP_CAP_LLM_MODEL=gpt-4",
25
+ "TTS_VOICE=alloy",
26
+ "OP_UI_LOGIN_PASSWORD=hunter2",
27
+ "OP_ASSISTANT_PORT=3800",
28
+ "",
29
+ ].join("\n"),
30
+ );
31
+ writeFileSync(
32
+ join(h, "vault", "stack", "guardian.env"),
33
+ "CHANNEL_DISCORD_SECRET=disc-abc\nCHANNEL_SLACK_SECRET=slack-xyz\n",
34
+ );
35
+ writeFileSync(join(h, "vault", "stack", "services", "some.secret"), "svc-val\n");
36
+ writeFileSync(join(h, "vault", "user", "apprise.yaml"), "urls:\n - mailto://x\n");
37
+ writeFileSync(join(h, "config", "stack.yml"), "version: 1\ncapabilities:\n llm: openai\n");
38
+ }
39
+
40
+ /** Sorted top-level entry names under a directory. */
41
+ function entries(dir: string): string[] {
42
+ return readdirSync(dir).sort();
43
+ }
44
+
45
+ beforeEach(() => {
46
+ prevOpHome = process.env.OP_HOME;
47
+ home = mkdtempSync(join(tmpdir(), "op-migrate-"));
48
+ process.env.OP_HOME = home;
49
+ });
50
+
51
+ afterEach(() => {
52
+ if (prevOpHome === undefined) delete process.env.OP_HOME;
53
+ else process.env.OP_HOME = prevOpHome;
54
+ rmSync(home, { recursive: true, force: true });
55
+ });
56
+
57
+ describe("ensureMigrated 0.10 → 0.11", () => {
58
+ it("migrates the vault layout, backs up, and stamps the layout version", () => {
59
+ seed010(home);
60
+ const report = ensureMigrated();
61
+
62
+ expect(report.migrated).toBe(true);
63
+ expect(report.from).toBe(0);
64
+ expect(report.to).toBe(CURRENT_LAYOUT_VERSION);
65
+ expect(report.backupDir).toBeTruthy();
66
+ expect(existsSync(report.backupDir!)).toBe(true);
67
+
68
+ const stackEnv = readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8");
69
+ expect(stackEnv).toContain("OP_HOST_UI_PORT=9000"); // renamed
70
+ expect(stackEnv).toContain("OP_TTS_VOICE=alloy"); // prefixed
71
+ expect(stackEnv).toContain("OP_ASSISTANT_PORT=3800"); // kept
72
+ expect(stackEnv).toContain(`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`); // commit
73
+ expect(stackEnv).not.toContain("OPENAI_API_KEY"); // quarantined
74
+ expect(stackEnv).not.toContain("OP_CAP_LLM_MODEL"); // quarantined
75
+
76
+ expect(readFileSync(join(home, "knowledge", "env", "stack.env.removed-secrets.bak"), "utf-8"))
77
+ .toContain("OPENAI_API_KEY=sk-secret123");
78
+ expect(readFileSync(join(home, "knowledge", "secrets", "op_ui_login_password"), "utf-8").trim())
79
+ .toBe("hunter2");
80
+ expect(readFileSync(join(home, "knowledge", "secrets", "channel_discord_secret"), "utf-8").trim())
81
+ .toBe("disc-abc");
82
+ expect(readFileSync(join(home, "knowledge", "secrets", "channel_slack_secret"), "utf-8").trim())
83
+ .toBe("slack-xyz");
84
+ expect(existsSync(join(home, "knowledge", "secrets", "some.secret"))).toBe(true);
85
+ expect(existsSync(join(home, "knowledge", "secrets", "apprise.yaml"))).toBe(true);
86
+ // stack.yml is removed in 0.11.0 — the migration must NOT create one.
87
+ expect(existsSync(join(home, "config", "stack", "stack.yml"))).toBe(false);
88
+ expect(readFileSync(join(home, "knowledge", "env", "user.env"), "utf-8")).toContain("MY_PREF=hello");
89
+
90
+ // Non-destructive: originals untouched.
91
+ expect(existsSync(join(home, "vault", "stack", "stack.env"))).toBe(true);
92
+ });
93
+
94
+ it("ends with exactly the expected 0.11 directories and every datum in its proper location", () => {
95
+ seed010(home);
96
+ ensureMigrated();
97
+
98
+ // Only the expected top-level directories exist. The legacy vault/ is
99
+ // intentionally retained (copy-only recovery copy); nothing stray is created.
100
+ expect(entries(home)).toEqual(["config", "data", "knowledge", "vault"]);
101
+
102
+ // knowledge/ holds exactly the env + secrets stores.
103
+ expect(entries(join(home, "knowledge"))).toEqual(["env", "secrets"]);
104
+
105
+ // Every migrated datum landed in its proper 0.11 location — no missing, no extra.
106
+ expect(entries(join(home, "knowledge", "env"))).toEqual([
107
+ "stack.env",
108
+ "stack.env.removed-secrets.bak",
109
+ "user.env",
110
+ ]);
111
+ expect(entries(join(home, "knowledge", "secrets"))).toEqual([
112
+ "apprise.yaml",
113
+ "channel_discord_secret",
114
+ "channel_slack_secret",
115
+ "op_ui_login_password",
116
+ "some.secret",
117
+ ]);
118
+
119
+ // The full backup landed under data/backups (and nowhere else top-level).
120
+ expect(existsSync(join(home, "data", "backups"))).toBe(true);
121
+
122
+ // The retained vault/ carries a safe-removal README.
123
+ expect(existsSync(join(home, "vault", "README.md"))).toBe(true);
124
+
125
+ // Nothing leaked into a wrong place: no 0.11 secrets under knowledge/env,
126
+ // and no plaintext login password left inside the migrated stack.env.
127
+ expect(existsSync(join(home, "knowledge", "env", "op_ui_login_password"))).toBe(false);
128
+ expect(readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8"))
129
+ .not.toContain("hunter2");
130
+ });
131
+
132
+ it("migrates a minimal home (only stack.env) without creating stray files", () => {
133
+ mkdirSync(join(home, "vault", "stack"), { recursive: true });
134
+ mkdirSync(join(home, "data"), { recursive: true });
135
+ writeFileSync(
136
+ join(home, "vault", "stack", "stack.env"),
137
+ "OP_IMAGE_TAG=0.10.2\nOP_ASSISTANT_PORT=3800\n",
138
+ );
139
+ const report = ensureMigrated();
140
+ expect(report.migrated).toBe(true);
141
+
142
+ // env/ has only stack.env — no user.env, no removed-secrets.bak (there were
143
+ // no secrets/cap keys to quarantine).
144
+ expect(entries(join(home, "knowledge", "env"))).toEqual(["stack.env"]);
145
+ // secrets/ exists (created) but is empty — nothing to migrate.
146
+ expect(entries(join(home, "knowledge", "secrets"))).toEqual([]);
147
+ const stackEnv = readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8");
148
+ expect(stackEnv).toContain("OP_IMAGE_TAG=0.10.2");
149
+ expect(stackEnv).toContain(`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`);
150
+ });
151
+
152
+ it("does not write a removed-secrets.bak when stack.env has no secret/cap keys", () => {
153
+ mkdirSync(join(home, "vault", "stack"), { recursive: true });
154
+ mkdirSync(join(home, "data"), { recursive: true });
155
+ writeFileSync(join(home, "vault", "stack", "stack.env"), "OP_ASSISTANT_PORT=3800\n");
156
+ ensureMigrated();
157
+ expect(existsSync(join(home, "knowledge", "env", "stack.env.removed-secrets.bak"))).toBe(false);
158
+ });
159
+
160
+ it("writes a safe-removal README into the retained vault/", () => {
161
+ seed010(home);
162
+ ensureMigrated();
163
+ const readme = readFileSync(join(home, "vault", "README.md"), "utf-8");
164
+ // It explains what the directory is and how to remove it safely.
165
+ expect(readme).toContain("RECOVERY COPY");
166
+ expect(readme).toContain("How to remove it safely");
167
+ expect(readme).toContain("gio trash");
168
+ expect(readme).toContain("data/backups");
169
+ // The original migrated files are still present (README is additive only).
170
+ expect(existsSync(join(home, "vault", "stack", "stack.env"))).toBe(true);
171
+ });
172
+
173
+ it("dry-run does not write the vault README", () => {
174
+ seed010(home);
175
+ ensureMigrated({ dryRun: true });
176
+ expect(existsSync(join(home, "vault", "README.md"))).toBe(false);
177
+ });
178
+
179
+ it("does not clobber a pre-existing vault/README.md", () => {
180
+ seed010(home);
181
+ writeFileSync(join(home, "vault", "README.md"), "user's own notes\n");
182
+ ensureMigrated();
183
+ expect(readFileSync(join(home, "vault", "README.md"), "utf-8")).toBe("user's own notes\n");
184
+ });
185
+
186
+ it("converts addons[] from a nested config/stack/stack.yml too", () => {
187
+ seed010(home);
188
+ rmSync(join(home, "config", "stack.yml"), { force: true });
189
+ mkdirSync(join(home, "config", "stack"), { recursive: true });
190
+ writeFileSync(join(home, "config", "stack", "stack.yml"), "version: 2\naddons:\n - voice\n");
191
+ ensureMigrated();
192
+ expect(readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8"))
193
+ .toContain("OP_ENABLED_ADDONS=voice");
194
+ });
195
+
196
+ it("normalizes channel secret names to lowercase and skips invalid ones", () => {
197
+ mkdirSync(join(home, "vault", "stack"), { recursive: true });
198
+ mkdirSync(join(home, "data"), { recursive: true });
199
+ writeFileSync(join(home, "vault", "stack", "stack.env"), "OP_ASSISTANT_PORT=3800\n");
200
+ writeFileSync(
201
+ join(home, "vault", "stack", "guardian.env"),
202
+ // valid (mixed case → lowercase), and an invalid name with a space (skipped).
203
+ "CHANNEL_Discord_SECRET=abc\nCHANNEL_BAD NAME_SECRET=nope\n",
204
+ );
205
+ ensureMigrated();
206
+ expect(existsSync(join(home, "knowledge", "secrets", "channel_discord_secret"))).toBe(true);
207
+ expect(entries(join(home, "knowledge", "secrets"))).toEqual(["channel_discord_secret"]);
208
+ });
209
+
210
+ it("preserves user-edited destination files (copy-only, skip-if-exists)", () => {
211
+ seed010(home);
212
+ // Simulate a partially-migrated home where the user already has a user.env.
213
+ mkdirSync(join(home, "knowledge", "env"), { recursive: true });
214
+ writeFileSync(join(home, "knowledge", "env", "user.env"), "MY_PREF=edited-by-user\n");
215
+ ensureMigrated();
216
+ // The existing destination must NOT be clobbered by the vault copy.
217
+ expect(readFileSync(join(home, "knowledge", "env", "user.env"), "utf-8"))
218
+ .toContain("edited-by-user");
219
+ });
220
+
221
+ it("copies auth.json best-effort and surfaces a verify-providers note", () => {
222
+ seed010(home);
223
+ writeFileSync(join(home, "vault", "stack", "auth.json"), '{"openai":{"type":"api"}}');
224
+ const report = ensureMigrated();
225
+ expect(existsSync(join(home, "knowledge", "secrets", "auth.json"))).toBe(true);
226
+ expect(report.notes.join(" ")).toContain("auth.json");
227
+ });
228
+
229
+ it("converts a legacy stack.yml addons[] into OP_ENABLED_ADDONS", () => {
230
+ seed010(home);
231
+ writeFileSync(join(home, "config", "stack.yml"), "version: 2\naddons:\n - voice\n - discord\n");
232
+ ensureMigrated();
233
+ const stackEnv = readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8");
234
+ expect(stackEnv).toContain("OP_ENABLED_ADDONS=discord,voice");
235
+ expect(existsSync(join(home, "config", "stack", "stack.yml"))).toBe(false);
236
+ });
237
+
238
+ it("is idempotent — a second run is a no-op", () => {
239
+ seed010(home);
240
+ ensureMigrated();
241
+ const second = ensureMigrated();
242
+ expect(second.migrated).toBe(false);
243
+ expect(second.to).toBe(CURRENT_LAYOUT_VERSION);
244
+ });
245
+
246
+ it("dry-run writes nothing", () => {
247
+ seed010(home);
248
+ const report = ensureMigrated({ dryRun: true });
249
+ expect(report.migrated).toBe(true);
250
+ expect(existsSync(join(home, "knowledge", "env", "stack.env"))).toBe(false);
251
+ expect(report.backupDir).toBeNull();
252
+ });
253
+
254
+ it("aborts (no changes) when the backup cannot be created", () => {
255
+ seed010(home);
256
+ // Make data/ a file so backupOpenPalmHome's mkdir of data/backups fails.
257
+ rmSync(join(home, "data"), { recursive: true, force: true });
258
+ writeFileSync(join(home, "data"), "not a dir");
259
+ expect(() => ensureMigrated()).toThrow(MigrationError);
260
+ expect(existsSync(join(home, "knowledge", "env", "stack.env"))).toBe(false);
261
+ });
262
+
263
+ it("treats an already-0.11 home (no vault) as current and stamps it", () => {
264
+ mkdirSync(join(home, "knowledge", "env"), { recursive: true });
265
+ writeFileSync(join(home, "knowledge", "env", "stack.env"), "OP_IMAGE_TAG=0.11.0\n");
266
+ const report = ensureMigrated();
267
+ expect(report.migrated).toBe(false);
268
+ expect(report.to).toBe(CURRENT_LAYOUT_VERSION);
269
+ expect(readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8"))
270
+ .toContain(`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`);
271
+ });
272
+ });