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 +2 -2
- package/src/commands/install.ts +96 -10
- package/src/install-flow.test.ts +7 -9
- package/src/lib/docker.ts +7 -3
- package/src/lib/env.ts +7 -15
- package/src/main.test.ts +15 -15
- package/src/setup-wizard/server-errors.test.ts +31 -38
- package/src/setup-wizard/server-integration.test.ts +36 -42
- package/src/setup-wizard/server.test.ts +12 -14
- package/src/setup-wizard/server.ts +8 -0
- package/src/setup-wizard/wizard-state.js +3 -0
- package/src/setup-wizard/wizard.js +33 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openpalm",
|
|
3
|
-
"version": "0.10.0
|
|
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.
|
|
29
|
+
"@openpalm/lib": ">=0.10.0 <1.0.0",
|
|
30
30
|
"citty": "^0.2.1",
|
|
31
31
|
"yaml": "^2.8.0"
|
|
32
32
|
}
|
package/src/commands/install.ts
CHANGED
|
@@ -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
|
-
|
|
200
|
-
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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(
|
|
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'));
|
package/src/install-flow.test.ts
CHANGED
|
@@ -114,18 +114,16 @@ function seedFromLocal(homeDir: string, enabledAddons: string[] = []): void {
|
|
|
114
114
|
|
|
115
115
|
function makeSetupSpec(): Record<string, unknown> {
|
|
116
116
|
return {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
#
|
|
80
|
-
|
|
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
|
-
'
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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("
|
|
168
|
+
expect(data.error).toContain("connections");
|
|
173
169
|
} finally {
|
|
174
170
|
stop();
|
|
175
171
|
}
|
|
176
172
|
});
|
|
177
173
|
|
|
178
|
-
it("returns 400 when
|
|
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
|
-
|
|
191
|
-
// no
|
|
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("
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 —
|
|
508
|
+
// Server is gone — use last known service list if available
|
|
504
509
|
stopDeployPolling();
|
|
505
|
-
|
|
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
|
}
|