Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute
| `archive plan\|execute` | 归档已合并变更并重建 INDEX |
| `index rebuild plan\|execute` | 重建变更索引 |

所有输出为单行 JSON:成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。
所有输出为单行 JSON:成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。`--json` 参数为历史兼容保留,接受即无操作(输出恒为 JSON)。

## 平台兼容

- `--files` 路径参数接受 Windows 反斜杠写法(如 `lib\a.mjs`),自动归一为正斜杠并与 git 状态、conflict 比对对齐。
- `brief.md` 解析容忍 LF/CRLF(兼容 Windows `core.autocrlf` 检出与手工编辑),CLI 写回一律统一为 LF。
- git fetch/push 带 120 秒超时,避免凭据弹窗导致的永久挂起;GitHub API 超时见下方环境变量。

## 退出码

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

```bash
npm test # node --test,35 个契约测试覆盖全部命令域
npm test # node --test,38 个契约测试覆盖全部命令域
```

行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。
49 changes: 28 additions & 21 deletions cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import { args, HELP } from './lib/args.mjs'
import { out, fail } from './lib/output.mjs'
import { plan } from './lib/plan.mjs'
import { root, repo } from './lib/git.mjs'
import { brief, write, tasks } from './lib/brief.mjs'
import { root } from './lib/git.mjs'
import { brief, write } from './lib/brief.mjs'
import { confirm, name } from './lib/input.mjs'
import { pr } from './lib/github.mjs'
import { err } from './lib/errors.mjs'
import * as branch from './lib/domains/branch.mjs'
import * as sync from './lib/domains/sync.mjs'
import * as review from './lib/domains/review.mjs'
Expand All @@ -20,26 +20,32 @@ import * as task from './lib/domains/task.mjs'
import * as inspect from './lib/domains/inspect.mjs'
import * as index from './lib/domains/index.mjs'

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

// execute 与 plan 用同一 planData 重算并对比 hash,brief 里的 planHash 是 execute 的前置凭证
async function executeDomain(d, r, o) {
// execute 与 plan 用同一 planData 重算并对比 hash。带 --name 的域以 brief 里的 planHash 为前置凭证;
// 无 brief 的域(如 index rebuild)--plan-hash 是唯一凭证
async function executeDomain(c, mod, r, o) {
confirm(o)
const mod = DOMAINS[d]
const e = plan(d, await mod.planData(r, o))
if (o['plan-hash'] && o['plan-hash'] !== e.planHash) throw Error('PLAN_HASH_INVALID')
const n = name(o), b = brief(r, n), x = e.data
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 })
return mod.execute(r, o, x, b)
const e = plan(c, await mod.planData(r, o))
if (o['plan-hash'] && o['plan-hash'] !== e.planHash) throw err('PLAN_HASH_INVALID')
if (!o.name) {
if (!o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED', 2)
return mod.execute(r, o, e.data)
}
const b = brief(r, name(o))
if (b.data.workflow.planHash !== e.planHash) {
const code = b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED'
throw err(code, code, b.data.workflow.planHash ? 1 : 2)
}
return mod.execute(r, o, e.data, b)
}

