- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
150 lines
4.9 KiB
TypeScript
150 lines
4.9 KiB
TypeScript
import { join } from "node:path";
|
|
import type { DB, Kysely } from "@umt/db";
|
|
import { sql } from "@umt/db";
|
|
import type { Config } from "./config.ts";
|
|
|
|
export interface PreflightProblem {
|
|
check: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface PreflightDeps {
|
|
config: Config;
|
|
db: Kysely<DB>;
|
|
/** 测试可注入;默认取当前进程 uid(Windows 上为 null) */
|
|
uid?: number | null;
|
|
}
|
|
|
|
// 明显的占位符密钥:生产环境拒绝启动
|
|
const PLACEHOLDER = /change[-_]?me|replace[-_]?me|dev[-_]?only|example/i;
|
|
|
|
/** 启动自检:返回问题列表,空数组表示通过。不打印变量值。 */
|
|
export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDeps): Promise<PreflightProblem[]> {
|
|
const problems: PreflightProblem[] = [];
|
|
const prod = config.NODE_ENV === "production";
|
|
|
|
if (prod && PLACEHOLDER.test(config.SECRET_MASTER_KEY)) {
|
|
problems.push({ check: "secret", message: "SECRET_MASTER_KEY 仍是占位符,生产环境必须换成随机串" });
|
|
}
|
|
if (prod && uid === 0) {
|
|
problems.push({ check: "user", message: "生产环境不允许以 root 运行" });
|
|
}
|
|
try {
|
|
await sql`select 1`.execute(db);
|
|
} catch (err) {
|
|
problems.push({ check: "db", message: `数据库不可达:${(err as Error).message}` });
|
|
}
|
|
if (config.WEB_ROOT) {
|
|
try {
|
|
await Deno.stat(join(config.WEB_ROOT, "index.html"));
|
|
} catch {
|
|
problems.push({ check: "web", message: `WEB_ROOT 下没有 index.html: ${config.WEB_ROOT}` });
|
|
}
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
// ── 存储自检:数据目录必须可写;库根 / 下载器映射路径不可写只告警 ──
|
|
|
|
export interface MountedPath {
|
|
kind: "library_root" | "path_mapping";
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
export interface StorageWarning extends MountedPath {
|
|
reason: string;
|
|
}
|
|
|
|
export interface StorageCheckResult {
|
|
problems: PreflightProblem[];
|
|
warnings: StorageWarning[];
|
|
}
|
|
|
|
const errText = (e: unknown) => (e as { code?: string }).code ?? (e as Error).message;
|
|
|
|
/** 在目录里写一个探针文件再删掉;失败抛错 */
|
|
async function probeWrite(dir: string): Promise<void> {
|
|
const probe = join(dir, `.umt-write-probe-${Deno.pid}-${crypto.randomUUID().slice(0, 8)}`);
|
|
await Deno.writeTextFile(probe, "probe\n");
|
|
await Deno.remove(probe);
|
|
}
|
|
|
|
/** 读出已启用的库根与下载器路径映射在本进程视角下的路径 */
|
|
export async function loadMountedPaths(db: Kysely<DB>): Promise<MountedPath[]> {
|
|
const roots = await db
|
|
.selectFrom("library_root")
|
|
.select(["id", "name", "local_path"])
|
|
.where("enabled", "=", true)
|
|
.execute();
|
|
const maps = await db
|
|
.selectFrom("path_mapping")
|
|
.select(["id", "remote_prefix", "local_prefix"])
|
|
.where("enabled", "=", true)
|
|
.where("view_kind", "=", "downloader")
|
|
.execute();
|
|
return [
|
|
...roots.map((r) => ({ kind: "library_root" as const, id: r.id, name: r.name, path: r.local_path })),
|
|
...maps.map((m) => ({ kind: "path_mapping" as const, id: m.id, name: m.remote_prefix, path: m.local_prefix })),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* dataDir 下建 agent-sessions/backups/exports/tmp 并逐个探测可写(失败 = problem,阻止启动);
|
|
* sessionDir 若在 dataDir 之外也单独探测;mounts 逐个检查可读可写(失败 = warning,不阻止启动)。
|
|
*/
|
|
export async function checkStorage(opts: {
|
|
dataDir: string;
|
|
sessionDir: string;
|
|
subdirs: readonly string[];
|
|
mounts: MountedPath[];
|
|
}): Promise<StorageCheckResult> {
|
|
const problems: PreflightProblem[] = [];
|
|
const warnings: StorageWarning[] = [];
|
|
const dirs = [...opts.subdirs.map((s) => join(opts.dataDir, s))];
|
|
if (!dirs.includes(opts.sessionDir)) dirs.push(opts.sessionDir);
|
|
for (const dir of dirs) {
|
|
try {
|
|
await Deno.mkdir(dir, { recursive: true });
|
|
await probeWrite(dir);
|
|
} catch (e) {
|
|
problems.push({ check: "data_dir", message: `数据目录不可写:${dir}(${errText(e)});检查卷挂载与 PUID/PGID` });
|
|
}
|
|
}
|
|
for (const m of opts.mounts) {
|
|
try {
|
|
const st = await Deno.stat(m.path);
|
|
if (!st.isDirectory) throw Object.assign(new Error("不是目录"), { code: "ENOTDIR" });
|
|
for await (const _ of Deno.readDir(m.path)) break;
|
|
} catch (e) {
|
|
warnings.push({ ...m, reason: `不可读:${errText(e)}` });
|
|
continue;
|
|
}
|
|
try {
|
|
await probeWrite(m.path);
|
|
} catch (e) {
|
|
warnings.push({ ...m, reason: `不可写:${errText(e)}` });
|
|
}
|
|
}
|
|
return { problems, warnings };
|
|
}
|
|
|
|
/** 存储告警写审计(actor=system),失败不影响启动 */
|
|
export async function auditStorageWarnings(db: Kysely<DB>, warnings: StorageWarning[]): Promise<void> {
|
|
if (!warnings.length) return;
|
|
await db
|
|
.insertInto("audit_event")
|
|
.values(
|
|
warnings.map((w) => ({
|
|
actor_type: "system",
|
|
actor_id: "preflight",
|
|
action: "preflight.path_unavailable",
|
|
target_type: w.kind,
|
|
target_id: w.id,
|
|
payload: JSON.stringify({ name: w.name, path: w.path, reason: w.reason, uid: Deno.uid() }),
|
|
})),
|
|
)
|
|
.execute();
|
|
}
|