@oj-module/cli 0.2.2 → 0.2.6

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 (231) hide show
  1. package/devkit/README.md +14 -3
  2. package/devkit/SKILL.md +16 -4
  3. package/devkit/framework-dev.md +8 -6
  4. package/devkit/manual.md +243 -23
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +143 -8
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/args.ts +67 -0
  10. package/src/build.ts +11 -3
  11. package/src/bundle.ts +197 -0
  12. package/src/contract/__tests__/handler-scan.test.ts +82 -0
  13. package/src/contract/check.ts +2 -1
  14. package/src/contract/handler-scan.ts +32 -0
  15. package/src/deterministic-tar.ts +120 -0
  16. package/src/dev.ts +4 -2
  17. package/src/enable.ts +28 -0
  18. package/src/index.ts +63 -2
  19. package/src/info.ts +3 -2
  20. package/src/init.ts +189 -14
  21. package/src/install.ts +268 -0
  22. package/src/keygen.ts +88 -0
  23. package/src/layout.ts +31 -0
  24. package/src/lifecycle/__tests__/authoring.test.ts +109 -0
  25. package/src/lifecycle/__tests__/bundle.test.ts +150 -0
  26. package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
  27. package/src/lifecycle/__tests__/fixtures/README.md +59 -0
  28. package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
  29. package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
  30. package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
  31. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
  32. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
  33. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
  36. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
  37. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
  38. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
  39. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
  40. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
  41. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
  42. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
  43. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
  44. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
  45. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
  46. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
  47. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
  48. package/src/lifecycle/__tests__/helpers.ts +78 -0
  49. package/src/lifecycle/__tests__/install.test.ts +514 -0
  50. package/src/lifecycle/__tests__/keygen.test.ts +61 -0
  51. package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
  52. package/src/lifecycle/__tests__/publish.test.ts +80 -0
  53. package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
  54. package/src/lifecycle/__tests__/sign.test.ts +131 -0
  55. package/src/lifecycle/__tests__/smoke.test.ts +61 -0
  56. package/src/lifecycle/__tests__/trust.test.ts +177 -0
  57. package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
  58. package/src/lifecycle/keygen.ts +40 -0
  59. package/src/lifecycle/sign.ts +74 -0
  60. package/src/manifest-contract.ts +696 -0
  61. package/src/oj-cert.ts +19 -0
  62. package/src/oj-cmd.ts +3 -3
  63. package/src/preview.ts +2 -2
  64. package/src/publish.ts +150 -0
  65. package/src/sign.ts +128 -0
  66. package/src/site.ts +146 -0
  67. package/src/trust.ts +116 -0
  68. package/src/uninstall.ts +29 -0
  69. package/src/usage.ts +35 -1
  70. package/templates/README.md +1 -1
  71. package/templates/api/config.yaml +47 -0
  72. package/templates/api/seed.mysql.sql +1 -1
  73. package/templates/api/seed.pg.sql +1 -1
  74. package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
  75. package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
  76. package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
  77. package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
  78. package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
  79. package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
  80. package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
  81. package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
  82. package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
  83. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
  84. package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
  85. package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
  86. package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
  87. package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
  88. package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
  89. package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
  90. package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
  91. package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
  92. package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
  93. package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
  94. package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
  95. package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
  96. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
  97. package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
  98. package/templates/api/src/_platform/_shared/service/access.ts +2 -2
  99. package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
  100. package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
  101. package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
  102. package/templates/api/src/_platform/manifest.yaml +1 -0
  103. package/templates/api/src/_platform/market/api.ts +883 -0
  104. package/templates/api/src/_platform/market/bootstrap.ts +39 -0
  105. package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
  106. package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
  107. package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
  108. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
  109. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
  110. package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
  111. package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
  112. package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
  113. package/templates/api/src/_platform/schema.yaml +21 -4
  114. package/templates/api/src/_platform/seed.sql +2 -2
  115. package/templates/api/src/_platform/solutions/api.ts +212 -0
  116. package/templates/api/src/demo/manifest.yaml +5 -0
  117. package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
  118. package/templates/api/src/demo/schema.yaml +11 -0
  119. package/templates/api/src/demo/seed.sql +5 -0
  120. package/templates/api/src/demo/todos/api.ts +59 -0
  121. package/templates/api/src/home/line/api.ts +57 -0
  122. package/templates/api/src/home/manifest.yaml +6 -0
  123. package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
  124. package/templates/api/src/home/pie/api.ts +40 -0
  125. package/templates/api/src/home/schema.yaml +14 -0
  126. package/templates/api/src/home/seed.sql +70 -0
  127. package/templates/api/src/system/oplog/api.ts +44 -2
  128. package/templates/api/tests/fixture.ts +16 -0
  129. package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
  130. package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
  131. package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
  132. package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
  133. package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
  134. package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
  135. package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
  136. package/templates/api/tests/lifecycle/register.spec.ts +194 -0
  137. package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
  138. package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
  139. package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
  140. package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
  141. package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
  142. package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
  143. package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
  144. package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
  145. package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
  146. package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
  147. package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
  148. package/templates/api/tests/rbac-settings.spec.ts +8 -0
  149. package/templates/init.include.yaml +62 -0
  150. package/templates/openapi.json +527 -0
  151. package/templates/package.json +52 -0
  152. package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
  153. package/templates/web/src/_platform/__client/api.ts +335 -0
  154. package/templates/web/src/about/entry.ts +42 -0
  155. package/templates/web/src/about/locales/en-US.json +16 -0
  156. package/templates/web/src/about/locales/zh-CN.json +16 -0
  157. package/templates/web/src/about/pages/constants.ts +20 -0
  158. package/templates/web/src/about/pages/index.tsx +135 -0
  159. package/templates/web/src/access/entry.ts +74 -0
  160. package/templates/web/src/access/locales/en-US.json +46 -0
  161. package/templates/web/src/access/locales/zh-CN.json +46 -0
  162. package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
  163. package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
  164. package/templates/web/src/access/pages/button-control/index.tsx +181 -0
  165. package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
  166. package/templates/web/src/access/pages/page-control/index.tsx +142 -0
  167. package/templates/web/src/demo/__client/api.schemas.ts +20 -0
  168. package/templates/web/src/demo/__client/api.ts +89 -0
  169. package/templates/web/src/exception/entry.ts +80 -0
  170. package/templates/web/src/exception/locales/en-US.json +13 -0
  171. package/templates/web/src/exception/locales/zh-CN.json +13 -0
  172. package/templates/web/src/exception/pages/403/index.tsx +30 -0
  173. package/templates/web/src/exception/pages/404/index.tsx +30 -0
  174. package/templates/web/src/exception/pages/500/index.tsx +30 -0
  175. package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
  176. package/templates/web/src/home/__client/api.schemas.ts +22 -0
  177. package/templates/web/src/home/__client/api.ts +113 -0
  178. package/templates/web/src/login/locales/en-US.json +6 -0
  179. package/templates/web/src/login/locales/zh-CN.json +6 -0
  180. package/templates/web/src/login/pages/login.tsx +67 -18
  181. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
  182. package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
  183. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
  184. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
  185. package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
  186. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
  187. package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
  188. package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
  189. package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
  190. package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
  191. package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
  192. package/templates/web/src/oj-market/api/client.ts +135 -0
  193. package/templates/web/src/oj-market/api/context.ts +11 -0
  194. package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
  195. package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
  196. package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
  197. package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
  198. package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
  199. package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
  200. package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
  201. package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
  202. package/templates/web/src/oj-market/entry.ts +27 -0
  203. package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
  204. package/templates/web/src/oj-market/locales/en-US.json +100 -0
  205. package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
  206. package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
  207. package/templates/web/src/oj-market/types.ts +84 -0
  208. package/templates/web/src/oj-market/utils/poll.ts +36 -0
  209. package/templates/web/src/oj-market/utils/semver.ts +14 -0
  210. package/templates/web/src/outside/entry.ts +80 -0
  211. package/templates/web/src/outside/locales/en-US.json +10 -0
  212. package/templates/web/src/outside/locales/zh-CN.json +10 -0
  213. package/templates/web/src/personal-center/entry.ts +1 -10
  214. package/templates/web/src/personal-center/locales/en-US.json +1 -5
  215. package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
  216. package/templates/web/src/route-nest/entry.ts +73 -0
  217. package/templates/web/src/route-nest/locales/en-US.json +9 -0
  218. package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
  219. package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
  220. package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
  221. package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
  222. package/templates/web/src/system/__client/api.schemas.ts +12 -0
  223. package/templates/web/src/system/__client/api.ts +25 -1
  224. package/templates/web/src/system/pages/oplog/index.tsx +1 -1
  225. package/templates/web/src/system/pages/roles/index.tsx +1 -0
  226. package/templates/web/src/system/pages/settings/index.tsx +1 -1
  227. package/templates/web/src/system/pages/users/index.tsx +1 -1
  228. package/templates/web.config.ts +32 -40
  229. package/vendor/host-versions.json +3 -3
  230. package/templates/pnpm-workspace.yaml +0 -14
  231. package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
