cameras/cli/onvif-dashboard.mjs
Michael Mainguy 30b1f36e1b 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>
2026-09-19 19:09:13 -05:00

129 lines
5.2 KiB
JavaScript
Executable File

#!/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";
import { applyEnvironment, firstRun, SECRETS_FILE } from "../src/lib/first-run.ts";
import { installMediamtx } from "../src/lib/mediamtx-install.ts";
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
Environment:
CAMERAS_DATA_DIR where data lives (asked once, then remembered)
ADMIN_AUTH_FILE, CAMERA_REGISTRY_FILE, CAMERA_CREDENTIALS_FILE,
AUDIT_LOG_FILE, RECORDINGS_DIR, MEDIAMTX_BIN override individual paths
`;
/** Asks a question when there's a terminal; returns undefined when there isn't. */
function prompter() {
if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined;
return async (question, fallback) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
return await rl.question(`${question} [${fallback}] `);
} finally {
rl.close();
}
};
}
async function setup() {
const ask = prompter();
const result = await firstRun({ ask });
console.log(`\nData folder: ${result.dataDir}`);
console.log(`Key for stored camera logins: ${path.join(result.dataDir, SECRETS_FILE)}`);
if (!result.videoReady) {
const install = ask ? await ask("Download MediaMTX now? Live video and recording need it. (y/n)", "y") : "n";
if (/^y/i.test(install || "y")) await installVideo();
else console.log("Skipped. Run onvif-dashboard install-video when you want live video.");
} else {
console.log("Live video: MediaMTX is installed.");
}
await applyEnvironment();
const admin = await readAdminFile(adminFilePath()).catch(() => null);
console.log(
admin
? `\nAdmin login: already set up (${adminFilePath()}).`
: `\nNo admin login yet. Start the app and open it in a browser: it prints a one-time setup code` +
` in this console, which you enter to create the login. You can skip that, but then anyone on` +
` your network can use it.`,
);
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(
result.status === "installed"
? `Installed MediaMTX ${result.version} at ${result.binary} (checksum verified).`
: `MediaMTX ${result.version} is already installed at ${result.binary}.`,
);
}
const { positionals } = parseArgs({ allowPositionals: true, strict: false });
const command = positionals[0] ?? "help";
try {
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 {
console.error(`Unknown command: ${command}\n`);
console.log(HELP);
process.exit(2);
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}