Add the first-run experience and the onvif-dashboard command

- src/lib/first-run.ts settles where data lives (asked once, remembered
  in a pointer under the user's config dir), creates the owner-only key
  that protects stored camera logins, and reports whether MediaMTX is
  installed. Without a terminal nothing prompts: defaults are taken and
  logged, so a service still starts. Running it again changes nothing.
- cli/onvif-dashboard.mjs is the published command: setup, install-video
  and help. Node runs the TypeScript in src/lib directly, so the CLI
  needs no build of its own.

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

View File

@ -1077,3 +1077,9 @@
{"id":"evt-xvzexr3d4fyh","type":"node.status_changed","subject":"iss-rab8hnz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T23:54:47.875Z","parents":["evt-zy1a4sk0tc37"],"hash":"07560237cef3dc19f3dc9c905f46306c4a2371c023cf778ab9b74e7c6792a215"} {"id":"evt-xvzexr3d4fyh","type":"node.status_changed","subject":"iss-rab8hnz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T23:54:47.875Z","parents":["evt-zy1a4sk0tc37"],"hash":"07560237cef3dc19f3dc9c905f46306c4a2371c023cf778ab9b74e7c6792a215"}
{"id":"evt-2x3g58fk7cwy","type":"node.created","subject":"mea-kha11x6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"npm packaging: 2 of 5 issues done","body":"Counted from vrek on 2026-09-19: iss-57eqhn2 (package identity, MIT, publishable package.json) and iss-rab8hnz (every data path behind src/lib/paths.ts) are closed with evidence. Left: iss-kz5c88a first-run experience, iss-ej4ahga standalone build and start command, iss-9zcawn7 publish and install on a clean machine.","status":"recorded","owner":null,"attrs":{"value":2,"applies_at":"2026-09-19"}},"at":"2026-09-19T23:54:54.082Z","parents":["evt-xvzexr3d4fyh"],"hash":"b99544debb7dfefe2f317b7b3a1c96afe1c6567dfe3847ed952f1de8fe798668"} {"id":"evt-2x3g58fk7cwy","type":"node.created","subject":"mea-kha11x6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"npm packaging: 2 of 5 issues done","body":"Counted from vrek on 2026-09-19: iss-57eqhn2 (package identity, MIT, publishable package.json) and iss-rab8hnz (every data path behind src/lib/paths.ts) are closed with evidence. Left: iss-kz5c88a first-run experience, iss-ej4ahga standalone build and start command, iss-9zcawn7 publish and install on a clean machine.","status":"recorded","owner":null,"attrs":{"value":2,"applies_at":"2026-09-19"}},"at":"2026-09-19T23:54:54.082Z","parents":["evt-xvzexr3d4fyh"],"hash":"b99544debb7dfefe2f317b7b3a1c96afe1c6567dfe3847ed952f1de8fe798668"}
{"id":"evt-7t57v5scysa9","type":"edge.added","subject":"mea-kha11x6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-kha11x6","to":"gol-k56hc69"},"at":"2026-09-19T23:54:54.083Z","parents":["evt-2x3g58fk7cwy"],"hash":"8efd00dee34eac7287d7ff89cd51d3d1b03014d1baec7a0049e254587885439d"} {"id":"evt-7t57v5scysa9","type":"edge.added","subject":"mea-kha11x6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-kha11x6","to":"gol-k56hc69"},"at":"2026-09-19T23:54:54.083Z","parents":["evt-2x3g58fk7cwy"],"hash":"8efd00dee34eac7287d7ff89cd51d3d1b03014d1baec7a0049e254587885439d"}
{"id":"evt-e6hdherdeq9k","type":"node.created","subject":"ver-cct55bk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"First run asks where data should live with the working directory as the default and remembers the answer, creates an owner-only credential key, reports whether MediaMTX is installed and offers to fetch it, explains the admin setup, never prompts without a terminal, and changes nothing when run again","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T23:59:59.063Z","parents":["evt-7t57v5scysa9"],"hash":"30a888695e091d1c0cbceacde079815fdfafd36fd8dad9040b4a1ed3cd04c305"}
{"id":"evt-1n36vqvx16jk","type":"edge.added","subject":"ver-cct55bk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-cct55bk","to":"iss-kz5c88a"},"at":"2026-09-19T23:59:59.065Z","parents":["evt-e6hdherdeq9k"],"hash":"ea7adcf5e6de830baf1fe4fd833487aec1d3429e65d898d46c237a36e7659b66"}
{"id":"evt-eqrerenwyntb","type":"verification.recorded","subject":"ver-cct55bk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/first-run.ts with src/lib/first-run.test.ts (21 tests: the pointer file under XDG_CONFIG_HOME, env over remembered over asked over default, empty and relative answers, owner-only secrets 0600, nothing regenerated on a second run, the non-interactive path, applyEnvironment never overriding the environment). cli/onvif-dashboard.mjs with cli/onvif-dashboard.test.ts running the real command in a temporary directory. A real non-interactive run in /tmp created only .data/secrets.env and the config pointer, and touched nothing in the repo. Full suite 936 tests pass, 99.7% lines; tsc, eslint and next build clean."},"at":"2026-09-19T23:59:59.066Z","parents":["evt-1n36vqvx16jk"],"hash":"d9a6bae5f50ec8ab81e59da07a8af67a4895a503a6d1c6850b7eddb10914c2bd"}
{"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"}

