Ship a standalone build and an onvif-dashboard start command

- next.config.ts builds standalone output with image optimization off,
  and scripts/bundle-standalone.mjs puts the static files beside the
  server, then drops what tracing swept in but the app never runs (the
  MediaMTX binary and sharp's platform binaries, 79 MB). The build is
  27 MB and npm pack is 4.9 MB.
- The CLI gained start, which runs that server from any directory with
  the remembered data folder and stored key in its environment, and
  admin, so the messages in the UI can name a command that exists.
- Messages that said "npm run …" now say "onvif-dashboard …".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 19:09:13 -05:00
parent 65345d0984
commit 30b1f36e1b
13 changed files with 115 additions and 10 deletions

View File

@ -1083,3 +1083,9 @@
{"id":"evt-5rfrprcqxrt0","type":"node.status_changed","subject":"iss-kz5c88a","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T00:00:01.419Z","parents":["evt-eqrerenwyntb"],"hash":"90695a74ba466023c8958bbc69904a2662e1dc9e0d76bca4eb07846891987a17"}
{"id":"evt-nwrj4gzcx3kr","type":"node.created","subject":"mea-t76mxwf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"npm packaging: 3 of 5 issues done","body":"Counted from vrek on 2026-09-19: package identity, the paths module and the first-run experience are closed with evidence. Left: iss-ej4ahga standalone build and start command, iss-9zcawn7 publish and install on a clean machine.","status":"recorded","owner":null,"attrs":{"value":3,"applies_at":"2026-09-19"}},"at":"2026-09-20T00:00:03.766Z","parents":["evt-5rfrprcqxrt0"],"hash":"cc01c984fca3860f31f63b81ea476c93a524742e2731b49ab30f14ef7c31ca5a"}
{"id":"evt-nnmw5y70jh2v","type":"edge.added","subject":"mea-t76mxwf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-t76mxwf","to":"gol-k56hc69"},"at":"2026-09-20T00:00:03.767Z","parents":["evt-nwrj4gzcx3kr"],"hash":"ced3f88a47aab1cf0e574138436e828205b800cd02c7ea30fe6a9b21f497d5f9"}
{"id":"evt-hhvg5v4mr4fe","type":"node.created","subject":"ver-0gx8s86","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The build produces a standalone server with its static files, pruned of what the app never runs, and onvif-dashboard start runs it from any directory","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T00:08:53.630Z","parents":["evt-nnmw5y70jh2v"],"hash":"b259000d6ac7367bd7f22e69abd2889955d14fddf9d35e78438be96425de6e9b"}
{"id":"evt-se7t2mq52hda","type":"edge.added","subject":"ver-0gx8s86","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-0gx8s86","to":"iss-ej4ahga"},"at":"2026-09-20T00:08:53.633Z","parents":["evt-hhvg5v4mr4fe"],"hash":"36da77eea43d6980bbd08665f6c10700ab5117832efb9ba8fce2540b8a777fb7"}
{"id":"evt-qxcfkcrdwhsr","type":"verification.recorded","subject":"ver-0gx8s86","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"next.config.ts sets output: \"standalone\" and images.unoptimized; scripts/bundle-standalone.mjs copies .next/static and public beside the server and drops the traced-but-unused MediaMTX binary and sharp's platform binaries (79 MB), leaving .next/standalone at 27 MB and npm pack at 4.9 MB. cli/onvif-dashboard.mjs gained start (PORT/HOSTNAME, signals forwarded, quiet first run when nothing is set up) and admin. Checked for real: started from /tmp with its own data folder, /login answered HTTP 307 to setup, the console printed the one-time setup code, and it reported MediaMTX missing. Messages that said \"npm run …\" now name the installed command. Full suite 937 tests pass, 99.7% lines; tsc and eslint clean."},"at":"2026-09-20T00:08:53.634Z","parents":["evt-se7t2mq52hda"],"hash":"60c1fe2b8f8358174aad5b495257ba9908ac5ba4186e5f8345a58b638e36f7ce"}
{"id":"evt-svss3f90kf71","type":"node.status_changed","subject":"iss-ej4ahga","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T00:08:56.449Z","parents":["evt-qxcfkcrdwhsr"],"hash":"86783b1bae9e8acbdeed1191d27e175226cba155011fad66fd9a4befa53edc9d"}
{"id":"evt-s93xtm5nv20z","type":"node.created","subject":"mea-mtq4xsm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"npm packaging: 4 of 5 issues done","body":"Counted from vrek on 2026-09-19: package identity, the paths module, the first-run experience and the standalone build with a start command are closed with evidence. Left: iss-9zcawn7, publishing and installing on a machine that has never held this repo, which the user has to do since publishing needs their npm account.","status":"recorded","owner":null,"attrs":{"value":4,"applies_at":"2026-09-19"}},"at":"2026-09-20T00:08:58.895Z","parents":["evt-svss3f90kf71"],"hash":"b7a4892d809e426538d084c9da3c2af3d0e91b6f21cd064b1d2b772f2fcffa52"}
{"id":"evt-w855xpb3jfwm","type":"edge.added","subject":"mea-mtq4xsm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-mtq4xsm","to":"gol-k56hc69"},"at":"2026-09-20T00:08:58.896Z","parents":["evt-s93xtm5nv20z"],"hash":"c5fa46275ff117df73f142897cbfb085a3fefd06e939b5809d64447ea56486d1"}

