@oj-module/cli 0.2.29 → 0.2.42

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 (117) hide show
  1. package/devkit/manual.md +5 -5
  2. package/devkit/module-dev-and-cdn-playground.md +1 -1
  3. package/package.json +2 -2
  4. package/shell-dist/assets/{index-CnJsHOe-.js → index-FYt9CjFd.js} +50 -17
  5. package/shell-dist/assets/runtime.js +2 -2
  6. package/shell-dist/index.html +3 -3
  7. package/shell-dist/versions.json +3 -3
  8. package/src/__tests__/cli.test.ts +23 -14
  9. package/src/__tests__/list.test.ts +176 -0
  10. package/src/__tests__/registry-cli.test.ts +522 -0
  11. package/src/__tests__/registry-export.test.ts +319 -0
  12. package/src/__tests__/registry-import.test.ts +336 -0
  13. package/src/__tests__/registry-sign.test.ts +352 -0
  14. package/src/cli-output.ts +81 -0
  15. package/src/cli.ts +298 -39
  16. package/src/config-cmd.ts +100 -0
  17. package/src/contract/__tests__/handler-scan.test.ts +74 -2
  18. package/src/contract/handler-scan.ts +27 -2
  19. package/src/enable.ts +1 -1
  20. package/src/exit-codes.ts +33 -0
  21. package/src/index.ts +5 -9
  22. package/src/install.ts +120 -38
  23. package/src/lifecycle/__tests__/authoring.test.ts +6 -5
  24. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
  25. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
  26. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
  27. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
  28. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
  29. package/src/lifecycle/__tests__/helpers.ts +10 -6
  30. package/src/lifecycle/__tests__/install.test.ts +72 -15
  31. package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
  32. package/src/lifecycle/__tests__/publish.test.ts +174 -33
  33. package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
  34. package/src/lifecycle/__tests__/sign.test.ts +9 -8
  35. package/src/lifecycle/__tests__/smoke.test.ts +1 -1
  36. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  37. package/src/lifecycle/sign.ts +8 -7
  38. package/src/list.ts +146 -0
  39. package/src/manifest-contract.ts +11 -5
  40. package/src/market/__tests__/bootstrap.test.ts +4 -1
  41. package/src/market/__tests__/build-index.test.ts +529 -42
  42. package/src/market/__tests__/fixture.test.ts +17 -1
  43. package/src/market/__tests__/publish.test.ts +8 -7
  44. package/src/market/__tests__/setup.test.ts +4 -4
  45. package/src/market/__tests__/sharding.test.ts +90 -0
  46. package/src/market/__tests__/verify.test.ts +205 -12
  47. package/src/market/__tests__/yank-ledger.test.ts +60 -0
  48. package/src/market/bootstrap.ts +8 -3
  49. package/src/market/build-index.ts +407 -40
  50. package/src/market/cdn-layout.ts +3 -2
  51. package/src/market/command.ts +40 -6
  52. package/src/market/failure-playbook.ts +1 -1
  53. package/src/market/fixture.ts +20 -3
  54. package/src/market/index-builder.ts +120 -47
  55. package/src/market/publish.ts +3 -1
  56. package/src/market/setup.ts +3 -3
  57. package/src/market/sharding.ts +94 -0
  58. package/src/market/trial.ts +1 -1
  59. package/src/market/verify.ts +213 -50
  60. package/src/market/walkthrough.ts +7 -6
  61. package/src/market/yank-ledger.ts +91 -0
  62. package/src/publish.ts +250 -38
  63. package/src/registry-export.ts +405 -0
  64. package/src/registry-import.ts +410 -0
  65. package/src/registry-sidecar.ts +84 -0
  66. package/src/registry-sign.ts +322 -0
  67. package/src/registry-source.ts +192 -0
  68. package/src/sign.ts +13 -10
  69. package/src/site.ts +4 -4
  70. package/src/trust.ts +1 -1
  71. package/src/uninstall.ts +14 -2
  72. package/src/usage.ts +130 -25
  73. package/src/view.ts +94 -0
  74. package/templates/cdn/README.md +35 -5
  75. package/templates/config.yaml +10 -0
  76. package/templates/init.include.yaml +1 -1
  77. package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
  78. package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
  79. package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
  80. package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
  81. package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
  82. package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
  83. package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
  84. package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
  85. package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
  86. package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
  87. package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
  88. package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
  89. package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
  90. package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
  91. package/templates/src/_platform/api/manifest.yaml +1 -0
  92. package/templates/src/_platform/api/market/api.ts +362 -99
  93. package/templates/src/_platform/api/market/index-source.ts +112 -0
  94. package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
  95. package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
  96. package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
  97. package/templates/src/_platform/api/schema.yaml +8 -0
  98. package/templates/src/_platform/api/solutions/api.ts +8 -3
  99. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
  100. package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
  101. package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
  102. package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
  103. package/templates/src/oj-market/web/locales/en-US.json +1 -0
  104. package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
  105. package/templates/src/oj-market/web/types.ts +3 -1
  106. package/templates/src/system/web/entry.ts +5 -2
  107. package/templates/tests/lifecycle/installer.spec.ts +24 -0
  108. package/templates/tests/lifecycle/market-api.spec.ts +641 -85
  109. package/templates/tests/lifecycle/preflight.spec.ts +52 -0
  110. package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
  111. package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
  112. package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
  113. package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
  114. package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
  115. package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
  116. package/templates/tests/rbac-solution-perms.spec.ts +41 -0
  117. package/vendor/host-versions.json +3 -3
