@oj-module/cli 0.2.6 → 0.2.14

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 (169) hide show
  1. package/README.md +1 -1
  2. package/devkit/README.md +70 -24
  3. package/devkit/framework-dev.md +14 -5
  4. package/devkit/manual.md +28 -23
  5. package/devkit/module-dev-and-cdn-playground.md +197 -0
  6. package/devkit/sql-builder-missing-capabilities-pr.md +268 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/runtime.js +130 -45
  9. package/shell-dist/index.html +2 -2
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/build-backend.test.ts +64 -0
  12. package/src/__tests__/versions.test.ts +26 -0
  13. package/src/build.ts +9 -0
  14. package/src/bundle.ts +8 -5
  15. package/src/contract/__tests__/handler-scan.test.ts +29 -0
  16. package/src/contract/__tests__/market-client.integration.test.ts +170 -0
  17. package/src/contract/handler-scan.ts +35 -4
  18. package/src/dev.ts +10 -2
  19. package/src/index.ts +9 -3
  20. package/src/init.ts +24 -0
  21. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  22. package/src/market/__tests__/bootstrap.test.ts +204 -0
  23. package/src/market/__tests__/build-index.test.ts +154 -0
  24. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  25. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  26. package/src/market/__tests__/fixture.test.ts +48 -0
  27. package/src/market/__tests__/publish.test.ts +109 -0
  28. package/src/market/__tests__/report.test.ts +51 -0
  29. package/src/market/__tests__/setup.test.ts +114 -0
  30. package/src/market/__tests__/verify.test.ts +111 -0
  31. package/src/market/bootstrap.ts +255 -0
  32. package/src/market/build-index.ts +159 -0
  33. package/src/market/cdn-layout.ts +122 -0
  34. package/src/market/command.ts +139 -0
  35. package/src/market/failure-playbook.ts +57 -0
  36. package/src/market/fixture.ts +138 -0
  37. package/src/market/index-builder.ts +76 -0
  38. package/src/market/publish.ts +156 -0
  39. package/src/market/report.ts +67 -0
  40. package/src/market/scan.ts +99 -0
  41. package/src/market/setup.ts +144 -0
  42. package/src/market/trial.ts +121 -0
  43. package/src/market/verify.ts +298 -0
  44. package/src/market/walkthrough.ts +506 -0
  45. package/src/oj.ts +3 -1
  46. package/src/preview-config.ts +177 -0
  47. package/src/preview.ts +106 -56
  48. package/src/sign.ts +21 -6
  49. package/src/usage.ts +22 -2
  50. package/src/vendor.ts +4 -2
  51. package/src/versions.ts +28 -0
  52. package/templates/api/.ojm-api-exempt.json +6 -2
  53. package/templates/api/config.yaml +24 -2
  54. package/templates/api/src/_platform/README.md +31 -0
  55. package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
  56. package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
  57. package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
  58. package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
  59. package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
  60. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  61. package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
  62. package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
  63. package/templates/api/src/_platform/_lifecycle/uninstall.ts +29 -56
  64. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
  65. package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +20 -13
  66. package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
  67. package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
  68. package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +25 -22
  69. package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
  70. package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
  71. package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
  72. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +40 -0
  73. package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +18 -0
  74. package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +16 -13
  75. package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
  76. package/templates/api/src/_platform/contacts/api.ts +2 -2
  77. package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
  78. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  79. package/templates/api/src/_platform/manifest.yaml +4 -1
  80. package/templates/api/src/_platform/market/api.ts +54 -16
  81. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  82. package/templates/api/src/_platform/schema.yaml +26 -0
  83. package/templates/api/src/_platform/solutions/api.ts +13 -12
  84. package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
  85. package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
  86. package/templates/api/src/auth/_shared/session.ts +26 -22
  87. package/templates/api/src/auth/api.ts +1 -1
  88. package/templates/api/src/demo/todos/api.ts +8 -8
  89. package/templates/api/src/home/line/api.ts +4 -4
  90. package/templates/api/src/home/pie/api.ts +4 -4
  91. package/templates/api/src/notification/_shared/notify.ts +97 -57
  92. package/templates/api/src/notification/api.ts +1 -1
  93. package/templates/api/src/notification/manifest.yaml +1 -0
  94. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  95. package/templates/api/src/notification/schema.yaml +7 -0
  96. package/templates/api/src/notification/seed.sql +5 -4
  97. package/templates/api/src/personal-center/profile/api.ts +63 -0
  98. package/templates/api/src/personal-center/upload/api.ts +1 -1
  99. package/templates/api/src/system/login-methods/api.ts +1 -1
  100. package/templates/api/src/system/oplog/api.ts +3 -3
  101. package/templates/api/src/system/permissions/api.ts +2 -2
  102. package/templates/api/src/system/roles/api.ts +2 -2
  103. package/templates/api/src/system/roles/bindings/api.ts +2 -2
  104. package/templates/api/src/system/settings/api.ts +2 -2
  105. package/templates/api/src/system/users/api.ts +2 -2
  106. package/templates/api/src/system/users/forbidden/api.ts +2 -2
  107. package/templates/api/src/system/users/password/api.ts +2 -2
  108. package/templates/api/src/system/users/roles/api.ts +2 -2
  109. package/templates/api/src/web/user-info/api.ts +4 -5
  110. package/templates/api/tests/auth-session.spec.ts +1 -1
  111. package/templates/api/tests/auth-throttle.spec.ts +1 -1
  112. package/templates/api/tests/fixture.ts +8 -16
  113. package/templates/api/tests/guard.spec.ts +1 -1
  114. package/templates/api/tests/l1-builder.test.ts +91 -0
  115. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  116. package/templates/api/tests/lifecycle/market-api.spec.ts +2 -4
  117. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  118. package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
  119. package/templates/api/tests/lifecycle/uninstall.spec.ts +29 -89
  120. package/templates/api/tests/query-utils.spec.ts +85 -1
  121. package/templates/api/tests/rbac-audit.spec.ts +1 -1
  122. package/templates/api/tests/rbac-contacts.spec.ts +2 -2
  123. package/templates/api/tests/rbac-guard.spec.ts +1 -1
  124. package/templates/api/tests/rbac-roles.spec.ts +2 -2
  125. package/templates/api/tests/rbac-settings.spec.ts +66 -21
  126. package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
  127. package/templates/api/tests/rbac-users.spec.ts +1 -1
  128. package/templates/api/tsconfig.json +8 -0
  129. package/templates/cdn/README.md +48 -0
  130. package/templates/env.d.ts +2 -0
  131. package/templates/global.d.ts +850 -0
  132. package/templates/init.include.yaml +14 -2
  133. package/templates/logs/task-events.jsonl +0 -0
  134. package/templates/openapi.json +580 -23
  135. package/templates/package.json +2 -1
  136. package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
  137. package/templates/web/src/_platform/__client/api.ts +152 -39
  138. package/templates/web/src/notification/entry.ts +46 -9
  139. package/templates/web/src/notification/locales/en-US.json +19 -0
  140. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  141. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  142. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
  143. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
  144. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
  145. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
  146. package/templates/web/src/oj-market/api/client.ts +5 -105
  147. package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
  148. package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
  149. package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
  150. package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
  151. package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
  152. package/templates/web/src/oj-market/entry.ts +8 -2
  153. package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
  154. package/templates/web/src/oj-market/locales/en-US.json +2 -1
  155. package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
  156. package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
  157. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  158. package/templates/web/src/personal-center/__client/api.ts +25 -1
  159. package/templates/web/src/personal-center/entry.ts +5 -0
  160. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  161. package/templates/web/src/system/entry.ts +2 -1
  162. package/templates/web.config.ts +13 -10
  163. package/vendor/host-versions.json +3 -3
  164. package/templates/api/src/_platform/_shared/service/oplog.ts +0 -47
  165. package/templates/api/src/notifications/api.ts +0 -32
  166. package/templates/api/src/notifications/manifest.yaml +0 -5
  167. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  168. package/templates/web/src/notifications/__client/api.ts +0 -80
  169. /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `ojm market build-index`(T4):扫描 `cdn/*.tgz` → 提取内嵌 manifest → 全量重建两份索引并签名。
