Skip to content

Commit 2064a31

Browse files
authored
Merge pull request #3 from stack-wuh/refactor/20260917-refactor-compat-and-domain-convergence
CLI 平台兼容性修复与领域架构收敛
2 parents 2caf0e9 + a0481a6 commit 2064a31

24 files changed

Lines changed: 465 additions & 135 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute
2424
| `archive plan\|execute` | 归档已合并变更并重建 INDEX |
2525
| `index rebuild plan\|execute` | 重建变更索引 |
2626

27-
所有输出为单行 JSON:成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。
27+
所有输出为单行 JSON:成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。`--json` 参数为历史兼容保留,接受即无操作(输出恒为 JSON)。
28+
29+
## 平台兼容
30+
31+
- `--files` 路径参数接受 Windows 反斜杠写法(如 `lib\a.mjs`),自动归一为正斜杠并与 git 状态、conflict 比对对齐。
32+
- `brief.md` 解析容忍 LF/CRLF(兼容 Windows `core.autocrlf` 检出与手工编辑),CLI 写回一律统一为 LF。
33+
- git fetch/push 带 120 秒超时,避免凭据弹窗导致的永久挂起;GitHub API 超时见下方环境变量。
2834

2935
## 退出码
3036

@@ -47,7 +53,7 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute
4753
## 开发
4854

4955
```bash
50-
npm test # node --test,35 个契约测试覆盖全部命令域
56+
npm test # node --test,38 个契约测试覆盖全部命令域
5157
```
5258

5359
行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。

‎cli.mjs‎

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
import { args, HELP } from './lib/args.mjs'
33
import { out, fail } from './lib/output.mjs'
44
import { plan } from './lib/plan.mjs'
5-
import { root, repo } from './lib/git.mjs'
6-
import { brief, write, tasks } from './lib/brief.mjs'
5+
import { root } from './lib/git.mjs'
6+
import { brief, write } from './lib/brief.mjs'
77
import { confirm, name } from './lib/input.mjs'
8-
import { pr } from './lib/github.mjs'
8+
import { err } from './lib/errors.mjs'
99
import * as branch from './lib/domains/branch.mjs'
1010
import * as sync from './lib/domains/sync.mjs'
1111
import * as review from './lib/domains/review.mjs'
@@ -20,26 +20,32 @@ import * as task from './lib/domains/task.mjs'
2020
import * as inspect from './lib/domains/inspect.mjs'
2121
import * as index from './lib/domains/index.mjs'
2222

23-
const DOMAINS = { branch, sync, review, commit, publish, release, reconcile, archive, issue }
23+
const DOMAINS = { branch, sync, review, commit, publish, release, reconcile, archive, issue, index }
2424