package/devkit/manual.md CHANGED
@@ -1605,7 +1605,7 @@ export default async (path: string, html: string) => {
1605
1605
 
1606
1606
  | 类别 | 命令 | 谁实现 | 一句话 |
1607
1607
  |---|---|---|---|
1608
- | **ojm 原生** | `init` / `dev` / `build` / `preview` / `api` / `info` / `vendor` / `merge` / `install` / `enable` / `disable` / `uninstall` / `trust` / `keygen` / `sign` / `publish` | `ojm` 自己 | 前后端一体工程的脚手架、开发、构建、契约生成、版本/清单、oj 分发、清单合并;方案包安装面与签名信任(install/enable/disable/uninstall 见 §11,trust/keygen/sign 见 §12,publish 见 §11.2) |
1608
+ | **ojm 原生** | `init` / `dev` / `build` / `preview` / `api` / `info` / `vendor` / `merge` / `install` / `list` / `enable` / `disable` / `uninstall` / `trust` / `view` / `keygen` / `sign` / `publish` / `registry` | `ojm` 自己 | 前后端一体工程的脚手架、开发、构建、契约生成、版本/清单、oj 分发、清单合并;方案包安装面与签名信任(install/list/enable/disable/uninstall 见 §11,trust/keygen/sign 见 §12,publish 见 §11.2);`registry` 是索引/吊销清单/离线包的外置签名与流转;整条 registry 链路(发版 / 安装 / 运维 / 查询)的图文流程见 [`prd/20261026-registry-cli-commands-design.md`](../prd/20261026-registry-cli-commands-design.md) |
1609
1609
  | **oj 透传** | `test` / `exec` / `openapi` / `migrate` / `schema` | `oj`(ojm 仅转发) | 这 5 个**就是 oj 的同名子命令**,ojm 只负责定位 `bin/oj` 并补上 `-c config.yaml` |
1610
1610
 
1611
1611
  > **为什么只有这 5 个走透传?** oj 还有 `serve` / `build` / `secret`(`keygen`/`seal` 等)
@@ -1781,7 +1781,7 @@ module-root 下数据源是「扫描 + `web.config.ts` 登记」并集(未登
1781
1781
 
1782
1782
  | 子命令 | 一句话 |
1783
1783
  |---|---|
1784
- | `ojm market setup [--url <站点>] [--user <u> --pass <p>] [--new-pass <p>]` | **外部真 CDN 场景才需要**:把 `framework.market_cdn_url` 改写为绝对地址;账号须 **superadmin**(该设置安全组仅 superadmin 可写,admin 角色会被 403);首跑若账号处于首次改密期会先自动改密再写入 |
1784
+ | `ojm market setup [--site-url <站点>] [--user <u> --pass <p>] [--new-pass <p>]` | **外部真 CDN 场景才需要**:把 `framework.market_cdn_url` 改写为绝对地址;账号须 **superadmin**(该设置安全组仅 superadmin 可写,admin 角色会被 403);首跑若账号处于首次改密期会先自动改密再写入 |
1785
1785
  | `ojm market publish [--fixture <name>@<version>] [--real <方案工程>] [--id <key>] [--key <pem>] [--rebuild-index] [--check]` | 把方案包落进 `cdn/` 并做包级 detached 签名;**不碰索引**(索引由 `build-index` 重建,避免多人发布互相覆盖) |
1786
1786
  | `ojm market build-index [--id <key>] [--key <pem>] [--check]` | 扫描 `cdn/*.tgz`、提取包内 manifest,全量重建 `bundles.yaml` + `manifests.yaml` 并签名;结果只取决于目录内容,连续执行字节一致 |
1787
1787
  | `ojm market verify [--file <tgz>] [--rebuild-index]` | 离线验包(不起站点、不装、不要凭据):sha256 完整性 / 包级 Ed25519 验签 / 索引验签 / manifest 结构分列输出;`--rebuild-index` 额外比对重建索引与盘上索引的漂移 |
@@ -1908,9 +1908,9 @@ flowchart TD
1908
1908
  `install` / `enable` / `disable` / `uninstall` / `trust` 都是**瘦客户端**——它们把请求发到目标站点
1909
1909
  (需 superadmin),本地只做「登录 → 提交 → 轮询任务至终态」并打印结果。三处寻址约定完全一致:
1910
1910
 
1911
- - `--url <站点地址>`:缺省 `http://127.0.0.1:9778`;
1911
+ - `--site-url <站点地址>`:缺省 `http://127.0.0.1:9778`;
1912
1912
  - `--user <账号>` / `--pass <密码>`:站点凭据;
1913
- - 等价环境变量 `OJM_URL` / `OJM_USER` / `OJM_PASS`(命令行缺省时回退读取,**凭据只走内存不落盘**);
1913
+ - 等价环境变量 `OJM_SITE_URL` / `OJM_USER` / `OJM_PASS`(命令行缺省时回退读取,**凭据只走内存不落盘**);
1914
1914
  - `--locale <zh-CN|en-US>`:`install` 专用,切换失败文案双语(默认按站点 locale)。
1915
1915
 
1916
1916
  | 命令 | 作用 | 注意 |
@@ -1963,7 +1963,7 @@ ojm keygen --id <publisher>.<key-name>
1963
1963
  ojm sign dist/<name>-<version>.tgz --id <publisher>.<key-name> --check
1964
1964
 
1965
1965
  # ③ 安装/分发:.sig 自动探测随包上传
1966
- ojm install dist/<name>-<version>.tgz --url <站点> --user superadmin --pass …
1966
+ ojm install dist/<name>-<version>.tgz --site-url <站点> --user superadmin --pass …
1967
1967
  ```
1968
1968
 
1969
1969
  宿主验签六码(失败文案双语,`--locale` 切换):`sig_missing`(缺签)/ `sig_alg`(算法
@@ -165,7 +165,7 @@ node "$CLI_PKG/bin/ojm.mjs" market verify --rebuild-index
165
165
  node "$CLI_PKG/bin/ojm.mjs" build # 生成 api/dist + web/dist(含 api/dist/versions.json)
166
166
  # config:platform.root 改成 api/dist(安装目标恒为 release 部署,root 必须与 --api-path 同源)
167
167
  (bin/oj serve -c config.yaml -b /api --api-path api/dist --console-log &)
168
- node "$CLI_PKG/bin/ojm.mjs" market setup --url http://127.0.0.1:9778 --user superadmin --pass 123456 --new-pass <新口令>
168
+ node "$CLI_PKG/bin/ojm.mjs" market setup --site-url http://127.0.0.1:9778 --user superadmin --pass 123456 --new-pass <新口令>
169
169
  # 市场页点安装 → 首次停在「待信任」→ 核对指纹 → ojm trust confirm --task <id> → 重试 → done
170
170
  ```
171
171
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.29",
4
+ "version": "0.2.42",
5
5
  "description": "React Antd 模块工程命令行工具(含预构建宿主 shell-dist)",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -49,7 +49,7 @@
49
49
  ],
50
50
  "dependencies": {
51
51
  "@faker-js/faker": "^10.4.0",
52
- "@oj-module/runtime": "0.2.29",
52
+ "@oj-module/runtime": "0.2.42",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "cac": "^6.7.14",
@@ -235,9 +235,54 @@ async function hydrateUser() {
235
235
  useAuthStore.subscribe((state, prev) => {
236
236
  if (state.token !== prev.token) hydrateUser().then(() => {
237
237
  useAccessStore.getState().setAccessStore(getRoutes());
238
+ repatchShellRoutes();
238
239
  });
239
240
  });
240
241
  /**
242
+ * 宿主路由根 id:patchRoutes 重登记的 parentId(与 runtime 的 ROOT_ROUTE_ID
243
+ * 同名无碍——各自作用于各自 router 实例)。
244
+ */
245
+ var SHELL_ROOT_ID = "shell-root";
246
+ /** Boot 建表后持有的宿主 router 实例;token 变化时按新角色快照重登记其路由表。 */
247
+ var shellRouter = null;
248
+ /**
249
+ * 宿主路由表 children:index 重定向 + 模块路由(getHostRoutes 同源重写)+
250
+ * catch-all 兜底。Boot 建表与登录后重登记共用同一构建口,保证两处同源。
251
+ */
252
+ function buildShellChildren() {
253
+ const hostRoutes = getHostRoutes();
254
+ return [
255
+ {
256
+ index: true,
257
+ element: /* @__PURE__ */ jsx(Navigate, {
258
+ to: hostRoutes[0]?.path ?? "/",
259
+ replace: true
260
+ })
261
+ },
262
+ ...hostRoutes,
263
+ {
264
+ path: "*",
265
+ element: /* @__PURE__ */ jsx(CatchAll, {})
266
+ }
267
+ ];
268
+ }
269
+ /**
270
+ * 按当前角色快照重登记宿主路由表(B16 回归,2026-10-24)。
271
+ *
272
+ * setAccessStore 内部的 router.patchRoutes 打的是 runtime 单例 router,而宿主
273
+ * 渲染的是本文件 Boot 里自建的 router——仅调 setAccessStore 只会更新菜单
274
+ * (access store),路由表保持 Boot 期快照。后果:Boot 期 user-info 失败
275
+ * (后端闪断/过期 token 刷新失败)→ 角色空 → requiredRoles 门禁模块被过滤出
276
+ * 路由表;随后 SPA 登录(不刷新页面)菜单回来了,但 /system/* 仍不在路由表里,
277
+ * 直达恒落 catch-all 404「返回首页」,须硬刷新才恢复。
278
+ */
279
+ function repatchShellRoutes() {
280
+ if (!shellRouter) return;
281
+ shellRouter.patchRoutes(SHELL_ROOT_ID, buildShellChildren());
282
+ const { pathname, search } = shellRouter.state.location;
283
+ shellRouter.navigate(`${pathname}${search}`, { replace: true }).catch(() => {});
284
+ }
285
+ /**
241
286
  * 直链兜底页(catch-all 路由元素)。无匹配路由时渲染框架 `PageError`,
242
287
  * 把 title 设为框架 404 文案(`exception.notFoundTitle` = "404"),
243
288
  * 观感与无权限面板同构,替代 React Router 默认的「Unexpected Application Error!」。
@@ -299,27 +344,14 @@ function Boot() {
299
344
  await hydrateUser();
300
345
  await loadAll(manifest);
301
346
  if (cancelled) return;
302
- const hostRoutes = getHostRoutes();
303
- setRouter(createBrowserRouter([{
347
+ shellRouter = createBrowserRouter([{
304
348
  path: "/",
349
+ id: SHELL_ROOT_ID,
305
350
  element: /* @__PURE__ */ jsxs(ErrorBoundary, {
306
351
  FallbackComponent: PageError,
307
352
  children: [/* @__PURE__ */ jsx(LayoutEffects, {}), /* @__PURE__ */ jsx(Outlet, {})]
308
353
  }),
309
- children: [
310
- {
311
- index: true,
312
- element: /* @__PURE__ */ jsx(Navigate, {
313
- to: hostRoutes[0]?.path ?? "/",
314
- replace: true
315
- })
316
- },
317
- ...hostRoutes,
318
- {
319
- path: "*",
320
- element: /* @__PURE__ */ jsx(CatchAll, {})
321
- }
322
- ]
354
+ children: buildShellChildren()
323
355
  }], {
324
356
  /**
325
357
  * 子路径部署(oj `mounts: [{ prefix: /oj, web: dist, spa: true }]`):
@@ -330,7 +362,8 @@ function Boot() {
330
362
  *
331
363
  * 浏览器 URL 不受影响:react-router 只是在匹配/生成时剥/补前缀。
332
364
  */
333
- basename: base }));
365
+ basename: base });
366
+ setRouter(shellRouter);
334
367
  } catch (e) {
335
368
  if (!cancelled) setError(e instanceof Error ? e.message : String(e));
336
369
  }
