openpalm 0.9.4 → 0.9.6
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 +40 -12
- package/e2e/start-wizard-server.ts +64 -0
- package/package.json +2 -1
- package/src/commands/install.ts +129 -19
- package/src/commands/logs.ts +4 -15
- package/src/commands/restart.ts +39 -11
- package/src/commands/service.ts +29 -3
- package/src/commands/start.ts +47 -16
- package/src/commands/status.ts +13 -2
- package/src/commands/stop.ts +36 -11
- package/src/commands/uninstall.ts +28 -3
- package/src/commands/update.ts +22 -2
- package/src/lib/admin.ts +27 -2
- package/src/lib/docker.ts +27 -100
- package/src/lib/paths.ts +13 -23
- package/src/lib/staging.ts +72 -0
- package/src/main.test.ts +7 -2
- package/src/setup-wizard/index.html +349 -0
- package/src/setup-wizard/server.test.ts +347 -0
- package/src/setup-wizard/server.ts +297 -0
- package/src/setup-wizard/wizard.css +952 -0
- package/src/setup-wizard/wizard.js +1104 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createSetupServer } from "./server.ts";
|
|
6
|
+
import type { CoreAssetProvider } from "@openpalm/lib";
|
|
7
|
+
|
|
8
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
let tempBase: string;
|
|
11
|
+
let configDir: string;
|
|
12
|
+
let dataDir: string;
|
|
13
|
+
let stateDir: string;
|
|
14
|
+
|
|
15
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
16
|
+
|
|
17
|
+
function createStubAssetProvider(): CoreAssetProvider {
|
|
18
|
+
return {
|
|
19
|
+
coreCompose: () => "services:\n caddy:\n image: caddy:latest\n",
|
|
20
|
+
caddyfile: () =>
|
|
21
|
+
":80 {\n @denied not remote_ip 127.0.0.0/8 ::1\n respond @denied 403\n}\n",
|
|
22
|
+
ollamaCompose: () => "services:\n ollama:\n image: ollama/ollama\n",
|
|
23
|
+
agentsMd: () => "# Agents\n",
|
|
24
|
+
opencodeConfig: () => '{"$schema":"https://opencode.ai/config.json"}\n',
|
|
25
|
+
adminOpencodeConfig: () => '{"$schema":"https://opencode.ai/config.json","plugin":["@openpalm/admin-tools"]}\n',
|
|
26
|
+
secretsSchema: () => "ADMIN_TOKEN=string\n",
|
|
27
|
+
stackSchema: () => "OPENPALM_IMAGE_TAG=string\n",
|
|
28
|
+
cleanupLogs: () => "name: cleanup-logs\nschedule: daily\n",
|
|
29
|
+
cleanupData: () => "name: cleanup-data\nschedule: weekly\n",
|
|
30
|
+
validateConfig: () => "name: validate-config\nschedule: hourly\n",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeSetupDirs(): void {
|
|
35
|
+
tempBase = mkdtempSync(join(tmpdir(), "openpalm-server-test-"));
|
|
36
|
+
configDir = join(tempBase, "config");
|
|
37
|
+
dataDir = join(tempBase, "data");
|
|
38
|
+
stateDir = join(tempBase, "state");
|
|
39
|
+
|
|
40
|
+
for (const dir of [
|
|
41
|
+
configDir,
|
|
42
|
+
join(configDir, "channels"),
|
|
43
|
+
join(configDir, "connections"),
|
|
44
|
+
join(configDir, "assistant"),
|
|
45
|
+
join(configDir, "automations"),
|
|
46
|
+
join(configDir, "stash"),
|
|
47
|
+
dataDir,
|
|
48
|
+
join(dataDir, "admin"),
|
|
49
|
+
join(dataDir, "memory"),
|
|
50
|
+
join(dataDir, "assistant"),
|
|
51
|
+
join(dataDir, "guardian"),
|
|
52
|
+
join(dataDir, "caddy"),
|
|
53
|
+
join(dataDir, "caddy", "data"),
|
|
54
|
+
join(dataDir, "caddy", "config"),
|
|
55
|
+
join(dataDir, "automations"),
|
|
56
|
+
join(dataDir, "opencode"),
|
|
57
|
+
stateDir,
|
|
58
|
+
join(stateDir, "artifacts"),
|
|
59
|
+
join(stateDir, "audit"),
|
|
60
|
+
join(stateDir, "artifacts", "channels"),
|
|
61
|
+
join(stateDir, "automations"),
|
|
62
|
+
join(stateDir, "opencode"),
|
|
63
|
+
]) {
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
writeFileSync(join(stateDir, "artifacts", "stack.env"), "OPENPALM_SETUP_COMPLETE=false\n");
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(configDir, "secrets.env"),
|
|
70
|
+
[
|
|
71
|
+
"# OpenPalm Secrets",
|
|
72
|
+
"export OPENPALM_ADMIN_TOKEN=",
|
|
73
|
+
"export ADMIN_TOKEN=",
|
|
74
|
+
"export OPENAI_API_KEY=",
|
|
75
|
+
"export OPENAI_BASE_URL=",
|
|
76
|
+
"export ANTHROPIC_API_KEY=",
|
|
77
|
+
"export GROQ_API_KEY=",
|
|
78
|
+
"export MISTRAL_API_KEY=",
|
|
79
|
+
"export GOOGLE_API_KEY=",
|
|
80
|
+
"export MEMORY_USER_ID=default_user",
|
|
81
|
+
"export MEMORY_AUTH_TOKEN=abc123",
|
|
82
|
+
"export OWNER_NAME=",
|
|
83
|
+
"export OWNER_EMAIL=",
|
|
84
|
+
"",
|
|
85
|
+
].join("\n")
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Test Suites ──────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
// Incrementing port counter to ensure no port conflicts between tests
|
|
92
|
+
let nextPort = 19100;
|
|
93
|
+
|
|
94
|
+
describe("setup wizard server", () => {
|
|
95
|
+
let serverPort: number;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
makeSetupDirs();
|
|
99
|
+
|
|
100
|
+
savedEnv.OPENPALM_CONFIG_HOME = process.env.OPENPALM_CONFIG_HOME;
|
|
101
|
+
savedEnv.OPENPALM_DATA_HOME = process.env.OPENPALM_DATA_HOME;
|
|
102
|
+
savedEnv.OPENPALM_STATE_HOME = process.env.OPENPALM_STATE_HOME;
|
|
103
|
+
process.env.OPENPALM_CONFIG_HOME = configDir;
|
|
104
|
+
process.env.OPENPALM_DATA_HOME = dataDir;
|
|
105
|
+
process.env.OPENPALM_STATE_HOME = stateDir;
|
|
106
|
+
|
|
107
|
+
// Use incrementing ports to avoid conflicts between sequential tests
|
|
108
|
+
serverPort = nextPort++;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterEach(() => {
|
|
112
|
+
process.env.OPENPALM_CONFIG_HOME = savedEnv.OPENPALM_CONFIG_HOME;
|
|
113
|
+
process.env.OPENPALM_DATA_HOME = savedEnv.OPENPALM_DATA_HOME;
|
|
114
|
+
process.env.OPENPALM_STATE_HOME = savedEnv.OPENPALM_STATE_HOME;
|
|
115
|
+
if (tempBase) rmSync(tempBase, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("serves the wizard HTML at GET /setup", async () => {
|
|
119
|
+
const { server, stop } = createSetupServer(serverPort, {
|
|
120
|
+
assetProvider: createStubAssetProvider(),
|
|
121
|
+
configDir,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(`http://localhost:${serverPort}/setup`);
|
|
126
|
+
expect(res.status).toBe(200);
|
|
127
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
128
|
+
const body = await res.text();
|
|
129
|
+
expect(body).toContain("OpenPalm Setup Wizard");
|
|
130
|
+
} finally {
|
|
131
|
+
stop();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("serves wizard.js at GET /setup/wizard.js", async () => {
|
|
136
|
+
const { server, stop } = createSetupServer(serverPort, {
|
|
137
|
+
assetProvider: createStubAssetProvider(),
|
|
138
|
+
configDir,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(`http://localhost:${serverPort}/setup/wizard.js`);
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
expect(res.headers.get("content-type")).toContain("application/javascript");
|
|
145
|
+
} finally {
|
|
146
|
+
stop();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("serves wizard.css at GET /setup/wizard.css", async () => {
|
|
151
|
+
const { server, stop } = createSetupServer(serverPort, {
|
|
152
|
+
assetProvider: createStubAssetProvider(),
|
|
153
|
+
configDir,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetch(`http://localhost:${serverPort}/setup/wizard.css`);
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
expect(res.headers.get("content-type")).toContain("text/css");
|
|
160
|
+
} finally {
|
|
161
|
+
stop();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns setup status at GET /api/setup/status", async () => {
|
|
166
|
+
const { server, stop } = createSetupServer(serverPort, {
|
|
167
|
+
assetProvider: createStubAssetProvider(),
|
|
168
|
+
configDir,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(`http://localhost:${serverPort}/api/setup/status`);
|
|
173
|
+
expect(res.status).toBe(200);
|
|
174
|
+
const data = (await res.json()) as { ok: boolean; setupComplete: boolean };
|
|
175
|
+
expect(data.ok).toBe(true);
|
|
176
|
+
expect(data.setupComplete).toBe(false);
|
|
177
|
+
} finally {
|
|
178
|
+
stop();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("returns provider detection at GET /api/setup/detect-providers", async () => {
|
|
183
|
+
const { server, stop } = createSetupServer(serverPort, {
|
|
184
|
+
assetProvider: createStubAssetProvider(),
|
|
185
|
+
configDir,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// detectLocalProviders probes real network endpoints with 3s timeouts each,
|
|
190
|
+
// so we allow a generous timeout for this test.
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const timer = setTimeout(() => controller.abort(), 15000);
|
|
193
|
+
const res = await fetch(`http://localhost:${serverPort}/api/setup/detect-providers`, {
|
|
194
|
+
signal: controller.signal,
|
|
195
|
+
});
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
expect(res.status).toBe(200);
|
|
198
|
+
const data = (await res.json()) as { ok: boolean; providers: unknown[] };
|
|
199
|
+
expect(data.ok).toBe(true);
|
|
200
|
+
expect(Array.isArray(data.providers)).toBe(true);
|
|
201
|
+
} finally {
|
|
202
|
+
stop();
|
|
203
|
+
}
|
|
204
|
+
}, 20000); // Extended test timeout for network probing
|
|
205
|
+
|
|
206
|
+
it("returns 404 for unknown routes", async () => {
|
|
207
|
+
const { server, stop } = createSetupServer(serverPort, {
|
|
208
|
+
assetProvider: createStubAssetProvider(),
|
|
209
|
+
configDir,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const res = await fetch(`http://localhost:${serverPort}/nonexistent`);
|
|
214
|
+
expect(res.status).toBe(404);
|
|
215
|
+
const data = (await res.json()) as { ok: boolean; error: string };
|
|
216
|
+
expect(data.ok).toBe(false);
|
|
217
|
+
expect(data.error).toBe("not_found");
|
|
218
|
+
} finally {
|
|
219
|
+
stop();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns deploy status at GET /api/setup/deploy-status", async () => {
|
|
224
|
+
const { server, stop, updateDeployStatus } = createSetupServer(serverPort, {
|
|
225
|
+
assetProvider: createStubAssetProvider(),
|
|
226
|
+
configDir,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
updateDeployStatus([
|
|
231
|
+
{ service: "caddy", status: "pending", label: "Caddy" },
|
|
232
|
+
{ service: "memory", status: "pulling", label: "Memory" },
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
const res = await fetch(`http://localhost:${serverPort}/api/setup/deploy-status`);
|
|
236
|
+
expect(res.status).toBe(200);
|
|
237
|
+
const data = (await res.json()) as {
|
|
238
|
+
ok: boolean;
|
|
239
|
+
setupComplete: boolean;
|
|
240
|
+
deployStatus: Array<{ service: string; status: string; label: string }>;
|
|
241
|
+
};
|
|
242
|
+
expect(data.ok).toBe(true);
|
|
243
|
+
expect(data.deployStatus).toHaveLength(2);
|
|
244
|
+
expect(data.deployStatus[0].service).toBe("caddy");
|
|
245
|
+
} finally {
|
|
246
|
+
stop();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("rejects invalid JSON on POST /api/setup/complete", async () => {
|
|
251
|
+
const { server, stop } = createSetupServer(serverPort, {
|
|
252
|
+
assetProvider: createStubAssetProvider(),
|
|
253
|
+
configDir,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: { "Content-Type": "application/json" },
|
|
260
|
+
body: "not-json",
|
|
261
|
+
});
|
|
262
|
+
expect(res.status).toBe(400);
|
|
263
|
+
const data = (await res.json()) as { ok: boolean; error: string };
|
|
264
|
+
expect(data.ok).toBe(false);
|
|
265
|
+
expect(data.error).toBe("invalid_json");
|
|
266
|
+
} finally {
|
|
267
|
+
stop();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("completes setup and resolves waitForComplete", async () => {
|
|
272
|
+
const { server, stop, waitForComplete } = createSetupServer(serverPort, {
|
|
273
|
+
assetProvider: createStubAssetProvider(),
|
|
274
|
+
configDir,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const body = {
|
|
279
|
+
adminToken: "test-admin-token-12345",
|
|
280
|
+
ownerName: "Test",
|
|
281
|
+
ownerEmail: "test@example.com",
|
|
282
|
+
memoryUserId: "test_user",
|
|
283
|
+
ollamaEnabled: false,
|
|
284
|
+
connections: [
|
|
285
|
+
{
|
|
286
|
+
id: "openai-main",
|
|
287
|
+
name: "OpenAI",
|
|
288
|
+
provider: "openai",
|
|
289
|
+
baseUrl: "https://api.openai.com",
|
|
290
|
+
apiKey: "sk-test-key-123",
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
assignments: {
|
|
294
|
+
llm: { connectionId: "openai-main", model: "gpt-4o" },
|
|
295
|
+
embeddings: { connectionId: "openai-main", model: "text-embedding-3-small" },
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Fire POST and await both the response and the completion signal
|
|
300
|
+
const [res, result] = await Promise.all([
|
|
301
|
+
fetch(`http://localhost:${serverPort}/api/setup/complete`, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { "Content-Type": "application/json" },
|
|
304
|
+
body: JSON.stringify(body),
|
|
305
|
+
}),
|
|
306
|
+
waitForComplete(),
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
expect(res.status).toBe(200);
|
|
310
|
+
const data = (await res.json()) as { ok: boolean };
|
|
311
|
+
expect(data.ok).toBe(true);
|
|
312
|
+
expect(result.ok).toBe(true);
|
|
313
|
+
|
|
314
|
+
// Subsequent POST should return "already complete"
|
|
315
|
+
const res2 = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: { "Content-Type": "application/json" },
|
|
318
|
+
body: JSON.stringify(body),
|
|
319
|
+
});
|
|
320
|
+
expect(res2.status).toBe(200);
|
|
321
|
+
const data2 = (await res2.json()) as { ok: boolean; message: string };
|
|
322
|
+
expect(data2.message).toBe("Setup already complete");
|
|
323
|
+
} finally {
|
|
324
|
+
stop();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("returns 400 for invalid setup input on POST /api/setup/complete", async () => {
|
|
329
|
+
const { server, stop } = createSetupServer(serverPort, {
|
|
330
|
+
assetProvider: createStubAssetProvider(),
|
|
331
|
+
configDir,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const res = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: { "Content-Type": "application/json" },
|
|
338
|
+
body: JSON.stringify({ adminToken: "short" }),
|
|
339
|
+
});
|
|
340
|
+
expect(res.status).toBe(400);
|
|
341
|
+
const data = (await res.json()) as { ok: boolean; error: string };
|
|
342
|
+
expect(data.ok).toBe(false);
|
|
343
|
+
} finally {
|
|
344
|
+
stop();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI setup wizard HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Serves the setup wizard UI and provides API endpoints for provider detection,
|
|
5
|
+
* model listing, and setup completion. Runs temporarily during `openpalm install`,
|
|
6
|
+
* blocking until the user completes the wizard.
|
|
7
|
+
*
|
|
8
|
+
* Uses Bun.serve() with a fetch handler for routing.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
type SetupInput,
|
|
12
|
+
type SetupResult,
|
|
13
|
+
type CoreAssetProvider,
|
|
14
|
+
performSetup,
|
|
15
|
+
detectProviders,
|
|
16
|
+
isSetupComplete,
|
|
17
|
+
fetchProviderModels,
|
|
18
|
+
resolveConfigHome,
|
|
19
|
+
resolveStateHome,
|
|
20
|
+
FilesystemAssetProvider,
|
|
21
|
+
resolveDataHome,
|
|
22
|
+
} from "@openpalm/lib";
|
|
23
|
+
|
|
24
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
type DeployStatusEntry = {
|
|
27
|
+
service: string;
|
|
28
|
+
status: "pending" | "pulling" | "ready" | "running" | "error";
|
|
29
|
+
label: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type SetupServerState = {
|
|
33
|
+
setupComplete: boolean;
|
|
34
|
+
setupResult: SetupResult | null;
|
|
35
|
+
deployStatus: DeployStatusEntry[];
|
|
36
|
+
deployError: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ── JSON Response Helpers ────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
42
|
+
return new Response(JSON.stringify(body), {
|
|
43
|
+
status,
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
"Cache-Control": "no-store",
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function errorResponse(status: number, code: string, message: string): Response {
|
|
52
|
+
return jsonResponse(status, { ok: false, error: code, message });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Route Matching ───────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Match a URL path against a pattern with `:param` segments.
|
|
59
|
+
* Returns the matched params or null if no match.
|
|
60
|
+
*/
|
|
61
|
+
function matchRoute(
|
|
62
|
+
path: string,
|
|
63
|
+
pattern: string
|
|
64
|
+
): Record<string, string> | null {
|
|
65
|
+
const pathSegments = path.split("/").filter(Boolean);
|
|
66
|
+
const patternSegments = pattern.split("/").filter(Boolean);
|
|
67
|
+
|
|
68
|
+
if (pathSegments.length !== patternSegments.length) return null;
|
|
69
|
+
|
|
70
|
+
const params: Record<string, string> = {};
|
|
71
|
+
for (let i = 0; i < patternSegments.length; i++) {
|
|
72
|
+
const patSeg = patternSegments[i];
|
|
73
|
+
if (patSeg.startsWith(":")) {
|
|
74
|
+
params[patSeg.slice(1)] = decodeURIComponent(pathSegments[i]);
|
|
75
|
+
} else if (patSeg !== pathSegments[i]) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return params;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Server Factory ───────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export type SetupServer = {
|
|
85
|
+
server: ReturnType<typeof Bun.serve>;
|
|
86
|
+
waitForComplete: () => Promise<SetupResult>;
|
|
87
|
+
stop: () => void;
|
|
88
|
+
/** Update deploy status for a service (for progress tracking). */
|
|
89
|
+
updateDeployStatus: (entries: DeployStatusEntry[]) => void;
|
|
90
|
+
setDeployError: (error: string) => void;
|
|
91
|
+
markAllRunning: () => void;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create and start the setup wizard HTTP server.
|
|
96
|
+
*
|
|
97
|
+
* @param port - Port to listen on (default 8100)
|
|
98
|
+
* @param opts - Optional overrides for asset provider and config dir
|
|
99
|
+
*/
|
|
100
|
+
export function createSetupServer(
|
|
101
|
+
port: number = 8100,
|
|
102
|
+
opts?: {
|
|
103
|
+
assetProvider?: CoreAssetProvider;
|
|
104
|
+
configDir?: string;
|
|
105
|
+
}
|
|
106
|
+
): SetupServer {
|
|
107
|
+
const configDir = opts?.configDir ?? resolveConfigHome();
|
|
108
|
+
const assetProvider = opts?.assetProvider ?? new FilesystemAssetProvider(resolveDataHome());
|
|
109
|
+
|
|
110
|
+
// Mutable server state
|
|
111
|
+
const state: SetupServerState = {
|
|
112
|
+
setupComplete: false,
|
|
113
|
+
setupResult: null,
|
|
114
|
+
deployStatus: [],
|
|
115
|
+
deployError: null,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Completion signal: resolves when setup POST succeeds
|
|
119
|
+
let resolveComplete: ((result: SetupResult) => void) | null = null;
|
|
120
|
+
const completionPromise = new Promise<SetupResult>((resolve) => {
|
|
121
|
+
resolveComplete = resolve;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── Request Handler ──────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
async function handleRequest(req: Request): Promise<Response> {
|
|
127
|
+
const url = new URL(req.url);
|
|
128
|
+
const path = url.pathname;
|
|
129
|
+
const method = req.method;
|
|
130
|
+
|
|
131
|
+
// ── Static Assets ────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
if (method === "GET" && (path === "/setup" || path === "/setup/")) {
|
|
134
|
+
return new Response(WIZARD_HTML, {
|
|
135
|
+
status: 200,
|
|
136
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (method === "GET" && path === "/setup/wizard.js") {
|
|
141
|
+
return new Response(WIZARD_JS, {
|
|
142
|
+
status: 200,
|
|
143
|
+
headers: { "Content-Type": "application/javascript; charset=utf-8" },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (method === "GET" && path === "/setup/wizard.css") {
|
|
148
|
+
return new Response(WIZARD_CSS, {
|
|
149
|
+
status: 200,
|
|
150
|
+
headers: { "Content-Type": "text/css; charset=utf-8" },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── API: Setup Status ────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
if (method === "GET" && path === "/api/setup/status") {
|
|
157
|
+
const stateDir = resolveStateHome();
|
|
158
|
+
const complete = isSetupComplete(stateDir, configDir);
|
|
159
|
+
return jsonResponse(200, {
|
|
160
|
+
ok: true,
|
|
161
|
+
setupComplete: complete || state.setupComplete,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── API: Detect Providers ────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
if (method === "GET" && path === "/api/setup/detect-providers") {
|
|
168
|
+
try {
|
|
169
|
+
const providers = await detectProviders();
|
|
170
|
+
return jsonResponse(200, { ok: true, providers });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return errorResponse(500, "detection_failed", String(err));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── API: Fetch Models for a Provider ─────────────────────────────
|
|
177
|
+
// Uses POST to keep API keys out of URLs/query strings.
|
|
178
|
+
|
|
179
|
+
const modelsMatch = matchRoute(path, "/api/setup/models/:provider");
|
|
180
|
+
if (method === "POST" && modelsMatch) {
|
|
181
|
+
const provider = modelsMatch.provider;
|
|
182
|
+
|
|
183
|
+
let reqBody: Record<string, unknown> = {};
|
|
184
|
+
try {
|
|
185
|
+
reqBody = (await req.json()) as Record<string, unknown>;
|
|
186
|
+
} catch {
|
|
187
|
+
return errorResponse(400, "invalid_json", "Request body must be valid JSON");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const apiKey = typeof reqBody.apiKey === "string" ? reqBody.apiKey : "";
|
|
191
|
+
const baseUrl = typeof reqBody.baseUrl === "string" ? reqBody.baseUrl : "";
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const result = await fetchProviderModels(provider, apiKey, baseUrl, configDir);
|
|
195
|
+
return jsonResponse(200, { ok: true, ...result });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return errorResponse(500, "model_fetch_failed", String(err));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── API: Complete Setup ──────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
if (method === "POST" && path === "/api/setup/complete") {
|
|
204
|
+
if (state.setupComplete) {
|
|
205
|
+
return jsonResponse(200, { ok: true, message: "Setup already complete" });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let body: unknown;
|
|
209
|
+
try {
|
|
210
|
+
body = await req.json();
|
|
211
|
+
} catch {
|
|
212
|
+
return errorResponse(400, "invalid_json", "Request body must be valid JSON");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const input = body as SetupInput;
|
|
216
|
+
const result = await performSetup(input, assetProvider);
|
|
217
|
+
|
|
218
|
+
if (result.ok) {
|
|
219
|
+
state.setupComplete = true;
|
|
220
|
+
state.setupResult = result;
|
|
221
|
+
// Signal completion
|
|
222
|
+
resolveComplete?.(result);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return jsonResponse(result.ok ? 200 : 400, result);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── API: Deploy Status ───────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
if (method === "GET" && path === "/api/setup/deploy-status") {
|
|
231
|
+
return jsonResponse(200, {
|
|
232
|
+
ok: true,
|
|
233
|
+
setupComplete: state.setupComplete,
|
|
234
|
+
deployStatus: state.deployStatus,
|
|
235
|
+
deployError: state.deployError,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── 404 ──────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
return errorResponse(404, "not_found", `Route not found: ${method} ${path}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Start Server ─────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
const server = Bun.serve({
|
|
247
|
+
port,
|
|
248
|
+
hostname: "127.0.0.1",
|
|
249
|
+
fetch: handleRequest,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
server,
|
|
254
|
+
waitForComplete: () => completionPromise,
|
|
255
|
+
stop: () => server.stop(),
|
|
256
|
+
updateDeployStatus: (entries: DeployStatusEntry[]) => {
|
|
257
|
+
state.deployStatus = entries;
|
|
258
|
+
},
|
|
259
|
+
setDeployError: (error: string) => {
|
|
260
|
+
state.deployError = error;
|
|
261
|
+
},
|
|
262
|
+
markAllRunning: () => {
|
|
263
|
+
for (const entry of state.deployStatus) {
|
|
264
|
+
if (entry.status !== "error") {
|
|
265
|
+
entry.status = "running";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Convenience: Wait for Setup Complete ─────────────────────────────────
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* High-level helper: starts the server, waits for setup to complete, then stops.
|
|
276
|
+
*/
|
|
277
|
+
export async function waitForSetupComplete(
|
|
278
|
+
port: number = 8100,
|
|
279
|
+
opts?: {
|
|
280
|
+
assetProvider?: CoreAssetProvider;
|
|
281
|
+
configDir?: string;
|
|
282
|
+
}
|
|
283
|
+
): Promise<SetupResult> {
|
|
284
|
+
const { server, waitForComplete, stop } = createSetupServer(port, opts);
|
|
285
|
+
try {
|
|
286
|
+
return await waitForComplete();
|
|
287
|
+
} finally {
|
|
288
|
+
stop();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Static Assets (wizard UI from task 2.1) ─────────────────────────────
|
|
293
|
+
// Embedded at build time via Bun text imports from sibling files.
|
|
294
|
+
|
|
295
|
+
import WIZARD_HTML from "./index.html" with { type: "text" };
|
|
296
|
+
import WIZARD_JS from "./wizard.js" with { type: "text" };
|
|
297
|
+
import WIZARD_CSS from "./wizard.css" with { type: "text" };
|