@robsun/create-keystone-app 0.2.0 → 0.2.2

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 CHANGED
@@ -5,6 +5,7 @@
5
5
  npx @robsun/create-keystone-app <dir> [options]
6
6
  pnpm dlx @robsun/create-keystone-app <dir> [options]
7
7
  ```
8
+ 不传 options 会进入交互式向导。
8
9
 
9
10
  ## 选项
10
11
  - `<dir>`:目标目录(必填),可为新目录名或 `.`(当前目录)。
@@ -17,6 +18,19 @@ pnpm dlx @robsun/create-keystone-app <dir> [options]
17
18
  npx @robsun/create-keystone-app my-app --db=postgres --queue=redis --storage=s3
18
19
  ```
19
20
 
21
+ ## 模块生成器
22
+ ```bash
23
+ npx --package @robsun/create-keystone-app create-keystone-module <module-name> [options]
24
+ pnpm dlx --package @robsun/create-keystone-app create-keystone-module <module-name> [options]
25
+ ```
26
+ 不传 options 会进入交互式向导。
27
+
28
+ 选项:
29
+ - `--frontend-only`:只生成前端模块。
30
+ - `--backend-only`:只生成后端模块。
31
+ - `--with-crud`:包含 CRUD 示例代码。
32
+ - `--skip-register`:跳过自动注册步骤。
33
+
20
34
  ## 初始化后操作