25-
// execute 与 plan 用同一 planData 重算并对比 hash,brief 里的 planHash 是 execute 的前置凭证
26-
async function executeDomain(d, r, o) {
25+
// execute 与 plan 用同一 planData 重算并对比 hash。带 --name 的域以 brief 里的 planHash 为前置凭证;
26+
// 无 brief 的域(如 index rebuild)--plan-hash 是唯一凭证
27+
async function executeDomain(c, mod, r, o) {
2728
confirm(o)
28-
const mod = DOMAINS[d]
29-
const e = plan(d, await mod.planData(r, o))
30-
if (o['plan-hash'] && o['plan-hash'] !== e.planHash) throw Error('PLAN_HASH_INVALID')
31-
const n = name(o), b = brief(r, n), x = e.data
32-
if (b.data.workflow.planHash !== e.planHash) throw Object.assign(Error(b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED'), { status: b.data.workflow.planHash ? 1 : 2 })
33-
return mod.execute(r, o, x, b)
29+
const e = plan(c, await mod.planData(r, o))
30+
if (o['plan-hash'] && o['plan-hash'] !== e.planHash) throw err('PLAN_HASH_INVALID')
31+
if (!o.name) {
32+
if (!o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED', 2)
33+
return mod.execute(r, o, e.data)
34+
}
35+
const b = brief(r, name(o))
36+
if (b.data.workflow.planHash !== e.planHash) {
37+
const code = b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED'
38+
throw err(code, code, b.data.workflow.planHash ? 1 : 2)
39+
}
40+
return mod.execute(r, o, e.data, b)
3441
}
3542

36-
async function planDomain(d, r, o) {
37-
const mod = DOMAINS[d]
38-
const e = plan(d, await mod.planData(r, o))
43+
async function planDomain(c, mod, r, o) {
44+
const e = plan(c, await mod.planData(r, o))
45+
if (!o.name) return e
3946
const b = brief(r, name(o))
4047
b.data.workflow.planHash = e.planHash
41-
if (d === 'release') b.data.workflow.release = { files: e.data.files, message: e.data.message, title: e.data.title, body: e.data.body }
42-
if (d === 'issue') b.data.workflow.issuePlan = { title: e.data.title, body: e.data.body, labels: e.data.labels }
48+
mod.persistPlan?.(b, e)
4349
write(b)
4450
return e
4551
}
@@ -54,10 +60,11 @@ async function handle(r, p, o) {
5460
if (d === 'task' && a === 'set') return out({ ok: true, command: 'task.set', data: task.set(r, o) })
5561
if (d === 'change' && a === 'create') return out({ ok: true, command: 'change.create', data: change.create(r, o) })
5662
if (d === 'change' && a === 'approve') return out({ ok: true, command: 'change.approve', data: change.approve(r, o) })
57-
if (d === 'index' && a === 'rebuild' && s === 'plan') return out(index.rebuildPlan(r))
58-
if (d === 'index' && a === 'rebuild' && s === 'execute') return out({ ok: true, command: 'index.rebuild.execute', data: index.rebuildExecute(r, o) })
59-
if (Object.hasOwn(DOMAINS, d) && a === 'plan') return out(await planDomain(d, r, o))
60-
if (Object.hasOwn(DOMAINS, d) && a === 'execute') return out({ ok: true, command: `${d}.execute`, data: await executeDomain(d, r, o) })
63+
if (Object.hasOwn(DOMAINS, d)) {
64+
const verb = a === 'rebuild' ? s : a, c = a === 'rebuild' ? `${d}.rebuild` : d
65+
if (verb === 'plan') return out(await planDomain(c, DOMAINS[d], r, o))
66+
if (verb === 'execute') return out({ ok: true, command: `${c}.execute`, data: await executeDomain(c, DOMAINS[d], r, o) })
67+
}
6168
return fail('UNKNOWN_COMMAND', `unsupported command: ${p.join(' ')}`)
6269
}
6370

‎lib/brief.mjs‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
22
import { dirname, join } from 'node:path'
3+
import { err } from './errors.mjs'
34

45
export const ap = (r, n) => join(r, 'shadow-docs', 'changes', n, 'brief.md')
56
export const xp = (r, n) => join(r, 'shadow-docs', 'changes', 'archive', n, 'brief.md')
67

8+
// 读取容忍 LF/CRLF(Windows core.autocrlf 检出与手工编辑),写回恒为 LF
79
export function brief(r, n, arch = false) {
810
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) }
11+
if (!existsSync(path)) throw err('BRIEF_NOT_FOUND')
12+
const t = readFileSync(path, 'utf8'), open = /^---\r?\n/.exec(t)
13+
if (!open) throw err('BRIEF_FRONTMATTER_REQUIRED')
14+
const s = open[0].length, e = /\r?\n---\r?\n/.exec(t.slice(s))
15+
if (!e) throw err('BRIEF_FRONTMATTER_REQUIRED')
16+
return { path, data: JSON.parse(t.slice(s, s + e.index)), body: t.slice(s + e.index + e[0].length) }
1317
}
1418

1519
export function write(b) {
1620
mkdirSync(dirname(b.path), { recursive: true })
1721
const t = b.path + `.tmp-${process.pid}`
18-
writeFileSync(t, `---\n${JSON.stringify(b.data, null, 2)}\n---\n${b.body}`)
22+
writeFileSync(t, `---\n${JSON.stringify(b.data, null, 2)}\n---\n${b.body.replaceAll('\r\n', '\n')}`)
1923
renameSync(t, b.path)
2024
}
2125

‎lib/domains/archive.mjs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import { pr } from '../github.mjs'
55
import { buildIndex } from '../indexer.mjs'
66
import { brief, write, xp } from '../brief.mjs'
77
import { repo } from '../git.mjs'
8+
import { err } from '../errors.mjs'
89

910
export async function planData(r, o) {
1011
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+
if (b.data.review?.conclusion !== 'passed' || b.data.review.verifiedCommit !== q.head) throw err('REVIEW_NOT_PASSED')
1213
const x = await pr(b, r)
13-
if (!x.merged) throw Error('PR_NOT_MERGED')
14+
if (!x.merged) throw err('PR_NOT_MERGED')
1415
return { name: n, pullRequest: x.number, head: q.head }
1516
}
1617

‎lib/domains/change.mjs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { existsSync } from 'node:fs'
22
import { brief, write, ap } from '../brief.mjs'
3-
import { name, confirm, readBody } from '../input.mjs'
3+
import { name, confirm, readBody, fileList } from '../input.mjs'
4+
import { err } from '../errors.mjs'
45

56
export function create(r, o) {
67
confirm(o)
78
const n = name(o)
8-
if (existsSync(ap(r, n))) throw Error('CHANGE_EXISTS')
9+
if (existsSync(ap(r, n))) throw err('CHANGE_EXISTS')
910
const b = {
1011
path: ap(r, n),
1112
data: {
@@ -16,7 +17,7 @@ export function create(r, o) {
1617
status: 'draft',
1718
baseBranch: o['base-branch'] || 'main',
1819
branch: null,
19-
files: String(o.files || '').split(',').filter(Boolean).sort(),
20+
files: fileList(o.files),
2021
github: { repository: o.repository || null, issue: null, issueUrl: null, pullRequest: null, pullRequestUrl: null },
2122
review: { conclusion: 'pending', verifiedCommit: null, verifiedAt: null },
2223
workflow: { operation: null, checkpoint: null, planHash: null, updatedAt: null, lastError: null },

‎lib/domains/commit.mjs‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
1-
import { git, repo } from '../git.mjs'
1+
import { repo } from '../git.mjs'
22
import { brief, write } from '../brief.mjs'
3-
import { name } from '../input.mjs'
3+
import { name, fileList } from '../input.mjs'
4+
import { commitStep } from '../steps.mjs'
45

56
export async function planData(r, o) {
67
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+
return { name: n, files: fileList(o.files), message: o.message || null, brief: b.data, repo: q }
89
}
910

1011
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'])
12+
const commit = commitStep(r, x, b)
1713
write(b)
18-
return { commit: b.data.workflow.checkpoint }
14+
return { commit }
1915
}

‎lib/domains/index.mjs‎

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,12 @@
11
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
22
import { join } from 'node:path'
3-
import { plan } from '../plan.mjs'
4-
import { confirm } from '../input.mjs'
53
import { buildIndex } from '../indexer.mjs'
64

7-
function rebuildData(r) {
5+
export async function planData(r) {
86
return { content: buildIndex(r), current: existsSync(join(r, 'shadow-docs', 'INDEX.md')) ? readFileSync(join(r, 'shadow-docs', 'INDEX.md'), 'utf8') : '' }
97
}
108

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)
9+
export function execute(r, o, x) {
10+
writeFileSync(join(r, 'shadow-docs', 'INDEX.md'), x.content)
2011
return { path: 'shadow-docs/INDEX.md' }
2112
}

‎lib/domains/issue.mjs‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { api, repository } from '../github.mjs'
22
import { brief, write } from '../brief.mjs'
33
import { name } from '../input.mjs'
44
import { repo } from '../git.mjs'
5+
import { err } from '../errors.mjs'
56

67
export async function planData(r, o) {
78
const n = name(o), b = brief(r, n), q = repo(r), w = b.data.workflow.issuePlan || {}
@@ -16,15 +17,17 @@ export async function planData(r, o) {
1617
}
1718
}
1819

19-
export function execute(r, o, x, b) {
20-
return (async () => {
21-
if (!x.title) throw Error('ISSUE_TITLE_REQUIRED')
22-
const z = await api(`/repos/${x.repository}/issues`, { method: 'POST', body: JSON.stringify({ title: x.title, body: x.body, labels: x.labels }) })
23-
b.data.github.repository = x.repository
24-
b.data.github.issue = z.number
25-
b.data.github.issueUrl = z.html_url
26-
b.data.workflow.checkpoint = `issue:${z.number}`
27-
write(b)
28-
return { number: z.number, url: z.html_url }
29-
})()
20+
export function persistPlan(b, e) {
21+
b.data.workflow.issuePlan = { title: e.data.title, body: e.data.body, labels: e.data.labels }
22+
}
23+
24+
export async function execute(r, o, x, b) {
25+
if (!x.title) throw err('ISSUE_TITLE_REQUIRED')
26+
const z = await api(`/repos/${x.repository}/issues`, { method: 'POST', body: JSON.stringify({ title: x.title, body: x.body, labels: x.labels }) })
27+
b.data.github.repository = x.repository
28+
b.data.github.issue = z.number
29+
b.data.github.issueUrl = z.html_url
30+
b.data.workflow.checkpoint = `issue:${z.number}`
31+
write(b)
32+
return { number: z.number, url: z.html_url }
3033
}

‎lib/domains/publish.mjs‎

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,16 @@
1-
import { git, repo } from '../git.mjs'
1+
import { repo } from '../git.mjs'
22
import { brief, write } from '../brief.mjs'
33
import { name } from '../input.mjs'
4-
import { ext } from '../errors.mjs'
5-
import { repository, ensurePr } from '../github.mjs'
4+
import { repository } from '../github.mjs'
5+
import { pushAndOpenPr } from '../steps.mjs'
66

77
export async function planData(r, o) {
88
const n = name(o), b = brief(r, n), q = repo(r)
99
return { name: n, repository: repository(b, r), branch: b.data.branch || q.branch, baseBranch: b.data.baseBranch || 'main', title: o.title || n, body: o.body || '', head: q.head, brief: b.data }
1010
}
1111

12-
export function execute(r, o, x, b) {
13-
return (async () => {
14-
try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') }
15-
const { pr: z, created } = await ensurePr(x)
16-
b.data.github.pullRequest = z.number
17-
b.data.github.pullRequestUrl = z.html_url
18-
b.data.status = 'published'
19-
b.data.workflow.checkpoint = `pr:${z.number}`
20-
write(b)
21-
return { number: z.number, url: z.html_url, created }
22-
})()
12+
export async function execute(r, o, x, b) {
13+
const z = await pushAndOpenPr(r, x, b)
14+
write(b)
15+
return z
2316
}

‎lib/domains/release.mjs‎

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import { git, repo } from '../git.mjs'
1+
import { repo } from '../git.mjs'
22
import { brief, write } from '../brief.mjs'
3-
import { name } from '../input.mjs'
4-
import { ext } from '../errors.mjs'
5-
import { repository, ensurePr } from '../github.mjs'
3+
import { name, fileList } from '../input.mjs'
4+
import { repository } from '../github.mjs'
5+
import { commitStep, pushAndOpenPr } from '../steps.mjs'
66

77
export async function planData(r, o) {
88
const n = name(o), b = brief(r, n), q = repo(r), w = b.data.workflow.release || {}
99
return {
1010
name: n,
11-
files: String(o.files ?? w.files ?? '').split(',').filter(Boolean).sort(),
11+
files: fileList(o.files ?? w.files),
1212
message: o.message ?? w.message ?? null,
1313
title: o.title ?? w.title ?? n,
1414
body: o.body ?? w.body ?? '',
@@ -21,25 +21,13 @@ export async function planData(r, o) {
2121
}
2222
}
2323

24-
export function execute(r, o, x, b) {
25-
return (async () => {
26-
let commit = null
27-
if (!['committed', 'published'].includes(b.data.status)) {
28-
if (!x.message || !x.files.length) throw Error('COMMIT_INPUT_REQUIRED')
29-
if (x.files.some(f => ['.', '-A', '--all'].includes(f) || f.startsWith('../') || f.startsWith('/'))) throw Object.assign(Error('UNSUPPORTED_OPERATION'), { status: 4 })
30-
git(r, ['add', '--', ...x.files])
31-
git(r, ['commit', '-m', x.message])
32-
b.data.status = 'committed'
33-
b.data.workflow.checkpoint = git(r, ['rev-parse', 'HEAD'])
34-
}
35-
commit = b.data.workflow.checkpoint
36-
try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') }
37-
const { pr: z, created } = await ensurePr(x)
38-
b.data.github.pullRequest = z.number
39-
b.data.github.pullRequestUrl = z.html_url
40-
b.data.status = 'published'
41-
b.data.workflow.checkpoint = `pr:${z.number}`
42-
write(b)
43-
return { commit, number: z.number, url: z.html_url, created }
44-
})()
24+
export function persistPlan(b, e) {
25+
b.data.workflow.release = { files: e.data.files, message: e.data.message, title: e.data.title, body: e.data.body }
26+
}
27+
28+
export async function execute(r, o, x, b) {
29+
const commit = ['committed', 'published'].includes(b.data.status) ? b.data.workflow.checkpoint : commitStep(r, x, b)
30+
const z = await pushAndOpenPr(r, x, b)
31+
write(b)
32+
return { commit, ...z }
4533
}

0 commit comments

Comments
 (0)