@oj-module/cli 0.2.69 → 0.2.71

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 (147) hide show
  1. package/devkit/README.md +10 -8
  2. package/devkit/framework-dev.md +1 -1
  3. package/devkit/manual.md +45 -19
  4. package/devkit/module-dev-and-cdn-playground.md +20 -14
  5. package/devkit/publish-walkthrough.md +132 -43
  6. package/devkit/wecom-customer-reauth-guide.md +110 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/{runtime.zrv2zhj9j0.js → runtime.7x99hmm020.js} +2 -2
  9. package/shell-dist/bundles/oj-market-0.2.0.tgz +0 -0
  10. package/shell-dist/bundles/oj-market-0.2.0.tgz.sig +8 -0
  11. package/shell-dist/index.html +4 -4
  12. package/shell-dist/versions.json +3 -3
  13. package/src/__tests__/list.test.ts +7 -7
  14. package/src/__tests__/oj-health-host.test.ts +19 -0
  15. package/src/__tests__/site-tenant-header.test.ts +2 -2
  16. package/src/__tests__/templates-builtin-list.test.ts +47 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +11 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +2 -3
  19. package/src/__tests__/users-roles-display.test.ts +84 -0
  20. package/src/bundle.ts +1 -1
  21. package/src/contract/__tests__/market-client.integration.test.ts +23 -22
  22. package/src/enable.ts +2 -2
  23. package/src/init.ts +45 -0
  24. package/src/install.ts +5 -5
  25. package/src/lifecycle/__tests__/install.test.ts +29 -29
  26. package/src/lifecycle/__tests__/market-bundle.test.ts +87 -0
  27. package/src/lifecycle/__tests__/publish.test.ts +8 -10
  28. package/src/lifecycle/__tests__/trust.test.ts +14 -14
  29. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  30. package/src/list.ts +3 -3
  31. package/src/market/__tests__/bootstrap.test.ts +24 -7
  32. package/src/market/__tests__/failure-playbook.test.ts +0 -1
  33. package/src/market/__tests__/remote-push.test.ts +228 -0
  34. package/src/market/__tests__/verify.test.ts +45 -207
  35. package/src/market/bootstrap.ts +12 -7
  36. package/src/market/command.ts +33 -35
  37. package/src/market/db-seed.ts +129 -0
  38. package/src/market/failure-playbook.ts +3 -9
  39. package/src/market/fixture.ts +2 -6
  40. package/src/market/publish.ts +145 -89
  41. package/src/market/remote-push.ts +236 -0
  42. package/src/market/trial.ts +2 -2
  43. package/src/market/verify.ts +17 -270
  44. package/src/market/walkthrough.ts +38 -63
  45. package/src/oj.ts +13 -2
  46. package/src/preview.ts +5 -3
  47. package/src/publish.ts +3 -2
  48. package/src/scaffold-guard.ts +4 -0
  49. package/src/site.ts +1 -1
  50. package/src/trust.ts +8 -8
  51. package/src/uninstall.ts +2 -2
  52. package/src/usage.ts +23 -21
  53. package/templates/config.yaml +22 -3
  54. package/templates/init.include.yaml +16 -11
  55. package/templates/openapi.json +3948 -3655
  56. package/templates/src/.ojm-builtin.json +13 -0
  57. package/templates/src/README.md +5 -2
  58. package/templates/src/app/api/_platform/README.md +3 -2
  59. package/templates/src/app/api/_platform/_lifecycle/atomic.ts +7 -2
  60. package/templates/src/app/api/_platform/_lifecycle/bundle-converge.ts +158 -0
  61. package/templates/src/app/api/_platform/_lifecycle/history.ts +3 -2
  62. package/templates/src/app/api/_platform/_lifecycle/installer.ts +25 -5
  63. package/templates/src/app/api/_platform/_lifecycle/local-archive.ts +61 -0
  64. package/templates/src/app/api/_platform/_lifecycle/manifest-of-upload.ts +62 -0
  65. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +12 -2
  66. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +4 -1
  67. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +8 -2
  68. package/templates/src/app/api/_platform/_lifecycle/types.ts +1 -1
  69. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +14 -5
  70. package/templates/src/app/api/_platform/_shared/site-facade.ts +31 -0
  71. package/templates/src/app/api/_platform/manifest.yaml +1 -2
  72. package/templates/src/app/api/_platform/migrations/0003__market_catalog.mysql.sql +6 -0
  73. package/templates/src/app/api/_platform/migrations/0003__market_catalog.pg.sql +6 -0
  74. package/templates/src/app/api/_platform/migrations/0003__market_catalog.sql +18 -0
  75. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.mysql.sql +6 -0
  76. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.pg.sql +6 -0
  77. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.sql +6 -0
  78. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.mysql.sql +8 -0
  79. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.pg.sql +8 -0
  80. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.sql +8 -0
  81. package/templates/src/app/api/_platform/schema.yaml +8 -11
  82. package/templates/src/app/api/_platform/seed.mysql.sql +1 -1
  83. package/templates/src/app/api/_platform/seed.pg.sql +1 -1
  84. package/templates/src/app/api/_platform/seed.sql +1 -1
  85. package/templates/src/app/api/auth/_shared/wecom/sync.ts +6 -0
  86. package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -114
  87. package/templates/src/app/web/_platform/__client/index.ts +1 -333
  88. package/templates/src/app/web/system/locales/en-US.json +15 -1
  89. package/templates/src/app/web/system/locales/zh-CN.json +15 -1
  90. package/templates/src/app/web/system/pages/departments/sync-bar.tsx +9 -3
  91. package/templates/src/app/web/system/pages/roles/index.tsx +4 -8
  92. package/templates/src/app/web/system/pages/roles/role-label.ts +26 -0
  93. package/templates/src/app/web/system/pages/solutions/index.tsx +110 -0
  94. package/templates/src/app/web/system/pages/solutions/market-entry.ts +46 -0
  95. package/templates/src/app/web/system/pages/users/index.tsx +12 -2
  96. package/templates/src/app/web/system/pages/users/match-keyword.ts +19 -0
  97. package/templates/src/app/web/system/pages/users/roles.tsx +43 -14
  98. package/templates/src/app/web/system/routes.ts +18 -1
  99. package/templates/src/{app/api/_platform/market → oj-market/api}/api.ts +475 -592
  100. package/templates/src/oj-market/api/backfill.ts +221 -0
  101. package/templates/src/{app/api/_platform/market → oj-market/api}/bootstrap.ts +2 -2
  102. package/templates/src/oj-market/api/cdn/api.ts +312 -0
  103. package/templates/src/oj-market/api/cdn/shared.ts +104 -0
  104. package/templates/src/oj-market/api/cdn/signed-auth.ts +75 -0
  105. package/templates/src/oj-market/api/manifest.yaml +8 -0
  106. package/templates/src/oj-market/solution.yaml +21 -0
  107. package/templates/src/oj-market/web/__client/api.schemas.ts +167 -0
  108. package/templates/src/oj-market/web/__client/index.ts +509 -0
  109. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +6 -6
  110. package/templates/src/oj-market/web/api/client.ts +6 -6
  111. package/templates/src/oj-market/web/api/defaultClient.ts +31 -25
  112. package/templates/src/oj-market/web/entry.ts +5 -3
  113. package/templates/src/oj-market/web/hooks/useMarket.ts +6 -6
  114. package/templates/src/oj-market/web/pages/MarketApp.tsx +20 -3
  115. package/templates/src/oj-market/web/publish/locales/en-US.json +1 -1
  116. package/templates/src/oj-market/web/publish/locales/zh-CN.json +1 -1
  117. package/templates/src/oj-market/web/publish/pages/history.tsx +2 -11
  118. package/templates/src/oj-market/web/routes.ts +12 -0
  119. package/templates/src/oj-market/web/types.ts +14 -8
  120. package/templates/tests/lifecycle/anonymous-wiring.spec.ts +65 -0
  121. package/templates/tests/lifecycle/backfill.spec.ts +392 -0
  122. package/templates/tests/lifecycle/bootstrap.spec.ts +1 -1
  123. package/templates/tests/lifecycle/bundle-converge.spec.ts +264 -0
  124. package/templates/tests/lifecycle/cdn-upload.spec.ts +661 -0
  125. package/templates/tests/lifecycle/deploy-smoke.spec.ts +25 -0
  126. package/templates/tests/lifecycle/exec-grant.spec.ts +38 -0
  127. package/templates/tests/lifecycle/installer.spec.ts +36 -0
  128. package/templates/tests/lifecycle/market-api.spec.ts +448 -803
  129. package/templates/tests/lifecycle/preflight.spec.ts +18 -0
  130. package/templates/tests/lifecycle/trust.spec.ts +2 -2
  131. package/templates/tests/lifecycle/uninstall.spec.ts +36 -2
  132. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  133. package/templates/tests/solutions-entry.spec.ts +55 -0
  134. package/templates/tsconfig.paths.json +1 -0
  135. package/templates/web.config.ts +6 -6
  136. package/vendor/host-versions.json +3 -3
  137. package/bin/oj +0 -4
  138. package/src/market/__tests__/build-index.test.ts +0 -641
  139. package/src/market/__tests__/sharding.test.ts +0 -90
  140. package/src/market/__tests__/yank-ledger.test.ts +0 -60
  141. package/src/market/build-index.ts +0 -526
  142. package/src/market/index-builder.ts +0 -149
  143. package/src/market/sharding.ts +0 -94
  144. package/src/market/yank-ledger.ts +0 -91
  145. package/templates/src/app/api/_platform/_lifecycle/source-seq.ts +0 -88
  146. package/templates/src/app/api/_platform/market/index-source.ts +0 -112
  147. package/templates/tests/lifecycle/source-seq.spec.ts +0 -55
