@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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// REG-CS5 三级索引读取源(DIP,S2-1③):把「索引字节从哪来」与「索引怎么校验」分开。
|
|
2
|
+
// 本模块只负责取字节——HTTP(条件请求/304)与本地目录两实现;签名校验、同代钉值校验、
|
|
3
|
+
// 新鲜度校验全部留在调用方 market/api.ts(fetchIndex),读写两侧不重复实现校验语义。
|
|
4
|
+
//
|
|
5
|
+
// 三级索引形态(与 ojm market build-index 落盘逐字一致):
|
|
6
|
+
// catalog.yaml 总目录:{seq, issued_at, expires_at, shards: {<片号>: <代次钉值>},
|
|
7
|
+
// solutions: [{name, shards: [<片号>...]}]}——无版本明细,提交点
|
|
8
|
+
// shards/<n>.yaml 分片:{solutions: [{name, latest, versions: [<Form B 条目>]}]}——
|
|
9
|
+
// 纯内容无 meta,代次由 .sig 与 catalog 钉值携带;版本条目即 manifest
|
|
10
|
+
|
|
11
|
+
import type { FetchInitFace } from "../_shared/site-facade";
|
|
12
|
+
import { fsFace } from "../_lifecycle/atomic";
|
|
13
|
+
|
|
14
|
+
/** 索引文件种类(REG-CS1:签名覆盖 (kind, seq);与 ojm publish 的 IndexKind 逐字同集合) */
|
|
15
|
+
export type IndexKind = "catalog" | "shard";
|
|
16
|
+
|
|
17
|
+
/** 索引文件(正文 + detached 签名)。sig 缺失 → null:调用方按 index_sig_missing 拒载,不猜 */
|
|
18
|
+
export interface IndexFile {
|
|
19
|
+
body: string
|
|
20
|
+
sig: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 索引读取源(S2-1③ DIP):`read` 返回 null = 拉不到(网络错 / 非 2xx / 文件不存在),
|
|
25
|
+
* 拒载还是降级由调用方裁决——本模块不做任何校验决策、不抛业务错误。
|
|
26
|
+
*/
|
|
27
|
+
export interface IndexSource {
|
|
28
|
+
read: (kind: IndexKind, no?: number) => Promise<IndexFile | null>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 索引相对路径(CDN 布局单点:catalog.yaml + shards/<n>.yaml;片号由调用方校验为正整数) */
|
|
32
|
+
export function indexPathOf(kind: IndexKind, no?: number): string {
|
|
33
|
+
return kind === "catalog" ? "catalog.yaml" : `shards/${no}.yaml`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 条件请求缓存(进程/模块级,与 site-facade 的 canonicalOriginCache 同模式):
|
|
38
|
+
* url → {etag, body}。oj isolate 若按请求新建则退化为「请求内去重」,若被运行时复用则
|
|
39
|
+
* 跨请求生效——两种生命周期下语义都正确:304 时服务端已确认字节未变,缓存正文即可信。
|
|
40
|
+
*/
|
|
41
|
+
const etagCache = new Map<string, { etag: string, body: string }>();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* HTTP 索引源(S2-1③/④):ETag + If-None-Match 条件请求,304 走缓存正文不重传。
|
|
45
|
+
*
|
|
46
|
+
* **不再发 `cache-control: no-cache`**(旧口径):读面新鲜度改由服务端缓存头
|
|
47
|
+
* (`Cache-Control: max-age=60, stale-while-revalidate=60`,Q8:yank 最坏 120s 生效)
|
|
48
|
+
* 与条件请求共同承载。索引自身仍带 expires_at(REG-CS2 硬过期即拒)与 seq 水位
|
|
49
|
+
* (防回放),缓存层只决定「少传字节」,不决定「信不信」——§5.3 可用性通道的
|
|
50
|
+
* 「窗口内用缓存副本」由 HTTP 缓存层(中间层 + 服务端 ETag/304)兑现,读取器不存第二份真相。
|
|
51
|
+
*/
|
|
52
|
+
export function httpIndexSource(fetch: FetchInitFace, base: string): IndexSource {
|
|
53
|
+
const root = base.replace(/\/+$/, "");
|
|
54
|
+
const readOne = async (rel: string): Promise<string | null> => {
|
|
55
|
+
const url = `${root}/${rel}`;
|
|
56
|
+
const cached = etagCache.get(url);
|
|
57
|
+
let res: Awaited<ReturnType<FetchInitFace>>;
|
|
58
|
+
try {
|
|
59
|
+
res = await fetch(url, cached === undefined ? undefined : { headers: { "if-none-match": cached.etag } });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null; // 网络层失败:拉不到,拒载/降级决策归调用方
|
|
63
|
+
}
|
|
64
|
+
// 304:服务端认了我们的 ETag——正文未变,用缓存副本,不重传(BDD:重复拉取走 304)
|
|
65
|
+
if (res.status === 304 && cached !== undefined)
|
|
66
|
+
return cached.body;
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
return null;
|
|
69
|
+
const body = new TextDecoder().decode(await res.arrayBuffer());
|
|
70
|
+
// 无 ETag 的响应不进缓存:没有条件请求的锚点,缓存它毫无意义
|
|
71
|
+
const etag = res.headers?.get("etag") ?? res.headers?.get("ETag") ?? null;
|
|
72
|
+
if (etag !== null && etag !== "")
|
|
73
|
+
etagCache.set(url, { etag, body });
|
|
74
|
+
return body;
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
read: async (kind, no) => {
|
|
78
|
+
const rel = indexPathOf(kind, no);
|
|
79
|
+
const [body, sig] = await Promise.all([readOne(rel), readOne(`${rel}.sig`)]);
|
|
80
|
+
return body === null ? null : { body, sig };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 本地目录索引源(S2-1③ 第二实现):索引就在本机目录(离线消费 / 自供直读 / 测试夹具),
|
|
87
|
+
* 不经 HTTP。校验语义与 HTTP 实现完全同构——调用方看不出区别,这正是 DIP 的目的。
|
|
88
|
+
*/
|
|
89
|
+
export function localIndexSource(dir: string): IndexSource {
|
|
90
|
+
const root = dir.replace(/\/+$/, "");
|
|
91
|
+
return {
|
|
92
|
+
read: async (kind, no) => {
|
|
93
|
+
const f = fsFace();
|
|
94
|
+
const rel = indexPathOf(kind, no);
|
|
95
|
+
let body: string;
|
|
96
|
+
try {
|
|
97
|
+
body = new TextDecoder().decode(await f.readFile(`${root}/${rel}`));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null; // 目录里没有这个索引文件
|
|
101
|
+
}
|
|
102
|
+
let sig: string | null = null;
|
|
103
|
+
try {
|
|
104
|
+
sig = new TextDecoder().decode(await f.readFile(`${root}/${rel}.sig`));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
sig = null; // 缺签名:调用方按 index_sig_missing 拒载(与 HTTP 实现同口径)
|
|
108
|
+
}
|
|
109
|
+
return { body, sig };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- reg_source_seq mysql 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
|
|
2
|
+
-- 差异:INTEGER → BIGINT;TEXT 主键须给长度(前缀键无法承载长 URL),按 1024 收窄;
|
|
3
|
+
-- updated_at 用 BIGINT(秒级 epoch)。
|
|
4
|
+
CREATE TABLE IF NOT EXISTS reg_source_seq (
|
|
5
|
+
source_url VARCHAR(1024) NOT NULL,
|
|
6
|
+
max_seq BIGINT NOT NULL,
|
|
7
|
+
updated_at BIGINT NOT NULL,
|
|
8
|
+
PRIMARY KEY (source_url)
|
|
9
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- reg_source_seq pg 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
|
|
2
|
+
-- 差异:INTEGER → BIGINT(seq 单调长期累加);TEXT 在 pg 合法,保留 TEXT。
|
|
3
|
+
CREATE TABLE IF NOT EXISTS reg_source_seq (
|
|
4
|
+
source_url TEXT NOT NULL PRIMARY KEY,
|
|
5
|
+
max_seq BIGINT NOT NULL,
|
|
6
|
+
updated_at BIGINT NOT NULL
|
|
7
|
+
);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- reg_source_seq(REG-CS2 索引新鲜度):站点按源记住「见过的最大 seq」,回退即拒(防回放/降级)。
|
|
2
|
+
-- 受害者是站点,记录点就在站点(D1);CLI/API 同一判定,不另设状态文件。
|
|
3
|
+
-- 结构只进 migrations(S006);与 schema.yaml 列集一致。
|
|
4
|
+
CREATE TABLE IF NOT EXISTS reg_source_seq (
|
|
5
|
+
source_url TEXT NOT NULL PRIMARY KEY,
|
|
6
|
+
max_seq INTEGER NOT NULL,
|
|
7
|
+
updated_at INTEGER NOT NULL
|
|
8
|
+
);
|
|
@@ -127,3 +127,11 @@ tables:
|
|
|
127
127
|
operator: {type: text}
|
|
128
128
|
message: {type: text}
|
|
129
129
|
created_at: {type: integer, null: false}
|
|
130
|
+
# REG-CS2 索引新鲜度:站点按源记住见过的最大 seq(防回放/降级,回退即拒)。
|
|
131
|
+
# 读写由 _lifecycle/source-seq.ts 落到本表;source_url 为业务主键(无自增 id)。
|
|
132
|
+
reg_source_seq:
|
|
133
|
+
pk: source_url
|
|
134
|
+
columns:
|
|
135
|
+
source_url: {type: text, null: false}
|
|
136
|
+
max_seq: {type: integer, null: false}
|
|
137
|
+
updated_at: {type: integer, null: false}
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
import { getLastReport, persistReport, verifyReportsDirOf } from "../_lifecycle/verify-report";
|
|
26
26
|
import { respond } from "../_shared/respond";
|
|
27
27
|
import { assertSystemAdmin } from "../_shared/service/infra/access";
|
|
28
|
-
import { journalHeadOf, siteOrigin } from "../_shared/site-facade";
|
|
28
|
+
import { journalHeadOf, readCanonicalOrigin, siteOrigin } from "../_shared/site-facade";
|
|
29
29
|
|
|
30
30
|
/** 线格式行(与 packages/runtime/src/api/rbac/types.ts 的 SolutionRouteRow 对齐) */
|
|
31
31
|
export type { SolutionRouteRow } from "../_lifecycle/verify-checks";
|
|
@@ -41,6 +41,9 @@ export function solutionsEnv(): SolutionsEnv {
|
|
|
41
41
|
statePath: "modules/.solutions/state.yaml",
|
|
42
42
|
modulesJsonPath: "dist/web/modules.json",
|
|
43
43
|
apiPrefix: "/api",
|
|
44
|
+
// REG-CS6:深检探针补全用 configured canonical origin,绝不读请求头 Host(防 SSRF)。
|
|
45
|
+
// 此处不同步读 config(隔离环境只有异步读盘),canonicalOrigin 留待 verifyBody 内惰性解析。
|
|
46
|
+
configPath: "config.yaml",
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -96,8 +99,10 @@ async function verifyBody(env: SolutionsEnv, name: string): Promise<VerifyReport
|
|
|
96
99
|
|
|
97
100
|
const routes = routeRows.map(rowOf);
|
|
98
101
|
|
|
99
|
-
// ① 路由可达性(I/O 探针;
|
|
100
|
-
|
|
102
|
+
// ① 路由可达性(I/O 探针;origin 取自配置的 canonical_origin,REG-CS6 绝不读请求头 Host——
|
|
103
|
+
// 伪造 Host(如 169.254.169.254 云元数据)不能牵引探针出站;缺配置则 null → 跳过探针 info 项)
|
|
104
|
+
const canonicalOrigin = env.canonicalOrigin ?? (env.configPath ? (await readCanonicalOrigin(env.configPath)) ?? undefined : undefined);
|
|
105
|
+
const reach = await verifyRouteReachability(fetchOf(env), routes, siteOrigin(canonicalOrigin));
|
|
101
106
|
|
|
102
107
|
// ② RBAC 完整性:声明权限点 ↔ sys_permission 实有
|
|
103
108
|
const declaredPerms = [...new Set(routes.map(r => r.required_permission).filter((p): p is string => p != null))];
|
|
@@ -60,6 +60,19 @@ describe("cardGrid 卡片网格(BDD)", () => {
|
|
|
60
60
|
expect(screen.getByTestId("card-rollback-alpha")).toBeTruthy();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
it("given 索引带 license When 渲染 Then 卡片显示该项(P-2)", () => {
|
|
64
|
+
renderWithProviders(
|
|
65
|
+
<CardGrid rows={[{ ...installed, license: "MIT" }]} {...handlers} />,
|
|
66
|
+
new InMemoryMarketClient(),
|
|
67
|
+
);
|
|
68
|
+
expect(screen.getByTestId("card-license-alpha").textContent).toContain("MIT");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("given 索引未声明 license When 渲染 Then 卡片不出现许可行(不显示占位符)", () => {
|
|
72
|
+
renderWithProviders(<CardGrid rows={[installed]} {...handlers} />, new InMemoryMarketClient());
|
|
73
|
+
expect(screen.queryByTestId("card-license-alpha")).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
63
76
|
it("given 未安装卡片 When 渲染 Then 安装按钮显示「安装」而非「升级」,且无回滚按钮", () => {
|
|
64
77
|
renderWithProviders(<CardGrid rows={[notInstalled]} {...handlers} />, new InMemoryMarketClient());
|
|
65
78
|
expect(screen.getByText("安装")).toBeTruthy();
|
|
@@ -47,6 +47,44 @@ describe("installDialog 安装进度(BDD)", () => {
|
|
|
47
47
|
expect(screen.getByText("不可重试")).toBeTruthy();
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
it("given 索引带 license When 打开 Then 安装前展示「版本 + 许可」(P-2)", async () => {
|
|
51
|
+
const done: TaskRecord = {
|
|
52
|
+
id: "t1",
|
|
53
|
+
name: "alpha",
|
|
54
|
+
version: "1.2.0",
|
|
55
|
+
status: "done",
|
|
56
|
+
stage: "done",
|
|
57
|
+
progress: 100,
|
|
58
|
+
message: "ok",
|
|
59
|
+
stages: [],
|
|
60
|
+
startedAt: 0,
|
|
61
|
+
};
|
|
62
|
+
const client = new InMemoryMarketClient({ taskRecord: done, installResult: { task_id: "t1" } });
|
|
63
|
+
renderWithProviders(<InstallDialog row={{ ...row, license: "Apache-2.0" }} open onClose={() => {}} />, client);
|
|
64
|
+
expect(screen.getByTestId("install-meta").textContent).toContain("1.2.0");
|
|
65
|
+
expect(screen.getByTestId("install-license").textContent).toContain("Apache-2.0");
|
|
66
|
+
await waitFor(() => expect(screen.getByTestId("install-message").textContent).toBe("ok"), { timeout: 5000 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("given 索引未声明 license When 打开 Then 元信息只有版本、不出现许可段", async () => {
|
|
70
|
+
const done: TaskRecord = {
|
|
71
|
+
id: "t1",
|
|
72
|
+
name: "alpha",
|
|
73
|
+
version: "1.2.0",
|
|
74
|
+
status: "done",
|
|
75
|
+
stage: "done",
|
|
76
|
+
progress: 100,
|
|
77
|
+
message: "ok",
|
|
78
|
+
stages: [],
|
|
79
|
+
startedAt: 0,
|
|
80
|
+
};
|
|
81
|
+
const client = new InMemoryMarketClient({ taskRecord: done, installResult: { task_id: "t1" } });
|
|
82
|
+
renderWithProviders(<InstallDialog row={row} open onClose={() => {}} />, client);
|
|
83
|
+
expect(screen.getByTestId("install-meta").textContent).toContain("1.2.0");
|
|
84
|
+
expect(screen.queryByTestId("install-license")).toBeNull();
|
|
85
|
+
await waitFor(() => expect(screen.getByTestId("install-message").textContent).toBe("ok"), { timeout: 5000 });
|
|
86
|
+
});
|
|
87
|
+
|
|
50
88
|
it("given 回滚(显式旧版 version)When 打开 Then 以该版本触发 install 且标题为「回滚中」", async () => {
|
|
51
89
|
const done: TaskRecord = {
|
|
52
90
|
id: "t1",
|
|
@@ -75,6 +75,16 @@ export function CardItem({ row, busy, canManage = true, onInstall, onEnableToggl
|
|
|
75
75
|
</span>
|
|
76
76
|
)}
|
|
77
77
|
</div>
|
|
78
|
+
{/* 许可(P-2):索引未声明就不占位,避免满屏「未知」 */}
|
|
79
|
+
{typeof row.license === "string" && row.license !== "" && (
|
|
80
|
+
<div style={{ marginBottom: 12 }} data-testid={`card-license-${row.name}`}>
|
|
81
|
+
<Tag color="blue">
|
|
82
|
+
{t("oj-market:card:license")}
|
|
83
|
+
:
|
|
84
|
+
{row.license}
|
|
85
|
+
</Tag>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
78
88
|
<Space wrap>
|
|
79
89
|
<Button type="primary" size="small" loading={busy} onClick={() => onInstall(row)} disabled={busy || !canManage} title={manageTip}>
|
|
80
90
|
{installed ? t("oj-market:card:upgrade") : t("oj-market:card:install")}
|
|
@@ -121,6 +121,24 @@ export function InstallDialog({ row, open, onClose, onDone, version, mode }: Ins
|
|
|
121
121
|
footer={terminal ? <Button onClick={onClose}>{t("oj-market:task:close")}</Button> : null}
|
|
122
122
|
data-testid="install-dialog"
|
|
123
123
|
>
|
|
124
|
+
{/* 安装确认元信息(P-2):装之前把「装哪个版本、什么许可」摆在明面上 */}
|
|
125
|
+
{row && (
|
|
126
|
+
<div data-testid="install-meta" style={{ marginBottom: 12 }}>
|
|
127
|
+
<span>
|
|
128
|
+
{t("oj-market:card:version")}
|
|
129
|
+
:
|
|
130
|
+
{targetVersion ?? "-"}
|
|
131
|
+
</span>
|
|
132
|
+
{typeof row.license === "string" && row.license !== "" && (
|
|
133
|
+
<span data-testid="install-license">
|
|
134
|
+
{" · "}
|
|
135
|
+
{t("oj-market:card:license")}
|
|
136
|
+
:
|
|
137
|
+
{row.license}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
124
142
|
{errorTask && <Alert type="error" message={taskId!.slice("__error__".length)} />}
|
|
125
143
|
{!errorTask && !record && (
|
|
126
144
|
<Typography.Text>
|
|
@@ -52,13 +52,15 @@ export interface TaskRecord {
|
|
|
52
52
|
appliedSeq?: number
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** 卡片数据源行(GET /_platform/market/index):CDN
|
|
55
|
+
/** 卡片数据源行(GET /_platform/market/index):CDN 三级索引(catalog + shards)⊕ 本地 sidecar state */
|
|
56
56
|
export interface IndexRow {
|
|
57
57
|
name: string
|
|
58
58
|
latest: string | null
|
|
59
59
|
installed: string | null
|
|
60
60
|
enabled: boolean | null
|
|
61
61
|
upgradable: boolean
|
|
62
|
+
/** 最新版许可(SPDX 简化式,来自分片条目的 manifest.license;未声明 → null) */
|
|
63
|
+
license?: string | null
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/** 安装输入(POST /_platform/market/install) */
|
|
@@ -73,8 +73,11 @@ export default defineModule({
|
|
|
73
73
|
peerRuntime: ">=0.0.0",
|
|
74
74
|
// 路由级门禁(B16):非系统管理员不注入 /system 路由、菜单不可见;
|
|
75
75
|
// API 级 403 由 assertSystemAdmin + service 层硬 enforcement 兜底。
|
|
76
|
-
// 角色口径对齐后端 assertSystemAdmin(access.ts:
|
|
77
|
-
// admin
|
|
76
|
+
// 角色口径对齐后端 assertSystemAdmin(access.ts:87)——superadmin **或**
|
|
77
|
+
// admin 均可,两者必须同时保留:D12(2026-10-22)起账号名=角色名,admin 与
|
|
78
|
+
// superadmin 是两个独立账号(seed.sql),只留 ["admin"] 会把 superadmin 挡在
|
|
79
|
+
// 门外(注册期过滤 → 路由消失 → 登录后 404 兜底)。改这两个值前先跑
|
|
80
|
+
// tests/system-guard.regression.spec.ts(只留 ["admin"] 会立刻变红)。
|
|
78
81
|
config: { requiredRoles: ["admin", "superadmin"] },
|
|
79
82
|
routes,
|
|
80
83
|
i18n: {
|
|
@@ -260,6 +260,30 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
|
|
|
260
260
|
expect(await getTask(taskDir, "nope")).toBeNull();
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
it("preview 形态:派生 .ojm-preview.yaml 存在时两份 config 一起自增(否则 reload 恒 unchanged)", async () => {
|
|
264
|
+
// ojm preview 派生出来的那份才是服务端在读的 config(spa 挂载 + platform.root 改写)
|
|
265
|
+
const derived = join(root, ".ojm-preview.yaml");
|
|
266
|
+
await fsp.writeFile(derived, "mounts: []\nserver:\n reload_api: true\nvars:\n ojm_install_seq: 0\nauth:\n anonymous_paths:\n - /auth/login\n");
|
|
267
|
+
|
|
268
|
+
const t = await runInstall(makeReq());
|
|
269
|
+
expect(t.status).toBe("done");
|
|
270
|
+
|
|
271
|
+
const source = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
|
|
272
|
+
const preview = new TextDecoder().decode(await fsp.readFile(derived));
|
|
273
|
+
// 源 config 仍是真相(preview 重启后按它重建派生文件)
|
|
274
|
+
expect(source).toContain("ojm_install_seq: 1");
|
|
275
|
+
expect(source).toContain("- /demo-solution/ping");
|
|
276
|
+
// 派生文件也要变:reloadConfig 按它的文档 sha 判 unchanged
|
|
277
|
+
expect(preview).toContain("ojm_install_seq: 1");
|
|
278
|
+
expect(preview).toContain("- /demo-solution/ping");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("非 preview 形态:没有派生文件就不无中生有(dev / 生产部署只写源 config)", async () => {
|
|
282
|
+
const t = await runInstall(makeReq());
|
|
283
|
+
expect(t.status).toBe("done");
|
|
284
|
+
await expect(fsp.access(join(root, ".ojm-preview.yaml"))).rejects.toThrow();
|
|
285
|
+
});
|
|
286
|
+
|
|
263
287
|
it("P2-13 spawn 流式双路径:appliedSeq 仍在(跨 chunk 拼接解析)+ 进度字段形状不变(轮询消费面兼容)", async () => {
|
|
264
288
|
const base = bundleExec();
|
|
265
289
|
const exec: ExecFace = {
|