21
35
  ```bash
22
36
  cd <dir>
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
+ const readline = require('readline/promises');
5
+ const { stdin: input, stdout: output } = require('process');
4
6
 
5
7
  const usage = [
6
8
  'Usage: create-keystone-app <dir> [options]',
@@ -10,53 +12,129 @@ const usage = [
10
12
  ' --queue <memory|redis> Queue driver (default: memory)',
11
13
  ' --storage <local|s3> Storage driver (default: local)',
12
14
  ' -h, --help Show help',
15
+ '',
16
+ 'Run without options to use guided prompts.',
13
17
  ].join('\n');
14
18
 
15
- const args = parseArgs(process.argv.slice(2));
16
- if (args.help) {
17
- console.log(usage);
18
- process.exit(0);
19
- }
20
-
21
- if (!args.target) {
22
- console.error(usage);
19
+ main().catch((err) => {
20
+ const message = err instanceof Error ? err.message : String(err);
21
+ console.error(message);
23
22
  process.exit(1);
24
- }
23
+ });
24
+
25
+ async function main() {
26
+ const args = parseArgs(process.argv.slice(2));
27
+ if (args.help) {
28
+ console.log(usage);
29
+ return;
30
+ }
31
+
32
+ const canPrompt = isInteractive();
33
+ const rl = canPrompt ? readline.createInterface({ input, output }) : null;
34
+ let db;
35
+ let queue;
36
+ let storage;
37
+
38
+ try {
39
+ if (!args.target) {
40
+ if (!rl) {
41
+ console.error(usage);
42
+ process.exit(1);
43
+ }
44
+ args.target = await promptText(rl, 'Project directory', 'keystone-app');
45
+ }
46
+
47
+ db = normalizeChoice(args.db, ['sqlite', 'postgres'], 'db') || null;
48
+ queue = normalizeChoice(args.queue, ['memory', 'redis'], 'queue') || null;
49
+ storage = normalizeChoice(args.storage, ['local', 's3'], 'storage') || null;
25
50
 
26
- const db = normalizeChoice(args.db, ['sqlite', 'postgres'], 'db') || 'sqlite';
27
- const queue = normalizeChoice(args.queue, ['memory', 'redis'], 'queue') || 'memory';
28
- const storage = normalizeChoice(args.storage, ['local', 's3'], 'storage') || 'local';
51
+ if (!db) {
52
+ db = rl ? await promptSelect(rl, 'Select database driver', ['sqlite', 'postgres'], 0) : 'sqlite';
53
+ }
54
+ if (!queue) {
55
+ queue = rl ? await promptSelect(rl, 'Select queue driver', ['memory', 'redis'], 0) : 'memory';
56
+ }
57
+ if (!storage) {
58
+ storage = rl ? await promptSelect(rl, 'Select storage driver', ['local', 's3'], 0) : 'local';
59
+ }
60
+ } finally {
61
+ if (rl) {
62
+ rl.close();
63
+ }
64
+ }
29
65
 
30
- const targetDir = path.resolve(process.cwd(), args.target);
31
- const targetName = args.target === '.'
32
- ? path.basename(process.cwd())
33
- : path.basename(targetDir);
66
+ const targetDir = path.resolve(process.cwd(), args.target);
67
+ const targetName = args.target === '.'
68
+ ? path.basename(process.cwd())
69
+ : path.basename(targetDir);
34
70
 
35
- if (fs.existsSync(targetDir)) {
36
- const entries = fs.readdirSync(targetDir);
37
- if (entries.length > 0) {
38
- console.error(`Target directory is not empty: ${targetDir}`);
39
- process.exit(1);
71
+ if (fs.existsSync(targetDir)) {
72
+ const entries = fs.readdirSync(targetDir);
73
+ if (entries.length > 0) {
74
+ console.error(`Target directory is not empty: ${targetDir}`);
75
+ process.exit(1);
76
+ }
77
+ } else {
78
+ fs.mkdirSync(targetDir, { recursive: true });
40
79
  }
41
- } else {
42
- fs.mkdirSync(targetDir, { recursive: true });
80
+
81
+ const templateDir = path.resolve(__dirname, '..', 'template');
82
+ copyDir(templateDir, targetDir, {
83
+ '__APP_NAME__': normalizePackageName(targetName),
84
+ '__RAW_NAME__': targetName,
85
+ });
86
+
87
+ applyConfigOptions(targetDir, { db, queue, storage });
88
+
89
+ console.log(`Created ${targetName}`);
90
+ console.log('Next steps:');
91
+ console.log(` cd ${args.target}`);
92
+ console.log(' pnpm install');
93
+ console.log(' pnpm server:dev');
94
+ console.log(' pnpm web:dev');
95
+ console.log(' pnpm dev');
43
96
  }
44
97
 
45
- const templateDir = path.resolve(__dirname, '..', 'template');
46
- copyDir(templateDir, targetDir, {
47
- '__APP_NAME__': normalizePackageName(targetName),
48
- '__RAW_NAME__': targetName,
49
- });
98
+ function isInteractive() {
99
+ return Boolean(input.isTTY && output.isTTY);
100
+ }
50
101
 
51
- applyConfigOptions(targetDir, { db, queue, storage });
102
+ async function promptText(rl, label, defaultValue) {
103
+ while (true) {
104
+ const suffix = defaultValue ? ` (${defaultValue})` : '';
105
+ const answer = await rl.question(`${label}${suffix}: `);
106
+ const trimmed = answer.trim();
107
+ if (trimmed) {
108
+ return trimmed;
109
+ }
110
+ if (defaultValue) {
111
+ return defaultValue;
112
+ }
113
+ console.log('Please enter a value.');
114
+ }
115
+ }
52
116
 
53
- console.log(`Created ${targetName}`);
54
- console.log('Next steps:');
55
- console.log(` cd ${args.target}`);
56
- console.log(' pnpm install');
57
- console.log(' pnpm server:dev');
58
- console.log(' pnpm web:dev');
59
- console.log(' pnpm dev');
117
+ async function promptSelect(rl, label, choices, defaultIndex) {
118
+ while (true) {
119
+ const options = choices.map((choice, index) => ` ${index + 1}) ${choice}`).join('\n');
120
+ const answer = await rl.question(
121
+ `${label}\n${options}\nSelect an option [${defaultIndex + 1}]: `
122
+ );
123
+ const normalized = answer.trim().toLowerCase();
124
+ if (!normalized) {
125
+ return choices[defaultIndex];
126
+ }
127
+ const numeric = Number(normalized);
128
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length) {
129
+ return choices[numeric - 1];
130
+ }
131
+ const match = choices.find((choice) => choice.toLowerCase() === normalized);
132
+ if (match) {
133
+ return match;
134
+ }
135
+ console.log('Invalid selection. Try again.');
136
+ }
137
+ }
60
138
 
61
139
  function normalizePackageName(name) {
62
140
  const cleaned = String(name || '')
@@ -113,7 +191,6 @@ function applyConfigOptions(targetDir, options) {
113
191
  for (const filePath of configFiles) {
114
192
  updateFile(filePath, (content) => applyYamlOptions(content, options));
115
193
  }
116
-
117
194
  }
118
195
 
119
196
  function applyYamlOptions(content, options) {
@@ -236,4 +313,4 @@ function fail(message) {
236
313
  console.error(message);
237
314
  console.error(usage);
238
315
  process.exit(1);
239
- }
316
+ }
@@ -0,0 +1,638 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const readline = require('readline/promises')
5
+ const { stdin: input, stdout: output } = require('process')
6
+
7
+ const usage = [
8
+ 'Usage: create-module <module-name> [options]',
9
+ '',
10
+ 'Options:',
11
+ ' --frontend-only Only generate the frontend module',
12
+ ' --backend-only Only generate the backend module',
13
+ ' --with-crud Include CRUD example code',
14
+ ' --skip-register Skip auto registration steps',
15
+ ' -h, --help Show help',
16
+ '',
17
+ 'Run without options to use guided prompts.',
18
+ ].join('\n')
19
+
20
+ main().catch((err) => {
21
+ const message = err instanceof Error ? err.message : String(err)
22
+ console.error(message)
23
+ process.exit(1)
24
+ })
25
+
26
+ async function main() {
27
+ const args = parseArgs(process.argv.slice(2))
28
+ if (args.help) {
29
+ console.log(usage)
30
+ return
31
+ }
32
+
33
+ const canPrompt = isInteractive()
34
+ const rl = canPrompt ? readline.createInterface({ input, output }) : null
35
+
36
+ let includeFrontend = !args.backendOnly
37
+ let includeBackend = !args.frontendOnly
38
+ let withCrud = args.withCrud
39
+ let skipRegister = args.skipRegister
40
+
41
+ try {
42
+ if (!args.target) {
43
+ if (!rl) {
44
+ console.error(usage)
45
+ process.exit(1)
46
+ }
47
+ args.target = await promptText(rl, 'Module name')
48
+ }
49
+
50
+ if (args.frontendOnly && args.backendOnly) {
51
+ fail('Use either --frontend-only or --backend-only, not both.')
52
+ }
53
+
54
+ if (!args.frontendOnly && !args.backendOnly && rl) {
55
+ const selection = await promptSelect(
56
+ rl,
57
+ 'Generate which module(s)?',
58
+ ['frontend + backend', 'frontend only', 'backend only'],
59
+ 0
60
+ )
61
+ if (selection === 'frontend only') {
62
+ includeFrontend = true
63
+ includeBackend = false
64
+ } else if (selection === 'backend only') {
65
+ includeFrontend = false
66
+ includeBackend = true
67
+ }
68
+ }
69
+
70
+ if (!args.withCrud && rl) {
71
+ withCrud = await promptConfirm(rl, 'Include CRUD example code', false)
72
+ }
73
+ if (!args.skipRegister && rl) {
74
+ const autoRegister = await promptConfirm(rl, 'Auto-register module', true)
75
+ skipRegister = !autoRegister
76
+ }
77
+ } finally {
78
+ if (rl) {
79
+ rl.close()
80
+ }
81
+ }
82
+
83
+ const names = buildNames(args.target)
84
+ const rootDir = resolveProjectRoot(process.cwd(), includeFrontend, includeBackend)
85
+
86
+ const frontendSourceDir = path.join(rootDir, 'apps', 'web', 'src', 'modules', 'example')
87
+ const frontendTargetDir = path.join(rootDir, 'apps', 'web', 'src', 'modules', names.kebab)
88
+ const backendSourceDir = path.join(rootDir, 'apps', 'server', 'internal', 'modules', 'example')
89
+ const backendTargetDir = path.join(rootDir, 'apps', 'server', 'internal', 'modules', names.kebab)
90
+
91
+ if (includeFrontend) {
92
+ if (!fs.existsSync(frontendSourceDir)) {
93
+ fail(`Frontend example module not found at ${frontendSourceDir}`)
94
+ }
95
+ if (fs.existsSync(frontendTargetDir)) {
96
+ fail(`Frontend module already exists: ${frontendTargetDir}`)
97
+ }
98
+ copyModule(frontendSourceDir, frontendTargetDir, names, 'frontend')
99
+ if (!withCrud) {
100
+ pruneFrontendCrud(frontendTargetDir, names)
101
+ }
102
+ }
103
+
104
+ if (includeBackend) {
105
+ if (!fs.existsSync(backendSourceDir)) {
106
+ fail(`Backend example module not found at ${backendSourceDir}`)
107
+ }
108
+ if (fs.existsSync(backendTargetDir)) {
109
+ fail(`Backend module already exists: ${backendTargetDir}`)
110
+ }
111
+ copyModule(backendSourceDir, backendTargetDir, names, 'backend')
112
+ if (!withCrud) {
113
+ pruneBackendCrud(backendTargetDir, names)
114
+ }
115
+ }
116
+
117
+ if (!skipRegister) {
118
+ if (includeFrontend) {
119
+ registerFrontendModule(path.join(rootDir, 'apps', 'web', 'src', 'main.tsx'), names)
120
+ }
121
+ if (includeBackend) {
122
+ registerBackendModule(
123
+ path.join(rootDir, 'apps', 'server', 'internal', 'modules', 'manifest.go'),
124
+ path.join(rootDir, 'apps', 'server', 'config.yaml'),
125
+ names
126
+ )
127
+ }
128
+ }
129
+
130
+ const targets = []
131
+ if (includeFrontend) targets.push('frontend')
132
+ if (includeBackend) targets.push('backend')
133
+
134
+ console.log(`Created ${names.kebab} module (${targets.join(' + ')})`)
135
+ if (skipRegister) {
136
+ console.log('Registration skipped. Update the app entrypoints manually.')
137
+ } else {
138
+ console.log('Registration updated.')
139
+ }
140
+ if (!withCrud) {
141
+ console.log('Generated minimal scaffolding (no CRUD example).')
142
+ }
143
+ }
144
+
145
+ function isInteractive() {
146
+ return Boolean(input.isTTY && output.isTTY)
147
+ }
148
+
149
+ async function promptText(rl, label) {
150
+ while (true) {
151
+ const answer = await rl.question(`${label}: `)
152
+ const trimmed = answer.trim()
153
+ if (trimmed) {
154
+ return trimmed
155
+ }
156
+ console.log('Please enter a value.')
157
+ }
158
+ }
159
+
160
+ async function promptSelect(rl, label, choices, defaultIndex) {
161
+ while (true) {
162
+ const options = choices.map((choice, index) => ` ${index + 1}) ${choice}`).join('\n')
163
+ const answer = await rl.question(
164
+ `${label}\n${options}\nSelect an option [${defaultIndex + 1}]: `
165
+ )
166
+ const normalized = answer.trim().toLowerCase()
167
+ if (!normalized) {
168
+ return choices[defaultIndex]
169
+ }
170
+ const numeric = Number(normalized)
171
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length) {
172
+ return choices[numeric - 1]
173
+ }
174
+ const match = choices.find((choice) => choice.toLowerCase() === normalized)
175
+ if (match) {
176
+ return match
177
+ }
178
+ console.log('Invalid selection. Try again.')
179
+ }
180
+ }
181
+
182
+ async function promptConfirm(rl, label, defaultValue) {
183
+ while (true) {
184
+ const suffix = defaultValue ? 'Y/n' : 'y/N'
185
+ const answer = await rl.question(`${label} (${suffix}): `)
186
+ const normalized = answer.trim().toLowerCase()
187
+ if (!normalized) {
188
+ return defaultValue
189
+ }
190
+ if (['y', 'yes'].includes(normalized)) {
191
+ return true
192
+ }
193
+ if (['n', 'no'].includes(normalized)) {
194
+ return false
195
+ }
196
+ console.log('Please enter y or n.')
197
+ }
198
+ }
199
+
200
+ function buildNames(raw) {
201
+ const words = splitWords(raw)
202
+ if (words.length === 0) {
203
+ fail(`Invalid module name: ${raw}`)
204
+ }
205
+ const capitalized = words.map(capitalize)
206
+ const kebab = words.join('-')
207
+ const camel = words[0] + capitalized.slice(1).join('')
208
+ const pascal = capitalized.join('')
209
+ const lower = words.join('')
210
+ const display = capitalized.join(' ')
211
+ return {
212
+ raw,
213
+ words,
214
+ kebab,
215
+ camel,
216
+ pascal,
217
+ lower,
218
+ display,
219
+ }
220
+ }
221
+
222
+ function splitWords(value) {
223
+ const normalized = String(value || '')
224
+ .trim()
225
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
226
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
227
+ .trim()
228
+ if (!normalized) return []
229
+ return normalized.split(/\s+/).map((word) => word.toLowerCase())
230
+ }
231
+
232
+ function capitalize(value) {
233
+ return value ? value[0].toUpperCase() + value.slice(1) : ''
234
+ }
235
+
236
+ function resolveProjectRoot(startDir, includeFrontend, includeBackend) {
237
+ const candidates = [
238
+ startDir,
239
+ path.join(startDir, 'packages', 'create-keystone-app', 'template'),
240
+ path.join(startDir, 'template'),
241
+ ]
242
+ for (const candidate of candidates) {
243
+ const frontendOk =
244
+ !includeFrontend || fs.existsSync(path.join(candidate, 'apps', 'web', 'src', 'modules', 'example'))
245
+ const backendOk =
246
+ !includeBackend || fs.existsSync(path.join(candidate, 'apps', 'server', 'internal', 'modules', 'example'))
247
+ if (frontendOk && backendOk) {
248
+ return candidate
249
+ }
250
+ }
251
+ fail('Could not find a project with apps/web and apps/server module templates.')
252
+ }
253
+
254
+ function copyModule(srcDir, destDir, names, mode) {
255
+ copyDir(srcDir, destDir, (entryName) => renameSegment(entryName, names), (content, filePath) =>
256
+ transformContent(content, filePath, names, mode)
257
+ )
258
+ }
259
+
260
+ function copyDir(src, dest, renameEntry, transform) {
261
+ fs.mkdirSync(dest, { recursive: true })
262
+ const entries = fs.readdirSync(src, { withFileTypes: true })
263
+ for (const entry of entries) {
264
+ const srcPath = path.join(src, entry.name)
265
+ const nextName = renameEntry(entry.name)
266
+ const destPath = path.join(dest, nextName)
267
+ if (entry.isDirectory()) {
268
+ copyDir(srcPath, destPath, renameEntry, transform)
269
+ } else {
270
+ copyFile(srcPath, destPath, transform)
271
+ }
272
+ }
273
+ }
274
+
275
+ function copyFile(src, dest, transform) {
276
+ const content = fs.readFileSync(src, 'utf8')
277
+ const next = transform ? transform(content, dest) : content
278
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
279
+ fs.writeFileSync(dest, next, 'utf8')
280
+ }
281
+
282
+ function renameSegment(segment, names) {
283
+ if (segment === 'example') {
284
+ return names.kebab
285
+ }
286
+ return segment.replace(/Example/g, names.pascal).replace(/example/g, names.camel)
287
+ }
288
+
289
+ function transformContent(content, filePath, names, mode) {
290
+ const ext = path.extname(filePath).toLowerCase()
291
+ if (ext === '.go') {
292
+ return replaceInCode(
293
+ content,
294
+ [
295
+ ['Example', names.pascal],
296
+ ['example', names.lower],
297
+ ],
298
+ (value) => replaceStringLiteral(value, names, mode === 'backend' ? names.lower : names.camel)
299
+ )
300
+ }
301
+ if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) {
302
+ return replaceInCode(
303
+ content,
304
+ [
305
+ ['Example', names.pascal],
306
+ ['example', names.camel],
307
+ ],
308
+ (value) => replaceStringLiteral(value, names, names.camel)
309
+ )
310
+ }
311
+ return replaceText(content, names)
312
+ }
313
+
314
+ function replaceText(content, names) {
315
+ return applyReplacements(content, [
316
+ ['Example', names.display],
317
+ ['example', names.kebab],
318
+ ])
319
+ }
320
+
321
+ function replaceStringLiteral(value, names, pathWord) {
322
+ if (value.startsWith('./') || value.startsWith('../')) {
323
+ return applyReplacements(value, [
324
+ ['Example', names.pascal],
325
+ ['example', pathWord],
326
+ ])
327
+ }
328
+ return applyReplacements(value, [
329
+ ['Example', names.display],
330
+ ['example', names.kebab],
331
+ ])
332
+ }
333
+
334
+ function replaceInCode(content, codeReplacements, stringReplacer) {
335
+ let out = ''
336
+ let i = 0
337
+ let start = 0
338
+ let inString = null
339
+ let escaped = false
340
+ while (i < content.length) {
341
+ const ch = content[i]
342
+ if (!inString) {
343
+ if (ch === '"' || ch === "'" || ch === '`') {
344
+ out += applyReplacements(content.slice(start, i), codeReplacements)
345
+ inString = ch
346
+ out += ch
347
+ i += 1
348
+ start = i
349
+ continue
350
+ }
351
+ i += 1
352
+ continue
353
+ }
354
+ if (escaped) {
355
+ escaped = false
356
+ i += 1
357
+ continue
358
+ }
359
+ if (ch === '\\' && inString !== '`') {
360
+ escaped = true
361
+ i += 1
362
+ continue
363
+ }
364
+ if (ch === inString) {
365
+ out += stringReplacer(content.slice(start, i))
366
+ out += ch
367
+ inString = null
368
+ i += 1
369
+ start = i
370
+ continue
371
+ }
372
+ i += 1
373
+ }
374
+ if (start < content.length) {
375
+ const tail = content.slice(start)
376
+ out += inString ? stringReplacer(tail) : applyReplacements(tail, codeReplacements)
377
+ }
378
+ return out
379
+ }
380
+
381
+ function applyReplacements(value, replacements) {
382
+ let next = value
383
+ for (const [from, to] of replacements) {
384
+ next = next.split(from).join(to)
385
+ }
386
+ return next
387
+ }
388
+
389
+ function pruneFrontendCrud(targetDir, names) {
390
+ const servicesDir = path.join(targetDir, 'services')
391
+ if (fs.existsSync(servicesDir)) {
392
+ fs.rmSync(servicesDir, { recursive: true, force: true })
393
+ }
394
+ const typesFile = path.join(targetDir, 'types.ts')
395
+ if (fs.existsSync(typesFile)) {
396
+ fs.rmSync(typesFile, { force: true })
397
+ }
398
+ const pageFile = path.join(targetDir, 'pages', `${names.pascal}ItemsPage.tsx`)
399
+ const content = [
400
+ "import { Card, Typography } from 'antd'",
401
+ '',
402
+ `export function ${names.pascal}ItemsPage() {`,
403
+ ' return (',
404
+ ` <Card title="${names.display}">`,
405
+ ' <Typography.Paragraph type="secondary">',
406
+ ` ${names.display} module is ready. Start building your pages here.`,
407
+ ' </Typography.Paragraph>',
408
+ ' </Card>',
409
+ ' )',
410
+ '}',
411
+ '',
412
+ ].join('\n')
413
+ fs.writeFileSync(pageFile, content, 'utf8')
414
+ }
415
+
416
+ function pruneBackendCrud(targetDir, names) {
417
+ const dirs = ['api', 'bootstrap', 'domain', 'infra']
418
+ for (const dir of dirs) {
419
+ const fullPath = path.join(targetDir, dir)
420
+ if (fs.existsSync(fullPath)) {
421
+ fs.rmSync(fullPath, { recursive: true, force: true })
422
+ }
423
+ }
424
+ const moduleFile = path.join(targetDir, 'module.go')
425
+ const content = [
426
+ `package ${names.lower}`,
427
+ '',
428
+ 'import (',
429
+ '\t"github.com/gin-gonic/gin"',
430
+ '\t"gorm.io/gorm"',
431
+ '',
432
+ '\t"github.com/robsuncn/keystone/domain/permissions"',
433
+ '\t"github.com/robsuncn/keystone/infra/jobs"',
434
+ ')',
435
+ '',
436
+ 'type Module struct{}',
437
+ '',
438
+ 'func NewModule() *Module {',
439
+ '\treturn &Module{}',
440
+ '}',
441
+ '',
442
+ 'func (m *Module) Name() string {',
443
+ `\treturn "${names.kebab}"`,
444
+ '}',
445
+ '',
446
+ 'func (m *Module) RegisterRoutes(_ *gin.RouterGroup) {}',
447
+ '',
448
+ 'func (m *Module) RegisterModels() []interface{} {',
449
+ '\treturn nil',
450
+ '}',
451
+ '',
452
+ 'func (m *Module) RegisterPermissions(reg *permissions.Registry) error {',
453
+ '\tif reg == nil {',
454
+ '\t\treturn nil',
455
+ '\t}',
456
+ `\tif err := reg.CreateMenu("${names.kebab}:overview", "${names.display}", "${names.kebab}", 10); err != nil {`,
457
+ '\t\treturn err',
458
+ '\t}',
459
+ `\tif err := reg.CreateAction("${names.kebab}:overview:view", "View ${names.display}", "${names.kebab}", "${names.kebab}:overview"); err != nil {`,
460
+ '\t\treturn err',
461
+ '\t}',
462
+ '\treturn nil',
463
+ '}',
464
+ '',
465
+ 'func (m *Module) RegisterJobs(_ *jobs.Registry) error {',
466
+ '\treturn nil',
467
+ '}',
468
+ '',
469
+ 'func (m *Module) Migrate(_ *gorm.DB) error {',
470
+ '\treturn nil',
471
+ '}',
472
+ '',
473
+ 'func (m *Module) Seed(_ *gorm.DB) error {',
474
+ '\treturn nil',
475
+ '}',
476
+ '',
477
+ ].join('\n')
478
+ fs.writeFileSync(moduleFile, content, 'utf8')
479
+ }
480
+
481
+ function registerFrontendModule(mainPath, names) {
482
+ if (!fs.existsSync(mainPath)) {
483
+ return
484
+ }
485
+ const content = fs.readFileSync(mainPath, 'utf8')
486
+ const importLine = `import './modules/${names.kebab}'`
487
+ if (content.includes(importLine)) {
488
+ return
489
+ }
490
+ const lines = content.split(/\r?\n/)
491
+ let lastImport = -1
492
+ for (let i = 0; i < lines.length; i += 1) {
493
+ if (/^\s*import\s/.test(lines[i])) {
494
+ lastImport = i
495
+ }
496
+ }
497
+ if (lastImport === -1) {
498
+ lines.unshift(importLine)
499
+ } else {
500
+ lines.splice(lastImport + 1, 0, importLine)
501
+ }
502
+ fs.writeFileSync(mainPath, lines.join('\n'), 'utf8')
503
+ }
504
+
505
+ function registerBackendModule(manifestPath, configPath, names) {
506
+ updateManifest(manifestPath, names)
507
+ updateConfig(configPath, names)
508
+ }
509
+
510
+ function updateManifest(manifestPath, names) {
511
+ if (!fs.existsSync(manifestPath)) {
512
+ return
513
+ }
514
+ const content = fs.readFileSync(manifestPath, 'utf8')
515
+ const lines = content.split(/\r?\n/)
516
+ const importStart = lines.findIndex((line) => line.trim() === 'import (')
517
+ if (importStart === -1) {
518
+ return
519
+ }
520
+ const importEnd = lines.findIndex((line, index) => index > importStart && line.trim() === ')')
521
+ if (importEnd === -1) {
522
+ return
523
+ }
524
+ const importBlock = lines.slice(importStart + 1, importEnd)
525
+ const exampleImport = importBlock.find((line) => line.includes('/internal/modules/example'))
526
+ if (!exampleImport) {
527
+ return
528
+ }
529
+ const pathMatch = exampleImport.match(/\"([^\"]+)\"/)
530
+ if (!pathMatch) {
531
+ return
532
+ }
533
+ const modulePath = pathMatch[1].replace(/\/example$/, `/${names.kebab}`)
534
+ const alias = names.lower
535
+ const importLine = `${alias} "${modulePath}"`
536
+ if (!importBlock.some((line) => line.includes(`/${names.kebab}"`))) {
537
+ lines.splice(importEnd, 0, importLine)
538
+ }
539
+ const registerLine = `\tRegister(${alias}.NewModule())`
540
+ if (!lines.some((line) => line.trim() === registerLine.trim())) {
541
+ const registerIndices = lines
542
+ .map((line, index) => ({ line, index }))
543
+ .filter(({ line }) => line.includes('Register('))
544
+ if (registerIndices.length > 0) {
545
+ lines.splice(registerIndices[registerIndices.length - 1].index + 1, 0, registerLine)
546
+ } else {
547
+ const clearIndex = lines.findIndex((line) => line.includes('Clear()'))
548
+ if (clearIndex !== -1) {
549
+ lines.splice(clearIndex + 1, 0, registerLine)
550
+ }
551
+ }
552
+ }
553
+ fs.writeFileSync(manifestPath, lines.join('\n'), 'utf8')
554
+ }
555
+
556
+ function updateConfig(configPath, names) {
557
+ if (!fs.existsSync(configPath)) {
558
+ return
559
+ }
560
+ const content = fs.readFileSync(configPath, 'utf8')
561
+ if (content.includes(`- "${names.kebab}"`) || content.includes(`- '${names.kebab}'`)) {
562
+ return
563
+ }
564
+ const lines = content.split(/\r?\n/)
565
+ const modulesIndex = lines.findIndex((line) => line.trim() === 'modules:')
566
+ if (modulesIndex === -1) {
567
+ return
568
+ }
569
+ const enabledIndex = lines.findIndex((line, index) => index > modulesIndex && line.trim() === 'enabled:')
570
+ if (enabledIndex === -1) {
571
+ return
572
+ }
573
+ let insertIndex = enabledIndex + 1
574
+ let indent = null
575
+ for (let i = enabledIndex + 1; i < lines.length; i += 1) {
576
+ const line = lines[i]
577
+ if (!line.trim()) continue
578
+ if (!line.trim().startsWith('-')) {
579
+ insertIndex = i
580
+ break
581
+ }
582
+ if (indent === null) {
583
+ indent = line.match(/^\s*/)?.[0] ?? ''
584
+ }
585
+ insertIndex = i + 1
586
+ }
587
+ const prefix = indent ?? ' '
588
+ lines.splice(insertIndex, 0, `${prefix}- "${names.kebab}"`)
589
+ fs.writeFileSync(configPath, lines.join('\n'), 'utf8')
590
+ }
591
+
592
+ function parseArgs(argv) {
593
+ const out = {
594
+ target: null,
595
+ frontendOnly: false,
596
+ backendOnly: false,
597
+ withCrud: false,
598
+ skipRegister: false,
599
+ help: false,
600
+ }
601
+ for (const arg of argv) {
602
+ if (arg === '--help' || arg === '-h') {
603
+ out.help = true
604
+ continue
605
+ }
606
+ if (arg === '--frontend-only') {
607
+ out.frontendOnly = true
608
+ continue
609
+ }
610
+ if (arg === '--backend-only') {
611
+ out.backendOnly = true
612
+ continue
613
+ }
614
+ if (arg === '--with-crud') {
615
+ out.withCrud = true
616
+ continue
617
+ }
618
+ if (arg === '--skip-register') {
619
+ out.skipRegister = true
620
+ continue
621
+ }
622
+ if (arg.startsWith('-')) {
623
+ fail(`Unknown option: ${arg}`)
624
+ }
625
+ if (!out.target) {
626
+ out.target = arg
627
+ continue
628
+ }
629
+ fail('Too many arguments.')
630
+ }
631
+ return out
632
+ }
633
+
634
+ function fail(message) {
635
+ console.error(message)
636
+ console.error(usage)
637
+ process.exit(1)
638
+ }
package/package.json CHANGED
@@ -1,19 +1,23 @@
1
- {
1
+ {
2
2
  "name": "@robsun/create-keystone-app",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
+ "scripts": {
5
+ "build": "node scripts/build.js",
6
+ "prepublishOnly": "node scripts/build.js && node scripts/prune-template-deps.js"
7
+ },
4
8
  "publishConfig": {
5
9
  "access": "public"
6
10
  },
7
11
  "bin": {
8
- "create-keystone-app": "bin/create-keystone-app.js"
12
+ "create-keystone-app": "dist/create-keystone-app.js",
13
+ "create-keystone-module": "dist/create-module.js"
9
14
  },
10
15
  "files": [
11
- "bin",
16
+ "dist",
12
17
  "template",
13
18
  "README.md"
14
19
  ],
15
20
  "engines": {
16
21
  "node": ">=18"
17
- },
18
- "scripts": {}
19
- }
22
+ }
23
+ }
@@ -31,6 +31,18 @@ pnpm web:dev
31
31
  - `pnpm test`:Web 类型检查 + Lint + Vitest + Go 测试。