@@ -1,12 +1,13 @@
1
1
  /**
2
- * `ojm market publish`(T3):把方案包落进 CDN 目录。
2
+ * 市场发布包构建(A6 重构):buildSignedPackage = 合成/构建 + 确定性 tgz + 包级 detached 签名,
3
+ * 产出**内存字节**(不落 CDN)——本地落盘(publishPackage)与远端推送(remote-push.ts)
4
+ * 共用同一构建面(DRY:签名/打包一行不重复)。
3
5
  *
4
- * 职责收窄到「落包 + 包级签名」(D-1)——**不碰索引**,索引由 `build-index` 扫描重建。
5
- * 由此多人可并发发布:每人只写 `<name>-<version>.tgz`(名字含名+版本,天然不撞),
6
- * 没有共享可变索引文件,也就没有 TOCTOU。
6
+ * 本地落盘模式(publishPackage)仍保留:walkthrough/trial 的隔离站点需要 cdn/ 字节 +
7
+ * DB 目录行双写(内部通道);CLI 命令层 `ojm market publish` 缺省走远端推送。
7
8
  *
8
- * 签名原语一行都不重写:11 复用 `ojm sign` 的 `signCommand`(规范串 `<name>@<version>:<sha256>`,
9
- * detached `.tgz.sig` 五字段,与 Form B archive.signature 同源同构)。
9
+ * 签名原语一行都不重写:11 复用 `ojm sign` 的 `signCommand`(规范串 `<registry_id>:<name>@<version>:<sha256>`,
10
+ * detached `.tgz.sig` 六字段,与 Form B archive.signature 同源同构)。
10
11
  */
