@oj-module/cli 0.2.5 → 0.2.8

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 (105) hide show
  1. package/README.md +1 -1
  2. package/devkit/manual.md +19 -17
  3. package/devkit/sql-builder-missing-capabilities-pr.md +234 -0
  4. package/package.json +2 -2
  5. package/shell-dist/assets/runtime.js +2 -2
  6. package/shell-dist/index.html +2 -2
  7. package/shell-dist/versions.json +3 -3
  8. package/src/contract/__tests__/handler-scan.test.ts +29 -0
  9. package/src/contract/__tests__/market-client.integration.test.ts +167 -0
  10. package/src/contract/handler-scan.ts +35 -4
  11. package/src/dev.ts +10 -2
  12. package/src/index.ts +3 -3
  13. package/src/init.ts +44 -0
  14. package/src/preview-config.ts +163 -0
  15. package/src/preview.ts +105 -56
  16. package/src/usage.ts +3 -2
  17. package/templates/api/config.yaml +4 -2
  18. package/templates/api/src/_platform/README.md +31 -0
  19. package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
  20. package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
  21. package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
  22. package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
  23. package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
  24. package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
  25. package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
  26. package/templates/api/src/_platform/_lifecycle/uninstall.ts +13 -8
  27. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
  28. package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +8 -6
  29. package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
  30. package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
  31. package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +18 -17
  32. package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
  33. package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
  34. package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
  35. package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +12 -12
  36. package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
  37. package/templates/api/src/_platform/contacts/api.ts +2 -2
  38. package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
  39. package/templates/api/src/_platform/manifest.yaml +3 -1
  40. package/templates/api/src/_platform/market/api.ts +54 -16
  41. package/templates/api/src/_platform/schema.yaml +14 -0
  42. package/templates/api/src/_platform/solutions/api.ts +13 -12
  43. package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
  44. package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
  45. package/templates/api/src/auth/_shared/session.ts +25 -21
  46. package/templates/api/src/auth/api.ts +1 -1
  47. package/templates/api/src/demo/todos/api.ts +1 -0
  48. package/templates/api/src/home/line/api.ts +4 -4
  49. package/templates/api/src/home/pie/api.ts +4 -4
  50. package/templates/api/src/notification/_shared/notify.ts +40 -38
  51. package/templates/api/src/notifications/api.ts +1 -4
  52. package/templates/api/src/personal-center/upload/api.ts +1 -1
  53. package/templates/api/src/system/login-methods/api.ts +1 -1
  54. package/templates/api/src/system/oplog/api.ts +3 -3
  55. package/templates/api/src/system/permissions/api.ts +2 -2
  56. package/templates/api/src/system/roles/api.ts +2 -2
  57. package/templates/api/src/system/roles/bindings/api.ts +2 -2
  58. package/templates/api/src/system/settings/api.ts +2 -2
  59. package/templates/api/src/system/users/api.ts +2 -2
  60. package/templates/api/src/system/users/forbidden/api.ts +2 -2
  61. package/templates/api/src/system/users/password/api.ts +2 -2
  62. package/templates/api/src/system/users/roles/api.ts +2 -2
  63. package/templates/api/src/web/user-info/api.ts +2 -5
  64. package/templates/api/tests/auth-session.spec.ts +1 -1
  65. package/templates/api/tests/auth-throttle.spec.ts +1 -1
  66. package/templates/api/tests/guard.spec.ts +1 -1
  67. package/templates/api/tests/lifecycle/market-api.spec.ts +1 -1
  68. package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
  69. package/templates/api/tests/lifecycle/uninstall.spec.ts +1 -1
  70. package/templates/api/tests/query-utils.spec.ts +1 -1
  71. package/templates/api/tests/rbac-audit.spec.ts +1 -1
  72. package/templates/api/tests/rbac-contacts.spec.ts +2 -2
  73. package/templates/api/tests/rbac-guard.spec.ts +1 -1
  74. package/templates/api/tests/rbac-roles.spec.ts +2 -2
  75. package/templates/api/tests/rbac-settings.spec.ts +2 -2
  76. package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
  77. package/templates/api/tests/rbac-users.spec.ts +1 -1
  78. package/templates/api/tsconfig.json +8 -0
  79. package/templates/env.d.ts +2 -0
  80. package/templates/global.d.ts +816 -0
  81. package/templates/init.include.yaml +18 -1
  82. package/templates/logs/task-events.jsonl +0 -0
  83. package/templates/openapi.json +525 -13
  84. package/templates/package.json +2 -1
  85. package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
  86. package/templates/web/src/_platform/__client/api.ts +152 -39
  87. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
  88. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
  89. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
  90. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
  91. package/templates/web/src/oj-market/api/client.ts +5 -105
  92. package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
  93. package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
  94. package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
  95. package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
  96. package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
  97. package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
  98. package/templates/web/src/oj-market/locales/en-US.json +2 -1
  99. package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
  100. package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
  101. package/templates/web.config.ts +3 -3
  102. package/vendor/host-versions.json +3 -3
  103. /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
  104. /package/templates/api/src/_platform/_shared/service/{oplog.ts → infra/oplog.ts} +0 -0
  105. /package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +0 -0
package/src/dev.ts CHANGED
@@ -183,11 +183,19 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
183
183
 
184
184
  // 终端 Ctrl+C / `kill`(SIGINT / SIGTERM)终止 dev 进程时,后端 oj 必须随之消亡:
