@mingfuyun/star-allinone 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +17 -10
  2. package/examples/AGENTS.md +26 -0
  3. package/examples/projects/starlink-frontend.star-allinone.json +7 -0
  4. package/examples/projects/starscope-frontend.star-allinone.json +7 -0
  5. package/examples/skills/custom-domain-invariants/SKILL.md +16 -0
  6. package/package.json +5 -3
  7. package/scripts/cli.js +52 -13
  8. package/scripts/gen/fixtures/run.js +104 -0
  9. package/scripts/gen/fixtures/widgets.openapi.json +194 -0
  10. package/scripts/gen/names.js +117 -0
  11. package/scripts/gen/page.js +483 -0
  12. package/scripts/gen/project.js +154 -0
  13. package/scripts/gen/service.js +264 -0
  14. package/scripts/gen/spec.js +220 -0
  15. package/scripts/gen/ts.js +121 -0
  16. package/skills/README.md +45 -188
  17. package/skills/shared/api-to-service/SKILL.md +18 -10
  18. package/skills/shared/{api-mock/SKILL.md → api-to-service/references/mock.md} +3 -10
  19. package/skills/shared/design-to-page/SKILL.md +45 -6
  20. package/skills/shared/design-to-page/references/list-page-template.md +44 -0
  21. package/skills/shared/design-to-page/references/page-conventions.md +1 -1
  22. package/skills/shared/i-have-adhd/{SKILL.md → i-have-adhd.md} +0 -10
  23. package/skills/shared/project-conventions/references/architecture.md +3 -0
  24. package/skills/shared/publish-test/SKILL.md +4 -2
  25. package/skills/shared/review-and-submit/SKILL.md +8 -16
  26. package/skills/shared/{ai-review/SKILL.md → review-and-submit/references/ai-review.md} +4 -11
  27. package/skills/shared/{commit-current-changes/SKILL.md → review-and-submit/references/commit.md} +4 -12
  28. package/skills/shared/{impact-analysis/SKILL.md → review-and-submit/references/impact-analysis.md} +1 -10
  29. package/skills/shared/{verify/SKILL.md → review-and-submit/references/verify.md} +4 -11
  30. package/skills/shared/project-conventions/SKILL.md +0 -34
  31. /package/skills/shared/{api-mock → api-to-service}/references/mock-conventions.md +0 -0
