@dotdrelle/wiki-manager 0.14.9 → 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.
@@ -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
+ });
@@ -1,13 +1,32 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
2
3
  import { readFile, rename, rm, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
4
5
  import { promisify } from 'node:util';
6
+ import YAML from 'yaml';
7
+ import { checkMissingDockerImages } from './dockerImages.js';
5
8
  import { patchWikircProfile } from './wikirc.js';
6
9
  import { managerEnvFile, managerMcpEndpointsFile } from './env.js';
7
10
  import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
8
11
 
9
12
  const execFileAsync = promisify(execFile);
10
13
 
14
+ async function missingAgentImages() {
15
+ const composeFiles = [
16
+ join(managerRoot(), 'agents.docker-compose.yml'),
17
+ join(dirname(managerEnvFile()), 'agents.docker-compose.override.yml'),
18
+ ].filter(existsSync);
19
+ const images = [...new Set(composeFiles.flatMap((filePath) => {
20
+ try {
21
+ const config = YAML.parse(readFileSync(filePath, 'utf8')) ?? {};
22
+ return Object.values(config.services ?? {}).map((service) => service?.image).filter(Boolean);
23
+ } catch {
24
+ return [];
25
+ }
26
+ }))];
27
+ return checkMissingDockerImages(images);
28
+ }
29
+
11
30
  function wrapDockerError(err) {
12
31
  if (err?.code === 'ENOENT') {
13
32
  return new Error('wiki-workspace script not found. Reinstall wiki-manager.');
@@ -35,6 +54,8 @@ function wrapDockerError(err) {
35
54
 
36
55
  export async function startAgents(options = {}) {
37
56
  try {
57
+ const absentImages = await missingAgentImages();
58
+ if (absentImages.length > 0) options.onImagesMissing?.(absentImages);
38
59
  const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
39
60
  cwd: managerRoot(),
40
61
  env: {
@@ -51,7 +72,10 @@ export async function startAgents(options = {}) {
51
72
  timeout: options.timeout ?? 180_000,
52
73
  maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
53
74
  });
54
- return [stdout, stderr].filter(Boolean).join('\n').trim();
75
+ return {
76
+ output: [stdout, stderr].filter(Boolean).join('\n').trim(),
77
+ missingImages: absentImages,
78
+ };
55
79
  } catch (err) {
56
80
  throw wrapDockerError(err);
57
81
  }
@@ -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
+ });
@@ -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.error('runtime: source changed since start — restarting for fresh code.');
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
  }
@@ -1,6 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
3
3
  import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
4
+ import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
4
5
 
5
6
  const LEGACY_DONNA_ROLE = 'do' + 't';
6
7
 
@@ -47,7 +48,7 @@ function wrapLine(line: string, width: number) {
47
48
  return out;
48
49
  }
49
50
 
50
- type Segment = { text: string; color: string; width?: number; bg?: string };
51
+ type Segment = { text: string; color: string; width?: number; bg?: string; url?: string };
51
52
  type RenderedLine = {
52
53
  segments: Segment[];
53
54
  status?: boolean;
@@ -300,6 +301,16 @@ function renderMarkdownLines(lines: Array<{ text: string; isCode: boolean }>, ro
300
301
  const rendered = renderPlainMarkdown(text);
301
302
  const isCmdDesc = splitCmdDesc(rendered) !== null;
302
303
  const fallback = isCmdDesc ? '#FFFFFF' : colorForRenderedLine(rendered, role);
304
+ if (httpLinkParts(rendered).some((part: { url?: string }) => part.url)) {
305
+ output.push(...wrapHttpLinks(rendered, columns).map((parts: Array<{ text: string; url?: string }>) => ({
306
+ segments: parts.map((part) => ({
307
+ text: part.text || ' ',
308
+ color: part.url ? '#5DADE2' : fallback,
309
+ url: part.url,
310
+ })),
311
+ })));
312
+ continue;
313
+ }
303
314
  output.push(...wrapLine(rendered, columns).map((piece, idx) => ({
304
315
  segments: idx === 0
305
316
  ? segmentsForLine(piece, role, columns)
@@ -489,7 +500,11 @@ export function ConversationView(props: {
489
500
  ) : (
490
501
  <box height={1} flexDirection="row" overflow="hidden">
491
502
  <For each={line.segments}>
492
- {(seg: Segment) => <text width={seg.width} fg={seg.color} bg={seg.bg}>{seg.text}</text>}
503
+ {(seg: Segment) => (
504
+ <text width={seg.width} fg={seg.color} bg={seg.bg}>
505
+ {seg.url ? <a href={seg.url}>{seg.text}</a> : seg.text}
506
+ </text>
507
+ )}
493
508
  </For>
494
509
  </box>
495
510
  )
@@ -20,6 +20,7 @@ type LogLineParts = { time: string | null; message: string };
20
20
  // wrapped status/error), so fewer, readable entries beat more, truncated ones.
21
21
  const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
22
22
  const LOG_SLOTS = Array.from({ length: 24 }, (_, index) => index);
23
+ const PLAN_VIEWPORT_ROWS = 12;
23
24
 
24
25
  function wrapLine(value: string, width: number) {
25
26
  const max = Math.max(8, width);
@@ -131,29 +132,45 @@ function queueSummary(item: QueueItem) {
131
132
  }
132
133
 
133
134
  export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string }) {
134
- const lineWidth = () => Math.max(8, props.width - 2);
135
+ // Keep one column for the native vertical scrollbar when the plan is long.
136
+ const lineWidth = () => Math.max(8, props.width - 3);
135
137
  const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
136
138
  const icon = (status: string) =>
137
139
  status === 'done' ? '[✓]' : status === 'failed' ? '[✗]' : status === 'running' ? '[…]' : '[ ]';
138
- const title = () => props.jobName ? `Plan : ${props.jobName}` : 'Plan';
140
+ const visualRows = createMemo(() => props.plan.reduce((total, step) =>
141
+ total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, lineWidth()).slice(0, 2).length, 0));
142
+ const title = () => {
143
+ const label = props.jobName ? `Plan : ${props.jobName}` : 'Plan';
144
+ return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
145
+ };
146
+ const viewportRows = () => Math.min(PLAN_VIEWPORT_ROWS, Math.max(1, visualRows()));
139
147
  return (
140
148
  <box flexShrink={0} flexDirection="column" padding={1}>
141
149
  <text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
142
- <Index each={props.plan}>
143
- {(step) => {
144
- // Wrap step descriptions over up to 2 lines instead of truncating —
145
- // "Ingest des 39 documents raw/untrac…" hid the actual target.
146
- const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
147
- return (
148
- <box flexDirection="column">
149
- <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
150
- <Show when={lines()[1]}>
151
- <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
152
- </Show>
153
- </box>
154
- );
155
- }}
156
- </Index>
150
+ <scrollbox
151
+ height={viewportRows()}
152
+ scrollY={true}
153
+ scrollX={false}
154
+ stickyStart="top"
155
+ viewportCulling={true}
156
+ verticalScrollbarOptions={{ visible: visualRows() > PLAN_VIEWPORT_ROWS }}
157
+ >
158
+ <Index each={props.plan}>
159
+ {(step) => {
160
+ // Wrap step descriptions over up to 2 lines instead of truncating —
161
+ // "Ingest des 39 documents raw/untrac…" hid the actual target.
162
+ const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
163
+ return (
164
+ <box flexShrink={0} flexDirection="column">
165
+ <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
166
+ <Show when={lines()[1]}>
167
+ <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
168
+ </Show>
169
+ </box>
170
+ );
171
+ }}
172
+ </Index>
173
+ </scrollbox>
157
174
  </box>
158
175
  );
159
176
  }
@@ -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,13 +18,13 @@ 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 }> }
24
25
  | { kind: 'confirm'; title: string; message: string; yesLabel: string; noLabel: string }
25
26
  | { kind: 'select'; title: string; label: string; options: string[]; note?: string }
26
- | { kind: 'text'; title: string; label: string; placeholder?: string; prefill?: string; secret?: boolean }
27
+ | { kind: 'text'; title: string; label: string; note?: string; placeholder?: string; prefill?: string; secret?: boolean }
27
28
  | { kind: 'done' };
28
29
  type LogEntry = { icon: string; label: string; detail?: string };
29
30
 
@@ -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
  }
@@ -219,21 +234,32 @@ export function SetupWizard(props: {
219
234
  };
220
235
  }
221
236
  if (currentRoute === 'llm-baseurl') {
222
- const baseUrl = llm().baseUrl || defaultBaseUrl(llm().provider);
223
- return { kind: 'text', title: 'LLM configuration', label: 'Base URL', prefill: baseUrl, placeholder: baseUrl };
237
+ const example = defaultBaseUrl(llm().provider);
238
+ return {
239
+ kind: 'text',
240
+ title: 'LLM configuration',
241
+ label: `Base URL${example ? ` (example: ${example})` : ''}`,
242
+ prefill: llm().baseUrl || '',
243
+ };
224
244
  }
225
245
  if (currentRoute === 'llm-apikey') {
226
246
  return { kind: 'text', title: 'LLM configuration', label: 'API key (required)', secret: true };
227
247
  }
228
248
  if (currentRoute === 'llm-model') {
229
- const defaultModel = llm().model || fallbackModels(llm().provider)[0] || '';
230
- return { kind: 'text', title: 'LLM configuration', label: 'Model', prefill: defaultModel };
249
+ const example = fallbackModels(llm().provider)[0] || 'provider-agentic-model';
250
+ return {
251
+ kind: 'text',
252
+ title: 'LLM configuration',
253
+ label: `Model (example: ${example})`,
254
+ note: 'Required: an agentic model with tool/function calling support.',
255
+ prefill: llm().model || '',
256
+ };
231
257
  }
232
258
  if (currentRoute === 'vector-confirm') {
233
259
  return { kind: 'confirm', title: 'Vector search', message: 'Configure vector search?', yesLabel: 'Enable', noLabel: 'Skip' };
234
260
  }
235
261
  if (currentRoute === 'vector-baseurl') {
236
- const baseUrl = vector().baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider);
262
+ const baseUrl = vector().baseUrl || llm().baseUrl;
237
263
  return { kind: 'text', title: 'Vector search', label: 'Embeddings/rerank base URL', prefill: baseUrl, placeholder: baseUrl };
238
264
  }
239
265
  if (currentRoute === 'vector-apikey') {
@@ -326,6 +352,7 @@ export function SetupWizard(props: {
326
352
  function startupRoute(gap?: Gap) {
327
353
  if (!gap) return 'done';
328
354
  if (gap.kind === 'agents') return 'agents';
355
+ if (gap.kind === 'network') return 'network';
329
356
  if (gap.kind === 'workspace') return 'workspace-confirm';
330
357
  if (gap.kind === 'llm') return 'llm-provider';
331
358
  if (gap.kind === 'vector') return 'vector-confirm';
@@ -462,7 +489,10 @@ export function SetupWizard(props: {
462
489
  if (currentRoute === 'llm-provider') {
463
490
  const provider = normalizeProvider(value);
464
491
  setLlm((old: any) => {
465
- const baseUrl = (old.provider === provider && old.baseUrl) ? old.baseUrl : defaultBaseUrl(provider);
492
+ const asksForBaseUrl = provider === 'ollama' || provider === 'openai-compatible';
493
+ const baseUrl = (old.provider === provider && old.baseUrl)
494
+ ? old.baseUrl
495
+ : asksForBaseUrl ? '' : defaultBaseUrl(provider);
466
496
  return { ...old, provider, baseUrl, ...(provider === 'ollama' && !old.apiKey ? { apiKey: 'ollama' } : {}) };
467
497
  });
468
498
  if (provider === 'ollama' || provider === 'openai-compatible') return navigate('llm-baseurl');
@@ -513,6 +543,14 @@ export function SetupWizard(props: {
513
543
  });
514
544
  return;
515
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
+ }
516
554
  if (currentRoute === 'workspace-confirm') return navigate('workspace-name');
517
555
  if (currentRoute === 'vector-rerank') return navigate('vector-rerank-model');
518
556
  if (currentRoute === 'vector-confirm') {
@@ -560,7 +598,8 @@ export function SetupWizard(props: {
560
598
  return;
561
599
  }
562
600
  if (currentRoute === 'llm-baseurl') {
563
- setLlm((old: any) => ({ ...old, baseUrl: value || old.baseUrl || defaultBaseUrl(old.provider) }));
601
+ if (!value) return setError('Base URL is required.');
602
+ setLlm((old: any) => ({ ...old, baseUrl: value }));
564
603
  if (llm().provider === 'ollama') return navigate('llm-model');
565
604
  return navigate('llm-apikey');
566
605
  }
@@ -570,7 +609,8 @@ export function SetupWizard(props: {
570
609
  return navigate('llm-model');
571
610
  }
572
611
  if (currentRoute === 'vector-baseurl') {
573
- const baseUrl = value || vector().baseUrl || llm().baseUrl || defaultBaseUrl(llm().provider);
612
+ const baseUrl = value || vector().baseUrl || llm().baseUrl;
613
+ if (!baseUrl) return setError('Embeddings/rerank base URL is required.');
574
614
  setVector((old: any) => ({ ...old, provider: llm().provider, baseUrl }));
575
615
  return navigate('vector-apikey');
576
616
  }
@@ -750,6 +790,9 @@ export function SetupWizard(props: {
750
790
  <Show when={(step() as any).message || (step() as any).label}>
751
791
  <text height={1} fg="#D6DEE8">{(step() as any).message ?? (step() as any).label}</text>
752
792
  </Show>
793
+ <Show when={(step() as any).note}>
794
+ <text height={1} fg="#9CA3AF">{(step() as any).note}</text>
795
+ </Show>
753
796
  <Show when={step().kind === 'text'}>
754
797
  <box
755
798
  height={5}