View File

@ -1,6 +1,8 @@
#!/usr/bin/env node
// The command people get from npm (vrek iss-kz5c88a). Node runs the TypeScript in src/lib
// directly (type stripping, Node 24+), so there is nothing to build for the CLI itself.
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import { createInterface } from "node:readline/promises";
import { parseArgs } from "node:util";
import path from "node:path";
@ -11,7 +13,9 @@ import { adminFilePath, readAdminFile } from "../src/lib/admin-file.ts";
const HELP = `onvif-dashboard — watch and manage ONVIF cameras on your own network
Usage:
onvif-dashboard start run the dashboard (PORT, HOSTNAME)
onvif-dashboard setup choose where data lives and prepare this machine
onvif-dashboard admin create or replace the admin login (--force to replace)
onvif-dashboard install-video download MediaMTX, needed for live video and recording
onvif-dashboard help this message
@ -60,6 +64,41 @@ async function setup() {
console.log("\nReady. Start it with: onvif-dashboard start");
}
/** Runs the built server, with the remembered folder and stored key in its environment. */
async function start() {
await applyEnvironment();
if (!process.env.CAMERA_CREDENTIALS_KEY) {
// Never started before: take the defaults quietly rather than refuse to run.
await firstRun();
await applyEnvironment();
}
const server = path.join(import.meta.dirname, "..", ".next", "standalone", "server.js");
if (!(await access(server).then(() => true, () => false))) {
console.error("The built server is missing. From a source checkout, run: npm run build");
process.exit(1);
}
const child = spawn(process.execPath, [server], {
cwd: path.dirname(server),
stdio: "inherit",
env: { ...process.env, PORT: process.env.PORT ?? "3000", HOSTNAME: process.env.HOSTNAME ?? "0.0.0.0" },
});
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal));
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
}
/** Creates the admin login, prompting for it; the script it runs has the interactive bits. */
async function admin(args) {
await applyEnvironment();
const script = path.join(import.meta.dirname, "..", "scripts", "create-admin.mts");
const child = spawn(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", script, ...args], {
stdio: "inherit",
env: process.env,
});
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
}
async function installVideo() {
const result = await installMediamtx();
console.log(
@ -73,7 +112,9 @@ const { positionals } = parseArgs({ allowPositionals: true, strict: false });
const command = positionals[0] ?? "help";
try {
if (command === "setup") await setup();
if (command === "start") await start();
else if (command === "setup") await setup();
else if (command === "admin") await admin(process.argv.slice(3));
else if (command === "install-video") await installVideo();
else if (command === "help" || command === "--help" || command === "-h") console.log(HELP);
else {

View File

@ -57,6 +57,11 @@ describe("onvif-dashboard", () => {
});
});
it("offers the admin command the messages point people at", async () => {
const { stdout } = await cli(["admin", "--help"]);
expect(stdout).toContain("--force");
});
it("running it again changes nothing", async () => {
await cli(["setup"]);
const secrets = path.join(dir, ".data", "secrets.env");

View File

@ -26,6 +26,13 @@ const project = process.cwd();
const turbopackRoot = linkedPackagesRoot(project, ["onvif"]);
const nextConfig: NextConfig = {
// What gets published: a minimal server plus only the files it needs, so installing the
// package doesn't drag in Next and React (vrek iss-ej4ahga).
output: "standalone",
// Runtime data and the downloaded MediaMTX binary are not part of the app.
outputFileTracingExcludes: { "/*": ["bin/**/*", ".data/**/*", "node_modules/@img/**/*", "node_modules/sharp/**/*"] },
// next/image is unused, and leaving it on pulls sharp's platform binaries (tens of MB).
images: { unoptimized: true },
// onvif uses Node's dgram/os modules; load it with native require instead of bundling.
serverExternalPackages: ["onvif"],
...(turbopackRoot && { turbopack: { root: turbopackRoot } }),

View File

@ -38,7 +38,7 @@
],
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build && node scripts/bundle-standalone.mjs",
"start": "next start",
"lint": "eslint",
"test": "vitest run",

View File

@ -0,0 +1,46 @@
// After next build, the standalone server needs the static files beside it; Next leaves
// that copy to us (Next docs, config/output.md). Also drops in the CLI's own modules, so
// the published package can run from anywhere (vrek iss-ej4ahga).
import { access, cp, mkdir, readdir, rm, stat } from "node:fs/promises";
import path from "node:path";
const root = process.cwd();
const standalone = path.join(root, ".next", "standalone");
const exists = (file) => access(file).then(() => true, () => false);
/** Bytes under a path, so the build can say what it dropped. */
async function size(target) {
const info = await stat(target);
if (!info.isDirectory()) return info.size;
let total = 0;
for (const entry of await readdir(target)) total += await size(path.join(target, entry));
return total;
}
if (!(await exists(standalone))) {
console.error("No .next/standalone: is output: 'standalone' still set in next.config.ts?");
process.exit(1);
}
await mkdir(path.join(standalone, ".next"), { recursive: true });
await cp(path.join(root, ".next", "static"), path.join(standalone, ".next", "static"), { recursive: true });
if (await exists(path.join(root, "public"))) {
await cp(path.join(root, "public"), path.join(standalone, "public"), { recursive: true });
}
// Next's tracing sweeps in things the app never runs: the MediaMTX binary that happens to
// sit in bin/, and sharp's platform binaries, which are only for next/image (unused here).
// outputFileTracingExcludes didn't keep them out, so they go now, before publishing.
const prune = ["bin", "node_modules/@img", "node_modules/sharp"];
let freed = 0;
for (const name of prune) {
const target = path.join(standalone, name);
if (!(await exists(target))) continue;
freed += await size(target);
await rm(target, { recursive: true, force: true });
}
console.log(
`Bundled the standalone server with its static files` +
(freed ? `, and dropped ${(freed / 1024 / 1024).toFixed(0)} MB it doesn't need.` : "."),
);

