openpalm 0.10.0-rc9 → 0.10.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpalm",
3
- "version": "0.10.0-rc9",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
6
  "description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
@@ -26,7 +26,7 @@
26
26
  "build:windows-arm64": "bun build src/main.ts --compile --target=bun-windows-arm64 --outfile dist/openpalm-cli-windows-arm64.exe"
27
27
  },
28
28
  "dependencies": {
29
- "@openpalm/lib": ">=0.9.8 <1.0.0",
29
+ "@openpalm/lib": ">=0.10.0 <1.0.0",
30
30
  "citty": "^0.2.1",
31
31
  "yaml": "^2.8.0"
32
32
  }
@@ -5,7 +5,7 @@ import cliPkg from '../../package.json' with { type: 'json' };
5
5
  import { defaultWorkDir } from '../lib/paths.ts';
6
6
  import { resolveOpenPalmHome, resolveConfigDir, resolveVaultDir, resolveDataDir } from '@openpalm/lib';
7
7
  import { ensureSecrets, ensureStackEnv, resolveRequestedImageTag } from '../lib/env.ts';
8
- import { ensureDirectoryTree, seedOpenPalmDir, openBrowser, runDockerCompose } from '../lib/docker.ts';
8
+ import { ensureDirectoryTree, seedOpenPalmDir, openBrowser, runDockerCompose, runDockerComposeCapture } from '../lib/docker.ts';
9
9
  import {
10
10
  ensureOpenCodeConfig, ensureOpenCodeSystemConfig,
11
11
  performSetup,
@@ -195,9 +195,15 @@ async function prepareInstallFiles(
195
195
  try { ensureOpenCodeConfig(); ensureOpenCodeSystemConfig(); } catch (err) { logger.debug('failed to ensure OpenCode config', { error: String(err) }); }
196
196
 
197
197
  try {
198
+ // Download + validate wrapped in a single timeout. The download can be
199
+ // slow on first install (binary fetch from GitHub) but must not block
200
+ // the install indefinitely — 30s is generous enough for most connections.
198
201
  await Promise.race([
199
- runVarlockValidation(dataDir, vaultDir),
200
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5_000)),
202
+ (async () => {
203
+ const varlockBin = await ensureVarlock();
204
+ await runVarlockValidation(varlockBin, vaultDir);
205
+ })(),
206
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30_000)),
201
207
  ]);
202
208
  console.log('Configuration validated.');
203
209
  } catch (err) { logger.debug('varlock validation skipped', { error: String(err) }); }
@@ -246,6 +252,7 @@ async function runWizardInstall(configDir: string, noOpen: boolean, noStart = fa
246
252
  }
247
253
 
248
254
  console.log('Setup complete. Checking Docker...');
255
+ wizard.setDeploying(true);
249
256
  await requireDocker();
250
257
 
251
258
  console.log('Starting services...');
@@ -257,13 +264,16 @@ async function runWizardInstall(configDir: string, noOpen: boolean, noStart = fa
257
264
  const allServices = await buildManagedServices(state);
258
265
  const composeArgs = fullComposeArgs(state);
259
266
  try {
260
- wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Waiting...'));
267
+ wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Pulling images...'));
261
268
  await runDockerCompose([...composeArgs, 'pull', ...allServices]).catch(() => {
262
269
  console.warn('Warning: image pull failed — if this is your first install, check your network connection.');
263
270
  });
264
271
  wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'pending', 'Starting...'));
265
272
  await runDockerCompose([...composeArgs, 'up', '-d', ...allServices]);
266
- wizard.markAllRunning();
273
+
274
+ // Poll container health so the wizard shows real progress per-service
275
+ await pollContainerHealth(composeArgs, allServices, wizard);
276
+
267
277
  console.log('\n✓ All services are running:');
