@oj-module/cli 0.2.29 → 0.2.42
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/manual.md +5 -5
- package/devkit/module-dev-and-cdn-playground.md +1 -1
- package/package.json +2 -2
- package/shell-dist/assets/{index-CnJsHOe-.js → index-FYt9CjFd.js} +50 -17
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +23 -14
- package/src/__tests__/list.test.ts +176 -0
- package/src/__tests__/registry-cli.test.ts +522 -0
- package/src/__tests__/registry-export.test.ts +319 -0
- package/src/__tests__/registry-import.test.ts +336 -0
- package/src/__tests__/registry-sign.test.ts +352 -0
- package/src/cli-output.ts +81 -0
- package/src/cli.ts +298 -39
- package/src/config-cmd.ts +100 -0
- package/src/contract/__tests__/handler-scan.test.ts +74 -2
- package/src/contract/handler-scan.ts +27 -2
- package/src/enable.ts +1 -1
- package/src/exit-codes.ts +33 -0
- package/src/index.ts +5 -9
- package/src/install.ts +120 -38
- package/src/lifecycle/__tests__/authoring.test.ts +6 -5
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
- package/src/lifecycle/__tests__/helpers.ts +10 -6
- package/src/lifecycle/__tests__/install.test.ts +72 -15
- package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
- package/src/lifecycle/__tests__/publish.test.ts +174 -33
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
- package/src/lifecycle/__tests__/sign.test.ts +9 -8
- package/src/lifecycle/__tests__/smoke.test.ts +1 -1
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/lifecycle/sign.ts +8 -7
- package/src/list.ts +146 -0
- package/src/manifest-contract.ts +11 -5
- package/src/market/__tests__/bootstrap.test.ts +4 -1
- package/src/market/__tests__/build-index.test.ts +529 -42
- package/src/market/__tests__/fixture.test.ts +17 -1
- package/src/market/__tests__/publish.test.ts +8 -7
- package/src/market/__tests__/setup.test.ts +4 -4
- package/src/market/__tests__/sharding.test.ts +90 -0
- package/src/market/__tests__/verify.test.ts +205 -12
- package/src/market/__tests__/yank-ledger.test.ts +60 -0
- package/src/market/bootstrap.ts +8 -3
- package/src/market/build-index.ts +407 -40
- package/src/market/cdn-layout.ts +3 -2
- package/src/market/command.ts +40 -6
- package/src/market/failure-playbook.ts +1 -1
- package/src/market/fixture.ts +20 -3
- package/src/market/index-builder.ts +120 -47
- package/src/market/publish.ts +3 -1
- package/src/market/setup.ts +3 -3
- package/src/market/sharding.ts +94 -0
- package/src/market/trial.ts +1 -1
- package/src/market/verify.ts +213 -50
- package/src/market/walkthrough.ts +7 -6
- package/src/market/yank-ledger.ts +91 -0
- package/src/publish.ts +250 -38
- package/src/registry-export.ts +405 -0
- package/src/registry-import.ts +410 -0
- package/src/registry-sidecar.ts +84 -0
- package/src/registry-sign.ts +322 -0
- package/src/registry-source.ts +192 -0
- package/src/sign.ts +13 -10
- package/src/site.ts +4 -4
- package/src/trust.ts +1 -1
- package/src/uninstall.ts +14 -2
- package/src/usage.ts +130 -25
- package/src/view.ts +94 -0
- package/templates/cdn/README.md +35 -5
- package/templates/config.yaml +10 -0
- package/templates/init.include.yaml +1 -1
- package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
- package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
- package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
- package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
- package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
- package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
- package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
- package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
- package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
- package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
- package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
- package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
- package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
- package/templates/src/_platform/api/manifest.yaml +1 -0
- package/templates/src/_platform/api/market/api.ts +362 -99
- package/templates/src/_platform/api/market/index-source.ts +112 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
- package/templates/src/_platform/api/schema.yaml +8 -0
- package/templates/src/_platform/api/solutions/api.ts +8 -3
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
- package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
- package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
- package/templates/src/oj-market/web/locales/en-US.json +1 -0
- package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
- package/templates/src/oj-market/web/types.ts +3 -1
- package/templates/src/system/web/entry.ts +5 -2
- package/templates/tests/lifecycle/installer.spec.ts +24 -0
- package/templates/tests/lifecycle/market-api.spec.ts +641 -85
- package/templates/tests/lifecycle/preflight.spec.ts +52 -0
- package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
- package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
- package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
- package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
- package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
- package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
- package/templates/tests/rbac-solution-perms.spec.ts +41 -0
- package/vendor/host-versions.json +3 -3
package/src/enable.ts
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// 退出码契约(REG-R12):0 成功 / 1 业务失败 / 2 用法错误。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要三档而不是「非零即失败」:CI 里这两类失败的处置完全不同——
|
|
4
|
+
// · 1 = 命令跑通了、但业务说不(包不存在、签名不符、站点拒绝)→ 看输出修数据;
|
|
5
|
+
// · 2 = 命令根本没跑(选项拼错、必需参数缺失、互斥选项同时给)→ 改脚本。
|
|
6
|
+
// 混在 1 里,CI 只能靠解析人话文案来分辨,而文案是会改的。
|
|
7
|
+
//
|
|
8
|
+
// 三类例外的边界(`exitCodeOf`):
|
|
9
|
+
// · `UsageError` → 2(本文件显式构造的「用法」类错误,含 cac 的未知选项/未知命令);
|
|
10
|
+
// · 其余一切 Error → 1(业务失败;未预期异常也归这里,不假装是用法问题);
|
|
11
|
+
// · 成功 → 0(调用方不显式设置,进程自然退 0)。
|
|
12
|
+
//
|
|
13
|
+
// 纪律:只有**能指出改哪一行命令行**的错误才配 `UsageError`。拿不准就归 1——
|
|
14
|
+
// 把业务失败误判成 2 会让 CI 去改脚本,比反过来更难查。
|
|
15
|
+
|
|
16
|
+
export const EXIT_OK = 0;
|
|
17
|
+
export const EXIT_FAILURE = 1;
|
|
18
|
+
export const EXIT_USAGE = 2;
|
|
19
|
+
|
|
20
|
+
/** 用法错误:消息必须是「怎么改命令行」的人话(含正确语法) */
|
|
21
|
+
export class UsageError extends Error {
|
|
22
|
+
readonly exitCode = EXIT_USAGE;
|
|
23
|
+
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "UsageError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 错误 → 退出码(`reportCliError` 与 index.ts 的兜底共用这一个判定) */
|
|
31
|
+
export function exitCodeOf(error: unknown): number {
|
|
32
|
+
return error instanceof UsageError ? EXIT_USAGE : EXIT_FAILURE;
|
|
33
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
|
-
import { buildCli, localizeCacError, reportCliError,
|
|
2
|
+
import { buildCli, localizeCacError, reportCliError, reportUnknownCommand, rewriteHelpArgv } from "./cli";
|
|
3
3
|
import { isKnownCommand, resolveLang, usageText } from "./usage";
|
|
4
4
|
import { readCliVersion } from "./versions";
|
|
5
5
|
|
|
@@ -14,7 +14,7 @@ async function main() {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const rewritten = rewriteHelpArgv(argv);
|
|
17
|
-
const cli = buildCli(process.cwd(), lang);
|
|
17
|
+
const cli = buildCli(process.cwd(), lang, { argv: rewritten });
|
|
18
18
|
let unknown: string | undefined;
|
|
19
19
|
cli.on("command:*", () => {
|
|
20
20
|
// cac 在 --help 输出后会 unsetMatchedCommand 再发 command:*(args[0] 可能是子命令,如 verify)——
|
|
@@ -25,13 +25,9 @@ async function main() {
|
|
|
25
25
|
});
|
|
26
26
|
cli.parse(["node", "ojm", ...rewritten]);
|
|
27
27
|
|
|
28
|
-
// 未知命令:help 形态报「未知命令」、否则打帮助;均 exit
|
|
28
|
+
// 未知命令:help 形态报「未知命令」、否则打帮助;均 exit 2(裸 ojm 打帮助退 0 不变)
|
|
29
29
|
if (unknown !== undefined) {
|
|
30
|
-
const r =
|
|
31
|
-
if (cli.options.help)
|
|
32
|
-
console.error(r.text);
|
|
33
|
-
else
|
|
34
|
-
console.log(r.text);
|
|
30
|
+
const r = reportUnknownCommand(unknown, lang, !!cli.options.help, rewritten);
|
|
35
31
|
process.exit(r.code);
|
|
36
32
|
}
|
|
37
33
|
if (argv.length === 0)
|
|
@@ -40,4 +36,4 @@ async function main() {
|
|
|
40
36
|
|
|
41
37
|
main().catch((error: unknown) => reportCliError(localizeCacError(error, resolveLang()), (code) => {
|
|
42
38
|
process.exit(code);
|
|
43
|
-
}));
|
|
39
|
+
}, process.argv.slice(2)));
|
package/src/install.ts
CHANGED
|
@@ -8,8 +8,67 @@ import type { TaskRecord } from "./site";
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import process from "node:process";
|
|
11
|
+
import { UsageError } from "./exit-codes";
|
|
12
|
+
import { originOf, resolveRegistrySource } from "./registry-source";
|
|
11
13
|
import { callApi, isFutureSchema, login, pollTask, resolveSite, TASK_SCHEMA_VERSION_KNOWN, unwrapEnvelope } from "./site";
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* REG-R5 / S5-2②:安装目标的**唯一**归一点(grep 可证——全仓只有这一处把 `@scope/name`
|
|
17
|
+
* 换算成物理名 slug)。服务端 install 只认 slug(收到 `@`/`/` 即 400 `invalid_physical_name`),
|
|
18
|
+
* 所以「目录键 → 物理名」的换算必须只在 CLI 完成一次,绝不分层;服务端是最后一道闸。
|
|
19
|
+
*
|
|
20
|
+
* 形态:`@scope/name[@version]` / `scope/name[@version]` → slug=`scope-name`;裸 `slug[@version]`
|
|
21
|
+
* 视为已是物理名,原样透传。字符集与下游 `naming.ts` 的 `SEGMENT_RE`/`SLUG_RE` 同口径
|
|
22
|
+
* (小写字母开头 + 小写字母/数字/连字符,slug 上限 64),保证 CLI 推导的 slug 与发布侧落盘名逐字一致。
|
|
23
|
+
*/
|
|
24
|
+
const SEGMENT_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
25
|
+
const SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
26
|
+
|
|
27
|
+
export function parseRegistryTarget(raw: string): { slug: string, version: string | null } {
|
|
28
|
+
let rest = String(raw ?? "").trim().replace(/^@/, "");
|
|
29
|
+
if (rest === "")
|
|
30
|
+
throw new UsageError(`[ojm] 非法安装目标:「${raw}」(registry 源用法:ojm install @<scope>/<name>[@version] --registry <url>)`);
|
|
31
|
+
// 版本:尾随 @(与 scope 前缀的 @ 不冲突——先剥掉前缀 @ 再取 lastIndexOf)
|
|
32
|
+
let version: string | null = null;
|
|
33
|
+
const at = rest.lastIndexOf("@");
|
|
34
|
+
if (at > 0) {
|
|
35
|
+
version = rest.slice(at + 1);
|
|
36
|
+
rest = rest.slice(0, at);
|
|
37
|
+
}
|
|
38
|
+
if (rest.includes("/")) {
|
|
39
|
+
const slash = rest.indexOf("/");
|
|
40
|
+
const scope = rest.slice(0, slash);
|
|
41
|
+
const name = rest.slice(slash + 1);
|
|
42
|
+
if (!SEGMENT_RE.test(scope) || !SEGMENT_RE.test(name))
|
|
43
|
+
throw new UsageError(`[ojm] 目录键「${raw}」不合法:scope 与 name 都须匹配 ${SEGMENT_RE.source}(REG-R5)`);
|
|
44
|
+
const slug = `${scope}-${name}`;
|
|
45
|
+
if (!SLUG_RE.test(slug))
|
|
46
|
+
throw new UsageError(`[ojm] 物理名「${slug}」不合法:须匹配 ${SLUG_RE.source}(slug 上限 64 字符)`);
|
|
47
|
+
return { slug, version: version === "" ? null : version };
|
|
48
|
+
}
|
|
49
|
+
if (!SLUG_RE.test(rest))
|
|
50
|
+
throw new UsageError(`[ojm] 物理名「${rest}」不合法:须匹配 ${SLUG_RE.source}(裸 slug 须为小写字母开头的小写字母/数字/连字符)`);
|
|
51
|
+
return { slug: rest, version: version === "" ? null : version };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* REG-R9② 旧站点拒:站点平台 API 版本是否 ≥ `min`。`undefined`(meta 端点缺失/未注入)按
|
|
56
|
+
* 旧版本处理——宁可不装,不静默降级到不支持 registry 源的站点。
|
|
57
|
+
*/
|
|
58
|
+
export function isSiteApiAtLeast(apiVersion: string | undefined, min: string): boolean {
|
|
59
|
+
if (apiVersion === undefined)
|
|
60
|
+
return false;
|
|
61
|
+
const parse = (v: string): [number, number, number] | null => {
|
|
62
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
63
|
+
return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
64
|
+
};
|
|
65
|
+
const a = parse(apiVersion);
|
|
66
|
+
const b = parse(min);
|
|
67
|
+
if (a === null || b === null)
|
|
68
|
+
return false;
|
|
69
|
+
return a[0] > b[0] || (a[0] === b[0] && (a[1] > b[1] || (a[1] === b[1] && a[2] >= b[2])));
|
|
70
|
+
}
|
|
71
|
+
|
|
13
72
|
export type Locale = "zh-CN" | "en-US";
|
|
14
73
|
|
|
15
74
|
/** 契约附录 A 十阶段指引(zh/en 双语列;文案单一来源 = 契约附录 A 表,此处为渲染镜像) */
|
|
@@ -87,8 +146,8 @@ const VERIFY_CODE_COPY: Record<string, { reason: { zh: string, en: string }, hin
|
|
|
87
146
|
},
|
|
88
147
|
sig_format: {
|
|
89
148
|
reason: {
|
|
90
|
-
zh: "签名编码违规:公钥非 64 位 hex、签名非标准 base64(禁 base64url),或规范串版本标记非
|
|
91
|
-
en: "Signature encoding violation: public key is not 64-hex, signature is not standard base64 (base64url forbidden), or the canonical-version marker is not
|
|
149
|
+
zh: "签名编码违规:公钥非 64 位 hex、签名非标准 base64(禁 base64url),或规范串版本标记非 v2",
|
|
150
|
+
en: "Signature encoding violation: public key is not 64-hex, signature is not standard base64 (base64url forbidden), or the canonical-version marker is not v2",
|
|
92
151
|
},
|
|
93
152
|
hint: {
|
|
94
153
|
zh: "请让发布方使用标准签名工具(ojm sign)重新签名",
|
|
@@ -184,7 +243,7 @@ export function parseTarget(target: string): InstallTarget {
|
|
|
184
243
|
}
|
|
185
244
|
|
|
186
245
|
export interface InstallOptions {
|
|
187
|
-
|
|
246
|
+
siteUrl?: string
|
|
188
247
|
user?: string
|
|
189
248
|
pass?: string
|
|
190
249
|
/** file 源身份覆盖(文件名不可解析时的显式出口);cdn 源同名冲突时报错提示二选一 */
|
|
@@ -193,6 +252,8 @@ export interface InstallOptions {
|
|
|
193
252
|
version?: string
|
|
194
253
|
/** 终态文案语言(缺省 zh-CN) */
|
|
195
254
|
locale?: string
|
|
255
|
+
/** REG-R9②:从指定 registry 源安装(白名单在服务端;旧站点 API < 0.2.0 不支持 → exit 1) */
|
|
256
|
+
registry?: string
|
|
196
257
|
/** 测试注入;缺省 globalThis.fetch */
|
|
197
258
|
fetchFn?: typeof fetch
|
|
198
259
|
/** 测试注入;缺省真睡 */
|
|
@@ -206,48 +267,69 @@ export interface InstallOptions {
|
|
|
206
267
|
*/
|
|
207
268
|
export async function installCommand(target: string, opts: InstallOptions = {}): Promise<TaskRecord> {
|
|
208
269
|
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
209
|
-
const parsed = parseTarget(target);
|
|
210
270
|
const { base, user, pass } = resolveSite(opts);
|
|
211
271
|
const token = await login(fetchFn, base, user, pass);
|
|
212
272
|
|
|
213
273
|
let response: { task_id: string };
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
form.append("sig", new Blob([new Uint8Array(fs.readFileSync(sigPath))], { type: "application/json" }), path.basename(sigPath));
|
|
231
|
-
console.log(`[ojm] 已随包上传签名侧车:${path.basename(sigPath)}`);
|
|
232
|
-
}
|
|
233
|
-
else {
|
|
234
|
-
console.log(`[ojm] 未发现签名侧车(${path.basename(sigPath)})——宿主验签硬 fail-closed,未签名包将被拒(sig_missing);签名方法见 devkit「签名与信任」`);
|
|
235
|
-
}
|
|
236
|
-
const res = await fetchFn(`${base}/api/_platform/market/install`, {
|
|
237
|
-
method: "POST",
|
|
238
|
-
headers: { authorization: `Bearer ${token}` },
|
|
239
|
-
body: form,
|
|
274
|
+
if (opts.registry !== undefined) {
|
|
275
|
+
// REG-R9②:registry 源安装。归一(目录键 → slug)只在此一处;旧站点拒;白名单在服务端。
|
|
276
|
+
const src = resolveRegistrySource({ flag: opts.registry });
|
|
277
|
+
const { slug, version } = parseRegistryTarget(target);
|
|
278
|
+
if (opts.name !== undefined && opts.name !== slug)
|
|
279
|
+
throw new UsageError(`[ojm] --name「${opts.name}」与 registry 目标推导的物理名「${slug}」不一致——registry 源由目标推导身份,请二选一`);
|
|
280
|
+
// 旧站点拒:meta apiVersion < 0.2.0 不支持 registry 源(REG-R9② 升版反噬 F4 的人话面)
|
|
281
|
+
const meta = await callApi<{ apiVersion?: string }>(fetchFn, base, token, "GET", "/api/_platform/market/meta");
|
|
282
|
+
if (!isSiteApiAtLeast(meta.apiVersion, "0.2.0"))
|
|
283
|
+
throw new Error(`[ojm] 目标站点平台 API 版本过低(需 ≥ 0.2.0 才支持 --registry 源安装,实为 ${meta.apiVersion ?? "未知"})——请升级目标站点的 oj-module,或改用 CDN/file 源`);
|
|
284
|
+
const registryOrigin = originOf(src.url) ?? src.url;
|
|
285
|
+
response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", "/api/_platform/market/install", {
|
|
286
|
+
name: slug,
|
|
287
|
+
version: opts.version ?? version,
|
|
288
|
+
source: "registry",
|
|
289
|
+
registry: registryOrigin,
|
|
240
290
|
});
|
|
241
|
-
|
|
291
|
+
console.log(`[ojm] 已从 registry 源提交安装任务(源 ${registryOrigin},物理名 ${slug})`);
|
|
242
292
|
}
|
|
243
293
|
else {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
name
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
294
|
+
const parsed = parseTarget(target);
|
|
295
|
+
if (parsed.kind === "file") {
|
|
296
|
+
const name = opts.name ?? parsed.name;
|
|
297
|
+
if (name === null)
|
|
298
|
+
throw new Error(`[ojm] 无法从文件名推导方案名:${target}——请用 --name <name>(及 --version <version>)显式指定`);
|
|
299
|
+
const version = opts.version ?? parsed.version;
|
|
300
|
+
const bytes = new Uint8Array(fs.readFileSync(parsed.path));
|
|
301
|
+
const form = new FormData();
|
|
302
|
+
form.append("name", name);
|
|
303
|
+
if (version !== null)
|
|
304
|
+
form.append("version", version);
|
|
305
|
+
form.append("source", "file");
|
|
306
|
+
form.append("file", new Blob([bytes], { type: "application/gzip" }), path.basename(parsed.path));
|
|
307
|
+
// detached 侧车签名(P2-3,G4):同目录 <tgz>.sig 自动探测随 multipart 第二文件上传——
|
|
308
|
+
// 缺签即被宿主 G4 拒(sig_missing 人话转述),与 Form B 同一信任模型
|
|
309
|
+
const sigPath = `${parsed.path}.sig`;
|
|
310
|
+
if (fs.existsSync(sigPath)) {
|
|
311
|
+
form.append("sig", new Blob([new Uint8Array(fs.readFileSync(sigPath))], { type: "application/json" }), path.basename(sigPath));
|
|
312
|
+
console.log(`[ojm] 已随包上传签名侧车:${path.basename(sigPath)}`);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
console.log(`[ojm] 未发现签名侧车(${path.basename(sigPath)})——宿主验签硬 fail-closed,未签名包将被拒(sig_missing);签名方法见 devkit「签名与信任」`);
|
|
316
|
+
}
|
|
317
|
+
const res = await fetchFn(`${base}/api/_platform/market/install`, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
headers: { authorization: `Bearer ${token}` },
|
|
320
|
+
body: form,
|
|
321
|
+
});
|
|
322
|
+
response = await unwrapInstall(res);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
if (opts.name !== undefined && opts.name !== parsed.name)
|
|
326
|
+
throw new Error(`[ojm] --name「${opts.name}」与目标「${parsed.name}」不一致——请二选一`);
|
|
327
|
+
response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", "/api/_platform/market/install", {
|
|
328
|
+
name: parsed.name,
|
|
329
|
+
version: opts.version ?? parsed.version,
|
|
330
|
+
source: "cdn",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
251
333
|
}
|
|
252
334
|
|
|
253
335
|
const record = await pollTask(fetchFn, base, token, response.task_id, { sleep: opts.sleep });
|
|
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
8
8
|
import { DEFAULT_KEY_ID, isValidKeyId, keygenCommand } from "../../keygen";
|
|
9
9
|
import { signCommand } from "../../sign";
|
|
10
10
|
import { readPrivateKeyPem } from "../keygen";
|
|
11
|
-
import { fingerprintOfPublicKey, signCanonical as signCanonicalLocal, verifyCanonical } from "../sign";
|
|
11
|
+
import { CANON, fingerprintOfPublicKey, signCanonical as signCanonicalLocal, verifyCanonical } from "../sign";
|
|
12
12
|
|
|
13
13
|
const tmp = path.join(os.tmpdir(), `ojm-authoring-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
14
14
|
|
|
@@ -66,20 +66,21 @@ describe("ojm sign 命令体(detached .sig,G4 载体)", () => {
|
|
|
66
66
|
fs.writeFileSync(TGZ, "demo-archive-bytes\n");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it("签名产出 .sig
|
|
69
|
+
it("签名产出 .sig 六字段(alg/key_id/public_key/value/canon/registry_id),且 verifyCanonical 通过", () => {
|
|
70
70
|
const kp = keygenCommand({ id: "acme.market", out: path.join(tmp, "k"), yes: true, cwd: tmp });
|
|
71
|
-
const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath });
|
|
71
|
+
const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath, registryId: "oj-registry" });
|
|
72
72
|
const sig = JSON.parse(fs.readFileSync(r.sigPath, "utf8"));
|
|
73
73
|
expect(sig.alg).toBe("Ed25519");
|
|
74
74
|
expect(sig.key_id).toBe("acme.market");
|
|
75
75
|
expect(sig.public_key).toBe(kp.publicKeyHex);
|
|
76
|
-
expect(sig.canon).toBe(
|
|
76
|
+
expect(sig.canon).toBe(CANON);
|
|
77
|
+
expect(sig.registry_id).toBe("oj-registry");
|
|
77
78
|
expect(verifyCanonical(sig.public_key, r.canonical, sig.value)).toBe(true);
|
|
78
79
|
});
|
|
79
80
|
|
|
80
81
|
it("--check 离线自检:真签名通过(checkPassed=true);--key+--id 自定义位置", () => {
|
|
81
82
|
const kp = keygenCommand({ id: "acme.market", out: path.join(tmp, "k"), yes: true, cwd: tmp });
|
|
82
|
-
const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath, check: true });
|
|
83
|
+
const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath, check: true, registryId: "oj-registry" });
|
|
83
84
|
expect(r.checkPassed).toBe(true);
|
|
84
85
|
});
|
|
85
86
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"alg": "Ed25519",
|
|
3
3
|
"key_id": "ojm.test",
|
|
4
4
|
"public_key": "6320cd295df4a2cf48b90282daae32e133fefd0459d71af92d6c376889d038ef",
|
|
5
|
-
"value": "
|
|
6
|
-
"canon": "
|
|
5
|
+
"value": "AOtQDT2VLSQrVespj5JLMBYTCdXdWVLKh7mowJJw/mrYvfrlTwnLKvpGIMLvDZYtD+XxlHoRN/lBFVHVirpAAw==",
|
|
6
|
+
"canon": "v2",
|
|
7
|
+
"registry_id": "test.registry"
|
|
7
8
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"alg": "Ed25519",
|
|
3
3
|
"key_id": "ojm.test",
|
|
4
4
|
"public_key": "6320cd295df4a2cf48b90282daae32e133fefd0459d71af92d6c376889d038ef",
|
|
5
|
-
"value": "
|
|
6
|
-
"canon": "
|
|
5
|
+
"value": "oOtQDT2VLSQrVespj5JLMBYTCdXdWVLKh7mowJJw/mrYvfrlTwnLKvpGIMLvDZYtD+XxlHoRN/lBFVHVirpAAw==",
|
|
6
|
+
"canon": "v2",
|
|
7
|
+
"registry_id": "test.registry"
|
|
7
8
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"alg": "Ed25519",
|
|
3
3
|
"key_id": "stranger.unknown",
|
|
4
4
|
"public_key": "a329626d90b337a969f66b101ad8bda4dc95f8c6f000731083147c262e8bbabb",
|
|
5
|
-
"value": "
|
|
6
|
-
"canon": "
|
|
5
|
+
"value": "qexZRu47FY3tGzjdutR0SxJ75v3ybCZNTZJmD/FbQQhhl+QWMaPCo9OKFsTsQHykB6vNXX3T921kqOM81mzkAA==",
|
|
6
|
+
"canon": "v2",
|
|
7
|
+
"registry_id": "test.registry"
|
|
7
8
|
}
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
export declare const FIXTURE_NAME: string;
|
|
3
3
|
export declare const FIXTURE_VERSION: string;
|
|
4
4
|
export declare const CANON: string;
|
|
5
|
+
export declare const FIXTURE_REGISTRY_ID: string;
|
|
5
6
|
export declare const TRUSTED_KEY: { key_id: string, public_key_hex: string };
|
|
6
7
|
export declare const UNKNOWN_KEY: { key_id: string, public_key_hex: string };
|
|
7
|
-
export declare function canonicalString(
|
|
8
|
+
export declare function canonicalString(registryId: string, slug: string, version: string, sha256: string): string;
|
|
8
9
|
export declare function fingerprintOf(publicKeyHex: string): string;
|
|
9
10
|
export declare function signCanonicalB64(privateKey: unknown, canonical: string): string;
|
|
10
11
|
export declare function verifyCanonicalB64(publicKeyHex: string, canonical: string, valueB64: string): boolean;
|
|
@@ -14,8 +15,8 @@ export declare function generate(outDir?: string): {
|
|
|
14
15
|
tgzPath: string
|
|
15
16
|
paths: { tgz: string, good: string, bad: string, unknown: string }
|
|
16
17
|
sig: {
|
|
17
|
-
good: { alg: string, key_id: string, public_key: string, value: string, canon: string }
|
|
18
|
-
bad: { alg: string, key_id: string, public_key: string, value: string, canon: string }
|
|
19
|
-
unknown: { alg: string, key_id: string, public_key: string, value: string, canon: string }
|
|
18
|
+
good: { alg: string, key_id: string, public_key: string, value: string, canon: string, registry_id: string }
|
|
19
|
+
bad: { alg: string, key_id: string, public_key: string, value: string, canon: string, registry_id: string }
|
|
20
|
+
unknown: { alg: string, key_id: string, public_key: string, value: string, canon: string, registry_id: string }
|
|
20
21
|
}
|
|
21
22
|
};
|
|
@@ -30,13 +30,15 @@ const FIXTURE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
30
30
|
|
|
31
31
|
export const FIXTURE_NAME = "demo-solution";
|
|
32
32
|
export const FIXTURE_VERSION = "0.2.0";
|
|
33
|
-
/**
|
|
34
|
-
export const CANON = "
|
|
33
|
+
/** 规范串版本标记(REG-CS3 v2:canon 字段恒 "v2") */
|
|
34
|
+
export const CANON = "v2";
|
|
35
|
+
/** 夹具签发方 registry 身份(REG-CS3 v2 规范串首段,跨仓重放判定真源) */
|
|
36
|
+
export const FIXTURE_REGISTRY_ID = "test.registry";
|
|
35
37
|
const SEED_PREFIX = "ojm-lifecycle-fixture-v1:";
|
|
36
38
|
|
|
37
|
-
/**
|
|
38
|
-
export function canonicalString(
|
|
39
|
-
return `${
|
|
39
|
+
/** REG-CS3 v2 规范串格式(逐字:`<registry_id>:<slug>@<version>:<sha256>`) */
|
|
40
|
+
export function canonicalString(registryId, slug, version, sha256) {
|
|
41
|
+
return `${registryId}:${slug}@${version}:${sha256}`;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** 指纹 = sha256Hex(publicKey) 全 64 hex(契约 §2.11,UI 分组展示用) */
|
|
@@ -155,7 +157,7 @@ function tamperFirstChar(valueB64) {
|
|
|
155
157
|
export function generate(outDir = FIXTURE_DIR) {
|
|
156
158
|
const tgz = buildTgz(fixtureMembers());
|
|
157
159
|
const sha256 = createHash("sha256").update(tgz).digest("hex");
|
|
158
|
-
const canonical = canonicalString(FIXTURE_NAME, FIXTURE_VERSION, sha256);
|
|
160
|
+
const canonical = canonicalString(FIXTURE_REGISTRY_ID, FIXTURE_NAME, FIXTURE_VERSION, sha256);
|
|
159
161
|
|
|
160
162
|
const sigOf = (keyId, publicKeyHex, privateKey) => ({
|
|
161
163
|
alg: "Ed25519",
|
|
@@ -163,6 +165,7 @@ export function generate(outDir = FIXTURE_DIR) {
|
|
|
163
165
|
public_key: publicKeyHex,
|
|
164
166
|
value: signCanonicalB64(privateKey, canonical),
|
|
165
167
|
canon: CANON,
|
|
168
|
+
registry_id: FIXTURE_REGISTRY_ID,
|
|
166
169
|
});
|
|
167
170
|
|
|
168
171
|
const good = sigOf(TRUSTED_KEY.key_id, TRUSTED_KEY.public_key_hex, trusted.privateKey);
|
|
@@ -26,13 +26,15 @@ export function nodeVerifyEd25519(publicKeyHex: string, msg: string, sigB64: str
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** detached 侧车 / Form B archive.signature 的字段形状(契约 §2.11 / C2-35) */
|
|
29
|
+
/** detached 侧车 / Form B archive.signature 的字段形状(契约 §2.11 / C2-35,REG-CS3 增 registry_id) */
|
|
30
30
|
export interface SigFile {
|
|
31
31
|
alg: string
|
|
32
32
|
key_id: string
|
|
33
33
|
public_key: string
|
|
34
34
|
value: string
|
|
35
35
|
canon: string
|
|
36
|
+
/** 签发方 registry 身份(REG-CS3 v2 规范串首段;跨仓重放判定真源) */
|
|
37
|
+
registry_id: string
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export interface SignedArchive {
|
|
@@ -54,15 +56,17 @@ export function defaultTestKey(): KeyPair {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
|
-
* 对 tgz 字节签名:sha256(bytes) →
|
|
58
|
-
*
|
|
59
|
+
* 对 tgz 字节签名:sha256(bytes) → 规范串(REG-CS3 v2:`<registry_id>:<slug>@<version>:<sha256>`)
|
|
60
|
+
* → Ed25519 → .sig 五字段 + registry_id。
|
|
61
|
+
* `keyPair` 缺省 = defaultTestKey();传第二个 keyPair 即「未知发布方」场景;`registryId` 缺省 = "test.registry"。
|
|
59
62
|
*/
|
|
60
|
-
export function signedArchiveFor(tgz: Uint8Array, name: string, version: string, opts?: { keyPair?: KeyPair, keyId?: string }): SignedArchive {
|
|
63
|
+
export function signedArchiveFor(tgz: Uint8Array, name: string, version: string, opts?: { keyPair?: KeyPair, keyId?: string, registryId?: string }): SignedArchive {
|
|
61
64
|
const keyPair = opts?.keyPair ?? defaultTestKey();
|
|
65
|
+
const registryId = opts?.registryId ?? "test.registry";
|
|
62
66
|
const sha256 = createHash("sha256").update(tgz).digest("hex");
|
|
63
|
-
const value = signCanonical(keyPair.privateKeyPem, canonicalString(name, version, sha256));
|
|
67
|
+
const value = signCanonical(keyPair.privateKeyPem, canonicalString(registryId, name, version, sha256));
|
|
64
68
|
return {
|
|
65
|
-
sig: { alg: SIG_ALG, key_id: opts?.keyId ?? "test.publisher", public_key: keyPair.publicKeyHex, value, canon: CANON },
|
|
69
|
+
sig: { alg: SIG_ALG, key_id: opts?.keyId ?? "test.publisher", public_key: keyPair.publicKeyHex, value, canon: CANON, registry_id: registryId },
|
|
66
70
|
keyId: opts?.keyId ?? "test.publisher",
|
|
67
71
|
publicKeyHex: keyPair.publicKeyHex,
|
|
68
72
|
fingerprint: fingerprintOfPublicKey(keyPair.publicKeyHex),
|
|
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
9
|
import { setEnabledCommand } from "../../enable";
|
|
10
|
-
import { installCommand, parseTarget, renderOutcome } from "../../install";
|
|
10
|
+
import { installCommand, isSiteApiAtLeast, parseRegistryTarget, parseTarget, renderOutcome } from "../../install";
|
|
11
11
|
import { pollTask, resolveSite } from "../../site";
|
|
12
12
|
|
|
13
13
|
/** 终态记录工厂(§10.2 形状,测试只需关键字段) */
|
|
@@ -71,12 +71,12 @@ describe("resolveSite · 站点寻址(flag > 环境变量 > 缺省模板 serve
|
|
|
71
71
|
expect(resolveSite({ user: "u", pass: "p" })).toEqual({ base: "http://127.0.0.1:9778", user: "u", pass: "p" });
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("环境变量
|
|
75
|
-
process.env.
|
|
74
|
+
it("环境变量 OJM_SITE_URL/OJM_USER/OJM_PASS 兜底;flag 优先;尾斜杠归一", () => {
|
|
75
|
+
process.env.OJM_SITE_URL = "http://env:9/";
|
|
76
76
|
process.env.OJM_USER = "eu";
|
|
77
77
|
process.env.OJM_PASS = "ep";
|
|
78
78
|
expect(resolveSite({})).toEqual({ base: "http://env:9", user: "eu", pass: "ep" });
|
|
79
|
-
expect(resolveSite({
|
|
79
|
+
expect(resolveSite({ siteUrl: "http://flag:8/", user: "fu", pass: "fp" }).base).toBe("http://flag:8");
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
it("缺凭据即人话报错(凭据只走内存不落盘)", () => {
|
|
@@ -251,7 +251,7 @@ describe("schemaVersion 旧记录兼容(P2-10 升版纪律读方侧:高版
|
|
|
251
251
|
const exitBackup = process.exitCode;
|
|
252
252
|
process.exitCode = 0;
|
|
253
253
|
try {
|
|
254
|
-
await installCommand(tgz, {
|
|
254
|
+
await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
|
|
255
255
|
expect(process.exitCode).toBe(1);
|
|
256
256
|
}
|
|
257
257
|
finally {
|
|
@@ -354,7 +354,7 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
|
|
|
354
354
|
"/api/_platform/market/install": () => ok({ task_id: "t1" }),
|
|
355
355
|
"/api/_platform/market/task": () => ok(rec()),
|
|
356
356
|
});
|
|
357
|
-
await installCommand(tgz, {
|
|
357
|
+
await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
|
|
358
358
|
// 登录体
|
|
359
359
|
const loginBody = JSON.parse(String(calls[0]?.init?.body));
|
|
360
360
|
expect(loginBody).toEqual({ username: "u", password: "p" });
|
|
@@ -388,7 +388,7 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
|
|
|
388
388
|
const exitBackup = process.exitCode;
|
|
389
389
|
process.exitCode = 0;
|
|
390
390
|
try {
|
|
391
|
-
await installCommand(tgz, {
|
|
391
|
+
await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
|
|
392
392
|
expect(process.exitCode).toBe(1);
|
|
393
393
|
}
|
|
394
394
|
finally {
|
|
@@ -400,13 +400,13 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
|
|
|
400
400
|
it("detached 侧车自动探测(P2-3,G4):同目录 .sig 随 multipart 第二文件上传", async () => {
|
|
401
401
|
const tgz = join(dir, "demo-solution-0.1.0.tgz");
|
|
402
402
|
writeFileSync(tgz, "fake-tgz-bytes");
|
|
403
|
-
writeFileSync(`${tgz}.sig`, JSON.stringify({ alg: "Ed25519", key_id: "test.publisher", public_key: "a".repeat(64), value: "AAAA", canon: "
|
|
403
|
+
writeFileSync(`${tgz}.sig`, JSON.stringify({ alg: "Ed25519", key_id: "test.publisher", public_key: "a".repeat(64), value: "AAAA", canon: "v2", registry_id: "test.registry" }));
|
|
404
404
|
const { fetchFn, calls } = fetchStub({
|
|
405
405
|
"/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
|
|
406
406
|
"/api/_platform/market/install": () => ok({ task_id: "t1" }),
|
|
407
407
|
"/api/_platform/market/task": () => ok(rec()),
|
|
408
408
|
});
|
|
409
|
-
await installCommand(tgz, {
|
|
409
|
+
await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
|
|
410
410
|
const form = calls[1]?.init?.body as FormData;
|
|
411
411
|
expect(form.get("sig")).toBeInstanceOf(File);
|
|
412
412
|
expect((form.get("sig") as File).name).toBe("demo-solution-0.1.0.tgz.sig");
|
|
@@ -421,7 +421,7 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
|
|
|
421
421
|
"/api/_platform/market/install": () => ok({ task_id: "t1" }),
|
|
422
422
|
"/api/_platform/market/task": () => ok(rec()),
|
|
423
423
|
});
|
|
424
|
-
await installCommand(tgz, {
|
|
424
|
+
await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
|
|
425
425
|
const form = calls[1]?.init?.body as FormData;
|
|
426
426
|
expect(form.get("sig")).toBeNull();
|
|
427
427
|
expect(logs.join("\n")).toContain("未发现签名侧车");
|
|
@@ -433,7 +433,7 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
|
|
|
433
433
|
const { fetchFn } = fetchStub({
|
|
434
434
|
"/api/auth/login": () => ({ ok: true, status: 200, text: async () => JSON.stringify({ code: 401, msg: "invalid credentials" }) }),
|
|
435
435
|
});
|
|
436
|
-
await expect(installCommand(tgz, {
|
|
436
|
+
await expect(installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "bad", fetchFn })).rejects.toThrow(/invalid credentials/);
|
|
437
437
|
});
|
|
438
438
|
});
|
|
439
439
|
|
|
@@ -444,7 +444,7 @@ describe("installCommand · cdn 源(JSON 体 name@version)", () => {
|
|
|
444
444
|
"/api/_platform/market/install": () => ok({ task_id: "t1" }),
|
|
445
445
|
"/api/_platform/market/task": () => ok(rec()),
|
|
446
446
|
});
|
|
447
|
-
await installCommand("demo-solution", {
|
|
447
|
+
await installCommand("demo-solution", { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
|
|
448
448
|
const installCall = calls[1]!;
|
|
449
449
|
expect((installCall.init?.headers as Record<string, string>)["content-type"]).toBe("application/json");
|
|
450
450
|
expect(JSON.parse(String(installCall.init?.body))).toEqual({ name: "demo-solution", version: null, source: "cdn" });
|
|
@@ -456,7 +456,7 @@ describe("installCommand · cdn 源(JSON 体 name@version)", () => {
|
|
|
456
456
|
"/api/_platform/market/install": () => ok({ task_id: "t1" }),
|
|
457
457
|
"/api/_platform/market/task": () => ok(rec()),
|
|
458
458
|
});
|
|
459
|
-
await installCommand("demo-solution@0.2.0", {
|
|
459
|
+
await installCommand("demo-solution@0.2.0", { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
|
|
460
460
|
expect(JSON.parse(String(calls[1]?.init?.body)).version).toBe("0.2.0");
|
|
461
461
|
});
|
|
462
462
|
});
|
|
@@ -467,7 +467,7 @@ describe("setEnabledCommand · enable/disable 瘦客户端(D8)", () => {
|
|
|
467
467
|
"/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
|
|
468
468
|
"/api/_platform/market/enable": () => ok({ ok: true, message: "启用完成:四处同写" }),
|
|
469
469
|
});
|
|
470
|
-
await setEnabledCommand("demo-solution", true, {
|
|
470
|
+
await setEnabledCommand("demo-solution", true, { siteUrl: "http://x", user: "u", pass: "p", fetchFn });
|
|
471
471
|
const call = calls[1]!;
|
|
472
472
|
expect(call.url).toContain("/api/_platform/market/enable");
|
|
473
473
|
expect(JSON.parse(String(call.init?.body))).toEqual({ name: "demo-solution", enabled: true });
|
|
@@ -480,7 +480,7 @@ describe("setEnabledCommand · enable/disable 瘦客户端(D8)", () => {
|
|
|
480
480
|
"/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
|
|
481
481
|
"/api/_platform/market/enable": () => ok({ ok: false, message: "禁用失败:「x」未安装" }),
|
|
482
482
|
});
|
|
483
|
-
await setEnabledCommand("x", false, {
|
|
483
|
+
await setEnabledCommand("x", false, { siteUrl: "http://x", user: "u", pass: "p", fetchFn: fetchFn2 });
|
|
484
484
|
expect(process.exitCode).toBe(1);
|
|
485
485
|
}
|
|
486
486
|
finally {
|
|
@@ -489,3 +489,60 @@ describe("setEnabledCommand · enable/disable 瘦客户端(D8)", () => {
|
|
|
489
489
|
expect(logs.join("\n")).toContain("禁用失败:「x」未安装");
|
|
490
490
|
});
|
|
491
491
|
});
|
|
492
|
+
|
|
493
|
+
describe("s5-2② REG-R9 install 源切换:registry(CLI 归一 + 旧站点拒)", () => {
|
|
494
|
+
it("parseRegistryTarget:@scope/name → slug=scope-name、version=null", () => {
|
|
495
|
+
expect(parseRegistryTarget("@acme/demo")).toEqual({ slug: "acme-demo", version: null });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("parseRegistryTarget:@scope/name@version 拆出版本;acme/demo 等价", () => {
|
|
499
|
+
expect(parseRegistryTarget("@acme/demo@1.2.3")).toEqual({ slug: "acme-demo", version: "1.2.3" });
|
|
500
|
+
expect(parseRegistryTarget("acme/demo@1.2.3")).toEqual({ slug: "acme-demo", version: "1.2.3" });
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("parseRegistryTarget:裸 slug 原样透传(含 @version)", () => {
|
|
504
|
+
expect(parseRegistryTarget("acme-demo")).toEqual({ slug: "acme-demo", version: null });
|
|
505
|
+
expect(parseRegistryTarget("acme-demo@2.0.0")).toEqual({ slug: "acme-demo", version: "2.0.0" });
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("parseRegistryTarget:大写/空段/空串即抛 UsageError(REG-R5 字符集)", () => {
|
|
509
|
+
expect(() => parseRegistryTarget("@ACME/demo")).toThrow(/REG-R5/);
|
|
510
|
+
expect(() => parseRegistryTarget("@acme/")).toThrow();
|
|
511
|
+
expect(() => parseRegistryTarget("")).toThrow(/registry 源用法/);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("isSiteApiAtLeast:0.1.0 < 0.2.0 → false;0.2.0/0.2.1 → true;undefined → false", () => {
|
|
515
|
+
expect(isSiteApiAtLeast("0.1.0", "0.2.0")).toBe(false);
|
|
516
|
+
expect(isSiteApiAtLeast("0.2.0", "0.2.0")).toBe(true);
|
|
517
|
+
expect(isSiteApiAtLeast("0.2.1", "0.2.0")).toBe(true);
|
|
518
|
+
expect(isSiteApiAtLeast(undefined, "0.2.0")).toBe(false);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("installCommand registry:新站点(apiVersion 0.2.0)POST source=registry + registry=origin(slug 归一)", async () => {
|
|
522
|
+
const { fetchFn, calls } = fetchStub({
|
|
523
|
+
"/api/auth/login": () => ok({ access_token: "tok" }),
|
|
524
|
+
"/api/_platform/market/meta": () => ok({ apiVersion: "0.2.0" }),
|
|
525
|
+
"/api/_platform/market/install": () => ok({ task_id: "t1" }),
|
|
526
|
+
"/api/_platform/market/task": () => ok(rec()),
|
|
527
|
+
});
|
|
528
|
+
const r = await installCommand("@acme/demo", { siteUrl: "http://site", user: "u", pass: "p", registry: "https://r.example.com/", fetchFn });
|
|
529
|
+
expect(r.status).toBe("done");
|
|
530
|
+
const call = calls.find(c => c.url.includes("/api/_platform/market/install"));
|
|
531
|
+
expect(call).toBeDefined();
|
|
532
|
+
const body = JSON.parse(String(call!.init!.body));
|
|
533
|
+
expect(body).toMatchObject({ name: "acme-demo", source: "registry", registry: "https://r.example.com" });
|
|
534
|
+
expect(body.version).toBeNull();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("installCommand registry:旧站点(apiVersion 0.1.0)→ 抛「站点版本过低」且不 POST install", async () => {
|
|
538
|
+
const { fetchFn, calls } = fetchStub({
|
|
539
|
+
"/api/auth/login": () => ok({ access_token: "tok" }),
|
|
540
|
+
"/api/_platform/market/meta": () => ok({ apiVersion: "0.1.0" }),
|
|
541
|
+
"/api/_platform/market/install": () => ok({ task_id: "t1" }),
|
|
542
|
+
});
|
|
543
|
+
await expect(installCommand("@acme/demo", { siteUrl: "http://site", user: "u", pass: "p", registry: "https://r.example.com", fetchFn }))
|
|
544
|
+
.rejects
|
|
545
|
+
.toThrow(/版本过低/);
|
|
546
|
+
expect(calls.some(c => c.url.includes("/api/_platform/market/install"))).toBe(false);
|
|
547
|
+
});
|
|
548
|
+
});
|