Files
cms-website/scripts/sf.ts
T
浅念 c28c1655a4 fix(source): DB 事务化;storageRoot 入 metadata;siteCreated 与并发测试;CLI 体验与校验小修
评审修复(4 项 Important + 8 项 Minor):
- DB 事务化:site→connection→snapshots 全部落库包进 db.transaction 单事务
  ("任何失败零落库"字面成立);助手改收 Pick<select|insert|execute> 结构类型
  (tx 与直连 db 通用)。并发撞唯一约束不在事务内补查(语句失败后续查 25P02),
  改为 registerSource 层事务级重试(≤3 次,第二次 select 命中复用分支)
- connection 并发:select-then-insert 无约束可兜底,事务内 pg_advisory_xact_lock
  (siteId:name) 串行化同键登记(kind 枚举扩展推迟 REST 通道,注释注明)
- storageRoot 绝对路径固化进每条 snapshot metadata 与报告 inputs(快照不可变,
  事后无法补写——永久缺口封堵)
- ensureSite 返回 created;RegisterResult.siteCreated;CLI 站点已存在时提示
  --name/--locale/--origin 未生效
- 媒体复制期逐文件 sha 复核(copyAndHash 对拍清单,源中途变更 → COPY_FAILED)
- 空媒体目录拒绝(EMPTY_MEDIA_DIR)
- CLI:PATH_NOT_ALLOWED 追加 --allow 提示;--origin 经 new URL 校验(http/s)、
  --locale 语言标签形状校验,fail fast;开跑打印生效白名单(审计)
- makeRunId 服务导出、CLI 复用;.tmp 残留手工清理策略写入文件头注释
- 测试 +2:并发同输入恰好一组行/双方同 id/新建标志恰一(3 次重跑稳定);
  isUniqueViolation 形状单测(drizzle cause.code / 裸 code / 其他)

