@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.
- package/README.md +1 -1
- package/devkit/README.md +70 -24
- package/devkit/framework-dev.md +14 -5
- package/devkit/manual.md +28 -23
- package/devkit/module-dev-and-cdn-playground.md +197 -0
- package/devkit/sql-builder-missing-capabilities-pr.md +268 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +130 -45
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +64 -0
- package/src/__tests__/versions.test.ts +26 -0
- package/src/build.ts +9 -0
- package/src/bundle.ts +8 -5
- package/src/contract/__tests__/handler-scan.test.ts +29 -0
- package/src/contract/__tests__/market-client.integration.test.ts +170 -0
- package/src/contract/handler-scan.ts +35 -4
- package/src/dev.ts +10 -2
- package/src/index.ts +9 -3
- package/src/init.ts +24 -0
- package/src/lifecycle/__tests__/bundle.test.ts +3 -3
- package/src/market/__tests__/bootstrap.test.ts +204 -0
- package/src/market/__tests__/build-index.test.ts +154 -0
- package/src/market/__tests__/cdn-layout.test.ts +96 -0
- package/src/market/__tests__/failure-playbook.test.ts +33 -0
- package/src/market/__tests__/fixture.test.ts +48 -0
- package/src/market/__tests__/publish.test.ts +109 -0
- package/src/market/__tests__/report.test.ts +51 -0
- package/src/market/__tests__/setup.test.ts +114 -0
- package/src/market/__tests__/verify.test.ts +111 -0
- package/src/market/bootstrap.ts +255 -0
- package/src/market/build-index.ts +159 -0
- package/src/market/cdn-layout.ts +122 -0
- package/src/market/command.ts +139 -0
- package/src/market/failure-playbook.ts +57 -0
- package/src/market/fixture.ts +138 -0
- package/src/market/index-builder.ts +76 -0
- package/src/market/publish.ts +156 -0
- package/src/market/report.ts +67 -0
- package/src/market/scan.ts +99 -0
- package/src/market/setup.ts +144 -0
- package/src/market/trial.ts +121 -0
- package/src/market/verify.ts +298 -0
- package/src/market/walkthrough.ts +506 -0
- package/src/oj.ts +3 -1
- package/src/preview-config.ts +177 -0
- package/src/preview.ts +106 -56
- package/src/sign.ts +21 -6
- package/src/usage.ts +22 -2
- package/src/vendor.ts +4 -2
- package/src/versions.ts +28 -0
- package/templates/api/.ojm-api-exempt.json +6 -2
- package/templates/api/config.yaml +24 -2
- package/templates/api/src/_platform/README.md +31 -0
- package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
- package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
- package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
- package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
- package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +29 -56
- package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
- package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +20 -13
- package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
- package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
- package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +25 -22
- package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
- package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
- package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
- package/templates/api/src/_platform/_shared/service/infra/oplog.ts +40 -0
- package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +18 -0
- package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +16 -13
- package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
- package/templates/api/src/_platform/contacts/api.ts +2 -2
- package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
- package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
- package/templates/api/src/_platform/manifest.yaml +4 -1
- package/templates/api/src/_platform/market/api.ts +54 -16
- package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
- package/templates/api/src/_platform/schema.yaml +26 -0
- package/templates/api/src/_platform/solutions/api.ts +13 -12
- package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
- package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
- package/templates/api/src/auth/_shared/session.ts +26 -22
- package/templates/api/src/auth/api.ts +1 -1
- package/templates/api/src/demo/todos/api.ts +8 -8
- package/templates/api/src/home/line/api.ts +4 -4
- package/templates/api/src/home/pie/api.ts +4 -4
- package/templates/api/src/notification/_shared/notify.ts +97 -57
- package/templates/api/src/notification/api.ts +1 -1
- package/templates/api/src/notification/manifest.yaml +1 -0
- package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
- package/templates/api/src/notification/schema.yaml +7 -0
- package/templates/api/src/notification/seed.sql +5 -4
- package/templates/api/src/personal-center/profile/api.ts +63 -0
- package/templates/api/src/personal-center/upload/api.ts +1 -1
- package/templates/api/src/system/login-methods/api.ts +1 -1
- package/templates/api/src/system/oplog/api.ts +3 -3
- package/templates/api/src/system/permissions/api.ts +2 -2
- package/templates/api/src/system/roles/api.ts +2 -2
- package/templates/api/src/system/roles/bindings/api.ts +2 -2
- package/templates/api/src/system/settings/api.ts +2 -2
- package/templates/api/src/system/users/api.ts +2 -2
- package/templates/api/src/system/users/forbidden/api.ts +2 -2
- package/templates/api/src/system/users/password/api.ts +2 -2
- package/templates/api/src/system/users/roles/api.ts +2 -2
- package/templates/api/src/web/user-info/api.ts +4 -5
- package/templates/api/tests/auth-session.spec.ts +1 -1
- package/templates/api/tests/auth-throttle.spec.ts +1 -1
- package/templates/api/tests/fixture.ts +8 -16
- package/templates/api/tests/guard.spec.ts +1 -1
- package/templates/api/tests/l1-builder.test.ts +91 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
- package/templates/api/tests/lifecycle/market-api.spec.ts +2 -4
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
- package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/api/tests/lifecycle/uninstall.spec.ts +29 -89
- package/templates/api/tests/query-utils.spec.ts +85 -1
- package/templates/api/tests/rbac-audit.spec.ts +1 -1
- package/templates/api/tests/rbac-contacts.spec.ts +2 -2
- package/templates/api/tests/rbac-guard.spec.ts +1 -1
- package/templates/api/tests/rbac-roles.spec.ts +2 -2
- package/templates/api/tests/rbac-settings.spec.ts +66 -21
- package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
- package/templates/api/tests/rbac-users.spec.ts +1 -1
- package/templates/api/tsconfig.json +8 -0
- package/templates/cdn/README.md +48 -0
- package/templates/env.d.ts +2 -0
- package/templates/global.d.ts +850 -0
- package/templates/init.include.yaml +14 -2
- package/templates/logs/task-events.jsonl +0 -0
- package/templates/openapi.json +580 -23
- package/templates/package.json +2 -1
- package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
- package/templates/web/src/_platform/__client/api.ts +152 -39
- package/templates/web/src/notification/entry.ts +46 -9
- package/templates/web/src/notification/locales/en-US.json +19 -0
- package/templates/web/src/notification/locales/zh-CN.json +19 -0
- package/templates/web/src/notification/pages/center/index.tsx +131 -0
- package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
- package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
- package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
- package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
- package/templates/web/src/oj-market/api/client.ts +5 -105
- package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
- package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
- package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
- package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
- package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
- package/templates/web/src/oj-market/entry.ts +8 -2
- package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
- package/templates/web/src/oj-market/locales/en-US.json +2 -1
- package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
- package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
- package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
- package/templates/web/src/personal-center/__client/api.ts +25 -1
- package/templates/web/src/personal-center/entry.ts +5 -0
- package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
- package/templates/web/src/system/entry.ts +2 -1
- package/templates/web.config.ts +13 -10
- package/vendor/host-versions.json +3 -3
- package/templates/api/src/_platform/_shared/service/oplog.ts +0 -47
- package/templates/api/src/notifications/api.ts +0 -32
- package/templates/api/src/notifications/manifest.yaml +0 -5
- package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
- package/templates/web/src/notifications/__client/api.ts +0 -80
- /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ojm market setup`(T5):把 `framework.market_cdn_url` 指向本地模拟 CDN。
|
|
3
|
+
*
|
|
4
|
+
* 两件容易踩的事都收在这一处:
|
|
5
|
+
* 1. **地址必须是绝对的**,且端口来自 config 的 `server.port`——dev 形态下 dev server(5174)
|
|
6
|
+
* 只会把 `/api` 反代给 oj 子进程,`/cdn/*` 会被静态层回落成 index.html(设计 §3.1.1)。
|
|
7
|
+
* 2. **首跑凭据悬挂**:seed admin `must_change=1`,登录拿的是限权 token,写不了 system 设置;
|
|
8
|
+
* 须先 `PUT /api/auth/password` 改密换全权 token(该端点是悬挂期唯一可用写端)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
|
|
14
|
+
import { resolveSite } from "../site";
|
|
15
|
+
import { resolveCdnLayout } from "./cdn-layout";
|
|
16
|
+
|
|
17
|
+
export const MARKET_CDN_URL_SETTING = "framework.market_cdn_url";
|
|
18
|
+
|
|
19
|
+
export interface SetupOptions {
|
|
20
|
+
projectRoot?: string
|
|
21
|
+
/** 站点地址(缺省取 OJM_URL 或 http://127.0.0.1:9778) */
|
|
22
|
+
url?: string
|
|
23
|
+
user?: string
|
|
24
|
+
pass?: string
|
|
25
|
+
/** 新口令;不给则随机生成并只在终端打印一次(不落盘、不进日志) */
|
|
26
|
+
newPass?: string
|
|
27
|
+
/** 测试注入;缺省全局 fetch */
|
|
28
|
+
fetchFn?: typeof fetch
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SetupResult {
|
|
32
|
+
siteUrl: string
|
|
33
|
+
cdnBaseUrl: string
|
|
34
|
+
/** 是否走了改密闭环(must_change 悬挂期) */
|
|
35
|
+
changedPassword: boolean
|
|
36
|
+
/** 随机生成的新口令(仅当未显式给 newPass 时才有值) */
|
|
37
|
+
generatedPassword?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function setupMarketUrl(opts: SetupOptions = {}): Promise<SetupResult> {
|
|
41
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
42
|
+
const layout = resolveCdnLayout(projectRoot);
|
|
43
|
+
const site = resolveSite({ url: opts.url, user: opts.user, pass: opts.pass });
|
|
44
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
45
|
+
|
|
46
|
+
const token = await obtainToken(fetchFn, site.base, site.user, site.pass, opts.newPass, projectRoot);
|
|
47
|
+
|
|
48
|
+
await putSetting(fetchFn, site.base, token.value, MARKET_CDN_URL_SETTING, layout.baseUrl);
|
|
49
|
+
console.log(`[ojm] 市场地址已设置:${layout.baseUrl}`);
|
|
50
|
+
console.log(`[ojm] 市场页刷新后即可看到 ${pathOf(layout.dir)} 里的方案卡片`);
|
|
51
|
+
return {
|
|
52
|
+
siteUrl: site.base,
|
|
53
|
+
cdnBaseUrl: layout.baseUrl,
|
|
54
|
+
changedPassword: token.changedPassword,
|
|
55
|
+
generatedPassword: token.generatedPassword,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 登录并在必要时走改密闭环,返回可用 token */
|
|
60
|
+
async function obtainToken(fetchFn: typeof fetch, base: string, user: string, pass: string, newPass: string | undefined, projectRoot: string): Promise<{ value: string, changedPassword: boolean, generatedPassword?: string }> {
|
|
61
|
+
let login: { access_token?: string, restricted?: boolean };
|
|
62
|
+
try {
|
|
63
|
+
login = await postJson(fetchFn, `${base}/api/auth/login`, { username: user, password: pass });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[ojm] 连不上站点 ${base}:${(error as Error).message}\n`
|
|
68
|
+
+ "下一步:先起站点(ojm dev),再跑本命令。",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const token = String(login.access_token ?? "");
|
|
72
|
+
if (token === "")
|
|
73
|
+
throw new Error("[ojm] 登录响应缺 access_token——核对站点版本与账号");
|
|
74
|
+
if (login.restricted !== true)
|
|
75
|
+
return { value: token, changedPassword: false };
|
|
76
|
+
|
|
77
|
+
// must_change 悬挂期:限权 token 写不了 system 设置,先改密换全权 token
|
|
78
|
+
const next = newPass ?? randomBytes(9).toString("base64url");
|
|
79
|
+
const bundle = await putJson(fetchFn, `${base}/api/auth/password`, token, { old_password: pass, new_password: next });
|
|
80
|
+
const full = String(bundle.access_token ?? "");
|
|
81
|
+
if (full === "") {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"[ojm] 改密后未取得全权 token——账号仍处于 must_change 悬挂期。\n"
|
|
84
|
+
+ "下一步:用浏览器登录一次完成改密,或换一个已完成改密的超管账号重试。",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
console.log(`[ojm] 账号处于首次改密期,已改密并换取全权 token(新口令:${newPass === undefined ? next : "<由 --new-pass 指定>"})`);
|
|
88
|
+
if (newPass === undefined)
|
|
89
|
+
console.log("[ojm] 该口令仅此一次显示,不入日志不入盘——请自行留存");
|
|
90
|
+
void projectRoot;
|
|
91
|
+
return { value: full, changedPassword: true, generatedPassword: newPass === undefined ? next : undefined };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function putSetting(fetchFn: typeof fetch, base: string, token: string, key: string, value: string): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
await putJson(fetchFn, `${base}/api/system/settings`, token, { key, value });
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const message = (error as Error).message;
|
|
100
|
+
throw new Error(
|
|
101
|
+
`[ojm] 写入系统设置 ${key} 失败:${message}\n`
|
|
102
|
+
+ "下一步:确认账号是系统管理员(非仅超管模块权限),或用浏览器在「系统设置」里手动填写同一地址。",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function postJson(fetchFn: typeof fetch, url: string, body: unknown): Promise<Record<string, unknown>> {
|
|
108
|
+
const res = await fetchFn(url, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
});
|
|
113
|
+
return (await unwrap(res)) as Record<string, unknown>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function putJson(fetchFn: typeof fetch, url: string, token: string, body: unknown): Promise<Record<string, unknown>> {
|
|
117
|
+
const res = await fetchFn(url, {
|
|
118
|
+
method: "PUT",
|
|
119
|
+
headers: { "content-type": "application/json", "authorization": `Bearer ${token}` },
|
|
120
|
+
body: JSON.stringify(body),
|
|
121
|
+
});
|
|
122
|
+
return (await unwrap(res)) as Record<string, unknown>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** oj 信封解包({code,msg,data},code=0 为成) */
|
|
126
|
+
async function unwrap(res: Response): Promise<unknown> {
|
|
127
|
+
const text = await res.text();
|
|
128
|
+
let env: { code?: number, msg?: string, data?: unknown } | null = null;
|
|
129
|
+
try {
|
|
130
|
+
env = JSON.parse(text) as { code?: number, msg?: string, data?: unknown };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new Error(`[ojm] 站点响应不可解析(HTTP ${res.status}):${text.slice(0, 200)}`);
|
|
134
|
+
}
|
|
135
|
+
if (env === null || typeof env.code !== "number")
|
|
136
|
+
throw new Error(`[ojm] 站点响应缺 code 字段:${text.slice(0, 200)}`);
|
|
137
|
+
if (env.code !== 0)
|
|
138
|
+
throw new Error(`code=${env.code}:${env.msg ?? "未知错误"}`);
|
|
139
|
+
return env.data;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pathOf(dir: string): string {
|
|
143
|
+
return dir;
|
|
144
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ojm market trial`(T9):在一次性隔离站点里跑完整安装,验证「这个包能不能装」。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:开发态装不了自己(见 devkit 教学文档 §2),而真站点装了要清场、且首次会污染
|
|
5
|
+
* 信任库。trial 给你一个「别人家的站点」:随机端口 + 一次性 DB + 一次性发布方密钥 + 隔离
|
|
6
|
+
* CDN,跑完即毁(--keep 保留现场)。
|
|
7
|
+
*
|
|
8
|
+
* 复用纪律:站点生命周期全在 `bootstrap.ts`,站点调用四件套全在 `site.ts`——本文件只编排,
|
|
9
|
+
* 不重写登录/轮询/取证。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { TaskRecord } from "../site";
|
|
13
|
+
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
import { callApi, login, pollTask } from "../site";
|
|
16
|
+
import { bootstrapIsolatedSite } from "./bootstrap";
|
|
17
|
+
import { parseFixtureSpec } from "./fixture";
|
|
18
|
+
import { setupMarketUrl } from "./setup";
|
|
19
|
+
|
|
20
|
+
export interface TrialOptions {
|
|
21
|
+
/** 源工程(提供 bin/oj 与 cli 模板);缺省 process.cwd() */
|
|
22
|
+
projectRoot?: string
|
|
23
|
+
/** 待试装的方案;缺省 demo-solution@0.1.0 */
|
|
24
|
+
fixture?: string
|
|
25
|
+
/** 保留隔离现场(排查用) */
|
|
26
|
+
keep?: boolean
|
|
27
|
+
/** 首败即停(默认也停——单次试装没有后续步骤可续) */
|
|
28
|
+
strict?: boolean
|
|
29
|
+
onLog?: (line: string) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TrialResult {
|
|
33
|
+
ok: boolean
|
|
34
|
+
/** 隔离站点端口(keep 时供人工访问) */
|
|
35
|
+
port: number
|
|
36
|
+
root: string
|
|
37
|
+
stages: TaskRecord["stages"]
|
|
38
|
+
message: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function trialInstall(opts: TrialOptions = {}): Promise<TrialResult> {
|
|
42
|
+
const log = opts.onLog ?? ((line: string) => console.log(line));
|
|
43
|
+
const spec = parseFixtureSpec(opts.fixture);
|
|
44
|
+
log(`[ojm] 试装 ${spec.name}@${spec.version}(隔离站点,跑完即毁)`);
|
|
45
|
+
|
|
46
|
+
const site = await bootstrapIsolatedSite({
|
|
47
|
+
projectRoot: opts.projectRoot,
|
|
48
|
+
fixture: opts.fixture,
|
|
49
|
+
keep: opts.keep,
|
|
50
|
+
onLog: log,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// 1) 打开市场地址:隔离库是全新 seed,首登必然处于改密悬挂期 → setup 顺带完成改密
|
|
55
|
+
const newPass = "trial-pass-1";
|
|
56
|
+
await setupMarketUrl({
|
|
57
|
+
projectRoot: site.root,
|
|
58
|
+
url: site.baseUrl,
|
|
59
|
+
user: site.admin.user,
|
|
60
|
+
pass: site.admin.pass,
|
|
61
|
+
newPass,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const token = await login(fetch, site.baseUrl, site.admin.user, newPass);
|
|
65
|
+
|
|
66
|
+
// 2) 首次安装:预期停在 needs_trust(发布方是本次临时生成的身份)
|
|
67
|
+
let task = await installOnce(site.baseUrl, token, spec.name, spec.version);
|
|
68
|
+
if (task.status === "needs_trust") {
|
|
69
|
+
const fingerprint = task.trust?.fingerprint ?? "";
|
|
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 });
|
|
72
|
+
task = await installOnce(site.baseUrl, token, spec.name, spec.version);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ok = task.status === "done";
|
|
76
|
+
log(ok
|
|
77
|
+
? `[ojm] 试装通过:${spec.name}@${spec.version} 在隔离站点装成(${task.stages.length} 段全 ok)`
|
|
78
|
+
: `[ojm] 试装失败:status=${task.status} stage=${task.stage ?? "?"}——${task.message}`);
|
|
79
|
+
return { ok, port: site.port, root: site.root, stages: task.stages, message: task.message };
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await site.cleanup();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 一次安装 + 轮询到终态 */
|
|
87
|
+
async function installOnce(base: string, token: string, name: string, version: string): Promise<TaskRecord> {
|
|
88
|
+
// 站点返回 {task_id}(安装同步跑完后回填);mock 兼容 id 形态
|
|
89
|
+
const started = await callApi<{ task_id?: string, id?: string }>(fetch, base, token, "POST", "/api/_platform/market/install", {
|
|
90
|
+
name,
|
|
91
|
+
version,
|
|
92
|
+
source: "cdn",
|
|
93
|
+
});
|
|
94
|
+
const taskId = String(started.task_id ?? started.id ?? "");
|
|
95
|
+
if (taskId === "")
|
|
96
|
+
throw new Error("[ojm] 安装接口未返回 task id——站点版本与 ojm 不匹配?");
|
|
97
|
+
return pollTask(fetch, base, token, taskId, { interval0: 500, intervalMax: 2000, timeoutMs: 120_000 });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 进程入口:`ojm market trial [--fixture name@ver] [--keep]` */
|
|
101
|
+
export async function trialCommand(argv: string[]): Promise<void> {
|
|
102
|
+
const opts: TrialOptions = {};
|
|
103
|
+
for (let i = 0; i < argv.length; i++) {
|
|
104
|
+
const a = argv[i]!;
|
|
105
|
+
if (a === "--keep") {
|
|
106
|
+
opts.keep = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (a === "--strict") {
|
|
110
|
+
opts.strict = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (a === "--fixture") {
|
|
114
|
+
opts.fixture = argv[i + 1] ?? "";
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const result = await trialInstall(opts);
|
|
119
|
+
if (!result.ok)
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
}
|