@dotdrelle/wiki-manager 0.14.10 → 0.14.12
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/.env.example +3 -1
- package/README.md +42 -2
- package/agents.docker-compose.yml +4 -5
- package/mcp.endpoints.example.json +2 -2
- package/package.json +2 -2
- package/src/agent/graph.js +8 -5
- package/src/agent/graph.test.js +4 -1
- package/src/cli/wiki-manager.js +23 -4
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +3 -0
- package/src/core/env.js +27 -3
- package/src/core/env.test.js +72 -0
- package/src/core/mcp.js +1 -1
- package/src/core/startupCheck.js +340 -7
- package/src/core/startupCheck.test.js +190 -3
- package/src/core/wikiWorkspace.test.js +9 -0
- package/src/runtime/lifecycle.js +1 -1
- package/src/shell/SetupWizard.tsx +25 -1
- package/src/shell/StartupScreen.tsx +42 -9
- package/src/shell/repl.js +34 -6
- package/src/shell/repl.test.js +77 -0
- package/src/shell/tui.tsx +60 -3
- package/wiki-workspace +20 -17
package/src/core/startupCheck.js
CHANGED
|
@@ -2,17 +2,88 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
-
import {
|
|
5
|
+
import { activeCacertPath, cacertEnv } from './cacert.js';
|
|
6
|
+
import { COMPOSE_SERVICES, parseComposePsJson, serviceStates } from './compose.js';
|
|
7
|
+
import { buildMcpStatus, discoverMcpTools } from './mcp.js';
|
|
6
8
|
import { listWikircProfiles, loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
|
|
7
9
|
import { listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
|
|
8
10
|
|
|
9
11
|
const execFileAsync = promisify(execFile);
|
|
12
|
+
const DEFAULT_CONNECTIVITY_URL = 'https://registry.npmjs.org/-/ping';
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
function commandError(err) {
|
|
15
|
+
return String(err?.stderr ?? err?.stdout ?? err?.message ?? err ?? 'unknown error').trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function dockerFailure(err) {
|
|
19
|
+
if (err?.code === 'ENOENT') return { ok: false, context: { dockerMissing: true } };
|
|
20
|
+
const detail = commandError(err);
|
|
21
|
+
const unavailable = /Cannot connect to the Docker daemon|Is the docker daemon running|docker daemon|connection refused/i.test(detail);
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
context: unavailable
|
|
25
|
+
? { dockerUnavailable: true, dockerError: detail }
|
|
26
|
+
: { dockerUnavailable: true, dockerError: detail || 'Docker availability check failed.' },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function checkDockerAvailability({ exec = execFileAsync } = {}) {
|
|
31
|
+
try {
|
|
32
|
+
const { stdout } = await exec('docker', ['info', '--format', '{{json .ServerVersion}}'], {
|
|
33
|
+
cwd: managerRoot(),
|
|
34
|
+
env: process.env,
|
|
35
|
+
timeout: 5000,
|
|
36
|
+
maxBuffer: 1024 * 1024,
|
|
37
|
+
});
|
|
38
|
+
return { ok: true, detail: String(stdout ?? '').trim().replace(/^"|"$/g, '') || 'available' };
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return dockerFailure(err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function checkInternetConnectivity({
|
|
45
|
+
exec = execFileAsync,
|
|
46
|
+
url = process.env.WIKI_MANAGER_CONNECTIVITY_URL ?? DEFAULT_CONNECTIVITY_URL,
|
|
47
|
+
timeoutMs = 7000,
|
|
48
|
+
} = {}) {
|
|
49
|
+
const cacertPath = activeCacertPath();
|
|
50
|
+
const proxyUrl = process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY ?? null;
|
|
51
|
+
const proxyEnabled = process.env.NODE_USE_ENV_PROXY === '1';
|
|
52
|
+
if (cacertPath && !existsSync(cacertPath)) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
context: { url, error: `CA certificate not found: ${cacertPath}`, cacertPath, proxyUrl, proxyEnabled },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const node = process.versions.bun ? (process.env.WIKI_MANAGER_NODE_BIN ?? 'node') : process.execPath;
|
|
59
|
+
const script = [
|
|
60
|
+
'const controller = new AbortController();',
|
|
61
|
+
`const timer = setTimeout(() => controller.abort(), ${Math.max(1000, Number(timeoutMs) || 7000)});`,
|
|
62
|
+
"try { const response = await fetch(process.argv[1], { method: 'GET', signal: controller.signal }); if (!response.ok) throw new Error('HTTP ' + response.status); } finally { clearTimeout(timer); }",
|
|
63
|
+
].join(' ');
|
|
64
|
+
try {
|
|
65
|
+
await exec(node, ['--input-type=module', '-e', script, url], {
|
|
66
|
+
cwd: managerRoot(),
|
|
67
|
+
// Run in a fresh Node process so both environment-proxy support and a
|
|
68
|
+
// custom CA are applied at process startup, just like the runtime.
|
|
69
|
+
env: { ...process.env, ...cacertEnv(cacertPath) },
|
|
70
|
+
timeout: Math.max(2000, Number(timeoutMs) + 1000),
|
|
71
|
+
maxBuffer: 1024 * 1024,
|
|
72
|
+
});
|
|
73
|
+
return { ok: true, detail: url, context: { url, cacertPath, proxyUrl, proxyEnabled } };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
context: { url, error: commandError(err) || 'Internet connectivity check failed.', cacertPath, proxyUrl, proxyEnabled },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function checkAgents({ exec = execFileAsync } = {}) {
|
|
12
83
|
const composeFile = join(managerRoot(), 'agents.docker-compose.yml');
|
|
13
84
|
if (!existsSync(composeFile)) return null;
|
|
14
85
|
try {
|
|
15
|
-
const { stdout } = await
|
|
86
|
+
const { stdout } = await exec('docker', [
|
|
16
87
|
'compose',
|
|
17
88
|
'--project-directory',
|
|
18
89
|
managerRoot(),
|
|
@@ -115,16 +186,278 @@ function checkWikirc(workspace) {
|
|
|
115
186
|
}
|
|
116
187
|
}
|
|
117
188
|
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
189
|
+
function startupSession(workspace) {
|
|
190
|
+
const session = {
|
|
191
|
+
workspace: workspace.name,
|
|
192
|
+
workspacePath: workspace.workspacePath,
|
|
193
|
+
workspaceEnvFile: workspace.envFile,
|
|
194
|
+
workspaceEnv: workspace.env,
|
|
195
|
+
wikirc: null,
|
|
196
|
+
wikircConfig: null,
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
const loaded = loadWikircProfile(workspace.workspacePath, 'default');
|
|
200
|
+
session.wikirc = {
|
|
201
|
+
profile: loaded.profile.name,
|
|
202
|
+
fileName: loaded.profile.fileName,
|
|
203
|
+
path: loaded.profile.path,
|
|
204
|
+
};
|
|
205
|
+
session.wikircConfig = loaded.config;
|
|
206
|
+
} catch { /* configuration gap is reported separately */ }
|
|
207
|
+
return session;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function checkWorkspaceContainers(workspace) {
|
|
211
|
+
try {
|
|
212
|
+
const states = await serviceStates(startupSession(workspace));
|
|
213
|
+
const unavailable = COMPOSE_SERVICES.filter((name) => !states[name]?.running);
|
|
214
|
+
return {
|
|
215
|
+
ok: unavailable.length === 0,
|
|
216
|
+
pending: unavailable.length > 0,
|
|
217
|
+
detail: unavailable.length === 0 ? `${COMPOSE_SERVICES.length} running` : `To start: ${unavailable.join(', ')}`,
|
|
218
|
+
context: { states, unavailable, command: `wiki-workspace up ${workspace.name}` },
|
|
219
|
+
};
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
pending: true,
|
|
224
|
+
detail: 'Workspace containers remain to be started',
|
|
225
|
+
context: { error: commandError(err), command: `wiki-workspace up ${workspace.name}` },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isLocalEndpoint(url) {
|
|
231
|
+
try {
|
|
232
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
233
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === 'host.docker.internal';
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function mcpFailureKind(value) {
|
|
240
|
+
if (value.status === 'missing') return 'configuration';
|
|
241
|
+
const message = String(value.toolError ?? '');
|
|
242
|
+
if (/\b(401|403|unauthorized|forbidden)\b/i.test(message)) return 'authentication';
|
|
243
|
+
if (/initialize|json|protocol|parse|session id/i.test(message)) return 'protocol';
|
|
244
|
+
if (/fetch failed|ECONNREFUSED|ENOTFOUND|timeout|aborted|socket/i.test(message)) return 'unreachable';
|
|
245
|
+
return 'unavailable';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function checkMcpConnections(workspace, {
|
|
249
|
+
internetAvailable = true,
|
|
250
|
+
buildStatus = buildMcpStatus,
|
|
251
|
+
discover = discoverMcpTools,
|
|
252
|
+
} = {}) {
|
|
253
|
+
let configured;
|
|
254
|
+
try {
|
|
255
|
+
configured = buildStatus(startupSession(workspace));
|
|
256
|
+
} catch (err) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
detail: 'MCP configuration invalid',
|
|
260
|
+
context: { error: commandError(err), endpoints: [] },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const skipped = {};
|
|
264
|
+
const candidates = {};
|
|
265
|
+
for (const [name, endpoint] of Object.entries(configured)) {
|
|
266
|
+
if (!internetAvailable && endpoint.external && !isLocalEndpoint(endpoint.url)) {
|
|
267
|
+
skipped[name] = { ...endpoint, tools: [], toolError: 'Skipped: Internet unavailable', preflightSkipped: true };
|
|
268
|
+
} else {
|
|
269
|
+
candidates[name] = endpoint;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
let discovered;
|
|
273
|
+
try {
|
|
274
|
+
discovered = await discover(candidates);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
detail: 'MCP handshake failed',
|
|
279
|
+
context: { error: commandError(err), endpoints: [] },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const endpoints = { ...discovered, ...skipped };
|
|
283
|
+
const details = Object.entries(endpoints).map(([name, value]) => ({
|
|
284
|
+
name,
|
|
285
|
+
status: value.preflightSkipped ? 'skipped' : value.status === 'connected' ? 'connected' : 'failed',
|
|
286
|
+
tools: Array.isArray(value.tools) ? value.tools.length : 0,
|
|
287
|
+
reason: value.preflightSkipped ? 'internet unavailable' : value.status === 'connected' ? null : mcpFailureKind(value),
|
|
288
|
+
error: value.toolError ?? value.detail ?? null,
|
|
289
|
+
}));
|
|
290
|
+
const connected = details.filter((item) => item.status === 'connected');
|
|
291
|
+
const failed = details.filter((item) => item.status === 'failed');
|
|
292
|
+
const skippedCount = details.filter((item) => item.status === 'skipped').length;
|
|
293
|
+
return {
|
|
294
|
+
ok: failed.length === 0,
|
|
295
|
+
pending: failed.length > 0 || skippedCount > 0,
|
|
296
|
+
detail: `${connected.length} connected${failed.length ? `, ${failed.length} pending` : ''}${skippedCount ? `, ${skippedCount} waiting for Internet` : ''}`,
|
|
297
|
+
context: { endpoints: details, command: '/mcp status' },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function preflightStatus(gaps, checks) {
|
|
302
|
+
const setupRequired = gaps.some((gap) => gap.kind === 'workspace' || gap.kind === 'llm');
|
|
303
|
+
if (setupRequired) return 'setup_required';
|
|
304
|
+
return checks.every((check) => check.ok || check.skipped) ? 'ready' : 'degraded';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function compactNames(names, limit = 6) {
|
|
308
|
+
const visible = names.slice(0, limit);
|
|
309
|
+
return `${visible.join(', ')}${names.length > limit ? ` +${names.length - limit} more` : ''}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function compactContainerSummary(results, pending) {
|
|
313
|
+
if (pending.length === 0) {
|
|
314
|
+
return `${results.length}/${results.length} workspaces running: ${compactNames(results.map((item) => item.workspace.name))}`;
|
|
315
|
+
}
|
|
316
|
+
const groups = new Map();
|
|
317
|
+
for (const item of pending) {
|
|
318
|
+
const services = item.result.context?.unavailable?.length
|
|
319
|
+
? item.result.context.unavailable.join(', ')
|
|
320
|
+
: 'services';
|
|
321
|
+
const names = groups.get(services) ?? [];
|
|
322
|
+
names.push(item.workspace.name);
|
|
323
|
+
groups.set(services, names);
|
|
324
|
+
}
|
|
325
|
+
const grouped = [...groups.entries()].slice(0, 3).map(([services, names]) =>
|
|
326
|
+
`${compactNames(names, 4)} — services: ${services}`);
|
|
327
|
+
const hiddenGroups = groups.size > 3 ? `; +${groups.size - 3} service group(s)` : '';
|
|
328
|
+
return `${pending.length}/${results.length} workspaces to start: ${grouped.join('; ')}${hiddenGroups}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function runChecks({
|
|
332
|
+
dockerCheck = checkDockerAvailability,
|
|
333
|
+
internetCheck = checkInternetConnectivity,
|
|
334
|
+
agentsCheck = checkAgents,
|
|
335
|
+
workspaceContainersCheck = checkWorkspaceContainers,
|
|
336
|
+
mcpCheck = checkMcpConnections,
|
|
337
|
+
onCheck = () => {},
|
|
338
|
+
} = {}) {
|
|
339
|
+
// Keep infrastructure checks first and sequential: the order is part of the
|
|
340
|
+
// startup contract and makes proxy/CA diagnostics understandable.
|
|
341
|
+
const docker = await dockerCheck();
|
|
342
|
+
onCheck({ kind: 'docker', ...docker });
|
|
343
|
+
const internet = await internetCheck();
|
|
344
|
+
onCheck({ kind: 'internet', ...internet });
|
|
345
|
+
|
|
346
|
+
const workspaces = [...listWorkspaces()].sort((a, b) => a.name.localeCompare(b.name));
|
|
347
|
+
const agents = docker.ok ? await agentsCheck() : null;
|
|
121
348
|
const gaps = [];
|
|
349
|
+
if (!docker.ok) gaps.push({ kind: 'agents', context: docker.context ?? { dockerUnavailable: true } });
|
|
350
|
+
if (!internet.ok) gaps.push({ kind: 'network', context: internet.context ?? {} });
|
|
122
351
|
if (agents) gaps.push(agents);
|
|
352
|
+
onCheck({
|
|
353
|
+
kind: 'agents',
|
|
354
|
+
ok: docker.ok && !agents,
|
|
355
|
+
skipped: !docker.ok,
|
|
356
|
+
pending: !docker.ok || Boolean(agents),
|
|
357
|
+
detail: !docker.ok
|
|
358
|
+
? 'Waiting for Docker'
|
|
359
|
+
: agents?.context?.downServices?.length
|
|
360
|
+
? `To start: ${agents.context.downServices.join(', ')}`
|
|
361
|
+
: 'Running',
|
|
362
|
+
context: { ...(agents?.context ?? {}), command: 'wiki-workspace agents up' },
|
|
363
|
+
});
|
|
123
364
|
const workspaceGap = checkWorkspace(workspaces);
|
|
124
365
|
if (workspaceGap) {
|
|
125
366
|
gaps.push(workspaceGap);
|
|
367
|
+
onCheck({ kind: 'workspace', ok: false, detail: 'No valid workspace initialization' });
|
|
368
|
+
onCheck({ kind: 'containers', ok: false, skipped: true, pending: true, detail: 'Waiting for workspace initialization' });
|
|
369
|
+
onCheck({ kind: 'mcp', ok: false, skipped: true, pending: true, detail: 'Waiting for workspace initialization' });
|
|
126
370
|
return gaps;
|
|
127
371
|
}
|
|
128
|
-
|
|
372
|
+
const workspaceChecks = workspaces.map((workspace) => ({ workspace, gaps: checkWikirc(workspace) }));
|
|
373
|
+
const wikircGaps = workspaceChecks.flatMap((item) => item.gaps);
|
|
374
|
+
const invalidWorkspaces = workspaceChecks
|
|
375
|
+
.filter((item) => item.gaps.some((gap) => gap.kind === 'llm'))
|
|
376
|
+
.map((item) => item.workspace.name);
|
|
377
|
+
gaps.push(...wikircGaps);
|
|
378
|
+
onCheck({
|
|
379
|
+
kind: 'workspace',
|
|
380
|
+
ok: invalidWorkspaces.length === 0,
|
|
381
|
+
pending: invalidWorkspaces.length > 0,
|
|
382
|
+
detail: `${workspaces.length} registered: ${compactNames(workspaces.map((workspace) => workspace.name))}${invalidWorkspaces.length ? ` — configuration pending: ${compactNames(invalidWorkspaces)}` : ''}`,
|
|
383
|
+
context: { workspaces: workspaces.map((workspace) => workspace.name), invalidWorkspaces },
|
|
384
|
+
});
|
|
385
|
+
if (!docker.ok) {
|
|
386
|
+
onCheck({
|
|
387
|
+
kind: 'containers',
|
|
388
|
+
ok: false,
|
|
389
|
+
skipped: true,
|
|
390
|
+
pending: true,
|
|
391
|
+
detail: `Waiting for Docker — ${workspaces.length} workspace(s) to check`,
|
|
392
|
+
context: { command: `wiki-workspace up ${workspaces[0].name}` },
|
|
393
|
+
});
|
|
394
|
+
} else {
|
|
395
|
+
const results = await Promise.all(workspaces.map(async (workspace) => ({
|
|
396
|
+
workspace,
|
|
397
|
+
result: await workspaceContainersCheck(workspace),
|
|
398
|
+
})));
|
|
399
|
+
const pending = results.filter((item) => !item.result.ok);
|
|
400
|
+
onCheck({
|
|
401
|
+
kind: 'containers',
|
|
402
|
+
ok: pending.length === 0,
|
|
403
|
+
pending: pending.length > 0,
|
|
404
|
+
detail: compactContainerSummary(results, pending),
|
|
405
|
+
context: {
|
|
406
|
+
workspaces: results.map((item) => ({ name: item.workspace.name, ...item.result })),
|
|
407
|
+
unavailable: pending.flatMap((item) => item.result.context?.unavailable ?? []),
|
|
408
|
+
command: pending[0]?.result.context?.command ?? `wiki-workspace up ${pending[0]?.workspace.name ?? workspaces[0].name}`,
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
// MCP endpoints are independent connections. Keep probing them even when
|
|
413
|
+
// Docker is down: a remote or host-native MCP may still be healthy, while
|
|
414
|
+
// local container-backed endpoints will report their own reachability.
|
|
415
|
+
const mcpResults = await Promise.all(workspaces.map(async (workspace) => ({
|
|
416
|
+
workspace,
|
|
417
|
+
result: await mcpCheck(workspace, { internetAvailable: internet.ok }),
|
|
418
|
+
})));
|
|
419
|
+
const mcpPending = mcpResults.filter((item) => !item.result.ok);
|
|
420
|
+
const endpointChecks = mcpResults.flatMap((item) =>
|
|
421
|
+
(item.result.context?.endpoints ?? []).map((endpoint) => ({ workspace: item.workspace.name, ...endpoint })));
|
|
422
|
+
const connectedEndpoints = endpointChecks.filter((endpoint) => endpoint.status === 'connected').length;
|
|
423
|
+
const pendingEndpoints = endpointChecks.filter((endpoint) => endpoint.status === 'failed').length;
|
|
424
|
+
const skippedEndpoints = endpointChecks.filter((endpoint) => endpoint.status === 'skipped').length;
|
|
425
|
+
onCheck({
|
|
426
|
+
kind: 'mcp',
|
|
427
|
+
ok: mcpPending.length === 0,
|
|
428
|
+
pending: mcpPending.length > 0 || skippedEndpoints > 0,
|
|
429
|
+
detail: `${workspaces.length} workspace(s) checked — ${connectedEndpoints} connected${pendingEndpoints ? `, ${pendingEndpoints} pending` : ''}${skippedEndpoints ? `, ${skippedEndpoints} waiting for Internet` : ''}`,
|
|
430
|
+
context: {
|
|
431
|
+
endpoints: endpointChecks,
|
|
432
|
+
command: `/use ${mcpPending[0]?.workspace.name ?? workspaces[0].name}, then /mcp status`,
|
|
433
|
+
},
|
|
434
|
+
});
|
|
129
435
|
return gaps;
|
|
130
436
|
}
|
|
437
|
+
|
|
438
|
+
export async function runPreflightChecks(options = {}) {
|
|
439
|
+
const checks = [];
|
|
440
|
+
const externalOnCheck = options.onCheck;
|
|
441
|
+
const gaps = await runChecks({
|
|
442
|
+
...options,
|
|
443
|
+
onCheck: (check) => {
|
|
444
|
+
checks.push(check);
|
|
445
|
+
externalOnCheck?.(check);
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
return { gaps, checks, status: preflightStatus(gaps, checks) };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function withRuntimePreflight(preflight, runtime) {
|
|
452
|
+
const runtimeCheck = runtime?.url
|
|
453
|
+
? { kind: 'runtime', ok: true, detail: runtime.started ? 'Started' : 'Connected' }
|
|
454
|
+
: { kind: 'runtime', ok: false, detail: runtime?.error ?? 'Unavailable' };
|
|
455
|
+
const checks = [...(preflight?.checks ?? []).filter((check) => check.kind !== 'runtime'), runtimeCheck];
|
|
456
|
+
return {
|
|
457
|
+
...(preflight ?? { gaps: [] }),
|
|
458
|
+
checks,
|
|
459
|
+
status: preflight?.status === 'setup_required'
|
|
460
|
+
? 'setup_required'
|
|
461
|
+
: checks.every((check) => check.ok || check.skipped) ? 'ready' : 'degraded',
|
|
462
|
+
};
|
|
463
|
+
}
|
|
@@ -4,7 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import test from 'node:test';
|
|
7
|
-
import { runChecks } from './startupCheck.js';
|
|
7
|
+
import { checkInternetConnectivity, checkMcpConnections, runChecks, runPreflightChecks, withRuntimePreflight } from './startupCheck.js';
|
|
8
8
|
|
|
9
9
|
async function withWorkspace(wikircLines, fn) {
|
|
10
10
|
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-startup-check-'));
|
|
@@ -44,7 +44,13 @@ test('runChecks treats default LLM config without baseUrl as incomplete', async
|
|
|
44
44
|
' vector:',
|
|
45
45
|
' enabled: true',
|
|
46
46
|
], async () => {
|
|
47
|
-
const gaps = await runChecks(
|
|
47
|
+
const gaps = await runChecks({
|
|
48
|
+
dockerCheck: async () => ({ ok: true }),
|
|
49
|
+
internetCheck: async () => ({ ok: true }),
|
|
50
|
+
agentsCheck: async () => null,
|
|
51
|
+
workspaceContainersCheck: async () => ({ ok: true, detail: 'running' }),
|
|
52
|
+
mcpCheck: async () => ({ ok: true, detail: 'connected' }),
|
|
53
|
+
});
|
|
48
54
|
assert.equal(hasGap(gaps, 'llm'), true);
|
|
49
55
|
});
|
|
50
56
|
});
|
|
@@ -60,7 +66,188 @@ test('runChecks treats default LLM config with provider, baseUrl, apiKey and mod
|
|
|
60
66
|
' vector:',
|
|
61
67
|
' enabled: true',
|
|
62
68
|
], async () => {
|
|
63
|
-
const gaps = await runChecks(
|
|
69
|
+
const gaps = await runChecks({
|
|
70
|
+
dockerCheck: async () => ({ ok: true }),
|
|
71
|
+
internetCheck: async () => ({ ok: true }),
|
|
72
|
+
agentsCheck: async () => null,
|
|
73
|
+
workspaceContainersCheck: async () => ({ ok: true, detail: 'running' }),
|
|
74
|
+
mcpCheck: async () => ({ ok: true, detail: 'connected' }),
|
|
75
|
+
});
|
|
64
76
|
assert.equal(hasGap(gaps, 'llm'), false);
|
|
65
77
|
});
|
|
66
78
|
});
|
|
79
|
+
|
|
80
|
+
test('runChecks reports and probes every registered workspace', async () => {
|
|
81
|
+
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-multi-startup-'));
|
|
82
|
+
const registryRoot = join(root, 'registry');
|
|
83
|
+
for (const name of ['alpha', 'beta']) {
|
|
84
|
+
const registryPath = join(registryRoot, name);
|
|
85
|
+
const workspacePath = join(root, name);
|
|
86
|
+
mkdirSync(registryPath, { recursive: true });
|
|
87
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
88
|
+
writeFileSync(join(registryPath, '.env'), `WORKSPACE_NAME=${name}\nWIKI_WORKSPACE_PATH=${workspacePath}\n`, 'utf8');
|
|
89
|
+
writeFileSync(join(workspacePath, '.wikirc.yaml'), [
|
|
90
|
+
'llm:',
|
|
91
|
+
' provider: openai-compatible',
|
|
92
|
+
' baseUrl: http://localhost:8000/v1',
|
|
93
|
+
' apiKey: key',
|
|
94
|
+
' model: chat-model',
|
|
95
|
+
'',
|
|
96
|
+
].join('\n'), 'utf8');
|
|
97
|
+
}
|
|
98
|
+
const previousDir = process.env.WIKI_WORKSPACES_DIR;
|
|
99
|
+
process.env.WIKI_WORKSPACES_DIR = registryRoot;
|
|
100
|
+
try {
|
|
101
|
+
const containerCalls = [];
|
|
102
|
+
const mcpCalls = [];
|
|
103
|
+
const reports = [];
|
|
104
|
+
await runChecks({
|
|
105
|
+
dockerCheck: async () => ({ ok: true }),
|
|
106
|
+
internetCheck: async () => ({ ok: true }),
|
|
107
|
+
agentsCheck: async () => null,
|
|
108
|
+
workspaceContainersCheck: async (workspace) => {
|
|
109
|
+
containerCalls.push(workspace.name);
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
pending: true,
|
|
113
|
+
detail: 'to start',
|
|
114
|
+
context: { unavailable: ['serve', 'mcp-http', 'production-mcp'], command: `wiki-workspace up ${workspace.name}` },
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
mcpCheck: async (workspace) => {
|
|
118
|
+
mcpCalls.push(workspace.name);
|
|
119
|
+
return { ok: true, detail: 'connected', context: { endpoints: [{ name: 'wiki', status: 'connected' }] } };
|
|
120
|
+
},
|
|
121
|
+
onCheck: (check) => reports.push(check),
|
|
122
|
+
});
|
|
123
|
+
assert.deepEqual(containerCalls, ['alpha', 'beta']);
|
|
124
|
+
assert.deepEqual(mcpCalls, ['alpha', 'beta']);
|
|
125
|
+
const workspaceReport = reports.find((check) => check.kind === 'workspace');
|
|
126
|
+
assert.equal(workspaceReport.detail, '2 registered: alpha, beta');
|
|
127
|
+
assert.equal(
|
|
128
|
+
reports.find((check) => check.kind === 'containers').detail,
|
|
129
|
+
'2/2 workspaces to start: alpha, beta — services: serve, mcp-http, production-mcp',
|
|
130
|
+
);
|
|
131
|
+
assert.match(reports.find((check) => check.kind === 'mcp').detail, /2 workspace\(s\) checked/);
|
|
132
|
+
} finally {
|
|
133
|
+
if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
|
|
134
|
+
else process.env.WIKI_WORKSPACES_DIR = previousDir;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('runChecks executes Docker before Internet and reports both failures in order', async () => {
|
|
139
|
+
const calls = [];
|
|
140
|
+
const reports = [];
|
|
141
|
+
const gaps = await runChecks({
|
|
142
|
+
dockerCheck: async () => { calls.push('docker'); return { ok: false, context: { dockerUnavailable: true } }; },
|
|
143
|
+
internetCheck: async () => { calls.push('internet'); return { ok: false, context: { error: 'offline' } }; },
|
|
144
|
+
agentsCheck: async () => { calls.push('agents'); return null; },
|
|
145
|
+
onCheck: (check) => reports.push(check.kind),
|
|
146
|
+
});
|
|
147
|
+
assert.deepEqual(calls, ['docker', 'internet']);
|
|
148
|
+
assert.deepEqual(reports.slice(0, 3), ['docker', 'internet', 'agents']);
|
|
149
|
+
assert.deepEqual(gaps.slice(0, 2).map((gap) => gap.kind), ['agents', 'network']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('MCP preflight distinguishes authentication failure and skips remote endpoints while offline', async () => {
|
|
153
|
+
const workspace = { name: 'demo', workspacePath: '/tmp/demo', envFile: '/tmp/demo.env', env: {} };
|
|
154
|
+
const result = await checkMcpConnections(workspace, {
|
|
155
|
+
internetAvailable: false,
|
|
156
|
+
buildStatus: () => ({
|
|
157
|
+
local: { status: 'configured', url: 'http://127.0.0.1:3333/mcp' },
|
|
158
|
+
remote: { status: 'configured', url: 'https://mcp.example.test', external: true },
|
|
159
|
+
}),
|
|
160
|
+
discover: async (endpoints) => ({
|
|
161
|
+
local: { ...endpoints.local, status: 'configured', tools: [], toolError: '401 Unauthorized' },
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
assert.equal(result.ok, false);
|
|
165
|
+
assert.deepEqual(result.context.endpoints.map(({ name, status, reason }) => ({ name, status, reason })), [
|
|
166
|
+
{ name: 'local', status: 'failed', reason: 'authentication' },
|
|
167
|
+
{ name: 'remote', status: 'skipped', reason: 'internet unavailable' },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('MCP preflight reports invalid endpoint configuration without aborting startup', async () => {
|
|
172
|
+
const result = await checkMcpConnections({ name: 'demo', workspacePath: '/tmp/demo', env: {} }, {
|
|
173
|
+
buildStatus: () => { throw new Error('Unexpected token in mcp.endpoints.json'); },
|
|
174
|
+
});
|
|
175
|
+
assert.equal(result.ok, false);
|
|
176
|
+
assert.equal(result.detail, 'MCP configuration invalid');
|
|
177
|
+
assert.match(result.context.error, /mcp\.endpoints\.json/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('preflight status is setup_required without a valid workspace and runtime failure only degrades', async () => {
|
|
181
|
+
const previousDir = process.env.WIKI_WORKSPACES_DIR;
|
|
182
|
+
const root = await mkdtemp(join(tmpdir(), 'wiki-manager-empty-startup-'));
|
|
183
|
+
process.env.WIKI_WORKSPACES_DIR = root;
|
|
184
|
+
try {
|
|
185
|
+
const preflight = await runPreflightChecks({
|
|
186
|
+
dockerCheck: async () => ({ ok: true }),
|
|
187
|
+
internetCheck: async () => ({ ok: true }),
|
|
188
|
+
agentsCheck: async () => null,
|
|
189
|
+
});
|
|
190
|
+
assert.equal(preflight.status, 'setup_required');
|
|
191
|
+
assert.equal(withRuntimePreflight(preflight, { error: 'offline' }).status, 'setup_required');
|
|
192
|
+
const ready = { gaps: [], status: 'ready', checks: [{ kind: 'docker', ok: true }] };
|
|
193
|
+
assert.equal(withRuntimePreflight(ready, { error: 'offline' }).status, 'degraded');
|
|
194
|
+
} finally {
|
|
195
|
+
if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
|
|
196
|
+
else process.env.WIKI_WORKSPACES_DIR = previousDir;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('runChecks presents Internet failure before stopped agents after Docker succeeds', async () => {
|
|
201
|
+
const gaps = await runChecks({
|
|
202
|
+
dockerCheck: async () => ({ ok: true }),
|
|
203
|
+
internetCheck: async () => ({ ok: false, context: { error: 'offline' } }),
|
|
204
|
+
agentsCheck: async () => ({ kind: 'agents', context: { downServices: ['documents'] } }),
|
|
205
|
+
workspaceContainersCheck: async () => ({ ok: false, detail: 'stopped' }),
|
|
206
|
+
mcpCheck: async () => ({ ok: false, detail: 'unavailable' }),
|
|
207
|
+
});
|
|
208
|
+
assert.deepEqual(gaps.slice(0, 2).map((gap) => gap.kind), ['network', 'agents']);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('runChecks still probes MCP endpoints when Docker is unavailable', async () => {
|
|
212
|
+
await withWorkspace([
|
|
213
|
+
'llm:',
|
|
214
|
+
' provider: openai-compatible',
|
|
215
|
+
' baseUrl: http://localhost:8000/v1',
|
|
216
|
+
' apiKey: key',
|
|
217
|
+
' model: chat-model',
|
|
218
|
+
], async () => {
|
|
219
|
+
const calls = [];
|
|
220
|
+
await runChecks({
|
|
221
|
+
dockerCheck: async () => ({ ok: false, context: { dockerUnavailable: true } }),
|
|
222
|
+
internetCheck: async () => ({ ok: true }),
|
|
223
|
+
agentsCheck: async () => { calls.push('agents'); return null; },
|
|
224
|
+
workspaceContainersCheck: async () => { calls.push('containers'); return { ok: true }; },
|
|
225
|
+
mcpCheck: async () => { calls.push('mcp'); return { ok: true, detail: 'remote connected' }; },
|
|
226
|
+
});
|
|
227
|
+
assert.deepEqual(calls, ['mcp']);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('checkInternetConnectivity uses a fresh Node process with proxy and CA environment', async () => {
|
|
232
|
+
const calls = [];
|
|
233
|
+
const previousProxy = process.env.HTTPS_PROXY;
|
|
234
|
+
const previousProxyFlag = process.env.NODE_USE_ENV_PROXY;
|
|
235
|
+
process.env.HTTPS_PROXY = 'http://proxy.test:8080';
|
|
236
|
+
process.env.NODE_USE_ENV_PROXY = '1';
|
|
237
|
+
try {
|
|
238
|
+
const result = await checkInternetConnectivity({
|
|
239
|
+
url: 'https://connectivity.test/ping',
|
|
240
|
+
exec: async (...args) => { calls.push(args); return { stdout: '' }; },
|
|
241
|
+
});
|
|
242
|
+
assert.equal(result.ok, true);
|
|
243
|
+
assert.equal(calls.length, 1);
|
|
244
|
+
assert.equal(calls[0][1].at(-1), 'https://connectivity.test/ping');
|
|
245
|
+
assert.equal(calls[0][2].env.HTTPS_PROXY, 'http://proxy.test:8080');
|
|
246
|
+
assert.equal(result.context.proxyEnabled, true);
|
|
247
|
+
} finally {
|
|
248
|
+
if (previousProxy === undefined) delete process.env.HTTPS_PROXY;
|
|
249
|
+
else process.env.HTTPS_PROXY = previousProxy;
|
|
250
|
+
if (previousProxyFlag === undefined) delete process.env.NODE_USE_ENV_PROXY;
|
|
251
|
+
else process.env.NODE_USE_ENV_PROXY = previousProxyFlag;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
@@ -22,3 +22,12 @@ test('wiki-workspace checks runtime pid command before killing', async () => {
|
|
|
22
22
|
assert.match(script, /if ! runtime_pid_matches; then\n printf 'refusing to stop pid/);
|
|
23
23
|
assert.match(script, /kill "\$\(cat "\$pid_file"\)"/);
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
test('wiki-workspace regenerates CA compose overrides instead of retaining removed services', async () => {
|
|
27
|
+
const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
|
|
28
|
+
|
|
29
|
+
assert.doesNotMatch(script, /if \[\[ ! -f "\$override_path" \]\]; then/);
|
|
30
|
+
assert.match(script, /local tmp_override="\$override_path\.tmp\.\$\$"/);
|
|
31
|
+
assert.match(script, /mv "\$tmp_override" "\$override_path"/);
|
|
32
|
+
assert.match(script, /Changes are overwritten on the next compose command/);
|
|
33
|
+
});
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function ensureRuntime({
|
|
|
83
83
|
if (!forceRestart && !stale && actualCacertPath === expectedCacertPath) {
|
|
84
84
|
return { url, started: false, health: existing, token: auth.token, tokenPath: auth.tokenPath };
|
|
85
85
|
}
|
|
86
|
-
if (stale) console.
|
|
86
|
+
if (stale) console.log('\x1b[32mruntime: source changed since start — restarting for fresh code.\x1b[0m');
|
|
87
87
|
await postRuntimeShutdown({ url, token: auth.token });
|
|
88
88
|
await waitForRuntimeShutdown(url, auth.token, 2500);
|
|
89
89
|
}
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { useKeyboard, usePaste } from '@opentui/solid';
|
|
5
5
|
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js';
|
|
6
6
|
import { fallbackModels, normalizeProvider } from '../core/modelFetch.js';
|
|
7
|
+
import { checkInternetConnectivity } from '../core/startupCheck.js';
|
|
7
8
|
import {
|
|
8
9
|
createNewWorkspace,
|
|
9
10
|
deleteWorkspaceAndFiles,
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
import { listWorkspaces, workspacesDir } from '../core/workspaces.js';
|
|
18
19
|
import { loadWikircProfile } from '../core/wikirc.js';
|
|
19
20
|
|
|
20
|
-
type Gap = { kind: 'agents' | 'workspace' | 'llm' | 'vector'; context?: Record<string, any> };
|
|
21
|
+
type Gap = { kind: 'agents' | 'network' | 'workspace' | 'llm' | 'vector'; context?: Record<string, any> };
|
|
21
22
|
type Mode = 'startup' | 'setup';
|
|
22
23
|
type Step =
|
|
23
24
|
| { kind: 'menu'; title: string; items: Array<{ label: string; value: string; muted?: boolean }> }
|
|
@@ -195,6 +196,20 @@ export function SetupWizard(props: {
|
|
|
195
196
|
noLabel: 'Skip',
|
|
196
197
|
};
|
|
197
198
|
}
|
|
199
|
+
if (currentRoute === 'network') {
|
|
200
|
+
const context = currentGap()?.context ?? {};
|
|
201
|
+
const transport = context.proxyUrl
|
|
202
|
+
? `Proxy: ${context.proxyEnabled ? context.proxyUrl : `${context.proxyUrl} (NODE_USE_ENV_PROXY is not enabled)`}`
|
|
203
|
+
: 'Proxy: direct connection';
|
|
204
|
+
const certificate = context.cacertPath ? `CA: ${context.cacertPath}` : 'CA: system trust store';
|
|
205
|
+
return {
|
|
206
|
+
kind: 'confirm',
|
|
207
|
+
title: 'Internet connectivity',
|
|
208
|
+
message: `Could not reach ${context.url ?? 'the connectivity endpoint'}.\n${transport}\n${certificate}\n${context.error ?? ''}`.trim(),
|
|
209
|
+
yesLabel: 'Retry',
|
|
210
|
+
noLabel: 'Skip',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
198
213
|
if (currentRoute === 'workspace-confirm') {
|
|
199
214
|
return { kind: 'confirm', title: 'Workspace', message: 'No workspace configured.', yesLabel: 'Create', noLabel: 'Skip' };
|
|
200
215
|
}
|
|
@@ -337,6 +352,7 @@ export function SetupWizard(props: {
|
|
|
337
352
|
function startupRoute(gap?: Gap) {
|
|
338
353
|
if (!gap) return 'done';
|
|
339
354
|
if (gap.kind === 'agents') return 'agents';
|
|
355
|
+
if (gap.kind === 'network') return 'network';
|
|
340
356
|
if (gap.kind === 'workspace') return 'workspace-confirm';
|
|
341
357
|
if (gap.kind === 'llm') return 'llm-provider';
|
|
342
358
|
if (gap.kind === 'vector') return 'vector-confirm';
|
|
@@ -527,6 +543,14 @@ export function SetupWizard(props: {
|
|
|
527
543
|
});
|
|
528
544
|
return;
|
|
529
545
|
}
|
|
546
|
+
if (currentRoute === 'network') {
|
|
547
|
+
await runAction(async () => {
|
|
548
|
+
const result = await checkInternetConnectivity();
|
|
549
|
+
if (!result.ok) throw new Error(result.context?.error ?? 'Internet connectivity check failed.');
|
|
550
|
+
nextStartup('Internet connectivity verified');
|
|
551
|
+
});
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
530
554
|
if (currentRoute === 'workspace-confirm') return navigate('workspace-name');
|
|
531
555
|
if (currentRoute === 'vector-rerank') return navigate('vector-rerank-model');
|
|
532
556
|
if (currentRoute === 'vector-confirm') {
|