@oj-module/cli 0.2.23 → 0.2.26

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 (56) hide show
  1. package/devkit/manual.md +7 -5
  2. package/devkit/module-dev-and-cdn-playground.md +10 -10
  3. package/package.json +4 -4
  4. package/shell-dist/assets/runtime.js +5 -5
  5. package/shell-dist/index.html +2 -2
  6. package/shell-dist/versions.json +3 -3
  7. package/src/__tests__/build-backend.test.ts +13 -13
  8. package/src/__tests__/cli.test.ts +225 -0
  9. package/src/__tests__/layout.test.ts +8 -5
  10. package/src/__tests__/new-scaffold.test.ts +59 -0
  11. package/src/__tests__/usage.test.ts +45 -47
  12. package/src/build.ts +4 -4
  13. package/src/bundle.ts +9 -9
  14. package/src/cli.ts +265 -0
  15. package/src/contract/check.ts +12 -5
  16. package/src/dev.ts +17 -0
  17. package/src/index.ts +31 -211
  18. package/src/init.ts +2 -4
  19. package/src/keygen.ts +0 -19
  20. package/src/layout.ts +11 -11
  21. package/src/lifecycle/__tests__/authoring.test.ts +3 -14
  22. package/src/lifecycle/__tests__/bundle.test.ts +24 -24
  23. package/src/lifecycle/__tests__/install.test.ts +0 -23
  24. package/src/lifecycle/__tests__/new.test.ts +1 -1
  25. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  26. package/src/market/__tests__/bootstrap.test.ts +24 -0
  27. package/src/market/__tests__/setup.test.ts +8 -1
  28. package/src/market/bootstrap.ts +10 -8
  29. package/src/market/cdn-layout.ts +3 -3
  30. package/src/market/command.ts +0 -23
  31. package/src/market/setup.ts +14 -2
  32. package/src/market/trial.ts +3 -3
  33. package/src/market/walkthrough.ts +7 -6
  34. package/src/new.ts +21 -3
  35. package/src/preview-config.ts +2 -2
  36. package/src/preview.ts +5 -4
  37. package/src/publish.ts +0 -22
  38. package/src/sign.ts +0 -22
  39. package/src/usage.ts +482 -210
  40. package/src/vendor.ts +5 -1
  41. package/templates/README.md +3 -3
  42. package/templates/cdn/README.md +6 -6
  43. package/templates/config.yaml +5 -4
  44. package/templates/init.include.yaml +38 -2
  45. package/templates/seed.mysql.sql +2 -1
  46. package/templates/seed.pg.sql +2 -1
  47. package/templates/src/_platform/api/market/api.ts +11 -8
  48. package/templates/src/_platform/api/seed.sql +2 -1
  49. package/templates/src/_platform/api/solutions/api.ts +2 -2
  50. package/templates/src/system/web/pages/users/roles.tsx +3 -1
  51. package/templates/tests/lifecycle/market-api.spec.ts +35 -5
  52. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  53. package/templates/tests/lifecycle/verify-report.spec.ts +1 -1
  54. package/vendor/host-versions.json +3 -3
  55. package/src/args.ts +0 -172
  56. package/src/lifecycle/__tests__/args.test.ts +0 -20