11
12
 
12
13
  import { createHash } from "node:crypto";
@@ -17,6 +18,8 @@ import process from "node:process";
17
18
 
18
19
  import { buildBundle } from "../bundle";
19
20
  import { createDeterministicTgz } from "../deterministic-tar";
21
+ import { isValidKeyId } from "../keygen";
22
+ import { readPrivateKeyPem } from "../lifecycle/keygen";
20
23
  import { assertDefaultModulesUntouched } from "../scaffold-guard";
21
24
  import { signCommand } from "../sign";
22
25
  import { resolveCdnLayout } from "./cdn-layout";
@@ -25,7 +28,7 @@ import { buildFixturePackage, parseFixtureSpec } from "./fixture";
25
28
  export interface MarketPublishOptions {
26
29
  /** 站点工程根(含 api/config.yaml 与 cdn/);缺省 process.cwd() */
27
30
  projectRoot?: string
28
- /** `--fixture <name>@<version>`;缺省 demo-solution@0.1.0 */
31
+ /** `--fixture <name@version>`;缺省 demo-solution@0.1.0 */
29
32
  fixture?: string
30
33
  /** `--real <方案工程根>`:走 ojm build --bundle 真构建(替代 fixture) */
31
34
  real?: string
@@ -35,77 +38,97 @@ export interface MarketPublishOptions {
35
38
  key?: string
36
39
  /** 内存私钥 PEM(一次性/隔离场景,不落盘) */
37
40
  privateKeyPem?: string
38
- /** 落包后离线自检(verifyCanonical 复核包级签名) */
39
- check?: boolean
40
41
  /** 签发方 registry 身份(REG-CS3 v2 防跨仓库重放;透传 signCommand --registry-id) */
41
42
  registryId?: string
42
- /** 命令层是否紧接着重建索引(仅用于措辞:true 时不打印「索引未重建」) */
43
- rebuildIndex?: boolean
43
+ /** 落包后离线自检(verifyCanonical 复核包级签名) */
44
+ check?: boolean
44
45
  }
45
46
 
46
- export interface MarketPublishResult {
47
+ /** 已签名发布包(内存字节态;落盘/推送由调用方决定) */
48
+ export interface SignedPackage {
47
49
  name: string
48
50
  version: string
49
- tgzPath: string
50
- sigPath: string
51
+ tgzBytes: Uint8Array
52
+ /** tgz 字节 sha256(内容寻址;上传 canonical 与 Form B archive.sha256 同源) */
51
53
  sha256: string
52
54
  size: number
53
- canonical: string
55
+ /** 包级 detached 签名(六字段,与 .tgz.sig 旁车文件内容一致) */
56
+ sig: Record<string, unknown>
57
+ sigBytes: Uint8Array
54
58
  keyId: string
55
59
  publicKeyHex: string
56
60
  fingerprint: string
61
+ /** 包级 canonical(`<registry_id>:<name>@<version>:<sha256>`) */
62
+ canonical: string
63
+ /** 本次签名用私钥(远端推送构造上传 canonical 要用;内存态不落盘纪律不变) */
64
+ privateKeyPem: string
57
65
  }
58
66
 
59
- export async function publishPackage(opts: MarketPublishOptions = {}): Promise<MarketPublishResult> {
60
- const projectRoot = opts.projectRoot ?? process.cwd();
61
- const layout = resolveCdnLayout(projectRoot);
62
- if (!fs.existsSync(layout.dir)) {
63
- fs.mkdirSync(layout.dir, { recursive: true });
64
- console.log(`[ojm] 已建立 CDN 目录 ${path.relative(projectRoot, layout.dir)}`);
67
+ /**
68
+ * 解析签名身份(内存 PEM > --key 显式文件 > ~/.ojm/keys/<id>/private.pem)。
69
+ * 与 sign.ts signCommand 同源规则;独立抽出是因为推送路径不经 signCommand 也要签
70
+ * 上传 canonical(`cdn-upload:<站点 registry>:<file>:<sha256>`)——同一身份两把签名。
71
+ */
72
+ export function resolveSigningIdentity(opts: { id?: string, key?: string, privateKeyPem?: string, cwd?: string }): { keyId: string, privateKeyPem: string } {
73
+ const cwd = opts.cwd ?? process.cwd();
74
+ if (opts.privateKeyPem !== undefined && opts.privateKeyPem !== "") {
75
+ const keyId = opts.id ?? "";
76
+ if (keyId === "")
77
+ throw new Error("[ojm] 使用内存私钥时须同时给 --id <publisher.id>(.sig 的发布方标识;C2-37 命名空间)");
78
+ if (!isValidKeyId(keyId))
79
+ throw new Error(`[ojm] key_id 命名空间非法(C2-37):「${keyId}」`);
80
+ return { keyId, privateKeyPem: opts.privateKeyPem };
81
+ }
82
+ let keyFile: string;
83
+ let keyId: string;
84
+ if (opts.key !== undefined) {
85
+ keyFile = path.resolve(cwd, opts.key);
86
+ const m = /[/\\]\.ojm[/\\]keys[/\\]([^/\\]+)[/\\]private\.pem$/.exec(keyFile);
87
+ keyId = opts.id ?? m?.[1] ?? "";
88
+ if (keyId === "")
89
+ throw new Error("[ojm] --key 指向自定义位置时须同时给 --id <publisher.id>(.sig 的发布方标识;C2-37 命名空间)");
65
90
  }
91
+ else {
92
+ keyId = opts.id ?? "";
93
+ if (keyId === "")
94
+ throw new Error("[ojm] 缺少签名身份:--id <publisher.id>(从 ~/.ojm/keys/<id>/ 取私钥)或 --key <pem> --id <publisher.id>。先生成:ojm keygen --id <publisher.id>");
95
+ keyFile = path.join(os.homedir(), ".ojm", "keys", keyId, "private.pem");
96
+ }
97
+ if (!isValidKeyId(keyId))
98
+ throw new Error(`[ojm] key_id 命名空间非法(C2-37):「${keyId}」`);
99
+ if (!fs.existsSync(keyFile))
100
+ throw new Error(`[ojm] 私钥文件不存在:${keyFile}(用 ojm keygen --id ${keyId} 生成,或 --key 指定)`);
101
+ return { keyId, privateKeyPem: readPrivateKeyPem(keyFile) };
102
+ }
66
103
 
67
- const staging = mkdtempSync(path.join(os.tmpdir(), "ojm-market-publish-"));
104
+ /** 构建 + 包级签名(staging 内完成,staging 自清理;产出内存字节) */
105
+ export async function buildSignedPackage(opts: MarketPublishOptions = {}): Promise<SignedPackage> {
106
+ const staging = mkdtempSync(path.join(os.tmpdir(), "ojm-market-build-"));
68
107
  try {
69
- const { name, version, tgzPath, sha256, preExisted } = opts.real !== undefined
70
- ? await buildRealPackage(opts.real, staging, layout.dir)
71
- : await buildFixture({ fixture: opts.fixture, staging, cdnDir: layout.dir });
72
-
73
- // 原子性:tgz 已落盘而后续环节失败时,新包必须捡回来,不留半成品
74
- const rollbackPackage = () => {
75
- if (!preExisted)
76
- rmSync(tgzPath, { force: true });
77
- };
78
-
79
- let signed: ReturnType<typeof signCommand>;
80
- try {
81
- signed = signCommand(tgzPath, { id: opts.id, key: opts.key, privateKeyPem: opts.privateKeyPem, check: opts.check, registryId: opts.registryId });
82
- }
83
- catch (error) {
84
- rollbackPackage();
85
- throw error;
86
- }
87
- if (signed.checkPassed === false) {
88
- rollbackPackage();
89
- throw new Error(`[ojm] 包级签名自检失败,已移除未签通的包:${tgzPath}`);
90
- }
91
- const size = fs.statSync(tgzPath).size;
92
- console.log(`[ojm] 包已落 CDN → ${path.relative(projectRoot, tgzPath)}(${size} 字节)`);
93
- console.log(`[ojm] 发布方 ${signed.keyId};指纹 ${signed.fingerprint}`);
94
- // 带了 --rebuild-index 时由命令层紧接着重建索引,这里不能再报「未重建」(否则前后两句自相矛盾)
95
- if (opts.rebuildIndex !== true)
96
- console.log("[ojm] 索引未重建:多人场景请由维护者跑 ojm market build-index");
108
+ const identity = resolveSigningIdentity({ id: opts.id, key: opts.key, privateKeyPem: opts.privateKeyPem, cwd: opts.projectRoot });
109
+ const { name, version, tgzPath, sha256 } = opts.real !== undefined
110
+ ? await buildRealPackage(opts.real, staging)
111
+ : await buildFixture({ fixture: opts.fixture, staging });
97
112
 
113
+ const signed = signCommand(tgzPath, { id: identity.keyId, privateKeyPem: identity.privateKeyPem, check: opts.check, registryId: opts.registryId });
114
+ if (signed.checkPassed === false)
115
+ throw new Error("[ojm] 包级签名自检失败,已丢弃未签通的包");
116
+ const tgzBytes = new Uint8Array(fs.readFileSync(tgzPath));
117
+ const sigBytes = new Uint8Array(fs.readFileSync(signed.sigPath));
118
+ const sig = JSON.parse(new TextDecoder().decode(sigBytes)) as Record<string, unknown>;
98
119
  return {
99
120
  name,
100
121
  version,
101
- tgzPath,
102
- sigPath: signed.sigPath,
122
+ tgzBytes,
103
123
  sha256,
104
- size,
105
- canonical: signed.canonical,
124
+ size: tgzBytes.byteLength,
125
+ sig,
126
+ sigBytes,
106
127
  keyId: signed.keyId,
107
128
  publicKeyHex: signed.publicKeyHex,
108
129
  fingerprint: signed.fingerprint,
130
+ canonical: signed.canonical,
131
+ privateKeyPem: identity.privateKeyPem,
109
132
  };
110
133
  }
111
134
  finally {
@@ -113,61 +136,94 @@ export async function publishPackage(opts: MarketPublishOptions = {}): Promise<M
113
136
  }
114
137
  }
115
138
 
116
- /** fixture 路径:合成 → 确定性打包 → 与既有同名版本比对 */
117
- async function buildFixture(args: { fixture?: string, staging: string, cdnDir: string }): Promise<{ name: string, version: string, tgzPath: string, sha256: string, preExisted: boolean }> {
118
- const spec = parseFixtureSpec(args.fixture);
119
- const pkg = buildFixturePackage({ stagingDir: args.staging, name: spec.name, version: spec.version });
120
- const tgzPath = path.join(args.cdnDir, `${spec.name}-${spec.version}.tgz`);
139
+ export interface MarketPublishResult {
140
+ name: string
141
+ version: string
142
+ tgzPath: string
143
+ sigPath: string
144
+ sha256: string
145
+ size: number
146
+ canonical: string
147
+ keyId: string
148
+ publicKeyHex: string
149
+ fingerprint: string
150
+ }
151
+
152
+ /**
153
+ * 本地落盘模式:把已签名包写进 cdn/(内部通道——walkthrough/trial 隔离站点用;
154
+ * CLI 命令层 publish 缺省走远端推送,本地-only 发布模式已退役)。
155
+ * 多人可并发:每人只写 `<name>-<version>.tgz`(名字含名+版本,天然不撞)。
156
+ */
157
+ export async function publishPackage(opts: MarketPublishOptions = {}): Promise<MarketPublishResult> {
158
+ const projectRoot = opts.projectRoot ?? process.cwd();
159
+ const layout = resolveCdnLayout(projectRoot);
160
+ if (!fs.existsSync(layout.dir)) {
161
+ fs.mkdirSync(layout.dir, { recursive: true });
162
+ console.log(`[ojm] 已建立 CDN 目录 ${path.relative(projectRoot, layout.dir)}`);
163
+ }
164
+ const built = await buildSignedPackage(opts);
165
+
166
+ const tgzPath = path.join(layout.dir, `${built.name}-${built.version}.tgz`);
167
+ const sigPath = `${tgzPath}.sig`;
121
168
  const before = captureExisting(tgzPath);
169
+ // 同 name@version 重发:字节一致即幂等;字节不一致是真正的冲突——同一版本号出现两份
170
+ // 不同的包会毁掉内容寻址前提,必须升 version 重发
171
+ if (before.exists && before.sha256 !== built.sha256) {
172
+ throw new Error(
173
+ `[ojm] 包冲突:${built.name}@${built.version} 已存在且字节不一致(${before.sha256.slice(0, 12)} vs ${built.sha256.slice(0, 12)})。\n`
174
+ + "下一步:内容有变更请升 version 后重发(同一版本号只允许一份字节);"
175
+ + "确需覆盖请先删除 CDN 目录里的旧 tgz。",
176
+ );
177
+ }
178
+ fs.writeFileSync(tgzPath, built.tgzBytes);
179
+ fs.writeFileSync(sigPath, built.sigBytes);
180
+ console.log(`[ojm] 包已落 CDN → ${path.relative(projectRoot, tgzPath)}(${built.size} 字节)`);
181
+ console.log(`[ojm] 发布方 ${built.keyId};指纹 ${built.fingerprint}`);
182
+
183
+ return {
184
+ name: built.name,
185
+ version: built.version,
186
+ tgzPath,
187
+ sigPath,
188
+ sha256: built.sha256,
189
+ size: built.size,
190
+ canonical: built.canonical,
191
+ keyId: built.keyId,
192
+ publicKeyHex: built.publicKeyHex,
193
+ fingerprint: built.fingerprint,
194
+ };
195
+ }
122
196
 
197
+ /** fixture 路径:合成 → 确定性打包(staging 内) */
198
+ async function buildFixture(args: { fixture?: string, staging: string }): Promise<{ name: string, version: string, tgzPath: string, sha256: string }> {
199
+ const spec = parseFixtureSpec(args.fixture);
200
+ const pkg = buildFixturePackage({ stagingDir: args.staging, name: spec.name, version: spec.version });
201
+ const tgzPath = path.join(args.staging, `${spec.name}-${spec.version}.tgz`);
123
202
  const sha256 = await createDeterministicTgz(pkg.dir, tgzPath);
124
- assertNoByteConflict(tgzPath, before, spec);
125
- return { name: spec.name, version: spec.version, tgzPath, sha256, preExisted: before.exists };
203
+ return { name: spec.name, version: spec.version, tgzPath, sha256 };
126
204
  }
127
205
 
128
- /** `--real`:走既有 ojm build --bundle,产物搬到 CDN 目录(一次只发一份:多方案工程请显式指定) */
129
- async function buildRealPackage(solutionRoot: string, staging: string, cdnDir: string): Promise<{ name: string, version: string, tgzPath: string, sha256: string, preExisted: boolean }> {
206
+ /** `--real`:走既有 ojm build --bundle,产物归拢 staging(一次只发一份:多方案工程请显式指定) */
207
+ async function buildRealPackage(solutionRoot: string, staging: string): Promise<{ name: string, version: string, tgzPath: string, sha256: string }> {
130
208
  // 与 ojm build 同一道完整性门禁:发布链路同样不许携带被改过的默认模块
131
209
  assertDefaultModulesUntouched(solutionRoot);
132
210
  const builtList = await buildBundle(solutionRoot, { out: staging });
133
211
  if (builtList.length !== 1) {
134
212
  throw new Error(
135
213
  `[ojm] market publish --real 一次只发布一份方案,但该工程打出了 ${builtList.length} 份用户方案。`
136
- + "请改用显式链路:在方案工程里 `ojm build --bundle <name>`,再 `ojm sign dist/<name>-<version>.tgz --id <publisher>.<key>`,"
137
- + "把 tgz + .sig 放进 cdn/ 后 `ojm market build-index`。",
214
+ + "请改用显式链路:在方案工程里 `ojm build --bundle <name>`,再 `ojm sign dist/<name>-<version>.tgz --id <publisher>.<key>`。",
138
215
  );
139
216
  }
140
217
  const built = builtList[0]!;
141
- const tgzPath = path.join(cdnDir, `${built.name}-${built.version}.tgz`);
142
- const before = captureExisting(tgzPath);
143
-
218
+ const tgzPath = path.join(staging, `${built.name}-${built.version}.tgz`);
144
219
  fs.copyFileSync(built.tgz, tgzPath);
145
- assertNoByteConflict(tgzPath, before, { name: built.name, version: built.version });
146
- return { name: built.name, version: built.version, tgzPath, sha256: built.sha256, preExisted: before.exists };
220
+ return { name: built.name, version: built.version, tgzPath, sha256: built.sha256 };
147
221
  }
148
222
 
149
- /** 落包前先记下同名文件的指纹(可能不存在) */
223
+ /** 落包前记下同名文件的指纹(可能不存在) */
150
224
  function captureExisting(tgzPath: string): { exists: boolean, sha256: string } {
151
225
  if (!fs.existsSync(tgzPath))
152
226
  return { exists: false, sha256: "" };
153
227
  const bytes = new Uint8Array(fs.readFileSync(tgzPath));
154
228
  return { exists: true, sha256: createHash("sha256").update(bytes).digest("hex") };
155
229
  }
156
-
157
- /**
158
- * 同 name@version 重发:字节一致即幂等;字节不一致是真正的冲突——
159
- * 同一个版本号出现两份不同的包会毁掉内容寻址前提,必须升 version 重发。
160
- */
161
- function assertNoByteConflict(tgzPath: string, before: { exists: boolean, sha256: string }, spec: { name: string, version: string }): void {
162
- if (!before.exists)
163
- return;
164
- const bytes = new Uint8Array(fs.readFileSync(tgzPath));
165
- const after = createHash("sha256").update(bytes).digest("hex");
166
- if (after === before.sha256)
167
- return;
168
- throw new Error(
169
- `[ojm] 包冲突:${spec.name}@${spec.version} 已存在且字节不一致(${before.sha256.slice(0, 12)} vs ${after.slice(0, 12)})。\n`
170
- + "下一步:内容有变更请升 version 后重发(同一版本号只允许一份字节);"
171
- + "确需覆盖请先删除 CDN 目录里的旧 tgz。",
172
- );
173
- }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * 市场远端推送(A6,计划 v4 §五):`ojm market publish` 缺省链路。
3
+ *
4
+ * 远端 = oj 模板站点(纯站点无 ojm CLI):签名制推送(不经账号口令;企微用户无障碍)——
5
+ * X-OJM-Key-Id / X-OJM-Signature 头 + 站点信任库 + Ed25519,canonical 由**站点**从实收字节
6
+ * 重算 sha256 重构(`cdn-upload:<站点 registry_id>:<file>:<sha256>`)——换字节即 401。
7
+ *
8
+ * 顺序(pair 约束,钉死):先推 `.sig` 旁车再推 `.tgz`——服务端 tgz 未配对不入库(409)。
9
+ * 半成功(sig 已推、tgz 被拒):报错文案带「重跑本命令幂等补齐」——同字节重传幂等(服务端
10
+ * 查库仲裁),无人工清场负担。退出码 1 由命令层置(本库以抛错表达失败)。
11
+ *
12
+ * 站点身份发现:推送前先 GET catalog(匿名)——响应顶层 registry_id(A6 新增发现面);
13
+ * 同请求兼作 2×2 兼容矩阵探测:旧站点无 catalog API → 404 → 「站点版本过旧」人话。
14
+ */
15
+
16
+ import type { MarketPublishOptions } from "./publish";
17
+ import { createHash } from "node:crypto";
18
+
19
+ import process from "node:process";
20
+ import { signCanonical } from "../lifecycle/sign";
21
+ import { unwrapEnvelope } from "../site";
22
+ import { buildSignedPackage, resolveSigningIdentity } from "./publish";
23
+
24
+ /** 站点目录探测结果(catalog 响应顶层字段) */
25
+ export interface MarketDiscovery {
26
+ apiVersion: string | null
27
+ /** 站点身份(= 上传/yank canonical 首段);缺失在发现期即人话拒,故此后恒非空 */
28
+ registryId: string
29
+ }
30
+
31
+ /** 去掉尾斜杠的站点基址(env 回落与 resolveSite 同序:flag > OJM_SITE_URL) */
32
+ export function resolvePushTarget(siteUrl?: string): string {
33
+ const base = (siteUrl ?? process.env.OJM_SITE_URL ?? "").replace(/\/+$/, "");
34
+ if (base === "")
35
+ throw new Error("[ojm] 远端发布须指定目标站点:--site-url <https://your-site>(或环境变量 OJM_SITE_URL)");
36
+ return base;
37
+ }
38
+
39
+ /**
40
+ * GET /api/oj-market/catalog 探测(page=1&size=1 最小请求):发现 registry_id +
41
+ * 旧站点人话分流(404 → 「站点版本过旧」+ 迁移指向;网络层失败 → 人话不抛原生错)。
42
+ */
43
+ export async function discoverMarket(fetchFn: typeof fetch, base: string): Promise<MarketDiscovery> {
44
+ let res: Response;
45
+ try {
46
+ res = await fetchFn(`${base}/api/oj-market/catalog?page=1&size=1`, { method: "GET" });
47
+ }
48
+ catch (error) {
49
+ throw new Error(`[ojm] 连不上站点 ${base}(${(error as Error).message})——核对 --site-url / 网络后重试`);
50
+ }
51
+ if (res.status === 404)
52
+ throw new Error(`[ojm] 站点 ${base} 版本过旧(无 /api/oj-market/catalog 目录 API)——目标站点须先升级到 DB 目录版本;迁移指南见 docs/migration/ 市场远程发布篇`);
53
+ if (!res.ok)
54
+ throw new Error(`[ojm] 站点目录探测被拒(HTTP ${res.status})——匿名可读端点不应要求凭据,核对站点配置`);
55
+ const raw = await res.json() as { code?: number, data?: unknown };
56
+ // 站点统一信封 {code,msg,data}(AC-D16);匿名读端点 code!==0 时按无目录处理
57
+ if (typeof raw.code === "number" && raw.code !== 0)
58
+ throw new Error(`[ojm] 站点目录探测失败(code=${raw.code})——目标站点须为 DB 目录版本;迁移指南见 docs/migration/ 市场远程发布篇`);
59
+ const body = (typeof raw.code === "number" ? raw.data : raw) as { api_version?: unknown, registry_id?: unknown };
60
+ const registryId = typeof body.registry_id === "string" && body.registry_id !== "" ? body.registry_id : null;
61
+ if (registryId === null)
62
+ throw new Error(`[ojm] 站点 ${base} 未配置 registry_id(签名推送 canonical 首段 = 站点身份)——请站点管理员在 config.yaml vars 增加 registry_id 后重试`);
63
+ return { apiVersion: typeof body.api_version === "string" ? body.api_version : null, registryId };
64
+ }
65
+
66
+ /** 推送失败:带阶段(sig/tgz)与 HTTP 语义的人话错(命令层据此置 exitCode 1) */
67
+ export class RemotePushError extends Error {
68
+ constructor(
69
+ public readonly stage: "sig" | "tgz",
70
+ public readonly status: number,
71
+ message: string,
72
+ ) {
73
+ super(message);
74
+ this.name = "RemotePushError";
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 单文件签名推送:canonical `cdn-upload:<站点 registry_id>:<文件名>:<实收字节 sha256>`
80
+ * 由发布方私钥签名(与服务端重算口径逐字一致——本函数按同一公式构造)。
81
+ */
82
+ async function pushSignedFile(
83
+ fetchFn: typeof fetch,
84
+ base: string,
85
+ registryId: string,
86
+ fileName: string,
87
+ bytes: Uint8Array,
88
+ keyId: string,
89
+ privateKeyPem: string,
90
+ ): Promise<unknown> {
91
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
92
+ const signature = signCanonical(privateKeyPem, `cdn-upload:${registryId}:${fileName}:${sha256}`);
93
+ const form = new FormData();
94
+ form.append("file", new Blob([bytes.slice()]), fileName);
95
+ const res = await fetchFn(`${base}/api/oj-market/cdn/upload?name=${encodeURIComponent(fileName)}`, {
96
+ method: "POST",
97
+ headers: { "x-ojm-key-id": keyId, "x-ojm-signature": signature },
98
+ body: form,
99
+ });
100
+ // unwrapEnvelope 的人话已含服务端指引(403 附 trust add、409 附升 version 指引);
101
+ // 这里包阶段上下文 + 下一步(错误文案数据表面)
102
+ const text = await res.text();
103
+ let env: { code?: number, msg?: string } | null = null;
104
+ try {
105
+ env = JSON.parse(text) as { code?: number, msg?: string };
106
+ }
107
+ catch {}
108
+ const status = res.status;
109
+ if (!res.ok || (typeof env?.code === "number" && env.code !== 0)) {
110
+ const msg = env?.msg ?? text.slice(0, 200);
111
+ throw new RemotePushError(fileName.endsWith(".sig") ? "sig" : "tgz", status, `[ojm] 推送 ${fileName} 被拒(HTTP ${res.status}):${msg}`);
112
+ }
113
+ return env?.code === 0 ? (env as { data?: unknown }).data : env;
114
+ }
115
+
116
+ export interface RemotePublishOptions extends MarketPublishOptions {
117
+ /** 目标站点基址(缺省 OJM_SITE_URL;无则人话报错——不猜默认站点) */
118
+ siteUrl?: string
119
+ /** 测试注入 fetch;缺省全局 fetch */
120
+ fetchFn?: typeof fetch
121
+ /** 日志旁路(测试收集用) */
122
+ onLog?: (line: string) => void
123
+ }
124
+
125
+ export interface RemotePublishResult {
126
+ name: string
127
+ version: string
128
+ sha256: string
129
+ size: number
130
+ keyId: string
131
+ fingerprint: string
132
+ /** 站点身份(= 上传 canonical 首段) */
133
+ registryId: string
134
+ siteUrl: string
135
+ }
136
+
137
+ /**
138
+ * 远端发布主链:构建 + 包级签名(buildSignedPackage)→ catalog 探测(registry_id)→
139
+ * sig→tgz 顺序推送。半成功(sig 成 tgz 败)时错误文案追加「重跑本命令幂等补齐」。
140
+ */
141
+ export async function remotePublish(opts: RemotePublishOptions = {}): Promise<RemotePublishResult> {
142
+ const log = opts.onLog ?? ((line: string) => console.log(line));
143
+ const base = resolvePushTarget(opts.siteUrl);
144
+ const fetchFn = opts.fetchFn ?? fetch;
145
+
146
+ log(`[ojm] 远端发布目标 ${base}:构建发布包…`);
147
+ const built = await buildSignedPackage(opts);
148
+ log(`[ojm] 包已构建并签名:${built.name}@${built.version}(${built.size} 字节,发布方 ${built.keyId})`);
149
+
150
+ const discovery = await discoverMarket(fetchFn, base);
151
+ log(`[ojm] 站点目录就绪(api_version ${discovery.apiVersion ?? "?"},registry_id ${discovery.registryId})——先推 .sig 旁车`);
152
+
153
+ let sigPushed = false;
154
+ try {
155
+ await pushSignedFile(fetchFn, base, discovery.registryId, `${built.name}-${built.version}.tgz.sig`, built.sigBytes, built.keyId, built.privateKeyPem);
156
+ sigPushed = true;
157
+ log(`[ojm] .sig 旁车已推送(key_id ${built.keyId})`);
158
+ await pushSignedFile(fetchFn, base, discovery.registryId, `${built.name}-${built.version}.tgz`, built.tgzBytes, built.keyId, built.privateKeyPem);
159
+ }
160
+ catch (error) {
161
+ // 半成功:sig 已落、tgz 未至——服务端 pair 约束下重跑本命令同字节幂等补齐,无清场负担
162
+ if (sigPushed && error instanceof RemotePushError && error.stage === "tgz")
163
+ throw new RemotePushError(error.stage, error.status, `${error.message}\n[ojm] .sig 旁车已推送成功——重跑本命令幂等补齐(同字节重传幂等,pair 自动完成)`);
164
+ throw error;
165
+ }
166
+ log(`[ojm] 发布完成:${built.name}@${built.version} 已进 ${base} 市场目录(DB 目录行 + cdn/ 字节)`);
167
+
168
+ return {
169
+ name: built.name,
170
+ version: built.version,
171
+ sha256: built.sha256,
172
+ size: built.size,
173
+ keyId: built.keyId,
174
+ fingerprint: built.fingerprint,
175
+ registryId: discovery.registryId,
176
+ siteUrl: base,
177
+ };
178
+ }
179
+
180
+ export interface RemoteYankOptions {
181
+ /** 目标站点基址(缺省 OJM_SITE_URL) */
182
+ siteUrl?: string
183
+ /** 方案名(product) */
184
+ name: string
185
+ /** 版本 */
186
+ version: string
187
+ /** 恢复(unyank);缺省 = 下线 */
188
+ restore?: boolean
189
+ /** 下线原因(审计留痕;可选) */
190
+ reason?: string
191
+ /** 操作人(审计留痕;缺省由站点记 key_id) */
192
+ by?: string
193
+ id?: string
194
+ key?: string
195
+ privateKeyPem?: string
196
+ /** 测试注入 fetch;缺省全局 fetch */
197
+ fetchFn?: typeof fetch
198
+ }
199
+
200
+ export interface RemoteYankResult {
201
+ product: string
202
+ version: string
203
+ yanked: boolean
204
+ idempotent: boolean
205
+ }
206
+
207
+ /**
208
+ * 远端 yank(计划 v4 §五):POST /api/oj-market/yank,canonical
209
+ * `market-yank:<站点 registry_id>:<product>:<version>`(无内容哈希——重放退化为幂等,
210
+ * 与服务端口径一致)。仅发布者本人可下线/恢复(服务端按行 key_id 授权,不符 403)。
211
+ */
212
+ export async function remoteYank(opts: RemoteYankOptions): Promise<RemoteYankResult> {
213
+ const base = resolvePushTarget(opts.siteUrl);
214
+ const fetchFn = opts.fetchFn ?? fetch;
215
+ const discovery = await discoverMarket(fetchFn, base);
216
+ const { keyId, privateKeyPem } = resolveSigningIdentity({ id: opts.id, key: opts.key, privateKeyPem: opts.privateKeyPem });
217
+ const signature = signCanonical(privateKeyPem, `market-yank:${discovery.registryId}:${opts.name}:${opts.version}`);
218
+ const res = await fetchFn(`${base}/api/oj-market/yank`, {
219
+ method: "POST",
220
+ headers: { "content-type": "application/json", "x-ojm-key-id": keyId, "x-ojm-signature": signature },
221
+ body: JSON.stringify({
222
+ product: opts.name,
223
+ version: opts.version,
224
+ ...(opts.restore === true ? { restore: true } : {}),
225
+ ...(opts.reason !== undefined ? { reason: opts.reason } : {}),
226
+ ...(opts.by !== undefined ? { by: opts.by } : {}),
227
+ }),
228
+ });
229
+ const data = await unwrapEnvelope(res) as { product?: string, version?: string, yanked?: boolean, idempotent?: boolean };
230
+ return {
231
+ product: String(data.product ?? opts.name),
232
+ version: String(data.version ?? opts.version),
233
+ yanked: data.yanked === true,
234
+ idempotent: data.idempotent === true,
235
+ };
236
+ }
@@ -68,7 +68,7 @@ export async function trialInstall(opts: TrialOptions = {}): Promise<TrialResult
68
68
  if (task.status === "needs_trust") {
69
69
  const fingerprint = task.trust?.fingerprint ?? "";
70
70
  log(`[ojm] 待信任发布方 ${task.trust?.key_id ?? "?"},指纹 ${fingerprint.slice(0, 16)}…(隔离站点,自动确认)`);
71
- await callApi(fetch, site.baseUrl, token, "POST", "/api/_platform/market/trust", { task_id: task.id });
71
+ await callApi(fetch, site.baseUrl, token, "POST", "/api/oj-market/trust", { task_id: task.id });
72
72
  task = await installOnce(site.baseUrl, token, spec.name, spec.version);
73
73
  }
74
74
 
@@ -86,7 +86,7 @@ export async function trialInstall(opts: TrialOptions = {}): Promise<TrialResult
86
86
  /** 一次安装 + 轮询到终态 */
87
87
  async function installOnce(base: string, token: string, name: string, version: string): Promise<TaskRecord> {
88
88
  // 站点返回 {task_id}(安装同步跑完后回填);mock 兼容 id 形态
89
- const started = await callApi<{ task_id?: string, id?: string }>(fetch, base, token, "POST", "/api/_platform/market/install", {
89
+ const started = await callApi<{ task_id?: string, id?: string }>(fetch, base, token, "POST", "/api/oj-market/install", {
90
90
  name,
91
91
  version,
92
92
  source: "cdn",