@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 CHANGED
@@ -27,6 +27,8 @@ WORKSPACES_ROOT=/path/to/workspaces
27
27
  # HTTP_PROXY=http://proxy.example:11011
28
28
  # Always bypass the proxy for the local runtime, local MCPs, and Docker host.
29
29
  # NO_PROXY=localhost,127.0.0.1,host.docker.internal
30
+ # Optional organization-approved HTTPS endpoint for the ShellUI startup probe.
31
+ # WIKI_MANAGER_CONNECTIVITY_URL=https://registry.npmjs.org/-/ping
30
32
 
31
33
  # ── Agent auth tokens ──────────────────────────────────────────────────────────
32
34
  #
@@ -51,7 +53,7 @@ DOCUMENTS_MCP_AUTH_TOKEN=
51
53
  # All image OCR is delegated to an OpenAI-compatible vision LLM. The default
52
54
  # Default target is the Albert API (etalab); set DOCUMENT_LLM_API_KEY.
53
55
  # DOCUMENT_LLM_BASE_URL=https://albert.api.etalab.gouv.fr/v1
54
- # DOCUMENT_LLM_MODEL=LightOnOCR-2-1B
56
+ # DOCUMENT_LLM_MODEL=lightonai/LightOnOCR-2-1B
55
57
  # DOCUMENT_LLM_API_KEY=
56
58
  # DOCUMENT_LLM_TIMEOUT_SECONDS=120
57
59
 
package/README.md CHANGED
@@ -8,7 +8,7 @@ endpoints, and provides the `donna` shell: an agent-first terminal UI that can
8
8
  inspect workspaces, run safe manager commands, call MCP tools, guide production
9
9
  jobs, and run one-shot headless tasks.
10
10
 
11
- Current coordinated release: **0.14.5**. Managed `llm-wiki` services expose
11
+ Current coordinated release: **0.14.11**. Managed `llm-wiki` services expose
12
12
  the Wiki Graph v2 browser and APIs; rebuild the `llm-wiki` image when deploying
13
13
  this release through Docker.
14
14
 
@@ -86,6 +86,27 @@ npm install --global --allow-scripts=bun @dotdrelle/wiki-manager
86
86
  In both modes, launch the commands from the directory that should hold the
87
87
  manager state (`workspaces/`, `.env`, and `mcp.endpoints.json`).
88
88
 
89
+ At interactive ShellUI startup, the manager checks Docker availability first,
90
+ then outbound HTTPS connectivity, followed by agent and workspace
91
+ configuration, workspace containers, MCP handshakes (`tools/list`), and the
92
+ Donna runtime. The connectivity probe uses the configured HTTP(S) proxy and
93
+ custom CA settings. MCP diagnostics distinguish configuration, authentication,
94
+ reachability, and protocol failures. Remote MCPs are skipped when Internet is
95
+ offline, while local endpoints are still checked; MCPs are also checked when
96
+ Docker is unavailable because they may run elsewhere.
97
+
98
+ The startup screen reports **Ready**, **Degraded**, or **Setup required** and
99
+ keeps the shell usable in degraded mode. It offers **Retry pending checks**,
100
+ **Start services**, and **Open diagnostics** (service logs plus detailed MCP
101
+ status). Set
102
+ `WIKI_MANAGER_CONNECTIVITY_URL` only when the default npm-registry ping endpoint
103
+ must be replaced by an organization-approved HTTPS endpoint.
104
+
105
+ After a workspace is loaded, ShellUI displays `/status` automatically as the
106
+ first operational view. When **Open workspace** also starts services, the status
107
+ snapshot is produced afterwards so it reflects their current container and MCP
108
+ state.
109
+
89
110
  ## Functional overview
90
111
 
