create-halo-plugin-template 1.0.3 → 1.0.5
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/README.md +17 -11
- package/build.gradle +14 -11
- package/docs/first-npm-release-checklist.md +2 -2
- package/docs/publish-template.md +4 -4
- package/docs/template-pruning.md +5 -3
- package/package.json +1 -1
- package/scripts/create-project.mjs +39 -25
- package/scripts/init-template.mjs +63 -27
- package/scripts/lib/template-meta.mjs +64 -0
- package/scripts/verify-template.mjs +39 -13
- package/src/main/java/run/halo/plugintemplate/endpoint/PluginTemplateConsoleEndpoint.java +3 -1
- package/src/main/java/run/halo/plugintemplate/endpoint/PluginTemplatePublicEndpoint.java +3 -1
- package/src/main/java/run/halo/plugintemplate/endpoint/PluginTemplateUcEndpoint.java +3 -1
- package/src/main/java/run/halo/plugintemplate/scheme/PluginTemplateRecord.java +2 -1
- package/src/main/java/run/halo/plugintemplate/setting/PluginTemplateSettingKeys.java +6 -2
- package/src/main/resources/extensions/roleTemplate-console.yaml +4 -4
- package/src/main/resources/extensions/roleTemplate-uc.yaml +2 -2
package/README.md
CHANGED
|
@@ -25,11 +25,13 @@
|
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
node scripts/create-project.mjs \
|
|
28
|
-
--plugin-name
|
|
28
|
+
--plugin-name todo \
|
|
29
29
|
--base-package com.example.helloworld \
|
|
30
|
-
--display-name "
|
|
30
|
+
--display-name "Todo" \
|
|
31
31
|
--author-name "Your Name" \
|
|
32
|
-
--
|
|
32
|
+
--route-prefix /plugin-todo \
|
|
33
|
+
--permission-prefix plugin:plugin-todo \
|
|
34
|
+
--target-dir ../plugin-todo \
|
|
33
35
|
--install \
|
|
34
36
|
--build
|
|
35
37
|
```
|
|
@@ -48,9 +50,9 @@ node scripts/create-project.mjs \
|
|
|
48
50
|
|
|
49
51
|
```bash
|
|
50
52
|
npm create halo-plugin-template@latest -- \
|
|
51
|
-
--plugin-name
|
|
53
|
+
--plugin-name todo \
|
|
52
54
|
--base-package com.example.helloworld \
|
|
53
|
-
--display-name "
|
|
55
|
+
--display-name "Todo" \
|
|
54
56
|
--author-name "Your Name"
|
|
55
57
|
```
|
|
56
58
|
|
|
@@ -93,13 +95,15 @@ npm run release:prepare -- --bump patch --push
|
|
|
93
95
|
|
|
94
96
|
```bash
|
|
95
97
|
node scripts/init-template.mjs \
|
|
96
|
-
--plugin-name
|
|
98
|
+
--plugin-name todo \
|
|
97
99
|
--base-package com.example.helloworld \
|
|
98
|
-
--display-name "
|
|
100
|
+
--display-name "Todo" \
|
|
99
101
|
--author-name "Your Name" \
|
|
102
|
+
--route-prefix /plugin-todo \
|
|
103
|
+
--permission-prefix plugin:plugin-todo \
|
|
100
104
|
--author-website "https://github.com/your-name" \
|
|
101
105
|
--repo-owner your-name \
|
|
102
|
-
--description "
|
|
106
|
+
--description "Todo - Halo 插件"
|
|
103
107
|
```
|
|
104
108
|
|
|
105
109
|
这个脚本是一次性的。执行后会连同自身模板常量一起改写,确保生成出来的项目里不再残留模板占位符。
|
|
@@ -108,10 +112,12 @@ node scripts/init-template.mjs \
|
|
|
108
112
|
|
|
109
113
|
```bash
|
|
110
114
|
node scripts/verify-template.mjs \
|
|
111
|
-
--plugin-name
|
|
115
|
+
--plugin-name todo \
|
|
112
116
|
--base-package com.example.helloworld \
|
|
113
|
-
--display-name "
|
|
114
|
-
--author-name "Your Name"
|
|
117
|
+
--display-name "Todo" \
|
|
118
|
+
--author-name "Your Name" \
|
|
119
|
+
--route-prefix /plugin-todo \
|
|
120
|
+
--permission-prefix plugin:plugin-todo
|
|
115
121
|
```
|
|
116
122
|
|
|
117
123
|
然后安装前端依赖并启动开发环境:
|
package/build.gradle
CHANGED
|
@@ -58,14 +58,16 @@ tasks.named('generatePluginComponentsIdx') {
|
|
|
58
58
|
notCompatibleWithConfigurationCache('This task invokes "Task.project" at execution time, which is not supported with configuration cache.')
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
if (file('scripts/verify-template.mjs').exists()) {
|
|
62
|
+
tasks.register('verifyTemplateConsistency', Exec) {
|
|
63
|
+
group = 'verification'
|
|
64
|
+
description = 'Verify the starter repository or initialized plugin stays internally consistent'
|
|
65
|
+
commandLine 'node', 'scripts/verify-template.mjs'
|
|
66
|
+
}
|
|
66
67
|
|
|
67
|
-
tasks.named('check') {
|
|
68
|
-
|
|
68
|
+
tasks.named('check') {
|
|
69
|
+
dependsOn tasks.named('verifyTemplateConsistency')
|
|
70
|
+
}
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
halo {
|
|
@@ -76,16 +78,17 @@ haloPlugin {
|
|
|
76
78
|
openApi {
|
|
77
79
|
outputDir = file("$rootDir/api-docs/openapi/v3_0")
|
|
78
80
|
groupingRules {
|
|
79
|
-
|
|
81
|
+
plugintemplateApis {
|
|
80
82
|
displayName = 'Extension API for halo-plugin-template'
|
|
81
83
|
pathsToMatch = [
|
|
82
|
-
'/apis/console.
|
|
83
|
-
'/apis/uc.
|
|
84
|
+
'/apis/console.plugintemplate.halo.run/v1alpha1/**',
|
|
85
|
+
'/apis/uc.plugintemplate.halo.run/v1alpha1/**',
|
|
86
|
+
'/apis/public.plugintemplate.halo.run/v1alpha1/**'
|
|
84
87
|
]
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
groupedApiMappings = [
|
|
88
|
-
'/v3/api-docs/
|
|
91
|
+
'/v3/api-docs/plugintemplateApis': 'plugintemplateApis.json'
|
|
89
92
|
]
|
|
90
93
|
generator {
|
|
91
94
|
outputDir = file("${projectDir}/ui/src/api/generated")
|
|
@@ -51,8 +51,8 @@ npm view create-halo-plugin-template version
|
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
53
|
npm create halo-plugin-template@latest -- \
|
|
54
|
-
--plugin-name
|
|
54
|
+
--plugin-name todo \
|
|
55
55
|
--base-package com.example.helloworld \
|
|
56
|
-
--display-name "
|
|
56
|
+
--display-name "Todo" \
|
|
57
57
|
--author-name "Your Name"
|
|
58
58
|
```
|
package/docs/publish-template.md
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
npm create halo-plugin-template@latest -- \
|
|
18
|
-
--plugin-name
|
|
18
|
+
--plugin-name todo \
|
|
19
19
|
--base-package com.example.helloworld \
|
|
20
|
-
--display-name "
|
|
20
|
+
--display-name "Todo" \
|
|
21
21
|
--author-name "Your Name"
|
|
22
22
|
```
|
|
23
23
|
|
|
@@ -31,9 +31,9 @@ npm create halo-plugin-template@latest -- \
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
npm init @your-scope/halo-plugin-template@latest -- \
|
|
34
|
-
--plugin-name
|
|
34
|
+
--plugin-name todo \
|
|
35
35
|
--base-package com.example.helloworld \
|
|
36
|
-
--display-name "
|
|
36
|
+
--display-name "Todo" \
|
|
37
37
|
--author-name "Your Name"
|
|
38
38
|
```
|
|
39
39
|
|
package/docs/template-pruning.md
CHANGED
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
node scripts/verify-template.mjs \
|
|
34
|
-
--plugin-name
|
|
34
|
+
--plugin-name todo \
|
|
35
35
|
--base-package com.example.helloworld \
|
|
36
|
-
--display-name "
|
|
37
|
-
--author-name "Your Name"
|
|
36
|
+
--display-name "Todo" \
|
|
37
|
+
--author-name "Your Name" \
|
|
38
|
+
--route-prefix /plugin-todo \
|
|
39
|
+
--permission-prefix plugin:plugin-todo
|
|
38
40
|
|
|
39
41
|
./gradlew build
|
|
40
42
|
cd ui && pnpm test:unit
|
package/package.json
CHANGED
|
@@ -5,6 +5,11 @@ import path from 'node:path'
|
|
|
5
5
|
import process from 'node:process'
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import {
|
|
9
|
+
normalizePermissionPrefix,
|
|
10
|
+
normalizePluginName,
|
|
11
|
+
normalizeRoutePrefix,
|
|
12
|
+
} from './lib/template-meta.mjs'
|
|
8
13
|
|
|
9
14
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
10
15
|
const TEMPLATE_ROOT = path.resolve(SCRIPT_DIR, '..')
|
|
@@ -22,38 +27,33 @@ const SKIP_RELATIVE_PATHS = new Set([
|
|
|
22
27
|
'.npmignore',
|
|
23
28
|
'.github/workflows/publish-npm.yaml',
|
|
24
29
|
'bin',
|
|
30
|
+
'docs',
|
|
25
31
|
'docs/publish-template.md',
|
|
26
32
|
'package.json',
|
|
27
33
|
'package-lock.json',
|
|
28
34
|
'pnpm-lock.yaml',
|
|
29
|
-
'scripts
|
|
30
|
-
'scripts/publish-check.mjs',
|
|
35
|
+
'scripts',
|
|
31
36
|
'ui/build',
|
|
32
37
|
])
|
|
33
38
|
|
|
34
39
|
const usage = `
|
|
35
40
|
Usage:
|
|
36
41
|
node scripts/create-project.mjs \\
|
|
37
|
-
--plugin-name
|
|
42
|
+
--plugin-name todo \\
|
|
38
43
|
--base-package com.example.helloworld \\
|
|
39
|
-
--display-name "
|
|
44
|
+
--display-name "Todo" \\
|
|
40
45
|
--author-name "Your Name" \\
|
|
41
|
-
[--
|
|
46
|
+
[--route-prefix /plugin-todo] \\
|
|
47
|
+
[--permission-prefix plugin:plugin-todo] \\
|
|
48
|
+
[--target-dir ../plugin-todo] \\
|
|
42
49
|
[--author-website "https://github.com/your-name"] \\
|
|
43
50
|
[--repo-owner your-name] \\
|
|
44
|
-
[--description "
|
|
51
|
+
[--description "Todo - Halo 插件"] \\
|
|
45
52
|
[--install] \\
|
|
46
53
|
[--build] \\
|
|
47
54
|
[--halo-server]
|
|
48
55
|
`.trim()
|
|
49
56
|
|
|
50
|
-
const slugify = (value) =>
|
|
51
|
-
value
|
|
52
|
-
.trim()
|
|
53
|
-
.toLowerCase()
|
|
54
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
55
|
-
.replace(/^-+|-+$/g, '')
|
|
56
|
-
|
|
57
57
|
const parseArgs = (argv) => {
|
|
58
58
|
const parsed = {
|
|
59
59
|
install: false,
|
|
@@ -121,7 +121,9 @@ const parseArgs = (argv) => {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
const pluginName =
|
|
124
|
+
const pluginName = normalizePluginName(parsed['plugin-name'])
|
|
125
|
+
const routePrefix = normalizeRoutePrefix(parsed['route-prefix'], pluginName)
|
|
126
|
+
const permissionPrefix = normalizePermissionPrefix(parsed['permission-prefix'], pluginName)
|
|
125
127
|
|
|
126
128
|
return {
|
|
127
129
|
pluginName,
|
|
@@ -131,6 +133,8 @@ const parseArgs = (argv) => {
|
|
|
131
133
|
authorWebsite: parsed['author-website'] || '',
|
|
132
134
|
repoOwner: parsed['repo-owner'] || '',
|
|
133
135
|
description: parsed.description || '',
|
|
136
|
+
routePrefix,
|
|
137
|
+
permissionPrefix,
|
|
134
138
|
targetDir: path.resolve(parsed['target-dir'] || path.resolve(process.cwd(), pluginName)),
|
|
135
139
|
install: parsed.install,
|
|
136
140
|
build: parsed.build,
|
|
@@ -148,6 +152,12 @@ const shouldCopy = (sourcePath) => {
|
|
|
148
152
|
return true
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
for (const skippedPath of SKIP_RELATIVE_PATHS) {
|
|
156
|
+
if (relativePath === skippedPath || relativePath.startsWith(`${skippedPath}/`)) {
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
151
161
|
if (SKIP_RELATIVE_PATHS.has(relativePath)) {
|
|
152
162
|
return false
|
|
153
163
|
}
|
|
@@ -200,8 +210,8 @@ const buildGeneratedReadme = (options) => `# ${options.displayName}
|
|
|
200
210
|
|
|
201
211
|
- 插件名:\`${options.pluginName}\`
|
|
202
212
|
- Java 包名:\`${options.basePackage}\`
|
|
203
|
-
- UI
|
|
204
|
-
-
|
|
213
|
+
- UI 路由前缀:\`${options.routePrefix}\`
|
|
214
|
+
- 权限前缀:\`${options.permissionPrefix}:*\`
|
|
205
215
|
|
|
206
216
|
## 环境要求
|
|
207
217
|
|
|
@@ -234,9 +244,6 @@ pnpm dev
|
|
|
234
244
|
# 后端单测
|
|
235
245
|
./gradlew test
|
|
236
246
|
|
|
237
|
-
# 一致性检查
|
|
238
|
-
node scripts/verify-template.mjs
|
|
239
|
-
|
|
240
247
|
# 前端检查
|
|
241
248
|
cd ui
|
|
242
249
|
pnpm verify
|
|
@@ -253,7 +260,8 @@ cd ..
|
|
|
253
260
|
|
|
254
261
|
- 后端先补接口和 Springdoc 注解,再执行 \`./gradlew generateApiClient\`。
|
|
255
262
|
- 前端统一从 \`ui/src/api/index.ts\` 暴露 API,不要在页面里写裸 URL。
|
|
256
|
-
-
|
|
263
|
+
- 通过 \`--route-prefix\` 和 \`--permission-prefix\` 传入的规则已经写入项目,不需要再手工全局替换。
|
|
264
|
+
- 生成项目默认不再附带模板仓库的 \`docs/\` 和 \`scripts/\` 目录,保持业务仓库更轻。
|
|
257
265
|
|
|
258
266
|
## 目录说明
|
|
259
267
|
|
|
@@ -264,12 +272,10 @@ cd ..
|
|
|
264
272
|
- \`ui/src/components/\`:业务级共享组件
|
|
265
273
|
- \`ui/src/api/index.ts\`:前端唯一 API 包装出口
|
|
266
274
|
- \`ui/src/api/generated/\`:由 \`generateApiClient\` 生成并已接入的客户端代码
|
|
267
|
-
- \`docs/rsbuild-switch.md\`:从当前模板切换到 Rsbuild 的最小差异说明
|
|
268
|
-
- \`docs/template-pruning.md\`:初始化后如何裁剪模板能力的操作建议
|
|
269
275
|
|
|
270
276
|
## 裁剪模板
|
|
271
277
|
|
|
272
|
-
如果你只做 Console 页面,或不需要附件扩展、UC
|
|
278
|
+
如果你只做 Console 页面,或不需要附件扩展、UC 页面,直接删除对应的 \`ucRoutes\`、扩展点和角色模板即可。
|
|
273
279
|
|
|
274
280
|
## 许可证
|
|
275
281
|
|
|
@@ -286,7 +292,7 @@ const writeGeneratedReadme = async (options) => {
|
|
|
286
292
|
|
|
287
293
|
const buildInitArgs = (options) => {
|
|
288
294
|
const args = [
|
|
289
|
-
path.join(
|
|
295
|
+
path.join(SCRIPT_DIR, 'init-template.mjs'),
|
|
290
296
|
'--plugin-name',
|
|
291
297
|
options.pluginName,
|
|
292
298
|
'--base-package',
|
|
@@ -295,6 +301,10 @@ const buildInitArgs = (options) => {
|
|
|
295
301
|
options.displayName,
|
|
296
302
|
'--author-name',
|
|
297
303
|
options.authorName,
|
|
304
|
+
'--route-prefix',
|
|
305
|
+
options.routePrefix,
|
|
306
|
+
'--permission-prefix',
|
|
307
|
+
options.permissionPrefix,
|
|
298
308
|
]
|
|
299
309
|
|
|
300
310
|
if (options.authorWebsite) {
|
|
@@ -312,7 +322,7 @@ const buildInitArgs = (options) => {
|
|
|
312
322
|
|
|
313
323
|
const buildVerifyArgs = (options) => {
|
|
314
324
|
const args = [
|
|
315
|
-
path.join(
|
|
325
|
+
path.join(SCRIPT_DIR, 'verify-template.mjs'),
|
|
316
326
|
'--plugin-name',
|
|
317
327
|
options.pluginName,
|
|
318
328
|
'--base-package',
|
|
@@ -321,6 +331,10 @@ const buildVerifyArgs = (options) => {
|
|
|
321
331
|
options.displayName,
|
|
322
332
|
'--author-name',
|
|
323
333
|
options.authorName,
|
|
334
|
+
'--route-prefix',
|
|
335
|
+
options.routePrefix,
|
|
336
|
+
'--permission-prefix',
|
|
337
|
+
options.permissionPrefix,
|
|
324
338
|
]
|
|
325
339
|
|
|
326
340
|
if (options.authorWebsite) {
|
|
@@ -3,11 +3,23 @@
|
|
|
3
3
|
import fs from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import process from 'node:process'
|
|
6
|
+
import {
|
|
7
|
+
getApiGroupKey,
|
|
8
|
+
normalizePermissionPrefix,
|
|
9
|
+
normalizePluginName,
|
|
10
|
+
normalizeRoutePrefix,
|
|
11
|
+
reverseBasePackage,
|
|
12
|
+
slugify,
|
|
13
|
+
toClassPrefix,
|
|
14
|
+
toKebabToken,
|
|
15
|
+
} from './lib/template-meta.mjs'
|
|
6
16
|
|
|
7
17
|
const TEMPLATE = {
|
|
8
18
|
pluginName: 'halo-plugin-template',
|
|
9
19
|
basePackage: 'run.halo.plugintemplate',
|
|
10
20
|
classPrefix: 'PluginTemplate',
|
|
21
|
+
routePrefix: '/halo-plugin-template',
|
|
22
|
+
permissionPrefix: 'plugin:halo-plugin-template',
|
|
11
23
|
displayName: 'Halo Plugin Template',
|
|
12
24
|
authorName: 'Template Author',
|
|
13
25
|
authorWebsite: 'https://github.com/example',
|
|
@@ -48,36 +60,17 @@ const SKIP_DIRS = new Set([
|
|
|
48
60
|
const usage = `
|
|
49
61
|
Usage:
|
|
50
62
|
node scripts/init-template.mjs \\
|
|
51
|
-
--plugin-name
|
|
63
|
+
--plugin-name todo \\
|
|
52
64
|
--base-package com.example.helloworld \\
|
|
53
|
-
--display-name "
|
|
65
|
+
--display-name "Todo" \\
|
|
54
66
|
--author-name "Your Name" \\
|
|
67
|
+
[--route-prefix /plugin-todo] \\
|
|
68
|
+
[--permission-prefix plugin:plugin-todo] \\
|
|
55
69
|
[--author-website "https://github.com/your-name"] \\
|
|
56
70
|
[--repo-owner your-name] \\
|
|
57
|
-
[--description "
|
|
71
|
+
[--description "Todo - Halo 插件"]
|
|
58
72
|
`.trim()
|
|
59
73
|
|
|
60
|
-
const slugify = (value) =>
|
|
61
|
-
value
|
|
62
|
-
.trim()
|
|
63
|
-
.toLowerCase()
|
|
64
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
65
|
-
.replace(/^-+|-+$/g, '')
|
|
66
|
-
|
|
67
|
-
const toClassPrefix = (pluginName) => {
|
|
68
|
-
const parts = pluginName.split(/[^a-zA-Z0-9]+/).filter(Boolean)
|
|
69
|
-
if (!parts.length) {
|
|
70
|
-
return 'PluginTemplate'
|
|
71
|
-
}
|
|
72
|
-
return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const toKebabToken = (value) =>
|
|
76
|
-
value
|
|
77
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
78
|
-
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
79
|
-
.toLowerCase()
|
|
80
|
-
|
|
81
74
|
const parseArgs = (argv) => {
|
|
82
75
|
const parsed = {}
|
|
83
76
|
|
|
@@ -102,10 +95,12 @@ const parseArgs = (argv) => {
|
|
|
102
95
|
}
|
|
103
96
|
|
|
104
97
|
return {
|
|
105
|
-
pluginName:
|
|
98
|
+
pluginName: normalizePluginName(parsed['plugin-name']),
|
|
106
99
|
basePackage: parsed['base-package'].trim(),
|
|
107
100
|
displayName: parsed['display-name'].trim(),
|
|
108
101
|
authorName: parsed['author-name'].trim(),
|
|
102
|
+
routePrefix: normalizeRoutePrefix(parsed['route-prefix'], normalizePluginName(parsed['plugin-name'])),
|
|
103
|
+
permissionPrefix: normalizePermissionPrefix(parsed['permission-prefix'], normalizePluginName(parsed['plugin-name'])),
|
|
109
104
|
authorWebsite: parsed['author-website']?.trim() || '',
|
|
110
105
|
repoOwner: parsed['repo-owner']?.trim() || '',
|
|
111
106
|
description: parsed.description?.trim() || '',
|
|
@@ -168,7 +163,33 @@ const pruneEmptyDirectories = async (startPath, stopPath) => {
|
|
|
168
163
|
}
|
|
169
164
|
}
|
|
170
165
|
|
|
171
|
-
const
|
|
166
|
+
const pruneEmptyDirectoriesRecursively = async (directory, stopPath) => {
|
|
167
|
+
let entries
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
entries = await fs.readdir(directory, { withFileTypes: true })
|
|
171
|
+
} catch {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (!entry.isDirectory()) {
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
await pruneEmptyDirectoriesRecursively(path.join(directory, entry.name), stopPath)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (directory === stopPath) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const remainingEntries = await fs.readdir(directory)
|
|
187
|
+
if (remainingEntries.length === 0) {
|
|
188
|
+
await fs.rmdir(directory)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const renamePackageDirectory = async (sourceRoot, targetRoot, pruneStopPath) => {
|
|
172
193
|
try {
|
|
173
194
|
await fs.access(sourceRoot)
|
|
174
195
|
} catch {
|
|
@@ -177,7 +198,7 @@ const renamePackageDirectory = async (sourceRoot, targetRoot) => {
|
|
|
177
198
|
|
|
178
199
|
await fs.mkdir(path.dirname(targetRoot), { recursive: true })
|
|
179
200
|
await fs.rename(sourceRoot, targetRoot)
|
|
180
|
-
await pruneEmptyDirectories(path.dirname(sourceRoot),
|
|
201
|
+
await pruneEmptyDirectories(path.dirname(sourceRoot), pruneStopPath)
|
|
181
202
|
}
|
|
182
203
|
|
|
183
204
|
const collectRenameTargets = async (directory, token, result = []) => {
|
|
@@ -221,6 +242,10 @@ const main = async () => {
|
|
|
221
242
|
const authorWebsite = options.authorWebsite || `https://github.com/${repoOwner}`
|
|
222
243
|
const description = options.description || `${options.displayName} - Halo 插件`
|
|
223
244
|
const nextPackagePath = options.basePackage.split('.').join('/')
|
|
245
|
+
const templateApiGroup = reverseBasePackage(TEMPLATE.basePackage)
|
|
246
|
+
const nextApiGroup = reverseBasePackage(options.basePackage)
|
|
247
|
+
const templateApiGroupKey = getApiGroupKey(TEMPLATE.basePackage)
|
|
248
|
+
const nextApiGroupKey = getApiGroupKey(options.basePackage)
|
|
224
249
|
|
|
225
250
|
const replacements = [
|
|
226
251
|
[
|
|
@@ -242,6 +267,11 @@ const main = async () => {
|
|
|
242
267
|
[TEMPLATE.authorWebsite, authorWebsite],
|
|
243
268
|
[TEMPLATE_PACKAGE_PATH, nextPackagePath],
|
|
244
269
|
[TEMPLATE.basePackage, options.basePackage],
|
|
270
|
+
[TEMPLATE.routePrefix, options.routePrefix],
|
|
271
|
+
[TEMPLATE.permissionPrefix, options.permissionPrefix],
|
|
272
|
+
[templateApiGroup, nextApiGroup],
|
|
273
|
+
[`${templateApiGroupKey}Apis`, `${nextApiGroupKey}Apis`],
|
|
274
|
+
[`"${templateApiGroupKey}"`, `"${nextApiGroupKey}"`],
|
|
245
275
|
[TEMPLATE.classPrefix, classPrefix],
|
|
246
276
|
[TEMPLATE.displayName, options.displayName],
|
|
247
277
|
[TEMPLATE.description, description],
|
|
@@ -258,17 +288,23 @@ const main = async () => {
|
|
|
258
288
|
await renamePackageDirectory(
|
|
259
289
|
path.join(ROOT, 'src/main/java', TEMPLATE_PACKAGE_PATH),
|
|
260
290
|
path.join(ROOT, 'src/main/java', nextPackagePath),
|
|
291
|
+
path.join(ROOT, 'src/main/java'),
|
|
261
292
|
)
|
|
262
293
|
await renamePackageDirectory(
|
|
263
294
|
path.join(ROOT, 'src/test/java', TEMPLATE_PACKAGE_PATH),
|
|
264
295
|
path.join(ROOT, 'src/test/java', nextPackagePath),
|
|
296
|
+
path.join(ROOT, 'src/test/java'),
|
|
265
297
|
)
|
|
298
|
+
await pruneEmptyDirectoriesRecursively(path.join(ROOT, 'src/main/java'), path.join(ROOT, 'src/main/java'))
|
|
299
|
+
await pruneEmptyDirectoriesRecursively(path.join(ROOT, 'src/test/java'), path.join(ROOT, 'src/test/java'))
|
|
266
300
|
await renameClassPrefixPaths(ROOT, TEMPLATE.classPrefix, classPrefix)
|
|
267
301
|
await renameClassPrefixPaths(ROOT, templateClassPrefixKebab, classPrefixKebab)
|
|
268
302
|
|
|
269
303
|
console.log(`Initialized template as ${options.pluginName}`)
|
|
270
304
|
console.log(`Base package: ${options.basePackage}`)
|
|
271
305
|
console.log(`Class prefix: ${classPrefix}`)
|
|
306
|
+
console.log(`Route prefix: ${options.routePrefix}`)
|
|
307
|
+
console.log(`Permission prefix: ${options.permissionPrefix}`)
|
|
272
308
|
console.log(`Repository owner: ${repoOwner}`)
|
|
273
309
|
} catch (error) {
|
|
274
310
|
console.error(error instanceof Error ? error.message : String(error))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export const slugify = (value) =>
|
|
2
|
+
value
|
|
3
|
+
.trim()
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
6
|
+
.replace(/^-+|-+$/g, '')
|
|
7
|
+
|
|
8
|
+
export const normalizePluginName = (value) => {
|
|
9
|
+
const slug = slugify(value)
|
|
10
|
+
|
|
11
|
+
if (!slug) {
|
|
12
|
+
return ''
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (slug.startsWith('halo-plugin-')) {
|
|
16
|
+
return `plugin-${slug.slice('halo-plugin-'.length)}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (slug.startsWith('plugin-')) {
|
|
20
|
+
return slug
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return `plugin-${slug}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const normalizeRoutePrefix = (value, pluginName) => {
|
|
27
|
+
const raw = (value?.trim() || `/${pluginName}`).replace(/^\/+/, '')
|
|
28
|
+
if (!raw) {
|
|
29
|
+
return '/'
|
|
30
|
+
}
|
|
31
|
+
return `/${raw.replace(/\/+$/, '')}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const normalizePermissionPrefix = (value, pluginName) =>
|
|
35
|
+
(value?.trim() || `plugin:${pluginName}`).replace(/:+$/, '')
|
|
36
|
+
|
|
37
|
+
export const toClassPrefix = (pluginName) => {
|
|
38
|
+
const parts = pluginName.split(/[^a-zA-Z0-9]+/).filter(Boolean)
|
|
39
|
+
if (!parts.length) {
|
|
40
|
+
return 'PluginTemplate'
|
|
41
|
+
}
|
|
42
|
+
return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const toKebabToken = (value) =>
|
|
46
|
+
value
|
|
47
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
48
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
|
|
51
|
+
export const reverseBasePackage = (basePackage) =>
|
|
52
|
+
basePackage
|
|
53
|
+
.split('.')
|
|
54
|
+
.map((segment) => segment.trim())
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.reverse()
|
|
57
|
+
.join('.')
|
|
58
|
+
|
|
59
|
+
export const getApiGroupKey = (basePackage) =>
|
|
60
|
+
basePackage
|
|
61
|
+
.split('.')
|
|
62
|
+
.map((segment) => segment.trim())
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.at(-1) || 'plugin'
|
|
@@ -9,7 +9,6 @@ const REQUIRED_FILES = [
|
|
|
9
9
|
'README.md',
|
|
10
10
|
'build.gradle',
|
|
11
11
|
'settings.gradle',
|
|
12
|
-
'scripts/init-template.mjs',
|
|
13
12
|
'src/main/resources/plugin.yaml',
|
|
14
13
|
'src/main/resources/extensions/settings.yaml',
|
|
15
14
|
'src/main/resources/extensions/roleTemplate-console.yaml',
|
|
@@ -17,8 +16,6 @@ const REQUIRED_FILES = [
|
|
|
17
16
|
'ui/src/index.ts',
|
|
18
17
|
'ui/src/api/index.ts',
|
|
19
18
|
'ui/src/api/generated/index.ts',
|
|
20
|
-
'docs/rsbuild-switch.md',
|
|
21
|
-
'docs/template-pruning.md',
|
|
22
19
|
]
|
|
23
20
|
const TEXT_EXTENSIONS = new Set([
|
|
24
21
|
'.md',
|
|
@@ -59,13 +56,15 @@ Usage:
|
|
|
59
56
|
node scripts/verify-template.mjs
|
|
60
57
|
|
|
61
58
|
node scripts/verify-template.mjs \\
|
|
62
|
-
--plugin-name
|
|
59
|
+
--plugin-name todo \\
|
|
63
60
|
--base-package com.example.helloworld \\
|
|
64
|
-
--display-name "
|
|
61
|
+
--display-name "Todo" \\
|
|
65
62
|
--author-name "Your Name" \\
|
|
63
|
+
[--route-prefix /plugin-todo] \\
|
|
64
|
+
[--permission-prefix plugin:plugin-todo] \\
|
|
66
65
|
[--author-website "https://github.com/your-name"] \\
|
|
67
66
|
[--repo-owner your-name] \\
|
|
68
|
-
[--description "
|
|
67
|
+
[--description "Todo - Halo 插件"]
|
|
69
68
|
`.trim()
|
|
70
69
|
|
|
71
70
|
const parseArgs = (argv) => {
|
|
@@ -252,10 +251,16 @@ const verifyRepo = async (args) => {
|
|
|
252
251
|
const consolePath = extractJavaConstant(settingKeysContent, 'CONSOLE_PATH')
|
|
253
252
|
const ucPath = extractJavaConstant(settingKeysContent, 'UC_PATH')
|
|
254
253
|
const generatedClientPath = extractJavaConstant(settingKeysContent, 'GENERATED_CLIENT_PATH')
|
|
254
|
+
const routePrefix = extractJavaConstant(settingKeysContent, 'ROUTE_PREFIX')
|
|
255
|
+
const configuredPermissionPrefix = extractJavaConstant(settingKeysContent, 'PERMISSION_PREFIX')
|
|
256
|
+
const apiGroupSuffix = extractJavaConstant(settingKeysContent, 'API_GROUP_SUFFIX')
|
|
257
|
+
const apiGroupKey = extractJavaConstant(settingKeysContent, 'API_GROUP_KEY')
|
|
255
258
|
const basePackage = extractGradleGroup(buildGradleContent)
|
|
256
259
|
const rootProjectName = extractRootProjectName(settingsGradleContent)
|
|
257
260
|
const packageDirectory = path.posix.join('src/main/java', ...basePackage.split('.'))
|
|
258
|
-
const permissionPrefix =
|
|
261
|
+
const permissionPrefix = `${configuredPermissionPrefix}:`
|
|
262
|
+
const resolvedConsolePath = consolePath || routePrefix
|
|
263
|
+
const resolvedUcPath = ucPath || routePrefix
|
|
259
264
|
|
|
260
265
|
if (!pluginName || !displayName || !basePackage) {
|
|
261
266
|
fail(results, 'Unable to resolve plugin constants from SettingKeys.java or build.gradle')
|
|
@@ -280,10 +285,12 @@ const verifyRepo = async (args) => {
|
|
|
280
285
|
assertContains(results, pluginYamlContent, `displayName: "${displayName}"`, 'plugin.yaml displayName is consistent')
|
|
281
286
|
assertContains(results, settingsYamlContent, `name: ${settingName}`, 'settings.yaml metadata name is consistent')
|
|
282
287
|
|
|
283
|
-
assertContains(results, buildGradleContent, `/apis/console.${
|
|
284
|
-
assertContains(results, buildGradleContent, `/apis/uc.${
|
|
285
|
-
assertContains(results,
|
|
286
|
-
assertContains(results,
|
|
288
|
+
assertContains(results, buildGradleContent, `/apis/console.${apiGroupSuffix}/v1alpha1/**`, 'OpenAPI console path matches API group suffix')
|
|
289
|
+
assertContains(results, buildGradleContent, `/apis/uc.${apiGroupSuffix}/v1alpha1/**`, 'OpenAPI UC path matches API group suffix')
|
|
290
|
+
assertContains(results, buildGradleContent, `/apis/public.${apiGroupSuffix}/v1alpha1/**`, 'OpenAPI public path matches API group suffix')
|
|
291
|
+
assertContains(results, buildGradleContent, `${apiGroupKey}Apis`, 'OpenAPI group key matches API group key')
|
|
292
|
+
assertContains(results, uiIndexContent, `path: '${resolvedConsolePath}'`, 'Console route path matches setting constants')
|
|
293
|
+
assertContains(results, uiIndexContent, `path: '${resolvedUcPath}'`, 'UC route path matches setting constants')
|
|
287
294
|
assertContains(results, uiIndexContent, permissionPrefix, 'UI permissions use the current plugin prefix')
|
|
288
295
|
assertContains(results, providerContent, `${pluginName}-admin-shell`, 'UI provider namespace follows the plugin prefix')
|
|
289
296
|
assertContains(results, uiApiContent, `from '@/api/generated'`, 'UI API wrapper imports from the generated client')
|
|
@@ -296,8 +303,15 @@ const verifyRepo = async (args) => {
|
|
|
296
303
|
|
|
297
304
|
assertContains(results, consoleRoleTemplateContent, permissionPrefix, 'Console role template uses the plugin permission prefix')
|
|
298
305
|
assertContains(results, ucRoleTemplateContent, permissionPrefix, 'UC role template uses the plugin permission prefix')
|
|
299
|
-
|
|
300
|
-
|
|
306
|
+
if (readmeContent.includes('node scripts/verify-template.mjs')) {
|
|
307
|
+
pass(results, 'README documents the verification command')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!(await exists('docs/template-pruning.md')) || readmeContent.includes('docs/template-pruning.md')) {
|
|
311
|
+
pass(results, 'README/doc pruning guidance is consistent')
|
|
312
|
+
} else {
|
|
313
|
+
fail(results, 'README references are inconsistent with the generated docs layout')
|
|
314
|
+
}
|
|
301
315
|
|
|
302
316
|
if (await exists(path.posix.join(generatedClientPath, 'api'))) {
|
|
303
317
|
pass(results, `Generated API directory exists: ${generatedClientPath}/api`)
|
|
@@ -329,6 +343,18 @@ const verifyRepo = async (args) => {
|
|
|
329
343
|
pass(results, `Expected display name matches: ${displayName}`)
|
|
330
344
|
}
|
|
331
345
|
|
|
346
|
+
if (args['route-prefix'] && args['route-prefix'] !== routePrefix) {
|
|
347
|
+
fail(results, `Expected route prefix ${args['route-prefix']}, got ${routePrefix}`)
|
|
348
|
+
} else if (args['route-prefix']) {
|
|
349
|
+
pass(results, `Expected route prefix matches: ${routePrefix}`)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (args['permission-prefix'] && args['permission-prefix'] !== configuredPermissionPrefix) {
|
|
353
|
+
fail(results, `Expected permission prefix ${args['permission-prefix']}, got ${configuredPermissionPrefix}`)
|
|
354
|
+
} else if (args['permission-prefix']) {
|
|
355
|
+
pass(results, `Expected permission prefix matches: ${configuredPermissionPrefix}`)
|
|
356
|
+
}
|
|
357
|
+
|
|
332
358
|
if (args['author-name']) {
|
|
333
359
|
assertContains(
|
|
334
360
|
results,
|
|
@@ -9,12 +9,14 @@ import org.springframework.web.reactive.function.server.ServerResponse;
|
|
|
9
9
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
|
10
10
|
import run.halo.app.extension.GroupVersion;
|
|
11
11
|
import run.halo.plugintemplate.endpoint.routes.PluginTemplateOverviewRoutes;
|
|
12
|
+
import run.halo.plugintemplate.setting.PluginTemplateSettingKeys;
|
|
12
13
|
|
|
13
14
|
@Component
|
|
14
15
|
@RequiredArgsConstructor
|
|
15
16
|
public class PluginTemplateConsoleEndpoint implements CustomEndpoint {
|
|
16
17
|
|
|
17
|
-
public static final String CONSOLE_GROUP_VERSION = "console.
|
|
18
|
+
public static final String CONSOLE_GROUP_VERSION = "console."
|
|
19
|
+
+ PluginTemplateSettingKeys.API_GROUP_SUFFIX + "/v1alpha1";
|
|
18
20
|
public static final String CONSOLE_TAG = "PluginTemplateConsole";
|
|
19
21
|
|
|
20
22
|
private final PluginTemplateOverviewRoutes overviewRoutes;
|
|
@@ -5,6 +5,7 @@ import org.springframework.web.reactive.function.server.RouterFunction;
|
|
|
5
5
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
|
6
6
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
|
7
7
|
import run.halo.app.extension.GroupVersion;
|
|
8
|
+
import run.halo.plugintemplate.setting.PluginTemplateSettingKeys;
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Public endpoint placeholder.
|
|
@@ -12,7 +13,8 @@ import run.halo.app.extension.GroupVersion;
|
|
|
12
13
|
*/
|
|
13
14
|
public class PluginTemplatePublicEndpoint implements CustomEndpoint {
|
|
14
15
|
|
|
15
|
-
public static final String PUBLIC_GROUP_VERSION = "public.
|
|
16
|
+
public static final String PUBLIC_GROUP_VERSION = "public."
|
|
17
|
+
+ PluginTemplateSettingKeys.API_GROUP_SUFFIX + "/v1alpha1";
|
|
16
18
|
|
|
17
19
|
@Override
|
|
18
20
|
public RouterFunction<ServerResponse> endpoint() {
|
|
@@ -9,12 +9,14 @@ import org.springframework.web.reactive.function.server.ServerResponse;
|
|
|
9
9
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
|
10
10
|
import run.halo.app.extension.GroupVersion;
|
|
11
11
|
import run.halo.plugintemplate.endpoint.routes.PluginTemplateOverviewRoutes;
|
|
12
|
+
import run.halo.plugintemplate.setting.PluginTemplateSettingKeys;
|
|
12
13
|
|
|
13
14
|
@Component
|
|
14
15
|
@RequiredArgsConstructor
|
|
15
16
|
public class PluginTemplateUcEndpoint implements CustomEndpoint {
|
|
16
17
|
|
|
17
|
-
public static final String UC_GROUP_VERSION = "uc.
|
|
18
|
+
public static final String UC_GROUP_VERSION = "uc."
|
|
19
|
+
+ PluginTemplateSettingKeys.API_GROUP_SUFFIX + "/v1alpha1";
|
|
18
20
|
public static final String UC_TAG = "PluginTemplateUc";
|
|
19
21
|
|
|
20
22
|
private final PluginTemplateOverviewRoutes overviewRoutes;
|
|
@@ -8,12 +8,13 @@ import lombok.EqualsAndHashCode;
|
|
|
8
8
|
import lombok.ToString;
|
|
9
9
|
import run.halo.app.extension.AbstractExtension;
|
|
10
10
|
import run.halo.app.extension.GVK;
|
|
11
|
+
import run.halo.plugintemplate.setting.PluginTemplateSettingKeys;
|
|
11
12
|
|
|
12
13
|
@Data
|
|
13
14
|
@EqualsAndHashCode(callSuper = true)
|
|
14
15
|
@ToString(callSuper = true)
|
|
15
16
|
@GVK(
|
|
16
|
-
group =
|
|
17
|
+
group = PluginTemplateSettingKeys.API_GROUP_SUFFIX,
|
|
17
18
|
version = "v1alpha1",
|
|
18
19
|
kind = "PluginTemplateRecord",
|
|
19
20
|
plural = "pluginTemplateRecords",
|
|
@@ -6,8 +6,12 @@ public final class PluginTemplateSettingKeys {
|
|
|
6
6
|
public static final String DISPLAY_NAME = "Halo Plugin Template";
|
|
7
7
|
public static final String SETTING_NAME = "halo-plugin-template-settings";
|
|
8
8
|
public static final String CONFIG_MAP_NAME = "halo-plugin-template-configmap";
|
|
9
|
-
public static final String
|
|
10
|
-
public static final String
|
|
9
|
+
public static final String ROUTE_PREFIX = "/halo-plugin-template";
|
|
10
|
+
public static final String PERMISSION_PREFIX = "plugin:halo-plugin-template";
|
|
11
|
+
public static final String API_GROUP_SUFFIX = "plugintemplate.halo.run";
|
|
12
|
+
public static final String API_GROUP_KEY = "plugintemplate";
|
|
13
|
+
public static final String CONSOLE_PATH = ROUTE_PREFIX;
|
|
14
|
+
public static final String UC_PATH = ROUTE_PREFIX;
|
|
11
15
|
public static final String GENERATED_CLIENT_PATH = "ui/src/api/generated";
|
|
12
16
|
public static final String GENERAL_GROUP = "general";
|
|
13
17
|
public static final String UI_GROUP = "ui";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
kind: Role
|
|
2
2
|
apiVersion: v1alpha1
|
|
3
3
|
metadata:
|
|
4
|
-
name: rt-console.
|
|
4
|
+
name: rt-console.plugintemplate.halo.run-view
|
|
5
5
|
labels:
|
|
6
6
|
halo.run/role-template: "true"
|
|
7
7
|
annotations:
|
|
@@ -11,7 +11,7 @@ metadata:
|
|
|
11
11
|
rbac.authorization.halo.run/module: "Halo 插件模板"
|
|
12
12
|
rules:
|
|
13
13
|
- apiGroups:
|
|
14
|
-
- console.
|
|
14
|
+
- console.plugintemplate.halo.run
|
|
15
15
|
resources:
|
|
16
16
|
- template-overview
|
|
17
17
|
verbs:
|
|
@@ -21,7 +21,7 @@ rules:
|
|
|
21
21
|
kind: Role
|
|
22
22
|
apiVersion: v1alpha1
|
|
23
23
|
metadata:
|
|
24
|
-
name: rt-console.
|
|
24
|
+
name: rt-console.plugintemplate.halo.run-manage
|
|
25
25
|
labels:
|
|
26
26
|
halo.run/role-template: "true"
|
|
27
27
|
annotations:
|
|
@@ -31,7 +31,7 @@ metadata:
|
|
|
31
31
|
rbac.authorization.halo.run/module: "Halo 插件模板"
|
|
32
32
|
rules:
|
|
33
33
|
- apiGroups:
|
|
34
|
-
- console.
|
|
34
|
+
- console.plugintemplate.halo.run
|
|
35
35
|
resources:
|
|
36
36
|
- template-overview
|
|
37
37
|
verbs:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
kind: Role
|
|
2
2
|
apiVersion: v1alpha1
|
|
3
3
|
metadata:
|
|
4
|
-
name: rt-uc.
|
|
4
|
+
name: rt-uc.plugintemplate.halo.run-personal
|
|
5
5
|
labels:
|
|
6
6
|
halo.run/role-template: "true"
|
|
7
7
|
annotations:
|
|
@@ -11,7 +11,7 @@ metadata:
|
|
|
11
11
|
rbac.authorization.halo.run/module: "Halo 插件模板"
|
|
12
12
|
rules:
|
|
13
13
|
- apiGroups:
|
|
14
|
-
- uc.
|
|
14
|
+
- uc.plugintemplate.halo.run
|
|
15
15
|
resources:
|
|
16
16
|
- template-overview
|
|
17
17
|
verbs:
|