@openpalm/lib 0.11.0-rc.6 → 0.11.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.
@@ -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
+ });