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:
parent
0556b3913e
commit
65345d0984
@ -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-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-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
87
cli/onvif-dashboard.mjs
Executable 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);
|
||||
}
|
||||
68
cli/onvif-dashboard.test.ts
Normal file
68
cli/onvif-dashboard.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -27,6 +27,10 @@
|
||||
".next/static",
|
||||
"public",
|
||||
"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/install-mediamtx.mts",
|
||||
"LICENSE",
|
||||
@ -68,5 +72,8 @@
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"onvif-dashboard": "cli/onvif-dashboard.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
190
src/lib/first-run.test.ts
Normal file
190
src/lib/first-run.test.ts
Normal 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
153
src/lib/first-run.ts
Normal 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;
|
||||
}
|
||||
@ -15,7 +15,7 @@ export default defineConfig({
|
||||
test: {
|
||||
// Server code runs in Node; component tests opt in with `// @vitest-environment jsdom`.
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts", "cli/**/*.test.ts"],
|
||||
restoreMocks: true,
|
||||
unstubEnvs: true,
|
||||
coverage: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user