package/src/init.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `ojm init` —— 前后端一体化工程脚手架(设计 §3,D1/D3/D6/D10)。
3
3
  *
4
- * 产物:api/(oj 后端 + 现场签发证书 + seed)、web/src/(前端模块)、
5
- * bin/(从 everpan/only-js 最新 release 联网下载:oj + plugins + devkit)、
4
+ * 产物:api/(oj 后端 + seed)、config/(config.yaml + 现场签发证书,与 api/、web/ 同级)、
5
+ * web/src/(前端模块)、bin/(从 everpan/only-js 最新 release 联网下载:oj + plugins + devkit)、
6
6
  * 工程配置文件。
7
7
  *
8
8
  * 幂等补缺:对既有工程重跑只补缺失文件(bin/oj 已在则跳过下载),config.yaml 与
@@ -15,6 +15,8 @@ import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import process from "node:process";
17
17
  import { fileURLToPath } from "node:url";
18
+ import yaml from "yaml";
19
+ import { resolveCertDir, resolveConfigPath } from "./layout";
18
20
  import { mintDevCert } from "./oj-cert";
19
21
  import { resolveOjBin, vendorCommand } from "./vendor";
20
22
  import { readHostVersions } from "./versions";
@@ -56,13 +58,15 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
56
58
  const templatesDir = path.join(cliRoot, "templates");
57
59
  const jwtSecret = crypto.randomBytes(32).toString("hex");
58
60
  const projectName = path.basename(destDir);
61
+ const include = readInitInclude(templatesDir);
59
62
  copyTemplates(templatesDir, destDir, {
60
63
  __JWT_SECRET__: jwtSecret,
61
64
  __PROJECT_NAME__: projectName,
62
- }, report);
65
+ }, report, include, templatesDir);
66
+ pruneUnincludedApiPaths(destDir, include, report);
63
67
 
64
- // 2) 证书三件套(现场签发,D6)
65
- const certDir = path.join(destDir, "api/config");
68
+ // 2) 证书三件套(现场签发,D6)——落点即 resolveCertDir()(config.yaml 同级的 config/)
69
+ const certDir = resolveCertDir(destDir);
66
70
  if (fs.existsSync(path.join(certDir, "cert.jws"))) {
67
71
  report.skipped.push("api/config/cert.jws");
68
72
  }
@@ -142,6 +146,13 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
142
146
  // 的「已存在即跳过」会把审批挡在门外(0.1.0 发布实测)——必须合并
143
147
  ensureAllowBuilds(destDir, report);
144
148
 
149
+ // 5c) .gitignore:模板自带一份(模板兼作可运行 demo 用),但 npm/pnpm pack
150
+ // 会剥离 `.gitignore` 点文件(npm-packlist always-exclude,`files` 字段
151
+ // 绕不过),收录进 init.include.yaml 只会让安装包缺文件、readInitInclude
152
+ // fail-fast 把整个 init 打挂(0.2.5 发布实测:目标目录只剩空壳)——
153
+ // 与 pnpm-workspace.yaml 同型,改由 init 现场生成
154
+ ensureGitignore(destDir, report);
155
+
145
156
  console.log(`[ojm] init 完成:${destDir}`);
146
157
  console.log(`[ojm] 新增 ${report.created.length} 项,跳过(已存在)${report.skipped.length} 项`);
147
158
  for (const item of report.skipped)
