8f42c838c1
Plan 4 Task 2:build_jobs 生命周期(queued→snapshotting→validating(空)→succeeded) + build_input_snapshots 行(三哈希/主题/config 引用/真实版本列)+ 六文件物化 (site/content/taxonomies/routes/assets/render-manifest——canonicalJson 确定性落盘)。 - render-snapshot.ts:纯物化(db 只读)——published 修订(ADR-010 冻结语义)+ 路由/媒体 ready 集合 → 六文件;bodyHtml URL 重写(剥 host 按 path 与 ready 集合 public_path/source_url 双源比对,命中根相对化、未命中保留+计数);媒体实体路径 三候选解析(快照 storageRoot→仓库存储根→incoming 兜底) - build.service.ts:幂等键 = site+published 集合哈希+主题版本(复用不重跑); repeatable read 单事务一致性读取+落盘+快照行;报告 artifacts/jobs/<id>/reports/ build.json 先于终态落行;解析期失败零 job 行(NOT_PUBLISHED/SITE_NOT_FOUND) - sf.ts + build:create;build.module.ts Nest 薄壳 - tests/integration/build-snapshot.test.ts:10 测试(状态机/六文件面/URL 重写/ 字节确定性/NOT_PUBLISHED/ADR-010 双修订/重写器纯函数四例) Co-Authored-By: Claude Code <noreply@anthropic.com>
364 lines
19 KiB
JavaScript
364 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
||
// sf CLI(Plan 2 Task 7 + Plan 3 Task 1 + Plan 4 Task 1-2):commander 五命令——
|
||
// - wxr:probe:只读诊断(任意可读路径;Task 8 直读 cms 用此通道),出 JSON/HTML 报告不入库;
|
||
// - source:register:调 registerSource 登记 content-addressed 快照(路径白名单校验在服务内);
|
||
// - import:调 createImportJob 建 import_jobs 并推进生命周期(Task 6:默认编排管道——四家写入器汇流);
|
||
// - content:publish:调 publishSiteContent 显式发布(dry-run 清单+confirm token → --confirm 执行);
|
||
// - build:create:调 createBuildJob 冻结 Render Snapshot(六文件物化 + build_input_snapshots;dist 是 Task 5)。
|
||
// 执行机制(Plan 2 评审钉死):根 devDep tsx 直吃 TS 源码(含 apps/api 的 Nest 源码与
|
||
// packages 的装饰器/TS 源文件),@sf/* 经根/包 tsconfig paths 直连 src——无需预构建 dist。
|
||
// 注意:CLI 只 import register.service.ts / import.service.ts / publish.service.ts /
|
||
// build.service.ts(零装饰器纯函数文件),不拉起 Nest 模块——反射元数据依赖由此避免;module 文件留给 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 { BuildError, createBuildJob } from '../apps/api/src/modules/build/build.service.js'
|
||
import {
|
||
ImportError,
|
||
createImportJob,
|
||
type CreateImportJobInput,
|
||
} from '../apps/api/src/modules/import/import.service.js'
|
||
import {
|
||
DEFAULT_ACTOR_LABEL,
|
||
PublishError,
|
||
publishSiteContent,
|
||
} from '../apps/api/src/modules/publish/publish.service.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)), '..')
|
||
|
||
/** confirm 输出的发布清单行数上限(超出以 "…and M more" 截断——192 条真实数据面可读性) */
|
||
const CONFIRM_LIST_CAP = 20
|
||
|
||
/** 探测报告摘要(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(阶段 4:来源登记、导入、显式发布与 Render Snapshot 冻结构建)')
|
||
.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 / 媒体目录 / 媒体映射侧车 JSON,可多选)')
|
||
.requiredOption('--site <key>', '站点 key(不存在则自动创建)')
|
||
.option('--wxr <path>', 'WXR 文件路径')
|
||
.option('--sitemap <path>', 'sitemap 文件路径')
|
||
.option('--media <dir>', '媒体目录路径(已解包目录,递归登记)')
|
||
.option('--media-map <path>', '媒体映射侧车 JSON 路径([{sourceUrl, filePath}] 数组,登记为 manual_url_list 快照)')
|
||
.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
|
||
mediaMap?: string
|
||
allow: string[]
|
||
name?: string
|
||
locale?: string
|
||
origin?: string
|
||
}) => {
|
||
if (
|
||
opts.wxr === undefined &&
|
||
opts.sitemap === undefined &&
|
||
opts.media === undefined &&
|
||
opts.mediaMap === undefined
|
||
) {
|
||
throw new SourceRegisterError('NO_INPUTS', '至少提供 --wxr / --sitemap / --media / --media-map 之一')
|
||
}
|
||
// 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
|
||
if (opts.mediaMap !== undefined) input.mediaMapPath = opts.mediaMap
|
||
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()
|
||
}
|
||
})
|
||
|
||
program
|
||
.command('import')
|
||
.description('导入已登记的 wxr 快照(Task 6 编排管道:term/content/media/route 四家写入器汇流 + 导入报告)')
|
||
.requiredOption('--site <key>', '站点 key')
|
||
.option('--snapshot <id>', 'wxr 快照 id(缺省按 connection+kind=wxr 选最新)')
|
||
.option('--key <key>', '幂等键(缺省 = site+snapshot+版本块 自动派生;重复提交同 key 复用既有 job 不重跑)')
|
||
.option('--connection <name>', '连接名(快照解析的 connection 侧,缺省 default)', 'default')
|
||
.option('--products-path <path>', '产品主列表页路径(前置事实 9,规范化值记入 job 报告)', '/products/')
|
||
.option('--news-path <path>', '新闻主列表页路径(同上)', '/news/')
|
||
.action(async (opts: {
|
||
site: string
|
||
snapshot?: string
|
||
key?: string
|
||
connection: string
|
||
productsPath: string
|
||
newsPath: string
|
||
}) => {
|
||
// getDb 走 DATABASE_URL_TEST ?? DATABASE_URL 回退链——临时库冒烟只需 env 覆盖
|
||
const db = getDb()
|
||
// exactOptionalPropertyTypes:缺席键不传 undefined 值(与 source:register 同款)
|
||
const input: CreateImportJobInput = {
|
||
siteKey: opts.site,
|
||
connectionName: opts.connection,
|
||
productsPath: opts.productsPath,
|
||
newsPath: opts.newsPath,
|
||
}
|
||
if (opts.snapshot !== undefined) input.snapshotId = opts.snapshot
|
||
if (opts.key !== undefined) input.idempotencyKey = opts.key
|
||
try {
|
||
const result = await createImportJob(db, input)
|
||
console.log(
|
||
`[import] job=${result.jobId} status=${result.status}${result.reused ? '(复用既有 job,未重跑)' : ''}`,
|
||
)
|
||
console.log(` site=${opts.site} mapping_version=${result.mappingVersion}`)
|
||
// 复用口径忠实化:快照/版本块只在新跑时解析返回——复用时权威记录在既有 job 报告。
|
||
// 非复用路径结构性填满这些可选字段(服务侧不变量),此处非空断言表达该约定
|
||
if (!result.reused) {
|
||
console.log(` connection=${result.connectionId!}`)
|
||
console.log(
|
||
` wxr 快照: ${result.wxrSnapshot!.sha256.slice(0, 12)}… (${result.wxrSnapshot!.id})`,
|
||
)
|
||
console.log(
|
||
` media_bundle: ${result.mediaBundleSnapshot! === null ? '(无)' : result.mediaBundleSnapshot!.id} / manual_url_list: ${result.manualUrlListSnapshot! === null ? '(无)' : result.manualUrlListSnapshot!.id}`,
|
||
)
|
||
console.log(
|
||
` 版本: adapter=${result.versions!.adapter} mapping=${result.versions!.mapping} sanitizer=${result.versions!.sanitizer} parser=${result.versions!.parser}`,
|
||
)
|
||
}
|
||
if (result.reportPath !== null) console.log(` report: ${result.reportPath}`)
|
||
console.log(` total ${result.durationMs.toFixed(1)}ms`)
|
||
// 崩溃残留哨兵:复用命中非终态(queued/running)= 上一次执行未走完终态更新——
|
||
// 本命令不自动重跑(幂等纪律),操作者显式换 --key 才发起新执行
|
||
if (result.reused && (result.status === 'queued' || result.status === 'running')) {
|
||
console.error(` 警告:job 处于 ${result.status}——可能为崩溃残留,重试请显式换 --key`)
|
||
}
|
||
if (result.status === 'failed') process.exitCode = 1
|
||
} finally {
|
||
await (db as { $client: { end: () => Promise<void> } }).$client.end()
|
||
}
|
||
})
|
||
|
||
program
|
||
.command('content:publish')
|
||
.description('显式发布:entry_locales.published ← current(V2 spec §8;dry-run 出差异清单+confirm token,--confirm <token> 执行;导入永不发布)')
|
||
.requiredOption('--site <key>', '站点 key')
|
||
.option('--dry-run', '只读展示将发布的条目清单(不带 --confirm 时的缺省行为——安全优先)')
|
||
.option('--confirm <token>', '确认执行(携带 dry-run 输出的 confirm token)')
|
||
.option('--actor <label>', `审计 actor 标签(audit_logs.actor_label,缺省 ${DEFAULT_ACTOR_LABEL})`, DEFAULT_ACTOR_LABEL)
|
||
.action(async (opts: { site: string; dryRun?: boolean; confirm?: string; actor: string }) => {
|
||
// 缺省 dry-run(安全优先):不带 --confirm 永不落发布写入;--dry-run 与 --confirm
|
||
// 同时给出时仍按 dry-run 执行(提示操作者矛盾输入)
|
||
const dryRun = opts.confirm === undefined
|
||
if (!dryRun && opts.dryRun === true) {
|
||
console.error(' 注意:--dry-run 与 --confirm 同时给出——按 dry-run 执行(安全优先)')
|
||
}
|
||
// getDb 走 DATABASE_URL_TEST ?? DATABASE_URL 回退链——临时库冒烟只需 env 覆盖
|
||
const db = getDb()
|
||
// exactOptionalPropertyTypes:缺席键不传 undefined 值(与 source:register 同款)
|
||
const input = {
|
||
siteKey: opts.site,
|
||
dryRun,
|
||
actor: opts.actor,
|
||
...(opts.confirm !== undefined ? { confirmToken: opts.confirm } : {}),
|
||
}
|
||
try {
|
||
const result = await publishSiteContent(db, input)
|
||
if (result.dryRun) {
|
||
console.log(
|
||
`[content:publish] site=${opts.site} dry-run:待发布 ${result.candidates.length} 条(已等 ${result.upToDateCount} 条,零写入)`,
|
||
)
|
||
for (const c of result.candidates) {
|
||
console.log(
|
||
` ${c.typeKey}/${c.slug} [${c.locale}] ${c.currentRevisionId.slice(0, 8)}… → ${c.publishedRevisionId === null ? 'NULL' : `${c.publishedRevisionId.slice(0, 8)}…`}`,
|
||
)
|
||
}
|
||
console.log(` confirm token: ${result.confirmToken}`)
|
||
console.log(` 执行发布:pnpm sf content:publish --site ${opts.site} --confirm ${result.confirmToken}`)
|
||
} else {
|
||
console.log(
|
||
`[content:publish] site=${opts.site} 发布完成:published=${result.publishedCount}(已等 ${result.upToDateCount} 条)`,
|
||
)
|
||
if (result.publishedCount === 0) {
|
||
console.log(' 无待发布条目(no-op)')
|
||
} else {
|
||
// 发布清单(事务前快照面;与 dry-run 同行格式——修订对 published→current)
|
||
const listed = result.candidates.slice(0, CONFIRM_LIST_CAP)
|
||
for (const c of listed) {
|
||
console.log(
|
||
` ${c.typeKey}/${c.slug} [${c.locale}] ${c.publishedRevisionId === null ? 'NULL' : `${c.publishedRevisionId.slice(0, 8)}…`} → ${c.currentRevisionId.slice(0, 8)}…`,
|
||
)
|
||
}
|
||
if (result.candidates.length > listed.length) {
|
||
console.log(` …and ${result.candidates.length - listed.length} more`)
|
||
}
|
||
console.log(` audit: action=content.publish actor=${opts.actor}`)
|
||
if (result.siteConfigRevision !== null) {
|
||
console.log(
|
||
` site_config_revisions: v${result.siteConfigRevision.version}${result.siteConfigRevision.created ? '(首版生成)' : '(已有,跳过)'}`,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
await (db as { $client: { end: () => Promise<void> } }).$client.end()
|
||
}
|
||
})
|
||
|
||
program
|
||
.command('build:create')
|
||
.description('冻结 Render Snapshot:物化六文件(site/content/taxonomies/routes/assets/render-manifest)+ build_input_snapshots 行;dist 产物是 Task 5')
|
||
.requiredOption('--site <key>', '站点 key')
|
||
.option('--key <key>', '幂等键(缺省 = site+published 集合哈希+主题版本 自动派生;重复提交同 key 复用既有 job 不重跑)')
|
||
.option('--artifacts <dir>', '产物根目录(缺省 repoRoot/artifacts)')
|
||
.action(async (opts: { site: string; key?: string; artifacts?: string }) => {
|
||
// getDb 走 DATABASE_URL_TEST ?? DATABASE_URL 回退链——临时库冒烟只需 env 覆盖
|
||
const db = getDb()
|
||
// exactOptionalPropertyTypes:缺席键不传 undefined 值(与 source:register 同款)
|
||
const input: { siteKey: string; idempotencyKey?: string; artifactsRoot?: string } = { siteKey: opts.site }
|
||
if (opts.key !== undefined) input.idempotencyKey = opts.key
|
||
if (opts.artifacts !== undefined) input.artifactsRoot = opts.artifacts
|
||
try {
|
||
const result = await createBuildJob(db, input)
|
||
console.log(
|
||
`[build:create] job=${result.jobId} status=${result.status}${result.reused ? '(复用既有 job,未重跑)' : ''}`,
|
||
)
|
||
console.log(` site=${result.siteKey} input: ${result.inputDir}`)
|
||
if (!result.reused) {
|
||
const c = result.counts!
|
||
console.log(
|
||
` counts: contents=${c.contents} taxonomies=${c.taxonomies} routes=${c.routes} assets=${c.assets}`,
|
||
)
|
||
console.log(
|
||
` urlRewrite: rewritten=${result.urlRewrite!.rewritten} unresolved=${result.urlRewrite!.unresolved}`,
|
||
)
|
||
if (result.issues!.length > 0) {
|
||
console.log(` issues (${result.issues!.length}):`)
|
||
for (const issue of result.issues!) console.log(` - ${issue}`)
|
||
}
|
||
}
|
||
console.log(
|
||
` hashes: content=${result.sourceHashes.content.slice(0, 12)}… route=${result.sourceHashes.route.slice(0, 12)}… asset=${result.sourceHashes.asset.slice(0, 12)}…`,
|
||
)
|
||
console.log(` report: ${result.reportPath}`)
|
||
console.log(` total ${result.durationMs.toFixed(1)}ms`)
|
||
if (result.status === 'failed') process.exitCode = 1
|
||
} finally {
|
||
await (db as { $client: { end: () => Promise<void> } }).$client.end()
|
||
}
|
||
})
|
||
|
||
// parseAsync:action 内抛错走此 catch(commander 默认会 process.exit(1) 并打印堆栈,
|
||
// 这里收敛为单行错误信息 + exitCode——SourceRegisterError/ImportError/PublishError/BuildError 打 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 if (err instanceof ImportError) {
|
||
console.error(`[sf] ${err.code}: ${err.message}`)
|
||
} else if (err instanceof PublishError) {
|
||
console.error(`[sf] ${err.code}: ${err.message}`)
|
||
} else if (err instanceof BuildError) {
|
||
console.error(`[sf] ${err.code}: ${err.message}`)
|
||
} else {
|
||
console.error(`[sf] ${err instanceof Error ? err.message : String(err)}`)
|
||
}
|
||
process.exitCode = 1
|
||
})
|