87
cli/onvif-dashboard.mjs Executable file
View File

@ -0,0 +1,87 @@
#!/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 { 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 setup choose where data lives and prepare this machine
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");
}
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 === "setup") await setup();
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);
}

View File

@ -0,0 +1,68 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, realpath, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
/** Runs the published command as a real process, in a directory of its own. */
const run = promisify(execFile);
const CLI = path.join(import.meta.dirname, "onvif-dashboard.mjs");
let dir: string;
beforeEach(async () => {
// realpath because macOS temp folders are symlinks, and the CLI reports resolved paths.
dir = await realpath(await mkdtemp(path.join(os.tmpdir(), "cli-")));
});
afterEach(() => rm(dir, { recursive: true, force: true }));
const cli = (args: string[]) =>
run(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", CLI, ...args], {
cwd: dir,
env: {
...process.env,
XDG_CONFIG_HOME: path.join(dir, "config"),
CAMERAS_BIN_DIR: path.join(dir, "bin"),
CAMERAS_DATA_DIR: undefined,
},
}).catch((err: Error & { code?: number; stdout?: string; stderr?: string }) => ({
code: err.code,
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
}));
describe("onvif-dashboard", () => {
it("explains itself, and says so when the command is unknown", async () => {
expect((await cli(["help"])).stdout).toContain("onvif-dashboard setup");
const bad = (await cli(["wat"])) as { code?: number; stderr: string };
expect(bad.code).toBe(2);
expect(bad.stderr).toContain("Unknown command: wat");
});
it("sets a machine up without a terminal: default folder, a key, and what to do next", async () => {
const { stdout } = await cli(["setup"]);
const data = path.join(dir, ".data");
expect(stdout).toContain("No terminal to ask");
expect(stdout).toContain(`Data folder: ${data}`);
expect(stdout).toContain("install-video");
expect(stdout).toContain("No admin login yet");
expect(stdout).toContain("onvif-dashboard start");
const secrets = path.join(data, "secrets.env");
expect((await stat(secrets)).mode & 0o777).toBe(0o600);
expect(await readFile(secrets, "utf8")).toMatch(/CAMERA_CREDENTIALS_KEY=[0-9a-f]{64}/);
expect(JSON.parse(await readFile(path.join(dir, "config/onvif-dashboard/config.json"), "utf8"))).toEqual({
dataDir: data,
});
});
it("running it again changes nothing", async () => {
await cli(["setup"]);
const secrets = path.join(dir, ".data", "secrets.env");
const before = await readFile(secrets, "utf8");
const { stdout } = await cli(["setup"]);
expect(await readFile(secrets, "utf8")).toBe(before);
expect(stdout).not.toContain("Made a key");
});
});

View File

@ -27,6 +27,10 @@
".next/static", ".next/static",
"public", "public",
"cli", "cli",
"src/lib/paths.ts",
"src/lib/first-run.ts",
"src/lib/mediamtx-install.ts",
"src/lib/admin-file.ts",
"scripts/create-admin.mts", "scripts/create-admin.mts",
"scripts/install-mediamtx.mts", "scripts/install-mediamtx.mts",
"LICENSE", "LICENSE",
@ -68,5 +72,8 @@
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"vitest": "^5.0.1" "vitest": "^5.0.1"
},
"bin": {
"onvif-dashboard": "cli/onvif-dashboard.mjs"
} }
} }

190
src/lib/first-run.test.ts Normal file
View File

