import { existsSync, readFileSync } from "node:fs"; import { extname } from "node:path"; import { runCommand } from "./command"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { composeConfig } from "./docker"; interface CheckItem { name: string; ok: boolean; detail: unknown; } const syntaxFiles = [ "scripts/cli.ts", "scripts/src/check.ts", "scripts/src/code-queue.ts", "scripts/src/command.ts", "scripts/src/decision-center.ts", "scripts/src/deploy.ts", "scripts/src/docker.ts", "scripts/src/e2e.ts", "scripts/src/remote.ts", "src/components/backend-core/src/index.ts", "src/components/frontend/src/index.ts", "src/components/frontend/src/app.tsx", "src/components/frontend/src/decision-center.tsx", "src/components/provider-gateway/src/index.ts", "src/components/microservices/oa-event-flow/src/index.ts", "src/components/microservices/k3sctl-adapter/src/index.ts", "src/components/microservices/mdtodo/src/index.ts", "src/components/microservices/decision-center/src/index.ts", "src/components/microservices/code-queue-mgr/src/index.ts", ]; export interface CheckOptions { full: boolean; files: boolean; scriptsTypecheck: boolean; components: boolean; compose: boolean; logs: boolean; } const defaultCheckOptions: CheckOptions = { full: false, files: false, scriptsTypecheck: false, components: false, compose: false, logs: false, }; export function parseCheckOptions(args: string[]): CheckOptions { const options = { ...defaultCheckOptions }; for (const arg of args) { if (arg === "--full") { options.full = true; options.files = true; options.scriptsTypecheck = true; options.components = true; options.compose = true; options.logs = true; } else if (arg === "--files") { options.files = true; } else if (arg === "--scripts-typecheck") { options.scriptsTypecheck = true; } else if (arg === "--components") { options.components = true; } else if (arg === "--compose") { options.compose = true; } else if (arg === "--logs") { options.logs = true; } else if (arg === "--basic" || arg === "--syntax-only") { options.full = false; options.files = false; options.scriptsTypecheck = false; options.components = false; options.compose = false; options.logs = false; } else if (arg === "--help" || arg === "-h") { throw new Error("check usage: bun scripts/cli.ts check [--syntax-only|--full|--files|--scripts-typecheck|--components|--compose|--logs]"); } else { throw new Error(`unknown check option: ${arg}`); } } return options; } function fileItem(path: string): CheckItem { const absolute = rootPath(path); return { name: `file:${path}`, ok: existsSync(absolute), detail: absolute }; } function commandItem(name: string, command: string[], timeoutMs = 30_000): CheckItem { const result = runCommand(command, repoRoot, { timeoutMs }); return { name, ok: result.exitCode === 0, detail: { command, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, stdoutTail: result.stdout.slice(-4000), stderrTail: result.stderr.slice(-4000), }, }; } function syntaxItem(): CheckItem { const failures: Array<{ path: string; error: string }> = []; const checked: string[] = []; const ts = new Bun.Transpiler({ loader: "ts" }); const tsx = new Bun.Transpiler({ loader: "tsx" }); for (const path of syntaxFiles) { const absolute = rootPath(path); try { const source = readFileSync(absolute, "utf8"); const loader = extname(path) === ".tsx" ? tsx : ts; loader.transformSync(source); checked.push(path); } catch (error) { failures.push({ path, error: error instanceof Error ? error.message : String(error) }); } } return { name: "syntax:transpile", ok: failures.length === 0, detail: { checked, failures }, }; } function unifiedLogRotationItem(): CheckItem { const serviceFiles = [ "src/components/backend-core/src/logger.ts", "src/components/frontend/src/index.ts", "src/components/provider-gateway/src/index.ts", "src/components/microservices/code-queue/src/index.ts", "src/components/microservices/k3sctl-adapter/src/index.ts", "src/components/microservices/mdtodo/src/index.ts", "src/components/microservices/project-manager/src/index.ts", "src/components/microservices/baidu-netdisk/src/index.ts", "src/components/microservices/oa-event-flow/src/index.ts", "src/components/microservices/decision-center/src/index.ts", "src/components/microservices/code-queue-mgr/src/index.ts", ]; const offenders = serviceFiles.flatMap((path) => { const text = readFileSync(rootPath(path), "utf8"); const directLogAppend = /appendFileSync\(\s*(?:config\.)?logFile\b/u.test(text) || /appendFileSync\(\s*process\.env\.LOG_FILE\b/u.test(text); const missingWriter = !text.includes("createHourlyJsonlWriter"); return directLogAppend || missingWriter ? [{ path, directLogAppend, missingWriter }] : []; }); return { name: "logs:unified-hourly-rotation", ok: offenders.length === 0, detail: { sharedWriter: "src/components/shared/src/rotating-jsonl.ts", checkedFiles: serviceFiles, offenders, }, }; } function skippedItem(name: string, reason: string, enableWith: string): CheckItem { return { name, ok: true, detail: { skipped: true, reason, enableWith } }; } export function runChecks(config: UniDeskConfig, options: CheckOptions = defaultCheckOptions): { ok: boolean; mode: string; options: CheckOptions; items: CheckItem[] } { const items: CheckItem[] = [ { name: "config:validated", ok: true, detail: { project: config.project.name, runtime: config.runtime } }, commandItem("bun:version", ["bun", "--version"]), syntaxItem(), ]; if (options.files) { items.push( fileItem("scripts/cli.ts"), fileItem("AGENTS.md"), fileItem("TEST.md"), fileItem("docker-compose.yml"), fileItem("src/components/backend-core/src/index.ts"), fileItem("src/components/frontend/src/index.ts"), fileItem("src/components/provider-gateway/src/index.ts"), fileItem("src/components/microservices/oa-event-flow/src/index.ts"), fileItem("src/components/microservices/k3sctl-adapter/src/index.ts"), fileItem("src/components/microservices/mdtodo/src/index.ts"), fileItem("src/components/microservices/decision-center/src/index.ts"), fileItem("src/components/microservices/code-queue-mgr/src/index.ts"), fileItem("scripts/src/deploy.ts"), fileItem("scripts/src/e2e.ts"), ); } else { items.push(skippedItem("files:required-entrypoints", "required file presence scan is opt-in", "--files or --full")); } if (options.scriptsTypecheck) { items.push(commandItem("typescript:scripts", ["bunx", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"])); } else { items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full")); } if (options.logs) { items.push(unifiedLogRotationItem()); } else { items.push(skippedItem("logs:unified-hourly-rotation", "heavy policy scan is opt-in", "--logs or --full")); } if (options.components) { items.push(commandItem("typescript:components", ["bunx", "tsc", "-p", "src/tsconfig.check.json", "--pretty", "false"], 180_000)); } else { items.push(skippedItem("typescript:components", "full component TypeScript check is opt-in", "--components or --full")); } if (options.compose) { const compose = composeConfig(config); items.push({ name: "docker-compose:config", ok: compose.result.exitCode === 0, detail: { command: compose.command, exitCode: compose.result.exitCode, signal: compose.result.signal, timedOut: compose.result.timedOut, stdoutTail: compose.result.stdout.slice(-4000), stderrTail: compose.result.stderr.slice(-4000), runtimeEnv: compose.runtimeEnv, }, }); } else { items.push(skippedItem("docker-compose:config", "Docker Compose config rendering is opt-in", "--compose or --full")); } return { ok: items.every((item) => item.ok), mode: options.full ? "full" : "basic", options, items }; }