tha-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +193 -0
- package/LICENSE +21 -0
- package/README.md +406 -2
- package/package.json +42 -4
- package/tha/cli.mjs +116 -0
- package/tha/commands/adopt.mjs +119 -0
- package/tha/commands/check.mjs +226 -0
- package/tha/commands/diff.mjs +70 -0
- package/tha/commands/init.mjs +262 -0
- package/tha/commands/remove.mjs +328 -0
- package/tha/commands/sync.mjs +826 -0
- package/tha/config/field.mjs +178 -0
- package/tha/config/load.mjs +188 -0
- package/tha/config/manifest.mjs +75 -0
- package/tha/config/paths.mjs +124 -0
- package/tha/config/project.mjs +52 -0
- package/tha/config/rules.mjs +85 -0
- package/tha/config/schema.mjs +306 -0
- package/tha/config/template.mjs +78 -0
- package/tha/generate/domain/api.mjs +67 -0
- package/tha/generate/domain/context.mjs +76 -0
- package/tha/generate/domain/detail.mjs +35 -0
- package/tha/generate/domain/entry.mjs +91 -0
- package/tha/generate/domain/executors.mjs +93 -0
- package/tha/generate/domain/form.mjs +34 -0
- package/tha/generate/domain/index.mjs +72 -0
- package/tha/generate/domain/permissions.mjs +35 -0
- package/tha/generate/domain/store.mjs +68 -0
- package/tha/generate/domain/view.mjs +29 -0
- package/tha/generate/global.mjs +111 -0
- package/tha/generate/header.mjs +29 -0
- package/tha/generate/names.mjs +45 -0
- package/tha/generate/plan.mjs +41 -0
- package/tha/kit/ThaCrudPage.vue +344 -0
- package/tha/kit/ThaDetail.vue +146 -0
- package/tha/kit/ThaForm.vue +194 -0
- package/tha/kit/request.ts +30 -0
- package/tha/kit/transport.ts +11 -0
- package/tha/utils/args.mjs +59 -0
- package/tha/utils/compare.mjs +38 -0
- package/tha/utils/files.mjs +175 -0
- package/tha/utils/format.mjs +23 -0
- package/tha/utils/output.mjs +79 -0
- package/tha/utils/style.mjs +147 -0
- package/tha/utils/ts.mjs +73 -0
- package/tha/verify/ast.mjs +143 -0
- package/tha/verify/boundary.mjs +219 -0
- package/tha/verify/contract.mjs +186 -0
- package/tha/verify/openapi.mjs +345 -0
- package/tha/verify/rules.mjs +91 -0
- package/tha/verify/scan.mjs +59 -0
- package/tha.mjs +22 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
import * as readline from 'node:readline/promises'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { stdin, stdout } from 'node:process'
|
|
5
|
+
import { loadProject } from '../config/project.mjs'
|
|
6
|
+
import { relPath, absPath } from '../config/paths.mjs'
|
|
7
|
+
import { manualPaths } from '../config/manifest.mjs'
|
|
8
|
+
import { buildPlan } from '../generate/plan.mjs'
|
|
9
|
+
import { comparePlan } from '../utils/compare.mjs'
|
|
10
|
+
import { parseArgs } from '../utils/args.mjs'
|
|
11
|
+
import {
|
|
12
|
+
exists,
|
|
13
|
+
generatedHeader,
|
|
14
|
+
isGenerated,
|
|
15
|
+
isEmptyDir,
|
|
16
|
+
listDirectories,
|
|
17
|
+
listFiles,
|
|
18
|
+
read,
|
|
19
|
+
removeDir,
|
|
20
|
+
removeFile,
|
|
21
|
+
splitGenerated,
|
|
22
|
+
writeAll
|
|
23
|
+
} from '../utils/files.mjs'
|
|
24
|
+
import { formatWritten } from '../utils/format.mjs'
|
|
25
|
+
import { printDiff, printErrors, printHeader } from '../utils/output.mjs'
|
|
26
|
+
import { glyph, ui } from '../utils/style.mjs'
|
|
27
|
+
|
|
28
|
+
// 全局生成物:不属于任何单个模块,由所有模块共同决定
|
|
29
|
+
function globalFileSet(dirs) {
|
|
30
|
+
return new Set([
|
|
31
|
+
relPath(dirs, 'router', 'routes.generated.ts'),
|
|
32
|
+
relPath(dirs, 'layouts', 'menu.generated.ts')
|
|
33
|
+
])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runSync({ root, args }) {
|
|
37
|
+
const { flags, positional } = parseArgs(args, {
|
|
38
|
+
boolean: ['json', 'help', 'force', 'dry-run', 'yes', 'all', 'prune', 'no-format']
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (flags.help) {
|
|
42
|
+
printSyncHelp()
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const moduleName = positional[0]?.trim()
|
|
47
|
+
|
|
48
|
+
if (flags.all && moduleName) {
|
|
49
|
+
throw new Error('--all and a module name are exclusive (--all 不能和模块名一起用)')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const project = loadProject(root)
|
|
53
|
+
|
|
54
|
+
if (project.errors.length > 0) {
|
|
55
|
+
if (flags.json) {
|
|
56
|
+
console.log(JSON.stringify({ ok: false, errors: project.errors }, null, 2))
|
|
57
|
+
} else {
|
|
58
|
+
printErrors(project.errors)
|
|
59
|
+
}
|
|
60
|
+
process.exitCode = 1
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --prune 隐含全量:先清理残留,再重新生成
|
|
65
|
+
if (flags.prune) flags.all = true
|
|
66
|
+
|
|
67
|
+
// 不带模块、也没 --all:只列模块,一个文件都不写
|
|
68
|
+
if (!moduleName && !flags.all) {
|
|
69
|
+
printModuleList(project.domains, flags)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const target = moduleName
|
|
74
|
+
? project.domains.find((domain) => domain.name === moduleName)
|
|
75
|
+
: null
|
|
76
|
+
|
|
77
|
+
if (moduleName && !target) {
|
|
78
|
+
throw new Error(`没有模块「${moduleName}」。可用:${project.domains.map((domain) => domain.name).join(' / ')}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const allEntries = comparePlan(buildPlan(project.domains, root, project.dirs), root, manualPaths(root))
|
|
82
|
+
const entries = moduleName
|
|
83
|
+
? allEntries.filter((entry) => isTargetFile(entry.path, moduleName, project.dirs))
|
|
84
|
+
: allEntries
|
|
85
|
+
|
|
86
|
+
const configChanges = target ? readConfigChanges(root, target) : null
|
|
87
|
+
const residue = flags.prune ? findResidue(root, project.domains, project.dirs) : []
|
|
88
|
+
|
|
89
|
+
if (flags['dry-run']) {
|
|
90
|
+
if (flags.json) {
|
|
91
|
+
console.log(
|
|
92
|
+
JSON.stringify(
|
|
93
|
+
{
|
|
94
|
+
ok: true,
|
|
95
|
+
dryRun: true,
|
|
96
|
+
mode: moduleName ? 'one' : 'all',
|
|
97
|
+
module: moduleName ?? null,
|
|
98
|
+
configChanges,
|
|
99
|
+
prune: residue.map((item) => item.rel),
|
|
100
|
+
skippedPreview: previewSkipped(entries, project.domains, moduleName, flags.force, project.dirs),
|
|
101
|
+
files: entries.map((entry) => ({ path: entry.path, status: entry.status }))
|
|
102
|
+
},
|
|
103
|
+
null,
|
|
104
|
+
2
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
} else {
|
|
108
|
+
if (residue.length > 0) printResidue(residue)
|
|
109
|
+
printChanges(configChanges, entries, {
|
|
110
|
+
skipped: previewSkipped(entries, project.domains, moduleName, flags.force, project.dirs)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- 先用 prune 清掉「模块删了,生成物还留着」的残留 ----
|
|
117
|
+
let pruned = []
|
|
118
|
+
|
|
119
|
+
if (residue.length > 0) {
|
|
120
|
+
if (!flags.json) printResidue(residue)
|
|
121
|
+
|
|
122
|
+
if (!flags.yes && !flags.json) {
|
|
123
|
+
const ok = await confirm(`${residue.length} leftovers to delete, delete them too? (有 ${residue.length} 个文件该删了,一起删掉?) [y/N] `)
|
|
124
|
+
if (!ok) throw new Error('prune cancelled, nothing deleted (已取消,没有删除残留,本次也没有生成)')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const item of residue) {
|
|
128
|
+
if (item.kind === 'dir') removeDir(path.join(root, item.rel))
|
|
129
|
+
else removeFile(path.join(root, item.rel))
|
|
130
|
+
}
|
|
131
|
+
pruned = residue.map((item) => item.rel)
|
|
132
|
+
|
|
133
|
+
// 文件删了,空目录还留着(src/pages/tmp、domains/tmp2/executors)→ 一路清到最近的非空目录
|
|
134
|
+
for (const item of residue) {
|
|
135
|
+
pruneEmptyDirs(path.dirname(path.join(root, item.rel)), root)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!flags.json) {
|
|
139
|
+
console.log(` ${ui.success(glyph.ok)} ${ui.label(`已删掉 ${pruned.length} 个残留文件`, `${pruned.length} leftovers removed`)}`)
|
|
140
|
+
console.log('')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --prune 是「清理残留」的显式动作:模块目录已经空掉的,也一起收掉
|
|
145
|
+
// (上一次 prune 只删了文件、留下空壳的情况,这次不该再留着)
|
|
146
|
+
if (flags.prune) pruneEmptyModuleDirs(root, project.dirs)
|
|
147
|
+
|
|
148
|
+
// ---- 计算写入计划:全量按模块隔离,一个模块冲突不拖垮其他模块 ----
|
|
149
|
+
const plan = []
|
|
150
|
+
const skipped = []
|
|
151
|
+
|
|
152
|
+
if (moduleName) {
|
|
153
|
+
// 单模块:保持旧语义——这一模块(含全局文件)有冲突就整体停下
|
|
154
|
+
const blocked = entries.filter(isBlocked)
|
|
155
|
+
|
|
156
|
+
if (blocked.length > 0 && !flags.force) {
|
|
157
|
+
printBlockedJsonOrText(blocked, flags)
|
|
158
|
+
process.exitCode = 1
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
plan.push(...entries.filter((entry) => isWritable(entry) || (flags.force && isBlocked(entry))))
|
|
163
|
+
} else {
|
|
164
|
+
const { groups, globalEntries } = groupEntries(entries, project.domains, project.dirs)
|
|
165
|
+
|
|
166
|
+
for (const group of groups) {
|
|
167
|
+
const blocked = group.entries.filter(isBlocked)
|
|
168
|
+
|
|
169
|
+
if (blocked.length > 0 && !flags.force) {
|
|
170
|
+
skipped.push({ module: group.domain.name, blocked })
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
plan.push(
|
|
175
|
+
...group.entries.filter((entry) => isWritable(entry) || (flags.force && isBlocked(entry)))
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const globalBlocked = globalEntries.filter(isBlocked)
|
|
180
|
+
|
|
181
|
+
if (globalBlocked.length > 0 && !flags.force) {
|
|
182
|
+
skipped.push({ module: '全局路由/菜单', blocked: globalBlocked })
|
|
183
|
+
} else {
|
|
184
|
+
plan.push(
|
|
185
|
+
...globalEntries.filter((entry) => isWritable(entry) || (flags.force && isBlocked(entry)))
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (plan.length === 0 && skipped.length === 0 && pruned.length === 0) {
|
|
191
|
+
if (flags.json) {
|
|
192
|
+
console.log(JSON.stringify({ ok: true, written: [], pruned: [] }, null, 2))
|
|
193
|
+
} else {
|
|
194
|
+
console.log('')
|
|
195
|
+
console.log(` ${ui.success(glyph.ok)} ${ui.label('文件都是最新的,没有需要生成的内容', 'All files are up to date')}`)
|
|
196
|
+
console.log('')
|
|
197
|
+
}
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!flags.json) {
|
|
202
|
+
printChanges(configChanges, entries, { skipped })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!flags.yes && !flags.json) {
|
|
206
|
+
if (!stdin.isTTY) {
|
|
207
|
+
console.error('Not a terminal, nothing written. Add --yes to write. (现在不是终端,没写入。要写入请加 --yes。)')
|
|
208
|
+
process.exitCode = 1
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const scope = moduleName ? `模块 ${moduleName}` : `全部模块(${project.domains.length} 个)`
|
|
213
|
+
const ok = await confirm(`\nWrite these changes to ${scope}? (把这些变化写进 ${scope}?) [y/N] `)
|
|
214
|
+
if (!ok) {
|
|
215
|
+
console.log('Cancelled, nothing written (prune already applied). (已取消,没有写入,残留清理已生效)')
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 事务写入:全部落临时文件后再统一提交,失败不留半套
|
|
221
|
+
writeAll(plan)
|
|
222
|
+
|
|
223
|
+
// 格式化:项目装了 Prettier 才做,没有就跳过(不硬依赖)
|
|
224
|
+
const format = flags['no-format']
|
|
225
|
+
? { formatted: false, reason: 'disabled' }
|
|
226
|
+
: formatWritten(root, plan.map((entry) => entry.path))
|
|
227
|
+
|
|
228
|
+
if (flags.json) {
|
|
229
|
+
console.log(
|
|
230
|
+
JSON.stringify(
|
|
231
|
+
{
|
|
232
|
+
ok: skipped.length === 0,
|
|
233
|
+
mode: moduleName ? 'one' : 'all',
|
|
234
|
+
module: moduleName ?? null,
|
|
235
|
+
configChanges,
|
|
236
|
+
written: plan.map((entry) => entry.path),
|
|
237
|
+
pruned,
|
|
238
|
+
formatted: format.formatted,
|
|
239
|
+
skipped: skipped.map((group) => ({
|
|
240
|
+
module: group.module,
|
|
241
|
+
blocked: group.blocked.map((entry) => ({ path: entry.path, status: entry.status }))
|
|
242
|
+
}))
|
|
243
|
+
},
|
|
244
|
+
null,
|
|
245
|
+
2
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
if (skipped.length > 0) process.exitCode = 1
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
console.log('')
|
|
253
|
+
console.log(
|
|
254
|
+
` ${ui.success(glyph.ok)} ${ui.label(
|
|
255
|
+
`已生成 ${plan.length} 个文件`,
|
|
256
|
+
`${plan.length} files generated`
|
|
257
|
+
)}`
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if (skipped.length > 0) {
|
|
261
|
+
console.log(
|
|
262
|
+
` ${ui.warning(glyph.warn)} ${ui.label(
|
|
263
|
+
`跳过 ${skipped.length} 组:有文件挡住,没覆盖`,
|
|
264
|
+
`${skipped.length} groups skipped: conflicts left untouched`
|
|
265
|
+
)}`
|
|
266
|
+
)
|
|
267
|
+
for (const group of skipped) {
|
|
268
|
+
console.log(` ${ui.path(group.module)}`)
|
|
269
|
+
for (const entry of group.blocked) {
|
|
270
|
+
console.log(` ${ui.danger(glyph.warn)} ${ui.path(entry.path)}`)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
console.log(` ${ui.muted('To overwrite (确认覆盖请运行): tha sync --all --force')}`)
|
|
274
|
+
process.exitCode = 1
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (format.reason === 'prettier-not-installed') {
|
|
278
|
+
console.log(` ${ui.muted('prettier not installed, files written but not formatted (没装 Prettier,文件写了但没格式化)')}`)
|
|
279
|
+
} else if (format.reason === 'prettier-failed') {
|
|
280
|
+
console.log(` ${ui.muted('prettier failed, files written but not formatted (Prettier 执行失败,文件写了但没格式化)')}`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log('')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 不带模块时的「只列不写」:模块清单 + 下一步用法
|
|
288
|
+
* @param domains 已校验通过的模块
|
|
289
|
+
* @param flags 命令行开关(--json)
|
|
290
|
+
*/
|
|
291
|
+
function printModuleList(domains, flags) {
|
|
292
|
+
if (flags.json) {
|
|
293
|
+
console.log(
|
|
294
|
+
JSON.stringify(
|
|
295
|
+
{
|
|
296
|
+
ok: true,
|
|
297
|
+
mode: 'list',
|
|
298
|
+
modules: domains.map((domain) => ({
|
|
299
|
+
name: domain.name,
|
|
300
|
+
title: domain.title,
|
|
301
|
+
path: domain.path
|
|
302
|
+
}))
|
|
303
|
+
},
|
|
304
|
+
null,
|
|
305
|
+
2
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
printHeader('模块列表', 'Modules')
|
|
312
|
+
|
|
313
|
+
if (domains.length === 0) {
|
|
314
|
+
console.log(` ${ui.muted('No modules yet (还没有模块)')} ${ui.command('tha init <模块>')}`)
|
|
315
|
+
console.log('')
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const domain of domains) {
|
|
320
|
+
console.log(` ${ui.path(domain.name.padEnd(14))} ${ui.label(domain.title, domain.path)}`)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.log('')
|
|
324
|
+
console.log(` ${ui.muted('Nothing is written without a module (不带模块不会写文件)')}`)
|
|
325
|
+
console.log(` ${ui.bullet(`${ui.command('tha sync <模块>')} ${ui.muted('one module (只更新一个模块)')}`)}`)
|
|
326
|
+
console.log(` ${ui.bullet(`${ui.command('tha sync --all')} ${ui.muted('all modules (更新全部模块)')}`)}`)
|
|
327
|
+
console.log(` ${ui.bullet(`${ui.command('tha sync --all --prune')} ${ui.muted('remove leftovers (清理已删模块的残留)')}`)}`)
|
|
328
|
+
console.log('')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 找出「模块已删除、生成物还在」的残留(只认 CLI 写过的文件,手写文件不动)
|
|
333
|
+
* @param root 项目根
|
|
334
|
+
* @param domains 现存模块
|
|
335
|
+
* @returns [{ rel, kind: 'file' | 'dir' }]
|
|
336
|
+
*/
|
|
337
|
+
/**
|
|
338
|
+
* 从 dir 逐级向上删空目录,遇到非空或越出项目根就停。
|
|
339
|
+
* 只清 prune 留下的空壳,不碰任何还有文件的目录。
|
|
340
|
+
*/
|
|
341
|
+
function pruneEmptyDirs(dir, root) {
|
|
342
|
+
let current = dir
|
|
343
|
+
|
|
344
|
+
while (true) {
|
|
345
|
+
const rel = path.relative(root, current)
|
|
346
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return
|
|
347
|
+
if (!isEmptyDir(current)) return
|
|
348
|
+
removeDir(current)
|
|
349
|
+
current = path.dirname(current)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 递归清掉生成物目录(api / stores / domains / views)里的空目录,只保顶层本身。
|
|
355
|
+
*/
|
|
356
|
+
function pruneEmptyModuleDirs(root, dirs) {
|
|
357
|
+
for (const key of ['api', 'stores', 'domains', 'views']) {
|
|
358
|
+
const base = path.join(root, dirs[key])
|
|
359
|
+
if (exists(base)) sweeping(base, true)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function sweeping(dir, keepSelf) {
|
|
363
|
+
for (const entry of listDirectories(dir)) sweeping(path.join(dir, entry), false)
|
|
364
|
+
if (!keepSelf && isEmptyDir(dir)) removeDir(dir)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function findResidue(root, domains, dirs) {
|
|
369
|
+
const known = new Set(domains.map((domain) => domain.name))
|
|
370
|
+
const residue = []
|
|
371
|
+
const pushFile = (rel) => {
|
|
372
|
+
const full = path.join(root, rel)
|
|
373
|
+
if (exists(full) && isGenerated(read(full))) residue.push({ rel, kind: 'file' })
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const file of listFiles(absPath(root, dirs, 'api'), '.ts')) {
|
|
377
|
+
pushFile(relPath(dirs, 'api', file))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const dir of listDirectories(absPath(root, dirs, 'stores'))) {
|
|
381
|
+
if (known.has(dir)) continue
|
|
382
|
+
pushFile(relPath(dirs, 'stores', dir, 'index.ts'))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
for (const dir of listDirectories(absPath(root, dirs, 'views'))) {
|
|
386
|
+
if (known.has(dir)) continue
|
|
387
|
+
for (const file of listFiles(absPath(root, dirs, 'views', dir), '.vue')) {
|
|
388
|
+
pushFile(relPath(dirs, 'views', dir, file))
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for (const dir of listDirectories(absPath(root, dirs, 'domains'))) {
|
|
393
|
+
if (known.has(dir)) continue
|
|
394
|
+
const configRel = relPath(dirs, 'domains', dir, 'config.ts')
|
|
395
|
+
if (exists(path.join(root, configRel))) continue
|
|
396
|
+
|
|
397
|
+
// 只清「目录里全是生成物」的;含手写文件(中枢/自定义执行器)时,只清其中的生成物
|
|
398
|
+
const domainRel = relPath(dirs, 'domains', dir)
|
|
399
|
+
if (onlyGenerated(root, domainRel)) {
|
|
400
|
+
residue.push({ rel: domainRel, kind: 'dir' })
|
|
401
|
+
} else {
|
|
402
|
+
pushFile(relPath(dirs, 'domains', dir, 'context.ts'))
|
|
403
|
+
pushFile(relPath(dirs, 'domains', dir, 'executors/crud.generated.ts'))
|
|
404
|
+
pushFile(relPath(dirs, 'domains', dir, 'permissions.ts'))
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 过滤掉仍被现存模块引用的 api 文件(known 里没有名字的才可能是残留)
|
|
409
|
+
return residue.filter((item) => {
|
|
410
|
+
if (!item.rel.startsWith(`${dirs.api}/`)) return true
|
|
411
|
+
const name = item.rel.slice(dirs.api.length + 1).replace(/\.ts$/, '')
|
|
412
|
+
return !known.has(name)
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 目录是否只含生成物(递归):空目录也算
|
|
418
|
+
*/
|
|
419
|
+
function onlyGenerated(root, rel) {
|
|
420
|
+
const walk = (dir) => {
|
|
421
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
422
|
+
for (const entry of entries) {
|
|
423
|
+
const full = path.join(dir, entry.name)
|
|
424
|
+
if (entry.isDirectory()) {
|
|
425
|
+
if (!walk(full)) return false
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
428
|
+
if (!isGenerated(read(full))) return false
|
|
429
|
+
}
|
|
430
|
+
return true
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return walk(path.join(root, rel))
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* dry-run 时的跳过预告:不写文件,但要先说清哪些模块会被冲突挡住
|
|
438
|
+
* @param entries 待比较的条目
|
|
439
|
+
* @param domains 全部模块
|
|
440
|
+
* @param moduleName 单模块名(有值时只算该模块)
|
|
441
|
+
* @param force 是否 --force(强制覆盖时不会有跳过)
|
|
442
|
+
*/
|
|
443
|
+
function previewSkipped(entries, domains, moduleName, force, dirs) {
|
|
444
|
+
if (force) return []
|
|
445
|
+
|
|
446
|
+
if (moduleName) {
|
|
447
|
+
const blocked = entries.filter(isBlocked)
|
|
448
|
+
return blocked.length > 0 ? [{ module: moduleName, blocked }] : []
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const { groups, globalEntries } = groupEntries(entries, domains, dirs)
|
|
452
|
+
const skipped = []
|
|
453
|
+
|
|
454
|
+
for (const group of groups) {
|
|
455
|
+
const blocked = group.entries.filter(isBlocked)
|
|
456
|
+
if (blocked.length > 0) skipped.push({ module: group.domain.name, blocked })
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const globalBlocked = globalEntries.filter(isBlocked)
|
|
460
|
+
if (globalBlocked.length > 0) {
|
|
461
|
+
skipped.push({ module: '全局路由/菜单', blocked: globalBlocked })
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return skipped
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function groupEntries(entries, domains, dirs) {
|
|
468
|
+
const globalFiles = globalFileSet(dirs)
|
|
469
|
+
const globalEntries = entries.filter((entry) => globalFiles.has(entry.path))
|
|
470
|
+
const groups = domains.map((domain) => ({
|
|
471
|
+
domain,
|
|
472
|
+
entries: entries.filter(
|
|
473
|
+
(entry) => !globalFiles.has(entry.path) && belongsToModule(entry.path, domain.name, dirs)
|
|
474
|
+
)
|
|
475
|
+
}))
|
|
476
|
+
|
|
477
|
+
return { groups, globalEntries }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function belongsToModule(file, name, dirs) {
|
|
481
|
+
return (
|
|
482
|
+
file === relPath(dirs, 'api', `${name}.ts`) ||
|
|
483
|
+
file.startsWith(`${relPath(dirs, 'stores', name)}/`) ||
|
|
484
|
+
file.startsWith(`${relPath(dirs, 'domains', name)}/`) ||
|
|
485
|
+
file.startsWith(`${relPath(dirs, 'views', name)}/`)
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isBlocked(entry) {
|
|
490
|
+
return entry.status === 'conflict' || entry.status === 'edited'
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function isWritable(entry) {
|
|
494
|
+
return entry.status === 'add' || entry.status === 'update'
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function printBlockedJsonOrText(blocked, flags) {
|
|
498
|
+
if (flags.json) {
|
|
499
|
+
console.log(
|
|
500
|
+
JSON.stringify(
|
|
501
|
+
{
|
|
502
|
+
ok: false,
|
|
503
|
+
blocked: blocked.map((entry) => ({ path: entry.path, status: entry.status }))
|
|
504
|
+
},
|
|
505
|
+
null,
|
|
506
|
+
2
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
printHeader('生成结果', 'Sync')
|
|
513
|
+
printBlocked(blocked)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function printResidue(residue) {
|
|
517
|
+
console.log('')
|
|
518
|
+
console.log(
|
|
519
|
+
` ${ui.warning(glyph.warn)} ${ui.label(
|
|
520
|
+
`发现 ${residue.length} 个文件该删了(模块已经删掉,它的文件还在)`,
|
|
521
|
+
`${residue.length} leftovers found`
|
|
522
|
+
)}`
|
|
523
|
+
)
|
|
524
|
+
for (const item of residue) {
|
|
525
|
+
console.log(` ${ui.danger(glyph.warn)} ${ui.path(item.rel)}`)
|
|
526
|
+
}
|
|
527
|
+
console.log('')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function printChanges(configChanges, entries, options = {}) {
|
|
531
|
+
printHeader('生成结果', 'Sync')
|
|
532
|
+
|
|
533
|
+
if (configChanges) {
|
|
534
|
+
printConfigChanges(configChanges)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
printDiff(entries, { header: false })
|
|
538
|
+
|
|
539
|
+
const updates = entries.filter((entry) => entry.status === 'update')
|
|
540
|
+
|
|
541
|
+
if (updates.length > 0) {
|
|
542
|
+
console.log(` ${ui.label('文件内容变化', 'File changes')}`)
|
|
543
|
+
for (const entry of updates) {
|
|
544
|
+
printFileDiff(entry)
|
|
545
|
+
}
|
|
546
|
+
console.log('')
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (options.skipped?.length > 0) {
|
|
550
|
+
console.log(
|
|
551
|
+
` ${ui.warning(glyph.warn)} ${ui.label(
|
|
552
|
+
'这些模块会被跳过(有文件挡住)',
|
|
553
|
+
'These modules will be skipped (files in the way)'
|
|
554
|
+
)}`
|
|
555
|
+
)
|
|
556
|
+
for (const group of options.skipped) {
|
|
557
|
+
console.log(` ${ui.path(group.module)}`)
|
|
558
|
+
for (const entry of group.blocked) {
|
|
559
|
+
console.log(` ${ui.danger(glyph.warn)} ${ui.path(entry.path)}`)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
console.log('')
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function printConfigChanges(changes) {
|
|
567
|
+
console.log(` ${ui.label('配置变化', 'Config changes')}`)
|
|
568
|
+
|
|
569
|
+
if (!changes.available) {
|
|
570
|
+
console.log(` ${ui.muted('no previous config recorded, comparing files only (没有上次的配置记录,只能看文件变化)')}`)
|
|
571
|
+
console.log('')
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (changes.rows.length === 0) {
|
|
576
|
+
console.log(` ${ui.muted('No config changes (配置没有变化)')}`)
|
|
577
|
+
console.log('')
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
for (const row of changes.rows) {
|
|
582
|
+
console.log(
|
|
583
|
+
` ${ui.path(row.key)}: ${ui.danger(String(row.old))} → ${ui.success(String(row.new))}`
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
console.log('')
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function printFileDiff(entry) {
|
|
590
|
+
const oldContent = read(entry.target)
|
|
591
|
+
const before = splitGenerated(oldContent)?.body ?? oldContent
|
|
592
|
+
const after = splitGenerated(entry.content)?.body ?? entry.content
|
|
593
|
+
const lines = lineDiff(before, after)
|
|
594
|
+
const visible = hunks(lines)
|
|
595
|
+
|
|
596
|
+
console.log(` ${ui.warning(glyph.update)} ${ui.path(entry.path)}`)
|
|
597
|
+
if (visible.length === 0) {
|
|
598
|
+
console.log(` ${ui.muted('config changed only (只是配置变了,生成的代码没变)')}`)
|
|
599
|
+
}
|
|
600
|
+
for (const line of visible) {
|
|
601
|
+
if (line.type === '@@') {
|
|
602
|
+
console.log(` ${ui.muted('@@')}`)
|
|
603
|
+
continue
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const mark = line.type === '+' ? '+' : line.type === '-' ? '-' : ' '
|
|
607
|
+
const paint =
|
|
608
|
+
line.type === '+' ? ui.success : line.type === '-' ? ui.danger : ui.muted
|
|
609
|
+
console.log(` ${paint(`${mark} ${line.text}`)}`)
|
|
610
|
+
}
|
|
611
|
+
console.log('')
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function printBlocked(blocked) {
|
|
615
|
+
console.error(` ${ui.danger(glyph.err)} ${ui.label('没有覆盖', 'Not overwritten')}`)
|
|
616
|
+
console.error(` ${ui.muted('These files need confirmation (这些文件需要你确认):')}`)
|
|
617
|
+
|
|
618
|
+
const edited = blocked.filter((entry) => entry.status === 'edited')
|
|
619
|
+
const existing = blocked.filter((entry) => entry.status === 'conflict')
|
|
620
|
+
|
|
621
|
+
if (existing.length > 0) {
|
|
622
|
+
console.error(` ${ui.muted(`${existing.length} hand-written files, not generated by CLI (手写文件,不是 CLI 生成):`)}`)
|
|
623
|
+
for (const entry of existing) {
|
|
624
|
+
console.error(` ${ui.danger(glyph.warn)} ${ui.path(entry.path)}`)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (edited.length > 0) {
|
|
628
|
+
console.error(` ${ui.muted(`${edited.length} files edited by hand (被手动修改过):`)}`)
|
|
629
|
+
for (const entry of edited) {
|
|
630
|
+
console.error(` ${ui.danger(glyph.warn)} ${ui.path(entry.path)}`)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
console.error(` ${ui.muted('To overwrite (要覆盖请运行): tha sync --force')}`)
|
|
635
|
+
console.error(` ${ui.muted('handwritten executors are untouched (executors/ 里的手写文件不会被改动)')}`)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function confirm(question) {
|
|
639
|
+
const rl = readline.createInterface({ input: stdin, output: stdout })
|
|
640
|
+
try {
|
|
641
|
+
const answer = (await rl.question(question)).trim().toLowerCase()
|
|
642
|
+
return answer === 'y' || answer === 'yes'
|
|
643
|
+
} finally {
|
|
644
|
+
rl.close()
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function readConfigChanges(root, target) {
|
|
649
|
+
const executorFile = path.join(
|
|
650
|
+
root,
|
|
651
|
+
'src',
|
|
652
|
+
'domains',
|
|
653
|
+
target.name,
|
|
654
|
+
'executors',
|
|
655
|
+
'crud.generated.ts'
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
let oldConfig = null
|
|
659
|
+
try {
|
|
660
|
+
const header = generatedHeader(read(executorFile))
|
|
661
|
+
oldConfig = header?.config ?? null
|
|
662
|
+
} catch {
|
|
663
|
+
oldConfig = null
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (!oldConfig) {
|
|
667
|
+
return { available: false, rows: [] }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
available: true,
|
|
672
|
+
rows: diffValues(oldConfig, snapshot(target))
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function snapshot(domain) {
|
|
677
|
+
return {
|
|
678
|
+
name: domain.name,
|
|
679
|
+
title: domain.title,
|
|
680
|
+
path: domain.path,
|
|
681
|
+
api: domain.api,
|
|
682
|
+
fields: domain.fields,
|
|
683
|
+
list: domain.list,
|
|
684
|
+
crud: domain.crud,
|
|
685
|
+
permissions: domain.permissions
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function diffValues(oldValue, newValue, prefix = '') {
|
|
690
|
+
const rows = []
|
|
691
|
+
const keys = new Set([
|
|
692
|
+
...Object.keys(oldValue ?? {}),
|
|
693
|
+
...Object.keys(newValue ?? {})
|
|
694
|
+
])
|
|
695
|
+
|
|
696
|
+
for (const key of keys) {
|
|
697
|
+
const at = prefix ? `${prefix}.${key}` : key
|
|
698
|
+
const before = oldValue?.[key]
|
|
699
|
+
const after = newValue?.[key]
|
|
700
|
+
|
|
701
|
+
if (isObject(before) && isObject(after)) {
|
|
702
|
+
rows.push(...diffValues(before, after, at))
|
|
703
|
+
continue
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
|
707
|
+
rows.push({
|
|
708
|
+
key: at,
|
|
709
|
+
old: display(before),
|
|
710
|
+
new: display(after)
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return rows
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function display(value) {
|
|
719
|
+
if (value === undefined) return '(无)'
|
|
720
|
+
if (typeof value === 'string') return JSON.stringify(value)
|
|
721
|
+
return JSON.stringify(value)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function isObject(value) {
|
|
725
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function hunks(lines, context = 2) {
|
|
729
|
+
const keep = new Set()
|
|
730
|
+
|
|
731
|
+
lines.forEach((line, index) => {
|
|
732
|
+
if (line.type === ' ') return
|
|
733
|
+
for (let i = Math.max(0, index - context); i <= Math.min(lines.length - 1, index + context); i += 1) {
|
|
734
|
+
keep.add(i)
|
|
735
|
+
}
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
if (keep.size === 0) return []
|
|
739
|
+
|
|
740
|
+
const result = []
|
|
741
|
+
let previous = -1
|
|
742
|
+
|
|
743
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
744
|
+
if (!keep.has(i)) continue
|
|
745
|
+
if (previous !== -1 && i > previous + 1) result.push({ type: '@@', text: '' })
|
|
746
|
+
result.push(lines[i])
|
|
747
|
+
previous = i
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return result
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function lineDiff(before, after) {
|
|
754
|
+
const a = before.split('\n')
|
|
755
|
+
const b = after.split('\n')
|
|
756
|
+
const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0))
|
|
757
|
+
|
|
758
|
+
for (let i = a.length - 1; i >= 0; i -= 1) {
|
|
759
|
+
for (let j = b.length - 1; j >= 0; j -= 1) {
|
|
760
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const lines = []
|
|
765
|
+
let i = 0
|
|
766
|
+
let j = 0
|
|
767
|
+
|
|
768
|
+
while (i < a.length && j < b.length) {
|
|
769
|
+
if (a[i] === b[j]) {
|
|
770
|
+
lines.push({ type: ' ', text: a[i] })
|
|
771
|
+
i += 1
|
|
772
|
+
j += 1
|
|
773
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
774
|
+
lines.push({ type: '-', text: a[i] })
|
|
775
|
+
i += 1
|
|
776
|
+
} else {
|
|
777
|
+
lines.push({ type: '+', text: b[j] })
|
|
778
|
+
j += 1
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
while (i < a.length) {
|
|
783
|
+
lines.push({ type: '-', text: a[i] })
|
|
784
|
+
i += 1
|
|
785
|
+
}
|
|
786
|
+
while (j < b.length) {
|
|
787
|
+
lines.push({ type: '+', text: b[j] })
|
|
788
|
+
j += 1
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return lines
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function isTargetFile(file, name, dirs) {
|
|
795
|
+
if (globalFileSet(dirs).has(file)) return true
|
|
796
|
+
|
|
797
|
+
return (
|
|
798
|
+
file.startsWith(`${relPath(dirs, 'api', name)}.`) ||
|
|
799
|
+
file.startsWith(`${relPath(dirs, 'stores', name)}/`) ||
|
|
800
|
+
file.startsWith(`${relPath(dirs, 'domains', name)}/`) ||
|
|
801
|
+
file.startsWith(`${relPath(dirs, 'views', name)}/`)
|
|
802
|
+
)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function printSyncHelp() {
|
|
806
|
+
console.log('')
|
|
807
|
+
console.log(` ${ui.heading('生成结果', 'sync · generate pages')}`)
|
|
808
|
+
console.log(` ${ui.rule(50)}`)
|
|
809
|
+
console.log('')
|
|
810
|
+
console.log(` ${ui.label('用法', 'Usage')}`)
|
|
811
|
+
console.log(` ${ui.command('tha sync')} ${ui.muted('list modules (只列模块,不写文件)')}`)
|
|
812
|
+
console.log(` ${ui.command('tha sync <模块>')} [--force] [--dry-run] [--yes] [--json]`)
|
|
813
|
+
console.log(` ${ui.command('tha sync --all')} [--force] [--prune] [--dry-run] [--yes] [--json]`)
|
|
814
|
+
console.log('')
|
|
815
|
+
console.log(` ${ui.label('说明', 'Notes')}`)
|
|
816
|
+
console.log(` ${ui.bullet('one module (指定模块:只更新该模块 + 全局路由/菜单)')}`)
|
|
817
|
+
console.log(` ${ui.bullet('isolate modules (--all:更新全部模块;某个模块被挡住时只跳过它,不拖累其他)')}`)
|
|
818
|
+
console.log(` ${ui.bullet('remove leftovers (--prune:清理「模块已删、生成物还在」的残留后再全量生成)')}`)
|
|
819
|
+
console.log(` ${ui.bullet('generates 5 files: api, store, context, executors, view (生成 5 个文件:接口、状态、上下文、执行器、页面)')}`)
|
|
820
|
+
console.log(` ${ui.bullet('模块入口 index.ts 缺失时补一个骨架;已有的不覆盖')}`)
|
|
821
|
+
console.log(` ${ui.bullet('transactional writes (写入是事务的:任一失败不留半套文件)')}`)
|
|
822
|
+
console.log(` ${ui.bullet('项目装了 Prettier 会自动格式化;--no-format 关闭')}`)
|
|
823
|
+
console.log(` ${ui.bullet('--yes 跳过确认;--dry-run 只预览;--force 覆盖已有文件')}`)
|
|
824
|
+
console.log(` ${ui.bullet('disable color (关闭颜色)')}`)
|
|
825
|
+
console.log('')
|
|
826
|
+
}
|