generator-mico-cli 0.2.24 → 0.2.25

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
@@ -10,6 +10,9 @@
10
10
  | [docs/micro-react-generator.md](docs/micro-react-generator.md) | Monorepo 项目生成器说明 |
11
11
  | [docs/subapp-react-generator.md](docs/subapp-react-generator.md) | React 子应用生成器说明 |
12
12
  | [docs/subapp-umd-generator.md](docs/subapp-umd-generator.md) | UMD 组件包生成器说明 |
13
+ | [docs/feat-integration-tests.md](docs/feat-integration-tests.md) | 集成测试方案(Vitest + yeoman-test) |
14
+ | [docs/fix-command-injection.md](docs/fix-command-injection.md) | 修复 npm version 查询命令注入风险 |
15
+ | [docs/refactor-code-quality-optimizations.md](docs/refactor-code-quality-optimizations.md) | 代码质量优化(DRY / 错误处理 / 确定性 ID / 可配置化 / 冗余清理) |
13
16
 
14
17
  ## 要求
15
18
 
@@ -69,20 +72,39 @@ mico create micro-react -- --help
69
72
 
70
73
  ## 配置文件
71
74
 
72
- 可以在项目目录或用户主目录创建 `.micorc` 或 `.micorc.json` 文件来预设默认值:
75
+ 可以在项目目录或用户主目录创建 `.micorc` 或 `.micorc.json` 文件来预设默认值。
76
+
77
+ 配置查找顺序:
78
+ 1. 当前目录(或 monorepo 根目录)的 `.micorc` / `.micorc.json`
79
+ 2. 用户主目录的 `.micorc` / `.micorc.json`
80
+
81
+ ### 完整字段列表
73
82
 
74
83
  ```json
75
84
  {
85
+ "projectName": "my-app",
76
86
  "packageScope": "@my-company",
77
87
  "cdnPrefix": "portal",
78
88
  "author": "Team <team@example.com>",
79
- "defaultSubappName": "subapp"
89
+ "defaultSubappName": "subapp",
90
+ "devPort": "8010",
91
+ "defaultUmdName": "my-widget",
92
+ "umdDevPort": "9100"
80
93
  }
81
94
  ```
82
95
 
83
- 配置查找顺序:
84
- 1. 当前目录的 `.micorc` 或 `.micorc.json`
85
- 2. 用户主目录的 `.micorc` `.micorc.json`
96
+ | 字段 | 类型 | 适用生成器 | 说明 | 默认值 |
97
+ |------|------|-----------|------|--------|
98
+ | `projectName` | string | micro-react | 项目名称 | 当前目录名 |
99
+ | `packageScope` | string | 所有 | 包作用域(如 `@my-company`) | micro-react: `@<projectName>`;其他: 从根 `package.json` 检测 |
100
+ | `cdnPrefix` | string | micro-react | CDN 路径前缀(如 `portal`、`admin/v2`) | 空字符串 |
101
+ | `author` | string | micro-react | 作者信息 | `Your Name <email@example.com>` |
102
+ | `defaultSubappName` | string | subapp-react | 子应用默认名称 | `subapp` |
103
+ | `devPort` | string | subapp-react | 子应用开发端口 | `8010` |
104
+ | `defaultUmdName` | string | subapp-umd | UMD 包默认名称 | `my-widget` |
105
+ | `umdDevPort` | string | subapp-umd | UMD 包开发端口 | `9100` |
106
+
107
+ 所有字段均为可选,未配置时使用默认值或交互式提示。
86
108
 
87
109
  ## Monorepo 项目生成器 (micro-react)
88
110
 
@@ -138,9 +160,9 @@ mico create subapp-umd
138
160
 
139
161
  DevTools Sources 面板中可在 `webpack://<name>/src/` 下定位原始 TypeScript 源码进行断点调试。
140
162
 
141
- ## 本地调试
163
+ ## 开发指南
142
164
 
143
- 克隆项目后,执行以下步骤进行本地开发和调试:
165
+ ### 环境准备
144
166
 
145
167
  ```bash
146
168
  # 1. 安装依赖
@@ -168,24 +190,92 @@ mico create subapp-react
168
190
  mico create subapp-umd
169
191
  ```
170
192
 
171
- 调试完成后,可以取消全局链接:
193
+ 调试完成后,取消全局链接:
172
194
 
173
195
  ```bash
174
196
  npm unlink -g generator-mico-cli
175
197
  ```
176
198
 
