diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index 1c4249a..ae84ae3 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -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"} diff --git a/cli/onvif-dashboard.mjs b/cli/onvif-dashboard.mjs new file mode 100755 index 0000000..4d0f5b1 --- /dev/null +++ b/cli/onvif-dashboard.mjs @@ -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); +} diff --git a/cli/onvif-dashboard.test.ts b/cli/onvif-dashboard.test.ts new file mode 100644 index 0000000..c645da6 --- /dev/null +++ b/cli/onvif-dashboard.test.ts @@ -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"); + }); +}); diff --git a/package.json b/package.json index e5ae24a..7f90e58 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/lib/first-run.test.ts b/src/lib/first-run.test.ts new file mode 100644 index 0000000..808e57b --- /dev/null +++ b/src/lib/first-run.test.ts @@ -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[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 = {}; + 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 = {}; + await applyEnvironment(env, home); + expect(env.CAMERA_CREDENTIALS_KEY).toBeUndefined(); + }); +}); diff --git a/src/lib/first-run.ts b/src/lib/first-run.ts new file mode 100644 index 0000000..a318b1c --- /dev/null +++ b/src/lib/first-run.ts @@ -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; + +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 { + 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 { + const values: Record = {}; + 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; 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; + log?: (line: string) => void; + /** Whether the MediaMTX binary is present; injected so tests don't touch the disk. */ + exists?: (file: string) => Promise; +} + +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 { + 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 { + 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; +} diff --git a/vitest.config.mts b/vitest.config.mts index 6b5998d..c9d40e4 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -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: {