@oj-module/cli 0.2.30 → 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/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- 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/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
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// REG-R12 CLI 自动化契约:退出码三档(0/1/2)、`--json` 信封、`--dry-run` 零写入、
|
|
2
|
+
// `OJM_REGISTRY_TOKEN`,以及 `ojm config ls --show-origin`。
|
|
3
|
+
//
|
|
4
|
+
// 为什么这些用例走**真分发**(buildCli → 真 publishCommand → 真 fs)而不 mock:
|
|
5
|
+
// 契约的全部价值在于「同一份 argv 在 CI 里得到同样的退出码与可解析输出」。
|
|
6
|
+
// 一旦把业务 mock 掉,测的就只剩「mock 被调用了」,退 0/1/2 的分界也不再受检验。
|
|
7
|
+
//
|
|
8
|
+
// 三条纪律在这里被钉成断言(不只是注释):
|
|
9
|
+
// ① `--json` 下 **stdout 只有一行信封**:人话一个字都不许混进来(混进来 CI 就没法 parse);
|
|
10
|
+
// ② `--dry-run` **盘上一个字节都不变**,且有正控制(非 dry-run 会真的写)——
|
|
11
|
+
// 否则「零写入」可能只是「什么都没干」;
|
|
12
|
+
// ③ 凭据**永不回显**:日志里只出现来源名,token 明文在任何通道都搜不到。
|
|
13
|
+
import type { KeyPair } from "../lifecycle/keygen";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import process from "node:process";
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
20
|
+
import { buildCli, localizeCacError, reportCliError, reportUnknownCommand } from "../cli";
|
|
21
|
+
import { failEnvelope, OK_CODE, okEnvelope } from "../cli-output";
|
|
22
|
+
import { configRows } from "../config-cmd";
|
|
23
|
+
import { EXIT_FAILURE, EXIT_OK, EXIT_USAGE, exitCodeOf, UsageError } from "../exit-codes";
|
|
24
|
+
import { defaultTestKey } from "../lifecycle/__tests__/helpers";
|
|
25
|
+
import { describeCredential, normalizeRegistryUrl, resolveRegistryCredential, resolveRegistrySource, resolveRegistrySourceOptional } from "../registry-source";
|
|
26
|
+
import { uninstallCommand } from "../uninstall";
|
|
27
|
+
import { parseViewTarget, viewCommand } from "../view";
|
|
28
|
+
|
|
29
|
+
const CATALOG = `shards:
|
|
30
|
+
"1": 1
|
|
31
|
+
solutions:
|
|
32
|
+
- name: demo-solution
|
|
33
|
+
shards:
|
|
34
|
+
- 1
|
|
35
|
+
`;
|
|
36
|
+
const SHARD1 = `solutions:
|
|
37
|
+
- name: demo-solution
|
|
38
|
+
latest: 0.1.0
|
|
39
|
+
versions:
|
|
40
|
+
- version: 0.1.0
|
|
41
|
+
file: demo-solution-0.1.0.tgz
|
|
42
|
+
`;
|
|
43
|
+
const REGISTRY = "https://registry.example.com";
|
|
44
|
+
const TOKEN = "s3cr3t-token-must-never-be-printed";
|
|
45
|
+
|
|
46
|
+
let out: string[];
|
|
47
|
+
let err: string[];
|
|
48
|
+
let home: string;
|
|
49
|
+
let homeBackup: string | undefined;
|
|
50
|
+
let tmpRoot: string;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
out = [];
|
|
54
|
+
err = [];
|
|
55
|
+
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => out.push(args.map(String).join(" ")));
|
|
56
|
+
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => err.push(args.map(String).join(" ")));
|
|
57
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ojm-r12-"));
|
|
58
|
+
// 空 HOME:让「有没有本地凭据文件」这件事由用例决定,而不是被开发机现状左右
|
|
59
|
+
home = path.join(tmpRoot, "home");
|
|
60
|
+
fs.mkdirSync(home, { recursive: true });
|
|
61
|
+
homeBackup = process.env.HOME;
|
|
62
|
+
process.env.HOME = home;
|
|
63
|
+
delete process.env.OJM_REGISTRY_TOKEN;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.restoreAllMocks();
|
|
68
|
+
if (homeBackup === undefined)
|
|
69
|
+
delete process.env.HOME;
|
|
70
|
+
else
|
|
71
|
+
process.env.HOME = homeBackup;
|
|
72
|
+
delete process.env.OJM_REGISTRY_TOKEN;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
interface RunResult { out: string[], err: string[], exitCode: number | undefined }
|
|
76
|
+
|
|
77
|
+
/** 跑一次真 CLI:`argv` 同时喂给 buildCli 与 reportCliError(`--json` 失败信封的归因依据) */
|
|
78
|
+
async function run(argv: string[], cwd: string): Promise<RunResult> {
|
|
79
|
+
// 一个用例里可能连跑多轮(如「不加 --show-origin」再「--json」):每轮重开通道,别混账
|
|
80
|
+
out.length = 0;
|
|
81
|
+
err.length = 0;
|
|
82
|
+
const exitSpy = vi.fn();
|
|
83
|
+
const exit = exitSpy as unknown as (code: number) => never;
|
|
84
|
+
const cli = buildCli(cwd, "zh-CN", { exit, argv });
|
|
85
|
+
try {
|
|
86
|
+
cli.parse(["node", "ojm", ...argv], { run: true });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
// cac 的未知选项/缺参是**同步**抛的:真入口在 index.ts 顶层 catch 里同样落到 reportCliError,
|
|
90
|
+
// 测试若只 try 不接,就会绕开「--json 失败也给信封」这条契约
|
|
91
|
+
reportCliError(localizeCacError(error, "zh-CN"), exit, argv);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await vi.waitUntil(() => out.length > 0 || err.length > 0 || exitSpy.mock.calls.length > 0, { timeout: 3000, interval: 10 });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// 无输出型动作:兜底继续(断言会以空输出失败,比这里抛更清楚)
|
|
98
|
+
}
|
|
99
|
+
await new Promise(r => setTimeout(r, 30));
|
|
100
|
+
return { out: [...out], err: [...err], exitCode: exitSpy.mock.calls[0]?.[0] as number | undefined };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 建一个可发布的索引目录(catalog + 1 片 + 私钥),返回目录与 key 路径 */
|
|
104
|
+
function publishDir(opts: { registry?: string } = {}): { dir: string, keyPath: string, key: KeyPair } {
|
|
105
|
+
const dir = fs.mkdtempSync(path.join(tmpRoot, "proj-"));
|
|
106
|
+
fs.writeFileSync(path.join(dir, "catalog.yaml"), CATALOG);
|
|
107
|
+
fs.mkdirSync(path.join(dir, "shards"), { recursive: true });
|
|
108
|
+
fs.writeFileSync(path.join(dir, "shards", "1.yaml"), SHARD1);
|
|
109
|
+
const key = defaultTestKey();
|
|
110
|
+
const keyPath = path.join(dir, "key.pem");
|
|
111
|
+
fs.writeFileSync(keyPath, key.privateKeyPem);
|
|
112
|
+
if (opts.registry !== undefined)
|
|
113
|
+
fs.writeFileSync(path.join(dir, ".ojmrc"), JSON.stringify({ registry: opts.registry }));
|
|
114
|
+
return { dir, keyPath, key };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 目录快照:相对路径 → (sha256, mtimeMs)。dry-run 的「零写入」用它逐项比对 */
|
|
118
|
+
function snapshot(root: string): Record<string, string> {
|
|
119
|
+
const out: Record<string, string> = {};
|
|
120
|
+
const walk = (d: string): void => {
|
|
121
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
122
|
+
const p = path.join(d, e.name);
|
|
123
|
+
if (e.isDirectory()) {
|
|
124
|
+
walk(p);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const st = fs.statSync(p);
|
|
128
|
+
out[path.relative(root, p)] = `${createHash("sha256").update(fs.readFileSync(p)).digest("hex")}@${st.mtimeMs}`;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
walk(root);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** stdout 恰好一行、可解析;返回解析后的对象(解析失败就让用例红在这个断言上) */
|
|
136
|
+
function stdoutEnvelope(r: RunResult): { ok: boolean, command: string, code: string, message: string, data: Record<string, unknown> | null } {
|
|
137
|
+
expect(r.out).toHaveLength(1);
|
|
138
|
+
return JSON.parse(r.out[0]!) as ReturnType<typeof stdoutEnvelope>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe("a 退出码三档:0 成功 / 1 业务失败 / 2 用法错误", () => {
|
|
142
|
+
it("0:publish --dry-run 算得出来就是成功(人话在 stdout,无 --json 时)", async () => {
|
|
143
|
+
const { dir, keyPath } = publishDir({ registry: REGISTRY });
|
|
144
|
+
const r = await run(["publish", "--id", "test.publisher", "--key", keyPath, "--dry-run"], dir);
|
|
145
|
+
expect(r.exitCode).toBeUndefined(); // 不 exit 即 0
|
|
146
|
+
expect(r.out.join("\n")).toContain("--dry-run:只算不写");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("2:publish --registry 不是 http(s) 地址(命令行写错,不是业务拒绝)", async () => {
|
|
150
|
+
const { dir, keyPath } = publishDir();
|
|
151
|
+
const r = await run(["publish", "--id", "test.publisher", "--key", keyPath, "--registry", "registry.example.com"], dir);
|
|
152
|
+
expect(r.exitCode).toBe(EXIT_USAGE);
|
|
153
|
+
expect(r.err.join("\n")).toContain("只接受 http(s) 地址");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("2:publish 缺 --id(少一个 token,补上就能跑)", async () => {
|
|
157
|
+
const { dir } = publishDir();
|
|
158
|
+
const r = await run(["publish", "--dry-run"], dir);
|
|
159
|
+
expect(r.exitCode).toBe(EXIT_USAGE);
|
|
160
|
+
expect(r.err.join("\n")).toContain("--id");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("1:publish 目录里没有 catalog.yaml(命令跑通了,业务说不)", async () => {
|
|
164
|
+
const empty = fs.mkdtempSync(path.join(tmpRoot, "empty-"));
|
|
165
|
+
const keyPath = path.join(empty, "key.pem");
|
|
166
|
+
fs.writeFileSync(keyPath, defaultTestKey().privateKeyPem);
|
|
167
|
+
const r = await run(["publish", "--id", "test.publisher", "--key", keyPath], empty);
|
|
168
|
+
expect(r.exitCode).toBe(EXIT_FAILURE);
|
|
169
|
+
expect(r.err.join("\n")).toContain("须含 catalog.yaml");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("2:config 未知子命令(不静默当 ls 跑)", async () => {
|
|
173
|
+
const { dir } = publishDir();
|
|
174
|
+
const r = await run(["config", "set"], dir);
|
|
175
|
+
expect(r.exitCode).toBe(EXIT_USAGE);
|
|
176
|
+
expect(r.err.join("\n")).toContain("config 只支持 ls");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("0:config ls 在「一条源都没配」时也是成功(诊断命令要把空着显示出来)", async () => {
|
|
180
|
+
const { dir } = publishDir();
|
|
181
|
+
const r = await run(["config", "ls"], dir);
|
|
182
|
+
expect(r.exitCode).toBeUndefined();
|
|
183
|
+
expect(r.out.join("\n")).toContain("(未配置)");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("2:uninstall 不接 --registry(Q7:卸载不涉及仓库)", async () => {
|
|
187
|
+
const { dir } = publishDir();
|
|
188
|
+
const r = await run(["uninstall", "demo", "--registry", REGISTRY], dir);
|
|
189
|
+
expect(r.exitCode).toBe(EXIT_USAGE);
|
|
190
|
+
expect(r.err.join("\n")).toContain("不涉及仓库");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("未知选项也是 2(cac 报错被升格为用法错误)", async () => {
|
|
194
|
+
const { dir, keyPath } = publishDir();
|
|
195
|
+
const r = await run(["publish", "--id", "test.publisher", "--key", keyPath, "--nope"], dir);
|
|
196
|
+
expect(r.exitCode).toBe(EXIT_USAGE);
|
|
197
|
+
expect(r.err.join("\n")).toContain("未知选项:--nope");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("未知命令 + --json:stdout 只有信封,帮助文本改走 stderr(实测踩过的洞)", () => {
|
|
201
|
+
// 回归:以前 `ojm nope --json` 把整页帮助打到 stdout,CI 的 JSON.parse 直接炸
|
|
202
|
+
const r = reportUnknownCommand("nope", "zh-CN", false, ["nope", "--json"]);
|
|
203
|
+
expect(r.code).toBe(EXIT_USAGE);
|
|
204
|
+
expect(out).toHaveLength(1);
|
|
205
|
+
const env = JSON.parse(out[0]!) as { ok: boolean, code: string, command: string };
|
|
206
|
+
expect([env.ok, env.code, env.command]).toEqual([false, "usage_error", "nope"]);
|
|
207
|
+
expect(err.join("\n")).toContain("工程脚手架"); // 帮助还在,只是在人通道
|
|
208
|
+
// 不带 --json:帮助照旧走 stdout(交互用户看到的就是帮助)
|
|
209
|
+
out.length = 0;
|
|
210
|
+
err.length = 0;
|
|
211
|
+
reportUnknownCommand("nope", "zh-CN", false, ["nope"]);
|
|
212
|
+
expect(out.join("\n")).toContain("工程脚手架");
|
|
213
|
+
expect(err).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("exitCodeOf:只有 UsageError 归 2,其余一律 1(拿不准就归 1)", () => {
|
|
217
|
+
expect(exitCodeOf(new UsageError("x"))).toBe(EXIT_USAGE);
|
|
218
|
+
expect(exitCodeOf(new Error("x"))).toBe(EXIT_FAILURE);
|
|
219
|
+
expect(exitCodeOf("x")).toBe(EXIT_FAILURE);
|
|
220
|
+
expect(EXIT_OK).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("b --json 稳定字段(schema 断言,不做快照全量比对)", () => {
|
|
225
|
+
it("成功信封:字段名与类型固定,payload 只含契约里的键", async () => {
|
|
226
|
+
const { dir, keyPath } = publishDir({ registry: REGISTRY });
|
|
227
|
+
process.env.OJM_REGISTRY_TOKEN = TOKEN;
|
|
228
|
+
const r = await run(["publish", "--id", "test.publisher", "--key", keyPath, "--dry-run", "--json"], dir);
|
|
229
|
+
const env = stdoutEnvelope(r);
|
|
230
|
+
// 信封:加字段可以,改名/删字段是破坏性变更 → 这里把「现在是哪五个」写死
|
|
231
|
+
expect(Object.keys(env).sort()).toEqual(["code", "command", "data", "message", "ok"]);
|
|
232
|
+
expect(env.ok).toBe(true);
|
|
233
|
+
expect(env.command).toBe("publish");
|
|
234
|
+
expect(env.code).toBe(OK_CODE);
|
|
235
|
+
expect(typeof env.message).toBe("string");
|
|
236
|
+
// 载荷:CI 靠这些键分支
|
|
237
|
+
expect(Object.keys(env.data!).sort()).toEqual([
|
|
238
|
+
"catalog",
|
|
239
|
+
"checkPassed",
|
|
240
|
+
"credential",
|
|
241
|
+
"dryRun",
|
|
242
|
+
"fingerprint",
|
|
243
|
+
"keyId",
|
|
244
|
+
"registry",
|
|
245
|
+
"seq",
|
|
246
|
+
"shards",
|
|
247
|
+
"writes",
|
|
248
|
+
]);
|
|
249
|
+
expect(env.data!.dryRun).toBe(true);
|
|
250
|
+
expect(env.data!.seq).toBe(1);
|
|
251
|
+
expect(env.data!.keyId).toBe("test.publisher");
|
|
252
|
+
expect(env.data!.fingerprint).toMatch(/^[0-9a-f]{64}$/);
|
|
253
|
+
expect(env.data!.registry).toEqual({ url: REGISTRY, level: "project" });
|
|
254
|
+
expect(env.data!.credential).toBe("env");
|
|
255
|
+
expect(env.data!.catalog).toBe("catalog.yaml");
|
|
256
|
+
expect(env.data!.shards).toEqual(["shards/1.yaml"]);
|
|
257
|
+
expect(env.data!.writes).toEqual(["shards/1.yaml", "shards/1.yaml.sig", "catalog.yaml", "catalog.yaml.sig"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("stdout 只有信封:人话全在 stderr(CI 不必从文案里刨 JSON)", async () => {
|
|
261
|
+
const { dir, keyPath } = publishDir({ registry: REGISTRY });
|
|
262
|
+
process.env.OJM_REGISTRY_TOKEN = TOKEN;
|
|
263
|
+
const r = await run(["publish", "--id", "test.publisher", "--key", keyPath, "--dry-run", "--json"], dir);
|
|
264
|
+
expect(r.out).toHaveLength(1);
|
|
265
|
+
expect(() => JSON.parse(r.out[0]!)).not.toThrow();
|
|
266
|
+
// 人类可读的每一行都在 stderr
|
|
267
|
+
expect(r.err.join("\n")).toContain("[ojm] 仓库源");
|
|
268
|
+
expect(r.err.join("\n")).toContain("--dry-run:只算不写");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("失败信封:ok=false、data=null、code 区分 usage_error / failed", async () => {
|
|
272
|
+
const { dir, keyPath } = publishDir();
|
|
273
|
+
const usage = await run(["publish", "--id", "test.publisher", "--key", keyPath, "--registry", "nope", "--json"], dir);
|
|
274
|
+
expect(usage.exitCode).toBe(EXIT_USAGE);
|
|
275
|
+
const ue = stdoutEnvelope(usage);
|
|
276
|
+
expect(ue.ok).toBe(false);
|
|
277
|
+
expect(ue.data).toBeNull();
|
|
278
|
+
expect([ue.code, ue.command]).toEqual(["usage_error", "publish"]);
|
|
279
|
+
expect(Object.keys(ue).sort()).toEqual(["code", "command", "data", "message", "ok"]);
|
|
280
|
+
|
|
281
|
+
const failed = await run(["publish", "--json"], dir);
|
|
282
|
+
expect(failed.exitCode).toBe(EXIT_USAGE); // 缺 --id 仍是用法错误
|
|
283
|
+
const fe = stdoutEnvelope(failed);
|
|
284
|
+
expect(fe.code).toBe("usage_error");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("信封构造器:成功/失败的 code 与 data 语义不混(失败永不夹带半份载荷)", () => {
|
|
288
|
+
expect(okEnvelope("publish", { a: 1 })).toMatchObject({ ok: true, code: OK_CODE, data: { a: 1 } });
|
|
289
|
+
expect(failEnvelope("publish", "failed", "boom")).toMatchObject({ ok: false, code: "failed", data: null });
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("c --dry-run 全程零写入(有正控制)", () => {
|
|
294
|
+
it("dry-run 后盘上一个字节都没变,且 .sig 一个都没落地", async () => {
|
|
295
|
+
const { dir, keyPath } = publishDir({ registry: REGISTRY });
|
|
296
|
+
const before = snapshot(dir);
|
|
297
|
+
const r = await run(["publish", "--id", "test.publisher", "--key", keyPath, "--dry-run", "--json"], dir);
|
|
298
|
+
const env = stdoutEnvelope(r);
|
|
299
|
+
expect(env.data!.dryRun).toBe(true);
|
|
300
|
+
// 「本应写入」照常报出来:CI 可以先 diff 意图,再决定是否真发
|
|
301
|
+
expect(env.data!.writes).toHaveLength(4);
|
|
302
|
+
expect(snapshot(dir)).toEqual(before);
|
|
303
|
+
expect(fs.existsSync(path.join(dir, "catalog.yaml.sig"))).toBe(false);
|
|
304
|
+
expect(fs.existsSync(path.join(dir, "shards", "1.yaml.sig"))).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("正控制:同目录去掉 --dry-run 就真的写(证明零写入不是「什么都没干」)", async () => {
|
|
308
|
+
const { dir, keyPath } = publishDir({ registry: REGISTRY });
|
|
309
|
+
const before = snapshot(dir);
|
|
310
|
+
await run(["publish", "--id", "test.publisher", "--key", keyPath, "--json"], dir);
|
|
311
|
+
const after = snapshot(dir);
|
|
312
|
+
expect(after).not.toEqual(before);
|
|
313
|
+
expect(fs.existsSync(path.join(dir, "catalog.yaml.sig"))).toBe(true);
|
|
314
|
+
expect(fs.existsSync(path.join(dir, "shards", "1.yaml.sig"))).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("d OJM_REGISTRY_TOKEN:CI 不跑 login 也能发(且不落任何凭据文件)", () => {
|
|
319
|
+
it("gherkin:env token + 无本地凭据文件 → exit 0 + stdout 是 JSON + 没有上传", async () => {
|
|
320
|
+
const { dir, keyPath } = publishDir({ registry: REGISTRY });
|
|
321
|
+
process.env.OJM_REGISTRY_TOKEN = TOKEN;
|
|
322
|
+
// 「无本地凭据文件」是事实而非假设:HOME 指向空目录
|
|
323
|
+
expect(fs.existsSync(path.join(home, ".ojm", "config.json"))).toBe(false);
|
|
324
|
+
const before = snapshot(dir);
|
|
325
|
+
|
|
326
|
+
const r = await run(["publish", "--json", "--dry-run", "--id", "test.publisher", "--key", keyPath], dir);
|
|
327
|
+
|
|
328
|
+
expect(r.exitCode).toBeUndefined();
|
|
329
|
+
const env = stdoutEnvelope(r);
|
|
330
|
+
expect(env.ok).toBe(true);
|
|
331
|
+
expect(env.data!.credential).toBe("env");
|
|
332
|
+
expect(env.data!.registry).toEqual({ url: REGISTRY, level: "project" });
|
|
333
|
+
// 没有上传:dry-run 不写盘,也从未产生任何网络副作用(本命令今天只落本地目录)
|
|
334
|
+
expect(snapshot(dir)).toEqual(before);
|
|
335
|
+
// 凭据来源写在日志里,token 明文不写在任何通道
|
|
336
|
+
expect(r.err.join("\n")).toContain("OJM_REGISTRY_TOKEN(已设置)");
|
|
337
|
+
expect([...r.out, ...r.err].join("\n")).not.toContain(TOKEN);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("凭据解析:env > 用户配置分桶 > 缺失;工程级 .ojmrc 永不收 token", () => {
|
|
341
|
+
const userFile = path.join(home, ".ojm", "config.json");
|
|
342
|
+
fs.mkdirSync(path.dirname(userFile), { recursive: true });
|
|
343
|
+
fs.writeFileSync(userFile, JSON.stringify({
|
|
344
|
+
registry: REGISTRY,
|
|
345
|
+
registries: { [new URL(REGISTRY).origin]: { token: "store-token" } },
|
|
346
|
+
}));
|
|
347
|
+
const source = resolveRegistrySource({ cwd: tmpRoot, home });
|
|
348
|
+
|
|
349
|
+
expect(resolveRegistryCredential(source, { home }).kind).toBe("store");
|
|
350
|
+
expect(resolveRegistryCredential(source, { home, env: { OJM_REGISTRY_TOKEN: TOKEN } as NodeJS.ProcessEnv })).toMatchObject({ kind: "env", token: TOKEN });
|
|
351
|
+
// 换源不会串用:分桶的键是 origin
|
|
352
|
+
expect(resolveRegistryCredential({ url: "https://other.example.com", level: "flag", originFile: null }, { home }).kind).toBe("missing");
|
|
353
|
+
// .ojmrc 里写 token 也不被读(它要入库,token 入仓即泄露):换个只有工程级配置的源,
|
|
354
|
+
// 用户配置里没有它的分桶 → 仍然「缺失」,证明 token 键被无视
|
|
355
|
+
const projectOnly = "https://project-only.example.com";
|
|
356
|
+
fs.writeFileSync(path.join(tmpRoot, ".ojmrc"), JSON.stringify({ registry: projectOnly, token: "project-token" }));
|
|
357
|
+
expect(resolveRegistryCredential(resolveRegistrySource({ cwd: tmpRoot, home }), { home, env: {} as NodeJS.ProcessEnv }).kind).toBe("missing");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("describeCredential 只报来源,永不回显 token", () => {
|
|
361
|
+
const text = describeCredential({ kind: "env", token: TOKEN, origin: "OJM_REGISTRY_TOKEN" });
|
|
362
|
+
expect(text).toContain("OJM_REGISTRY_TOKEN");
|
|
363
|
+
expect(text).not.toContain(TOKEN);
|
|
364
|
+
const stored = describeCredential({ kind: "store", token: TOKEN, origin: "/home/u/.ojm/config.json(https://r)" });
|
|
365
|
+
expect(stored).toContain("已保存");
|
|
366
|
+
expect(stored).not.toContain(TOKEN);
|
|
367
|
+
expect(describeCredential({ kind: "missing", token: null, origin: null })).toContain("缺失");
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("e 源解析:--registry > .ojmrc > 用户配置;没配 ≠ 配错", () => {
|
|
372
|
+
it("三级顺序逐级生效,高层一给就不再读文件", () => {
|
|
373
|
+
const proj = fs.mkdtempSync(path.join(tmpRoot, "proj-"));
|
|
374
|
+
fs.writeFileSync(path.join(proj, ".ojmrc"), JSON.stringify({ registry: "https://project.example.com/" }));
|
|
375
|
+
const userFile = path.join(home, ".ojm", "config.json");
|
|
376
|
+
fs.mkdirSync(path.dirname(userFile), { recursive: true });
|
|
377
|
+
fs.writeFileSync(userFile, JSON.stringify({ registry: "https://user.example.com" }));
|
|
378
|
+
|
|
379
|
+
expect(resolveRegistrySource({ cwd: proj, home })).toMatchObject({ url: "https://project.example.com", level: "project" });
|
|
380
|
+
expect(resolveRegistrySource({ cwd: proj, home, flag: "https://flag.example.com" })).toMatchObject({ url: "https://flag.example.com", level: "flag" });
|
|
381
|
+
const empty = fs.mkdtempSync(path.join(tmpRoot, "noproj-"));
|
|
382
|
+
expect(resolveRegistrySource({ cwd: empty, home })).toMatchObject({ url: "https://user.example.com", level: "user" });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("一条源都没配 → Optional 返 null,严格版抛用法错误(列出三种配法)", () => {
|
|
386
|
+
const empty = fs.mkdtempSync(path.join(tmpRoot, "none-"));
|
|
387
|
+
const emptyHome = fs.mkdtempSync(path.join(tmpRoot, "nohome-"));
|
|
388
|
+
expect(resolveRegistrySourceOptional({ cwd: empty, home: emptyHome })).toBeNull();
|
|
389
|
+
expect(() => resolveRegistrySource({ cwd: empty, home: emptyHome })).toThrow(/未指定仓库源[\s\S]*--registry[\s\S]*\.ojmrc/);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("配了但配坏了 → 抛错(绝不降级成「未配置」)", () => {
|
|
393
|
+
const bad = fs.mkdtempSync(path.join(tmpRoot, "bad-"));
|
|
394
|
+
fs.writeFileSync(path.join(bad, ".ojmrc"), "{ not json");
|
|
395
|
+
expect(() => resolveRegistrySourceOptional({ cwd: bad, home })).toThrow(/配置文件不可解析/);
|
|
396
|
+
// 坏 .ojmrc 不是用法错误(退出码 1):改的是文件内容,不是命令行
|
|
397
|
+
expect(exitCodeOf(new Error("[ojm] 配置文件不可解析"))).toBe(EXIT_FAILURE);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("normalizeRegistryUrl:去尾斜杠、只收 http(s)、空值即用法错误", () => {
|
|
401
|
+
expect(normalizeRegistryUrl("https://r.example.com///")).toBe("https://r.example.com");
|
|
402
|
+
expect(() => normalizeRegistryUrl("ftp://r")).toThrow(UsageError);
|
|
403
|
+
expect(() => normalizeRegistryUrl(" ")).toThrow(UsageError);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("f config ls --show-origin:把「从哪来」摊开", () => {
|
|
408
|
+
it("人话列:registry.url 带来源级别,token 只报来源", async () => {
|
|
409
|
+
const { dir } = publishDir({ registry: REGISTRY });
|
|
410
|
+
process.env.OJM_REGISTRY_TOKEN = TOKEN;
|
|
411
|
+
const r = await run(["config", "ls", "--show-origin"], dir);
|
|
412
|
+
const text = r.out.join("\n");
|
|
413
|
+
expect(r.exitCode).toBeUndefined();
|
|
414
|
+
expect(text).toMatch(/registry\.url\s+https:\/\/registry\.example\.com\s+← \.ojmrc/);
|
|
415
|
+
expect(text).toMatch(/registry\.token\s+OJM_REGISTRY_TOKEN(已设置)/);
|
|
416
|
+
expect(text).toContain("site.url");
|
|
417
|
+
expect(text).not.toContain(TOKEN);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("--show-origin 省略时不加来源列(但 --json 里恒有 origin 字段)", async () => {
|
|
421
|
+
const { dir } = publishDir({ registry: REGISTRY });
|
|
422
|
+
const plain = await run(["config", "ls"], dir);
|
|
423
|
+
expect(plain.out.join("\n")).not.toContain("←");
|
|
424
|
+
|
|
425
|
+
const json = await run(["config", "ls", "--json"], dir);
|
|
426
|
+
const env = stdoutEnvelope(json);
|
|
427
|
+
expect(env.command).toBe("config ls");
|
|
428
|
+
expect(Object.keys(env.data!).sort()).toEqual(["rows"]);
|
|
429
|
+
const rows = env.data!.rows as Array<Record<string, unknown>>;
|
|
430
|
+
expect(rows.map(row => row.key)).toEqual(["registry.url", "registry.token", "site.url", "site.credentials"]);
|
|
431
|
+
for (const row of rows)
|
|
432
|
+
expect(Object.keys(row).sort()).toEqual(["key", "origin", "value"]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("configRows:未配置时四行都退化成人话,而不是抛错", () => {
|
|
436
|
+
const rows = configRows({ cwd: tmpRoot, home, env: {} as NodeJS.ProcessEnv });
|
|
437
|
+
expect(rows[0]).toMatchObject({ key: "registry.url", value: "(未配置)" });
|
|
438
|
+
expect(rows[0]!.origin).toContain("--registry");
|
|
439
|
+
expect(rows[1]).toMatchObject({ key: "registry.token", value: "(无源可绑定)" });
|
|
440
|
+
expect(rows[2]!.value).toMatch(/^https?:\/\//);
|
|
441
|
+
expect(rows[3]!.value).toContain("缺失");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("configRows:站点凭据只报设没设,不回显账号密码", () => {
|
|
445
|
+
const rows = configRows({ cwd: tmpRoot, home, env: { OJM_USER: "admin", OJM_PASS: "p@ss" } as NodeJS.ProcessEnv });
|
|
446
|
+
const site = rows.find(row => row.key === "site.credentials")!;
|
|
447
|
+
expect(site.value).toContain("已设置");
|
|
448
|
+
expect(JSON.stringify(rows)).not.toContain("p@ss");
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe("g Q7:uninstall 在联网前就拒绝 --registry", () => {
|
|
453
|
+
it("命令体直接抛用法错误,一次 fetch 都没发", async () => {
|
|
454
|
+
const fetchFn = vi.fn();
|
|
455
|
+
await expect(uninstallCommand("demo", { siteUrl: "http://x", user: "u", pass: "p", registry: REGISTRY, fetchFn: fetchFn as unknown as typeof fetch }))
|
|
456
|
+
.rejects
|
|
457
|
+
.toThrow(/不涉及仓库/);
|
|
458
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
describe("h REG-R5:ojm view 同时显示目录键与物理名", () => {
|
|
463
|
+
const PKG = { scope: "acme", name: "demo", slug: "acme-demo", directoryKey: "@acme/demo", createdAt: 1_700_000_000_000 };
|
|
464
|
+
|
|
465
|
+
/** 只实现 fetch 用到的三件事:ok/status/text(unwrapEnvelope 的口径) */
|
|
466
|
+
function jsonResponse(body: unknown): { ok: boolean, status: number, text: () => Promise<string> } {
|
|
467
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(body) };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function fakeSite(): { fetchFn: ReturnType<typeof vi.fn>, calls: Array<{ url: string, init?: RequestInit }> } {
|
|
471
|
+
const calls: Array<{ url: string, init?: RequestInit }> = [];
|
|
472
|
+
const fetchFn = vi.fn(async (url: string, init?: RequestInit) => {
|
|
473
|
+
calls.push({ url, init });
|
|
474
|
+
if (String(url).endsWith("/api/auth/login"))
|
|
475
|
+
return jsonResponse({ code: 0, data: { access_token: "site-token" } });
|
|
476
|
+
return jsonResponse({ code: 0, data: PKG });
|
|
477
|
+
});
|
|
478
|
+
return { fetchFn, calls };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
it("只收目录键:裸 slug 不反推(换算不可逆)", () => {
|
|
482
|
+
expect(parseViewTarget("@acme/demo")).toEqual({ scope: "acme", name: "demo" });
|
|
483
|
+
expect(parseViewTarget("acme/demo")).toEqual({ scope: "acme", name: "demo" });
|
|
484
|
+
for (const bad of ["demo", "acme-x-demo", "@acme", "@acme/", "@Acme/demo", "@acme/de_mo"]) {
|
|
485
|
+
expect(() => parseViewTarget(bad), bad).toThrow(UsageError);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("登录后按目录键取一条,两个名字都打印(物理名不在客户端重算)", async () => {
|
|
490
|
+
const { fetchFn, calls } = fakeSite();
|
|
491
|
+
const data = await viewCommand("@acme/demo", { siteUrl: "http://site.test", user: "u", pass: "p", fetchFn: fetchFn as unknown as typeof fetch });
|
|
492
|
+
expect(data).toEqual(PKG);
|
|
493
|
+
expect(calls).toHaveLength(2);
|
|
494
|
+
expect(calls[0]!.url).toBe("http://site.test/api/auth/login");
|
|
495
|
+
expect(calls[1]!.url).toBe("http://site.test/api/oj-registry/packages/acme/demo");
|
|
496
|
+
expect((calls[1]!.init?.headers as Record<string, string>).authorization).toBe("Bearer site-token");
|
|
497
|
+
|
|
498
|
+
const text = out.join("\n");
|
|
499
|
+
expect(text).toContain("@acme/demo");
|
|
500
|
+
expect(text).toContain("acme-demo");
|
|
501
|
+
expect(text).toContain("2023-11-14"); // createdAt=1700000000000 → UTC 格式化留痕
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("--json:stdout 只有一行信封,人话改走 stderr", async () => {
|
|
505
|
+
const { fetchFn } = fakeSite();
|
|
506
|
+
await viewCommand("@acme/demo", { siteUrl: "http://site.test", user: "u", pass: "p", json: true, fetchFn: fetchFn as unknown as typeof fetch });
|
|
507
|
+
const env = stdoutEnvelope({ out, err, exitCode: undefined });
|
|
508
|
+
expect(env.command).toBe("view");
|
|
509
|
+
expect(Object.keys(env.data!).sort()).toEqual(["createdAt", "directoryKey", "name", "scope", "site", "slug"]);
|
|
510
|
+
expect(env.data!).toMatchObject({ directoryKey: "@acme/demo", slug: "acme-demo", site: "http://site.test" });
|
|
511
|
+
expect(err.join("\n")).toContain("物理名");
|
|
512
|
+
expect(err.join("\n")).toContain("acme-demo");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("缺站点凭据 → 用法错误(exit 2 语义),一次 fetch 都不发", async () => {
|
|
516
|
+
const { fetchFn } = fakeSite();
|
|
517
|
+
await expect(viewCommand("@acme/demo", { user: "", pass: "", fetchFn: fetchFn as unknown as typeof fetch }))
|
|
518
|
+
.rejects
|
|
519
|
+
.toBeInstanceOf(UsageError);
|
|
520
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
521
|
+
});
|
|
522
|
+
});
|