32
32
  - `pnpm clean`:清理构建产物。
33
33
 
34
+ ## 模块生成器
35
+ ```bash
36
+ pnpm create:module <module-name> [options]
37
+ ```
38
+ 不传 options 会进入交互式向导。
39
+
40
+ 选项:
41
+ - `--frontend-only`:只生成前端模块。
42
+ - `--backend-only`:只生成后端模块。
43
+ - `--with-crud`:包含 CRUD 示例代码。
44
+ - `--skip-register`:跳过自动注册步骤。
45
+
34
46
  ## 目录结构
35
47
  - `apps/web/`:React + Vite 壳与业务模块(`src/modules/*`)。
36
48
  - `apps/server/`:Go API + 模块注册/迁移/种子数据(含 `config.yaml`)。
@@ -1,7 +1,7 @@
1
1
  package modules
2
2
 
3
3
  import (
4
- example "__APP_NAME__/apps/server/internal/modules/example"
4
+ example "__APP_NAME__/apps/server/internal/modules/example"
5
5
  )
6
6
 
7
7
  // RegisterAll wires the module registry for this app.
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@ant-design/icons": "^6.1.0",
20
- "@robsun/keystone-web-core": "0.1.7",
20
+ "@robsun/keystone-web-core": "0.1.8",
21
21
  "antd": "^6.0.1",
