@oj-module/cli 0.2.66 → 0.2.68
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/bin/.oj-version +1 -0
- package/bin/oj +4 -0
- package/devkit/README.md +15 -7
- package/devkit/SKILL.md +38 -4
- package/devkit/framework-dev.md +10 -4
- package/devkit/manual.md +146 -24
- package/devkit/menu-rules.md +19 -4
- package/devkit/module-dev-and-cdn-playground.md +4 -0
- package/devkit/publish-walkthrough.md +132 -37
- package/devkit/wecom-internal-app.md +8 -3
- package/devkit/wecom-multi-corp-links.md +163 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
- package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.1xzahckt10.js} +4863 -4847
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +31 -0
- package/src/__tests__/new-scaffold.test.ts +13 -0
- package/src/__tests__/publish-keygen.test.ts +157 -0
- package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
- package/src/__tests__/templates-schema-columns.test.ts +15 -2
- package/src/__tests__/templates-tenant-scope.test.ts +6 -2
- package/src/build/module-def.ts +1 -0
- package/src/builtin.ts +3 -2
- package/src/bundle.ts +8 -3
- package/src/cli.ts +21 -5
- package/src/dev.ts +15 -10
- package/src/init.ts +47 -41
- package/src/install.ts +11 -4
- package/src/keygen.ts +54 -0
- package/src/lifecycle/__tests__/bundle.test.ts +21 -3
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
- package/src/lifecycle/__tests__/smoke.test.ts +2 -2
- package/src/manifest-contract.ts +91 -8
- package/src/market/bootstrap.ts +26 -2
- package/src/market/command.ts +1 -0
- package/src/market/publish.ts +8 -1
- package/src/market/walkthrough.ts +24 -8
- package/src/new.ts +2 -0
- package/src/scaffold-guard.ts +227 -0
- package/src/site.ts +2 -0
- package/src/usage.ts +13 -9
- package/src/vendor.ts +5 -1
- package/templates/config.yaml +4 -2
- package/templates/init.include.yaml +10 -3
- package/templates/openapi.json +460 -1
- package/templates/src/README.md +37 -0
- package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +78 -25
- package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
- package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
- package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
- package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
- package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
- package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
- package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +25 -10
- package/templates/src/app/api/_platform/manifest.yaml +1 -0
- package/templates/src/app/api/_platform/market/api.ts +235 -10
- package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
- package/templates/src/app/api/_platform/schema.yaml +18 -0
- package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
- package/templates/src/app/api/_platform/seed.pg.sql +10 -0
- package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
- package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
- package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
- package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
- package/templates/src/app/api/home/seed.mysql.sql +71 -0
- package/templates/src/app/api/home/seed.pg.sql +71 -0
- package/templates/src/app/api/notification/_shared/notify.ts +6 -6
- package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
- package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
- package/templates/src/app/api/notification/seed.mysql.sql +8 -0
- package/templates/src/app/api/notification/seed.pg.sql +11 -0
- package/templates/src/app/api/publish/_shared/identity.ts +69 -0
- package/templates/src/app/api/publish/api.ts +349 -0
- package/templates/src/app/api/publish/manifest.yaml +8 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
- package/templates/src/app/api/publish/schema.yaml +33 -0
- package/templates/src/app/api/system/login-methods/api.ts +2 -1
- package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
- package/templates/src/app/web/_platform/__client/index.ts +25 -1
- package/templates/src/app/web/entry.ts +9 -5
- package/templates/src/app/web/home/routes.ts +5 -0
- package/templates/src/app/web/login/locales/en-US.json +7 -1
- package/templates/src/app/web/login/locales/zh-CN.json +7 -1
- package/templates/src/app/web/login/pages/login.tsx +79 -86
- package/templates/src/app/web/login/routes.ts +54 -1
- package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
- package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
- package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
- package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
- package/templates/src/app/web/system/locales/en-US.json +1 -8
- package/templates/src/app/web/system/locales/zh-CN.json +1 -8
- package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
- package/templates/src/app/web/system/routes.ts +0 -6
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
- package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
- package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
- package/templates/src/oj-market/web/api/client.ts +3 -0
- package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
- package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
- package/templates/src/oj-market/web/entry.ts +31 -2
- package/templates/src/oj-market/web/locales/en-US.json +5 -1
- package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
- package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
- package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
- package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
- package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
- package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
- package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
- package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
- package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
- package/templates/src/oj-market/web/publish/routes.ts +41 -0
- package/templates/src/oj-market/web/types.ts +30 -0
- package/templates/tests/lifecycle/compensate.spec.ts +207 -0
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
- package/templates/tests/lifecycle/installer.spec.ts +28 -9
- package/templates/tests/lifecycle/market-api.spec.ts +164 -12
- package/templates/tests/lifecycle/preflight.spec.ts +68 -0
- package/templates/tests/lifecycle/register.spec.ts +22 -0
- package/templates/tests/lifecycle/role-title-translate.spec.ts +4 -34
- package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
- package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
- package/templates/tests/publish-credential.spec.ts +354 -0
- package/templates/tests/publish-identity.spec.ts +44 -0
- package/templates/tests/rbac-solution-perms.spec.ts +48 -3
- package/templates/tests/wecom-client-timeout.spec.ts +33 -3
- package/templates/tests/wecom-landing.spec.ts +63 -1
- package/templates/tsconfig.paths.json +1 -0
- package/templates/web.config.ts +8 -4
- package/vendor/host-versions.json +3 -3
- package/templates/seed.mysql.sql +0 -6
- package/templates/seed.pg.sql +0 -6
- package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
- package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
- package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
- package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
- package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
- package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 「发布管理」页的纯逻辑(无 React、无 IO)——状态颜色、可撤销判定、给用户的命令行。
|
|
2
|
+
// 抽出来的唯一理由:这三件事是页面里最该被钉死的口径,且都必须能脱离 antd 断言。
|
|
3
|
+
|
|
4
|
+
export type CredentialStatus = "active" | "used" | "revoked" | "expired";
|
|
5
|
+
|
|
6
|
+
/** 状态 → antd Tag 颜色。Record 全量约束:新增状态时这里编译期就缺键。 */
|
|
7
|
+
const STATUS_COLOR: Record<CredentialStatus, string> = {
|
|
8
|
+
active: "processing",
|
|
9
|
+
used: "success",
|
|
10
|
+
revoked: "default",
|
|
11
|
+
expired: "warning",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function statusColor(status: CredentialStatus): string {
|
|
15
|
+
return STATUS_COLOR[status];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 能否撤销:只有「待兑换」可以。
|
|
20
|
+
*
|
|
21
|
+
* 兑换只下发 key_id,公钥是发布方本机生成的——站点手里没有那把钥匙,也就没有「撤回」可言;
|
|
22
|
+
* 撤回一条已兑换的凭证只会制造「以为撤了其实没撤」的错觉(后端的 400 文案就是这个意思,
|
|
23
|
+
* 真要停用只能到宿主信任库移除对应公钥)。按钮仍要渲染(禁用态),不用隐藏:
|
|
24
|
+
* 隐藏会让用户以为页面没做完。
|
|
25
|
+
*/
|
|
26
|
+
export function canRevoke(status: string): boolean {
|
|
27
|
+
return status === "active";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 复制给用户的一行命令。
|
|
32
|
+
*
|
|
33
|
+
* 只带凭证,不带 `--site`:站点地址由 CLI 从本机配置或上次发布记录里取,
|
|
34
|
+
* 页面拼一个 origin 反而在反代/多域名部署下更容易错。
|
|
35
|
+
*/
|
|
36
|
+
export function buildKeygenCommand(token: string): string {
|
|
37
|
+
return `ojm keygen --credential ${token}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 统一信封错误 → 人话。后端 fail(status, message) 的文案本身就是给用户看的,
|
|
42
|
+
* 故优先取 `msg`(ContractApiError),其次 Error.message。
|
|
43
|
+
*
|
|
44
|
+
* 与 system/pages/errors.ts 的 errMsg 同构但不互相 import:那是系统管理根内部模块,
|
|
45
|
+
* 让「发布管理」依赖它会把两个根的加载顺序绑在一起(多根聚合下互不依赖是硬约定)。
|
|
46
|
+
*/
|
|
47
|
+
export function errorText(e: unknown): string {
|
|
48
|
+
const msg = (e as { msg?: unknown } | null)?.msg;
|
|
49
|
+
if (typeof msg === "string" && msg !== "")
|
|
50
|
+
return msg;
|
|
51
|
+
return e instanceof Error ? e.message : String(e);
|
|
52
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Alert, Button, Input, Modal, Space, Typography } from "antd";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useTranslation } from "react-i18next";
|
|
4
|
+
|
|
5
|
+
import { buildKeygenCommand } from "./model";
|
|
6
|
+
|
|
7
|
+
/** 申请成功的返回值:明文只在这一刻存在 */
|
|
8
|
+
export interface IssuedCredential {
|
|
9
|
+
token: string
|
|
10
|
+
key_id: string
|
|
11
|
+
product: string
|
|
12
|
+
token_hint?: string
|
|
13
|
+
expires_at: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 复制到剪贴板:非 https / 无权限时静默失败(输入框里的文本本来就能手动选中) */
|
|
17
|
+
async function copy(text: string): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
await navigator.clipboard.writeText(text);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// 不打断流程:用户可直接在只读输入框里选中复制
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 一次性明文弹窗。
|
|
28
|
+
*
|
|
29
|
+
* 三条不可退让的约束:
|
|
30
|
+
* · 不渲染 = 没有。`issued` 为 null 时整个 Modal 不挂载,关闭后 DOM 里不再有明文
|
|
31
|
+
* (服务端只存 sha256,关掉就真的找不回来——所以文案必须先说清楚)。
|
|
32
|
+
* · 不可点遮罩/ESC 关闭:误触的代价是重新申请一张凭证。
|
|
33
|
+
* · 凭证与命令都是只读输入框 + 复制按钮:64 位 hex 手抄必错。
|
|
34
|
+
*/
|
|
35
|
+
export function TokenModal({ issued, onClose }: { issued: IssuedCredential | null, onClose: () => void }) {
|
|
36
|
+
const { t } = useTranslation();
|
|
37
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
38
|
+
if (!issued)
|
|
39
|
+
return null;
|
|
40
|
+
const command = buildKeygenCommand(issued.token);
|
|
41
|
+
const copyRow = (testId: string, value: string) => (
|
|
42
|
+
<Space.Compact style={{ width: "100%", marginBottom: 16 }}>
|
|
43
|
+
<Input readOnly value={value} data-testid={testId} />
|
|
44
|
+
<Button
|
|
45
|
+
onClick={() => {
|
|
46
|
+
void copy(value);
|
|
47
|
+
setCopied(testId);
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{copied === testId ? t("publish:issued.copied") : t("publish:issued.copy")}
|
|
51
|
+
</Button>
|
|
52
|
+
</Space.Compact>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Modal
|
|
57
|
+
open
|
|
58
|
+
closable={false}
|
|
59
|
+
maskClosable={false}
|
|
60
|
+
keyboard={false}
|
|
61
|
+
title={t("publish:issued.title")}
|
|
62
|
+
footer={<Button type="primary" onClick={onClose}>{t("publish:issued.done")}</Button>}
|
|
63
|
+
>
|
|
64
|
+
<div data-testid="publish-issued">
|
|
65
|
+
<Alert type="warning" showIcon message={t("publish:issued.warning")} style={{ marginBottom: 16 }} />
|
|
66
|
+
<Typography.Paragraph>
|
|
67
|
+
<Typography.Text type="secondary">{t("publish:issued.identity")}</Typography.Text>
|
|
68
|
+
<br />
|
|
69
|
+
<Typography.Text copyable={{ text: issued.key_id }} code>{issued.key_id}</Typography.Text>
|
|
70
|
+
</Typography.Paragraph>
|
|
71
|
+
<Typography.Paragraph style={{ marginBottom: 4 }}>
|
|
72
|
+
<Typography.Text type="secondary">{t("publish:issued.token")}</Typography.Text>
|
|
73
|
+
</Typography.Paragraph>
|
|
74
|
+
{copyRow("publish-token", issued.token)}
|
|
75
|
+
<Typography.Paragraph style={{ marginBottom: 4 }}>
|
|
76
|
+
<Typography.Text type="secondary">{t("publish:issued.command")}</Typography.Text>
|
|
77
|
+
</Typography.Paragraph>
|
|
78
|
+
{copyRow("publish-command", command)}
|
|
79
|
+
</div>
|
|
80
|
+
</Modal>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AppRouteRecordRaw, ModuleContext } from "@oj-module/runtime";
|
|
2
|
+
|
|
3
|
+
import { HistoryOutlined, KeyOutlined } from "@ant-design/icons";
|
|
4
|
+
|
|
5
|
+
import { createElement, lazy } from "react";
|
|
6
|
+
|
|
7
|
+
import { createPublishClient } from "./__client";
|
|
8
|
+
|
|
9
|
+
const PublishPage = lazy(() => import("./pages"));
|
|
10
|
+
const ReleaseHistoryPage = lazy(() => import("./pages/history"));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 「方案市场」下的两个子页(单模单根:菜单只有 `/oj-market` 一个根)。
|
|
14
|
+
*
|
|
15
|
+
* 路径是**相对父路由**声明的:`publish` → `/oj-market/publish`,`history` → `/oj-market/history`。
|
|
16
|
+
* 发布管理不挂 handle.roles:申请发布凭证是普通登录用户的能力,超管多出来的只是
|
|
17
|
+
* 「看到所有租户的凭证」,由后端按角色决定返回范围,前端不重复判权。
|
|
18
|
+
*/
|
|
19
|
+
export const routes: AppRouteRecordRaw[] = [
|
|
20
|
+
{
|
|
21
|
+
path: "publish",
|
|
22
|
+
Component: PublishPage,
|
|
23
|
+
handle: {
|
|
24
|
+
title: "publish:menu.publish",
|
|
25
|
+
icon: createElement(KeyOutlined),
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
path: "history",
|
|
30
|
+
Component: ReleaseHistoryPage,
|
|
31
|
+
handle: {
|
|
32
|
+
title: "publish:menu.history",
|
|
33
|
+
icon: createElement(HistoryOutlined),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** 构造即接线:登记契约前缀 /publish + 绑定 scoped request,页面直接 import 的函数才有通道 */
|
|
39
|
+
export function onInit(ctx: ModuleContext): void {
|
|
40
|
+
createPublishClient(ctx);
|
|
41
|
+
}
|
|
@@ -61,6 +61,12 @@ export interface IndexRow {
|
|
|
61
61
|
upgradable: boolean
|
|
62
62
|
/** 最新版许可(SPDX 简化式,来自分片条目的 manifest.license;未声明 → null) */
|
|
63
63
|
license?: string | null
|
|
64
|
+
/** 发布者(分片条目 archive.signature.key_id,经验签的签名身份;与后端 trust_state/key_id 同用 snake_case) */
|
|
65
|
+
publisher_key?: string | null
|
|
66
|
+
/** 方案简介(manifest.summary,C2-04 单行 1..200;索引未带该字段 → undefined/null) */
|
|
67
|
+
summary?: string | null
|
|
68
|
+
/** 方案详细描述(manifest.description,C2-05 ≤2000;索引未带该字段 → undefined/null) */
|
|
69
|
+
description?: string | null
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/** 安装输入(POST /_platform/market/install) */
|
|
@@ -97,3 +103,27 @@ export interface UninstallImpact {
|
|
|
97
103
|
affectedRoles: number
|
|
98
104
|
affectedUsers: number
|
|
99
105
|
}
|
|
106
|
+
|
|
107
|
+
/** 发布历史一行(`market_release`;来源是签名索引,追加式记录) */
|
|
108
|
+
export interface ReleaseRow {
|
|
109
|
+
id: number
|
|
110
|
+
product: string
|
|
111
|
+
version: string
|
|
112
|
+
key_id: string | null
|
|
113
|
+
sha256: string | null
|
|
114
|
+
size: number | null
|
|
115
|
+
published_at: number
|
|
116
|
+
/** file = 分发包 Last-Modified;index = 索引生成时间兜底 */
|
|
117
|
+
published_at_source: "file" | "index"
|
|
118
|
+
yanked: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 发布历史分页结果 */
|
|
122
|
+
export interface ReleasePage {
|
|
123
|
+
list: ReleaseRow[]
|
|
124
|
+
total: number
|
|
125
|
+
page: number
|
|
126
|
+
size: number
|
|
127
|
+
/** 本次同步索引失败的人话(页面照常显示已入库历史) */
|
|
128
|
+
syncError: string | null
|
|
129
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// BDD:安装补偿清单(P0 §3.5)。锁后失败把站点恢复到「装前代」——旧代继续服务、任务 partial+可重试。
|
|
2
|
+
// 直接驱动 compensate()(不重跑整个状态机),配真实内存库夹具(installDb)+ 真实临时文件系统 +
|
|
3
|
+
// stub reload 面,覆盖:完整回滚、幂等、部分失败仍尽力回滚、匿名面不随失败放大(验收 4)。
|
|
4
|
+
//
|
|
5
|
+
// 资产回滚原语:removeAnonymousPaths(text-edit)/ revertLock(lock)/ removeModulesJsonEntry
|
|
6
|
+
// (deploy-web)/ revertRegistrations(uninstall,复用卸载事务)。各原语幂等(缺则 no-op),
|
|
7
|
+
// 故补偿可安全被重试端点反复调用。
|
|
8
|
+
|
|
9
|
+
import * as fsp from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
13
|
+
import { atomicWriteText } from "../../src/app/api/_platform/_lifecycle/atomic";
|
|
14
|
+
import { compensate } from "../../src/app/api/_platform/_lifecycle/compensate";
|
|
15
|
+
import { parseLock, writeLock } from "../../src/app/api/_platform/_lifecycle/lock";
|
|
16
|
+
import { appendAnonymousPaths, removeAnonymousPaths } from "../../src/app/api/_platform/_lifecycle/text-edit";
|
|
17
|
+
import { installDb } from "../fixture";
|
|
18
|
+
|
|
19
|
+
const g = globalThis as any;
|
|
20
|
+
|
|
21
|
+
const BASE_CONFIG = "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n - /system/login-methods\n";
|
|
22
|
+
|
|
23
|
+
function nodeFsFace() {
|
|
24
|
+
return {
|
|
25
|
+
writeFile: (p: string, d: any) => fsp.writeFile(p, d),
|
|
26
|
+
readFile: (p: string) => fsp.readFile(p),
|
|
27
|
+
mkdir: (p: string, o: any) => fsp.mkdir(p, o),
|
|
28
|
+
remove: (p: string, o: any) => fsp.rm(p, o),
|
|
29
|
+
rename: (o: string, n: string) => fsp.rename(o, n),
|
|
30
|
+
copyFile: (s: string, d: string) => fsp.copyFile(s, d),
|
|
31
|
+
readdir: (p: string) => fsp.readdir(p),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let root: string;
|
|
36
|
+
let configPath: string;
|
|
37
|
+
let previewPath: string;
|
|
38
|
+
let sharedRoot: string;
|
|
39
|
+
let lockPath: string;
|
|
40
|
+
let modulesJsonPath: string;
|
|
41
|
+
let dbo: any;
|
|
42
|
+
let reloadCalls: number;
|
|
43
|
+
let reloadThrow: boolean;
|
|
44
|
+
|
|
45
|
+
const LOCK_BEFORE: Record<string, string> = { existing: "1.0.0" };
|
|
46
|
+
|
|
47
|
+
/** 把站点摆成「demo-solution 已锁后落位」状态,返回本次新增的匿名路径(= 补偿的输入 deferredAnonymous) */
|
|
48
|
+
async function seedInstalled(anonPaths: string[]): Promise<string[]> {
|
|
49
|
+
const added = appendAnonymousPaths(BASE_CONFIG, anonPaths);
|
|
50
|
+
await atomicWriteText(configPath, added.content);
|
|
51
|
+
await atomicWriteText(previewPath, added.content);
|
|
52
|
+
// 锁:装前快照 + 本次模块条目
|
|
53
|
+
await writeLock(lockPath, { ...LOCK_BEFORE, "demo-solution": "0.1.0" });
|
|
54
|
+
// modules.json:含本次模块条目
|
|
55
|
+
await atomicWriteText(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", entry: "modules/demo-solution/0.1.0/entry.js", enabled: true }], null, 2)}\n`);
|
|
56
|
+
// DB:路由/角色/权限各一行为 demo-solution(registering 段已落位)
|
|
57
|
+
await g.db.table("sys_solution_route").insert({ path: "/demo-solution", module: "demo-solution", category: "main" }).run();
|
|
58
|
+
await g.db.table("sys_role").insert({ name: "demo-solution.admin", module: "demo-solution" }).run();
|
|
59
|
+
await g.db.table("sys_permission").insert({ code: "demo-solution.view", module: "demo-solution" }).run();
|
|
60
|
+
return added.added;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function countAnon(cfg: string): number {
|
|
64
|
+
const lines = cfg.split("\n");
|
|
65
|
+
const idx = lines.findIndex(l => /^\s*anonymous_paths\s*:/.test(l));
|
|
66
|
+
if (idx === -1)
|
|
67
|
+
return 0;
|
|
68
|
+
let n = 0;
|
|
69
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
70
|
+
const line = lines[i]!;
|
|
71
|
+
if (line.trim() === "" || /^\s*#/.test(line))
|
|
72
|
+
continue;
|
|
73
|
+
if (!line.replace(/#.*$/, "").trim().startsWith("- "))
|
|
74
|
+
break;
|
|
75
|
+
n++;
|
|
76
|
+
}
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
root = await fsp.mkdtemp(join(tmpdir(), "ojm-compensate-"));
|
|
82
|
+
configPath = join(root, "config.yaml");
|
|
83
|
+
previewPath = join(root, ".ojm-preview.yaml");
|
|
84
|
+
sharedRoot = join(root, "modules");
|
|
85
|
+
lockPath = join(sharedRoot, "manifests.yaml");
|
|
86
|
+
modulesJsonPath = join(root, "dist", "modules.json");
|
|
87
|
+
await fsp.mkdir(sharedRoot, { recursive: true });
|
|
88
|
+
await fsp.mkdir(join(root, "dist"), { recursive: true });
|
|
89
|
+
g.fs = nodeFsFace();
|
|
90
|
+
g.yaml = { parse: async (t: string) => JSON.parse(t) };
|
|
91
|
+
g.http = {};
|
|
92
|
+
dbo = installDb();
|
|
93
|
+
reloadCalls = 0;
|
|
94
|
+
reloadThrow = false;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(async () => {
|
|
98
|
+
g.fs = undefined;
|
|
99
|
+
g.http = undefined;
|
|
100
|
+
g.db = undefined;
|
|
101
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function makeInput(deferredAnonymous: string[]): any {
|
|
105
|
+
return {
|
|
106
|
+
name: "demo-solution",
|
|
107
|
+
version: "0.1.0",
|
|
108
|
+
configPath,
|
|
109
|
+
deferredAnonymous,
|
|
110
|
+
sharedRoot,
|
|
111
|
+
modulesJsonPath,
|
|
112
|
+
lockBefore: LOCK_BEFORE,
|
|
113
|
+
lockExisted: true,
|
|
114
|
+
reload: {
|
|
115
|
+
reloadConfig: async () => {
|
|
116
|
+
reloadCalls++;
|
|
117
|
+
if (reloadThrow)
|
|
118
|
+
throw new Error("reload 模拟失败");
|
|
119
|
+
return { generation: 2, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] };
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("compensate 完整回滚到装前代", () => {
|
|
126
|
+
it("锁/config匿名/modules.json/DB 全部回退,旧代仍服务(reload 已调用)", async () => {
|
|
127
|
+
const added = await seedInstalled(["demo-solution/ping", "demo-solution/health"]);
|
|
128
|
+
const r = await compensate(makeInput(added));
|
|
129
|
+
|
|
130
|
+
expect(r.allOk).toBe(true);
|
|
131
|
+
expect(r.steps.map(s => s.step)).toEqual(["config.anonymous", "lock", "modules.json", "db.registrations", "reload"]);
|
|
132
|
+
|
|
133
|
+
// 锁回到装前快照(无 demo-solution)
|
|
134
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
|
|
135
|
+
// config:新增匿名条目被精确移除,基线两条保留;seq 不被回滚(单调无害)
|
|
136
|
+
const cfg = await fsp.readFile(configPath, "utf8");
|
|
137
|
+
expect(cfg).toContain("- /auth/login");
|
|
138
|
+
expect(cfg).toContain("- /system/login-methods");
|
|
139
|
+
expect(cfg).not.toContain("demo-solution/ping");
|
|
140
|
+
expect(cfg).not.toContain("demo-solution/health");
|
|
141
|
+
expect(countAnon(cfg)).toBe(2);
|
|
142
|
+
// preview 派生同样被清理
|
|
143
|
+
const prev = await fsp.readFile(previewPath, "utf8");
|
|
144
|
+
expect(prev).not.toContain("demo-solution/ping");
|
|
145
|
+
// modules.json 无 demo-solution 条目
|
|
146
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as unknown[];
|
|
147
|
+
expect(list.some((e: any) => e.name === "demo-solution")).toBe(false);
|
|
148
|
+
// DB 注册被级联清掉
|
|
149
|
+
expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
150
|
+
expect(dbo.prepare("SELECT * FROM sys_role WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
151
|
+
expect(dbo.prepare("SELECT * FROM sys_permission WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
152
|
+
// reload 被调用一次(活动代回到装前态)
|
|
153
|
+
expect(reloadCalls).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("compensate 幂等(可重试)", () => {
|
|
158
|
+
it("连续两次补偿:第二次全 no-op,终态不变,reload 仍各调用一次", async () => {
|
|
159
|
+
const added = await seedInstalled(["demo-solution/ping"]);
|
|
160
|
+
const first = await compensate(makeInput(added));
|
|
161
|
+
const second = await compensate(makeInput(added));
|
|
162
|
+
expect(first.allOk).toBe(true);
|
|
163
|
+
expect(second.allOk).toBe(true);
|
|
164
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
|
|
165
|
+
expect(countAnon(await fsp.readFile(configPath, "utf8"))).toBe(2);
|
|
166
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as unknown[];
|
|
167
|
+
expect(list.some((e: any) => e.name === "demo-solution")).toBe(false);
|
|
168
|
+
expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
169
|
+
expect(reloadCalls).toBe(2); // 两次各触发一次 reload(幂等但不跳过 reload)
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("compensate 部分失败仍尽力回滚", () => {
|
|
174
|
+
it("reload 步抛错 → 该步标记失败、其余四步仍生效、allOk=false", async () => {
|
|
175
|
+
const added = await seedInstalled(["demo-solution/ping"]);
|
|
176
|
+
reloadThrow = true;
|
|
177
|
+
const r = await compensate(makeInput(added));
|
|
178
|
+
|
|
179
|
+
expect(r.allOk).toBe(false);
|
|
180
|
+
const reloadStep = r.steps.find(s => s.step === "reload")!;
|
|
181
|
+
expect(reloadStep.ok).toBe(false);
|
|
182
|
+
// 其余步成功
|
|
183
|
+
for (const s of r.steps.filter(s => s.step !== "reload")) {
|
|
184
|
+
expect(s.ok).toBe(true);
|
|
185
|
+
}
|
|
186
|
+
// 即便 reload 失败,已落位资产仍被回退
|
|
187
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
|
|
188
|
+
expect(countAnon(await fsp.readFile(configPath, "utf8"))).toBe(2);
|
|
189
|
+
expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("compensate 匿名面不随失败放大(验收 4)", () => {
|
|
194
|
+
it("同一方案连续两次失败安装:config 匿名条目数不增长", async () => {
|
|
195
|
+
// 第一次失败:装前基线 2 条,新增 1 条后补偿
|
|
196
|
+
const added = await seedInstalled(["demo-solution/ping"]);
|
|
197
|
+
await compensate(makeInput(added));
|
|
198
|
+
const afterFirst = await fsp.readFile(configPath, "utf8");
|
|
199
|
+
expect(countAnon(afterFirst)).toBe(2); // 回到基线
|
|
200
|
+
|
|
201
|
+
// 第二次失败:再次以同样 deferredAnonymous 补偿(模拟又失败一次)
|
|
202
|
+
await compensate(makeInput(added));
|
|
203
|
+
const afterSecond = await fsp.readFile(configPath, "utf8");
|
|
204
|
+
expect(countAnon(afterSecond)).toBe(2); // 不增长
|
|
205
|
+
expect(removeAnonymousPaths(afterSecond, added).removed).toEqual([]); // 已无残留可删
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// BDD:部署/热更/冒烟三步(P1-9,十阶段 stage 8~10)。
|
|
2
2
|
// deploy-web = staging/web 产物树真复制到站点 modules 目录 + modules.json 读-改-原子写 upsert
|
|
3
3
|
// (D13:读者永不见半截 JSON;R30:entry/css 路径 POSIX 分隔符序列化;同名整体替换、他条目原样保留);
|
|
4
|
-
// reload = 注入 reloadConfig 面(platform 特权 API),unchanged =
|
|
4
|
+
// reload = 注入 reloadConfig 面(platform 特权 API),unchanged = 安装幂等成功 / 卸载恒严格(R18 修订——加载靠锁 diff 触发,不再依赖 vars 哨兵),
|
|
5
5
|
// expected 段键(缺省 ["vars"],B-11 reload 触发面)须逐一 ∈ applied;
|
|
6
6
|
// smoke = 探活(health 2xx / 缺省首个非匿名路由连通即可)+ integrity(部署落位后重算 sha384 vs
|
|
7
7
|
// modules.json 条目,R10 包内容口径;缺失 fail-closed)+ 注册一致性(升级态「包含非相等」:
|
|
@@ -19,7 +19,7 @@ import { tmpdir } from "node:os";
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
21
21
|
import { deployWeb, posixPath, upsertModulesJson } from "../../src/app/api/_platform/_lifecycle/steps/deploy-web";
|
|
22
|
-
import { reloadAndAssert } from "../../src/app/api/_platform/_lifecycle/steps/reload";
|
|
22
|
+
import { assertReloadApplied, reloadAndAssert } from "../../src/app/api/_platform/_lifecycle/steps/reload";
|
|
23
23
|
import { probeUrlOf, smoke } from "../../src/app/api/_platform/_lifecycle/steps/smoke";
|
|
24
24
|
|
|
25
25
|
const g = globalThis as any;
|
|
@@ -260,31 +260,42 @@ describe("deployWeb(stage=deploying:staging/web 树 → 站点 modules 目
|
|
|
260
260
|
});
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
describe("reloadAndAssert(stage=reloading:R18 unchanged=失败)", () => {
|
|
263
|
+
describe("reloadAndAssert(stage=reloading:R18 修订——安装 unchanged=幂等成功且默认不强制模块符号;卸载 unchanged/缺 -name@ver=严格失败;force+unchanged=失败)", () => {
|
|
264
264
|
function rlCtx(result?: ReloadResult, throws = false): ReloadContext {
|
|
265
265
|
const face: ReloadFace = {
|
|
266
266
|
reloadConfig: throws
|
|
267
267
|
? async () => {
|
|
268
268
|
throw new Error("server.reloadConfig: reload in progress (busy)");
|
|
269
269
|
}
|
|
270
|
-
: async () => result ?? { generation: 8, unchanged: false, applied: ["
|
|
270
|
+
: async () => result ?? { generation: 8, unchanged: false, applied: ["+demo-solution@0.1.0"], restart_pending: [], kept_listener: [] },
|
|
271
271
|
};
|
|
272
272
|
return { manifest: solManifest(), name: "demo-solution", version: "0.1.0", state: stateStore(), reload: face };
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
it("成功:applied 含
|
|
275
|
+
it("成功:applied 含 +name@ver(api 模块装配)→ ok=true,message 含 generation", async () => {
|
|
276
276
|
const r = await reloadAndAssert(rlCtx());
|
|
277
277
|
expect(r.stage).toBe("reloading");
|
|
278
278
|
expect(r.ok).toBe(true);
|
|
279
279
|
expect(r.message).toContain("8");
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
-
it("unchanged → ok=
|
|
282
|
+
it("unchanged → ok=true(R18 修订:安装幂等成功 = 已是最新装配,不再视为失败)", async () => {
|
|
283
283
|
const r = await reloadAndAssert(rlCtx({ generation: 7, unchanged: true, applied: [] }));
|
|
284
|
+
expect(r.ok).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("force + unchanged → ok=false(--force 要求强制重装配却无变更可重做)", async () => {
|
|
288
|
+
const r = await reloadAndAssert({ ...rlCtx({ generation: 7, unchanged: true, applied: [] }), force: true });
|
|
284
289
|
expect(r.ok).toBe(false);
|
|
285
290
|
expect(r.message).toContain("unchanged");
|
|
286
291
|
});
|
|
287
292
|
|
|
293
|
+
it("卸载 unchanged → ok=false(锁未摘除 = 卸载空转,恒严格)", async () => {
|
|
294
|
+
const r = assertReloadApplied({ generation: 7, unchanged: true, applied: [] }, { hasApi: true, kind: "uninstall", name: "demo-solution", version: "0.1.0" });
|
|
295
|
+
expect(r.ok).toBe(false);
|
|
296
|
+
expect(r.message).toContain("卸载空转");
|
|
297
|
+
});
|
|
298
|
+
|
|
288
299
|
it("face 抛错(并发 busy)→ ok=false retryable=true", async () => {
|
|
289
300
|
const r = await reloadAndAssert(rlCtx(undefined, true));
|
|
290
301
|
expect(r.ok).toBe(false);
|
|
@@ -292,10 +303,15 @@ describe("reloadAndAssert(stage=reloading:R18 unchanged=失败)", () => {
|
|
|
292
303
|
expect(r.message).toContain("busy");
|
|
293
304
|
});
|
|
294
305
|
|
|
295
|
-
it("
|
|
296
|
-
const r = await reloadAndAssert(
|
|
306
|
+
it("安装不再强制 +name@ver(R18 修订:applied 空属正常幂等态,挂载真通交由 smoke 段行为化校验)", async () => {
|
|
307
|
+
const r = await reloadAndAssert(rlCtx({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [], kept_listener: [] }));
|
|
308
|
+
expect(r.ok).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("卸载缺 -name@ver → ok=false(卸载须显式拆装,锁未摘除即空转回归拦截点)", async () => {
|
|
312
|
+
const r = assertReloadApplied({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [] }, { hasApi: true, kind: "uninstall", name: "demo-solution", version: "0.1.0" });
|
|
297
313
|
expect(r.ok).toBe(false);
|
|
298
|
-
expect(r.message).toContain("
|
|
314
|
+
expect(r.message).toContain("demo-solution@0.1.0");
|
|
299
315
|
});
|
|
300
316
|
|
|
301
317
|
it("返回形状违约(applied 非数组)→ ok=false retryable=false", async () => {
|
|
@@ -337,15 +353,13 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
|
|
|
337
353
|
expect(r.message).toContain("不计入失败");
|
|
338
354
|
});
|
|
339
355
|
|
|
340
|
-
it("health 401
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// 真失败仍由非 401(如 500,见下条)兜底,不靠 anonymous 白名单。
|
|
356
|
+
it("health 401 与是否声明 anonymous 无关(统一 deferred,真失败靠非 401 兜底)", async () => {
|
|
357
|
+
// 设计明确「health 不强制、无凭证预期 401」,故 health 401 一律 deferred,
|
|
358
|
+
// 真失败仍由非 401(如 500,见下条)兜底。
|
|
344
359
|
await seedDeployed();
|
|
345
360
|
const r = await smoke(smCtx({
|
|
346
361
|
manifest: solManifest({ health: "/demo-solution/ping" }),
|
|
347
362
|
fetch: async () => ({ ok: false, status: 401 }),
|
|
348
|
-
deferredAnonymous: ["/demo-solution/other"],
|
|
349
363
|
}));
|
|
350
364
|
expect(r.ok).toBe(true);
|
|
351
365
|
expect(r.message).toContain("restart_pending");
|
|
@@ -394,6 +408,16 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
|
|
|
394
408
|
}));
|
|
395
409
|
expect(r.ok).toBe(true);
|
|
396
410
|
});
|
|
411
|
+
|
|
412
|
+
it("无 health:首个非匿名路由 404 → 探活失败(P0 §3.5:首个路由 404/连通失败 = 冒烟失败,避免假成功)", async () => {
|
|
413
|
+
await seedDeployed();
|
|
414
|
+
const r = await smoke(smCtx({
|
|
415
|
+
fetch: async () => ({ ok: false, status: 404 }),
|
|
416
|
+
}));
|
|
417
|
+
expect(r.ok).toBe(false);
|
|
418
|
+
expect(r.message).toContain("探活");
|
|
419
|
+
expect(r.message).toContain("404");
|
|
420
|
+
});
|
|
397
421
|
});
|
|
398
422
|
|
|
399
423
|
describe("smoke integrity(R10:部署落位后重算 sha384 vs modules.json)", () => {
|
|
@@ -156,7 +156,7 @@ function makeReq(overrides: Partial<InstallRequest> = {}): InstallRequest {
|
|
|
156
156
|
configPath: join(root, "config.yaml"),
|
|
157
157
|
fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer }),
|
|
158
158
|
exec: bundleExec(),
|
|
159
|
-
reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: [
|
|
159
|
+
reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: [`+demo-solution@${version}`], restart_pending: [], kept_listener: [] }) },
|
|
160
160
|
sha256Hex: d => createHash("sha256").update(d).digest("hex"),
|
|
161
161
|
verifyEd25519: nodeVerifyEd25519,
|
|
162
162
|
trustPath,
|
|
@@ -181,8 +181,8 @@ beforeEach(async () => {
|
|
|
181
181
|
modulesJsonPath = join(root, "dist", "modules.json");
|
|
182
182
|
dbPath = join(root, "db.sqlite");
|
|
183
183
|
await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
|
|
184
|
-
// 站点 config.yaml 夹具(reloading
|
|
185
|
-
await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n
|
|
184
|
+
// 站点 config.yaml 夹具(reloading 段仅做 anonymous_paths 接线;模板 config.yaml 随发形态)
|
|
185
|
+
await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
|
|
186
186
|
g.fs = nodeFsFace();
|
|
187
187
|
g.yaml = { parse: async (t: string) => JSON.parse(t) };
|
|
188
188
|
g.http = {};
|
|
@@ -226,9 +226,8 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
|
|
|
226
226
|
await fsp.access(join(versionDir, "routes.js"));
|
|
227
227
|
await fsp.access(join(versionDir, "migrations", "0001__init.sql"));
|
|
228
228
|
await expect(fsp.access(join(versionDir, "web"))).rejects.toThrow();
|
|
229
|
-
// reloading
|
|
229
|
+
// reloading 段仅做 anonymous_paths 接线(幂等追加;不再依赖 vars 哨兵触发 reload)
|
|
230
230
|
const cfg = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
|
|
231
|
-
expect(cfg).toContain("ojm_install_seq: 1");
|
|
232
231
|
// C2-26 anonymous 运行时接线(P2-15③):方案匿名端点补进 auth.anonymous_paths(幂等追加)
|
|
233
232
|
expect(cfg).toContain("- /demo-solution/ping");
|
|
234
233
|
// web 产物落位 + modules.json 条目(R10 integrity 为包内容重算值)
|
|
@@ -260,10 +259,10 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
|
|
|
260
259
|
expect(await getTask(taskDir, "nope")).toBeNull();
|
|
261
260
|
});
|
|
262
261
|
|
|
263
|
-
it("preview 形态:派生 .ojm-preview.yaml 存在时两份 config
|
|
262
|
+
it("preview 形态:派生 .ojm-preview.yaml 存在时两份 config 一起接线(否则 reload 恒 unchanged)", async () => {
|
|
264
263
|
// ojm preview 派生出来的那份才是服务端在读的 config(spa 挂载 + platform.root 改写)
|
|
265
264
|
const derived = join(root, ".ojm-preview.yaml");
|
|
266
|
-
await fsp.writeFile(derived, "mounts: []\nserver:\n reload_api: true\nvars:\n
|
|
265
|
+
await fsp.writeFile(derived, "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
|
|
267
266
|
|
|
268
267
|
const t = await runInstall(makeReq());
|
|
269
268
|
expect(t.status).toBe("done");
|
|
@@ -271,10 +270,8 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
|
|
|
271
270
|
const source = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
|
|
272
271
|
const preview = new TextDecoder().decode(await fsp.readFile(derived));
|
|
273
272
|
// 源 config 仍是真相(preview 重启后按它重建派生文件)
|
|
274
|
-
expect(source).toContain("ojm_install_seq: 1");
|
|
275
273
|
expect(source).toContain("- /demo-solution/ping");
|
|
276
274
|
// 派生文件也要变:reloadConfig 按它的文档 sha 判 unchanged
|
|
277
|
-
expect(preview).toContain("ojm_install_seq: 1");
|
|
278
275
|
expect(preview).toContain("- /demo-solution/ping");
|
|
279
276
|
});
|
|
280
277
|
|
|
@@ -1313,3 +1310,25 @@ describe("冒烟探活必须自报租户头(企微代开发多租户 v3 Phase
|
|
|
1313
1310
|
expect(t.stage).toBe("smoke");
|
|
1314
1311
|
});
|
|
1315
1312
|
});
|
|
1313
|
+
|
|
1314
|
+
describe("Phase 3:无守护 partial 判定(P0 §3.6 / 验收 15)", () => {
|
|
1315
|
+
it("热更返回待重启段非空 → 装完标 partial + restartRequired(不判 done,避免假成功)", async () => {
|
|
1316
|
+
// 无守护形态(P4 supervisor 未实施):匿名接线等 restart_pending 段需宿主重启才完全生效,
|
|
1317
|
+
// 不应报 done 误导。reload 返回 restart_pending 非空即标 partial + restartRequired。
|
|
1318
|
+
const t = await runInstall(makeReq({
|
|
1319
|
+
reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["+demo-solution@0.1.0"], restart_pending: ["auth"], kept_listener: [] }) },
|
|
1320
|
+
}));
|
|
1321
|
+
expect(t.status).toBe("partial");
|
|
1322
|
+
expect(t.restartRequired).toBe(true);
|
|
1323
|
+
expect(t.message).toContain("待重启段");
|
|
1324
|
+
expect(t.message).toContain("重启");
|
|
1325
|
+
// 安装产物已落位(非失败语义②补偿):锁/版本目录仍在,任务非 failed
|
|
1326
|
+
expect(t.stage).not.toBe("failed");
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it("热更待重启段为空 → 正常 done(restartRequired 不置位)", async () => {
|
|
1330
|
+
const t = await runInstall(makeReq());
|
|
1331
|
+
expect(t.status).toBe("done");
|
|
1332
|
+
expect(t.restartRequired).toBeUndefined();
|
|
1333
|
+
});
|
|
1334
|
+
});
|