Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ intentionally absent until their authority contracts are available.
- pnpm 10
- a Coven daemon that exposes the Phase 1 memory reads

## Install and launch

Install the local dashboard executable from npm:

```bash
npm install -g @opencoven/coven-memory-dashboard
coven-memory-dashboard
```

The executable starts the packaged production server, validates the emitted
bare loopback URL, opens it with a shell-free platform browser command, and
stays attached to the server process. When the dashboard is installed as the
optional companion to the Coven npm CLI, `coven memory open` launches the same
entrypoint.

The npm artifact contains the application build and runtime code only. It
contains no memory, daemon credential, launch token, or machine-specific build
root. Genuine data is requested at runtime from the local Coven daemon and
never becomes part of the package.

## Development

```bash
Expand Down
191 changes: 191 additions & 0 deletions bin/coven-memory-dashboard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import { constants as osConstants } from "node:os";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";

const modulePath = fileURLToPath(import.meta.url);
const packageRoot = resolve(dirname(modulePath), "..");
const serverPath = resolve(packageRoot, "server.ts");
const launchPattern = /(?:^|\n)Coven Memory: ([^\r\n]+)/;

export function parseLaunchUrl(output) {
const match = launchPattern.exec(output);
if (!match) {
return null;
}

const emitted = match[1];
const emittedLoopback =
/^http:\/\/(?:127\.0\.0\.1|\[::1\]):([1-9]\d{0,4})\/$/.exec(
emitted
);
if (!emittedLoopback || Number(emittedLoopback[1]) > 65_535) {
throw new Error("refusing invalid launch URL");
}

let url;
try {
url = new URL(emitted);
} catch {
throw new Error("refusing invalid launch URL");
}
const loopback =
url.protocol === "http:" &&
(url.hostname === "127.0.0.1" || url.hostname === "[::1]") &&
!url.username &&
!url.password &&
url.pathname === "/" &&
!url.search &&
!url.hash;
if (!loopback) {
throw new Error("refusing invalid launch URL");
}
return url;
}

export function browserCommand(platform, url) {
if (platform === "darwin") {
return { program: "open", args: [url] };
}
if (platform === "win32") {
return {
program: "rundll32.exe",
args: ["url.dll,FileProtocolHandler", url]
};
}
return { program: "xdg-open", args: [url] };
}

export function isMainModule(
moduleFile,
argvFile,
canonicalize = realpathSync
) {
try {
return canonicalize(moduleFile) === canonicalize(argvFile);
} catch {
return false;
}
}

export function signalExitCode(signal, signals = osConstants.signals) {
const signalNumber = signals[signal];
return signalNumber === undefined ? 1 : 128 + signalNumber;
}

function openBrowser(url) {
if (process.env.COVEN_MEMORY_NO_BROWSER === "1") {
return;
}
const spec = browserCommand(process.platform, url.href);
const opener = spawn(spec.program, spec.args, {
detached: true,
stdio: "ignore",
windowsHide: true
});
opener.on("error", (error) => {
process.stderr.write(
`Could not open the browser automatically: ${error.message}\n`
);
});
opener.unref();
}

export function run() {
const child = spawn(process.execPath, ["--import", "tsx", serverPath], {
cwd: packageRoot,
env: { ...process.env, NODE_ENV: "production" },
stdio: ["inherit", "pipe", "inherit"],
windowsHide: false
});
let buffered = "";
let opened = false;
let startupFailed = false;

function failStartup(message) {
if (startupFailed) {
return;
}
startupFailed = true;
process.stderr.write(`${message}\n`);
process.exitCode = 1;
child.kill("SIGTERM");
}

child.stdout.on("data", (chunk) => {
process.stdout.write(chunk);
if (opened || startupFailed) {
return;
}
buffered += chunk.toString("utf8");
if (buffered.length > 64 * 1024) {
failStartup("Dashboard startup output exceeded limit.");
return;
}
let launchUrl;
try {
launchUrl = parseLaunchUrl(buffered);
} catch (error) {
failStartup(
`Refused dashboard launch URL: ${
error instanceof Error ? error.message : "invalid output"
}`
);
return;
}
if (launchUrl) {
opened = true;
buffered = "";
openBrowser(launchUrl);
}
});

const signalHandlers = new Map();
for (const signal of ["SIGINT", "SIGTERM"]) {
const handler = () => {
if (!child.killed) {
child.kill(signal);
}
};
signalHandlers.set(signal, handler);
process.on(signal, handler);
}

child.on("error", (error) => {
process.stderr.write(`Failed to start Coven Memory: ${error.message}\n`);
process.exitCode = 1;
});

child.on("exit", (code, signal) => {
if (signal) {
if (!startupFailed) {
const handler = signalHandlers.get(signal);
if (handler) {
process.off(signal, handler);
}
if (process.platform === "win32") {
process.exit(signalExitCode(signal));
} else {
process.kill(process.pid, signal);
}
}
return;
}
if (!opened && !startupFailed) {
process.stderr.write(
"Coven Memory exited before emitting a launch URL.\n"
);
process.exitCode = 1;
return;
}
if (!startupFailed) {
process.exitCode = code ?? 1;
}
});
}

if (process.argv[1] && isMainModule(modulePath, process.argv[1])) {
run();
}
40 changes: 38 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,49 @@
{
"name": "@opencoven/coven-memory-dashboard",
"version": "0.1.0",
"private": true,
"private": false,
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"packageManager": "pnpm@10.34.0",
"bin": {
"coven-memory-dashboard": "bin/coven-memory-dashboard.mjs"
},
"files": [
".next/BUILD_ID",
".next/app-path-routes-manifest.json",
".next/build-manifest.json",
".next/package.json",
".next/prerender-manifest.json",
".next/required-server-files.json",
".next/routes-manifest.json",
".next/server",
".next/static",
"bin/coven-memory-dashboard.mjs",
"src/lib/memory-types.ts",
"src/server/api-response.ts",
"src/server/daemon-transport.ts",
"src/server/listen-options.ts",
"src/server/local-transport.ts",
"src/server/memory-contract.ts",
"src/server/memory-gateway.ts",
"src/server/request-guard.ts",
"src/server/runtime.ts",
"src/server/security-headers.ts",
"README.md",
"next-env.d.ts",
"next.config.ts",
"server.ts",
"tsconfig.json"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "tsx server.ts",
"build": "next build",
"build:package": "pnpm build && node scripts/sanitize-build-artifact.mjs",
"start": "NODE_ENV=production tsx server.ts",
"lint": "eslint . --max-warnings=0",
"typecheck": "tsc --noEmit",
Expand All @@ -19,6 +53,8 @@
"audit:prod": "pnpm audit --prod --audit-level high",
"fake-daemon": "node scripts/fake-memory-daemon.mjs",
"test:smoke": "node scripts/smoke-dashboard.mjs",
"test:package": "pnpm build:package && node --test scripts/dashboard-bin.test.mjs scripts/sanitize-build-artifact.test.mjs && node scripts/package-contents-test.mjs",
"prepack": "pnpm build:package",
"check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke && ./scripts/guard-scan.sh"
},
"dependencies": {
Expand All @@ -27,6 +63,7 @@
"react": "19.2.7",
"react-dom": "19.2.7",
"react-markdown": "10.1.0",
"tsx": "4.20.6",
"zod": "4.4.3"
},
"devDependencies": {
Expand All @@ -39,7 +76,6 @@
"eslint-config-next": "16.2.11",
"jsdom": "^27.4.0",
"playwright": "1.62.0",
"tsx": "^4.20.6",
"typescript": "6.0.3",
"vitest": "4.1.9"
},
Expand Down
Loading
Loading