@@ -0,0 +1,117 @@
1
+ function toPascal(value) {
2
+ return String(value || '')
3
+ .replace(/[^\w]+/g, ' ')
4
+ .split(/[\s_-]+/)
5
+ .filter(Boolean)
6
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
7
+ .join('');
8
+ }
9
+
10
+ function toCamel(value) {
11
+ const pascal = toPascal(value);
12
+ return pascal ? pascal.charAt(0).toLowerCase() + pascal.slice(1) : '';
13
+ }
14
+
15
+ function toNamespace(domain) {
16
+ return `${String(domain)
17
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
18
+ .replace(/[^\w]+/g, '_')
19
+ .replace(/_+/g, '_')
20
+ .toUpperCase()}_API`;
21
+ }
22
+
23
+ function toServiceFile(domain) {
24
+ return toCamel(domain);
25
+ }
26
+
27
+ function normalizePath(apiPath) {
28
+ if (!apiPath) return '/';
29
+ const withSlash = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
30
+ return withSlash.replace(/\/{2,}/g, '/');
31
+ }
32
+
33
+ function pathVariants(apiPath) {
34
+ const normalized = normalizePath(apiPath);
35
+ const trimmed = normalized.replace(/\/+$/, '') || '/';
36
+ const withSlash = trimmed === '/' ? '/' : `${trimmed}/`;
37
+ return [...new Set([normalized, trimmed, withSlash])];
38
+ }
39
+
40
+ function pathParamsFromPath(apiPath) {
41
+ return [...String(apiPath).matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
42
+ }
43
+
44
+ function pathToTemplate(apiPath) {
45
+ const replaced = normalizePath(apiPath).replace(/\{([^}]+)\}/g, '${$1}');
46
+ return replaced.includes('${') ? `\`${replaced}\`` : `'${replaced}'`;
47
+ }
48
+
49
+ function parseOperationSelector(raw) {
50
+ const value = String(raw || '').trim();
51
+ if (!value) throw new Error('Missing --operation. Example: GET /api/v1/devices/');
52
+ const operationId = value.match(/^operationId\s*:\s*(.+)$/i);
53
+ if (operationId) return { operationId: operationId[1].trim() };
54
+ const spaced = value.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\/\S+)/i);
55
+ if (spaced) {
56
+ return { method: spaced[1].toLowerCase(), path: spaced[2] };
57
+ }
58
+ if (value.startsWith('/')) return { path: value };
59
+ return { operationId: value };
60
+ }
61
+
62
+ function functionName(operation) {
63
+ if (operation.operationId) return toCamel(operation.operationId);
64
+ const params = pathParamsFromPath(operation.path);
65
+ const segments = normalizePath(operation.path)
66
+ .replace(/\{[^}]+\}/g, '')
67
+ .split('/')
68
+ .filter(Boolean);
69
+ const resource = toPascal(segments[segments.length - 1] || 'Resource');
70
+ const method = operation.method.toLowerCase();
71
+ if (method === 'get' && params.length) return `get${resource}Detail`;
72
+ if (method === 'get') return `list${resource}`;
73
+ if (method === 'post') return `create${resource}`;
74
+ if (method === 'patch' || method === 'put') return `update${resource}`;
75
+ if (method === 'delete') return `delete${resource}`;
76
+ return toCamel(`${method}_${resource}`);
77
+ }
78
+
79
+ function requestHelper(operation) {
80
+ const method = operation.method.toLowerCase();
81
+ const contentType = operation.contentType || '';
82
+ if (method === 'get' && /export|download/.test(operation.path)) return 'export';
83
+ if (contentType.includes('multipart')) return 'import';
84
+ if (method === 'get') return 'get';
85
+ if (method === 'post') return 'post';
86
+ if (method === 'patch') return 'patch';
87
+ if (method === 'put') return 'put';
88
+ if (method === 'delete') return 'delete';
89
+ return method;
90
+ }
91
+
92
+ function domainFromPath(apiPath) {
93
+ const segments = normalizePath(apiPath).split('/').filter(Boolean);
94
+ const afterApi = segments[0] === 'api' ? segments.slice(2) : segments;
95
+ return toPascal(afterApi[0] || 'Common');
96
+ }
97
+
98
+ function typeName(prefix, suffix, useIPrefix) {
99
+ const name = `${toPascal(prefix)}${toPascal(suffix)}`;
100
+ return useIPrefix ? `I${name}` : name;
101
+ }
102
+
103
+ module.exports = {
104
+ toPascal,
105
+ toCamel,
106
+ toNamespace,
107
+ toServiceFile,
108
+ normalizePath,
109
+ pathVariants,
110
+ pathParamsFromPath,
111
+ pathToTemplate,
112
+ parseOperationSelector,
113
+ functionName,
114
+ requestHelper,
115
+ domainFromPath,
116
+ typeName,
117
+ };
@@ -0,0 +1,483 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { toPascal, parseOperationSelector } = require('./names');
4
+ const { generateService } = require('./service');
5
+ const { loadProject, writeFile } = require('./project');
6
+
7
+ function detectLayout(root) {
8
+ const hasPageContainer = fs.existsSync(path.join(root, 'src/components/PageContainer/index.tsx'));
9
+ const hasProTableLess = fs.existsSync(path.join(root, 'src/styles/proTable.less'));
10
+ const hasContainerConfig = fs.existsSync(path.join(root, 'src/components/Page/Container/config.tsx'));
11
+ return { hasPageContainer, hasProTableLess, hasContainerConfig };
12
+ }
13
+
14
+ function featureNames(options) {
15
+ const feature = options.feature.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
16
+ if (!feature) throw new Error('Missing --feature. Example: --feature Widgets');
17
+ const parts = feature.split('/');
18
+ const leaf = toPascal(parts[parts.length - 1]);
19
+ const dir = [...parts.slice(0, -1), leaf].join('/');
20
+ return {
21
+ featurePath: dir,
22
+ leaf,
23
+ component: `./${dir}`,
24
+ drawer: `${leaf}Drawer`,
25
+ confirm: `${leaf}ConfirmModal`,
26
+ title: options.title || leaf,
27
+ route: options.route || `/${leaf.replace(/[A-Z]/g, (ch, i) => (i ? '-' : '') + ch.toLowerCase())}`,
28
+ };
29
+ }
30
+
31
+ function columnFields(itemTypeSource = '') {
32
+ const fields = [...itemTypeSource.matchAll(/^\s+(\w+)\??:/gm)].map((match) => match[1]);
33
+ const preferred = fields.filter((name) => !['created_at', 'updated_at', 'createTime', 'updateTime'].includes(name));
34
+ return (preferred.length ? preferred : fields).slice(0, 6);
35
+ }
36
+
37
+ function emitColumns(names, itemType, fields, permissions) {
38
+ const columns = fields.map((field, index) => {
39
+ const search = index < 2;
40
+ return ` {
41
+ title: '${field}',
42
+ dataIndex: '${field}',
43
+ key: '${field}',
44
+ hideInSearch: ${search ? 'false' : 'true'},
45
+ ellipsis: true,
46
+ render: (_, record) => record.${field} ?? '-',
47
+ }`;
48
+ });
49
+ if (!columns.length) {
50
+ columns.push(` {
51
+ title: 'ID',
52
+ dataIndex: 'id',
53
+ key: 'id',
54
+ hideInSearch: true,
55
+ }`);
56
+ }
57
+ const permCreate = permissions.create ? ` permission: '${permissions.create}',\n` : '';
58
+ const permEdit = permissions.edit ? ` permission: '${permissions.edit}',\n` : '';
59
+ const permDelete = permissions.delete ? ` permission: '${permissions.delete}',\n` : '';
60
+ return `import type {
61
+ ITableProps,
62
+ TableSearchType,
63
+ } from '@/components/CommonTable/type';
64
+ import type { ProColumns } from '@ant-design/pro-components';
65
+
66
+ type Handlers = {
67
+ handleEdit: (record: ${itemType}) => void;
68
+ handleDelete: (record: ${itemType}) => void;
69
+ };
70
+
71
+ type ToolbarHandlers = { handleCreate: () => void };
72
+
73
+ export const columns = (): ProColumns<${itemType}>[] => [
74
+ ${columns.join(',\n')},
75
+ ];
76
+
77
+ export const getActionColumn = ({
78
+ handleEdit,
79
+ handleDelete,
80
+ }: Handlers): NonNullable<
81
+ ITableProps<${itemType}>['actionColumn']
82
+ >['actions'] => [
83
+ {
84
+ key: 'edit',
85
+ ${permEdit} getText: () => '编辑',
86
+ onClick: handleEdit,
87
+ },
88
+ {
89
+ key: 'delete',
90
+ ${permDelete} getText: () => '删除',
91
+ onClick: handleDelete,
92
+ },
93
+ ];
94
+
95
+ export const getToolBarRender = ({
96
+ handleCreate,
97
+ }: ToolbarHandlers): TableSearchType[] => [
98
+ {
99
+ key: 'create',
100
+ text: '新增',
101
+ type: 'primary',
102
+ ${permCreate} onClick: handleCreate,
103
+ },
104
+ ];
105
+ `;
106
+ }
107
+
108
+ function emitIndex(names, layout, types, fns, pagination) {
109
+ const pageImport = layout.hasPageContainer
110
+ ? `import PageContainer from '@/components/PageContainer';\n`
111
+ : '';
112
+ const open = layout.hasPageContainer
113
+ ? ` <PageContainer header={{ title: undefined }}>`
114
+ : ' <>';
115
+ const close = layout.hasPageContainer ? ` </PageContainer>` : ' </>';
116
+ const listAccess = pagination === 'results-count'
117
+ ? 'list: data?.results || [],\n total: data?.count || 0,'
118
+ : 'list: data?.list || [],\n total: data?.total || 0,';
119
+ const queryType = types.queryType || 'Record<string, unknown>';
120
+ const itemType = types.itemType || 'Record<string, unknown>';
121
+ const listFn = fns.list || `list${names.leaf}`;
122
+ const deleteCall = fns.remove
123
+ ? ` await Service.${fns.remove}(record.id);`
124
+ : ` // TODO: call delete service`;
125
+ const rowKey = 'id';
126
+ return `import MFProTable from '@/components/CommonTable/MFProTable';
127
+ import type { MFTableRef } from '@/components/CommonTable/type';
128
+ ${pageImport}import Service from '@/services';
129
+ import { message } from 'antd';
130
+ import { useCallback, useRef } from 'react';
131
+ import ${names.confirm}, {
132
+ type ${names.confirm}Ref,
133
+ } from './components/${names.confirm}';
134
+ import ${names.drawer}, {
135
+ type ${names.drawer}Ref,
136
+ } from './components/${names.drawer}';
137
+ import { columns, getActionColumn, getToolBarRender } from './const';
138
+ import styles from './index.less';
139
+
140
+ export default function ${names.leaf}() {
141
+ const tableRef = useRef<MFTableRef>(null);
142
+ const drawerRef = useRef<${names.drawer}Ref>(null);
143
+ const confirmRef = useRef<${names.confirm}Ref>(null);
144
+
145
+ const fetchData = useCallback(async (params: ${queryType}) => {
146
+ try {
147
+ const { data } = await Service.${listFn}(params);
148
+ return {
149
+ code: 0,
150
+ data: {
151
+ ${listAccess}
152
+ },
153
+ };
154
+ } catch (error) {
155
+ console.error('获取${names.title}失败:', error);
156
+ return {
157
+ code: 0,
158
+ data: {
159
+ list: [],
160
+ total: 0,
161
+ },
162
+ };
163
+ }
164
+ }, []);
165
+
166
+ const handleCreate = () => {
167
+ drawerRef.current?.show();
168
+ };
169
+
170
+ const handleEdit = (record: ${itemType}) => {
171
+ drawerRef.current?.show({ record });
172
+ };
173
+
174
+ const handleDelete = (record: ${itemType}) => {
175
+ confirmRef.current?.show({
176
+ title: \`确定删除该记录吗?\`,
177
+ onConfirm: async () => {
178
+ ${deleteCall}
179
+ message.success('删除成功');
180
+ tableRef.current?.reload();
181
+ },
182
+ });
183
+ };
184
+
185
+ return (
186
+ ${open}
187
+ <div className={styles.page}>
188
+ <MFProTable<${itemType}, ${queryType}>
189
+ ref={tableRef}
190
+ actionColumn={{
191
+ actions: getActionColumn({ handleEdit, handleDelete }),
192
+ }}
193
+ bordered
194
+ columns={columns()}
195
+ fetchData={fetchData}
196
+ queryParams={{}}
197
+ rowKey="${rowKey}"
198
+ scroll={{ x: 'max-content' }}
199
+ topTitle={false}
200
+ toolBarRender={getToolBarRender({ handleCreate })}
201
+ />
202
+ </div>
203
+ <${names.drawer} ref={drawerRef} reload={() => tableRef.current?.reload()} />
204
+ <${names.confirm} ref={confirmRef} />
205
+ ${close}
206
+ );
207
+ }
208
+ `;
209
+ }
210
+
211
+ function emitLess(layout) {
212
+ if (layout.hasProTableLess) {
213
+ return `@import '@/styles/proTable.less';
214
+
215
+ .page {
216
+ min-width: 0;
217
+ .pro-table-page(false, false);
218
+ }
219
+ `;
220
+ }
221
+ return `.page {
222
+ min-width: 0;
223
+ }
224
+ `;
225
+ }
226
+
227
+ function emitDrawer(names, types, fns) {
228
+ const itemType = types.itemType || 'Record<string, unknown>';
229
+ const createCall = fns.create
230
+ ? ` await Service.${fns.create}(values);`
231
+ : ' // TODO: call create service';
232
+ const updateCall = fns.update
233
+ ? ` await Service.${fns.update}(record.id, values);`
234
+ : ' // TODO: call update service';
235
+ return `import Service from '@/services';
236
+ import { ProForm, ProFormText } from '@ant-design/pro-components';
237
+ import { CustomDrawer, HocModal } from '@mingfuyun/star-allinone';
238
+ import { Form, message } from 'antd';
239
+ import { useEffect, useState } from 'react';
240
+
241
+ type ${names.drawer}Args = {
242
+ record?: ${itemType};
243
+ };
244
+
245
+ type Props = ${names.drawer}Args & {
246
+ visible: boolean;
247
+ handleCancel: () => void;
248
+ reload: () => void;
249
+ };
250
+
251
+ export interface ${names.drawer}Ref {
252
+ show: (args?: ${names.drawer}Args) => void;
253
+ hide: () => void;
254
+ }
255
+
256
+ const ${names.drawer}: React.FC<Props> = ({
257
+ visible,
258
+ handleCancel,
259
+ record,
260
+ reload,
261
+ }) => {
262
+ const [form] = Form.useForm();
263
+ const [loading, setLoading] = useState(false);
264
+ const editing = Boolean(record);
265
+
266
+ useEffect(() => {
267
+ if (!visible) return;
268
+ form.resetFields();
269
+ if (record) form.setFieldsValue(record);
270
+ }, [form, record, visible]);
271
+
272
+ const handleConfirm = async () => {
273
+ try {
274
+ const values = await form.validateFields();
275
+ setLoading(true);
276
+ if (editing && record) {
277
+ ${updateCall}
278
+ } else {
279
+ ${createCall}
280
+ }
281
+ message.success(editing ? '编辑成功' : '新增成功');
282
+ reload();
283
+ handleCancel();
284
+ } catch (error) {
285
+ console.error('提交${names.title}失败:', error);
286
+ } finally {
287
+ setLoading(false);
288
+ }
289
+ };
290
+
291
+ return (
292
+ <CustomDrawer
293
+ loading={loading}
294
+ open={visible}
295
+ title={editing ? '编辑${names.title}' : '新增${names.title}'}
296
+ width={480}
297
+ onClose={handleCancel}
298
+ onConfirm={handleConfirm}
299
+ >
300
+ <ProForm
301
+ form={form}
302
+ labelCol={{ flex: '88px' }}
303
+ layout="horizontal"
304
+ submitter={false}
305
+ >
306
+ <ProFormText
307
+ name="name"
308
+ label="名称"
309
+ placeholder="请输入名称"
310
+ rules={[{ required: true, message: '请输入名称' }]}
311
+ />
312
+ </ProForm>
313
+ </CustomDrawer>
314
+ );
315
+ };
316
+
317
+ export default HocModal(${names.drawer});
318
+ `;
319
+ }
320
+
321
+ function emitConfirm(names) {
322
+ return `import { BaseModal, HocModal } from '@mingfuyun/star-allinone';
323
+ import { Typography } from 'antd';
324
+ import { useState } from 'react';
325
+
326
+ export type ${names.confirm}Args = {
327
+ title: string;
328
+ content?: React.ReactNode;
329
+ onConfirm: () => Promise<void>;
330
+ };
331
+
332
+ type Props = ${names.confirm}Args & {
333
+ visible: boolean;
334
+ handleCancel: () => void;
335
+ };
336
+
337
+ export interface ${names.confirm}Ref {
338
+ show: (args: ${names.confirm}Args) => void;
339
+ hide: () => void;
340
+ }
341
+
342
+ const ${names.confirm}: React.FC<Props> = ({
343
+ visible,
344
+ handleCancel,
345
+ title,
346
+ content,
347
+ onConfirm,
348
+ }) => {
349
+ const [loading, setLoading] = useState(false);
350
+
351
+ const handleOk = async () => {
352
+ setLoading(true);
353
+ try {
354
+ await onConfirm();
355
+ handleCancel();
356
+ } catch (error) {
357
+ console.error('确认操作失败:', error);
358
+ } finally {
359
+ setLoading(false);
360
+ }
361
+ };
362
+
363
+ return (
364
+ <BaseModal
365
+ confirmLoading={loading}
366
+ destroyOnClose
367
+ maskClosable={false}
368
+ title={title}
369
+ visible={visible}
370
+ width={480}
371
+ onCancel={handleCancel}
372
+ onOk={handleOk}
373
+ >
374
+ <Typography.Paragraph style={{ marginBottom: 0 }}>
375
+ {content || '操作提交后将立即生效,请确认是否继续。'}
376
+ </Typography.Paragraph>
377
+ </BaseModal>
378
+ );
379
+ };
380
+
381
+ export default HocModal(${names.confirm});
382
+ `;
383
+ }
384
+
385
+ function patchRoute(root, names, dryRun) {
386
+ const file = path.join(root, 'config/route.ts');
387
+ if (!fs.existsSync(file)) {
388
+ throw new Error('Missing config/route.ts');
389
+ }
390
+ const current = fs.readFileSync(file, 'utf8');
391
+ if (current.includes(`path: '${names.route}'`) || current.includes(`path: "${names.route}"`)) {
392
+ console.log(`route already has ${names.route}`);
393
+ return;
394
+ }
395
+ const snippet = ` {
396
+ name: '${names.title}',
397
+ path: '${names.route}',
398
+ component: '${names.component}',
399
+ },
400
+ `;
401
+ let next = current;
402
+ if (current.includes("path: '/*'") || current.includes('path: "/*"')) {
403
+ next = current.replace(/\{\s*path:\s*['"]\/\*['"]/, `${snippet} $&`);
404
+ } else {
405
+ next = current.replace(/\];\s*$/, `${snippet}];\n`);
406
+ }
407
+ writeFile(file, next, dryRun);
408
+ }
409
+
410
+ function generatePage(cwd, options) {
411
+ const operations = [
412
+ ['list', options.listOperation],
413
+ ['create', options.createOperation],
414
+ ['update', options.updateOperation],
415
+ ['remove', options.deleteOperation],
416
+ ].filter(([, value]) => value);
417
+
418
+ const project = loadProject(cwd, {
419
+ ...options,
420
+ requireSpec: operations.length > 0,
421
+ });
422
+ const names = featureNames(options);
423
+ const layout = detectLayout(project.root);
424
+ const pageDir = path.join(project.root, 'src/pages', names.featurePath);
425
+ const files = [
426
+ path.join(pageDir, 'index.tsx'),
427
+ path.join(pageDir, 'index.less'),
428
+ path.join(pageDir, 'const.tsx'),
429
+ path.join(pageDir, 'components', `${names.drawer}.tsx`),
430
+ path.join(pageDir, 'components', `${names.confirm}.tsx`),
431
+ ];
432
+ const exists = files.filter((file) => fs.existsSync(file));
433
+ if (exists.length) {
434
+ throw new Error(`Refusing to overwrite existing page files:\n${exists.map((file) => path.relative(project.root, file)).join('\n')}`);
435
+ }
436
+
437
+ const fns = {};
438
+ let types = { itemType: `${toPascal(names.leaf)}_API.${names.leaf}Item`, queryType: undefined };
439
+ let pagination = 'list-total';
440
+ let itemSource = '';
441
+
442
+ for (const [key, selector] of operations) {
443
+ const result = generateService(cwd, {
444
+ ...options,
445
+ operation: selector,
446
+ mockFile: false,
447
+ });
448
+ fns[key] = result.fn;
449
+ if (key === 'list') {
450
+ types = {
451
+ itemType: result.itemType || types.itemType,
452
+ queryType: result.queryType,
453
+ };
454
+ pagination = result.pagination || pagination;
455
+ itemSource = result.nextTypings || '';
456
+ if (result.status === 'reuse' && result.typingsPath && fs.existsSync(result.typingsPath)) {
457
+ itemSource = fs.readFileSync(result.typingsPath, 'utf8');
458
+ }
459
+ }
460
+ }
461
+
462
+ const fields = columnFields(itemSource);
463
+ const permissions = {
464
+ create: options.permissionCreate,
465
+ edit: options.permissionEdit,
466
+ delete: options.permissionDelete,
467
+ };
468
+
469
+ writeFile(files[0], emitIndex(names, layout, types, fns, pagination), project.dryRun);
470
+ writeFile(files[1], emitLess(layout), project.dryRun);
471
+ writeFile(files[2], emitColumns(names, types.itemType, fields, permissions), project.dryRun);
472
+ writeFile(files[3], emitDrawer(names, types, fns), project.dryRun);
473
+ writeFile(files[4], emitConfirm(names), project.dryRun);
474
+ patchRoute(project.root, names, project.dryRun);
475
+
476
+ if (layout.hasContainerConfig) {
477
+ console.log(`starscope-style tab container detected. Register ${names.featurePath} in src/components/Page/Container/config.tsx if this page should appear as a tab.`);
478
+ }
479
+ console.log(`${project.dryRun ? '[dry-run] ' : ''}generated page ${names.route} -> src/pages/${names.featurePath}`);
480
+ return names;
481
+ }
482
+
483
+ module.exports = { generatePage, featureNames };