22
22
  "dayjs": "^1.11.19",
23
23
  "react": "^19.2.0",
@@ -15,5 +15,4 @@ createRoot(document.getElementById('root')!).render(
15
15
  <StrictMode>
16
16
  <KeystoneApp />
17
17
  </StrictMode>
18
- )
19
-
18
+ )
@@ -7,6 +7,7 @@
7
7
  "build": "node scripts/build.js",
8
8
  "build:web": "pnpm --filter web build",
9
9
  "check-modules": "node scripts/check-modules.js",
10
+ "create:module": "pnpm dlx --package @robsun/create-keystone-app create-keystone-module",
10
11
  "test": "pnpm check-modules && node scripts/test.js",
11
12
  "lint": "pnpm --filter web lint",
12
13
  "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml}\"",
@@ -27,8 +27,8 @@ importers:
27
27
  specifier: ^6.1.0
28
28
  version: 6.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
29
29
  '@robsun/keystone-web-core':
30
- specifier: 0.1.7
31
- version: 0.1.7(@ant-design/icons@6.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router-dom@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))
30
+ specifier: 0.1.8
31
+ version: 0.1.8(@ant-design/icons@6.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router-dom@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))
32
32
  antd:
33
33
  specifier: ^6.0.1
34
34
  version: 6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -924,7 +924,7 @@ packages:
924
924
  react: '>=16.9.0'
925
925
  react-dom: '>=16.9.0'
926
926
 
927
- '@robsun/keystone-web-core@0.1.7':
927
+ '@robsun/keystone-web-core@0.1.8':
928
928
  resolution: {integrity: sha512-r6Qfs1LxTZBv6GVIf5s6LREuTl/lskq8L3Y9YynUFKvL1tuZ3h3sThxPvvP7VQbBZn5As/XUv16kpqqVXX1HvQ==}
929
929
  peerDependencies:
930
930
  '@ant-design/icons': ^6.1.0
@@ -3744,7 +3744,7 @@ snapshots:
3744
3744
  react: 19.2.3
3745
3745
  react-dom: 19.2.3(react@19.2.3)
3746
3746
 
3747
- '@robsun/keystone-web-core@0.1.7(@ant-design/icons@6.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router-dom@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))':
3747
+ '@robsun/keystone-web-core@0.1.8(@ant-design/icons@6.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router-dom@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))':
3748
3748
  dependencies:
3749
3749
  '@ant-design/icons': 6.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
3750
3750
  '@vitejs/plugin-react': 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))
@@ -6027,3 +6027,4 @@ snapshots:
6027
6027
  use-sync-external-store: 1.6.0(react@19.2.3)
6028
6028
 
6029
6029
  zwitch@2.0.4: {}
6030
+