268
278
  for (const svc of allServices) {
269
279
  console.log(` • ${svc}`);
@@ -273,7 +283,10 @@ async function runWizardInstall(configDir: string, noOpen: boolean, noStart = fa
273
283
  console.log(` Memory API: http://localhost:${3898}`);
274
284
  console.log(` Guardian: http://localhost:${3899}`);
275
285
  console.log('');
276
- await new Promise(resolve => setTimeout(resolve, 3000));
286
+ // pollContainerHealth returns as soon as all services are healthy, but
287
+ // the frontend polls every 2.5s — keep the server alive long enough for
288
+ // at least 2-3 polls to fetch the final "all running" state with URLs.
289
+ await new Promise(resolve => setTimeout(resolve, 8000));
277
290
  } catch (err) {
278
291
  wizard.updateDeployStatus(buildDeployStatusEntries(allServices, 'error', String(err)));
279
292
  wizard.setDeployError(String(err));
@@ -291,17 +304,91 @@ async function runFileInstall(filePath: string, noStart: boolean): Promise<void>
291
304
  throw new Error(`Setup config file not found: ${filePath}. Check the --file path and try again.`);
292
305
  }
293
306
  const config = await parseConfigFile(filePath, await Bun.file(filePath).text());
294
- if (config.version === 1) throw new Error('v1 setup config format is no longer supported. Use the v2 SetupSpec format (with a "spec" field).');
295
- if (!config.spec) throw new Error('Setup config must contain a "spec" field with the v2 StackSpec.');
307
+
308
+ // Normalize old wrapped format: { spec: { version, capabilities }, capabilities: [...] }
309
+ // into flat format: { version, capabilities: {...}, connections: [...] }
310
+ if (config.spec && typeof config.spec === 'object') {
311
+ const spec = config.spec as Record<string, unknown>;
312
+ // Old format had connections array as top-level "capabilities"
313
+ if (Array.isArray(config.capabilities)) config.connections = config.capabilities;
314
+ config.version = spec.version;
315
+ config.capabilities = spec.capabilities;
316
+ delete config.spec;
317
+ }
318
+
319
+ if (config.version !== 2) throw new Error('Setup config must be version 2. See example.spec.yaml for the format.');
320
+ if (!config.capabilities || typeof config.capabilities !== 'object' || Array.isArray(config.capabilities)) {
321
+ throw new Error('Setup config must contain a "capabilities" object (llm, embeddings, memory).');
322
+ }
323
+
324
+ // Resolve security.adminToken from environment when not in spec
325
+ const security = (config.security ?? {}) as Record<string, unknown>;
326
+ if (!security.adminToken && process.env.OP_ADMIN_TOKEN) {
327
+ security.adminToken = process.env.OP_ADMIN_TOKEN;
328
+ config.security = security;
329
+ }
296
330
 
297
331
  const result = await performSetup(config as unknown as SetupSpec);
298
332
  if (!result.ok) throw new Error(`Setup failed: ${result.error}`);
299
333
  console.log('Setup complete.');
300
334
  if (noStart) { console.log('Config written. Run `openpalm start` to start services.'); return; }
301
335
  await requireDocker();
336
+ await ensureVolumeMountTargets(resolveOpenPalmHome(), resolveVaultDir());
302
337
  await deployServices('install');
303
338
  }
304
339
 
