feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s
TallyNote release / linux-x64 (push) Failing after 2m41s
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user