177
- ## 同步子应用模板
199
+ ### 运行测试
178
200
 
179
- 将源项目的 `apps/homepage` 同步到生成器模板目录:
201
+ 项目使用 Vitest + yeoman-test 进行单元测试和集成测试:
180
202
 
181
203
  ```bash
182
- SOURCE_PROJECT_ROOT=<source-project-root> npm run sync:subapp-react-template
204
+ # 运行全部测试
205
+ pnpm test
206
+
207
+ # watch 模式
208
+ pnpm test:watch
183
209
  ```
184
210
 
185
- 也可以直接传入路径:
211
+ 测试目录结构:
212
+
213
+ | 目录/文件 | 说明 |
214
+ |----------|------|
215
+ | `tests/unit/utils.test.js` | `lib/utils.js` 的单元测试 |
216
+ | `tests/integration/micro-react.test.js` | micro-react 生成器集成测试 |
217
+ | `tests/integration/subapp-react.test.js` | subapp-react 生成器集成测试 |
218
+ | `tests/integration/subapp-umd.test.js` | subapp-umd 生成器集成测试 |
219
+ | `tests/helpers/setup.js` | 测试共享工具(路径常量、monorepo fixture 工厂) |
220
+
221
+ ### Scripts 说明
222
+
223
+ | 脚本 | 命令 | 说明 |
224
+ |------|------|------|
225
+ | `sync:subapp-react-template` | `pnpm run sync:subapp-react-template -- <source-path>` | 将源项目的 `apps/homepage` 同步到 `subapp-react` 模板目录,忽略清单见 `generators/subapp-react/ignore-list.json` |
226
+ | `verify:micro-react` | `node scripts/verify-micro-react.js [test-dir]` | 端到端验证 micro-react + subapp-react 生成器,自动创建项目并回车通过所有提示 |
227
+
228
+ #### verify-micro-react.js
229
+
230
+ 自动化验证脚本,依次执行:
231
+ 1. 创建或清空测试目录
232
+ 2. 运行 `mico create micro-react`(自动回车通过 5 个提示)
233
+ 3. 运行 `mico create subapp-react`(自动回车通过 4 个提示)
234
+
235
+ 测试目录支持三种方式指定:
236
+ - 命令行参数:`node scripts/verify-micro-react.js /path/to/dir`
237
+ - 环境变量:`VERIFY_TEST_DIR=/path node scripts/verify-micro-react.js`
238
+ - 默认值:`../test-app2`
239
+
240
+ #### sync-subapp-react-template.js
241
+
242
+ 从源 monorepo 项目同步 `apps/homepage` 到生成器模板目录:
186
243
 
187
244
  ```bash
188
- npm run sync:subapp-react-template -- <source-project-root>
245
+ # 通过环境变量
246
+ SOURCE_PROJECT_ROOT=<source-path> pnpm run sync:subapp-react-template
247
+
248
+ # 通过命令行参数
249
+ pnpm run sync:subapp-react-template -- <source-path>
250
+ ```
251
+
252
+ 会自动记录源仓库的 git commit 信息到 `.template-version.json`。
253
+
254
+ ### 项目结构
255
+
256
+ ```
257
+ mico-cli/
258
+ ├── bin/mico.js # CLI 入口(命令解析、生成器调度)
259
+ ├── lib/utils.js # 共享工具函数(字符串转换、文件处理、npm 版本查询、配置加载)
260
+ ├── generators/
261
+ │ ├── micro-react/ # Monorepo 项目生成器
262
+ │ ├── subapp-react/ # React 子应用生成器
263
+ │ └── subapp-umd/ # UMD 组件包生成器
264
+ ├── scripts/ # 辅助脚本
265
+ ├── tests/ # 测试文件
266
+ ├── docs/ # 功能文档
267
+ └── vitest.config.js # 测试配置
189
268
  ```
190
269
 
191
- 忽略清单位于 `generators/subapp-react/ignore-list.json`。
270
+ ### 提交规范
271
+
272
+ 请使用语义化提交信息:
273
+
274
+ ```
275
+ feat: 添加新功能
276
+ fix: 修复 Bug
277
+ refactor: 代码重构
278
+ docs: 文档更新
279
+ test: 测试相关
280
+ chore: 构建/工具链变更
281
+ ```
package/bin/mico.js CHANGED
@@ -6,6 +6,10 @@ const fs = require('node:fs');
6
6
  const path = require('node:path');
7
7
  const readline = require('node:readline');
8
8
 