@@ -154,7 +165,7 @@ const defaultSealer: Sealer = async (destDir, plainSecret) => {
154
165
  const ojBin = resolveOjBin(destDir);
155
166
  if (!fs.existsSync(ojBin))
156
167
  return null;
157
- const keysDir = path.join(destDir, "api/config");
168
+ const keysDir = resolveCertDir(destDir);
158
169
  const publicKey = path.join(keysDir, "secrets-public.pem");
159
170
  try {
160
171
  fs.mkdirSync(keysDir, { recursive: true });
@@ -178,7 +189,7 @@ const defaultSealer: Sealer = async (destDir, plainSecret) => {
178
189
  * 并补 `secrets:` 段指向私钥。失败一律降级为明文 + 告警——init 不因加密失败而失败。
179
190
  */
180
191
  async function sealJwtSecret(destDir: string, report: InitReport, seal: Sealer = defaultSealer): Promise<void> {
181
- const configPath = path.join(destDir, "api/config.yaml");
192
+ const configPath = resolveConfigPath(destDir);
182
193
  if (!fs.existsSync(configPath))
183
194
  return;
184
195
  const text = fs.readFileSync(configPath, "utf-8");
@@ -192,43 +203,68 @@ async function sealJwtSecret(destDir: string, report: InitReport, seal: Sealer =
192
203
  return;
193
204
  }
194
205
  const next = text.replace(match[0], `${match.groups.indent}jwt_secret: "${sealed}"`);
195
- // secrets 段:私钥只放部署机,路径相对 config 目录
206
+ // secrets 段:私钥只放部署机,路径相对 config 目录(config.yaml 同级)
196
207
  const withSecrets = `${next.trimEnd()}\nsecrets:\n private_key_path: ./config/secrets-private.pem\n public_key_path: ./config/secrets-public.pem\n`;
197
208
  fs.writeFileSync(configPath, withSecrets);
198
209
  report.created.push("api/config/{secrets-private.pem,secrets-public.pem} + auth.jwt_secret 已密封(ENC[...])");
199
210
  }
200
211
 
201
- /** 递归拷贝模板;文本文件做占位符替换;已存在的目标文件一律跳过 */
212
+ /**
213
+ * 递归拷贝模板;文本文件做占位符替换;已存在的目标文件一律跳过。
214
+ *
215
+ * 白名单语义(init.include.yaml):清单内路径整棵拷贝;清单外一律不拷、不计入
216
+ * created/skipped(demo 模块/模板自运行产物/元数据的大头在清单外)。清单外的
217
+ * 目录仅在「有收录子孙」时才穿行(如 `api/`、`web/src/`、`web/src/demo/`——
218
+ * 只收录了后者底下的 entry/locales/pages),穿行时未收录的子项继续被挡下。
219
+ */
202
220
  function copyTemplates(
203
221
  srcDir: string,
204
222
  destDir: string,
205
223
  placeholders: Record<string, string>,
206
224
  report: InitReport,
225
+ include: Set<string>,
226
+ rootDir: string,
227
+ underIncluded = false,
207
228
  ): void {
208
229
  for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
209
230
  const src = path.join(srcDir, entry.name);
210
231
  const dest = path.join(destDir, entry.name);
232
+ const srcRel = path.relative(rootDir, src);
233
+ const listed = include.has(srcRel);
234
+ if (!underIncluded && !listed && !hasIncludedDescendant(include, srcRel))
235
+ continue;
211
236
  if (entry.isDirectory()) {
212
237
  // 空目录不进工程:git 不跟踪空目录,模板里残留的空目录(如误建的
213
238
  // api/src/_shared)会被 oj build 当作缺 manifest.yaml 的模块 fail-fast
214
239
  if (!dirHasFiles(src))
215
240
  continue;
216
241
  fs.mkdirSync(dest, { recursive: true });
217
- copyTemplates(src, dest, placeholders, report);
242
+ copyTemplates(src, dest, placeholders, report, include, rootDir, underIncluded || listed);
218
243
  continue;
219
244
  }
220
- const rel = path.relative(destDir, dest);
245
+ const destRel = path.relative(destDir, dest);
221
246
  if (fs.existsSync(dest)) {
222
- report.skipped.push(rel);
247
+ report.skipped.push(destRel);
223
248
  continue;
224
249
  }
225
250
  let content = fs.readFileSync(src, "utf-8");
226
251
  for (const [token, value] of Object.entries(placeholders))
227
252
  content = content.replaceAll(token, value);
253
+ content = stripRegion(content, "demo-only");
228
254
  fs.mkdirSync(path.dirname(dest), { recursive: true });
229
255
  fs.writeFileSync(dest, content);
230
- report.created.push(rel);
256
+ report.created.push(destRel);
257
+ }
258
+ }
259
+
260
+ /** srcRel 是否为白名单里某条收录路径的祖先目录——决定清单外目录要不要穿行 */
261
+ function hasIncludedDescendant(include: Set<string>, srcRel: string): boolean {
262
+ const prefix = `${srcRel}/`;
263
+ for (const p of include) {
264
+ if (p.startsWith(prefix))
265
+ return true;
231
266
  }
267
+ return false;
232
268
  }
233
269
 
234
270
  /** 目录(递归)是否含至少一个文件——空目录判定供 copyTemplates 跳过用 */
@@ -242,6 +278,85 @@ function dirHasFiles(dir: string): boolean {
242
278
  return false;
243
279
  }
244
280
 
281
+ /**
282
+ * 读取模板根 `init.include.yaml` 的 `include` 白名单,归一为相对模板根的路径集合。
283
+ * 清单随模板一起发布,缺失即安装包损坏——直接 fail-fast,不留「静默少拷」的
284
+ * 退化路径。条目指向不存在的路径同样 fail-fast:拼错一个目录名不会当场报错,
285
+ * 只会在用户工程里留下难以追溯的缺文件(旧 exclude 清单没有这类校验面)。
286
+ */
287
+ function readInitInclude(templatesDir: string): Set<string> {
288
+ const yamlPath = path.join(templatesDir, "init.include.yaml");
289
+ if (!fs.existsSync(yamlPath))
290
+ throw new Error(`模板缺少 init.include.yaml:${yamlPath}(安装包损坏?)`);
291
+ const doc = yaml.parse(fs.readFileSync(yamlPath, "utf-8")) as { include?: string[] } | null;
292
+ const out = new Set<string>();
293
+ for (const raw of doc?.include ?? []) {
294
+ const p = path.normalize(raw).replace(/^(\.\/)+/, "");
295
+ out.add(p);
296
+ if (!fs.existsSync(path.join(templatesDir, p)))
297
+ throw new Error(`init.include.yaml 收录了不存在的路径:${p}(拼写错误或模板缺文件?)`);
298
+ }
299
+ return out;
300
+ }
301
+
302
+ /**
303
+ * 从脚手架产出的 openapi.json 里删掉未进白名单的 api 模块端点。
304
+ *
305
+ * 模板的 openapi.json 是「全量演示工程」的产物,含 demo-only 模块(demo/home)
306
+ * 的端点;这些模块的文件不拷进新工程,契约里留着它们会让 `ojm api --check`
307
+ * 一上手就报「openapi 多」(有契约无 handler)。故只保留白名单内 api 模块
308
+ * 名下的 `/api/<mod>/` 路径,其余裁掉——契约与脚手架实际模块严格一致。
309
+ */
310
+ function pruneUnincludedApiPaths(destDir: string, include: Set<string>, report: InitReport): void {
311
+ // 只裁本次新建的 openapi.json:init 是幂等补缺,不改动既有工程文件
312
+ if (!report.created.includes("openapi.json"))
313
+ return;
314
+ const mods = new Set([...include]
315
+ .map(p => (p.startsWith("api/src/") ? p.slice("api/src/".length) : null))
316
+ .filter((m): m is string => !!m && !m.includes("/")));
317
+ if (mods.size === 0)
318
+ return;
319
+ const openapiPath = path.join(destDir, "openapi.json");
320
+ const doc = JSON.parse(fs.readFileSync(openapiPath, "utf-8")) as { paths?: Record<string, unknown> };
321
+ const paths = doc.paths ?? {};
322
+ const dropped = Object.keys(paths).filter((p) => {
323
+ const mod = /^\/api\/([^/]+)\//.exec(p)?.[1];
324
+ return !!mod && !mods.has(mod);
325
+ });
326
+ if (dropped.length === 0)
327
+ return;
328
+ for (const p of dropped)
329
+ delete paths[p];
330
+ fs.writeFileSync(openapiPath, `${JSON.stringify(doc, null, "\t")}\n`);
331
+ report.created.push(`openapi.json 裁掉未收录 api 模块端点 ${dropped.length} 条`);
332
+ }
333
+
334
+ /**
335
+ * 删除文本里的 `#region <name>` … `#endregion` 区间(含标记行本身)。
336
+ * 用于把模板内置的 demo-only 模块条目从脚手架产出的配置里剥离,避免留下
337
+ * 指向未拷贝文件的死条目。标记写不成对时原样返回,不做猜测。
338
+ */
339
+ function stripRegion(text: string, name: string): string {
340
+ const lines = text.split("\n");
341
+ const start = new RegExp(`^\\s*//\\s*#region\\s+${name}\\b`);
342
+ const end = /^\s*\/\/\s*#endregion\b/;
343
+ const kept: string[] = [];
344
+ let inRegion = false;
345
+ for (const line of lines) {
346
+ if (inRegion) {
347
+ if (end.test(line))
348
+ inRegion = false;
349
+ continue;
350
+ }
351
+ if (start.test(line)) {
352
+ inRegion = true;
353
+ continue;
354
+ }
355
+ kept.push(line);
356
+ }
357
+ return inRegion ? text : kept.join("\n");
358
+ }
359
+
245
360
  function copyIfAbsent(src: string, dest: string, report: InitReport): void {
246
361
  if (fs.existsSync(dest)) {
247
362
  report.skipped.push(path.basename(dest));
@@ -253,14 +368,74 @@ function copyIfAbsent(src: string, dest: string, report: InitReport): void {
253
368
  }
254
369
 
255
370
  /**
256
- * 确保 pnpm-workspace.yaml 的 allowBuilds 含 `esbuild: true`。
371
+ * 新工程 .gitignore:不存在则生成;已存在则幂等跳过(用户规则永不覆盖)。
372
+ *
373
+ * 内容与 templates/.gitignore 对齐(模板兼作可运行 demo,ignore 的是同一套
374
+ * 现场产物):dev 下载的 bin/oj、sqlite/日志/现场签发的私钥与证书、构建产物。
375
+ * 它不能随模板拷贝——npm/pnpm pack 剥离 `.gitignore`,详见 initProject 5c。
376
+ */
377
+ const SCAFFOLD_GITIGNORE = `# 由 ojm init 生成。
378
+ node_modules/
379
+ bin/
380
+ .ojm-tmp-*
381
+ # 本地 sqlite 与运行日志
382
+ api/db.sqlite
383
+ api/db.sqlite-shm
384
+ api/db.sqlite-wal
385
+ api/logs/
386
+ # 现场签发的开发证书与凭据私钥(ojm dev 缺证时自动签发,见 oj-cert.ts);私钥绝不入仓
387
+ api/config/*.pem
388
+ api/config/cert.jws
389
+ # 构建产物
390
+ api/dist/
391
+ web/dist/
392
+ modules/dist/
393
+ `;
394
+
395
+ function ensureGitignore(destDir: string, report: InitReport): void {
396
+ const gitignorePath = path.join(destDir, ".gitignore");
397
+ if (fs.existsSync(gitignorePath)) {
398
+ report.skipped.push(".gitignore");
399
+ return;
400
+ }
401
+ fs.writeFileSync(gitignorePath, SCAFFOLD_GITIGNORE);
402
+ report.created.push(".gitignore");
403
+ }
404
+
405
+ /**
406
+ * 确保工程根 pnpm-workspace.yaml 的 allowBuilds 含 `esbuild: true`。
407
+ * - 文件不存在(本函数即唯一来源,见下)→ 按 SCAFFOLD_WORKSPACE_YAML 生成
257
408
  * - pnpm v11 自动生成的占位行("set this to true or false")→ 填 true
258
409
  * - 有 allowBuilds 段无 esbuild 行 → 段内追加
259
410
  * - 无 allowBuilds 段 → 文末追加整段
260
411
  * - 显式 `esbuild: false` → 用户的选择,尊重不动
412
+ *
413
+ * 本文件由 init 生成而非模板拷贝:模板目录本身是根 workspace 的成员,若带一份
414
+ * pnpm-workspace.yaml,pnpm 会就近把它当 workspace 根,`cd templates && pnpm i`
415
+ * 就找不到父级 catalog(ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC)。
261
416
  */
417
+ const SCAFFOLD_WORKSPACE_YAML = `# 由 ojm init 生成。
418
+ # pnpm v11 起构建审批只读 pnpm-workspace.yaml(package.json 的 pnpm 字段
419
+ # 已废弃)。缺了 allowBuilds,esbuild 的 postinstall 会被默认拦截,之后所有
420
+ # pnpm 命令报 ERR_PNPM_IGNORED_BUILDS。
421
+ allowBuilds:
422
+ esbuild: true
423
+
424
+ # @oj-module/cli 经 catalog: 拉 @redocly/cli,而 catalog 只作用于 workspace
425
+ # 自有 package.json 的依赖,对 cli(外部包)的 catalog: 传递依赖无效;不钉的话
426
+ # 会漂到 latest,撞本机 minimumReleaseAge 供应链策略被拒
427
+ # (ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION)。钉到 cli 实测通过的 2.51.0。
428
+ overrides:
429
+ '@redocly/cli': 2.51.0
430
+ `;
431
+
262
432
  function ensureAllowBuilds(destDir: string, report: InitReport): void {
263
433
  const yamlPath = path.join(destDir, "pnpm-workspace.yaml");
434
+ if (!fs.existsSync(yamlPath)) {
435
+ fs.writeFileSync(yamlPath, SCAFFOLD_WORKSPACE_YAML);
436
+ report.created.push("pnpm-workspace.yaml(构建审批 + redocly 钉版)");
437
+ return;
438
+ }
264
439
  let content = fs.readFileSync(yamlPath, "utf-8");
265
440
  if (/^\s+esbuild:\s*(?:true|false)\s*$/m.test(content)) {
266
441
  if (/^\s+esbuild:\s*true\s*$/m.test(content))
package/src/install.ts ADDED
@@ -0,0 +1,268 @@
1
+ import type { TaskRecord } from "./site";
2
+ // ojm install(P1-13,D6):方案包安装瘦客户端。目标两种形态:
3
+ // · 本地 tgz 路径(<name>-<version>.tgz 文件名推导身份,--name/--version 可覆盖)→ file 源 multipart 上传;
4
+ // · name[@version] → cdn 源(站点侧 Form B 索引解析,安装器不拼 URL——市场侧职责,api.ts 同纪律)。
5
+ // 流程:登录 → POST /api/_platform/market/install → 轮询 task?id= 至终态 → 渲染。
6
+ // 终态渲染(契约附录 A 同表取用,R28/DRY):失败/partial 打 error.reason + retryable 标注 +
7
+ // hintKey 对应双语指引(--locale zh-CN|en-US,缺省 zh-CN;文案真源为契约附录 A 表)。
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import process from "node:process";
11
+ import { callApi, isFutureSchema, login, pollTask, resolveSite, TASK_SCHEMA_VERSION_KNOWN, unwrapEnvelope } from "./site";
12
+
13
+ export type Locale = "zh-CN" | "en-US";
14
+
15
+ /** 契约附录 A 十阶段指引(zh/en 双语列;文案单一来源 = 契约附录 A 表,此处为渲染镜像) */
16
+ const STAGE_HINTS: Record<string, { zh: string, en: string }> = {
17
+ preflight: { zh: "请修正方案清单或释放占用的名称与前缀后重新安装", en: "Fix the manifest or release the occupied name and prefix, then install again" },
18
+ downloading: { zh: "请检查网络与市场地址(框架设置 → 市场源),稍后重试", en: "Check the network and market URL (Framework settings → Market source), then retry" },
19
+ verifying: { zh: "分发包可能损坏,请重试下载;持续失败请联系发布方", en: "The archive may be corrupted; retry the download, or contact the publisher if it persists" },
20
+ extracting: { zh: "安装已终止且零落盘;若反复出现,请勿使用该分发包并联系发布方", en: "Installation aborted with nothing written; if it repeats, do not use this archive and contact the publisher" },
21
+ migrating: { zh: "数据已保留原状,请修复后重试;必要时回退到上一版本", en: "Data is left intact; fix the issue and retry, or roll back to the previous version if needed" },
22
+ lock_commit: { zh: "存在并发构建或安装,请稍后重试", en: "A concurrent build or install is in progress; retry later" },
23
+ registering: { zh: "请重试;多次失败请检查站点数据库状态", en: "Retry; if it keeps failing, check the site database state" },
24
+ deploying_web: { zh: "请重试;方案已部分生效,若持续失败请禁用该方案", en: "Retry; the solution is partially live — disable it if the failure persists" },
25
+ reloading: { zh: "站点仍在使用旧配置服务,请稍后重试或联系运维", en: "The site still serves the old config; retry later or contact ops" },
26
+ smoke: { zh: "方案已部分生效,请按详情排查;可先禁用该方案恢复原状", en: "The solution is partially live; inspect the details, or disable it to restore the previous state" },
27
+ };
28
+
29
+ /** retryable 全局两值(契约附录 A:全 stage 共用) */
30
+ const RETRYABLE_LABELS: Record<Locale, { yes: string, no: string }> = {
31
+ "zh-CN": { yes: "本次失败可重试", no: "本次失败不可自动重试" },
32
+ "en-US": { yes: "This failure is retryable", no: "This failure is not retryable" },
33
+ };
34
+
35
+ /**
36
+ * verifying 段签名失败六码双语文案(契约附录 A.2;文案真源 = 契约附录 A.2 表,此处为渲染镜像,
37
+ * 缺失即测试红,同 C2-25 纪律)。`error.reason` 走 key(六码),渲染层按 locale 取本表;
38
+ * 非六码失败(其它 stage)reason 仍是服务端可读串,渲染原样回退(不丢信息)。
39
+ */
40
+ const VERIFY_CODE_COPY: Record<string, { reason: { zh: string, en: string }, hint: { zh: string, en: string } }> = {
41
+ sig_missing: {
42
+ reason: {
43
+ zh: "缺少签名:未找到 detached 签名侧车(.sig)且市场清单无 signature 字段——宿主已启用强制验签,未签名包一律拒装",
44
+ en: "Missing signature: no detached .sig sidecar and the manifest has no signature field — the host enforces signing, so unsigned packages are rejected",
45
+ },
46
+ hint: {
47
+ zh: "请用 ojm sign 对包签名后重传,或确认市场源已发布带签名的版本",
48
+ en: "Sign the package with `ojm sign` and re-upload, or confirm the market source published a signed version",
49
+ },
50
+ },
51
+ sig_alg: {
52
+ reason: {
53
+ zh: "签名算法不被接受:仅支持 Ed25519(alg 必须逐字为 \"Ed25519\",大小写敏感)",
54
+ en: "Unsupported signature algorithm: only Ed25519 is accepted (alg must be exactly \"Ed25519\", case-sensitive)",
55
+ },
56
+ hint: { zh: "请让发布方改用 Ed25519 签名", en: "Ask the publisher to sign with Ed25519" },
57
+ },
58
+ sig_key: {
59
+ reason: {
60
+ zh: "公钥标识(key_id)不合法,或与信任库绑定不一致(首次绑定不可覆盖)",
61
+ en: "Key identifier (key_id) is invalid, or does not match the trusted-key binding (first binding cannot be overwritten)",
62
+ },
63
+ hint: {
64
+ zh: "请核对 key_id 命名空间(<publisher>.<id>),或联系运维将正确公钥加入信任库",
65
+ en: "Check the key_id namespace (<publisher>.<id>), or ask ops to add the correct public key to the trust store",
66
+ },
67
+ },
68
+ tampered: {
69
+ reason: {
70
+ zh: "验签失败:包体、公钥或签名任一被篡改(Ed25519 验签返回 false)",
71
+ en: "Signature verification failed: the archive, public key, or signature was tampered with (Ed25519 verify returned false)",
72
+ },
73
+ hint: {
74
+ zh: "重新下载安装包并核对发布方指纹;若持续失败请勿使用该包",
75
+ en: "Re-download the package and verify the publisher's fingerprint; if it persists, do not use this package",
76
+ },
77
+ },
78
+ unknown_key: {
79
+ reason: {
80
+ zh: "未知发布方(key_id 不在信任库):需经 TOFU 确认后方可安装",
81
+ en: "Unknown publisher (key_id not in the trust store): requires TOFU confirmation before install",
82
+ },
83
+ hint: {
84
+ zh: "请与发布方核对指纹后执行 ojm trust confirm --task <id>",
85
+ en: "Verify the fingerprint with the publisher, then run `ojm trust confirm --task <id>`",
86
+ },
87
+ },
88
+ sig_format: {
89
+ reason: {
90
+ zh: "签名编码违规:公钥非 64 位 hex、签名非标准 base64(禁 base64url),或规范串版本标记非 v1",
91
+ en: "Signature encoding violation: public key is not 64-hex, signature is not standard base64 (base64url forbidden), or the canonical-version marker is not v1",
92
+ },
93
+ hint: {
94
+ zh: "请让发布方使用标准签名工具(ojm sign)重新签名",
95
+ en: "Ask the publisher to re-sign with the standard tooling (ojm sign)",
96
+ },
97
+ },
98
+ };
99
+
100
+ function normalizeLocale(locale?: string): Locale {
101
+ return locale === "en-US" || locale === "en" ? "en-US" : "zh-CN";
102
+ }
103
+
104
+ /**
105
+ * 终态渲染(纯函数,人话多行文本):done 单行;failed/partial = 标题 + 原因 + 指引 + 重试标注
106
+ * (hintKey 随标注行附上,便于对照契约附录 A)。
107
+ */
108
+ export function renderOutcome(record: TaskRecord, locale?: string): string {
109
+ const lang = normalizeLocale(locale);
110
+ // P2-10 升版纪律(读方):不认识的高版本 → 人话降级提示 + 不误渲染成功——
111
+ // 语义可能已漂移(status 字段含义不再可信),只出降级提示,连 done 也不报「安装完成」
112
+ if (isFutureSchema(record)) {
113
+ return lang === "en-US"
114
+ ? `[ojm] Task record schema version ${record.schemaVersion} is newer than this CLI supports (${TASK_SCHEMA_VERSION_KNOWN}) — fields may be missing or semantics drifted. Upgrade ojm and retry.`
115
+ : `[ojm] 任务记录 schema 版本 ${record.schemaVersion} 高于本 CLI 支持的版本(${TASK_SCHEMA_VERSION_KNOWN})——字段可能缺失或语义漂移,请升级 ojm 后重试。`;
116
+ }
117
+ if (record.status === "done") {
118
+ return lang === "en-US"
119
+ ? `[ojm] Install complete: ${record.name} ${record.version}`
120
+ : `[ojm] 安装完成:${record.name} ${record.version}`;
121
+ }
122
+ if (record.status === "needs_trust") {
123
+ // 第五终态(P2-3,G3):未知发布方待 TOFU 确认——**非成功态、非失败崩溃**;
124
+ // 指纹分组展示 + 确认命令 + 重试指引(契约附录 A.2 unknown_key)
125
+ const t = record.trust;
126
+ const fp = t?.fingerprint ? (t.fingerprint.match(/.{1,8}/g) ?? []).join(" ") : "(未知)";
127
+ return lang === "en-US"
128
+ ? [
129
+ `[ojm] Trust confirmation required (publisher: ${t?.key_id ?? "unknown"})`,
130
+ `Fingerprint (sha256 of public key): ${fp}`,
131
+ `Verify it with the publisher, then run: ojm trust confirm --task ${record.id}`,
132
+ "After confirming, retry the install command.",
133
+ ].join("\n")
134
+ : [
135
+ `[ojm] 待信任确认(发布方:${t?.key_id ?? "未知"})——安装未完成`,
136
+ `指纹(公钥 sha256,请与发布方公布值逐字核对):${fp}`,
137
+ `确认命令:ojm trust confirm --task ${record.id}`,
138
+ "确认入库后重试安装命令即可继续。",
139
+ ].join("\n");
140
+ }
141
+ const retryable = RETRYABLE_LABELS[lang][record.error?.retryable ? "yes" : "no"];
142
+ const stage = record.error?.hintKey?.replace(/^lifecycle\.stage\./, "").replace(/\.hint$/, "") ?? record.stage ?? "";
143
+ const en = lang === "en-US";
144
+ // 六码走 key:hintKey 形如 lifecycle.stage.verifying.<code>,按 locale 取本表(fail-fast 测试据此断言实际渲染)
145
+ const code = /^lifecycle\.stage\.verifying\.(sig_missing|sig_alg|sig_key|tampered|unknown_key|sig_format)$/
146
+ .exec(record.error?.hintKey ?? "")?.[1];
147
+ const reason = code
148
+ ? (VERIFY_CODE_COPY[code]?.reason[en ? "en" : "zh"] ?? record.error?.reason ?? record.message)
149
+ : (record.error?.reason ?? record.message);
150
+ const hint = code
151
+ ? (VERIFY_CODE_COPY[code]?.hint[en ? "en" : "zh"] ?? STAGE_HINTS[stage]?.[en ? "en" : "zh"] ?? "")
152
+ : (STAGE_HINTS[stage]?.[en ? "en" : "zh"] ?? (en ? "Inspect the task details on the site for more information" : "请到站点任务详情页查看更多信息"));
153
+ const head = record.status === "partial"
154
+ ? (lang === "en-US"
155
+ ? `[ojm] Install partially applied (stage: ${stage}) — the solution is partly live, see hint below`
156
+ : `[ojm] 安装部分生效(stage:${stage})——方案已部分上线,详见下方指引`)
157
+ : (lang === "en-US"
158
+ ? `[ojm] Install failed (stage: ${stage})`
159
+ : `[ojm] 安装失败(stage:${stage})`);
160
+ return [
161
+ head,
162
+ lang === "en-US" ? `Reason: ${reason}` : `原因:${reason}`,
163
+ lang === "en-US" ? `Hint: ${hint}` : `指引:${hint}`,
164
+ `${retryable}(${record.error?.hintKey ?? `lifecycle.stage.${stage}.hint`})`,
165
+ ].join("\n");
166
+ }
167
+
168
+ export type InstallTarget = { kind: "file", path: string, name: string | null, version: string | null } | { kind: "cdn", name: string, version: string | null };
169
+
170
+ /** 目标解析:.tgz 路径 → file 源(<name>-<version>.tgz 文件名推导身份);否则 name[@version] → cdn 源 */
171
+ export function parseTarget(target: string): InstallTarget {
172
+ if (target.endsWith(".tgz")) {
173
+ const stem = path.basename(target).replace(/\.tgz$/i, "");
174
+ // 文件名推导:<name>-<version>(版本 = 尾段点分数字开头,可带 -prerelease 段;ojm build --bundle 产物命名,Finding C)
175
+ const m = /^(.+)-(\d[\w.]+(?:-[\w.]+)?)$/.exec(stem);
176
+ return { kind: "file", path: target, name: m?.[1] ?? null, version: m?.[2] ?? null };
177
+ }
178
+ const at = target.indexOf("@");
179
+ const name = at === -1 ? target : target.slice(0, at);
180
+ const version = at === -1 ? null : target.slice(at + 1);
181
+ if (name.trim() === "")
182
+ throw new Error(`[ojm] 非法安装目标:「${target}」(用法:ojm install <file.tgz | name[@version]>)`);
183
+ return { kind: "cdn", name: name.trim(), version };
184
+ }
185
+
186
+ export interface InstallOptions {
187
+ url?: string
188
+ user?: string
189
+ pass?: string
190
+ /** file 源身份覆盖(文件名不可解析时的显式出口);cdn 源同名冲突时报错提示二选一 */
191
+ name?: string
192
+ /** 显式版本(覆盖文件名/目标推导) */
193
+ version?: string
194
+ /** 终态文案语言(缺省 zh-CN) */
195
+ locale?: string
196
+ /** 测试注入;缺省 globalThis.fetch */
197
+ fetchFn?: typeof fetch
198
+ /** 测试注入;缺省真睡 */
199
+ sleep?: (ms: number) => Promise<void>
200
+ }
201
+
202
+ /**
203
+ * 安装命令体:解析目标 → 登录 → POST install(file=multipart / cdn=JSON)→ 轮询至终态 →
204
+ * 渲染人话。失败/partial 打印全文后置 process.exitCode = 1(不抛——多行渲染已输出,栈是噪音),
205
+ * 返回终态记录供调用方(真机冒烟)断言。
206
+ */
207
+ export async function installCommand(target: string, opts: InstallOptions = {}): Promise<TaskRecord> {
208
+ const fetchFn = opts.fetchFn ?? globalThis.fetch;
209
+ const parsed = parseTarget(target);
210
+ const { base, user, pass } = resolveSite(opts);
211
+ const token = await login(fetchFn, base, user, pass);
212
+
213
+ let response: { task_id: string };
214
+ if (parsed.kind === "file") {
215
+ const name = opts.name ?? parsed.name;
216
+ if (name === null)
217
+ throw new Error(`[ojm] 无法从文件名推导方案名:${target}——请用 --name <name>(及 --version <version>)显式指定`);
218
+ const version = opts.version ?? parsed.version;
219
+ const bytes = new Uint8Array(fs.readFileSync(parsed.path));
220
+ const form = new FormData();
221
+ form.append("name", name);
222
+ if (version !== null)
223
+ form.append("version", version);
224
+ form.append("source", "file");
225
+ form.append("file", new Blob([bytes], { type: "application/gzip" }), path.basename(parsed.path));
226
+ // detached 侧车签名(P2-3,G4):同目录 <tgz>.sig 自动探测随 multipart 第二文件上传——
227
+ // 缺签即被宿主 G4 拒(sig_missing 人话转述),与 Form B 同一信任模型
228
+ const sigPath = `${parsed.path}.sig`;
229
+ if (fs.existsSync(sigPath)) {
230
+ form.append("sig", new Blob([new Uint8Array(fs.readFileSync(sigPath))], { type: "application/json" }), path.basename(sigPath));
231
+ console.log(`[ojm] 已随包上传签名侧车:${path.basename(sigPath)}`);
232
+ }
233
+ else {
234
+ console.log(`[ojm] 未发现签名侧车(${path.basename(sigPath)})——宿主验签硬 fail-closed,未签名包将被拒(sig_missing);签名方法见 devkit「签名与信任」`);
235
+ }
236
+ const res = await fetchFn(`${base}/api/_platform/market/install`, {
237
+ method: "POST",
238
+ headers: { authorization: `Bearer ${token}` },
239
+ body: form,
240
+ });
241
+ response = await unwrapInstall(res);
242
+ }
243
+ else {
244
+ if (opts.name !== undefined && opts.name !== parsed.name)
245
+ throw new Error(`[ojm] --name「${opts.name}」与目标「${parsed.name}」不一致——请二选一`);
246
+ response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", "/api/_platform/market/install", {
247
+ name: parsed.name,
248
+ version: opts.version ?? parsed.version,
249
+ source: "cdn",
250
+ });
251
+ }
252
+
253
+ const record = await pollTask(fetchFn, base, token, response.task_id, { sleep: opts.sleep });
254
+ console.log(renderOutcome(record, opts.locale));
255
+ // P2-10:未来 schema 版本即使 status=done 也按非成功态处理(语义可能漂移,不误报成功)
256
+ if (record.status !== "done" || isFutureSchema(record))
257
+ process.exitCode = 1;
258
+ return record;
259
+ }
260
+
261
+ /** file 源响应解包:复用站点信封语义,额外校验 task_id 在场(防编排面版本错配静默空转) */
262
+ async function unwrapInstall(res: { ok: boolean, status: number, text: () => Promise<string> }): Promise<{ task_id: string }> {
263
+ const data = await unwrapEnvelope(res) as { task_id?: string };
264
+ const taskId = String(data?.task_id ?? "");
265
+ if (taskId === "")
266
+ throw new Error("[ojm] install 响应缺 task_id——核对站点平台编排面版本(peerPlatform 预检)");
267
+ return { task_id: taskId };
268
+ }
package/src/keygen.ts ADDED
@@ -0,0 +1,88 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ // ojm keygen(P2-4,作者工具链):生成 Ed25519 签名密钥对并落盘。
5
+ // 落点缺省 `~/.ojm/keys/<id>/`(private.pem 0600 + public.hex);`--out` 指到**工程树内**
6
+ // (cwd 之下)即警告并要求 `--yes` 显式确认——私钥入仓 = 泄露(P2-12 全仓 grep 零命中的
7
+ // 前提是私钥只活在 ~/.ojm/keys 或用户显式指定的受管位置)。
8
+ // 纯函数面(generateKeyPair/导出/签名)在 lifecycle/sign.ts 与 lifecycle/keygen.ts;
9
+ // 本文件只做命令编排(SRP):解析 → 校验 key_id 命名空间(C2-37)→ 生成 → 落盘 → 打印。
10
+ import process from "node:process";
11
+ import { generateKeyPair } from "./lifecycle/keygen";
12
+ import { fingerprintOfPublicKey } from "./lifecycle/sign";
13
+
14
+ export interface KeygenOptions {
15
+ /** 发布方 key_id(C2-37:<publisher>.<id> 全小写,禁裸通用名) */
16
+ id?: string
17
+ /** 输出目录(缺省 ~/.ojm/keys/<id>) */
18
+ out?: string
19
+ /** 工程树内落盘的显式确认(缺省拒绝) */
20
+ yes?: boolean
21
+ /** 测试注入;缺省 process.cwd() */
22
+ cwd?: string
23
+ }
24
+
25
+ export const DEFAULT_KEY_ID = "local.dev";
26
+
27
+ /** `ojm keygen [--id <publisher.id>] [--out <dir>] [--yes]` 参数解析(值式 flag 逐项消费,P6 同款纪律) */
28
+ export function parseKeygenArgs(argv: string[]): { id?: string, out?: string, yes?: boolean } {
29
+ const values: Record<string, string> = {};
30
+ let yes = false;
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i]!;
33
+ if (a === "--yes") {
34
+ yes = true;
35
+ continue;
36
+ }
37
+ if (a === "--id" || a === "--out") {
38
+ values[a] = argv[i + 1] ?? "";
39
+ i++;
40
+ continue;
41
+ }
42
+ }
43
+ return { id: values["--id"], out: values["--out"], yes };
44
+ }
45
+
46
+ /** C2-37 key_id 命名空间(与 isolate 侧 trust.ts isValidKeyId 同规则;禁裸通用名单段) */
47
+ export function isValidKeyId(keyId: string): boolean {
48
+ return /^[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)+$/.test(keyId);
49
+ }
50
+
51
+ export interface KeygenResult {
52
+ keyId: string
53
+ publicKeyHex: string
54
+ fingerprint: string
55
+ dir: string
56
+ privateKeyPath: string
57
+ publicKeyPath: string
58
+ }
59
+
60
+ /**
61
+ * keygen 命令体。返回产物路径(测试/调用方断言用);stdout 打印人话摘要。
62
+ * 工程树内落盘无 --yes → 抛错(私钥不得意外入仓——仓内出现即 P2-12 grep 红灯)。
63
+ */
64
+ export function keygenCommand(opts: KeygenOptions = {}): KeygenResult {
65
+ const keyId = opts.id ?? DEFAULT_KEY_ID;
66
+ if (!isValidKeyId(keyId))
67
+ throw new Error(`[ojm] key_id 命名空间非法(C2-37:<publisher>.<id> 全小写、禁裸通用名单段):「${keyId}」——示例:acme.market`);
68
+ const cwd = opts.cwd ?? process.cwd();
69
+ const home = path.join(os.homedir(), ".ojm", "keys", keyId);
70
+ const dir = opts.out === undefined ? home : path.resolve(cwd, opts.out);
71
+ if (path.resolve(cwd) !== path.parse(cwd).root && dir.startsWith(`${path.resolve(cwd)}/`) && opts.yes !== true) {
72
+ throw new Error(`[ojm] 输出目录在工程树内(${path.relative(cwd, dir) || "."})——私钥入仓即泄露。请使用缺省位置 ~/.ojm/keys/<id>,或确认已知风险后加 --yes 重试(建议把该目录加进 .gitignore)`);
73
+ }
74
+ const kp = generateKeyPair();
75
+ const fingerprint = fingerprintOfPublicKey(kp.publicKeyHex);
76
+ const privateKeyPath = path.join(dir, "private.pem");
77
+ const publicKeyPath = path.join(dir, "public.hex");
78
+ fs.mkdirSync(dir, { recursive: true });
79
+ fs.writeFileSync(privateKeyPath, kp.privateKeyPem, { mode: 0o600 });
80
+ fs.writeFileSync(publicKeyPath, `${kp.publicKeyHex}\n`);
81
+ console.log(`[ojm] 已生成 Ed25519 密钥对:${dir}`);
82
+ console.log(`[ojm] key_id:${keyId}(发布方标识,签名与信任库按它绑定;轮换 = 新 key_id,禁覆写)`);
83
+ console.log(`[ojm] public_key:${kp.publicKeyHex}`);
84
+ console.log(`[ojm] 指纹(sha256(publicKey),公布给宿主核对):${fingerprint}`);
85
+ console.log(`[ojm] 私钥文件:${privateKeyPath}(0600;永不入仓、永不入日志——P2-5 redact 钉面)`);
86
+ console.log(`[ojm] 下一步:ojm sign <pkg>.tgz --id ${keyId}(或 --key ${privateKeyPath} --id ${keyId})`);
87
+ return { keyId, publicKeyHex: kp.publicKeyHex, fingerprint, dir, privateKeyPath, publicKeyPath };
88
+ }