cameras/cli/onvif-dashboard.mjs
Michael Mainguy 2667967cf0 Say what a scan is doing, and remember where MediaMTX went (0.1.3)
- A scan no longer looks hung: the button counts seconds against the
  chosen timeout, the page says in words that it is listening for
  cameras and asking every address in turn, the refresh afterwards has
  its own state, and the result reports how many cameras were found, how
  long it took and how many addresses were checked. scanNetwork() returns
  that report; discoverCameras() still exists on top of it.
- The config pointer now remembers the binary folder beside the data
  folder, so install-video and start can't disagree about where MediaMTX
  lives, whichever directory each is run from.

Checked through an installed tarball against the real network: 2 cameras
found, 4,093 addresses probed, 3.0 s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 20:00:30 -05:00

138 lines
5.4 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,
rememberBinDir,
SECRETS_FILE,
serverEnvironment,
} from "../dist/lib/first-run.js";
import { installMediamtx } from "../dist/lib/mediamtx-install.js";
import { adminFilePath, readAdminFile } from "../dist/lib/admin-file.js";
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();
}
// Settle every path now: the server runs from inside the package, not from here.
await serverEnvironment();
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.mjs");
const child = spawn(process.execPath, [script, ...args], {
stdio: "inherit",
env: process.env,
});
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
}
async function installVideo() {
// Remember where it goes, so starting from another directory still finds it.
const binDir = await rememberBinDir();
const result = await installMediamtx({ binDir });
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);
}