@oj-module/cli 0.2.8 → 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 (98) hide show
  1. package/devkit/README.md +70 -24
  2. package/devkit/framework-dev.md +14 -5
  3. package/devkit/manual.md +9 -6
  4. package/devkit/module-dev-and-cdn-playground.md +197 -0
  5. package/devkit/sql-builder-missing-capabilities-pr.md +38 -4
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +130 -45
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/build-backend.test.ts +64 -0
  11. package/src/__tests__/versions.test.ts +26 -0
  12. package/src/build.ts +9 -0
  13. package/src/bundle.ts +8 -5
  14. package/src/contract/__tests__/market-client.integration.test.ts +13 -10
  15. package/src/index.ts +6 -0
  16. package/src/init.ts +22 -0
  17. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  18. package/src/market/__tests__/bootstrap.test.ts +204 -0
  19. package/src/market/__tests__/build-index.test.ts +154 -0
  20. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  21. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  22. package/src/market/__tests__/fixture.test.ts +48 -0
  23. package/src/market/__tests__/publish.test.ts +109 -0
  24. package/src/market/__tests__/report.test.ts +51 -0
  25. package/src/market/__tests__/setup.test.ts +114 -0
  26. package/src/market/__tests__/verify.test.ts +111 -0
  27. package/src/market/bootstrap.ts +255 -0
  28. package/src/market/build-index.ts +159 -0
  29. package/src/market/cdn-layout.ts +122 -0
  30. package/src/market/command.ts +139 -0
  31. package/src/market/failure-playbook.ts +57 -0
  32. package/src/market/fixture.ts +138 -0
  33. package/src/market/index-builder.ts +76 -0
  34. package/src/market/publish.ts +156 -0
  35. package/src/market/report.ts +67 -0
  36. package/src/market/scan.ts +99 -0
  37. package/src/market/setup.ts +144 -0
  38. package/src/market/trial.ts +121 -0
  39. package/src/market/verify.ts +298 -0
  40. package/src/market/walkthrough.ts +506 -0
  41. package/src/oj.ts +3 -1
  42. package/src/preview-config.ts +14 -0
  43. package/src/preview.ts +4 -3
  44. package/src/sign.ts +21 -6
  45. package/src/usage.ts +19 -0
  46. package/src/vendor.ts +4 -2
  47. package/src/versions.ts +28 -0
  48. package/templates/api/.ojm-api-exempt.json +6 -2
  49. package/templates/api/config.yaml +20 -0
  50. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  51. package/templates/api/src/_platform/_lifecycle/uninstall.ts +21 -53
  52. package/templates/api/src/_platform/_shared/service/domain/contacts.ts +15 -10
  53. package/templates/api/src/_platform/_shared/service/domain/solution-perms.ts +8 -6
  54. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +16 -23
  55. package/templates/api/src/_platform/_shared/service/infra/query.ts +18 -0
  56. package/templates/api/src/_platform/_shared/service/infra/rate.ts +4 -1
  57. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  58. package/templates/api/src/_platform/manifest.yaml +1 -0
  59. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  60. package/templates/api/src/_platform/schema.yaml +12 -0
  61. package/templates/api/src/auth/_shared/session.ts +2 -2
  62. package/templates/api/src/demo/todos/api.ts +8 -9
  63. package/templates/api/src/notification/_shared/notify.ts +64 -26
  64. package/templates/api/src/notification/api.ts +1 -1
  65. package/templates/api/src/notification/manifest.yaml +1 -0
  66. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  67. package/templates/api/src/notification/schema.yaml +7 -0
  68. package/templates/api/src/notification/seed.sql +5 -4
  69. package/templates/api/src/personal-center/profile/api.ts +63 -0
  70. package/templates/api/src/web/user-info/api.ts +3 -1
  71. package/templates/api/tests/fixture.ts +8 -16
  72. package/templates/api/tests/l1-builder.test.ts +91 -0
  73. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  74. package/templates/api/tests/lifecycle/market-api.spec.ts +1 -3
  75. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  76. package/templates/api/tests/lifecycle/uninstall.spec.ts +28 -88
  77. package/templates/api/tests/query-utils.spec.ts +85 -1
  78. package/templates/api/tests/rbac-settings.spec.ts +64 -19
  79. package/templates/cdn/README.md +48 -0
  80. package/templates/global.d.ts +36 -2
  81. package/templates/init.include.yaml +3 -2
  82. package/templates/openapi.json +55 -10
  83. package/templates/web/src/notification/entry.ts +46 -9
  84. package/templates/web/src/notification/locales/en-US.json +19 -0
  85. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  86. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  87. package/templates/web/src/oj-market/entry.ts +8 -2
  88. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  89. package/templates/web/src/personal-center/__client/api.ts +25 -1
  90. package/templates/web/src/personal-center/entry.ts +5 -0
  91. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  92. package/templates/web/src/system/entry.ts +2 -1
  93. package/templates/web.config.ts +10 -7
  94. package/vendor/host-versions.json +3 -3
  95. package/templates/api/src/notifications/api.ts +0 -29
  96. package/templates/api/src/notifications/manifest.yaml +0 -5
  97. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  98. package/templates/web/src/notifications/__client/api.ts +0 -80
