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:
@@ -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
|
||||
|
||||
|
||||
@@ -17,3 +17,4 @@ venv/
|
||||
*.pyc
|
||||
*.pyo
|
||||
Thumbs.db
|
||||
*.tgz
|
||||
|
||||
Generated
+5
@@ -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':
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user