Kaynağa Gözat

feat(v7): M5 发布产物与 CI——npm files 白名单 + 安装链路 e2e 双平台

- package.json files 白名单(bin/src/roles/skills/adapters/templates,103 文件 75KB)
- scripts/pack-install-e2e.mjs:pack→干净沙箱安装→中文用户名路径 init→vendored bin 建书→next→update,
  全程 args 数组不走 shell;CI 新增 install-e2e job(ubuntu+windows)——beta 判据「Windows 中文路径全链路」M5 半达成
- init 报告支持等级直引 registry.verified(不夸大 smoke 状态);--help 补宿主通道/安装/多本书段
- 335 测试绿 + drift 绿 + 本地 e2e 全通
lingfengQAQ 14 saat önce
ebeveyn
işleme
416547b94b

+ 19 - 0
.github/workflows/v7-ci.yml

@@ -30,3 +30,22 @@ jobs:
         run: node scripts/build-host-shells.mjs --check
       - name: 版本门槛冒烟
         run: node bin/webnovel-writer.js --version
+
+  install-e2e:
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, windows-latest]
+    runs-on: ${{ matrix.os }}
+    defaults:
+      run:
+        working-directory: v7
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: '22.13.0'
+      - name: 安装依赖
+        run: npm ci
+      - name: 安装链路端到端(npm pack → 中文路径 init → 建书 → next → update)
+        run: npm run e2e:install

+ 17 - 1
v7/bin/webnovel-writer.js

