Skip to content

Commit 5616baa

Browse files
committed
refactor: modularize CLI into lib/ command domains
单文件按职责拆分:lib/{args,output,errors,plan,git,brief,input,github,indexer} + lib/domains/ 九个 plan/execute 命令域(branch/sync/review/commit/publish/ release/reconcile/archive/issue)与四个特殊命令域(change/task/inspect/index)。 cli.mjs 仅保留入口路由与 plan/execute 公共管线(hash 重算对比、planHash 持久化)。 行为契约不变:命令、JSON 输出、错误码、planHash 机制逐表达式照搬; scripts/pack.mjs 新增目录 tarball 打包;CI 矩阵 ubuntu/macos/windows × node 20/22/24。
1 parent ffa1c80 commit 5616baa

25 files changed

Lines changed: 621 additions & 42 deletions

‎.github/workflows/test.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: test
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
strategy:
10+
fail-fast: false
11+
matrix:
12+
os: [ubuntu-latest, macos-latest, windows-latest]
13+
node: [20, 22, 24]
14+
runs-on: ${{ matrix.os }}
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: ${{ matrix.node }}
20+
- run: npm test

‎cli.mjs‎

Lines changed: 69 additions & 42 deletions
Large diffs are not rendered by default.

‎lib/args.mjs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const HELP = 'repo inspect\nchange create|approve\nissue plan|execute\nbranch plan|execute\nsync plan|execute\nconflict inspect\ntask list|set\nreview plan|execute\ncommit plan|execute\npublish plan|execute\nrelease plan|execute\npr inspect\nreconcile plan|execute\narchive plan|execute\nindex rebuild plan|execute'
2+
3+
export function args(a) {
4+
const p = [], o = {}
5+
for (let i = 0; i < a.length; i++) {
6+
if (!a[i].startsWith('--')) p.push(a[i])
7+
else { const k = a[i].slice(2); if (['confirm', 'json'].includes(k)) o[k] = true; else o[k] = a[++i] }
8+
}
9+
return { p, o }
10+
}

‎lib/brief.mjs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2+
import { dirname, join } from 'node:path'
3+
4+
export const ap = (r, n) => join(r, 'shadow-docs', 'changes', n, 'brief.md')
5+
export const xp = (r, n) => join(r, 'shadow-docs', 'changes', 'archive', n, 'brief.md')
6+
7+
export function brief(r, n, arch = false) {
8+
const path = arch ? xp(r, n) : ap(r, n)
9+
if (!existsSync(path)) throw Error('BRIEF_NOT_FOUND')
10+
const t = readFileSync(path, 'utf8'), e = t.indexOf('\n---\n', 4)
11+
if (e < 0) throw Error('BRIEF_FRONTMATTER_REQUIRED')
12+
return { path, data: JSON.parse(t.slice(4, e)), body: t.slice(e + 5) }
13+
}
14+
15+
export function write(b) {
16+
mkdirSync(dirname(b.path), { recursive: true })
17+
const t = b.path + `.tmp-${process.pid}`
18+
writeFileSync(t, `---\n${JSON.stringify(b.data, null, 2)}\n---\n${b.body}`)
19+
renameSync(t, b.path)
20+
}
21+
22+
export function tasks(s) {
23+
return [...s.matchAll(/^\s*- \[([ xX])\]\s+(.+)$/gm)].map((m, i) => ({ id: `task-${i + 1}`, done: m[1].toLowerCase() === 'x', text: m[2] }))
24+
}

‎lib/domains/archive.mjs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
2+
import { dirname, join } from 'node:path'
3+
import { name } from '../input.mjs'
4+
import { pr } from '../github.mjs'
5+
import { buildIndex } from '../indexer.mjs'
6+
import { brief, write, xp } from '../brief.mjs'
7+
import { repo } from '../git.mjs'
8+
9+
export async function planData(r, o) {
10+
const n = name(o), b = brief(r, n), q = repo(r)
11+
if (b.data.review?.conclusion !== 'passed' || b.data.review.verifiedCommit !== q.head) throw Error('REVIEW_NOT_PASSED')
12+
const x = await pr(b, r)
13+
if (!x.merged) throw Error('PR_NOT_MERGED')
14+
return { name: n, pullRequest: x.number, head: q.head }
15+
}
16+
17+
export function execute(r, o, x, b) {
18+
const n = name(o)
19+
b.data.status = 'archived'
20+
b.data.workflow.checkpoint = `merged-pr:${x.pullRequest}`
21+
write(b)
22+
const dest = dirname(xp(r, n))
23+
mkdirSync(dirname(dest), { recursive: true })
24+
renameSync(dirname(b.path), dest)
25+
writeFileSync(join(r, 'shadow-docs', 'INDEX.md'), buildIndex(r))
26+
return { path: xp(r, n) }
27+
}

