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,93 @@
1
+ // ============ 执行器模板 ============
2
+ // crud 关掉的能力不生成对应函数;每个写动作入口都过 ctx.can —— 权限在这里真正生效。
3
+
4
+ import { pascal } from '../names.mjs'
5
+ import { importSpec } from '../../config/paths.mjs'
6
+
7
+ export function executorsBody(domain, dirs) {
8
+ const type = pascal(domain.name)
9
+ const apiModule = importSpec(dirs, 'api', domain.name)
10
+ const { create, update, remove } = domain.crud
11
+ const needDetail = domain.pages.includes('detail') || domain.pages.includes('form')
12
+
13
+ const imports = [`import type { ${type}Context } from '../context'`]
14
+
15
+ const typeImports = []
16
+ if (needDetail) typeImports.push(type)
17
+ if (create) typeImports.push(`${type}Create`)
18
+ if (update) typeImports.push(`${type}Update`)
19
+ if (typeImports.length > 0) {
20
+ imports.unshift(`import type { ${typeImports.join(', ')} } from '${apiModule}'`)
21
+ }
22
+
23
+ const blocks = [
24
+ `/* 执行器:完整流程、业务判断、请求都只在这里。 */
25
+ export async function refresh${type}(ctx: ${type}Context): Promise<boolean> {
26
+ const ok = await ctx.run('list', async () => {
27
+ const items = await ctx.api.list()
28
+ ctx.state.setItems(items)
29
+ return true
30
+ })
31
+ return ok === true
32
+ }`
33
+ ]
34
+
35
+ if (needDetail) {
36
+ blocks.push(`export async function load${type}(id: number, ctx: ${type}Context): Promise<${type} | undefined> {
37
+ if (!ctx.can('view')) return undefined
38
+
39
+ return ctx.run('list', async () => {
40
+ const row = await ctx.api.detail(id)
41
+ ctx.state.upsert(row)
42
+ return row
43
+ })
44
+ }`)
45
+ }
46
+
47
+ if (create) {
48
+ blocks.push(`export async function create${type}(input: ${type}Create, ctx: ${type}Context): Promise<boolean> {
49
+ if (!ctx.can('create')) return false
50
+
51
+ const ok = await ctx.run('create', async () => {
52
+ const row = await ctx.api.create(input)
53
+ ctx.state.upsert(row)
54
+ return true
55
+ })
56
+ return ok === true
57
+ }`)
58
+ }
59
+
60
+ if (update) {
61
+ blocks.push(`export async function update${type}(
62
+ input: { id: number; data: ${type}Update },
63
+ ctx: ${type}Context
64
+ ): Promise<boolean> {
65
+ if (!ctx.can('update')) return false
66
+
67
+ const ok = await ctx.run('update', async () => {
68
+ const row = await ctx.api.update(input.id, input.data)
69
+ ctx.state.upsert(row)
70
+ return true
71
+ })
72
+ return ok === true
73
+ }`)
74
+ }
75
+
76
+ if (remove) {
77
+ blocks.push(`export async function remove${type}(id: number, ctx: ${type}Context): Promise<boolean> {
78
+ if (!ctx.can('remove')) return false
79
+
80
+ const ok = await ctx.run('remove', async () => {
81
+ await ctx.api.remove(id)
82
+ ctx.state.removeById(id)
83
+ return true
84
+ })
85
+ return ok === true
86
+ }`)
87
+ }
88
+
89
+ return `${imports.join('\n')}
90
+
91
+ ${blocks.join('\n\n')}
92
+ `
93
+ }
@@ -0,0 +1,34 @@
1
+ // ============ 表单页模板 ============
2
+ // pages 含 form 才生成。带 id 是编辑,不带 id 是新增 —— 同一个模板两种用法。
3
+
4
+ import { pascal } from '../names.mjs'
5
+ import { importSpec } from '../../config/paths.mjs'
6
+
7
+ export function formBody(domain, dirs) {
8
+ const type = pascal(domain.name)
9
+ const kitForm = importSpec(dirs, 'components', 'tha/ThaForm.vue')
10
+ const config = importSpec(dirs, 'domains', domain.name, 'config')
11
+ const hub = importSpec(dirs, 'domains', domain.name)
12
+ const store = importSpec(dirs, 'stores', domain.name)
13
+
14
+ return `<script setup lang="ts">
15
+ import { computed } from 'vue'
16
+ import { useRoute } from 'vue-router'
17
+ import ThaForm from '${kitForm}'
18
+ import { ${domain.name}Config } from '${config}'
19
+ import { use${type}Module } from '${hub}'
20
+ import { use${type}Store } from '${store}'
21
+
22
+ const route = useRoute()
23
+ const actions = use${type}Module()
24
+ const store = use${type}Store()
25
+
26
+ /* 有 id 就是编辑,没有就是新增 */
27
+ const id = computed(() => (route.params.id ? Number(route.params.id) : undefined))
28
+ </script>
29
+
30
+ <template>
31
+ <ThaForm :config="${domain.name}Config" :actions="actions" :store="store" :id="id" />
32
+ </template>
33
+ `
34
+ }
@@ -0,0 +1,72 @@
1
+ // ============ 一个标准域生成哪些文件 ============
2
+ // 基线 5 个(接口 / 状态 / 上下文 / 执行器 / 列表页),
3
+ // pages 声明的详情页、表单页按需追加。
4
+
5
+ import { generatedFile, generatedVueFile } from '../header.mjs'
6
+ import { relPath } from '../../config/paths.mjs'
7
+ import { apiBody } from './api.mjs'
8
+ import { contextBody } from './context.mjs'
9
+ import { executorsBody } from './executors.mjs'
10
+ import { storeBody } from './store.mjs'
11
+ import { viewBody } from './view.mjs'
12
+ import { detailBody } from './detail.mjs'
13
+ import { formBody } from './form.mjs'
14
+
15
+ export function domainFiles(domain, dirs) {
16
+ const source = relPath(dirs, 'domains', domain.name, 'config.ts')
17
+
18
+ const files = [
19
+ {
20
+ path: relPath(dirs, 'api', `${domain.name}.ts`),
21
+ content: generatedFile(source, apiBody(domain, dirs))
22
+ },
23
+ {
24
+ path: relPath(dirs, 'stores', domain.name, 'index.ts'),
25
+ content: generatedFile(source, storeBody(domain, dirs))
26
+ },
27
+ {
28
+ path: relPath(dirs, 'domains', domain.name, 'context.ts'),
29
+ content: generatedFile(source, contextBody(domain, dirs))
30
+ },
31
+ {
32
+ path: relPath(dirs, 'domains', domain.name, 'executors/crud.generated.ts'),
33
+ content: generatedFile(source, executorsBody(domain, dirs), {
34
+ config: snapshot(domain)
35
+ })
36
+ },
37
+ {
38
+ path: relPath(dirs, 'views', domain.name, 'Index.vue'),
39
+ content: generatedVueFile(source, viewBody(domain, dirs))
40
+ }
41
+ ]
42
+
43
+ if (domain.pages.includes('detail')) {
44
+ files.push({
45
+ path: relPath(dirs, 'views', domain.name, 'Detail.vue'),
46
+ content: generatedVueFile(source, detailBody(domain, dirs))
47
+ })
48
+ }
49
+
50
+ if (domain.pages.includes('form')) {
51
+ files.push({
52
+ path: relPath(dirs, 'views', domain.name, 'Form.vue'),
53
+ content: generatedVueFile(source, formBody(domain, dirs))
54
+ })
55
+ }
56
+
57
+ return files
58
+ }
59
+
60
+ function snapshot(domain) {
61
+ return {
62
+ name: domain.name,
63
+ title: domain.title,
64
+ path: domain.path,
65
+ api: domain.api,
66
+ fields: domain.fields,
67
+ list: domain.list,
68
+ crud: domain.crud,
69
+ permissions: domain.permissions,
70
+ pages: domain.pages
71
+ }
72
+ }
@@ -0,0 +1,35 @@
1
+ // ============ 权限骨架模板 ============
2
+ // 只补一次,之后归人。CLI 只保证「有一个明确的判定入口」,
3
+ // 不猜项目怎么存权限(用户 store / 路由 meta / 后端下发都行)。
4
+
5
+ import { pascal } from '../names.mjs'
6
+
7
+ export function permissionsBody(domain) {
8
+ const type = pascal(domain.name)
9
+ const codes = domain.permissions ?? {}
10
+
11
+ const codeLines = Object.entries(codes)
12
+ .map(([action, code]) => ` ${action}: '${code}'`)
13
+ .join(',\n')
14
+
15
+ return `import type { ${type}Action } from './context'
16
+
17
+ /* 权限码:来自 config.permissions,改这里不会回头影响 config。
18
+ * 只声明部分动作是允许的,没声明的动作由 can${type} 自行决定。 */
19
+ export const ${domain.name}PermissionCodes: Partial<Record<${type}Action, string>> = {
20
+ ${codeLines}
21
+ }
22
+
23
+ /**
24
+ * 权限判定:执行器入口与页面按钮共用这一处。
25
+ * 骨架默认放行 —— 刚创建时不该把模块锁死;接真实权限后改成对应判断。
26
+ * @param action 动作名(view / create / update / remove)
27
+ * @returns 是否允许
28
+ */
29
+ export function can${type}(action: ${type}Action): boolean {
30
+ // TODO: 接项目自己的权限来源,例如用户 store 里的权限码集合
31
+ void action
32
+ return true
33
+ }
34
+ `
35
+ }
@@ -0,0 +1,68 @@
1
+ // ============ 状态层模板 ============
2
+ // PendingKey 跟着 crud 走:关掉的动作不出现在 pending 里。
3
+
4
+ import { pascal } from '../names.mjs'
5
+ import { importSpec } from '../../config/paths.mjs'
6
+
7
+ export function storeBody(domain, dirs) {
8
+ const type = pascal(domain.name)
9
+ const apiModule = importSpec(dirs, 'api', domain.name)
10
+ const { create, update, remove } = domain.crud
11
+
12
+ const actions = ['list', ...(create ? ['create'] : []), ...(update ? ['update'] : []), ...(remove ? ['remove'] : [])]
13
+ const pendingInit = actions.map((action) => ` ${action}: false`).join(',\n')
14
+ const writable = create || update
15
+
16
+ const members = ['items', 'pending', 'error', 'setItems']
17
+ if (writable) members.push('upsert')
18
+ if (remove) members.push('removeById')
19
+ members.push('setPending', 'setError')
20
+
21
+ const functions = [
22
+ ` function setItems(list: ${type}[]) {
23
+ items.value = list
24
+ }`
25
+ ]
26
+
27
+ if (writable) {
28
+ functions.push(` function upsert(row: ${type}) {
29
+ const index = items.value.findIndex((item) => item.id === row.id)
30
+ if (index >= 0) items.value.splice(index, 1, row)
31
+ else items.value.unshift(row)
32
+ }`)
33
+ }
34
+
35
+ if (remove) {
36
+ functions.push(` function removeById(id: number) {
37
+ items.value = items.value.filter((item) => item.id !== id)
38
+ }`)
39
+ }
40
+
41
+ functions.push(` function setPending(key: PendingKey, value: boolean) {
42
+ pending[key] = value
43
+ }`)
44
+
45
+ functions.push(` function setError(message: string | null) {
46
+ error.value = message
47
+ }`)
48
+
49
+ return `import { defineStore } from 'pinia'
50
+ import { reactive, ref } from 'vue'
51
+ import type { ${type} } from '${apiModule}'
52
+
53
+ export type PendingKey = ${actions.map((action) => `'${action}'`).join(' | ')}
54
+
55
+ /* 状态层:原子更新;不发请求、不做业务判断。 */
56
+ export const use${type}Store = defineStore('${domain.name}', () => {
57
+ const items = ref<${type}[]>([])
58
+ const pending = reactive<Record<PendingKey, boolean>>({
59
+ ${pendingInit}
60
+ })
61
+ const error = ref<string | null>(null)
62
+
63
+ ${functions.join('\n\n')}
64
+
65
+ return { ${members.join(', ')} }
66
+ })
67
+ `
68
+ }
@@ -0,0 +1,29 @@
1
+ // ============ 列表页模板 ============
2
+ // 薄壳:页面实现由 CLI 提供的 ThaCrudPage 承担(init 时脚手架到项目里),
3
+ // 项目想换 UI 只需要改这一个组件,不用动任何模块。
4
+
5
+ import { pascal } from '../names.mjs'
6
+ import { importSpec } from '../../config/paths.mjs'
7
+
8
+ export function viewBody(domain, dirs) {
9
+ const type = pascal(domain.name)
10
+ const kitPage = importSpec(dirs, 'components', 'tha/ThaCrudPage.vue')
11
+ const config = importSpec(dirs, 'domains', domain.name, 'config')
12
+ const hub = importSpec(dirs, 'domains', domain.name)
13
+ const store = importSpec(dirs, 'stores', domain.name)
14
+
15
+ return `<script setup lang="ts">
16
+ import ThaCrudPage from '${kitPage}'
17
+ import { ${domain.name}Config } from '${config}'
18
+ import { use${type}Module } from '${hub}'
19
+ import { use${type}Store } from '${store}'
20
+
21
+ const actions = use${type}Module()
22
+ const store = use${type}Store()
23
+ </script>
24
+
25
+ <template>
26
+ <ThaCrudPage :config="${domain.name}Config" :actions="actions" :store="store" />
27
+ </template>
28
+ `
29
+ }
@@ -0,0 +1,111 @@
1
+ // ============ 全局注册表模板 ============
2
+ // 路由按 pages 展开:列表、新增、编辑、详情各一条;菜单只认列表页。
3
+
4
+ import { generatedFile } from './header.mjs'
5
+ import { quote } from './names.mjs'
6
+ import { importSpec, relPath } from '../config/paths.mjs'
7
+
8
+ export function globalFiles(domains, dirs) {
9
+ const source = 'tha:global'
10
+
11
+ return [
12
+ {
13
+ path: relPath(dirs, 'router', 'routes.generated.ts'),
14
+ content: generatedFile(source, routesBody(domains, dirs))
15
+ },
16
+ {
17
+ path: relPath(dirs, 'layouts', 'menu.generated.ts'),
18
+ content: generatedFile(source, menuBody(domains))
19
+ }
20
+ ]
21
+ }
22
+
23
+ function routesBody(domains, dirs) {
24
+ const routes = []
25
+
26
+ for (const domain of domains) {
27
+ const meta = routeMeta(domain)
28
+
29
+ routes.push(
30
+ routeEntry(domain.path, domain.name, importSpec(dirs, 'views', domain.name, 'Index.vue'), meta)
31
+ )
32
+
33
+ if (domain.pages.includes('form')) {
34
+ const formView = importSpec(dirs, 'views', domain.name, 'Form.vue')
35
+ // /new 必须排在 /:id 前面,否则会被当成 id
36
+ routes.push(routeEntry(`${domain.path}/new`, `${domain.name}Create`, formView, meta))
37
+ routes.push(routeEntry(`${domain.path}/:id/edit`, `${domain.name}Edit`, formView, meta))
38
+ }
39
+
40
+ if (domain.pages.includes('detail')) {
41
+ routes.push(
42
+ routeEntry(
43
+ `${domain.path}/:id`,
44
+ `${domain.name}Detail`,
45
+ importSpec(dirs, 'views', domain.name, 'Detail.vue'),
46
+ meta
47
+ )
48
+ )
49
+ }
50
+ }
51
+
52
+ return `import type { RouteRecordRaw } from 'vue-router'
53
+
54
+ /* 全局路由注册表:由所有模块的 config 生成,不要手动修改。 */
55
+ export const generatedRoutes: RouteRecordRaw[] = ${arrayBody(routes)}
56
+ `
57
+ }
58
+
59
+ function routeEntry(path, name, target, meta) {
60
+ return ` {
61
+ path: ${quote(path)},
62
+ name: ${quote(name)},
63
+ component: () => import(${quote(target)}),
64
+ meta: ${meta}
65
+ }`
66
+ }
67
+
68
+ function routeMeta(domain) {
69
+ const permissions = domain.permissions ?? {}
70
+ const keys = Object.keys(permissions)
71
+
72
+ if (keys.length === 0) {
73
+ return `{ title: ${quote(domain.title)} }`
74
+ }
75
+
76
+ const lines = keys.map((key) => ` ${key}: ${quote(permissions[key])}`)
77
+ return `{
78
+ title: ${quote(domain.title)},
79
+ permissions: {
80
+ ${lines.join(',\n')}
81
+ }
82
+ }`
83
+ }
84
+
85
+ function menuBody(domains) {
86
+ const items = domains.map((domain) => {
87
+ const permission = domain.permissions?.view
88
+ return ` {
89
+ title: ${quote(domain.title)},
90
+ path: ${quote(domain.path)}${permission ? `,
91
+ permission: ${quote(permission)}` : ''}
92
+ }`
93
+ })
94
+
95
+ return `/* 全局菜单注册表:由所有模块的 config 生成,不要手动修改。 */
96
+ export interface GeneratedMenuItem {
97
+ title: string
98
+ path: string
99
+ permission?: string
100
+ }
101
+
102
+ export const generatedMenu: GeneratedMenuItem[] = ${arrayBody(items)}
103
+ `
104
+ }
105
+
106
+ /**
107
+ * 空模块时输出 `[]`,有模块时按行展开(避免空数组里留一串空行)
108
+ */
109
+ function arrayBody(items) {
110
+ return items.length === 0 ? '[]' : `[\n${items.join(',\n')}\n]`
111
+ }
@@ -0,0 +1,29 @@
1
+ import { sha256 } from '../utils/files.mjs'
2
+
3
+ export function generatedFile(source, body, options = {}) {
4
+ const hash = sha256(body)
5
+ const config = options.config
6
+ ? `\n * config: ${Buffer.from(JSON.stringify(options.config)).toString('base64')}`
7
+ : ''
8
+
9
+ return `/* @tha-generated
10
+ * source: ${source}
11
+ * hash: ${hash}${config}
12
+ * DO NOT EDIT. 由 tha sync 生成。
13
+ */
14
+ ${body}`
15
+ }
16
+
17
+ export function generatedVueFile(source, body, options = {}) {
18
+ const hash = sha256(body)
19
+ const config = options.config
20
+ ? `\nconfig: ${Buffer.from(JSON.stringify(options.config)).toString('base64')}`
21
+ : ''
22
+
23
+ return `<!-- @tha-generated
24
+ source: ${source}
25
+ hash: ${hash}${config}
26
+ DO NOT EDIT. 由 tha sync 生成。
27
+ -->
28
+ ${body}`
29
+ }
@@ -0,0 +1,45 @@
1
+ export function pascal(value) {
2
+ return String(value)
3
+ .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
4
+ .replace(/^[a-z]/, (char) => char.toUpperCase())
5
+ }
6
+
7
+ export function quote(value) {
8
+ return `'${String(value)
9
+ .replace(/\\/g, '\\\\')
10
+ .replace(/'/g, "\\'")
11
+ .replace(/\r?\n/g, '\\n')}'`
12
+ }
13
+
14
+ export function fieldTsType(field) {
15
+ if (field.type === 'enum') {
16
+ const values = (field.options ?? []).map((option) => quote(option.value))
17
+ return values.length > 0 ? values.join(' | ') : 'string'
18
+ }
19
+
20
+ if (field.type === 'number') return 'number'
21
+ if (field.type === 'boolean') return 'boolean'
22
+ return 'string'
23
+ }
24
+
25
+ export function fieldLiteral(key, field) {
26
+ const value = {
27
+ key,
28
+ label: field.label,
29
+ type: field.type
30
+ }
31
+
32
+ if (field.required) value.required = true
33
+ if (field.options) value.options = field.options
34
+ if (field.default !== undefined) value.default = field.default
35
+
36
+ return JSON.stringify(value, null, 2)
37
+ }
38
+
39
+ export function indent(value, spaces) {
40
+ const pad = ' '.repeat(spaces)
41
+ return String(value)
42
+ .split('\n')
43
+ .map((line) => `${pad}${line}`)
44
+ .join('\n')
45
+ }
@@ -0,0 +1,41 @@
1
+ // ============ 生成计划:模块清单 → 待写文件清单 ============
2
+ // 两件「只补一次」的事在这里:中枢骨架、权限骨架。
3
+ // 它们一旦存在就归模块所有,sync 不再覆盖,也不再进 plan。
4
+
5
+ import { absPath, relPath } from '../config/paths.mjs'
6
+ import { exists } from '../utils/files.mjs'
7
+ import { domainFiles } from './domain/index.mjs'
8
+ import { entryBody } from './domain/entry.mjs'
9
+ import { permissionsBody } from './domain/permissions.mjs'
10
+ import { globalFiles } from './global.mjs'
11
+
12
+ export function buildPlan(domains, root, dirs) {
13
+ const files = []
14
+
15
+ for (const domain of domains) {
16
+ files.push(...domainFiles(domain, dirs))
17
+
18
+ const entry = absPath(root, dirs, 'domains', domain.name, 'index.ts')
19
+ if (!exists(entry)) {
20
+ files.push({
21
+ path: relPath(dirs, 'domains', domain.name, 'index.ts'),
22
+ content: entryBody(domain, dirs)
23
+ })
24
+ }
25
+
26
+ // 权限骨架只在声明了权限码时补;空对象等于没声明,can 恒为 true,不需要文件
27
+ if (domain.permissions && Object.keys(domain.permissions).length > 0) {
28
+ const permissions = absPath(root, dirs, 'domains', domain.name, 'permissions.ts')
29
+ if (!exists(permissions)) {
30
+ files.push({
31
+ path: relPath(dirs, 'domains', domain.name, 'permissions.ts'),
32
+ content: permissionsBody(domain, dirs)
33
+ })
34
+ }
35
+ }
36
+ }
37
+
38
+ files.push(...globalFiles(domains, dirs))
39
+
40
+ return files.sort((left, right) => left.path.localeCompare(right.path))
41
+ }