@openpalm/lib 0.9.9 → 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.
- package/README.md +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +158 -886
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- 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
|
-
|
|
6
|
+
validateSetupSpec,
|
|
8
7
|
buildSecretsFromSetup,
|
|
9
|
-
|
|
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 {
|
|
18
|
-
import
|
|
19
|
-
import {
|
|
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
|
|
17
|
+
function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
|
|
24
18
|
return {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
/**
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
64
|
+
// ── Tests: validateSetupSpec ────────────────────────────────────────────
|
|
67
65
|
|
|
68
|
-
describe("
|
|
66
|
+
describe("validateSetupSpec", () => {
|
|
69
67
|
it("accepts a valid input", () => {
|
|
70
|
-
const result =
|
|
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 =
|
|
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
|
|
82
|
-
const
|
|
83
|
-
|
|
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("
|
|
84
|
+
expect(result.errors.some((e) => e.includes("security object is required"))).toBe(true);
|
|
86
85
|
});
|
|
87
86
|
|
|
88
|
-
it("rejects
|
|
89
|
-
const
|
|
90
|
-
|
|
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("
|
|
92
|
+
expect(result.errors.some((e) => e.includes("security.adminToken"))).toBe(true);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
it("rejects
|
|
96
|
-
const
|
|
97
|
-
|
|
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("
|
|
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
|
|
111
|
-
const result =
|
|
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("
|
|
117
|
-
const
|
|
123
|
+
it("accepts any provider string", () => {
|
|
124
|
+
const spec = makeValidSpec({
|
|
118
125
|
connections: [
|
|
119
|
-
{ id: "
|
|
126
|
+
{ id: "custom", name: "Custom", provider: "any-provider", baseUrl: "", apiKey: "" },
|
|
120
127
|
],
|
|
121
128
|
});
|
|
122
|
-
const result =
|
|
123
|
-
expect(result.valid).toBe(
|
|
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
|
|
134
|
+
const spec = makeValidSpec({
|
|
129
135
|
connections: [
|
|
130
136
|
{ id: "-invalid", name: "Bad", provider: "openai", baseUrl: "", apiKey: "" },
|
|
131
137
|
],
|
|
132
138
|
});
|
|
133
|
-
const result =
|
|
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
|
|
139
|
-
const input =
|
|
140
|
-
(input
|
|
141
|
-
const result =
|
|
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("
|
|
157
|
+
expect(result.errors.some((e) => e.includes("capabilities.llm"))).toBe(true);
|
|
144
158
|
});
|
|
145
159
|
|
|
146
|
-
it("rejects missing
|
|
147
|
-
const input =
|
|
148
|
-
(input.
|
|
149
|
-
const result =
|
|
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("
|
|
165
|
+
expect(result.errors.some((e) => e.includes("capabilities.embeddings"))).toBe(true);
|
|
152
166
|
});
|
|
153
167
|
|
|
154
|
-
it("rejects
|
|
155
|
-
const input =
|
|
156
|
-
input.
|
|
157
|
-
const result =
|
|
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("
|
|
173
|
+
expect(result.errors.some((e) => e.includes("capabilities.memory"))).toBe(true);
|
|
160
174
|
});
|
|
161
175
|
|
|
162
|
-
it("rejects
|
|
163
|
-
const input =
|
|
164
|
-
input.
|
|
165
|
-
const result =
|
|
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("
|
|
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
|
|
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 =
|
|
191
|
+
const result = validateSetupSpec(spec);
|
|
178
192
|
expect(result.valid).toBe(true);
|
|
179
193
|
});
|
|
180
194
|
|
|
181
|
-
it("rejects
|
|
182
|
-
const input =
|
|
183
|
-
|
|
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
|
|
189
|
-
const input =
|
|
190
|
-
|
|
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
|
|
196
|
-
const input =
|
|
197
|
-
|
|
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("
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
expect(secrets.
|
|
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("
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
expect(secrets.
|
|
215
|
-
expect(secrets.
|
|
216
|
-
expect(secrets.
|
|
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("
|
|
220
|
-
const
|
|
221
|
-
|
|
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("
|
|
225
|
-
const
|
|
226
|
-
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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("
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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("
|
|
297
|
-
const
|
|
298
|
-
{ id: "
|
|
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
|
|
302
|
-
|
|
303
|
-
expect(
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
expect(
|
|
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
|
|
344
|
+
let homeDir: string;
|
|
319
345
|
let configDir: string;
|
|
346
|
+
let vaultDir: string;
|
|
320
347
|
let dataDir: string;
|
|
321
|
-
let
|
|
348
|
+
let logsDir: string;
|
|
322
349
|
|
|
323
350
|
const savedEnv: Record<string, string | undefined> = {};
|
|
324
351
|
|
|
325
352
|
beforeEach(() => {
|
|
326
|
-
|
|
327
|
-
configDir = join(
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
350
|
-
join(
|
|
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
|
-
|
|
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
|
|
401
|
+
// Seed a user.env placeholder
|
|
363
402
|
writeFileSync(
|
|
364
|
-
join(
|
|
403
|
+
join(vaultDir, "user", "user.env"),
|
|
365
404
|
[
|
|
366
|
-
"# OpenPalm
|
|
367
|
-
"
|
|
368
|
-
"
|
|
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.
|
|
385
|
-
|
|
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.
|
|
394
|
-
|
|
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
|
|
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
|
|
409
|
-
const result = await performSetup(
|
|
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(
|
|
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
|
|
417
|
-
const result = await performSetup(
|
|
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
|
|
421
|
-
expect(
|
|
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
|
|
429
|
-
const result = await performSetup(
|
|
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
|
|
433
|
-
expect(
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
expect(
|
|
437
|
-
expect(
|
|
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("
|
|
444
|
-
const result = await performSetup(
|
|
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
|
|
448
|
-
const stagedCompose = join(
|
|
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("
|
|
453
|
-
const input =
|
|
454
|
-
|
|
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
|
|
496
|
+
const result = await performSetup(input);
|
|
471
497
|
expect(result.ok).toBe(true);
|
|
472
498
|
|
|
473
|
-
//
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
expect(
|
|
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 =
|
|
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
|
|
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
|
|
501
|
-
|
|
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
|
|
506
|
-
const result = await performSetup(
|
|
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 =
|
|
513
|
-
expect(spec
|
|
514
|
-
expect(spec
|
|
515
|
-
expect(spec.
|
|
516
|
-
expect(spec.
|
|
517
|
-
expect(spec.
|
|
518
|
-
expect(spec.
|
|
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("
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1155
|
-
const
|
|
1156
|
-
expect(
|
|
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
|
|
1160
|
-
const
|
|
1161
|
-
|
|
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
|
|
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
|
|
1181
|
-
expect(
|
|
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
|
});
|