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.
This commit is contained in:
Lum1104
2026-07-10 17:57:06 +08:00
parent e5b6442178
commit 0c247317a0
8 changed files with 576 additions and 0 deletions
+3
View File
@@ -51,6 +51,9 @@ jobs:
- name: Build skill
run: pnpm --filter @understand-anything/skill build
- name: Build viewer
run: pnpm --filter understand-anything-viewer build
- name: Test core
run: pnpm --filter @understand-anything/core test
+1
View File
@@ -17,3 +17,4 @@ venv/
*.pyc
*.pyo
Thumbs.db
*.tgz
+5
View File
@@ -172,6 +172,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
zustand:
specifier: ^5.0.0
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
@@ -211,6 +214,8 @@ importers:
understand-anything-plugin/packages/tree-sitter-swift-wasm: {}
understand-anything-plugin/packages/viewer: {}
packages:
'@ampproject/remapping@2.3.0':
+158
View File
@@ -0,0 +1,158 @@
// End-to-end tests for the standalone viewer (packages/viewer/bin/viewer.mjs).
// Spawns the real server against a fixture project and exercises the token
// gate, graph sanitisation, file-content allowlist, and .ua/legacy resolution.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const VIEWER_BIN = join(
REPO_ROOT,
"understand-anything-plugin",
"packages",
"viewer",
"bin",
"viewer.mjs",
);
const VIEWER_DIST = join(REPO_ROOT, "understand-anything-plugin", "packages", "viewer", "dist");
function fixtureGraph() {
return {
version: "1.0.0",
project: {
name: "fixture", languages: ["ts"], frameworks: [], description: "d",
analyzedAt: "t", gitCommitHash: "",
},
nodes: [
{
id: "file:src/a.ts", type: "file", name: "a.ts", filePath: "src/a.ts",
summary: "s", tags: [], complexity: "simple",
},
],
edges: [],
layers: [],
tour: [],
};
}
function setupProject(dataDirName) {
const root = mkdtempSync(join(tmpdir(), "ua-viewer-"));
const dataDir = join(root, dataDirName);
mkdirSync(dataDir, { recursive: true });
writeFileSync(join(dataDir, "knowledge-graph.json"), JSON.stringify(fixtureGraph()));
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, "src", "a.ts"), "export const a = 1;\n");
writeFileSync(join(root, "secret.txt"), "not in graph\n");
return root;
}
/** Start the viewer and wait for the printed URL. Returns { proc, url, token, port }. */
function startViewer(projectRoot) {
return new Promise((resolvePromise, rejectPromise) => {
const proc = spawn(
process.execPath,
[VIEWER_BIN, projectRoot, "--no-open", "--port", "0"],
{ env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"] },
);
let out = "";
const timer = setTimeout(() => {
proc.kill();
rejectPromise(new Error(`viewer did not start.\n${out}`));
}, 10_000);
const onData = (chunk) => {
out += String(chunk);
const m = out.match(/http:\/\/127\.0\.0\.1:(\d+)\/\?token=([a-f0-9]+)/);
if (m) {
clearTimeout(timer);
resolvePromise({ proc, url: m[0], port: Number(m[1]), token: m[2] });
}
};
proc.stdout.on("data", onData);
proc.stderr.on("data", onData);
proc.on("exit", (code) => {
clearTimeout(timer);
rejectPromise(new Error(`viewer exited with ${code}.\n${out}`));
});
});
}
describe.skipIf(!existsSync(VIEWER_DIST))("understand-anything-viewer", () => {
let root;
let viewer;
beforeAll(async () => {
root = setupProject(".ua");
viewer = await startViewer(root);
}, 15_000);
afterAll(() => {
viewer?.proc.kill();
if (root) rmSync(root, { recursive: true, force: true });
});
const base = () => `http://127.0.0.1:${viewer.port}`;
it("serves the embedded dashboard index", async () => {
const res = await fetch(`${base()}/`);
expect(res.status).toBe(200);
expect(await res.text()).toContain("<!doctype html>");
});
it("rejects data requests without the token", async () => {
const res = await fetch(`${base()}/knowledge-graph.json`);
expect(res.status).toBe(403);
});
it("serves the graph from .ua/ with a valid token", async () => {
const res = await fetch(`${base()}/knowledge-graph.json?token=${viewer.token}`);
expect(res.status).toBe(200);
const graph = await res.json();
expect(graph.nodes).toHaveLength(1);
expect(graph.nodes[0].filePath).toBe("src/a.ts");
});
it("serves file content only for files listed in the graph", async () => {
const ok = await fetch(
`${base()}/file-content.json?token=${viewer.token}&path=${encodeURIComponent("src/a.ts")}`,
);
expect(ok.status).toBe(200);
const body = await ok.json();
expect(body.content).toContain("export const a");
expect(body.language).toBe("typescript");
const denied = await fetch(
`${base()}/file-content.json?token=${viewer.token}&path=secret.txt`,
);
expect(denied.status).toBe(404);
});
it("rejects path traversal in file-content", async () => {
const res = await fetch(
`${base()}/file-content.json?token=${viewer.token}&path=${encodeURIComponent("../outside.txt")}`,
);
expect(res.status).toBe(400);
});
it("blocks static requests escaping dist/", async () => {
const res = await fetch(`${base()}/%2e%2e/package.json`);
expect([403, 404]).toContain(res.status);
});
it("falls back to legacy .understand-anything/ projects", async () => {
const legacyRoot = setupProject(".understand-anything");
const legacyViewer = await startViewer(legacyRoot);
try {
const res = await fetch(
`http://127.0.0.1:${legacyViewer.port}/knowledge-graph.json?token=${legacyViewer.token}`,
);
expect(res.status).toBe(200);
expect((await res.json()).nodes).toHaveLength(1);
} finally {
legacyViewer.proc.kill();
rmSync(legacyRoot, { recursive: true, force: true });
}
}, 15_000);
});
@@ -0,0 +1,26 @@
# understand-anything-viewer
Standalone read-only viewer for [Understand-Anything](https://github.com/Egonex-AI/Understand-Anything) knowledge graphs. Opens the full interactive dashboard for a graph that was already generated with `/understand` — no Claude Code, no LLM, no API key. Only Node.js (>= 18) is required.
## Usage
Run the tarball attached to each GitHub release directly (no npm registry involved):
```bash
npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz /path/to/analyzed/project
```
The project directory (default: current directory) must contain a data directory — `.ua/` or legacy `.understand-anything/` — with a `knowledge-graph.json`. The terminal prints a tokenized URL (`http://127.0.0.1:<port>/?token=…`) and opens it in your browser.
Options: `--port <n>` (default 5173, auto-increments if taken), `--no-open`.
Everything is served read-only from local disk, bound to `127.0.0.1`, and gated behind a one-time access token — no data leaves your machine.
## Building the tarball (maintainers)
```bash
pnpm --filter understand-anything-viewer pack:release
gh release upload <tag> understand-anything-plugin/packages/viewer/understand-anything-viewer-*.tgz
```
The pack step builds the dashboard and embeds its compiled `dist/` into the package, producing a fully self-contained, zero-dependency tarball.
@@ -0,0 +1,327 @@
#!/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);
@@ -0,0 +1,29 @@
#!/usr/bin/env node
/**
* Build the standalone viewer: build the dashboard (and its core dependency),
* then embed the compiled frontend under this package's dist/ so the packed
* tarball is fully self-contained (no runtime dependencies).
*
* Run from anywhere inside the monorepo:
* pnpm --filter understand-anything-viewer build
*/
import { execSync } from "node:child_process";
import { cpSync, rmSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const dashboardDist = join(here, "..", "dashboard", "dist");
const viewerDist = join(here, "dist");
execSync("pnpm --filter @understand-anything/core build", { stdio: "inherit", cwd: here });
execSync("pnpm --filter @understand-anything/dashboard build", { stdio: "inherit", cwd: here });
if (!existsSync(dashboardDist)) {
console.error(`Error: dashboard build output not found at ${dashboardDist}`);
process.exit(1);
}
rmSync(viewerDist, { recursive: true, force: true });
cpSync(dashboardDist, viewerDist, { recursive: true });
console.log(`Embedded dashboard build into ${viewerDist}`);
@@ -0,0 +1,27 @@
{
"name": "understand-anything-viewer",
"version": "2.9.0",
"description": "Standalone read-only viewer for Understand-Anything knowledge graphs — no Claude Code or LLM required.",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/Egonex-AI/Understand-Anything.git",
"directory": "understand-anything-plugin/packages/viewer"
},
"bin": {
"understand-anything-viewer": "bin/viewer.mjs"
},
"files": [
"bin",
"dist",
"README.md"
],
"engines": {
"node": ">=18"
},
"scripts": {
"build": "node build.mjs",
"pack:release": "node build.mjs && npm pack"
}
}