@@ -304,10 +304,10 @@ function getAppInfo() {
304
304
  return {
305
305
  "pkg": {
306
306
  "name": "@oj-module/runtime",
307
- "version": "0.2.29",
307
+ "version": "0.2.42",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-10-24 20:31:54"
310
+ "lastBuildTime": "2026-10-26 10:15:05"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -5,9 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" href="/favicon.ico" />
7
7
  <title>React Antd Admin · Shell</title>
8
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-5zLHUGQHePNQzqOPB7RBQA=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-2Xr8lEfbU8h45hDFonkWmg=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
9
9
 
10
- <script type="importmap" nonce="5zLHUGQHePNQzqOPB7RBQA==">{
10
+ <script type="importmap" nonce="2Xr8lEfbU8h45hDFonkWmg==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -133,7 +133,7 @@
133
133
  "dayjs/locale/zh-cn": "/assets/ojm-dayjs-locale-zh-cn.js"
134
134
  }
135
135
  }</script>
136
- <script type="module" crossorigin src="/assets/index-CnJsHOe-.js"></script>
136
+ <script type="module" crossorigin src="/assets/index-FYt9CjFd.js"></script>
137
137
  </head>
138
138
  <body>
139
139
  <div id="root"></div>
@@ -7,9 +7,9 @@
7
7
  "react-router": "7.18.2",
8
8
  "react-router/dom": "7.18.2",
9
9
  "@tanstack/react-query": "5.102.8",
10
- "@oj-module/runtime": "0.2.29",
11
- "@oj-module/runtime/contract": "0.2.29",
12
- "@oj-module/runtime/contract/errors": "0.2.29",
10
+ "@oj-module/runtime": "0.2.42",
11
+ "@oj-module/runtime/contract": "0.2.42",
12
+ "@oj-module/runtime/contract/errors": "0.2.42",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -23,6 +23,9 @@ const buildBundleMock = vi.hoisted(() => vi.fn(async () => [{ file: "x.tgz" }]))
23
23
  vi.mock("../bundle", () => ({ buildBundle: buildBundleMock }));
24
24
  const vendorCommandMock = vi.hoisted(() => vi.fn(async () => {}));
25
25
  vi.mock("../vendor", () => ({ vendorCommand: vendorCommandMock }));
26
+ // REG-CS3 v2:sign 的 --registry-id 曾只被 signCommand 要求、却没在 CLI 上接线,命令成死路
27
+ const signCommandMock = vi.hoisted(() => vi.fn(() => ({})));
28
+ vi.mock("../sign", () => ({ signCommand: signCommandMock }));
26
29
 
27
30
  beforeEach(() => {
28
31
  logs.length = 0;
@@ -35,6 +38,7 @@ afterEach(() => {
35
38
  vi.restoreAllMocks();
36
39
  buildBundleMock.mockClear();
37
40
  vendorCommandMock.mockClear();
41
+ signCommandMock.mockClear();
38
42
  });
39
43
 
40
44
  interface RunResult { out: string, unknown: string[], exitCode: number | undefined }
@@ -116,22 +120,22 @@ describe("反常识行为修正(20261023-0937)", () => {
116
120
  expect(isKnownCommand("nope")).toBe(false);
117
121
  });
118
122
 
119
- it("unknownCommandResult:help 形态报未知命令(双语),code 1", async () => {
123
+ it("unknownCommandResult:help 形态报未知命令(双语),code 2(用法错误)", async () => {
120
124
  const { unknownCommandResult } = await import("../cli");
121
125
  const zh = unknownCommandResult("nope", "zh-CN", true);
122
126
  expect(zh.text).toContain("[ojm] 未知命令:nope");
123
- expect(zh.code).toBe(1);
127
+ expect(zh.code).toBe(2);
124
128
  const en = unknownCommandResult("nope", "en-US", true);
125
129
  expect(en.text).toContain("[ojm] Unknown command: nope");
126
- expect(en.code).toBe(1);
130
+ expect(en.code).toBe(2);
127
131
  });
128
132
 
129
- it("unknownCommandResult:非 help 形态给顶层帮助,code 1", async () => {
133
+ it("unknownCommandResult:非 help 形态给顶层帮助,code 2(用法错误)", async () => {
130
134
  const { unknownCommandResult } = await import("../cli");
131
135
  const { usageText } = await import("../usage");
132
136
  const r = unknownCommandResult("nope", "zh-CN", false);
133
137
  expect(r.text).toBe(usageText("zh-CN"));
134
- expect(r.code).toBe(1);
138
+ expect(r.code).toBe(2);
135
139
  });
136
140
 
137
141
  it("localizeCacError:未知选项本地化(双语)+ 指引", async () => {
@@ -152,28 +156,28 @@ describe("反常识行为修正(20261023-0937)", () => {
152
156
  });
153
157
 
154
158
  describe("校验搬入 action 后行为保持", () => {
155
- it("new 多余位置参数显式拒绝(F8)", async () => {
159
+ it("new 多余位置参数显式拒绝(F8),exit 2", async () => {
156
160
  const r = await run(["new", "crm", "erp"], "zh-CN", tmp);
157
161
  expect(r.out).toContain("只接受一个方案名");
158
- expect(r.exitCode).toBe(1);
162
+ expect(r.exitCode).toBe(2);
159
163
  });
160
164
 
161
- it("new --layout 非 container|none 报错", async () => {
165
+ it("new --layout 非 container|none 报错,exit 2", async () => {
162
166
  const r = await run(["new", "demo", "--layout", "bogus"], "zh-CN", tmp);
163
167
  expect(r.out).toContain("--layout 只接受 container | none");
164
- expect(r.exitCode).toBe(1);
168
+ expect(r.exitCode).toBe(2);
165
169
  });
166
170
 
167
- it("new 缺 name 打印用法并 exit 1", async () => {
171
+ it("new 缺 name 打印用法并 exit 2", async () => {
168
172
  const r = await run(["new"], "zh-CN", tmp);
169
173
  expect(r.out).toContain("ojm new <name>");
170
- expect(r.exitCode).toBe(1);
174
+ expect(r.exitCode).toBe(2);
171
175
  });
172
176
 
173
- it("vendor 非法 tag 报错", async () => {
177
+ it("vendor 非法 tag 报错(exit 2:改命令行即可)", async () => {
174
178
  const r = await run(["vendor", "notatag"], "zh-CN", tmp);
175
179
  expect(r.out).toContain("非法版本号");
176
- expect(r.exitCode).toBe(1);
180
+ expect(r.exitCode).toBe(2);
177
181
  });
178
182
 
179
183
  it("vendor 0.1.12 规范化补 v 前缀后传入 vendorCommand", async () => {
@@ -188,7 +192,7 @@ describe("校验搬入 action 后行为保持", () => {
188
192
  });
189
193
 
190
194
  describe("参数映射", () => {
191
- it("merge <out> <inputs...> variadic 映射(缺文件报错提及输入名)", async () => {
195
+ it("merge <out> <inputs...> variadic 映射(缺文件报错提及输入名,exit 1:业务失败)", async () => {
192
196
  const r = await run(["merge", "out.json", "in1.json", "in2.json"], "zh-CN", tmp);
193
197
  expect(r.out).toContain("in1.json");
194
198
  expect(r.exitCode).toBe(1);
@@ -204,6 +208,11 @@ describe("参数映射", () => {
204
208
  await run(["build", "--bundle"], "zh-CN", tmp, () => buildBundleMock.mock.calls.length > 0);
205
209
  expect(buildBundleMock).toHaveBeenCalledWith(tmp, { out: undefined, name: undefined });
206
210
  });
211
+
212
+ it("sign --registry-id 映射到 signCommand.registryId(REG-CS3 v2 规范串首段)", async () => {
213
+ await run(["sign", "demo-0.1.0.tgz", "--id", "acme.market", "--registry-id", "oj-registry"], "zh-CN", tmp, () => signCommandMock.mock.calls.length > 0);
214
+ expect(signCommandMock).toHaveBeenCalledWith("demo-0.1.0.tgz", { id: "acme.market", key: undefined, check: undefined, registryId: "oj-registry" });
215
+ });
207
216
  });
208
217
 
209
218
  describe("help 词形", () => {
@@ -0,0 +1,176 @@
1
+ // BDD:`ojm list`(站点已装方案的可查面)。
2
+ //
3
+ // 到 0.3.2 为止 CLI 没有任何命令能回答「这个站点上装了什么」——装完只有一条终态文案。
4
+ // 用例盯四件事:
5
+ // ① 缺省只列**已装**(installed != null),`--all` 才连未装行一起列;
6
+ // ② 三个派生结论不许算错:upgradable / enabled / trust_state 都原样取自站点索引行;
7
+ // ③ 空清算是成功(exit 0),并在人话里给出下一步(`ojm list --all`);
8
+ // ④ `--json` 下 stdout 只有一行信封,命令名 `list`,数据在 data.solutions。
9
+ import type { SolutionRow } from "../list";
10
+ import process from "node:process";
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { buildCli, localizeCacError, reportCliError } from "../cli";
13
+ import { listCommand } from "../list";
14
+
15
+ /** fetch stub:按 URL 前缀分发,记录全部调用(uninstall.test.ts 同范式) */
16
+ function fetchStub(handlers: Record<string, (url: string) => unknown>): { fetchFn: typeof fetch, calls: string[] } {
17
+ const calls: string[] = [];
18
+ const fetchFn = (async (url: string | URL) => {
19
+ const u = String(url);
20
+ calls.push(u);
21
+ for (const [prefix, handler] of Object.entries(handlers)) {
22
+ if (u.includes(prefix))
23
+ return handler(u);
24
+ }
25
+ throw new Error(`fetchStub 未覆盖:${u}`);
26
+ }) as typeof fetch;
27
+ return { fetchFn, calls };
28
+ }
29
+
30
+ const ok = (data: unknown) => ({ ok: true, status: 200, text: async () => JSON.stringify({ code: 0, data }) });
31
+ const LOGIN = () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 });
32
+
33
+ const ROWS = [
34
+ { name: "demo-solution", latest: "0.1.2", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.1.0", "0.1.2"], trust_state: "trusted" },
35
+ { name: "acme-demo", latest: "2.0.0", installed: "2.0.0", enabled: false, upgradable: false, available: ["2.0.0"], trust_state: "untrusted" },
36
+ { name: "not-installed-one", latest: "1.0.0", installed: null, enabled: null, upgradable: false, available: ["1.0.0"], trust_state: null },
37
+ ];
38
+
39
+ let logs: string[];
40
+
41
+ beforeEach(() => {
42
+ logs = [];
43
+ vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
44
+ logs.push(args.map(String).join(" "));
45
+ });
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ describe("listCommand:已装清单", () => {
53
+ it("scenario: 缺省只列已装,按名字排序,upgradable / trust_state 原样透出", async () => {
54
+ const { fetchFn, calls } = fetchStub({
55
+ "/api/auth/login": LOGIN,
56
+ "/api/_platform/market/index": () => ok(ROWS),
57
+ });
58
+ const rows = await listCommand({ siteUrl: "http://x", user: "u", pass: "p", fetchFn });
59
+
60
+ expect(rows.map(r => r.name)).toEqual(["acme-demo", "demo-solution"]); // 未装的不出现
61
+ expect(rows.find(r => r.name === "demo-solution")).toEqual({
62
+ name: "demo-solution",
63
+ installed: "0.1.0",
64
+ enabled: true,
65
+ latest: "0.1.2",
66
+ upgradable: true,
67
+ trustState: "trusted",
68
+ });
69
+ // 登录用的是站点寻址(Bearer 由 callApi 注入,这里只断言两次调用与顺序)
70
+ expect(calls).toEqual(["http://x/api/auth/login", "http://x/api/_platform/market/index"]);
71
+
72
+ const text = logs.join("\n");
73
+ expect(text).toContain("已装 2 个方案");
74
+ expect(text).toContain("NAME");
75
+ expect(text).toContain("可升级:demo-solution");
76
+ expect(text).toContain("签名不受信:acme-demo");
77
+ expect(text).toContain("no"); // 禁用态
78
+ });
79
+
80
+ it("scenario: --all 连未装行一起列(installed 显示 -),人话里给两个计数", async () => {
81
+ const { fetchFn } = fetchStub({
82
+ "/api/auth/login": LOGIN,
83
+ "/api/_platform/market/index": () => ok(ROWS),
84
+ });
85
+ const rows = await listCommand({ siteUrl: "http://x", user: "u", pass: "p", fetchFn, all: true });
86
+
87
+ expect(rows.map(r => r.name)).toEqual(["acme-demo", "demo-solution", "not-installed-one"]);
88
+ expect(rows.find(r => r.name === "not-installed-one")).toMatchObject({ installed: null, upgradable: false, enabled: null });
89
+ const text = logs.join("\n");
90
+ expect(text).toContain("3 个方案(已装 2 个)");
91
+ expect(text).toMatch(/not-installed-one\s+-/); // 未装的行 installed 列是 `-`
92
+ });
93
+
94
+ it("scenario: 站点上一个方案都没装 → 成功(exit 0 语义),人话指路 --all", async () => {
95
+ const { fetchFn } = fetchStub({
96
+ "/api/auth/login": LOGIN,
97
+ "/api/_platform/market/index": () => ok([ROWS[2]]),
98
+ });
99
+ const rows = await listCommand({ siteUrl: "http://x", user: "u", pass: "p", fetchFn });
100
+ expect(rows).toEqual([]);
101
+ expect(logs.join("\n")).toContain("还没有已装方案");
102
+ expect(logs.join("\n")).toContain("ojm list --all");
103
+ });
104
+
105
+ it("scenario: 缺凭据 → 用法错误(2),不去联网", async () => {
106
+ const { fetchFn, calls } = fetchStub({});
107
+ await expect(listCommand({ siteUrl: "http://x", fetchFn })).rejects.toThrowError(/缺少站点凭据/);
108
+ expect(calls).toEqual([]);
109
+ });
110
+ });
111
+
112
+ /* ────────────────────────────── CLI 接线与信封 ────────────────────────────── */
113
+
114
+ interface RunResult { out: string[], err: string[], exitCode: number | undefined }
115
+
116
+ async function run(argv: string[], fetchFn: typeof fetch): Promise<RunResult> {
117
+ vi.stubGlobal("fetch", fetchFn);
118
+ const out: string[] = [];
119
+ const err: string[] = [];
120
+ vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
121
+ out.push(args.map(String).join(" "));
122
+ });
123
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
124
+ err.push(args.map(String).join(" "));
125
+ });
126
+ const exitSpy = vi.fn();
127
+ const exit = exitSpy as unknown as (code: number) => never;
128
+ const cli = buildCli(process.cwd(), "zh-CN", { exit, argv });
129
+ try {
130
+ cli.parse(["node", "ojm", ...argv], { run: true });
131
+ }
132
+ catch (error) {
133
+ reportCliError(localizeCacError(error, "zh-CN"), exit, argv);
134
+ }
135
+ await vi.waitUntil(() => out.length > 0 || err.length > 0 || exitSpy.mock.calls.length > 0, { timeout: 3000, interval: 10 });
136
+ return { out, err, exitCode: exitSpy.mock.calls[0]?.[0] as number | undefined };
137
+ }
138
+
139
+ describe("list:CLI 接线与信封", () => {
140
+ it("scenario: --json → stdout 只有一行信封,data.solutions 是过滤后的已装行", async () => {
141
+ const { fetchFn } = fetchStub({
142
+ "/api/auth/login": LOGIN,
143
+ "/api/_platform/market/index": () => ok(ROWS),
144
+ });
145
+ const r = await run(["list", "--site-url", "http://x", "--user", "u", "--pass", "p", "--json"], fetchFn);
146
+
147
+ expect(r.exitCode).toBeUndefined();
148
+ expect(r.out).toHaveLength(1);
149
+ const envelope = JSON.parse(r.out[0]!) as { ok: boolean, command: string, code: string, data: { site: string, all: boolean, solutions: SolutionRow[] } };
150
+ expect(envelope).toMatchObject({ ok: true, command: "list", code: "ok" });
151
+ expect(envelope.data.site).toBe("http://x");
152
+ expect(envelope.data.all).toBe(false);
153
+ expect(envelope.data.solutions.map(s => s.name)).toEqual(["acme-demo", "demo-solution"]);
154
+ });
155
+
156
+ it("scenario: --all 也进信封(all = true 且包含未装行)", async () => {
157
+ const { fetchFn } = fetchStub({
158
+ "/api/auth/login": LOGIN,
159
+ "/api/_platform/market/index": () => ok(ROWS),
160
+ });
161
+ const r = await run(["list", "--site-url", "http://x", "--user", "u", "--pass", "p", "--all", "--json"], fetchFn);
162
+ const envelope = JSON.parse(r.out[0]!) as { data: { all: boolean, solutions: SolutionRow[] } };
163
+ expect(envelope.data.all).toBe(true);
164
+ expect(envelope.data.solutions).toHaveLength(3);
165
+ });
166
+
167
+ it("scenario: 站点报错(索引 503)→ 业务失败(1),错误话说清楚", async () => {
168
+ const { fetchFn } = fetchStub({
169
+ "/api/auth/login": LOGIN,
170
+ "/api/_platform/market/index": () => ({ ok: false, status: 503, text: async () => JSON.stringify({ code: 503, msg: "未配置市场地址" }) }),
171
+ });
172
+ const r = await run(["list", "--site-url", "http://x", "--user", "u", "--pass", "p"], fetchFn);
173
+ expect(r.exitCode).toBe(1);
174
+ expect(r.err.join("\n")).toContain("未配置市场地址");
175
+ });
176
+ });