@dotdrelle/wiki-manager 0.15.26 → 0.15.28

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.
Files changed (38) hide show
  1. package/.env.example +18 -5
  2. package/README.md +35 -13
  3. package/agents.docker-compose.override.example.yml +82 -0
  4. package/agents.docker-compose.yml +8 -2
  5. package/docker-compose.override.example.yml +81 -0
  6. package/docker-compose.yml +23 -17
  7. package/package.json +4 -2
  8. package/src/cli/wiki-manager.js +13 -4
  9. package/src/commands/slash.js +53 -39
  10. package/src/core/agentEvents.js +20 -0
  11. package/src/core/agentEvents.test.js +38 -1
  12. package/src/core/buildInfo.json +2 -2
  13. package/src/core/commandFailure.js +81 -0
  14. package/src/core/commandFailure.test.js +67 -0
  15. package/src/core/compose.js +20 -3
  16. package/src/core/composeOverrides.test.js +74 -0
  17. package/src/core/dockerCompose.test.js +69 -2
  18. package/src/core/env.js +59 -0
  19. package/src/core/env.test.js +33 -1
  20. package/src/core/mcp.js +1 -1
  21. package/src/core/setEnvValue.test.js +96 -0
  22. package/src/runtime/client.js +18 -0
  23. package/src/runtime/lifecycle.js +5 -1
  24. package/src/runtime/server.js +43 -1
  25. package/src/runtime/server.test.js +117 -0
  26. package/src/runtime/store.js +47 -0
  27. package/src/runtime/store.test.js +89 -1
  28. package/src/shell/LeftPane.tsx +46 -16
  29. package/src/shell/RightPane.tsx +2 -4
  30. package/src/shell/SetupWizard.tsx +19 -7
  31. package/src/shell/externalLinks.js +24 -2
  32. package/src/shell/openExternal.js +53 -0
  33. package/src/shell/repl.js +33 -10
  34. package/src/shell/repl.test.js +107 -9
  35. package/src/shell/setupWizardPlaceholders.test.js +66 -0
  36. package/src/shell/tui.tsx +54 -25
  37. package/src/shell/useSession.ts +59 -6
  38. package/wiki-workspace +84 -6
@@ -1,6 +1,43 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from './agentEvents.js';
3
+ import { conversationEventSequences, createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from './agentEvents.js';
4
+
5
+ function sequenced(events) {
6
+ return events.map((event, index) => ({ ...event, sequence: index + 1 }));
7
+ }
8
+
9
+ test('conversationEventSequences maps every entry back to the event that produced it', () => {
10
+ const events = sequenced([
11
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'first question' } }),
12
+ createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'first answer' } }),
13
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['do something'] } }),
14
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'second question' } }),
15
+ createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'second answer' } }),
16
+ ]);
17
+
18
+ const projection = reduceAgentEvents(events);
19
+ const sequences = conversationEventSequences(events);
20
+
21
+ // One sequence per conversation entry, and the mapping is derived by the same
22
+ // applyEvent the projection uses, so it cannot drift from what is displayed.
23
+ assert.equal(sequences.length, projection.conversation.length);
24
+ assert.deepEqual(sequences, [1, 2, 4, 5]);
25
+ // Redo on "second question" (index 2) truncates after sequence 4: the plan
26
+ // event at 3 predates it and survives, the answer at 5 does not.
27
+ assert.equal(sequences[2], 4);
28
+ });
29
+
30
+ test('a streamed reply keeps the sequence of the delta that created it', () => {
31
+ const events = sequenced([
32
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }),
33
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'par' } }),
34
+ createAgentEvent('assistant_delta', { origin: 'runtime', payload: { delta: 'tial' } }),
35
+ ]);
36
+
37
+ // Deltas mutate the last entry in place instead of appending; the entry must
38
+ // not be re-stamped with every later delta or a redo would truncate too late.
39
+ assert.deepEqual(conversationEventSequences(events), [1, 2]);
40
+ });
4
41
 
