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.
Files changed (52) hide show
  1. package/ARCHITECTURE.md +193 -0
  2. package/LICENSE +21 -0
  3. package/README.md +406 -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,345 @@
1
+ // ============ OpenAPI 契约比对:config.fields 与后端真实字段对不对得上 ============
2
+ // 真源是项目里的 OpenAPI 文件,不是 config。config 只是「这一页要用哪些字段」的声明。
3
+ // 只比字段名 / 类型 / 必填,不比描述、示例与枚举值集合(那些不构成运行时依赖)。
4
+ //
5
+ // 兼容的是「schema 方言」,不是「后端语言」:
6
+ // - OAS 3.0:nullable、allOf、$ref
7
+ // - OAS 3.1:type 是数组、anyOf / oneOf、字段级 $ref(FastAPI / Pydantic v2、NestJS)
8
+ // - 包装层:{ code, data } / { result } / { items } 往下钻一层
9
+ // - Swagger 2.0:不解析,明确报错让人先转换(2.0 结构完全不同,养第二套解析不划算)
10
+
11
+ import path from 'node:path'
12
+ import { parse as parseYaml } from 'yaml'
13
+ import { exists, read } from '../utils/files.mjs'
14
+
15
+ const ACTION_LABEL = { create: '新增', update: '修改' }
16
+ import { relPath } from '../config/paths.mjs'
17
+
18
+ // config 类型 → OpenAPI 允许的类型(date/datetime 在 OpenAPI 里都是 string + format)
19
+ const TYPE_COMPATIBLE = {
20
+ string: ['string'],
21
+ text: ['string'],
22
+ number: ['number', 'integer'],
23
+ boolean: ['boolean'],
24
+ date: ['string'],
25
+ datetime: ['string'],
26
+ enum: ['string', 'number', 'integer']
27
+ }
28
+
29
+ // 常见的统一响应包装键。只在「业务字段一个都没命中」时才尝试解包,
30
+ // 避免把真有 data 字段的业务对象拆错。
31
+ const WRAPPER_KEYS = ['data', 'result', 'items', 'records', 'list', 'payload']
32
+
33
+ /**
34
+ * OpenAPI 比对入口
35
+ * @param modules 模块描述列表
36
+ * @param root 项目根目录
37
+ * @param dirs 目录映射
38
+ * @returns [{ file, level, message }] —— level: 'error' | 'warn'
39
+ */
40
+ export function checkOpenApiContracts(modules, root, dirs) {
41
+ const problems = []
42
+
43
+ for (const module of modules) {
44
+ if (module.source !== 'config') continue
45
+
46
+ const contractRel = module.domain.api?.contract
47
+ if (!contractRel) continue
48
+
49
+ const configRel = relPath(dirs, 'domains', module.name, 'config.ts')
50
+ const report = (level, message) => problems.push({ file: configRel, level, message: `${contractRel}:${message}` })
51
+
52
+ const contractFile = path.join(root, contractRel)
53
+ if (!exists(contractFile)) {
54
+ report('error', 'API spec file not found (接口文档文件找不到)')
55
+ continue
56
+ }
57
+
58
+ let spec
59
+ const isYaml = /\.(ya?ml)$/i.test(contractRel)
60
+ const format = isYaml ? 'YAML' : 'JSON'
61
+ try {
62
+ const text = read(contractFile)
63
+ spec = isYaml ? parseYaml(text) : JSON.parse(text)
64
+ } catch (err) {
65
+ const message = err instanceof Error ? err.message : String(err)
66
+ report('error', `API spec is not valid ${format} (接口文档不是合法 ${format}): ${message}`)
67
+ continue
68
+ }
69
+
70
+ if (!spec || typeof spec !== 'object') {
71
+ report('error', 'API spec is empty (接口文档是空的)')
72
+ continue
73
+ }
74
+
75
+ if (spec.swagger === '2.0') {
76
+ report(
77
+ 'error',
78
+ 'swagger 2.0 detected, convert it first (文档是旧格式 Swagger 2.0,先转成新版再比): ' +
79
+ 'npx swagger2openapi ' + contractRel + ' -o ' + contractRel
80
+ )
81
+ continue
82
+ }
83
+
84
+ const base = module.domain.api.base
85
+ const schema = rowSchema(spec, base)
86
+ if (!schema) {
87
+ const available = Object.keys(spec.paths ?? {}).slice(0, 6).join(', ')
88
+ report(
89
+ 'warn',
90
+ `no response schema for ${base}, skipped (文档里找不到这个接口的返回结构,这项跳过)` +
91
+ (available ? ` (available paths: ${available})` : '')
92
+ )
93
+ continue
94
+ }
95
+
96
+ compareFields(spec, module.domain, unwrapSchema(spec, schema, module.domain.fields), report)
97
+
98
+ // 请求体:生成器按 form 字段构造 create / update 的 payload,也要和契约对得上。
99
+ // 没有 requestBody 声明时只报一条 warn,不逐字段误报。
100
+ const missingBodies = []
101
+ for (const action of ['create', 'update']) {
102
+ if (module.domain.crud?.[action] === false) continue
103
+ const body = requestSchema(spec, base, action)
104
+ if (!body) {
105
+ missingBodies.push(action)
106
+ continue
107
+ }
108
+ compareRequestFields(spec, module.domain, body, action, report)
109
+ }
110
+ if (missingBodies.length > 0) {
111
+ report(
112
+ 'warn',
113
+ `no request body for ${missingBodies.map((a) => ACTION_LABEL[a]).join(', ')}, skipped (文档里没写提交字段,这块跳过)`
114
+ )
115
+ }
116
+ }
117
+
118
+ return problems
119
+ }
120
+
121
+ /**
122
+ * 取某个接口「一行数据」的 schema:先找 $ref,再拆数组
123
+ */
124
+ function rowSchema(spec, base) {
125
+ const item = findPath(spec, base)
126
+ if (!item) return null
127
+
128
+ const schema =
129
+ item.get?.responses?.['200']?.content?.['application/json']?.schema ??
130
+ item.post?.responses?.['201']?.content?.['application/json']?.schema
131
+
132
+ return resolve(spec, schema, 0)
133
+ }
134
+
135
+ /**
136
+ * 找路径:容忍尾斜杠差异。FastAPI 里挂 prefix 的列表接口常是 `/doc/`,
137
+ * 而 config 的 api.base 是 `/doc`(拼请求 URL 用),两边不该为此互相迁就。
138
+ */
139
+ function findPath(spec, base) {
140
+ const paths = spec.paths ?? {}
141
+ if (paths[base]) return paths[base]
142
+ const alt = base.endsWith('/') ? base.slice(0, -1) : `${base}/`
143
+ return paths[alt] ?? null
144
+ }
145
+
146
+ /**
147
+ * 取请求体 schema:create 看 POST,update 看 PUT / PATCH(POST 兜底)
148
+ */
149
+ function requestSchema(spec, base, action) {
150
+ const item = findPath(spec, base)
151
+ if (!item) return null
152
+
153
+ // create 只认 POST,update 只认 PUT / PATCH —— POST 归 create,不能拿来给 update 兜底
154
+ const methods = action === 'create' ? ['post'] : ['put', 'patch']
155
+
156
+ for (const method of methods) {
157
+ const schema = item[method]?.requestBody?.content?.['application/json']?.schema
158
+ if (schema) return resolve(spec, schema, 0)
159
+ }
160
+
161
+ return null
162
+ }
163
+
164
+ function resolve(spec, node, depth) {
165
+ if (!node || depth > 10) return null
166
+
167
+ if (node.$ref) {
168
+ const name = node.$ref.split('/').pop()
169
+ return resolve(spec, spec.components?.schemas?.[name], depth + 1)
170
+ }
171
+
172
+ // 列表接口返回数组,比对的是数组里那一行
173
+ if (node.type === 'array' || node.items) return resolve(spec, node.items, depth + 1)
174
+
175
+ // allOf 常见于「基类 + 补充字段」,合并成扁平对象
176
+ if (node.allOf) {
177
+ const merged = { type: 'object', properties: {}, required: [] }
178
+ for (const part of node.allOf) {
179
+ const resolved = resolve(spec, part, depth + 1)
180
+ Object.assign(merged.properties, resolved?.properties ?? {})
181
+ merged.required.push(...(resolved?.required ?? []))
182
+ }
183
+ return merged
184
+ }
185
+
186
+ return node
187
+ }
188
+
189
+ /**
190
+ * 包装层解包:{ code, data: [...] } → data 那一层。
191
+ * 只在这些条件同时成立时才解包:业务字段一个都没命中、包装键存在、解开后能命中。
192
+ */
193
+ function unwrapSchema(spec, schema, fields) {
194
+ const props = schema?.properties ?? {}
195
+ const keys = Object.keys(props)
196
+ if (keys.length === 0) return schema
197
+ if (keys.some((key) => fields[key])) return schema
198
+
199
+ for (const wrapper of WRAPPER_KEYS) {
200
+ const inner = props[wrapper]
201
+ if (!inner) continue
202
+ const resolved = resolve(spec, inner, 0)
203
+ const innerProps = Object.keys(resolved?.properties ?? {})
204
+ if (innerProps.some((key) => fields[key] || key === 'id')) return resolved
205
+ }
206
+
207
+ return schema
208
+ }
209
+
210
+ /**
211
+ * 把各种方言抹平成 { types: string[], format, nullable }
212
+ * types 是「后端可能返回的全部非 null 类型」;为空表示不可判定,调用方跳过类型检查。
213
+ */
214
+ function normalize(spec, node, depth) {
215
+ const empty = { types: [], format: null, nullable: false }
216
+ if (!node || depth > 10) return empty
217
+
218
+ if (node.$ref) {
219
+ const name = node.$ref.split('/').pop()
220
+ return normalize(spec, spec.components?.schemas?.[name], depth + 1)
221
+ }
222
+
223
+ // OAS 3.1:type 是数组,null 表示可空
224
+ if (Array.isArray(node.type)) {
225
+ const types = node.type.filter((t) => t !== null)
226
+ return { types, format: node.format ?? null, nullable: node.type.includes('null') }
227
+ }
228
+
229
+ // OAS 3.1 / 3.0:anyOf / oneOf 表达可空与联合
230
+ const branches = node.anyOf ?? node.oneOf
231
+ if (!node.type && Array.isArray(branches)) {
232
+ const types = []
233
+ let nullable = false
234
+ let format = node.format ?? null
235
+ for (const branch of branches) {
236
+ if (branch?.type === 'null') {
237
+ nullable = true
238
+ continue
239
+ }
240
+ const resolved = normalize(spec, branch, depth + 1)
241
+ nullable = nullable || resolved.nullable
242
+ for (const t of resolved.types) if (!types.includes(t)) types.push(t)
243
+ if (!format && resolved.format) format = resolved.format
244
+ }
245
+ return { types, format, nullable }
246
+ }
247
+
248
+ // 内联枚举没有 type,按字符串算(后端枚举值通常是字符串)
249
+ if (!node.type && Array.isArray(node.enum)) return { types: ['string'], format: null, nullable: !!node.nullable }
250
+
251
+ return { types: node.type ? [node.type] : [], format: node.format ?? null, nullable: !!node.nullable }
252
+ }
253
+
254
+ /**
255
+ * 请求体比对:只比会真的提交上去的字段(form !== false)。
256
+ * 口径与响应侧一致——「会导致请求失败或数据丢失」= error,「可能是有意为之」= warn。
257
+ */
258
+ function compareRequestFields(spec, domain, schema, action, report) {
259
+ const props = schema?.properties ?? {}
260
+ const required = Array.isArray(schema?.required) ? schema.required : []
261
+ const submittable = Object.entries(domain.fields).filter(([, field]) => field.form !== false)
262
+
263
+ for (const [key, field] of submittable) {
264
+ const prop = props[key]
265
+
266
+ if (!prop) {
267
+ report(
268
+ 'error',
269
+ `field ${key} not accepted in ${action} body (表单会提交这个字段,但接口不收)`
270
+ )
271
+ continue
272
+ }
273
+
274
+ const info = normalize(spec, prop, 0)
275
+ const expected = TYPE_COMPATIBLE[field.type] ?? []
276
+
277
+ if (expected.length > 0 && info.types.length > 0 && !info.types.some((t) => expected.includes(t))) {
278
+ report('error', `提交的 ${key} 类型对不上:配置写 ${field.type},后端是 ${info.types.join(' | ')}`)
279
+ continue
280
+ }
281
+
282
+ const wanted = field.type === 'date' ? 'date' : field.type === 'datetime' ? 'date-time' : null
283
+ if (wanted && info.format && info.format !== wanted) {
284
+ report('warn', `提交的 ${key} 时间格式对不上:配置 ${wanted},后端 ${info.format}`)
285
+ }
286
+ }
287
+
288
+ // 后端要求必填,但 config 里没有这个可提交字段 → 一定 422
289
+ for (const key of required) {
290
+ if (key === 'id') continue
291
+ const field = domain.fields[key]
292
+ if (!field || field.form === false) {
293
+ report('error', `required ${key} in ${action} body, but config has no submittable field (后端要求必传,配置里没有可提交字段)`)
294
+ }
295
+ }
296
+
297
+ // 后端可选、config 没声明:可能是有意为之
298
+ for (const key of Object.keys(props)) {
299
+ if (key === 'id') continue
300
+ if (!domain.fields[key]) {
301
+ report('warn', `后端接受 ${key} 这个提交字段,配置里没写,表单不会提交它`)
302
+ }
303
+ }
304
+ }
305
+
306
+ function compareFields(spec, domain, schema, report) {
307
+ const props = schema?.properties ?? {}
308
+ const required = Array.isArray(schema?.required) ? schema.required : []
309
+
310
+ for (const [key, field] of Object.entries(domain.fields)) {
311
+ const prop = props[key]
312
+
313
+ if (!prop) {
314
+ report('error', `field ${key} missing in backend contract (配置里有,后端没有,请求发出去会被丢掉)`)
315
+ continue
316
+ }
317
+
318
+ const info = normalize(spec, prop, 0)
319
+ const expected = TYPE_COMPATIBLE[field.type] ?? []
320
+
321
+ if (expected.length > 0 && info.types.length > 0 && !info.types.some((t) => expected.includes(t))) {
322
+ report('error', `type mismatch on ${key}: config says ${field.type}, backend says ${info.types.join(' | ')} (类型对不上)`)
323
+ continue
324
+ }
325
+
326
+ const wanted = field.type === 'date' ? 'date' : field.type === 'datetime' ? 'date-time' : null
327
+ if (wanted && info.format && info.format !== wanted) {
328
+ report('warn', `${key} 的时间格式对不上:配置 ${wanted},后端 ${info.format}`)
329
+ }
330
+
331
+ if (field.required && !required.includes(key)) {
332
+ report('warn', `${key} 你标了必填,后端是可选的`)
333
+ }
334
+ if (!field.required && required.includes(key)) {
335
+ report('warn', `required backend-side, missing in config (后端要求必填,配置里没标)`)
336
+ }
337
+ }
338
+
339
+ for (const key of Object.keys(props)) {
340
+ if (key === 'id') continue
341
+ if (!domain.fields[key]) {
342
+ report('warn', `后端有 ${key} 这个字段,配置里没写(列表和表单都不会显示它)`)
343
+ }
344
+ }
345
+ }
@@ -0,0 +1,91 @@
1
+ // ============ 护栏规则表:每条规则只在这里定义一次 ============
2
+ // 规则 id 是「文档 ↔ 检查器」的唯一纽带:架构图改了哪句,就改这条规则的 basis;
3
+ // 输出里出现哪个 id,就能回溯到哪句架构约束。
4
+ // level:error = 破架构(退出码 1);warn = 灰区(--strict 时才算失败)。
5
+ // 判断逻辑在 boundary.mjs,这里只放「说给人和 CI 听的话」。
6
+ //
7
+ // 文案口径:title / fix 是给开发看的结果行,只用业务语言,不出现中枢 / 零件 / 生成位这类架构名词;
8
+ // basis 保留架构原话,用于回溯到文档。
9
+
10
+ export const RULES = {
11
+ 'HUB-01': {
12
+ level: 'error',
13
+ title: '模块入口绕开动作,直接调内部函数',
14
+ basis: '中枢只做分发:把意图交给执行器;禁止直接依赖执行器内部步骤',
15
+ fix: '把这类控制动作放进动作文件(如 stop),或由 context.ts 提供'
16
+ },
17
+ 'HUB-03': {
18
+ level: 'warn',
19
+ title: '模块入口不是单例,被重复创建',
20
+ basis: '每个领域全局唯一实例,视图不能各造一个「私人大脑」',
21
+ fix: '在模块顶层用 let 缓存:if (!module) module = createXxxModule()'
22
+ },
23
+ 'EXE-01': {
24
+ level: 'error',
25
+ title: '两个动作文件互相 import',
26
+ basis: '执行器之间互不引用:共用逻辑下沉成零件,用例顺序留在各自执行器里',
27
+ fix: '把共用的那段抽到 steps/,两个动作都调它'
28
+ },
29
+ 'EXE-02': {
30
+ level: 'error',
31
+ title: '动作文件绕开 ctx,直接调接口或改状态',
32
+ basis: '执行器只认 ctx:网络走 ctx.api,状态走 ctx.state',
33
+ fix: '改从 ctx.api / ctx.state 取;缺什么就往 context.ts 里加'
34
+ },
35
+ 'EXE-03': {
36
+ level: 'error',
37
+ title: '动作文件直接用工具库(该从 ctx 拿)',
38
+ basis: '执行器只认 ctx:日志/时钟/流/鉴权走 ctx.ports,判定走 ctx.errors',
39
+ fix: '把实现放进 context.ts 的端口;确实是纯函数的登记到纯函数白名单'
40
+ },
41
+ 'STEP-01': {
42
+ level: 'warn',
43
+ title: '共用的函数只有一个动作在用',
44
+ basis: '零件是「同一段步骤被第二个用例用到」才下沉的;只有一个使用者是过度设计',
45
+ fix: '先内联回动作文件;真有第二个动作用时再抽出来'
46
+ },
47
+ 'VIEW-01': {
48
+ level: 'error',
49
+ title: '页面绕开模块入口,直接调接口',
50
+ basis: '视图只表达意图:动作走域中枢,数据直读状态层',
51
+ fix: '改调 useXxxModule() 上的动作;缺动作就在模块入口加一个'
52
+ },
53
+ 'VIEW-02': {
54
+ level: 'error',
55
+ title: '页面自己创建了模块实例',
56
+ basis: '每个领域全局唯一实例:视图只能经 useXxxModule() 取,不能自己 create',
57
+ fix: '改用 useXxxModule();需要独立能力就在模块入口加动作'
58
+ },
59
+ 'STORE-01': {
60
+ level: 'error',
61
+ title: '状态文件里发请求',
62
+ basis: '状态层只存状态,不发请求',
63
+ fix: '把请求挪进动作文件,拿到结果再写回 state'
64
+ },
65
+ 'UTIL-01': {
66
+ level: 'error',
67
+ title: '工具文件里用了 UI 库或业务模块',
68
+ basis: '传输层只上报不越权:不感知业务模块,不做展示',
69
+ fix: '提示交给页面(订阅错误总线);业务判断交给动作文件'
70
+ }
71
+ }
72
+
73
+ /**
74
+ * 执行器/零件允许直接 import 的纯函数工具
75
+ * 判据:无副作用、不持有状态、不依赖运行时环境(换实现无意义)
76
+ * 新增一条必须写理由:它为什么不属于「外界」
77
+ */
78
+ export const PURE_UTILS = ['@/utils/adapters', '@/utils/format', '@/utils/number']
79
+
80
+ /** 基础设施里属于「能力」而非「纯函数」的模块:必须经 ctx.ports 注入 */
81
+ export const PORT_UTILS = [
82
+ '@/utils/stream',
83
+ '@/utils/request',
84
+ '@/utils/auth',
85
+ '@/utils/logger',
86
+ '@/utils/stopwatch',
87
+ '@/utils/time',
88
+ '@/utils/token',
89
+ '@/utils/errorBus',
90
+ '@/utils/error'
91
+ ]
@@ -0,0 +1,59 @@
1
+ // ============ 模块发现:config 驱动的模块 + 纯手写模块 ============
2
+ // 护栏模式的前提是「看得见手写模块」:复杂模块没有 config,构造机不生成它,
3
+ // 但它仍然在架构里,仍然要被检查。扫描只看目录形状,不看有没有 config。
4
+
5
+ import path from 'node:path'
6
+ import { exists, listDirectories, walkFiles } from '../utils/files.mjs'
7
+ import { absPath, relPath } from '../config/paths.mjs'
8
+
9
+ /**
10
+ * 描述一个模块的实际文件形状
11
+ * @param root 项目根目录
12
+ * @param name 模块目录名(<domains>/<name>)
13
+ * @param dirs 目录映射
14
+ * @returns { name, hub(相对路径或 null), executors(绝对路径), steps(绝对路径) }
15
+ */
16
+ export function describeModule(root, name, dirs) {
17
+ const dir = absPath(root, dirs, 'domains', name)
18
+ const hub = path.join(dir, 'index.ts')
19
+
20
+ return {
21
+ name,
22
+ hub: exists(hub) ? relPath(dirs, 'domains', name, 'index.ts') : null,
23
+ executors: walkFiles(path.join(dir, 'executors'), ['.ts']),
24
+ steps: walkFiles(path.join(dir, 'steps'), ['.ts'])
25
+ }
26
+ }
27
+
28
+ /**
29
+ * 扫描没有 config 的手写模块
30
+ * @param root 项目根目录
31
+ * @param configured 已有 config 的模块名集合
32
+ * @param dirs 目录映射
33
+ * @returns 模块描述列表(source = 'manual')
34
+ */
35
+ export function scanManualModules(root, configured, dirs) {
36
+ const dir = absPath(root, dirs, 'domains')
37
+ if (!exists(dir)) return []
38
+
39
+ return listDirectories(dir)
40
+ .filter((name) => !configured.has(name))
41
+ .map((name) => describeModule(root, name, dirs))
42
+ .filter((module) => module.hub || module.executors.length > 0 || module.steps.length > 0)
43
+ .map((module) => ({ ...module, source: 'manual' }))
44
+ }
45
+
46
+ /**
47
+ * 给 config 驱动的模块补上实际文件形状
48
+ * @param domains 已通过配置校验的模块
49
+ * @param root 项目根目录
50
+ * @param dirs 目录映射
51
+ * @returns 模块描述列表(source = 'config',附带原始配置)
52
+ */
53
+ export function describeConfigured(domains, root, dirs) {
54
+ return domains.map((domain) => ({
55
+ ...describeModule(root, domain.name, dirs),
56
+ source: 'config',
57
+ domain
58
+ }))
59
+ }
package/tha.mjs ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './tha/cli.mjs'
3
+ import { configureStyle, glyph, ui } from './tha/utils/style.mjs'
4
+
5
+ const argv = process.argv.slice(2)
6
+
7
+ configureStyle({
8
+ noColor: argv.includes('--no-color'),
9
+ color: !argv.includes('--no-color') && argv.includes('--color')
10
+ })
11
+
12
+ run(argv).catch((error) => {
13
+ const message = error instanceof Error ? error.message : String(error)
14
+ const [headline, ...details] = message.split('\n')
15
+
16
+ console.error('')
17
+ console.error(` ${ui.danger(glyph.err)} ${ui.bold(headline)}`)
18
+ for (const line of details) {
19
+ console.error(` ${ui.muted(line)}`)
20
+ }
21
+ process.exitCode = 1
22
+ })