@dotdrelle/wiki-manager 0.15.25 → 0.15.27
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 +18 -5
- package/README.md +35 -13
- package/agents.docker-compose.override.example.yml +82 -0
- package/agents.docker-compose.yml +14 -2
- package/docker-compose.override.example.yml +81 -0
- package/docker-compose.yml +23 -17
- package/package.json +4 -2
- package/src/cli/wiki-manager.js +3 -1
- package/src/commands/slash.js +53 -39
- package/src/core/agentEvents.js +20 -0
- package/src/core/agentEvents.test.js +38 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/commandFailure.js +81 -0
- package/src/core/commandFailure.test.js +67 -0
- package/src/core/compose.js +20 -3
- package/src/core/composeOverrides.test.js +74 -0
- package/src/core/dockerCompose.test.js +69 -2
- package/src/core/env.js +59 -0
- package/src/core/env.test.js +33 -1
- package/src/core/mcp.js +1 -1
- package/src/core/setEnvValue.test.js +96 -0
- package/src/runtime/client.js +18 -0
- package/src/runtime/lifecycle.js +5 -1
- package/src/runtime/server.js +43 -1
- package/src/runtime/server.test.js +117 -0
- package/src/runtime/store.js +47 -0
- package/src/runtime/store.test.js +89 -1
- package/src/shell/LeftPane.tsx +46 -16
- package/src/shell/RightPane.tsx +2 -4
- package/src/shell/SetupWizard.tsx +19 -7
- package/src/shell/externalLinks.js +24 -2
- package/src/shell/openExternal.js +53 -0
- package/src/shell/repl.js +33 -10
- package/src/shell/repl.test.js +91 -9
- package/src/shell/setupWizardPlaceholders.test.js +66 -0
- package/src/shell/tui.tsx +54 -25
- package/src/shell/useSession.ts +42 -6
- 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([
|
package/src/core/buildInfo.json
CHANGED
|
@@ -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
|
+
});
|
package/src/core/compose.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 ${
|
|
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
|
-
|
|
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.`);
|
package/src/core/env.test.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.27';
|
|
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
|
+
});
|