98 lines
4.0 KiB
TypeScript
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;
|
|
});
|