@ -0,0 +1,190 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
applyEnvironment,
configFile,
ensureSecrets,
firstRun,
parseEnvFile,
readConfig,
writeConfig,
} from "./first-run";
let home: string;
let work: string;
let cwd: string;
beforeEach(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "first-run-home-"));
work = await mkdtemp(path.join(os.tmpdir(), "first-run-data-"));
// Tests must never write into the repo, and the default folder is relative to cwd.
cwd = await mkdtemp(path.join(os.tmpdir(), "first-run-cwd-"));
vi.spyOn(process, "cwd").mockReturnValue(cwd);
});
afterEach(async () => {
for (const dir of [home, work, cwd]) await rm(dir, { recursive: true, force: true });
});
const logs: string[] = [];
const log = (line: string) => logs.push(line);
beforeEach(() => (logs.length = 0));
/** No terminal by default, and MediaMTX already installed, unless a test says otherwise. */
const run = (options: Parameters<typeof firstRun>[0] = {}) =>
firstRun({ env: {}, home, log, exists: async () => true, ...options });
describe("configFile", () => {
it("sits under the user's config directory, honouring XDG_CONFIG_HOME", () => {
expect(configFile({}, "/home/mm")).toBe("/home/mm/.config/onvif-dashboard/config.json");
expect(configFile({ XDG_CONFIG_HOME: "/cfg" }, "/home/mm")).toBe("/cfg/onvif-dashboard/config.json");
});
});
describe("readConfig / writeConfig", () => {
it("remembers the folder and reads it back", async () => {
await writeConfig("/srv/clips", {}, home);
expect(await readConfig({}, home)).toEqual({ dataDir: "/srv/clips" });
});
it.each([["", "missing file"], ["{oops", "not JSON"], ["{}", "no folder"], ['{"dataDir":3}', "wrong type"]])(
"treats %j (%s) as nothing remembered",
async (text) => {
if (text) {
await mkdir(path.dirname(configFile({}, home)), { recursive: true });
await writeFile(configFile({}, home), text);
}
expect(await readConfig({}, home)).toEqual({});
},
);
});
describe("parseEnvFile", () => {
it("reads KEY=value lines and ignores comments and noise", () => {
expect(parseEnvFile("# comment\nA=1\n B = two \n\nnot a line\nlowercase=x\nC=")).toEqual({
A: "1",
B: "two",
C: "",
});
});
});
describe("ensureSecrets", () => {
it("creates an owner-only key file once, then leaves it alone", async () => {
const first = await ensureSecrets(work);
expect(first.created).toBe(true);
expect(first.values.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/);
const file = path.join(work, "secrets.env");
expect((await stat(file)).mode & 0o777).toBe(0o600);
expect(await readFile(file, "utf8")).toContain("Keep this file private");
const again = await ensureSecrets(work);
expect(again.created).toBe(false);
expect(again.values.CAMERA_CREDENTIALS_KEY).toBe(first.values.CAMERA_CREDENTIALS_KEY);
});
it("keeps other values already in the file", async () => {
await writeFile(path.join(work, "secrets.env"), "OTHER=keep\n");
const { values } = await ensureSecrets(work);
expect(values).toMatchObject({ OTHER: "keep" });
expect(values.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/);
});
});
describe("firstRun", () => {
it("asks where data should live, offering the working directory, and remembers the answer", async () => {
const ask = vi.fn(async () => work);
const result = await run({ ask });
expect(ask).toHaveBeenCalledWith("Where should this keep its data?", path.join(process.cwd(), ".data"));
expect(result).toMatchObject({ dataDir: work, source: "answered", secretCreated: true, videoReady: true });
expect(await readConfig({}, home)).toEqual({ dataDir: work });
expect(logs.join(" ")).toContain("Made a key for stored camera logins");
});
it("takes the offered default when the answer is empty", async () => {
expect((await run({ ask: async (_q, fallback) => fallback })).dataDir).toBe(path.join(cwd, ".data"));
});
it("resolves a relative answer against the directory it was started in", async () => {
const original = process.cwd;
process.cwd = () => cwd; // path.resolve reads this directly, so the spy isn't enough
try {
expect((await run({ ask: async () => "clips" })).dataDir).toBe(path.join(cwd, "clips"));
} finally {
process.cwd = original;
}
});
it("checks for MediaMTX on disk when not told otherwise", async () => {
const env = { CAMERAS_DATA_DIR: work, MEDIAMTX_BIN: path.join(work, "mediamtx") };
expect((await firstRun({ env, home, log })).videoReady).toBe(false);
await writeFile(env.MEDIAMTX_BIN, "#!/bin/sh\n");
expect((await firstRun({ env, home, log })).videoReady).toBe(true);
});
it("never blocks without a terminal: takes the default and says so", async () => {
const result = await run();
expect(result).toMatchObject({ source: "default", dataDir: path.join(process.cwd(), ".data") });
expect(logs.join(" ")).toContain("No terminal to ask");
expect(logs.join(" ")).toContain("CAMERAS_DATA_DIR");
});
it("uses the environment first, and doesn't remember what it was told", async () => {
const ask = vi.fn();
const result = await run({ env: { CAMERAS_DATA_DIR: work }, ask });
expect(result).toMatchObject({ dataDir: work, source: "environment" });
expect(ask).not.toHaveBeenCalled();
expect(await readConfig({}, home)).toEqual({});
});
it("uses the remembered folder on later runs, without asking again", async () => {
await writeConfig(work, {}, home);
const ask = vi.fn();
const result = await run({ ask });
expect(result).toMatchObject({ dataDir: work, source: "remembered", secretCreated: true });
expect(ask).not.toHaveBeenCalled();
const again = await run({ ask });
expect(again.secretCreated).toBe(false); // nothing regenerated
});
it("says when live video needs installing", async () => {
const result = await run({ env: { CAMERAS_DATA_DIR: work }, exists: async () => false });
expect(result.videoReady).toBe(false);
expect(logs.join(" ")).toContain("install-video");
});
it("creates the data folder owner-only", async () => {
const dir = path.join(work, "nested", "data");
await run({ env: { CAMERAS_DATA_DIR: dir } });
expect((await stat(dir)).mode & 0o777).toBe(0o700);
});
});
describe("applyEnvironment", () => {
it("fills in the remembered folder and the stored key for this run", async () => {
await writeConfig(work, {}, home);
await ensureSecrets(work);
const env: Record<string, string | undefined> = {};
await applyEnvironment(env, home);
expect(env.CAMERAS_DATA_DIR).toBe(work);
expect(env.CAMERA_CREDENTIALS_KEY).toMatch(/^[0-9a-f]{64}$/);
});
it("never overrides what the environment already says", async () => {
await writeConfig(work, {}, home);
await ensureSecrets(work);
const env = { CAMERAS_DATA_DIR: "/elsewhere", CAMERA_CREDENTIALS_KEY: "mine" };
await applyEnvironment(env, home);
expect(env).toEqual({ CAMERAS_DATA_DIR: "/elsewhere", CAMERA_CREDENTIALS_KEY: "mine" });
});
it("does nothing when there is neither a pointer nor a key file", async () => {
const env: Record<string, string | undefined> = {};
await applyEnvironment(env, home);
expect(env.CAMERA_CREDENTIALS_KEY).toBeUndefined();
});
});