5
42
  test('reduceAgentEvents: run_started clears stale plan', () => {
6
43
  const projection = reduceAgentEvents([
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.26",
3
- "commit": "1af3dea"
2
+ "version": "0.15.28",
3
+ "commit": "11b99e6"
4
4
  }
@@ -0,0 +1,81 @@
1
+ // Docker and Compose speak to operators in their own vocabulary: exit codes,
2
+ // interpolation warnings, daemon socket paths, the full command line with every
3
+ // -f and --env-file. The ShellUI used to paste that verbatim into the
4
+ // conversation, so a missing image and an unreadable workspace looked identical
5
+ // — a wall of flags. Classify the failure into a stable reason code, hand the
6
+ // reason to Donna, and keep the raw text for the runtime log lane only.
7
+
8
+ const SIGNATURES = [
9
+ {
10
+ reason: 'docker-not-installed',
11
+ match: (text) => /docker: (command )?not found|'docker' is not recognized/i.test(text),
12
+ },
13
+ {
14
+ reason: 'docker-daemon-unavailable',
15
+ match: (text) => /cannot connect to the docker daemon|is the docker daemon running|docker daemon is not running/i.test(text),
16
+ },
17
+ {
18
+ reason: 'image-unavailable',
19
+ match: (text) => /manifest unknown|pull access denied|no such image|image .* not found|repository does not exist/i.test(text),
20
+ },
21
+ {
22
+ reason: 'port-already-in-use',
23
+ match: (text) => /port is already allocated|address already in use|bind: permission denied/i.test(text),
24
+ },
25
+ {
26
+ reason: 'unknown-service',
27
+ match: (text) => /no such service|has no service/i.test(text),
28
+ },
29
+ {
30
+ reason: 'permission-denied',
31
+ match: (text) => /permission denied while trying to connect|got permission denied|operation not permitted/i.test(text),
32
+ },
33
+ {
34
+ reason: 'workspace-path-unavailable',
35
+ match: (text) => /no such file or directory|is not a directory|invalid mount config/i.test(text),
36
+ },
37
+ {
38
+ reason: 'configuration-variable-missing',
39
+ // Compose interpolation: only a real failure when the variable is required
40
+ // (`${VAR:?...}`); the plain "not set" form is a warning we now avoid.
41
+ match: (text) => /required variable .* is missing|variable is not set/i.test(text),
42
+ },
43
+ {
44
+ reason: 'network-unreachable',
45
+ match: (text) => /network is unreachable|temporary failure in name resolution|tls handshake timeout|proxyconnect/i.test(text),
46
+ },
47
+ ];
48
+
49
+ export function rawFailureText(err) {
50
+ return [err?.stderr, err?.stdout, err?.message]
51
+ .filter(Boolean)
52
+ .map(String)
53
+ .join('\n')
54
+ .trim();
55
+ }
56
+
57
+ export function classifyCommandFailure(err) {
58
+ if (err?.code === 'ENOENT') return 'command-not-found';
59
+ const text = rawFailureText(err);
60
+ if (!text) return 'unknown';
61
+ for (const { reason, match } of SIGNATURES) {
62
+ if (match(text)) return reason;
63
+ }
64
+ return 'unknown';
65
+ }
66
+
67
+ // The single line worth showing an operator when nothing matched: the last
68
+ // non-empty output line, stripped of the command echo and of absolute paths
69
+ // that only describe this machine's install layout.
70
+ export function failureHint(err, { maxLength = 200 } = {}) {
71
+ const lines = rawFailureText(err)
72
+ .split(/\r?\n/)
73
+ .map((line) => line.trim())
74
+ .filter(Boolean)
75
+ .filter((line) => !/^command failed:/i.test(line))
76
+ .filter((line) => !/^time=".*"\s+level=warning/i.test(line))
77
+ .filter((line) => !/^docker compose /i.test(line));
78
+ const last = lines.at(-1) ?? '';
79
+ const hint = last.replace(/(^|\s)(\/[^\s]+)/g, (match, prefix, path) => `${prefix}${path.split('/').at(-1)}`);
80
+ return hint.length > maxLength ? `${hint.slice(0, maxLength - 1)}…` : hint;
81
+ }
@@ -0,0 +1,67 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { classifyCommandFailure, failureHint, rawFailureText } from './commandFailure.js';
4
+ import { localizedOperationFailure } from '../commands/slash.js';
5
+
6
+ const COMPOSE_COMMAND = 'Command failed: docker compose -f /home/p/.nvm/versions/node/v24/lib/node_modules/@dotdrelle/wiki-manager/docker-compose.yml -p wiki-juno --env-file /mnt/c/Users/p/Documents/docker/llm-wiki/.env up -d';
7
+
8
+ test('common docker failures map to a stable reason code', () => {
9
+ const cases = [
10
+ [{ message: `${COMPOSE_COMMAND}\ndocker: command not found` }, 'docker-not-installed'],
11
+ [{ message: 'Cannot connect to the Docker daemon at unix:///var/run/docker.sock.' }, 'docker-daemon-unavailable'],
12
+ [{ stderr: 'Error response from daemon: manifest unknown' }, 'image-unavailable'],
13
+ [{ stderr: 'Bind for 0.0.0.0:3100 failed: port is already allocated' }, 'port-already-in-use'],
14
+ [{ stderr: 'error while creating mount source path: no such file or directory' }, 'workspace-path-unavailable'],
15
+ [{ stderr: 'proxyconnect tcp: dial tcp 10.0.0.1:3128: i/o timeout' }, 'network-unreachable'],
16
+ [{ stderr: 'no such service: all' }, 'unknown-service'],
17
+ [{ code: 'ENOENT' }, 'command-not-found'],
18
+ [{ message: `${COMPOSE_COMMAND}\nsomething nobody has seen before` }, 'unknown'],
19
+ ];
20
+
21
+ for (const [error, expected] of cases) {
22
+ assert.equal(classifyCommandFailure(error), expected, `expected ${expected} for ${rawFailureText(error)}`);
23
+ }
24
+ });
25
+
26
+ test('the fallback hint drops the command echo, compose warnings and host paths', () => {
27
+ const error = {
28
+ message: [
29
+ COMPOSE_COMMAND,
30
+ 'time="2026-07-28T11:37:41+02:00" level=warning msg="The \\"CONNECTORS_MCP_PORT\\" variable is not set."',
31
+ 'error while creating mount source path /mnt/c/Users/p/Documents/docker/llm-wiki/workspaces/juno: denied',
32
+ ].join('\n'),
33
+ };
34
+
35
+ const hint = failureHint(error);
36
+ assert.doesNotMatch(hint, /Command failed/);
37
+ assert.doesNotMatch(hint, /level=warning/);
38
+ assert.doesNotMatch(hint, /--env-file|-f |docker compose/);
39
+ // Only the basename survives: absolute paths describe this machine's install
40
+ // layout and mean nothing to the person reading the answer.
41
+ assert.doesNotMatch(hint, /\/mnt\/c/);
42
+ assert.match(hint, /juno/);
43
+ });
44
+
45
+ test('a failed operation reaches Donna as facts, never as docker output', () => {
46
+ const error = { message: `${COMPOSE_COMMAND}\nCannot connect to the Docker daemon at unix:///var/run/docker.sock.` };
47
+ const result = localizedOperationFailure({ operation: 'start', target: 'workspace-services', error });
48
+
49
+ assert.equal(result.rawOutput, true);
50
+ assert.deepEqual(JSON.parse(result.output), {
51
+ operation: 'start',
52
+ target: 'workspace-services',
53
+ status: 'failed',
54
+ reason: 'docker-daemon-unavailable',
55
+ });
56
+ assert.doesNotMatch(result.output, /docker compose|--env-file|unix:\/\//);
57
+ assert.doesNotMatch(result.agentTrigger, /docker compose|--env-file|unix:\/\//);
58
+ assert.match(result.agentTrigger, /action concrète/);
59
+ });
60
+
61
+ test('an unclassified failure still carries a sanitized detail for Donna', () => {
62
+ const error = { message: `${COMPOSE_COMMAND}\nsomething nobody has seen before` };
63
+ const facts = JSON.parse(localizedOperationFailure({ operation: 'stop', target: 'agents', error }).output);
64
+
65
+ assert.equal(facts.reason, 'unknown');
66
+ assert.equal(facts.detail, 'something nobody has seen before');
67
+ });
@@ -5,7 +5,13 @@ import { promisify } from 'node:util';
5
5
  import YAML from 'yaml';
6
6
  import { cacertEnv, ensureCacertComposeOverride } from './cacert.js';
7
7
  import { checkMissingDockerImages } from './dockerImages.js';
8
- import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, resolveAgentsDataDir } from './env.js';
8
+ import {
9
+ managerComposeOverrideFile,
10
+ managerEnvFile,
11
+ managerMcpEndpointsFile,
12
+ readEnvFile,
13
+ resolveAgentsDataDir,
14
+ } from './env.js';
9
15
  import { managerRoot } from './workspaces.js';
10
16
 
11
17
  const execFileAsync = promisify(execFile);
@@ -98,6 +104,11 @@ function composeBaseArgs(session) {
98
104
  const compose = composeFile();
99
105
  const cacertOverride = ensureCacertComposeOverride(compose);
100
106
  const args = ['compose', '-f', compose];
107
+ // Same merge order as wiki-workspace: packaged file, then the user-owned
108
+ // override, then the generated CA override last so a --cacert change always
109
+ // wins over a stale hand-written CA path.
110
+ const userOverride = managerComposeOverrideFile();
111
+ if (existsSync(userOverride)) args.push('-f', userOverride);
101
112
  if (cacertOverride) args.push('-f', cacertOverride);
102
113
  args.push('-p', projectName(session));
103
114
  const managerEnvPath = managerEnvFile();
@@ -321,12 +332,18 @@ export async function stopService(session, service) {
321
332
 
322
333
  export async function serviceLogs(session, service, options = {}) {
323
334
  if (!service) throw new Error('Usage: /logs <service> [tail]');
335
+ // `all`, `ui`, `wiki`, `production` are aliases, not Compose services.
336
+ // start/stop resolved them; logs passed the word straight to Docker, so
337
+ // `/logs all` died with `no such service: all` — the one spelling the help
338
+ // text and the completion list both suggest.
339
+ const aliases = serviceAliases();
340
+ const targets = aliases[service] ?? [service];
324
341
  const tail = String(Number.isFinite(options.tail) ? options.tail : 120);
325
- const output = await runCompose(session, ['logs', '--tail', tail, service], {
342
+ const output = await runCompose(session, ['logs', '--tail', tail, ...targets], {
326
343
  timeout: 60_000,
327
344
  maxBuffer: 1024 * 1024 * 8,
328
345
  });
329
- return output || `No logs for ${service}.`;
346
+ return output || `No logs for ${targets.join(', ')}.`;
330
347
  }
331
348
 
332
349
  export async function runWikiCli(session, args, options = {}) {
@@ -0,0 +1,74 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import YAML from 'yaml';
8
+ import { COMPOSE_OVERRIDES, ensureManagerScaffold, managerComposeOverrideFile } from './env.js';
9
+
10
+ function withStateDir(run) {
11
+ const root = mkdtempSync(join(tmpdir(), 'wiki-manager-overrides-'));
12
+ const previous = process.env.WIKI_MANAGER_ENV_FILE;
13
+ process.env.WIKI_MANAGER_ENV_FILE = join(root, '.env');
14
+ try {
15
+ return run(root);
16
+ } finally {
17
+ if (previous === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
18
+ else process.env.WIKI_MANAGER_ENV_FILE = previous;
19
+ }
20
+ }
21
+
22
+ test('packaged override templates are valid compose files with an empty services map', async () => {
23
+ for (const { example } of COMPOSE_OVERRIDES) {
24
+ const raw = await readFile(new URL(`../../${example}`, import.meta.url), 'utf8');
25
+ const parsed = YAML.parse(raw);
26
+ assert.deepEqual(parsed?.services, {}, `${example} must parse to an empty services map`);
27
+ // A named service here would be merged into every deployment and become a
28
+ // phantom service as soon as the packaged file drops it.
29
+ assert.equal(Object.keys(parsed).length, 1, `${example} must declare nothing but services`);
30
+ }
31
+ });
32
+
33
+ test('scaffold seeds both compose overrides once and never rewrites them', () => {
34
+ withStateDir(() => {
35
+ const created = ensureManagerScaffold();
36
+ for (const { target } of COMPOSE_OVERRIDES) {
37
+ assert.ok(created.includes(target), `expected ${target} to be created`);
38
+ }
39
+
40
+ const edited = '# operator edit\nservices:\n serve:\n environment:\n - HTTP_PROXY=http://proxy:3128\n';
41
+ const workspaceOverride = managerComposeOverrideFile('docker-compose.override.yml');
42
+ writeFileSync(workspaceOverride, edited, 'utf8');
43
+
44
+ const second = ensureManagerScaffold();
45
+ assert.equal(readFileSync(workspaceOverride, 'utf8'), edited, 'operator edits must survive re-scaffolding');
46
+ for (const { target } of COMPOSE_OVERRIDES) {
47
+ assert.ok(!second.includes(target), `${target} must not be recreated`);
48
+ }
49
+ });
50
+ });
51
+
52
+ test('the Node compose path merges the user override before the generated CA override', async () => {
53
+ const source = await readFile(new URL('./compose.js', import.meta.url), 'utf8');
54
+ const userOverrideIndex = source.indexOf("args.push('-f', userOverride)");
55
+ const cacertIndex = source.indexOf("args.push('-f', cacertOverride)");
56
+ assert.ok(userOverrideIndex > 0, 'composeBaseArgs must include the user override');
57
+ assert.ok(cacertIndex > userOverrideIndex, 'the CA override must be merged last');
58
+ });
59
+
60
+ test('wiki-workspace seeds the overrides once and merges them in both stacks', async () => {
61
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
62
+
63
+ assert.match(script, /ensure_compose_override\(\) \{/);
64
+ // Seed-once contract: the opposite of cacert_compose_args, which always
65
+ // replaces its generated file.
66
+ assert.match(script, /\[\[ -f "\$target" \]\] && return 0/);
67
+ assert.match(script, /ensure_compose_override 'agents\.docker-compose\.override\.example\.yml' 'agents\.docker-compose\.override\.yml'/);
68
+ assert.match(script, /ensure_compose_override 'docker-compose\.override\.example\.yml' 'docker-compose\.override\.yml'/);
69
+ assert.match(script, /local user_override="\$MANAGER_STATE_DIR\/docker-compose\.override\.yml"/);
70
+ assert.match(
71
+ script,
72
+ /-f "\$ROOT_DIR\/docker-compose\.yml" \$\{override_args\[@\]\+"\$\{override_args\[@\]\}"\} \$\{cacert_args\[@\]\+"\$\{cacert_args\[@\]\}"\}/,
73
+ );
74
+ });
@@ -3,6 +3,7 @@ import { test } from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
4
  import YAML from 'yaml';
5
5
  import { configuredAgentImages } from './wikiSetup.js';
6
+ import { REQUIRED_ENV_KEYS } from './env.js';
6
7
 
7
8
  test('workspace compose does not start a per-workspace agent runtime', async () => {
8
9
  const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
@@ -18,6 +19,73 @@ test('workspace compose does not start a per-workspace agent runtime', async ()
18
19
  );
19
20
  });
20
21
 
22
+ test('no compose service relies on a bare environment passthrough', async () => {
23
+ // `- VAR` makes Compose print `The "VAR" variable is not set. Defaulting to a
24
+ // blank string.` for every key the operator left as a commented placeholder —
25
+ // CONNECTORS_MCP_PORT once connectors were enabled. That warning reached the
26
+ // ShellUI looking like a failure. Every entry must carry its own default.
27
+ for (const file of ['../../docker-compose.yml', '../../agents.docker-compose.yml']) {
28
+ const compose = YAML.parse(await readFile(new URL(file, import.meta.url), 'utf8'));
29
+ for (const [name, service] of Object.entries(compose.services ?? {})) {
30
+ for (const entry of service.environment ?? []) {
31
+ assert.match(String(entry), /=/, `${file} ${name}: "${entry}" must be written as VAR=\${VAR:-default}`);
32
+ }
33
+ }
34
+ }
35
+
36
+ const workspace = YAML.parse(await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8'));
37
+ assert.ok(workspace.services.serve.environment.includes('CONNECTORS_MCP_PORT=${CONNECTORS_MCP_PORT:-3338}'));
38
+ });
39
+
40
+ test('required .env keys agree with the compose defaults they mirror', async () => {
41
+ // Three files carry the same values: .env.example (what the operator reads),
42
+ // the compose default (what the container actually receives) and
43
+ // REQUIRED_ENV_KEYS (what a migration writes). A silent divergence is exactly
44
+ // the bug this pins: the documents agent called an endpoint the .env said
45
+ // nothing about.
46
+ const envExample = await readFile(new URL('../../.env.example', import.meta.url), 'utf8');
47
+ const composeFiles = await Promise.all(
48
+ ['../../agents.docker-compose.yml', '../../docker-compose.yml']
49
+ .map((file) => readFile(new URL(file, import.meta.url), 'utf8')),
50
+ );
51
+ const compose = composeFiles.join('\n');
52
+
53
+ for (const [key, value] of Object.entries(REQUIRED_ENV_KEYS)) {
54
+ assert.match(
55
+ envExample,
56
+ new RegExp(`^${key}=${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'),
57
+ `.env.example must ship ${key} active with the same value`,
58
+ );
59
+ const composeDefault = compose.match(new RegExp(`\\$\\{${key}:-([^}]*)\\}`));
60
+ if (!composeDefault) continue;
61
+ assert.equal(composeDefault[1], value, `${key}: compose default and REQUIRED_ENV_KEYS disagree`);
62
+ }
63
+ });
64
+
65
+ test('no compose default points a deployment at an LLM provider it did not choose', async () => {
66
+ // A hardcoded `${DOCUMENT_LLM_BASE_URL:-https://…}` sent every install to a
67
+ // third-party endpoint the operator never saw, since the key ships commented.
68
+ // The .env is the only source; an empty value also neutralises the image's
69
+ // own ENV fallback.
70
+ const agents = await readFile(new URL('../../agents.docker-compose.yml', import.meta.url), 'utf8');
71
+ const envExample = await readFile(new URL('../../.env.example', import.meta.url), 'utf8');
72
+
73
+ for (const key of ['DOCUMENT_LLM_BASE_URL', 'DOCUMENT_LLM_MODEL', 'DOCUMENT_LLM_API_KEY']) {
74
+ assert.match(agents, new RegExp(`- ${key}=\\$\\{${key}:-\\}`), `${key} must have an empty compose default`);
75
+ assert.doesNotMatch(envExample, new RegExp(`^${key}=.+$`, 'm'), `${key} must stay commented in .env.example`);
76
+ }
77
+ });
78
+
79
+ test('log reading resolves service aliases like start and stop do', async () => {
80
+ // `/logs all` used to reach Docker verbatim and fail with `no such service:
81
+ // all` — while `all` is exactly what /help and the completion list suggest.
82
+ const source = await readFile(new URL('./compose.js', import.meta.url), 'utf8');
83
+ const logs = source.slice(source.indexOf('export async function serviceLogs'));
84
+ assert.match(logs, /const aliases = serviceAliases\(\);/);
85
+ assert.match(logs, /const targets = aliases\[service\] \?\? \[service\];/);
86
+ assert.match(logs, /\['logs', '--tail', tail, \.\.\.targets\]/);
87
+ });
88
+
21
89
  test('agent compose services run as the host uid and gid', async () => {
22
90
  const workspaceRaw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
23
91
  const workspaceCompose = YAML.parse(workspaceRaw);
@@ -31,8 +99,7 @@ test('agent compose services run as the host uid and gid', async () => {
31
99
  assert.deepEqual(agentsCompose.services.connectors.profiles, ['connectors']);
32
100
  assert.equal(agentsCompose.services.connectors.environment.includes('GOOGLE_OAUTH_CALLBACK_URL=${GOOGLE_OAUTH_CALLBACK_URL:-}'), true);
33
101
  assert.equal(agentsCompose.services.connectors.volumes.includes('${AGENTS_DATA_DIR:-./.agents-data}/connectors:/data'), true);
34
- assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_BASE_URL=${DOCUMENT_LLM_BASE_URL:-https://albert.api.etalab.gouv.fr/v1}'), true);
35
- assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_MODEL=${DOCUMENT_LLM_MODEL:-lightonai/LightOnOCR-2-1B}'), true);
102
+ // OCR endpoint, model and key carry no default — see the dedicated test.
36
103
  assert.equal(agentsCompose.services.documents.environment.includes('DOCUMENT_LLM_API_KEY=${DOCUMENT_LLM_API_KEY:-}'), true);
37
104
  });
38
105
 
package/src/core/env.js CHANGED
@@ -39,6 +39,51 @@ export function managerMcpEndpointsFile() {
39
39
  return join(managerStateDir(), 'mcp.endpoints.json');
40
40
  }
41
41
 
42
+ // User-owned compose overrides, one per stack, next to the manager .env.
43
+ // Deliberately NOT under .wiki/runtime: everything there is generated state
44
+ // that every compose command rewrites (see cacert.js). These two are seeded
45
+ // once and never touched again, so an operator has a supported place to fix a
46
+ // deployment — proxy passthrough, extra mounts, optional agents — instead of
47
+ // editing a generated file and losing the change on the next command.
48
+ export const COMPOSE_OVERRIDES = [
49
+ { example: 'docker-compose.override.example.yml', target: 'docker-compose.override.yml' },
50
+ { example: 'agents.docker-compose.override.example.yml', target: 'agents.docker-compose.override.yml' },
51
+ ];
52
+
53
+ export function managerComposeOverrideFile(target = 'docker-compose.override.yml') {
54
+ return join(managerStateDir(), target);
55
+ }
56
+
57
+ // Keys the deployment cannot work without. `.env.example` ships them active,
58
+ // but the scaffold only copies that file on first run, so installs predating a
59
+ // new required key never receive it — the same additive-migration gap the
60
+ // endpoints file had.
61
+ export const REQUIRED_ENV_KEYS = {
62
+ // `serve` runs in Docker and calls the host runtime through
63
+ // host.docker.internal. A runtime bound to 127.0.0.1 refuses that connection,
64
+ // and the workspace UI silently shows no runtime. Exposing the port always
65
+ // generates a token (see resolveRuntimeAuthToken), so this is not an
66
+ // unauthenticated bind.
67
+ WIKI_MANAGER_RUNTIME_HOST: '0.0.0.0',
68
+ };
69
+
70
+ function commentedAssignment(line, key) {
71
+ return new RegExp(`^\\s*#+\\s*${key}=`).test(line);
72
+ }
73
+
74
+ // Mirrors set_env_value in wiki-workspace: reuse the commented placeholder so
75
+ // the value lands under the comment block that documents it, and never
76
+ // overwrite an active assignment — that one is the operator's own choice.
77
+ export function writeEnvValueIfMissing(filePath, key, value) {
78
+ const lines = readFileSync(filePath, 'utf8').split(/\r?\n/);
79
+ if (lines.some((line) => line.startsWith(`${key}=`))) return false;
80
+ const placeholder = lines.findIndex((line) => commentedAssignment(line, key));
81
+ if (placeholder >= 0) lines[placeholder] = `${key}=${value}`;
82
+ else lines.push(`${key}=${value}`);
83
+ writeFileSync(filePath, lines.join('\n'));
84
+ return true;
85
+ }
86
+
42
87
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
43
88
 
44
89
  // First-run scaffolding: a fresh install directory has neither
@@ -104,6 +149,20 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
104
149
  .replace(/^# WIKI_WORKSPACES_DIR=.*$/m, `WIKI_WORKSPACES_DIR=${workspacesRoot}`);
105
150
  writeFileSync(envFile, content);
106
151
  created.push('.env');
152
+ } else if (existsSync(envFile)) {
153
+ for (const [key, value] of Object.entries(REQUIRED_ENV_KEYS)) {
154
+ if (writeEnvValueIfMissing(envFile, key, value)) created.push(`.env ${key}=${value}`);
155
+ }
156
+ }
157
+ // Seed once, never rewrite: an existing file is the operator's, whatever it
158
+ // contains. Losing a hand-written proxy block on a package update is exactly
159
+ // the failure mode this scaffold exists to prevent.
160
+ for (const { example, target } of COMPOSE_OVERRIDES) {
161
+ const examplePath = join(packageRoot, example);
162
+ const targetPath = managerComposeOverrideFile(target);
163
+ if (!existsSync(examplePath) || existsSync(targetPath)) continue;
164
+ copyFileSync(examplePath, targetPath);
165
+ created.push(target);
107
166
  }
108
167
  if (created.length > 0) {
109
168
  log(`configuration initialized successfully in ${managerStateDir()} — created ${created.join(' and ')} from packaged defaults. Optional credentials can be added later for external services.`);
@@ -30,11 +30,43 @@ test('scaffold copies the packaged examples into a fresh directory', () => {
30
30
  assert.ok(endpoints.mcpServers);
31
31
  assert.ok(endpoints.chatAccess);
32
32
  const env = readFileSync(join(dir, '.env'), 'utf8');
33
- assert.match(env, /^# WIKI_MANAGER_RUNTIME_HOST=0\.0\.0\.0$/m);
33
+ // Active, not commented: serve runs in Docker and reaches the runtime
34
+ // through host.docker.internal, which a loopback bind refuses.
35
+ assert.match(env, /^WIKI_MANAGER_RUNTIME_HOST=0\.0\.0\.0$/m);
34
36
  assert.match(env, /^# WIKI_MANAGER_RUNTIME_PORT=7788$/m);
35
37
  });
36
38
  });
37
39
 
40
+ test('an install predating the required runtime host receives it on its placeholder', () => {
41
+ withTempManagerDir((dir) => {
42
+ const envFile = join(dir, '.env');
43
+ writeFileSync(envFile, [
44
+ 'WORKSPACES_ROOT=/srv/workspaces',
45
+ '# WIKI_MANAGER_RUNTIME_PORT=7788',
46
+ '# WIKI_MANAGER_RUNTIME_HOST=0.0.0.0',
47
+ '',
48
+ ].join('\n'));
49
+
50
+ const created = ensureManagerScaffold();
51
+ assert.ok(created.some((item) => item.includes('WIKI_MANAGER_RUNTIME_HOST')));
52
+ const env = readFileSync(envFile, 'utf8');
53
+ assert.match(env, /^WIKI_MANAGER_RUNTIME_HOST=0\.0\.0\.0$/m);
54
+ assert.doesNotMatch(env, /^#\s*WIKI_MANAGER_RUNTIME_HOST=/m);
55
+ assert.match(env, /^WORKSPACES_ROOT=\/srv\/workspaces$/m);
56
+ });
57
+ });
58
+
59
+ test('an operator who deliberately kept the loopback bind is never overruled', () => {
60
+ withTempManagerDir((dir) => {
61
+ const envFile = join(dir, '.env');
62
+ writeFileSync(envFile, 'WIKI_MANAGER_RUNTIME_HOST=127.0.0.1\n');
63
+
64
+ const created = ensureManagerScaffold();
65
+ assert.ok(!created.some((item) => item.includes('WIKI_MANAGER_RUNTIME_HOST')));
66
+ assert.match(readFileSync(envFile, 'utf8'), /^WIKI_MANAGER_RUNTIME_HOST=127\.0\.0\.1$/m);
67
+ });
68
+ });
69
+
38
70
  test('scaffold merges missing top-level keys into an existing endpoints file', () => {
39
71
  withTempManagerDir((dir) => {
40
72
  const endpointsFile = join(dir, 'mcp.endpoints.json');
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.15.26';
4
+ const WIKI_MANAGER_VERSION = '0.15.28';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -0,0 +1,96 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+
9
+ const scriptPath = fileURLToPath(new URL('../../wiki-workspace', import.meta.url));
10
+
11
+ // Extracts the pure env-file helpers from wiki-workspace and exercises them for
12
+ // real: these functions rewrite the operator's .env in place, so a source-grep
13
+ // assertion would not catch an off-by-one in the comment matching.
14
+ function bashHelpers() {
15
+ const script = readFileSync(scriptPath, 'utf8');
16
+ return ['commented_assignment', 'set_env_value', 'env_value']
17
+ .map((name) => {
18
+ const start = script.indexOf(`\n${name}() {`);
19
+ assert.ok(start >= 0, `missing helper: ${name}`);
20
+ const end = script.indexOf('\n}\n', start);
21
+ assert.ok(end > start, `unterminated helper: ${name}`);
22
+ return script.slice(start, end + 3);
23
+ })
24
+ .join('\n');
25
+ }
26
+
27
+ function runHelpers(envContent, commands) {
28
+ const dir = mkdtempSync(join(tmpdir(), 'wiki-manager-setenv-'));
29
+ const envFile = join(dir, '.env');
30
+ const runner = join(dir, 'run.sh');
31
+ writeFileSync(envFile, envContent, 'utf8');
32
+ writeFileSync(runner, `set -euo pipefail\n${bashHelpers()}\nf="${envFile}"\n${commands}\n`, 'utf8');
33
+ execFileSync('bash', [runner], { encoding: 'utf8' });
34
+ return readFileSync(envFile, 'utf8');
35
+ }
36
+
37
+ test('a generated key reuses its commented placeholder instead of being appended', () => {
38
+ const result = runHelpers(
39
+ '# Generated automatically when missing.\n# OAUTH_STATE_SECRET=\n# OAUTH_START_TOKEN=\n',
40
+ 'set_env_value "$f" OAUTH_STATE_SECRET aaa\n',
41
+ );
42
+
43
+ assert.match(result, /^OAUTH_STATE_SECRET=aaa$/m);
44
+ // The value must land on the placeholder's own line, under the comment block
45
+ // that documents it — not orphaned at the end of the file.
46
+ assert.doesNotMatch(result, /^#\s*OAUTH_STATE_SECRET=/m);
47
+ assert.ok(
48
+ result.indexOf('OAUTH_STATE_SECRET=aaa') < result.indexOf('# OAUTH_START_TOKEN='),
49
+ 'the placeholder line must be replaced in place',
50
+ );
51
+ // A sibling placeholder nobody asked for stays commented.
52
+ assert.match(result, /^# OAUTH_START_TOKEN=$/m);
53
+ });
54
+
55
+ test('placeholder matching tolerates the spacing variants shipped in .env.example', () => {
56
+ const result = runHelpers(
57
+ '#GOOGLE_OAUTH_CALLBACK_URL=http://old\n## WIKI_MANAGER_RUNTIME_TOKEN=\n',
58
+ 'set_env_value "$f" GOOGLE_OAUTH_CALLBACK_URL http://new\nset_env_value "$f" WIKI_MANAGER_RUNTIME_TOKEN ccc\n',
59
+ );
60
+
61
+ assert.equal(result, 'GOOGLE_OAUTH_CALLBACK_URL=http://new\nWIKI_MANAGER_RUNTIME_TOKEN=ccc\n');
62
+ });
63
+
64
+ test('an active assignment wins and never gains a second uncommented twin', () => {
65
+ const result = runHelpers(
66
+ '# A prose comment mentioning NO_PROXY without assigning it\nNO_PROXY=keepme\n# NO_PROXY=should-stay-commented\n',
67
+ 'set_env_value "$f" NO_PROXY localhost\n',
68
+ );
69
+
70
+ assert.match(result, /^NO_PROXY=localhost$/m);
71
+ assert.match(result, /^# NO_PROXY=should-stay-commented$/m);
72
+ assert.match(result, /^# A prose comment mentioning NO_PROXY without assigning it$/m);
73
+ assert.equal(result.match(/^NO_PROXY=/gm).length, 1);
74
+ });
75
+
76
+ test('a key absent from the file is still appended', () => {
77
+ const result = runHelpers('EXISTING=1\n', 'set_env_value "$f" BRAND_NEW_KEY zzz\n');
78
+
79
+ assert.match(result, /^EXISTING=1$/m);
80
+ assert.match(result, /^BRAND_NEW_KEY=zzz$/m);
81
+ });
82
+
83
+ test('commented placeholders stay unset for readers', () => {
84
+ const dir = mkdtempSync(join(tmpdir(), 'wiki-manager-setenv-read-'));
85
+ const envFile = join(dir, '.env');
86
+ const runner = join(dir, 'run.sh');
87
+ writeFileSync(envFile, '# OAUTH_START_TOKEN=\nCME_MCP_AUTH_TOKEN=set\n', 'utf8');
88
+ writeFileSync(
89
+ runner,
90
+ `set -euo pipefail\n${bashHelpers()}\nenv_value "${envFile}" OAUTH_START_TOKEN "<unset>"\nenv_value "${envFile}" CME_MCP_AUTH_TOKEN "<unset>"\n`,
91
+ 'utf8',
92
+ );
93
+ const output = execFileSync('bash', [runner], { encoding: 'utf8' });
94
+
95
+ assert.equal(output, '<unset>\nset\n');
96
+ });