340
+ /**
341
+ * Poll `docker compose ps` until all services are running/healthy (or timeout).
342
+ * Updates the wizard deploy status per-service so the frontend shows real progress.
343
+ */
344
+ async function pollContainerHealth(
345
+ composeArgs: string[],
346
+ services: string[],
347
+ wizard: ReturnType<typeof createSetupServer>,
348
+ ): Promise<void> {
349
+ const MAX_WAIT_MS = 120_000; // 2 minutes
350
+ const POLL_INTERVAL = 3_000;
351
+ const start = Date.now();
352
+ const running = new Set<string>();
353
+ const psArgs = [...composeArgs, 'ps', '--format', 'json'];
354
+ let prevRunningCount = 0;
355
+
356
+ while (Date.now() - start < MAX_WAIT_MS) {
357
+ try {
358
+ const output = await runDockerComposeCapture(psArgs);
359
+ for (const line of output.trim().split('\n')) {
360
+ if (!line.trim()) continue;
361
+ try {
362
+ const container = JSON.parse(line) as { Service?: string; State?: string; Health?: string };
363
+ const svc = container.Service;
364
+ if (!svc || !services.includes(svc)) continue;
365
+ const isHealthy = container.Health === 'healthy' || (container.State === 'running' && !container.Health);
366
+ if (isHealthy) running.add(svc);
367
+ } catch { /* skip malformed JSON line */ }
368
+ }
369
+ } catch { /* compose ps failed — retry next tick */ }
370
+
371
+ if (running.size !== prevRunningCount) {
372
+ prevRunningCount = running.size;
373
+ const entries = services.map(svc => ({
374
+ service: svc,
375
+ status: (running.has(svc) ? 'running' : 'pending') as 'running' | 'pending',
376
+ label: running.has(svc) ? 'Running' : 'Starting...',
377
+ }));
378
+ wizard.updateDeployStatus(entries);
379
+ }
380
+
381
+ if (running.size >= services.length) return;
382
+
383
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
384
+ }
385
+
386
+ // Timeout: mark remaining as running so the UI completes, but warn
387
+ const pending = services.filter(s => !running.has(s));
388
+ console.warn(`Warning: health check timed out for: ${pending.join(', ')}. They may still be starting.`);
389
+ wizard.markAllRunning();
390
+ }
391
+
305
392
  /**
306
393
  * Parse all compose files under homeDir/stack/ and pre-create every host-side
307
394
  * volume mount target as the current user. This prevents Docker from creating
@@ -381,8 +468,7 @@ async function ensureVolumeMountTargets(homeDir: string, vaultDir: string): Prom
381
468
  }
382
469
  }
383
470
 
384
- async function runVarlockValidation(_dataDir: string, vaultDir: string): Promise<void> {
385
- const varlockBin = await ensureVarlock();
471
+ async function runVarlockValidation(varlockBin: string, vaultDir: string): Promise<void> {
386
472
  const schemaPath = join(vaultDir, 'user', 'user.env.schema');
387
473
  if (!(await Bun.file(schemaPath).exists())) return;
388
474
  const tmpDir = await prepareVarlockDir(schemaPath, join(vaultDir, 'user', 'user.env'));
@@ -114,18 +114,16 @@ function seedFromLocal(homeDir: string, enabledAddons: string[] = []): void {
114
114
 
115
115
  function makeSetupSpec(): Record<string, unknown> {
116
116
  return {
117
- spec: {
118
- version: 2,
119
- capabilities: {
120
- llm: 'ollama/qwen2.5-coder:3b',
121
- embeddings: { provider: 'ollama', model: 'nomic-embed-text:latest', dims: 768 },
122
- memory: { userId: 'testuser', customInstructions: '' },
123
- slm: 'ollama/qwen2.5-coder:3b',
124
- },
117
+ version: 2,
118
+ capabilities: {
119
+ llm: 'ollama/qwen2.5-coder:3b',
120
+ embeddings: { provider: 'ollama', model: 'nomic-embed-text:latest', dims: 768 },
121
+ memory: { userId: 'testuser', customInstructions: '' },
122
+ slm: 'ollama/qwen2.5-coder:3b',
125
123
  },
126
124
  security: { adminToken: 'test-admin-token-12345' },
127
125
  owner: { name: 'Test', email: 'test@test.com' },
128
- capabilities: [{
126
+ connections: [{
129
127
  id: 'ollama',
130
128
  name: 'Ollama',
131
129
  provider: 'ollama',
package/src/lib/docker.ts CHANGED
@@ -161,12 +161,16 @@ export async function seedOpenPalmDir(
161
161
  if (!res.ok) throw new Error(`Failed to download tarball (HTTP ${res.status})`);
162
162
  await Bun.write(tmpTar, res);
163
163
 
164
+ // Extract full tarball — avoid --wildcards which is GNU tar-only and
165
+ // breaks on macOS (BSD tar), causing silent extraction failure.
164
166
  const extractProc = Bun.spawn(
165
- ['tar', 'xzf', tmpTar, '--strip-components=1', '--wildcards',
166
- '*/.openpalm/stack/core.compose.yml', '*/.openpalm/registry/*', '*/.openpalm/vault/*', '*/core/assistant/opencode/*'],
167
+ ['tar', 'xzf', tmpTar, '--strip-components=1'],
167
168
  { cwd: tmpDir, stdout: 'ignore', stderr: 'pipe' },
168
169
  );
169
- await extractProc.exited;
170
+ const extractCode = await extractProc.exited;
171
+ if (extractCode !== 0) {
172
+ throw new Error(`tar extraction failed (exit code ${extractCode})`);
173
+ }
170
174
 
171
175
  const srcCoreCompose = join(tmpDir, '.openpalm', 'stack', 'core.compose.yml');
172
176
  if (!await Bun.file(srcCoreCompose).exists()) {
package/src/lib/env.ts CHANGED
@@ -71,21 +71,13 @@ export async function ensureSecrets(vaultDir: string): Promise<void> {
71
71
  }
72
72
 
73
73
  mkdirSync(join(vaultDir, 'user'), { recursive: true });
74
- const userId = process.env.USER || process.env.LOGNAME || process.env.USERNAME || 'default_user';
75
- const content = `# OpenPalm User Secrets generated by openpalm install
76
- # All values are configured via the setup wizard.
77
- # This file is compatible with both \`source user.env\` and Docker Compose env_file.
78
-
79
- # LLM provider keys (configure at least one via the setup wizard)
80
- export OPENAI_API_KEY=
81
- export OPENAI_BASE_URL=
82
- # export ANTHROPIC_API_KEY=
83
- # export GROQ_API_KEY=
84
- # export MISTRAL_API_KEY=
85
- # export GOOGLE_API_KEY=
86
-
87
- # Memory
88
- export MEMORY_USER_ID=${userId}
74
+ // user.env is for user-added custom env vars only.
75
+ // All standard secrets (API keys, tokens) live in stack.env.
76
+ // Do NOT put API key placeholders here — user.env is loaded after
77
+ // stack.env by Docker Compose, so empty values would override real keys.
78
+ const content = `# OpenPalm — User Extensions
79
+ # Add any custom environment variables here.
80
+ # These are loaded by compose alongside stack.env.
89
81
  `;
90
82
 
91
83
  await Bun.write(secretsPath, content);
package/src/main.test.ts CHANGED
@@ -9,22 +9,21 @@ import { detectHostInfo, main, reconcileStackEnvImageTag, resolveRequestedImageT
9
9
  function writeMinimalSetupSpec(dir: string): string {
10
10
  const specPath = join(dir, 'setup-spec.yaml');
11
11
  const yaml = [
12
- 'spec:',
13
- ' version: 2',
14
- ' capabilities:',
15
- ' llm: openai/gpt-4o',
16
- ' embeddings:',
17
- ' provider: openai',
18
- ' model: text-embedding-3-small',
19
- ' dims: 1536',
20
- ' memory:',
21
- ' userId: test_user',
12
+ 'version: 2',
13
+ 'capabilities:',
14
+ ' llm: openai/gpt-4o',
15
+ ' embeddings:',
16
+ ' provider: openai',
17
+ ' model: text-embedding-3-small',
18
+ ' dims: 1536',
19
+ ' memory:',
20
+ ' userId: test_user',
22
21
  'security:',
23
22
  ' adminToken: test-admin-token-12345',
24
23
  'owner:',
25
24
  ' name: Test User',
26
25
  ' email: test@example.com',
27
- 'capabilities:',
26
+ 'connections:',
28
27
  ' - id: openai',
29
28
  ' name: OpenAI',
30
29
  ' provider: openai',
@@ -582,7 +581,7 @@ describe('cli entrypoint (subprocess)', () => {
582
581
  });
583
582
 
584
583
  describe('secrets.env generation', () => {
585
- it('generates user.env with export prefix and user-managed keys', async () => {
584
+ it('generates user.env as empty placeholder (no API key placeholders)', async () => {
586
585
  const { ensureSecrets } = await import('./lib/env.ts');
587
586
  const tempDir = mkdtempSync(join(tmpdir(), 'openpalm-secrets-'));
588
587
  const vaultDir = join(tempDir, 'vault');
@@ -591,11 +590,12 @@ describe('secrets.env generation', () => {
591
590
  try {
592
591
  await ensureSecrets(vaultDir);
593
592
  const content = await Bun.file(join(vaultDir, 'user', 'user.env')).text();
594
- expect(content).toContain('export OPENAI_API_KEY=');
595
- expect(content).toContain('export MEMORY_USER_ID=');
596
- // System secrets (OP_ADMIN_TOKEN, OP_MEMORY_TOKEN) belong in stack.env, not user.env
593
+ // user.env is for user-added custom vars only — no API key placeholders
594
+ // (empty values would override real keys in stack.env via compose env-file precedence)
595
+ expect(content).not.toContain('OPENAI_API_KEY');
597
596
  expect(content).not.toContain('OP_ADMIN_TOKEN');
598
597
  expect(content).not.toContain('OP_MEMORY_TOKEN');
598
+ expect(content).toContain('User Extensions');
599
599
  } finally {
600
600
  rmSync(tempDir, { recursive: true, force: true });
601
601
  }
@@ -123,15 +123,13 @@ describe("setup wizard server error scenarios", () => {
123
123
  headers: { "Content-Type": "application/json" },
124
124
  body: JSON.stringify({
125
125
  // no security.adminToken
126
- spec: {
127
- version: 2,
128
- capabilities: {
129
- llm: "openai/gpt-4o",
130
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
131
- memory: { userId: "user1", customInstructions: "" },
132
- },
126
+ version: 2,
127
+ capabilities: {
128
+ llm: "openai/gpt-4o",
129
+ embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
130
+ memory: { userId: "user1", customInstructions: "" },
133
131
  },
134
- capabilities: [{ id: "c1", name: "C1", provider: "openai", baseUrl: "", apiKey: "sk-test" }],
132
+ connections: [{ id: "c1", name: "C1", provider: "openai", baseUrl: "", apiKey: "sk-test" }],
135
133
  }),
136
134
  });
137
135
  expect(res.status).toBe(400);
@@ -143,7 +141,7 @@ describe("setup wizard server error scenarios", () => {
143
141
  }
144
142
  });
145
143
 
146
- it("returns 400 when capabilities array is not an array", async () => {
144
+ it("returns 400 when connections array is not an array", async () => {
147
145
  const { stop } = createSetupServer(serverPort, {
148
146
  configDir,
149
147
  });
@@ -153,29 +151,27 @@ describe("setup wizard server error scenarios", () => {
153
151
  method: "POST",
154
152
  headers: { "Content-Type": "application/json" },
155
153
  body: JSON.stringify({
156
- spec: {
157
- version: 2,
158
- capabilities: {
159
- llm: "openai/gpt-4o",
160
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
161
- memory: { userId: "user1", customInstructions: "" },
162
- },
154
+ version: 2,
155
+ capabilities: {
156
+ llm: "openai/gpt-4o",
157
+ embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
158
+ memory: { userId: "user1", customInstructions: "" },
163
159
  },
164
160
  security: { adminToken: "valid-token-12345" },
165
161
  owner: { name: "Test User", email: "test@example.com" },
166
- capabilities: "not-an-array",
162
+ connections: "not-an-array",
167
163
  }),
168
164
  });
169
165
  expect(res.status).toBe(400);
170
166
  const data = (await res.json()) as { ok: boolean; error: string };
171
167
  expect(data.ok).toBe(false);
172
- expect(data.error).toContain("capabilities");
168
+ expect(data.error).toContain("connections");
173
169
  } finally {
174
170
  stop();
175
171
  }
176
172
  });
177
173
 
178
- it("returns 400 when spec is missing", async () => {
174
+ it("returns 400 when capabilities config is missing", async () => {
179
175
  const { stop } = createSetupServer(serverPort, {
180
176
  configDir,
181
177
  });
@@ -185,16 +181,17 @@ describe("setup wizard server error scenarios", () => {
185
181
  method: "POST",
186
182
  headers: { "Content-Type": "application/json" },
187
183
  body: JSON.stringify({
184
+ version: 2,
188
185
  security: { adminToken: "valid-token-12345" },
189
186
  owner: { name: "Test User", email: "test@example.com" },
190
- capabilities: [{ id: "c1", name: "C1", provider: "openai", baseUrl: "", apiKey: "sk-test" }],
191
- // no spec
187
+ connections: [{ id: "c1", name: "C1", provider: "openai", baseUrl: "", apiKey: "sk-test" }],
188
+ // no capabilities
192
189
  }),
193
190
  });
194
191
  expect(res.status).toBe(400);
195
192
  const data = (await res.json()) as { ok: boolean; error: string };
196
193
  expect(data.ok).toBe(false);
197
- expect(data.error).toContain("spec");
194
+ expect(data.error).toContain("capabilities");
198
195
  } finally {
199
196
  stop();
200
197
  }
@@ -210,17 +207,15 @@ describe("setup wizard server error scenarios", () => {
210
207
  method: "POST",
211
208
  headers: { "Content-Type": "application/json" },
212
209
  body: JSON.stringify({
213
- spec: {
214
- version: 2,
215
- capabilities: {
216
- llm: "fakeprovider/gpt-4o",
217
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
218
- memory: { userId: "user1", customInstructions: "" },
219
- },
210
+ version: 2,
211
+ capabilities: {
212
+ llm: "fakeprovider/gpt-4o",
213
+ embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
214
+ memory: { userId: "user1", customInstructions: "" },
220
215
  },
221
216
  security: { adminToken: "valid-token-12345" },
222
217
  owner: { name: "Test User", email: "test@example.com" },
223
- capabilities: [{ id: "c1", name: "C1", provider: "fakeprovider", baseUrl: "", apiKey: "sk-test" }],
218
+ connections: [{ id: "c1", name: "C1", provider: "fakeprovider", baseUrl: "", apiKey: "sk-test" }],
224
219
  }),
225
220
  });
226
221
  // Connection-provider matching is no longer validated; setup succeeds
@@ -242,17 +237,15 @@ describe("setup wizard server error scenarios", () => {
242
237
  method: "POST",
243
238
  headers: { "Content-Type": "application/json" },
244
239
  body: JSON.stringify({
245
- spec: {
246
- version: 2,
247
- capabilities: {
248
- llm: "anthropic/claude-3-opus", // No anthropic capability provided
249
- embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
250
- memory: { userId: "user1", customInstructions: "" },
251
- },
240
+ version: 2,
241
+ capabilities: {
242
+ llm: "anthropic/claude-3-opus", // No anthropic connection provided
243
+ embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
244
+ memory: { userId: "user1", customInstructions: "" },
252
245
  },
253
246
  security: { adminToken: "valid-token-12345" },
254
247
  owner: { name: "Test User", email: "test@example.com" },
255
- capabilities: [{ id: "c1", name: "C1", provider: "openai", baseUrl: "", apiKey: "sk-test" }],
248
+ connections: [{ id: "c1", name: "C1", provider: "openai", baseUrl: "", apiKey: "sk-test" }],
256
249
  }),
257
250
  });
258
251
  // Provider matching is no longer validated; setup succeeds
@@ -220,24 +220,22 @@ describe("setup wizard server integration", () => {
220
220
 
221
221
  try {
222
222
  const body = {
223
- spec: {
224
- version: 2,
225
- capabilities: {
226
- llm: "ollama/qwen2.5-coder:3b",
227
- embeddings: {
228
- provider: "ollama",
229
- model: "nomic-embed-text",
230
- dims: 768,
231
- },
232
- memory: {
233
- userId: "integ_user",
234
- customInstructions: "",
235
- },
223
+ version: 2,
224
+ capabilities: {
225
+ llm: "ollama/qwen2.5-coder:3b",
226
+ embeddings: {
227
+ provider: "ollama",
228
+ model: "nomic-embed-text",
229
+ dims: 768,
230
+ },
231
+ memory: {
232
+ userId: "integ_user",
233
+ customInstructions: "",
236
234
  },
237
235
  },
238
236
  security: { adminToken: "integration-test-token-123" },
239
237
  owner: { name: "Integration Test", email: "integ@test.local" },
240
- capabilities: [
238
+ connections: [
241
239
  {
242
240
  id: "ollama-local",
243
241
  name: "Ollama Local",
@@ -301,24 +299,22 @@ describe("setup wizard server integration", () => {
301
299
 
302
300
  // Complete setup
303
301
  const body = {
304
- spec: {
305
- version: 2,
306
- capabilities: {
307
- llm: "openai/gpt-4o",
308
- embeddings: {
309
- provider: "openai",
310
- model: "text-embedding-3-small",
311
- dims: 1536,
312
- },
313
- memory: {
314
- userId: "status_user",
315
- customInstructions: "",
316
- },
302
+ version: 2,
303
+ capabilities: {
304
+ llm: "openai/gpt-4o",
305
+ embeddings: {
306
+ provider: "openai",
307
+ model: "text-embedding-3-small",
308
+ dims: 1536,
309
+ },
310
+ memory: {
311
+ userId: "status_user",
312
+ customInstructions: "",
317
313
  },
318
314
  },
319
315
  security: { adminToken: "status-test-token-123" },
320
316
  owner: { name: "Status Test", email: "status@test.local" },
321
- capabilities: [
317
+ connections: [
322
318
  {
323
319
  id: "openai-test",
324
320
  name: "OpenAI",
@@ -429,24 +425,22 @@ describe("setup wizard server integration", () => {
429
425
 
430
426
  try {
431
427
  const body = {
432
- spec: {
433
- version: 2,
434
- capabilities: {
435
- llm: "openai/gpt-4o",
436
- embeddings: {
437
- provider: "openai",
438
- model: "text-embedding-3-small",
439
- dims: 1536,
440
- },
441
- memory: {
442
- userId: "retry_user",
443
- customInstructions: "",
444
- },
428
+ version: 2,
429
+ capabilities: {
430
+ llm: "openai/gpt-4o",
431
+ embeddings: {
432
+ provider: "openai",
433
+ model: "text-embedding-3-small",
434
+ dims: 1536,
435
+ },
436
+ memory: {
437
+ userId: "retry_user",
438
+ customInstructions: "",
445
439
  },
446
440
  },
447
441
  security: { adminToken: "retry-test-token-123" },
448
442
  owner: { name: "Retry Test", email: "retry@test.local" },
449
- capabilities: [
443
+ connections: [
450
444
  {
451
445
  id: "openai-retry",
452
446
  name: "OpenAI",
@@ -247,24 +247,22 @@ describe("setup wizard server", () => {
247
247
 
248
248
  try {
249
249
  const body = {
250
- spec: {
251
- version: 2,
252
- capabilities: {
253
- llm: "openai/gpt-4o",
254
- embeddings: {
255
- provider: "openai",
256
- model: "text-embedding-3-small",
257
- dims: 1536,
258
- },
259
- memory: {
260
- userId: "test_user",
261
- customInstructions: "",
262
- },
250
+ version: 2,
251
+ capabilities: {
252
+ llm: "openai/gpt-4o",
253
+ embeddings: {
254
+ provider: "openai",
255
+ model: "text-embedding-3-small",
256
+ dims: 1536,
257
+ },
258
+ memory: {
259
+ userId: "test_user",
260
+ customInstructions: "",
263
261
  },
264
262
  },
265
263
  security: { adminToken: "test-admin-token-12345" },
266
264
  owner: { name: "Test", email: "test@example.com" },
267
- capabilities: [
265
+ connections: [
268
266
  {
269
267
  id: "openai-main",
270
268
  name: "OpenAI",
@@ -32,6 +32,8 @@ type SetupServerState = {
32
32
  setupResult: SetupResult | null;
33
33
  deployStatus: DeployStatusEntry[];
34
34
  deployError: string | null;
35
+ /** True while services are being started (distinguishes from --no-start). */
36
+ deploying: boolean;
35
37
  };
