@openpalm/lib 0.9.4

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,476 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ validateSetupInput,
7
+ buildSecretsFromSetup,
8
+ buildConnectionEnvVarMap,
9
+ performSetup,
10
+ } from "./setup.js";
11
+ import type { SetupInput, SetupConnection } from "./setup.js";
12
+ import type { CoreAssetProvider } from "./core-asset-provider.js";
13
+
14
+ // ── Helpers ──────────────────────────────────────────────────────────────
15
+
16
+ function makeValidInput(overrides?: Partial<SetupInput>): SetupInput {
17
+ return {
18
+ adminToken: "test-admin-token-12345",
19
+ ownerName: "Test User",
20
+ ownerEmail: "test@example.com",
21
+ memoryUserId: "test_user",
22
+ ollamaEnabled: false,
23
+ connections: [
24
+ {
25
+ id: "openai-main",
26
+ name: "OpenAI",
27
+ provider: "openai",
28
+ baseUrl: "https://api.openai.com",
29
+ apiKey: "sk-test-key-123",
30
+ },
31
+ ],
32
+ assignments: {
33
+ llm: { connectionId: "openai-main", model: "gpt-4o" },
34
+ embeddings: { connectionId: "openai-main", model: "text-embedding-3-small" },
35
+ },
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ /** Stub asset provider that returns minimal content for all assets. */
41
+ function createStubAssetProvider(): CoreAssetProvider {
42
+ return {
43
+ coreCompose: () => "services:\n caddy:\n image: caddy:latest\n",
44
+ caddyfile: () =>
45
+ ":80 {\n @denied not remote_ip 127.0.0.0/8 ::1\n respond @denied 403\n}\n",
46
+ ollamaCompose: () => "services:\n ollama:\n image: ollama/ollama\n",
47
+ agentsMd: () => "# Agents\n",
48
+ opencodeConfig: () => '{"$schema":"https://opencode.ai/config.json"}\n',
49
+ adminOpencodeConfig: () => '{"$schema":"https://opencode.ai/config.json","plugin":["@openpalm/admin-tools"]}\n',
50
+ secretsSchema: () => "ADMIN_TOKEN=string\n",
51
+ stackSchema: () => "OPENPALM_IMAGE_TAG=string\n",
52
+ cleanupLogs: () => "name: cleanup-logs\nschedule: daily\n",
53
+ cleanupData: () => "name: cleanup-data\nschedule: weekly\n",
54
+ validateConfig: () => "name: validate-config\nschedule: hourly\n",
55
+ };
56
+ }
57
+
58
+ // ── Tests: validateSetupInput ────────────────────────────────────────────
59
+
60
+ describe("validateSetupInput", () => {
61
+ it("accepts a valid input", () => {
62
+ const result = validateSetupInput(makeValidInput());
63
+ expect(result.valid).toBe(true);
64
+ expect(result.errors).toHaveLength(0);
65
+ });
66
+
67
+ it("rejects null input", () => {
68
+ const result = validateSetupInput(null);
69
+ expect(result.valid).toBe(false);
70
+ expect(result.errors).toContain("Input must be a non-null object");
71
+ });
72
+
73
+ it("rejects missing adminToken", () => {
74
+ const input = makeValidInput({ adminToken: "" });
75
+ const result = validateSetupInput(input);
76
+ expect(result.valid).toBe(false);
77
+ expect(result.errors.some((e) => e.includes("adminToken"))).toBe(true);
78
+ });
79
+
80
+ it("rejects short adminToken", () => {
81
+ const input = makeValidInput({ adminToken: "short" });
82
+ const result = validateSetupInput(input);
83
+ expect(result.valid).toBe(false);
84
+ expect(result.errors.some((e) => e.includes("at least 8"))).toBe(true);
85
+ });
86
+
87
+ it("rejects empty connections array", () => {
88
+ const input = makeValidInput({ connections: [] });
89
+ const result = validateSetupInput(input);
90
+ expect(result.valid).toBe(false);
91
+ expect(result.errors.some((e) => e.includes("connections"))).toBe(true);
92
+ });
93
+
94
+ it("rejects duplicate connection IDs", () => {
95
+ const conn: SetupConnection = {
96
+ id: "dup",
97
+ name: "Dup",
98
+ provider: "openai",
99
+ baseUrl: "",
100
+ apiKey: "",
101
+ };
102
+ const input = makeValidInput({ connections: [conn, conn] });
103
+ const result = validateSetupInput(input);
104
+ expect(result.valid).toBe(false);
105
+ expect(result.errors.some((e) => e.includes("Duplicate"))).toBe(true);
106
+ });
107
+
108
+ it("rejects unsupported provider", () => {
109
+ const input = makeValidInput({
110
+ connections: [
111
+ { id: "bad", name: "Bad", provider: "unsupported-provider", baseUrl: "", apiKey: "" },
112
+ ],
113
+ });
114
+ const result = validateSetupInput(input);
115
+ expect(result.valid).toBe(false);
116
+ expect(result.errors.some((e) => e.includes("outside wizard scope"))).toBe(true);
117
+ });
118
+
119
+ it("rejects invalid connection ID pattern", () => {
120
+ const input = makeValidInput({
121
+ connections: [
122
+ { id: "-invalid", name: "Bad", provider: "openai", baseUrl: "", apiKey: "" },
123
+ ],
124
+ });
125
+ const result = validateSetupInput(input);
126
+ expect(result.valid).toBe(false);
127
+ expect(result.errors.some((e) => e.includes("must start with a letter or digit"))).toBe(true);
128
+ });
129
+
130
+ it("rejects missing assignments.llm", () => {
131
+ const input = makeValidInput();
132
+ (input.assignments as Record<string, unknown>).llm = null;
133
+ const result = validateSetupInput(input);
134
+ expect(result.valid).toBe(false);
135
+ expect(result.errors.some((e) => e.includes("assignments.llm"))).toBe(true);
136
+ });
137
+
138
+ it("rejects missing assignments.embeddings", () => {
139
+ const input = makeValidInput();
140
+ (input.assignments as Record<string, unknown>).embeddings = null;
141
+ const result = validateSetupInput(input);
142
+ expect(result.valid).toBe(false);
143
+ expect(result.errors.some((e) => e.includes("assignments.embeddings"))).toBe(true);
144
+ });
145
+
146
+ it("rejects non-integer embeddingDims", () => {
147
+ const input = makeValidInput();
148
+ input.assignments.embeddings.embeddingDims = 1.5;
149
+ const result = validateSetupInput(input);
150
+ expect(result.valid).toBe(false);
151
+ expect(result.errors.some((e) => e.includes("embeddingDims"))).toBe(true);
152
+ });
153
+
154
+ it("rejects assignment referencing non-existent connection", () => {
155
+ const input = makeValidInput();
156
+ input.assignments.llm.connectionId = "does-not-exist";
157
+ const result = validateSetupInput(input);
158
+ expect(result.valid).toBe(false);
159
+ expect(result.errors.some((e) => e.includes("does not match any connection"))).toBe(true);
160
+ });
161
+
162
+ it("accepts multiple connections with different IDs", () => {
163
+ const input = makeValidInput({
164
+ connections: [
165
+ { id: "openai-main", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
166
+ { id: "ollama-local", name: "Ollama", provider: "ollama", baseUrl: "http://localhost:11434", apiKey: "" },
167
+ ],
168
+ });
169
+ const result = validateSetupInput(input);
170
+ expect(result.valid).toBe(true);
171
+ });
172
+ });
173
+
174
+ // ── Tests: buildSecretsFromSetup ─────────────────────────────────────────
175
+
176
+ describe("buildSecretsFromSetup", () => {
177
+ it("includes admin token in both keys", () => {
178
+ const secrets = buildSecretsFromSetup(makeValidInput());
179
+ expect(secrets.OPENPALM_ADMIN_TOKEN).toBe("test-admin-token-12345");
180
+ expect(secrets.ADMIN_TOKEN).toBe("test-admin-token-12345");
181
+ });
182
+
183
+ it("sets SYSTEM_LLM_* from the LLM connection", () => {
184
+ const secrets = buildSecretsFromSetup(makeValidInput());
185
+ expect(secrets.SYSTEM_LLM_PROVIDER).toBe("openai");
186
+ expect(secrets.SYSTEM_LLM_MODEL).toBe("gpt-4o");
187
+ expect(secrets.SYSTEM_LLM_BASE_URL).toBe("https://api.openai.com");
188
+ expect(secrets.OPENAI_BASE_URL).toBe("https://api.openai.com/v1");
189
+ });
190
+
191
+ it("sets MEMORY_USER_ID", () => {
192
+ const secrets = buildSecretsFromSetup(makeValidInput());
193
+ expect(secrets.MEMORY_USER_ID).toBe("test_user");
194
+ });
195
+
196
+ it("defaults MEMORY_USER_ID when empty", () => {
197
+ const secrets = buildSecretsFromSetup(makeValidInput({ memoryUserId: "" }));
198
+ expect(secrets.MEMORY_USER_ID).toBe("default_user");
199
+ });
200
+
201
+ it("sets owner info when provided", () => {
202
+ const secrets = buildSecretsFromSetup(makeValidInput());
203
+ expect(secrets.OWNER_NAME).toBe("Test User");
204
+ expect(secrets.OWNER_EMAIL).toBe("test@example.com");
205
+ });
206
+
207
+ it("omits owner info when empty", () => {
208
+ const secrets = buildSecretsFromSetup(makeValidInput({ ownerName: "", ownerEmail: "" }));
209
+ expect(secrets.OWNER_NAME).toBeUndefined();
210
+ expect(secrets.OWNER_EMAIL).toBeUndefined();
211
+ });
212
+
213
+ it("maps API key to correct env var", () => {
214
+ const secrets = buildSecretsFromSetup(makeValidInput());
215
+ expect(secrets.OPENAI_API_KEY).toBe("sk-test-key-123");
216
+ });
217
+
218
+ it("overrides Ollama base URL when ollamaEnabled is true", () => {
219
+ const input = makeValidInput({
220
+ ollamaEnabled: true,
221
+ connections: [
222
+ { id: "ollama-1", name: "Ollama", provider: "ollama", baseUrl: "http://localhost:11434", apiKey: "" },
223
+ ],
224
+ assignments: {
225
+ llm: { connectionId: "ollama-1", model: "llama3.2" },
226
+ embeddings: { connectionId: "ollama-1", model: "nomic-embed-text" },
227
+ },
228
+ });
229
+ const secrets = buildSecretsFromSetup(input);
230
+ // System LLM base URL should use in-stack Ollama URL
231
+ expect(secrets.SYSTEM_LLM_BASE_URL).toBe("http://ollama:11434");
232
+ expect(secrets.OPENAI_BASE_URL).toBe("http://ollama:11434/v1");
233
+ });
234
+ });
235
+
236
+ // ── Tests: buildConnectionEnvVarMap ──────────────────────────────────────
237
+
238
+ describe("buildConnectionEnvVarMap", () => {
239
+ it("maps a single OpenAI connection", () => {
240
+ const connections: SetupConnection[] = [
241
+ { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
242
+ ];
243
+ const map = buildConnectionEnvVarMap(connections);
244
+ expect(map.get("openai-1")).toBe("OPENAI_API_KEY");
245
+ });
246
+
247
+ it("namespaces duplicate provider env vars with safe IDs", () => {
248
+ const connections: SetupConnection[] = [
249
+ { id: "openai_1", name: "OpenAI Primary", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
250
+ { id: "openai_2", name: "OpenAI Secondary", provider: "openai", baseUrl: "", apiKey: "sk-def" },
251
+ ];
252
+ const map = buildConnectionEnvVarMap(connections);
253
+ expect(map.get("openai_1")).toBe("OPENAI_API_KEY");
254
+ expect(map.get("openai_2")).toBe("OPENAI_API_KEY_OPENAI_2");
255
+ });
256
+
257
+ it("skips connections with unsafe env var keys (hyphen in ID)", () => {
258
+ const connections: SetupConnection[] = [
259
+ { id: "openai-1", name: "OpenAI Primary", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
260
+ { id: "openai-2", name: "OpenAI Secondary", provider: "openai", baseUrl: "", apiKey: "sk-def" },
261
+ ];
262
+ const map = buildConnectionEnvVarMap(connections);
263
+ expect(map.get("openai-1")).toBe("OPENAI_API_KEY");
264
+ // openai-2 generates OPENAI_API_KEY_OPENAI-2 which fails the SAFE_ENV_KEY_RE (hyphen)
265
+ expect(map.has("openai-2")).toBe(false);
266
+ });
267
+
268
+ it("maps different providers to their canonical env vars", () => {
269
+ const connections: SetupConnection[] = [
270
+ { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
271
+ { id: "groq-1", name: "Groq", provider: "groq", baseUrl: "", apiKey: "gsk-abc" },
272
+ ];
273
+ const map = buildConnectionEnvVarMap(connections);
274
+ expect(map.get("openai-1")).toBe("OPENAI_API_KEY");
275
+ expect(map.get("groq-1")).toBe("GROQ_API_KEY");
276
+ });
277
+
278
+ it("uses OPENAI_API_KEY fallback for unmapped providers", () => {
279
+ const connections: SetupConnection[] = [
280
+ { id: "ollama-1", name: "Ollama", provider: "ollama", baseUrl: "", apiKey: "" },
281
+ ];
282
+ const map = buildConnectionEnvVarMap(connections);
283
+ expect(map.get("ollama-1")).toBe("OPENAI_API_KEY");
284
+ });
285
+ });
286
+
287
+ // ── Tests: performSetup ──────────────────────────────────────────────────
288
+
289
+ describe("performSetup", () => {
290
+ let tempBase: string;
291
+ let configDir: string;
292
+ let dataDir: string;
293
+ let stateDir: string;
294
+
295
+ const savedEnv: Record<string, string | undefined> = {};
296
+
297
+ beforeEach(() => {
298
+ tempBase = mkdtempSync(join(tmpdir(), "openpalm-setup-"));
299
+ configDir = join(tempBase, "config");
300
+ dataDir = join(tempBase, "data");
301
+ stateDir = join(tempBase, "state");
302
+
303
+ // Create required directory structure
304
+ for (const dir of [
305
+ configDir,
306
+ join(configDir, "channels"),
307
+ join(configDir, "connections"),
308
+ join(configDir, "assistant"),
309
+ join(configDir, "automations"),
310
+ join(configDir, "stash"),
311
+ dataDir,
312
+ join(dataDir, "admin"),
313
+ join(dataDir, "memory"),
314
+ join(dataDir, "assistant"),
315
+ join(dataDir, "guardian"),
316
+ join(dataDir, "caddy"),
317
+ join(dataDir, "caddy", "data"),
318
+ join(dataDir, "caddy", "config"),
319
+ join(dataDir, "automations"),
320
+ join(dataDir, "opencode"),
321
+ stateDir,
322
+ join(stateDir, "artifacts"),
323
+ join(stateDir, "audit"),
324
+ join(stateDir, "artifacts", "channels"),
325
+ join(stateDir, "automations"),
326
+ join(stateDir, "opencode"),
327
+ ]) {
328
+ mkdirSync(dir, { recursive: true });
329
+ }
330
+
331
+ // Create stub stack.env so isSetupComplete doesn't crash
332
+ writeFileSync(join(stateDir, "artifacts", "stack.env"), "OPENPALM_SETUP_COMPLETE=false\n");
333
+
334
+ // Seed a secrets.env file to avoid ensureSecrets() file-not-found
335
+ writeFileSync(
336
+ join(configDir, "secrets.env"),
337
+ [
338
+ "# OpenPalm Secrets",
339
+ "export OPENPALM_ADMIN_TOKEN=",
340
+ "export ADMIN_TOKEN=",
341
+ "export OPENAI_API_KEY=",
342
+ "export OPENAI_BASE_URL=",
343
+ "export ANTHROPIC_API_KEY=",
344
+ "export GROQ_API_KEY=",
345
+ "export MISTRAL_API_KEY=",
346
+ "export GOOGLE_API_KEY=",
347
+ "export MEMORY_USER_ID=default_user",
348
+ "export MEMORY_AUTH_TOKEN=abc123",
349
+ "export OWNER_NAME=",
350
+ "export OWNER_EMAIL=",
351
+ "",
352
+ ].join("\n")
353
+ );
354
+
355
+ // Override env vars for test isolation
356
+ savedEnv.OPENPALM_CONFIG_HOME = process.env.OPENPALM_CONFIG_HOME;
357
+ savedEnv.OPENPALM_DATA_HOME = process.env.OPENPALM_DATA_HOME;
358
+ savedEnv.OPENPALM_STATE_HOME = process.env.OPENPALM_STATE_HOME;
359
+ process.env.OPENPALM_CONFIG_HOME = configDir;
360
+ process.env.OPENPALM_DATA_HOME = dataDir;
361
+ process.env.OPENPALM_STATE_HOME = stateDir;
362
+ });
363
+
364
+ afterEach(() => {
365
+ process.env.OPENPALM_CONFIG_HOME = savedEnv.OPENPALM_CONFIG_HOME;
366
+ process.env.OPENPALM_DATA_HOME = savedEnv.OPENPALM_DATA_HOME;
367
+ process.env.OPENPALM_STATE_HOME = savedEnv.OPENPALM_STATE_HOME;
368
+ rmSync(tempBase, { recursive: true, force: true });
369
+ });
370
+
371
+ it("returns an error for invalid input", async () => {
372
+ const result = await performSetup(
373
+ { adminToken: "short" } as SetupInput,
374
+ createStubAssetProvider()
375
+ );
376
+ expect(result.ok).toBe(false);
377
+ expect(result.error).toBeDefined();
378
+ });
379
+
380
+ it("writes secrets.env with the admin token", async () => {
381
+ const result = await performSetup(makeValidInput(), createStubAssetProvider());
382
+ expect(result.ok).toBe(true);
383
+
384
+ const secretsContent = readFileSync(join(configDir, "secrets.env"), "utf-8");
385
+ expect(secretsContent).toContain("test-admin-token-12345");
386
+ });
387
+
388
+ it("writes memory config", async () => {
389
+ const result = await performSetup(makeValidInput(), createStubAssetProvider());
390
+ expect(result.ok).toBe(true);
391
+
392
+ const memConfigPath = join(dataDir, "memory", "default_config.json");
393
+ expect(existsSync(memConfigPath)).toBe(true);
394
+
395
+ const memConfig = JSON.parse(readFileSync(memConfigPath, "utf-8"));
396
+ expect(memConfig.mem0.llm.config.model).toBe("gpt-4o");
397
+ expect(memConfig.mem0.embedder.config.model).toBe("text-embedding-3-small");
398
+ });
399
+
400
+ it("writes connection profiles document", async () => {
401
+ const result = await performSetup(makeValidInput(), createStubAssetProvider());
402
+ expect(result.ok).toBe(true);
403
+
404
+ const profilesPath = join(configDir, "connections", "profiles.json");
405
+ expect(existsSync(profilesPath)).toBe(true);
406
+
407
+ const doc = JSON.parse(readFileSync(profilesPath, "utf-8"));
408
+ expect(doc.version).toBe(1);
409
+ expect(doc.profiles).toHaveLength(1);
410
+ expect(doc.profiles[0].id).toBe("openai-main");
411
+ expect(doc.assignments.llm.model).toBe("gpt-4o");
412
+ expect(doc.assignments.embeddings.model).toBe("text-embedding-3-small");
413
+ });
414
+
415
+ it("creates staged artifacts directory", async () => {
416
+ const result = await performSetup(makeValidInput(), createStubAssetProvider());
417
+ expect(result.ok).toBe(true);
418
+
419
+ // applyInstall should have staged the compose file
420
+ const stagedCompose = join(stateDir, "artifacts", "docker-compose.yml");
421
+ expect(existsSync(stagedCompose)).toBe(true);
422
+ });
423
+
424
+ it("uses Ollama in-stack URL when ollamaEnabled is true", async () => {
425
+ const input = makeValidInput({
426
+ ollamaEnabled: true,
427
+ connections: [
428
+ {
429
+ id: "ollama-local",
430
+ name: "Ollama",
431
+ provider: "ollama",
432
+ baseUrl: "http://localhost:11434",
433
+ apiKey: "",
434
+ },
435
+ ],
436
+ assignments: {
437
+ llm: { connectionId: "ollama-local", model: "llama3.2" },
438
+ embeddings: { connectionId: "ollama-local", model: "nomic-embed-text" },
439
+ },
440
+ });
441
+
442
+ const result = await performSetup(input, createStubAssetProvider());
443
+ expect(result.ok).toBe(true);
444
+
445
+ // Connection profiles should use the in-stack URL
446
+ const profilesPath = join(configDir, "connections", "profiles.json");
447
+ const doc = JSON.parse(readFileSync(profilesPath, "utf-8"));
448
+ expect(doc.profiles[0].baseUrl).toBe("http://ollama:11434");
449
+ });
450
+
451
+ it("resolves embedding dims from EMBEDDING_DIMS lookup", async () => {
452
+ const input = makeValidInput({
453
+ connections: [
454
+ {
455
+ id: "ollama-local",
456
+ name: "Ollama",
457
+ provider: "ollama",
458
+ baseUrl: "http://localhost:11434",
459
+ apiKey: "",
460
+ },
461
+ ],
462
+ assignments: {
463
+ llm: { connectionId: "ollama-local", model: "llama3.2" },
464
+ embeddings: { connectionId: "ollama-local", model: "nomic-embed-text" },
465
+ },
466
+ });
467
+
468
+ const result = await performSetup(input, createStubAssetProvider());
469
+ expect(result.ok).toBe(true);
470
+
471
+ // nomic-embed-text is 768 dims per EMBEDDING_DIMS
472
+ const memConfigPath = join(dataDir, "memory", "default_config.json");
473
+ const memConfig = JSON.parse(readFileSync(memConfigPath, "utf-8"));
474
+ expect(memConfig.mem0.vector_store.config.embedding_model_dims).toBe(768);
475
+ });
476
+ });