- 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>
191 lines
7.4 KiB
TypeScript
191 lines
7.4 KiB
TypeScript
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();
|
|
});
|
|
});
|