View File

@ -42,7 +42,7 @@ describe("/login", () => {
const html = renderToStaticMarkup(await loginPage("/?refresh=500"));
expect(html).toContain("Sign in");
expect(html).toContain('name="next" value="/?refresh=500"');
expect(html).toContain("npm run admin:create -- --force");
expect(html).toContain("onvif-dashboard admin --force");
});
it("drops an off-site destination", async () => {
@ -69,7 +69,7 @@ describe("/setup", () => {
expect(ensureSetupCode).toHaveBeenCalled();
expect(html).toContain("Secure the camera dashboard");
expect(html).toContain("printed in the server&#x27;s console");
expect(html).toContain("npm run admin:create");
expect(html).toContain("onvif-dashboard admin");
expect(html).toContain('name="code"');
});

View File

@ -21,7 +21,7 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) {
</p>
<LoginForm next={destination} />
<p className="mt-6 text-xs text-zinc-500">
Forgot the password? On the server, run <code>npm run admin:create -- --force</code>.
Forgot the password? On the server, run <code>onvif-dashboard admin --force</code>.
</p>
</main>
);

View File

@ -22,7 +22,7 @@ export default async function SetupPage() {
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
The <strong>setup code</strong> is printed in the server&apos;s console, so only someone
with access to the server can claim this login. Prefer the command line? Run{" "}
<code>npm run admin:create</code> on the server instead.
<code>onvif-dashboard admin</code> on the server instead.
</p>
<SetupForm />

View File

@ -109,7 +109,7 @@ describe("MediamtxSupervisor", () => {
children[0].emit("error", Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }));
expect(sup.status()).toEqual({
state: "missing",
detail: "MediaMTX is not installed. Run npm run video:install.",
detail: "MediaMTX is not installed. Run: onvif-dashboard install-video",
restarts: 0,
});
expect(timers).toHaveLength(0);

View File

@ -103,7 +103,7 @@ export class MediamtxSupervisor {
this.child = null;
if (err.code === "ENOENT") {
// Not installed: retrying won't help until someone runs the installer.
this.set("missing", "MediaMTX is not installed. Run npm run video:install.");
this.set("missing", "MediaMTX is not installed. Run: onvif-dashboard install-video");
this.log(`[video] ${this.current.detail}`);
return;
}

View File

@ -22,7 +22,7 @@ describe("setup code", () => {
const message = log.mock.calls[0][0];
expect(message).toContain("NOT secured");
expect(message).toContain("open /setup");
expect(message).toContain("npm run admin:create");
expect(message).toContain("onvif-dashboard admin");
expect(printedCode()).toBe(`${code.slice(0, 4)}-${code.slice(4)}`);
});

View File

@ -44,7 +44,7 @@ function announce(code: string, log: (message: string) => void) {
"",
` ${pretty(code)}`,
"",
" Or create the admin without the browser: npm run admin:create",
" Or create the admin without the browser: onvif-dashboard admin",
rule,
"",
].join("\n"),