@oj-module/cli 0.2.56 → 0.2.62

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 (50) hide show
  1. package/devkit/README.md +7 -4
  2. package/devkit/SKILL.md +4 -1
  3. package/devkit/manual.md +4 -2
  4. package/devkit/module-dev-and-cdn-playground.md +7 -0
  5. package/devkit/publish-walkthrough.md +199 -0
  6. package/package.json +2 -2
  7. package/shell-dist/assets/{index-CImwskAz.js → index-BKtXxIXz.js} +32 -2
  8. package/shell-dist/assets/runtime.js +6 -6
  9. package/shell-dist/index.html +3 -3
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/port-check.test.ts +40 -0
  12. package/src/__tests__/vendor-devkit.test.ts +87 -0
  13. package/src/__tests__/vendor.test.ts +39 -0
  14. package/src/build/backend.ts +131 -0
  15. package/src/build/module-build.ts +364 -0
  16. package/src/build/module-def.ts +400 -0
  17. package/src/build/site-merge.ts +33 -0
  18. package/src/build.ts +16 -885
  19. package/src/bundle.ts +61 -0
  20. package/src/cli.ts +11 -1
  21. package/src/contract/emit-stub.ts +7 -3
  22. package/src/dev-secrets.ts +315 -0
  23. package/src/dev.ts +26 -3
  24. package/src/init.ts +27 -39
  25. package/src/lifecycle/__tests__/bundle-summary.test.ts +106 -0
  26. package/src/lifecycle/__tests__/bundle.test.ts +24 -1
  27. package/src/lifecycle/__tests__/deterministic-tar.test.ts +19 -2
  28. package/src/market/bootstrap.ts +5 -2
  29. package/src/new.ts +27 -0
  30. package/src/oj.ts +82 -9
  31. package/src/port-check.ts +38 -0
  32. package/src/rekey.ts +51 -0
  33. package/src/static-handler.ts +7 -2
  34. package/src/usage.ts +12 -2
  35. package/src/vendor.ts +66 -5
  36. package/templates/openapi.json +7 -0
  37. package/templates/src/app/api/_platform/_lifecycle/installer.ts +8 -3
  38. package/templates/src/app/api/_platform/_lifecycle/steps/register.ts +53 -0
  39. package/templates/src/app/api/_platform/_shared/service/domain/roles.ts +4 -1
  40. package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +7 -3
  41. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +3 -0
  42. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +3 -0
  43. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +8 -0
  44. package/templates/src/app/api/_platform/schema.yaml +2 -0
  45. package/templates/src/app/api/system/roles/api.ts +2 -1
  46. package/templates/src/app/web/system/__client/api.schemas.ts +1 -0
  47. package/templates/src/app/web/system/pages/roles/index.tsx +7 -1
  48. package/templates/tests/lifecycle/role-title-translate.spec.ts +171 -0
  49. package/templates/tests/rbac-roles.spec.ts +9 -0
  50. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,400 @@
