openpalm 0.9.8 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/playwright.config.ts +16 -0
- package/src/commands/install-file.test.ts +306 -0
- package/src/commands/install-services.test.ts +12 -7
- package/src/commands/install-services.ts +1 -1
- package/src/commands/install.ts +113 -30
- package/src/commands/restart.ts +2 -35
- package/src/commands/service.ts +0 -17
- package/src/commands/start.ts +5 -43
- package/src/commands/status.ts +0 -9
- package/src/commands/stop.ts +4 -36
- package/src/commands/uninstall.ts +23 -14
- package/src/commands/update.ts +0 -9
- package/src/lib/docker.ts +25 -7
- package/src/lib/env.ts +6 -59
- package/src/lib/paths.ts +11 -1
- package/src/lib/staging.ts +3 -3
- package/src/main.test.ts +50 -82
- package/src/setup-wizard/index.html +114 -180
- package/src/setup-wizard/server-errors.test.ts +429 -0
- package/src/setup-wizard/server-integration.test.ts +511 -0
- package/src/setup-wizard/server.test.ts +6 -6
- package/src/setup-wizard/server.ts +17 -5
- package/src/setup-wizard/standalone.ts +166 -0
- package/src/setup-wizard/wizard.css +892 -299
- package/src/setup-wizard/wizard.js +1172 -559
- package/src/lib/admin.ts +0 -107
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side integration tests for the setup wizard.
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise the actual HTTP endpoints with real backend behavior:
|
|
5
|
+
* - Model fetching against a running Ollama instance
|
|
6
|
+
* - Full setup completion flow with file artifact verification
|
|
7
|
+
* - Deploy status lifecycle (pending -> running transitions)
|
|
8
|
+
* - Post-completion state transitions
|
|
9
|
+
*
|
|
10
|
+
* Requires: Ollama running on localhost:11434
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
13
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { createSetupServer } from "./server.ts";
|
|
17
|
+
import type { CoreAssetProvider } from "@openpalm/lib";
|
|
18
|
+
|
|
19
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
let tempBase: string;
|
|
22
|
+
let configDir: string;
|
|
23
|
+
let dataDir: string;
|
|
24
|
+
let stateDir: string;
|
|
25
|
+
|
|
26
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
27
|
+
|
|
28
|
+
function createStubAssetProvider(): CoreAssetProvider {
|
|
29
|
+
return {
|
|
30
|
+
coreCompose: () => "services:\n caddy:\n image: caddy:latest\n",
|
|
31
|
+
caddyfile: () =>
|
|
32
|
+
":80 {\n @denied not remote_ip 127.0.0.0/8 ::1\n respond @denied 403\n}\n",
|
|
33
|
+
ollamaCompose: () => "services:\n ollama:\n image: ollama/ollama\n",
|
|
34
|
+
adminCompose: () => "services:\n admin:\n image: openpalm/admin\n",
|
|
35
|
+
agentsMd: () => "# Agents\n",
|
|
36
|
+
opencodeConfig: () => '{"$schema":"https://opencode.ai/config.json"}\n',
|
|
37
|
+
adminOpencodeConfig: () => '{"$schema":"https://opencode.ai/config.json","plugin":["@openpalm/admin-tools"]}\n',
|
|
38
|
+
secretsSchema: () => "ADMIN_TOKEN=string\n",
|
|
39
|
+
stackSchema: () => "OPENPALM_IMAGE_TAG=string\n",
|
|
40
|
+
cleanupLogs: () => "name: cleanup-logs\nschedule: daily\n",
|
|
41
|
+
cleanupData: () => "name: cleanup-data\nschedule: weekly\n",
|
|
42
|
+
validateConfig: () => "name: validate-config\nschedule: hourly\n",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeSetupDirs(): void {
|
|
47
|
+
tempBase = mkdtempSync(join(tmpdir(), "openpalm-server-integ-test-"));
|
|
48
|
+
configDir = join(tempBase, "config");
|
|
49
|
+
dataDir = join(tempBase, "data");
|
|
50
|
+
stateDir = join(tempBase, "state");
|
|
51
|
+
|
|
52
|
+
for (const dir of [
|
|
53
|
+
configDir,
|
|
54
|
+
join(configDir, "channels"),
|
|
55
|
+
join(configDir, "connections"),
|
|
56
|
+
join(configDir, "assistant"),
|
|
57
|
+
join(configDir, "automations"),
|
|
58
|
+
join(configDir, "stash"),
|
|
59
|
+
dataDir,
|
|
60
|
+
join(dataDir, "admin"),
|
|
61
|
+
join(dataDir, "memory"),
|
|
62
|
+
join(dataDir, "assistant"),
|
|
63
|
+
join(dataDir, "guardian"),
|
|
64
|
+
join(dataDir, "caddy"),
|
|
65
|
+
join(dataDir, "caddy", "data"),
|
|
66
|
+
join(dataDir, "caddy", "config"),
|
|
67
|
+
join(dataDir, "automations"),
|
|
68
|
+
join(dataDir, "opencode"),
|
|
69
|
+
stateDir,
|
|
70
|
+
join(stateDir, "artifacts"),
|
|
71
|
+
join(stateDir, "audit"),
|
|
72
|
+
join(stateDir, "artifacts", "channels"),
|
|
73
|
+
join(stateDir, "automations"),
|
|
74
|
+
join(stateDir, "opencode"),
|
|
75
|
+
]) {
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
writeFileSync(join(stateDir, "artifacts", "stack.env"), "OPENPALM_SETUP_COMPLETE=false\n");
|
|
80
|
+
writeFileSync(
|
|
81
|
+
join(configDir, "secrets.env"),
|
|
82
|
+
[
|
|
83
|
+
"# OpenPalm Secrets",
|
|
84
|
+
"export OPENPALM_ADMIN_TOKEN=",
|
|
85
|
+
"export ADMIN_TOKEN=",
|
|
86
|
+
"export OPENAI_API_KEY=",
|
|
87
|
+
"export OPENAI_BASE_URL=",
|
|
88
|
+
"export ANTHROPIC_API_KEY=",
|
|
89
|
+
"export GROQ_API_KEY=",
|
|
90
|
+
"export MISTRAL_API_KEY=",
|
|
91
|
+
"export GOOGLE_API_KEY=",
|
|
92
|
+
"export MEMORY_USER_ID=default_user",
|
|
93
|
+
"export MEMORY_AUTH_TOKEN=abc123",
|
|
94
|
+
"export OWNER_NAME=",
|
|
95
|
+
"export OWNER_EMAIL=",
|
|
96
|
+
"",
|
|
97
|
+
].join("\n")
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Check if Ollama is reachable before running integration tests. */
|
|
102
|
+
async function isOllamaAvailable(): Promise<boolean> {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch("http://localhost:11434/api/tags", {
|
|
105
|
+
signal: AbortSignal.timeout(3000),
|
|
106
|
+
});
|
|
107
|
+
return res.ok;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Port counter starting above the other test files to avoid conflicts
|
|
114
|
+
let nextPort = 19300;
|
|
115
|
+
|
|
116
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("setup wizard server integration", () => {
|
|
119
|
+
let serverPort: number;
|
|
120
|
+
let ollamaUp: boolean;
|
|
121
|
+
|
|
122
|
+
beforeEach(async () => {
|
|
123
|
+
makeSetupDirs();
|
|
124
|
+
|
|
125
|
+
savedEnv.OPENPALM_CONFIG_HOME = process.env.OPENPALM_CONFIG_HOME;
|
|
126
|
+
savedEnv.OPENPALM_DATA_HOME = process.env.OPENPALM_DATA_HOME;
|
|
127
|
+
savedEnv.OPENPALM_STATE_HOME = process.env.OPENPALM_STATE_HOME;
|
|
128
|
+
process.env.OPENPALM_CONFIG_HOME = configDir;
|
|
129
|
+
process.env.OPENPALM_DATA_HOME = dataDir;
|
|
130
|
+
process.env.OPENPALM_STATE_HOME = stateDir;
|
|
131
|
+
|
|
132
|
+
serverPort = nextPort++;
|
|
133
|
+
ollamaUp = await isOllamaAvailable();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
process.env.OPENPALM_CONFIG_HOME = savedEnv.OPENPALM_CONFIG_HOME;
|
|
138
|
+
process.env.OPENPALM_DATA_HOME = savedEnv.OPENPALM_DATA_HOME;
|
|
139
|
+
process.env.OPENPALM_STATE_HOME = savedEnv.OPENPALM_STATE_HOME;
|
|
140
|
+
if (tempBase) rmSync(tempBase, { recursive: true, force: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ── Model fetching against real Ollama ──────────────────────────────────
|
|
144
|
+
|
|
145
|
+
it("fetches real model list from Ollama", async () => {
|
|
146
|
+
if (!ollamaUp) {
|
|
147
|
+
console.log("SKIP: Ollama not available at localhost:11434");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { stop } = createSetupServer(serverPort, {
|
|
152
|
+
assetProvider: createStubAssetProvider(),
|
|
153
|
+
configDir,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetch(`http://localhost:${serverPort}/api/setup/models/ollama`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: JSON.stringify({ apiKey: "", baseUrl: "http://localhost:11434" }),
|
|
161
|
+
});
|
|
162
|
+
expect(res.status).toBe(200);
|
|
163
|
+
const data = (await res.json()) as {
|
|
164
|
+
ok: boolean;
|
|
165
|
+
models: string[];
|
|
166
|
+
status: string;
|
|
167
|
+
reason: string;
|
|
168
|
+
};
|
|
169
|
+
expect(data.ok).toBe(true);
|
|
170
|
+
expect(data.status).toBe("ok");
|
|
171
|
+
expect(Array.isArray(data.models)).toBe(true);
|
|
172
|
+
expect(data.models.length).toBeGreaterThan(0);
|
|
173
|
+
} finally {
|
|
174
|
+
stop();
|
|
175
|
+
}
|
|
176
|
+
}, 10000);
|
|
177
|
+
|
|
178
|
+
it("returns recoverable error for Ollama with empty baseUrl (default is docker-internal)", async () => {
|
|
179
|
+
const { stop } = createSetupServer(serverPort, {
|
|
180
|
+
assetProvider: createStubAssetProvider(),
|
|
181
|
+
configDir,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
// Ollama default URL is host.docker.internal:11434 (unreachable from host)
|
|
186
|
+
const res = await fetch(`http://localhost:${serverPort}/api/setup/models/ollama`, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "Content-Type": "application/json" },
|
|
189
|
+
body: JSON.stringify({ apiKey: "", baseUrl: "" }),
|
|
190
|
+
});
|
|
191
|
+
const data = (await res.json()) as {
|
|
192
|
+
ok: boolean;
|
|
193
|
+
models: string[];
|
|
194
|
+
status: string;
|
|
195
|
+
reason: string;
|
|
196
|
+
};
|
|
197
|
+
if (res.status === 200) {
|
|
198
|
+
// Ollama is reachable on this host (e.g. via host.docker.internal)
|
|
199
|
+
expect(data.ok).toBe(true);
|
|
200
|
+
expect(data.status).toBe("ok");
|
|
201
|
+
} else {
|
|
202
|
+
// Ollama default URL unreachable — expect 502
|
|
203
|
+
expect(res.status).toBe(502);
|
|
204
|
+
expect(data.ok).toBe(false);
|
|
205
|
+
expect(data.models).toEqual([]);
|
|
206
|
+
expect(data.status).toBe("recoverable_error");
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
stop();
|
|
210
|
+
}
|
|
211
|
+
}, 10000);
|
|
212
|
+
|
|
213
|
+
// ── Full setup flow via HTTP ────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
it("completes full setup with Ollama and verifies file artifacts", async () => {
|
|
216
|
+
if (!ollamaUp) {
|
|
217
|
+
console.log("SKIP: Ollama not available at localhost:11434");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { stop, waitForComplete } = createSetupServer(serverPort, {
|
|
222
|
+
assetProvider: createStubAssetProvider(),
|
|
223
|
+
configDir,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const body = {
|
|
228
|
+
version: 1,
|
|
229
|
+
owner: { name: "Integration Test", email: "integ@test.local" },
|
|
230
|
+
security: { adminToken: "integration-test-token-123" },
|
|
231
|
+
memory: { userId: "integ_user" },
|
|
232
|
+
connections: [
|
|
233
|
+
{
|
|
234
|
+
id: "ollama-local",
|
|
235
|
+
name: "Ollama Local",
|
|
236
|
+
provider: "ollama",
|
|
237
|
+
baseUrl: "http://localhost:11434",
|
|
238
|
+
apiKey: "",
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
assignments: {
|
|
242
|
+
llm: { connectionId: "ollama-local", model: "qwen2.5-coder:3b" },
|
|
243
|
+
embeddings: {
|
|
244
|
+
connectionId: "ollama-local",
|
|
245
|
+
model: "nomic-embed-text",
|
|
246
|
+
embeddingDims: 768,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const [res, result] = await Promise.all([
|
|
252
|
+
fetch(`http://localhost:${serverPort}/api/setup/complete`, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: { "Content-Type": "application/json" },
|
|
255
|
+
body: JSON.stringify(body),
|
|
256
|
+
}),
|
|
257
|
+
waitForComplete(),
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
expect(res.status).toBe(200);
|
|
261
|
+
const data = (await res.json()) as { ok: boolean };
|
|
262
|
+
expect(data.ok).toBe(true);
|
|
263
|
+
expect(result.ok).toBe(true);
|
|
264
|
+
|
|
265
|
+
// Verify secrets.env was written with the admin token
|
|
266
|
+
const secretsContent = readFileSync(join(configDir, "secrets.env"), "utf-8");
|
|
267
|
+
expect(secretsContent).toContain("integration-test-token-123");
|
|
268
|
+
expect(secretsContent).toContain("OWNER_NAME=Integration Test");
|
|
269
|
+
|
|
270
|
+
// Verify memory config was written
|
|
271
|
+
const memConfigPath = join(dataDir, "memory", "default_config.json");
|
|
272
|
+
expect(existsSync(memConfigPath)).toBe(true);
|
|
273
|
+
const memConfig = JSON.parse(readFileSync(memConfigPath, "utf-8"));
|
|
274
|
+
expect(memConfig.mem0.llm.config.model).toBe("qwen2.5-coder:3b");
|
|
275
|
+
expect(memConfig.mem0.embedder.config.model).toBe("nomic-embed-text");
|
|
276
|
+
expect(memConfig.mem0.vector_store.config.embedding_model_dims).toBe(768);
|
|
277
|
+
|
|
278
|
+
// Verify connection profiles were written
|
|
279
|
+
const profilesPath = join(configDir, "connections", "profiles.json");
|
|
280
|
+
expect(existsSync(profilesPath)).toBe(true);
|
|
281
|
+
const profiles = JSON.parse(readFileSync(profilesPath, "utf-8"));
|
|
282
|
+
expect(profiles.profiles).toHaveLength(1);
|
|
283
|
+
expect(profiles.profiles[0].provider).toBe("ollama");
|
|
284
|
+
expect(profiles.assignments.llm.model).toBe("qwen2.5-coder:3b");
|
|
285
|
+
|
|
286
|
+
// Verify staged compose artifact exists
|
|
287
|
+
const stagedCompose = join(stateDir, "artifacts", "docker-compose.yml");
|
|
288
|
+
expect(existsSync(stagedCompose)).toBe(true);
|
|
289
|
+
|
|
290
|
+
// Verify openpalm.yaml stack spec was written
|
|
291
|
+
const specPath = join(configDir, "openpalm.yaml");
|
|
292
|
+
expect(existsSync(specPath)).toBe(true);
|
|
293
|
+
} finally {
|
|
294
|
+
stop();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── Setup state reflects completion ─────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
it("setup status returns true after successful completion", async () => {
|
|
301
|
+
const { stop, waitForComplete } = createSetupServer(serverPort, {
|
|
302
|
+
assetProvider: createStubAssetProvider(),
|
|
303
|
+
configDir,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
// Before setup: should be incomplete
|
|
308
|
+
const beforeRes = await fetch(`http://localhost:${serverPort}/api/setup/status`);
|
|
309
|
+
const beforeData = (await beforeRes.json()) as { ok: boolean; setupComplete: boolean };
|
|
310
|
+
expect(beforeData.setupComplete).toBe(false);
|
|
311
|
+
|
|
312
|
+
// Complete setup
|
|
313
|
+
const body = {
|
|
314
|
+
version: 1,
|
|
315
|
+
security: { adminToken: "status-test-token-123" },
|
|
316
|
+
memory: { userId: "status_user" },
|
|
317
|
+
connections: [
|
|
318
|
+
{
|
|
319
|
+
id: "openai-test",
|
|
320
|
+
name: "OpenAI",
|
|
321
|
+
provider: "openai",
|
|
322
|
+
baseUrl: "https://api.openai.com",
|
|
323
|
+
apiKey: "sk-test-key-status",
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
assignments: {
|
|
327
|
+
llm: { connectionId: "openai-test", model: "gpt-4o" },
|
|
328
|
+
embeddings: { connectionId: "openai-test", model: "text-embedding-3-small" },
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
await Promise.all([
|
|
333
|
+
fetch(`http://localhost:${serverPort}/api/setup/complete`, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: { "Content-Type": "application/json" },
|
|
336
|
+
body: JSON.stringify(body),
|
|
337
|
+
}),
|
|
338
|
+
waitForComplete(),
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
// After setup: should be complete
|
|
342
|
+
const afterRes = await fetch(`http://localhost:${serverPort}/api/setup/status`);
|
|
343
|
+
const afterData = (await afterRes.json()) as { ok: boolean; setupComplete: boolean };
|
|
344
|
+
expect(afterData.setupComplete).toBe(true);
|
|
345
|
+
} finally {
|
|
346
|
+
stop();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ── Deploy status lifecycle ─────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
it("deploy status transitions through pending -> running via markAllRunning", async () => {
|
|
353
|
+
const { stop, updateDeployStatus, markAllRunning } = createSetupServer(serverPort, {
|
|
354
|
+
assetProvider: createStubAssetProvider(),
|
|
355
|
+
configDir,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
// Initially empty
|
|
360
|
+
const emptyRes = await fetch(`http://localhost:${serverPort}/api/setup/deploy-status`);
|
|
361
|
+
const emptyData = (await emptyRes.json()) as {
|
|
362
|
+
ok: boolean;
|
|
363
|
+
deployStatus: Array<{ service: string; status: string }>;
|
|
364
|
+
};
|
|
365
|
+
expect(emptyData.deployStatus).toHaveLength(0);
|
|
366
|
+
|
|
367
|
+
// Set to pending
|
|
368
|
+
updateDeployStatus([
|
|
369
|
+
{ service: "caddy", status: "pending", label: "Caddy" },
|
|
370
|
+
{ service: "memory", status: "pending", label: "Memory" },
|
|
371
|
+
{ service: "assistant", status: "pending", label: "Assistant" },
|
|
372
|
+
]);
|
|
373
|
+
|
|
374
|
+
const pendingRes = await fetch(`http://localhost:${serverPort}/api/setup/deploy-status`);
|
|
375
|
+
const pendingData = (await pendingRes.json()) as {
|
|
376
|
+
ok: boolean;
|
|
377
|
+
deployStatus: Array<{ service: string; status: string }>;
|
|
378
|
+
};
|
|
379
|
+
expect(pendingData.deployStatus).toHaveLength(3);
|
|
380
|
+
expect(pendingData.deployStatus.every((e) => e.status === "pending")).toBe(true);
|
|
381
|
+
|
|
382
|
+
// Transition to running
|
|
383
|
+
markAllRunning();
|
|
384
|
+
|
|
385
|
+
const runningRes = await fetch(`http://localhost:${serverPort}/api/setup/deploy-status`);
|
|
386
|
+
const runningData = (await runningRes.json()) as {
|
|
387
|
+
ok: boolean;
|
|
388
|
+
deployStatus: Array<{ service: string; status: string }>;
|
|
389
|
+
};
|
|
390
|
+
expect(runningData.deployStatus.every((e) => e.status === "running")).toBe(true);
|
|
391
|
+
} finally {
|
|
392
|
+
stop();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("markAllRunning preserves error status entries", async () => {
|
|
397
|
+
const { stop, updateDeployStatus, markAllRunning } = createSetupServer(serverPort, {
|
|
398
|
+
assetProvider: createStubAssetProvider(),
|
|
399
|
+
configDir,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
updateDeployStatus([
|
|
404
|
+
{ service: "caddy", status: "pulling", label: "Caddy" },
|
|
405
|
+
{ service: "memory", status: "error", label: "Memory" },
|
|
406
|
+
]);
|
|
407
|
+
|
|
408
|
+
markAllRunning();
|
|
409
|
+
|
|
410
|
+
const res = await fetch(`http://localhost:${serverPort}/api/setup/deploy-status`);
|
|
411
|
+
const data = (await res.json()) as {
|
|
412
|
+
ok: boolean;
|
|
413
|
+
deployStatus: Array<{ service: string; status: string }>;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const caddy = data.deployStatus.find((e) => e.service === "caddy");
|
|
417
|
+
const memory = data.deployStatus.find((e) => e.service === "memory");
|
|
418
|
+
expect(caddy?.status).toBe("running");
|
|
419
|
+
expect(memory?.status).toBe("error"); // Error entries stay as-is
|
|
420
|
+
} finally {
|
|
421
|
+
stop();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// ── Setup retry after deploy error ──────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
it("allows re-completing setup after a deploy error", async () => {
|
|
428
|
+
const { stop, waitForComplete, setDeployError } = createSetupServer(serverPort, {
|
|
429
|
+
assetProvider: createStubAssetProvider(),
|
|
430
|
+
configDir,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const body = {
|
|
435
|
+
version: 1,
|
|
436
|
+
security: { adminToken: "retry-test-token-123" },
|
|
437
|
+
memory: { userId: "retry_user" },
|
|
438
|
+
connections: [
|
|
439
|
+
{
|
|
440
|
+
id: "openai-retry",
|
|
441
|
+
name: "OpenAI",
|
|
442
|
+
provider: "openai",
|
|
443
|
+
baseUrl: "https://api.openai.com",
|
|
444
|
+
apiKey: "sk-test-key-retry",
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
assignments: {
|
|
448
|
+
llm: { connectionId: "openai-retry", model: "gpt-4o" },
|
|
449
|
+
embeddings: { connectionId: "openai-retry", model: "text-embedding-3-small" },
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// First setup completes successfully
|
|
454
|
+
await Promise.all([
|
|
455
|
+
fetch(`http://localhost:${serverPort}/api/setup/complete`, {
|
|
456
|
+
method: "POST",
|
|
457
|
+
headers: { "Content-Type": "application/json" },
|
|
458
|
+
body: JSON.stringify(body),
|
|
459
|
+
}),
|
|
460
|
+
waitForComplete(),
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
// Simulate deploy error
|
|
464
|
+
setDeployError("caddy failed to start");
|
|
465
|
+
|
|
466
|
+
// Retry should be allowed (not blocked by "already complete")
|
|
467
|
+
const retryRes = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
|
|
468
|
+
method: "POST",
|
|
469
|
+
headers: { "Content-Type": "application/json" },
|
|
470
|
+
body: JSON.stringify(body),
|
|
471
|
+
});
|
|
472
|
+
expect(retryRes.status).toBe(200);
|
|
473
|
+
const retryData = (await retryRes.json()) as { ok: boolean };
|
|
474
|
+
expect(retryData.ok).toBe(true);
|
|
475
|
+
} finally {
|
|
476
|
+
stop();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ── Provider detection integration ──────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
it("detect-providers finds Ollama when it is running", async () => {
|
|
483
|
+
if (!ollamaUp) {
|
|
484
|
+
console.log("SKIP: Ollama not available at localhost:11434");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const { stop } = createSetupServer(serverPort, {
|
|
489
|
+
assetProvider: createStubAssetProvider(),
|
|
490
|
+
configDir,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const res = await fetch(`http://localhost:${serverPort}/api/setup/detect-providers`, {
|
|
495
|
+
signal: AbortSignal.timeout(15000),
|
|
496
|
+
});
|
|
497
|
+
expect(res.status).toBe(200);
|
|
498
|
+
const data = (await res.json()) as {
|
|
499
|
+
ok: boolean;
|
|
500
|
+
providers: Array<{ provider: string; url: string; available: boolean }>;
|
|
501
|
+
};
|
|
502
|
+
expect(data.ok).toBe(true);
|
|
503
|
+
|
|
504
|
+
const ollama = data.providers.find((p) => p.provider === "ollama");
|
|
505
|
+
expect(ollama).toBeDefined();
|
|
506
|
+
expect(ollama!.available).toBe(true);
|
|
507
|
+
} finally {
|
|
508
|
+
stop();
|
|
509
|
+
}
|
|
510
|
+
}, 20000);
|
|
511
|
+
});
|
|
@@ -20,6 +20,7 @@ function createStubAssetProvider(): CoreAssetProvider {
|
|
|
20
20
|
caddyfile: () =>
|
|
21
21
|
":80 {\n @denied not remote_ip 127.0.0.0/8 ::1\n respond @denied 403\n}\n",
|
|
22
22
|
ollamaCompose: () => "services:\n ollama:\n image: ollama/ollama\n",
|
|
23
|
+
adminCompose: () => "services:\n admin:\n image: openpalm/admin\n",
|
|
23
24
|
agentsMd: () => "# Agents\n",
|
|
24
25
|
opencodeConfig: () => '{"$schema":"https://opencode.ai/config.json"}\n',
|
|
25
26
|
adminOpencodeConfig: () => '{"$schema":"https://opencode.ai/config.json","plugin":["@openpalm/admin-tools"]}\n',
|
|
@@ -276,11 +277,10 @@ describe("setup wizard server", () => {
|
|
|
276
277
|
|
|
277
278
|
try {
|
|
278
279
|
const body = {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
ollamaEnabled: false,
|
|
280
|
+
version: 1,
|
|
281
|
+
owner: { name: "Test", email: "test@example.com" },
|
|
282
|
+
security: { adminToken: "test-admin-token-12345" },
|
|
283
|
+
memory: { userId: "test_user" },
|
|
284
284
|
connections: [
|
|
285
285
|
{
|
|
286
286
|
id: "openai-main",
|
|
@@ -335,7 +335,7 @@ describe("setup wizard server", () => {
|
|
|
335
335
|
const res = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
|
|
336
336
|
method: "POST",
|
|
337
337
|
headers: { "Content-Type": "application/json" },
|
|
338
|
-
body: JSON.stringify({ adminToken: "short" }),
|
|
338
|
+
body: JSON.stringify({ version: 1, security: { adminToken: "short" } }),
|
|
339
339
|
});
|
|
340
340
|
expect(res.status).toBe(400);
|
|
341
341
|
const data = (await res.json()) as { ok: boolean; error: string };
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* Uses Bun.serve() with a fetch handler for routing.
|
|
9
9
|
*/
|
|
10
10
|
import {
|
|
11
|
-
type
|
|
11
|
+
type SetupConfig,
|
|
12
12
|
type SetupResult,
|
|
13
13
|
type CoreAssetProvider,
|
|
14
|
-
|
|
14
|
+
performSetupFromConfig,
|
|
15
15
|
detectProviders,
|
|
16
16
|
isSetupComplete,
|
|
17
17
|
fetchProviderModels,
|
|
@@ -192,6 +192,9 @@ export function createSetupServer(
|
|
|
192
192
|
|
|
193
193
|
try {
|
|
194
194
|
const result = await fetchProviderModels(provider, apiKey, baseUrl, configDir);
|
|
195
|
+
if (result.status !== "ok") {
|
|
196
|
+
return jsonResponse(502, { ok: false, ...result });
|
|
197
|
+
}
|
|
195
198
|
return jsonResponse(200, { ok: true, ...result });
|
|
196
199
|
} catch (err) {
|
|
197
200
|
return errorResponse(500, "model_fetch_failed", String(err));
|
|
@@ -201,7 +204,8 @@ export function createSetupServer(
|
|
|
201
204
|
// ── API: Complete Setup ──────────────────────────────────────────
|
|
202
205
|
|
|
203
206
|
if (method === "POST" && path === "/api/setup/complete") {
|
|
204
|
-
if (
|
|
207
|
+
// Allow re-running if deploy failed (user clicked retry)
|
|
208
|
+
if (state.setupComplete && !state.deployError) {
|
|
205
209
|
return jsonResponse(200, { ok: true, message: "Setup already complete" });
|
|
206
210
|
}
|
|
207
211
|
|
|
@@ -212,12 +216,20 @@ export function createSetupServer(
|
|
|
212
216
|
return errorResponse(400, "invalid_json", "Request body must be valid JSON");
|
|
213
217
|
}
|
|
214
218
|
|
|
215
|
-
const
|
|
216
|
-
|
|
219
|
+
const config = body as SetupConfig;
|
|
220
|
+
let result: SetupResult;
|
|
221
|
+
try {
|
|
222
|
+
result = await performSetupFromConfig(config, assetProvider);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return errorResponse(500, "setup_failed", String(err));
|
|
225
|
+
}
|
|
217
226
|
|
|
218
227
|
if (result.ok) {
|
|
219
228
|
state.setupComplete = true;
|
|
220
229
|
state.setupResult = result;
|
|
230
|
+
// Reset deploy state for fresh polling
|
|
231
|
+
state.deployStatus = [];
|
|
232
|
+
state.deployError = null;
|
|
221
233
|
// Signal completion
|
|
222
234
|
resolveComplete?.(result);
|
|
223
235
|
}
|