3
+ *
4
+ * 为什么不增量合并:索引是**共享可变单文件**,多人发布时读-改-写天然 TOCTOU,且索引签名会被
5
+ * 后发者覆盖。而 `market/api.ts verifyIndexSig` 注释已写明「索引签名只做完整性,信任由 TOFU/
6
+ * 信任库承载」——索引本就不是信任锚,那就把它降级为派生产物:谁都能重建,重建结果只取决于目录内容。
7
+ */
8
+
9
+ import type { IndexedPackage } from "./index-builder";
10
+ import { createHash } from "node:crypto";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import process from "node:process";
15
+
16
+ import { stringify as yamlStringify } from "yaml";
17
+ import { recomputeIndex, signIndex } from "../publish";
18
+ import { resolveCdnLayout } from "./cdn-layout";
19
+ import { buildIndexData } from "./index-builder";
20
+ import { readEmbeddedManifest } from "./scan";
21
+
22
+ export interface BuildIndexOptions {
23
+ projectRoot?: string
24
+ id?: string
25
+ key?: string
26
+ privateKeyPem?: string
27
+ /** 重建后离线自检(verifyCanonical 复核两份索引签名) */
28
+ check?: boolean
29
+ }
30
+
31
+ export interface BuildIndexResult {
32
+ bundlesPath: string
33
+ manifestsPath: string
34
+ bundlesSigPath: string
35
+ manifestsSigPath: string
36
+ /** 入索引的方案数 */
37
+ solutions: number
38
+ /** 因缺内嵌 manifest 等原因被跳过的文件名 */
39
+ skipped: string[]
40
+ checkPassed?: boolean
41
+ }
42
+
43
+ export async function buildIndex(opts: BuildIndexOptions = {}): Promise<BuildIndexResult> {
44
+ const projectRoot = opts.projectRoot ?? process.cwd();
45
+ const layout = resolveCdnLayout(projectRoot);
46
+ if (!fs.existsSync(layout.dir))
47
+ throw new Error(`[ojm] CDN 目录不存在:${layout.dir}(先跑 ojm market publish,或检查 config 的 /cdn 挂载)`);
48
+
49
+ const files = fs.readdirSync(layout.dir).filter(f => f.endsWith(".tgz")).sort();
50
+ const packages: IndexedPackage[] = [];
51
+ const skipped: string[] = [];
52
+ for (const file of files) {
53
+ const abs = path.join(layout.dir, file);
54
+ const bytes = new Uint8Array(fs.readFileSync(abs));
55
+ let manifest: Record<string, unknown> | null = null;
56
+ try {
57
+ manifest = readEmbeddedManifest(bytes);
58
+ }
59
+ catch (error) {
60
+ console.warn(`[ojm] 跳过 ${file}:${(error as Error).message}`);
61
+ }
62
+ const name = typeof manifest?.name === "string" ? manifest.name as string : "";
63
+ const version = typeof manifest?.version === "string" ? manifest.version as string : "";
64
+ if (manifest === null || name === "" || version === "") {
65
+ skipped.push(file);
66
+ console.warn(`[ojm] 跳过 ${file}:包内缺 manifest.yaml 或缺少 name/version`);
67
+ continue;
68
+ }
69
+ packages.push({
70
+ name,
71
+ version,
72
+ file,
73
+ sha256: createHash("sha256").update(bytes).digest("hex"),
74
+ size: bytes.byteLength,
75
+ manifest,
76
+ signature: readSidecarSig(abs),
77
+ });
78
+ }
79
+
80
+ const data = buildIndexData(packages);
81
+ const bundlesText = normalizeYaml(data.bundles);
82
+ const manifestsText = normalizeYaml(data.manifests);
83
+ const bundlesPath = path.join(layout.dir, "bundles.yaml");
84
+ const manifestsPath = path.join(layout.dir, "manifests.yaml");
85
+ fs.writeFileSync(bundlesPath, bundlesText);
86
+ fs.writeFileSync(manifestsPath, manifestsText);
87
+
88
+ // 索引是多人协作产物:格式再 determinism 一次(recomputeIndex 即 sortKeysDeep + 确定性 stringify)
89
+ const signed = signIndexes({ bundlesPath, manifestsPath, id: opts.id, key: opts.key, privateKeyPem: opts.privateKeyPem });
90
+
91
+ console.log(`[ojm] 索引已重建:${data.bundles.solutions.length} 个方案 / ${packages.length} 个版本(来源 ${files.length} 个 tgz)`);
92
+ if (skipped.length > 0)
93
+ console.warn(`[ojm] ${skipped.length} 个包被跳过:${skipped.join(", ")}`);
94
+
95
+ return {
96
+ bundlesPath,
97
+ manifestsPath,
98
+ bundlesSigPath: signed.bundlesSigPath,
99
+ manifestsSigPath: signed.manifestsSigPath,
100
+ solutions: data.bundles.solutions.length,
101
+ skipped,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * 读包级 detached 签名侧车(`<tgz>.sig`),转成 Form B `archive.signature`。
107
+ *
108
+ * 为什么非读不可:安装器 verifying 段只认 Form B 的 archive.signature(同目录侧车不参与判定),
109
+ * 没有它一律 `sig_missing` 硬 fail-closed——索引是「包 + 签名」的聚合视图,少带签名等于把包报成不可装。
110
+ */
111
+ function readSidecarSig(tgzPath: string): Record<string, unknown> | undefined {
112
+ const sigPath = `${tgzPath}.sig`;
113
+ if (!fs.existsSync(sigPath))
114
+ return undefined;
115
+ try {
116
+ const parsed = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as unknown;
117
+ return isObj(parsed) ? parsed as Record<string, unknown> : undefined;
118
+ }
119
+ catch {
120
+ console.warn(`[ojm] ${sigPath} 不是合法 JSON,已按无签名处理(该包会被安装器以 sig_missing 拒装)`);
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ function isObj(v: unknown): v is Record<string, unknown> {
126
+ return v !== null && typeof v === "object" && !Array.isArray(v);
127
+ }
128
+
129
+ /** 确定性 stringify:键序归一 + 无软换行,和既有 publish 的索引口径逐字一致 */
130
+ function normalizeYaml(value: unknown): string {
131
+ return recomputeIndex(yamlStringify(value, { lineWidth: 0 }));
132
+ }
133
+
134
+ function signIndexes(args: { bundlesPath: string, manifestsPath: string, id?: string, key?: string, privateKeyPem?: string }): { bundlesSigPath: string, manifestsSigPath: string } {
135
+ const pem = args.privateKeyPem ?? readPemFrom(args.id, args.key);
136
+ const keyId = args.id ?? "";
137
+ if (keyId === "")
138
+ throw new Error("[ojm] 重建索引须给签名身份:--id <publisher.id>(.sig 的发布方标识;C2-37 命名空间)");
139
+ const bundlesText = fs.readFileSync(args.bundlesPath, "utf-8");
140
+ const manifestsText = fs.readFileSync(args.manifestsPath, "utf-8");
141
+ const bundlesSig = signIndex("bundles", bundlesText, pem, keyId);
142
+ const manifestsSig = signIndex("manifests", manifestsText, pem, keyId);
143
+ const bundlesSigPath = `${args.bundlesPath}.sig`;
144
+ const manifestsSigPath = `${args.manifestsPath}.sig`;
145
+ fs.writeFileSync(bundlesSigPath, `${JSON.stringify(bundlesSig, null, 2)}\n`);
146
+ fs.writeFileSync(manifestsSigPath, `${JSON.stringify(manifestsSig, null, 2)}\n`);
147
+ console.log(`[ojm] 索引签名已写出(发布方 ${keyId})`);
148
+ return { bundlesSigPath, manifestsSigPath };
149
+ }
150
+
151
+ /** 从身份目录或私钥文件读出 PEM(磁盘路径是常规场景;隔离场景应直接传内存 PEM) */
152
+ function readPemFrom(id?: string, key?: string): string {
153
+ const fsKey = key ?? (id === undefined ? undefined : path.join(os.homedir(), ".ojm", "keys", id, "private.pem"));
154
+ if (fsKey === undefined)
155
+ throw new Error("[ojm] 缺少签名身份:--id <publisher.id> 或 --key <pem 路径>");
156
+ if (!fs.existsSync(fsKey))
157
+ throw new Error(`[ojm] 私钥文件不存在:${fsKey}(用 ojm keygen --id ${id} 生成)`);
158
+ return fs.readFileSync(fsKey, "utf-8");
159
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * market CDN 的布局单点:**目录在哪 + 基址是什么**只在这里算(计划 §4.3 单一真相表)。
3
+ *
4
+ * 两条不能破的规矩:
5
+ * 1. **不许硬编码端口/主机/挂载路径**——全部从 `api/config.yaml` 读。
6
+ * dev 形态下 `/cdn` 只在 oj 子进程(`server.port`)上成立,dev server 自己(5174)
7
+ * 会把 `/cdn/*` 交给静态层、未命中就回落 index.html(设计 §3.1.1);写成相对路径或
8
+ * 写死 9778,市场页拿到的会是 HTML 而不是索引 YAML。
9
+ * 2. **基址无尾斜杠**——`market/api.ts` 按 `${base}/bundles.yaml` 拼。
10
+ *
11
+ * 文本版(`resolveCdnLayoutFromConfig`)与工程版(`resolveCdnLayout`)分工:前者纯函数便于单测,
12
+ * 后者负责读盘。取值口径只有前者一份,后者不重算。
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { parse as yamlParse } from "yaml";
18
+
19
+ import { resolveConfigPath } from "../layout";
20
+
21
+ /** 本地模拟 CDN 的挂载前缀(模板 api/config.yaml 随 Jinfirst 发布) */
22
+ export const CDN_MOUNT_PREFIX = "/cdn";
23
+
24
+ export interface CdnLayout {
25
+ /** cdn 目录绝对路径(由挂载的 `web` 相对 config 所在目录解析) */
26
+ dir: string
27
+ /** 挂载前缀,无尾斜杠(如 `/cdn`) */
28
+ prefix: string
29
+ /** 市场基址:`http://<host>:<port><prefix>`,无尾斜杠 */
30
+ baseUrl: string
31
+ }
32
+
33
+ /** 从工程根出发解析(会读 `api/config.yaml`) */
34
+ export function resolveCdnLayout(projectRoot: string): CdnLayout {
35
+ const configPath = resolveConfigPath(projectRoot);
36
+ return resolveCdnLayoutFromConfig(fs.readFileSync(configPath, "utf-8"), configPath);
37
+ }
38
+
39
+ /** 从 config 文本解析(纯函数,不落 IO) */
40
+ export function resolveCdnLayoutFromConfig(configText: string, configPath: string): CdnLayout {
41
+ const doc = isObj(yamlParse(configText)) ? yamlParse(configText) as Record<string, unknown> : {};
42
+ const mount = findCdnMount(mountsOf(doc), configPath);
43
+ const configDir = path.dirname(configPath);
44
+
45
+ const web = mount.web;
46
+ if (typeof web !== "string" || web.trim() === "") {
47
+ throw new Error(
48
+ `[ojm] ${configPath} 的 /cdn 挂载缺少 web 目录(当前是 api 挂载或目录为空)。\n`
49
+ + "下一步:把这一条改成\n"
50
+ + " - prefix: /cdn\n"
51
+ + " web: ../cdn\n",
52
+ );
53
+ }
54
+
55
+ const prefix = normalizePrefix(String(mount.prefix));
56
+ const port = readPort(doc, configPath);
57
+ const host = readHost(doc);
58
+
59
+ return {
60
+ dir: path.resolve(configDir, web),
61
+ prefix,
62
+ baseUrl: `http://${host}:${port}${prefix}`,
63
+ };
64
+ }
65
+
66
+ function isObj(v: unknown): v is Record<string, unknown> {
67
+ return v !== null && typeof v === "object" && !Array.isArray(v);
68
+ }
69
+
70
+ function mountsOf(doc: Record<string, unknown>): unknown[] {
71
+ const mounts = doc.mounts;
72
+ return Array.isArray(mounts) ? mounts : [];
73
+ }
74
+
75
+ /** 前缀归一化:补前导斜杠、去尾斜杠(拼接口径统一) */
76
+ function normalizePrefix(raw: string): string {
77
+ let prefix = raw.trim();
78
+ if (!prefix.startsWith("/"))
79
+ prefix = `/${prefix}`;
80
+ while (prefix.length > 1 && prefix.endsWith("/"))
81
+ prefix = prefix.slice(0, -1);
82
+ return prefix;
83
+ }
84
+
85
+ function findCdnMount(mounts: unknown[], configPath: string): Record<string, unknown> {
86
+ const hit = mounts.find((entry) => {
87
+ if (!isObj(entry) || typeof entry.prefix !== "string")
88
+ return false;
89
+ return normalizePrefix(entry.prefix) === CDN_MOUNT_PREFIX;
90
+ });
91
+ if (hit)
92
+ return hit as Record<string, unknown>;
93
+
94
+ throw new Error(
95
+ `[ojm] ${configPath} 的 mounts 里没有 /cdn 条目——本地模拟 CDN 无处可挂。\n`
96
+ + "出现这条错误说明该工程的 config 被手改过或来自旧模板。\n"
97
+ + "下一步:把下面两行粘到 mounts: 段里(位置在 /api 之后、任何 `/` 之前):\n"
98
+ + " - prefix: /cdn\n"
99
+ + " web: ../cdn\n",
100
+ );
101
+ }
102
+
103
+ /** 端口取自 config,口径与 oj-config.readOjPort 一致(缺失/非法即人称报错) */
104
+ function readPort(doc: Record<string, unknown>, configPath: string): number {
105
+ const server = isObj(doc.server) ? doc.server as Record<string, unknown> : {};
106
+ const raw = server.port;
107
+ const port = typeof raw === "number" || typeof raw === "string" ? Number(raw) : Number.NaN;
108
+ if (!Number.isInteger(port) || port <= 0) {
109
+ throw new Error(
110
+ `[ojm] ${configPath} 缺少合法的 server.port。\n`
111
+ + "该文件由 ojm init 生成,手动改动后请保留端口配置(oj 代码默认 9778)。",
112
+ );
113
+ }
114
+ return port;
115
+ }
116
+
117
+ /** 主机缺省回落到 oj 模板显式钉死的 127.0.0.1(config.yaml 注释解释过为何不用 localhost) */
118
+ function readHost(doc: Record<string, unknown>): string {
119
+ const server = isObj(doc.server) ? doc.server as Record<string, unknown> : {};
120
+ const host = server.host;
121
+ return typeof host === "string" && host.trim() !== "" ? host.trim() : "127.0.0.1";
122
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `ojm market <sub>` 参数面(T3–T5 的命令入口)。
3
+ *
4
+ * 只做参数解析 + 调库函数:所有真逻辑都在同目录的 publish / build-index / setup 里,
5
+ * 这里不掺业务,便于测试直接打函数而不走 argv。
6
+ */
7
+
8
+ import process from "node:process";
9
+
10
+ import { buildIndex } from "./build-index";
11
+ import { publishPackage } from "./publish";
12
+ import { setupMarketUrl } from "./setup";
13
+ import { trialCommand } from "./trial";
14
+ import { verifyMarket } from "./verify";
15
+ import { walkthroughCommand } from "./walkthrough";
16
+
17
+ interface Flags {
18
+ id?: string
19
+ key?: string
20
+ fixture?: string
21
+ real?: string
22
+ url?: string
23
+ user?: string
24
+ pass?: string
25
+ newPass?: string
26
+ rebuildIndex?: boolean
27
+ check?: boolean
28
+ }
29
+
30
+ const VALUE_FLAGS = new Set(["--id", "--key", "--fixture", "--real", "--url", "--user", "--pass", "--new-pass"]);
31
+
32
+ function parseFlags(argv: string[]): Flags {
33
+ const flags: Flags = {};
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const arg = argv[i]!;
36
+ if (arg === "--rebuild-index") {
37
+ flags.rebuildIndex = true;
38
+ continue;
39
+ }
40
+ if (arg === "--check") {
41
+ flags.check = true;
42
+ continue;
43
+ }
44
+ if (VALUE_FLAGS.has(arg)) {
45
+ const value = argv[i + 1] ?? "";
46
+ i++;
47
+ if (arg === "--id")
48
+ flags.id = value;
49
+ else if (arg === "--key")
50
+ flags.key = value;
51
+ else if (arg === "--fixture")
52
+ flags.fixture = value;
53
+ else if (arg === "--real")
54
+ flags.real = value;
55
+ else if (arg === "--url")
56
+ flags.url = value;
57
+ else if (arg === "--user")
58
+ flags.user = value;
59
+ else if (arg === "--pass")
60
+ flags.pass = value;
61
+ else if (arg === "--new-pass")
62
+ flags.newPass = value;
63
+ }
64
+ }
65
+ return flags;
66
+ }
67
+
68
+ export async function marketCommand(sub: string, argv: string[]): Promise<void> {
69
+ const flags = parseFlags(argv);
70
+ switch (sub) {
71
+ case "publish": {
72
+ await publishPackage({
73
+ fixture: flags.fixture,
74
+ real: flags.real,
75
+ id: flags.id,
76
+ key: flags.key,
77
+ check: flags.check,
78
+ });
79
+ if (flags.rebuildIndex === true)
80
+ await buildIndex({ id: flags.id, key: flags.key, check: flags.check });
81
+ return;
82
+ }
83
+ case "verify": {
84
+ const result = verifyMarket({ file: flags.fixture === undefined ? undefined : flags.fixture, rebuildCheck: flags.rebuildIndex });
85
+ for (const check of result.checks)
86
+ console.log(`${check.ok ? "✓" : "✗"} ${check.item}:${check.detail}`);
87
+ if (result.drift !== undefined && result.drift.length > 0) {
88
+ for (const line of result.drift)
89
+ console.log(`✗ 索引漂移:${line}`);
90
+ }
91
+ console.log(result.conclusion);
92
+ if (!result.passed)
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ case "trial": {
97
+ await trialCommand(argv);
98
+ return;
99
+ }
100
+ case "walkthrough": {
101
+ await walkthroughCommand(argv);
102
+ return;
103
+ }
104
+ case "build-index": {
105
+ await buildIndex({ id: flags.id, key: flags.key, check: flags.check });
106
+ return;
107
+ }
108
+ case "setup": {
109
+ const result = await setupMarketUrl({ url: flags.url, user: flags.user, pass: flags.pass, newPass: flags.newPass });
110
+ console.log(`[ojm] 站点 ${result.siteUrl} 的市场地址已指向 ${result.cdnBaseUrl}`);
111
+ return;
112
+ }
113
+ default:
114
+ throw new Error(`[ojm] 未知子命令:ojm market ${sub}\n可用:setup / publish / build-index`);
115
+ }
116
+ }
117
+
118
+ /** `ojm market` 无子命令时的提示(与 usage.ts 同源风格) */
119
+ export function marketUsage(): string {
120
+ return [
121
+ "ojm market setup [--url <站点地址>] [--user <u> --pass <p>] [--new-pass <p>] 把市场地址指向本地 cdn/",
122
+ "ojm market publish [--fixture <name>@<version>] [--real <方案工程>] [--id <key>] [--rebuild-index]",
123
+ "ojm market build-index [--id <key>] [--check] 扫描 cdn/ 重建索引",
124
+ "ojm market verify [--file <tgz>] [--rebuild-index] 离线验包(不起站点、不装)",
125
+ "ojm market trial [--fixture <name>@<version>] [--keep] 隔离站点试装(跑完即毁)",
126
+ "ojm market walkthrough [--only <op>] [--strict] [--keep] 隔离站点跑全套市场操作并出 AC 表",
127
+ ].join("\n");
128
+ }
129
+
130
+ /** 进程入口:从 argv 取子命令(index.ts 负责 dispatch 到此处) */
131
+ export async function runMarket(argv: string[]): Promise<void> {
132
+ const sub = argv[0] ?? "";
133
+ if (sub === "" || sub === "--help" || sub === "-h") {
134
+ console.log(marketUsage());
135
+ return;
136
+ }
137
+ await marketCommand(sub, argv.slice(1));
138
+ void process;
139
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 失败路径人话文案表(T12 收尾,对标 `keygen.ts` 双段式:成因一句 + 下一步一句)。
3
+ *
4
+ * 逐失败码/阶段一条,随 walkthrough 报告落盘(`logs/walkthrough-<ts>.md` 的文案表段落)。
5
+ * traceability:`__tests__/failure-playbook.test.ts` 守着 REQUIRED_CODES——每个 neg 场景
6
+ * 预期命中的失败码都必须有条目,补新 neg op 时同步加行。
7
+ */
8
+
9
+ export interface FailurePlaybookEntry {
10
+ /** 失败码(六码 reason 或阶段级签名) */
11
+ code: string
12
+ /** 预期失败阶段("安装前" 表示 POST 即被拒、任务未创建) */
13
+ stage: string
14
+ /** 成因一句(发生了什么) */
15
+ cause: string
16
+ /** 下一步一句(怎么办) */
17
+ next: string
18
+ }
19
+
20
+ export const FAILURE_PLAYBOOK: FailurePlaybookEntry[] = [
21
+ {
22
+ code: "sig_missing",
23
+ stage: "verifying",
24
+ cause: "分发包未签名:发布方未做 detached 签名,或 CDN 目录里 .sig 被裁剪",
25
+ next: "请发布方重发 `ojm market publish --check` 复核签名后重建索引",
26
+ },
27
+ {
28
+ code: "tampered",
29
+ stage: "verifying",
30
+ cause: "包体或签名与索引不符:分发包被篡改,或同版本号被换包重发",
31
+ next: "从可信源重新获取分发包;发布方应升版本号重发(同版本号只允许一份字节)",
32
+ },
33
+ {
34
+ code: "unknown_key",
35
+ stage: "verifying",
36
+ cause: "发布方 key 不在宿主信任库,安装停在 needs_trust 待确认",
37
+ next: "核对指纹后 `ojm trust confirm --task <task_id>` 入库,再重装即可",
38
+ },
39
+ {
40
+ code: "rollback_guard",
41
+ stage: "migrating",
42
+ cause: "包内迁移最大 seq 低于账本 head:禁降版部署(OJ-9 红线),库保持原状",
43
+ next: "正道是升级到新版本;确需回退请联系运维走对账自愈,勿强行降版",
44
+ },
45
+ {
46
+ code: "index_sig",
47
+ stage: "安装前(索引解析)",
48
+ cause: "市场索引签名校验失败:bundles/manifests.yaml 被篡改或签名与字节不符",
49
+ next: "重建索引并签名 `ojm market build-index`;同时核对 CDN 目录写入来源",
50
+ },
51
+ {
52
+ code: "zip_slip",
53
+ stage: "安装前(上传校验)",
54
+ cause: "分发包含越界成员(../ 或绝对路径),R1 zip-slip 防护拒在解包前",
55
+ next: "勿安装来路不明的包;发布方检查打包脚本,成员名必须是包内相对路径",
56
+ },
57
+ ];
@@ -0,0 +1,138 @@
1
+ /**
2
+ * fixture 方案包合成(T3):生成一棵**能被安装器真正装下**的最小合法方案树。
3
+ *
4
+ * 形状取自 `scripts/verify-solution-smoke/make-solution.mjs`——那套字段组合已被真链路
5
+ * (oj serve 安装 + ping 端点)验证过,这里照抄布局而不是另发明一套(DRY)。
6
+ * 打包交给 `createDeterministicTgz`(同输入字节一致,不受 mtime/uid 影响)。
7
+ */
8
+
9
+ import { Buffer } from "node:buffer";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { gzipSync } from "node:zlib";
13
+ import { stringify as yamlStringify } from "yaml";
14
+
15
+ export interface FixturePackage {
16
+ name: string
17
+ version: string
18
+ /** 方案树根目录:<staging>/<name>-<version> */
19
+ dir: string
20
+ manifestPath: string
21
+ }
22
+
23
+ export const DEFAULT_FIXTURE_NAME = "demo-solution";
24
+ export const DEFAULT_FIXTURE_VERSION = "0.1.0";
25
+
26
+ /** 解析 `--fixture <name>@<version>`;缺省/缺段用默认值补齐 */
27
+ export function parseFixtureSpec(spec?: string): { name: string, version: string } {
28
+ if (spec === undefined || spec.trim() === "" || spec === "latest")
29
+ return { name: DEFAULT_FIXTURE_NAME, version: DEFAULT_FIXTURE_VERSION };
30
+ const at = spec.lastIndexOf("@");
31
+ if (at <= 0)
32
+ return { name: spec.trim(), version: DEFAULT_FIXTURE_VERSION };
33
+ const name = spec.slice(0, at).trim();
34
+ const version = spec.slice(at + 1).trim();
35
+ return { name: name === "" ? DEFAULT_FIXTURE_NAME : name, version: version === "" ? DEFAULT_FIXTURE_VERSION : version };
36
+ }
37
+
38
+ export function buildFixturePackage(opts: { stagingDir: string, name?: string, version?: string }): FixturePackage {
39
+ const name = opts.name ?? DEFAULT_FIXTURE_NAME;
40
+ const version = opts.version ?? DEFAULT_FIXTURE_VERSION;
41
+ const dir = path.join(opts.stagingDir, `${name}-${version}`);
42
+ const mod = path.join(dir, "api", `${name}-${version}`);
43
+ const webDir = path.join(dir, "web", "modules", name, version);
44
+ fs.mkdirSync(path.join(mod, "migrations"), { recursive: true });
45
+ fs.mkdirSync(path.join(mod, "ping"), { recursive: true });
46
+ fs.mkdirSync(webDir, { recursive: true });
47
+
48
+ fs.writeFileSync(path.join(dir, "manifest.yaml"), yamlStringify(solutionManifest(name, version)));
49
+ // oj 模块清单:oj 侧对齐 schema/迁移归属
50
+ fs.writeFileSync(path.join(mod, "manifest.yaml"), `name: ${name}\ndesc: ${name} ${version} 模块\nversion: ${version}\ntables:\n - ${tableOf(name)}\n`);
51
+ fs.writeFileSync(path.join(mod, "routes.js"), `export default [{ method: "get", pattern: "${name}/ping", file: "ping/api.js" }];\n`);
52
+ // ping 处理器必须调 oj 运行时的全局 json.ok:直接 return 对象会被丢掉,装完看不到健康探针响应
53
+ fs.writeFileSync(path.join(mod, "ping", "api.js"), `export default { async get() { json.ok({ pong: true, module: "${name}", version: "${version}" }); } };\n`);
54
+ fs.writeFileSync(path.join(mod, "migrations", "0001__init.sql"), `CREATE TABLE ${tableOf(name)} (id INTEGER PRIMARY KEY, title TEXT NOT NULL);\n`);
55
+ // 迁移阶梯(T12):minor ≥ 2 多一段 ALTER——upgrade 真的多走一段迁移,rollback 才有
56
+ // 「包内最大 seq < 账本 head」可拦(OJ-9 宿主层预锁拦截的物理前提)。确定性:内容只依赖 name。
57
+ if (minorOf(version) >= 2)
58
+ fs.writeFileSync(path.join(mod, "migrations", "0002__more.sql"), `ALTER TABLE ${tableOf(name)} ADD COLUMN body TEXT;\n`);
59
+ fs.writeFileSync(path.join(webDir, "entry.js"), `console.log("${name} ${version}");\n`);
60
+ fs.writeFileSync(path.join(webDir, "main.css"), `.${cssClassOf(name, version)} { color: red; }\n`);
61
+
62
+ return { name, version, dir, manifestPath: path.join(dir, "manifest.yaml") };
63
+ }
64
+
65
+ /** 表名:只允许标识符字符(fixture 名可能带连字符) */
66
+ function tableOf(name: string): string {
67
+ return `${name.replace(/\W/g, "_")}_note`;
68
+ }
69
+
70
+ /** minor 段取数(迁移阶梯判定用;非 semver 形态一律按 0 处理) */
71
+ function minorOf(version: string): number {
72
+ const m = /^\d+\.(\d+)/.exec(version);
73
+ return m === null ? 0 : Number(m[1]);
74
+ }
75
+
76
+ /**
77
+ * 手造 zip-slip 恶意 tgz(成员 `../evil.sql`)——neg-evil op 的上传载荷。
78
+ * 为什么不用 createDeterministicTgz:它只打包真实目录树,文件系统里造不出 `../` 成员;
79
+ * ustar 头是纯字节布局,手写 40 行可控成员名。确定性:mtime 置 0,同输入恒同字节。
80
+ */
81
+ export function buildEvilZipSlipTgz(outPath: string): string {
82
+ const chunks: Buffer[] = [];
83
+ for (const [name, content] of [
84
+ ["../evil.sql", "DROP TABLE users;"],
85
+ ["manifest.yaml", "name: evil-solution\nversion: 9.9.9\n"],
86
+ ] as Array<[string, string]>) {
87
+ const header = Buffer.alloc(512);
88
+ header.write(name, 0, "utf-8"); // name[100]
89
+ header.write("0000644\0", 100); // mode
90
+ header.write("0000000\0", 108); // uid
91
+ header.write("0000000\0", 116); // gid
92
+ header.write(`${content.length.toString(8).padStart(11, "0")}\0`, 124); // size[12]
93
+ header.write("00000000000\0", 136); // mtime[12] 置 0(确定性)
94
+ header.fill(0x20, 148, 156); // chksum 先填空格
95
+ header.write("0", 156); // typeflag:普通文件
96
+ header.write("ustar\0", 257); // magic
97
+ header.write("00", 263); // version
98
+ let sum = 0;
99
+ for (const b of header)
100
+ sum += b;
101
+ header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148); // chksum[8]
102
+ chunks.push(header, Buffer.from(content));
103
+ const pad = Math.ceil(content.length / 512) * 512 - content.length;
104
+ if (pad > 0)
105
+ chunks.push(Buffer.alloc(pad));
106
+ }
107
+ chunks.push(Buffer.alloc(1024)); // 尾零块
108
+ fs.writeFileSync(outPath, gzipSync(Buffer.concat(chunks)));
109
+ return outPath;
110
+ }
111
+
112
+ function cssClassOf(name: string, version: string): string {
113
+ return `${name}-v${version.replace(/[^0-9A-Z]/gi, "-")}`;
114
+ }
115
+
116
+ /** 方案清单(Form A:archive 只写 file,sha256/size 由索引侧填 Form B) */
117
+ function solutionManifest(name: string, version: string): Record<string, unknown> {
118
+ return {
119
+ name,
120
+ version,
121
+ title: `${name} fixture`,
122
+ summary: `本地模拟 CDN 的 fixture 方案(${name} ${version})`,
123
+ author: "ojm-market",
124
+ license: "MIT",
125
+ changelog: [`${version} fixture 初始版本`],
126
+ peerRuntime: ">=0.0.0",
127
+ health: `/${name}/ping`,
128
+ archive: { file: `${name}-${version}.tgz` },
129
+ api: { module: name, prefix: `/api/${name}`, tables: [tableOf(name)], anonymous: [`${name}/ping`] },
130
+ web: { entry: `modules/${name}/${version}/entry.js`, css: [`modules/${name}/${version}/main.css`] },
131
+ routes: [
132
+ { path: `/${name}`, category: "main", order: 200, title_key: `${name}:menu.main` },
133
+ { path: `/${name}/ping`, category: "hidden", order: 205, title_key: `${name}:menu.ping` },
134
+ ],
135
+ permissions: [{ code: `${name}.view`, title_key: `${name}:perm.view` }],
136
+ roles: [{ name: `${name}.admin`, title_key: `${name}:role.admin`, permissions: [`${name}.view`] }],
137
+ };
138
+ }