@openpalm/lib 0.9.8 → 0.10.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.
Files changed (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +159 -849
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. package/src/control-plane/staging.ts +0 -399
@@ -2,31 +2,35 @@ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { parse as yamlParse } from "yaml";
6
5
  import {
7
- validateSetupInput,
6
+ validateSetupSpec,
8
7
  buildSecretsFromSetup,
9
- buildConnectionEnvVarMap,
8
+ buildSystemSecretsFromSetup,
10
9
  performSetup,
11
- validateSetupConfig,
12
- normalizeToSetupInput,
13
- buildChannelCredentialEnvVars,
14
- performSetupFromConfig,
15
- CHANNEL_CREDENTIAL_ENV_MAP,
16
10
  } from "./setup.js";
17
- import type { SetupInput, SetupConnection, SetupConfig } from "./setup.js";
18
- import type { CoreAssetProvider } from "./core-asset-provider.js";
19
- import { STACK_SPEC_FILENAME } from "./stack-spec.js";
11
+ import type { SetupSpec, SetupConnection } from "./setup.js";
12
+ import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
13
+ import type { StackSpec } from "./stack-spec.js";
20
14
 
21
15
  // ── Helpers ──────────────────────────────────────────────────────────────
22
16
 
23
- function makeValidInput(overrides?: Partial<SetupInput>): SetupInput {
17
+ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
24
18
  return {
25
- adminToken: "test-admin-token-12345",
26
- ownerName: "Test User",
27
- ownerEmail: "test@example.com",
28
- memoryUserId: "test_user",
29
- ollamaEnabled: false,
19
+ version: 2,
20
+ capabilities: {
21
+ llm: "openai/gpt-4o",
22
+ embeddings: {
23
+ provider: "openai",
24
+ model: "text-embedding-3-small",
25
+ dims: 1536,
26
+ },
27
+ memory: {
28
+ userId: "test_user",
29
+ customInstructions: "",
30
+ },
31
+ },
32
+ security: { adminToken: "test-admin-token-12345" },
33
+ owner: { name: "Test User", email: "test@example.com" },
30
34
  connections: [
31
35
  {
32
36
  id: "openai-main",
@@ -36,67 +40,70 @@ function makeValidInput(overrides?: Partial<SetupInput>): SetupInput {
36
40
  apiKey: "sk-test-key-123",
37
41
  },
38
42
  ],
39
- assignments: {
40
- llm: { connectionId: "openai-main", model: "gpt-4o" },
41
- embeddings: { connectionId: "openai-main", model: "text-embedding-3-small" },
42
- },
43
43
  ...overrides,
44
44
  };
45
45
  }
46
46
 
47
- /** Stub asset provider that returns minimal content for all assets. */
48
- function createStubAssetProvider(): CoreAssetProvider {
49
- return {
50
- coreCompose: () => "services:\n caddy:\n image: caddy:latest\n",
51
- caddyfile: () =>
52
- ":80 {\n @denied not remote_ip 127.0.0.0/8 ::1\n respond @denied 403\n}\n",
53
- ollamaCompose: () => "services:\n ollama:\n image: ollama/ollama\n",
54
- adminCompose: () => "services:\n admin:\n image: openpalm/admin\n",
55
- agentsMd: () => "# Agents\n",
56
- opencodeConfig: () => '{"$schema":"https://opencode.ai/config.json"}\n',
57
- adminOpencodeConfig: () => '{"$schema":"https://opencode.ai/config.json","plugin":["@openpalm/admin-tools"]}\n',
58
- secretsSchema: () => "ADMIN_TOKEN=string\n",
59
- stackSchema: () => "OPENPALM_IMAGE_TAG=string\n",
60
- cleanupLogs: () => "name: cleanup-logs\nschedule: daily\n",
61
- cleanupData: () => "name: cleanup-data\nschedule: weekly\n",
62
- validateConfig: () => "name: validate-config\nschedule: hourly\n",
63
- };
47
+ /** Seed the minimal asset files that ensure* functions expect to find at OP_HOME. */
48
+ function seedRequiredAssets(homeDir: string): void {
49
+ mkdirSync(join(homeDir, "stack"), { recursive: true });
50
+ writeFileSync(join(homeDir, "stack", "core.compose.yml"), "services:\n assistant:\n image: assistant:latest\n");
51
+ mkdirSync(join(homeDir, "data", "assistant"), { recursive: true });
52
+ writeFileSync(join(homeDir, "data", "assistant", "opencode.jsonc"), '{"$schema":"https://opencode.ai/config.json"}\n');
53
+ writeFileSync(join(homeDir, "data", "assistant", "AGENTS.md"), "# Agents\n");
54
+ mkdirSync(join(homeDir, "vault", "user"), { recursive: true });
55
+ writeFileSync(join(homeDir, "vault", "user", "user.env.schema"), "ADMIN_TOKEN=string\n");
56
+ mkdirSync(join(homeDir, "vault", "stack"), { recursive: true });
57
+ writeFileSync(join(homeDir, "vault", "stack", "stack.env.schema"), "OP_IMAGE_TAG=string\n");
58
+ mkdirSync(join(homeDir, "config", "automations"), { recursive: true });
59
+ writeFileSync(join(homeDir, "config", "automations", "cleanup-logs.yml"), "name: cleanup-logs\nschedule: daily\n");
60
+ writeFileSync(join(homeDir, "config", "automations", "cleanup-data.yml"), "name: cleanup-data\nschedule: weekly\n");
61
+ writeFileSync(join(homeDir, "config", "automations", "validate-config.yml"), "name: validate-config\nschedule: hourly\n");
64
62
  }
65
63
 
66
- // ── Tests: validateSetupInput ────────────────────────────────────────────
64
+ // ── Tests: validateSetupSpec ────────────────────────────────────────────
67
65
 
68
- describe("validateSetupInput", () => {
66
+ describe("validateSetupSpec", () => {
69
67
  it("accepts a valid input", () => {
70
- const result = validateSetupInput(makeValidInput());
68
+ const result = validateSetupSpec(makeValidSpec());
71
69
  expect(result.valid).toBe(true);
72
70
  expect(result.errors).toHaveLength(0);
73
71
  });
74
72
 
75
73
  it("rejects null input", () => {
76
- const result = validateSetupInput(null);
74
+ const result = validateSetupSpec(null);
77
75
  expect(result.valid).toBe(false);
78
76
  expect(result.errors).toContain("Input must be a non-null object");
79
77
  });
80
78
 
81
- it("rejects missing adminToken", () => {
82
- const input = makeValidInput({ adminToken: "" });
83
- const result = validateSetupInput(input);
79
+ it("rejects missing security object", () => {
80
+ const spec = makeValidSpec();
81
+ (spec as Record<string, unknown>).security = null;
82
+ const result = validateSetupSpec(spec);
84
83
  expect(result.valid).toBe(false);
85
- expect(result.errors.some((e) => e.includes("adminToken"))).toBe(true);
84
+ expect(result.errors.some((e) => e.includes("security object is required"))).toBe(true);
86
85
  });
87
86
 
88
- it("rejects short adminToken", () => {
89
- const input = makeValidInput({ adminToken: "short" });
90
- const result = validateSetupInput(input);
87
+ it("rejects missing security.adminToken", () => {
88
+ const spec = makeValidSpec();
89
+ spec.security.adminToken = "";
90
+ const result = validateSetupSpec(spec);
91
91
  expect(result.valid).toBe(false);
92
- expect(result.errors.some((e) => e.includes("at least 8"))).toBe(true);
92
+ expect(result.errors.some((e) => e.includes("security.adminToken"))).toBe(true);
93
93
  });
94
94
 
95
- it("rejects empty connections array", () => {
96
- const input = makeValidInput({ connections: [] });
97
- const result = validateSetupInput(input);
95
+ it("rejects short security.adminToken", () => {
96
+ const spec = makeValidSpec();
97
+ spec.security.adminToken = "short";
98
+ const result = validateSetupSpec(spec);
98
99
  expect(result.valid).toBe(false);
99
- expect(result.errors.some((e) => e.includes("connections"))).toBe(true);
100
+ expect(result.errors.some((e) => e.includes("at least 8"))).toBe(true);
101
+ });
102
+
103
+ it("accepts empty connections array", () => {
104
+ const spec = makeValidSpec({ connections: [] });
105
+ const result = validateSetupSpec(spec);
106
+ expect(result.valid).toBe(true);
100
107
  });
101
108
 
102
109
  it("rejects duplicate connection IDs", () => {
@@ -107,94 +114,125 @@ describe("validateSetupInput", () => {
107
114
  baseUrl: "",
108
115
  apiKey: "",
109
116
  };
110
- const input = makeValidInput({ connections: [conn, conn] });
111
- const result = validateSetupInput(input);
117
+ const spec = makeValidSpec({ connections: [conn, conn] });
118
+ const result = validateSetupSpec(spec);
112
119
  expect(result.valid).toBe(false);
113
120
  expect(result.errors.some((e) => e.includes("Duplicate"))).toBe(true);
114
121
  });
115
122
 
116
- it("rejects unsupported provider", () => {
117
- const input = makeValidInput({
123
+ it("accepts any provider string", () => {
124
+ const spec = makeValidSpec({
118
125
  connections: [
119
- { id: "bad", name: "Bad", provider: "unsupported-provider", baseUrl: "", apiKey: "" },
126
+ { id: "custom", name: "Custom", provider: "any-provider", baseUrl: "", apiKey: "" },
120
127
  ],
121
128
  });
122
- const result = validateSetupInput(input);
123
- expect(result.valid).toBe(false);
124
- expect(result.errors.some((e) => e.includes("outside wizard scope"))).toBe(true);
129
+ const result = validateSetupSpec(spec);
130
+ expect(result.valid).toBe(true);
125
131
  });
126
132
 
127
133
  it("rejects invalid connection ID pattern", () => {
128
- const input = makeValidInput({
134
+ const spec = makeValidSpec({
129
135
  connections: [
130
136
  { id: "-invalid", name: "Bad", provider: "openai", baseUrl: "", apiKey: "" },
131
137
  ],
132
138
  });
133
- const result = validateSetupInput(input);
139
+ const result = validateSetupSpec(spec);
134
140
  expect(result.valid).toBe(false);
135
141
  expect(result.errors.some((e) => e.includes("must start with a letter or digit"))).toBe(true);
136
142
  });
137
143
 
138
- it("rejects missing assignments.llm", () => {
139
- const input = makeValidInput();
140
- (input.assignments as Record<string, unknown>).llm = null;
141
- const result = validateSetupInput(input);
144
+ it("rejects wrong version", () => {
145
+ const input = makeValidSpec();
146
+ (input as Record<string, unknown>).version = 1;
147
+ const result = validateSetupSpec(input);
148
+ expect(result.valid).toBe(false);
149
+ expect(result.errors.some((e) => e.includes("version must be 2"))).toBe(true);
150
+ });
151
+
152
+ it("rejects missing capabilities.llm", () => {
153
+ const input = makeValidSpec();
154
+ (input.capabilities as Record<string, unknown>).llm = "";
155
+ const result = validateSetupSpec(input);
142
156
  expect(result.valid).toBe(false);
143
- expect(result.errors.some((e) => e.includes("assignments.llm"))).toBe(true);
157
+ expect(result.errors.some((e) => e.includes("capabilities.llm"))).toBe(true);
144
158
  });
145
159
 
146
- it("rejects missing assignments.embeddings", () => {
147
- const input = makeValidInput();
148
- (input.assignments as Record<string, unknown>).embeddings = null;
149
- const result = validateSetupInput(input);
160
+ it("rejects missing capabilities.embeddings", () => {
161
+ const input = makeValidSpec();
162
+ (input.capabilities as Record<string, unknown>).embeddings = null;
163
+ const result = validateSetupSpec(input);
150
164
  expect(result.valid).toBe(false);
151
- expect(result.errors.some((e) => e.includes("assignments.embeddings"))).toBe(true);
165
+ expect(result.errors.some((e) => e.includes("capabilities.embeddings"))).toBe(true);
152
166
  });
153
167
 
154
- it("rejects non-integer embeddingDims", () => {
155
- const input = makeValidInput();
156
- input.assignments.embeddings.embeddingDims = 1.5;
157
- const result = validateSetupInput(input);
168
+ it("rejects missing capabilities.memory", () => {
169
+ const input = makeValidSpec();
170
+ (input.capabilities as Record<string, unknown>).memory = null;
171
+ const result = validateSetupSpec(input);
158
172
  expect(result.valid).toBe(false);
159
- expect(result.errors.some((e) => e.includes("embeddingDims"))).toBe(true);
173
+ expect(result.errors.some((e) => e.includes("capabilities.memory"))).toBe(true);
160
174
  });
161
175
 
162
- it("rejects assignment referencing non-existent connection", () => {
163
- const input = makeValidInput();
164
- input.assignments.llm.connectionId = "does-not-exist";
165
- const result = validateSetupInput(input);
176
+ it("rejects non-integer embeddings.dims", () => {
177
+ const input = makeValidSpec();
178
+ input.capabilities.embeddings.dims = 1.5;
179
+ const result = validateSetupSpec(input);
166
180
  expect(result.valid).toBe(false);
167
- expect(result.errors.some((e) => e.includes("does not match any connection"))).toBe(true);
181
+ expect(result.errors.some((e) => e.includes("dims must be a positive integer"))).toBe(true); // or 0 (auto-resolve)
168
182
  });
169
183
 
170
184
  it("accepts multiple connections with different IDs", () => {
171
- const input = makeValidInput({
185
+ const spec = makeValidSpec({
172
186
  connections: [
173
187
  { id: "openai-main", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
174
188
  { id: "ollama-local", name: "Ollama", provider: "ollama", baseUrl: "http://localhost:11434", apiKey: "" },
175
189
  ],
176
190
  });
177
- const result = validateSetupInput(input);
191
+ const result = validateSetupSpec(spec);
178
192
  expect(result.valid).toBe(true);
179
193
  });
180
194
 
181
- it("rejects memoryUserId with dots", () => {
182
- const input = makeValidInput({ memoryUserId: "user.name" });
183
- const result = validateSetupInput(input);
195
+ it("rejects memory.userId with dots", () => {
196
+ const input = makeValidSpec();
197
+ input.capabilities.memory.userId = "user.name";
198
+ const result = validateSetupSpec(input);
184
199
  expect(result.valid).toBe(false);
185
200
  expect(result.errors.some((e) => e.includes("alphanumeric and underscores only"))).toBe(true);
186
201
  });
187
202
 
188
- it("rejects memoryUserId with hyphens", () => {
189
- const input = makeValidInput({ memoryUserId: "user-name" });
190
- const result = validateSetupInput(input);
203
+ it("rejects memory.userId with hyphens", () => {
204
+ const input = makeValidSpec();
205
+ input.capabilities.memory.userId = "user-name";
206
+ const result = validateSetupSpec(input);
191
207
  expect(result.valid).toBe(false);
192
208
  expect(result.errors.some((e) => e.includes("alphanumeric and underscores only"))).toBe(true);
193
209
  });
194
210
 
195
- it("accepts memoryUserId with underscores", () => {
196
- const input = makeValidInput({ memoryUserId: "user_name_123" });
197
- const result = validateSetupInput(input);
211
+ it("accepts memory.userId with underscores", () => {
212
+ const input = makeValidSpec();
213
+ input.capabilities.memory.userId = "user_name_123";
214
+ const result = validateSetupSpec(input);
215
+ expect(result.valid).toBe(true);
216
+ });
217
+
218
+ it("accepts valid owner fields", () => {
219
+ const spec = makeValidSpec({ owner: { name: "Alice", email: "alice@test.com" } });
220
+ const result = validateSetupSpec(spec);
221
+ expect(result.valid).toBe(true);
222
+ });
223
+
224
+ it("rejects non-string owner.name", () => {
225
+ const spec = makeValidSpec();
226
+ (spec.owner as Record<string, unknown>).name = 42;
227
+ const result = validateSetupSpec(spec);
228
+ expect(result.valid).toBe(false);
229
+ expect(result.errors.some((e) => e.includes("owner.name"))).toBe(true);
230
+ });
231
+
232
+ it("accepts valid memory section", () => {
233
+ const spec = makeValidSpec();
234
+ spec.capabilities.memory.userId = "my_user";
235
+ const result = validateSetupSpec(spec);
198
236
  expect(result.valid).toBe(true);
199
237
  });
200
238
  });
@@ -202,256 +240,248 @@ describe("validateSetupInput", () => {
202
240
  // ── Tests: buildSecretsFromSetup ─────────────────────────────────────────
203
241
 
204
242
  describe("buildSecretsFromSetup", () => {
205
- it("includes admin token in both keys", () => {
206
- const secrets = buildSecretsFromSetup(makeValidInput());
207
- expect(secrets.OPENPALM_ADMIN_TOKEN).toBe("test-admin-token-12345");
208
- expect(secrets.ADMIN_TOKEN).toBe("test-admin-token-12345");
243
+ it("does not include admin token in user secrets", () => {
244
+ const spec = makeValidSpec();
245
+ const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
246
+ expect(secrets.OP_ADMIN_TOKEN).toBeUndefined();
247
+ expect(secrets.ADMIN_TOKEN).toBeUndefined();
209
248
  });
210
249
 
211
- it("sets SYSTEM_LLM_* from the LLM connection", () => {
212
- const secrets = buildSecretsFromSetup(makeValidInput());
213
- expect(secrets.SYSTEM_LLM_PROVIDER).toBe("openai");
214
- expect(secrets.SYSTEM_LLM_MODEL).toBe("gpt-4o");
215
- expect(secrets.SYSTEM_LLM_BASE_URL).toBe("https://api.openai.com");
216
- expect(secrets.OPENAI_BASE_URL).toBe("https://api.openai.com/v1");
250
+ it("does not include SYSTEM_LLM_* in user secrets (lives in stack.env via OP_CAP_*)", () => {
251
+ const spec = makeValidSpec();
252
+ const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
253
+ expect(secrets.SYSTEM_LLM_PROVIDER).toBeUndefined();
254
+ expect(secrets.SYSTEM_LLM_MODEL).toBeUndefined();
255
+ expect(secrets.SYSTEM_LLM_BASE_URL).toBeUndefined();
217
256
  });
218
257
 
219
- it("sets MEMORY_USER_ID", () => {
220
- const secrets = buildSecretsFromSetup(makeValidInput());
221
- expect(secrets.MEMORY_USER_ID).toBe("test_user");
258
+ it("persists OPENAI_BASE_URL from openai connection", () => {
259
+ const spec = makeValidSpec();
260
+ const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
261
+ expect(secrets.OPENAI_BASE_URL).toBe("https://api.openai.com");
222
262
  });
223
263
 
224
- it("defaults MEMORY_USER_ID when empty", () => {
225
- const secrets = buildSecretsFromSetup(makeValidInput({ memoryUserId: "" }));
226
- expect(secrets.MEMORY_USER_ID).toBe("default_user");
264
+ it("does not include MEMORY_USER_ID in user secrets (lives in stack.env via OP_CAP_*)", () => {
265
+ const spec = makeValidSpec();
266
+ const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
267
+ expect(secrets.MEMORY_USER_ID).toBeUndefined();
227
268
  });
228
269
 
229
270
  it("sets owner info when provided", () => {
230
- const secrets = buildSecretsFromSetup(makeValidInput());
271
+ const spec = makeValidSpec();
272
+ const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
231
273
  expect(secrets.OWNER_NAME).toBe("Test User");
232
274
  expect(secrets.OWNER_EMAIL).toBe("test@example.com");
233
275
  });
234
276
 
235
277
  it("omits owner info when empty", () => {
236
- const secrets = buildSecretsFromSetup(makeValidInput({ ownerName: "", ownerEmail: "" }));
278
+ const spec = makeValidSpec({ owner: { name: "", email: "" } });
279
+ const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
237
280
  expect(secrets.OWNER_NAME).toBeUndefined();
238
281
  expect(secrets.OWNER_EMAIL).toBeUndefined();
239
282
  });
240
283
 
241
284
  it("maps API key to correct env var", () => {
242
- const secrets = buildSecretsFromSetup(makeValidInput());
285
+ const spec = makeValidSpec();
286
+ const secrets = buildSecretsFromSetup(spec.connections, spec.owner);
243
287
  expect(secrets.OPENAI_API_KEY).toBe("sk-test-key-123");
244
288
  });
245
289
 
246
- it("overrides Ollama base URL when ollamaEnabled is true", () => {
247
- const input = makeValidInput({
248
- ollamaEnabled: true,
249
- connections: [
250
- { id: "ollama-1", name: "Ollama", provider: "ollama", baseUrl: "http://localhost:11434", apiKey: "" },
251
- ],
252
- assignments: {
253
- llm: { connectionId: "ollama-1", model: "llama3.2" },
254
- embeddings: { connectionId: "ollama-1", model: "nomic-embed-text" },
255
- },
256
- });
257
- const secrets = buildSecretsFromSetup(input);
258
- // System LLM base URL should use in-stack Ollama URL
259
- expect(secrets.SYSTEM_LLM_BASE_URL).toBe("http://ollama:11434");
260
- expect(secrets.OPENAI_BASE_URL).toBe("http://ollama:11434/v1");
261
- });
262
- });
263
-
264
- // ── Tests: buildConnectionEnvVarMap ──────────────────────────────────────
265
-
266
- describe("buildConnectionEnvVarMap", () => {
267
- it("maps a single OpenAI connection", () => {
268
- const connections: SetupConnection[] = [
269
- { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
270
- ];
271
- const map = buildConnectionEnvVarMap(connections);
272
- expect(map.get("openai-1")).toBe("OPENAI_API_KEY");
273
- });
274
-
275
- it("namespaces duplicate provider env vars with safe IDs", () => {
276
- const connections: SetupConnection[] = [
277
- { id: "openai_1", name: "OpenAI Primary", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
278
- { id: "openai_2", name: "OpenAI Secondary", provider: "openai", baseUrl: "", apiKey: "sk-def" },
279
- ];
280
- const map = buildConnectionEnvVarMap(connections);
281
- expect(map.get("openai_1")).toBe("OPENAI_API_KEY");
282
- expect(map.get("openai_2")).toBe("OPENAI_API_KEY_OPENAI_2");
290
+ it("falls back to process.env when apiKey is empty", () => {
291
+ const saved = process.env.OPENAI_API_KEY;
292
+ process.env.OPENAI_API_KEY = "sk-from-env";
293
+ try {
294
+ const caps: SetupConnection[] = [
295
+ { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "" },
296
+ ];
297
+ const secrets = buildSecretsFromSetup(caps);
298
+ expect(secrets.OPENAI_API_KEY).toBe("sk-from-env");
299
+ } finally {
300
+ if (saved !== undefined) process.env.OPENAI_API_KEY = saved;
301
+ else delete process.env.OPENAI_API_KEY;
302
+ }
283
303
  });
284
304
 
285
- it("skips connections with unsafe env var keys (hyphen in ID)", () => {
286
- const connections: SetupConnection[] = [
287
- { id: "openai-1", name: "OpenAI Primary", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
288
- { id: "openai-2", name: "OpenAI Secondary", provider: "openai", baseUrl: "", apiKey: "sk-def" },
289
- ];
290
- const map = buildConnectionEnvVarMap(connections);
291
- expect(map.get("openai-1")).toBe("OPENAI_API_KEY");
292
- // openai-2 generates OPENAI_API_KEY_OPENAI-2 which fails the SAFE_ENV_KEY_RE (hyphen)
293
- expect(map.has("openai-2")).toBe(false);
305
+ it("spec apiKey takes precedence over process.env", () => {
306
+ const saved = process.env.OPENAI_API_KEY;
307
+ process.env.OPENAI_API_KEY = "sk-from-env";
308
+ try {
309
+ const caps: SetupConnection[] = [
310
+ { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-from-spec" },
311
+ ];
312
+ const secrets = buildSecretsFromSetup(caps);
313
+ expect(secrets.OPENAI_API_KEY).toBe("sk-from-spec");
314
+ } finally {
315
+ if (saved !== undefined) process.env.OPENAI_API_KEY = saved;
316
+ else delete process.env.OPENAI_API_KEY;
317
+ }
294
318
  });
295
319
 
296
- it("maps different providers to their canonical env vars", () => {
297
- const connections: SetupConnection[] = [
298
- { id: "openai-1", name: "OpenAI", provider: "openai", baseUrl: "", apiKey: "sk-abc" },
299
- { id: "groq-1", name: "Groq", provider: "groq", baseUrl: "", apiKey: "gsk-abc" },
320
+ it("does not include Ollama base URL in user secrets when ollamaEnabled (lives in stack.env via OP_CAP_*)", () => {
321
+ const caps: SetupConnection[] = [
322
+ { id: "ollama-1", name: "Ollama", provider: "ollama", baseUrl: "http://localhost:11434", apiKey: "" },
300
323
  ];
301
- const map = buildConnectionEnvVarMap(connections);
302
- expect(map.get("openai-1")).toBe("OPENAI_API_KEY");
303
- expect(map.get("groq-1")).toBe("GROQ_API_KEY");
324
+ const secrets = buildSecretsFromSetup(caps);
325
+ // These are no longer written to user.env — they live in stack.env via OP_CAP_* vars
326
+ expect(secrets.SYSTEM_LLM_BASE_URL).toBeUndefined();
327
+ expect(secrets.OPENAI_BASE_URL).toBeUndefined();
304
328
  });
329
+ });
305
330
 
306
- it("uses OPENAI_API_KEY fallback for unmapped providers", () => {
307
- const connections: SetupConnection[] = [
308
- { id: "ollama-1", name: "Ollama", provider: "ollama", baseUrl: "", apiKey: "" },
309
- ];
310
- const map = buildConnectionEnvVarMap(connections);
311
- expect(map.get("ollama-1")).toBe("OPENAI_API_KEY");
331
+ describe("buildSystemSecretsFromSetup", () => {
332
+ it("includes distinct admin and assistant credentials", () => {
333
+ const secrets = buildSystemSecretsFromSetup("test-admin-token-12345");
334
+ expect(secrets.OP_ADMIN_TOKEN).toBe("test-admin-token-12345");
335
+ expect(typeof secrets.OP_ASSISTANT_TOKEN).toBe("string");
336
+ expect(secrets.OP_ASSISTANT_TOKEN).not.toBe("test-admin-token-12345");
337
+ expect(typeof secrets.OP_MEMORY_TOKEN).toBe("string");
312
338
  });
313
339
  });
314
340
 
315
341
  // ── Tests: performSetup ──────────────────────────────────────────────────
316
342
 
317
343
  describe("performSetup", () => {
318
- let tempBase: string;
344
+ let homeDir: string;
319
345
  let configDir: string;
346
+ let vaultDir: string;
320
347
  let dataDir: string;
321
- let stateDir: string;
348
+ let logsDir: string;
322
349
 
323
350
  const savedEnv: Record<string, string | undefined> = {};
324
351
 
325
352
  beforeEach(() => {
326
- tempBase = mkdtempSync(join(tmpdir(), "openpalm-setup-"));
327
- configDir = join(tempBase, "config");
328
- dataDir = join(tempBase, "data");
329
- stateDir = join(tempBase, "state");
353
+ homeDir = mkdtempSync(join(tmpdir(), "openpalm-setup-"));
354
+ configDir = join(homeDir, "config");
355
+ vaultDir = join(homeDir, "vault");
356
+ dataDir = join(homeDir, "data");
357
+ logsDir = join(homeDir, "logs");
330
358
 
331
359
  // Create required directory structure
332
360
  for (const dir of [
361
+ homeDir,
333
362
  configDir,
363
+ join(configDir, "automations"),
334
364
  join(configDir, "channels"),
335
- join(configDir, "connections"),
336
365
  join(configDir, "assistant"),
337
- join(configDir, "automations"),
338
366
  join(configDir, "stash"),
367
+ vaultDir,
339
368
  dataDir,
340
369
  join(dataDir, "admin"),
341
370
  join(dataDir, "memory"),
342
371
  join(dataDir, "assistant"),
343
372
  join(dataDir, "guardian"),
344
- join(dataDir, "caddy"),
345
- join(dataDir, "caddy", "data"),
346
- join(dataDir, "caddy", "config"),
347
373
  join(dataDir, "automations"),
348
374
  join(dataDir, "opencode"),
349
- stateDir,
350
- join(stateDir, "artifacts"),
351
- join(stateDir, "audit"),
352
- join(stateDir, "artifacts", "channels"),
353
- join(stateDir, "automations"),
354
- join(stateDir, "opencode"),
375
+ logsDir,
376
+ join(logsDir, "opencode"),
355
377
  ]) {
356
378
  mkdirSync(dir, { recursive: true });
357
379
  }
358
380
 
359
381
  // Create stub stack.env so isSetupComplete doesn't crash
360
- writeFileSync(join(stateDir, "artifacts", "stack.env"), "OPENPALM_SETUP_COMPLETE=false\n");
382
+ mkdirSync(join(vaultDir, "stack"), { recursive: true });
383
+ mkdirSync(join(vaultDir, "user"), { recursive: true });
384
+ writeFileSync(
385
+ join(vaultDir, "stack", "stack.env"),
386
+ [
387
+ "OP_SETUP_COMPLETE=false",
388
+ "OP_ADMIN_TOKEN=",
389
+ "OPENAI_API_KEY=",
390
+ "OPENAI_BASE_URL=",
391
+ "ANTHROPIC_API_KEY=",
392
+ "GROQ_API_KEY=",
393
+ "MISTRAL_API_KEY=",
394
+ "GOOGLE_API_KEY=",
395
+ "OWNER_NAME=",
396
+ "OWNER_EMAIL=",
397
+ "",
398
+ ].join("\n")
399
+ );
361
400
 
362
- // Seed a secrets.env file to avoid ensureSecrets() file-not-found
401
+ // Seed a user.env placeholder
363
402
  writeFileSync(
364
- join(configDir, "secrets.env"),
403
+ join(vaultDir, "user", "user.env"),
365
404
  [
366
- "# OpenPalm Secrets",
367
- "export OPENPALM_ADMIN_TOKEN=",
368
- "export ADMIN_TOKEN=",
369
- "export OPENAI_API_KEY=",
370
- "export OPENAI_BASE_URL=",
371
- "export ANTHROPIC_API_KEY=",
372
- "export GROQ_API_KEY=",
373
- "export MISTRAL_API_KEY=",
374
- "export GOOGLE_API_KEY=",
375
- "export MEMORY_USER_ID=default_user",
376
- "export MEMORY_AUTH_TOKEN=abc123",
377
- "export OWNER_NAME=",
378
- "export OWNER_EMAIL=",
405
+ "# OpenPalm — User Extensions",
406
+ "# Add any custom environment variables here.",
407
+ "# These are loaded by compose alongside stack.env.",
379
408
  "",
380
409
  ].join("\n")
381
410
  );
382
411
 
412
+ // Seed required asset files at OP_HOME
413
+ seedRequiredAssets(homeDir);
414
+
383
415
  // Override env vars for test isolation
384
- savedEnv.OPENPALM_CONFIG_HOME = process.env.OPENPALM_CONFIG_HOME;
385
- savedEnv.OPENPALM_DATA_HOME = process.env.OPENPALM_DATA_HOME;
386
- savedEnv.OPENPALM_STATE_HOME = process.env.OPENPALM_STATE_HOME;
387
- process.env.OPENPALM_CONFIG_HOME = configDir;
388
- process.env.OPENPALM_DATA_HOME = dataDir;
389
- process.env.OPENPALM_STATE_HOME = stateDir;
416
+ savedEnv.OP_HOME = process.env.OP_HOME;
417
+ process.env.OP_HOME = homeDir;
390
418
  });
391
419
 
392
420
  afterEach(() => {
393
- process.env.OPENPALM_CONFIG_HOME = savedEnv.OPENPALM_CONFIG_HOME;
394
- process.env.OPENPALM_DATA_HOME = savedEnv.OPENPALM_DATA_HOME;
395
- process.env.OPENPALM_STATE_HOME = savedEnv.OPENPALM_STATE_HOME;
396
- rmSync(tempBase, { recursive: true, force: true });
421
+ process.env.OP_HOME = savedEnv.OP_HOME;
422
+ rmSync(homeDir, { recursive: true, force: true });
397
423
  });
398
424
 
399
425
  it("returns an error for invalid input", async () => {
400
426
  const result = await performSetup(
401
- { adminToken: "short" } as SetupInput,
402
- createStubAssetProvider()
427
+ { security: { adminToken: "short" } } as SetupSpec
403
428
  );
404
429
  expect(result.ok).toBe(false);
405
430
  expect(result.error).toBeDefined();
406
431
  });
407
432
 
408
- it("writes secrets.env with the admin token", async () => {
409
- const result = await performSetup(makeValidInput(), createStubAssetProvider());
433
+ it("writes stack.env with the admin token", async () => {
434
+ const result = await performSetup(makeValidSpec());
410
435
  expect(result.ok).toBe(true);
411
436
 
412
- const secretsContent = readFileSync(join(configDir, "secrets.env"), "utf-8");
437
+ const secretsContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
413
438
  expect(secretsContent).toContain("test-admin-token-12345");
414
439
  });
415
440
 
416
- it("writes memory config", async () => {
417
- const result = await performSetup(makeValidInput(), createStubAssetProvider());
441
+ it("writes OP_CAP_* vars to stack.env for capabilities", async () => {
442
+ const result = await performSetup(makeValidSpec());
418
443
  expect(result.ok).toBe(true);
419
444
 
420
- const memConfigPath = join(dataDir, "memory", "default_config.json");
421
- expect(existsSync(memConfigPath)).toBe(true);
422
-
423
- const memConfig = JSON.parse(readFileSync(memConfigPath, "utf-8"));
424
- expect(memConfig.mem0.llm.config.model).toBe("gpt-4o");
425
- expect(memConfig.mem0.embedder.config.model).toBe("text-embedding-3-small");
445
+ const stackEnvContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
446
+ expect(stackEnvContent).toContain("OP_CAP_LLM_MODEL=gpt-4o");
447
+ expect(stackEnvContent).toContain("OP_CAP_EMBEDDINGS_MODEL=text-embedding-3-small");
426
448
  });
427
449
 
428
- it("writes connection profiles document", async () => {
429
- const result = await performSetup(makeValidInput(), createStubAssetProvider());
450
+ it("writes capabilities to stack.yml v2", async () => {
451
+ const result = await performSetup(makeValidSpec());
430
452
  expect(result.ok).toBe(true);
431
453
 
432
- const profilesPath = join(configDir, "connections", "profiles.json");
433
- expect(existsSync(profilesPath)).toBe(true);
434
-
435
- const doc = JSON.parse(readFileSync(profilesPath, "utf-8"));
436
- expect(doc.version).toBe(1);
437
- expect(doc.profiles).toHaveLength(1);
438
- expect(doc.profiles[0].id).toBe("openai-main");
439
- expect(doc.assignments.llm.model).toBe("gpt-4o");
440
- expect(doc.assignments.embeddings.model).toBe("text-embedding-3-small");
454
+ const spec = readStackSpec(configDir);
455
+ expect(spec).not.toBeNull();
456
+ expect(spec!.version).toBe(2);
457
+ expect(spec!.capabilities.llm).toBe("openai/gpt-4o");
458
+ expect(spec!.capabilities.embeddings.model).toBe("text-embedding-3-small");
459
+ expect(spec!.capabilities.embeddings.provider).toBe("openai");
441
460
  });
442
461
 
443
- it("creates staged artifacts directory", async () => {
444
- const result = await performSetup(makeValidInput(), createStubAssetProvider());
462
+ it("writes core compose file to stack/", async () => {
463
+ const result = await performSetup(makeValidSpec());
445
464
  expect(result.ok).toBe(true);
446
465
 
447
- // applyInstall should have staged the compose file
448
- const stagedCompose = join(stateDir, "artifacts", "docker-compose.yml");
466
+ // applyInstall should have written the compose file to stack/ (not config/components/)
467
+ const stagedCompose = join(homeDir, "stack", "core.compose.yml");
449
468
  expect(existsSync(stagedCompose)).toBe(true);
450
469
  });
451
470
 
452
- it("uses Ollama in-stack URL when ollamaEnabled is true", async () => {
453
- const input = makeValidInput({
454
- ollamaEnabled: true,
471
+ it("writes ollama capabilities without addon metadata in stack.yml", async () => {
472
+ const input = makeValidSpec({
473
+ capabilities: {
474
+ llm: "ollama/llama3.2",
475
+ embeddings: {
476
+ provider: "ollama",
477
+ model: "nomic-embed-text",
478
+ dims: 768,
479
+ },
480
+ memory: {
481
+ userId: "test_user",
482
+ customInstructions: "",
483
+ },
484
+ },
455
485
  connections: [
456
486
  {
457
487
  id: "ollama-local",
@@ -461,23 +491,32 @@ describe("performSetup", () => {
461
491
  apiKey: "",
462
492
  },
463
493
  ],
464
- assignments: {
465
- llm: { connectionId: "ollama-local", model: "llama3.2" },
466
- embeddings: { connectionId: "ollama-local", model: "nomic-embed-text" },
467
- },
468
494
  });
469
495
 
470
- const result = await performSetup(input, createStubAssetProvider());
496
+ const result = await performSetup(input);
471
497
  expect(result.ok).toBe(true);
472
498
 
473
- // Connection profiles should use the in-stack URL
474
- const profilesPath = join(configDir, "connections", "profiles.json");
475
- const doc = JSON.parse(readFileSync(profilesPath, "utf-8"));
476
- expect(doc.profiles[0].baseUrl).toBe("http://ollama:11434");
499
+ // v2 spec should have correct capabilities without addon metadata
500
+ const spec = readStackSpec(configDir);
501
+ expect(spec).not.toBeNull();
502
+ expect(spec!.version).toBe(2);
503
+ expect(spec!.capabilities.llm).toBe("ollama/llama3.2");
477
504
  });
478
505
 
479
506
  it("resolves embedding dims from EMBEDDING_DIMS lookup", async () => {
480
- const input = makeValidInput({
507
+ const input = makeValidSpec({
508
+ capabilities: {
509
+ llm: "ollama/llama3.2",
510
+ embeddings: {
511
+ provider: "ollama",
512
+ model: "nomic-embed-text",
513
+ dims: 0, // Should be resolved from lookup
514
+ },
515
+ memory: {
516
+ userId: "test_user",
517
+ customInstructions: "",
518
+ },
519
+ },
481
520
  connections: [
482
521
  {
483
522
  id: "ollama-local",
@@ -487,709 +526,77 @@ describe("performSetup", () => {
487
526
  apiKey: "",
488
527
  },
489
528
  ],
490
- assignments: {
491
- llm: { connectionId: "ollama-local", model: "llama3.2" },
492
- embeddings: { connectionId: "ollama-local", model: "nomic-embed-text" },
493
- },
494
529
  });
495
530
 
496
- const result = await performSetup(input, createStubAssetProvider());
531
+ const result = await performSetup(input);
497
532
  expect(result.ok).toBe(true);
498
533
 
499
- // nomic-embed-text is 768 dims per EMBEDDING_DIMS
500
- const memConfigPath = join(dataDir, "memory", "default_config.json");
501
- const memConfig = JSON.parse(readFileSync(memConfigPath, "utf-8"));
502
- expect(memConfig.mem0.vector_store.config.embedding_model_dims).toBe(768);
534
+ // nomic-embed-text is 768 dims per EMBEDDING_DIMS — verify via stack.env OP_CAP_EMBEDDINGS_DIMS
535
+ const stackEnvContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
536
+ expect(stackEnvContent).toContain("OP_CAP_EMBEDDINGS_DIMS=768");
503
537
  });
504
538
 
505
- it("writes openpalm.yaml with correct structure", async () => {
506
- const result = await performSetup(makeValidInput(), createStubAssetProvider());
539
+ it("writes stack.yml with correct v2 structure", async () => {
540
+ const result = await performSetup(makeValidSpec());
507
541
  expect(result.ok).toBe(true);
508
542
 
509
543
  const specPath = join(configDir, STACK_SPEC_FILENAME);
510
544
  expect(existsSync(specPath)).toBe(true);
511
545
 
512
- const spec = yamlParse(readFileSync(specPath, "utf-8"));
513
- expect(spec.version).toBe(3);
514
- expect(spec.connections).toBeArrayOfSize(1);
515
- expect(spec.connections[0].id).toBe("openai-main");
516
- expect(spec.connections[0].provider).toBe("openai");
517
- expect(spec.assignments.llm.model).toBe("gpt-4o");
518
- expect(spec.assignments.embeddings.model).toBe("text-embedding-3-small");
519
- expect(spec.ollamaEnabled).toBe(false);
546
+ const spec = readStackSpec(configDir);
547
+ expect(spec).not.toBeNull();
548
+ expect(spec!.version).toBe(2);
549
+ expect(spec!.capabilities.llm).toBe("openai/gpt-4o");
550
+ expect(spec!.capabilities.embeddings.provider).toBe("openai");
551
+ expect(spec!.capabilities.embeddings.model).toBe("text-embedding-3-small");
552
+ expect(spec!.capabilities.memory.userId).toBe("test_user");
520
553
  });
521
554
 
522
- it("does not corrupt profile when duplicate connection ID with hyphen is skipped by env var map", async () => {
523
- // When two connections share a provider and the second has a hyphen in the ID,
524
- // buildConnectionEnvVarMap skips it (OPENAI_API_KEY_OPENAI-2 fails SAFE_ENV_KEY_RE).
525
- // The profile for that connection should have hasApiKey=false and apiKeyEnvVar=""
526
- // rather than passing undefined through via a non-null assertion.
527
- const input = makeValidInput({
555
+ it("completes setup even when duplicate connection ID with hyphen is skipped by env var map", async () => {
556
+ const input = makeValidSpec({
528
557
  connections: [
529
558
  { id: "openai_primary", name: "OpenAI Primary", provider: "openai", baseUrl: "https://api.openai.com", apiKey: "sk-primary" },
530
559
  { id: "openai-secondary", name: "OpenAI Secondary", provider: "openai", baseUrl: "https://api.openai.com", apiKey: "sk-secondary" },
531
560
  ],
532
- assignments: {
533
- llm: { connectionId: "openai_primary", model: "gpt-4o" },
534
- embeddings: { connectionId: "openai_primary", model: "text-embedding-3-small" },
535
- },
536
- });
537
-
538
- const result = await performSetup(input, createStubAssetProvider());
539
- expect(result.ok).toBe(true);
540
-
541
- const profilesPath = join(configDir, "connections", "profiles.json");
542
- const doc = JSON.parse(readFileSync(profilesPath, "utf-8"));
543
- expect(doc.profiles).toHaveLength(2);
544
-
545
- // The second connection's env var was skipped — hasApiKey must be false, apiKeyEnvVar must be ""
546
- const secondary = doc.profiles.find((p: { id: string }) => p.id === "openai-secondary");
547
- expect(secondary).toBeDefined();
548
- expect(secondary.auth.mode).toBe("none");
549
- });
550
- });
551
-
552
- // ── Helpers: SetupConfig ─────────────────────────────────────────────────
553
-
554
- function makeValidConfig(overrides?: Partial<SetupConfig>): SetupConfig {
555
- return {
556
- version: 1,
557
- owner: { name: "Test User", email: "test@example.com" },
558
- security: { adminToken: "test-admin-token-12345" },
559
- connections: [
560
- {
561
- id: "openai-main",
562
- name: "OpenAI",
563
- provider: "openai",
564
- baseUrl: "https://api.openai.com",
565
- apiKey: "sk-test-key-123",
566
- },
567
- ],
568
- assignments: {
569
- llm: { connectionId: "openai-main", model: "gpt-4o" },
570
- embeddings: { connectionId: "openai-main", model: "text-embedding-3-small" },
571
- },
572
- memory: { userId: "test_user" },
573
- ...overrides,
574
- };
575
- }
576
-
577
- // ── Tests: validateSetupConfig ───────────────────────────────────────────
578
-
579
- describe("validateSetupConfig", () => {
580
- it("accepts a valid config", () => {
581
- const result = validateSetupConfig(makeValidConfig());
582
- expect(result.valid).toBe(true);
583
- expect(result.errors).toHaveLength(0);
584
- });
585
-
586
- it("rejects null input", () => {
587
- const result = validateSetupConfig(null);
588
- expect(result.valid).toBe(false);
589
- expect(result.errors).toContain("Config must be a non-null object");
590
- });
591
-
592
- it("rejects wrong version", () => {
593
- const config = { ...makeValidConfig(), version: 2 };
594
- const result = validateSetupConfig(config);
595
- expect(result.valid).toBe(false);
596
- expect(result.errors.some((e) => e.includes("version must be 1"))).toBe(true);
597
- });
598
-
599
- it("rejects missing security object", () => {
600
- const config = makeValidConfig();
601
- (config as Record<string, unknown>).security = null;
602
- const result = validateSetupConfig(config);
603
- expect(result.valid).toBe(false);
604
- expect(result.errors.some((e) => e.includes("security object is required"))).toBe(true);
605
- });
606
-
607
- it("rejects missing security.adminToken", () => {
608
- const config = makeValidConfig();
609
- config.security.adminToken = "";
610
- const result = validateSetupConfig(config);
611
- expect(result.valid).toBe(false);
612
- expect(result.errors.some((e) => e.includes("security.adminToken"))).toBe(true);
613
- });
614
-
615
- it("rejects short security.adminToken", () => {
616
- const config = makeValidConfig();
617
- config.security.adminToken = "short";
618
- const result = validateSetupConfig(config);
619
- expect(result.valid).toBe(false);
620
- expect(result.errors.some((e) => e.includes("at least 8"))).toBe(true);
621
- });
622
-
623
- it("rejects empty connections array", () => {
624
- const config = makeValidConfig({ connections: [] });
625
- const result = validateSetupConfig(config);
626
- expect(result.valid).toBe(false);
627
- expect(result.errors.some((e) => e.includes("connections"))).toBe(true);
628
- });
629
-
630
- it("rejects duplicate connection IDs", () => {
631
- const conn: SetupConnection = {
632
- id: "dup",
633
- name: "Dup",
634
- provider: "openai",
635
- baseUrl: "",
636
- apiKey: "",
637
- };
638
- const config = makeValidConfig({ connections: [conn, conn] });
639
- const result = validateSetupConfig(config);
640
- expect(result.valid).toBe(false);
641
- expect(result.errors.some((e) => e.includes("Duplicate"))).toBe(true);
642
- });
643
-
644
- it("rejects unsupported provider", () => {
645
- const config = makeValidConfig({
646
- connections: [
647
- { id: "bad", name: "Bad", provider: "unsupported-provider", baseUrl: "", apiKey: "" },
648
- ],
649
- });
650
- const result = validateSetupConfig(config);
651
- expect(result.valid).toBe(false);
652
- expect(result.errors.some((e) => e.includes("outside wizard scope"))).toBe(true);
653
- });
654
-
655
- it("rejects missing assignments.llm", () => {
656
- const config = makeValidConfig();
657
- (config.assignments as Record<string, unknown>).llm = null;
658
- const result = validateSetupConfig(config);
659
- expect(result.valid).toBe(false);
660
- expect(result.errors.some((e) => e.includes("assignments.llm"))).toBe(true);
661
- });
662
-
663
- it("rejects missing assignments.embeddings", () => {
664
- const config = makeValidConfig();
665
- (config.assignments as Record<string, unknown>).embeddings = null;
666
- const result = validateSetupConfig(config);
667
- expect(result.valid).toBe(false);
668
- expect(result.errors.some((e) => e.includes("assignments.embeddings"))).toBe(true);
669
- });
670
-
671
- it("rejects assignment referencing non-existent connection", () => {
672
- const config = makeValidConfig();
673
- config.assignments.llm.connectionId = "does-not-exist";
674
- const result = validateSetupConfig(config);
675
- expect(result.valid).toBe(false);
676
- expect(result.errors.some((e) => e.includes("does not match any connection"))).toBe(true);
677
- });
678
-
679
- it("requires discord botToken when discord channel is an enabled object", () => {
680
- const config = makeValidConfig({
681
- channels: {
682
- discord: { applicationId: "123456" },
683
- },
684
- });
685
- const result = validateSetupConfig(config);
686
- expect(result.valid).toBe(false);
687
- expect(result.errors.some((e) => e.includes("discord.botToken"))).toBe(true);
688
- });
689
-
690
- it("does not require discord botToken when enabled is false", () => {
691
- const config = makeValidConfig({
692
- channels: {
693
- discord: { enabled: false },
694
- },
695
- });
696
- const result = validateSetupConfig(config);
697
- expect(result.valid).toBe(true);
698
- });
699
-
700
- it("requires slack slackBotToken and slackAppToken when slack is an enabled object", () => {
701
- const config = makeValidConfig({
702
- channels: {
703
- slack: { allowedChannels: "#general" },
704
- },
705
- });
706
- const result = validateSetupConfig(config);
707
- expect(result.valid).toBe(false);
708
- expect(result.errors.some((e) => e.includes("slack.slackBotToken"))).toBe(true);
709
- expect(result.errors.some((e) => e.includes("slack.slackAppToken"))).toBe(true);
710
- });
711
-
712
- it("accepts slack with required tokens", () => {
713
- const config = makeValidConfig({
714
- channels: {
715
- slack: { slackBotToken: "xoxb-test", slackAppToken: "xapp-test" },
716
- },
717
- });
718
- const result = validateSetupConfig(config);
719
- expect(result.valid).toBe(true);
720
- });
721
-
722
- it("accepts channels as boolean values", () => {
723
- const config = makeValidConfig({
724
- channels: { chat: true, api: false },
725
- });
726
- const result = validateSetupConfig(config);
727
- expect(result.valid).toBe(true);
728
- });
729
-
730
- it("rejects invalid channel value type", () => {
731
- const config = makeValidConfig({
732
- channels: { chat: "yes" as unknown as boolean },
733
561
  });
734
- const result = validateSetupConfig(config);
735
- expect(result.valid).toBe(false);
736
- expect(result.errors.some((e) => e.includes("channels.chat"))).toBe(true);
737
- });
738
-
739
- it("accepts valid owner fields", () => {
740
- const config = makeValidConfig({ owner: { name: "Alice", email: "alice@test.com" } });
741
- const result = validateSetupConfig(config);
742
- expect(result.valid).toBe(true);
743
- });
744
-
745
- it("rejects non-string owner.name", () => {
746
- const config = makeValidConfig();
747
- (config.owner as Record<string, unknown>).name = 42;
748
- const result = validateSetupConfig(config);
749
- expect(result.valid).toBe(false);
750
- expect(result.errors.some((e) => e.includes("owner.name"))).toBe(true);
751
- });
752
-
753
- it("accepts valid memory section", () => {
754
- const config = makeValidConfig({ memory: { userId: "my_user" } });
755
- const result = validateSetupConfig(config);
756
- expect(result.valid).toBe(true);
757
- });
758
-
759
- it("rejects non-string memory.userId", () => {
760
- const config = makeValidConfig();
761
- (config.memory as Record<string, unknown>).userId = 123;
762
- const result = validateSetupConfig(config);
763
- expect(result.valid).toBe(false);
764
- expect(result.errors.some((e) => e.includes("memory.userId"))).toBe(true);
765
- });
766
562
 
767
- it("rejects memory.userId with dots", () => {
768
- const config = makeValidConfig({ memory: { userId: "user.name" } });
769
- const result = validateSetupConfig(config);
770
- expect(result.valid).toBe(false);
771
- expect(result.errors.some((e) => e.includes("alphanumeric and underscores only"))).toBe(true);
772
- });
773
-
774
- it("rejects memory.userId with hyphens", () => {
775
- const config = makeValidConfig({ memory: { userId: "user-name" } });
776
- const result = validateSetupConfig(config);
777
- expect(result.valid).toBe(false);
778
- expect(result.errors.some((e) => e.includes("alphanumeric and underscores only"))).toBe(true);
779
- });
780
-
781
- it("rejects non-integer embeddingDims", () => {
782
- const config = makeValidConfig();
783
- config.assignments.embeddings.embeddingDims = 1.5;
784
- const result = validateSetupConfig(config);
785
- expect(result.valid).toBe(false);
786
- expect(result.errors.some((e) => e.includes("embeddingDims"))).toBe(true);
787
- });
788
-
789
- it("rejects non-boolean/object service value", () => {
790
- const config = makeValidConfig({ services: { admin: "yes" } as unknown as Record<string, boolean> });
791
- const result = validateSetupConfig(config);
792
- expect(result.valid).toBe(false);
793
- expect(result.errors).toContainEqual(expect.stringContaining("services.admin"));
794
- });
795
-
796
- it("rejects service object without enabled boolean", () => {
797
- const config = makeValidConfig({ services: { admin: { enabled: "yes" } } as unknown as Record<string, boolean> });
798
- const result = validateSetupConfig(config);
799
- expect(result.valid).toBe(false);
800
- expect(result.errors).toContainEqual(expect.stringContaining("services.admin.enabled"));
801
- });
802
- });
803
-
804
- // ── Tests: normalizeToSetupInput ─────────────────────────────────────────
805
-
806
- describe("normalizeToSetupInput", () => {
807
- it("maps all fields correctly for a full config", () => {
808
- const config = makeValidConfig();
809
- const input = normalizeToSetupInput(config);
810
-
811
- expect(input.adminToken).toBe("test-admin-token-12345");
812
- expect(input.ownerName).toBe("Test User");
813
- expect(input.ownerEmail).toBe("test@example.com");
814
- expect(input.memoryUserId).toBe("test_user");
815
- expect(input.ollamaEnabled).toBe(false);
816
- expect(input.connections).toHaveLength(1);
817
- expect(input.connections[0].id).toBe("openai-main");
818
- expect(input.assignments.llm.model).toBe("gpt-4o");
819
- expect(input.assignments.embeddings.model).toBe("text-embedding-3-small");
820
- });
821
-
822
- it("defaults memoryUserId when not provided", () => {
823
- const config = makeValidConfig({ memory: undefined });
824
- const input = normalizeToSetupInput(config);
825
- expect(input.memoryUserId).toBe("default_user");
826
- });
827
-
828
- it("maps tts string to voice.tts", () => {
829
- const config = makeValidConfig();
830
- config.assignments.tts = "kokoro";
831
- const input = normalizeToSetupInput(config);
832
- expect(input.voice?.tts).toBe("kokoro");
833
- });
834
-
835
- it("maps tts object to voice.tts using engine field", () => {
836
- const config = makeValidConfig();
837
- config.assignments.tts = { engine: "openai-tts", model: "tts-1" };
838
- const input = normalizeToSetupInput(config);
839
- expect(input.voice?.tts).toBe("openai-tts");
840
- });
841
-
842
- it("maps stt string to voice.stt", () => {
843
- const config = makeValidConfig();
844
- config.assignments.stt = "whisper-local";
845
- const input = normalizeToSetupInput(config);
846
- expect(input.voice?.stt).toBe("whisper-local");
847
- });
848
-
849
- it("maps stt object to voice.stt using engine field", () => {
850
- const config = makeValidConfig();
851
- config.assignments.stt = { engine: "openai-stt", model: "whisper-1" };
852
- const input = normalizeToSetupInput(config);
853
- expect(input.voice?.stt).toBe("openai-stt");
854
- });
855
-
856
- it("omits voice when neither tts nor stt are set", () => {
857
- const config = makeValidConfig();
858
- const input = normalizeToSetupInput(config);
859
- expect(input.voice).toBeUndefined();
860
- });
861
-
862
- it("handles null tts/stt values", () => {
863
- const config = makeValidConfig();
864
- config.assignments.tts = null;
865
- config.assignments.stt = null;
866
- const input = normalizeToSetupInput(config);
867
- expect(input.voice).toBeUndefined();
868
- });
869
-
870
- it("extracts enabled channels from boolean values", () => {
871
- const config = makeValidConfig({
872
- channels: { chat: true, api: true, discord: false },
873
- });
874
- const input = normalizeToSetupInput(config);
875
- expect(input.channels).toContain("chat");
876
- expect(input.channels).toContain("api");
877
- expect(input.channels).not.toContain("discord");
878
- });
879
-
880
- it("extracts enabled channels from object values", () => {
881
- const config = makeValidConfig({
882
- channels: {
883
- discord: { botToken: "bot-token-123", enabled: true },
884
- slack: { slackBotToken: "xoxb-test", slackAppToken: "xapp-test", enabled: false },
885
- },
886
- });
887
- const input = normalizeToSetupInput(config);
888
- expect(input.channels).toContain("discord");
889
- expect(input.channels).not.toContain("slack");
890
- });
891
-
892
- it("defaults channel enabled to true when object has no enabled field", () => {
893
- const config = makeValidConfig({
894
- channels: {
895
- discord: { botToken: "bot-token-123" },
896
- },
897
- });
898
- const input = normalizeToSetupInput(config);
899
- expect(input.channels).toContain("discord");
900
- });
901
-
902
- it("extracts services from boolean and object values", () => {
903
- const config = makeValidConfig({
904
- services: {
905
- admin: true,
906
- ollama: false,
907
- openviking: { enabled: true },
908
- },
909
- });
910
- const input = normalizeToSetupInput(config);
911
- expect(input.services?.admin).toBe(true);
912
- expect(input.services?.ollama).toBe(false);
913
- expect(input.ollamaEnabled).toBe(false);
914
- });
915
-
916
- it("sets ollamaEnabled from services.ollama", () => {
917
- const config = makeValidConfig({
918
- services: { ollama: true },
919
- });
920
- const input = normalizeToSetupInput(config);
921
- expect(input.ollamaEnabled).toBe(true);
922
- });
923
-
924
- it("omits channels when all channels are disabled", () => {
925
- const config = makeValidConfig({
926
- channels: { chat: false, api: false, discord: { enabled: false } },
927
- });
928
- const input = normalizeToSetupInput(config);
929
- expect(input.channels).toBeUndefined();
930
- });
931
-
932
- it("omits channels when none are configured", () => {
933
- const config = makeValidConfig({ channels: undefined });
934
- const input = normalizeToSetupInput(config);
935
- expect(input.channels).toBeUndefined();
936
- });
937
-
938
- it("omits services when none are configured", () => {
939
- const config = makeValidConfig({ services: undefined });
940
- const input = normalizeToSetupInput(config);
941
- expect(input.services).toBeUndefined();
942
- });
943
- });
944
-
945
- // ── Tests: buildChannelCredentialEnvVars ──────────────────────────────────
946
-
947
- describe("buildChannelCredentialEnvVars", () => {
948
- it("maps discord credentials to env vars", () => {
949
- const envVars = buildChannelCredentialEnvVars({
950
- discord: {
951
- botToken: "bot-token-123",
952
- applicationId: "app-id-456",
953
- allowedGuilds: "guild1,guild2",
954
- },
955
- });
956
- expect(envVars.DISCORD_BOT_TOKEN).toBe("bot-token-123");
957
- expect(envVars.DISCORD_APPLICATION_ID).toBe("app-id-456");
958
- expect(envVars.DISCORD_ALLOWED_GUILDS).toBe("guild1,guild2");
959
- });
960
-
961
- it("maps slack credentials to env vars", () => {
962
- const envVars = buildChannelCredentialEnvVars({
963
- slack: {
964
- slackBotToken: "xoxb-slack-token",
965
- slackAppToken: "xapp-slack-token",
966
- allowedChannels: "#general,#random",
967
- },
968
- });
969
- expect(envVars.SLACK_BOT_TOKEN).toBe("xoxb-slack-token");
970
- expect(envVars.SLACK_APP_TOKEN).toBe("xapp-slack-token");
971
- expect(envVars.SLACK_ALLOWED_CHANNELS).toBe("#general,#random");
972
- });
973
-
974
- it("converts boolean values to strings", () => {
975
- const envVars = buildChannelCredentialEnvVars({
976
- discord: {
977
- botToken: "bot-token-123",
978
- registerCommands: true,
979
- },
980
- });
981
- expect(envVars.DISCORD_REGISTER_COMMANDS).toBe("true");
982
- });
983
-
984
- it("skips boolean-only channel entries", () => {
985
- const envVars = buildChannelCredentialEnvVars({
986
- chat: true,
987
- api: false,
988
- });
989
- expect(Object.keys(envVars)).toHaveLength(0);
990
- });
991
-
992
- it("skips unknown channels not in CHANNEL_CREDENTIAL_ENV_MAP", () => {
993
- const envVars = buildChannelCredentialEnvVars({
994
- "custom-channel": {
995
- apiKey: "custom-key",
996
- enabled: true,
997
- },
998
- });
999
- expect(Object.keys(envVars)).toHaveLength(0);
1000
- });
1001
-
1002
- it("skips undefined and null credential values", () => {
1003
- const envVars = buildChannelCredentialEnvVars({
1004
- discord: {
1005
- botToken: "bot-token-123",
1006
- applicationId: undefined,
1007
- allowedGuilds: undefined,
1008
- },
1009
- });
1010
- expect(envVars.DISCORD_BOT_TOKEN).toBe("bot-token-123");
1011
- expect(envVars.DISCORD_APPLICATION_ID).toBeUndefined();
1012
- expect(envVars.DISCORD_ALLOWED_GUILDS).toBeUndefined();
1013
- });
1014
-
1015
- it("skips empty string credential values", () => {
1016
- const envVars = buildChannelCredentialEnvVars({
1017
- discord: {
1018
- botToken: "bot-token-123",
1019
- applicationId: "",
1020
- },
1021
- });
1022
- expect(envVars.DISCORD_BOT_TOKEN).toBe("bot-token-123");
1023
- expect(envVars.DISCORD_APPLICATION_ID).toBeUndefined();
1024
- });
1025
-
1026
- it("returns empty object for undefined channels", () => {
1027
- const envVars = buildChannelCredentialEnvVars(undefined);
1028
- expect(Object.keys(envVars)).toHaveLength(0);
1029
- });
1030
-
1031
- it("handles multiple channels simultaneously", () => {
1032
- const envVars = buildChannelCredentialEnvVars({
1033
- discord: { botToken: "discord-bot" },
1034
- slack: { slackBotToken: "slack-bot", slackAppToken: "slack-app" },
1035
- chat: true,
1036
- });
1037
- expect(envVars.DISCORD_BOT_TOKEN).toBe("discord-bot");
1038
- expect(envVars.SLACK_BOT_TOKEN).toBe("slack-bot");
1039
- expect(envVars.SLACK_APP_TOKEN).toBe("slack-app");
1040
- expect(Object.keys(envVars)).toHaveLength(3);
1041
- });
1042
- });
1043
-
1044
- // ── Tests: CHANNEL_CREDENTIAL_ENV_MAP ────────────────────────────────────
1045
-
1046
- describe("CHANNEL_CREDENTIAL_ENV_MAP", () => {
1047
- it("has discord mappings", () => {
1048
- expect(CHANNEL_CREDENTIAL_ENV_MAP.discord).toBeDefined();
1049
- expect(CHANNEL_CREDENTIAL_ENV_MAP.discord.botToken).toBe("DISCORD_BOT_TOKEN");
1050
- });
1051
-
1052
- it("has slack mappings", () => {
1053
- expect(CHANNEL_CREDENTIAL_ENV_MAP.slack).toBeDefined();
1054
- expect(CHANNEL_CREDENTIAL_ENV_MAP.slack.slackBotToken).toBe("SLACK_BOT_TOKEN");
1055
- });
1056
- });
1057
-
1058
- // ── Tests: performSetupFromConfig ────────────────────────────────────────
1059
-
1060
- describe("performSetupFromConfig", () => {
1061
- let tempBase: string;
1062
- let configDir: string;
1063
- let dataDir: string;
1064
- let stateDir: string;
1065
-
1066
- const savedEnv: Record<string, string | undefined> = {};
1067
-
1068
- beforeEach(() => {
1069
- tempBase = mkdtempSync(join(tmpdir(), "openpalm-setup-config-"));
1070
- configDir = join(tempBase, "config");
1071
- dataDir = join(tempBase, "data");
1072
- stateDir = join(tempBase, "state");
1073
-
1074
- // Create required directory structure
1075
- for (const dir of [
1076
- configDir,
1077
- join(configDir, "channels"),
1078
- join(configDir, "connections"),
1079
- join(configDir, "assistant"),
1080
- join(configDir, "automations"),
1081
- join(configDir, "stash"),
1082
- dataDir,
1083
- join(dataDir, "admin"),
1084
- join(dataDir, "memory"),
1085
- join(dataDir, "assistant"),
1086
- join(dataDir, "guardian"),
1087
- join(dataDir, "caddy"),
1088
- join(dataDir, "caddy", "data"),
1089
- join(dataDir, "caddy", "config"),
1090
- join(dataDir, "automations"),
1091
- join(dataDir, "opencode"),
1092
- stateDir,
1093
- join(stateDir, "artifacts"),
1094
- join(stateDir, "audit"),
1095
- join(stateDir, "artifacts", "channels"),
1096
- join(stateDir, "automations"),
1097
- join(stateDir, "opencode"),
1098
- ]) {
1099
- mkdirSync(dir, { recursive: true });
1100
- }
1101
-
1102
- // Create stub stack.env so isSetupComplete doesn't crash
1103
- writeFileSync(join(stateDir, "artifacts", "stack.env"), "OPENPALM_SETUP_COMPLETE=false\n");
1104
-
1105
- // Seed a secrets.env file to avoid ensureSecrets() file-not-found
1106
- writeFileSync(
1107
- join(configDir, "secrets.env"),
1108
- [
1109
- "# OpenPalm Secrets",
1110
- "export OPENPALM_ADMIN_TOKEN=",
1111
- "export ADMIN_TOKEN=",
1112
- "export OPENAI_API_KEY=",
1113
- "export OPENAI_BASE_URL=",
1114
- "export ANTHROPIC_API_KEY=",
1115
- "export GROQ_API_KEY=",
1116
- "export MISTRAL_API_KEY=",
1117
- "export GOOGLE_API_KEY=",
1118
- "export MEMORY_USER_ID=default_user",
1119
- "export MEMORY_AUTH_TOKEN=abc123",
1120
- "export OWNER_NAME=",
1121
- "export OWNER_EMAIL=",
1122
- "",
1123
- ].join("\n")
1124
- );
1125
-
1126
- // Override env vars for test isolation
1127
- savedEnv.OPENPALM_CONFIG_HOME = process.env.OPENPALM_CONFIG_HOME;
1128
- savedEnv.OPENPALM_DATA_HOME = process.env.OPENPALM_DATA_HOME;
1129
- savedEnv.OPENPALM_STATE_HOME = process.env.OPENPALM_STATE_HOME;
1130
- process.env.OPENPALM_CONFIG_HOME = configDir;
1131
- process.env.OPENPALM_DATA_HOME = dataDir;
1132
- process.env.OPENPALM_STATE_HOME = stateDir;
1133
- });
1134
-
1135
- afterEach(() => {
1136
- process.env.OPENPALM_CONFIG_HOME = savedEnv.OPENPALM_CONFIG_HOME;
1137
- process.env.OPENPALM_DATA_HOME = savedEnv.OPENPALM_DATA_HOME;
1138
- process.env.OPENPALM_STATE_HOME = savedEnv.OPENPALM_STATE_HOME;
1139
- rmSync(tempBase, { recursive: true, force: true });
1140
- });
1141
-
1142
- it("returns an error for invalid config", async () => {
1143
- const config = makeValidConfig();
1144
- (config as Record<string, unknown>).version = 99;
1145
- const result = await performSetupFromConfig(config, createStubAssetProvider());
1146
- expect(result.ok).toBe(false);
1147
- expect(result.error).toContain("version must be 1");
1148
- });
1149
-
1150
- it("completes setup with a valid config", async () => {
1151
- const result = await performSetupFromConfig(makeValidConfig(), createStubAssetProvider());
563
+ const result = await performSetup(input);
1152
564
  expect(result.ok).toBe(true);
1153
565
 
1154
- // Verify secrets.env was written
1155
- const secretsContent = readFileSync(join(configDir, "secrets.env"), "utf-8");
1156
- expect(secretsContent).toContain("test-admin-token-12345");
566
+ // v2 spec should still have correct capabilities
567
+ const spec = readStackSpec(configDir);
568
+ expect(spec).not.toBeNull();
569
+ expect(spec!.version).toBe(2);
570
+ expect(spec!.capabilities.llm).toBe("openai/gpt-4o");
1157
571
  });
1158
572
 
1159
- it("writes channel credentials to secrets.env", async () => {
1160
- const config = makeValidConfig({
1161
- channels: {
573
+ it("writes channel credentials to stack.env when channelCredentials provided", async () => {
574
+ const input = makeValidSpec({
575
+ channelCredentials: {
1162
576
  discord: {
1163
577
  botToken: "discord-bot-token-xyz",
1164
578
  applicationId: "discord-app-id-123",
1165
579
  },
1166
580
  },
581
+ capabilities: {
582
+ llm: "openai/gpt-4o",
583
+ embeddings: {
584
+ provider: "openai",
585
+ model: "text-embedding-3-small",
586
+ dims: 1536,
587
+ },
588
+ memory: {
589
+ userId: "test_user",
590
+ customInstructions: "",
591
+ },
592
+ },
1167
593
  });
1168
- const result = await performSetupFromConfig(config, createStubAssetProvider());
1169
- expect(result.ok).toBe(true);
1170
594
 
1171
- const secretsContent = readFileSync(join(configDir, "secrets.env"), "utf-8");
1172
- expect(secretsContent).toContain("discord-bot-token-xyz");
1173
- expect(secretsContent).toContain("discord-app-id-123");
1174
- });
1175
-
1176
- it("writes memory config with correct models", async () => {
1177
- const result = await performSetupFromConfig(makeValidConfig(), createStubAssetProvider());
595
+ const result = await performSetup(input);
1178
596
  expect(result.ok).toBe(true);
1179
597
 
1180
- const memConfigPath = join(dataDir, "memory", "default_config.json");
1181
- expect(existsSync(memConfigPath)).toBe(true);
1182
-
1183
- const memConfig = JSON.parse(readFileSync(memConfigPath, "utf-8"));
1184
- expect(memConfig.mem0.llm.config.model).toBe("gpt-4o");
1185
- expect(memConfig.mem0.embedder.config.model).toBe("text-embedding-3-small");
1186
- });
1187
-
1188
- it("creates staged artifacts", async () => {
1189
- const result = await performSetupFromConfig(makeValidConfig(), createStubAssetProvider());
1190
- expect(result.ok).toBe(true);
1191
-
1192
- const stagedCompose = join(stateDir, "artifacts", "docker-compose.yml");
1193
- expect(existsSync(stagedCompose)).toBe(true);
598
+ const stackEnvContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
599
+ expect(stackEnvContent).toContain("discord-bot-token-xyz");
600
+ expect(stackEnvContent).toContain("discord-app-id-123");
1194
601
  });
1195
602
  });