cameras/scripts/create-admin.mjs
Michael Mainguy daaecb7452 Fix the published package: compile the CLI, bundle onvif (0.1.1)
Installing 0.1.0 failed twice, both my mistakes.

- Node refuses to strip types under node_modules, so shipping .ts for the
  CLI could never work from an installed package. tsconfig.cli.json now
  compiles those modules to dist/ as ES modules, and the helper scripts
  are plain .mjs.
- serverExternalPackages made Turbopack emit require("onvif-<hash>"),
  a name that resolves nowhere, because onvif comes from a git URL.
  Bundling it fixes that; checked from an installed tarball, where
  /api/discover answered 200 and a real camera's info and snapshot came
  back through the packaged server.

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

97 lines
3.2 KiB
JavaScript

// Creates (or with --force, replaces) the admin login file, outside the app, so the web
// interface never has to run unsecured. No dependencies beyond the app's own; runs on
// Node 24+ directly:
//
// onvif-dashboard admin # prompts for username and password
// onvif-dashboard admin -- --username admin # prompts for password only
// onvif-dashboard admin -- --force # replace an existing admin (resets password)
// printf '%s\n' "$PW" | onvif-dashboard admin -- --username admin # non-interactive
//
// Writes ADMIN_AUTH_FILE if set, otherwise ./.data/admin.json, owner-only (0600).
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { parseArgs } from "node:util";
import {
adminFilePath,
createAdminRecord,
passwordSchema,
readAdminFile,
usernameSchema,
writeAdminFile,
} from "../dist/lib/admin-file.js";
const { values } = parseArgs({
options: {
username: { type: "string" },
force: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
});
if (values.help) {
console.log("Usage: onvif-dashboard admin -- [--username NAME] [--force]");
process.exit(0);
}
const file = adminFilePath();
const interactive = process.stdin.isTTY === true;
// Echo is switched off while a password is typed.
let muted = false;
const output = new Writable({
write(chunk, _encoding, done) {
if (!muted) process.stdout.write(chunk);
done();
},
});
const rl = createInterface({ input: process.stdin, output, terminal: interactive });
// Piped input can arrive before the questions are asked, so read it line by line.
const lines = interactive ? null : rl[Symbol.asyncIterator]();
async function ask(question, { secret = false } = {}) {
if (lines) {
const next = await lines.next();
return next.done ? "" : String(next.value);
}
if (!secret) return rl.question(question);
process.stdout.write(question);
muted = true;
const answer = await rl.question("");
muted = false;
process.stdout.write("\n");
return answer;
}
function fail(message) {
console.error(`Error: ${message}`);
rl.close();
process.exit(1);
}
async function main() {
const existing = await readAdminFile(file).catch((err) => fail(err.message));
if (existing && !values.force) {
fail(`an admin ("${existing.username}") already exists in ${file}. Re-run with --force to replace it.`);
}
const username = values.username ?? (await ask("Admin username: "));
const name = usernameSchema.safeParse(username);
if (!name.success) fail(`username ${name.error.issues[0].message}`);
const password = await ask("Password (12+ characters): ", { secret: true });
const strong = passwordSchema.safeParse(password);
if (!strong.success) fail(`password: ${strong.error.issues[0].message}`);
if (interactive && (await ask("Repeat password: ", { secret: true })) !== password) {
fail("passwords don't match.");
}
rl.close();
await writeAdminFile(file, await createAdminRecord(username, password), {
overwrite: values.force,
});
console.log(`Admin "${username}" saved to ${file} (owner-only).`);
if (existing) console.log("Existing sessions are signed out; log in again with the new password.");
}
await main();