153
src/lib/first-run.ts Normal file
View File

@ -0,0 +1,153 @@
/**
* First run (vrek iss-kz5c88a, dec-wx1j9gj): decide where the app keeps its files, make
* sure the key that protects stored camera logins exists, and say whether the video bridge
* is installed.
*
* Two rules shape this. The answer has to be remembered, so a globally installed app finds
* its data again from any directory: the chosen folder is written to a small pointer file
* under the user's config directory. And nothing may block a service: without a terminal
* the defaults are taken and the choices are logged.
*
* Plain Node on purpose (no "server-only", no path aliases) so the CLI can use it.
*/
import { randomBytes } from "node:crypto";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { dataDir as defaultDataDir, mediamtxBinary } from "./paths.ts";
type Env = Record<string, string | undefined>;
export const CONFIG_NAME = "onvif-dashboard";
export const SECRETS_FILE = "secrets.env";
/** Where the pointer to the data folder lives: $XDG_CONFIG_HOME or ~/.config. */
export function configFile(env: Env = process.env, home = os.homedir()): string {
return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), CONFIG_NAME, "config.json");
}
export async function readConfig(env: Env = process.env, home = os.homedir()): Promise<{ dataDir?: string }> {
try {
const parsed: unknown = JSON.parse(await readFile(configFile(env, home), "utf8"));
const dir = (parsed as { dataDir?: unknown }).dataDir;
return typeof dir === "string" && dir ? { dataDir: dir } : {};
} catch {
return {};
}
}
export async function writeConfig(dir: string, env: Env = process.env, home = os.homedir()): Promise<void> {
const file = configFile(env, home);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify({ dataDir: dir }, null, 2)}\n`);
}
/** KEY=value lines, as written by setup and read at startup. */
export function parseEnvFile(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const line of text.split("\n")) {
const match = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
if (match && !line.trimStart().startsWith("#")) values[match[1]] = match[2].trim();
}
return values;
}
/**
* Makes sure CAMERA_CREDENTIALS_KEY exists, creating one on first run and keeping it
* owner-only. Returns the values and whether a key was just made.
*/
export async function ensureSecrets(dir: string): Promise<{ values: Record<string, string>; created: boolean }> {
const file = path.join(dir, SECRETS_FILE);
const existing = parseEnvFile(await readFile(file, "utf8").catch(() => ""));
if (existing.CAMERA_CREDENTIALS_KEY) return { values: existing, created: false };
const values = { ...existing, CAMERA_CREDENTIALS_KEY: randomBytes(32).toString("hex") };
await mkdir(dir, { recursive: true, mode: 0o700 });
const text = Object.entries(values)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
await writeFile(file, `# Written by onvif-dashboard. Keep this file private.\n${text}\n`, { mode: 0o600 });
await chmod(file, 0o600);
return { values, created: true };
}
export interface FirstRunOptions {
env?: Env;
home?: string;
/** Asks the user; undefined when there is no terminal, so the default is taken. */
ask?: (question: string, fallback: string) => Promise<string>;
log?: (line: string) => void;
/** Whether the MediaMTX binary is present; injected so tests don't touch the disk. */
exists?: (file: string) => Promise<boolean>;
}
export interface FirstRunResult {
dataDir: string;
/** Where the choice came from, which is worth saying out loud. */
source: "environment" | "remembered" | "answered" | "default";
secretCreated: boolean;
videoReady: boolean;
}
/**
* Settles where data lives and makes sure the credential key exists. Safe to run again:
* nothing already set is regenerated or overwritten.
*/
export async function firstRun(options: FirstRunOptions = {}): Promise<FirstRunResult> {
const env = options.env ?? process.env;
const home = options.home ?? os.homedir();
const log = options.log ?? ((line: string) => console.log(line));
const exists =
options.exists ??
(async (file: string) => {
const { access } = await import("node:fs/promises");
return access(file).then(
() => true,
() => false,
);
});
let dataDir: string;
let source: FirstRunResult["source"];
const remembered = await readConfig(env, home);
if (env.CAMERAS_DATA_DIR) {
dataDir = env.CAMERAS_DATA_DIR;
source = "environment";
} else if (remembered.dataDir) {
dataDir = remembered.dataDir;
source = "remembered";
} else {
const fallback = defaultDataDir({});
if (options.ask) {
const answer = (await options.ask("Where should this keep its data?", fallback)).trim();
dataDir = path.resolve(answer || fallback);
source = "answered";
} else {
dataDir = fallback;
source = "default";
log(`[setup] No terminal to ask; keeping data in ${dataDir}. Set CAMERAS_DATA_DIR to change it.`);
}
await writeConfig(dataDir, env, home);
}
await mkdir(dataDir, { recursive: true, mode: 0o700 });
const { created } = await ensureSecrets(dataDir);
if (created) log(`[setup] Made a key for stored camera logins in ${path.join(dataDir, SECRETS_FILE)}.`);
const videoReady = await exists(mediamtxBinary(env));
if (!videoReady) log("[setup] Live video and recording need MediaMTX: run onvif-dashboard install-video.");
return { dataDir, source, secretCreated: created, videoReady };
}
/** Puts the remembered folder and the stored key into the environment for this run. */
export async function applyEnvironment(env: Env = process.env, home = os.homedir()): Promise<void> {
if (!env.CAMERAS_DATA_DIR) {
const remembered = await readConfig(env, home);
if (remembered.dataDir) env.CAMERAS_DATA_DIR = remembered.dataDir;
}
const dir = env.CAMERAS_DATA_DIR ?? defaultDataDir(env);
const stored = parseEnvFile(await readFile(path.join(dir, SECRETS_FILE), "utf8").catch(() => ""));
for (const [key, value] of Object.entries(stored)) env[key] ??= value;
}

View File

@ -15,7 +15,7 @@ export default defineConfig({
test: { test: {
// Server code runs in Node; component tests opt in with `// @vitest-environment jsdom`. // Server code runs in Node; component tests opt in with `// @vitest-environment jsdom`.
environment: "node", environment: "node",
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts"], include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts", "cli/**/*.test.ts"],
restoreMocks: true, restoreMocks: true,
unstubEnvs: true, unstubEnvs: true,
coverage: { coverage: {