@@ -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
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Form B 索引聚合(T4):把「一批已扫出的包」变成 bundles.yaml / manifests.yaml 的数据形状。
3
+ *
4
+ * 纯数据变换,不落 IO、不签名——IO 与签名归 `build-index.ts`。形状严格对齐安装侧的消费口径
5
+ * (`market/api.ts resolveCdn`):`file` 必须逐字 `<name>-<version>.tgz`,否则安装侧 P2-11 会
6
+ * 判索引投毒直接 502。
7
+ */
8
+
9
+ export interface IndexedPackage {
10
+ name: string
11
+ version: string
12
+ /** tgz 文件名(由 name+version 派生,逐字约束在此处一次成型) */
13
+ file: string
14
+ sha256: string
15
+ size: number
16
+ /** 包内 manifest(Form A),入 manifests.yaml 时补成 Form B */
17
+ manifest: Record<string, unknown>
18
+ /** 包级 detached 签名(`<tgz>.sig` 五字段);缺则 Form B 不带 signature,安装侧按 sig_missing 拒装 */
19
+ signature?: Record<string, unknown>
20
+ }
21
+
22
+ export interface BuiltIndex {
23
+ bundles: { solutions: Array<Record<string, unknown>> }
24
+ manifests: Array<Record<string, unknown>>
25
+ }
26
+
27
+ export function buildIndexData(packages: IndexedPackage[]): BuiltIndex {
28
+ const byName = new Map<string, IndexedPackage[]>();
29
+ for (const pkg of packages) {
30
+ const list = byName.get(pkg.name) ?? [];
31
+ list.push(pkg);
32
+ byName.set(pkg.name, list);
33
+ }
34
+
35
+ const solutions: Array<Record<string, unknown>> = [];
36
+ const manifests: Array<Record<string, unknown>> = [];
37
+ for (const name of [...byName.keys()].sort()) {
38
+ const list = [...byName.get(name)!].sort((a, b) => compareVersion(a.version, b.version));
39
+ solutions.push({
40
+ name,
41
+ latest: list[list.length - 1]!.version,
42
+ versions: list.map(v => ({ file: `${v.name}-${v.version}.tgz`, sha256: v.sha256, size: v.size, version: v.version })),
43
+ });
44
+ for (const pkg of list) {
45
+ const file = `${name}-${pkg.version}.tgz`;
46
+ manifests.push({
47
+ ...pkg.manifest,
48
+ name,
49
+ version: pkg.version,
50
+ archive: {
51
+ file,
52
+ sha256: pkg.sha256,
53
+ size: pkg.size,
54
+ ...(pkg.signature === undefined ? {} : { signature: pkg.signature }),
55
+ },
56
+ });
57
+ }
58
+ }
59
+ return { bundles: { solutions }, manifests };
60
+ }
61
+
62
+ /** semver 数值比较(够用的子集:只比较 `x.y.z` 的数值段,非数字段按 0 计) */
63
+ export function compareVersion(a: string, b: string): number {
64
+ const pa = numeric(a);
65
+ const pb = numeric(b);
66
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
67
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
68
+ if (diff !== 0)
69
+ return diff;
70
+ }
71
+ return 0;
72
+ }
73
+
74
+ function numeric(version: string): number[] {
75
+ return version.replace(/^v/, "").split(".").map(part => Number.parseInt(part, 10) || 0);
76
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `ojm market publish`(T3):把方案包落进 CDN 目录。
3
+ *
4
+ * 职责收窄到「落包 + 包级签名」(D-1)——**不碰索引**,索引由 `build-index` 扫描重建。
5
+ * 由此多人可并发发布:每人只写 `<name>-<version>.tgz`(名字含名+版本,天然不撞),
6
+ * 没有共享可变索引文件,也就没有 TOCTOU。
7
+ *
8
+ * 签名原语一行都不重写:11 复用 `ojm sign` 的 `signCommand`(规范串 `<name>@<version>:<sha256>`,
9
+ * detached `.tgz.sig` 五字段,与 Form B archive.signature 同源同构)。
10
+ */
11
+
12
+ import { createHash } from "node:crypto";
13
+ import fs, { mkdtempSync, rmSync } from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import process from "node:process";
17
+
18
+ import { buildBundle } from "../bundle";
19
+ import { createDeterministicTgz } from "../deterministic-tar";
20
+ import { signCommand } from "../sign";
21
+ import { resolveCdnLayout } from "./cdn-layout";
22
+ import { buildFixturePackage, parseFixtureSpec } from "./fixture";
23
+
24
+ export interface MarketPublishOptions {
25
+ /** 站点工程根(含 api/config.yaml 与 cdn/);缺省 process.cwd() */
26
+ projectRoot?: string
27
+ /** `--fixture <name>@<version>`;缺省 demo-solution@0.1.0 */
28
+ fixture?: string
29
+ /** `--real <方案工程根>`:走 ojm build --bundle 真构建(替代 fixture) */
30
+ real?: string
31
+ /** 发布方 key_id(C2-37) */
32
+ id?: string
33
+ /** 私钥 PEM 文件路径(与 privateKeyPem 二选一) */
34
+ key?: string
35
+ /** 内存私钥 PEM(一次性/隔离场景,不落盘) */
36
+ privateKeyPem?: string
37
+ /** 落包后离线自检(verifyCanonical 复核包级签名) */
38
+ check?: boolean
39
+ }
40
+
41
+ export interface MarketPublishResult {
42
+ name: string
43
+ version: string
44
+ tgzPath: string
45
+ sigPath: string
46
+ sha256: string
47
+ size: number
48
+ canonical: string
49
+ keyId: string
50
+ publicKeyHex: string
51
+ fingerprint: string
52
+ }
53
+
54
+ export async function publishPackage(opts: MarketPublishOptions = {}): Promise<MarketPublishResult> {
55
+ const projectRoot = opts.projectRoot ?? process.cwd();
56
+ const layout = resolveCdnLayout(projectRoot);
57
+ if (!fs.existsSync(layout.dir)) {
58
+ fs.mkdirSync(layout.dir, { recursive: true });
59
+ console.log(`[ojm] 已建立 CDN 目录 ${path.relative(projectRoot, layout.dir)}`);
60
+ }
61
+
62
+ const staging = mkdtempSync(path.join(os.tmpdir(), "ojm-market-publish-"));
63
+ try {
64
+ const { name, version, tgzPath, sha256, preExisted } = opts.real !== undefined
65
+ ? await buildRealPackage(opts.real, staging, layout.dir)
66
+ : await buildFixture({ fixture: opts.fixture, staging, cdnDir: layout.dir });
67
+
68
+ // 原子性:tgz 已落盘而后续环节失败时,新包必须捡回来,不留半成品
69
+ const rollbackPackage = () => {
70
+ if (!preExisted)
71
+ rmSync(tgzPath, { force: true });
72
+ };
73
+
74
+ let signed: ReturnType<typeof signCommand>;
75
+ try {
76
+ signed = signCommand(tgzPath, { id: opts.id, key: opts.key, privateKeyPem: opts.privateKeyPem, check: opts.check });
77
+ }
78
+ catch (error) {
79
+ rollbackPackage();
80
+ throw error;
81
+ }
82
+ if (signed.checkPassed === false) {
83
+ rollbackPackage();
84
+ throw new Error(`[ojm] 包级签名自检失败,已移除未签通的包:${tgzPath}`);
85
+ }
86
+ const size = fs.statSync(tgzPath).size;
87
+ console.log(`[ojm] 包已落 CDN → ${path.relative(projectRoot, tgzPath)}(${size} 字节)`);
88
+ console.log(`[ojm] 发布方 ${signed.keyId};指纹 ${signed.fingerprint}`);
89
+ console.log("[ojm] 索引未重建:多人场景请由维护者跑 ojm market build-index");
90
+
91
+ return {
92
+ name,
93
+ version,
94
+ tgzPath,
95
+ sigPath: signed.sigPath,
96
+ sha256,
97
+ size,
98
+ canonical: signed.canonical,
99
+ keyId: signed.keyId,
100
+ publicKeyHex: signed.publicKeyHex,
101
+ fingerprint: signed.fingerprint,
102
+ };
103
+ }
104
+ finally {
105
+ rmSync(staging, { recursive: true, force: true });
106
+ }
107
+ }
108
+
109
+ /** fixture 路径:合成 → 确定性打包 → 与既有同名版本比对 */
110
+ async function buildFixture(args: { fixture?: string, staging: string, cdnDir: string }): Promise<{ name: string, version: string, tgzPath: string, sha256: string, preExisted: boolean }> {
111
+ const spec = parseFixtureSpec(args.fixture);
112
+ const pkg = buildFixturePackage({ stagingDir: args.staging, name: spec.name, version: spec.version });
113
+ const tgzPath = path.join(args.cdnDir, `${spec.name}-${spec.version}.tgz`);
114
+ const before = captureExisting(tgzPath);
115
+
116
+ const sha256 = await createDeterministicTgz(pkg.dir, tgzPath);
117
+ assertNoByteConflict(tgzPath, before, spec);
118
+ return { name: spec.name, version: spec.version, tgzPath, sha256, preExisted: before.exists };
119
+ }
120
+
121
+ /** `--real`:走既有 ojm build --bundle,产物搬到 CDN 目录 */
122
+ async function buildRealPackage(solutionRoot: string, staging: string, cdnDir: string): Promise<{ name: string, version: string, tgzPath: string, sha256: string, preExisted: boolean }> {
123
+ const built = await buildBundle(solutionRoot, { out: staging });
124
+ const tgzPath = path.join(cdnDir, `${built.name}-${built.version}.tgz`);
125
+ const before = captureExisting(tgzPath);
126
+
127
+ fs.copyFileSync(built.tgz, tgzPath);
128
+ assertNoByteConflict(tgzPath, before, { name: built.name, version: built.version });
129
+ return { name: built.name, version: built.version, tgzPath, sha256: built.sha256, preExisted: before.exists };
130
+ }
131
+
132
+ /** 落包前先记下同名文件的指纹(可能不存在) */
133
+ function captureExisting(tgzPath: string): { exists: boolean, sha256: string } {
134
+ if (!fs.existsSync(tgzPath))
135
+ return { exists: false, sha256: "" };
136
+ const bytes = new Uint8Array(fs.readFileSync(tgzPath));
137
+ return { exists: true, sha256: createHash("sha256").update(bytes).digest("hex") };
138
+ }
139
+
140
+ /**
141
+ * 同 name@version 重发:字节一致即幂等;字节不一致是真正的冲突——
142
+ * 同一个版本号出现两份不同的包会毁掉内容寻址前提,必须升 version 重发。
143
+ */
144
+ function assertNoByteConflict(tgzPath: string, before: { exists: boolean, sha256: string }, spec: { name: string, version: string }): void {
145
+ if (!before.exists)
146
+ return;
147
+ const bytes = new Uint8Array(fs.readFileSync(tgzPath));
148
+ const after = createHash("sha256").update(bytes).digest("hex");
149
+ if (after === before.sha256)
150
+ return;
151
+ throw new Error(
152
+ `[ojm] 包冲突:${spec.name}@${spec.version} 已存在且字节不一致(${before.sha256.slice(0, 12)} vs ${after.slice(0, 12)})。\n`
153
+ + "下一步:内容有变更请升 version 后重发(同一版本号只允许一份字节);"
154
+ + "确需覆盖请先删除 CDN 目录里的旧 tgz。",
155
+ );
156
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * walkthrough 报告落盘 + 白黑名单脱敏(T12 收尾,设计 §3.6.3)。
3
+ *
4
+ * 白名单(可落盘):key_id、公钥、指纹、task_id、AC 表、阶段结果、sha256——报告常规内容。
5
+ * 黑名单(禁落盘,脱敏兜底):Bearer token、JWT、私钥 PEM、jwt_secret 值——正常路径本不该
6
+ * 进报告(凭据只在内存),此处是第二道防线:即便某条 detail 意外带了敏感串,落盘前也被掩码。
7
+ */
8
+
9
+ import type { FailurePlaybookEntry } from "./failure-playbook";
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
+ import path from "node:path";
12
+ import process from "node:process";
13
+
14
+ /** 黑名单模式(g 旗标,replace 全局掩码) */
15
+ const BLACKLIST: Array<{ re: RegExp, mask: string }> = [
16
+ { re: /Bearer\s+[\w.~-]+/g, mask: "Bearer ***" },
17
+ { re: /eyJ[\w-]+\.[\w-]+\.[\w-]+/g, mask: "***" }, // JWT 三段形态
18
+ { re: /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, mask: "[redacted PEM]" },
19
+ { re: /(jwt_secret\s*[:=]\s*)\S+/g, mask: "$1***" },
20
+ ];
21
+
22
+ /** 报告文本过黑名单(幂等:已掩码文本二次过检不再变化) */
23
+ export function sanitizeReportText(text: string): string {
24
+ let out = text;
25
+ for (const { re, mask } of BLACKLIST)
26
+ out = out.replace(re, mask);
27
+ return out;
28
+ }
29
+
30
+ export interface WalkthroughReportRow {
31
+ key: string
32
+ title: string
33
+ ok: boolean
34
+ detail: string
35
+ }
36
+
37
+ /** 渲染 AC 表 + 汇总行 + 失败文案表,脱敏后落 `logs/walkthrough-<ts>.md`;返回落盘路径 */
38
+ export function writeWalkthroughReport(opts: { rows: WalkthroughReportRow[], port: number, playbook: FailurePlaybookEntry[], logDir?: string }): string {
39
+ const dir = opts.logDir ?? path.join(process.cwd(), "logs");
40
+ mkdirSync(dir, { recursive: true });
41
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
42
+ const file = path.join(dir, `walkthrough-${ts}.md`);
43
+ const pass = opts.rows.filter(r => r.ok).length;
44
+ const failed = opts.rows.length - pass;
45
+ const lines = [
46
+ "# ojm market walkthrough 报告",
47
+ "",
48
+ `- 时间:${new Date().toISOString()}`,
49
+ `- 隔离站点端口:${opts.port}`,
50
+ `- 汇总:${opts.rows.length} 项 —— ${pass} PASS / ${failed} FAIL`,
51
+ "",
52
+ "## AC 表",
53
+ "",
54
+ "| op | 结果 | 说明 |",
55
+ "|---|---|---|",
56
+ ...opts.rows.map(r => `| ${r.key} | ${r.ok ? "PASS" : "FAIL"} | ${r.ok ? r.title : `${r.title}——${r.detail}`} |`),
57
+ "",
58
+ "## 失败路径人话文案表",
59
+ "",
60
+ "| 失败码 | 阶段 | 成因 | 下一步 |",
61
+ "|---|---|---|---|",
62
+ ...opts.playbook.map(e => `| ${e.code} | ${e.stage} | ${e.cause} | ${e.next} |`),
63
+ "",
64
+ ];
65
+ writeFileSync(file, sanitizeReportText(lines.join("\n")));
66
+ return file;
67
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * tgz 读端(T4):gunzip + 手写 ustar 解析,只为了从包里取出内嵌 `manifest.yaml`。
3
+ *
4
+ * 为什么手写而不用第三方 tar 库:写端 `deterministic-tar.ts` 就是手写 ustar(注释给了理由——
5
+ * 跨平台 tar 方言会毁掉确定性),读端与它对称才可预期;且这里只需要「按路径取一个成员」,
6
+ * 引一个通用 tar 库属于杀鸡用牛刀。相比「另存一份 manifest 到包外」的做法,
7
+ * 包内 manifest 是单一真相——不存在旁路文件与包内容漂移的可能。
8
+ */
9
+
10
+ import { Buffer } from "node:buffer";
11
+ import { gunzipSync } from "node:zlib";
12
+ import { parse as yamlParse } from "yaml";
13
+
14
+ const BLOCK = 512;
15
+
16
+ export interface ScannedEntry {
17
+ name: string
18
+ size: number
19
+ content: Buffer
20
+ }
21
+
22
+ /** 把 .tgz 解开为可读成员列表(无视 EOF 末尾的零块与补齐 bytes) */
23
+ export function readTgz(bytes: Uint8Array): ScannedEntry[] {
24
+ let raw: Buffer;
25
+ try {
26
+ raw = gunzipSync(Buffer.from(bytes));
27
+ }
28
+ catch (error) {
29
+ throw new Error(`不是合法的 gzip 包:${(error as Error).message}`);
30
+ }
31
+ const out: ScannedEntry[] = [];
32
+ let offset = 0;
33
+ while (offset + BLOCK <= raw.length) {
34
+ const header = raw.subarray(offset, offset + BLOCK);
35
+ if (isZeroBlock(header))
36
+ break;
37
+ const size = readOctal(header, 124, 12);
38
+ const typeflag = String.fromCharCode(header[156]!);
39
+ const name = entryName(header);
40
+ offset += BLOCK;
41
+ if (typeflag === "0" || typeflag === "\0") {
42
+ const content = raw.subarray(offset, offset + size);
43
+ out.push({ name, size, content });
44
+ }
45
+ else if (typeflag !== "5") {
46
+ // 其余类型(链接/设备等)不该出现在本地 CDN 包里;跳过即可,不因此拒绝整个包
47
+ }
48
+ const pad = (BLOCK - (size % BLOCK)) % BLOCK;
49
+ offset += size + pad;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * 取包内 manifest.yaml;找不到返回 null(调用方决定跳过还是报错)。
56
+ *
57
+ * 两种布局都认:直接以方案目录为根打的包(`manifest.yaml`)与外层再套一层
58
+ * `<name>-<version>/` 的包——写端 `createDeterministicTgz` 的成员路径是相对传入目录的,
59
+ * 传方案目录即前者;手 tar 打包常是后者。只认顶层那一份,不认 api 子目录下的其他 manifest。
60
+ */
61
+ export function readEmbeddedManifest(bytes: Uint8Array): Record<string, unknown> | null {
62
+ const hit = readTgz(bytes).find(e => /^(?:[^/]+\/)?manifest\.yaml$/.test(e.name));
63
+ if (hit === undefined)
64
+ return null;
65
+ // manifest 是完整 YAML(含 api/web/routes 嵌套结构),必须走真解析器:
66
+ // 它是索引的唯一真相源,解析不准就等于给下游喂假索引。
67
+ return (yamlParse(hit.content.toString("utf-8")) ?? null) as Record<string, unknown> | null;
68
+ }
69
+
70
+ function isZeroBlock(block: Buffer): boolean {
71
+ for (const byte of block) {
72
+ if (byte !== 0)
73
+ return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ /** name(100) + prefix(155) 复原完整路径(与写端 splitUstarName 对称) */
79
+ function entryName(header: Buffer): string {
80
+ const name = cstring(header, 0, 100);
81
+ const prefix = cstring(header, 345, 155);
82
+ if (prefix === "")
83
+ return name;
84
+ return `${prefix}/${name}`;
85
+ }
86
+
87
+ function cstring(buf: Buffer, offset: number, length: number): string {
88
+ const slice = buf.subarray(offset, offset + length);
89
+ const end = slice.indexOf(0);
90
+ return slice.subarray(0, end === -1 ? length : end).toString("utf-8");
91
+ }
92
+
93
+ function readOctal(buf: Buffer, offset: number, length: number): number {
94
+ const text = cstring(buf, offset, length).trim();
95
+ if (text === "")
96
+ return 0;
97
+ const value = Number.parseInt(text, 8);
98
+ return Number.isNaN(value) ? 0 : value;
99
+ }