1
+ // 模块元数据读取(SRP 拆分自 build.ts,2026-11-03,计划 docs/plans/20261103-0930):
2
+ // 用 esbuild 把 entry 打包成单文件并 import(),读 defineModule 入参——
3
+ // 本文件只做这一件事,构建编排见 backend.ts / module-build.ts。
4
+ import type { Plugin as EsbuildPlugin } from "esbuild";
5
+ import type { RouteDefinitionLike } from "../manifest-routes-derive";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import process from "node:process";
9
+ import { pathToFileURL } from "node:url";
10
+ import { build as esbuild } from "esbuild";
11
+ import { resolveLayout } from "../layout";
12
+
13
+ /**
14
+ * `@oj-module/runtime` 的只读占位源码(设计文档 B10 / §4.3)。
15
+ *
16
+ * 读取模块定义时只需要 entry 顶层调用 `defineModule({ name, version, ... })` 的
17
+ * 结果,而绝不需要真正加载框架运行时(其产物含 Vite 专有的 `?react`/`?url` svg
18
+ * 导入,Node 下无法加载)。通过 esbuild 虚拟模块插件内联进 bundle,从源头避开 svg
19
+ * 问题;其余共享依赖保持 external,由 tsx/Node 在 import() 时按真实包加载。
20
+ *
21
+ * 这里列出运行时的「值导出」,使 esbuild 不会报 missing export(类型导出无需提供)。
22
+ * `defineModule` 仅把入参原样回传,CLI 即可读取 name/version 等元数据。
23
+ */
24
+ const RUNTIME_STUB_SOURCE = `
25
+ const _fn = (...a) => a[a.length - 1];
26
+ const _obj = () => ({});
27
+ // 组件
28
+ export const BasicContent = _fn;
29
+ export const BasicButton = _fn;
30
+ export const BasicTable = _fn;
31
+ export const Iframe = _fn;
32
+ export const AccessControl = _fn;
33
+ export const FormAvatarItem = _fn;
34
+ export const FormTreeItem = _fn;
35
+ export const AntdApp = _fn;
36
+ // 页面级错误边界(增量⑥):宿主壳与模块自绘子树包裹用
37
+ export const ErrorBoundary = _fn;
38
+ export const PageError = _fn;
39
+ // api
40
+ export const notify = _obj;
41
+ export const fetchPie = _fn;
42
+ export const fetchLine = _fn;
43
+ export const fetchLogin = _fn;
44
+ export const fetchLogout = _fn;
45
+ export const fetchAsyncRoutes = _fn;
46
+ export const fetchUserInfo = _fn;
47
+ export const fetchRefreshToken = _fn;
48
+ export const fetchRoleList = _fn;
49
+ export const fetchAddRoleItem = _fn;
50
+ export const fetchUpdateRoleItem = _fn;
51
+ export const fetchDeleteRoleItem = _fn;
52
+ export const fetchRoleMenu = _fn;
53
+ export const fetchMenuByRoleId = _fn;
54
+ export const fetchMenuList = _fn;
55
+ export const fetchAddMenuItem = _fn;
56
+ export const fetchUpdateMenuItem = _fn;
57
+ export const fetchDeleteMenuItem = _fn;
58
+ // RBAC(阶段 C 增补,与 runtime api/rbac 对齐)
59
+ export const fetchRbacRoleList = _fn;
60
+ export const fetchRbacPermissionList = _fn;
61
+ export const fetchRbacCreateRole = _fn;
62
+ export const fetchRbacUpdateBindings = _fn;
63
+ export const fetchRbacDeleteRole = _fn;
64
+ export const fetchRbacUserList = _fn;
65
+ export const fetchRbacCreateUser = _fn;
66
+ export const fetchRbacUpdateUserRoles = _fn;
67
+ export const fetchRbacAdminResetPassword = _fn;
68
+ export const fetchRbacSetForbidden = _fn;
69
+ export const fetchRbacOplog = _fn;
70
+ export const fetchRbacOplogPost = _fn;
71
+ export const fetchRbacSettings = _fn;
72
+ export const fetchRbacUpdateSetting = _fn;
73
+ export const fetchRbacContacts = _fn;
74
+ export const fetchRbacContactDetail = _fn;
75
+ export const fetchLoginMethods = _fn;
76
+ export const fetchChangePassword = _fn;
77
+ export const fetchLdapLogin = _fn;
78
+ // 方案路由归并(P1-14/R21 读端点 + 分组纯函数)
79
+ export const fetchSolutionRoutes = _fn;
80
+ export const roleLabel = _fn;
81
+ export const canEditRoleBindings = _fn;
82
+ export const canDeleteRole = _fn;
83
+ export const canAssignRole = _fn;
84
+ export const bindingDiff = _fn;
85
+ export const groupByModule = _fn;
86
+ export const groupSettingsByModule = _fn;
87
+ // hooks 与权限常量
88
+ export const useAccess = _fn;
89
+ export const usePreferences = _fn;
90
+ // i18n 初始化(宿主链路消费)
91
+ export const setupI18n = _fn;
92
+ // 全局副作用(宿主链路消费)
93
+ export const LayoutEffects = _fn;
94
+ export const accessControlCodes = _obj;
95
+ export const AccessControlRoles = _obj;
96
+ export const permissionPrefix = "permission:button";
97
+ // store
98
+ export const useAccessStore = _fn;
99
+ export const useAuthStore = _fn;
100
+ // 方案路由归并 store(token 就绪自动重拉,R21)
101
+ export const useSolutionRoutes = _fn;
102
+ export const useUserStore = _fn;
103
+ export const useTabsStore = _fn;
104
+ // 租户上下文(L2):请求头由运行时注入,模块只在登出清理 / URL 带 tenant 参数时读写
105
+ export const useTenantStore = _fn;
106
+ export const currentTenant = () => "default";
107
+ export const bootstrapTenant = () => "default";
108
+ export const DEFAULT_TENANT = "default";
109
+ export const TENANT_HEADER = "X-TENANT-ID";
110
+ export const TENANT_QUERY_PARAM = "tenant";
111
+ // 页签 key 唯一正确取口(keepAlive 下 useLocation 是激活页位置,0.1.21)
112
+ export const useTabKey = _fn;
113
+ // 图标
114
+ export const menuIcons = _obj;
115
+ export const EmbeddedIcon = _fn;
116
+ export const ExternalIcon = _fn;
117
+ export const LayoutCenterIcon = _fn;
118
+ export const LayoutLeftIcon = _fn;
119
+ export const LayoutRightIcon = _fn;
120
+ export const MixedNavigationIcon = _fn;
121
+ export const OutsidePageIcon = _fn;
122
+ export const ProfileCardIcon = _fn;
123
+ export const ServerErrorIcon = _fn;
124
+ export const SideNavigationIcon = _fn;
125
+ export const TopNavigationIcon = _fn;
126
+ export const TwoColumnNavigationIcon = _fn;
127
+ export const RiAccountCircleLine = _fn;
128
+ export const RiContrastFill = _fn;
129
+ export const RiFullscreenExitLine = _fn;
130
+ export const RiFullscreenLine = _fn;
131
+ export const RiMailCheckLine = _fn;
132
+ export const RiMoonIcon = _fn;
133
+ export const RiReactjsLine = _fn;
134
+ export const RiSunIcon = _fn;
135
+ export const RiUserSettingsLine = _fn;
136
+ // 工具与常量
137
+ export const handleTree = _fn;
138
+ export const traverseTreeValues = _fn;
139
+ export const filterTree = _fn;
140
+ export const mapTree = _fn;
141
+ export const getAllExpandedKeys = _fn;
142
+ export const getAppInfo = _fn;
143
+ export const getRedirectPath = _fn;
144
+ // 站点部署 base(子路径部署):stub 只需占位,元数据读取期不拼 URL
145
+ export const resolveSiteBase = _fn;
146
+ export const withSiteBase = _fn;
147
+ // API 端点 base(整页跳转的后端端点、上传 action 用它拼 URL,与站点 base 正交)
148
+ export const resolveApiBase = _fn;
149
+ export const getYesNoOptions = _fn;
150
+ export const getBooleanOptions = _fn;
151
+ // 模块契约
152
+ export const defineModule = (d) => d;
153
+ export const defineRoutes = (...a) => a[a.length - 1];
154
+ export const defineGuard = (...a) => a[a.length - 1];
155
+ export const getModules = () => [];
156
+ export const getModule = () => undefined;
157
+ export const getRoutes = () => [];
158
+ export const getHostRoutes = () => [];
159
+ // 模块生效 web 路由前缀段取值口(R1.3:写进 modules.json 的 routePrefix,未声明回落 name)
160
+ export const getModuleRoutePrefix = _fn;
161
+ export const getRegisteredStore = () => undefined;
162
+ export const getRegisteredApiPrefix = () => undefined;
163
+ export const loadAll = async () => [];
164
+ export const unloadModule = async () => {};
165
+ export const useSlotNodes = () => [];
166
+ // AC-D15:zod re-export——stub 用可调用代理(契约 schema 不在元数据读取期求值,仅占位防 missing export)
167
+ export const z = new Proxy({}, { get: () => _fn });
168
+ // R29:ky 实例 re-export(第一方市场编排面)——同 z,元数据读取期仅需可调用占位
169
+ export const request = new Proxy({}, { get: () => _fn });
170
+ `;
171
+
172
+ /**
173
+ * esbuild 插件:把 `@oj-module/runtime` 解析到一个内联的只读占位虚拟模块,
174
+ * 避免真正加载框架运行时(含 svg)。用虚拟模块而非 alias 指向实体文件,是因为
175
+ * 实体路径依赖 import.meta.url,在 vitest 等变换环境下拿不到合法的 file URL。
176
+ */
177
+ const runtimeStubPlugin: EsbuildPlugin = {
178
+ name: "ojm-runtime-stub",
179
+ setup(b) {
180
+ b.onResolve({ filter: /^@oj-module\/runtime$/ }, () => ({
181
+ path: "@oj-module/runtime",
182
+ namespace: "ojm-runtime-stub",
183
+ }));
184
+ b.onLoad({ filter: /.*/, namespace: "ojm-runtime-stub" }, () => ({
185
+ contents: RUNTIME_STUB_SOURCE,
186
+ loader: "js",
187
+ resolveDir: process.cwd(),
188
+ }));
189
+ },
190
+ };
191
+
192
+ /**
193
+ * 把 entry 内所有动态 import 的目标(lazy 页面、i18n JSON)替换为虚拟空模块:
194
+ * 元数据读取只需要 defineModule 的入参对象,动态模块永远不该执行。
195
+ * 若放任这些模块进 bundle,esbuild 输出 ESM 时会把页面模块的裸导入 hoist 到
196
+ * bundle 顶层(如 pro-components),Node import() 时即被求值并可能加载失败;
197
+ * 若标为 external,vitest 等变换环境又会在 transform 阶段强行解析相对说明符。
198
+ * 空模块两条路都堵死(P3.4)。
199
+ */
200
+ const dynamicImportStubPlugin: EsbuildPlugin = {
201
+ name: "ojm-dynamic-import-stub",
202
+ setup(b) {
203
+ // esbuild 的 onResolve 不支持按 kind 过滤,在回调里判断后放行其余 resolver
204
+ b.onResolve({ filter: /^\./ }, (args) => {
205
+ if (args.kind === "dynamic-import") {
206
+ return { path: args.path, namespace: "ojm-dynamic-stub" };
207
+ }
208
+ return null;
209
+ });
210
+ b.onLoad({ filter: /.*/, namespace: "ojm-dynamic-stub" }, () => ({
211
+ contents: "export default {};",
212
+ loader: "js",
213
+ }));
214
+ },
215
+ };
216
+
217
+ /**
218
+ * 元数据读取封闭性(P3 验收发现):裸导入全部桩化,`ojm init` 产出的外部工程
219
+ * 尚未安装依赖(@oj-module/* 未发布),真实 import() 会
220
+ * ERR_MODULE_NOT_FOUND;读元数据只需要 defineModule 入参,共享依赖执行与否无关。
221
+ *
222
+ * - 值统一为「自引用可调用代理」:任意属性链访问/调用都安全(JSX 元素在
223
+ * 模块顶层求值等形态)。
224
+ * - 从 modulesSrc 源码扫出的具名导入经 ownKeys/getOwnPropertyDescriptor 陷阱
225
+ * 静态暴露——esbuild 对 CJS 桩做 __toESM 属性拷贝(走这两把陷阱),仅 get
226
+ * 陷阱拷不出名字,顶层 `React.createElement` 会直接崩(验收实测)。
227
+ * - `#` 开头的工程子路径导入不桩化,走默认解析。
228
+ */
229
+ /**
230
+ * 扫描 modulesSrc 下全部源码,收集「裸说明符 → 具名导入列表」。
231
+ * 只为给桩模块提供可静态暴露的名字(供 esbuild __toESM 拷贝),不求精确:
232
+ * 解析不到的形状(多行、注释包裹等)只会让该名字未被种子,运行时落到 get
233
+ * 陷阱兜底;扫到未使用的名字也无害。
234
+ */
235
+ function collectBareImportedNames(...roots: string[]): Map<string, string[]> {
236
+ const found = new Map<string, Set<string>>();
237
+ const record = (spec: string, clause: string) => {
238
+ if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("#"))
239
+ return;
240
+ const set = found.get(spec) ?? new Set<string>();
241
+ for (const raw of clause.split(",")) {
242
+ const name = raw.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
243
+ if (name && /^[\w$]+$/.test(name))
244
+ set.add(name);
245
+ }
246
+ set.add("default");
247
+ found.set(spec, set);
248
+ };
249
+ const walk = (dir: string) => {
250
+ let entries: fs.Dirent[];
251
+ try {
252
+ entries = fs.readdirSync(dir, { withFileTypes: true });
253
+ }
254
+ catch {
255
+ return;
256
+ }
257
+ for (const entry of entries) {
258
+ const filePath = path.join(dir, entry.name);
259
+ if (entry.isDirectory()) {
260
+ walk(filePath);
261
+ continue;
262
+ }
263
+ if (!/\.(?:ts|tsx|js|jsx|mjs)$/.test(entry.name))
264
+ continue;
265
+ const source = fs.readFileSync(filePath, "utf-8");
266
+ for (const m of source.matchAll(/(?:import|export)\s+(?:type\s+)?\{([^}]*)\}\s*(?:from\s*)?["']([^"']+)["']/g))
267
+ record(m[2], m[1]);
268
+ for (const m of source.matchAll(/import\s+(\w+)\s*(?:,\s*\{([^}]*)\}\s*)?from\s*["']([^"']+)["']/g)) {
269
+ if (m[1] && m[3])
270
+ record(m[3], m[1]);
271
+ if (m[2] && m[3])
272
+ record(m[3], m[2]);
273
+ }
274
+ // namespace / default 导入的属性访问(`React.lazy` / `ns.foo()`):
275
+ // 名字从成员访问推断,否则 __toESM 拷贝后这些名字不存在(playground 实测)
276
+ const bindings = new Map<string, string>();
277
+ for (const m of source.matchAll(/import\s+(?:\*\s+as\s+)?(\w+)\s+from\s*["']([^"']+)["']/g)) {
278
+ if (m[1] && m[2] && !m[2].startsWith(".") && !m[2].startsWith("#"))
279
+ bindings.set(m[1], m[2]);
280
+ }
281
+ for (const [binding, spec] of bindings) {
282
+ for (const m of source.matchAll(new RegExp(`\\b${binding}\\.(\\w+)`, "g"))) {
283
+ const set = found.get(spec) ?? new Set<string>();
284
+ set.add(m[1]);
285
+ found.set(spec, set);
286
+ }
287
+ }
288
+ }
289
+ };
290
+ for (const root of roots)
291
+ walk(root);
292
+ return new Map([...found].map(([spec, set]) => [spec, [...set]]));
293
+ }
294
+
295
+ /** 每次元数据读取一个实例:modulesSrc 扫描出的「裸说明符 → 具名导入列表」 */
296
+ function makeBareImportStubPlugin(namesBySpec: Map<string, string[]>): EsbuildPlugin {
297
+ return {
298
+ name: "ojm-bare-import-stub",
299
+ setup(b) {
300
+ b.onResolve({ filter: /^[^./#]/ }, (args) => {
301
+ // 排除入口自身与绝对路径:entryPoints 在 Windows 上以盘符绝对路径(d:/...)
302
+ // 传入,会命中上面的裸说明符过滤被桩化成「可 thenable 的代理」——
303
+ // 整个入口模块变成 new Proxy(function(){},{get:()=>value,apply:()=>value}),
304
+ // mod.default 因而是一个 thenable,async readModuleDefinition 执行 return
305
+ // 时被当 thenable 接管、其 .then 永远不回调 resolve → Promise 永 pending →
306
+ // 事件循环排空 → Node 以退出码 0 静默退出(pnpm dev 起不来)。
307
+ // macOS/Linux 绝对路径以 / 开头已被过滤排除,故这是 Windows-only bug。
308
+ if (args.kind === "entry-point")
309
+ return null;
310
+ if (/^[a-z]:/i.test(args.path))
311
+ return null;
312
+ return { path: args.path, namespace: "ojm-bare-stub" };
313
+ });
314
+ b.onLoad({ filter: /.*/, namespace: "ojm-bare-stub" }, (args) => {
315
+ const names = namesBySpec.get(args.path) ?? [];
316
+ return {
317
+ contents: [
318
+ // `apply` 返回 value(自身)而非 undefined:桩必须**可无限链式调用**。
319
+ // 反例(真踩过):zustand v5 是柯里化 API——`create()(initializer)`
320
+ // 先调一次取工厂、再调一次取 store,且**都在模块顶层求值**。
321
+ // 若 apply 返回 undefined,元数据读取即抛
322
+ // `(0, import_zustand.create)(...) is not a function`,
323
+ // 整个 `ojm dev/build` 起不来(模块代码本身完全合法)。
324
+ // 同类形态:`axios.create(...)`、`i18next.createInstance(...)`、
325
+ // `createTheme(...)` 等顶层库工厂。桩只在元数据读取期跑,放宽只会
326
+ // 把「崩溃」变成「无害空转」。
327
+ "const value = new Proxy(function () {}, { get: () => value, apply: () => value });",
328
+ `const names = ${JSON.stringify(names)};`,
329
+ "module.exports = new Proxy({}, {",
330
+ "\tget: () => value,",
331
+ "\tgetOwnPropertyDescriptor: (_t, p) => names.includes(p) ? { enumerable: true, configurable: true, get: () => value } : undefined,",
332
+ "\townKeys: () => names,",
333
+ "});",
334
+ ].join("\n"),
335
+ loader: "js",
336
+ };
337
+ });
338
+ },
339
+ };
340
+ }
341
+
342
+ /**
343
+ * 用 esbuild 把 entry 打包成单文件(含 runtime 占位 stub 与动态 import 空 stub),
344
+ * 写出到工程内临时目录后以普通 `import()` 加载,从而读取模块定义
345
+ * (name/version/peerRuntime/config)而无需真正加载整个框架运行时
346
+ * (其源码含 Vite 专有的 `?react`/`?url` svg 导入),替代脆弱的正则(B10)。
347
+ *
348
+ * P3.4 起同时供主仓库 `scripts/build-modules.ts` 复用(经 exports `./build`)。
349
+ */
350
+ export async function readModuleDefinition(entryFile: string, projectRoot: string) {
351
+ // 必须落在工程目录内(而非 os.tmpdir),否则 bundle 外部化的共享依赖
352
+ // (react / antd …)在 import() 时无法从 /tmp 解析到 node_modules。
353
+ const outDir = fs.mkdtempSync(path.join(projectRoot, ".ojm-tmp-"));
354
+ try {
355
+ // entry 可能登记在 modulesSrc 之外(playground 登记仓库级模块),
356
+ // entry 所在目录同样入扫,保证其相对导入图的具名导入都被种子
357
+ const bareNames = collectBareImportedNames(resolveLayout(projectRoot).modulesSrc, path.dirname(entryFile));
358
+ await esbuild({
359
+ entryPoints: [entryFile],
360
+ // 固定输出名 entry.js:esbuild 默认取 entry 文件 basename,
361
+ // 会让非 entry.ts 文件名(如测试夹具)的产物对不上后续 import 路径
362
+ entryNames: "entry",
363
+ bundle: true,
364
+ // ESM 输出 + import() 加载:入口经下方 onResolve 修正后不会被裸导入桩误伤为
365
+ // thenable 代理,Windows/macOS 行为一致(此前 Windows 卡死的根因是入口被桩化成
366
+ // 代理导致 async return 被 thenable 接管而永不 settle)。元数据读取只需取
367
+ // `.default`,无需 CJS 语义。
368
+ format: "esm",
369
+ platform: "node",
370
+ packages: "external",
371
+ plugins: [runtimeStubPlugin, dynamicImportStubPlugin, makeBareImportStubPlugin(bareNames)],
372
+ outdir: outDir,
373
+ jsx: "automatic",
374
+ // 元数据读取不需要 css:empty 装载器把 `import "./styles.css"` 抹成空模块,
375
+ // 避免裸导入桩拦截 css 内部的 `@import "tailwindcss"`(js 桩无法注入 css)
376
+ loader: { ".ts": "ts", ".tsx": "tsx", ".json": "json", ".css": "empty" },
377
+ logLevel: "silent",
378
+ });
379
+ const bundled = path.join(outDir, "entry.js");
380
+ // 加载桩化产物(ESM):入口不会被裸导入桩误伤为 thenable 代理(见 onResolve 修正),
381
+ // 故 mod.default 是真实定义,import() 正常 settle。
382
+ const mod = await import(pathToFileURL(bundled).href);
383
+ const definition = mod.default;
384
+ if (!definition?.name || !definition?.version) {
385
+ throw new Error(`${entryFile} 的 default 导出缺少 name 或 version`);
386
+ }
387
+ return definition as {
388
+ name: string
389
+ version: string
390
+ peerRuntime?: string
391
+ config?: { dependencies?: string[] }
392
+ // R5:entry.ts 声明的路由树(含 handle.category/order/title),
393
+ // buildBundle 据此派生 manifest.routes(单一真源,避免手工双写漂移)
394
+ routes?: RouteDefinitionLike[]
395
+ };
396
+ }
397
+ finally {
398
+ fs.rmSync(outDir, { recursive: true, force: true });
399
+ }
400
+ }
@@ -0,0 +1,33 @@
1
+ // 宿主站点合并(SRP 拆分自 build.ts,2026-11-03,计划 docs/plans/20261103-0930):
2
+ // shell-dist 全量拷贝 + index.html 的 siteBase/apiBase 改写,仅 `ojm build` 调用。
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { normalizeSiteBase, rewriteIndexHtml } from "../site-base";
6
+ import { resolveShellDist } from "../versions";
7
+
8
+ /**
9
+ * 全站合并(仅 `ojm build` 调用,设计 §5):清场后拷 shell dist 全量,
10
+ * 再由模块构建写入 modules.json 与 modules/。清场防旧哈希资产无限累积
11
+ * (shell 每次重构建生成新哈希文件名);devServer 热重建路径不合并——
12
+ * dev 的 / 与 /assets/* 直接服务 shell dist,合并产物无人消费。
13
+ *
14
+ * siteBase 非空时(站点挂载在 `/oj` 这类子路径)改写 index.html 的站内绝对
15
+ * URL;apiBase 非空时注入 `window.__OJ_API_BASE__`(API 挂载前缀部署期可配,
16
+ * 见 site-base.ts)。两者都为空则产物与既有行为逐字节一致。
17
+ */
18
+ export function mergeShellSite(distDir: string, siteBase: string, apiBase: string): void {
19
+ const shellDist = resolveShellDist();
20
+ fs.rmSync(distDir, { recursive: true, force: true });
21
+ fs.mkdirSync(path.dirname(distDir), { recursive: true });
22
+ fs.cpSync(shellDist, distDir, { recursive: true });
23
+
24
+ const indexPath = path.join(distDir, "index.html");
25
+ if (normalizeSiteBase(siteBase) !== "/" || apiBase.trim()) {
26
+ fs.writeFileSync(indexPath, rewriteIndexHtml(fs.readFileSync(indexPath, "utf-8"), siteBase, apiBase));
27
+ if (normalizeSiteBase(siteBase) !== "/")
28
+ console.log(`[ojm] 站点 index.html 已按 siteBase=${normalizeSiteBase(siteBase)} 重写(子路径部署)`);
29
+ if (apiBase.trim())
30
+ console.log(`[ojm] API 前缀已注入 index.html:${apiBase.trim().replace(/\/+$/, "")}(apiBase)`);
31
+ }
32
+ console.log(`[ojm] 已合并宿主站点 → ${distDir}`);
33
+ }