9
+ const { setupErrorHandlers } = require('../lib/utils');
10
+
11
+ setupErrorHandlers();
12
+
9
13
  const rootDir = path.resolve(__dirname, '..');
10
14
  const pkg = JSON.parse(
11
15
  fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')
@@ -85,8 +89,8 @@ function listGenerators() {
85
89
  if (fs.existsSync(metaPath)) {
86
90
  try {
87
91
  meta = { ...meta, ...JSON.parse(fs.readFileSync(metaPath, 'utf8')) };
88
- } catch {
89
- // 忽略解析错误
92
+ } catch (e) {
93
+ console.warn(` ⚠ Failed to parse ${metaPath}: ${e.message}`);
90
94
  }
91
95
  }
92
96
 
@@ -409,17 +413,25 @@ function runGenerator(generator, rest, passthroughArgs, options = {}) {
409
413
 
410
414
  const child = spawn('yo', yoArgs, { stdio: 'inherit', env });
411
415
 
416
+ let exited = false;
417
+ const quit = (code) => {
418
+ if (!exited) {
419
+ exited = true;
420
+ process.exit(code);
421
+ }
422
+ };
423
+
412
424
  child.on('error', (error) => {
413
425
  if (error && error.code === 'ENOENT') {
414
426
  console.error('Cannot find "yo". Install it with: npm install -g yo');
415
427
  } else {
416
428
  console.error(error);
417
429
  }
418
- process.exit(1);
430
+ quit(1);
419
431
  });
420
432
 
421
433
  child.on('exit', (code) => {
422
- process.exit(typeof code === 'number' ? code : 1);
434
+ quit(typeof code === 'number' ? code : 1);
423
435
  });
424
436
  }
425
437
 
@@ -19,6 +19,7 @@ const config: ReturnType<typeof defineConfig> = {
19
19
  window.__MICO_MENUS__ = ${JSON.stringify(mockMenus)};
20
20
  window.__MICO_PAGES__ = ${JSON.stringify(mockPages)};
21
21
  window.__MICO_CONFIG__ = {
22
+ appId: '<%= projectName %>',
22
23
  appName: '<%= projectName %>',
23
24
  title: '测试样例',
24
25
  logo: '',
@@ -62,7 +63,6 @@ const config: ReturnType<typeof defineConfig> = {
62
63
  },
63
64
  define: {
64
65
  'process.env.NODE_ENV': 'development',
65
- 'process.env.APP_ID': '<%= projectName %>',
66
66
  'process.env.API_BASE_URL': '',
67
67
  'process.env.PROXY_SUFFIX': '/proxy/<%= projectName %>_svr',
68
68
  'process.env.LOGIN_ENDPOINT':
@@ -21,7 +21,6 @@ const config: ReturnType<typeof defineConfig> = {
21
21
  },
22
22
  define: {
23
23
  'process.env.NODE_ENV': 'development',
24
- 'process.env.APP_ID': '<%= projectName %>',
25
24
  'process.env.API_BASE_URL': 'https://dashboard-api-test.micoplatform.com',
26
25
  'process.env.PROXY_SUFFIX': '/proxy/<%= projectName %>_svr',
27
26
  'process.env.LOGIN_ENDPOINT':
@@ -21,7 +21,6 @@ const config: ReturnType<typeof defineConfig> = {
21
21
  },
22
22
  define: {
23
23
  'process.env.NODE_ENV': 'testing',
24
- 'process.env.APP_ID': '<%= projectName %>',
25
24
  'process.env.API_BASE_URL': 'https://dashboard-api-test.micoplatform.com',
26
25
  'process.env.PROXY_SUFFIX': '/proxy/<%= projectName %>_svr',
27
26
  'process.env.LOGIN_ENDPOINT':
@@ -16,7 +16,6 @@ const config: ReturnType<typeof defineConfig> = {
16
16
  devtool: 'hidden-source-map',
17
17
  define: {
18
18
  'process.env.NODE_ENV': 'production',
19
- 'process.env.APP_ID': '<%= projectName %>',
20
19
  'process.env.API_BASE_URL': 'https://dashboard-api.micoplatform.com',
21
20
  'process.env.PROXY_SUFFIX': '/proxy/<%= projectName %>_svr',
22
21
  'process.env.LOGIN_ENDPOINT':
@@ -0,0 +1,88 @@
1
+ # 修复 SSO 无限重定向循环
2
+
3
+ > 创建时间:2026-03-12
4
+
5
+ ## 功能概述
6
+
7
+ 修复 SSO 登录流程中 `redirect_count` 防护机制被过早清除,导致 `fetchUserInfo` 返回 401 时可能触发无限 SSO 重定向循环的问题。
8
+
9
+ ## 问题分析
10
+
11
+ ### 原始流程(存在缺陷)
12
+
13
+ ```
14
+ SSO 回跳(URL 含 redirect_count=1 & ticket)
15
+ → ensureSsoSession() ticket 换 token 成功
16
+ → 立即删除 redirect_count ← 问题根因
17
+ → fetchUserInfo() 返回 401
18
+ → refreshAuthToken() 失败
19
+ → handleAuthFailureRedirect() 检查 redirect_count = 0(已被删除)
20
+ → 允许重定向 → 跳转 SSO
21
+ → SSO 自动登录回跳 → 重复以上流程 → 无限循环
22
+ ```
23
+
24
+ ### 根因
25
+
26
+ `ensureSsoSession()` 在 ticket 换取 token 成功后**立即删除** URL 中的 `redirect_count` 参数,但后续的 `fetchUserInfo` 尚未执行。当 `fetchUserInfo` 返回 401 且 token 刷新失败时,`handleAuthFailureRedirect()` 因检测不到 `redirect_count` 而误判为首次重定向,触发循环。
27
+
28
+ ## 技术方案
29
+
30
+ ### 核心思路
31
+
32
+ 将 `redirect_count` 的清除时机从"SSO ticket 换 token 成功后"延后到"整个认证流程(SSO + fetchUserInfo)完全成功后"。
33
+
34
+ ### 修复后流程
35
+
36
+ ```
37
+ SSO 回跳(URL 含 redirect_count=1 & ticket)
38
+ → ensureSsoSession() ticket 换 token 成功
39
+ → 保留 redirect_count ← 修复点
40
+ → fetchUserInfo() 返回 401
41
+ → refreshAuthToken() 失败
42
+ → handleAuthFailureRedirect() 检查 redirect_count = 1
43
+ → 已达最大重定向次数 → 停止重定向 ✅
44
+ ```
45
+
46
+ ```
47
+ SSO 回跳(URL 含 redirect_count=1 & ticket)
48
+ → ensureSsoSession() ticket 换 token 成功
49
+ → 保留 redirect_count
50
+ → fetchUserInfo() 成功
51
+ → clearRedirectCount() 清除 redirect_count ✅
52
+ ```
53
+
54
+ ## 文件清单
55
+
56
+ ### 修改文件
57
+
58
+ | 文件路径 | 修改内容 |
59
+ | --- | --- |
60
+ | `src/common/request/sso.ts` | 移除 `ensureSsoSession` 中过早删除 `redirect_count` 的逻辑;新增 `clearRedirectCount()` 函数 |
61
+ | `src/app.tsx` | 导入 `clearRedirectCount`,在 `fetchUserInfo` 成功后调用 |
62
+
63
+ ## API / 组件接口
64
+
65
+ ### clearRedirectCount
66
+
67
+ ```typescript
68
+ export const clearRedirectCount = (): void
69
+ ```
70
+
71
+ 清除 URL 中的 `redirect_count` 参数。仅在整个认证流程完全成功后调用。
72
+
73
+ ## 设计决策
74
+
75
+ | 决策点 | 选择 | 理由 |
76
+ | --- | --- | --- |
77
+ | redirect_count 清除时机 | 延后到 fetchUserInfo 成功后 | 最小改动,不改变现有 URL 参数机制,仅调整调用时序 |
78
+ | clearRedirectCount 放置位置 | `sso.ts` | 与 `handleAuthFailureRedirect` 同属 SSO 模块,保持内聚 |
79
+
80
+ ## 注意事项
81
+
82
+ - `handleAuthFailureRedirect` 可能在 `request/index.ts`(refresh 失败时)和 `app.tsx`(fetchUserInfo 返回 null 时)被双重调用,但修复后两处检查到 `redirect_count >= 1` 均会停止重定向,不影响正确性
83
+ - `redirect_count` 最大值为 1,即只允许一次自动 SSO 重定向尝试
84
+
85
+ ## 相关文档
86
+
87
+ - [请求模块架构](./arch-请求模块.md)
88
+ - [菜单权限控制](./feature-菜单权限控制.md)
@@ -17,6 +17,7 @@ import {
17
17
  setMicroAppProps,
18
18
  } from './common/micro';
19
19
  import {
20
+ clearRedirectCount,
20
21
  ensureSsoSession,
21
22
  handleAuthFailureRedirect,
22
23
  } from './common/request/sso';
@@ -140,6 +141,7 @@ export async function getInitialState(): Promise<{
140
141
  if (getStoredAuthToken()) {
141
142
  const userInfo = await fetchUserInfoFn();
142
143
  if (userInfo) {
144
+ clearRedirectCount();
143
145
  return {
144
146
  fetchUserInfo: fetchUserInfoFn,
145
147
  currentUser: userInfo,
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * 请求客户端配置管理
3
- * appId 优先从 window.__MICO_WORKSPACE__ 获取;
3
+ * appId 优先从 window.__MICO_WORKSPACE__ 获取,其次从 window.__MICO_CONFIG__ 获取;
4
4
  * externalLoginPath 优先从 window.__MICO_WORKSPACE__.casServerLoginUrl 获取;
5
- * 其余 defaultClientOptions / apiBaseUrl / proxySuffix / loginEndpoint / refreshEndpoint 优先从 window.__MICO_CONFIG__ 同名字段获取。
5
+ * 其余 apiBaseUrl / proxySuffix / loginEndpoint / refreshEndpoint 优先从 window.__MICO_CONFIG__ 同名字段获取,兜底取 process.env 环境变量。
6
6
  */
7
7
 
8
8
  import { getStoredAuthToken } from '../auth/auth-manager';
@@ -20,7 +20,7 @@ function getMicoWorkspace(): Window['__MICO_WORKSPACE__'] {
20
20
 
21
21
  function buildDefaultClientOptions(): RequestClientOptions {
22
22
  const envDefaults: RequestClientOptions = {
23
- appId: process.env.APP_ID ?? '',
23
+ appId: '',
24
24
  ticketParam: 'ticket',
25
25
  logoutPath: '/logout',
26
26
  apiBaseUrl: process.env.API_BASE_URL ?? '',
@@ -184,7 +184,6 @@ export const initDefaultInterceptors = (
184
184
  return data;
185
185
  });
186
186
 
187
-
188
187
  // 处理业务错误码({ code, msg, data } 格式)
189
188
  addResponseInterceptor(async (data: unknown) => {
190
189
  if (
@@ -194,16 +193,17 @@ export const initDefaultInterceptors = (
194
193
  !('result' in data) &&
195
194
  !('success' in data)
196
195
  ) {
197
- const typedData = data as { code?: number; msg?: string };
198
- if (typeof typedData.code === 'number' && typedData.code !== 200) {
196
+ const typedData = data as { code?: number | string; msg?: string };
197
+ const code = Number(typedData.code);
198
+ if (!isNaN(code) && code !== 200 && code !== 0) {
199
199
  const error = new Error(
200
- typedData.msg || `请求失败 (${typedData.code})`,
200
+ typedData.msg || `请求失败 (${code})`,
201
201
  ) as Error & {
202
202
  code: number;
203
203
  msg: string;
204
204
  response: unknown;
205
205
  };
206
- error.code = typedData.code;
206
+ error.code = code;
207
207
  error.msg = typedData.msg || '';
208
208
  error.response = data;
209
209
  throw error;
@@ -113,16 +113,6 @@ export const ensureSsoSession = async (): Promise<void> => {
113
113
  setFetchingToken(false);
114
114
 
115
115
  if (resolveAuthToken()) {
116
- // SSO 认证成功,删除 URL 中的 redirect_count 参数
117
- if (typeof window !== 'undefined') {
118
- const current = new URL(window.location.href);
119
- current.searchParams.delete('redirect_count');
120
- const nextUrl = `${current.pathname}${
121
- current.search ? `?${current.searchParams}` : ''
122
- }${current.hash}`;
123
- window.history.replaceState(null, '', nextUrl);
124
- }
125
-
126
116
  processPendingRequests();
127
117
  } else {
128
118
  throw new Error('SSO 认证失败');
@@ -143,3 +133,19 @@ export const ensureSsoSession = async (): Promise<void> => {
143
133
 
144
134
  await ticketPromise;
145
135
  };
136
+
137
+ /**
138
+ * 清除 URL 中的 redirect_count 参数
139
+ * 仅在整个认证流程(SSO + fetchUserInfo)完全成功后调用,
140
+ * 避免过早清除导致无限重定向循环
141
+ */
142
+ export const clearRedirectCount = (): void => {
143
+ if (typeof window === 'undefined') return;
144
+ const current = new URL(window.location.href);
145
+ if (!current.searchParams.has('redirect_count')) return;
146
+ current.searchParams.delete('redirect_count');
147
+ const nextUrl = `${current.pathname}${
148
+ current.search ? `?${current.searchParams}` : ''
149
+ }${current.hash}`;
150
+ window.history.replaceState(null, '', nextUrl);
151
+ };
@@ -86,6 +86,10 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
86
86
  };
87
87
  }, [env, routePath]);
88
88
 
89
+ // ref 持有最新的 buildProps,避免加载 effect 依赖它导致子应用被重载
90
+ const buildPropsRef = useRef(buildProps);
91
+ buildPropsRef.current = buildProps;
92
+
89
93
  // 当 props 变化时,通知子应用更新(不重新加载)
90
94
  useEffect(() => {
91
95
  if (state.mounted) {
@@ -93,7 +97,7 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
93
97
  }
94
98
  }, [state.mounted, buildProps]);
95
99
 
96
- // 加载微应用
100
+ // 加载微应用(只在 entry/appName/isAuthReady 变化时触发)
97
101
  useEffect(() => {
98
102
  const wrapper = wrapperRef.current;
99
103
  if (!wrapper) {
@@ -117,7 +121,7 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
117
121
  name: appName,
118
122
  entry,
119
123
  target: wrapper,
120
- props: buildProps(),
124
+ props: buildPropsRef.current(),
121
125
  });
122
126
 
123
127
  return () => {
@@ -125,7 +129,7 @@ const MicroAppLoader: React.FC<MicroAppLoaderProps> = ({
125
129
  microAppManager.cancel();
126
130
  microAppManager.setStateCallback(null);
127
131
  };
128
- }, [entry, appName, isAuthReady, buildProps]);
132
+ }, [entry, appName, isAuthReady]);
129
133
 
130
134
  return (
131
135
  <div className="micro-app-container">
@@ -79,7 +79,7 @@ body {
79
79
  }
80
80
  }
81
81
 
82
- .<%= projectName %>-sider {
82
+ .layout-web-sider {
83
83
  box-shadow: none;
84
84
  background-color: @color-text-5;
85
85
  z-index: 999;
@@ -10,6 +10,7 @@ const {
10
10
  transformDestPath,
11
11
  isTemplateFile,
12
12
  getPackageVersionsParallel,
13
+ detectPackageScope,
13
14
  setupErrorHandlers,
14
15
  createLogger,
15
16
  loadMicorc,
@@ -61,24 +62,6 @@ module.exports = class extends Generator {
61
62
  }
62
63
  }
63
64
 
64
- _detectPackageScope() {
65
- // 从根 package.json 的 name 推断 scope(与 micro-react 生成的项目一致)
66
- const rootPkgPath = path.join(this.monorepoRoot, 'package.json');
67
- if (fs.existsSync(rootPkgPath)) {
68
- try {
69
- const pkg = JSON.parse(fs.readFileSync(rootPkgPath, 'utf-8'));
70
- if (pkg.name) {
71
- return pkg.name.startsWith('@')
72
- ? pkg.name.replace(/^(@[^/]+)\/.*/, '$1')
73
- : `@${pkg.name}`;
74
- }
75
- } catch (e) {
76
- // 忽略解析错误
77
- }
78
- }
79
- return null;
80
- }
81
-
82
65
  async prompting() {
83
66
  try {
84
67
  if (!this.monorepoRoot) {
@@ -88,7 +71,7 @@ module.exports = class extends Generator {
88
71
  process.exit(1);
89
72
  }
90
73
 
91
- const detectedScope = this._detectPackageScope();
74
+ const detectedScope = detectPackageScope(this.monorepoRoot);
92
75
  const rc = this.rcConfig || {};
93
76
 
94
77
  this.answers = await this.prompt([
@@ -349,7 +332,7 @@ module.exports = class extends Generator {
349
332
  ' accessControlEnabled: false,',
350
333
  ' adminOnly: false,',
351
334
  ' routeKey: null,',
352
- ` mainDocumentId: ${Math.floor(Math.random() * 900) + 100},`,
335
+ ` mainDocumentId: ${maxId + 1},`,
353
336
  " version: '',",
354
337
  ' },',
355
338
  ].join('\n');
@@ -10,6 +10,7 @@ const {
10
10
  collectFiles,
11
11
  transformDestPath,
12
12
  isTemplateFile,
13
+ detectPackageScope,
13
14
  setupErrorHandlers,
14
15
  createLogger,
15
16
  loadMicorc,
@@ -53,26 +54,9 @@ module.exports = class extends Generator {
53
54
  }
54
55
  }
55
56
 
56
- _detectPackageScope() {
57
- const rootPkgPath = path.join(this.monorepoRoot, 'package.json');
58
- if (fs.existsSync(rootPkgPath)) {
59
- try {
60
- const pkg = JSON.parse(fs.readFileSync(rootPkgPath, 'utf-8'));
61
- if (pkg.name) {
62
- return pkg.name.startsWith('@')
63
- ? pkg.name.replace(/^(@[^/]+)\/.*/, '$1')
64
- : `@${pkg.name}`;
65
- }
66
- } catch (e) {
67
- // ignore
68
- }
69
- }
70
- return null;
71
- }
72
-
73
57
  async prompting() {
74
58
  try {
75
- const detectedScope = this._detectPackageScope();
59
+ const detectedScope = detectPackageScope(this.monorepoRoot);
76
60
  const rc = this.rcConfig || {};
77
61
 
78
62
  this.answers = await this.prompt([
@@ -57,8 +57,10 @@ module.exports = (env, argv) => {
57
57
  port: <%= devPort %>,
58
58
  hot: true,
59
59
  open: false,
60
+ allowedHosts: 'all',
60
61
  headers: {
61
62
  'Access-Control-Allow-Origin': '*',
63
+ 'Access-Control-Allow-Methods': 'GET',
62
64
  },
63
65
  static: {
64
66
  directory: path.join(__dirname, 'public'),
package/lib/utils.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
- const { execSync, exec } = require('node:child_process');
5
+ const { execSync, execFileSync, execFile } = require('node:child_process');
6
6
  const os = require('node:os');
7
7
 
8
8
  /**
@@ -122,6 +122,31 @@ function isTemplateFile(filePath) {
122
122
  return TEMPLATE_EXTENSIONS.has(path.extname(filePath));
123
123
  }
124
124
 
125
+ /**
126
+ * 从 monorepo 根目录的 package.json 推断包 scope
127
+ * @param {string} monorepoRoot - monorepo 根目录路径
128
+ * @returns {string|null} 推断出的 scope(如 '@my-project'),未找到返回 null
129
+ */
130
+ function detectPackageScope(monorepoRoot) {
131
+ const rootPkgPath = path.join(monorepoRoot, 'package.json');
132
+ if (fs.existsSync(rootPkgPath)) {
133
+ try {
134
+ const pkg = JSON.parse(fs.readFileSync(rootPkgPath, 'utf-8'));
135
+ if (pkg.name) {
136
+ return pkg.name.startsWith('@')
137
+ ? pkg.name.replace(/^(@[^/]+)\/.*/, '$1')
138
+ : `@${pkg.name}`;
139
+ }
140
+ } catch {
141
+ // ignore
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** 合法 npm 包名正则:可选 @scope/name,仅允许字母数字、连字符、点、下划线 */
148
+ const VALID_PACKAGE_NAME_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
149
+
125
150
  /** scope 包含 mico 时使用的 registry(如 @mico-platform/*) */
126
151
  const MICO_NPM_REGISTRY = 'https://nexus-vywrajy.micoworld.net/repository/mico-base-common/';
127
152
 
@@ -146,11 +171,14 @@ function getRegistryForPackage(packageName) {
146
171
  * @returns {string} 版本号,如 '1.2.3'
147
172
  */
148
173
  function getLatestNpmVersion(packageName, fallback = '1.0.0', timeoutMs = 8000, cwd) {
174
+ if (!VALID_PACKAGE_NAME_RE.test(packageName)) {
175
+ return fallback;
176
+ }
149
177
  const registry = getRegistryForPackage(packageName);
150
- const registryArg = registry ? `--registry=${registry}` : '';
178
+ const args = ['view', packageName, 'version'];
179
+ if (registry) args.push(`--registry=${registry}`);
151
180
  try {
152
- const cmd = `npm view ${packageName} version ${registryArg}`.trim();
153
- const out = execSync(cmd, {
181
+ const out = execFileSync('npm', args, {
154
182
  encoding: 'utf-8',
155
183
  timeout: timeoutMs,
156
184
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -172,28 +200,38 @@ function getLatestNpmVersion(packageName, fallback = '1.0.0', timeoutMs = 8000,
172
200
  * @returns {Promise<string>} 版本号,如 '1.2.3'
173
201
  */
174
202
  function getLatestNpmVersionAsync(packageName, fallback = '1.0.0', timeoutMs = 8000, cwd) {
203
+ if (!VALID_PACKAGE_NAME_RE.test(packageName)) {
204
+ return Promise.resolve(fallback);
205
+ }
175
206
  return new Promise((resolve) => {
207
+ let settled = false;
208
+ const done = (value) => {
209
+ if (!settled) {
210
+ settled = true;
211
+ resolve(value);
212
+ }
213
+ };
214
+
176
215
  const registry = getRegistryForPackage(packageName);
177
- const registryArg = registry ? `--registry=${registry}` : '';
178
- const cmd = `npm view ${packageName} version ${registryArg}`.trim();
216
+ const args = ['view', packageName, 'version'];
217
+ if (registry) args.push(`--registry=${registry}`);
179
218
 
180
- const child = exec(cmd, {
219
+ const child = execFile('npm', args, {
181
220
  encoding: 'utf-8',
182
221
  timeout: timeoutMs,
183
222
  ...(cwd && { cwd })
184
223
  }, (error, stdout) => {
185
224
  if (error) {
186
- resolve(fallback);
225
+ done(fallback);
187
226
  return;
188
227
  }
189
228
  const v = stdout.trim();
190
- resolve(v && /^\d+\.\d+\.\d+/.test(v) ? v : fallback);
229
+ done(v && /^\d+\.\d+\.\d+/.test(v) ? v : fallback);
191
230
  });
192
231
 
193
- // 确保超时后也能 resolve
194
232
  setTimeout(() => {
195
233
  child.kill();
196
- resolve(fallback);
234
+ done(fallback);
197
235
  }, timeoutMs + 100);
198
236
  });
199
237
  }
@@ -277,8 +315,7 @@ function mergeDefaults(defaults, rcConfig) {
277
315
  */
278
316
  function checkCommand(command) {
279
317
  try {
280
- const versionFlag = command === 'pnpm' ? '--version' : '--version';
281
- const out = execSync(`${command} ${versionFlag}`, {
318
+ const out = execSync(`${command} --version`, {
282
319
  encoding: 'utf-8',
283
320
  stdio: ['pipe', 'pipe', 'pipe'],
284
321
  timeout: 5000
@@ -396,8 +433,11 @@ function createLogger(generator) {
396
433
 
397
434
  /**
398
435
  * 安装全局未捕获异常处理
436
+ * 在 Vitest 等测试环境中自动跳过,避免干扰测试 runner 的进程管理
399
437
  */
400
438
  function setupErrorHandlers() {
439
+ if (process.env.VITEST) return;
440
+
401
441
  process.on('uncaughtException', (error) => {
402
442
  console.error('');
403
443
  console.error('❌ Unexpected error:');
@@ -431,6 +471,7 @@ module.exports = {
431
471
  toPascal,
432
472
  toCamel,
433
473
  // Generator 工具
474
+ detectPackageScope,
434
475
  shouldIgnorePath,
435
476
  collectFiles,
436
477
  transformDestPath,
package/package.json CHANGED
@@ -1,22 +1,35 @@
1
1
  {
2
2
  "name": "generator-mico-cli",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
4
4
  "description": "Yeoman generator for Mico CLI projects",
5
- "keywords": ["yeoman-generator", "generator", "cli"],
5
+ "keywords": [
6
+ "yeoman-generator",
7
+ "generator",
8
+ "cli"
9
+ ],
6
10
  "license": "MIT",
7
11
  "main": "generators/subapp-react/index.js",
8
12
  "bin": {
9
13
  "mico": "bin/mico.js"
10
14
  },
11
- "files": ["bin", "lib", "generators"],
15
+ "files": [
16
+ "bin",
17
+ "lib",
18
+ "generators"
19
+ ],
12
20
  "scripts": {
13
21
  "format": "biome format --write .",
14
22
  "format:check": "biome format .",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
15
25
  "sync:subapp-react-template": "node scripts/sync-subapp-react-template.js",
16
26
  "verify:micro-react": "node scripts/verify-micro-react.js"
17
27
  },
18
28
  "devDependencies": {
19
- "@biomejs/biome": "^1.9.4"
29
+ "@biomejs/biome": "^1.9.4",
30
+ "vitest": "^4.0.18",
31
+ "yeoman-environment": "^3.19.3",
32
+ "yeoman-test": "^6.3.0"
20
33
  },
21
34
  "engines": {
22
35
  "node": ">=18"