@dotdrelle/wiki-manager 0.6.47 → 0.7.3

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.
@@ -0,0 +1,164 @@
1
+ import { buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/graph.js';
2
+ import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
+ import { activitySnapshot, newNonTerminalActivities } from './activity.js';
4
+ import { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
5
+
6
+ export function abortError(message = 'Agent run cancelled.') {
7
+ const err = new Error(message);
8
+ err.name = 'AbortError';
9
+ return err;
10
+ }
11
+
12
+ export function throwIfAborted(signal, message) {
13
+ if (signal?.aborted) throw abortError(message);
14
+ }
15
+
16
+ export async function runAgentTurn(agent, session, input, {
17
+ messages = [],
18
+ signal = null,
19
+ } = {}) {
20
+ let streamedContent = '';
21
+ session._onStream = (delta) => { streamedContent += delta; };
22
+ session._onStreamReset = () => { streamedContent = ''; };
23
+ let result;
24
+ try {
25
+ result = await agent.invoke({ input, session, messages });
26
+ } finally {
27
+ delete session._onStream;
28
+ delete session._onStreamReset;
29
+ }
30
+ if (result.streamedInline) {
31
+ return streamedContent.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
32
+ }
33
+ if (result.response != null) return result.response;
34
+ if (result.readyToStream && session.llm?.stream) {
35
+ const { system, messages: streamMessages = [] } = result.streamContext ?? {};
36
+ let content = '';
37
+ for await (const delta of session.llm.stream({
38
+ system: system ?? buildAgentSystemPrompt({ input, session }),
39
+ messages: streamMessages,
40
+ signal,
41
+ })) {
42
+ content += delta;
43
+ }
44
+ return content.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
45
+ }
46
+ return buildLimitedAgentResponse({ input, session });
47
+ }
48
+
49
+ export async function runAgenticLoop(agent, session, initialInput, {
50
+ maxTurns,
51
+ timeoutMs,
52
+ signal = null,
53
+ runId = null,
54
+ runTurn = runAgentTurn,
55
+ waitForActivities,
56
+ planOrigin = 'llm',
57
+ onTurnStart = null,
58
+ onTurnResponse = null,
59
+ onAssistantMessage = null,
60
+ onPlanExtracted = null,
61
+ onPlanAlreadySet = null,
62
+ onComplete = null,
63
+ onPendingSteps = null,
64
+ onActivitiesStarted = null,
65
+ onActivitiesCompleted = null,
66
+ onMaxTurns = null,
67
+ abortMessage = 'Agent run cancelled.',
68
+ } = {}) {
69
+ if (!waitForActivities) throw new Error('runAgenticLoop requires waitForActivities.');
70
+ const conversationHistory = [];
71
+ let currentInput = initialInput;
72
+
73
+ for (let turn = 1; turn <= maxTurns; turn += 1) {
74
+ throwIfAborted(signal, abortMessage);
75
+ onTurnStart?.({ turn, maxTurns });
76
+
77
+ const snapshot = activitySnapshot(session);
78
+ const response = await runTurn(agent, session, currentInput, {
79
+ messages: conversationHistory,
80
+ signal,
81
+ });
82
+ onTurnResponse?.({ turn, response });
83
+ onAssistantMessage?.({ response, runId });
84
+
85
+ conversationHistory.push(
86
+ { role: 'user', content: currentInput },
87
+ { role: 'assistant', content: response },
88
+ );
89
+
90
+ if (turn === 1) {
91
+ if (session.headlessPlan === null) {
92
+ const extractedPlan = extractHeadlessPlan(response);
93
+ if (extractedPlan) {
94
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
95
+ origin: planOrigin,
96
+ runId,
97
+ payload: { steps: extractedPlan },
98
+ }));
99
+ onPlanExtracted?.({ steps: session.headlessPlan ?? extractedPlan, fallback: true });
100
+ }
101
+ } else {
102
+ onPlanAlreadySet?.({ steps: session.headlessPlan });
103
+ }
104
+ }
105
+
106
+ const newPending = newNonTerminalActivities(snapshot, session);
107
+ if (newPending.length === 0) {
108
+ const pendingSteps = (session.headlessPlan ?? []).filter((step) => step.status === 'pending');
109
+ if (pendingSteps.length === 0) {
110
+ onComplete?.();
111
+ return { ok: true };
112
+ }
113
+ onPendingSteps?.({ pendingSteps });
114
+ currentInput = pendingStepsPrompt(initialInput, session.headlessPlan);
115
+ continue;
116
+ }
117
+
118
+ onActivitiesStarted?.({ activities: newPending });
119
+ const waitResult = await waitForActivities(session, newPending, { timeoutMs, signal });
120
+ if (!waitResult.ok) {
121
+ return {
122
+ ok: false,
123
+ timedOut: Boolean(waitResult.timedOut),
124
+ completed: waitResult.completed ?? [],
125
+ waitResult,
126
+ };
127
+ }
128
+
129
+ const completed = waitResult.completed ?? [];
130
+ const summary = formatCompletedActivities(completed);
131
+ onActivitiesCompleted?.({ completed, summary });
132
+ currentInput = completedActivitiesPrompt(initialInput, session.headlessPlan, summary);
133
+ }
134
+
135
+ onMaxTurns?.({ maxTurns });
136
+ return { ok: false, maxTurns: true };
137
+ }
138
+
139
+ function pendingStepsPrompt(initialInput, plan) {
140
+ return [
141
+ 'Original task:',
142
+ initialInput,
143
+ '',
144
+ `Plan status:\n${formatPlanStatus(plan)}`,
145
+ '',
146
+ 'No new background activity was started in the previous turn.',
147
+ 'Continue the original plan. Start the next pending step only.',
148
+ 'If required information is missing and cannot be inferred, stop with a clear blocker.',
149
+ ].join('\n');
150
+ }
151
+
152
+ function completedActivitiesPrompt(initialInput, plan, summary) {
153
+ return [
154
+ 'Original task:',
155
+ initialInput,
156
+ '',
157
+ plan ? `Plan status:\n${formatPlanStatus(plan)}\n` : null,
158
+ 'Completed activities:',
159
+ summary,
160
+ '',
161
+ 'Continue the original plan. Start the next required step only.',
162
+ 'If all steps are complete, provide the final summary.',
163
+ ].filter(Boolean).join('\n');
164
+ }
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
4
+ import { runAgenticLoop } from './agentLoop.js';
5
+
6
+ test('runAgenticLoop waits for new activities and continues with a completion summary', async () => {
7
+ const session = {
8
+ activities: {},
9
+ headlessPlan: null,
10
+ };
11
+ const inputs = [];
12
+ const callbacks = [];
13
+ const agent = {
14
+ async invoke({ input, session: turnSession }) {
15
+ inputs.push(input);
16
+ if (inputs.length === 1) {
17
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
18
+ payload: {
19
+ activity: {
20
+ id: 'job-1',
21
+ source: 'production',
22
+ kind: 'job',
23
+ label: 'production job',
24
+ status: 'running',
25
+ terminal: false,
26
+ },
27
+ },
28
+ }));
29
+ return { response: 'Started production job.' };
30
+ }
31
+ return { response: 'All done.' };
32
+ },
33
+ };
34
+
35
+ const result = await runAgenticLoop(agent, session, 'Build workspace', {
36
+ maxTurns: 3,
37
+ timeoutMs: 1000,
38
+ waitForActivities: async (turnSession, startedActivities) => {
39
+ assert.equal(startedActivities.length, 1);
40
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
41
+ payload: {
42
+ activity: {
43
+ id: 'job-1',
44
+ source: 'production',
45
+ kind: 'job',
46
+ label: 'production job',
47
+ status: 'done',
48
+ terminal: true,
49
+ },
50
+ },
51
+ }));
52
+ if (turnSession.headlessPlan?.[0]) turnSession.headlessPlan[0].status = 'done';
53
+ return { ok: true, completed: Object.values(turnSession.activities) };
54
+ },
55
+ onActivitiesStarted: ({ activities }) => callbacks.push(`started:${activities.length}`),
56
+ onActivitiesCompleted: ({ summary }) => callbacks.push(summary),
57
+ });
58
+
59
+ assert.equal(result.ok, true);
60
+ assert.equal(inputs.length, 2);
61
+ assert.match(inputs[1], /Completed activities:/);
62
+ assert.match(inputs[1], /production job-1: done/);
63
+ assert.deepEqual(callbacks, ['started:1', '- production job-1: done']);
64
+ });
65
+
66
+ test('runAgenticLoop extracts a fallback numbered plan from first response', async () => {
67
+ const session = {
68
+ activities: {},
69
+ headlessPlan: null,
70
+ };
71
+ const result = await runAgenticLoop({
72
+ async invoke() {
73
+ return { response: '1. Collect sources\n2. Build page' };
74
+ },
75
+ }, session, 'Plan task', {
76
+ maxTurns: 1,
77
+ timeoutMs: 1000,
78
+ waitForActivities: async () => assert.fail('No activities should be waited for.'),
79
+ });
80
+
81
+ assert.equal(result.ok, false);
82
+ assert.equal(result.maxTurns, true);
83
+ assert.equal(session.headlessPlan.length, 2);
84
+ assert.equal(session.headlessPlan[0].description, 'Collect sources');
85
+ });
@@ -40,8 +40,7 @@ function composeOverrideContent(cacertPath, services) {
40
40
  services: serviceEntries,
41
41
  };
