Adds the `codegraph ui [path]` command (alias `web`) and `src/ui-server/`, a `node:http` server with no framework and no new dependency. The command reads an index that already exists — it never creates one, so a missing index prints the same friendly guidance the MCP tools give instead of a stack trace, and a sensitive system directory is refused up front. Security is the substance here, not the routing. The server binds 127.0.0.1 only, answers GET and HEAD only, and sends no CORS headers ever. The realistic attack on a process that serves your source code from a local port is DNS rebinding, so every request must carry a loopback `Host` (on our port) and, if it carries an `Origin` at all, a loopback one — anything else is 403 before the filesystem is touched. Every path resolves through the engine's existing `validatePathWithinRoot` chokepoint, which already handles `../` traversal and in-tree symlinks pointing out of the root (#527); `..` segments are refused outright so a traversal attempt gets a 404 rather than the SPA shell. `PathRefusalError` moves from `mcp/tools.ts` into the dependency-free `errors.ts` (re-exported from its old home, so class identity and every `instanceof` check are unchanged) — that is what lets a non-MCP read sink enforce the same refusal without importing the MCP tool graph. Assets come from `dist/viewer/` resolved relative to `__dirname`, the way `db/index.ts` finds `schema.sql`. Hashed assets are cached immutably, `index.html` never. Port 4747, or the next free one — an explicit `--port` stays explicit rather than silently moving. `--no-open` skips the browser, and `CODEGRAPH_BROWSER` picks one (or `none` to suppress it), which is also what makes "did it open a browser" testable end to end. `resolveProjectFile` and the `/api/` handler seam are the boundary CG-42's JSON API plugs into; `/api/*` 404s as JSON so a typo'd endpoint never returns the app shell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
77 lines
2.7 KiB
TypeScript
77 lines
2.7 KiB
TypeScript
/**
|
|
* Locating the built browser viewer on disk.
|
|
*
|
|
* The viewer is a static Vite build that ships inside the package, exactly like
|
|
* `schema.sql` and the tree-sitter grammars: emitted into `dist/viewer/`,
|
|
* copied wholesale by `scripts/build-bundle.sh`, packed by
|
|
* `scripts/pack-npm.sh`. So it is found the same way `db/index.ts` finds
|
|
* `schema.sql` — relative to `__dirname`, never to `process.cwd()`, which is
|
|
* whatever directory the user happened to be standing in.
|
|
*
|
|
* `dist/viewer`, NOT `dist/ui`: `src/ui/` is the engine's TERMINAL ui and tsc
|
|
* already compiles it to `dist/ui/`. See `ui/vite.config.ts`.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { VIEWER_PATH_ENV } from './constants';
|
|
|
|
export { VIEWER_PATH_ENV };
|
|
|
|
/**
|
|
* The viewer build is missing — the package was assembled without it, or the
|
|
* repo was built with `tsc` alone. Carries user-facing remediation rather than
|
|
* a stack trace, because the CLI prints `.message` verbatim.
|
|
*/
|
|
export class ViewerMissingError extends Error {
|
|
constructor(searched: readonly string[]) {
|
|
super(
|
|
'The CodeGraph viewer assets are missing from this installation.\n' +
|
|
'Looked in:\n' +
|
|
searched.map((p) => ` ${p}`).join('\n') +
|
|
'\n\nIf you installed CodeGraph normally, reinstall it — the release bundle ' +
|
|
'ships the viewer.\nIf you are working from a source checkout, run: npm run build'
|
|
);
|
|
this.name = 'ViewerMissingError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Candidate locations for the viewer, most-specific first.
|
|
*
|
|
* 1. The `CODEGRAPH_VIEWER_PATH` override.
|
|
* 2. `<__dirname>/../viewer` — the shipped layout (`dist/ui-server/` →
|
|
* `dist/viewer/`).
|
|
* 3. `<__dirname>/../../dist/viewer` — running the TypeScript straight out of
|
|
* `src/` (vitest, tsx), where `__dirname` is `src/ui-server/`.
|
|
*/
|
|
export function viewerDirCandidates(): string[] {
|
|
const override = process.env[VIEWER_PATH_ENV]?.trim();
|
|
const candidates = [
|
|
path.join(__dirname, '..', 'viewer'),
|
|
path.join(__dirname, '..', '..', 'dist', 'viewer'),
|
|
];
|
|
return override ? [path.resolve(override), ...candidates] : candidates;
|
|
}
|
|
|
|
/**
|
|
* Resolve the directory holding the built viewer.
|
|
*
|
|
* @throws {ViewerMissingError} when no candidate contains an `index.html`.
|
|
*/
|
|
export function resolveViewerDir(): string {
|
|
const candidates = viewerDirCandidates();
|
|
for (const dir of candidates) {
|
|
try {
|
|
if (fs.statSync(path.join(dir, 'index.html')).isFile()) {
|
|
// realpath so the containment checks in `security.ts` compare like for
|
|
// like when the install lives behind a symlink (Homebrew, nvm, pnpm).
|
|
return fs.realpathSync(dir);
|
|
}
|
|
} catch {
|
|
// Not here — try the next candidate.
|
|
}
|
|
}
|
|
throw new ViewerMissingError(candidates);
|
|
}
|