@phren/cli 0.0.28 → 0.0.33
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/mcp/dist/capabilities/cli.js +2 -5
- package/mcp/dist/capabilities/mcp.js +5 -8
- package/mcp/dist/capabilities/types.js +2 -5
- package/mcp/dist/capabilities/vscode.js +2 -5
- package/mcp/dist/capabilities/web-ui.js +2 -5
- package/mcp/dist/{cli-actions.js → cli/actions.js} +25 -21
- package/mcp/dist/{cli.js → cli/cli.js} +13 -13
- package/mcp/dist/{cli-config.js → cli/config.js} +12 -12
- package/mcp/dist/{cli-extract.js → cli/extract.js} +8 -8
- package/mcp/dist/{cli-govern.js → cli/govern.js} +28 -17
- package/mcp/dist/{cli-graph.js → cli/graph.js} +10 -9
- package/mcp/dist/{cli-hooks-citations.js → cli/hooks-citations.js} +2 -2
- package/mcp/dist/{cli-hooks-context.js → cli/hooks-context.js} +23 -23
- package/mcp/dist/{cli-hooks-globs.js → cli/hooks-globs.js} +4 -4
- package/mcp/dist/{cli-hooks-output.js → cli/hooks-output.js} +9 -10
- package/mcp/dist/{cli-hooks-session.js → cli/hooks-session.js} +58 -117
- package/mcp/dist/{cli-hooks.js → cli/hooks.js} +27 -26
- package/mcp/dist/{cli-namespaces.js → cli/namespaces.js} +25 -24
- package/mcp/dist/{cli-ops.js → cli/ops.js} +9 -9
- package/mcp/dist/{cli-search.js → cli/search.js} +12 -11
- package/mcp/dist/cli-hooks-git.js +243 -0
- package/mcp/dist/cli-hooks-prompt.js +323 -0
- package/mcp/dist/cli-hooks-session-handlers.js +337 -0
- package/mcp/dist/cli-hooks-stop.js +519 -0
- package/mcp/dist/{content-archive.js → content/archive.js} +16 -29
- package/mcp/dist/{content-citation.js → content/citation.js} +5 -5
- package/mcp/dist/{content-dedup.js → content/dedup.js} +9 -12
- package/mcp/dist/{content-learning.js → content/learning.js} +41 -20
- package/mcp/dist/{content-validate.js → content/validate.js} +5 -5
- package/mcp/dist/{core-finding.js → core/finding.js} +4 -4
- package/mcp/dist/{core-project.js → core/project.js} +4 -4
- package/mcp/dist/{core-search.js → core/search.js} +2 -2
- package/mcp/dist/{data-access.js → data/access.js} +142 -15
- package/mcp/dist/{data-tasks.js → data/tasks.js} +7 -5
- package/mcp/dist/embedding.js +9 -14
- package/mcp/dist/entrypoint.js +11 -11
- package/mcp/dist/{finding-context.js → finding/context.js} +2 -2
- package/mcp/dist/{finding-impact.js → finding/impact.js} +3 -3
- package/mcp/dist/{finding-journal.js → finding/journal.js} +4 -4
- package/mcp/dist/{finding-lifecycle.js → finding/lifecycle.js} +13 -7
- package/mcp/dist/governance/audit.js +30 -0
- package/mcp/dist/{governance-locks.js → governance/locks.js} +14 -9
- package/mcp/dist/{governance-policy.js → governance/policy.js} +23 -12
- package/mcp/dist/{governance-rbac.js → governance/rbac.js} +4 -4
- package/mcp/dist/{governance-scores.js → governance/scores.js} +10 -11
- package/mcp/dist/hooks.js +53 -37
- package/mcp/dist/index-query.js +4 -1
- package/mcp/dist/index.js +54 -30
- package/mcp/dist/{init-config.js → init/config.js} +6 -6
- package/mcp/dist/{init.js → init/init.js} +80 -69
- package/mcp/dist/{init-preferences.js → init/preferences.js} +3 -3
- package/mcp/dist/{init-setup.js → init/setup.js} +17 -19
- package/mcp/dist/{init-shared.js → init/shared.js} +4 -4
- package/mcp/dist/init-bootstrap.js +21 -0
- package/mcp/dist/init-detect.js +38 -0
- package/mcp/dist/init-env.js +114 -0
- package/mcp/dist/init-fresh.js +234 -0
- package/mcp/dist/init-hooks.js +26 -0
- package/mcp/dist/init-mcp.js +65 -0
- package/mcp/dist/init-modes.js +135 -0
- package/mcp/dist/init-npm.js +37 -0
- package/mcp/dist/init-project-local.js +99 -0
- package/mcp/dist/init-semantic.js +48 -0
- package/mcp/dist/init-types.js +1 -0
- package/mcp/dist/init-uninstall.js +504 -0
- package/mcp/dist/init-update.js +96 -0
- package/mcp/dist/init-walkthrough.js +524 -0
- package/mcp/dist/{link-checksums.js → link/checksums.js} +5 -5
- package/mcp/dist/{link-context.js → link/context.js} +4 -4
- package/mcp/dist/{link-doctor.js → link/doctor.js} +20 -22
- package/mcp/dist/{link.js → link/link.js} +26 -31
- package/mcp/dist/{link-skills.js → link/skills.js} +10 -10
- package/mcp/dist/logger.js +11 -3
- package/mcp/dist/package-metadata.js +1 -1
- package/mcp/dist/phren-art.js +4 -126
- package/mcp/dist/phren-paths.js +30 -12
- package/mcp/dist/proactivity.js +3 -3
- package/mcp/dist/profile-store.js +5 -6
- package/mcp/dist/project-config.js +2 -2
- package/mcp/dist/project-topics.js +17 -47
- package/mcp/dist/provider-adapters.js +1 -1
- package/mcp/dist/query-correlation.js +1 -1
- package/mcp/dist/runtime-profile.js +1 -1
- package/mcp/dist/{session-checkpoints.js → session/checkpoints.js} +3 -3
- package/mcp/dist/{session-utils.js → session/utils.js} +1 -1
- package/mcp/dist/{shared-content.js → shared/content.js} +7 -7
- package/mcp/dist/{shared-data-utils.js → shared/data-utils.js} +28 -3
- package/mcp/dist/{shared-embedding-cache.js → shared/embedding-cache.js} +3 -3
- package/mcp/dist/{shared-fragment-graph.js → shared/fragment-graph.js} +19 -42
- package/mcp/dist/shared/governance.js +4 -0
- package/mcp/dist/{shared-index.js → shared/index.js} +105 -132
- package/mcp/dist/{shared-ollama.js → shared/ollama.js} +25 -7
- package/mcp/dist/shared/process.js +24 -0
- package/mcp/dist/{shared-retrieval.js → shared/retrieval.js} +22 -24
- package/mcp/dist/{shared-search-fallback.js → shared/search-fallback.js} +18 -20
- package/mcp/dist/{shared-sqljs.js → shared/sqljs.js} +3 -3
- package/mcp/dist/{shared-vector-index.js → shared/vector-index.js} +3 -3
- package/mcp/dist/shared.js +6 -60
- package/mcp/dist/{shell-entry.js → shell/entry.js} +6 -6
- package/mcp/dist/{shell-input.js → shell/input.js} +13 -13
- package/mcp/dist/{shell-palette.js → shell/palette.js} +3 -3
- package/mcp/dist/{shell-render.js → shell/render.js} +2 -2
- package/mcp/dist/{shell.js → shell/shell.js} +11 -11
- package/mcp/dist/{shell-state-store.js → shell/state-store.js} +5 -5
- package/mcp/dist/{shell-view-list.js → shell/view-list.js} +1 -1
- package/mcp/dist/{shell-view.js → shell/view.js} +13 -13
- package/mcp/dist/{skill-files.js → skill/files.js} +9 -9
- package/mcp/dist/{skill-registry.js → skill/registry.js} +5 -5
- package/mcp/dist/{skill-state.js → skill/state.js} +1 -4
- package/mcp/dist/startup-embedding.js +2 -2
- package/mcp/dist/status.js +15 -14
- package/mcp/dist/{tasks-github.js → task/github.js} +3 -2
- package/mcp/dist/{task-hygiene.js → task/hygiene.js} +4 -4
- package/mcp/dist/{task-lifecycle.js → task/lifecycle.js} +8 -13
- package/mcp/dist/telemetry.js +3 -4
- package/mcp/dist/tool-registry.js +29 -17
- package/mcp/dist/tools/config.js +530 -0
- package/mcp/dist/{mcp-data.js → tools/data.js} +8 -10
- package/mcp/dist/{mcp-extract-facts.js → tools/extract-facts.js} +6 -6
- package/mcp/dist/{mcp-extract.js → tools/extract.js} +6 -6
- package/mcp/dist/tools/finding.js +584 -0
- package/mcp/dist/{mcp-graph.js → tools/graph.js} +11 -14
- package/mcp/dist/{mcp-hooks.js → tools/hooks.js} +6 -6
- package/mcp/dist/{mcp-memory.js → tools/memory.js} +5 -5
- package/mcp/dist/tools/ops.js +468 -0
- package/mcp/dist/tools/search.js +672 -0
- package/mcp/dist/{mcp-session.js → tools/session.js} +51 -25
- package/mcp/dist/{mcp-skills.js → tools/skills.js} +42 -35
- package/mcp/dist/{mcp-tasks.js → tools/tasks.js} +155 -282
- package/mcp/dist/{memory-ui-data.js → ui/data.js} +31 -17
- package/mcp/dist/{memory-ui.js → ui/memory-ui.js} +3 -3
- package/mcp/dist/{memory-ui-page.js → ui/page.js} +5 -7
- package/mcp/dist/ui/server.js +1024 -0
- package/mcp/dist/update.js +2 -2
- package/mcp/dist/utils.js +63 -19
- package/package.json +2 -2
- package/scripts/preuninstall.mjs +31 -0
- package/starter/global/CLAUDE.md +3 -2
- package/mcp/dist/governance-audit.js +0 -22
- package/mcp/dist/mcp-config.js +0 -551
- package/mcp/dist/mcp-finding.js +0 -594
- package/mcp/dist/mcp-ops.js +0 -363
- package/mcp/dist/mcp-search.js +0 -668
- package/mcp/dist/memory-ui-server.js +0 -1411
- package/mcp/dist/shared-governance.js +0 -4
- /package/mcp/dist/{content-metadata.js → content/metadata.js} +0 -0
- /package/mcp/dist/{shared-stemmer.js → shared/stemmer.js} +0 -0
- /package/mcp/dist/{shell-types.js → shell/types.js} +0 -0
- /package/mcp/dist/{mcp-types.js → tools/types.js} +0 -0
- /package/mcp/dist/{memory-ui-assets.js → ui/assets.js} +0 -0
- /package/mcp/dist/{memory-ui-graph.js → ui/graph.js} +0 -0
- /package/mcp/dist/{memory-ui-scripts.js → ui/scripts.js} +0 -0
- /package/mcp/dist/{memory-ui-styles.js → ui/styles.js} +0 -0
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import { timingSafeEqual } from "crypto";
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import * as querystring from "querystring";
|
|
7
|
+
import { spawn, execFileSync } from "child_process";
|
|
8
|
+
import { computePhrenLiveStateToken, getProjectDirs, } from "../shared.js";
|
|
9
|
+
import { editFinding, readReviewQueue, removeFinding, readFindings, addFinding as addFindingStore, readTasksAcrossProjects, addTask as addTaskStore, completeTask as completeTaskStore, removeTask as removeTaskStore, updateTask as updateTaskStore, TASKS_FILENAME, } from "../data/access.js";
|
|
10
|
+
import { isValidProjectName, errorMessage, queueFilePath, safeProjectPath } from "../utils.js";
|
|
11
|
+
import { readInstallPreferences, writeInstallPreferences, writeGovernanceInstallPreferences } from "../init/preferences.js";
|
|
12
|
+
import { buildGraph, collectProjectsForUI, collectSkillsForUI, getHooksData, isAllowedSkillPath, readSyncSnapshot, recentAccepted, recentUsage, } from "./data.js";
|
|
13
|
+
import { CONSOLIDATION_ENTRY_THRESHOLD } from "../content/validate.js";
|
|
14
|
+
import { ensureTopicReferenceDoc, getProjectTopicsResponse, listProjectReferenceDocs, pinProjectTopicSuggestion, readReferenceContent, reclassifyLegacyTopicDocs, unpinProjectTopicSuggestion, writeProjectTopics, } from "../project-topics.js";
|
|
15
|
+
import { getWorkflowPolicy, updateWorkflowPolicy, mergeConfig, getRetentionPolicy, getProjectConfigOverrides, VALID_TASK_MODES } from "../governance/policy.js";
|
|
16
|
+
import { readProjectConfig, updateProjectConfigOverrides } from "../project-config.js";
|
|
17
|
+
import { findSkill } from "../skill/registry.js";
|
|
18
|
+
import { setSkillEnabledAndSync } from "../skill/files.js";
|
|
19
|
+
import { repairPreexistingInstall } from "../init/setup.js";
|
|
20
|
+
import { logger } from "../logger.js";
|
|
21
|
+
const CSRF_TOKEN_TTL_MS = 15 * 60 * 1000;
|
|
22
|
+
const MAX_FORM_BODY_BYTES = 1_048_576;
|
|
23
|
+
const WEB_UI_READY_ATTEMPTS = 12;
|
|
24
|
+
const WEB_UI_READY_DELAY_MS = 75;
|
|
25
|
+
const WEB_UI_PORT_RETRY_ATTEMPTS = 3;
|
|
26
|
+
export function getWebUiBrowserCommand(url, platform = process.platform) {
|
|
27
|
+
if (platform === "darwin")
|
|
28
|
+
return { command: "open", args: [url] };
|
|
29
|
+
if (platform === "win32")
|
|
30
|
+
return { command: process.env.ComSpec || "cmd.exe", args: ["/c", "start", "", url] };
|
|
31
|
+
return { command: "xdg-open", args: [url] };
|
|
32
|
+
}
|
|
33
|
+
async function launchWebUiBrowser(url) {
|
|
34
|
+
const { command, args } = getWebUiBrowserCommand(url);
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
try {
|
|
37
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
38
|
+
child.once("error", reject);
|
|
39
|
+
child.once("spawn", () => {
|
|
40
|
+
child.removeListener("error", reject);
|
|
41
|
+
child.unref();
|
|
42
|
+
resolve();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
reject(err);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export async function waitForWebUiReady(url, attempts = WEB_UI_READY_ATTEMPTS, delayMs = WEB_UI_READY_DELAY_MS) {
|
|
51
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
52
|
+
const ready = await new Promise((resolve) => {
|
|
53
|
+
const req = http.get(url, (res) => {
|
|
54
|
+
res.resume();
|
|
55
|
+
resolve(true);
|
|
56
|
+
});
|
|
57
|
+
req.setTimeout(1000, () => {
|
|
58
|
+
req.destroy();
|
|
59
|
+
resolve(false);
|
|
60
|
+
});
|
|
61
|
+
req.on("error", () => resolve(false));
|
|
62
|
+
});
|
|
63
|
+
if (ready)
|
|
64
|
+
return true;
|
|
65
|
+
if (attempt < attempts - 1) {
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
function isAddressInUse(err) {
|
|
72
|
+
return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
|
|
73
|
+
}
|
|
74
|
+
async function listenOnLoopback(server, port) {
|
|
75
|
+
return await new Promise((resolve, reject) => {
|
|
76
|
+
const onError = (err) => {
|
|
77
|
+
server.off("listening", onListening);
|
|
78
|
+
reject(err);
|
|
79
|
+
};
|
|
80
|
+
const onListening = () => {
|
|
81
|
+
server.off("error", onError);
|
|
82
|
+
const address = server.address();
|
|
83
|
+
if (!address || typeof address === "string") {
|
|
84
|
+
reject(new Error("failed to determine web-ui port"));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
resolve(address.port);
|
|
88
|
+
};
|
|
89
|
+
server.once("error", onError);
|
|
90
|
+
server.once("listening", onListening);
|
|
91
|
+
server.listen(port, "127.0.0.1");
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async function bindWebUiPort(server, requestedPort, allowPortFallback) {
|
|
95
|
+
const candidates = [requestedPort];
|
|
96
|
+
if (allowPortFallback && requestedPort > 0) {
|
|
97
|
+
for (let i = 1; i <= WEB_UI_PORT_RETRY_ATTEMPTS; i++) {
|
|
98
|
+
candidates.push(requestedPort + i);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let lastError = null;
|
|
102
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
103
|
+
const candidate = candidates[i];
|
|
104
|
+
try {
|
|
105
|
+
if (candidate !== requestedPort) {
|
|
106
|
+
logger.info("web-ui", `port ${candidate - 1} is busy, retrying on ${candidate}`);
|
|
107
|
+
}
|
|
108
|
+
return await listenOnLoopback(server, candidate);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
lastError = err;
|
|
112
|
+
if (!allowPortFallback || !isAddressInUse(err) || i === candidates.length - 1)
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw lastError instanceof Error ? lastError : new Error("failed to bind web-ui server");
|
|
117
|
+
}
|
|
118
|
+
function pruneExpiredCsrfTokens(csrfTokens) {
|
|
119
|
+
if (!csrfTokens)
|
|
120
|
+
return;
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
for (const [token, createdAt] of csrfTokens) {
|
|
123
|
+
if (now - createdAt > CSRF_TOKEN_TTL_MS)
|
|
124
|
+
csrfTokens.delete(token);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function setCommonHeaders(res, nonce) {
|
|
128
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
129
|
+
if (nonce) {
|
|
130
|
+
// Page responses: allow nonce-gated inline scripts but disallow inline event handlers
|
|
131
|
+
res.setHeader("Content-Security-Policy", `default-src 'self'; script-src 'self' 'nonce-${nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.bunny.net; font-src https://fonts.bunny.net; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'`);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// API responses: no inline scripts needed
|
|
135
|
+
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'");
|
|
136
|
+
}
|
|
137
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
138
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
139
|
+
}
|
|
140
|
+
function getSubmittedAuthToken(req, url, parsedBody) {
|
|
141
|
+
const authHeader = req.headers.authorization;
|
|
142
|
+
if (typeof authHeader === "string") {
|
|
143
|
+
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
144
|
+
if (bearerMatch)
|
|
145
|
+
return bearerMatch[1];
|
|
146
|
+
}
|
|
147
|
+
const query = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
|
|
148
|
+
const queryAuth = query._auth;
|
|
149
|
+
if (typeof queryAuth === "string")
|
|
150
|
+
return queryAuth;
|
|
151
|
+
const bodyAuth = parsedBody?._auth;
|
|
152
|
+
if (typeof bodyAuth === "string")
|
|
153
|
+
return bodyAuth;
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
function authTokensMatch(submitted, authToken) {
|
|
157
|
+
if (!authToken || !submitted)
|
|
158
|
+
return false;
|
|
159
|
+
const submittedBuffer = Buffer.from(submitted);
|
|
160
|
+
const authTokenBuffer = Buffer.from(authToken);
|
|
161
|
+
if (submittedBuffer.length !== authTokenBuffer.length)
|
|
162
|
+
return false;
|
|
163
|
+
return timingSafeEqual(submittedBuffer, authTokenBuffer);
|
|
164
|
+
}
|
|
165
|
+
function rejectUnauthorized(res, json = false) {
|
|
166
|
+
if (json) {
|
|
167
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
168
|
+
res.end(JSON.stringify({ ok: false, error: "Unauthorized" }));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
172
|
+
res.end("Unauthorized");
|
|
173
|
+
}
|
|
174
|
+
function requireGetAuth(req, res, url, authToken, json = false) {
|
|
175
|
+
if (!authToken)
|
|
176
|
+
return true;
|
|
177
|
+
const submitted = getSubmittedAuthToken(req, url);
|
|
178
|
+
if (authTokensMatch(submitted, authToken))
|
|
179
|
+
return true;
|
|
180
|
+
rejectUnauthorized(res, json);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
function readFormBody(req, res) {
|
|
184
|
+
const contentLength = parseInt(req.headers["content-length"] || "0", 10);
|
|
185
|
+
if (contentLength > MAX_FORM_BODY_BYTES) {
|
|
186
|
+
res.writeHead(413, { "content-type": "text/plain" });
|
|
187
|
+
res.end("Request body too large");
|
|
188
|
+
return Promise.resolve(null);
|
|
189
|
+
}
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
let body = "";
|
|
192
|
+
let received = 0;
|
|
193
|
+
req.on("data", (chunk) => {
|
|
194
|
+
received += chunk.length;
|
|
195
|
+
if (received > MAX_FORM_BODY_BYTES) {
|
|
196
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
197
|
+
res.end(JSON.stringify({ ok: false, error: "Request body too large" }));
|
|
198
|
+
req.destroy();
|
|
199
|
+
resolve(null);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
body += String(chunk);
|
|
203
|
+
});
|
|
204
|
+
req.on("end", () => resolve(querystring.parse(body)));
|
|
205
|
+
req.on("error", () => resolve(null));
|
|
206
|
+
req.on("close", () => {
|
|
207
|
+
if (received > MAX_FORM_BODY_BYTES)
|
|
208
|
+
resolve(null);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function requirePostAuth(req, res, url, parsed, authToken, json = false) {
|
|
213
|
+
if (!authToken)
|
|
214
|
+
return true;
|
|
215
|
+
const submitted = getSubmittedAuthToken(req, url, parsed);
|
|
216
|
+
if (authTokensMatch(submitted, authToken))
|
|
217
|
+
return true;
|
|
218
|
+
rejectUnauthorized(res, json);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
function requireCsrf(res, parsed, csrfTokens, json = false) {
|
|
222
|
+
if (!csrfTokens)
|
|
223
|
+
return true;
|
|
224
|
+
pruneExpiredCsrfTokens(csrfTokens);
|
|
225
|
+
const submitted = String(parsed._csrf || "");
|
|
226
|
+
if (submitted && csrfTokens.delete(submitted))
|
|
227
|
+
return true;
|
|
228
|
+
if (json) {
|
|
229
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
230
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid or missing CSRF token" }));
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
234
|
+
res.end("Invalid or missing CSRF token");
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
function readProjectQueue(phrenPath, profile) {
|
|
238
|
+
const projects = getProjectDirs(phrenPath, profile).map((projectDir) => path.basename(projectDir)).filter((project) => project !== "global");
|
|
239
|
+
const items = [];
|
|
240
|
+
for (const project of projects) {
|
|
241
|
+
const queueResult = readReviewQueue(phrenPath, project);
|
|
242
|
+
const queueItems = queueResult.ok ? queueResult.data : [];
|
|
243
|
+
for (const item of queueItems) {
|
|
244
|
+
items.push({
|
|
245
|
+
project,
|
|
246
|
+
section: item.section,
|
|
247
|
+
line: item.line,
|
|
248
|
+
text: item.text,
|
|
249
|
+
date: item.date,
|
|
250
|
+
machine: item.machine || undefined,
|
|
251
|
+
model: item.model || undefined,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return items;
|
|
256
|
+
}
|
|
257
|
+
function parseTopicsPayload(raw) {
|
|
258
|
+
try {
|
|
259
|
+
const parsed = JSON.parse(raw);
|
|
260
|
+
if (!Array.isArray(parsed))
|
|
261
|
+
return null;
|
|
262
|
+
return parsed.map((topic) => ({
|
|
263
|
+
slug: String(topic?.slug || ""),
|
|
264
|
+
label: String(topic?.label || ""),
|
|
265
|
+
description: String(topic?.description || ""),
|
|
266
|
+
keywords: Array.isArray(topic?.keywords) ? topic.keywords.map((keyword) => String(keyword)) : [],
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function parseTopicPayload(raw) {
|
|
274
|
+
try {
|
|
275
|
+
const topic = JSON.parse(raw);
|
|
276
|
+
if (!topic || typeof topic !== "object")
|
|
277
|
+
return null;
|
|
278
|
+
return {
|
|
279
|
+
slug: String(topic.slug || ""),
|
|
280
|
+
label: String(topic.label || ""),
|
|
281
|
+
description: String(topic.description || ""),
|
|
282
|
+
keywords: Array.isArray(topic.keywords)
|
|
283
|
+
? topic.keywords.map((keyword) => String(keyword))
|
|
284
|
+
: [],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function parseQs(url) {
|
|
292
|
+
return url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
|
|
293
|
+
}
|
|
294
|
+
function jsonOk(res, data, status = 200) {
|
|
295
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
296
|
+
res.end(JSON.stringify(data));
|
|
297
|
+
}
|
|
298
|
+
function jsonErr(res, error, status = 200) {
|
|
299
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
300
|
+
res.end(JSON.stringify({ ok: false, error }));
|
|
301
|
+
}
|
|
302
|
+
function withPostBody(req, res, url, ctx, handler) {
|
|
303
|
+
void readFormBody(req, res).then((parsed) => {
|
|
304
|
+
if (!parsed)
|
|
305
|
+
return;
|
|
306
|
+
if (!requirePostAuth(req, res, url, parsed, ctx.authToken, true))
|
|
307
|
+
return;
|
|
308
|
+
if (!requireCsrf(res, parsed, ctx.csrfTokens, true))
|
|
309
|
+
return;
|
|
310
|
+
handler(parsed);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
// ── GET handlers ──────────────────────────────────────────────────────────────
|
|
314
|
+
function handleGetHome(res, ctx) {
|
|
315
|
+
const nonce = crypto.randomBytes(16).toString("base64");
|
|
316
|
+
setCommonHeaders(res, nonce);
|
|
317
|
+
const html = ctx.renderPage(ctx.phrenPath, ctx.authToken, nonce);
|
|
318
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
319
|
+
res.end(html);
|
|
320
|
+
}
|
|
321
|
+
function handleGetProjects(res, ctx) {
|
|
322
|
+
jsonOk(res, collectProjectsForUI(ctx.phrenPath, ctx.profile));
|
|
323
|
+
}
|
|
324
|
+
function handleGetChangeToken(res, ctx) {
|
|
325
|
+
jsonOk(res, { token: computePhrenLiveStateToken(ctx.phrenPath) });
|
|
326
|
+
}
|
|
327
|
+
function handleGetRuntimeHealth(res, ctx) {
|
|
328
|
+
jsonOk(res, readSyncSnapshot(ctx.phrenPath));
|
|
329
|
+
}
|
|
330
|
+
function handleGetReviewQueue(res, ctx) {
|
|
331
|
+
jsonOk(res, readProjectQueue(ctx.phrenPath, ctx.profile));
|
|
332
|
+
}
|
|
333
|
+
function handleGetReviewActivity(res, ctx) {
|
|
334
|
+
jsonOk(res, { accepted: recentAccepted(ctx.phrenPath), usage: recentUsage(ctx.phrenPath) });
|
|
335
|
+
}
|
|
336
|
+
function handleGetProjectContent(res, url, ctx) {
|
|
337
|
+
const qs = parseQs(url);
|
|
338
|
+
const project = String(qs.project || "");
|
|
339
|
+
const file = String(qs.file || "");
|
|
340
|
+
if (!project || !isValidProjectName(project) || !file)
|
|
341
|
+
return jsonErr(res, "Invalid project or file", 400);
|
|
342
|
+
const allowedFiles = ["FINDINGS.md", TASKS_FILENAME, "CLAUDE.md", "summary.md"];
|
|
343
|
+
if (!allowedFiles.includes(file))
|
|
344
|
+
return jsonErr(res, `File not allowed: ${file}`, 400);
|
|
345
|
+
const filePath = safeProjectPath(ctx.phrenPath, project, file);
|
|
346
|
+
if (!filePath)
|
|
347
|
+
return jsonErr(res, "Invalid project or file path", 400);
|
|
348
|
+
if (!fs.existsSync(filePath))
|
|
349
|
+
return jsonErr(res, `File not found: ${file}`);
|
|
350
|
+
jsonOk(res, { ok: true, content: fs.readFileSync(filePath, "utf8") });
|
|
351
|
+
}
|
|
352
|
+
function handleGetProjectTopics(res, url, ctx) {
|
|
353
|
+
const project = String(parseQs(url).project || "");
|
|
354
|
+
if (!project || !isValidProjectName(project))
|
|
355
|
+
return jsonErr(res, "Invalid project", 400);
|
|
356
|
+
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
|
|
357
|
+
}
|
|
358
|
+
function handleGetProjectReferenceList(res, url, ctx) {
|
|
359
|
+
const project = String(parseQs(url).project || "");
|
|
360
|
+
if (!project || !isValidProjectName(project))
|
|
361
|
+
return jsonErr(res, "Invalid project", 400);
|
|
362
|
+
jsonOk(res, { ok: true, ...listProjectReferenceDocs(ctx.phrenPath, project) });
|
|
363
|
+
}
|
|
364
|
+
function handleGetProjectReferenceContent(res, url, ctx) {
|
|
365
|
+
const qs = parseQs(url);
|
|
366
|
+
const contentResult = readReferenceContent(ctx.phrenPath, String(qs.project || ""), String(qs.file || ""));
|
|
367
|
+
res.writeHead(contentResult.ok ? 200 : 400, { "content-type": "application/json; charset=utf-8" });
|
|
368
|
+
res.end(JSON.stringify(contentResult.ok ? { ok: true, content: contentResult.content } : { ok: false, error: contentResult.error }));
|
|
369
|
+
}
|
|
370
|
+
function handleGetSkills(res, ctx) {
|
|
371
|
+
jsonOk(res, collectSkillsForUI(ctx.phrenPath, ctx.profile));
|
|
372
|
+
}
|
|
373
|
+
function handleGetSkillContent(res, url, ctx) {
|
|
374
|
+
const filePath = String(parseQs(url).path || "");
|
|
375
|
+
if (!filePath || !isAllowedSkillPath(filePath, ctx.phrenPath))
|
|
376
|
+
return jsonErr(res, "Invalid path", 400);
|
|
377
|
+
if (!fs.existsSync(filePath))
|
|
378
|
+
return jsonErr(res, "File not found");
|
|
379
|
+
jsonOk(res, { ok: true, content: fs.readFileSync(filePath, "utf8") });
|
|
380
|
+
}
|
|
381
|
+
function handleGetHooks(res, ctx) {
|
|
382
|
+
jsonOk(res, getHooksData(ctx.phrenPath));
|
|
383
|
+
}
|
|
384
|
+
async function handleGetSearch(res, url, ctx) {
|
|
385
|
+
const searchParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
|
|
386
|
+
const query = searchParams.get("q") || searchParams.get("query") || "";
|
|
387
|
+
const searchProject = searchParams.get("project") || undefined;
|
|
388
|
+
const searchType = searchParams.get("type") || undefined;
|
|
389
|
+
const searchLimit = parseInt(searchParams.get("limit") || "10", 10) || 10;
|
|
390
|
+
if (!query.trim())
|
|
391
|
+
return jsonErr(res, "Missing query parameter (q or query).");
|
|
392
|
+
try {
|
|
393
|
+
const { runSearch } = await import("../cli/search.js");
|
|
394
|
+
const result = await runSearch({ query, limit: Math.min(searchLimit, 50), project: searchProject, type: searchType }, ctx.phrenPath, ctx.profile || "");
|
|
395
|
+
const fileDates = {};
|
|
396
|
+
for (const line of result.lines) {
|
|
397
|
+
const srcMatch = line.match(/^\[([^\]]+)\]\s/);
|
|
398
|
+
if (srcMatch) {
|
|
399
|
+
const sourceKey = srcMatch[1];
|
|
400
|
+
if (fileDates[sourceKey])
|
|
401
|
+
continue;
|
|
402
|
+
const slashIdx = sourceKey.indexOf("/");
|
|
403
|
+
if (slashIdx > 0) {
|
|
404
|
+
try {
|
|
405
|
+
const filePath = path.join(ctx.phrenPath, sourceKey.slice(0, slashIdx), sourceKey.slice(slashIdx + 1));
|
|
406
|
+
if (fs.existsSync(filePath))
|
|
407
|
+
fileDates[sourceKey] = fs.statSync(filePath).mtime.toISOString();
|
|
408
|
+
}
|
|
409
|
+
catch { /* skip */ }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
jsonOk(res, { ok: true, query, results: result.lines, fileDates });
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
jsonErr(res, errorMessage(err));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function handleGetGraph(res, url, ctx) {
|
|
420
|
+
const graphParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
|
|
421
|
+
jsonOk(res, await buildGraph(ctx.phrenPath, ctx.profile, graphParams.get("project") || undefined));
|
|
422
|
+
}
|
|
423
|
+
function handleGetScores(res, ctx) {
|
|
424
|
+
let scores = {};
|
|
425
|
+
try {
|
|
426
|
+
const raw = fs.readFileSync(path.join(ctx.phrenPath, ".runtime", "memory-scores.json"), "utf-8");
|
|
427
|
+
const parsed = JSON.parse(raw);
|
|
428
|
+
if (parsed && typeof parsed === "object")
|
|
429
|
+
scores = parsed;
|
|
430
|
+
}
|
|
431
|
+
catch { /* file missing or unparseable */ }
|
|
432
|
+
jsonOk(res, scores);
|
|
433
|
+
}
|
|
434
|
+
function handleGetTasks(res, ctx) {
|
|
435
|
+
try {
|
|
436
|
+
const docs = readTasksAcrossProjects(ctx.phrenPath, ctx.profile);
|
|
437
|
+
const tasks = [];
|
|
438
|
+
for (const doc of docs) {
|
|
439
|
+
for (const section of ["Active", "Queue", "Done"]) {
|
|
440
|
+
for (const item of doc.items[section]) {
|
|
441
|
+
tasks.push({
|
|
442
|
+
project: doc.project, section: item.section, line: item.line, priority: item.priority,
|
|
443
|
+
pinned: item.pinned, githubIssue: item.githubIssue, githubUrl: item.githubUrl,
|
|
444
|
+
context: item.context, checked: item.checked, sessionId: item.sessionId,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
jsonOk(res, { ok: true, tasks });
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
jsonOk(res, { ok: false, error: errorMessage(err), tasks: [] });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function handleGetSettings(res, url, ctx) {
|
|
456
|
+
try {
|
|
457
|
+
const prefs = readInstallPreferences(ctx.phrenPath);
|
|
458
|
+
const workflowPolicy = getWorkflowPolicy(ctx.phrenPath);
|
|
459
|
+
const retentionPolicy = getRetentionPolicy(ctx.phrenPath);
|
|
460
|
+
const hooksData = getHooksData(ctx.phrenPath);
|
|
461
|
+
const proactivityFindings = prefs.proactivityFindings || prefs.proactivity || "high";
|
|
462
|
+
const settingsProject = String(parseQs(url).project || "");
|
|
463
|
+
const merged = settingsProject && isValidProjectName(settingsProject) ? mergeConfig(ctx.phrenPath, settingsProject) : null;
|
|
464
|
+
const overrides = settingsProject && isValidProjectName(settingsProject) ? getProjectConfigOverrides(ctx.phrenPath, settingsProject) : null;
|
|
465
|
+
let projectInfo = null;
|
|
466
|
+
if (settingsProject && isValidProjectName(settingsProject)) {
|
|
467
|
+
const projectDir = path.join(ctx.phrenPath, settingsProject);
|
|
468
|
+
const configFile = path.join(projectDir, "phren.project.yaml");
|
|
469
|
+
const projConfig = readProjectConfig(ctx.phrenPath, settingsProject);
|
|
470
|
+
const findingsPath = path.join(projectDir, "FINDINGS.md");
|
|
471
|
+
const taskPath = path.join(projectDir, "tasks.md");
|
|
472
|
+
let findingCount = 0;
|
|
473
|
+
if (fs.existsSync(findingsPath))
|
|
474
|
+
findingCount = (fs.readFileSync(findingsPath, "utf8").match(/^- /gm) || []).length;
|
|
475
|
+
let taskCount = 0;
|
|
476
|
+
if (fs.existsSync(taskPath)) {
|
|
477
|
+
const queueMatch = fs.readFileSync(taskPath, "utf8").match(/## Queue[\s\S]*?(?=## |$)/);
|
|
478
|
+
if (queueMatch)
|
|
479
|
+
taskCount = (queueMatch[0].match(/^- /gm) || []).length;
|
|
480
|
+
}
|
|
481
|
+
projectInfo = {
|
|
482
|
+
diskPath: projConfig.sourcePath || projectDir, ownership: projConfig.ownership || "default",
|
|
483
|
+
configFile, configExists: fs.existsSync(configFile), hasFindings: fs.existsSync(findingsPath),
|
|
484
|
+
hasTasks: fs.existsSync(taskPath), hasSummary: fs.existsSync(path.join(projectDir, "summary.md")),
|
|
485
|
+
hasClaudeMd: fs.existsSync(path.join(projectDir, "CLAUDE.md")), findingCount, taskCount,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
jsonOk(res, {
|
|
489
|
+
ok: true, proactivity: prefs.proactivity || "high", proactivityFindings,
|
|
490
|
+
proactivityTask: prefs.proactivityTask || prefs.proactivity || "high", taskMode: workflowPolicy.taskMode,
|
|
491
|
+
findingSensitivity: workflowPolicy.findingSensitivity || "balanced", autoCaptureEnabled: proactivityFindings !== "low",
|
|
492
|
+
consolidationEntryThreshold: CONSOLIDATION_ENTRY_THRESHOLD, hooksEnabled: hooksData.globalEnabled,
|
|
493
|
+
mcpEnabled: prefs.mcpEnabled !== false, hookTools: hooksData.tools,
|
|
494
|
+
retentionPolicy, workflowPolicy, merged, overrides, projectInfo,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
jsonErr(res, errorMessage(err));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function handleGetConfig(res, url, ctx) {
|
|
502
|
+
const project = String(parseQs(url).project || "");
|
|
503
|
+
if (project && !isValidProjectName(project))
|
|
504
|
+
return jsonErr(res, "Invalid project name", 400);
|
|
505
|
+
try {
|
|
506
|
+
const config = mergeConfig(ctx.phrenPath, project || undefined);
|
|
507
|
+
const projects = getProjectDirs(ctx.phrenPath, ctx.profile).map((d) => path.basename(d)).filter((p) => p !== "global");
|
|
508
|
+
jsonOk(res, { ok: true, config, projects });
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
jsonErr(res, errorMessage(err));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function handleGetCsrfToken(res, ctx) {
|
|
515
|
+
if (!ctx.csrfTokens)
|
|
516
|
+
return jsonOk(res, { ok: true, token: null });
|
|
517
|
+
pruneExpiredCsrfTokens(ctx.csrfTokens);
|
|
518
|
+
const token = crypto.randomUUID();
|
|
519
|
+
ctx.csrfTokens.set(token, Date.now());
|
|
520
|
+
jsonOk(res, { ok: true, token });
|
|
521
|
+
}
|
|
522
|
+
function handleGetFindings(res, pathname, ctx) {
|
|
523
|
+
const project = decodeURIComponent(pathname.slice("/api/findings/".length));
|
|
524
|
+
if (!project || !isValidProjectName(project))
|
|
525
|
+
return jsonErr(res, "Invalid project name", 400);
|
|
526
|
+
const result = readFindings(ctx.phrenPath, project);
|
|
527
|
+
jsonOk(res, result.ok ? { ok: true, data: { project, findings: result.data } } : { ok: false, error: result.error });
|
|
528
|
+
}
|
|
529
|
+
// ── POST handlers ─────────────────────────────────────────────────────────────
|
|
530
|
+
function handlePostSync(req, res, url, ctx) {
|
|
531
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
532
|
+
const message = String(parsed.message || "update phren");
|
|
533
|
+
try {
|
|
534
|
+
const EXEC_TIMEOUT = 15_000;
|
|
535
|
+
const runGit = (args) => execFileSync("git", args, { cwd: ctx.phrenPath, encoding: "utf8", timeout: EXEC_TIMEOUT }).trim();
|
|
536
|
+
const status = runGit(["status", "--porcelain"]);
|
|
537
|
+
if (!status)
|
|
538
|
+
return jsonOk(res, { ok: true, message: "Nothing to sync — working tree clean." });
|
|
539
|
+
runGit(["add", "--", "*.md", "*.json", "*.yaml", "*.yml", "*.jsonl", "*.txt"]);
|
|
540
|
+
const stagedFiles = runGit(["diff", "--cached", "--name-only"]);
|
|
541
|
+
if (!stagedFiles)
|
|
542
|
+
return jsonOk(res, { ok: true, message: "Nothing to sync — no matching files to commit." });
|
|
543
|
+
runGit(["commit", "-m", message, "--only", "--", ...stagedFiles.split("\n").filter(Boolean)]);
|
|
544
|
+
let pushed = false;
|
|
545
|
+
try {
|
|
546
|
+
if (runGit(["remote"])) {
|
|
547
|
+
runGit(["push"]);
|
|
548
|
+
pushed = true;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch { /* no remote or push failed */ }
|
|
552
|
+
const changedFiles = status.split("\n").filter(Boolean).length;
|
|
553
|
+
jsonOk(res, { ok: true, message: `Synced ${changedFiles} file(s).${pushed ? " Pushed to remote." : " No remote, saved locally."}` });
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
jsonErr(res, errorMessage(err));
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
function handlePostApprove(req, res, url, ctx) {
|
|
561
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
562
|
+
const project = String(parsed.project || "");
|
|
563
|
+
const line = String(parsed.line || "");
|
|
564
|
+
if (!project || !isValidProjectName(project) || !line)
|
|
565
|
+
return jsonErr(res, "Missing project or line");
|
|
566
|
+
try {
|
|
567
|
+
const qPath = queueFilePath(ctx.phrenPath, project);
|
|
568
|
+
if (fs.existsSync(qPath)) {
|
|
569
|
+
const lines = fs.readFileSync(qPath, "utf8").split("\n").filter((l) => l.trim() !== line.trim());
|
|
570
|
+
fs.writeFileSync(qPath, lines.join("\n"));
|
|
571
|
+
}
|
|
572
|
+
jsonOk(res, { ok: true });
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
jsonErr(res, errorMessage(err));
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
function handlePostReject(req, res, url, ctx) {
|
|
580
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
581
|
+
const project = String(parsed.project || "");
|
|
582
|
+
const line = String(parsed.line || "");
|
|
583
|
+
if (!project || !isValidProjectName(project) || !line)
|
|
584
|
+
return jsonErr(res, "Missing project or line");
|
|
585
|
+
try {
|
|
586
|
+
const qPath = queueFilePath(ctx.phrenPath, project);
|
|
587
|
+
if (fs.existsSync(qPath)) {
|
|
588
|
+
const lines = fs.readFileSync(qPath, "utf8").split("\n").filter((l) => l.trim() !== line.trim());
|
|
589
|
+
fs.writeFileSync(qPath, lines.join("\n"));
|
|
590
|
+
}
|
|
591
|
+
const findingText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
|
|
592
|
+
if (findingText)
|
|
593
|
+
removeFinding(ctx.phrenPath, project, findingText);
|
|
594
|
+
jsonOk(res, { ok: true });
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
jsonErr(res, errorMessage(err));
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
function handlePostEdit(req, res, url, ctx) {
|
|
602
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
603
|
+
const project = String(parsed.project || "");
|
|
604
|
+
const line = String(parsed.line || "");
|
|
605
|
+
const newText = String(parsed.new_text || "");
|
|
606
|
+
if (!project || !isValidProjectName(project) || !line || !newText)
|
|
607
|
+
return jsonErr(res, "Missing project, line, or new_text");
|
|
608
|
+
try {
|
|
609
|
+
const oldText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
|
|
610
|
+
const result = editFinding(ctx.phrenPath, project, oldText, newText);
|
|
611
|
+
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
|
|
612
|
+
}
|
|
613
|
+
catch (err) {
|
|
614
|
+
jsonErr(res, errorMessage(err));
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
function handlePostSkillSave(req, res, url, ctx) {
|
|
619
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
620
|
+
const filePath = String(parsed.path || "");
|
|
621
|
+
const content = String(parsed.content || "");
|
|
622
|
+
if (!filePath || !isAllowedSkillPath(filePath, ctx.phrenPath))
|
|
623
|
+
return jsonErr(res, "Invalid path");
|
|
624
|
+
try {
|
|
625
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
626
|
+
const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
|
|
627
|
+
fs.writeFileSync(tmpPath, content);
|
|
628
|
+
fs.renameSync(tmpPath, filePath);
|
|
629
|
+
jsonOk(res, { ok: true });
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
jsonErr(res, errorMessage(err));
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
function handlePostSkillToggle(req, res, url, ctx) {
|
|
637
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
638
|
+
const project = String(parsed.project || "");
|
|
639
|
+
const name = String(parsed.name || "");
|
|
640
|
+
const enabled = String(parsed.enabled || "") === "true";
|
|
641
|
+
if (!project || !name || (project.toLowerCase() !== "global" && !isValidProjectName(project)))
|
|
642
|
+
return jsonErr(res, "Invalid skill toggle request");
|
|
643
|
+
const skill = findSkill(ctx.phrenPath, ctx.profile || "", project, name);
|
|
644
|
+
if (!skill || "error" in skill)
|
|
645
|
+
return jsonErr(res, skill && "error" in skill ? skill.error : "Skill not found");
|
|
646
|
+
setSkillEnabledAndSync(ctx.phrenPath, project, skill.name, enabled);
|
|
647
|
+
jsonOk(res, { ok: true, enabled });
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
function handlePostHookToggle(req, res, url, ctx) {
|
|
651
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
652
|
+
const tool = String(parsed.tool || "").toLowerCase();
|
|
653
|
+
if (!["claude", "copilot", "cursor", "codex"].includes(tool))
|
|
654
|
+
return jsonErr(res, "Invalid tool");
|
|
655
|
+
const prefs = readInstallPreferences(ctx.phrenPath);
|
|
656
|
+
const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
|
|
657
|
+
const current = toolPrefs[tool] !== false && prefs.hooksEnabled !== false;
|
|
658
|
+
writeInstallPreferences(ctx.phrenPath, { hookTools: { ...toolPrefs, [tool]: !current } });
|
|
659
|
+
jsonOk(res, { ok: true, enabled: !current });
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
function handlePostTopicsSave(req, res, url, ctx) {
|
|
663
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
664
|
+
const project = String(parsed.project || "");
|
|
665
|
+
if (!project || !isValidProjectName(project))
|
|
666
|
+
return jsonErr(res, "Invalid project", 400);
|
|
667
|
+
const topics = parseTopicsPayload(String(parsed.topics || ""));
|
|
668
|
+
if (!topics)
|
|
669
|
+
return jsonErr(res, "Invalid topics payload", 400);
|
|
670
|
+
const saved = writeProjectTopics(ctx.phrenPath, project, topics);
|
|
671
|
+
if (!saved.ok)
|
|
672
|
+
return jsonOk(res, saved);
|
|
673
|
+
for (const topic of saved.topics) {
|
|
674
|
+
const ensured = ensureTopicReferenceDoc(ctx.phrenPath, project, topic);
|
|
675
|
+
if (!ensured.ok)
|
|
676
|
+
return jsonErr(res, ensured.error);
|
|
677
|
+
}
|
|
678
|
+
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
function handlePostTopicsReclassify(req, res, url, ctx) {
|
|
682
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
683
|
+
const project = String(parsed.project || "");
|
|
684
|
+
if (!project || !isValidProjectName(project))
|
|
685
|
+
return jsonErr(res, "Invalid project", 400);
|
|
686
|
+
jsonOk(res, { ok: true, ...reclassifyLegacyTopicDocs(ctx.phrenPath, project) });
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
function handlePostTopicsPin(req, res, url, ctx) {
|
|
690
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
691
|
+
const project = String(parsed.project || "");
|
|
692
|
+
if (!project || !isValidProjectName(project))
|
|
693
|
+
return jsonErr(res, "Invalid project", 400);
|
|
694
|
+
const topic = parseTopicPayload(String(parsed.topic || ""));
|
|
695
|
+
if (!topic)
|
|
696
|
+
return jsonErr(res, "Invalid topic payload", 400);
|
|
697
|
+
const pinned = pinProjectTopicSuggestion(ctx.phrenPath, project, topic);
|
|
698
|
+
if (!pinned.ok)
|
|
699
|
+
return jsonOk(res, pinned);
|
|
700
|
+
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
function handlePostTopicsUnpin(req, res, url, ctx) {
|
|
704
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
705
|
+
const project = String(parsed.project || "");
|
|
706
|
+
if (!project || !isValidProjectName(project))
|
|
707
|
+
return jsonErr(res, "Invalid project", 400);
|
|
708
|
+
const unpinned = unpinProjectTopicSuggestion(ctx.phrenPath, project, String(parsed.slug || ""));
|
|
709
|
+
if (!unpinned.ok)
|
|
710
|
+
return jsonOk(res, unpinned);
|
|
711
|
+
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function handlePostTaskAction(req, res, url, ctx, action) {
|
|
715
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
716
|
+
const project = String(parsed.project || "");
|
|
717
|
+
const item = String(parsed.item || "");
|
|
718
|
+
if (!project || !item || !isValidProjectName(project))
|
|
719
|
+
return jsonErr(res, "Missing or invalid project/item", 400);
|
|
720
|
+
if (action === "complete") {
|
|
721
|
+
const result = completeTaskStore(ctx.phrenPath, project, item);
|
|
722
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
|
|
723
|
+
}
|
|
724
|
+
else if (action === "add") {
|
|
725
|
+
const result = addTaskStore(ctx.phrenPath, project, item);
|
|
726
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? `Task added: ${result.data.line}` : undefined, error: result.ok ? undefined : result.error });
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
const result = removeTaskStore(ctx.phrenPath, project, item);
|
|
730
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function handlePostTaskUpdate(req, res, url, ctx) {
|
|
735
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
736
|
+
const project = String(parsed.project || "");
|
|
737
|
+
const item = String(parsed.item || "");
|
|
738
|
+
if (!project || !item || !isValidProjectName(project))
|
|
739
|
+
return jsonErr(res, "Missing or invalid project/item", 400);
|
|
740
|
+
const updates = {};
|
|
741
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "text"))
|
|
742
|
+
updates.text = String(parsed.text || "");
|
|
743
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "priority"))
|
|
744
|
+
updates.priority = String(parsed.priority || "");
|
|
745
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "section"))
|
|
746
|
+
updates.section = String(parsed.section || "");
|
|
747
|
+
const result = updateTaskStore(ctx.phrenPath, project, item, updates);
|
|
748
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
function handlePostSettingsFindingSensitivity(req, res, url, ctx) {
|
|
752
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
753
|
+
const value = String(parsed.value || "");
|
|
754
|
+
const valid = ["minimal", "conservative", "balanced", "aggressive"];
|
|
755
|
+
if (!valid.includes(value))
|
|
756
|
+
return jsonErr(res, `Invalid finding sensitivity: "${value}". Must be one of: ${valid.join(", ")}`);
|
|
757
|
+
const result = updateWorkflowPolicy(ctx.phrenPath, { findingSensitivity: value });
|
|
758
|
+
jsonOk(res, result.ok ? { ok: true, findingSensitivity: result.data.findingSensitivity } : { ok: false, error: result.error });
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
function handlePostSettingsTaskMode(req, res, url, ctx) {
|
|
762
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
763
|
+
const value = String(parsed.value || "").trim().toLowerCase();
|
|
764
|
+
const valid = VALID_TASK_MODES;
|
|
765
|
+
if (!valid.includes(value))
|
|
766
|
+
return jsonErr(res, `Invalid task mode: "${value}". Must be one of: ${valid.join(", ")}`);
|
|
767
|
+
const result = updateWorkflowPolicy(ctx.phrenPath, { taskMode: value });
|
|
768
|
+
jsonOk(res, result.ok ? { ok: true, taskMode: result.data.taskMode } : { ok: false, error: result.error });
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
function handlePostSettingsProactivity(req, res, url, ctx) {
|
|
772
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
773
|
+
const value = String(parsed.value || "").trim().toLowerCase();
|
|
774
|
+
const valid = ["high", "medium", "low"];
|
|
775
|
+
if (!valid.includes(value))
|
|
776
|
+
return jsonErr(res, `Invalid proactivity: "${value}". Must be one of: ${valid.join(", ")}`);
|
|
777
|
+
writeInstallPreferences(ctx.phrenPath, { proactivity: value });
|
|
778
|
+
writeGovernanceInstallPreferences(ctx.phrenPath, { proactivity: value });
|
|
779
|
+
jsonOk(res, { ok: true, proactivity: value });
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
function handlePostSettingsAutoCapture(req, res, url, ctx) {
|
|
783
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
784
|
+
const enabled = String(parsed.enabled || "").toLowerCase() === "true";
|
|
785
|
+
const next = enabled ? "high" : "low";
|
|
786
|
+
writeInstallPreferences(ctx.phrenPath, { proactivityFindings: next });
|
|
787
|
+
writeGovernanceInstallPreferences(ctx.phrenPath, { proactivityFindings: next });
|
|
788
|
+
jsonOk(res, { ok: true, autoCaptureEnabled: enabled, proactivityFindings: next });
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
function handlePostSettingsMcpEnabled(req, res, url, ctx) {
|
|
792
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
793
|
+
const enabled = String(parsed.enabled || "").toLowerCase() === "true";
|
|
794
|
+
writeInstallPreferences(ctx.phrenPath, { mcpEnabled: enabled });
|
|
795
|
+
jsonOk(res, { ok: true, mcpEnabled: enabled });
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
function handlePostSettingsProjectOverrides(req, res, url, ctx) {
|
|
799
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
800
|
+
const project = String(parsed.project || "");
|
|
801
|
+
const field = String(parsed.field || "");
|
|
802
|
+
const value = String(parsed.value || "");
|
|
803
|
+
const clearField = String(parsed.clear || "") === "true";
|
|
804
|
+
if (!project || !isValidProjectName(project))
|
|
805
|
+
return jsonErr(res, "Invalid project name", 400);
|
|
806
|
+
const registeredProjects = getProjectDirs(ctx.phrenPath, ctx.profile).map((d) => path.basename(d)).filter((p) => p !== "global");
|
|
807
|
+
const registrationWarning = registeredProjects.includes(project) ? undefined : `Project '${project}' is not registered in the active profile. Config was saved but it will have no effect until the project is added with 'phren add'.`;
|
|
808
|
+
const VALID_FIELDS = {
|
|
809
|
+
findingSensitivity: ["minimal", "conservative", "balanced", "aggressive"],
|
|
810
|
+
proactivity: ["high", "medium", "low"], proactivityFindings: ["high", "medium", "low"],
|
|
811
|
+
proactivityTask: ["high", "medium", "low"], taskMode: ["off", "manual", "suggest", "auto"],
|
|
812
|
+
};
|
|
813
|
+
const NUMERIC_RETENTION_FIELDS = ["ttlDays", "retentionDays", "autoAcceptThreshold", "minInjectConfidence"];
|
|
814
|
+
const NUMERIC_WORKFLOW_FIELDS = ["lowConfidenceThreshold"];
|
|
815
|
+
try {
|
|
816
|
+
updateProjectConfigOverrides(ctx.phrenPath, project, (current) => {
|
|
817
|
+
const next = { ...current };
|
|
818
|
+
if (clearField) {
|
|
819
|
+
if (field in VALID_FIELDS)
|
|
820
|
+
delete next[field];
|
|
821
|
+
else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
|
|
822
|
+
if (next.retentionPolicy)
|
|
823
|
+
delete next.retentionPolicy[field];
|
|
824
|
+
}
|
|
825
|
+
else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
|
|
826
|
+
if (next.workflowPolicy)
|
|
827
|
+
delete next.workflowPolicy[field];
|
|
828
|
+
}
|
|
829
|
+
return next;
|
|
830
|
+
}
|
|
831
|
+
if (field in VALID_FIELDS) {
|
|
832
|
+
if (!VALID_FIELDS[field].includes(value))
|
|
833
|
+
throw new Error(`Invalid value "${value}" for ${field}`);
|
|
834
|
+
next[field] = value;
|
|
835
|
+
}
|
|
836
|
+
else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
|
|
837
|
+
const num = parseFloat(value);
|
|
838
|
+
if (!Number.isFinite(num) || num < 0)
|
|
839
|
+
throw new Error(`Invalid numeric value for ${field}`);
|
|
840
|
+
next.retentionPolicy = { ...next.retentionPolicy, [field]: num };
|
|
841
|
+
}
|
|
842
|
+
else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
|
|
843
|
+
const num = parseFloat(value);
|
|
844
|
+
if (!Number.isFinite(num) || num < 0 || num > 1)
|
|
845
|
+
throw new Error(`Invalid value for ${field} (must be 0-1)`);
|
|
846
|
+
next.workflowPolicy = { ...next.workflowPolicy, [field]: num };
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
throw new Error(`Unknown config field: ${field}`);
|
|
850
|
+
}
|
|
851
|
+
return next;
|
|
852
|
+
});
|
|
853
|
+
jsonOk(res, { ok: true, config: mergeConfig(ctx.phrenPath, project), ...(registrationWarning ? { warning: registrationWarning } : {}) });
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
jsonErr(res, errorMessage(err));
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
function handleFindingsWrite(req, res, url, pathname, ctx) {
|
|
861
|
+
const project = decodeURIComponent(pathname.slice("/api/findings/".length));
|
|
862
|
+
if (!project || !isValidProjectName(project))
|
|
863
|
+
return jsonErr(res, "Invalid project name", 400);
|
|
864
|
+
if (req.method === "POST") {
|
|
865
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
866
|
+
const text = String(parsed.text || "");
|
|
867
|
+
if (!text)
|
|
868
|
+
return jsonErr(res, "text is required");
|
|
869
|
+
const result = addFindingStore(ctx.phrenPath, project, text);
|
|
870
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
else if (req.method === "PUT") {
|
|
874
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
875
|
+
const oldText = String(parsed.old_text || "");
|
|
876
|
+
const newText = String(parsed.new_text || "");
|
|
877
|
+
if (!oldText || !newText)
|
|
878
|
+
return jsonErr(res, "old_text and new_text are required");
|
|
879
|
+
const result = editFinding(ctx.phrenPath, project, oldText, newText);
|
|
880
|
+
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
// DELETE
|
|
885
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
886
|
+
const text = String(parsed.text || "");
|
|
887
|
+
if (!text)
|
|
888
|
+
return jsonErr(res, "text is required");
|
|
889
|
+
const result = removeFinding(ctx.phrenPath, project, text);
|
|
890
|
+
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// ── Main router ───────────────────────────────────────────────────────────────
|
|
895
|
+
export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
|
|
896
|
+
try {
|
|
897
|
+
repairPreexistingInstall(phrenPath);
|
|
898
|
+
}
|
|
899
|
+
catch (err) {
|
|
900
|
+
logger.debug("web-ui", `web-ui repair: ${errorMessage(err)}`);
|
|
901
|
+
}
|
|
902
|
+
const ctx = {
|
|
903
|
+
phrenPath, profile, authToken: opts?.authToken, csrfTokens: opts?.csrfTokens, renderPage,
|
|
904
|
+
};
|
|
905
|
+
return http.createServer(async (req, res) => {
|
|
906
|
+
const url = req.url || "/";
|
|
907
|
+
const pathname = url.includes("?") ? url.slice(0, url.indexOf("?")) : url;
|
|
908
|
+
// Home page
|
|
909
|
+
if (req.method === "GET" && pathname === "/") {
|
|
910
|
+
if (!requireGetAuth(req, res, url, ctx.authToken))
|
|
911
|
+
return;
|
|
912
|
+
pruneExpiredCsrfTokens(ctx.csrfTokens);
|
|
913
|
+
if (ctx.csrfTokens)
|
|
914
|
+
ctx.csrfTokens.set(crypto.randomUUID(), Date.now());
|
|
915
|
+
return handleGetHome(res, ctx);
|
|
916
|
+
}
|
|
917
|
+
setCommonHeaders(res);
|
|
918
|
+
// Auth gate for all GET /api/* routes
|
|
919
|
+
if (pathname.startsWith("/api/") && req.method === "GET" && !requireGetAuth(req, res, url, ctx.authToken))
|
|
920
|
+
return;
|
|
921
|
+
// ── GET routes ──
|
|
922
|
+
if (req.method === "GET") {
|
|
923
|
+
switch (pathname) {
|
|
924
|
+
case "/api/projects": return handleGetProjects(res, ctx);
|
|
925
|
+
case "/api/change-token": return handleGetChangeToken(res, ctx);
|
|
926
|
+
case "/api/runtime-health": return handleGetRuntimeHealth(res, ctx);
|
|
927
|
+
case "/api/review-queue": return handleGetReviewQueue(res, ctx);
|
|
928
|
+
case "/api/review-activity": return handleGetReviewActivity(res, ctx);
|
|
929
|
+
case "/api/project-topics": return handleGetProjectTopics(res, url, ctx);
|
|
930
|
+
case "/api/project-reference-list": return handleGetProjectReferenceList(res, url, ctx);
|
|
931
|
+
case "/api/project-reference-content": return handleGetProjectReferenceContent(res, url, ctx);
|
|
932
|
+
case "/api/skills": return handleGetSkills(res, ctx);
|
|
933
|
+
case "/api/hooks": return handleGetHooks(res, ctx);
|
|
934
|
+
case "/api/scores": return handleGetScores(res, ctx);
|
|
935
|
+
case "/api/tasks": return handleGetTasks(res, ctx);
|
|
936
|
+
case "/api/settings": return handleGetSettings(res, url, ctx);
|
|
937
|
+
case "/api/config": return handleGetConfig(res, url, ctx);
|
|
938
|
+
case "/api/csrf-token": return handleGetCsrfToken(res, ctx);
|
|
939
|
+
case "/api/search": return await handleGetSearch(res, url, ctx);
|
|
940
|
+
}
|
|
941
|
+
// Prefix-matched GET routes
|
|
942
|
+
if (pathname.startsWith("/api/project-content"))
|
|
943
|
+
return handleGetProjectContent(res, url, ctx);
|
|
944
|
+
if (pathname.startsWith("/api/skill-content"))
|
|
945
|
+
return handleGetSkillContent(res, url, ctx);
|
|
946
|
+
if (pathname.startsWith("/api/graph"))
|
|
947
|
+
return await handleGetGraph(res, url, ctx);
|
|
948
|
+
if (pathname.startsWith("/api/findings/"))
|
|
949
|
+
return handleGetFindings(res, pathname, ctx);
|
|
950
|
+
}
|
|
951
|
+
// ── POST/PUT/DELETE routes ──
|
|
952
|
+
if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
|
|
953
|
+
switch (pathname) {
|
|
954
|
+
case "/api/sync": return handlePostSync(req, res, url, ctx);
|
|
955
|
+
case "/api/approve": return handlePostApprove(req, res, url, ctx);
|
|
956
|
+
case "/api/reject": return handlePostReject(req, res, url, ctx);
|
|
957
|
+
case "/api/edit": return handlePostEdit(req, res, url, ctx);
|
|
958
|
+
case "/api/skill-save": return handlePostSkillSave(req, res, url, ctx);
|
|
959
|
+
case "/api/skill-toggle": return handlePostSkillToggle(req, res, url, ctx);
|
|
960
|
+
case "/api/hook-toggle": return handlePostHookToggle(req, res, url, ctx);
|
|
961
|
+
case "/api/project-topics/save": return handlePostTopicsSave(req, res, url, ctx);
|
|
962
|
+
case "/api/project-topics/reclassify": return handlePostTopicsReclassify(req, res, url, ctx);
|
|
963
|
+
case "/api/project-topics/pin": return handlePostTopicsPin(req, res, url, ctx);
|
|
964
|
+
case "/api/project-topics/unpin": return handlePostTopicsUnpin(req, res, url, ctx);
|
|
965
|
+
case "/api/tasks/complete": return handlePostTaskAction(req, res, url, ctx, "complete");
|
|
966
|
+
case "/api/tasks/add": return handlePostTaskAction(req, res, url, ctx, "add");
|
|
967
|
+
case "/api/tasks/remove": return handlePostTaskAction(req, res, url, ctx, "remove");
|
|
968
|
+
case "/api/tasks/update": return handlePostTaskUpdate(req, res, url, ctx);
|
|
969
|
+
case "/api/settings/finding-sensitivity": return handlePostSettingsFindingSensitivity(req, res, url, ctx);
|
|
970
|
+
case "/api/settings/task-mode": return handlePostSettingsTaskMode(req, res, url, ctx);
|
|
971
|
+
case "/api/settings/proactivity": return handlePostSettingsProactivity(req, res, url, ctx);
|
|
972
|
+
case "/api/settings/auto-capture": return handlePostSettingsAutoCapture(req, res, url, ctx);
|
|
973
|
+
case "/api/settings/mcp-enabled": return handlePostSettingsMcpEnabled(req, res, url, ctx);
|
|
974
|
+
case "/api/settings/project-overrides": return handlePostSettingsProjectOverrides(req, res, url, ctx);
|
|
975
|
+
}
|
|
976
|
+
// Prefix-matched write routes
|
|
977
|
+
if (pathname.startsWith("/api/findings/"))
|
|
978
|
+
return handleFindingsWrite(req, res, url, pathname, ctx);
|
|
979
|
+
}
|
|
980
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
981
|
+
res.end("Not found");
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
export async function startWebUiServer(phrenPath, port, renderPage, profile, opts = {}) {
|
|
985
|
+
const authToken = crypto.randomUUID();
|
|
986
|
+
const csrfTokens = new Map();
|
|
987
|
+
const server = createWebUiHttpServer(phrenPath, renderPage, profile, { authToken, csrfTokens });
|
|
988
|
+
const boundPort = await bindWebUiPort(server, port, Boolean(opts.allowPortFallback && port !== 0));
|
|
989
|
+
const publicUrl = `http://127.0.0.1:${boundPort}`;
|
|
990
|
+
const reviewUrl = `${publicUrl}/?_auth=${encodeURIComponent(authToken)}`;
|
|
991
|
+
const ready = await waitForWebUiReady(reviewUrl);
|
|
992
|
+
process.stdout.write(`phren web-ui running at ${publicUrl}\n`);
|
|
993
|
+
process.stderr.write(`open: ${reviewUrl}\n`);
|
|
994
|
+
if (!ready) {
|
|
995
|
+
logger.warn("web-ui", "health check did not confirm readiness before launch");
|
|
996
|
+
}
|
|
997
|
+
const shouldAutoOpen = opts.autoOpen ?? Boolean(process.stdout.isTTY);
|
|
998
|
+
if (shouldAutoOpen && ready) {
|
|
999
|
+
try {
|
|
1000
|
+
if (opts.browserLauncher)
|
|
1001
|
+
await opts.browserLauncher(reviewUrl);
|
|
1002
|
+
else
|
|
1003
|
+
await launchWebUiBrowser(reviewUrl);
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
logger.warn("web-ui", `browser launch failed: ${errorMessage(err)}`);
|
|
1007
|
+
process.stdout.write(`secure session URL: ${reviewUrl}\n`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
else if (shouldAutoOpen && !ready) {
|
|
1011
|
+
logger.warn("web-ui", "skipped auto-open because readiness check failed; use the secure URL below");
|
|
1012
|
+
process.stdout.write(`secure session URL: ${reviewUrl}\n`);
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
process.stdout.write(`secure session URL: ${reviewUrl}\n`);
|
|
1016
|
+
}
|
|
1017
|
+
await new Promise((resolve) => {
|
|
1018
|
+
const shutdown = () => {
|
|
1019
|
+
server.close(() => resolve());
|
|
1020
|
+
};
|
|
1021
|
+
process.on("SIGTERM", shutdown);
|
|
1022
|
+
process.on("SIGINT", shutdown);
|
|
1023
|
+
});
|
|
1024
|
+
}
|