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.
- package/ARCHITECTURE.md +193 -0
- package/LICENSE +21 -0
- package/README.md +408 -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,30 @@
|
|
|
1
|
+
/* THA 基线请求层
|
|
2
|
+
* 只做最朴素的 fetch 包装,让生成物在空项目里也能跑。
|
|
3
|
+
* 项目已有自己的请求层时,删掉这个文件、保持下面五个方法签名即可。
|
|
4
|
+
* 后端返回结构不同(例如有 { code, data } 外壳),只改这一个地方。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
async function call<T>(url: string, init?: RequestInit): Promise<T> {
|
|
8
|
+
const response = await fetch(url, {
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
...init
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
throw new Error(`${response.status} ${response.statusText}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const text = await response.text()
|
|
18
|
+
return (text ? JSON.parse(text) : undefined) as T
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const request = {
|
|
22
|
+
list: <T>(base: string): Promise<T[]> => call<T[]>(base),
|
|
23
|
+
detail: <T>(id: number, base: string): Promise<T> => call<T>(`${base}/${id}`),
|
|
24
|
+
create: <T>(data: unknown, base: string): Promise<T> =>
|
|
25
|
+
call<T>(base, { method: 'POST', body: JSON.stringify(data) }),
|
|
26
|
+
update: <T>(id: number, data: unknown, base: string): Promise<T> =>
|
|
27
|
+
call<T>(`${base}/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
28
|
+
remove: <T>(id: number, base: string): Promise<T> =>
|
|
29
|
+
call<T>(`${base}/${id}`, { method: 'DELETE' })
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* THA 基线提示层
|
|
2
|
+
* 生成物只调 toast(level, message),不关心项目用哪套 UI 库。
|
|
3
|
+
* 项目已有提示层时,删掉这个文件、保持同样的导出签名即可。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type ToastLevel = 'info' | 'ok' | 'warn' | 'error'
|
|
7
|
+
|
|
8
|
+
export function toast(level: ToastLevel, message: string): void {
|
|
9
|
+
if (level === 'error') console.error(`[tha] ${message}`)
|
|
10
|
+
else console.log(`[tha] ${message}`)
|
|
11
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 极简参数解析:只支持 --key value、--key=value、--flag、-h。
|
|
3
|
+
* 不引入依赖,保证 CLI 本身可读、可测。
|
|
4
|
+
*/
|
|
5
|
+
export function parseArgs(argv, options = {}) {
|
|
6
|
+
const booleanFlags = new Set(options.boolean ?? [])
|
|
7
|
+
const valueFlags = new Set(options.value ?? [])
|
|
8
|
+
const flags = {}
|
|
9
|
+
const positional = []
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
12
|
+
const arg = argv[i]
|
|
13
|
+
|
|
14
|
+
if (arg === '--') {
|
|
15
|
+
positional.push(...argv.slice(i + 1))
|
|
16
|
+
break
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (arg === '-h') {
|
|
20
|
+
flags.help = true
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!arg.startsWith('--')) {
|
|
25
|
+
positional.push(arg)
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [key, inlineValue] = arg.slice(2).split('=', 2)
|
|
30
|
+
|
|
31
|
+
if (booleanFlags.has(key)) {
|
|
32
|
+
if (inlineValue !== undefined) {
|
|
33
|
+
throw new Error(`--${key} 不接受值`)
|
|
34
|
+
}
|
|
35
|
+
flags[key] = true
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!valueFlags.has(key)) {
|
|
40
|
+
throw new Error(`未知参数 --${key}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const value = inlineValue ?? argv[i + 1]
|
|
44
|
+
if (value === undefined || (inlineValue === undefined && value.startsWith('--'))) {
|
|
45
|
+
throw new Error(`--${key} 需要一个值`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
flags[key] = value
|
|
49
|
+
if (inlineValue === undefined) i += 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { flags, positional }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function requirePositional(positional, index, message) {
|
|
56
|
+
const value = positional[index]
|
|
57
|
+
if (!value) throw new Error(message)
|
|
58
|
+
return value
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { generatedHeader, isGenerated, read, sha256 } from './files.mjs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 比较生成计划与磁盘现状
|
|
7
|
+
* @param plan 生成计划 [{ path, content }]
|
|
8
|
+
* @param root 项目根目录
|
|
9
|
+
* @param manual 已接手(不再生成)的文件路径集合
|
|
10
|
+
* @returns 带 status 的条目:add / update / edited / conflict / same / manual
|
|
11
|
+
*/
|
|
12
|
+
export function comparePlan(plan, root, manual = new Set()) {
|
|
13
|
+
return plan.map((file) => {
|
|
14
|
+
const target = path.join(root, file.path)
|
|
15
|
+
|
|
16
|
+
// 已接手的生成位:CLI 不再写,也不参与漂移判定(但护栏照查)
|
|
17
|
+
if (manual.has(file.path)) {
|
|
18
|
+
return { ...file, status: 'manual', target }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!fs.existsSync(target)) {
|
|
22
|
+
return { ...file, status: 'add', target }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const current = read(target)
|
|
26
|
+
if (current === file.content) {
|
|
27
|
+
return { ...file, status: 'same', target }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (isGenerated(current)) {
|
|
31
|
+
const header = generatedHeader(current)
|
|
32
|
+
const intact = header && header.hash === sha256(header.body)
|
|
33
|
+
return { ...file, status: intact ? 'update' : 'edited', target }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { ...file, status: 'conflict', target }
|
|
37
|
+
})
|
|
38
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
export function toPosix(value) {
|
|
6
|
+
return value.split(path.sep).join('/')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function absolute(root, relative) {
|
|
10
|
+
return path.join(root, relative)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function relativePath(root, file) {
|
|
14
|
+
return toPosix(path.relative(root, file))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function exists(file) {
|
|
18
|
+
return fs.existsSync(file)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function read(file) {
|
|
22
|
+
return fs.readFileSync(file, 'utf8')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeAtomic(file, content) {
|
|
26
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
27
|
+
const temp = `${file}.tha-${process.pid}.tmp`
|
|
28
|
+
fs.writeFileSync(temp, content, 'utf8')
|
|
29
|
+
fs.renameSync(temp, file)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 两阶段写入(事务):先把所有内容落到临时文件,全部成功后再统一 rename 提交。
|
|
34
|
+
* 任一阶段失败都清理临时文件,原文件保持不动——不会出现"写到一半留半套"。
|
|
35
|
+
* @param entries 待写条目(每项需带 target 绝对路径 + content)
|
|
36
|
+
*/
|
|
37
|
+
export function writeAll(entries) {
|
|
38
|
+
const staged = []
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
fs.mkdirSync(path.dirname(entry.target), { recursive: true })
|
|
43
|
+
const temp = `${entry.target}.tha-${process.pid}.tmp`
|
|
44
|
+
fs.writeFileSync(temp, entry.content, 'utf8')
|
|
45
|
+
staged.push({ temp, target: entry.target })
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
cleanupStaged(staged)
|
|
49
|
+
throw error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const item of staged) fs.renameSync(item.temp, item.target)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cleanupStaged(staged) {
|
|
56
|
+
for (const item of staged) {
|
|
57
|
+
try {
|
|
58
|
+
fs.unlinkSync(item.temp)
|
|
59
|
+
} catch {
|
|
60
|
+
// 临时文件可能没写成功;清理失败不影响原文件
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function removeFile(file) {
|
|
66
|
+
fs.rmSync(file, { force: true })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function removeDir(dir) {
|
|
70
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 目录是否不存在或已空(删除后清理空壳目录用)
|
|
75
|
+
*/
|
|
76
|
+
export function isEmptyDir(dir) {
|
|
77
|
+
if (!fs.existsSync(dir)) return true
|
|
78
|
+
try {
|
|
79
|
+
return fs.readdirSync(dir).length === 0
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 列出目录下的文件名(按扩展名过滤,可选)
|
|
87
|
+
*/
|
|
88
|
+
export function listFiles(dir, extension) {
|
|
89
|
+
if (!fs.existsSync(dir)) return []
|
|
90
|
+
return fs
|
|
91
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
92
|
+
.filter((entry) => entry.isFile() && (!extension || entry.name.endsWith(extension)))
|
|
93
|
+
.map((entry) => entry.name)
|
|
94
|
+
.sort()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function sha256(value) {
|
|
98
|
+
return crypto.createHash('sha256').update(value).digest('hex')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isGenerated(content) {
|
|
102
|
+
return content.startsWith('/* @tha-generated') || content.startsWith('<!-- @tha-generated')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function splitGenerated(content) {
|
|
106
|
+
if (!isGenerated(content)) return null
|
|
107
|
+
|
|
108
|
+
const marker = content.startsWith('<!--') ? '-->\n' : '*/\n'
|
|
109
|
+
const end = content.indexOf(marker)
|
|
110
|
+
if (end < 0) return null
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
header: content.slice(0, end + marker.length),
|
|
114
|
+
body: content.slice(end + marker.length)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function generatedHeader(content) {
|
|
119
|
+
const split = splitGenerated(content)
|
|
120
|
+
if (!split) return null
|
|
121
|
+
const rawConfig = split.header.match(/config:\s*([A-Za-z0-9+/=]+)/)?.[1]
|
|
122
|
+
let config = null
|
|
123
|
+
|
|
124
|
+
if (rawConfig) {
|
|
125
|
+
try {
|
|
126
|
+
config = JSON.parse(Buffer.from(rawConfig, 'base64').toString('utf8'))
|
|
127
|
+
} catch {
|
|
128
|
+
config = null
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
source: split.header.match(/source:\s*(.+)/)?.[1]?.trim() ?? '',
|
|
134
|
+
hash: split.header.match(/hash:\s*([a-f0-9]+)/)?.[1] ?? '',
|
|
135
|
+
config,
|
|
136
|
+
body: split.body
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function listDirectories(dir) {
|
|
141
|
+
if (!fs.existsSync(dir)) return []
|
|
142
|
+
return fs
|
|
143
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
144
|
+
.filter((entry) => entry.isDirectory())
|
|
145
|
+
.map((entry) => entry.name)
|
|
146
|
+
.sort()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 递归收集目录下的文件(跳过 node_modules 与点开头目录)
|
|
151
|
+
* @param dir 起始目录(绝对路径)
|
|
152
|
+
* @param extensions 扩展名过滤,如 ['.ts', '.vue'];省略则收集全部
|
|
153
|
+
* @returns 文件的绝对路径列表(按路径排序)
|
|
154
|
+
*/
|
|
155
|
+
export function walkFiles(dir, extensions) {
|
|
156
|
+
if (!fs.existsSync(dir)) return []
|
|
157
|
+
|
|
158
|
+
const files = []
|
|
159
|
+
|
|
160
|
+
const visit = (current) => {
|
|
161
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
162
|
+
const full = path.join(current, entry.name)
|
|
163
|
+
|
|
164
|
+
if (entry.isDirectory()) {
|
|
165
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
166
|
+
visit(full)
|
|
167
|
+
} else if (!extensions || extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
168
|
+
files.push(full)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
visit(dir)
|
|
174
|
+
return files.sort()
|
|
175
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { exists } from './files.mjs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 用项目自带的 Prettier 格式化刚写入的文件。
|
|
7
|
+
* 不硬依赖 Prettier:项目没装就跳过(返回 skipped),不影响生成结果。
|
|
8
|
+
* @param root 项目根目录
|
|
9
|
+
* @param files 刚写入的相对路径列表(相对 root)
|
|
10
|
+
* @returns { formatted, reason }:reason 为 '' / 'prettier-not-installed' / 'prettier-failed' / 'nothing'
|
|
11
|
+
*/
|
|
12
|
+
export function formatWritten(root, files) {
|
|
13
|
+
if (files.length === 0) return { formatted: false, reason: 'nothing' }
|
|
14
|
+
|
|
15
|
+
const bin = path.join(root, 'node_modules', '.bin', 'prettier')
|
|
16
|
+
if (!exists(bin)) return { formatted: false, reason: 'prettier-not-installed' }
|
|
17
|
+
|
|
18
|
+
const result = spawnSync(bin, ['--write', ...files], { cwd: root, stdio: 'ignore' })
|
|
19
|
+
|
|
20
|
+
return result.status === 0
|
|
21
|
+
? { formatted: true, reason: '' }
|
|
22
|
+
: { formatted: false, reason: 'prettier-failed' }
|
|
23
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { glyph, ui } from './style.mjs'
|
|
2
|
+
|
|
3
|
+
export function printHeader(cn, en) {
|
|
4
|
+
console.log('')
|
|
5
|
+
console.log(` ${ui.heading(cn, en)}`)
|
|
6
|
+
console.log(` ${ui.rule()}`)
|
|
7
|
+
console.log('')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function printErrors(errors) {
|
|
11
|
+
if (errors.length === 0) return
|
|
12
|
+
|
|
13
|
+
console.error('')
|
|
14
|
+
console.error(` ${ui.danger(glyph.err)} ${ui.label('配置有问题', 'Config problem')}`)
|
|
15
|
+
|
|
16
|
+
for (const error of errors) {
|
|
17
|
+
const where = error.path ? `${error.file} → ${error.path}` : error.file
|
|
18
|
+
console.error(` ${ui.path(where)}`)
|
|
19
|
+
console.error(` ${ui.muted(error.message)}`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function printDiff(entries, options = {}) {
|
|
24
|
+
const groups = [
|
|
25
|
+
{ status: 'add', cn: '将新增', en: 'Will add', mark: glyph.add, paint: ui.success },
|
|
26
|
+
{ status: 'update', cn: '将更新', en: 'Will update', mark: glyph.update, paint: ui.warning },
|
|
27
|
+
{
|
|
28
|
+
status: 'edited',
|
|
29
|
+
cn: '手动改过',
|
|
30
|
+
en: 'Edited by hand',
|
|
31
|
+
mark: glyph.warn,
|
|
32
|
+
paint: ui.danger,
|
|
33
|
+
hint: 'changed by hand, sync will not overwrite (这些文件被手动改过,tha sync 不覆盖)'
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
status: 'conflict',
|
|
37
|
+
cn: '不会覆盖',
|
|
38
|
+
en: 'Will not overwrite',
|
|
39
|
+
mark: glyph.warn,
|
|
40
|
+
paint: ui.danger,
|
|
41
|
+
hint: 'existing files not written by CLI (这些文件已存在,但不是 CLI 写的)'
|
|
42
|
+
},
|
|
43
|
+
{ status: 'same', cn: '无变化', en: 'No change', mark: glyph.same, paint: ui.muted }
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
if (options.header !== false) printHeader('文件预览', 'Diff')
|
|
47
|
+
|
|
48
|
+
let printed = 0
|
|
49
|
+
|
|
50
|
+
for (const group of groups) {
|
|
51
|
+
const items = entries.filter((entry) => entry.status === group.status)
|
|
52
|
+
if (items.length === 0) continue
|
|
53
|
+
printed += 1
|
|
54
|
+
|
|
55
|
+
console.log(` ${ui.label(`${group.cn} ${items.length} 个`, `${group.en} ${items.length}`)}`)
|
|
56
|
+
if (group.hint) console.log(` ${ui.muted(group.hint)}`)
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
console.log(` ${group.paint(group.mark)} ${ui.path(item.path)}`)
|
|
59
|
+
}
|
|
60
|
+
console.log('')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (printed === 0) {
|
|
64
|
+
console.log(` ${ui.muted('No changes (没有变化)')}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function printNextSteps() {
|
|
69
|
+
const command = (name) => ui.command(name.padEnd(9))
|
|
70
|
+
|
|
71
|
+
console.log(` ${ui.label('下一步', 'Next')}`)
|
|
72
|
+
console.log(
|
|
73
|
+
` ${ui.arrow(command('tha sync'))} ${ui.muted('generate 5 files (生成 5 个标准文件;模块入口缺失时补一个,之后归你写)')}`
|
|
74
|
+
)
|
|
75
|
+
console.log('')
|
|
76
|
+
console.log(` ${ui.label('可选', 'Optional')}`)
|
|
77
|
+
console.log(` ${ui.bullet(command('tha diff'))} ${ui.muted('preview files (预览文件)')}`)
|
|
78
|
+
console.log(` ${ui.bullet(command('tha check'))} ${ui.muted('check config (检查配置)')}`)
|
|
79
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI 样式层:颜色、符号、间距、主题都只在这里定义。
|
|
3
|
+
*
|
|
4
|
+
* 命令层不直接写 ANSI,也不自己拼“✓ / ✗”。
|
|
5
|
+
* 想换风格,只改这个文件;想临时关颜色,用 --no-color 或 NO_COLOR=1。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ANSI = {
|
|
9
|
+
reset: '\u001b[0m',
|
|
10
|
+
bold: '\u001b[1m',
|
|
11
|
+
dim: '\u001b[2m',
|
|
12
|
+
red: '\u001b[31m',
|
|
13
|
+
green: '\u001b[32m',
|
|
14
|
+
yellow: '\u001b[33m',
|
|
15
|
+
blue: '\u001b[34m',
|
|
16
|
+
magenta: '\u001b[35m',
|
|
17
|
+
cyan: '\u001b[36m',
|
|
18
|
+
gray: '\u001b[90m'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const THEMES = {
|
|
22
|
+
terminal: {
|
|
23
|
+
accent: ANSI.cyan,
|
|
24
|
+
success: ANSI.green,
|
|
25
|
+
warning: ANSI.yellow,
|
|
26
|
+
danger: ANSI.red,
|
|
27
|
+
path: ANSI.blue,
|
|
28
|
+
muted: ANSI.gray
|
|
29
|
+
},
|
|
30
|
+
plain: {
|
|
31
|
+
accent: '',
|
|
32
|
+
success: '',
|
|
33
|
+
warning: '',
|
|
34
|
+
danger: '',
|
|
35
|
+
path: '',
|
|
36
|
+
muted: ''
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const glyph = Object.freeze({
|
|
41
|
+
ok: '✓',
|
|
42
|
+
err: '✗',
|
|
43
|
+
warn: '!',
|
|
44
|
+
next: '→',
|
|
45
|
+
dot: '·',
|
|
46
|
+
add: '+',
|
|
47
|
+
update: '~',
|
|
48
|
+
same: '=',
|
|
49
|
+
bullet: '·'
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const state = {
|
|
53
|
+
color: detectColor(),
|
|
54
|
+
theme: detectTheme()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function configureStyle(options = {}) {
|
|
58
|
+
const envTheme = options.theme ?? process.env.THA_THEME
|
|
59
|
+
state.theme = THEMES[envTheme] ? envTheme : 'terminal'
|
|
60
|
+
|
|
61
|
+
if (options.noColor) {
|
|
62
|
+
state.color = false
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.color) {
|
|
67
|
+
state.color = true
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (state.theme === 'plain') {
|
|
72
|
+
state.color = false
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
state.color = detectColor()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getTheme() {
|
|
80
|
+
return state.theme
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isColorEnabled() {
|
|
84
|
+
return state.color
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const ui = {
|
|
88
|
+
bold: (text) => paint(ANSI.bold, text),
|
|
89
|
+
dim: (text) => paint(ANSI.dim, text),
|
|
90
|
+
accent: (text) => paint(THEMES[state.theme].accent, text),
|
|
91
|
+
success: (text) => paint(THEMES[state.theme].success, text),
|
|
92
|
+
warning: (text) => paint(THEMES[state.theme].warning, text),
|
|
93
|
+
danger: (text) => paint(THEMES[state.theme].danger, text),
|
|
94
|
+
path: (text) => paint(THEMES[state.theme].path, text),
|
|
95
|
+
muted: (text) => paint(THEMES[state.theme].muted, text),
|
|
96
|
+
command: (text) => paint(ANSI.bold, paint(THEMES[state.theme].accent, text)),
|
|
97
|
+
|
|
98
|
+
/** "English (中文)",英文加粗,中文弱化。 */
|
|
99
|
+
label(cn, en) {
|
|
100
|
+
return en ? `${paint(ANSI.bold, en)}${paint(THEMES[state.theme].muted, ` (${cn})`)}` : paint(ANSI.bold, cn)
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/** 顶部品牌行:THA · Title (中文) */
|
|
104
|
+
heading(cn, en) {
|
|
105
|
+
const title = en ? paint(ANSI.bold, en) : paint(ANSI.bold, cn)
|
|
106
|
+
const suffix = en ? paint(THEMES[state.theme].muted, ` (${cn})`) : ''
|
|
107
|
+
return `${paint(THEMES[state.theme].accent, 'THA')} ${paint(THEMES[state.theme].muted, '·')} ${title}${suffix}`
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
rule(width = 34) {
|
|
111
|
+
return paint(THEMES[state.theme].muted, '─'.repeat(width))
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
ok(text) {
|
|
115
|
+
return `${paint(THEMES[state.theme].success, glyph.ok)} ${text}`
|
|
116
|
+
},
|
|
117
|
+
warn(text) {
|
|
118
|
+
return `${paint(THEMES[state.theme].warning, glyph.warn)} ${text}`
|
|
119
|
+
},
|
|
120
|
+
error(text) {
|
|
121
|
+
return `${paint(THEMES[state.theme].danger, glyph.err)} ${text}`
|
|
122
|
+
},
|
|
123
|
+
bullet(text) {
|
|
124
|
+
return `${paint(THEMES[state.theme].muted, glyph.bullet)} ${text}`
|
|
125
|
+
},
|
|
126
|
+
arrow(text) {
|
|
127
|
+
return `${paint(THEMES[state.theme].accent, glyph.next)} ${text}`
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function paint(code, text) {
|
|
132
|
+
if (!state.color || !code) return text
|
|
133
|
+
return `${code}${text}${ANSI.reset}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function detectTheme() {
|
|
137
|
+
const theme = process.env.THA_THEME
|
|
138
|
+
return THEMES[theme] ? theme : 'terminal'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function detectColor() {
|
|
142
|
+
if (process.env.THA_THEME === 'plain') return false
|
|
143
|
+
if (process.env.NO_COLOR) return false
|
|
144
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') return true
|
|
145
|
+
if (!process.stdout.isTTY) return false
|
|
146
|
+
return process.env.TERM !== 'dumb'
|
|
147
|
+
}
|
package/tha/utils/ts.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ============ typescript 按需加载:优先用项目自带的 ============
|
|
2
|
+
// CLI 只用 ts 做语法解析(读 config 字面量、遍历 AST),不做类型检查、不编译。
|
|
3
|
+
// 写成普通 dependencies,装 tha-cli 时 npm 自动带一份 5.x —— 用户不用自己装,
|
|
4
|
+
// 也不用为了它降级自己项目的 typescript。运行时优先用项目里 API 齐全的那份(省磁盘),
|
|
5
|
+
// 项目里是 7.x 这种不可用的,就退回 CLI 自带的那份。
|
|
6
|
+
//
|
|
7
|
+
// 用 Proxy 懒加载:import 阶段不碰 typescript,所以 `tha --help` 在任何环境里都能跑。
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
const requireFromProject = createRequire(path.join(process.cwd(), '__tha_resolve__.js'))
|
|
13
|
+
const requireFromSelf = createRequire(import.meta.url)
|
|
14
|
+
|
|
15
|
+
let real = null
|
|
16
|
+
|
|
17
|
+
/** 7.x 重写了编译器 API,只剩 version 字段;这几个是 CLI 全流程的硬依赖 */
|
|
18
|
+
const REQUIRED_APIS = ['createSourceFile', 'isObjectLiteralExpression', 'ScriptTarget', 'SyntaxKind']
|
|
19
|
+
|
|
20
|
+
function isUsable(mod) {
|
|
21
|
+
return REQUIRED_APIS.every((key) => mod[key] !== undefined)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function load() {
|
|
25
|
+
let incompatible = null
|
|
26
|
+
|
|
27
|
+
for (const req of [requireFromProject, requireFromSelf]) {
|
|
28
|
+
let mod
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
mod = req('typescript')
|
|
32
|
+
} catch {
|
|
33
|
+
// 这个位置没有,换下一个
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (isUsable(mod)) return mod
|
|
38
|
+
incompatible = incompatible ?? mod
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (incompatible) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
[
|
|
44
|
+
`项目里的 typescript 是 ${incompatible.version},这个版本不可用(7.x 重写了编译器 API)。`,
|
|
45
|
+
'请装 5.x:npm i -D typescript@5',
|
|
46
|
+
`The installed typescript (${incompatible.version}) is not compatible. Install 5.x: npm i -D typescript@5`
|
|
47
|
+
].join('\n')
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new Error(
|
|
52
|
+
[
|
|
53
|
+
'找不到可用的 typescript。CLI 自带的那份(dependencies 里的 5.x)也没解析出来。',
|
|
54
|
+
'请重装 tha-cli:npm i -D tha-cli',
|
|
55
|
+
'Cannot resolve a usable "typescript". Reinstall tha-cli: npm i -D tha-cli'
|
|
56
|
+
].join('\n')
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const ts = new Proxy(
|
|
61
|
+
{},
|
|
62
|
+
{
|
|
63
|
+
get(_target, property) {
|
|
64
|
+
if (!real) real = load()
|
|
65
|
+
const value = real[property]
|
|
66
|
+
return typeof value === 'function' ? value.bind(real) : value
|
|
67
|
+
},
|
|
68
|
+
has(_target, property) {
|
|
69
|
+
if (!real) real = load()
|
|
70
|
+
return property in real
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
)
|