‎lib/domains/branch.mjs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { git, repo } from '../git.mjs'
2+
import { brief, write } from '../brief.mjs'
3+
import { name } from '../input.mjs'
4+
5+
export async function planData(r, o) {
6+
const n = name(o), b = brief(r, n), q = repo(r)
7+
return { name: n, branch: `${b.data.type}/${n}`, baseBranch: b.data.baseBranch || 'main', brief: b.data, repo: q }
8+
}
9+
10+
export function execute(r, o, x, b) {
11+
if (x.repo.branch === x.baseBranch) git(r, ['switch', '-c', x.branch])
12+
b.data.branch = x.branch
13+
b.data.status = 'branched'
14+
write(b)
15+
return { branch: x.branch }
16+
}

‎lib/domains/change.mjs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { existsSync } from 'node:fs'
2+
import { brief, write, ap } from '../brief.mjs'
3+
import { name, confirm, readBody } from '../input.mjs'
4+
5+
export function create(r, o) {
6+
confirm(o)
7+
const n = name(o)
8+
if (existsSync(ap(r, n))) throw Error('CHANGE_EXISTS')
9+
const b = {
10+
path: ap(r, n),
11+
data: {
12+
schema: 'shadow-dev/v1',
13+
name: n,
14+
type: o.type || 'feat',
15+
scope: o.scope || null,
16+
status: 'draft',
17+
baseBranch: o['base-branch'] || 'main',
18+
branch: null,
19+
files: String(o.files || '').split(',').filter(Boolean).sort(),
20+
github: { repository: o.repository || null, issue: null, issueUrl: null, pullRequest: null, pullRequestUrl: null },
21+
review: { conclusion: 'pending', verifiedCommit: null, verifiedAt: null },
22+
workflow: { operation: null, checkpoint: null, planHash: null, updatedAt: null, lastError: null },
23+
},
24+
body: readBody(o, n),
25+
}
26+
write(b)
27+
return { name: n, path: b.path }
28+
}
29+
30+
export function approve(r, o) {
31+
confirm(o)
32+
const b = brief(r, name(o))
33+
b.data.status = 'proposed'
34+
write(b)
35+
return { name: o.name, status: 'proposed' }
36+
}

‎lib/domains/commit.mjs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { git, repo } from '../git.mjs'
2+
import { brief, write } from '../brief.mjs'
3+
import { name } from '../input.mjs'
4+
5+
export async function planData(r, o) {
6+
const n = name(o), b = brief(r, n), q = repo(r)
7+
return { name: n, files: String(o.files || '').split(',').filter(Boolean).sort(), message: o.message || null, brief: b.data, repo: q }
8+
}
9+
10+
export function execute(r, o, x, b) {
11+
if (!x.message || !x.files.length) throw Error('COMMIT_INPUT_REQUIRED')
12+
if (x.files.some(f => ['.', '-A', '--all'].includes(f) || f.startsWith('../') || f.startsWith('/'))) throw Object.assign(Error('UNSUPPORTED_OPERATION'), { status: 4 })
13+
git(r, ['add', '--', ...x.files])
14+
git(r, ['commit', '-m', x.message])
15+
b.data.status = 'committed'
16+
b.data.workflow.checkpoint = git(r, ['rev-parse', 'HEAD'])
17+
write(b)
18+
return { commit: b.data.workflow.checkpoint }
19+
}

‎lib/domains/index.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
import { plan } from '../plan.mjs'
4+
import { confirm } from '../input.mjs'
5+
import { buildIndex } from '../indexer.mjs'
6+
7+
function rebuildData(r) {
8+
return { content: buildIndex(r), current: existsSync(join(r, 'shadow-docs', 'INDEX.md')) ? readFileSync(join(r, 'shadow-docs', 'INDEX.md'), 'utf8') : '' }
9+
}
10+
11+
export function rebuildPlan(r) {
12+
return plan('index.rebuild', rebuildData(r))
13+
}
14+
15+
export function rebuildExecute(r, o) {
16+
confirm(o, true)
17+
const e = plan('index.rebuild', rebuildData(r))
18+
if (e.planHash !== o['plan-hash']) throw Error('PLAN_HASH_INVALID')
19+
writeFileSync(join(r, 'shadow-docs', 'INDEX.md'), e.data.content)
20+
return { path: 'shadow-docs/INDEX.md' }
21+
}

‎lib/domains/inspect.mjs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { readdirSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
import { repo } from '../git.mjs'
4+
import { brief } from '../brief.mjs'
5+
import { name } from '../input.mjs'
6+
import { pr } from '../github.mjs'
7+
8+
export function repoState(r) {
9+
return repo(r)
10+
}
11+
12+
export async function pullRequest(r, o) {
13+
return pr(brief(r, name(o)), r)
14+
}
15+
16+
export function conflict(r, o) {
17+
const n = name(o), files = new Set(brief(r, n).data.files || []), base = join(r, 'shadow-docs', 'changes'), overlaps = []
18+
for (const e of readdirSync(base, { withFileTypes: true }).filter(x => x.isDirectory() && x.name !== n && x.name !== 'archive')) {
19+
try {
20+
const fs = (brief(r, e.name).data.files || []).filter(x => files.has(x)).sort()
21+
if (fs.length) overlaps.push({ change: e.name, files: fs })
22+
} catch {}
23+
}
24+
return { overlaps }
25+
}

0 commit comments

Comments
 (0)