91
112
  The diagram below shows the whole picture at a glance: how **inputs** (external
@@ -664,7 +685,18 @@ Original files are stored under
664
685
  the documents agent to `<workspace>/raw/untracked/`. If the documents agent is
665
686
  down, the upload remains stored and can be converted later.
666
687
  Image files, scanned PDFs, and images detected inside PDF or Office documents
667
- are sent through LLM OCR automatically.
688
+ are sent through LLM OCR automatically. The bundled agent stack defaults to
689
+ the Albert-compatible endpoint and
690
+ [`lightonai/LightOnOCR-2-1B`](https://huggingface.co/lightonai/LightOnOCR-2-1B);
691
+ configure it with `DOCUMENT_LLM_BASE_URL`, `DOCUMENT_LLM_MODEL`, and the
692
+ dedicated `DOCUMENT_LLM_API_KEY` (an ambient `OPENAI_API_KEY` is not used).
693
+
694
+ In the served web Chat, a successful conversion also adds the new
695
+ `raw/untracked/*.md` path as a document-context badge. Up to five wiki or
696
+ pending Markdown documents can be selected. Only their paths are sent to
697
+ Donna; she reads the relevant documents through the configured read-only MCP
698
+ tools when the question refers to them. This does not ingest the document:
699
+ `wiki ingest` remains the explicit transition into the durable wiki.
668
700
 
669
701
  ## The `donna` Shell
670
702
 
@@ -701,6 +733,14 @@ The TUI uses a two-pane layout:
701
733
  `Ctrl+Q` toggles the tabs; clicking `Plan` or `Queue (N)` selects that tab
702
734
  directly. MCP connection details remain available through `/mcp status`.
703
735
 
736
+ In the served browser Activity panel, `Clear` is a per-tab display cleanup and
737
+ `Clear all` applies it to Plan, Local activity, Runtime activity, and Logs. It
738
+ does not delete a plan. **Reset plan** is the confirmed destructive operation:
739
+ it stops active work and purges the workspace runtime plan, activities, logs,
740
+ queue, and persisted runtime state. Donna can perform the same operation when
741
+ the user explicitly asks to delete, reset, abandon, or replace the current
742
+ plan. A request that only says to stop or cancel remains non-purging.
743
+
704
744
  Useful primitives:
705
745
 
706
746
  ```text
@@ -13,7 +13,7 @@
13
13
  # CME_MCP_AUTH_TOKEN — bearer token for cme agent (empty = no auth)
14
14
  # DOCUMENTS_MCP_AUTH_TOKEN — bearer token for documents agent (empty = no auth)
15
15
  # DOCUMENT_LLM_BASE_URL — https://albert.api.etalab.gouv.fr/v1
16
- # DOCUMENT_LLM_MODEL — LightOnOCR-2-1B
16
+ # DOCUMENT_LLM_MODEL — lightonai/LightOnOCR-2-1B
17
17
  # DOCUMENT_LLM_API_KEY — OpenAI/OpenAI-compatible API key for document OCR
18
18
  # DOCUMENT_LLM_TIMEOUT_SECONDS — 120
19
19
  #
@@ -63,9 +63,9 @@ services:
63
63
  - DOCUMENT_OUTPUT_DIR=/documents/output
64
64
  - WORKSPACES_ROOT=/workspaces
65
65
  - DOCUMENT_MAX_UPLOAD_BYTES=${DOCUMENT_MAX_UPLOAD_BYTES:-52428800}
66
- - DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://api.openai.com/v1}
67
- - DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-gpt-5.4-mini}
68
- - DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-${OPENAI_API_KEY:-}}
66
+ - DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://albert.api.etalab.gouv.fr/v1}
67
+ - DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-lightonai/LightOnOCR-2-1B}
68
+ - DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-}
69
69
  - DOCUMENT_LLM_TIMEOUT_SECONDS=${DOCUMENT_LLM_TIMEOUT_SECONDS:-120}
70
70
  # HTTPS — uncomment and place cert files in AGENTS_DATA_DIR/certs/
71
71
  #- MCP_SSL_CERTFILE=/certs/server.crt
@@ -76,4 +76,3 @@ services:
76
76
  - ${WORKSPACES_ROOT:?Set WORKSPACES_ROOT to the directory containing all workspace folders}:/workspaces
77
77
  #- ${AGENTS_DATA_DIR:-./.agents-data}/certs:/certs:ro
78
78
  restart: unless-stopped
79
-
@@ -27,9 +27,9 @@
27
27
  }
28
28
  },
29
29
  "chatAccess": {
30
- "maxToolIterations": 6,
30
+ "maxToolIterations": 8,
31
31
  "servers": {
32
- "wiki": { "allow": ["help_list", "help_read"] },
32
+ "wiki": { "allow": ["help_list", "help_read", "wiki_workspace_status", "wiki_list_pages", "wiki_read_page", "wiki_read_pages", "wiki_search_context", "wiki_collect_context", "wiki_read_ingested_source"] },
33
33
  "production": { "allow": ["production_job_status", "production_jobs_list"] },
34
34
  "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] }
35
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.14.10",
3
+ "version": "0.14.12",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/cacert.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -120,7 +120,10 @@ const RUNTIME_KILL_TOOL = {
120
120
  function: {
121
121
  name: 'runtime__kill',
122
122
  description: 'Hard-stop the workspace runtime: abort the active run, cancel its agent jobs, mark persisted runs interrupted and purge the control queue. Use when the user asks to remove/kill/clean the current run, its jobs or the queue.',
123
- parameters: { type: 'object', additionalProperties: false, properties: { runId: { type: 'string', description: 'Optional specific run id; omit to kill everything active in the workspace.' } } },
123
+ parameters: { type: 'object', additionalProperties: false, properties: {
124
+ runId: { type: 'string', description: 'Optional specific run id; omit to kill everything active in the workspace.' },
125
+ purge: { type: 'boolean', description: 'Set true only when the user explicitly asks to delete/reset/replace the current plan and runtime history.' },
126
+ } },
124
127
  },
125
128
  };
126
129
 
@@ -694,8 +697,8 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
694
697
  const workspace = session.workspace ?? null;
695
698
  try {
696
699
  if (tool === 'kill') {
697
- const result = await postRuntimeKill({ url, workspace, runId: args.runId ?? null });
698
- return `Runtime killed: ${result.runs ?? 0} run(s) interrupted, ${result.tasks ?? 0} task(s) cancelled, ${result.queued ?? 0} queued control request(s) purged.`;
700
+ const result = await postRuntimeKill({ url, workspace, runId: args.runId ?? null, purge: args.purge === true });
701
+ return `Runtime killed: ${result.runs ?? 0} run(s) interrupted, ${result.tasks ?? 0} task(s) cancelled, ${result.queued ?? 0} queued control request(s) purged${result.purged ? `; runtime state reset (${result.purged.events ?? 0} event(s))` : ''}.`;
699
702
  }
700
703
  if (tool === 'cancel') {
701
704
  const result = await postRuntimeCancel({ url, workspace });
@@ -942,7 +945,7 @@ export function buildAgentSystemPrompt(state) {
942
945
  workspaceProfile
943
946
  ? `Workspace profile (.wiki/profile.md) — durable user preferences, apply these to every reply (tone, tutoiement/vouvoiement, formatting, etc.):\n${workspaceProfile}`
944
947
  : null,
945
- 'Runtime control: you have runtime__status, runtime__cancel, runtime__kill, runtime__approve and runtime__enqueue. When the user asks to stop, remove, clean or kill the current run, its jobs or the queue ("supprime le job et la queue", "arr\u00eate tout"), call runtime__kill (or runtime__cancel for a soft stop of just the run) and confirm what was stopped. For questions about what is running or queued, call runtime__status and answer from its data. When the user consents to a pending approval in any phrasing ("vas-y", "ok pour l\'export"), call runtime__approve. When the user asks for a NEW action while a run is active, do not execute it: propose runtime__enqueue (run it after) or, if they insist it replaces the current work, runtime__kill then the new action.',
948
+ 'Runtime control: you have runtime__status, runtime__cancel, runtime__kill, runtime__approve and runtime__enqueue. When the user asks to stop, remove, clean or kill the current run, its jobs or the queue ("supprime le job et la queue", "arr\u00eate tout"), call runtime__kill (or runtime__cancel for a soft stop of just the run) and confirm what was stopped. When the user explicitly asks to delete, reset, abandon or replace the current plan, call runtime__kill with purge=true; never set purge=true for a simple stop. For questions about what is running or queued, call runtime__status and answer from its data. When the user consents to a pending approval in any phrasing ("vas-y", "ok pour l\'export"), call runtime__approve. When the user asks for a NEW action while a run is active, do not execute it: propose runtime__enqueue (run it after) or, if they insist it replaces the current work, runtime__kill then the new action.',
946
949
  'Report every runtime control outcome exactly as the tool returned it \u2014 never embellish. If runtime__kill reports 0 run(s)/0 task(s)/0 purged, say there was nothing active to stop or purge; do NOT claim a run, plan, pending approval or queue item was removed. If runtime__status returns an error or could not be read, say the runtime state could not be retrieved and do not describe a state you never obtained. Never assert that something was cleaned, cancelled, approved or purged unless that specific tool result confirms it.',
947
950
  'Durable profile updates are actions in this stabilized version: delegate them instead of writing directly.',
948
951
  ].filter(Boolean).join('\n');
@@ -1005,7 +1008,7 @@ function toolsForClassification(classification, writeTools, session = null) {
1005
1008
  return [SHELL_READ_COMMAND_TOOL, ...controlTools, ...capabilityRunTools, ...writeTools];
1006
1009
  }
1007
1010
 
1008
- const DONNA_READ_VERBS = new Set(['status', 'list', 'search', 'read', 'get', 'fetch']);
1011
+ const DONNA_READ_VERBS = new Set(['status', 'list', 'search', 'read', 'get', 'fetch', 'collect']);
1009
1012
 
1010
1013
  export function isDonnaReadTool(item) {
1011
1014
  const name = String(item?.function?.name ?? '');
@@ -1349,11 +1349,14 @@ test('Donna interprets a cleanup request and calls runtime__kill herself', async
1349
1349
  runtime: { url: 'http://runtime.test' },
1350
1350
  agentProjection: { status: 'running', activities: [], conversation: [] },
1351
1351
  llm: {
1352
- async completeWithTools({ tools }) {
1352
+ async completeWithTools({ tools, system }) {
1353
1353
  calls += 1;
1354
1354
  if (calls === 1) {
1355
1355
  const names = tools.map((tool) => tool.function.name);
1356
1356
  assert.ok(names.includes('runtime__kill'), 'control tools must be bound during an active run');
1357
+ const killTool = tools.find((tool) => tool.function.name === 'runtime__kill');
1358
+ assert.equal(killTool.function.parameters.properties.purge.type, 'boolean');
1359
+ assert.match(system, /delete, reset, abandon or replace the current plan/);
1357
1360
  return {
1358
1361
  content: null,
1359
1362
  message: { role: 'assistant', content: null },
@@ -7,7 +7,7 @@ loadManagerEnv();
7
7
  import { createAgentGraph } from '../agent/graph.js';
8
8
  import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
9
9
  import { runShell, runHeadlessChatTurn } from '../shell/repl.js';
10
- import { runChecks } from '../core/startupCheck.js';
10
+ import { runPreflightChecks, withRuntimePreflight } from '../core/startupCheck.js';
11
11
  import { applySessionWikircProfile } from '../core/sessionConfig.js';
12
12
  import { listWikircProfiles } from '../core/wikirc.js';
13
13
  import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
@@ -1085,6 +1085,9 @@ async function runRuntime(argv, agent) {
1085
1085
  response = await runHeadlessChatTurn(ephemeral, input, {
1086
1086
  history,
1087
1087
  onStep: ephemeral._onStep,
1088
+ // UI context from `wiki serve`: up to five selected wiki or raw
1089
+ // documents. Only paths are prompted; Donna reads through tools.
1090
+ openWikiPages: body.context?.openWikiPages ?? body.context?.openWikiPage,
1088
1091
  });
1089
1092
  } else {
1090
1093
  response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
@@ -1222,8 +1225,23 @@ export async function runCli(argv) {
1222
1225
  // in a random cwd must not litter files.
1223
1226
  const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
1224
1227
  if (scaffolded.length > 0) loadManagerEnv();
1225
- const gaps = await runChecks();
1226
- if (gaps.length > 0) await runStartupWizard(gaps);
1228
+ const reportCheck = ({ kind, ok, detail, context, skipped, pending }) => {
1229
+ const labels = { docker: 'Docker', internet: 'Internet', agents: 'Agent containers', workspace: 'Workspaces', containers: 'Workspace containers', mcp: 'MCP' };
1230
+ const label = labels[kind] ?? kind;
1231
+ const instruction = !ok && context?.command ? ` — command: ${context.command}` : '';
1232
+ const suffix = detail ? ` — ${detail}` : ` — ${context?.error ?? context?.dockerError ?? 'waiting'}`;
1233
+ const color = ok ? '\x1b[32m' : '\x1b[33m';
1234
+ const state = ok ? 'ready' : pending || skipped ? 'waiting' : 'needs attention';
1235
+ const icon = ok ? '✓' : pending || skipped ? '◐' : '✗';
1236
+ console.log(`${color}${icon} configuration: ${label} ${state}${suffix}${instruction}\x1b[0m`);
1237
+ };
1238
+ let preflight = await runPreflightChecks({ onCheck: reportCheck });
1239
+ if (preflight.gaps.length > 0) {
1240
+ await runStartupWizard(preflight.gaps);
1241
+ // The wizard may have created a workspace, started agents or repaired
1242
+ // configuration. Re-read everything before drawing the home screen.
1243
+ preflight = await runPreflightChecks();
1244
+ }
1227
1245
  let runtime = null;
1228
1246
  try {
1229
1247
  const { ensureRuntime } = await import('../runtime/lifecycle.js');
@@ -1234,11 +1252,12 @@ export async function runCli(argv) {
1234
1252
  runtime = unavailableRuntime(err);
1235
1253
  console.error(`Runtime unavailable: ${runtime.error}`);
1236
1254
  }
1255
+ preflight = withRuntimePreflight(preflight, runtime);
1237
1256
  // The owned-runtime shutdown happens inside the TUI's own exit paths
1238
1257
  // (see tui.tsx onShellExit): render() resolves at MOUNT, so anything
1239
1258
  // after this await would run while the shell is still on screen —
1240
1259
  // 0.12.9 shipped exactly that bug and killed the runtime under the user.
1241
- await runOpenTuiShell({ agent, packageJson, runtime });
1260
+ await runOpenTuiShell({ agent, packageJson, runtime, preflight });
1242
1261
  return;
1243
1262
  }
1244
1263
 
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.10",
3
- "commit": "79503aa"
2
+ "version": "0.14.12",
3
+ "commit": "621830d"
4
4
  }
@@ -22,6 +22,9 @@ test('agent compose services run as the host uid and gid', async () => {
22
22
  const agentsCompose = YAML.parse(agentsRaw);
23
23
  assert.equal(agentsCompose.services.cme.user, '${UID:-1000}:${GID:-1000}');
24
24
  assert.equal(agentsCompose.services.documents.user, '${UID:-1000}:${GID:-1000}');
25
+ assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://albert.api.etalab.gouv.fr/v1}'), true);
26
+ assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-lightonai/LightOnOCR-2-1B}'), true);
27
+ assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-}'), true);
25
28
  // MailerSend was an external-connector experiment — removed from the
26
29
  // default stack (the agent repo still exists for ad-hoc use).
27
30
  assert.equal(agentsCompose.services.mailer, undefined);
package/src/core/env.js CHANGED
@@ -51,9 +51,33 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
51
51
  const created = [];
52
52
  const endpointsFile = managerMcpEndpointsFile();
53
53
  const endpointsExample = join(packageRoot, 'mcp.endpoints.example.json');
54
- if (!existsSync(endpointsFile) && existsSync(endpointsExample)) {
55
- copyFileSync(endpointsExample, endpointsFile);
56
- created.push('mcp.endpoints.json');
54
+ if (existsSync(endpointsExample)) {
55
+ if (!existsSync(endpointsFile)) {
56
+ copyFileSync(endpointsExample, endpointsFile);
57
+ created.push('mcp.endpoints.json');
58
+ } else {
59
+ // Additive migration: the scaffold only copies the example on first run,
60
+ // so installs that predate a new top-level key (e.g. chatAccess) never
61
+ // receive it and the feature stays silently disabled. Merge ONLY the
62
+ // top-level keys missing from the operator's file; existing keys —
63
+ // including a hand-edited chatAccess — are never touched. To disable a
64
+ // feature key permanently, set it to null instead of deleting it: null is
65
+ // "present", so the merge preserves it and readers treat it as absent.
66
+ try {
67
+ const current = JSON.parse(readFileSync(endpointsFile, 'utf8'));
68
+ const example = JSON.parse(readFileSync(endpointsExample, 'utf8'));
69
+ if (current && typeof current === 'object' && !Array.isArray(current)) {
70
+ const missing = Object.keys(example).filter((key) => !(key in current));
71
+ if (missing.length > 0) {
72
+ for (const key of missing) current[key] = example[key];
73
+ writeFileSync(endpointsFile, `${JSON.stringify(current, null, 2)}\n`);
74
+ created.push(`mcp.endpoints.json keys: ${missing.join(', ')}`);
75
+ }
76
+ }
77
+ } catch {
78
+ // Unreadable or invalid JSON: leave the operator's file strictly alone.
79
+ }
80
+ }
57
81
  }
58
82
  const envFile = managerEnvFile();
59
83
  const envExample = join(packageRoot, '.env.example');
@@ -0,0 +1,72 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { ensureManagerScaffold } from './env.js';
8
+
9
+ // ensureManagerScaffold resolves the manager state dir from
10
+ // WIKI_MANAGER_ENV_FILE — point it at a temp dir for each test.
11
+ function withTempManagerDir(fn) {
12
+ const dir = mkdtempSync(join(tmpdir(), 'wiki-manager-env-test-'));
13
+ const previous = process.env.WIKI_MANAGER_ENV_FILE;
14
+ process.env.WIKI_MANAGER_ENV_FILE = join(dir, '.env');
15
+ try {
16
+ return fn(dir);
17
+ } finally {
18
+ if (previous === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
19
+ else process.env.WIKI_MANAGER_ENV_FILE = previous;
20
+ rmSync(dir, { recursive: true, force: true });
21
+ }
22
+ }
23
+
24
+ test('scaffold copies the packaged examples into a fresh directory', () => {
25
+ withTempManagerDir((dir) => {
26
+ const created = ensureManagerScaffold();
27
+ assert.ok(created.includes('mcp.endpoints.json'));
28
+ assert.ok(created.includes('.env'));
29
+ const endpoints = JSON.parse(readFileSync(join(dir, 'mcp.endpoints.json'), 'utf8'));
30
+ assert.ok(endpoints.mcpServers);
31
+ assert.ok(endpoints.chatAccess);
32
+ });
33
+ });
34
+
35
+ test('scaffold merges missing top-level keys into an existing endpoints file', () => {
36
+ withTempManagerDir((dir) => {
37
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
38
+ // Pre-chatAccess install: only mcpServers, with operator edits.
39
+ writeFileSync(endpointsFile, JSON.stringify({
40
+ mcpServers: { custom: { url: 'http://localhost:9999/mcp/' } },
41
+ }, null, 2));
42
+ const created = ensureManagerScaffold();
43
+ assert.ok(created.some((item) => item.includes('chatAccess')));
44
+ const merged = JSON.parse(readFileSync(endpointsFile, 'utf8'));
45
+ // Operator's servers untouched; new key added from the example.
46
+ assert.deepEqual(merged.mcpServers, { custom: { url: 'http://localhost:9999/mcp/' } });
47
+ assert.ok(merged.chatAccess?.servers?.wiki);
48
+ });
49
+ });
50
+
51
+ test('scaffold never overwrites an existing chatAccess, including explicit null', () => {
52
+ withTempManagerDir((dir) => {
53
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
54
+ writeFileSync(endpointsFile, JSON.stringify({
55
+ mcpServers: {},
56
+ chatAccess: null,
57
+ }, null, 2));
58
+ ensureManagerScaffold();
59
+ const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
60
+ // null means "deliberately disabled" — the merge must preserve it.
61
+ assert.equal(after.chatAccess, null);
62
+ });
63
+ });
64
+
65
+ test('scaffold leaves an invalid endpoints file strictly alone', () => {
66
+ withTempManagerDir((dir) => {
67
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
68
+ writeFileSync(endpointsFile, '{ not json');
69
+ ensureManagerScaffold();
70
+ assert.equal(readFileSync(endpointsFile, 'utf8'), '{ not json');
71
+ });
72
+ });
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.14.10';
4
+ const WIKI_MANAGER_VERSION = '0.14.12';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();