diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index ae84ae3..37d50fc 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -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"} diff --git a/cli/onvif-dashboard.mjs b/cli/onvif-dashboard.mjs index 4d0f5b1..e2cddc1 100755 --- a/cli/onvif-dashboard.mjs +++ b/cli/onvif-dashboard.mjs @@ -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 { diff --git a/cli/onvif-dashboard.test.ts b/cli/onvif-dashboard.test.ts index c645da6..929673f 100644 --- a/cli/onvif-dashboard.test.ts +++ b/cli/onvif-dashboard.test.ts @@ -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"); diff --git a/next.config.ts b/next.config.ts index b7cc26f..fc2e41b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 } }), diff --git a/package.json b/package.json index 7f90e58..bb6d4da 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/bundle-standalone.mjs b/scripts/bundle-standalone.mjs new file mode 100644 index 0000000..2f323ff --- /dev/null +++ b/scripts/bundle-standalone.mjs @@ -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.` : "."), +); diff --git a/src/app/auth-pages.test.tsx b/src/app/auth-pages.test.tsx index 169e7ce..e3b62ba 100644 --- a/src/app/auth-pages.test.tsx +++ b/src/app/auth-pages.test.tsx @@ -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's console"); - expect(html).toContain("npm run admin:create"); + expect(html).toContain("onvif-dashboard admin"); expect(html).toContain('name="code"'); }); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 0b87eba..b26f682 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -21,7 +21,7 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) {

- Forgot the password? On the server, run npm run admin:create -- --force. + Forgot the password? On the server, run onvif-dashboard admin --force.

); diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 42c3a63..4b15f0d 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -22,7 +22,7 @@ export default async function SetupPage() {

The setup code is printed in the server's console, so only someone with access to the server can claim this login. Prefer the command line? Run{" "} - npm run admin:create on the server instead. + onvif-dashboard admin on the server instead.

diff --git a/src/lib/mediamtx-supervisor.test.ts b/src/lib/mediamtx-supervisor.test.ts index 06c149a..650d5f9 100644 --- a/src/lib/mediamtx-supervisor.test.ts +++ b/src/lib/mediamtx-supervisor.test.ts @@ -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); diff --git a/src/lib/mediamtx-supervisor.ts b/src/lib/mediamtx-supervisor.ts index 0b2df44..d288897 100644 --- a/src/lib/mediamtx-supervisor.ts +++ b/src/lib/mediamtx-supervisor.ts @@ -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; } diff --git a/src/lib/setup-code.test.ts b/src/lib/setup-code.test.ts index 6055471..3ea9d8c 100644 --- a/src/lib/setup-code.test.ts +++ b/src/lib/setup-code.test.ts @@ -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)}`); }); diff --git a/src/lib/setup-code.ts b/src/lib/setup-code.ts index f22c7d7..7b1e837 100644 --- a/src/lib/setup-code.ts +++ b/src/lib/setup-code.ts @@ -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"),