185
185
  // 起一个统一优雅停机,先停 oj 再退出;并打印后端终止信息。
186
+ // 终端 Ctrl-C 是进程组信号:外层 bin 转发一份 + 组送达一份,内层会收到
187
+ // 两个 SIGINT——once 首次触发即摘除,第二个走默认动作杀死本进程,
188
+ // shutdownDev 跑不完(与 preview 同根因,2026-10-19 冒烟实测)。on +
189
+ // 幂等守卫吞掉重复信号。
190
+ let shuttingDown = false;
186
191
  const shutdown = () => {
192
+ if (shuttingDown)
193
+ return;
194
+ shuttingDown = true;
187
195
  void shutdownDev(server, oj, hub);
188
196
  };
189
- process.once("SIGINT", shutdown);
190
- process.once("SIGTERM", shutdown);
197
+ process.on("SIGINT", shutdown);
198
+ process.on("SIGTERM", shutdown);
191
199
 
192
200
  // 兜底:dev 进程以任何非优雅路径退出(未走 SIGINT/SIGTERM,如未捕获异常)时,
193
201
  // 同步强杀 oj,确保它不孤儿化继续占端口(shutdownDev 已停则 kill 为 no-op)。
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ import { buildBundle } from "./bundle";
7
7
  import { devServer } from "./dev";
8
8
  import { mergeManifests, printInfo } from "./info";
9
9
  import { initProject } from "./init";
10
- import { previewServer } from "./preview";
10
+ import { runPreview } from "./preview";
11
11
  import { usageText } from "./usage";
12
12
  import { vendorCommand } from "./vendor";
13
13
  import { readCliVersion } from "./versions";
@@ -51,8 +51,8 @@ async function main() {
51
51
  }
52
52
  case "preview": {
53
53
  const previewPortArg = Number(process.argv[3]);
54
- const previewPort = Number.isFinite(previewPortArg) && previewPortArg > 0 ? previewPortArg : undefined;
55
- await previewServer(projectRoot, { port: previewPort, ojStatic: process.argv.includes("--oj-static") });
54
+ const previewPort = Number.isInteger(previewPortArg) && previewPortArg > 0 ? previewPortArg : undefined;
55
+ await runPreview(projectRoot, { port: previewPort });
56
56
  break;
57
57
  }
