From 726bf3d807d8cef408ec7c1027793436d8a99489 Mon Sep 17 00:00:00 2001 From: rui Date: Fri, 3 Jul 2026 22:43:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3.2=20CI=20=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E5=AE=88=E6=8A=A4=E6=94=B9=E4=B8=BA=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E8=AE=BE=E6=96=BD=EF=BC=88lingshu=20ci=20install=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 lingshu ci install:从内置模板拷贝 .github/workflows/rules-consistency.yml 幂等(已存在跳过);--force 覆盖 - init 默认不再拷贝 .github/(copyTemplate 加 exclude 参数) - init 完成提示加 lingshu ci install 一行 - doctor 扩为 4 步:[4/4] 可选设施检查(不装 CI 也不算错误) - 模板 README 目录树 .github/workflows/ 移出常规树、脚注为"可选设施" - CLI 仓 README 加 lingshu ci install 段 - smoke 22 → 25:init 断言 .github/ 从"应存在"改为"禁止存在"(零侵入契约) + 3 用例(ci install 生成 / 幂等+--force / 非灵枢项目报错) --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++ README.md | 11 +++++- bin/lingshu.mjs | 5 +++ package.json | 2 +- src/commands/ci.mjs | 69 +++++++++++++++++++++++++++++++++++++ src/commands/doctor.mjs | 14 ++++++-- src/commands/init.mjs | 4 ++- src/core/template.mjs | 14 +++++++- templates/default/README.md | 6 ++-- tests/smoke.test.mjs | 57 ++++++++++++++++++++++++++++-- 10 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 src/commands/ci.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 64a543d..aee59e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,57 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [0.3.2] - 2026-07-03 + +> **主题:可选 CI**——`.github/workflows/` 从默认拷贝改为按需加装。 + +### 背景 + +用户反馈:**不是所有派生仓都用 GitHub Actions**(GitLab CI / Jenkins / +self-hosted / 或干脆不用 CI)。之前 `init` 默认拷贝 +`.github/workflows/rules-consistency.yml` 到派生仓,对上述场景形成隐性侵入 +——即便文件已技术中立,"未经我同意就配好 CI"本身就是一种侵入。 + +延续 v0.3.1 减法哲学,将 CI 一致性守护从"默认设施"降级为"可选加装"。 + +### Added + +- **`lingshu ci install`**:新增命令,从内置模板拷贝 + `.github/workflows/rules-consistency.yml` 到当前项目。 + - 幂等:文件已存在时输出"已存在,跳过",不覆盖用户已有内容。 + - `--force`:强制覆盖已存在的 workflow(用于修复被误改的情况)。 + - 与 `lingshu hooks install` 形成对称的可选设施命令族。 + +### Changed + +- **`lingshu init` 默认不再拷贝 `.github/`**:派生仓开箱不含 CI workflow。 + - "下一步"提示中加入 `lingshu ci install # 加装 GitHub CI 一致性守护(可选)`。 + - `copyTemplate` 增加 `exclude` 参数(如 `['.github']`),支持调用方精细控制排除项。 +- **`lingshu doctor` 扩为 4 步**:新增 `[4/4] 可选设施` 检查——存在 + `.github/workflows/rules-consistency.yml` 时打勾,不存在时以 dim 灰字提示 + `可运行 lingshu ci install 加装`(**不是错误,也不是 warning**)。 +- **模板 `README.md` 目录树**:`.github/workflows/` 一行移除(init 不装则不出现); + 相关"CI 守护"表述加"可选"注脚。 + +### Migration + +**存量派生仓完全不受影响**——他们的 `.github/workflows/rules-consistency.yml` +若已存在,`lingshu upgrade` 不主动删除,继续正常校验 SSoT 与产物一致性。 + +**新 init 项目**默认无 CI;如需守护: +```bash +lingshu ci install +``` + +### Tests + +- smoke 22 → 25(+3): + - `ci install:init 之后手动加装 workflow` + - `ci install:幂等(跳过)+ --force 覆盖` + - `ci install:非灵枢项目应报错` + - 修改 `init 创建零侵入项目结构` 断言:`.github/workflows/*.yml` 从"应存在" + 移到"禁止存在"(零侵入契约)。 + ## [0.3.1] - 2026-07-03 > **主题:工作流瘦身(Slim Workflow)** —— 连自己写的规矩也开始做减法。 @@ -261,6 +312,7 @@ lingshu upgrade # 执行 - 核心命令 `init` / `sync` / `doctor` / `tool` / `limb`。 - 包名 `@ruobai/lingshu`(若白知行 npm scope)。 +[0.3.2]: https://github.com/imrui/lingshu-cli/compare/v0.3.1...v0.3.2 [0.3.1]: https://github.com/imrui/lingshu-cli/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/imrui/lingshu-cli/compare/v0.2.6...v0.3.0 [0.2.6]: https://github.com/imrui/lingshu-cli/compare/v0.2.5...v0.2.6 diff --git a/README.md b/README.md index b62b0a8..6db1dbc 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ npm install -g @ruobai/lingshu npm install -g git+ssh://git@github.com/imrui/lingshu-cli.git # 锁定版本 -npm install -g git+ssh://git@github.com/imrui/lingshu-cli.git#v0.3.1 +npm install -g git+ssh://git@github.com/imrui/lingshu-cli.git#v0.3.2 ``` > 团队协作提示:v0.3 起派生仓不含 `package.json`,同步统一走全局 `lingshu sync`。**团队每位成员各全局安装一次**即可;CI 用 `npx -y @ruobai/lingshu` 临时调用。 @@ -119,6 +119,15 @@ lingshu init my-lingshu-app \ |--------|------| | `install` | 在当前项目安装内置 git hooks(`post-merge`:`git pull` 后自动 `lingshu sync`)。`init` 时已自动安装,本命令供存量项目补装 | +### `lingshu ci ` + +**可选**的 GitHub Actions CI 一致性守护。v0.3.2 起 `init` 不再默认拷贝 +`.github/workflows/`,由本命令按需加装。 + +| 子命令 | 说明 | +|--------|------| +| `install` | 拷贝 `.github/workflows/rules-consistency.yml` 到当前项目。幂等(已存在则跳过),加 `--force` 覆盖 | + ### `lingshu upgrade` 把存量项目迁移到 v0.3 零侵入结构。 diff --git a/bin/lingshu.mjs b/bin/lingshu.mjs index d6788e4..f243fd0 100644 --- a/bin/lingshu.mjs +++ b/bin/lingshu.mjs @@ -11,6 +11,7 @@ * tool 管理 AI 工具支持(list/track/untrack) * limb 管理肢体仓(list/add) * hooks 安装 git hooks(install) + * ci 安装可选 CI 一致性守护(install) * upgrade 迁移存量项目到 v0.3 零侵入结构 * --version, -v 显示版本 * --help, -h 显示帮助 @@ -58,6 +59,9 @@ ${pkg.description} v${pkg.version} hooks 安装灵枢 git hooks 子命令: install + ci 管理可选 CI 一致性守护(GitHub Actions workflow) + 子命令: install [--force] + upgrade 将存量项目迁移到 v0.3 零侵入结构 选项: --dry-run(仅预览), --force @@ -74,6 +78,7 @@ const COMMANDS = { tool: () => import('../src/commands/tool.mjs'), limb: () => import('../src/commands/limb.mjs'), hooks: () => import('../src/commands/hooks.mjs'), + ci: () => import('../src/commands/ci.mjs'), upgrade: () => import('../src/commands/upgrade.mjs'), }; diff --git a/package.json b/package.json index 99b8a48..05d3a6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ruobai/lingshu", - "version": "0.3.1", + "version": "0.3.2", "description": "灵枢架构 CLI — AI 原生项目脚手架 | 若白知行出品", "type": "module", "bin": { diff --git a/src/commands/ci.mjs b/src/commands/ci.mjs new file mode 100644 index 0000000..f08e730 --- /dev/null +++ b/src/commands/ci.mjs @@ -0,0 +1,69 @@ +/** + * lingshu ci + * + * 子命令: + * install 在当前项目安装 GitHub CI 一致性守护 + * workflow (.github/workflows/rules-consistency.yml)。 + * 幂等:文件已存在则跳过;--force 覆盖。 + * + * v0.3.2 起 CI 一致性守护改为可选设施——`init` 不再默认拷贝 .github/, + * 用户如需 GitHub Actions 校验真源与产物一致性,运行 `lingshu ci install` + * 显式加装。存量项目已装的 workflow 不受影响。 + */ + +import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { parseArgs } from '../utils/args.mjs'; +import { log, c } from '../utils/log.mjs'; +import { isLingshuProject } from '../core/adapters.mjs'; + +const WORKFLOW_REL = '.github/workflows/rules-consistency.yml'; + +export default async function ci({ args, pkgRoot }) { + const subcmd = args[0]; + const rest = args.slice(1); + + if (!subcmd || subcmd === '--help' || subcmd === '-h') { + console.log(` +lingshu ci + +子命令: + install 安装 GitHub CI 一致性守护 workflow(幂等,可加 --force 覆盖) + +示例: + lingshu ci install + lingshu ci install --force +`); + return; + } + + if (subcmd !== 'install') { + throw new Error(`未知子命令: ci ${subcmd}(可用: install)`); + } + + const { flags } = parseArgs(rest, { booleanFlags: ['force'] }); + const root = process.cwd(); + + if (!isLingshuProject(root)) { + throw new Error('当前目录不是灵枢项目(未找到 reference/rules/)'); + } + + const src = join(pkgRoot, 'templates/default', WORKFLOW_REL); + const dst = join(root, WORKFLOW_REL); + + if (!existsSync(src)) { + throw new Error(`模板中未找到 workflow 文件: ${WORKFLOW_REL}`); + } + + if (existsSync(dst) && !flags.force) { + log.warn(`已存在 ${WORKFLOW_REL},跳过`); + log.hint(`如需覆盖:${c.bold('lingshu ci install --force')}`); + return; + } + + mkdirSync(dirname(dst), { recursive: true }); + copyFileSync(src, dst); + + log.ok(`已安装 GitHub CI 一致性守护 → ${WORKFLOW_REL}`); + log.hint('workflow 在 PR/push 时校验 reference/rules/ 真源与 CLAUDE.md/AGENTS.md 产物一致性'); +} diff --git a/src/commands/doctor.mjs b/src/commands/doctor.mjs index b9cd020..1cb0799 100644 --- a/src/commands/doctor.mjs +++ b/src/commands/doctor.mjs @@ -18,7 +18,7 @@ export default async function doctor() { const fail = m => { console.log(c.red (` ✗ ${m}`)); errors++; }; // 1. 物理完整性(必需目录) - console.log(c.cyan('[1/3] 物理完整性')); + console.log(c.cyan('[1/4] 物理完整性')); const requiredDirs = [ 'reference/rules', 'reference/docs', @@ -31,7 +31,7 @@ export default async function doctor() { if (existsSync(join(root, 'reference/decisions'))) ok('reference/decisions(可选)'); // 2. SSoT 真源 - console.log(c.cyan('\n[2/3] SSoT 真源')); + console.log(c.cyan('\n[2/4] SSoT 真源')); const ssotFiles = [ 'reference/rules/ai-behavior.md', 'reference/rules/lingshu-core.md', @@ -42,7 +42,7 @@ export default async function doctor() { } // 3. 基线产物 - console.log(c.cyan('\n[3/3] 基线产物')); + console.log(c.cyan('\n[3/4] 基线产物')); const baselineFiles = [ 'CLAUDE.md', 'AGENTS.md', @@ -52,6 +52,14 @@ export default async function doctor() { else warn(`${f} 缺失(可运行 lingshu sync --baseline 生成)`); } + // 4. 可选设施 + console.log(c.cyan('\n[4/4] 可选设施')); + if (existsSync(join(root, '.github/workflows/rules-consistency.yml'))) { + ok('GitHub CI 一致性守护已安装'); + } else { + console.log(c.dim(' · GitHub CI 一致性守护 未安装(可运行 `lingshu ci install` 加装)')); + } + log.blank(); if (errors === 0 && warnings === 0) { log.ok('架构健康度:优秀'); diff --git a/src/commands/init.mjs b/src/commands/init.mjs index 3a67750..6fc71b0 100644 --- a/src/commands/init.mjs +++ b/src/commands/init.mjs @@ -58,9 +58,10 @@ export default async function init({ args, pkgRoot }) { log.hint(`模板路径: ${templatePath}`); // Step 1: 拷贝模板 + // .github/ 从 v0.3.2 起改为可选设施,init 默认不装;用户按需 `lingshu ci install` log.step('拷贝模板'); if (!here) mkdirSync(targetDir, { recursive: true }); - copyTemplate(templatePath, targetDir); + copyTemplate(templatePath, targetDir, { exclude: ['.github'] }); // _gitignore → .gitignore:npm publish 会把 .gitignore 当 .npmignore 吞掉, // 模板内只能用 _gitignore 入包,init 时复原为真正的 .gitignore。 @@ -151,6 +152,7 @@ export default async function init({ args, pkgRoot }) { if (!here) console.log(` cd ${finalName}`); console.log(' lingshu doctor # 健康检查'); console.log(' lingshu sync # 重新分发规则到本地 AI 工具'); + console.log(' lingshu ci install # 加装 GitHub CI 一致性守护(可选)'); console.log(' git push -u origin master # 推送到远程(如已设置 remote)'); log.blank(); } diff --git a/src/core/template.mjs b/src/core/template.mjs index 4d8d9c2..2dc368b 100644 --- a/src/core/template.mjs +++ b/src/core/template.mjs @@ -10,10 +10,18 @@ import { join, resolve, extname, sep } from 'node:path'; * * 过滤规则只看 src 相对 srcDir 的部分——这样无论 CLI 自身被全局安装到 * 哪个 node_modules 路径下,都不会误把整个模板树都过滤掉。 + * + * @param {string} srcDir + * @param {string} dstDir + * @param {object} [options] + * @param {string[]} [options.exclude] 额外排除的相对路径(对目录或文件均生效), + * 如 ['.github'] 会排除整个 .github/,['.github/workflows/rules-consistency.yml'] + * 仅排除单个文件。 */ -export function copyTemplate(srcDir, dstDir) { +export function copyTemplate(srcDir, dstDir, options = {}) { if (!existsSync(srcDir)) throw new Error(`模板目录不存在: ${srcDir}`); const srcRoot = resolve(srcDir); + const extraExcludes = (options.exclude ?? []).map((p) => p.replace(/\\/g, '/').replace(/\/$/, '')); cpSync(srcDir, dstDir, { recursive: true, filter: (src) => { @@ -27,6 +35,10 @@ export function copyTemplate(srcDir, dstDir) { if (lower.includes('/.git/') || lower.endsWith('/.git')) return false; if (lower.includes('/node_modules/') || lower.endsWith('/node_modules')) return false; if (lower.endsWith('/.claude/settings.local.json')) return false; + // 调用方额外指定的排除 + for (const ex of extraExcludes) { + if (rel === ex || rel.startsWith(ex + '/')) return false; + } return true; }, }); diff --git a/templates/default/README.md b/templates/default/README.md index 5f57ee5..e8e9be9 100644 --- a/templates/default/README.md +++ b/templates/default/README.md @@ -41,9 +41,11 @@ ├── .cursor/ .trae/ .qoder/ # AI 工具规则目录(按需生成 / gitignore) ├── .agent/ # Antigravity 规则目录 │ -├── .github/workflows/ # CI 一致性守护 ├── .gitignore # 忽略规则(物理隔绝肢体仓 + 个人产物) └── README.md + +# 可选设施(默认不装,需运行 `lingshu ci install` 加装) +# └── .github/workflows/ # GitHub Actions CI 一致性守护 ``` --- @@ -80,7 +82,7 @@ ### 自动化机制 - **`git pull` 后** → `post-merge` hook 检测 `reference/rules/` 变更,自动 `lingshu sync` -- **PR 提交时** → GitHub Actions 校验 baseline 产物一致性,漂移即拒绝合并 +- **PR 提交时** → GitHub Actions 校验 baseline 产物一致性(可选,`lingshu ci install` 加装) --- diff --git a/tests/smoke.test.mjs b/tests/smoke.test.mjs index 1772191..d2ca6ae 100644 --- a/tests/smoke.test.mjs +++ b/tests/smoke.test.mjs @@ -62,13 +62,13 @@ test('init 创建零侵入项目结构(无 .lingshu / 无 package.json)', () 'CLAUDE.md', 'AGENTS.md', '.gitignore', - '.github/workflows/rules-consistency.yml', ]) { assert.ok(existsSync(join(proj, p)), `缺失: ${p}`); } // 零侵入核心断言:派生仓不应再包含引擎与 package.json - for (const forbidden of ['.lingshu', 'package.json', '_gitignore']) { + // v0.3.2 起 .github/ 也改为可选设施(`lingshu ci install` 加装) + for (const forbidden of ['.lingshu', 'package.json', '_gitignore', '.github']) { assert.ok( !existsSync(join(proj, forbidden)), `零侵入契约:派生仓不应包含 ${forbidden}`, @@ -523,6 +523,59 @@ test('upgrade:init 出的新项目立即幂等(已是 v0.3.1)', () => { assert.match(r.stdout, /v0\.3\.1.*无需迁移|无需迁移/, 'init 之后 upgrade 应立即幂等'); }); +// ===== ci install(v0.3.2 可选 CI 一致性守护)===== + +test('ci install:init 之后手动加装 workflow', () => { + freshTmp(); + runCli(['init', 'ci-test', '--no-git', '--no-install-hooks']); + const proj = join(TMP, 'ci-test'); + const workflow = join(proj, '.github/workflows/rules-consistency.yml'); + + assert.ok(!existsSync(workflow), 'init 后 workflow 不应存在(v0.3.2 零侵入)'); + + const r = runCli(['ci', 'install'], { cwd: proj }); + assert.equal(r.status, 0, 'ci install 应成功'); + assert.match(r.stdout, /已安装.*一致性守护/); + assert.ok(existsSync(workflow), '安装后 workflow 应存在'); + + const content = readFileSync(workflow, 'utf8'); + assert.match(content, /npx -y @ruobai\/lingshu sync --check --baseline/, + 'workflow 应包含 npx 调用'); +}); + +test('ci install:幂等(文件已存在时跳过),--force 覆盖', () => { + freshTmp(); + runCli(['init', 'ci-idem', '--no-git', '--no-install-hooks']); + const proj = join(TMP, 'ci-idem'); + const workflow = join(proj, '.github/workflows/rules-consistency.yml'); + + runCli(['ci', 'install'], { cwd: proj }); + assert.ok(existsSync(workflow)); + + // 篡改文件内容,验证第二次 install 不会覆盖(幂等) + writeFileSync(workflow, '# tampered\n', 'utf8'); + let r = runCli(['ci', 'install'], { cwd: proj }); + assert.equal(r.status, 0); + assert.match(r.stdout, /已存在.*跳过/); + assert.equal(readFileSync(workflow, 'utf8'), '# tampered\n', + '幂等:不应覆盖已存在的文件'); + + // --force 覆盖 + r = runCli(['ci', 'install', '--force'], { cwd: proj }); + assert.equal(r.status, 0); + assert.match(readFileSync(workflow, 'utf8'), /npx -y @ruobai\/lingshu/, + '--force 应覆盖为模板内容'); +}); + +test('ci install:非灵枢项目应报错', () => { + freshTmp(); + const notLingshu = join(TMP, 'not-lingshu'); + mkdirSync(notLingshu, { recursive: true }); + const r = runCli(['ci', 'install'], { cwd: notLingshu }); + assert.notEqual(r.status, 0); + assert.match(r.stderr + r.stdout, /不是灵枢项目/); +}); + test.after(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); });