package/devkit/manual.md CHANGED
@@ -1764,20 +1764,22 @@ module-root 下数据源是「扫描 + `web.config.ts` 登记」并集(未登
1764
1764
  ### 10.3 本地市场链路:`ojm market` 子命令
1765
1765
 
1766
1766
  `market` 是一组**本地模拟 CDN + 市场链路**工具,专供作者在本机验证「方案包能不能发、能不能装」,
1767
- **不触碰真实生产 CDN**。所有产物落在工程根 `cdn/`(由 `config.yaml` 的 `/cdn` 挂载直出),
1768
- 首跑用 `market setup` 把系统设置 `framework.market_cdn_url` 指过去即可在本地市场页看到。
1767
+ **不触碰真实生产 CDN**。所有产物落在工程根 `cdn/`(由 `config.yaml` 的 `/cdn` 挂载直出)。
1768
+ **脚手架 seed 的 `framework.market_cdn_url` 缺省即相对路径 `/cdn`(本站点自供),起站即市场可用,
1769
+ 无需 setup**;只有指向外部真 CDN 时才需要 `market setup` 改写设置。
1769
1770
 
1770
- > 跑任何子命令前先 `ojm market setup` 一次(把模拟 CDN 地址写进站点配置)。
1771
+ > `market setup` 已非必需(见上);它只剩「指向外部真 CDN」一个场景。
1771
1772
  > 想看「这个包到底能不能装」而不污染工程,用 `ojm market trial`(一次性隔离站点,跑完即毁)。
1772
1773
  > 每个子命令都能 `ojm market <sub> --help` 单独看帮助。
1773
1774
 
1774
1775
  | 子命令 | 一句话 |
1775
1776
  |---|---|
1776
- | `ojm market setup [--url <站点>] [--user <u> --pass <p>] [--new-pass <p>]` | 把 `framework.market_cdn_url` 指向本地 `cdn/`;首跑若账号处于首次改密期会先自动改密再写入 |
1777
+ | `ojm market setup [--url <站点>] [--user <u> --pass <p>] [--new-pass <p>]` | **外部真 CDN 场景才需要**:把 `framework.market_cdn_url` 改写为绝对地址;账号须 **superadmin**(该设置安全组仅 superadmin 可写,admin 角色会被 403);首跑若账号处于首次改密期会先自动改密再写入 |
1777
1778
  | `ojm market publish [--fixture <name>@<version>] [--real <方案工程>] [--id <key>] [--key <pem>] [--rebuild-index] [--check]` | 把方案包落进 `cdn/` 并做包级 detached 签名;**不碰索引**(索引由 `build-index` 重建,避免多人发布互相覆盖) |
1778
1779
  | `ojm market build-index [--id <key>] [--key <pem>] [--check]` | 扫描 `cdn/*.tgz`、提取包内 manifest,全量重建 `bundles.yaml` + `manifests.yaml` 并签名;结果只取决于目录内容,连续执行字节一致 |
1779
1780
  | `ojm market verify [--file <tgz>] [--rebuild-index]` | 离线验包(不起站点、不装、不要凭据):sha256 完整性 / 包级 Ed25519 验签 / 索引验签 / manifest 结构分列输出;`--rebuild-index` 额外比对重建索引与盘上索引的漂移 |
1780
1781
  | `ojm market trial [--fixture <name>@<version>] [--keep]` | 在一次性隔离站点里跑完整安装,跑完即毁;`--keep` 保留现场供排查 |
1782
+ | `ojm market walkthrough [--fixture <name>@<version>] [--only <op>] [--keep] [--strict]` | 隔离站点上 14 项全套自检(主链 9:index/install/reinstall/enable/verify/history/upgrade/rollback/uninstall;负向 5:tamper/nosig/badindex/stranger/evil——预期失败口径,防线没拦住才算 FAIL);AC 表 + 失败文案脱敏落盘 `logs/walkthrough-<ts>.md`;`--only` 单跑验原子性 |
1781
1783
 
1782
1784
  - `--fixture <name>@<version>`:用内置合成包(无需先 build);`--real <方案工程>`:先 `ojm build --bundle` 再拿真实产物。
1783
1785
  - `--id <key>` / `--key <pem>`:包级签名身份(与 `ojm sign` 同体系,默认取 `~/.ojm/keys`)。
@@ -1936,7 +1938,7 @@ ojm keygen --id <publisher>.<key-name>
1936
1938
  ojm sign dist/<name>-<version>.tgz --id <publisher>.<key-name> --check
1937
1939
 
1938
1940
  # ③ 安装/分发:.sig 自动探测随包上传
1939
- ojm install dist/<name>-<version>.tgz --url <站点> --user admin --pass …
1941
+ ojm install dist/<name>-<version>.tgz --url <站点> --user superadmin --pass …
1940
1942
  ```
1941
1943
 
1942
1944
  宿主验签六码(失败文案双语,`--locale` 切换):`sig_missing`(缺签)/ `sig_alg`(算法
@@ -68,15 +68,15 @@
68
68
 
69
69
  模板自带一个**本地模拟 CDN**(工程根 `cdn/` + 工程根 `config.yaml` 里的 `/cdn` 挂载),配上 `ojm market` 四条命令,就能在本地复刻「发包 → 上架 → 索引 → 安装」全链路。不需要外部服务。
70
70
 
71
- ### 4.1 全流程(五步)
71
+ ### 4.1 全流程(五步,step 1 已省)
72
72
 
73
73
  ```bash
74
- # 0) 起站点(dev 或 serve 均可;市场地址必须指向 oj 子进程的端口,见 §5.1)
74
+ # 0) 起站点(dev 或 serve 均可)
75
75
  ojm dev
76
76
 
77
- # 1) 打开市场地址:把 framework.market_cdn_url 指向 http://127.0.0.1:9778/cdn
78
- # 站点管理员首次登录处于改密悬挂期时会自动改密换全权 token
79
- ojm market setup --user admin --pass 123456 --new-pass <你的口令>
77
+ # 1) 【免 setup】脚手架 seed 的 framework.market_cdn_url 缺省即 /cdn(本站点自供,
78
+ # dev 形态 ojm 已把 /cdn 反代给 oj 子进程)。只有指向外部真 CDN 时才需要:
79
+ # ojm market setup --user superadmin --pass 123456 --new-pass <你的口令>
80
80
 
81
81
  # 2) 发包:合成 fixture(或 --real 走 ojm build --bundle)→ 确定性 tgz → 包级签名
82
82
  # --rebuild-index 顺带重建索引
@@ -93,7 +93,7 @@ ojm market verify --rebuild-index
93
93
 
94
94
  | 命令 | 做什么 | **不**做什么 |
95
95
  |---|---|---|
96
- | `market setup` | 写系统设置 `framework.market_cdn_url` | 不改写工程根 `config.yaml`(`/cdn` 挂载由模板自带) |
96
+ | `market setup` | 写系统设置 `framework.market_cdn_url`(**外部真 CDN 场景才需要**;脚手架缺省 /cdn 免 setup) | 不改写工程根 `config.yaml`(`/cdn` 挂载由模板自带) |
97
97
  | `market publish` | 落 `<name>-<version>.tgz` + 包级 `.tgz.sig` | **不碰索引**(索引是共享单文件,多人发布会互相覆盖) |
98
98
  | `market build-index` | 扫 `cdn/*.tgz` 提取包内 manifest,全量重建两份索引并签名 | 不落包 |
99
99
  | `market verify` | 完整性 / 包级签 / 索引签 / manifest 结构四类校验 + 漂移检出 | 不起站点、不装、不访问任何远端 |
@@ -111,9 +111,9 @@ ojm market verify --rebuild-index
111
111
 
112
112
  ## 5. 两个已经踩过的坑(别再踩)
113
113
 
114
- ### 5.1 dev 形态下,`/cdn` 不在 dev server 上
114
+ ### 5.1 dev 形态下,`/cdn` 由 oj 子进程直出(ojm 已代为反代)
115
115
 
116
- `ojm dev` 的 Node 服务在 **5174**,只把 `/api` 反代给 oj 子进程;`/cdn` 是 **oj 的挂载**,只在 oj 子进程(`config.yaml` 的 `server.port`,默认 9778)上成立。所以市场地址**必须写绝对 URL** 且端口取自 config——写成相对 `/cdn` 的话,dev 下取到的是 index.html,报出来是难懂的 YAML 解析错,不是干净 404。`market setup` 已按 config 推导,别手改。
116
+ `ojm dev` 的 Node 服务在 **5174**,把 `/api` 与 `/cdn`(config 有 `/cdn` 挂载时)一并反代给 oj 子进程;`/cdn` 是 **oj 的挂载**,直出工程根 `cdn/`。`framework.market_cdn_url` 写成相对路径 `/cdn`(seed 缺省值)时,服务端按**请求源**补全同源绝对 URL 再拉取——dev 下请求走 5174,反代链路内透传,拿到的是真索引。2026-10-23 之前 dev 只反代 `/api`,相对基址会拿到静态层回落的 index.html(难懂的 YAML 解析错)——该坑已由 dev 反代 + 消费侧 `withSiteOrigin` 补全闭环。历史上下文:`market setup` 写绝对地址(`http://<host>:<config server.port>/cdn`)依旧兼容可用。
117
117
 
118
118
  ### 5.2 CDN 里摆了 `.tgz.sig` 侧车,不等于能装
119
119
 
@@ -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 admin --pass 123456 --new-pass <新口令>
168
+ node "$CLI_PKG/bin/ojm.mjs" market setup --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
 
@@ -174,7 +174,7 @@ node "$CLI_PKG/bin/ojm.mjs" market setup --url http://127.0.0.1:9778 --user admi
174
174
  | # | 现象 | 根因 / 正确做法 |
175
175
  |---|---|---|
176
176
  | ① | `oj build` 报 `tables 声明 "x" 不在 schema.yaml` | oj 用 `schema.yaml` 做表结构真源,`manifest.tables` 只是引用面;补 `schema.yaml`(`tables: {<t>: {pk, columns}}`),与迁移语句对齐 |
177
- | ② | `oj build` 成功但 `routes.js` 是空表、dist 里没有 handler | **只编译 `.ts` handler**;写成 `ping/api.js` 会被静默丢弃 → 改 `api.ts` |
177
+ | ② | `oj build` 成功但 `routes.js` 是空表、dist 里没有 handler | **只编译 `.ts` handler**;写成 `ping/api.js` 会被静默丢弃 → 改 `api.ts`。另一同形坑(构建也零报错):`api.ts` 写成裸具名导出(`export async function ping`)——oj 内省只读 **default export** 且链尾动词须在 METHODS 白名单(get/post/put/del/patch/head/options),裸导出的函数根本不在表里 → 改成 `const ping = { async get() {...} }; export default { ping }`(`ojm new --api` 骨架即此形状) |
178
178
  | ③ | `build --bundle` 报 `缺少 src/<name>/web/locales/…`(历史布局是 `web/src/<name>/locales/…`) | manifest 一旦引用 `title_key`/`label_key` 就必须双语 locale(后端方案也要);且 key 必须**嵌套** `{"menu":{"main":"…"}}`,平铺 `"menu.main"` 查不到 |
179
179
  | ④ | 安装 POST 返回 200,但 `market/task` 恒 404 | 宿主 config 缺 `fs:` 段 → 安装器落盘被 jail 拒、失败发生在任务文件写入前;0.2.13 起模板自带,旧工程手补 |
180
180
  | ⑤ | 安装卡 `preflight`:`无法读取宿主 runtime 版本` | 安装器读 `api/dist/versions.json`;0.2.13 起 `ojm build` 双落点(旧版手抄 `web/dist/versions.json`) |
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.23",
5
- "description": "React Antd Admin 模块工程命令行工具(含预构建宿主 shell-dist)",
4
+ "version": "0.2.26",
5
+ "description": "React Antd 模块工程命令行工具(含预构建宿主 shell-dist)",
6
6
  "license": "MIT",
7
7
  "exports": {
8
8
  ".": {
@@ -49,9 +49,10 @@
49
49
  ],
50
50
  "dependencies": {
51
51
  "@faker-js/faker": "^10.4.0",
52
- "@oj-module/runtime": "0.2.23",
52
+ "@oj-module/runtime": "0.2.26",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
+ "cac": "^6.7.14",
55
56
  "esbuild": "^0.28.2",
56
57
  "tailwindcss": "^4.3.0",
57
58
  "tsx": "^4.23.12",
@@ -97,7 +98,6 @@
97
98
  "zustand": "^5.0.13"
98
99
  },
99
100
  "publishConfig": {
100
- "registry": "https://registry.npmjs.org/",
101
101
  "access": "public"
102
102
  },
103
103
  "scripts": {
@@ -304,10 +304,10 @@ function getAppInfo() {
304
304
  return {
305
305
  "pkg": {
306
306
  "name": "@oj-module/runtime",
307
- "version": "0.2.23",
307
+ "version": "0.2.26",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-10-22 18:50:31"
310
+ "lastBuildTime": "2026-10-23 21:49:09"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -6961,8 +6961,7 @@ function LayoutTabbar() {
6961
6961
  location,
6962
6962
  currentRoute,
6963
6963
  setActiveKey,
6964
- addTab,
6965
- activeKey
6964
+ addTab
6966
6965
  ]);
6967
6966
  return /* @__PURE__ */ jsx("div", {
6968
6967
  className: classes.tabsContainer,
@@ -8874,9 +8873,10 @@ async function handleErrorResponse(response) {
8874
8873
  openMustChangeModal();
8875
8874
  return response;
8876
8875
  }
8876
+ if (response.status === 403) return response;
8877
8877
  const json = data;
8878
8878
  message$1.error(json.msg || response.statusText);
8879
- } else message$1.error(response.statusText);
8879
+ } else if (response.status !== 403) message$1.error(response.statusText);
8880
8880
  return response;
8881
8881
  }
8882
8882
  var init_error_response = __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-KUwgkL5p/qZWtW8VUNiLzg=='; 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-yoOEdSzDoQ4YJ5XCof4eFQ=='; 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="KUwgkL5p/qZWtW8VUNiLzg==">{
10
+ <script type="importmap" nonce="yoOEdSzDoQ4YJ5XCof4eFQ==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -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.23",
11
- "@oj-module/runtime/contract": "0.2.23",
12
- "@oj-module/runtime/contract/errors": "0.2.23",
10
+ "@oj-module/runtime": "0.2.26",
11
+ "@oj-module/runtime/contract": "0.2.26",
12
+ "@oj-module/runtime/contract/errors": "0.2.26",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -1,7 +1,7 @@
1
1
  // buildBackend spec:宿主 versions.json 双落点(方案安装的 C2-09 前置)。
2
2
  //
3
- // 安装器按 B-11 布局读 `<api>/dist/versions.json`(与 manifests.yaml 同层)做 peerRuntime
4
- // 强制比对;而站点壳的 versions.json 落在 web/dist。两者不同源时,新工程 `ojm build`
3
+ // 安装器按 B-11 布局读 `dist/api/versions.json`(与 manifests.yaml 同层)做 peerRuntime
4
+ // 强制比对;而站点壳的 versions.json 落在 dist/web。两者不同源时,新工程 `ojm build`
5
5
  // 完仍会在 preflight 被 C2-09 拒装——这条用例钉住「同源双写」,防止回归。
6
6
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
@@ -27,19 +27,19 @@ function newProject(): string {
27
27
  writeFileSync(join(root, "api", "src", "demo", "manifest.yaml"), "name: demo\nversion: 0.1.0\n", "utf-8");
28
28
  writeFileSync(join(root, "api", "config.yaml"), "server:\n port: 9778\n", "utf-8");
29
29
  // 模拟 oj build 已产出的 manifests.yaml(验证收尾动作不破坏既有产物)
30
- mkdirSync(join(root, "api", "dist"), { recursive: true });
31
- writeFileSync(join(root, "api", "dist", "manifests.yaml"), "demo: 0.1.0\n", "utf-8");
30
+ mkdirSync(join(root, "dist", "api"), { recursive: true });
31
+ writeFileSync(join(root, "dist", "api", "manifests.yaml"), "demo: 0.1.0\n", "utf-8");
32
32
  fakeOj(root);
33
33
  return root;
34
34
  }
35
35
 
36
36
  describe("given 宿主工程有 api/src When ojm build 后端", () => {
37
- it("then api/dist/versions.json 与 shell-dist 同源落地(安装器 C2-09 前置)", async () => {
37
+ it("then dist/api/versions.json 与 shell-dist 同源落地(安装器 C2-09 前置)", async () => {
38
38
  const root = newProject();
39
39
 
40
40
  await buildBackend(root);
41
41
 
42
- const hostVersions = join(root, "api", "dist", "versions.json");
42
+ const hostVersions = join(root, "dist", "api", "versions.json");
43
43
  expect(existsSync(hostVersions)).toBe(true);
44
44
  const parsed = JSON.parse(readFileSync(hostVersions, "utf-8")) as Record<string, string>;
45
45
  expect(typeof parsed["@oj-module/runtime"]).toBe("string");
@@ -50,16 +50,16 @@ describe("given 宿主工程有 api/src When ojm build 后端", () => {
50
50
 
51
51
  await buildBackend(root);
52
52
 
53
- expect(readFileSync(join(root, "api", "dist", "manifests.yaml"), "utf-8")).toBe("demo: 0.1.0\n");
53
+ expect(readFileSync(join(root, "dist", "api", "manifests.yaml"), "utf-8")).toBe("demo: 0.1.0\n");
54
54
  });
55
55
  });
56
56
 
57
57
  describe("given 纯前端工程(无 api/src)When ojm build 后端", () => {
58
- it("then 跳过构建、不产出 api/dist", async () => {
58
+ it("then 跳过构建、不产出 dist/api", async () => {
59
59
  const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-fe-"));
60
60
 
61
61
  expect(await buildBackend(root)).toBe(false);
62
- expect(existsSync(join(root, "api", "dist"))).toBe(false);
62
+ expect(existsSync(join(root, "dist", "api"))).toBe(false);
63
63
  });
64
64
  });
65
65
 
@@ -93,7 +93,7 @@ function newModuleRootProject(): string {
93
93
  }
94
94
 
95
95
  describe("given module-root 布局 When ojm build 后端", () => {
96
- it("then 一次调用、单一输出根:--module-root src + -o api/dist(与 -d 互斥)", async () => {
96
+ it("then 一次调用、单一输出根:--module-root src + -o dist/api(与 -d 互斥)", async () => {
97
97
  const root = newModuleRootProject();
98
98
  fakeOjRecording(root, true);
99
99
 
@@ -102,9 +102,9 @@ describe("given module-root 布局 When ojm build 后端", () => {
102
102
  // 逐模块 `-o <tmp>` 会让每个 tmp 各写一份 manifests.yaml,锁与兄弟目录关系全丢
103
103
  const invoked = readFileSync(join(root, "bin", "invoked"), "utf-8").trim();
104
104
  expect(invoked).toBe(
105
- `build -c ${join(root, "config.yaml")} --module-root ${join(root, "src")} -o ${join(root, "api", "dist")} --no-minify`,
105
+ `build -c ${join(root, "config.yaml")} --module-root ${join(root, "src")} -o ${join(root, "dist", "api")} --no-minify`,
106
106
  );
107
- expect(existsSync(join(root, "api", "dist", "versions.json"))).toBe(true);
107
+ expect(existsSync(join(root, "dist", "api", "versions.json"))).toBe(true);
108
108
  });
109
109
 
110
110
  it("then oj 缺 --module-root 能力时 fail-fast,不落任何产物", async () => {
@@ -113,6 +113,6 @@ describe("given module-root 布局 When ojm build 后端", () => {
113
113
 
114
114
  await expect(buildBackend(root)).rejects.toThrow(/不支持 --module-root/);
115
115
  expect(existsSync(join(root, "bin", "invoked"))).toBe(false); // 没跑 oj build
116
- expect(existsSync(join(root, "api", "dist"))).toBe(false);
116
+ expect(existsSync(join(root, "dist", "api"))).toBe(false);
117
117
  });
118
118
  });
@@ -0,0 +1,225 @@
1
+ import type { Lang } from "../usage";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { buildCli, rewriteHelpArgv } from "../cli";
7
+
8
+ /**
9
+ * dispatch 契约(cac 驱动后):
10
+ * - 帮助请求不执行 action(market setup --help 不建 CDN)
11
+ * - 未知命令触发 command:*(index.ts 兜底打帮助 + exit 1)
12
+ * - 校验从 parse*Args 搬入 action 后行为保持(new --layout / vendor tag)
13
+ * - 位置参数与 variadic 映射(merge out in1 in2)
14
+ * - --bundle 值映射到 buildBundle
15
+ * - help 词形重写(ojm help <cmd> → <cmd> --help)
16
+ */
17
+
18
+ const logs: string[] = [];
19
+ let exitSpy: ReturnType<typeof vi.fn>;
20
+
21
+ // 提升式 mock:vitest 测试体内注册的 vi.mock 拦不到 cli.ts 的 dynamic import,须 hoisted 顶层注册
22
+ const buildBundleMock = vi.hoisted(() => vi.fn(async () => [{ file: "x.tgz" }]));
23
+ vi.mock("../bundle", () => ({ buildBundle: buildBundleMock }));
24
+ const vendorCommandMock = vi.hoisted(() => vi.fn(async () => {}));
25
+ vi.mock("../vendor", () => ({ vendorCommand: vendorCommandMock }));
26
+
27
+ beforeEach(() => {
28
+ logs.length = 0;
29
+ exitSpy = vi.fn();
30
+ vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => logs.push(args.join(" ")));
31
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => logs.push(args.join(" ")));
32
+ });
33
+
34
+ afterEach(() => {
35
+ vi.restoreAllMocks();
36
+ buildBundleMock.mockClear();
37
+ vendorCommandMock.mockClear();
38
+ });
39
+
40
+ interface RunResult { out: string, unknown: string[], exitCode: number | undefined }
41
+
42
+ /** 跑一次 parse:捕获输出、command:* 与 exit;动作异步链经条件轮询拍平(vitest 下 dynamic import 解析需数百毫秒) */
43
+ async function run(argv: string[], lang: Lang, cwd: string, until?: () => boolean): Promise<RunResult> {
44
+ const cli = buildCli(cwd, lang, { exit: exitSpy as unknown as (code: number) => never });
45
+ const unknown: string[] = [];
46
+ cli.on("command:*", () => unknown.push(String(cli.args[0])));
47
+ cli.parse(["node", "ojm", ...argv], { run: true });
48
+ try {
49
+ await vi.waitUntil(() => logs.length > 0 || exitSpy.mock.calls.length > 0 || unknown.length > 0 || !!until?.(), { timeout: 2500, interval: 10 });
50
+ }
51
+ catch {
52
+ // 无输出型动作(如帮助打屏后无日志):兜底继续
53
+ }
54
+ await new Promise(r => setTimeout(r, 50));
55
+ return { out: logs.join("\n"), unknown, exitCode: exitSpy.mock.calls[0]?.[0] as number | undefined };
56
+ }
57
+
58
+ let tmp: string;
59
+ beforeEach(() => {
60
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ojm-cli-test-"));
61
+ });
62
+
63
+ describe("帮助不执行 action", () => {
64
+ it("子命令 --help 只出单命令帮助(不叠顶层分组帮助)", async () => {
65
+ // 回归:cac --help 后 unsetMatchedCommand 会让 args[0]=子命令,误叠顶层帮助(index.ts 已按 options.help 过滤)
66
+ const r = await run(["market", "verify", "--help"], "zh-CN", tmp);
67
+ expect(r.out).toContain("离线验包");
68
+ expect(r.out).not.toContain("oj 子命令透传");
69
+ });
70
+
71
+ it("market setup --help 打帮助、不触发 setup、不 exit", async () => {
72
+ const r = await run(["market", "setup", "--help"], "zh-CN", tmp);
73
+ expect(r.out).toContain("market");
74
+ expect(r.out).not.toContain("[ojm] 站点");
75
+ expect(r.exitCode).toBeUndefined();
76
+ expect(exitSpy).not.toHaveBeenCalled();
77
+ });
78
+ });
79
+
80
+ describe("单命令帮助(双语)", () => {
81
+ it("build --help 中文:语法 + 选项 + 详解", async () => {
82
+ const r = await run(["build", "--help"], "zh-CN", tmp);
83
+ expect(r.out).toContain("ojm build");
84
+ expect(r.out).toContain("--bundle");
85
+ expect(r.out).toContain("构建后端");
86
+ });
87
+
88
+ it("build --help 英文", async () => {
89
+ const r = await run(["build", "--help"], "en-US", tmp);
90
+ expect(r.out).toContain("Builds the backend");
91
+ });
92
+
93
+ it("market verify --help 按子命令注入详解(中/英)", async () => {
94
+ expect((await run(["market", "verify", "--help"], "zh-CN", tmp)).out).toContain("离线验包");
95
+ expect((await run(["market", "verify", "--help"], "en-US", tmp)).out).toMatch(/offline/i);
96
+ });
97
+
98
+ it("trust add --help 按子命令注入详解(中/英)", async () => {
99
+ expect((await run(["trust", "add", "--help"], "zh-CN", tmp)).out).toContain("新增信任");
100
+ expect((await run(["trust", "add", "--help"], "en-US", tmp)).out).toContain("64-hex public key");
101
+ });
102
+ });
103
+
104
+ describe("未知命令与兜底", () => {
105
+ it("未知命令触发 command:*", async () => {
106
+ const r = await run(["nope"], "zh-CN", tmp);
107
+ expect(r.unknown).toEqual(["nope"]);
108
+ });
109
+ });
110
+
111
+ describe("反常识行为修正(20261023-0937)", () => {
112
+ it("isKnownCommand:已知命令 true / 未知 false", async () => {
113
+ const { isKnownCommand } = await import("../usage");
114
+ expect(isKnownCommand("market")).toBe(true);
115
+ expect(isKnownCommand("trust")).toBe(true);
116
+ expect(isKnownCommand("nope")).toBe(false);
117
+ });
118
+
119
+ it("unknownCommandResult:help 形态报未知命令(双语),code 1", async () => {
120
+ const { unknownCommandResult } = await import("../cli");
121
+ const zh = unknownCommandResult("nope", "zh-CN", true);
122
+ expect(zh.text).toContain("[ojm] 未知命令:nope");
123
+ expect(zh.code).toBe(1);
124
+ const en = unknownCommandResult("nope", "en-US", true);
125
+ expect(en.text).toContain("[ojm] Unknown command: nope");
126
+ expect(en.code).toBe(1);
127
+ });
128
+
129
+ it("unknownCommandResult:非 help 形态给顶层帮助,code 1", async () => {
130
+ const { unknownCommandResult } = await import("../cli");
131
+ const { usageText } = await import("../usage");
132
+ const r = unknownCommandResult("nope", "zh-CN", false);
133
+ expect(r.text).toBe(usageText("zh-CN"));
134
+ expect(r.code).toBe(1);
135
+ });
136
+
137
+ it("localizeCacError:未知选项本地化(双语)+ 指引", async () => {
138
+ const { localizeCacError } = await import("../cli");
139
+ const zh = localizeCacError(new Error("Unknown option `--foo`"), "zh-CN");
140
+ expect(zh).toBeInstanceOf(Error);
141
+ expect((zh as Error).message).toContain("未知选项:--foo");
142
+ expect((zh as Error).message).toContain("--help");
143
+ const en = localizeCacError(new Error("Unknown option `--foo`"), "en-US");
144
+ expect((en as Error).message).toContain("Unknown option: --foo");
145
+ });
146
+
147
+ it("localizeCacError:非匹配错误原样返回(同一实例)", async () => {
148
+ const { localizeCacError } = await import("../cli");
149
+ const err = new Error("[ojm] 别的错误");
150
+ expect(localizeCacError(err, "zh-CN")).toBe(err);
151
+ });
152
+ });
153
+
154
+ describe("校验搬入 action 后行为保持", () => {
155
+ it("new 多余位置参数显式拒绝(F8)", async () => {
156
+ const r = await run(["new", "crm", "erp"], "zh-CN", tmp);
157
+ expect(r.out).toContain("只接受一个方案名");
158
+ expect(r.exitCode).toBe(1);
159
+ });
160
+
161
+ it("new --layout 非 container|none 报错", async () => {
162
+ const r = await run(["new", "demo", "--layout", "bogus"], "zh-CN", tmp);
163
+ expect(r.out).toContain("--layout 只接受 container | none");
164
+ expect(r.exitCode).toBe(1);
165
+ });
166
+
167
+ it("new 缺 name 打印用法并 exit 1", async () => {
168
+ const r = await run(["new"], "zh-CN", tmp);
169
+ expect(r.out).toContain("ojm new <name>");
170
+ expect(r.exitCode).toBe(1);
171
+ });
172
+
173
+ it("vendor 非法 tag 报错", async () => {
174
+ const r = await run(["vendor", "notatag"], "zh-CN", tmp);
175
+ expect(r.out).toContain("非法版本号");
176
+ expect(r.exitCode).toBe(1);
177
+ });
178
+
179
+ it("vendor 0.1.12 规范化补 v 前缀后传入 vendorCommand", async () => {
180
+ await run(["vendor", "0.1.12"], "zh-CN", tmp, () => vendorCommandMock.mock.calls.length > 0);
181
+ expect(vendorCommandMock).toHaveBeenCalledWith(tmp, { force: false, tag: "v0.1.12" });
182
+ });
183
+
184
+ it("vendor --force 透传 force", async () => {
185
+ await run(["vendor", "v0.1.12", "--force"], "zh-CN", tmp, () => vendorCommandMock.mock.calls.length > 0);
186
+ expect(vendorCommandMock).toHaveBeenCalledWith(tmp, { force: true, tag: "v0.1.12" });
187
+ });
188
+ });
189
+
190
+ describe("参数映射", () => {
191
+ it("merge <out> <inputs...> variadic 映射(缺文件报错提及输入名)", async () => {
192
+ const r = await run(["merge", "out.json", "in1.json", "in2.json"], "zh-CN", tmp);
193
+ expect(r.out).toContain("in1.json");
194
+ expect(r.exitCode).toBe(1);
195
+ });
196
+
197
+ it("build --bundle demo 映射 bundleName", async () => {
198
+ const r = await run(["build", "--bundle", "demo"], "zh-CN", tmp, () => buildBundleMock.mock.calls.length > 0);
199
+ expect(buildBundleMock).toHaveBeenCalledWith(tmp, { out: undefined, name: "demo" });
200
+ expect(r.exitCode).toBeUndefined();
201
+ });
202
+
203
+ it("build 裸 --bundle 无名称(全部用户方案)", async () => {
204
+ await run(["build", "--bundle"], "zh-CN", tmp, () => buildBundleMock.mock.calls.length > 0);
205
+ expect(buildBundleMock).toHaveBeenCalledWith(tmp, { out: undefined, name: undefined });
206
+ });
207
+ });
208
+
209
+ describe("help 词形", () => {
210
+ it("ojm help build → build --help", () => {
211
+ expect(rewriteHelpArgv(["help", "build"])).toEqual(["build", "--help"]);
212
+ });
213
+
214
+ it("ojm help trust add → trust add --help", () => {
215
+ expect(rewriteHelpArgv(["help", "trust", "add"])).toEqual(["trust", "add", "--help"]);
216
+ });
217
+
218
+ it("ojm help → 顶层 --help", () => {
219
+ expect(rewriteHelpArgv(["help"])).toEqual(["--help"]);
220
+ });
221
+
222
+ it("非 help 调用不改动", () => {
223
+ expect(rewriteHelpArgv(["build", "--bundle"])).toEqual(["build", "--bundle"]);
224
+ });
225
+ });
@@ -43,16 +43,16 @@ describe("resolveLayout / module-root", () => {
43
43
 
44
44
  expect(layout.kind).toBe("module-root");
45
45
  expect(layout.modulesSrc).toBe(join(root, "src"));
46
- expect(layout.distDir).toBe(join(root, "web", "dist"));
47
- expect(layout.apiDist).toBe(join(root, "api", "dist"));
46
+ expect(layout.distDir).toBe(join(root, "dist", "web"));
47
+ expect(layout.apiDist).toBe(join(root, "dist", "api"));
48
48
  expect(layout.watchTarget).toBe(join(root, "src"));
49
49
  // --module-root 取「源码树根目录」(oj 在其子树内递归找 manifest.yaml)
50
50
  expect(layout.moduleRoot).toBe(join(root, "src"));
51
51
  });
52
52
 
53
- it("web/dist 存在不会被误判成 flat 布局", () => {
53
+ it("dist/web 存在不会被误判成 flat 布局(历史产物目录不算源码)", () => {
54
54
  const root = newModuleRootProject();
55
- mkdirSync(join(root, "web", "dist", "modules", "wiki", "0.1.0"), { recursive: true });
55
+ mkdirSync(join(root, "dist", "web", "modules", "wiki", "0.1.0"), { recursive: true });
56
56
  expect(resolveLayout(root).kind).toBe("module-root");
57
57
  });
58
58
 
@@ -102,11 +102,13 @@ describe("resolveLayout / 历史布局", () => {
102
102
  expect(layout.kind).toBe("new");
103
103
  expect(layout.modulesSrc).toBe(join(root, "web", "src"));
104
104
  expect(layout.moduleRoot).toBeUndefined();
105
+ expect(layout.distDir).toBe(join(root, "dist", "web"));
106
+ expect(layout.apiDist).toBe(join(root, "dist", "api"));
105
107
  expect(apiSrcOf(layout, "wiki")).toBe(join(root, "api", "src", "wiki"));
106
108
  expect(webSrcOf(layout, "wiki")).toBe(join(root, "web", "src", "wiki"));
107
109
  });
108
110
 
109
- it("web/<name>/entry.ts 识别为 flat,产物落 build/", () => {
111
+ it("web/<name>/entry.ts 识别为 flat,web 产物落 build/(框架根仓约定不变),api 产物归 dist/api", () => {
110
112
  const root = newRoot();
111
113
  writeFile(root, "web/home/entry.ts", "export default {};\n");
112
114
  writeFile(root, "web/home/__client/api.ts", "export {};\n");
@@ -115,6 +117,7 @@ describe("resolveLayout / 历史布局", () => {
115
117
  expect(layout.kind).toBe("flat");
116
118
  expect(layout.modulesSrc).toBe(join(root, "web"));
117
119
  expect(layout.distDir).toBe(join(root, "build"));
120
+ expect(layout.apiDist).toBe(join(root, "dist", "api"));
118
121
  expect(webSrcOf(layout, "home")).toBe(join(root, "web", "home"));
119
122
  });
120
123
 
@@ -0,0 +1,59 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { createSolution } from "../new";
7
+
8
+ /**
9
+ * ojm new 骨架内容契约(20261023-1144 计划)。
10
+ * 背景:--api 骨架曾只产出裸命名导出 `export async function ping()`,
11
+ * oj 内省只读 default export 且链尾须为 HTTP 动词 → 打包后 routes.js 恒空、
12
+ * 装后探活 /api/<name>/ping 必然 404。骨架必须「生成即可路由」。
13
+ */
14
+
15
+ const tmpRoots: string[] = [];
16
+
17
+ function projectRoot(): string {
18
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "ojm-new-scaffold-"));
19
+ tmpRoots.push(root);
20
+ // createSolution 的 N0 工程判定:web.config.ts 与 src/ 至少一个
21
+ fs.mkdirSync(path.join(root, "src"), { recursive: true });
22
+ return root;
23
+ }
24
+
25
+ afterEach(() => {
26
+ for (const root of tmpRoots.splice(0)) {
27
+ fs.rmSync(root, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ describe("ojm new --api 骨架可路由(BDD)", () => {
32
+ it("给定 createSolution(api: true),生成的 api.ts 是 default-export 嵌套组形状", () => {
33
+ const root = projectRoot();
34
+ const result = createSolution(root, "crm", { api: true, layout: "none" });
35
+ const apiTs = fs.readFileSync(path.join(result.dir, "api", "api.ts"), "utf8");
36
+
37
+ // Then:default export 承载路由组(oj 内省只读 default export)
38
+ expect(apiTs).toContain("export default { ping }");
39
+ // Then:链尾是 HTTP 动词 get(METHODS 白名单),组名 ping → GET /api/crm/ping
40
+ expect(apiTs).toMatch(/const ping = \{[\s\S]*async get\(\)/);
41
+ // Then:落实注释承诺——出参挂平级 .response(pong: boolean)
42
+ expect(apiTs).toContain("ping.get.response");
43
+ expect(apiTs).toContain("pong");
44
+ // Then:不再有「裸命名导出而无 default export」的旧形状
45
+ expect(apiTs).not.toMatch(/^export async function ping/m);
46
+ });
47
+
48
+ it("给定 createSolution(api: true),solution.yaml 过自家 market verify 五必填(20261023-1155)", async () => {
49
+ const root = projectRoot();
50
+ const result = createSolution(root, "crm", { api: true, layout: "none" });
51
+ const { parse: yamlParse } = await import("yaml");
52
+ const doc = yamlParse(fs.readFileSync(path.join(result.dir, "solution.yaml"), "utf8")) as Record<string, unknown>;
53
+
54
+ // Then:market/verify.ts structureCheck 五必填 name/version/summary/peerRuntime/archive 齐备
55
+ for (const key of ["name", "version", "summary", "peerRuntime", "archive"])
56
+ expect(doc[key], `solution.yaml 缺 ${key}(market verify 必填)`).toBeDefined();
57
+ expect((doc.archive as Record<string, unknown>).file).toBe("crm-0.1.0.tgz");
58
+ });
59
+ });