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:
rootandClaude Fable 5.1 committed 2026-09-27 18:53:06 +08:00
1 parent d9f2af2f3c
commit 304feea141
226 files changed
+6600 -8619

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
# 构建上下文只需要源码与锁文件;依赖在镜像内由 deno install --frozen 按 deno.lock 重装
.git
**/node_modules
**/dist
**/build
**/.react-router
**/coverage
**/*.log
types
spikes
docs
.env
.env.*
!.env.example
*.md
!apps/**/README.md
+1
View File
@@ -10,3 +10,4 @@ coverage/
*.log *.log
.DS_Store .DS_Store
.vite/ .vite/
types/deno.d.ts
+31
View File
@@ -0,0 +1,31 @@
# 单容器镜像:一个 deno compile 出的 umt-server 二进制,同时承担 API(含 web 静态 SPA)与 worker。
# PostgreSQL 不在本镜像内,见 docker-compose.yml。
# 构建:docker build -t uhd-media-tool . 运行时版本锁定 Deno 2.9.7(ADR-004)
FROM denoland/deno:2.9.7 AS build
WORKDIR /src
COPY . .
RUN deno install --frozen
ARG APP_VERSION=""
ENV APP_VERSION=${APP_VERSION}
RUN deno task check:tokens && cd apps/web && deno task build
# --node-modules-dir=none + --exclude-unused-npm:只嵌入 server 依赖图可达的 npm 包(约 110 MB,而不是整个 node_modules)
# 权限在编译时固定:--allow-read 只放 /app;不给 --allow-run / --allow-write
RUN cd apps/server && deno task compile
FROM debian:trixie-slim
# 不装额外包:Deno 自带 Mozilla 根证书(外呼 HTTPS 不依赖 ca-certificates),健康探测用二进制自身的 fetch
RUN useradd --system --uid 10001 --home-dir /app --shell /usr/sbin/nologin umt
WORKDIR /app
COPY --from=build --chown=root:root /src/dist/umt-server /app/umt-server
COPY --from=build --chown=root:root /src/apps/web/build/client /app/public
COPY --chown=root:root --chmod=0755 docker/entrypoint.sh /app/entrypoint.sh
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4380 \
WEB_ROOT=/app/public
USER umt
EXPOSE 4380
# 用二进制自带的 fetch 打本机回环端口,不装 curl
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["/app/umt-server", "--role", "probe"]
ENTRYPOINT ["/app/entrypoint.sh"]
+12 -6
View File
@@ -4,15 +4,19 @@
## 快速启动 ## 快速启动
需要 Deno 2.9.7(锁定版本:`curl -fsSL https://deno.land/install.sh | sh -s v2.9.7`)和 Docker。不需要 Node.js / pnpm。
```sh ```sh
cp .env.example .env cp .env.example .env
pnpm db:up # postgres:16-alpine,端口 55433 deno task db:up # postgres:16-alpine,端口 55433
pnpm install deno install # 按 deno.lock 安装依赖到 node_modules
pnpm db:migrate deno task db:migrate
pnpm dev # API http://localhost:4380 控制台 http://localhost:5180 deno task dev # API http://localhost:4380 控制台 http://localhost:5180
``` ```
提交前:`pnpm run ci`(typecheck + lint + test + check:tokens + build)。注意不要用 `pnpm ci`,它在 pnpm 11 是内置的 clean-install。 提交前:`deno task ci`(typecheck:tsc 7.0.2 + deno check → biome → vitest → check:tokens → web 构建 + server 编译)。
生产/试运行:`docker compose up -d --build`(单容器 app + postgres,见 [docs/runbook/dev.md](docs/runbook/dev.md#docker-单容器部署))。
更多见 [docs/runbook/dev.md](docs/runbook/dev.md)。 更多见 [docs/runbook/dev.md](docs/runbook/dev.md)。
@@ -32,6 +36,8 @@ pnpm dev # API http://localhost:4380 控制台 http://localhost:5180
| `docs/adr` | 架构决策记录 | | `docs/adr` | 架构决策记录 |
| `docs/design` | 设计系统规范 | | `docs/design` | 设计系统规范 |
| `docs/runbook` | 运行手册 | | `docs/runbook` | 运行手册 |
| `config/` | tsc 7 用的 tsconfig 基座(根目录不放 tsconfig.json,否则 `deno check` 丢 Deno 全局) |
| `docker/` | 容器入口脚本 |
| `spikes/` | 独立兼容性验证(不属于 workspace) | | `spikes/` | 独立兼容性验证(不属于 workspace) |
技术选型:Node.js 24 + pnpm workspace + TypeScript 5.9 strict(偏离 REQ 的 Deno 方向,原因见 [ADR-001](docs/adr/ADR-001-runtime-node-not-deno.md))。 技术选型:Deno 2.9.7(锁定)+ TypeScript 7.0.2 strict,deno.json workspace(`nodeModulesDir: auto`,npm 依赖版本统一登记在根 `deno.json` 的 `imports`,`deno.lock` 锁定)。前端 Vite 7 + React Router 7 由 Deno 执行。决策见 [ADR-004](docs/adr/ADR-004-deno-runtime.md)(取代 ADR-001)。
+1 -1
View File
@@ -1,3 +1,3 @@
# @umt/server # @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` 单进程运行。业务规则不写在这里,只做编排、鉴权、审计与协议适配。
+12
View File
@@ -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"
}
}
+2 -23
View File
@@ -1,29 +1,8 @@
{ {
"name": "@umt/server", "name": "@umt/server",
"version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "exports": {
"dev": "tsx watch --env-file-if-exists=../../.env src/main.ts", ".": "./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"
} }
} }
-42
View File
@@ -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 -1
View File
@@ -5,6 +5,8 @@ const LogLevel = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "si
export const ConfigSchema = z.object({ export const ConfigSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(4380), 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:// 连接串"), DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"),
SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"), SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"),
LOG_LEVEL: LogLevel.default("info"), LOG_LEVEL: LogLevel.default("info"),
@@ -12,11 +14,13 @@ export const ConfigSchema = z.object({
DEV_ADMIN_KEY: z.string().min(8).optional(), DEV_ADMIN_KEY: z.string().min(8).optional(),
/** worker 心跳间隔 */ /** worker 心跳间隔 */
WORKER_HEARTBEAT_MS: z.coerce.number().int().min(1000).default(30_000), 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 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); const parsed = ConfigSchema.safeParse(env);
if (!parsed.success) { if (!parsed.success) {
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`); const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
+13 -8
View File
@@ -5,14 +5,15 @@ import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import { requestId } from "hono/request-id"; import { requestId } from "hono/request-id";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { Config } from "../config"; import type { Config } from "../config.ts";
import { isDev } from "../config"; import { isDev } from "../config.ts";
import { redactString } from "../logger"; import { redactString } from "../logger.ts";
import { type ApiKeyStore, type AuthEnv, apiKeyAuth, kyselyApiKeyStore, requireScope } from "./auth"; import { type ApiKeyStore, type AuthEnv, apiKeyAuth, kyselyApiKeyStore, requireScope } from "./auth.ts";
import { ApiException, errorBody } from "./errors"; import { ApiException, errorBody } from "./errors.ts";
import { healthRoutes } from "./routes/health"; import { healthRoutes } from "./routes/health.ts";
import { mountOpenApi } from "./routes/openapi"; import { mountOpenApi } from "./routes/openapi.ts";
import { type EventBus, sseRoutes } from "./sse"; import { type EventBus, sseRoutes } from "./sse.ts";
import { mountWebRoot } from "./static.ts";
export interface AppDeps { export interface AppDeps {
config: Pick<Config, "NODE_ENV" | "DEV_ADMIN_KEY">; config: Pick<Config, "NODE_ENV" | "DEV_ADMIN_KEY">;
@@ -24,6 +25,8 @@ export interface AppDeps {
db?: Kysely<DB>; db?: Kysely<DB>;
/** 测试可注入;默认用 db 构造 */ /** 测试可注入;默认用 db 构造 */
apiKeyStore?: ApiKeyStore; apiKeyStore?: ApiKeyStore;
/** 单容器模式:托管 web 静态产物的目录 */
webRoot?: string;
} }
export const API_PREFIX = "/api/v1"; 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 } }); 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; return app;
} }
export type App = ReturnType<typeof createApp>; export type App = ReturnType<typeof createApp>;
+1 -1
View File
@@ -2,7 +2,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
import type { ApiScope } from "@umt/contracts"; import type { ApiScope } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db"; import type { DB, Kysely } from "@umt/db";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { ApiException } from "./errors"; import { ApiException } from "./errors.ts";
export interface Principal { export interface Principal {
kind: "api_key" | "dev_admin"; kind: "api_key" | "dev_admin";
+1 -1
View File
@@ -1,7 +1,7 @@
import { type EventEnvelope, EventType } from "@umt/contracts"; import { type EventEnvelope, EventType } from "@umt/contracts";
import { Hono } from "hono"; import { Hono } from "hono";
import { streamSSE } from "hono/streaming"; import { streamSSE } from "hono/streaming";
import { ApiException } from "./errors"; import { ApiException } from "./errors.ts";
type Listener = (e: EventEnvelope) => void; type Listener = (e: EventEnvelope) => void;
+16
View File
@@ -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)));
}
+39 -3
View File
@@ -17,6 +17,7 @@ export function redact(value: unknown, depth = 0): unknown {
if (depth > 8) return value; if (depth > 8) return value;
if (typeof value === "string") return redactString(value); if (typeof value === "string") return redactString(value);
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1)); 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") { if (value && typeof value === "object") {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) { for (const [k, v] of Object.entries(value)) {
@@ -28,6 +29,20 @@ export function redact(value: unknown, depth = 0): unknown {
return value; 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 { export interface LoggerOptions {
level?: string; level?: string;
pretty?: boolean; pretty?: boolean;
@@ -41,7 +56,11 @@ export function createLogger(opts: LoggerOptions = {}): Logger {
formatters: { formatters: {
// 所有结构化字段(含 err 序列化结果)统一过一遍脱敏 // 所有结构化字段(含 err 序列化结果)统一过一遍脱敏
log: (obj) => redact(obj) as Record<string, unknown>, 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: { hooks: {
// msg 文本也脱敏 // msg 文本也脱敏
logMethod(args, method) { 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) { 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
View File
@@ -1,21 +1,34 @@
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { serve } from "@hono/node-server"; import { createDb, migrateToLatest } from "@umt/db";
import { createDb } from "@umt/db"; import denoJson from "../deno.json" with { type: "json" };
import pkg from "../package.json" with { type: "json" }; import { loadConfig } from "./config.ts";
import { loadConfig } from "./config"; import { createApp } from "./http/app.ts";
import { createApp } from "./http/app"; import { EventBus } from "./http/sse.ts";
import { EventBus } from "./http/sse"; import { createLogger } from "./logger.ts";
import { createLogger } from "./logger"; import { runPreflight } from "./preflight.ts";
import { startWorker } from "./worker"; 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]; 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; const role = values.role as Role;
if (!Roles.includes(role)) { if (!Roles.includes(role)) {
console.error(`--role 必须是 ${Roles.join("|")}`); 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>; let config: ReturnType<typeof loadConfig>;
@@ -23,30 +36,69 @@ try {
config = loadConfig(); config = loadConfig();
} catch (err) { } catch (err) {
console.error((err as Error).message); console.error((err as Error).message);
process.exit(2); Deno.exit(2);
} }
const logger = createLogger({ const logger = createLogger({
level: config.LOG_LEVEL, 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 }); 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 bus = new EventBus();
const stops: Array<() => Promise<void>> = []; const stops: Array<() => Promise<void>> = [];
if (role === "api" || role === "all") { if (role === "api" || role === "all") {
const app = createApp({ config, logger, bus, db, role, version: pkg.version }); const app = createApp({
const server = serve({ fetch: app.fetch, port: config.PORT }, (info) => { config,
logger.info({ port: info.port, role }, `API 已启动 http://localhost:${info.port}/api/v1/health`); logger,
bus,
db,
role,
version: denoJson.version,
...(config.WEB_ROOT ? { webRoot: config.WEB_ROOT } : {}),
}); });
stops.push( const ac = new AbortController();
() => const server = Deno.serve(
new Promise((resolve) => { {
// SSE 长连接不会自行结束,强制关闭 port: config.PORT,
(server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.(); hostname: config.HOST,
server.close(() => resolve()); 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") { if (role === "worker" || role === "all") {
const worker = startWorker({ logger, heartbeatMs: config.WORKER_HEARTBEAT_MS, bus }); const worker = startWorker({ logger, heartbeatMs: config.WORKER_HEARTBEAT_MS, bus });
@@ -54,13 +106,16 @@ if (role === "worker" || role === "all") {
} }
let stopping = false; let stopping = false;
async function shutdown(signal: string) { async function shutdown(signal: Deno.Signal) {
if (stopping) return; if (stopping) return;
stopping = true; stopping = true;
logger.info({ signal }, "正在退出"); logger.info({ signal }, "正在退出");
// 兜底:10 秒内没退干净就强退,避免容器 stop 超时被 SIGKILL
const force = setTimeout(() => Deno.exit(1), 10_000);
await Promise.allSettled(stops.map((s) => s())); await Promise.allSettled(stops.map((s) => s()));
await db.destroy(); await db.destroy();
process.exit(0); clearTimeout(force);
Deno.exit(0);
} }
process.on("SIGINT", () => void shutdown("SIGINT")); Deno.addSignalListener("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM")); Deno.addSignalListener("SIGTERM", () => void shutdown("SIGTERM"));
+45
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
import { EventType } from "@umt/contracts"; import { EventType } from "@umt/contracts";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { EventBus } from "../http/sse"; import type { EventBus } from "../http/sse.ts";
export interface WorkerDeps { export interface WorkerDeps {
logger: Logger; logger: Logger;
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { hasScope } from "../src/http/auth"; import { hasScope } from "../src/http/auth.ts";
import { DEV_KEY, json, memoryStore, testApp } from "./helpers"; import { DEV_KEY, json, memoryStore, testApp } from "./helpers.ts";
const store = memoryStore({ const store = memoryStore({
"key-read": { id: "k1", scopes: ["read"] }, "key-read": { id: "k1", scopes: ["read"] },
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { json, testApp } from "./helpers"; import { json, testApp } from "./helpers.ts";
describe("GET /api/v1/health", () => { describe("GET /api/v1/health", () => {
it("免鉴权返回 ok 信封", async () => { it("免鉴权返回 ok 信封", async () => {
+4 -4
View File
@@ -1,8 +1,8 @@
import { Writable } from "node:stream"; import { Writable } from "node:stream";
import { createApp } from "../src/http/app"; import { createApp } from "../src/http/app.ts";
import { type ApiKeyStore, hashApiKey } from "../src/http/auth"; import { type ApiKeyStore, hashApiKey } from "../src/http/auth.ts";
import { EventBus } from "../src/http/sse"; import { EventBus } from "../src/http/sse.ts";
import { createLogger } from "../src/logger"; import { createLogger } from "../src/logger.ts";
export const DEV_KEY = "dev-admin-test-key"; export const DEV_KEY = "dev-admin-test-key";
+36 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { redact, redactString } from "../src/logger"; import { redact, redactString } from "../src/logger.ts";
import { memoryLogger } from "./helpers"; import { memoryLogger } from "./helpers.ts";
describe("日志脱敏", () => { describe("日志脱敏", () => {
it("URL query 中的 passkey/token 被替换", () => { it("URL query 中的 passkey/token 被替换", () => {
@@ -26,3 +26,37 @@ describe("日志脱敏", () => {
for (const s of ["SECRET1", "SECRET2", "SECRET3", "SECRET4"]) expect(out).not.toContain(s); 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]" });
});
});
+47
View File
@@ -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([]);
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { EventBus } from "../src/http/sse"; import { EventBus } from "../src/http/sse.ts";
import { DEV_KEY, testApp } from "./helpers"; import { DEV_KEY, testApp } from "./helpers.ts";
describe("EventBus", () => { describe("EventBus", () => {
it("序号自增并按 after 补读,淘汰后标记 gap", () => { it("序号自增并按 after 补读,淘汰后标记 gap", () => {
+49
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../config/tsconfig.base.json",
"include": ["src", "test"] "include": ["src", "test"]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { HealthStatus } from "@umt/contracts"; import type { HealthStatus } from "@umt/contracts";
import { apiData, TTL_SECOND } from "~/lib/api"; import { apiData, TTL_SECOND } from "~/lib/api.ts";
/** 服务健康状态,15 秒轮询 */ /** 服务健康状态,15 秒轮询 */
export function useHealth() { export function useHealth() {
@@ -1,7 +1,7 @@
import { KeyRound } from "lucide-react"; import { KeyRound } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button.tsx";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,9 +10,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input.tsx";
import { clearAuth, getToken, setToken } from "~/lib/api"; import { clearAuth, getToken, setToken } from "~/lib/api.ts";
/** 设置本机使用的 API Key(存 localStorage,首版单管理员) */ /** 设置本机使用的 API Key(存 localStorage,首版单管理员) */
export function ApiKeyDialog() { export function ApiKeyDialog() {
@@ -1,7 +1,7 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Inbox } from "lucide-react"; import { Inbox } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
/** 空态:图标 + 标题 + 说明 + 可选操作。用于"尚无数据"与"功能建设中"占位 */ /** 空态:图标 + 标题 + 说明 + 可选操作。用于"尚无数据"与"功能建设中"占位 */
export function EmptyState({ export function EmptyState({
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { findNavItem, NAV_ITEMS } from "./nav"; import { findNavItem, NAV_ITEMS } from "./nav.ts";
describe("控制台导航", () => { describe("控制台导航", () => {
it("12 个工作区,路径唯一", () => { it("12 个工作区,路径唯一", () => {
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
/** 控制台页面标题:标题 + 一句说明 + 右侧操作区 */ /** 控制台页面标题:标题 + 一句说明 + 右侧操作区 */
export function PageHeader({ export function PageHeader({
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
export type StatusTone = "success" | "warning" | "destructive" | "neutral"; export type StatusTone = "success" | "warning" | "destructive" | "neutral";
@@ -1,8 +1,8 @@
import { Construction } from "lucide-react"; import { Construction } from "lucide-react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { EmptyState } from "./empty-state"; import { EmptyState } from "./empty-state.tsx";
import { findNavItem } from "./nav"; import { findNavItem } from "./nav.ts";
import { PageHeader } from "./page-header"; import { PageHeader } from "./page-header.tsx";
/** 工作区占位页:标题与说明取自导航表,正文为"建设中"空态 */ /** 工作区占位页:标题与说明取自导航表,正文为"建设中"空态 */
export function WorkspacePlaceholder({ milestone = "W3" }: { milestone?: string }) { export function WorkspacePlaceholder({ milestone = "W3" }: { milestone?: string }) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
/** /**
* 全站背景上左上角那束淡淡的棱镜漏光(样式见 app.css 的 .prism-leak)。 * 全站背景上左上角那束淡淡的棱镜漏光(样式见 app.css 的 .prism-leak)。
+2 -2
View File
@@ -3,8 +3,8 @@ import { useTheme } from "next-themes";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
export function ThemeToggle({ className }: { className?: string }) { export function ThemeToggle({ className }: { className?: string }) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
+2 -2
View File
@@ -1,7 +1,7 @@
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />; return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
+1 -1
View File
@@ -3,7 +3,7 @@
import { Avatar as AvatarPrimitive } from "radix-ui"; import { Avatar as AvatarPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
function Avatar({ function Avatar({
className, className,
+1 -1
View File
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui"; import { Slot } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
const badgeVariants = cva( 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", "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",
+1 -1
View File
@@ -2,7 +2,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
import { Slot } from "radix-ui"; import { Slot } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />; return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
+1 -1
View File
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui"; import { Slot } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
const buttonVariants = cva( const buttonVariants = cva(
// 全站只有这一套按钮(与动漫站同一思路):形状一律胶囊 / 正圆,颜色与材质只由 variant 决定, // 全站只有这一套按钮(与动漫站同一思路):形状一律胶囊 / 正圆,颜色与材质只由 variant 决定,
+1 -1
View File
@@ -2,7 +2,7 @@ import { zhCN } from "date-fns/locale";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker, type DayPickerProps } from "react-day-picker"; 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 // 日历(react-day-picker v10)的玻璃族皮肤:全部走语义 token + 玻璃 hover
// 语言(中性灰纱、选中 primary 药丸 + 顶部内高光、今日描边)。键盘导航 // 语言(中性灰纱、选中 primary 药丸 + 顶部内高光、今日描边)。键盘导航
+1 -1
View File
@@ -1,6 +1,6 @@
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
+2 -2
View File
@@ -1,8 +1,8 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui"; import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />; return <DialogPrimitive.Root data-slot="dialog" {...props} />;
+1 -1
View File
@@ -1,6 +1,6 @@
import type * as React from "react"; 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">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
+1 -1
View File
@@ -3,7 +3,7 @@
import { Label as LabelPrimitive } from "radix-ui"; import { Label as LabelPrimitive } from "radix-ui";
import type * as React from "react"; 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>) { function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
+1 -1
View File
@@ -1,5 +1,5 @@
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; 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]; const PAGE_SIZE_OPTIONS = [10, 15, 20, 50, 100];
+1 -1
View File
@@ -1,7 +1,7 @@
import { Popover as PopoverPrimitive } from "radix-ui"; import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react"; 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>) { function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />; return <PopoverPrimitive.Root data-slot="popover" {...props} />;
+1 -1
View File
@@ -2,7 +2,7 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui"; import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react"; 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>) { function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />; return <SelectPrimitive.Root data-slot="select" {...props} />;
+1 -1
View File
@@ -1,7 +1,7 @@
import { Separator as SeparatorPrimitive } from "radix-ui"; import { Separator as SeparatorPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
function Separator({ function Separator({
className, className,
+1 -1
View File
@@ -2,7 +2,7 @@ import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui"; import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react"; 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>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />; return <SheetPrimitive.Root data-slot="sheet" {...props} />;
+1 -1
View File
@@ -1,7 +1,7 @@
import { Slider as SliderPrimitive } from "radix-ui"; import { Slider as SliderPrimitive } from "radix-ui";
import * as React from "react"; import * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
function Slider({ function Slider({
className, className,
+1 -1
View File
@@ -1,7 +1,7 @@
import { Switch as SwitchPrimitive } from "radix-ui"; import { Switch as SwitchPrimitive } from "radix-ui";
import type * as React from "react"; 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>) { function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return ( return (
+1 -1
View File
@@ -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 = { export const DESIGN_TOKENS = {
"fontSizes": [ "fontSizes": [
"micro", "micro",
+1 -1
View File
@@ -1,7 +1,7 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { extendTailwindMerge } from "tailwind-merge"; import { extendTailwindMerge } from "tailwind-merge";
import { DESIGN_TOKENS } from "./design-tokens.generated"; import { DESIGN_TOKENS } from "./design-tokens.generated.ts";
/** /**
* 自定义工具类的 token(字号 / 圆角 / 缓动 / 时长)要告诉 tailwind-merge 它们属于哪一组: * 自定义工具类的 token(字号 / 圆角 / 缓动 / 时长)要告诉 tailwind-merge 它们属于哪一组:
+1 -1
View File
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { useState } from "react"; import { useState } from "react";
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; 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 type { Route } from "./+types/root";
import "@fontsource-variable/inter-tight"; import "@fontsource-variable/inter-tight";
+4 -4
View File
@@ -1,8 +1,8 @@
import { Bot, Send } from "lucide-react"; import { Bot, Send } from "lucide-react";
import { EmptyState } from "~/components/console/empty-state"; import { EmptyState } from "~/components/console/empty-state.tsx";
import { PageHeader } from "~/components/console/page-header"; import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input.tsx";
/** Agent 会话页占位:消息流 + 输入框骨架,W2/W3 接入 agent_run 与 SSE 增量 */ /** Agent 会话页占位:消息流 + 输入框骨架,W2/W3 接入 agent_run 与 SSE 增量 */
export default function AgentSession() { export default function AgentSession() {
+5 -5
View File
@@ -1,9 +1,9 @@
import { Activity, AlertTriangle, CircleCheck, HardDrive, ListTodo, Radio, Sparkles } from "lucide-react"; import { Activity, AlertTriangle, CircleCheck, HardDrive, ListTodo, Radio, Sparkles } from "lucide-react";
import { useHealth } from "~/api/health"; import { useHealth } from "~/api/health.ts";
import { EmptyState } from "~/components/console/empty-state"; import { EmptyState } from "~/components/console/empty-state.tsx";
import { PageHeader } from "~/components/console/page-header"; import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge"; import { StatusBadge } from "~/components/console/status-badge.tsx";
import { formatDateTime } from "~/lib/utils"; import { formatDateTime } from "~/lib/utils.ts";
// 首页六区块(REQ §13.4.3),W3 接入正式状态投影 // 首页六区块(REQ §13.4.3),W3 接入正式状态投影
const SECTIONS = [ const SECTIONS = [
+1 -1
View File
@@ -1,4 +1,4 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
// 占位:标题与说明取自 components/console/nav.ts // 占位:标题与说明取自 components/console/nav.ts
export default function IdentityPage() { export default function IdentityPage() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
// 占位:标题与说明取自 components/console/nav.ts // 占位:标题与说明取自 components/console/nav.ts
export default function InboxPage() { export default function InboxPage() {
+7 -7
View File
@@ -1,13 +1,13 @@
import { ChevronLeft, ChevronRight, Clapperboard, Menu } from "lucide-react"; import { ChevronLeft, ChevronRight, Clapperboard, Menu } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router"; import { Link, NavLink, Outlet, useLocation } from "react-router";
import { ApiKeyDialog } from "~/components/console/api-key-dialog"; import { ApiKeyDialog } from "~/components/console/api-key-dialog.tsx";
import { findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav"; import { findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts";
import PrismLeak from "~/components/prism-leak"; import PrismLeak from "~/components/prism-leak.tsx";
import { ThemeToggle } from "~/components/theme-toggle"; import { ThemeToggle } from "~/components/theme-toggle.tsx";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet.tsx";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils.ts";
const PORTAL_NAME = "媒体自动化"; const PORTAL_NAME = "媒体自动化";
+1 -1
View File
@@ -1,4 +1,4 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
// 占位:标题与说明取自 components/console/nav.ts // 占位:标题与说明取自 components/console/nav.ts
export default function LibraryPage() { export default function LibraryPage() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
// 占位:标题与说明取自 components/console/nav.ts // 占位:标题与说明取自 components/console/nav.ts
export default function OrganizePage() { export default function OrganizePage() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
// 占位:标题与说明取自 components/console/nav.ts // 占位:标题与说明取自 components/console/nav.ts
export default function PreferencesPage() { export default function PreferencesPage() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
// 占位:标题与说明取自 components/console/nav.ts // 占位:标题与说明取自 components/console/nav.ts
export default function SettingsPage() { export default function SettingsPage() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
// 占位:标题与说明取自 components/console/nav.ts // 占位:标题与说明取自 components/console/nav.ts
export default function SitesPage() { export default function SitesPage() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
// 占位:标题与说明取自 components/console/nav.ts // 占位:标题与说明取自 components/console/nav.ts
export default function SourcesPage() { 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 // 占位:标题与说明取自 components/console/nav.ts
export default function SubscriptionsPage() { export default function SubscriptionsPage() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder"; import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
// 占位:标题与说明取自 components/console/nav.ts // 占位:标题与说明取自 components/console/nav.ts
export default function TasksPage() { export default function TasksPage() {
+2 -2
View File
@@ -1,6 +1,6 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { EmptyState } from "~/components/console/empty-state"; import { EmptyState } from "~/components/console/empty-state.tsx";
import { buttonVariants } from "~/components/ui/button"; import { buttonVariants } from "~/components/ui/button.tsx";
export default function NotFound() { export default function NotFound() {
return ( return (
+3 -3
View File
@@ -7,10 +7,10 @@
"dev": "react-router dev --host 0.0.0.0 --port 5180", "dev": "react-router dev --host 0.0.0.0 --port 5180",
"build": "react-router build", "build": "react-router build",
"preview": "vite preview --host 0.0.0.0 --port 5180", "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", "test": "vitest run",
"gen:tokens": "node scripts/gen-design-tokens.mjs", "gen:tokens": "deno run -A scripts/gen-design-tokens.mjs",
"check:tokens": "node scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh" "check:tokens": "deno run -A scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter-tight": "^5.2.7", "@fontsource-variable/inter-tight": "^5.2.7",
+4 -4
View File
@@ -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, // 从 apps/web/app/app.css 抽出自定义工具类的 token 名,生成 app/lib/design-tokens.generated.ts,
// 供 cn() 的 tailwind-merge 登记。不登记的话合并器会把 `text-control` 当成文字颜色、 // 供 cn() 的 tailwind-merge 登记。不登记的话合并器会把 `text-control` 当成文字颜色、
// 和 `text-muted-foreground` 冲突后丢掉字号(2026-09-12 页面级复验抓到的)。 // 和 `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"; import { readFileSync, writeFileSync } from "node:fs";
const css = readFileSync(new URL("../app/app.css", import.meta.url), "utf8"); const css = readFileSync(new URL("../app/app.css", import.meta.url), "utf8");
@@ -15,7 +15,7 @@ const tokens = {
radii: pick("radius"), radii: pick("radius"),
durations: [...new Set([...css.matchAll(/^@utility duration-([a-z0-9-]+)/gm)].map((m) => m[1]))], 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; export const DESIGN_TOKENS = ${JSON.stringify(tokens, null, 2)} as const;
`; `;
const target = new URL("../app/lib/design-tokens.generated.ts", import.meta.url); 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"); current = readFileSync(target, "utf8");
} catch {} } catch {}
if (current !== out) { 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); process.exit(1);
} }
console.log( console.log(
-1
View File
@@ -10,7 +10,6 @@
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"], "rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": { "paths": {
"~/*": ["./app/*"] "~/*": ["./app/*"]
}, },
+45 -6
View File
@@ -1,6 +1,10 @@
{ {
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": { "files": {
"includes": [ "includes": [
"**", "**",
@@ -8,6 +12,7 @@
"!**/dist", "!**/dist",
"!**/build", "!**/build",
"!**/.react-router", "!**/.react-router",
"!types",
"!spikes", "!spikes",
"!docs", "!docs",
"!apps/web/app/app.css", "!apps/web/app/app.css",
@@ -21,21 +26,55 @@
"lineWidth": 120 "lineWidth": 120
}, },
"javascript": { "javascript": {
"formatter": { "quoteStyle": "double", "semicolons": "always", "trailingCommas": "all" } "formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"preset": "recommended", "preset": "recommended",
"style": { "noNonNullAssertion": "off" }, "style": {
"suspicious": { "noArrayIndexKey": "off" } "noNonNullAssertion": "off"
},
"suspicious": {
"noArrayIndexKey": "off"
},
"correctness": {
"useImportExtensions": "error"
}
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
} }
}, },
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"overrides": [ "overrides": [
{ {
"includes": ["apps/web/app/components/ui/**"], "includes": ["apps/web/app/components/ui/**"],
"linter": { "rules": { "a11y": "off", "security": { "noDangerouslySetInnerHtml": "off" } } } "linter": {
"rules": {
"a11y": "off",
"security": {
"noDangerouslySetInnerHtml": "off"
}
}
}
},
{
"includes": ["packages/adapters/src/sites/**", "packages/testkit/src/sites/**", "scripts/mp-sync/**"],
"linter": {
"rules": {
"correctness": {
"useImportExtensions": "off"
}
}
}
} }
] ]
} }
@@ -6,6 +6,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"moduleDetection": "force", "moduleDetection": "force",
"allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
@@ -18,5 +19,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"types": ["node"] "types": ["node"]
} },
// deno types 生成的 Deno 全局声明(`deno task types`,gitignored);tsc 7 靠它认识 Deno.*
"files": ["../types/deno.d.ts"]
} }
+77
View File
@@ -0,0 +1,77 @@
{
"workspace": [
"./packages/adapters",
"./packages/agent",
"./packages/contracts",
"./packages/db",
"./packages/domain",
"./packages/tasks",
"./packages/testkit",
"./apps/server",
"./apps/web"
],
"nodeModulesDir": "auto",
"imports": {
"@biomejs/biome": "npm:@biomejs/[email protected]",
"@earendil-works/pi-agent-core": "npm:@earendil-works/[email protected]",
"@earendil-works/pi-agent-core/": "npm:/@earendil-works/[email protected]/",
"@earendil-works/pi-ai": "npm:@earendil-works/[email protected]",
"@earendil-works/pi-ai/": "npm:/@earendil-works/[email protected]/",
"@hono/swagger-ui": "npm:@hono/[email protected]",
"@hono/zod-openapi": "npm:@hono/[email protected]",
"@types/node": "npm:@types/[email protected]",
"@types/nunjucks": "npm:@types/[email protected]",
"@types/pg": "npm:@types/[email protected]",
"@xmldom/xmldom": "npm:@xmldom/[email protected]",
"cheerio": "npm:[email protected]",
"fast-xml-parser": "npm:[email protected]",
"hono": "npm:[email protected]",
"hono/": "npm:/[email protected]/",
"kysely": "npm:[email protected]",
"kysely/": "npm:/[email protected]/",
"nunjucks": "npm:[email protected]",
"openworkflow": "npm:[email protected]",
"openworkflow/": "npm:/[email protected]/",
"pg": "npm:[email protected]",
"pino": "npm:[email protected]",
"pino-pretty": "npm:[email protected]",
"postgres": "npm:[email protected]",
"typebox": "npm:[email protected]",
"typescript": "npm:[email protected]",
"vitest": "npm:[email protected]",
"vitest/": "npm:/[email protected]/",
"xpath": "npm:[email protected]",
"yaml": "npm:[email protected]",
"zod": "npm:[email protected]"
},
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true
},
"tasks": {
"dev": {
"description": "同时起 server(4380)与 web(5180)",
"dependencies": ["dev:server", "dev:web"]
},
"dev:server": "cd apps/server && deno task dev",
"dev:web": "cd apps/web && deno task dev",
"db:up": "docker compose -f docker-compose.dev.yml up -d --wait",
"db:migrate": "deno run -A --env-file=.env packages/db/scripts/migrate.ts latest",
"db:reset": "deno run -A --env-file=.env packages/db/scripts/migrate.ts reset",
"gen:types": "deno run -A --env-file=.env packages/db/scripts/gen-types.ts",
"types": "mkdir -p types && deno types > types/deno.d.ts",
"typecheck": "deno task types && deno task typecheck:tsc && deno task typecheck:deno",
"typecheck:tsc": "deno task --members -j 1 typecheck",
"typecheck:deno": "deno check apps/server/src/main.ts packages/contracts/src/index.ts packages/db/src/index.ts packages/db/scripts/migrate.ts packages/db/scripts/gen-types.ts packages/db/migrations/*.ts packages/domain/src/index.ts packages/tasks/src/index.ts packages/tasks/migrations/*.ts packages/agent/src/index.ts && deno check --unstable-sloppy-imports packages/adapters/src/index.ts packages/testkit/src/index.ts",
"lint": "deno run -A npm:@biomejs/biome check .",
"fmt": "deno run -A npm:@biomejs/biome check --write .",
"test": "deno task --members -j 1 test",
"check:tokens": "cd apps/web && deno task check:tokens",
"build": "cd apps/web && deno task build && cd ../server && deno task compile",
"ci": "deno task typecheck && deno task lint && deno task test && deno task check:tokens && deno task build",
"gen:tokens": "cd apps/web && deno task gen:tokens"
}
}
Generated
+5437
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,4 +1,4 @@
# 开发数据库:pnpm db:up / docker compose -f docker-compose.dev.yml up -d # 开发数据库:deno task db:up / docker compose -f docker-compose.dev.yml up -d
name: umt-dev name: umt-dev
services: services:
postgres: postgres:
+52
View File
@@ -0,0 +1,52 @@
# 生产/试运行部署:app(单容器:API + worker + web 静态)+ postgres 两个服务。
# 用法:
# export SECRET_MASTER_KEY=$(openssl rand -hex 32) POSTGRES_PASSWORD=$(openssl rand -hex 16)
# docker compose up -d --build
# 开发只起数据库用 docker-compose.dev.yml。
name: umt
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: umt
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?请设置 POSTGRES_PASSWORD}
POSTGRES_DB: umt
volumes:
- umt-pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umt -d umt"]
interval: 5s
timeout: 3s
retries: 20
logging: *logging
app:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-}
image: uhd-media-tool:${APP_VERSION:-latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgres://umt:${POSTGRES_PASSWORD}@postgres:5432/umt
SECRET_MASTER_KEY: ${SECRET_MASTER_KEY:?请设置 SECRET_MASTER_KEY(至少 32 字节随机串)}
LOG_LEVEL: ${LOG_LEVEL:-info}
ports:
- "${UMT_PORT:-4380}:4380"
stop_grace_period: 15s
logging: *logging
volumes:
umt-pg-data:
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# 容器入口:迁移 → 启动自检 → api 与 worker 两个进程互相看护。
# 任一进程退出就停掉另一个并以非 0 退出,把"半死容器"变成整体退出,交给 restart 策略自愈。
set -euo pipefail
BIN=/app/umt-server
"$BIN" --role migrate
"$BIN" --role preflight
"$BIN" --role api &
api_pid=$!
"$BIN" --role worker &
worker_pid=$!
stop_all() {
kill -TERM "$api_pid" "$worker_pid" 2>/dev/null || true
wait "$api_pid" "$worker_pid" 2>/dev/null || true
}
on_signal() {
echo "entrypoint: 收到 $1,停止 api 与 worker" >&2
stop_all
exit 0
}
trap 'on_signal SIGTERM' TERM
trap 'on_signal SIGINT' INT
set +e
wait -n "$api_pid" "$worker_pid"
code=$?
set -e
echo "entrypoint: 有进程退出(code=$code),停止其余进程" >&2
stop_all
# 常驻进程不应自行正常退出;按失败处理以触发重启
if [ "$code" -eq 0 ]; then code=1; fi
exit "$code"
+3 -1
View File
@@ -1,6 +1,8 @@
# ADR-001:运行时采用 Node.js 24 LTS + pnpm workspace,而非 Deno # ADR-001:运行时采用 Node.js 24 LTS + pnpm workspace,而非 Deno
- 状态:已采纳(2026-09-27) > **Superseded by [ADR-004](ADR-004-deno-runtime.md)**(2026-09-27,W1.5 已迁移到 Deno 2.9.7 + TypeScript 7.0.2)。以下内容仅作历史记录。
- 状态:已被 ADR-004 取代(2026-09-27);原状态:已采纳(2026-09-27)
- 关联:REQ §9(技术架构选择)、docs/plan/00-first-version-plan.md §1 - 关联:REQ §9(技术架构选择)、docs/plan/00-first-version-plan.md §1
## 背景 ## 背景
+2 -2
View File
@@ -10,12 +10,12 @@
## 硬规则 ## 硬规则
1. **新增语义先加 token 再用。** 需要新的颜色、字号、圆角、时长或缓动时,先在 `app.css` 定义语义 token(日夜两套值都给),跑 `pnpm --filter @umt/web gen:tokens`,再在组件里引用。 1. **新增语义先加 token 再用。** 需要新的颜色、字号、圆角、时长或缓动时,先在 `app.css` 定义语义 token(日夜两套值都给),跑 `deno task gen:tokens`,再在组件里引用。
2. **禁止原色与十六进制。** 组件里不写 `#xxxxxx`、`text-[#..]`、`bg-red-500` 这类 Tailwind 调色板色、`white/10` 之类的黑白透明度,也不写 `text-[13px]`、`duration-300`、`ease-[cubic..]`、裸 `rounded`。状态只用 `success` / `warning` / `destructive`,并且配文字,不只靠颜色。 2. **禁止原色与十六进制。** 组件里不写 `#xxxxxx`、`text-[#..]`、`bg-red-500` 这类 Tailwind 调色板色、`white/10` 之类的黑白透明度,也不写 `text-[13px]`、`duration-300`、`ease-[cubic..]`、裸 `rounded`。状态只用 `success` / `warning` / `destructive`,并且配文字,不只靠颜色。
3. **材质按层级只有三种**,不在玻璃上叠玻璃:已在卡片里的空态用 `<EmptyState plain>`。 3. **材质按层级只有三种**,不在玻璃上叠玻璃:已在卡片里的空态用 `<EmptyState plain>`。
4. 确需例外(第三方库的颜色参数等),在该行或上一行写 `token-allow: 原因`。 4. 确需例外(第三方库的颜色参数等),在该行或上一行写 `token-allow: 原因`。
以上规则由 `pnpm check:tokens`(`apps/web/scripts/check-design-tokens.sh` + 生成物校验)在 `pnpm run ci` 中守住。 以上规则由 `deno task check:tokens`(`apps/web/scripts/check-design-tokens.sh` + 生成物校验)在 `deno task ci` 中守住。
## 控制台共享件 ## 控制台共享件
+3 -3
View File
@@ -41,7 +41,7 @@
2026-09-12 迁移时零散字号并入最近一档(10.5 → 11、13.5 → 14、0.72rem → 11、1.05rem → 17 等,最大偏差 0.6px,约 120 处),经页面级截图复验;标题阶逐档保留现状值,流式标题各处原值保留,收敛是设计决定另议。 2026-09-12 迁移时零散字号并入最近一档(10.5 → 11、13.5 → 14、0.72rem → 11、1.05rem → 17 等,最大偏差 0.6px,约 120 处),经页面级截图复验;标题阶逐档保留现状值,流式标题各处原值保留,收敛是设计决定另议。
tailwind-merge 不认识的 `text-*` 会被当成文字颜色,`text-control text-muted-foreground` 合并时字号会被丢掉。登记表不再手写:[gen-design-tokens.mjs](../../apps/web/scripts/gen-design-tokens.mjs) 从 app.css 抽出字号、缓动、圆角、时长 token 生成 `app/lib/design-tokens.generated.ts`,`cn()` 读它;改了 app.css 跑 `pnpm gen:tokens`,`pnpm run ci` 校验生成物是最新的。 tailwind-merge 不认识的 `text-*` 会被当成文字颜色,`text-control text-muted-foreground` 合并时字号会被丢掉。登记表不再手写:[gen-design-tokens.mjs](../../apps/web/scripts/gen-design-tokens.mjs) 从 app.css 抽出字号、缓动、圆角、时长 token 生成 `app/lib/design-tokens.generated.ts`,`cn()` 读它;改了 app.css 跑 `deno task gen:tokens`,`deno task ci` 校验生成物是最新的。
## 圆角 ## 圆角
@@ -81,11 +81,11 @@ tailwind-merge 不认识的 `text-*` 会被当成文字颜色,`text-control text
- 组件里不出现 Tailwind 调色板颜色(`emerald-*`、`amber-*`、`rose-*`、`blue-*` 等):状态用 `success` / `warning` / `destructive`,装饰性图标与强调用 `foreground` 的透明度。 - 组件里不出现 Tailwind 调色板颜色(`emerald-*`、`amber-*`、`rose-*`、`blue-*` 等):状态用 `success` / `warning` / `destructive`,装饰性图标与强调用 `foreground` 的透明度。
- 组件里不出现 `text-[Npx]` / `text-[Nrem]` / `text-[clamp(...)]`,字号只从阶梯取。 - 组件里不出现 `text-[Npx]` / `text-[Nrem]` / `text-[clamp(...)]`,字号只从阶梯取。
- 组件里不出现 `duration-N`、`ease-[...]`、裸 `rounded`、`rounded-[...]`。 - 组件里不出现 `duration-N`、`ease-[...]`、裸 `rounded`、`rounded-[...]`。
- 改了 app.css 里的 token 就跑 `pnpm gen:tokens`,让 tailwind-merge 的登记表跟上;CI 会校验。 - 改了 app.css 里的 token 就跑 `deno task gen:tokens`,让 tailwind-merge 的登记表跟上;CI 会校验。
- 播放器(`app/routes/stream/player/`)里压在画面上的东西用 `on-media` 族;流媒体浏览页用 `content` / `wash` 族。分不清就问一句:主题切到浅色时它应不应该变色。 - 播放器(`app/routes/stream/player/`)里压在画面上的东西用 `on-media` 族;流媒体浏览页用 `content` / `wash` 族。分不清就问一句:主题切到浅色时它应不应该变色。
- 新的语义需求先加 token 再用,不在组件里临时拼色。 - 新的语义需求先加 token 再用,不在组件里临时拼色。
- 按钮 hover 不填实心 `primary` / `foreground`;浮层不手写 `shadow-2xl`,用 `pg-float`。 - 按钮 hover 不填实心 `primary` / `foreground`;浮层不手写 `shadow-2xl`,用 `pg-float`。
- `scripts/check-design-tokens.sh` 在 `pnpm run ci` 里对整个 `app/` 做守卫(落地页组件与 `/dev/ui` 除外)。确实需要例外时(二维码必须白底、支付品牌色),在该行或上一行写注释 `token-allow: 原因`,不另开白名单。 - `scripts/check-design-tokens.sh` 在 `deno task ci` 里对整个 `app/` 做守卫(落地页组件与 `/dev/ui` 除外)。确实需要例外时(二维码必须白底、支付品牌色),在该行或上一行写注释 `token-allow: 原因`,不另开白名单。
## 迁移状态 ## 迁移状态
+1 -1
View File
@@ -30,7 +30,7 @@
- 流媒体页面的内容区统一用 `max-w-[1800px]` 加 `px-4 sm:px-6 lg:px-10`,标题、海报行和顶栏对齐。 - 流媒体页面的内容区统一用 `max-w-[1800px]` 加 `px-4 sm:px-6 lg:px-10`,标题、海报行和顶栏对齐。
7. **动效只做一次性的**。入场、按压回弹、悬停反馈可以做;持续运动的装饰不做。主按钮的光谱环是唯一的例外,而且在减少动态效果时停转。 7. **动效只做一次性的**。入场、按压回弹、悬停反馈可以做;持续运动的装饰不做。主按钮的光谱环是唯一的例外,而且在减少动态效果时停转。
这些规则里能机器检查的,由 [check-design-tokens.sh](../../apps/web/scripts/check-design-tokens.sh) 在 `pnpm run ci` 中守住(见[设计 token](design-tokens.md#规则))。 这些规则里能机器检查的,由 [check-design-tokens.sh](../../apps/web/scripts/check-design-tokens.sh) 在 `deno task ci` 中守住(见[设计 token](design-tokens.md#规则))。
## 结构 ## 结构
+1 -1
View File
@@ -12,7 +12,7 @@
| 数据库 | PostgreSQL 16;Kysely + pg;SQL 迁移文件 + Kysely Migrator;`kysely-codegen` 或手写 DB 类型 | 开发用 docker-compose 起 `postgres:16-alpine`,端口 55433。 | | 数据库 | PostgreSQL 16;Kysely + pg;SQL 迁移文件 + Kysely Migrator;`kysely-codegen` 或手写 DB 类型 | 开发用 docker-compose 起 `postgres:16-alpine`,端口 55433。 |
| 持久任务引擎 | 优先 `[email protected]`(peer: postgres.js);**须先通过 spike 验证**(重启恢复/等待信号/取消/重试)。未通过则用 `packages/tasks` 内的 PG 租约式任务表(lease + attempts + dead-letter)。 | 结论写 ADR-002。 | | 持久任务引擎 | 优先 `[email protected]`(peer: postgres.js);**须先通过 spike 验证**(重启恢复/等待信号/取消/重试)。未通过则用 `packages/tasks` 内的 PG 租约式任务表(lease + attempts + dead-letter)。 | 结论写 ADR-002。 |
| Agent 内核 | `@earendil-works/[email protected]` + `@earendil-works/[email protected]`,锁版本;通过自有 `AgentRuntimePort` 封装,显式工具白名单,不开放 bash/文件系统/任意网络 | 测试用脚本化 fake 模型 provider;真实上游由设置页配置。 | | Agent 内核 | `@earendil-works/[email protected]` + `@earendil-works/[email protected]`,锁版本;通过自有 `AgentRuntimePort` 封装,显式工具白名单,不开放 bash/文件系统/任意网络 | 测试用脚本化 fake 模型 provider;真实上游由设置页配置。 |
| 校验/测试 | 测试运行器以 Deno spike 结论为准(vitest via Deno 或 `deno test`);web 沿用 token 守卫脚本;lint/format 用 `deno lint/fmt` 或 biome(spike 定) | `deno task ci` = typecheck(tsgo 7)+ lint + test + check:tokens + build | | 校验/测试 | vitest 5(`deno run -A npm:vitest run`,`deno task test` 逐成员执行);类型门禁两道:tsc 7.0.2(`npm:[email protected]`,Go 原生实现,入口 `tsc`)+ `deno check`(Deno 内置 TS 6.0.3);lint/format 用 biome 2.5.14(开启 `useImportExtensions`,相对 import 必须带扩展名);web 沿用 token 守卫脚本 | `deno task ci` = typecheck(tsc 7.0.2 + deno check)+ lint + test + check:tokens + build(web 构建 + `deno compile` 单文件 server)。W1.5 实测(2026-09-27)连跑 3 次全绿,约 75 秒/次;测试数 server 24、adapters 214(+2 skip)、agent 27、contracts 361、db 11、domain 230、tasks 15、testkit 1、web 2 |
| 端口 | web dev 5180,API 4380,PG 55433 | 本机 3000/5175/5176/55432 已被占用,不要用 | | 端口 | web dev 5180,API 4380,PG 55433 | 本机 3000/5175/5176/55432 已被占用,不要用 |
| 参考源码 | MoviePilot v3: `/opt/moviepilot-plus/upstream/MoviePilot`(提交 e6820a8,GPLv3,只做行为对照与 TS 重写,不复制文件)。**数据来源适配(站点 spider、馒头、RSS、TMDB、qB)照抄 MP v3 实现迁移**(用户 2026-09-27 明确):`app/modules/indexer/spider/__init__.py`、`spider/mtorrent.py`、`app/application/rss.py`、`app/modules/themoviedb/tmdbapi.py`、`app/modules/qbittorrent/qbittorrent.py`;站点定义按 `app/application/site/sites.pyi` 的 schema 手写(MP 的定义文件 `user.sites.v3.bin` 是加密的,无法取用)。**用户 2026-09-27 追加:MP 开源的站点框架全部移植**——所有专用 spider(haidan/hddolby/rousi/sunnypt/tnode/torrentleech/yema/mtorrent)与所有站点类型用户信息解析器(nexus_php/nexus_audiences/nexus_hhanclub/nexus_project/nexus_rabbit/gazelle/unit3d/discuz/ipt/file_list/small_horse/zhixing/bitpt/…),文件与函数一一对应,并维护 `docs/porting/mp-sync-ledger.md` + `scripts/mp-sync` 差异脚本,以后跟随 MP 提交增量移植;MovieClaw 若本机不存在则按 REQ 附录链接对照 | 迁移对照记录写 `docs/porting/` | | 参考源码 | MoviePilot v3: `/opt/moviepilot-plus/upstream/MoviePilot`(提交 e6820a8,GPLv3,只做行为对照与 TS 重写,不复制文件)。**数据来源适配(站点 spider、馒头、RSS、TMDB、qB)照抄 MP v3 实现迁移**(用户 2026-09-27 明确):`app/modules/indexer/spider/__init__.py`、`spider/mtorrent.py`、`app/application/rss.py`、`app/modules/themoviedb/tmdbapi.py`、`app/modules/qbittorrent/qbittorrent.py`;站点定义按 `app/application/site/sites.pyi` 的 schema 手写(MP 的定义文件 `user.sites.v3.bin` 是加密的,无法取用)。**用户 2026-09-27 追加:MP 开源的站点框架全部移植**——所有专用 spider(haidan/hddolby/rousi/sunnypt/tnode/torrentleech/yema/mtorrent)与所有站点类型用户信息解析器(nexus_php/nexus_audiences/nexus_hhanclub/nexus_project/nexus_rabbit/gazelle/unit3d/discuz/ipt/file_list/small_horse/zhixing/bitpt/…),文件与函数一一对应,并维护 `docs/porting/mp-sync-ledger.md` + `scripts/mp-sync` 差异脚本,以后跟随 MP 提交增量移植;MovieClaw 若本机不存在则按 REQ 附录链接对照 | 迁移对照记录写 `docs/porting/` |
+42 -13
View File
@@ -2,20 +2,20 @@
## 前置 ## 前置
- Node.js ≥ 24(当前开发机 24.15)、pnpm 11(`packageManager` 已锁)、Docker。 - Deno 2.9.7(锁定;`curl -fsSL https://deno.land/install.sh | sh -s v2.9.7`)、Docker。不需要 Node.js / pnpm。
- 端口:web 5180、API 4380、PG 55433。本机 3000/5175/5176/55432 已被其他项目占用,不要改用。 - 端口:web 5180、API 4380、PG 55433。本机 3000/5175/5176/55432 已被其他项目占用,不要改用。
## 首次启动 ## 首次启动
```sh ```sh
cp .env.example .env # 按需修改;server 与 db 脚本都读仓库根 .env cp .env.example .env # 按需修改;server 与 db 脚本都读仓库根 .env
pnpm db:up # 起 postgres:16-alpine(docker-compose.dev.yml,数据卷 umt-pg-data) deno task db:up # 起 postgres:16-alpine(docker-compose.dev.yml,数据卷 umt-pg-data)
pnpm install deno install # 按 deno.lock 装依赖到 node_modules(成员包软链到 node_modules/@umt/*)
pnpm db:migrate # 执行 packages/db/migrations 下全部迁移 deno task db:migrate # 执行 packages/db/migrations 下全部迁移
pnpm dev # 同时起 server(4380)与 web(5180) deno task dev # 同时起 server(4380)与 web(5180)
``` ```
单独起:`pnpm dev:server`(默认 `--role all`,API + worker 同进程)、`pnpm dev:web`。 单独起:`deno task dev:server`(`deno run --watch`,默认 `--role all`,API + worker 同进程)、`deno task dev:web`。
## 验证 ## 验证
@@ -32,18 +32,47 @@ curl -N -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/even
| 命令 | 作用 | | 命令 | 作用 |
|---|---| |---|---|
| `pnpm run ci` | typecheck + lint + test + check:tokens + build,提交前必须全绿。注意 `pnpm ci` 在 pnpm 11 是内置的 clean-install 命令,不会执行本脚本 | | `deno task ci` | typecheck(tsc 7.0.2 + deno check)→ lint(biome)→ test(vitest)→ check:tokens → build,提交前必须全绿 |
| `pnpm format` | biome 自动修复格式与 import 排序 | | `deno task typecheck` | `deno types` 生成 `types/deno.d.ts` 后,逐个成员跑 tsc 7.0.2,再跑 `deno check`(Deno 内置 TS 6.0.3) |
| `pnpm db:reset` | 清空 public schema 并重新迁移(生产环境拒绝执行) | | `deno task lint` / `deno task fmt` | biome 检查 / 自动修复(含 import 排序;相对 import 必须带扩展名) |
| `pnpm --filter @umt/web gen:tokens` | 改了 app.css 后重新生成 token 登记表 | | `deno task test` | 逐个成员跑 vitest(`deno run -A npm:vitest run`);单包:`cd packages/<x> && deno task test` |
| `pnpm --filter @umt/server start` | 运行 `pnpm build` 产出的 `apps/server/dist/main.js` | | `deno task db:reset` | 清空 public schema 并重新迁移(生产环境拒绝执行) |
| `deno task gen:types` | 从已迁移的库反射生成 `packages/db/src/types.ts` |
| `deno task gen:tokens` | 改了 app.css 后重新生成 token 登记表 |
| `deno task build` | web 构建到 `apps/web/build/client` + server 编译为单文件 `dist/umt-server` |
| `cd apps/server && deno task start` | 以受限权限(net/env/sys/read)直接跑源码 |
依赖:npm 包版本只在根 `deno.json` 的 `imports` 里登记(web 的前端依赖仍在 `apps/web/package.json`)。改完跑 `deno install` 更新 `deno.lock`。有安装脚本的新依赖需要 `deno approve-scripts` 显式批准。
## 进程角色 ## 进程角色
`node dist/main.js --role api|worker|all`。`all` 下 worker 心跳会发布到进程内事件总线,SSE 可见;分进程部署时事件总线需改为 PG 投影(W2)。 `umt-server --role api|worker|all`(源码:`deno run ... apps/server/src/main.ts --role ...`)。`all` 下 worker 心跳会发布到进程内事件总线,SSE 可见;分进程部署时事件总线需改为 PG 投影(W2)。
一次性子命令(容器入口用):`--role migrate`(迁移到最新,迁移文件已编进二进制)、`--role preflight`(启动自检:生产环境拒绝占位符密钥与 root、DB 可达、`WEB_ROOT/index.html` 存在)、`--role probe`(打本机 `/api/v1/health`,HEALTHCHECK 用)。
设置 `WEB_ROOT` 后 API 进程在同一端口托管 web 构建产物:存在的文件直接返回,其余非 `/api` 路径回退 `index.html`。
## Docker 单容器部署
```sh
export SECRET_MASTER_KEY=$(openssl rand -hex 32) POSTGRES_PASSWORD=$(openssl rand -hex 16)
docker compose up -d --build # 服务:app(4380)+ postgres(数据卷 umt-pg-data)
curl localhost:4380/api/v1/health
```
- 镜像:`denoland/deno:2.9.7` 构建 web 与 `deno compile` 出二进制,运行时 `debian:trixie-slim`,非 root(uid 10001),约 270 MB。
- 入口 `docker/entrypoint.sh`:迁移 → 自检 → 同时起 api 与 worker 两个进程;任一退出则停掉另一个并以非 0 退出,由 `restart: unless-stopped` 拉起。
- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read=/app`,不给 `--allow-run` / 写权限。
- 构建版本号:`APP_VERSION=v0.1.0 docker compose build`(写入 web 的 `version.json`)。
## 测试数据库
需要 PG 的测试(db、tasks)默认连 `DATABASE_URL` 或 55433 开发库,不可达时跳过。tasks 包可用 `TASKS_TEST_DATABASE_URL` 指定独立库;每个测试文件使用带 pid+随机后缀的独立 schema,结束时删除,多份测试并发跑也不冲突。
## 排障 ## 排障
- `配置无效`:按提示检查 `.env`,`SECRET_MASTER_KEY` 至少 16 字符,`DATABASE_URL` 必须是 postgres://。 - `配置无效`:按提示检查 `.env`,`SECRET_MASTER_KEY` 至少 16 字符,`DATABASE_URL` 必须是 postgres://。
- health 返回 `db: "down"`:`docker compose -f docker-compose.dev.yml ps` 看容器健康状态。 - health 返回 `db: "down"`:`docker compose -f docker-compose.dev.yml ps` 看容器健康状态。
- 日志中的 passkey/token/cookie/authorization/password 字段与 URL query 参数会被替换为 `[REDACTED]`;若发现明文,属于缺陷。 - `deno check` 报找不到 `Deno`:检查根目录是否误放了 `tsconfig.json`(tsc 基座在 `config/tsconfig.base.json`)。
- tsc 报 `Deno` 未定义:先跑 `deno task types` 生成 `types/deno.d.ts`(已 gitignore)。
- 日志中的 passkey/token/cookie/authorization/password 字段与 URL query 参数会被替换为 `[REDACTED]`(含 `logger.child()` 绑定字段与 `err` 的 message/stack);若发现明文,属于缺陷。
-31
View File
@@ -1,31 +0,0 @@
{
"name": "uhd-media-tool",
"private": true,
"type": "module",
"packageManager": "[email protected]",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "pnpm --parallel --filter @umt/server --filter @umt/web dev",
"dev:web": "pnpm --filter @umt/web dev",
"dev:server": "pnpm --filter @umt/server dev",
"db:up": "docker compose -f docker-compose.dev.yml up -d --wait",
"db:migrate": "pnpm --filter @umt/db migrate",
"db:reset": "pnpm --filter @umt/db reset",
"typecheck": "pnpm -r --workspace-concurrency=4 typecheck",
"lint": "biome check .",
"format": "biome check --write .",
"test": "pnpm -r test",
"check:tokens": "pnpm --filter @umt/web check:tokens",
"build": "pnpm -r build",
"ci": "pnpm typecheck && pnpm lint && pnpm test && pnpm check:tokens && pnpm build"
},
"devDependencies": {
"@biomejs/biome": "^2.5.14",
"@types/node": "^24",
"tsx": "^4.23.15",
"typescript": "^5.9.2",
"vitest": "^5.0.2"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@umt/adapters",
"version": "0.0.0",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
}
}
-14
View File
@@ -1,22 +1,8 @@
{ {
"name": "@umt/adapters", "name": "@umt/adapters",
"version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"cheerio": "^1.2.0",
"fast-xml-parser": "^5.11.1",
"nunjucks": "^3.2.4"
},
"devDependencies": {
"@types/nunjucks": "^3.2.6",
"@umt/testkit": "workspace:*"
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import {
identitiesCompatible, identitiesCompatible,
MediaServerRegistry, MediaServerRegistry,
providerIdentity, providerIdentity,
} from "./index"; } from "./index.ts";
let mocks: MockEmby[] = []; let mocks: MockEmby[] = [];
afterEach(async () => { afterEach(async () => {
+1 -1
View File
@@ -1 +1 @@
export * from "./client"; export * from "./client.ts";
+1 -1
View File
@@ -6,7 +6,7 @@ import { link, lstat, mkdir, open, readdir, rename, stat, unlink, utimes } from
import { basename, dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import { Transform } from "node:stream"; import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { errnoOf, FsError, toFsError } from "./errors"; import { errnoOf, FsError, toFsError } from "./errors.ts";
export type VerifyMethod = "size" | "sha256" | "sample"; export type VerifyMethod = "size" | "sha256" | "sample";
+1 -1
View File
@@ -1,7 +1,7 @@
// 本项目新增:ffprobe 封装与 statfs 磁盘用量(REQ §8.2、§8.4.1);失败一律返回 unknown。 // 本项目新增:ffprobe 封装与 statfs 磁盘用量(REQ §8.2、§8.4.1);失败一律返回 unknown。
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { statfs } from "node:fs/promises"; import { statfs } from "node:fs/promises";
import { errnoOf } from "./errors"; import { errnoOf } from "./errors.ts";
export interface ProbeVideo { export interface ProbeVideo {
codec?: string; codec?: string;
+1 -1
View File
@@ -14,7 +14,7 @@ import {
scanIncremental, scanIncremental,
statSafe, statSafe,
sweepStalePartials, sweepStalePartials,
} from "./index"; } from "./index.ts";
const trees: TempTree[] = []; const trees: TempTree[] = [];
async function tree(...a: Parameters<typeof createTempTree>) { async function tree(...a: Parameters<typeof createTempTree>) {
+6 -6
View File
@@ -1,6 +1,6 @@
export * from "./copy"; export * from "./copy.ts";
export * from "./errors"; export * from "./errors.ts";
export * from "./ffprobe"; export * from "./ffprobe.ts";
export * from "./move"; export * from "./move.ts";
export * from "./scan"; export * from "./scan.ts";
export * from "./stat"; export * from "./stat.ts";
+2 -2
View File
@@ -2,8 +2,8 @@
// 调整:EXDEV 显式返回不降级;跨设备拆为 copy→verify→delete_source 可重入三步,删源失败 = attention。 // 调整:EXDEV 显式返回不降级;跨设备拆为 copy→verify→delete_source 可重入三步,删源失败 = attention。
import { link, lstat, rename, stat, unlink } from "node:fs/promises"; import { link, lstat, rename, stat, unlink } from "node:fs/promises";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { type CopyOptions, copyWithVerify, filesMatch, type VerifyMethod } from "./copy"; import { type CopyOptions, copyWithVerify, filesMatch, type VerifyMethod } from "./copy.ts";
import { errnoOf, FsError, toFsError } from "./errors"; import { errnoOf, FsError, toFsError } from "./errors.ts";
export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "exists" }; export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "exists" };
+2 -2
View File
@@ -2,8 +2,8 @@
import type { Dirent } from "node:fs"; import type { Dirent } from "node:fs";
import { lstat, readdir } from "node:fs/promises"; import { lstat, readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { errnoOf } from "./errors"; import { errnoOf } from "./errors.ts";
import { checkRoot, DEFAULT_ROOT_SENTINEL } from "./stat"; import { checkRoot, DEFAULT_ROOT_SENTINEL } from "./stat.ts";
export interface ScanEntry { export interface ScanEntry {
/** 相对根目录的路径,/ 分隔 */ /** 相对根目录的路径,/ 分隔 */
+1 -1
View File
@@ -1,7 +1,7 @@
// 本项目新增(REQ §8.2 挂载根哨兵);MP 无对应实现。 // 本项目新增(REQ §8.2 挂载根哨兵);MP 无对应实现。
import { lstat, stat, writeFile } from "node:fs/promises"; import { lstat, stat, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { errnoOf } from "./errors"; import { errnoOf } from "./errors.ts";
export const DEFAULT_ROOT_SENTINEL = ".uhd-root"; export const DEFAULT_ROOT_SENTINEL = ".uhd-root";
+6 -6
View File
@@ -1,8 +1,8 @@
// @umt/adapters 公开入口;W1 起在此导出实现 // @umt/adapters 公开入口;W1 起在此导出实现
export const PACKAGE_NAME = "@umt/adapters"; export const PACKAGE_NAME = "@umt/adapters";
export * from "./emby"; export * from "./emby/index.ts";
export * from "./fs"; export * from "./fs/index.ts";
export * from "./qbittorrent"; export * from "./qbittorrent/index.ts";
export * from "./rss"; export * from "./rss/index.ts";
export * from "./sites"; export * from "./sites/index.ts";
export * from "./tmdb"; export * from "./tmdb/index.ts";
+3 -3
View File
@@ -5,9 +5,9 @@
* 叠加:多实例、错误分类(DownloaderError)、提交幂等(本地 infohash 预查 + 收单超时按 hash 回查)。 * 叠加:多实例、错误分类(DownloaderError)、提交幂等(本地 infohash 预查 + 收单超时按 hash 回查)。
*/ */
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { DownloaderError, type DownloaderErrorKind } from "./errors"; import { DownloaderError, type DownloaderErrorKind } from "./errors.ts";
import { isDownloadComplete, mapQbState, type QbObservedState } from "./state"; import { isDownloadComplete, mapQbState, type QbObservedState } from "./state.ts";
import { BencodeError, parseMagnet, parseTorrent } from "./torrent"; import { BencodeError, parseMagnet, parseTorrent } from "./torrent.ts";
export interface QbInstanceConfig { export interface QbInstanceConfig {
/** 本系统内的下载器实例 ID(downloader_instance.id) */ /** 本系统内的下载器实例 ID(downloader_instance.id) */
+5 -5
View File
@@ -1,5 +1,5 @@
export * from "./client"; export * from "./client.ts";
export * from "./errors"; export * from "./errors.ts";
export * from "./paths"; export * from "./paths.ts";
export * from "./state"; export * from "./state.ts";
export * from "./torrent"; export * from "./torrent.ts";
@@ -1,7 +1,7 @@
import { createMockQb, type MockQb, makeMagnet, makeTorrent } from "@umt/testkit"; import { createMockQb, type MockQb, makeMagnet, makeTorrent } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { createQbClient, DownloaderError, isDownloadComplete, mapQbState, parseMagnet, parseTorrent } from "./index"; import { createQbClient, DownloaderError, isDownloadComplete, mapQbState, parseMagnet, parseTorrent } from "./index.ts";
import { fromDownloaderPath, toDownloaderPath } from "./paths"; import { fromDownloaderPath, toDownloaderPath } from "./paths.ts";
let mock: MockQb | null = null; let mock: MockQb | null = null;
afterEach(async () => { afterEach(async () => {
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more