0c247317a0
New zero-dependency package understand-anything-viewer: a small Node http server with the prebuilt dashboard embedded, replicating the dev server's endpoints and security model (127.0.0.1 bind, one-time access token on every data endpoint, graph filePath sanitisation, file-content allowlist / 1MB cap / binary rejection, .ua with legacy .understand-anything fallback). npm-packed and attached to each GitHub release as understand-anything-viewer.tgz, so teammates without Claude Code open a committed graph with: npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz <project> No npm registry publishing involved. End-to-end tests spawn the real server and cover the token gate, sanitisation, allowlist, traversal guards, and both data-directory layouts.
328 lines
11 KiB
JavaScript
328 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* understand-anything-viewer — serve a generated knowledge graph in the
|
|
* dashboard UI with nothing but Node.js. Read-only, no Claude Code, no LLM.
|
|
*
|
|
* Usage:
|
|
* understand-anything-viewer [project-dir] [--port <n>] [--no-open]
|
|
*
|
|
* The project directory (default: cwd) must contain a data directory —
|
|
* `.ua/` or legacy `.understand-anything/` — with a knowledge-graph.json
|
|
* produced by /understand.
|
|
*
|
|
* Security model mirrors the dashboard dev server (vite.config.ts):
|
|
* - binds to 127.0.0.1 only
|
|
* - every data endpoint requires the one-time ?token= printed at startup
|
|
* - graph JSON is served with node filePaths relativised to the project
|
|
* - /file-content.json only serves files listed in the graph, capped at
|
|
* 1 MB, never binary
|
|
*/
|
|
import { createServer } from "node:http";
|
|
import { spawn } from "node:child_process";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const DIST_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist");
|
|
const MAX_SOURCE_FILE_BYTES = 1024 * 1024;
|
|
// Legacy directory first — projects analyzed before the `.ua` rename keep
|
|
// their existing `.understand-anything/` data.
|
|
const UA_DIR_CANDIDATES = [".understand-anything", ".ua"];
|
|
|
|
// ── CLI args ───────────────────────────────────────────────────────────────
|
|
|
|
const args = process.argv.slice(2);
|
|
let projectRoot = process.cwd();
|
|
let port = 5173;
|
|
let portExplicit = false;
|
|
let openBrowser = true;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (a === "--port") {
|
|
port = Number(args[++i]);
|
|
portExplicit = true;
|
|
// 0 asks the OS for any free port.
|
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
console.error("Error: --port must be an integer between 0 and 65535");
|
|
process.exit(1);
|
|
}
|
|
} else if (a === "--no-open") {
|
|
openBrowser = false;
|
|
} else if (a === "--help" || a === "-h") {
|
|
console.log("Usage: understand-anything-viewer [project-dir] [--port <n>] [--no-open]");
|
|
process.exit(0);
|
|
} else if (!a.startsWith("-")) {
|
|
projectRoot = path.resolve(a);
|
|
} else {
|
|
console.error(`Error: unknown option ${a}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (!fs.existsSync(DIST_DIR)) {
|
|
console.error(
|
|
"Error: embedded dashboard build not found. This tarball was packed " +
|
|
"without running the build — run `pnpm --filter understand-anything-viewer build` first.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const graphDir = UA_DIR_CANDIDATES
|
|
.map((d) => path.join(projectRoot, d))
|
|
.find((d) => fs.existsSync(path.join(d, "knowledge-graph.json")));
|
|
|
|
if (!graphDir) {
|
|
console.error(
|
|
`Error: no knowledge graph found under ${projectRoot}\n` +
|
|
"Expected .ua/knowledge-graph.json (or legacy .understand-anything/). " +
|
|
"Generate one with /understand first, or pass the project directory as an argument.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const ACCESS_TOKEN = process.env.UNDERSTAND_ACCESS_TOKEN || crypto.randomBytes(16).toString("hex");
|
|
|
|
// ── Helpers (mirroring vite.config.ts) ────────────────────────────────────
|
|
|
|
function sendJson(res, statusCode, payload) {
|
|
res.statusCode = statusCode;
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function normalizeGraphPath(filePath) {
|
|
const rawPath = path.isAbsolute(filePath)
|
|
? filePath.startsWith(projectRoot)
|
|
? path.relative(projectRoot, filePath)
|
|
: null
|
|
: filePath;
|
|
if (rawPath === null) return null;
|
|
const normalized = path.normalize(rawPath);
|
|
if (
|
|
!normalized ||
|
|
normalized === "." ||
|
|
normalized.includes("\0") ||
|
|
normalized === ".." ||
|
|
normalized.startsWith(`..${path.sep}`) ||
|
|
path.isAbsolute(normalized)
|
|
) {
|
|
return null;
|
|
}
|
|
return normalized.split(path.sep).join("/");
|
|
}
|
|
|
|
function graphFilePathSet() {
|
|
const allowed = new Set();
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(path.join(graphDir, "knowledge-graph.json"), "utf-8"));
|
|
for (const node of raw.nodes ?? []) {
|
|
if (typeof node.filePath !== "string") continue;
|
|
const normalized = normalizeGraphPath(node.filePath);
|
|
if (normalized) allowed.add(normalized);
|
|
}
|
|
} catch {
|
|
return allowed;
|
|
}
|
|
return allowed;
|
|
}
|
|
|
|
function detectLanguage(filePath) {
|
|
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
const byExt = {
|
|
bash: "bash", c: "c", cc: "cpp", cpp: "cpp", cs: "csharp", css: "css",
|
|
go: "go", h: "c", hpp: "cpp", html: "markup", java: "java",
|
|
js: "javascript", jsx: "jsx", json: "json", md: "markdown",
|
|
mjs: "javascript", py: "python", rb: "ruby", rs: "rust", sh: "bash",
|
|
ts: "typescript", tsx: "tsx", txt: "text", yaml: "yaml", yml: "yaml",
|
|
};
|
|
return byExt[ext] ?? "text";
|
|
}
|
|
|
|
function readSourceFile(url) {
|
|
const reject = (message, statusCode = 400) => ({ statusCode, payload: { error: message } });
|
|
const requestedPath = url.searchParams.get("path") ?? "";
|
|
if (!requestedPath) return reject("Missing path");
|
|
if (requestedPath.includes("\0")) return reject("Invalid path");
|
|
if (path.isAbsolute(requestedPath)) return reject("Absolute paths are not allowed");
|
|
|
|
const normalizedPath = path.normalize(requestedPath);
|
|
if (
|
|
normalizedPath === "." ||
|
|
normalizedPath.startsWith(`..${path.sep}`) ||
|
|
normalizedPath === ".." ||
|
|
path.isAbsolute(normalizedPath)
|
|
) {
|
|
return reject("Path must stay inside the project");
|
|
}
|
|
|
|
const absoluteFile = path.resolve(projectRoot, normalizedPath);
|
|
const relativeToRoot = path.relative(projectRoot, absoluteFile);
|
|
if (
|
|
!relativeToRoot ||
|
|
relativeToRoot.startsWith(`..${path.sep}`) ||
|
|
relativeToRoot === ".." ||
|
|
path.isAbsolute(relativeToRoot)
|
|
) {
|
|
return reject("Path must stay inside the project");
|
|
}
|
|
const safeRelativePath = relativeToRoot.split(path.sep).join("/");
|
|
if (!graphFilePathSet().has(safeRelativePath)) {
|
|
return reject("File is not in the knowledge graph", 404);
|
|
}
|
|
|
|
let stat;
|
|
try {
|
|
stat = fs.statSync(absoluteFile);
|
|
} catch {
|
|
return reject("File not found", 404);
|
|
}
|
|
if (!stat.isFile()) return reject("Path is not a file");
|
|
if (stat.size > MAX_SOURCE_FILE_BYTES) return reject("File is too large to preview", 413);
|
|
|
|
const buffer = fs.readFileSync(absoluteFile);
|
|
if (buffer.includes(0)) return reject("Binary files cannot be previewed", 415);
|
|
|
|
const content = buffer.toString("utf8");
|
|
return {
|
|
statusCode: 200,
|
|
payload: {
|
|
path: safeRelativePath,
|
|
language: detectLanguage(relativeToRoot),
|
|
content,
|
|
sizeBytes: buffer.byteLength,
|
|
lineCount: content.length === 0 ? 0 : content.split(/\r\n|\n|\r/).length,
|
|
},
|
|
};
|
|
}
|
|
|
|
function serveGraphJson(res, fileName) {
|
|
const candidate = path.join(graphDir, fileName);
|
|
if (fs.existsSync(candidate)) {
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(candidate, "utf-8"));
|
|
// Sanitise absolute node filePaths so the developer's directory
|
|
// layout is never sent to the browser.
|
|
if (Array.isArray(raw.nodes)) {
|
|
raw.nodes = raw.nodes.map((node) => {
|
|
if (typeof node.filePath !== "string") return node;
|
|
const abs = node.filePath;
|
|
const rel = abs.startsWith(projectRoot)
|
|
? abs.slice(projectRoot.length).replace(/^[\\/]/, "")
|
|
: path.isAbsolute(abs)
|
|
? path.basename(abs)
|
|
: abs;
|
|
return { ...node, filePath: rel };
|
|
});
|
|
}
|
|
sendJson(res, 200, raw);
|
|
} catch {
|
|
sendJson(res, 500, { error: "Failed to read graph file" });
|
|
}
|
|
return;
|
|
}
|
|
if (fileName === "knowledge-graph.json") {
|
|
sendJson(res, 404, { error: "No knowledge graph found. Run /understand first." });
|
|
} else {
|
|
res.statusCode = 404;
|
|
res.end();
|
|
}
|
|
}
|
|
|
|
const CONTENT_TYPES = {
|
|
".css": "text/css", ".html": "text/html", ".ico": "image/x-icon",
|
|
".js": "text/javascript", ".json": "application/json", ".map": "application/json",
|
|
".png": "image/png", ".svg": "image/svg+xml", ".txt": "text/plain",
|
|
".wasm": "application/wasm", ".woff": "font/woff", ".woff2": "font/woff2",
|
|
};
|
|
|
|
function serveStatic(res, pathname) {
|
|
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
|
const absolute = path.resolve(DIST_DIR, relative);
|
|
if (absolute !== DIST_DIR && !absolute.startsWith(DIST_DIR + path.sep)) {
|
|
res.statusCode = 403;
|
|
res.end("Forbidden");
|
|
return;
|
|
}
|
|
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
|
|
res.statusCode = 404;
|
|
res.end("Not found");
|
|
return;
|
|
}
|
|
res.setHeader("Content-Type", CONTENT_TYPES[path.extname(absolute).toLowerCase()] ?? "application/octet-stream");
|
|
res.end(fs.readFileSync(absolute));
|
|
}
|
|
|
|
// ── Server ────────────────────────────────────────────────────────────────
|
|
|
|
const PROTECTED = new Set([
|
|
"/knowledge-graph.json",
|
|
"/domain-graph.json",
|
|
"/diff-overlay.json",
|
|
"/meta.json",
|
|
"/config.json",
|
|
"/file-content.json",
|
|
]);
|
|
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
const pathname = url.pathname;
|
|
|
|
if (!PROTECTED.has(pathname)) {
|
|
serveStatic(res, pathname);
|
|
return;
|
|
}
|
|
|
|
if (url.searchParams.get("token") !== ACCESS_TOKEN) {
|
|
sendJson(res, 403, { error: "Forbidden: missing or invalid token" });
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/file-content.json") {
|
|
const result = readSourceFile(url);
|
|
sendJson(res, result.statusCode, result.payload);
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/config.json") {
|
|
const candidate = path.join(graphDir, "config.json");
|
|
if (fs.existsSync(candidate)) {
|
|
try {
|
|
sendJson(res, 200, JSON.parse(fs.readFileSync(candidate, "utf-8")));
|
|
} catch {
|
|
sendJson(res, 500, { error: "Failed to read config file" });
|
|
}
|
|
return;
|
|
}
|
|
sendJson(res, 200, { autoUpdate: false, outputLanguage: "en" });
|
|
return;
|
|
}
|
|
|
|
serveGraphJson(res, pathname.slice(1));
|
|
});
|
|
|
|
function listen(attemptPort, attemptsLeft) {
|
|
server.once("error", (err) => {
|
|
if (err.code === "EADDRINUSE" && !portExplicit && attemptsLeft > 0) {
|
|
listen(attemptPort + 1, attemptsLeft - 1);
|
|
} else {
|
|
console.error(`Error: could not bind 127.0.0.1:${attemptPort} — ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
server.listen(attemptPort, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
const boundPort = typeof address === "object" && address ? address.port : attemptPort;
|
|
const dashboardUrl = `http://127.0.0.1:${boundPort}/?token=${ACCESS_TOKEN}`;
|
|
console.log(`\n Serving graph from ${graphDir}`);
|
|
console.log(` 🔑 Dashboard URL: ${dashboardUrl}\n`);
|
|
if (openBrowser) {
|
|
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
spawn(opener, [dashboardUrl], { shell: process.platform === "win32", stdio: "ignore", detached: true }).unref();
|
|
}
|
|
});
|
|
}
|
|
|
|
listen(port, 10);
|