async function planDomain(d, r, o) {
const mod = DOMAINS[d]
const e = plan(d, await mod.planData(r, o))
async function planDomain(c, mod, r, o) {
const e = plan(c, await mod.planData(r, o))
if (!o.name) return e
const b = brief(r, name(o))
b.data.workflow.planHash = e.planHash
if (d === 'release') b.data.workflow.release = { files: e.data.files, message: e.data.message, title: e.data.title, body: e.data.body }
if (d === 'issue') b.data.workflow.issuePlan = { title: e.data.title, body: e.data.body, labels: e.data.labels }
mod.persistPlan?.(b, e)
write(b)
return e
}
Expand All @@ -54,10 +60,11 @@ async function handle(r, p, o) {
if (d === 'task' && a === 'set') return out({ ok: true, command: 'task.set', data: task.set(r, o) })
if (d === 'change' && a === 'create') return out({ ok: true, command: 'change.create', data: change.create(r, o) })
if (d === 'change' && a === 'approve') return out({ ok: true, command: 'change.approve', data: change.approve(r, o) })
if (d === 'index' && a === 'rebuild' && s === 'plan') return out(index.rebuildPlan(r))
if (d === 'index' && a === 'rebuild' && s === 'execute') return out({ ok: true, command: 'index.rebuild.execute', data: index.rebuildExecute(r, o) })
if (Object.hasOwn(DOMAINS, d) && a === 'plan') return out(await planDomain(d, r, o))
if (Object.hasOwn(DOMAINS, d) && a === 'execute') return out({ ok: true, command: `${d}.execute`, data: await executeDomain(d, r, o) })
if (Object.hasOwn(DOMAINS, d)) {
const verb = a === 'rebuild' ? s : a, c = a === 'rebuild' ? `${d}.rebuild` : d
if (verb === 'plan') return out(await planDomain(c, DOMAINS[d], r, o))
if (verb === 'execute') return out({ ok: true, command: `${c}.execute`, data: await executeDomain(c, DOMAINS[d], r, o) })
}
return fail('UNKNOWN_COMMAND', `unsupported command: ${p.join(' ')}`)
}

Expand Down
14 changes: 9 additions & 5 deletions lib/brief.mjs
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { err } from './errors.mjs'

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

// 读取容忍 LF/CRLF(Windows core.autocrlf 检出与手工编辑),写回恒为 LF
export function brief(r, n, arch = false) {
const path = arch ? xp(r, n) : ap(r, n)
if (!existsSync(path)) throw Error('BRIEF_NOT_FOUND')
const t = readFileSync(path, 'utf8'), e = t.indexOf('\n---\n', 4)
if (e < 0) throw Error('BRIEF_FRONTMATTER_REQUIRED')
return { path, data: JSON.parse(t.slice(4, e)), body: t.slice(e + 5) }
if (!existsSync(path)) throw err('BRIEF_NOT_FOUND')
const t = readFileSync(path, 'utf8'), open = /^---\r?\n/.exec(t)
if (!open) throw err('BRIEF_FRONTMATTER_REQUIRED')
const s = open[0].length, e = /\r?\n---\r?\n/.exec(t.slice(s))
if (!e) throw err('BRIEF_FRONTMATTER_REQUIRED')
return { path, data: JSON.parse(t.slice(s, s + e.index)), body: t.slice(s + e.index + e[0].length) }
}

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

Expand Down
5 changes: 3 additions & 2 deletions lib/domains/archive.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { pr } from '../github.mjs'
import { buildIndex } from '../indexer.mjs'
import { brief, write, xp } from '../brief.mjs'
import { repo } from '../git.mjs'
import { err } from '../errors.mjs'

export async function planData(r, o) {
const n = name(o), b = brief(r, n), q = repo(r)
if (b.data.review?.conclusion !== 'passed' || b.data.review.verifiedCommit !== q.head) throw Error('REVIEW_NOT_PASSED')
if (b.data.review?.conclusion !== 'passed' || b.data.review.verifiedCommit !== q.head) throw err('REVIEW_NOT_PASSED')
const x = await pr(b, r)
if (!x.merged) throw Error('PR_NOT_MERGED')
if (!x.merged) throw err('PR_NOT_MERGED')
return { name: n, pullRequest: x.number, head: q.head }
}

Expand Down
7 changes: 4 additions & 3 deletions lib/domains/change.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { existsSync } from 'node:fs'
import { brief, write, ap } from '../brief.mjs'
import { name, confirm, readBody } from '../input.mjs'
import { name, confirm, readBody, fileList } from '../input.mjs'
import { err } from '../errors.mjs'

export function create(r, o) {
confirm(o)
const n = name(o)
if (existsSync(ap(r, n))) throw Error('CHANGE_EXISTS')
if (existsSync(ap(r, n))) throw err('CHANGE_EXISTS')
const b = {
path: ap(r, n),
data: {
Expand All @@ -16,7 +17,7 @@ export function create(r, o) {
status: 'draft',
baseBranch: o['base-branch'] || 'main',
branch: null,
files: String(o.files || '').split(',').filter(Boolean).sort(),
files: fileList(o.files),
github: { repository: o.repository || null, issue: null, issueUrl: null, pullRequest: null, pullRequestUrl: null },
review: { conclusion: 'pending', verifiedCommit: null, verifiedAt: null },
workflow: { operation: null, checkpoint: null, planHash: null, updatedAt: null, lastError: null },
Expand Down
16 changes: 6 additions & 10 deletions lib/domains/commit.mjs
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import { git, repo } from '../git.mjs'
import { repo } from '../git.mjs'
import { brief, write } from '../brief.mjs'
import { name } from '../input.mjs'
import { name, fileList } from '../input.mjs'
import { commitStep } from '../steps.mjs'

export async function planData(r, o) {
const n = name(o), b = brief(r, n), q = repo(r)
return { name: n, files: String(o.files || '').split(',').filter(Boolean).sort(), message: o.message || null, brief: b.data, repo: q }
return { name: n, files: fileList(o.files), message: o.message || null, brief: b.data, repo: q }
}

export function execute(r, o, x, b) {
if (!x.message || !x.files.length) throw Error('COMMIT_INPUT_REQUIRED')
if (x.files.some(f => ['.', '-A', '--all'].includes(f) || f.startsWith('../') || f.startsWith('/'))) throw Object.assign(Error('UNSUPPORTED_OPERATION'), { status: 4 })
git(r, ['add', '--', ...x.files])
git(r, ['commit', '-m', x.message])
b.data.status = 'committed'
b.data.workflow.checkpoint = git(r, ['rev-parse', 'HEAD'])
const commit = commitStep(r, x, b)
write(b)
return { commit: b.data.workflow.checkpoint }
return { commit }
}
15 changes: 3 additions & 12 deletions lib/domains/index.mjs
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { plan } from '../plan.mjs'
import { confirm } from '../input.mjs'
import { buildIndex } from '../indexer.mjs'

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

export function rebuildPlan(r) {
return plan('index.rebuild', rebuildData(r))
}

export function rebuildExecute(r, o) {
confirm(o, true)
const e = plan('index.rebuild', rebuildData(r))
if (e.planHash !== o['plan-hash']) throw Error('PLAN_HASH_INVALID')
writeFileSync(join(r, 'shadow-docs', 'INDEX.md'), e.data.content)
export function execute(r, o, x) {
writeFileSync(join(r, 'shadow-docs', 'INDEX.md'), x.content)
return { path: 'shadow-docs/INDEX.md' }
}
25 changes: 14 additions & 11 deletions lib/domains/issue.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { api, repository } from '../github.mjs'
import { brief, write } from '../brief.mjs'
import { name } from '../input.mjs'
import { repo } from '../git.mjs'
import { err } from '../errors.mjs'

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

export function execute(r, o, x, b) {
return (async () => {
if (!x.title) throw Error('ISSUE_TITLE_REQUIRED')
const z = await api(`/repos/${x.repository}/issues`, { method: 'POST', body: JSON.stringify({ title: x.title, body: x.body, labels: x.labels }) })
b.data.github.repository = x.repository
b.data.github.issue = z.number
b.data.github.issueUrl = z.html_url
b.data.workflow.checkpoint = `issue:${z.number}`
write(b)
return { number: z.number, url: z.html_url }
})()
export function persistPlan(b, e) {
b.data.workflow.issuePlan = { title: e.data.title, body: e.data.body, labels: e.data.labels }
}

export async function execute(r, o, x, b) {
if (!x.title) throw err('ISSUE_TITLE_REQUIRED')
const z = await api(`/repos/${x.repository}/issues`, { method: 'POST', body: JSON.stringify({ title: x.title, body: x.body, labels: x.labels }) })
b.data.github.repository = x.repository
b.data.github.issue = z.number
b.data.github.issueUrl = z.html_url
b.data.workflow.checkpoint = `issue:${z.number}`
write(b)
return { number: z.number, url: z.html_url }
}
21 changes: 7 additions & 14 deletions lib/domains/publish.mjs
Original file line number Diff line number Diff line change
@@ -1,23 +1,16 @@
import { git, repo } from '../git.mjs'
import { repo } from '../git.mjs'
import { brief, write } from '../brief.mjs'
import { name } from '../input.mjs'
import { ext } from '../errors.mjs'
import { repository, ensurePr } from '../github.mjs'
import { repository } from '../github.mjs'
import { pushAndOpenPr } from '../steps.mjs'

export async function planData(r, o) {
const n = name(o), b = brief(r, n), q = repo(r)
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 }
}

export function execute(r, o, x, b) {
return (async () => {
try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') }
const { pr: z, created } = await ensurePr(x)
b.data.github.pullRequest = z.number
b.data.github.pullRequestUrl = z.html_url
b.data.status = 'published'
b.data.workflow.checkpoint = `pr:${z.number}`
write(b)
return { number: z.number, url: z.html_url, created }
})()
export async function execute(r, o, x, b) {
const z = await pushAndOpenPr(r, x, b)
write(b)
return z
}
40 changes: 14 additions & 26 deletions lib/domains/release.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { git, repo } from '../git.mjs'
import { repo } from '../git.mjs'
import { brief, write } from '../brief.mjs'
import { name } from '../input.mjs'
import { ext } from '../errors.mjs'
import { repository, ensurePr } from '../github.mjs'
import { name, fileList } from '../input.mjs'
import { repository } from '../github.mjs'
import { commitStep, pushAndOpenPr } from '../steps.mjs'

export async function planData(r, o) {
const n = name(o), b = brief(r, n), q = repo(r), w = b.data.workflow.release || {}
return {
name: n,
files: String(o.files ?? w.files ?? '').split(',').filter(Boolean).sort(),
files: fileList(o.files ?? w.files),
message: o.message ?? w.message ?? null,
title: o.title ?? w.title ?? n,
body: o.body ?? w.body ?? '',
Expand All @@ -21,25 +21,13 @@ export async function planData(r, o) {
}
}

export function execute(r, o, x, b) {
return (async () => {
let commit = null
if (!['committed', 'published'].includes(b.data.status)) {
if (!x.message || !x.files.length) throw Error('COMMIT_INPUT_REQUIRED')
if (x.files.some(f => ['.', '-A', '--all'].includes(f) || f.startsWith('../') || f.startsWith('/'))) throw Object.assign(Error('UNSUPPORTED_OPERATION'), { status: 4 })
git(r, ['add', '--', ...x.files])
git(r, ['commit', '-m', x.message])
b.data.status = 'committed'
b.data.workflow.checkpoint = git(r, ['rev-parse', 'HEAD'])
}
commit = b.data.workflow.checkpoint
try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') }
const { pr: z, created } = await ensurePr(x)
b.data.github.pullRequest = z.number
b.data.github.pullRequestUrl = z.html_url
b.data.status = 'published'
b.data.workflow.checkpoint = `pr:${z.number}`
write(b)
return { commit, number: z.number, url: z.html_url, created }
})()
export function persistPlan(b, e) {
b.data.workflow.release = { files: e.data.files, message: e.data.message, title: e.data.title, body: e.data.body }
}

export async function execute(r, o, x, b) {
const commit = ['committed', 'published'].includes(b.data.status) ? b.data.workflow.checkpoint : commitStep(r, x, b)
const z = await pushAndOpenPr(r, x, b)
write(b)
return { commit, ...z }
}
7 changes: 4 additions & 3 deletions lib/domains/review.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { brief, write, tasks } from '../brief.mjs'
import { name } from '../input.mjs'
import { repo } from '../git.mjs'
import { err } from '../errors.mjs'

export async function planData(r, o) {
const n = name(o), b = brief(r, n), q = repo(r)
Expand All @@ -9,10 +10,10 @@ export async function planData(r, o) {

export function execute(r, o, x, b) {
const ts = tasks(b.body)
if (ts.length && !ts.every(t => t.done)) throw Error('TASKS_NOT_COMPLETE')
if (ts.length && !ts.every(t => t.done)) throw err('TASKS_NOT_COMPLETE')
const c = o.conclusion || 'passed'
if (!['passed', 'blocked'].includes(c)) throw Error('INVALID_CONCLUSION')
if (o.knowledge && !['新增', '更新', '废弃', '无需变更'].includes(o.knowledge)) throw Error('INVALID_KNOWLEDGE')
if (!['passed', 'blocked'].includes(c)) throw err('INVALID_CONCLUSION')
if (o.knowledge && !['新增', '更新', '废弃', '无需变更'].includes(o.knowledge)) throw err('INVALID_KNOWLEDGE')
b.data.review = { conclusion: c, verifiedCommit: x.verifiedCommit, verifiedAt: c === 'passed' ? new Date().toISOString() : b.data.review?.verifiedAt || null }
b.data.knowledge = o.knowledge ? { action: o.knowledge, target: o.target || null, reason: o.reason || null } : null
if (c === 'passed') b.data.status = 'reviewed'
Expand Down
8 changes: 4 additions & 4 deletions lib/domains/sync.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { git, repo } from '../git.mjs'
import { brief } from '../brief.mjs'
import { name } from '../input.mjs'
import { ext } from '../errors.mjs'
import { ext, err } from '../errors.mjs'

export async function planData(r, o) {
const n = name(o), b = brief(r, n), q = repo(r)
const dirty = q.changedFiles.filter(x => !x.startsWith('shadow-docs/'))
if (dirty.length) throw Error('DIRTY_WORKTREE')
try { git(r, ['fetch', 'origin', '--prune']) } catch { ext('GIT_FETCH_FAILED') }
if (dirty.length) throw err('DIRTY_WORKTREE')
try { git(r, ['fetch', 'origin', '--prune'], { timeout: 120000 }) } catch { ext('GIT_FETCH_FAILED') }
const up = `origin/${b.data.baseBranch || q.branch}`, to = git(r, ['rev-parse', up])
try { git(r, ['merge-base', '--is-ancestor', q.head, to]) } catch { throw Error('SYNC_NOT_FAST_FORWARD') }
try { git(r, ['merge-base', '--is-ancestor', q.head, to]) } catch { throw err('SYNC_NOT_FAST_FORWARD') }
return { name: n, from: q.head, to, upstream: up }
}

Expand Down
Loading
Loading