42
42
  return [
43
- '# Generated by wiki-manager. Safe to delete.',
44
- '# Rewritten when --cacert is used with a Docker Compose command.',
43
+ '# Generated by wiki-manager. Edit freely; delete to regenerate.',
45
44
  YAML.stringify(doc).trimEnd(),
46
45
  '',
47
46
  ].join('\n');
@@ -61,6 +60,8 @@ export function ensureCacertComposeOverride(composeFilePath, fileName = 'cacert.
61
60
  const runtimeDir = managerRuntimeDir();
62
61
  mkdirSync(runtimeDir, { recursive: true });
63
62
  const overridePath = join(runtimeDir, fileName);
64
- writeFileSync(overridePath, composeOverrideContent(cacertPath, services), 'utf8');
63
+ if (!existsSync(overridePath)) {
64
+ writeFileSync(overridePath, composeOverrideContent(cacertPath, services), 'utf8');
65
+ }
65
66
  return (_cacertOverrideCache = overridePath);
66
67
  }
@@ -9,7 +9,7 @@ import { managerRoot } from './workspaces.js';
9
9
 
10
10
  const execFileAsync = promisify(execFile);
11
11
 
12
- export const COMPOSE_SERVICES = ['serve', 'mcp-http', 'production-mcp'];
12
+ export const COMPOSE_SERVICES = ['serve', 'mcp-http', 'agent-runtime', 'production-mcp'];
13
13
  const SERVICE_DESCRIPTION_LABEL = 'wiki-manager.description';
