@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.
- package/devkit/README.md +70 -24
- package/devkit/framework-dev.md +14 -5
- package/devkit/manual.md +9 -6
- package/devkit/module-dev-and-cdn-playground.md +197 -0
- package/devkit/sql-builder-missing-capabilities-pr.md +38 -4
- 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__/market-client.integration.test.ts +13 -10
- package/src/index.ts +6 -0
- package/src/init.ts +22 -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 +14 -0
- package/src/preview.ts +4 -3
- package/src/sign.ts +21 -6
- package/src/usage.ts +19 -0
- 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 +20 -0
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +21 -53
- package/templates/api/src/_platform/_shared/service/domain/contacts.ts +15 -10
- package/templates/api/src/_platform/_shared/service/domain/solution-perms.ts +8 -6
- package/templates/api/src/_platform/_shared/service/infra/oplog.ts +16 -23
- package/templates/api/src/_platform/_shared/service/infra/query.ts +18 -0
- package/templates/api/src/_platform/_shared/service/infra/rate.ts +4 -1
- package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
- package/templates/api/src/_platform/manifest.yaml +1 -0
- package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
- package/templates/api/src/_platform/schema.yaml +12 -0
- package/templates/api/src/auth/_shared/session.ts +2 -2
- package/templates/api/src/demo/todos/api.ts +8 -9
- package/templates/api/src/notification/_shared/notify.ts +64 -26
- 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/web/user-info/api.ts +3 -1
- package/templates/api/tests/fixture.ts +8 -16
- 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 +1 -3
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
- package/templates/api/tests/lifecycle/uninstall.spec.ts +28 -88
- package/templates/api/tests/query-utils.spec.ts +85 -1
- package/templates/api/tests/rbac-settings.spec.ts +64 -19
- package/templates/cdn/README.md +48 -0
- package/templates/global.d.ts +36 -2
- package/templates/init.include.yaml +3 -2
- package/templates/openapi.json +55 -10
- 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/entry.ts +8 -2
- 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 +10 -7
- package/vendor/host-versions.json +3 -3
- package/templates/api/src/notifications/api.ts +0 -29
- 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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 隔离站点自举(T8):`ojm market trial` / `walkthrough` 的地基。
|
|
3
|
+
*
|
|
4
|
+
* 目标:给你一个「别人家的站点」来试装——不碰你的工程、DB、信任库、端口。
|
|
5
|
+
* 隔离六项各有落点:
|
|
6
|
+
* - **端口**:临时根 config 写入空闲端口(startOj 从 config 读端口,故派生 config 是唯一真相);
|
|
7
|
+
* - **DB**:oj 的 sqlite 按 config 所在目录解析,临时根内自然隔离(dbPath 暴露供断言);
|
|
8
|
+
* - **密钥**:发布方 Ed25519 由公开 seed 经 SHA-256 派生(照 fixtures/sig 夹具手法),
|
|
9
|
+
* 私钥只在内存,临时根内无 PEM(站点自己的开发证书走 mintDevCert,属站点身份);
|
|
10
|
+
* - **凭据**:隔离库是全新 seed,admin 口令即种子默认值,与真站点凭据零交集;
|
|
11
|
+
* - **信任库**:`.solutions/trusted-keys.yaml` 只在安装器写入时生成,全新根内不存在;
|
|
12
|
+
* - **CDN 目录**:临时根内 cdn/,由本模块自建(publish + build-index 直落)。
|
|
13
|
+
*
|
|
14
|
+
* 提库来源:`scripts/verify-solution-smoke/bootstrap-host.mjs`(同手法,脚本形态);
|
|
15
|
+
* 本文件是它的 cli 内嵌版(计划 §9.1:phase1 从这里提库而非重写)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { OjProcess } from "../oj";
|
|
19
|
+
import { Buffer } from "node:buffer";
|
|
20
|
+
import { createHash, createPrivateKey, randomBytes } from "node:crypto";
|
|
21
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import net from "node:net";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
import process from "node:process";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { buildBackend } from "../build";
|
|
29
|
+
import { exportRawPublicKeyHex, fingerprintOfPublicKey } from "../lifecycle/sign";
|
|
30
|
+
import { startOj } from "../oj";
|
|
31
|
+
import { mintDevCert } from "../oj-cert";
|
|
32
|
+
import { resolveOjBin } from "../vendor";
|
|
33
|
+
import { buildIndex } from "./build-index";
|
|
34
|
+
import { parseFixtureSpec } from "./fixture";
|
|
35
|
+
import { publishPackage } from "./publish";
|
|
36
|
+
|
|
37
|
+
/** 种子管理员(隔离库全新 seed;与 templates seed.sql 一致) */
|
|
38
|
+
const SEED_ADMIN_USER = "admin";
|
|
39
|
+
const SEED_ADMIN_PASS = "123456";
|
|
40
|
+
|
|
41
|
+
/** Ed25519 PKCS#8 DER 前缀(RFC 8410 固定头;与 gen-signed-fixtures.mjs 同一手法) */
|
|
42
|
+
const ED25519_PKCS8_PREFIX = "302e020100300506032b657004220420";
|
|
43
|
+
|
|
44
|
+
export interface IsolatedPublisher {
|
|
45
|
+
keyId: string
|
|
46
|
+
privateKeyPem: string
|
|
47
|
+
publicKeyHex: string
|
|
48
|
+
fingerprint: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IsolatedSite {
|
|
52
|
+
/** 临时根(含 api/ bin/ cdn/) */
|
|
53
|
+
root: string
|
|
54
|
+
port: number
|
|
55
|
+
baseUrl: string
|
|
56
|
+
apiBase: string
|
|
57
|
+
cdnDir: string
|
|
58
|
+
cdnBaseUrl: string
|
|
59
|
+
/** sqlite 库路径(config 目录相对解析;断言隔离用) */
|
|
60
|
+
dbPath: string
|
|
61
|
+
/** 一次性发布方身份(私钥只在内存) */
|
|
62
|
+
publisher: IsolatedPublisher
|
|
63
|
+
/** 隔离库的种子管理员 */
|
|
64
|
+
admin: { user: string, pass: string }
|
|
65
|
+
/** 销毁隔离根与 oj 进程;幂等 */
|
|
66
|
+
cleanup: () => Promise<void>
|
|
67
|
+
/** 销毁清单(--keep 时给人看这里到底有什么) */
|
|
68
|
+
teardownReport: () => string[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BootstrapOptions {
|
|
72
|
+
/** 源工程(提供 bin/oj 与 cli 内置模板);缺省 process.cwd() */
|
|
73
|
+
projectRoot?: string
|
|
74
|
+
/** 落进隔离 CDN 的 fixture;缺省 demo-solution@0.1.0 */
|
|
75
|
+
fixture?: string
|
|
76
|
+
/** 保留现场(cleanup 不删根,只打清单) */
|
|
77
|
+
keep?: boolean
|
|
78
|
+
/** 测试注入;缺省真 startOj */
|
|
79
|
+
starter?: (configPath: string, base: string, apiSrc: string) => OjProcess
|
|
80
|
+
/** 日志旁路(测试收集用) */
|
|
81
|
+
onLog?: (line: string) => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promise<IsolatedSite> {
|
|
85
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
86
|
+
const log = (line: string) => (opts.onLog ?? ((l: string) => console.log(l)))(line);
|
|
87
|
+
|
|
88
|
+
const ojBin = resolveOjBin(projectRoot);
|
|
89
|
+
if (!existsSync(ojBin)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`[ojm] 源工程未安装 oj 二进制:${ojBin}\n`
|
|
92
|
+
+ "下一步:在源工程目录跑一次 `ojm vendor`(经 npm 包 @oj-bin/oj 下载),再重试。",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const root = mkdtempSync(path.join(tmpdir(), "ojm-trial-"));
|
|
97
|
+
const apiDir = path.join(root, "api");
|
|
98
|
+
const configPath = path.join(apiDir, "config.yaml");
|
|
99
|
+
const cdnDir = path.join(root, "cdn");
|
|
100
|
+
const dbPath = path.join(apiDir, "db.sqlite");
|
|
101
|
+
|
|
102
|
+
// 1) api 树 + config(模板由 cli 包内置,init 同源;与 cli→templates 依赖红线不冲突——那是运行期文件而非 import)
|
|
103
|
+
const cliRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
104
|
+
const templateApi = path.join(cliRoot, "templates", "api");
|
|
105
|
+
if (!existsSync(path.join(templateApi, "config.yaml"))) {
|
|
106
|
+
throw new Error(`[ojm] cli 内置模板缺失:${templateApi}(@oj-module/cli 安装不完整?)`);
|
|
107
|
+
}
|
|
108
|
+
cpSync(path.join(templateApi, "src"), path.join(apiDir, "src"), { recursive: true });
|
|
109
|
+
writeFileSync(configPath, readFileSync(path.join(templateApi, "config.yaml"), "utf-8"), "utf-8");
|
|
110
|
+
|
|
111
|
+
// 2) oj 二进制与插件(startOj 按 <root>/bin/oj 解析;plugins 让 auth 插件可用)
|
|
112
|
+
mkdirSync(path.join(root, "bin"), { recursive: true });
|
|
113
|
+
cpSync(ojBin, path.join(root, "bin", "oj"), { recursive: true });
|
|
114
|
+
const versionMarker = path.join(projectRoot, "bin", ".oj-version");
|
|
115
|
+
if (existsSync(versionMarker)) {
|
|
116
|
+
cpSync(versionMarker, path.join(root, "bin", ".oj-version"));
|
|
117
|
+
}
|
|
118
|
+
const pluginsDir = path.join(projectRoot, "bin", "plugins");
|
|
119
|
+
if (existsSync(pluginsDir)) {
|
|
120
|
+
cpSync(pluginsDir, path.join(root, "bin", "plugins"), { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3) 派生 config:空闲端口 / 随机 jwt / CDN 挂载指绝对路径 / fs 段
|
|
124
|
+
const port = await freePort();
|
|
125
|
+
let cfg = readFileSync(configPath, "utf-8");
|
|
126
|
+
cfg = cfg.replace("port: 9778", `port: ${port}`);
|
|
127
|
+
if (!cfg.includes(`port: ${port}`)) {
|
|
128
|
+
throw new Error(`[ojm] 模板 config 未含 端口: 9778 字面(模板结构变了?):${configPath}`);
|
|
129
|
+
}
|
|
130
|
+
cfg = cfg.replace("__JWT_SECRET__", randomBytes(32).toString("hex"));
|
|
131
|
+
// 最小 SPA 挂载:真实部署里前端路由(深检会逐条探活)由站点壳承接;没有 web 挂载时
|
|
132
|
+
// 这些路径一律 404,深检会判成 anomaly。这里放一个 index.html 就够(不构建前端)。
|
|
133
|
+
// 挂在 /cdn 之后(前缀特异性匹配,顺序无关,仅为可读)。
|
|
134
|
+
const siteDir = path.join(root, "site");
|
|
135
|
+
mkdirSync(siteDir, { recursive: true });
|
|
136
|
+
writeFileSync(path.join(siteDir, "index.html"), "<!doctype html><html><body>ojm trial site</body></html>\n", "utf-8");
|
|
137
|
+
cfg = cfg.replace("web: ../cdn", `web: ${cdnDir}\n - prefix: /\n web: ${siteDir}\n spa: true`);
|
|
138
|
+
// 安装目标恒为 release 部署:装好的模块落在 api/dist,dev 形态(--api-path src)发现不了。
|
|
139
|
+
// 故隔离站点以 dist 起站,platform.root 同步改写(oj 校验 root 必须是生效的 api 挂载目录)。
|
|
140
|
+
cfg = cfg.replace(" root: src", " root: dist");
|
|
141
|
+
// auth 守卫在装配期读 anonymous_paths,安装器注册的新匿名端点要重启才生效(首次安装 smoke 会 401)。
|
|
142
|
+
// 隔离站点直接预置被测方案的探活路径——与 scripts/verify-solution-smoke/bootstrap-host.mjs 同手法,
|
|
143
|
+
// 让 trial 验的是安装机制本身,而不是重启时序。
|
|
144
|
+
const healthPath = `/${parseFixtureSpec(opts.fixture).name}/ping`;
|
|
145
|
+
if (!cfg.includes(`- ${healthPath}`))
|
|
146
|
+
cfg = cfg.replace(/^(\s*)- \/system\/login-methods$/m, `$1- /system/login-methods\n$1- ${healthPath}`);
|
|
147
|
+
// fs 段:模板自 0.2.13 起已自带(安装器落盘面);旧模板没有时才补,避免重复键
|
|
148
|
+
if (!/^fs:/m.test(cfg))
|
|
149
|
+
cfg = `${cfg.trimEnd()}\nfs:\n root: .\n readonly: false\n`;
|
|
150
|
+
writeFileSync(configPath, cfg, "utf-8");
|
|
151
|
+
|
|
152
|
+
// 4) 站点开发证书(oj serve 无绕过开关;mintDevCert 落 api/config 三件套)
|
|
153
|
+
await mintDevCert(path.join(apiDir, "config"));
|
|
154
|
+
|
|
155
|
+
// 5) 一次性发布方身份:seed 派生,私钥只在内存
|
|
156
|
+
const keyId = "trial.publisher";
|
|
157
|
+
const publisher = derivePublisherKey(randomBytes(16).toString("hex"));
|
|
158
|
+
|
|
159
|
+
// 6) 隔离 CDN:落 fixture 包 + 重建索引(publish/buildIndex 直落临时根)
|
|
160
|
+
mkdirSync(cdnDir, { recursive: true });
|
|
161
|
+
await publishPackage({ projectRoot: root, fixture: opts.fixture, id: keyId, privateKeyPem: publisher.privateKeyPem });
|
|
162
|
+
await buildIndex({ projectRoot: root, id: keyId, privateKeyPem: publisher.privateKeyPem });
|
|
163
|
+
|
|
164
|
+
// 7) 后端构建(api/dist + versions.json;安装与 reloading 都依赖它)
|
|
165
|
+
await buildBackend(root);
|
|
166
|
+
|
|
167
|
+
// 7b) 起 oj(starter 注入供测试);ready 失败即整体作废,不留孤儿
|
|
168
|
+
const starter = opts.starter ?? ((cfgPath: string, base: string, apiSrc: string) => startOj(cfgPath, base, apiSrc));
|
|
169
|
+
let oj: OjProcess;
|
|
170
|
+
try {
|
|
171
|
+
oj = starter(configPath, "/api", path.join(apiDir, "dist"));
|
|
172
|
+
await oj.ready;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
rmSync(root, { recursive: true, force: true });
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
log(`[ojm] 隔离站点就绪:http://127.0.0.1:${port}(根 ${root})`);
|
|
179
|
+
|
|
180
|
+
// 8) cleanup:停进程 → 按 keep 决定删不删根 → 摘信号;全程幂等
|
|
181
|
+
let done = false;
|
|
182
|
+
let cleanup: () => Promise<void>;
|
|
183
|
+
const onSignal = () => {
|
|
184
|
+
void cleanup();
|
|
185
|
+
};
|
|
186
|
+
cleanup = async () => {
|
|
187
|
+
if (done) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
done = true;
|
|
191
|
+
process.off("SIGINT", onSignal);
|
|
192
|
+
process.off("SIGTERM", onSignal);
|
|
193
|
+
await oj.stop().catch(() => {});
|
|
194
|
+
if (opts.keep === true) {
|
|
195
|
+
log(`[ojm] --keep:隔离根保留在 ${root}(内容见销毁清单)`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
rmSync(root, { recursive: true, force: true });
|
|
199
|
+
log("[ojm] 隔离站点已销毁");
|
|
200
|
+
};
|
|
201
|
+
process.on("SIGINT", onSignal);
|
|
202
|
+
process.on("SIGTERM", onSignal);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
root,
|
|
206
|
+
port,
|
|
207
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
208
|
+
apiBase: "/api",
|
|
209
|
+
cdnDir,
|
|
210
|
+
cdnBaseUrl: `http://127.0.0.1:${port}/cdn`,
|
|
211
|
+
dbPath,
|
|
212
|
+
publisher: { keyId, privateKeyPem: publisher.privateKeyPem, publicKeyHex: publisher.publicKeyHex, fingerprint: fingerprintOfPublicKey(publisher.publicKeyHex) },
|
|
213
|
+
admin: { user: SEED_ADMIN_USER, pass: SEED_ADMIN_PASS },
|
|
214
|
+
cleanup,
|
|
215
|
+
teardownReport: () => teardownReport(root, port),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* seed 派生一次性 Ed25519 身份(照 fixtures/sig 夹具手法:公开 seed → SHA-256 → PKCS#8 DER)。
|
|
221
|
+
* 私钥只在内存;label 每次随机,故每次 trial 都是不同身份(不会与任何既有信任库条目撞)。
|
|
222
|
+
* 导出供 walkthrough 的 neg-stranger op 派生「未登记 key」(DRY:不重写派生手法)。
|
|
223
|
+
*/
|
|
224
|
+
export function derivePublisherKey(label: string): { privateKeyPem: string, publicKeyHex: string } {
|
|
225
|
+
const seed = createHash("sha256").update(`ojm-trial-publisher:${label}`).digest();
|
|
226
|
+
const der = Buffer.concat([Buffer.from(ED25519_PKCS8_PREFIX, "hex"), seed]);
|
|
227
|
+
const key = createPrivateKey({ key: der, format: "der", type: "pkcs8" });
|
|
228
|
+
const privateKeyPem = key.export({ type: "pkcs8", format: "pem" }).toString();
|
|
229
|
+
return { privateKeyPem, publicKeyHex: exportRawPublicKeyHex(privateKeyPem) };
|
|
230
|
+
}
|
|
231
|
+
/** 空闲端口:listen(0) 取端口后立刻归还(本地一次性使用,竞态可接受) */
|
|
232
|
+
function freePort(): Promise<number> {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const server = net.createServer();
|
|
235
|
+
server.once("error", reject);
|
|
236
|
+
server.listen(0, "127.0.0.1", () => {
|
|
237
|
+
const address = server.address();
|
|
238
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
239
|
+
server.close(() => resolve(port));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 销毁清单:--keep 时告诉人这里到底有什么、怎么手动清 */
|
|
245
|
+
function teardownReport(root: string, port: number): string[] {
|
|
246
|
+
const lines = [
|
|
247
|
+
`隔离根:${root}`,
|
|
248
|
+
" api/ 站点树(src + config.yaml + config/ 证书 + db.sqlite)",
|
|
249
|
+
" bin/ oj 二进制与插件(拷贝自源工程)",
|
|
250
|
+
" cdn/ 隔离 CDN(fixture 包 + 两份索引 + 签名)",
|
|
251
|
+
`监听端口:127.0.0.1:${port}(进程已停)`,
|
|
252
|
+
`手动清理:rm -rf ${root}`,
|
|
253
|
+
];
|
|
254
|
+
return lines;
|
|
255
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ojm market build-index`(T4):扫描 `cdn/*.tgz` → 提取内嵌 manifest → 全量重建两份索引并签名。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不增量合并:索引是**共享可变单文件**,多人发布时读-改-写天然 TOCTOU,且索引签名会被
|
|
5
|
+
* 后发者覆盖。而 `market/api.ts verifyIndexSig` 注释已写明「索引签名只做完整性,信任由 TOFU/
|
|
6
|
+
* 信任库承载」——索引本就不是信任锚,那就把它降级为派生产物:谁都能重建,重建结果只取决于目录内容。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { IndexedPackage } from "./index-builder";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
|
|
16
|
+
import { stringify as yamlStringify } from "yaml";
|
|
17
|
+
import { recomputeIndex, signIndex } from "../publish";
|
|
18
|
+
import { resolveCdnLayout } from "./cdn-layout";
|
|
19
|
+
import { buildIndexData } from "./index-builder";
|
|
20
|
+
import { readEmbeddedManifest } from "./scan";
|
|
21
|
+
|
|
22
|
+
export interface BuildIndexOptions {
|
|
23
|
+
projectRoot?: string
|
|
24
|
+
id?: string
|
|
25
|
+
key?: string
|
|
26
|
+
privateKeyPem?: string
|
|
27
|
+
/** 重建后离线自检(verifyCanonical 复核两份索引签名) */
|
|
28
|
+
check?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BuildIndexResult {
|
|
32
|
+
bundlesPath: string
|
|
33
|
+
manifestsPath: string
|
|
34
|
+
bundlesSigPath: string
|
|
35
|
+
manifestsSigPath: string
|
|
36
|
+
/** 入索引的方案数 */
|
|
37
|
+
solutions: number
|
|
38
|
+
/** 因缺内嵌 manifest 等原因被跳过的文件名 */
|
|
39
|
+
skipped: string[]
|
|
40
|
+
checkPassed?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function buildIndex(opts: BuildIndexOptions = {}): Promise<BuildIndexResult> {
|
|
44
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
45
|
+
const layout = resolveCdnLayout(projectRoot);
|
|
46
|
+
if (!fs.existsSync(layout.dir))
|
|
47
|
+
throw new Error(`[ojm] CDN 目录不存在:${layout.dir}(先跑 ojm market publish,或检查 config 的 /cdn 挂载)`);
|
|
48
|
+
|
|
49
|
+
const files = fs.readdirSync(layout.dir).filter(f => f.endsWith(".tgz")).sort();
|
|
50
|
+
const packages: IndexedPackage[] = [];
|
|
51
|
+
const skipped: string[] = [];
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const abs = path.join(layout.dir, file);
|
|
54
|
+
const bytes = new Uint8Array(fs.readFileSync(abs));
|
|
55
|
+
let manifest: Record<string, unknown> | null = null;
|
|
56
|
+
try {
|
|
57
|
+
manifest = readEmbeddedManifest(bytes);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
console.warn(`[ojm] 跳过 ${file}:${(error as Error).message}`);
|
|
61
|
+
}
|
|
62
|
+
const name = typeof manifest?.name === "string" ? manifest.name as string : "";
|
|
63
|
+
const version = typeof manifest?.version === "string" ? manifest.version as string : "";
|
|
64
|
+
if (manifest === null || name === "" || version === "") {
|
|
65
|
+
skipped.push(file);
|
|
66
|
+
console.warn(`[ojm] 跳过 ${file}:包内缺 manifest.yaml 或缺少 name/version`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
packages.push({
|
|
70
|
+
name,
|
|
71
|
+
version,
|
|
72
|
+
file,
|
|
73
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
74
|
+
size: bytes.byteLength,
|
|
75
|
+
manifest,
|
|
76
|
+
signature: readSidecarSig(abs),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const data = buildIndexData(packages);
|
|
81
|
+
const bundlesText = normalizeYaml(data.bundles);
|
|
82
|
+
const manifestsText = normalizeYaml(data.manifests);
|
|
83
|
+
const bundlesPath = path.join(layout.dir, "bundles.yaml");
|
|
84
|
+
const manifestsPath = path.join(layout.dir, "manifests.yaml");
|
|
85
|
+
fs.writeFileSync(bundlesPath, bundlesText);
|
|
86
|
+
fs.writeFileSync(manifestsPath, manifestsText);
|
|
87
|
+
|
|
88
|
+
// 索引是多人协作产物:格式再 determinism 一次(recomputeIndex 即 sortKeysDeep + 确定性 stringify)
|
|
89
|
+
const signed = signIndexes({ bundlesPath, manifestsPath, id: opts.id, key: opts.key, privateKeyPem: opts.privateKeyPem });
|
|
90
|
+
|
|
91
|
+
console.log(`[ojm] 索引已重建:${data.bundles.solutions.length} 个方案 / ${packages.length} 个版本(来源 ${files.length} 个 tgz)`);
|
|
92
|
+
if (skipped.length > 0)
|
|
93
|
+
console.warn(`[ojm] ${skipped.length} 个包被跳过:${skipped.join(", ")}`);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
bundlesPath,
|
|
97
|
+
manifestsPath,
|
|
98
|
+
bundlesSigPath: signed.bundlesSigPath,
|
|
99
|
+
manifestsSigPath: signed.manifestsSigPath,
|
|
100
|
+
solutions: data.bundles.solutions.length,
|
|
101
|
+
skipped,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 读包级 detached 签名侧车(`<tgz>.sig`),转成 Form B `archive.signature`。
|
|
107
|
+
*
|
|
108
|
+
* 为什么非读不可:安装器 verifying 段只认 Form B 的 archive.signature(同目录侧车不参与判定),
|
|
109
|
+
* 没有它一律 `sig_missing` 硬 fail-closed——索引是「包 + 签名」的聚合视图,少带签名等于把包报成不可装。
|
|
110
|
+
*/
|
|
111
|
+
function readSidecarSig(tgzPath: string): Record<string, unknown> | undefined {
|
|
112
|
+
const sigPath = `${tgzPath}.sig`;
|
|
113
|
+
if (!fs.existsSync(sigPath))
|
|
114
|
+
return undefined;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as unknown;
|
|
117
|
+
return isObj(parsed) ? parsed as Record<string, unknown> : undefined;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
console.warn(`[ojm] ${sigPath} 不是合法 JSON,已按无签名处理(该包会被安装器以 sig_missing 拒装)`);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
126
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 确定性 stringify:键序归一 + 无软换行,和既有 publish 的索引口径逐字一致 */
|
|
130
|
+
function normalizeYaml(value: unknown): string {
|
|
131
|
+
return recomputeIndex(yamlStringify(value, { lineWidth: 0 }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function signIndexes(args: { bundlesPath: string, manifestsPath: string, id?: string, key?: string, privateKeyPem?: string }): { bundlesSigPath: string, manifestsSigPath: string } {
|
|
135
|
+
const pem = args.privateKeyPem ?? readPemFrom(args.id, args.key);
|
|
136
|
+
const keyId = args.id ?? "";
|
|
137
|
+
if (keyId === "")
|
|
138
|
+
throw new Error("[ojm] 重建索引须给签名身份:--id <publisher.id>(.sig 的发布方标识;C2-37 命名空间)");
|
|
139
|
+
const bundlesText = fs.readFileSync(args.bundlesPath, "utf-8");
|
|
140
|
+
const manifestsText = fs.readFileSync(args.manifestsPath, "utf-8");
|
|
141
|
+
const bundlesSig = signIndex("bundles", bundlesText, pem, keyId);
|
|
142
|
+
const manifestsSig = signIndex("manifests", manifestsText, pem, keyId);
|
|
143
|
+
const bundlesSigPath = `${args.bundlesPath}.sig`;
|
|
144
|
+
const manifestsSigPath = `${args.manifestsPath}.sig`;
|
|
145
|
+
fs.writeFileSync(bundlesSigPath, `${JSON.stringify(bundlesSig, null, 2)}\n`);
|
|
146
|
+
fs.writeFileSync(manifestsSigPath, `${JSON.stringify(manifestsSig, null, 2)}\n`);
|
|
147
|
+
console.log(`[ojm] 索引签名已写出(发布方 ${keyId})`);
|
|
148
|
+
return { bundlesSigPath, manifestsSigPath };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 从身份目录或私钥文件读出 PEM(磁盘路径是常规场景;隔离场景应直接传内存 PEM) */
|
|
152
|
+
function readPemFrom(id?: string, key?: string): string {
|
|
153
|
+
const fsKey = key ?? (id === undefined ? undefined : path.join(os.homedir(), ".ojm", "keys", id, "private.pem"));
|
|
154
|
+
if (fsKey === undefined)
|
|
155
|
+
throw new Error("[ojm] 缺少签名身份:--id <publisher.id> 或 --key <pem 路径>");
|
|
156
|
+
if (!fs.existsSync(fsKey))
|
|
157
|
+
throw new Error(`[ojm] 私钥文件不存在:${fsKey}(用 ojm keygen --id ${id} 生成)`);
|
|
158
|
+
return fs.readFileSync(fsKey, "utf-8");
|
|
159
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market CDN 的布局单点:**目录在哪 + 基址是什么**只在这里算(计划 §4.3 单一真相表)。
|
|
3
|
+
*
|
|
4
|
+
* 两条不能破的规矩:
|
|
5
|
+
* 1. **不许硬编码端口/主机/挂载路径**——全部从 `api/config.yaml` 读。
|
|
6
|
+
* dev 形态下 `/cdn` 只在 oj 子进程(`server.port`)上成立,dev server 自己(5174)
|
|
7
|
+
* 会把 `/cdn/*` 交给静态层、未命中就回落 index.html(设计 §3.1.1);写成相对路径或
|
|
8
|
+
* 写死 9778,市场页拿到的会是 HTML 而不是索引 YAML。
|
|
9
|
+
* 2. **基址无尾斜杠**——`market/api.ts` 按 `${base}/bundles.yaml` 拼。
|
|
10
|
+
*
|
|
11
|
+
* 文本版(`resolveCdnLayoutFromConfig`)与工程版(`resolveCdnLayout`)分工:前者纯函数便于单测,
|
|
12
|
+
* 后者负责读盘。取值口径只有前者一份,后者不重算。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { parse as yamlParse } from "yaml";
|
|
18
|
+
|
|
19
|
+
import { resolveConfigPath } from "../layout";
|
|
20
|
+
|
|
21
|
+
/** 本地模拟 CDN 的挂载前缀(模板 api/config.yaml 随 Jinfirst 发布) */
|
|
22
|
+
export const CDN_MOUNT_PREFIX = "/cdn";
|
|
23
|
+
|
|
24
|
+
export interface CdnLayout {
|
|
25
|
+
/** cdn 目录绝对路径(由挂载的 `web` 相对 config 所在目录解析) */
|
|
26
|
+
dir: string
|
|
27
|
+
/** 挂载前缀,无尾斜杠(如 `/cdn`) */
|
|
28
|
+
prefix: string
|
|
29
|
+
/** 市场基址:`http://<host>:<port><prefix>`,无尾斜杠 */
|
|
30
|
+
baseUrl: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 从工程根出发解析(会读 `api/config.yaml`) */
|
|
34
|
+
export function resolveCdnLayout(projectRoot: string): CdnLayout {
|
|
35
|
+
const configPath = resolveConfigPath(projectRoot);
|
|
36
|
+
return resolveCdnLayoutFromConfig(fs.readFileSync(configPath, "utf-8"), configPath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 从 config 文本解析(纯函数,不落 IO) */
|
|
40
|
+
export function resolveCdnLayoutFromConfig(configText: string, configPath: string): CdnLayout {
|
|
41
|
+
const doc = isObj(yamlParse(configText)) ? yamlParse(configText) as Record<string, unknown> : {};
|
|
42
|
+
const mount = findCdnMount(mountsOf(doc), configPath);
|
|
43
|
+
const configDir = path.dirname(configPath);
|
|
44
|
+
|
|
45
|
+
const web = mount.web;
|
|
46
|
+
if (typeof web !== "string" || web.trim() === "") {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`[ojm] ${configPath} 的 /cdn 挂载缺少 web 目录(当前是 api 挂载或目录为空)。\n`
|
|
49
|
+
+ "下一步:把这一条改成\n"
|
|
50
|
+
+ " - prefix: /cdn\n"
|
|
51
|
+
+ " web: ../cdn\n",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const prefix = normalizePrefix(String(mount.prefix));
|
|
56
|
+
const port = readPort(doc, configPath);
|
|
57
|
+
const host = readHost(doc);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
dir: path.resolve(configDir, web),
|
|
61
|
+
prefix,
|
|
62
|
+
baseUrl: `http://${host}:${port}${prefix}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
67
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mountsOf(doc: Record<string, unknown>): unknown[] {
|
|
71
|
+
const mounts = doc.mounts;
|
|
72
|
+
return Array.isArray(mounts) ? mounts : [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 前缀归一化:补前导斜杠、去尾斜杠(拼接口径统一) */
|
|
76
|
+
function normalizePrefix(raw: string): string {
|
|
77
|
+
let prefix = raw.trim();
|
|
78
|
+
if (!prefix.startsWith("/"))
|
|
79
|
+
prefix = `/${prefix}`;
|
|
80
|
+
while (prefix.length > 1 && prefix.endsWith("/"))
|
|
81
|
+
prefix = prefix.slice(0, -1);
|
|
82
|
+
return prefix;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function findCdnMount(mounts: unknown[], configPath: string): Record<string, unknown> {
|
|
86
|
+
const hit = mounts.find((entry) => {
|
|
87
|
+
if (!isObj(entry) || typeof entry.prefix !== "string")
|
|
88
|
+
return false;
|
|
89
|
+
return normalizePrefix(entry.prefix) === CDN_MOUNT_PREFIX;
|
|
90
|
+
});
|
|
91
|
+
if (hit)
|
|
92
|
+
return hit as Record<string, unknown>;
|
|
93
|
+
|
|
94
|
+
throw new Error(
|
|
95
|
+
`[ojm] ${configPath} 的 mounts 里没有 /cdn 条目——本地模拟 CDN 无处可挂。\n`
|
|
96
|
+
+ "出现这条错误说明该工程的 config 被手改过或来自旧模板。\n"
|
|
97
|
+
+ "下一步:把下面两行粘到 mounts: 段里(位置在 /api 之后、任何 `/` 之前):\n"
|
|
98
|
+
+ " - prefix: /cdn\n"
|
|
99
|
+
+ " web: ../cdn\n",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 端口取自 config,口径与 oj-config.readOjPort 一致(缺失/非法即人称报错) */
|
|
104
|
+
function readPort(doc: Record<string, unknown>, configPath: string): number {
|
|
105
|
+
const server = isObj(doc.server) ? doc.server as Record<string, unknown> : {};
|
|
106
|
+
const raw = server.port;
|
|
107
|
+
const port = typeof raw === "number" || typeof raw === "string" ? Number(raw) : Number.NaN;
|
|
108
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`[ojm] ${configPath} 缺少合法的 server.port。\n`
|
|
111
|
+
+ "该文件由 ojm init 生成,手动改动后请保留端口配置(oj 代码默认 9778)。",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return port;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 主机缺省回落到 oj 模板显式钉死的 127.0.0.1(config.yaml 注释解释过为何不用 localhost) */
|
|
118
|
+
function readHost(doc: Record<string, unknown>): string {
|
|
119
|
+
const server = isObj(doc.server) ? doc.server as Record<string, unknown> : {};
|
|
120
|
+
const host = server.host;
|
|
121
|
+
return typeof host === "string" && host.trim() !== "" ? host.trim() : "127.0.0.1";
|
|
122
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ojm market <sub>` 参数面(T3–T5 的命令入口)。
|
|
3
|
+
*
|
|
4
|
+
* 只做参数解析 + 调库函数:所有真逻辑都在同目录的 publish / build-index / setup 里,
|
|
5
|
+
* 这里不掺业务,便于测试直接打函数而不走 argv。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
|
|
10
|
+
import { buildIndex } from "./build-index";
|
|
11
|
+
import { publishPackage } from "./publish";
|
|
12
|
+
import { setupMarketUrl } from "./setup";
|
|
13
|
+
import { trialCommand } from "./trial";
|
|
14
|
+
import { verifyMarket } from "./verify";
|
|
15
|
+
import { walkthroughCommand } from "./walkthrough";
|
|
16
|
+
|
|
17
|
+
interface Flags {
|
|
18
|
+
id?: string
|
|
19
|
+
key?: string
|
|
20
|
+
fixture?: string
|
|
21
|
+
real?: string
|
|
22
|
+
url?: string
|
|
23
|
+
user?: string
|
|
24
|
+
pass?: string
|
|
25
|
+
newPass?: string
|
|
26
|
+
rebuildIndex?: boolean
|
|
27
|
+
check?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const VALUE_FLAGS = new Set(["--id", "--key", "--fixture", "--real", "--url", "--user", "--pass", "--new-pass"]);
|
|
31
|
+
|
|
32
|
+
function parseFlags(argv: string[]): Flags {
|
|
33
|
+
const flags: Flags = {};
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const arg = argv[i]!;
|
|
36
|
+
if (arg === "--rebuild-index") {
|
|
37
|
+
flags.rebuildIndex = true;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (arg === "--check") {
|
|
41
|
+
flags.check = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
45
|
+
const value = argv[i + 1] ?? "";
|
|
46
|
+
i++;
|
|
47
|
+
if (arg === "--id")
|
|
48
|
+
flags.id = value;
|
|
49
|
+
else if (arg === "--key")
|
|
50
|
+
flags.key = value;
|
|
51
|
+
else if (arg === "--fixture")
|
|
52
|
+
flags.fixture = value;
|
|
53
|
+
else if (arg === "--real")
|
|
54
|
+
flags.real = value;
|
|
55
|
+
else if (arg === "--url")
|
|
56
|
+
flags.url = value;
|
|
57
|
+
else if (arg === "--user")
|
|
58
|
+
flags.user = value;
|
|
59
|
+
else if (arg === "--pass")
|
|
60
|
+
flags.pass = value;
|
|
61
|
+
else if (arg === "--new-pass")
|
|
62
|
+
flags.newPass = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return flags;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function marketCommand(sub: string, argv: string[]): Promise<void> {
|
|
69
|
+
const flags = parseFlags(argv);
|
|
70
|
+
switch (sub) {
|
|
71
|
+
case "publish": {
|
|
72
|
+
await publishPackage({
|
|
73
|
+
fixture: flags.fixture,
|
|
74
|
+
real: flags.real,
|
|
75
|
+
id: flags.id,
|
|
76
|
+
key: flags.key,
|
|
77
|
+
check: flags.check,
|
|
78
|
+
});
|
|
79
|
+
if (flags.rebuildIndex === true)
|
|
80
|
+
await buildIndex({ id: flags.id, key: flags.key, check: flags.check });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
case "verify": {
|
|
84
|
+
const result = verifyMarket({ file: flags.fixture === undefined ? undefined : flags.fixture, rebuildCheck: flags.rebuildIndex });
|
|
85
|
+
for (const check of result.checks)
|
|
86
|
+
console.log(`${check.ok ? "✓" : "✗"} ${check.item}:${check.detail}`);
|
|
87
|
+
if (result.drift !== undefined && result.drift.length > 0) {
|
|
88
|
+
for (const line of result.drift)
|
|
89
|
+
console.log(`✗ 索引漂移:${line}`);
|
|
90
|
+
}
|
|
91
|
+
console.log(result.conclusion);
|
|
92
|
+
if (!result.passed)
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
case "trial": {
|
|
97
|
+
await trialCommand(argv);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
case "walkthrough": {
|
|
101
|
+
await walkthroughCommand(argv);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
case "build-index": {
|
|
105
|
+
await buildIndex({ id: flags.id, key: flags.key, check: flags.check });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "setup": {
|
|
109
|
+
const result = await setupMarketUrl({ url: flags.url, user: flags.user, pass: flags.pass, newPass: flags.newPass });
|
|
110
|
+
console.log(`[ojm] 站点 ${result.siteUrl} 的市场地址已指向 ${result.cdnBaseUrl}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
throw new Error(`[ojm] 未知子命令:ojm market ${sub}\n可用:setup / publish / build-index`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `ojm market` 无子命令时的提示(与 usage.ts 同源风格) */
|
|
119
|
+
export function marketUsage(): string {
|
|
120
|
+
return [
|
|
121
|
+
"ojm market setup [--url <站点地址>] [--user <u> --pass <p>] [--new-pass <p>] 把市场地址指向本地 cdn/",
|
|
122
|
+
"ojm market publish [--fixture <name>@<version>] [--real <方案工程>] [--id <key>] [--rebuild-index]",
|
|
123
|
+
"ojm market build-index [--id <key>] [--check] 扫描 cdn/ 重建索引",
|
|
124
|
+
"ojm market verify [--file <tgz>] [--rebuild-index] 离线验包(不起站点、不装)",
|
|
125
|
+
"ojm market trial [--fixture <name>@<version>] [--keep] 隔离站点试装(跑完即毁)",
|
|
126
|
+
"ojm market walkthrough [--only <op>] [--strict] [--keep] 隔离站点跑全套市场操作并出 AC 表",
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 进程入口:从 argv 取子命令(index.ts 负责 dispatch 到此处) */
|
|
131
|
+
export async function runMarket(argv: string[]): Promise<void> {
|
|
132
|
+
const sub = argv[0] ?? "";
|
|
133
|
+
if (sub === "" || sub === "--help" || sub === "-h") {
|
|
134
|
+
console.log(marketUsage());
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await marketCommand(sub, argv.slice(1));
|
|
138
|
+
void process;
|
|
139
|
+
}
|