58
58
  case "dev": {
package/src/init.ts CHANGED
@@ -146,6 +146,13 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
146
146
  // 的「已存在即跳过」会把审批挡在门外(0.1.0 发布实测)——必须合并
147
147
  ensureAllowBuilds(destDir, report);
148
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
+
149
156
  console.log(`[ojm] init 完成:${destDir}`);
150
157
  console.log(`[ojm] 新增 ${report.created.length} 项,跳过(已存在)${report.skipped.length} 项`);
151
158
  for (const item of report.skipped)
@@ -360,6 +367,43 @@ function copyIfAbsent(src: string, dest: string, report: InitReport): void {
360
367
  report.created.push(path.relative(process.cwd(), dest) || path.basename(dest));
361
368
  }
362
369
 
370
+ /**
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
+ # ojm preview 的派生 config(临时产物,进程退出即删;见 preview-config.ts)
382
+ api/.ojm-preview.yaml
383
+ # 本地 sqlite 与运行日志
384
+ api/db.sqlite
385
+ api/db.sqlite-shm
386
+ api/db.sqlite-wal
387
+ api/logs/
388
+ # 现场签发的开发证书与凭据私钥(ojm dev 缺证时自动签发,见 oj-cert.ts);私钥绝不入仓
389
+ api/config/*.pem
390
+ api/config/cert.jws
391
+ # 构建产物
392
+ api/dist/
393
+ web/dist/
394
+ modules/dist/
395
+ `;
396
+
397
+ function ensureGitignore(destDir: string, report: InitReport): void {
398
+ const gitignorePath = path.join(destDir, ".gitignore");
399
+ if (fs.existsSync(gitignorePath)) {
400
+ report.skipped.push(".gitignore");
401
+ return;
402
+ }
403
+ fs.writeFileSync(gitignorePath, SCAFFOLD_GITIGNORE);
404
+ report.created.push(".gitignore");
405
+ }
406
+
363
407
  /**
364
408
  * 确保工程根 pnpm-workspace.yaml 的 allowBuilds 含 `esbuild: true`。
365
409
  * - 文件不存在(本函数即唯一来源,见下)→ 按 SCAFFOLD_WORKSPACE_YAML 生成
@@ -0,0 +1,163 @@
1
+ /**
2
+ * preview 派生 config(设计 §6 单服务化,2026-10-19)。
3
+ *
4
+ * 单服务 preview 由 oj 一个进程同时出 API 与静态(web 挂载 + spa: true),
5
+ * 而 spa 只能来自 config 文件(CLI --app-path 的 upsert 保留但不设置
6
+ * spa——实测 oj 0.1.80)。用户在模板里手改 config 不可取(web/dist 缺失时
7
+ * oj serve 整体拒启,会连带挂掉 ojm dev),故 preview 现场生成一份
8
+ * 派生 config:
9
+ *
10
+ * - 与原 config 同目录(api/.ojm-preview.yaml,固定名)——config 内
11
+ * 相对路径(证书 / db / api 挂载目录)按 config 所在目录解析,派生后
12
+ * 与原件逐字一致,无需重写任何路径;
13
+ * - 唯一改动:upsert {prefix: "/", web: <siteDir>, spa: true} 挂载 +
14
+ * 显式传 port 时覆盖 server.port;
15
+ * - 临时产物:preview 进程退出即删(三路清理见 preview.ts),模板
16
+ * .gitignore 已收录该文件名。
17
+ *
18
+ * 纯变换(derivePreviewConfigText)与落盘(writePreviewConfig)分离:
19
+ * 前者可单测到「除注入点外 doc 与原 parse 深相等」,后者只做 IO。
20
+ */
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { parse, stringify } from "yaml";
25
+ import { resolveConfigPath } from "./layout";
26
+
27
+ /** 派生 config 文件名(与原 config 同目录;固定名 = 崩溃自愈,不积累残留) */
28
+ export const PREVIEW_CONFIG_NAME = ".ojm-preview.yaml";
29
+
30
+ export interface DerivePreviewConfigOptions {
31
+ /** 全站产物目录(layout.distDir),写入 web 挂载的绝对路径 */
32
+ siteDir: string
33
+ /** 显式端口时覆盖 server.port;缺省沿用原 config(oj 代码默认 9778) */
34
+ port?: number
35
+ /** 原 config 文本(测试注入);缺省读 api/config.yaml */
36
+ configText?: string
37
+ }
38
+
39
+ export interface PreviewConfigFiles {
40
+ /** 派生 config 路径(startOj 的 configPath) */
41
+ configPath: string
42
+ /** 同步删除派生 config(幂等;文件已不存在不抛) */
43
+ cleanup: () => void
44
+ }
45
+
46
+ /** YAML 解析失败的人话报错(口径对齐 oj-config.ts 的 readServerBlock) */
47
+ function parseConfigDoc(configText: string, configPath: string): Record<string, unknown> {
48
+ let doc: unknown;
49
+ try {
50
+ doc = parse(configText);
51
+ }
52
+ catch (error) {
53
+ throw new Error(
54
+ `[ojm] ${configPath} 解析失败(不是合法 YAML):${(error as Error).message}\n`
55
+ + "该文件由 ojm init 生成,改动后请用 YAML 语法检查确认缩进与引号。",
56
+ );
57
+ }
58
+ // 空 config(parse → null)合法:oj 用内置默认值;派生时从空对象建 mounts/server
59
+ if (doc === null || doc === undefined)
60
+ return {};
61
+ if (typeof doc !== "object" || Array.isArray(doc)) {
62
+ throw new TypeError(
63
+ `[ojm] ${configPath} 顶层不是映射(拿到 ${Array.isArray(doc) ? "序列" : typeof doc})——`
64
+ + "oj config 必须是键值结构,请检查文件内容。",
65
+ );
66
+ }
67
+ return doc as Record<string, unknown>;
68
+ }
69
+
70
+ /** 挂载条目 = 非空非数组的普通对象(mounts: 一行一条) */
71
+ function isMountEntry(m: unknown): m is Record<string, unknown> {
72
+ return m !== null && typeof m === "object" && !Array.isArray(m);
73
+ }
74
+
75
+ /** 找 prefix === "/" 的 web 挂载条目下标;没有返回 -1 */
76
+ function findRootWebMount(mounts: unknown[]): number {
77
+ return mounts.findIndex(m => isMountEntry(m) && m.prefix === "/" && "web" in m);
78
+ }
79
+
80
+ /**
81
+ * 是否存在 prefix === "/" 的 api 挂载(oj 语义:与 web 挂载配对时恒无 SPA
82
+ * 回落)。调用方(preview)必然注入 / web 挂载,故「有 / api 挂载」即「配对」。
83
+ */
84
+ export function hasRootApiMount(mounts: unknown[]): boolean {
85
+ return mounts.some(m => isMountEntry(m) && m.prefix === "/" && "api" in m);
86
+ }
87
+
88
+ /** mounts 段校验:缺省视为无挂载;存在但非序列 → 人话报错(不静默置空掩盖手误) */
89
+ function readMounts(doc: Record<string, unknown>, configPath: string): unknown[] {
90
+ if (doc.mounts === undefined || doc.mounts === null)
91
+ return [];
92
+ if (!Array.isArray(doc.mounts)) {
93
+ throw new TypeError(
94
+ `[ojm] ${configPath} 的 mounts 不是序列(拿到 ${typeof doc.mounts})——`
95
+ + "每个挂载占一行「- prefix: ...」列表,请对照 ojm init 生成的模板修正。",
96
+ );
97
+ }
98
+ return doc.mounts;
99
+ }
100
+
101
+ /**
102
+ * 原 config 文本 → 派生 config 文本。
103
+ *
104
+ * upsert 语义(DR-3):已有 / web 条目 → 覆盖 web 指向、强制 spa: true,
105
+ * 保留该条目其余键(如 headers);没有 → 追加新条目。其余挂载与原样
106
+ * 顶层段一律不动。
107
+ */
108
+ export function derivePreviewConfigText(configText: string, opts: DerivePreviewConfigOptions & { configPath?: string }): string {
109
+ const configPath = opts.configPath ?? "api/config.yaml";
110
+ const doc = parseConfigDoc(configText, configPath);
111
+
112
+ const mounts = [...readMounts(doc, configPath)];
113
+ const idx = findRootWebMount(mounts);
114
+ if (idx >= 0) {
115
+ // 保留用户自定义键(headers 等),只接管指向与回落
116
+ mounts[idx] = { ...(mounts[idx] as Record<string, unknown>), web: opts.siteDir, spa: true };
117
+ }
118
+ else {
119
+ mounts.push({ prefix: "/", web: opts.siteDir, spa: true });
120
+ }
121
+ doc.mounts = mounts;
122
+
123
+ if (opts.port !== undefined) {
124
+ const server = doc.server !== null && typeof doc.server === "object" && !Array.isArray(doc.server)
125
+ ? { ...(doc.server as Record<string, unknown>) }
126
+ : {};
127
+ server.port = opts.port;
128
+ doc.server = server;
129
+ }
130
+
131
+ return stringify(doc);
132
+ }
133
+
134
+ /** 读 config 文本的 mounts 序列(解析失败 / mounts 非序列即人话报错) */
135
+ export function parseMounts(configText: string, configPath = "api/config.yaml"): unknown[] {
136
+ return readMounts(parseConfigDoc(configText, configPath), configPath);
137
+ }
138
+
139
+ /**
140
+ * 落盘派生 config 并返回清理器。config 文本读取失败(不存在/无权限)直接
141
+ * 抛——四查已保证 config 存在,这里只可能是 IO 异常。
142
+ */
143
+ export function writePreviewConfig(projectRoot: string, opts: DerivePreviewConfigOptions): PreviewConfigFiles {
144
+ const configPath = resolveConfigPath(projectRoot);
145
+ const previewPath = path.join(path.dirname(configPath), PREVIEW_CONFIG_NAME);
146
+ const configText = opts.configText ?? fs.readFileSync(configPath, "utf-8");
147
+
148
+ fs.writeFileSync(previewPath, derivePreviewConfigText(configText, { ...opts, configPath }));
149
+
150
+ let cleaned = false;
151
+ const cleanup = () => {
152
+ if (cleaned)
153
+ return;
154
+ cleaned = true;
155
+ try {
156
+ fs.unlinkSync(previewPath);
157
+ }
158
+ catch {
159
+ // 已不存在(并发退出/手工删除)——幂等
160
+ }
161
+ };
162
+ return { configPath: previewPath, cleanup };
163
+ }
package/src/preview.ts CHANGED
@@ -1,44 +1,58 @@
1
1
  /**
2
- * `ojm preview` —— 生产形态预览(设计 §6)。
2
+ * `ojm preview` —— 生产形态预览(设计 §6,2026-10-19 单服务化)。
3
3
  *
4
- * 职责:校验产物(fail-fast 四查)→ `oj migrate`(失败即退,不起 server)
5
- * → `oj serve`(release/js,仅 API)→ ojm 静态层兜底(SPA 回退,无 SSE
6
- * 注入、不设 no-store)。默认静态归 ojm(与 oj 手册 §13「SPA 回退经前置
7
- * 反代补」一致);`--oj-static` 切换为 oj `--app-path` 直出(真 exercise
8
- * oj 静态层,history 深链接 404 是已知限制)。
4
+ * 职责:fail-fast 四查 → `oj migrate`(失败即退,不起 server)→ 派生
5
+ * preview config(注入 web 挂载 + spa: true)→ `oj serve`(release/js,
6
+ * **单进程同时出 API 与静态**)。ojm 不再自建 http server、不再反代——
7
+ * 前后端同端口同进程,与「oj serve + web 挂载」的生产公式逐字同形。
8
+ *
9
+ * 派生 config 为临时产物(api/.ojm-preview.yaml,与原件同目录保证相对
10
+ * 路径解析一致),三路清理:ready 拒绝路径 / SIGINT / process exit 兜底
11
+ * (语义与落盘见 preview-config.ts)。
12
+ *
13
+ * web.config.ts 的 htmlTransform 是 serve 期钩子,单服务下 oj 直出 HTML
14
+ * 无从介入——启动时检测到配置即 WARN 并指向 oj 的 html_meta /
15
+ * html_meta_handler(devkit 手册「静态站点与 per-route meta」)。
9
16
  *
10
17
  * 一律传绝对路径:oj 命令行相对 CWD、config 内相对 config 目录,混用即歧义。
11
18
  */
12
19
 
13
20
  import type { OjProcess } from "./oj";
21
+ import type { PreviewConfigFiles } from "./preview-config";
14
22
  import { execFileSync } from "node:child_process";
15
- import { existsSync } from "node:fs";
16
- import http from "node:http";
17
- import path, { normalize } from "node:path";
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import path from "node:path";
18
25
  import process from "node:process";
19
- import { proxyApi } from "./dev-proxy";
20
- import { loadHtmlTransform } from "./html-transform";
26
+ import { loadModulesConfig } from "./config";
21
27
  import { resolveConfigPath, resolveLayout } from "./layout";
22
28
  import { startOj } from "./oj";
23
29
  import { readOjApiPrefix } from "./oj-config";
24
- import { createStaticHandler, decodeReqPath, listenOnFreePort } from "./static-handler";
30
+ import { hasRootApiMount, parseMounts, writePreviewConfig } from "./preview-config";
25
31
  import { resolveOjBin } from "./vendor";
26
32
 
27
- const DEFAULT_PORT = 4173;
28
-
29
33
  export interface PreviewOptions {
34
+ /** 显式端口(覆盖派生 config 的 server.port);缺省沿用 config(oj 代码默认 9778) */
30
35
  port?: number
31
- /** 静态改由 oj --app-path 直出(history 深链接 404,已知限制) */
32
- ojStatic?: boolean
33
36
  /** 注入桩站点目录(默认 layout.distDir) */
34
37
  siteDir?: string
35
38
  /** 注入桩 oj 命令执行(migrate);默认 execFileSync(bin/oj, args, inherit) */
36
39
  execOj?: (args: string[]) => void
37
40
  /** 注入桩 oj 进程;默认 startOj */
38
41
  ojStarter?: (configPath: string, base: string, apiPath: string, extraArgs: string[]) => OjProcess
42
+ /** 注入桩派生 config 落盘(测试);默认 writePreviewConfig */
43
+ configWriter?: (projectRoot: string, opts: { siteDir: string, port?: number }) => PreviewConfigFiles
39
44
  }
40
45
 
41
- export async function previewServer(projectRoot: string, opts: PreviewOptions = {}): Promise<http.Server> {
46
+ export interface PreviewHandle {
47
+ /** 停 oj 子进程并清理派生 config(测试与编程式消费用) */
48
+ stop: () => Promise<void>
49
+ }
50
+
51
+ /**
52
+ * 单服务 preview。返回后进程保活依赖 oj 子进程 stdio 管道(ref 状态)
53
+ * 与已注册的信号 handler;Ctrl+C 走 SIGINT 善后。
54
+ */
55
+ export async function runPreview(projectRoot: string, opts: PreviewOptions = {}): Promise<PreviewHandle> {
42
56
  const layout = resolveLayout(projectRoot);
43
57
  const siteDir = opts.siteDir ?? layout.distDir;
44
58
  const ojBin = resolveOjBin(projectRoot);
@@ -55,8 +69,9 @@ export async function previewServer(projectRoot: string, opts: PreviewOptions =
55
69
  if (!existsSync(path.join(apiDist, "manifests.yaml")))
56
70
  throw new Error(`[ojm] ${apiDist} 缺少 manifests.yaml。\n请先 ojm build(后端 oj build 产物)。`);
57
71
 
58
- // htmlTransform(web.config.ts,可选)先于起进程校验:配置坏了不该留下 oj 孤儿
59
- const transform = await loadHtmlTransform(projectRoot);
72
+ // DR-5:htmlTransform 存在性检测(只读 web.config.ts 的字段,不 import 变换模块——
73
+ // 单服务下 oj 直出 HTML,钩子不生效;执行用户代码只会白付副作用)
74
+ await warnHtmlTransformUnsupported(projectRoot);
60
75
 
61
76
  const execOj = opts.execOj ?? ((args: string[]) => execFileSync(ojBin, args, { stdio: "inherit" }));
62
77
 
@@ -64,57 +79,91 @@ export async function previewServer(projectRoot: string, opts: PreviewOptions =
64
79
  console.log("[ojm] oj migrate(应用待执行迁移)…");
65
80
  execOj(["migrate", "-c", configPath, "-d", apiDist]);
66
81
 
82
+ // 派生 config 在 migrate 成功后落盘:失败即退路径零残留
67
83
  const base = readOjApiPrefix(configPath);
84
+ const writer = opts.configWriter ?? ((root: string, o: { siteDir: string, port?: number }) => writePreviewConfig(root, o));
85
+ const preview = writer(projectRoot, { siteDir, port: opts.port });
86
+
87
+ // / api 挂载与注入的 / web 挂载配对时 oj 不开 SPA 回落(oj 语义,见 devkit 手册 §13)
88
+ if (hasRootApiMount(parseMounts(readFileSync(configPath, "utf-8"), configPath)))
89
+ console.warn("[ojm] config 的 api 挂载位于 /:与 web 挂载配对时 oj 不做 SPA 深链回落(已知限制),深链接刷新将 404。");
90
+
68
91
  const starter = opts.ojStarter ?? ((cfg: string, b: string, apiPath: string, extraArgs: string[]) =>
69
92
  startOj(cfg, b, apiPath, undefined, extraArgs));
70
- const extraArgs = opts.ojStatic ? ["--app-path", siteDir] : [];
71
- const oj = starter(configPath, base, apiDist, extraArgs);
93
+ // starter 可能同步抛错(startOj 首行 readOjPort 对缺/坏 server.port 即 throw,
94
+ // 先于返回 OjProcess)——此刻 exit 兜底尚未注册,必须就地清理派生 config
95
+ let oj: OjProcess;
96
+ try {
97
+ oj = starter(preview.configPath, base, apiDist, []);
98
+ }
99
+ catch (error) {
100
+ preview.cleanup();
101
+ throw error;
102
+ }
72
103
  try {
73
104
  await oj.ready;
74
105
  }
75
106
  catch (error) {
76
- // F3:ready 拒绝必须先回收子进程,否则 oj 孤儿化继续占端口
107
+ // F3:ready 拒绝必须先回收子进程,否则 oj 孤儿化继续占端口;派生 config 一并清理
77
108
  await oj.stop().catch(() => {});
109
+ preview.cleanup();
78
110
  throw error;
79
111
  }
80
- console.log(`[ojm] oj 后端已就绪(release/js):${base} → http://127.0.0.1:${oj.port}`);
112
+ console.log(`[ojm] oj 已就绪(release/js,单服务:${base} API + 静态 SPA 同进程):http://127.0.0.1:${oj.port}`);
113
+ console.log(`\n[ojm] preview 已启动:http://localhost:${oj.port}`);
114
+ console.log(`[ojm] 静态由 oj web 挂载直出(spa 深链回落);派生 config ${path.basename(preview.configPath)} 为本进程临时产物,退出自动清理`);
81
115
 
82
- const ojTarget = `http://127.0.0.1:${oj.port}`;
83
- // 生产形态下按路由改写 title/OG(transform 已在起进程前加载校验)
84
- const serveStatic = createStaticHandler({ roots: [siteDir], reload: null, noStore: false, transform });
85
-
86
- const server = http.createServer((req, res) => {
87
- const urlPath = decodeReqPath(req.url ?? "/");
88
- if (urlPath === null) {
89
- res.writeHead(400, { "content-type": "text/plain" });
90
- res.end("400 Bad Request: malformed URL encoding");
91
- return;
92
- }
93
- const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
94
- // 反代前缀跟随 config 的 mounts:(api 挂载 prefix;F2)
95
- if (rel.startsWith(`${base}/`)) {
96
- void proxyApi(ojTarget)(req, res);
116
+ // 退出回收:派生 config 必须随 oj 一起消亡(test/integration 断言其不存在)
117
+ // 终端 Ctrl-C 是进程组信号:外层 bin 转发一份 + 组送达一份,内层会收到
118
+ // 两个 SIGINT。用 on + 幂等守卫吞掉第二个——once 在首次触发后即摘除,
119
+ // 第二个 SIGINT 会走默认动作杀死本进程,清理全没跑(2026-10-19 冒烟实测)。
120
+ let stopping = false;
121
+ const onSigint = () => {
122
+ if (stopping)
97
123
  return;
98
- }
99
- serveStatic(req, res);
100
- });
101
-
102
- server.on("close", () => {
103
- void oj.stop();
104
- });
105
- process.once("SIGINT", () => {
124
+ stopping = true;
106
125
  console.log("\n[ojm] 正在退出…");
107
- void oj.stop();
108
- server.close(() => process.exit(0));
126
+ // 同步先删派生 config:后续任何死亡路径(含信号)都零残留;oj 运行中
127
+ // 已读入 config,运行期删除文件无影响
128
+ preview.cleanup();
129
+ void oj.stop().finally(() => process.exit(0));
109
130
  setTimeout(() => process.exit(0), 1500).unref();
110
- });
131
+ };
132
+ process.on("SIGINT", onSigint);
133
+ // 兜底:非优雅路径退出(未捕获异常等)时同步清掉派生 config,防下次启动读到陈旧文件
134
+ const onExit = () => {
135
+ preview.cleanup();
136
+ };
137
+ process.once("exit", onExit);
138
+
139
+ // stop 摘除信号/退出 handler:编程式同进程多次调用不累积旧实例的闭包
140
+ // (CLI 单次调用行为不变)
141
+ const stop = async () => {
142
+ process.removeListener("SIGINT", onSigint);
143
+ process.removeListener("exit", onExit);
144
+ await oj.stop();
145
+ preview.cleanup();
146
+ };
111
147
 
112
- const actualPort = await listenOnFreePort(server, opts.port ?? DEFAULT_PORT);
113
- console.log(`\n[ojm] preview 已启动:http://localhost:${actualPort}`);
114
- if (opts.ojStatic)
115
- console.log(`[ojm] --oj-static:oj 已挂静态直出 http://127.0.0.1:${oj.port}(history 深链接 404 为已知限制;ojm 本口仍提供 SPA 兜底 + /api 反代)`);
116
- else
117
- console.log(`[ojm] 静态兜底:ojm(SPA 回退)+ ${base} 反代 oj`);
148
+ return { stop };
149
+ }
118
150
 
119
- return server;
151
+ /**
152
+ * web.config.ts 配了 htmlTransform → WARN。加载失败(缺文件/坏配置)静默
153
+ * 跳过:能走到 preview 的工程必然过 ojm build,config 问题会在那里先炸。
154
+ */
155
+ async function warnHtmlTransformUnsupported(projectRoot: string): Promise<void> {
156
+ try {
157
+ const config = await loadModulesConfig(projectRoot);
158
+ if (config.htmlTransform) {
159
+ console.warn(
160
+ "[ojm] web.config.ts 配置了 htmlTransform:单服务 preview 由 oj 直出 HTML,该钩子不生效。\n"
161
+ + "逐路由 meta 请改用 oj 的 server.html_meta(构建期 JSON)或 server.html_meta_handler(运行期 handler),"
162
+ + "见 bin/devkit/api-manual.md「静态站点与 per-route meta」。",
163
+ );
164
+ }
165
+ }
166
+ catch {
167
+ // config 加载失败:不阻塞 preview(build 已先行校验过)
168
+ }
120
169
  }
package/src/usage.ts CHANGED
@@ -20,8 +20,9 @@ export function usageText(): string {
20
20
  --bundle 方案包构建:manifest.yaml 契约校验(C2,fail-fast)→ 确定性 tgz
21
21
  → dist/<name>-<version>.tgz + sha256(市场安装器的上游产物)
22
22
  --out <dir> --bundle 输出目录(缺省 dist/)
23
- ojm preview [port] [--oj-static]
24
- 生产形态预览(migrate → oj serve + 静态兜底)
23
+ ojm preview [port]
24
+ 生产形态预览(migrate → oj serve 单服务:API 与静态 SPA 同进程,深链回落)
25
+ 静态由 oj web 挂载直出;ojm 不起第二个服务、不做反代
25
26
  ojm api [dir] [--check] [--docs] [--exempt <path>]
26
27
  契约代码生成(client / routes / openapi / stub)
27
28
  --check 四重对账(生成物同步 / route 双向 / routes.js / WS 路由鉴权)
@@ -24,11 +24,13 @@
24
24
 
25
25
  # 站点挂载(oj v0.1.58 起顶层扁平表:一行=一个 URL 前缀+一个目录,api/web 恰好其一)。
26
26
  # 被删除的 server.api_prefix / app_spa_fallback 已由 mounts 取代;
27
- # ojm dev/preview 的 SPA 深链回落由 ojm 自建静态层负责,无需 oj 的 spa。
27
+ # ojm dev 的 SPA 深链回落由 ojm 自建静态层负责;ojm preview 为单服务形态,
28
+ # 现场生成派生 config(api/.ojm-preview.yaml,注入下面的 web 挂载 + spa: true),无需手改。
28
29
  mounts:
29
30
  - prefix: /api # api 挂载:/api/<module>/...(目录相对 config 所在目录 → api/src)
30
31
  api: src
31
- # 生产若由 oj 直出前端(不走 ojm 静态层),取消下一行注释并先 build 出 web/dist:
32
+ # 生产若由 oj 直出前端(前后端同进程,即 ojm preview 的生产形态),
33
+ # 取消下一行注释并先 build 出 web/dist(preview 会自动注入等价挂载,无需手改):
32
34
  # - prefix: "/"
33
35
  # web: "../web/dist" # 相对 api/config.yaml → 项目根/web/dist
34
36
  # spa: true
@@ -0,0 +1,31 @@
1
+ # `_platform` —— 平台内核层
2
+
3
+ 本目录是 oj 框架的**平台内核**,不是业务模块(manifest `name: _platform` 仅声明共有表,
4
+ 不拥有业务路由前缀)。它服务于 `api/src` 下所有第一方内置模块(`system`/`auth`/`home`/…)
5
+ 以及运行时经方案市场安装的方案包。
6
+
7
+ ## 三层职责
8
+
9
+ 1. **`_shared/service/`** —— 跨模块服务(被业务模块向上 import)。
10
+ - `infra/`:跨模块基础设施(与具体业务无关的通用能力)
11
+ - `guard.ts` 判权决策矩阵(AC1-1~6)
12
+ - `access.ts` 判权 IO 出口(`assertSuperAdmin` / `assertSystemAdmin` / `assertPermission`)
13
+ - `query.ts` 分页/模糊查询工具
14
+ - `rate.ts` 速率限制(登录失败/联系人查询)
15
+ - `audit.ts` 审计原语(`auditOp` / `auditDeny`)
16
+ - `oplog.ts` 操作日志查询(`searchOplog`)
17
+ - `domain/`:管理域共享服务(多模块共用的「系统管理」域逻辑,所有权归 `_platform` 的 `sys_*` 表)
18
+ - `users.ts` / `roles.ts` / `settings.ts` / `solution-perms.ts` / `contacts.ts`
19
+ 2. **`_lifecycle/`** —— 方案生命周期引擎(安装/卸载/注册/验签/信任的声明式状态机,
20
+ 与编排端点 `market/api.ts` 职责分离,见 `market/api.ts` 顶部 ISP 注释)。
21
+ 3. **编排 API 面** —— 经 oj 扫描 `src/**/api.ts` 暴露,路径前缀即目录名:
22
+ - `contacts/api.ts` → `/api/_platform/contacts`
23
+ - `market/api.ts` → `/api/_platform/market`(方案市场,全 superadmin 门禁,R9)
24
+ - `solutions/api.ts` → `/api/_platform/solutions`(方案路由/验签数据源)
25
+
26
+ ## 边界约定
27
+
28
+ - 平台内核**不写**业务功能;业务功能归属第一方模块(`/api/<模块>/*`)或运行时方案包。
29
+ - `migrations/` / `schema.yaml` / `seed*.sql` 落地平台共有表(`users` + RBAC 六表 + 设置/日志/路由注册 + 信任库)。
30
+ - `system` 模块管理这些 `sys_*` 表,但其领域服务在本目录 `domain/`(共享内核),
31
+ 非 `system/` 内——避免跨模块 import 私有目录。
@@ -9,7 +9,7 @@ export interface FsFace {
9
9
  writeFile: (path: string, data: Uint8Array) => Promise<void>
10
10
  readFile: (path: string) => Promise<Uint8Array>
11
11
  mkdir: (path: string, opts?: { recursive?: boolean }) => Promise<void>
12
- remove: (path: string, opts?: { recursive?: boolean }) => Promise<void>
12
+ remove: (path: string, opts?: { recursive?: boolean, force?: boolean }) => Promise<void>
13
13
  rename: (oldPath: string, newPath: string) => Promise<void>
14
14
  /** 复制文件(R14 迁移前置备份:sqlite 库 copy 为 <db>.bak-<ts>);两路径须同文件系统无需递归 */
15
15
  copyFile: (src: string, dest: string) => Promise<void>
@@ -5,7 +5,7 @@
5
5
  // 参数化 insert,与 audit.ts 同纪律(值不拼字符串)。
6
6
 
7
7
  // db 为 oj isolate 注入全局(同模板 service 层惯例,installer.ts 同款;测试由夹具装 globalThis.db)
8
- declare const db: { exec: (sql: string, params: unknown[]) => Promise<unknown> };
8
+ declare const db: { exec: (sql: string, params: unknown[]) => Promise<unknown>, table: (name: string) => any };
9
9
 
10
10
  export interface HistoryRow {
11
11
  solution: string
@@ -20,10 +20,15 @@ export interface HistoryRow {
20
20
  /** 守卫式 insert:表不存在/写失败一律静默(留痕失败不影响主流程) */
21
21
  export async function recordHistory(row: HistoryRow): Promise<void> {
22
22
  try {
23
- await db.exec(
24
- "insert into market_history (solution, version, action, status, operator, message, created_at) values (?, ?, ?, ?, ?, ?, ?)",
25
- [row.solution, row.version, row.action, row.status, row.operator, row.message, Math.floor(Date.now() / 1000)],
26
- );
23
+ await db.table("market_history").insert({
24
+ solution: row.solution,
25
+ version: row.version,
26
+ action: row.action,
27
+ status: row.status,
28
+ operator: row.operator,
29
+ message: row.message,
30
+ created_at: Math.floor(Date.now() / 1000),
31
+ }).run();
27
32
  }
28
33
  catch {
29
34
  // 表不存在(市场方案未装)或写失败:静默跳过,不阻断主流程
@@ -14,7 +14,7 @@
14
14
  // setEnabled 四处同写(D12/AC-P1-6):锁摘除/恢复 + sidecar + sys_solution_route.enabled +
15
15
  // modules.json enabled;启用从 sidecar 摘除前记录恢复版本。db 为 isolate 全局(同模板 service 层)。
16
16
 
17
- import type { OpEntry } from "../_shared/service/audit";
17
+ import type { OpEntry } from "../_shared/service/infra/audit";
18
18
  import type { TaskLock } from "./lock";
19
19
  import type { StateStore } from "./state";
20
20
  import type { FetchFace } from "./steps/download";
@@ -22,7 +22,7 @@ import type { ExecFace } from "./steps/extract";
22
22
  import type { SettingDecl, SolutionDecl, SolutionSnapshot } from "./steps/register";
23
23
  import type { ReloadFace } from "./steps/reload";
24
24
  import type { InstallContext, InstallStage, StageResult, TaskRecord, TaskStatus } from "./types";
25
- import { auditOp } from "../_shared/service/audit";
25
+ import { auditOp } from "../_shared/service/infra/audit";
26
26
  import { atomicWriteText, fsFace, randomId } from "./atomic";
27
27
  import { recordHistory } from "./history";
28
28
  import { acquireTaskLock, commitLockEntry, readLock, writeLock } from "./lock";
@@ -40,7 +40,7 @@ import { appendAnonymousPaths, bumpInstallSeq } from "./text-edit";
40
40
  import { createTrustStore } from "./trust";
41
41
 
42
42
  // db/http 为 oj isolate 注入全局(同模板 service 层惯例,register.ts/audit.ts 同款;测试由夹具装 globalThis.db)
43
- declare const db: { exec: (sql: string, params: unknown[]) => Promise<unknown> };
43
+ declare const db: { exec: (sql: string, params: unknown[]) => Promise<unknown>, table: (name: string) => any };
44
44
 
45
45
  export type { InstallContext, InstallStage, StageResult, TaskRecord, TaskStatus };
46
46
 
@@ -1039,7 +1039,7 @@ export async function setEnabled(input: SetEnabledInput): Promise<{ ok: boolean,
1039
1039
  }
1040
1040
  // ③ route 表 enabled(生命周期开关,非注册内容直写,D10 不适用)
1041
1041
  try {
1042
- await db.exec("update sys_solution_route set enabled = ? where module = ?", [enabled ? 1 : 0, name]);
1042
+ await db.table("sys_solution_route").update({ enabled: enabled ? 1 : 0 }).where({ field: "module", op: "eq", value: name }).run();
1043
1043
  }
1044
1044
  catch (e) {
1045
1045
  audit(false, `route enabled 写失败:${String(e)}`);
@@ -101,7 +101,7 @@ export function sha384Bytes(data: Uint8Array): Uint8Array {
101
101
  view.setUint32(padded - 4, bitLenLo);
102
102
 
103
103
  const h: Array<[number, number]> = IV.map(x => [x[0], x[1]] as [number, number]);
104
- const w = Array.from({ length: 80 });
104
+ const w = Array.from({ length: 80 }, () => [0, 0] as [number, number]);
105
105
  for (let off = 0; off < padded; off += 128) {
106
106
  for (let t = 0; t < 16; t++) {
107
107
  w[t] = [view.getUint32(off + t * 8), view.getUint32(off + t * 8 + 4)];
@@ -39,7 +39,7 @@ export function archiveFileOf(manifest: unknown): { ok: true, file: string, arch
39
39
  if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
40
40
  return { ok: false, message: "C2-29:manifest 不是键值对象,archive 身份不可得" };
41
41
  }
42
- const archive = (manifest as Record<string, unknown>).archive;
42
+ const archive = (manifest as Record<string, unknown>).archive as Record<string, unknown>;
43
43
  if (typeof archive !== "object" || archive === null || Array.isArray(archive)) {
44
44
  return { ok: false, message: "C2-29:archive 段缺失或不是键值对象(Form B 真源须含 file/sha256/size)" };
45
45
  }