Files
TallyNote/server/cli/admin-init.ts
T
Qiufeng 9719429f4a
TallyNote release / linux-x64 (push) Failing after 2m41s
feat: add TallyNote local reimbursement ledger
2026-08-29 01:02:29 +08:00

98 lines
4.0 KiB
TypeScript

import { stdin as input, stdout as output } from "node:process";
import { mkdirSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { openDatabase } from "../db/index.js";
import { acquireInstanceLock, loadConfig, prepareDataDirectories } from "../config.js";
import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword } from "../security.js";
import { writeAudit } from "../audit.js";
function arg(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
async function readSecret(prompt: string): Promise<string> {
if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码");
output.write(prompt);
return await new Promise<string>((resolve, reject) => {
let value = "";
const wasRaw = Boolean(input.isRaw);
const onData = (chunk: Buffer) => {
const text = chunk.toString("utf8");
if (text === "\u0003") {
cleanup();
reject(new Error("已取消"));
} else if (text === "\r" || text === "\n") {
cleanup();
output.write("\n");
resolve(value);
} else if (text === "\u007f") {
value = value.slice(0, -1);
} else if (!text.includes("\u001b")) {
value += text;
}
};
const cleanup = () => {
input.off("data", onData);
input.setRawMode?.(wasRaw);
input.pause();
};
input.resume();
input.setRawMode?.(true);
input.on("data", onData);
});
}
async function main() {
const config = loadConfig();
prepareDataDirectories(config);
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
const release = acquireInstanceLock(config);
const database = openDatabase(config);
try {
const existing = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
if (existing.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
const username = arg("--username") ?? (await readSecret("用户名: "));
const displayName = arg("--display-name") ?? (await readSecret("显示名称: "));
const generate = process.argv.includes("--generate");
let password = generate ? temporaryPassword() : await readSecret("密码(至少 12 个字符): ");
if (!generate) {
const confirmation = await readSecret("再次输入密码: ");
if (password !== confirmation) throw new Error("两次密码输入不一致");
}
const policyError = validateNewPassword(password);
if (policyError) throw new Error(policyError);
const normalized = normalizeUsername(username);
if ([...normalized].length < 3) throw new Error("用户名至少需要 3 个字符");
const passwordHash = await hashPassword(password);
const id = randomUUID();
const now = Date.now();
database.sqlite.transaction(() => {
const current = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
if (current.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?)
`).run(id, username.normalize("NFKC").trim(), normalized, displayName.trim(), passwordHash, now);
writeAudit(database.sqlite, {
requestId: `cli:${randomUUID()}`,
actorUsername: "cli",
action: "admin.initialized",
targetType: "admin",
targetId: id,
after: { username: normalized, displayName: displayName.trim(), status: "active" },
});
})();
console.log(generate ? `已创建首位管理员。一次性密码:${password}` : "已创建首位管理员。");
} finally {
database.sqlite.close();
release();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});