openpalm 0.9.7 → 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.
@@ -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
- adminToken: "test-admin-token-12345",
280
- ownerName: "Test",
281
- ownerEmail: "test@example.com",
282
- memoryUserId: "test_user",
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 SetupInput,
11
+ type SetupConfig,
12
12
  type SetupResult,
13
13
  type CoreAssetProvider,
14
- performSetup,
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 (state.setupComplete) {
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 input = body as SetupInput;
216
- const result = await performSetup(input, assetProvider);
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
  }