@@ -51,8 +51,24 @@ if (!command || command === '--help') {
   console.log('  prepare-chapter <章号>                  备料:写出 工作区/本章写作材料.md')
   console.log('  mechanical-check <章号> [--draft=<路径>]  机检:字数/禁词/禁句式/复读/新专名/信息差候选')
   console.log('')
+  console.log('宿主通道(M5,AI 产物经文件回流;JSON 一律 --file/--payload 文件路径):')
+  console.log('  review-input <章号> [--draft=<路径>]      组装两审输入 → 工作区/审稿输入.json')
+  console.log('  save-review <章号> --file=<两审json>      两审报告校验合并 → 工作区/审稿.md')
+  console.log('  persist-outline --file=<json>            细纲落盘({细纲})')
+  console.log('  persist-book --file=<json> [--dir=<目录>] 建书落盘+登记({book,总纲,卷纲})')
+  console.log('  persist-volume-review --file=<json>      卷复盘落盘({卷号,卷摘要,下卷卷纲,伏笔条目})')
+  console.log('  persist-repair --file=<json>             修复回写({repairs},仅限检测失败清单内)')
+  console.log('  finalize <章号> --payload=<json>          定稿原子 commit + 缓存刷新')
+  console.log('')
+  console.log('安装与多本书(M5):')
+  console.log('  init [--hosts=a,b] [--force]             装出工作目录(AGENTS.md/.webnovel/平台壳)')
+  console.log('  update [--hosts=a,b] [--force]           升级:哈希未变更新,手改跳过并列出')
+  console.log('  list-books                               书单(登记缺失自动扫描重建)')
+  console.log('  switch-book <书名>                       换书:改「当前」标记')
+  console.log('  session-context                          会话上下文注入文本(hook 与入口同源)')
+  console.log('')
   console.log('状态机 / 例外流程(M3):')
-  console.log('  next                                    继续:状态机判定下一步(git 健康检查先行)')
+  console.log('  next [--json]                           继续:状态机判定下一步(--json 出完整 DTO)')
   console.log('  health-check                            体检:悬了太久/条目活跃率/连续弱钩,报告落工作区(文体项随 M5.5)')
   console.log('  impact <关键词>                          影响分析:哪些章建立在这个事实上(已发布/未发布)')
   console.log('  goto-chapter <章号> [--confirm]          回到第N章(先备份再回滚,作者不碰 git)')

+ 10 - 1
v7/package.json

@@ -9,8 +9,17 @@
   "bin": {
     "webnovel-writer": "bin/webnovel-writer.js"
   },
+  "files": [
+    "bin/",
+    "src/",
+    "roles/",
+    "skills/",
+    "adapters/",
+    "templates/"
+  ],
   "scripts": {
-    "test": "node --test"
+    "test": "node --test",
+    "e2e:install": "node scripts/pack-install-e2e.mjs"
   },
   "dependencies": {
     "js-yaml": "^5.2.0"

+ 120 - 0
v7/scripts/pack-install-e2e.mjs

@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+// M5 出口 D1(AC1 的 CI 半):npm pack 产物 → 干净中文路径工作目录 → init → 建书 → next → update 全链路。
+// 经 `npm run e2e:install` 运行(用 npm_execpath 定位 npm,全程 args 数组不走 shell,中文路径安全)。
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { promises as fs } from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const exec = promisify(execFile)
+const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
+
+const npmCli = process.env.npm_execpath
+if (!npmCli) {
+  console.error('请通过 npm run e2e:install 运行(脚本需要 npm_execpath 定位 npm)')
+  process.exit(1)
+}
+const npm = (args, opts = {}) =>
+  exec(process.execPath, [npmCli, ...args], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...opts })
+
+let failed = false
+const check = (cond, msg) => {
+  console.log(`${cond ? '✓' : '✗'} ${msg}`)
+  if (!cond) failed = true
+}
+const exists = async (p) => {
+  try {
+    await fs.access(p)
+    return true
+  } catch {
+    return false
+  }
+}
+
+const cleanups = []
+try {
+  // 1. pack(发布产物 = npx 消费的同一份包内容)
+  const packDest = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-pack-'))
+  cleanups.push(packDest)
+  const packOut = await npm(['pack', '--pack-destination', packDest], { cwd: pkgRoot })
+  const tgzName = packOut.stdout.trim().split('\n').at(-1).trim()
+  const tgz = path.join(packDest, tgzName)
+  check(await exists(tgz), `npm pack 产物:${tgzName}`)
+
+  // 2. 装进干净沙箱
+  const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-sandbox-'))
+  cleanups.push(sandbox)
+  await npm(['install', tgz, '--prefix', sandbox, '--no-audit', '--no-fund'], { cwd: sandbox })
+  const installedBin = path.join(sandbox, 'node_modules', 'webnovel-writer', 'bin', 'webnovel-writer.js')
+  check(await exists(installedBin), '安装产物 bin 存在')
+
+  // 3. 中文用户名模拟路径里 init(一条命令装出工作目录)
+  const base = await fs.mkdtemp(path.join(os.tmpdir(), '中文用户名-'))
+  cleanups.push(base)
+  const workdir = path.join(base, '工作目录')
+  await fs.mkdir(workdir, { recursive: true })
+  const node = (bin, args) =>
+    exec(process.execPath, [bin, ...args], { cwd: workdir, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
+
+  const init = await node(installedBin, ['init', '--hosts=claude-code,codex'])
+  console.log('--- init 报告 ---\n' + init.stdout + '-----------------')
+  for (const rel of [
+    '.webnovel/bin/webnovel-writer.js',
+    '.webnovel/node_modules/js-yaml/package.json',
+    '.webnovel/manifest.json',
+    '.webnovel/books.jsonl',
+    '.claude/skills/webnovel-writer/SKILL.md',
+    '.claude/agents/事实审查.md',
+    '.claude/settings.json',
+    '.codex/agents/事实审查.toml',
+    'AGENTS.md',
+  ]) {
+    check(await exists(path.join(workdir, rel)), `init 布局:${rel}`)
+  }
+
+  // 4. vendored bin 建第一本书(干净环境一条命令装出并建书 = AC1 链路)
+  const vendoredBin = path.join(workdir, '.webnovel', 'bin', 'webnovel-writer.js')
+  await fs.writeFile(
+    path.join(workdir, '建书.json'),
+    JSON.stringify({
+      book: { spec_version: '7.0', 书名: '雪落长安', 卷规模: 40, 体检周期: 50 },
+      总纲: '# 总纲\n## 结局\n终成一代大家。',
+      卷纲: '# 第1卷\n初入长安。',
+    }),
+    'utf8'
+  )
+  const pb = await node(vendoredBin, ['persist-book', '--file=建书.json'])
+  check(pb.stdout.includes('已登记为当前书'), '建书并登记为当前书(vendored bin)')
+  check(await exists(path.join(workdir, '雪落长安', 'book.yaml')), '书仓库 book.yaml')
+  check(await exists(path.join(workdir, '雪落长安', 'AGENTS.md')), '书仓库指路 AGENTS.md')
+
+  // 5. next --json → 序6 起草第 1 章
+  const nx = await node(vendoredBin, ['next', '--json'])
+  const dto = JSON.parse(nx.stdout)
+  check(dto.序 === 6 && dto.dto.nextChapter === 1, `next 判定起草第 1 章(实际:序${dto.序})`)
+
+  // 6. update 幂等重跑
+  const up = await node(installedBin, ['update'])
+  check(/升级完成|安装完成/.test(up.stdout), 'update 幂等可重跑')
+
+  if (failed) {
+    console.error('\n安装链路 e2e 存在失败项')
+    process.exit(1)
+  }
+  console.log('\n安装链路 e2e 全通过:pack → 安装 → 中文路径 init → 建书 → next → update')
+} catch (err) {
+  console.error(`安装链路 e2e 异常:${err.message}`)
+  if (err.stdout) console.error(err.stdout)
+  if (err.stderr) console.error(err.stderr)
+  process.exit(1)
+} finally {
+  for (const d of cleanups) {
+    try {
+      await fs.rm(d, { recursive: true, force: true, maxRetries: 3 })
+    } catch {
+      // 临时目录清理尽力而为
+    }
+  }
+}

+ 1 - 1
v7/src/installer/index.js

@@ -196,7 +196,7 @@ function buildReport({ workdir, version, hosts, detected, registry, written, ski
     lines.push('平台壳:')
     for (const h of hosts) {
       const cfg = registry.hosts[h] || {}
-      const tier = cfg.tier === 1 ? '一级(维护者亲测口径)' : cfg.tier === 2 ? '二级(社区反馈口径)' : '三级'
+      const tier = cfg.tier ? `${cfg.tier === 1 ? '一' : cfg.tier === 2 ? '二' : '三'}级${cfg.verified ? `(${cfg.verified})` : ''}` : ''
       const cap = []
       cap.push(cfg.agentCapable ? '两审=独立 subagent' : '两审=兼容模式(顺序自审,如实声明)')
       cap.push(cfg.hasHooks ? '启动自动注入书单(SessionStart)' : '启动由入口读书单(与 hook 等价)')