chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5)
- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
d9f2af2f3c
commit
304feea141
226 files changed
+6601
-8620
No files matched your search
@@ -1,3 +1,3 @@
|
||||
# @umt/server
|
||||
|
||||
Node 服务进程:Hono HTTP API(前缀 `/api/v1`,OpenAPI 见 `/api/openapi.json`、`/api/docs`)、SSE 事件流与 worker 入口。`--role api|worker|all` 决定本进程承担哪部分,默认 `all` 单进程运行。业务规则不写在这里,只做编排、鉴权、审计与协议适配。
|
||||
服务进程(Deno):Hono HTTP API(前缀 `/api/v1`,OpenAPI 见 `/api/openapi.json`、`/api/docs`)、SSE 事件流与 worker 入口。`--role api|worker|all` 决定本进程承担哪部分,默认 `all` 单进程运行。业务规则不写在这里,只做编排、鉴权、审计与协议适配。
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@umt/server",
|
||||
"version": "0.0.0",
|
||||
"exports": { ".": "./src/main.ts" },
|
||||
"tasks": {
|
||||
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
|
||||
"start": "deno run --allow-net --allow-env --allow-sys --allow-read --env-file=../../.env src/main.ts",
|
||||
"compile": "deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read=/app --include ../../packages/db/migrations --output ../../dist/umt-server src/main.ts",
|
||||
"test": "deno run -A npm:vitest run",
|
||||
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,8 @@
|
||||
{
|
||||
"name": "@umt/server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file-if-exists=../../.env src/main.ts",
|
||||
"start": "node --env-file-if-exists=../../.env dist/main.js",
|
||||
"build": "node scripts/build.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^2.1.1",
|
||||
"@hono/swagger-ui": "^0.6.1",
|
||||
"@hono/zod-openapi": "^1.6.3",
|
||||
"@umt/contracts": "workspace:*",
|
||||
"@umt/db": "workspace:*",
|
||||
"hono": "^4.13.9",
|
||||
"kysely": "^0.29.6",
|
||||
"pg": "^8.23.0",
|
||||
"pino": "^10.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.2",
|
||||
"pino-pretty": "^13.1.3"
|
||||
"exports": {
|
||||
".": "./src/main.ts"
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// 打包 server:工作区包(@umt/*)以源码内联,npm 依赖保持 external 运行时从 node_modules 加载。
|
||||
// external 依赖必须在本包 package.json 的 dependencies 里声明(工作区包的运行时依赖也要在此重复声明),
|
||||
// 否则运行时解析不到,构建直接报错。
|
||||
import { readFileSync } from "node:fs";
|
||||
import { builtinModules } from "node:module";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
const deps = new Set(Object.keys(pkg.dependencies ?? {}));
|
||||
const builtins = new Set(builtinModules);
|
||||
const pkgName = (p) => (p.startsWith("@") ? p.split("/").slice(0, 2).join("/") : p.split("/")[0]);
|
||||
|
||||
await build({
|
||||
entryPoints: ["src/main.ts"],
|
||||
outfile: "dist/main.js",
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
format: "esm",
|
||||
sourcemap: true,
|
||||
logLevel: "info",
|
||||
plugins: [
|
||||
{
|
||||
name: "external-npm",
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /^[^./]/ }, (args) => {
|
||||
if (args.path.startsWith("node:") || builtins.has(args.path)) return { path: args.path, external: true };
|
||||
const name = pkgName(args.path);
|
||||
if (name.startsWith("@umt/")) return undefined;
|
||||
if (!deps.has(name)) {
|
||||
return {
|
||||
errors: [
|
||||
{ text: `运行时依赖 ${name} 未在 apps/server/package.json dependencies 中声明(来自 ${args.importer})` },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { path: args.path, external: true };
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -5,6 +5,8 @@ const LogLevel = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "si
|
||||
export const ConfigSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(4380),
|
||||
/** 监听地址 */
|
||||
HOST: z.string().min(1).default("0.0.0.0"),
|
||||
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"),
|
||||
SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"),
|
||||
LOG_LEVEL: LogLevel.default("info"),
|
||||
@@ -12,11 +14,13 @@ export const ConfigSchema = z.object({
|
||||
DEV_ADMIN_KEY: z.string().min(8).optional(),
|
||||
/** worker 心跳间隔 */
|
||||
WORKER_HEARTBEAT_MS: z.coerce.number().int().min(1000).default(30_000),
|
||||
/** web 静态产物目录(单容器模式):设置后同端口托管 SPA,非 /api 路径回退 index.html */
|
||||
WEB_ROOT: z.string().min(1).optional(),
|
||||
});
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
/** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
export function loadConfig(env: Record<string, string | undefined> = Deno.env.toObject()): Config {
|
||||
const parsed = ConfigSchema.safeParse(env);
|
||||
if (!parsed.success) {
|
||||
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
|
||||
|
||||
@@ -5,14 +5,15 @@ import { cors } from "hono/cors";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { requestId } from "hono/request-id";
|
||||
import type { Logger } from "pino";
|
||||
import type { Config } from "../config";
|
||||
import { isDev } from "../config";
|
||||
import { redactString } from "../logger";
|
||||
import { type ApiKeyStore, type AuthEnv, apiKeyAuth, kyselyApiKeyStore, requireScope } from "./auth";
|
||||
import { ApiException, errorBody } from "./errors";
|
||||
import { healthRoutes } from "./routes/health";
|
||||
import { mountOpenApi } from "./routes/openapi";
|
||||
import { type EventBus, sseRoutes } from "./sse";
|
||||
import type { Config } from "../config.ts";
|
||||
import { isDev } from "../config.ts";
|
||||
import { redactString } from "../logger.ts";
|
||||
import { type ApiKeyStore, type AuthEnv, apiKeyAuth, kyselyApiKeyStore, requireScope } from "./auth.ts";
|
||||
import { ApiException, errorBody } from "./errors.ts";
|
||||
import { healthRoutes } from "./routes/health.ts";
|
||||
import { mountOpenApi } from "./routes/openapi.ts";
|
||||
import { type EventBus, sseRoutes } from "./sse.ts";
|
||||
import { mountWebRoot } from "./static.ts";
|
||||
|
||||
export interface AppDeps {
|
||||
config: Pick<Config, "NODE_ENV" | "DEV_ADMIN_KEY">;
|
||||
@@ -24,6 +25,8 @@ export interface AppDeps {
|
||||
db?: Kysely<DB>;
|
||||
/** 测试可注入;默认用 db 构造 */
|
||||
apiKeyStore?: ApiKeyStore;
|
||||
/** 单容器模式:托管 web 静态产物的目录 */
|
||||
webRoot?: string;
|
||||
}
|
||||
|
||||
export const API_PREFIX = "/api/v1";
|
||||
@@ -109,6 +112,8 @@ export function createApp(deps: AppDeps) {
|
||||
return c.json({ ok: true, data: { id: p.id, name: p.name, kind: p.kind, scopes: p.scopes } });
|
||||
});
|
||||
|
||||
if (deps.webRoot) mountWebRoot(app, deps.webRoot);
|
||||
|
||||
return app;
|
||||
}
|
||||
export type App = ReturnType<typeof createApp>;
|
||||
@@ -2,7 +2,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { ApiScope } from "@umt/contracts";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { ApiException } from "./errors";
|
||||
import { ApiException } from "./errors.ts";
|
||||
|
||||
export interface Principal {
|
||||
kind: "api_key" | "dev_admin";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type EventEnvelope, EventType } from "@umt/contracts";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { ApiException } from "./errors";
|
||||
import { ApiException } from "./errors.ts";
|
||||
|
||||
type Listener = (e: EventEnvelope) => void;
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Hono } from "hono";
|
||||
import { serveStatic } from "hono/deno";
|
||||
|
||||
/**
|
||||
* 托管 React Router SPA 构建产物(build/client):存在的文件直接返回,
|
||||
* 其余非 /api 的 GET 回退 index.html 交给前端路由。/api 下的未知路径仍走 JSON 404。
|
||||
* 注意 hono/deno 的 serveStatic 要求 path 相对 root,写绝对路径会 404。
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 适配任意 Env 的 Hono 实例
|
||||
export function mountWebRoot(app: Hono<any>, root: string) {
|
||||
const files = serveStatic({ root });
|
||||
const index = serveStatic({ root, path: "index.html" });
|
||||
const isApi = (path: string) => path === "/api" || path.startsWith("/api/");
|
||||
app.use("*", (c, next) => (isApi(c.req.path) ? next() : files(c, next)));
|
||||
app.get("*", (c, next) => (isApi(c.req.path) ? next() : index(c, next)));
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function redact(value: unknown, depth = 0): unknown {
|
||||
if (depth > 8) return value;
|
||||
if (typeof value === "string") return redactString(value);
|
||||
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
|
||||
if (value instanceof Error) return redactError(value, depth);
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
@@ -28,6 +29,20 @@ export function redact(value: unknown, depth = 0): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error 的 message/stack 是不可枚举属性,按普通对象处理会变成 `{}`;
|
||||
* 这里先按 pino 标准形状(type/message/stack + 自有字段 + cause)展开再脱敏。
|
||||
*/
|
||||
function redactError(err: Error, depth: number): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { type: err.name, message: redactString(err.message) };
|
||||
if (err.stack) out.stack = redactString(err.stack);
|
||||
for (const [k, v] of Object.entries(err)) {
|
||||
out[k] = SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null ? REDACTED : redact(v, depth + 1);
|
||||
}
|
||||
if (err.cause !== undefined) out.cause = redact(err.cause, depth + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface LoggerOptions {
|
||||
level?: string;
|
||||
pretty?: boolean;
|
||||
@@ -41,7 +56,11 @@ export function createLogger(opts: LoggerOptions = {}): Logger {
|
||||
formatters: {
|
||||
// 所有结构化字段(含 err 序列化结果)统一过一遍脱敏
|
||||
log: (obj) => redact(obj) as Record<string, unknown>,
|
||||
// 根 logger 的 base 绑定;child() 的绑定 pino 会跳过这里,由 redactChildBindings 处理
|
||||
bindings: (bindings) => redact(bindings) as Record<string, unknown>,
|
||||
},
|
||||
// err 已由 formatters.log 按 message/stack 展开并脱敏;默认 err 序列化器会把 type 改写成 "Object"
|
||||
serializers: { err: (v: unknown) => v },
|
||||
hooks: {
|
||||
// msg 文本也脱敏
|
||||
logMethod(args, method) {
|
||||
@@ -50,9 +69,26 @@ export function createLogger(opts: LoggerOptions = {}): Logger {
|
||||
},
|
||||
},
|
||||
};
|
||||
if (opts.destination) return pino(options, opts.destination);
|
||||
if (opts.destination) return redactChildBindings(pino(options, opts.destination));
|
||||
if (opts.pretty) {
|
||||
return pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } });
|
||||
return redactChildBindings(
|
||||
pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } }),
|
||||
);
|
||||
}
|
||||
return pino(options);
|
||||
// 同步写 stdout:进程随后 Deno.exit() 时(迁移/自检子命令、优雅退出)不丢尾部日志
|
||||
return redactChildBindings(pino(options, pino.destination({ dest: 1, sync: true })));
|
||||
}
|
||||
|
||||
/**
|
||||
* pino 的 child() 会把 formatters.bindings 重置为恒等函数,绑定字段(如 `child({ password })`)
|
||||
* 原样写进每一行。这里在根实例上覆盖 child:子孙 logger 经原型链继承,绑定先脱敏再交给 pino。
|
||||
*/
|
||||
function redactChildBindings(logger: Logger): Logger {
|
||||
type ChildFn = (this: Logger, bindings: pino.Bindings, options?: pino.ChildLoggerOptions) => Logger;
|
||||
const original = logger.child as unknown as ChildFn;
|
||||
const child: ChildFn = function (bindings, options) {
|
||||
return original.call(this, redact(bindings) as pino.Bindings, options);
|
||||
};
|
||||
logger.child = child as unknown as Logger["child"];
|
||||
return logger;
|
||||
}
|
||||
+82
-27
@@ -1,21 +1,34 @@
|
||||
import { parseArgs } from "node:util";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createDb } from "@umt/db";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { loadConfig } from "./config";
|
||||
import { createApp } from "./http/app";
|
||||
import { EventBus } from "./http/sse";
|
||||
import { createLogger } from "./logger";
|
||||
import { startWorker } from "./worker";
|
||||
import { createDb, migrateToLatest } from "@umt/db";
|
||||
import denoJson from "../deno.json" with { type: "json" };
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { createApp } from "./http/app.ts";
|
||||
import { EventBus } from "./http/sse.ts";
|
||||
import { createLogger } from "./logger.ts";
|
||||
import { runPreflight } from "./preflight.ts";
|
||||
import { startWorker } from "./worker/index.ts";
|
||||
|
||||
const Roles = ["api", "worker", "all"] as const;
|
||||
// api/worker/all 是常驻角色;migrate/preflight/probe 是容器入口脚本用的一次性子命令
|
||||
const Roles = ["api", "worker", "all", "migrate", "preflight", "probe"] as const;
|
||||
type Role = (typeof Roles)[number];
|
||||
|
||||
const { values } = parseArgs({ options: { role: { type: "string", default: "all" } }, strict: true });
|
||||
const { values } = parseArgs({ args: Deno.args, options: { role: { type: "string", default: "all" } }, strict: true });
|
||||
const role = values.role as Role;
|
||||
if (!Roles.includes(role)) {
|
||||
console.error(`--role 必须是 ${Roles.join("|")}`);
|
||||
process.exit(2);
|
||||
Deno.exit(2);
|
||||
}
|
||||
|
||||
// 健康探测:只打本机回环端口,不需要完整配置(HEALTHCHECK 用,镜像里不装 curl)
|
||||
if (role === "probe") {
|
||||
const port = Deno.env.get("PORT") ?? "4380";
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { signal: AbortSignal.timeout(3000) });
|
||||
const body = (await res.json()) as { data?: { status?: string } };
|
||||
Deno.exit(res.ok && body.data?.status === "ok" ? 0 : 1);
|
||||
} catch {
|
||||
Deno.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let config: ReturnType<typeof loadConfig>;
|
||||
@@ -23,30 +36,69 @@ try {
|
||||
config = loadConfig();
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exit(2);
|
||||
Deno.exit(2);
|
||||
}
|
||||
|
||||
const logger = createLogger({
|
||||
level: config.LOG_LEVEL,
|
||||
pretty: config.NODE_ENV === "development" && process.stdout.isTTY,
|
||||
pretty: config.NODE_ENV === "development" && Deno.stdout.isTerminal(),
|
||||
});
|
||||
const db = createDb({ connectionString: config.DATABASE_URL });
|
||||
|
||||
if (role === "migrate" || role === "preflight") {
|
||||
let code = 0;
|
||||
try {
|
||||
if (role === "migrate") {
|
||||
const { results } = await migrateToLatest(db);
|
||||
for (const r of results ?? []) logger.info({ migration: r.migrationName, status: r.status }, "migration");
|
||||
logger.info({ applied: results?.length ?? 0 }, "迁移完成");
|
||||
} else {
|
||||
const problems = await runPreflight({ config, db });
|
||||
for (const p of problems) logger.error({ check: p.check }, p.message);
|
||||
if (problems.length) code = 1;
|
||||
else logger.info("启动自检通过");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, `${role} 失败`);
|
||||
code = 1;
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
Deno.exit(code);
|
||||
}
|
||||
|
||||
const bus = new EventBus();
|
||||
const stops: Array<() => Promise<void>> = [];
|
||||
|
||||
if (role === "api" || role === "all") {
|
||||
const app = createApp({ config, logger, bus, db, role, version: pkg.version });
|
||||
const server = serve({ fetch: app.fetch, port: config.PORT }, (info) => {
|
||||
logger.info({ port: info.port, role }, `API 已启动 http://localhost:${info.port}/api/v1/health`);
|
||||
const app = createApp({
|
||||
config,
|
||||
logger,
|
||||
bus,
|
||||
db,
|
||||
role,
|
||||
version: denoJson.version,
|
||||
...(config.WEB_ROOT ? { webRoot: config.WEB_ROOT } : {}),
|
||||
});
|
||||
stops.push(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
// SSE 长连接不会自行结束,强制关闭
|
||||
(server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
const ac = new AbortController();
|
||||
const server = Deno.serve(
|
||||
{
|
||||
port: config.PORT,
|
||||
hostname: config.HOST,
|
||||
signal: ac.signal,
|
||||
onListen: ({ port }) =>
|
||||
logger.info(
|
||||
{ port, role, webRoot: config.WEB_ROOT ?? null },
|
||||
`API 已启动 http://localhost:${port}/api/v1/health`,
|
||||
),
|
||||
},
|
||||
app.fetch,
|
||||
);
|
||||
stops.push(async () => {
|
||||
// abort 后 Deno.serve 会断开 SSE 等长连接,finished 在所有连接关闭后 resolve
|
||||
ac.abort();
|
||||
await server.finished;
|
||||
});
|
||||
}
|
||||
if (role === "worker" || role === "all") {
|
||||
const worker = startWorker({ logger, heartbeatMs: config.WORKER_HEARTBEAT_MS, bus });
|
||||
@@ -54,13 +106,16 @@ if (role === "worker" || role === "all") {
|
||||
}
|
||||
|
||||
let stopping = false;
|
||||
async function shutdown(signal: string) {
|
||||
async function shutdown(signal: Deno.Signal) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
logger.info({ signal }, "正在退出");
|
||||
// 兜底:10 秒内没退干净就强退,避免容器 stop 超时被 SIGKILL
|
||||
const force = setTimeout(() => Deno.exit(1), 10_000);
|
||||
await Promise.allSettled(stops.map((s) => s()));
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
clearTimeout(force);
|
||||
Deno.exit(0);
|
||||
}
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
Deno.addSignalListener("SIGINT", () => void shutdown("SIGINT"));
|
||||
Deno.addSignalListener("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventType } from "@umt/contracts";
|
||||
import type { Logger } from "pino";
|
||||
import type { EventBus } from "../http/sse";
|
||||
import type { EventBus } from "../http/sse.ts";
|
||||
|
||||
export interface WorkerDeps {
|
||||
logger: Logger;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasScope } from "../src/http/auth";
|
||||
import { DEV_KEY, json, memoryStore, testApp } from "./helpers";
|
||||
import { hasScope } from "../src/http/auth.ts";
|
||||
import { DEV_KEY, json, memoryStore, testApp } from "./helpers.ts";
|
||||
|
||||
const store = memoryStore({
|
||||
"key-read": { id: "k1", scopes: ["read"] },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { json, testApp } from "./helpers";
|
||||
import { json, testApp } from "./helpers.ts";
|
||||
|
||||
describe("GET /api/v1/health", () => {
|
||||
it("免鉴权返回 ok 信封", async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Writable } from "node:stream";
|
||||
import { createApp } from "../src/http/app";
|
||||
import { type ApiKeyStore, hashApiKey } from "../src/http/auth";
|
||||
import { EventBus } from "../src/http/sse";
|
||||
import { createLogger } from "../src/logger";
|
||||
import { createApp } from "../src/http/app.ts";
|
||||
import { type ApiKeyStore, hashApiKey } from "../src/http/auth.ts";
|
||||
import { EventBus } from "../src/http/sse.ts";
|
||||
import { createLogger } from "../src/logger.ts";
|
||||
|
||||
export const DEV_KEY = "dev-admin-test-key";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redact, redactString } from "../src/logger";
|
||||
import { memoryLogger } from "./helpers";
|
||||
import { redact, redactString } from "../src/logger.ts";
|
||||
import { memoryLogger } from "./helpers.ts";
|
||||
|
||||
describe("日志脱敏", () => {
|
||||
it("URL query 中的 passkey/token 被替换", () => {
|
||||
@@ -26,3 +26,37 @@ describe("日志脱敏", () => {
|
||||
for (const s of ["SECRET1", "SECRET2", "SECRET3", "SECRET4"]) expect(out).not.toContain(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe("日志脱敏:child 绑定与 Error 序列化", () => {
|
||||
it("child() 绑定的敏感字段被脱敏", () => {
|
||||
const { logger, lines } = memoryLogger();
|
||||
logger.child({ component: "worker", password: "SECRET5", url: "https://x/?passkey=SECRET6" }).warn("child");
|
||||
const out = lines.join("");
|
||||
expect(out).not.toContain("SECRET5");
|
||||
expect(out).not.toContain("SECRET6");
|
||||
const line = JSON.parse(lines.at(-1)!);
|
||||
expect(line.component).toBe("worker");
|
||||
expect(line.password).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("err 按 message/stack 序列化而不是 {},且内容脱敏", () => {
|
||||
const { logger, lines } = memoryLogger();
|
||||
const err = Object.assign(new Error("GET https://x/dl?passkey=SECRET7 failed", { cause: new Error("inner") }), {
|
||||
code: "E_FETCH",
|
||||
token: "SECRET8",
|
||||
});
|
||||
logger.error({ err }, "boom");
|
||||
const line = JSON.parse(lines.at(-1)!);
|
||||
expect(line.err.type).toBe("Error");
|
||||
expect(line.err.message).toBe("GET https://x/dl?passkey=[REDACTED] failed");
|
||||
expect(line.err.stack).toContain("Error: GET https://x/dl?passkey=[REDACTED] failed");
|
||||
expect(line.err.code).toBe("E_FETCH");
|
||||
expect(line.err.token).toBe("[REDACTED]");
|
||||
expect(line.err.cause.message).toBe("inner");
|
||||
expect(lines.join("")).not.toMatch(/SECRET7|SECRET8/);
|
||||
});
|
||||
|
||||
it("redact() 直接处理 Error", () => {
|
||||
expect(redact(new Error("x?token=abc"))).toMatchObject({ type: "Error", message: "x?token=[REDACTED]" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { type Config, ConfigSchema } from "../src/config.ts";
|
||||
import { runPreflight } from "../src/preflight.ts";
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "umt-pre-"));
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
// 最小假 Kysely:只需要 sql`select 1`.execute(db) 走得通或失败
|
||||
const fakeDb = (ok: boolean) =>
|
||||
({
|
||||
getExecutor: () => ({
|
||||
transformQuery: (n: unknown) => n,
|
||||
compileQuery: (n: unknown) => ({ sql: "select 1", parameters: [], query: n }),
|
||||
executeQuery: async () => {
|
||||
if (!ok) throw new Error("connect ECONNREFUSED");
|
||||
return { rows: [{ "?column?": 1 }] };
|
||||
},
|
||||
}),
|
||||
}) as unknown as Kysely<DB>;
|
||||
|
||||
const cfg = (over: Record<string, string>): Config =>
|
||||
ConfigSchema.parse({ DATABASE_URL: "postgres://x/y", SECRET_MASTER_KEY: "a-real-random-secret-value-123", ...over });
|
||||
|
||||
describe("启动自检", () => {
|
||||
it("开发环境、DB 可达 → 通过", async () => {
|
||||
expect(await runPreflight({ config: cfg({}), db: fakeDb(true), uid: 0 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("生产环境:占位符密钥、root、DB 不可达、WEB_ROOT 缺 index.html 都报出", async () => {
|
||||
const problems = await runPreflight({
|
||||
config: cfg({ NODE_ENV: "production", SECRET_MASTER_KEY: "dev-only-change-me-dev-only", WEB_ROOT: dir }),
|
||||
db: fakeDb(false),
|
||||
uid: 0,
|
||||
});
|
||||
expect(problems.map((p) => p.check).sort()).toEqual(["db", "secret", "user", "web"]);
|
||||
});
|
||||
|
||||
it("WEB_ROOT 有 index.html、非 root → 通过", async () => {
|
||||
writeFileSync(join(dir, "index.html"), "<!doctype html>");
|
||||
const config = cfg({ NODE_ENV: "production", WEB_ROOT: dir });
|
||||
expect(await runPreflight({ config, db: fakeDb(true), uid: 1000 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EventBus } from "../src/http/sse";
|
||||
import { DEV_KEY, testApp } from "./helpers";
|
||||
import { EventBus } from "../src/http/sse.ts";
|
||||
import { DEV_KEY, testApp } from "./helpers.ts";
|
||||
|
||||
describe("EventBus", () => {
|
||||
it("序号自增并按 after 补读,淘汰后标记 gap", () => {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createApp } from "../src/http/app.ts";
|
||||
import { EventBus } from "../src/http/sse.ts";
|
||||
import { json, memoryLogger } from "./helpers.ts";
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "umt-web-"));
|
||||
mkdirSync(join(root, "assets"));
|
||||
writeFileSync(join(root, "index.html"), "<!doctype html><title>SPA</title>");
|
||||
writeFileSync(join(root, "assets", "app.js"), "console.log(1)");
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function app() {
|
||||
return createApp({
|
||||
config: { NODE_ENV: "production" },
|
||||
logger: memoryLogger().logger,
|
||||
bus: new EventBus(5),
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
webRoot: root,
|
||||
});
|
||||
}
|
||||
|
||||
describe("单容器静态托管(WEB_ROOT)", () => {
|
||||
it("存在的文件直接返回", async () => {
|
||||
const res = await app().request("/assets/app.js");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("console.log(1)");
|
||||
});
|
||||
|
||||
it("首页与前端路由回退 index.html", async () => {
|
||||
for (const path of ["/", "/settings", "/library/123"]) {
|
||||
const res = await app().request(path);
|
||||
expect(res.status, path).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/html");
|
||||
expect(await res.text()).toContain("<title>SPA</title>");
|
||||
}
|
||||
});
|
||||
|
||||
it("/api 下的未知路径不回退,仍是 JSON", async () => {
|
||||
const health = await app().request("/api/v1/health");
|
||||
expect((await json(health)).ok).toBe(true);
|
||||
const res = await app().request("/api/nope");
|
||||
expect(res.status).toBe(404);
|
||||
expect((await json(res)).error.code).toBe("not_found");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"extends": "../../config/tsconfig.base.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { HealthStatus } from "@umt/contracts";
|
||||
import { apiData, TTL_SECOND } from "~/lib/api";
|
||||
import { apiData, TTL_SECOND } from "~/lib/api.ts";
|
||||
|
||||
/** 服务健康状态,15 秒轮询 */
|
||||
export function useHealth() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { clearAuth, getToken, setToken } from "~/lib/api";
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { clearAuth, getToken, setToken } from "~/lib/api.ts";
|
||||
|
||||
/** 设置本机使用的 API Key(存 localStorage,首版单管理员) */
|
||||
export function ApiKeyDialog() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/** 空态:图标 + 标题 + 说明 + 可选操作。用于"尚无数据"与"功能建设中"占位 */
|
||||
export function EmptyState({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findNavItem, NAV_ITEMS } from "./nav";
|
||||
import { findNavItem, NAV_ITEMS } from "./nav.ts";
|
||||
|
||||
describe("控制台导航", () => {
|
||||
it("12 个工作区,路径唯一", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/** 控制台页面标题:标题 + 一句说明 + 右侧操作区 */
|
||||
export function PageHeader({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export type StatusTone = "success" | "warning" | "destructive" | "neutral";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Construction } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import { EmptyState } from "./empty-state";
|
||||
import { findNavItem } from "./nav";
|
||||
import { PageHeader } from "./page-header";
|
||||
import { EmptyState } from "./empty-state.tsx";
|
||||
import { findNavItem } from "./nav.ts";
|
||||
import { PageHeader } from "./page-header.tsx";
|
||||
|
||||
/** 工作区占位页:标题与说明取自导航表,正文为"建设中"空态 */
|
||||
export function WorkspacePlaceholder({ milestone = "W3" }: { milestone?: string }) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/**
|
||||
* 全站背景上左上角那束淡淡的棱镜漏光(样式见 app.css 的 .prism-leak)。
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
const buttonVariants = cva(
|
||||
// 全站只有这一套按钮(与动漫站同一思路):形状一律胶囊 / 正圆,颜色与材质只由 variant 决定,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { zhCN } from "date-fns/locale";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker, type DayPickerProps } from "react-day-picker";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 日历(react-day-picker v10)的玻璃族皮肤:全部走语义 token + 玻璃 hover
|
||||
// 语言(中性灰纱、选中 primary 药丸 + 顶部内高光、今日描边)。键盘导航
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 15, 20, 50, 100];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Switch as SwitchPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 pnpm gen:tokens
|
||||
// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 deno task gen:tokens
|
||||
export const DESIGN_TOKENS = {
|
||||
"fontSizes": [
|
||||
"micro",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { extendTailwindMerge } from "tailwind-merge";
|
||||
|
||||
import { DESIGN_TOKENS } from "./design-tokens.generated";
|
||||
import { DESIGN_TOKENS } from "./design-tokens.generated.ts";
|
||||
|
||||
/**
|
||||
* 自定义工具类的 token(字号 / 圆角 / 缓动 / 时长)要告诉 tailwind-merge 它们属于哪一组:
|
||||
|
||||
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { useState } from "react";
|
||||
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import { Toaster } from "~/components/ui/sonner.tsx";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import "@fontsource-variable/inter-tight";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Bot, Send } from "lucide-react";
|
||||
import { EmptyState } from "~/components/console/empty-state";
|
||||
import { PageHeader } from "~/components/console/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { EmptyState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
|
||||
/** Agent 会话页占位:消息流 + 输入框骨架,W2/W3 接入 agent_run 与 SSE 增量 */
|
||||
export default function AgentSession() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Activity, AlertTriangle, CircleCheck, HardDrive, ListTodo, Radio, Sparkles } from "lucide-react";
|
||||
import { useHealth } from "~/api/health";
|
||||
import { EmptyState } from "~/components/console/empty-state";
|
||||
import { PageHeader } from "~/components/console/page-header";
|
||||
import { StatusBadge } from "~/components/console/status-badge";
|
||||
import { formatDateTime } from "~/lib/utils";
|
||||
import { useHealth } from "~/api/health.ts";
|
||||
import { EmptyState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 首页六区块(REQ §13.4.3),W3 接入正式状态投影
|
||||
const SECTIONS = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function IdentityPage() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function InboxPage() {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ChevronLeft, ChevronRight, Clapperboard, Menu } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
import { ApiKeyDialog } from "~/components/console/api-key-dialog";
|
||||
import { findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav";
|
||||
import PrismLeak from "~/components/prism-leak";
|
||||
import { ThemeToggle } from "~/components/theme-toggle";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ApiKeyDialog } from "~/components/console/api-key-dialog.tsx";
|
||||
import { findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts";
|
||||
import PrismLeak from "~/components/prism-leak.tsx";
|
||||
import { ThemeToggle } from "~/components/theme-toggle.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
const PORTAL_NAME = "媒体自动化";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function LibraryPage() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function OrganizePage() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function PreferencesPage() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function SettingsPage() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function SitesPage() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function SourcesPage() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function SubscriptionsPage() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder";
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
export default function TasksPage() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from "react-router";
|
||||
import { EmptyState } from "~/components/console/empty-state";
|
||||
import { buttonVariants } from "~/components/ui/button";
|
||||
import { EmptyState } from "~/components/console/empty-state.tsx";
|
||||
import { buttonVariants } from "~/components/ui/button.tsx";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
"dev": "react-router dev --host 0.0.0.0 --port 5180",
|
||||
"build": "react-router build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 5180",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"typecheck": "react-router typegen && tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"gen:tokens": "node scripts/gen-design-tokens.mjs",
|
||||
"check:tokens": "node scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh"
|
||||
"gen:tokens": "deno run -A scripts/gen-design-tokens.mjs",
|
||||
"check:tokens": "deno run -A scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter-tight": "^5.2.7",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S deno run -A
|
||||
// 从 apps/web/app/app.css 抽出自定义工具类的 token 名,生成 app/lib/design-tokens.generated.ts,
|
||||
// 供 cn() 的 tailwind-merge 登记。不登记的话合并器会把 `text-control` 当成文字颜色、
|
||||
// 和 `text-muted-foreground` 冲突后丢掉字号(2026-09-12 页面级复验抓到的)。
|
||||
// 手动维护登记表会漂移,所以改成生成:改了 app.css 跑 `pnpm gen:tokens`,CI 校验生成物是最新的。
|
||||
// 手动维护登记表会漂移,所以改成生成:改了 app.css 跑 `cd apps/web && deno task gen:tokens`,CI 校验生成物是最新的。
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const css = readFileSync(new URL("../app/app.css", import.meta.url), "utf8");
|
||||
@@ -15,7 +15,7 @@ const tokens = {
|
||||
radii: pick("radius"),
|
||||
durations: [...new Set([...css.matchAll(/^@utility duration-([a-z0-9-]+)/gm)].map((m) => m[1]))],
|
||||
};
|
||||
const out = `// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 pnpm gen:tokens
|
||||
const out = `// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 deno task gen:tokens
|
||||
export const DESIGN_TOKENS = ${JSON.stringify(tokens, null, 2)} as const;
|
||||
`;
|
||||
const target = new URL("../app/lib/design-tokens.generated.ts", import.meta.url);
|
||||
@@ -25,7 +25,7 @@ if (process.argv.includes("--check")) {
|
||||
current = readFileSync(target, "utf8");
|
||||
} catch {}
|
||||
if (current !== out) {
|
||||
console.error("app/lib/design-tokens.generated.ts 已过期,运行 pnpm gen:tokens");
|
||||
console.error("app/lib/design-tokens.generated.ts 已过期,运行 cd apps/web && deno task gen:tokens");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user