36
38
 
37
39
  // ── JSON Response Helpers ────────────────────────────────────────────────
@@ -86,6 +88,7 @@ export type SetupServer = {
86
88
  /** Update deploy status for a service (for progress tracking). */
87
89
  updateDeployStatus: (entries: DeployStatusEntry[]) => void;
88
90
  setDeployError: (error: string) => void;
91
+ setDeploying: (value: boolean) => void;
89
92
  markAllRunning: () => void;
90
93
  };
91
94
 
@@ -111,6 +114,7 @@ export function createSetupServer(
111
114
  setupResult: null,
112
115
  deployStatus: [],
113
116
  deployError: null,
117
+ deploying: false,
114
118
  };
115
119
 
116
120
  // Completion signal: resolves when setup POST succeeds
@@ -243,6 +247,7 @@ export function createSetupServer(
243
247
  setupComplete: state.setupComplete,
244
248
  deployStatus: state.deployStatus,
245
249
  deployError: state.deployError,
250
+ deploying: state.deploying,
246
251
  });
247
252
  }
248
253
 
@@ -304,6 +309,9 @@ export function createSetupServer(
304
309
  setDeployError: (error: string) => {
305
310
  state.deployError = error;
306
311
  },
312
+ setDeploying: (value: boolean) => {
313
+ state.deploying = value;
314
+ },
307
315
  markAllRunning: () => {
308
316
  for (const entry of state.deployStatus) {
309
317
  if (entry.status !== "error") {
@@ -199,6 +199,9 @@ var verifyGeneration = {};
199
199
  /** Deploy poll error counter */
200
200
  var deployPollErrors = 0;
201
201
 
202
+ /** Last known deploy service entries — used as fallback when server stops */
203
+ var lastDeployData = null;
204
+
202
205
  // Initialize provider states
203
206
  PROVIDERS.forEach(function (p) {
204
207
  providerState[p.id] = {
@@ -367,29 +367,27 @@ function buildPayload() {
367
367
 
368
368
  // Build SetupSpec payload
369
369
  var payload = {
370
- spec: {
371
- version: 2,
372
- capabilities: {
373
- llm: llmProvider + "/" + (llm ? llm.model : ""),
374
- embeddings: {
375
- provider: embProvider,
376
- model: emb ? emb.model : "",
377
- dims: emb ? (emb.dims || 1536) : 1536,
378
- },
379
- memory: {
380
- userId: memoryUserId,
381
- customInstructions: "",
382
- },
370
+ version: 2,
371
+ capabilities: {
372
+ llm: llmProvider + "/" + (llm ? llm.model : ""),
373
+ embeddings: {
374
+ provider: embProvider,
375
+ model: emb ? emb.model : "",
376
+ dims: emb ? (emb.dims || 1536) : 1536,
377
+ },
378
+ memory: {
379
+ userId: memoryUserId,
380
+ customInstructions: "",
383
381
  },
384
- addons: addons,
385
382
  },
383
+ addons: addons,
386
384
  security: { adminToken: adminToken },
387
- capabilities: capabilities,
385
+ connections: capabilities,
388
386
  };
389
387
 
390
388
  // Add optional slm capability (uses its own provider, not the LLM provider)
391
389
  if (small && small.model) {
392
- payload.spec.capabilities.slm = small.connId + "/" + small.model;
390
+ payload.capabilities.slm = small.connId + "/" + small.model;
393
391
  }
394
392
 
395
393
  // Add reranking configuration if enabled
@@ -399,7 +397,7 @@ function buildPayload() {
399
397
  var rerankModel = $("reranking-model") ? ($("reranking-model").value || "").trim() : "";
400
398
  var topK = $("reranking-top-k") ? parseInt($("reranking-top-k").value, 10) : 20;
401
399
  var topN = $("reranking-top-n") ? parseInt($("reranking-top-n").value, 10) : 5;
402
- payload.spec.capabilities.reranking = {
400
+ payload.capabilities.reranking = {
403
401
  enabled: true,
404
402
  mode: rerankMode,
405
403
  model: rerankMode === "dedicated" ? rerankModel : "",
@@ -481,6 +479,13 @@ async function pollDeployStatus() {
481
479
  var data = await res.json();
482
480
  deployPollErrors = 0;
483
481
 
482
+ // Remember latest service list so we can show URLs if the server stops
483
+ if (data.deployStatus && data.deployStatus.length > 0) {
484
+ lastDeployData = data.deployStatus.map(function (s) {
485
+ return { service: s.service, status: s.status, label: s.label };
486
+ });
487
+ }
488
+
484
489
  updateDeployUI(data);
485
490
 
486
491
  if (data.deployError) {
@@ -492,17 +497,24 @@ async function pollDeployStatus() {
492
497
  stopDeployPolling();
493
498
  showDeployDone(data);
494
499
  }
495
- } else if (data.setupComplete && (!data.deployStatus || data.deployStatus.length === 0)) {
496
- // Setup complete but no deployment started (--no-start mode)
500
+ } else if (data.setupComplete && !data.deploying && (!data.deployStatus || data.deployStatus.length === 0)) {
501
+ // Setup complete and not deploying (--no-start mode)
497
502
  stopDeployPolling();
498
503
  showDeployDone({ deployStatus: [] });
499
504
  }
500
505
  } catch (e) {
501
506
  deployPollErrors++;
502
507
  if (deployPollErrors >= 3) {
503
- // Server is gone — setup completed without deployment (--no-start)
508
+ // Server is gone — use last known service list if available
504
509
  stopDeployPolling();
505
- showDeployDone({ deployStatus: [] });
510
+ if (lastDeployData && lastDeployData.length > 0) {
511
+ var doneEntries = lastDeployData.map(function (s) {
512
+ return { service: s.service, status: "running", label: s.label };
513
+ });
514
+ showDeployDone({ deployStatus: doneEntries });
515
+ } else {
516
+ showDeployDone({ deployStatus: [] });
517
+ }
506
518
  }
507
519
  }
508
520
  }