全套 263 绿;typecheck / lint 绿;CLI 冒烟对临时库验证事务路径、siteCreated
输出、defaults 落库(en-US / https://qa.example)、白名单打印与校验报错

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 13:06:48 +08:00

163 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// sf CLI(Plan 2 Task 7):commander 两命令——
// - wxr:probe:只读诊断(任意可读路径;Task 8 直读 cms 用此通道),出 JSON/HTML 报告不入库;
// - source:register:调 registerSource 登记 content-addressed 快照(路径白名单校验在服务内)。
// 执行机制(Plan 2 评审钉死):根 devDep tsx 直吃 TS 源码(含 apps/api 的 Nest 源码与
// packages 的装饰器/TS 源文件),@sf/* 经根/包 tsconfig paths 直连 src——无需预构建 dist。
// 注意:CLI 只 import register.service.ts(零装饰器纯函数文件),不拉起 Nest 模块——
// 反射元数据依赖由此避免;source.module.ts 留给 standalone context。
import { createReadStream } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Command } from 'commander'
import { renderReportHtml, runWxrAdapterReport, type WxrAdapterReport } from '@sf/adapter-wp'
import { getDb } from '../apps/api/src/db/client.js'
import {
makeRunId,
registerSource,
SourceRegisterError,
type RegisterInput,
} from '../apps/api/src/modules/source/register.service.js'
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
/** 探测报告摘要(stdout):版本块之外的计数/清洗/耗时 + 致命错误 */
function printProbeSummary(report: WxrAdapterReport): void {
console.log(` rows=${report.totals.rows} terms=${report.totals.terms} unsupported=${report.totals.unsupported} failed=${report.totals.failed}`)
console.log(` changedRows=${report.sanitize.changedRows} removed-tag=${report.sanitize.byDiffType['removed-tag']} removed-attr=${report.sanitize.byDiffType['removed-attr']}`)
console.log(` timing: parse=${report.timing.parseMs.toFixed(1)}ms sanitize=${report.timing.sanitizeMs.toFixed(1)}ms total=${report.timing.totalMs.toFixed(1)}ms`)
for (const type of Object.keys(report.counts).sort()) {
const c = report.counts[type]!
console.log(` ${type}: created=${c.created} skipped=${c.skipped} failed=${c.failed} unsupported=${c.unsupported}`)
}
if (report.fatalError !== null) {
console.error(` fatalError: ${report.fatalError}`)
}
}
const program = new Command()
program
.name('sf')
.description('SiteForge CLI(阶段 2:来源输入登记与 WXR 探测)')
.version('0.1.0')
program
.command('wxr:probe')
.description('只读诊断:WXR 全链解析(parse→map→sanitize)并出报告;不入库、不写业务存储,接受任意可读路径')
.requiredOption('--wxr <path>', 'WXR 文件路径(任意可读路径;损坏文件出报告不崩溃)')
.option('--out <dir>', '报告输出根目录', join(REPO_ROOT, 'artifacts', 'probe'))
.action(async (opts: { wxr: string; out: string }) => {
const wxrPath = resolve(opts.wxr)
const runId = makeRunId()
// Task 6 fatalError 契约:IO/损坏文件错误被 runWxrAdapterReport 收敛进报告——
// 这里永远能产出报告文件(createReadStream 的 ENOENT 同样走 fatalError 分支)
const report = await runWxrAdapterReport(createReadStream(wxrPath))
const dir = join(resolve(opts.out), runId)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8')
await writeFile(join(dir, 'report.html'), `${renderReportHtml(report)}\n`, 'utf8')
console.log(`[wxr:probe] runId=${runId}`)
console.log(` input: ${wxrPath}`)
printProbeSummary(report)
console.log(` report: ${join(dir, 'report.json')}`)
if (report.fatalError !== null) process.exitCode = 1
})
program
.command('source:register')
.description('登记来源输入为 content-addressed 快照(WXR / sitemap / 媒体目录,可多选)')
.requiredOption('--site <key>', '站点 key(不存在则自动创建)')
.option('--wxr <path>', 'WXR 文件路径')
.option('--sitemap <path>', 'sitemap 文件路径')
.option('--media <dir>', '媒体目录路径(已解包目录,递归登记)')
.option('--allow <dir...>', '额外输入白名单根目录(可多个;默认含 storage/incoming 与当前目录)', [])
.option('--name <name>', '新建站点时的显示名(缺省 = site key)')
.option('--locale <locale>', '新建站点时的默认 locale(缺省 en)')
.option('--origin <origin>', '新建站点时的生产域名(缺省 https://<key>.example)')
.action(async (opts: {
site: string
wxr?: string
sitemap?: string
media?: string
allow: string[]
name?: string
locale?: string
origin?: string
}) => {
if (opts.wxr === undefined && opts.sitemap === undefined && opts.media === undefined) {
throw new SourceRegisterError('NO_INPUTS', '至少提供 --wxr / --sitemap / --media 之一')
}
// fail fast:建站默认列在服务端只在"站点新建"时生效——先做形状校验,避免跑完摄取才报错
if (opts.origin !== undefined) {
let parsed: URL
try {
parsed = new URL(opts.origin)
} catch {
throw new Error(`--origin 不是合法 URL:${opts.origin}`)
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error(`--origin 必须是 http(s) URL:${opts.origin}`)
}
}
if (opts.locale !== undefined && !/^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(opts.locale)) {
throw new Error(`--locale 不符合语言标签形状(如 en、zh-CN):${opts.locale}`)
}
// CLI 是可信操作者通道:白名单 = storage/incoming(常规通道)+ 操作者所在 cwd(显式
// 于此运行的动作本身即放行依据)+ --allow 追加根。服务内的白名单校验(PATH_NOT_ALLOWED)
// 防的是未来程序化/REST 通道的越界读取。生效白名单先行打印(审计:路径放行决策可回溯)。
const allowedRoots = [join(REPO_ROOT, 'storage', 'incoming'), process.cwd(), ...opts.allow.map((d) => resolve(d))]
const input: RegisterInput = {
siteKey: opts.site,
allowedRoots,
storageRoot: join(REPO_ROOT, 'storage'),
reportRoot: join(REPO_ROOT, 'artifacts', 'register'),
}
if (opts.wxr !== undefined) input.wxrPath = opts.wxr
if (opts.sitemap !== undefined) input.sitemapPath = opts.sitemap
if (opts.media !== undefined) input.mediaDir = opts.media
const defaultsPassed = opts.name !== undefined || opts.locale !== undefined || opts.origin !== undefined
if (defaultsPassed) {
const defaults: NonNullable<RegisterInput['siteDefaults']> = {}
if (opts.name !== undefined) defaults.name = opts.name
if (opts.locale !== undefined) defaults.defaultLocale = opts.locale
if (opts.origin !== undefined) defaults.productionOrigin = opts.origin
input.siteDefaults = defaults
}
console.log(`[source:register] 允许目录:${allowedRoots.join(' ; ')}`)
// getDb 走 DATABASE_URL_TEST ?? DATABASE_URL 回退链——对临时库冒烟只需 env 覆盖,无需改代码
const db = getDb()
try {
const result = await registerSource(db, input)
console.log(`[source:register] runId=${result.runId} site=${result.siteKey} (${result.siteId}${result.siteCreated ? ' 新建' : ' 已存在'})`)
console.log(
` connection=${result.connectionId}${result.connectionCreated ? ' (created)' : ' (existing)'}`,
)
for (const s of result.snapshots) {
console.log(
` ${s.kind}: sha256=${s.sha256.slice(0, 12)}… bytes=${s.byteSize} ${s.reused ? 'snapshot复用' : 'snapshot新建'}${s.copied ? '' : ' 副本已存在'} (${s.durationMs.toFixed(1)}ms)`,
)
}
if (!result.siteCreated && defaultsPassed) {
console.error(' 注意:站点已存在,--name/--locale/--origin 未生效')
}
console.log(` report: ${result.reportPath} (total ${result.durationMs.toFixed(1)}ms)`)
} finally {
await (db as { $client: { end: () => Promise<void> } }).$client.end()
}
})
// parseAsync:action 内抛错走此 catch(commander 默认会 process.exit(1) 并打印堆栈,
// 这里收敛为单行错误信息 + exitCode——SourceRegisterError 打 code,其余原样呈现)
program.parseAsync(process.argv).catch((err: unknown) => {
if (err instanceof SourceRegisterError) {
console.error(`[sf] ${err.code}: ${err.message}`)
if (err.code === 'PATH_NOT_ALLOWED') {
console.error(' 提示:可用 --allow <dir> 放行额外目录,或将文件移入上述目录')
}
} else {
console.error(`[sf] ${err instanceof Error ? err.message : String(err)}`)
}
process.exitCode = 1
})