tha-cli 0.0.0-stage → 0.1.0

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.
Files changed (52) hide show
  1. package/ARCHITECTURE.md +193 -0
  2. package/LICENSE +21 -0
  3. package/README.md +408 -2
  4. package/package.json +42 -4
  5. package/tha/cli.mjs +116 -0
  6. package/tha/commands/adopt.mjs +119 -0
  7. package/tha/commands/check.mjs +226 -0
  8. package/tha/commands/diff.mjs +70 -0
  9. package/tha/commands/init.mjs +262 -0
  10. package/tha/commands/remove.mjs +328 -0
  11. package/tha/commands/sync.mjs +826 -0
  12. package/tha/config/field.mjs +178 -0
  13. package/tha/config/load.mjs +188 -0
  14. package/tha/config/manifest.mjs +75 -0
  15. package/tha/config/paths.mjs +124 -0
  16. package/tha/config/project.mjs +52 -0
  17. package/tha/config/rules.mjs +85 -0
  18. package/tha/config/schema.mjs +306 -0
  19. package/tha/config/template.mjs +78 -0
  20. package/tha/generate/domain/api.mjs +67 -0
  21. package/tha/generate/domain/context.mjs +76 -0
  22. package/tha/generate/domain/detail.mjs +35 -0
  23. package/tha/generate/domain/entry.mjs +91 -0
  24. package/tha/generate/domain/executors.mjs +93 -0
  25. package/tha/generate/domain/form.mjs +34 -0
  26. package/tha/generate/domain/index.mjs +72 -0
  27. package/tha/generate/domain/permissions.mjs +35 -0
  28. package/tha/generate/domain/store.mjs +68 -0
  29. package/tha/generate/domain/view.mjs +29 -0
  30. package/tha/generate/global.mjs +111 -0
  31. package/tha/generate/header.mjs +29 -0
  32. package/tha/generate/names.mjs +45 -0
  33. package/tha/generate/plan.mjs +41 -0
  34. package/tha/kit/ThaCrudPage.vue +344 -0
  35. package/tha/kit/ThaDetail.vue +146 -0
  36. package/tha/kit/ThaForm.vue +194 -0
  37. package/tha/kit/request.ts +30 -0
  38. package/tha/kit/transport.ts +11 -0
  39. package/tha/utils/args.mjs +59 -0
  40. package/tha/utils/compare.mjs +38 -0
  41. package/tha/utils/files.mjs +175 -0
  42. package/tha/utils/format.mjs +23 -0
  43. package/tha/utils/output.mjs +79 -0
  44. package/tha/utils/style.mjs +147 -0
  45. package/tha/utils/ts.mjs +73 -0
  46. package/tha/verify/ast.mjs +143 -0
  47. package/tha/verify/boundary.mjs +219 -0
  48. package/tha/verify/contract.mjs +186 -0
  49. package/tha/verify/openapi.mjs +345 -0
  50. package/tha/verify/rules.mjs +91 -0
  51. package/tha/verify/scan.mjs +59 -0
  52. 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
+ }