Files
lum1104--understand-anything/understand-anything-plugin/packages/viewer/bin/viewer.mjs
T
Lum1104 0c247317a0 feat(viewer): standalone dashboard viewer runnable via npx from release assets
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.
2026-07-10 17:57:06 +08:00

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);