14
14
 
15
15
  const DEFAULT_SERVICE_ALIASES = {
@@ -17,6 +17,7 @@ const DEFAULT_SERVICE_ALIASES = {
17
17
  ui: ['serve'],
18
18
  wiki: ['mcp-http'],
19
19
  mcp: ['mcp-http'],
20
+ runtime: ['agent-runtime'],
20
21
  production: ['production-mcp'],
21
22
  };
22
23
 
@@ -0,0 +1,14 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import YAML from 'yaml';
5
+
6
+ test('agent-runtime compose command passes runtime args to the image entrypoint', async () => {
7
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
8
+ const compose = YAML.parse(raw);
9
+ const service = compose.services['agent-runtime'];
10
+
11
+ assert.equal(service.image, 'dotdrelle/llm-wiki-manager:latest');
12
+ assert.equal(service.command, 'runtime --host 0.0.0.0 --port 7788 --state-dir /state');
13
+ assert.ok(!service.command.startsWith('wiki-manager '));
14
+ });
@@ -16,16 +16,16 @@ test('document intake stores uploads without requiring documents MCP', async ()
16
16
  const source = path.join(root, 'rapport.pdf');
17
17
  await writeFile(source, 'fake pdf content');
18
18
  const session = {
19
- workspace: 'juno',
19
+ workspace: 'my-project',
20
20
  mcp: {},
21
21
  };
22
22
 
23
23
  try {
24
24
  const { record, converted } = await storeAndMaybeConvertDocument(session, source);
25
25
  assert.equal(converted, false);
26
- assert.equal(record.workspace, 'juno');
26
+ assert.equal(record.workspace, 'my-project');
27
27
  assert.equal(record.status, 'stored');
28
- assert.equal(record.agentPath.startsWith('/documents/input/juno/'), true);
28
+ assert.equal(record.agentPath.startsWith('/documents/input/my-project/'), true);
29
29
  assert.equal(await readFile(record.storedPath, 'utf8'), 'fake pdf content');
30
30
 
31
31
  const uploads = await listDocumentUploads(session);
@@ -45,7 +45,7 @@ test('document intake accepts quoted absolute paths with spaces', async () => {
45
45
  const source = path.join(root, 'Screenshot 2026-06-21 at 10.03.36.png');
46
46
  await writeFile(source, 'fake png content');
47
47
  const session = {
48
- workspace: 'juno',
48
+ workspace: 'my-project',
49
49
  mcp: {},
50
50
  };
51
51
 
@@ -66,7 +66,7 @@ test('document intake accepts double-quoted absolute paths with spaces', async (
66
66
  const source = path.join(root, 'scan avec espace.pdf');
67
67
  await writeFile(source, 'fake pdf content');
68
68
  const session = {
69
- workspace: 'juno',
69
+ workspace: 'my-project',
70
70
  mcp: {},
71
71
  };
72
72
 
@@ -87,7 +87,7 @@ test('document intake replaces an existing upload with the same original filenam
87
87
  process.env.AGENTS_DATA_DIR = agentsDataDir;
88
88
  const source = path.join(root, 'rapport.pdf');
89
89
  const session = {
90
- workspace: 'juno',
90
+ workspace: 'my-project',
91
91
  mcp: {},
92
92
  };
93
93
 
@@ -98,7 +98,7 @@ test('document intake replaces an existing upload with the same original filenam
98
98
  await mkdir(path.dirname(outputPath), { recursive: true });
99
99
  await writeFile(outputPath, 'old markdown');
100
100
 
101
- const manifest = path.join(agentsDataDir, 'documents', 'uploads', 'juno.jsonl');
101
+ const manifest = path.join(agentsDataDir, 'documents', 'uploads', 'my-project.jsonl');
102
102
  const firstRecord = JSON.parse(await readFile(manifest, 'utf8'));
103
103
  await writeFile(manifest, `${JSON.stringify({ ...firstRecord, outputPath })}\n`, 'utf8');
104
104
 
package/src/core/env.js CHANGED
@@ -15,6 +15,12 @@ export function managerRuntimeDir() {
15
15
  return join(managerStateDir(), '.wiki-manager');
16
16
  }
17
17
 
18
+ export function defaultRuntimeStateDir() {
19
+ return process.env.WIKI_MANAGER_STATE_DIR
20
+ ? resolve(process.env.WIKI_MANAGER_STATE_DIR)
21
+ : managerRuntimeDir();
22
+ }
23
+
18
24
  export function managerEnvFile() {
19
25
  return process.env.WIKI_MANAGER_ENV_FILE
20
26
  ? resolve(process.env.WIKI_MANAGER_ENV_FILE)
@@ -1,6 +1,7 @@
1
1
  import { extractActivity, parseJsonText, sessionActivities } from './activity.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
3
  import { callMcpTool, formatMcpToolResult } from './mcp.js';
4
+ import { queueStoreFor } from './queueStore.js';
4
5
 
5
6
  const TERMINAL = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error']);
6
7
 
@@ -8,6 +9,10 @@ function now() {
8
9
  return new Date().toISOString();
9
10
  }
10
11
 
12
+ function notifyQueueUpdate(session) {
13
+ queueStoreFor(session).changed();
14
+ }
15
+
11
16
  function shortId() {
12
17
  return `q-${Math.random().toString(16).slice(2, 6)}`;
13
18
  }
@@ -17,8 +22,7 @@ function terminalStatus(status) {
17
22
  }
18
23
 
19
24
  export function ensureJobQueue(session) {
20
- session.jobQueue ??= [];
21
- return session.jobQueue;
25
+ return queueStoreFor(session).list();
22
26
  }
23
27
 
24
28
  export function productionLockBusy(session) {
@@ -41,6 +45,7 @@ export function enqueueProductionJob(session, args = {}, reason = 'waiting') {
41
45
  createdAt: now(),
42
46
  };
43
47
  ensureJobQueue(session).push(item);
48
+ notifyQueueUpdate(session);
44
49
  return item;
45
50
  }
46
51
 
@@ -85,10 +90,11 @@ export function formatQueue(session) {
85
90
  export function clearFinishedQueueItems(session) {
86
91
  const queue = ensureJobQueue(session);
87
92
  const before = queue.length;
88
- session.jobQueue = queue.filter((item) =>
93
+ const next = queue.filter((item) =>
89
94
  item.workspace !== session.workspace || !terminalStatus(item.status),
90
95
  );
91
- return before - session.jobQueue.length;
96
+ queueStoreFor(session).replace(next);
97
+ return before - next.length;
92
98
  }
93
99
 
94
100
  function findQueueItem(session, id) {
@@ -102,6 +108,7 @@ export async function cancelQueueItem(session, id) {
102
108
  const label = item.status === 'starting' ? 'starting' : 'queued';
103
109
  item.status = 'cancelled';
104
110
  item.finishedAt = now();
111
+ notifyQueueUpdate(session);
105
112
  return { ok: true, message: `Cancelled ${label} job ${id}.` };
106
113
  }
107
114
  if (item.status === 'running') {
@@ -109,6 +116,7 @@ export async function cancelQueueItem(session, id) {
109
116
  await callMcpTool(session.mcp, 'production', 'production_cancel_job', { jobId: item.jobId });
110
117
  item.status = 'cancelled';
111
118
  item.finishedAt = now();
119
+ notifyQueueUpdate(session);
112
120
  return { ok: true, message: `Cancellation requested for ${id} (${item.jobId}).` };
113
121
  }
114
122
  return { ok: false, message: `No cancel tool available for ${id}.` };
@@ -140,6 +148,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
140
148
 
141
149
  item.status = 'starting';
142
150
  item.startedAt = now();
151
+ notifyQueueUpdate(session);
143
152
  hooks.refresh?.();
144
153
  hooks.addLog?.(`queue: starting ${item.id} ${queueSummary(item.args)}`);
145
154
 
@@ -154,6 +163,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
154
163
  if (payload?.ok === false && payload?.error === 'workspace_busy') {
155
164
  item.status = 'waiting';
156
165
  item.reason = 'workspace_busy';
166
+ notifyQueueUpdate(session);
157
167
  hooks.addLog?.(`queue: ${item.id} still waiting, production lock busy`);
158
168
  hooks.refresh?.();
159
169
  return item;
@@ -164,6 +174,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
164
174
  item.jobId = activity.id;
165
175
  item.activityKey = activity.key;
166
176
  item.finishedAt = activity.terminal ? now() : undefined;
177
+ notifyQueueUpdate(session);
167
178
  dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
168
179
  origin: 'queue',
169
180
  payload: { activity },
@@ -171,6 +182,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
171
182
  } else {
172
183
  item.status = 'done';
173
184
  item.finishedAt = now();
185
+ notifyQueueUpdate(session);
174
186
  }
175
187
  hooks.addLog?.(`queue: ${item.id} ${item.status}${item.jobId ? ` job=${item.jobId}` : ''}`);
176
188
  hooks.refresh?.();
@@ -179,6 +191,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
179
191
  item.status = 'failed';
180
192
  item.error = err instanceof Error ? err.message : String(err);
181
193
  item.finishedAt = now();
194
+ notifyQueueUpdate(session);
182
195
  hooks.addLog?.(`queue: ${item.id} failed · ${item.error}`);
183
196
  hooks.refresh?.();
184
197
  return item;
@@ -193,5 +206,6 @@ export function syncQueueWithActivity(session, activity) {
193
206
  item.activityKey = activity.key;
194
207
  item.finishedAt = activity.terminal ? now() : item.finishedAt;
195
208
  item.error = activity.error ?? item.error;
209
+ notifyQueueUpdate(session);
196
210
  return item;
197
211
  }
package/src/core/mcp.js CHANGED
@@ -211,7 +211,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
211
211
  params: {
212
212
  protocolVersion: '2025-06-18',
213
213
  capabilities: {},
214
- clientInfo: { name: 'wiki-manager', version: '0.6.47' },
214
+ clientInfo: { name: 'wiki-manager', version: '0.7.3' },
215
215
  },
216
216
  }),
217
217
  });
@@ -0,0 +1,27 @@
1
+ export function createQueueStore(session, { persist = () => {} } = {}) {
2
+ session.jobQueue ??= [];
3
+ return {
4
+ list() {
5
+ session.jobQueue ??= [];
6
+ return session.jobQueue;
7
+ },
8
+ replace(queue) {
9
+ session.jobQueue = Array.isArray(queue) ? queue : [];
10
+ persist();
11
+ return session.jobQueue;
12
+ },
13
+ changed() {
14
+ session.jobQueue ??= [];
15
+ persist();
16
+ },
17
+ };
18
+ }
19
+
20
+ export function createMemoryQueueStore(session) {
21
+ return createQueueStore(session, { persist: () => session._onQueueUpdate?.(session.jobQueue) });
22
+ }
23
+
24
+ export function queueStoreFor(session) {
25
+ session.queueStore ??= createMemoryQueueStore(session);
26
+ return session.queueStore;
27
+ }
@@ -0,0 +1,31 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { enqueueProductionJob, ensureJobQueue } from './jobQueue.js';
4
+
5
+ test('job queue uses an injected queue store', () => {
6
+ const queue = [];
7
+ let changed = 0;
8
+ const session = {
9
+ workspace: 'docs',
10
+ queueStore: {
11
+ list() {
12
+ return queue;
13
+ },
14
+ replace(next) {
15
+ queue.splice(0, queue.length, ...next);
16
+ changed += 1;
17
+ return queue;
18
+ },
19
+ changed() {
20
+ changed += 1;
21
+ },
22
+ },
23
+ };
24
+
25
+ const item = enqueueProductionJob(session, { type: 'build' }, 'workspace_busy');
26
+
27
+ assert.equal(item.workspace, 'docs');
28
+ assert.equal(queue.length, 1);
29
+ assert.equal(ensureJobQueue(session), queue);
30
+ assert.equal(changed, 1);
31
+ });
@@ -33,12 +33,19 @@ export function listWorkspaces() {
33
33
  if (!existsSync(envFile)) return [];
34
34
  const env = readEnvFile(envFile);
35
35
  const workspacePath = env.WIKI_WORKSPACE_PATH || registryPath;
36
+ let resolvedWorkspacePath;
37
+ try {
38
+ resolvedWorkspacePath = realpathSync.native?.(workspacePath) ?? realpathSync(workspacePath);
39
+ } catch {
40
+ // Host-absolute WIKI_WORKSPACE_PATH not accessible here (e.g. inside a container); use registry dir.
41
+ resolvedWorkspacePath = registryPath;
42
+ }
36
43
  return [
37
44
  {
38
45
  name: env.WORKSPACE_NAME || entry.name,
39
46
  registryPath,
40
47
  envFile,
41
- workspacePath: realpathSync.native?.(workspacePath) ?? realpathSync(workspacePath),
48
+ workspacePath: resolvedWorkspacePath,
42
49
  env,
43
50
  },
44
51
  ];
@@ -0,0 +1,35 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { defaultRuntimeStateDir } from '../core/env.js';
5
+
6
+ export function runtimeTokenPath(stateDir = defaultRuntimeStateDir()) {
7
+ return join(resolve(stateDir), 'runtime.token');
8
+ }
9
+
10
+ export function runtimeTokenFromEnv() {
11
+ return process.env.WIKI_MANAGER_RUNTIME_TOKEN ?? process.env.RUNTIME_AUTH_TOKEN ?? null;
12
+ }
13
+
14
+ export function resolveRuntimeAuthToken({
15
+ host = '127.0.0.1',
16
+ stateDir = defaultRuntimeStateDir(),
17
+ explicitToken = runtimeTokenFromEnv(),
18
+ } = {}) {
19
+ if (explicitToken) return { token: explicitToken, source: 'env', tokenPath: null };
20
+ if (!isExposedHost(host)) return { token: null, source: 'none', tokenPath: null };
21
+
22
+ const tokenPath = runtimeTokenPath(stateDir);
23
+ if (existsSync(tokenPath)) {
24
+ const token = readFileSync(tokenPath, 'utf8').trim();
25
+ if (token) return { token, source: 'file', tokenPath };
26
+ }
27
+ const token = randomBytes(32).toString('hex');
28
+ mkdirSync(resolve(stateDir), { recursive: true });
29
+ writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
30
+ return { token, source: 'generated', tokenPath };
31
+ }
32
+
33
+ export function isExposedHost(host) {
34
+ return ['0.0.0.0', '::', '[::]'].includes(String(host ?? '').trim());
35
+ }
@@ -0,0 +1,21 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import { resolveRuntimeAuthToken } from './auth.js';
7
+
8
+ test('resolveRuntimeAuthToken: loopback host does not require token', () => {
9
+ const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
10
+ assert.equal(result.token, null);
11
+ assert.equal(result.source, 'none');
12
+ });
13
+
14
+ test('resolveRuntimeAuthToken: exposed host generates and reuses token', () => {
15
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-auth-'));
16
+ const first = resolveRuntimeAuthToken({ host: '0.0.0.0', stateDir, explicitToken: null });
17
+ const second = resolveRuntimeAuthToken({ host: '0.0.0.0', stateDir, explicitToken: null });
18
+ assert.equal(first.source, 'generated');
19
+ assert.equal(second.source, 'file');
20
+ assert.equal(second.token, first.token);
21
+ });