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,262 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import * as readline from 'node:readline/promises'
|
|
3
|
+
import { stdin, stdout } from 'node:process'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { API_RE, NAME_RE, PATH_RE, normalizeUrlPath } from '../config/schema.mjs'
|
|
6
|
+
import { renderInitConfig } from '../config/template.mjs'
|
|
7
|
+
import { CONFIG_FILE, DEFAULT_DIRS, loadDirs, relPath } from '../config/paths.mjs'
|
|
8
|
+
import { parseArgs } from '../utils/args.mjs'
|
|
9
|
+
import { absolute, exists, read, writeAtomic } from '../utils/files.mjs'
|
|
10
|
+
import { glyph, ui } from '../utils/style.mjs'
|
|
11
|
+
import { printNextSteps } from '../utils/output.mjs'
|
|
12
|
+
|
|
13
|
+
// 生成物会 import 的基线文件:空项目里没有,init 时补上(不覆盖已有文件)
|
|
14
|
+
const BASELINE = [
|
|
15
|
+
['ThaCrudPage.vue', ['components', 'tha/ThaCrudPage.vue']],
|
|
16
|
+
['ThaDetail.vue', ['components', 'tha/ThaDetail.vue']],
|
|
17
|
+
['ThaForm.vue', ['components', 'tha/ThaForm.vue']],
|
|
18
|
+
['request.ts', ['utils', 'request.ts']],
|
|
19
|
+
['transport.ts', ['utils', 'transport.ts']]
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const KIT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'kit')
|
|
23
|
+
|
|
24
|
+
export async function runInit({ root, args }) {
|
|
25
|
+
const { flags, positional } = parseArgs(args, {
|
|
26
|
+
boolean: ['force', 'dry-run', 'json', 'help', 'bare', 'config'],
|
|
27
|
+
value: ['title', 'path', 'api']
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
if (flags.help) {
|
|
31
|
+
printInitHelp()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { dirs } = loadDirs(root)
|
|
36
|
+
|
|
37
|
+
const needPrompt = stdin.isTTY && !flags.json
|
|
38
|
+
const rl = needPrompt
|
|
39
|
+
? readline.createInterface({ input: stdin, output: stdout })
|
|
40
|
+
: null
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
let name = positional[0]?.trim()
|
|
44
|
+
|
|
45
|
+
if (!name && rl) {
|
|
46
|
+
name = (
|
|
47
|
+
await ask(rl, {
|
|
48
|
+
cn: '模块名',
|
|
49
|
+
en: 'Module',
|
|
50
|
+
hint: 'lowercase or camelCase, e.g. doc, userProfile (小写或小驼峰,例如 doc、userProfile)',
|
|
51
|
+
fallback: ''
|
|
52
|
+
})
|
|
53
|
+
).trim()
|
|
54
|
+
}
|
|
55
|
+
if (!name) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
'Missing module (请提供模块名)\nUsage (用法): tha init <module>\nExample (示例): tha init doc'
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
if (!NAME_RE.test(name)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Invalid module name (模块名不合法): "${name}"\nExpected (期望): 小写开头,例如 doc、userProfile\nExample (示例): doc、userProfile`
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const relativeConfig = relPath(dirs, 'domains', name, 'config.ts')
|
|
67
|
+
const configPath = absolute(root, relativeConfig)
|
|
68
|
+
if (exists(configPath) && !flags.force && !flags['dry-run']) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`${path.relative(root, configPath)} already exists (已存在)\nTo overwrite (覆盖请加): --force`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const title =
|
|
75
|
+
flags.title ??
|
|
76
|
+
(rl
|
|
77
|
+
? await ask(rl, {
|
|
78
|
+
cn: '标题',
|
|
79
|
+
en: 'Title',
|
|
80
|
+
hint: 'shown in page and menu (显示在页面和菜单上)',
|
|
81
|
+
fallback: name
|
|
82
|
+
})
|
|
83
|
+
: name)
|
|
84
|
+
const routePath = normalizeUrlPath(
|
|
85
|
+
flags.path ??
|
|
86
|
+
(rl
|
|
87
|
+
? await ask(rl, {
|
|
88
|
+
cn: '页面地址',
|
|
89
|
+
en: 'Page path',
|
|
90
|
+
hint: 'URL path shown in the browser, e.g. /doc (浏览器里的地址,例如 /doc)',
|
|
91
|
+
fallback: `/${name}`
|
|
92
|
+
})
|
|
93
|
+
: `/${name}`),
|
|
94
|
+
`/${name}`
|
|
95
|
+
)
|
|
96
|
+
const apiBase = normalizeUrlPath(
|
|
97
|
+
flags.api ??
|
|
98
|
+
(rl
|
|
99
|
+
? await ask(rl, {
|
|
100
|
+
cn: '接口地址',
|
|
101
|
+
en: 'API path',
|
|
102
|
+
hint: 'API prefix, e.g. /docs (接口根路径,例如 /docs)',
|
|
103
|
+
fallback: `/${name}`
|
|
104
|
+
})
|
|
105
|
+
: `/${name}`),
|
|
106
|
+
`/${name}`
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (!PATH_RE.test(routePath)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Invalid page path (页面地址不合法): "${routePath}"\nExpected (期望): 以 / 开头,可多级\nExample (示例): /doc、/system/doc`
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
if (!API_RE.test(apiBase)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Invalid API path (接口地址不合法): "${apiBase}"\nExpected (期望): 以 / 开头,可多级\nExample (示例): /docs、/system/docs`
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const template = renderInitConfig({
|
|
121
|
+
name,
|
|
122
|
+
title: title.trim() || name,
|
|
123
|
+
path: routePath,
|
|
124
|
+
apiBase
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
if (flags['dry-run']) {
|
|
128
|
+
if (flags.json) {
|
|
129
|
+
console.log(
|
|
130
|
+
JSON.stringify(
|
|
131
|
+
{ created: false, dryRun: true, path: relativeConfig, config: template },
|
|
132
|
+
null,
|
|
133
|
+
2
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
} else {
|
|
137
|
+
console.log(template)
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
writeAtomic(configPath, template)
|
|
143
|
+
|
|
144
|
+
// 空项目基线:生成物要 import 的组件与工具,缺了就补(已有的不动)
|
|
145
|
+
const baseline = flags.bare ? { created: [], skipped: [] } : ensureBaseline(root, dirs)
|
|
146
|
+
const dirsConfig = flags.config ? ensureDirsConfig(root) : null
|
|
147
|
+
|
|
148
|
+
if (flags.json) {
|
|
149
|
+
console.log(
|
|
150
|
+
JSON.stringify(
|
|
151
|
+
{
|
|
152
|
+
created: true,
|
|
153
|
+
path: relativeConfig,
|
|
154
|
+
baseline: baseline.created,
|
|
155
|
+
kept: baseline.skipped,
|
|
156
|
+
dirsConfig
|
|
157
|
+
},
|
|
158
|
+
null,
|
|
159
|
+
2
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log('')
|
|
166
|
+
console.log(` ${ui.success(glyph.ok)} ${ui.label('配置已创建', 'Config created')}`)
|
|
167
|
+
console.log(` ${ui.path(relativeConfig)}`)
|
|
168
|
+
|
|
169
|
+
if (baseline.created.length > 0) {
|
|
170
|
+
console.log('')
|
|
171
|
+
console.log(` ${ui.label('公共文件已补齐', 'Base files added')}`)
|
|
172
|
+
for (const file of baseline.created) console.log(` ${ui.path(file)}`)
|
|
173
|
+
console.log(` ${ui.muted('Delete them if your project already has these (生成出来的代码要用这些文件;已有自己的实现就删掉)')}`)
|
|
174
|
+
}
|
|
175
|
+
if (baseline.skipped.length > 0) {
|
|
176
|
+
console.log(` ${ui.muted(`already exists, not overwritten: ${baseline.skipped.join(', ')} (已存在、未覆盖)`)}`)
|
|
177
|
+
}
|
|
178
|
+
if (dirsConfig) {
|
|
179
|
+
console.log(` ${ui.muted(`目录映射示例已写入 ${dirsConfig},改完删掉即回默认`)}`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log('')
|
|
183
|
+
printNextSteps()
|
|
184
|
+
} finally {
|
|
185
|
+
rl?.close()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 补齐基线文件:只写不存在的,绝不覆盖
|
|
191
|
+
*/
|
|
192
|
+
function ensureBaseline(root, dirs) {
|
|
193
|
+
const created = []
|
|
194
|
+
const skipped = []
|
|
195
|
+
|
|
196
|
+
for (const [source, [key, ...rest]] of BASELINE) {
|
|
197
|
+
const targetRel = relPath(dirs, key, ...rest)
|
|
198
|
+
if (exists(absolute(root, targetRel))) {
|
|
199
|
+
skipped.push(targetRel)
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const from = path.join(KIT_DIR, source)
|
|
204
|
+
if (!exists(from)) continue
|
|
205
|
+
|
|
206
|
+
writeAtomic(absolute(root, targetRel), read(from))
|
|
207
|
+
created.push(targetRel)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { created, skipped }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* --config:写一份全默认值的 tha.config.json,方便直接改
|
|
215
|
+
*/
|
|
216
|
+
function ensureDirsConfig(root) {
|
|
217
|
+
const target = path.join(root, CONFIG_FILE)
|
|
218
|
+
if (exists(target)) return null
|
|
219
|
+
|
|
220
|
+
writeAtomic(target, `${JSON.stringify({ dirs: { ...DEFAULT_DIRS } }, null, 2)}\n`)
|
|
221
|
+
return CONFIG_FILE
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function ask(rl, { cn, en, hint, fallback }) {
|
|
225
|
+
const suffix = fallback ? ` ${ui.muted(`[${fallback}]`)}` : ''
|
|
226
|
+
const answer = (
|
|
227
|
+
await rl.question(
|
|
228
|
+
` ${ui.accent('?')} ${ui.label(cn, en)}${suffix}\n` +
|
|
229
|
+
` ${ui.muted(hint)}\n` +
|
|
230
|
+
` ${ui.muted('›')} `
|
|
231
|
+
)
|
|
232
|
+
).trim()
|
|
233
|
+
return answer || fallback || ''
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function printInitHelp() {
|
|
237
|
+
console.log('')
|
|
238
|
+
console.log(` ${ui.heading('初始化', 'init · create config')}`)
|
|
239
|
+
console.log(` ${ui.rule(40)}`)
|
|
240
|
+
console.log('')
|
|
241
|
+
console.log(` ${ui.label('用法', 'Usage')}`)
|
|
242
|
+
console.log(
|
|
243
|
+
` ${ui.command('tha init')} <模块> [--title 标题] [--path /页面地址] [--api /接口地址]`
|
|
244
|
+
)
|
|
245
|
+
console.log(` ${ui.muted(' [--force] [--bare] [--config] [--dry-run] [--json]')}`)
|
|
246
|
+
console.log('')
|
|
247
|
+
console.log(` ${ui.label('例子', 'Examples')}`)
|
|
248
|
+
console.log(` ${ui.muted('tha init doc')}`)
|
|
249
|
+
console.log(` ${ui.muted('tha init doc --title 文档 --path /doc --api /docs')}`)
|
|
250
|
+
console.log(` ${ui.muted('tha init doc --bare --dry-run')}`)
|
|
251
|
+
console.log('')
|
|
252
|
+
console.log(` ${ui.label('说明', 'Notes')}`)
|
|
253
|
+
console.log(` ${ui.bullet('config only (只创建模块 config.ts,不生成业务代码)')}`)
|
|
254
|
+
console.log(
|
|
255
|
+
` ${ui.bullet('base files (同时补公共文件:组件 tha/*.vue、utils/request.ts、utils/transport.ts)')}`
|
|
256
|
+
)
|
|
257
|
+
console.log(` ${ui.bullet('bare: config only (--bare 只写 config,不补公共文件)')}`)
|
|
258
|
+
console.log(` ${ui.bullet('write dirs example (--config 额外写一份 tha.config.json 目录映射示例)')}`)
|
|
259
|
+
console.log(` ${ui.bullet('run tha sync next (下一步运行 tha sync 生成标准增删改查页面)')}`)
|
|
260
|
+
console.log(` ${ui.bullet('disable color (关闭颜色)')}`)
|
|
261
|
+
console.log('')
|
|
262
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import * as readline from 'node:readline/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { stdin, stdout } from 'node:process'
|
|
4
|
+
import { loadProject } from '../config/project.mjs'
|
|
5
|
+
import { importSpec, relPath } from '../config/paths.mjs'
|
|
6
|
+
import { globalFiles } from '../generate/global.mjs'
|
|
7
|
+
import { comparePlan } from '../utils/compare.mjs'
|
|
8
|
+
import { parseArgs } from '../utils/args.mjs'
|
|
9
|
+
import {
|
|
10
|
+
absolute,
|
|
11
|
+
exists,
|
|
12
|
+
isGenerated,
|
|
13
|
+
isEmptyDir,
|
|
14
|
+
listFiles,
|
|
15
|
+
read,
|
|
16
|
+
removeDir,
|
|
17
|
+
removeFile,
|
|
18
|
+
writeAll
|
|
19
|
+
} from '../utils/files.mjs'
|
|
20
|
+
import { glyph, ui } from '../utils/style.mjs'
|
|
21
|
+
|
|
22
|
+
export async function runRemove({ root, args }) {
|
|
23
|
+
const { flags, positional } = parseArgs(args, {
|
|
24
|
+
boolean: ['json', 'help', 'dry-run', 'yes', 'purge']
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
if (flags.help) {
|
|
28
|
+
printRemoveHelp()
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const name = positional[0]?.trim()
|
|
33
|
+
if (!name) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'Missing module (请提供模块名)\nUsage (用法): tha remove <module>\nExample (示例): tha remove doc'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 目录映射先取一次:删除前后模块列表会变,但目录映射不变
|
|
40
|
+
const { dirs } = loadProject(root)
|
|
41
|
+
const configRel = relPath(dirs, 'domains', name, 'config.ts')
|
|
42
|
+
if (!exists(absolute(root, configRel))) {
|
|
43
|
+
throw new Error(`Module not found (没有模块): "${name}", ${configRel} does not exist`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const targets = collectTargets(root, name, flags.purge, dirs)
|
|
47
|
+
const kept = collectKept(root, name, flags.purge, dirs)
|
|
48
|
+
|
|
49
|
+
if (flags['dry-run']) {
|
|
50
|
+
if (flags.json) {
|
|
51
|
+
console.log(
|
|
52
|
+
JSON.stringify(
|
|
53
|
+
{
|
|
54
|
+
ok: true,
|
|
55
|
+
dryRun: true,
|
|
56
|
+
module: name,
|
|
57
|
+
remove: targets.map((target) => target.rel),
|
|
58
|
+
keep: kept
|
|
59
|
+
},
|
|
60
|
+
null,
|
|
61
|
+
2
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
} else {
|
|
65
|
+
printHeader(`删除模块 ${name}`, 'Remove')
|
|
66
|
+
printTargets(targets, kept)
|
|
67
|
+
}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!flags.yes && !flags.json) {
|
|
72
|
+
if (!stdin.isTTY) {
|
|
73
|
+
throw new Error('Not a terminal, nothing deleted. Add --yes to delete. (现在不是终端,没删。要删请加 --yes。)')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!(await confirm(`\nRemove ${name}? ${targets.length} files will be deleted. (要删 ${name} 吗?会删 ${targets.length} 个文件) [y/N] `))) {
|
|
77
|
+
console.log('Cancelled, nothing deleted. (已取消,没有删除。)')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const target of targets) {
|
|
83
|
+
if (target.kind === 'dir') removeDir(target.path)
|
|
84
|
+
else removeFile(target.path)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
cleanupEmptyDirs(root, name, dirs)
|
|
88
|
+
|
|
89
|
+
// 模块没了,全局路由/菜单必须跟着重算,否则旧路由会留在 generated 里
|
|
90
|
+
const project = loadProject(root)
|
|
91
|
+
const globals = comparePlan(globalFiles(project.domains, project.dirs), root).filter(
|
|
92
|
+
(entry) => entry.status === 'add' || entry.status === 'update'
|
|
93
|
+
)
|
|
94
|
+
writeAll(globals)
|
|
95
|
+
|
|
96
|
+
if (flags.json) {
|
|
97
|
+
console.log(
|
|
98
|
+
JSON.stringify(
|
|
99
|
+
{
|
|
100
|
+
ok: true,
|
|
101
|
+
module: name,
|
|
102
|
+
removed: targets.map((target) => target.rel),
|
|
103
|
+
keep: kept,
|
|
104
|
+
regenerated: globals.map((entry) => entry.path)
|
|
105
|
+
},
|
|
106
|
+
null,
|
|
107
|
+
2
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log('')
|
|
114
|
+
console.log(
|
|
115
|
+
` ${ui.success(glyph.ok)} ${ui.label(
|
|
116
|
+
`已删除 ${name}:${targets.length} 个文件`,
|
|
117
|
+
`${name} removed: ${targets.length} files`
|
|
118
|
+
)}`
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if (kept.length > 0) {
|
|
122
|
+
console.log(` ${ui.warning(glyph.warn)} ${ui.label('手写文件保留(默认不动)', 'Handwritten files kept')}`)
|
|
123
|
+
for (const file of kept) {
|
|
124
|
+
console.log(` ${ui.path(file)}`)
|
|
125
|
+
}
|
|
126
|
+
console.log(` ${ui.muted('To remove all (要连手写文件一起删): tha remove ' + name + ' --purge')}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 保留的手写文件如果还 import 刚删掉的生成物,项目会直接编译不过 —— 必须让人看见
|
|
130
|
+
const dangling = findDanglingImports(root, name, kept, dirs)
|
|
131
|
+
if (dangling.length > 0) {
|
|
132
|
+
console.log(
|
|
133
|
+
` ${ui.danger(glyph.warn)} ${ui.label(
|
|
134
|
+
'保留的文件还在引用刚删掉的代码,项目会编译不过',
|
|
135
|
+
'Kept files still import removed code'
|
|
136
|
+
)}`
|
|
137
|
+
)
|
|
138
|
+
for (const item of dangling) {
|
|
139
|
+
console.log(` ${ui.path(item.file)} ${ui.muted(`→ ${item.missing}`)}`)
|
|
140
|
+
}
|
|
141
|
+
console.log(` ${ui.muted('Remove the imports by hand, or (手动清掉这些引用,或): tha remove ' + name + ' --purge')}`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(` ${ui.muted('routes and menu regenerated (全局路由/菜单已重算)')}`)
|
|
145
|
+
console.log('')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 保留的手写文件里,是否还 import 了本次删除的生成物(只提示,不改文件)
|
|
150
|
+
*/
|
|
151
|
+
function findDanglingImports(root, name, kept, dirs) {
|
|
152
|
+
const contextRel = relPath(dirs, 'domains', name, 'context.ts')
|
|
153
|
+
const crudRel = relPath(dirs, 'domains', name, 'executors/crud.generated.ts')
|
|
154
|
+
const permissionsRel = relPath(dirs, 'domains', name, 'permissions.ts')
|
|
155
|
+
const apiRel = relPath(dirs, 'api', `${name}.ts`)
|
|
156
|
+
|
|
157
|
+
const candidates = [
|
|
158
|
+
{ marker: './context', missing: contextRel },
|
|
159
|
+
{ marker: '../context', missing: contextRel },
|
|
160
|
+
{ marker: './executors/crud.generated', missing: crudRel },
|
|
161
|
+
{ marker: '../executors/crud.generated', missing: crudRel },
|
|
162
|
+
{ marker: './permissions', missing: permissionsRel },
|
|
163
|
+
{ marker: '../permissions', missing: permissionsRel },
|
|
164
|
+
{ marker: importSpec(dirs, 'api', name), missing: apiRel }
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
const hits = []
|
|
168
|
+
const seen = new Set()
|
|
169
|
+
|
|
170
|
+
for (const file of kept) {
|
|
171
|
+
if (!file.endsWith('.ts')) continue
|
|
172
|
+
const text = read(absolute(root, file))
|
|
173
|
+
|
|
174
|
+
for (const item of candidates) {
|
|
175
|
+
if (exists(absolute(root, item.missing))) continue
|
|
176
|
+
if (!text.includes(`'${item.marker}'`) && !text.includes(`"${item.marker}"`)) continue
|
|
177
|
+
if (seen.has(file)) continue
|
|
178
|
+
seen.add(file)
|
|
179
|
+
hits.push({ file, missing: item.missing })
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return hits
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 模块的标准生成物(不含中枢 index.ts 与自定义执行器)
|
|
188
|
+
*/
|
|
189
|
+
function moduleFiles(name, dirs) {
|
|
190
|
+
return [
|
|
191
|
+
relPath(dirs, 'api', `${name}.ts`),
|
|
192
|
+
relPath(dirs, 'stores', name, 'index.ts'),
|
|
193
|
+
relPath(dirs, 'domains', name, 'context.ts'),
|
|
194
|
+
relPath(dirs, 'domains', name, 'executors/crud.generated.ts'),
|
|
195
|
+
relPath(dirs, 'domains', name, 'permissions.ts'),
|
|
196
|
+
relPath(dirs, 'views', name, 'Index.vue'),
|
|
197
|
+
relPath(dirs, 'views', name, 'Detail.vue'),
|
|
198
|
+
relPath(dirs, 'views', name, 'Form.vue')
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 默认只删「模块定义 config + 带生成头的生成物」;被手写替换过的文件一律保留。
|
|
204
|
+
* --purge 连手写文件 / 整个模块目录一起删。
|
|
205
|
+
*/
|
|
206
|
+
function collectTargets(root, name, purge, dirs) {
|
|
207
|
+
const targets = []
|
|
208
|
+
const add = (rel, kind) => {
|
|
209
|
+
const full = absolute(root, rel)
|
|
210
|
+
if (exists(full)) targets.push({ rel, path: full, kind })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (purge) {
|
|
214
|
+
add(relPath(dirs, 'api', `${name}.ts`), 'file')
|
|
215
|
+
add(relPath(dirs, 'stores', name), 'dir')
|
|
216
|
+
add(relPath(dirs, 'domains', name), 'dir')
|
|
217
|
+
add(relPath(dirs, 'views', name), 'dir')
|
|
218
|
+
return targets
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// config 是模块定义本身,删模块必删
|
|
222
|
+
add(relPath(dirs, 'domains', name, 'config.ts'), 'file')
|
|
223
|
+
|
|
224
|
+
for (const rel of moduleFiles(name, dirs)) {
|
|
225
|
+
const full = absolute(root, rel)
|
|
226
|
+
if (!exists(full)) continue
|
|
227
|
+
if (isGenerated(read(full))) targets.push({ rel, path: full, kind: 'file' })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return targets
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 默认模式下手写文件的保留清单(只用于提示,不删)
|
|
235
|
+
*/
|
|
236
|
+
function collectKept(root, name, purge, dirs) {
|
|
237
|
+
if (purge) return []
|
|
238
|
+
|
|
239
|
+
const kept = []
|
|
240
|
+
const push = (rel) => {
|
|
241
|
+
if (exists(absolute(root, rel))) kept.push(rel)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 被手写替换的标准文件(生成头没了,就是人写的)
|
|
245
|
+
for (const rel of moduleFiles(name, dirs)) {
|
|
246
|
+
if (exists(absolute(root, rel)) && !isGenerated(read(absolute(root, rel)))) kept.push(rel)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
push(relPath(dirs, 'domains', name, 'index.ts'))
|
|
250
|
+
const executorsRel = relPath(dirs, 'domains', name, 'executors')
|
|
251
|
+
for (const file of listFiles(absolute(root, executorsRel))) {
|
|
252
|
+
if (file !== 'crud.generated.ts') push(relPath(dirs, 'domains', name, 'executors', file))
|
|
253
|
+
}
|
|
254
|
+
const viewsRel = relPath(dirs, 'views', name)
|
|
255
|
+
const generatedViews = new Set(['Index.vue', 'Detail.vue', 'Form.vue'])
|
|
256
|
+
for (const file of listFiles(absolute(root, viewsRel))) {
|
|
257
|
+
if (!generatedViews.has(file)) push(relPath(dirs, 'views', name, file))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return kept
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 清理删除后剩下的空目录(只清模块自己的,不碰上层公共目录)
|
|
265
|
+
*/
|
|
266
|
+
function cleanupEmptyDirs(root, name, dirs) {
|
|
267
|
+
const paths = [
|
|
268
|
+
relPath(dirs, 'stores', name),
|
|
269
|
+
relPath(dirs, 'domains', name, 'executors'),
|
|
270
|
+
relPath(dirs, 'domains', name),
|
|
271
|
+
relPath(dirs, 'views', name)
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
for (const rel of paths) {
|
|
275
|
+
const full = path.join(root, rel)
|
|
276
|
+
if (isEmptyDir(full)) removeDir(full)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function printTargets(targets, kept) {
|
|
281
|
+
console.log(` ${ui.label(`将删除 ${targets.length} 个`, `Will remove ${targets.length}`)}`)
|
|
282
|
+
for (const target of targets) {
|
|
283
|
+
console.log(` ${ui.danger(glyph.warn)} ${ui.path(target.rel)}`)
|
|
284
|
+
}
|
|
285
|
+
console.log('')
|
|
286
|
+
|
|
287
|
+
if (kept.length > 0) {
|
|
288
|
+
console.log(` ${ui.label(`手写文件保留 ${kept.length} 个`, `Keep handwritten ${kept.length}`)}`)
|
|
289
|
+
for (const file of kept) {
|
|
290
|
+
console.log(` ${ui.muted(glyph.dot)} ${ui.path(file)}`)
|
|
291
|
+
}
|
|
292
|
+
console.log('')
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function confirm(question) {
|
|
297
|
+
const rl = readline.createInterface({ input: stdin, output: stdout })
|
|
298
|
+
try {
|
|
299
|
+
const answer = (await rl.question(question)).trim().toLowerCase()
|
|
300
|
+
return answer === 'y' || answer === 'yes'
|
|
301
|
+
} finally {
|
|
302
|
+
rl.close()
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function printHeader(cn, en) {
|
|
307
|
+
console.log('')
|
|
308
|
+
console.log(` ${ui.heading(cn, en)}`)
|
|
309
|
+
console.log(` ${ui.rule(46)}`)
|
|
310
|
+
console.log('')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function printRemoveHelp() {
|
|
314
|
+
console.log('')
|
|
315
|
+
console.log(` ${ui.heading('删除模块', 'remove · delete module')}`)
|
|
316
|
+
console.log(` ${ui.rule(50)}`)
|
|
317
|
+
console.log('')
|
|
318
|
+
console.log(` ${ui.label('用法', 'Usage')}`)
|
|
319
|
+
console.log(` ${ui.command('tha remove')} <模块> [--purge] [--dry-run] [--yes] [--json]`)
|
|
320
|
+
console.log('')
|
|
321
|
+
console.log(` ${ui.label('说明', 'Notes')}`)
|
|
322
|
+
console.log(` ${ui.bullet('remove module files (删除 config 和生成的 5 个文件,并重算全局路由/菜单)')}`)
|
|
323
|
+
console.log(` ${ui.bullet('handwritten files kept (手写的模块入口和动作文件默认保留)')}`)
|
|
324
|
+
console.log(` ${ui.bullet('remove everything (--purge 连手写文件一起删,整个模块目录)')}`)
|
|
325
|
+
console.log(` ${ui.bullet('preview only (--dry-run 只列出将删除的文件)')}`)
|
|
326
|
+
console.log(` ${ui.bullet('disable color (关闭颜色)')}`)
|
|
327
|
+
console.log('')
|
|
328
|
+
}
|