增加新的规则
This commit is contained in:
1 parent
bb054db0ea
commit
d05ada27f5
9 files changed
+370
-18
No files matched your search
+12
-11
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: app-dev
|
name: app-dev
|
||||||
description: 在独立仓库按 TickShield(刻盾云)平台契约开发、审查、发布生态应用(micro/service):listingKey 四位一体、app.manifest.json、tickshield-node-runtime 基础镜像、@aigrego/guard 四道闸与 tenant_id 租户隔离、micro 前端 portal-sdk 宿主桥与平台同款 UI 皮肤(--sk-* 令牌 + ts-skin 换肤跟随)、release-cli 发布与商店上架部署。凡用户要求「开发一个平台应用 / 新建 app / 写个 micro 应用 / 做个像 secret-manager / demo-app 那样的应用 / 在门户里加个自己的应用 / 接入 TickShield / 上架应用到商店 / 审查这个 app 能不能部署到平台 / 帮我看看接入还缺什么 / 审一下这个应用的 UI / 界面跟平台不一致 / 换肤不跟随 / 深色模式瞎了 / 配色不统一 / 把界面改成平台那样」,或带着 注册不上去 / 401 missing app credentials / 心跳 404 / host must equal listingKey / 四道闸 403 / 跨租户数据串了 / image registry not registered / invalid manifest skipped / 应用中心导入失败 / 应用装上了但 /svc 不通 / PROD 白屏 CSP 报错 / 切到暗夜皮肤应用还是白底 / 青瓷皮肤下按钮还是蓝色 / 应用跟平台长得不一样 等症状,或提到 listingKey / app.manifest.json / 四道闸 / TDT / tenant_id / portal-sdk / release-cli / tickshield-node-runtime / --sk- 令牌 / ts-skin / 皮肤 时,务必使用本 skill——即使用户没说「应用接入」四个字。Use whenever the user develops, scaffolds, audits, reviews (including UI/skin consistency), onboards, publishes or deploys a TickShield platform app (micro or service) from an independent repository. 边界:非平台场景的通用 Dockerfile 选型评审用 runtime-image;通用 Gitea Actions 工作流规范用 gitea-actions;CI 工作流的发布 job 改造/新建发布工作流用 app-publish;git 提交推送与日报用 push-and-report;平台基座本身(platform/gateway/packages/* 控制面)的开发与平台设计系统(DESIGN.md / skins/index.ts)的修改不归本 skill。
|
description: 在独立仓库按 TickShield(刻盾云)平台契约开发、审查、发布生态应用(micro/service):listingKey 四位一体、app.manifest.json、tickshield-node-runtime 基础镜像、@aigrego/guard 四道闸与 tenant_id 租户隔离、micro 前端 portal-sdk 宿主桥与平台同款 UI 皮肤(--sk-* 令牌 + ts-skin 换肤跟随)、release-cli 发布与商店上架部署。凡用户要求「开发一个平台应用 / 新建 app / 写个 micro 应用 / 做个像 secret-manager / demo-app 那样的应用 / 在门户里加个自己的应用 / 接入 TickShield / 上架应用到商店 / 审查这个 app 能不能部署到平台 / 帮我看看接入还缺什么 / 审一下这个应用的 UI / 界面跟平台不一致 / 换肤不跟随 / 深色模式瞎了 / 配色不统一 / 把界面改成平台那样」,或带着 注册不上去 / 401 missing app credentials / 心跳 404 / host must equal listingKey / 四道闸 403 / 跨租户数据串了 / image registry not registered / invalid manifest skipped / 应用中心导入失败 / 应用装上了但 /svc 不通 / PROD 白屏 CSP 报错 / 切到暗夜皮肤应用还是白底 / 青瓷皮肤下按钮还是蓝色 / 应用跟平台长得不一样 等症状,或提到 应用是否合规 / 能不能发布 / 体检 / 自检 / dist 超 64MB / 大产物 / 外置静态资源 / connect-src / CORS / wasm 核心 / listingKey / app.manifest.json / 四道闸 / TDT / tenant_id / portal-sdk / release-cli / tickshield-node-runtime / --sk- 令牌 / ts-skin / 皮肤 时,务必使用本 skill——即使用户没说「应用接入」四个字。Use whenever the user develops, scaffolds, audits, reviews (including UI/skin consistency), onboards, publishes or deploys a TickShield platform app (micro or service) from an independent repository. 边界:非平台场景的通用 Dockerfile 选型评审用 runtime-image;通用 Gitea Actions 工作流规范用 gitea-actions;CI 工作流的发布 job 改造/新建发布工作流用 app-publish;git 提交推送与日报用 push-and-report;平台基座本身(platform/gateway/packages/* 控制面)的开发与平台设计系统(DESIGN.md / skins/index.ts)的修改不归本 skill。
|
||||||
---
|
---
|
||||||
|
|
||||||
# app-dev — TickShield 生态应用开发规范(独立仓库 → 可直接部署上架)
|
# app-dev — TickShield 生态应用开发规范(独立仓库 → 可直接部署上架)
|
||||||
@@ -12,20 +12,20 @@ TickShield 生态应用的交付物只有两样:**镜像 + `app.manifest.json`
|
|||||||
|
|
||||||
micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且跟随皮肤切换**(light / dark / jade)。基准见 `references/ui-style.md`,令牌由 `scripts/sync-skin-tokens.sh` 从平台 `skins/index.ts` 生成,跟随机制是 `localStorage['ts-skin']` + `storage` 事件。这不是"美化建议",是接入审核的 H 节(写死色值 / 没有皮肤桥 = die)。
|
micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且跟随皮肤切换**(light / dark / jade)。基准见 `references/ui-style.md`,令牌由 `scripts/sync-skin-tokens.sh` 从平台 `skins/index.ts` 生成,跟随机制是 `localStorage['ts-skin']` + `storage` 事件。这不是"美化建议",是接入审核的 H 节(写死色值 / 没有皮肤桥 = die)。
|
||||||
|
|
||||||
契约锚点:本 skill 基线 = TickShield 平台 v4.0 + APPSTORE-RUNTIME + FR-101 registries 白名单 + FR-102/TKT-557 导航与图标规范(2026-10)+ 平台设计系统 DESIGN.md / skins/index.ts(2026-10-22 快照)。与平台冲突时,以平台仓 `docs/reference/应用开发与上架指引.md`、`apps/portal/internal/modules/registry/manifest.go` 的校验代码、`apps/web/src/skins/index.ts` 的令牌定义为准,并回本仓库更新本 skill。`docs/reference/App出仓与集成指引.md`、`App开发者指南.html` 是**已废弃的历史文档**(@xgent 旧体系),禁止照搬。
|
契约锚点:本 skill 基线 = TickShield 平台 v4.0 + APPSTORE-RUNTIME + FR-101 registries 白名单 + FR-102/TKT-557 导航与图标规范(2026-10)+ 大产物外置 / per-app CSP(2026-10,embedCsp.connectSrc)+ 平台设计系统 DESIGN.md / skins/index.ts(2026-10-22 快照)。与平台冲突时,以平台仓 `docs/reference/应用开发与上架指引.md`、`apps/platform/internal/modules/registry/manifest.go`(校验)、`apps/platform/internal/modules/registry/csp.go` 与 `apps/platform/cmd/platform/main.go`(CSP 下发)、`apps/web/src/skins/index.ts`(令牌定义)为准,并回本仓库更新本 skill。`docs/reference/App出仓与集成指引.md`、`App开发者指南.html` 是**已废弃的历史文档**(@xgent 旧体系),禁止照搬。
|
||||||
|
|
||||||
## 总流程
|
## 总流程
|
||||||
|
|
||||||
```
|
```
|
||||||
第 0 步 备料分流(判模式 + 采集四要素)
|
第 0 步 备料分流(判模式 + 采集四要素)
|
||||||
新建:第 1 步 生成骨架(含 UI 皮肤层)→ 第 2 步 静态自检(scripts/check-app.sh)
|
新建:第 1 步 生成骨架(含 UI 皮肤层 + 仓库 AGENTS.md)→ 第 2 步 静态自检(scripts/check-app.sh)
|
||||||
审查:第 1 步 逐项审计(checklist.md A–H)→ 第 2 步 确认后修正 + 静态自检
|
审查:第 1 步 逐项审计(checklist.md A–I)→ 第 2 步 确认后修正 + 静态自检
|
||||||
汇合:第 3 步 联调验证(需 dev 栈,含换肤跟随)→ 第 4 步 发布指引 → 第 5 步 汇报
|
汇合:第 3 步 联调验证(需 dev 栈,含换肤跟随)→ 第 4 步 发布指引 → 第 5 步 汇报
|
||||||
```
|
```
|
||||||
|
|
||||||
除非用户明确只要聊聊思路,完整走全部 5 步。
|
除非用户明确只要聊聊思路,完整走全部 5 步。
|
||||||
|
|
||||||
**调用形态分流(硬规则)**:裸调用(无任务输入)= **只读审计态**——对 app 做**全量审核(含 UI)**,只出审计报告与修复/新建方案,**永不主动改文件、永不执行任何写操作**;报告与方案交付即停,等用户确认后才进入对应模式的执行段。空仓库裸调用同理:只输出「接入差距评估 + 新建方案」供确认,不直接生成骨架。带任务输入(如「帮我开发 X 应用」「按报告修」「把界面改成平台那样」)= 任务即授权对应动作,按流程走;但发布等写平台操作仍需显式确认(见「人机权责」)。
|
**调用形态分流(硬规则)**:裸调用(无任务输入)= **只读审计态**——**第一步先执行 `bash <本skill目录>/scripts/check-app.sh .`**(不需要用户写任何任务提示词),把输出的 `RESULT: PASS|FAIL` 与 die/warn 明细作为报告第一段;再对照 `references/checklist.md`(A–I)做全量审核(含 UI),只出审计报告与修复/新建方案,**永不主动改文件、永不执行任何写操作**;报告与方案交付即停,等用户确认后才进入对应模式的执行段。空仓库裸调用同理:只输出「接入差距评估 + 新建方案」供确认,不直接生成骨架。带任务输入(如「帮我开发 X 应用」「按报告修」「把界面改成平台那样」)= 任务即授权对应动作,按流程走;但发布等写平台操作仍需显式确认(见「人机权责」)。
|
||||||
|
|
||||||
**UI 审核不单独开关**:裸调用就是审核,审的就是「接入契约 + UI 一致性」两件事,不给 `--ui` 之类的子命令——用户说「/app-dev」而没带任何要求时,默认动作是审,不是改。
|
**UI 审核不单独开关**:裸调用就是审核,审的就是「接入契约 + UI 一致性」两件事,不给 `--ui` 之类的子命令——用户说「/app-dev」而没带任何要求时,默认动作是审,不是改。
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且
|
|||||||
- `web/app.css` 是外置样式(PROD CSP 下不靠内联 style),颜色全部 `var(--sk-*)`;构建脚本要把它拷进 `dist/`。
|
- `web/app.css` 是外置样式(PROD CSP 下不靠内联 style),颜色全部 `var(--sk-*)`;构建脚本要把它拷进 `dist/`。
|
||||||
- `.gitea/workflows/release.yaml`(照 `gitea-app-release.yaml`,提示配 secrets)
|
- `.gitea/workflows/release.yaml`(照 `gitea-app-release.yaml`,提示配 secrets)
|
||||||
- `docs/PRD_app-<listingKey>.md` 草稿(照 `PRD_app-template.md`,文档状态标「草稿」)
|
- `docs/PRD_app-<listingKey>.md` 草稿(照 `PRD_app-template.md`,文档状态标「草稿」)
|
||||||
|
- `AGENTS.md`(照 `references/templates/AGENTS.md`:仓库根硬规则,让后续任何 Agent 在不写任务提示词时也先跑 `check-app.sh`;已有 AGENTS.md 时只在末尾追加带标记的「TickShield 接入自检」段,不覆盖原文)
|
||||||
- `.aigrego-registry.env.example` + 在 `.gitignore` 追加 `.aigrego-registry.env`
|
- `.aigrego-registry.env.example` + 在 `.gitignore` 追加 `.aigrego-registry.env`
|
||||||
|
|
||||||
生成后逐文件说明「这个文件哪几处必须改、哪几处永远别动」;UI 层额外说明一句:**颜色改 class 不改色值,皮肤跟随不要自己实现**(照 `src/skin/apply.ts` 用)。
|
生成后逐文件说明「这个文件哪几处必须改、哪几处永远别动」;UI 层额外说明一句:**颜色改 class 不改色值,皮肤跟随不要自己实现**(照 `src/skin/apply.ts` 用)。
|
||||||
@@ -68,13 +69,13 @@ micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且
|
|||||||
|
|
||||||
## 第 1~2 步(审查):逐项审计 → 修正
|
## 第 1~2 步(审查):逐项审计 → 修正
|
||||||
|
|
||||||
1. 读 `references/checklist.md`,对照逐项审计,输出三档结论表:**通过 / 不符(die 级)/ 风险(warn 级)**,每项给证据(文件:行号)。
|
1. 读 `references/checklist.md`,对照逐项审计,输出三档结论表:**通过 / 不符(die 级)/ 风险(warn 级)**,每项给证据(文件:行号)。报告固定含一项「自检入口」:仓库根 `AGENTS.md` 是否写死「开工前先跑 `check-app.sh`」(缺失 = warn,用户确认后照 `references/templates/AGENTS.md` 补,这是「不写任务提示词也能体检」的落点)。
|
||||||
2. **UI 审核(H 节)单独成段**:先读 `references/ui-style.md` 建立基准,再按 H1–H12 审。要点是"换肤能不能跟随",不是"好不好看"——判据永远是「写死色值了吗 / 有皮肤桥吗 / 首帧前落令牌了吗 / 三套皮肤实机切过吗」。H11 需要 dev 栈实机切皮肤,跑不了就写「未验证」,不许拿静态检查绿冒充。
|
2. **UI 审核(H 节)单独成段**:先读 `references/ui-style.md` 建立基准,再按 H1–H12 审。要点是"换肤能不能跟随",不是"好不好看"——判据永远是「写死色值了吗 / 有皮肤桥吗 / 首帧前落令牌了吗 / 三套皮肤实机切过吗」。H11 需要 dev 栈实机切皮肤,跑不了就写「未验证」,不许拿静态检查绿冒充。
|
||||||
3. 修正清单列给用户,**经确认后**逐项修;修完跑静态自检。
|
3. 修正清单列给用户,**经确认后**逐项修;修完跑静态自检。
|
||||||
|
|
||||||
**裸调用到此为止**:审计报告 + 修复方案就是交付物,不执行任何修正;用户回复确认(如「按方案修」)后才进入第 2 步修正。裸调用的报告必须同时含「接入契约结论」与「UI 一致性结论」两段,缺一段等于没审。
|
**裸调用到此为止**:审计报告 + 修复方案就是交付物,不执行任何修正;用户回复确认(如「按方案修」)后才进入第 2 步修正。裸调用的报告必须同时含「接入契约结论」与「UI 一致性结论」两段,缺一段等于没审。
|
||||||
|
|
||||||
→ 验收:审计表覆盖 checklist 全部 die 级项(A–H 全覆盖);修正经用户逐批确认。
|
→ 验收:审计表覆盖 checklist 全部 die 级项(A–I 全覆盖);修正经用户逐批确认。
|
||||||
|
|
||||||
## 第 2 步:静态自检
|
## 第 2 步:静态自检
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ micro 应用还有第三条硬要求:**UI 与平台基座同一套视觉,且
|
|||||||
bash <本skill目录>/scripts/check-app.sh <仓库根>
|
bash <本skill目录>/scripts/check-app.sh <仓库根>
|
||||||
```
|
```
|
||||||
|
|
||||||
die 级不过 → 修了再往下走;warn 级列进汇报。check-app.sh 只查静态契约(manifest 字段/Dockerfile 三要素/入口接线/保留路径/密钥卫生/**UI 令牌与皮肤桥**),不替代第 3 步真环境联调。UI 部分它只能证明"没写死色值、桥接代码在",**不能证明换肤真的跟随**——那一步必须实机切。
|
die 级不过 → 修了再往下走;warn 级列进汇报。**末尾有一行机器可读结论 `RESULT: PASS|FAIL`**(CI/Agent 直接引用它),并附 `die=N warn=N`。check-app.sh 覆盖 checklist A–I 的 `[S]` 项:manifest 字段(含 `embedCsp` 结构与去重)/Dockerfile 三要素/入口接线/保留路径/密钥卫生/**UI 令牌与皮肤桥**/**大产物体积与外置加载方式**;不替代第 3 步真环境联调。UI 部分它只能证明"没写死色值、桥接代码在",**不能证明换肤真的跟随**——那一步必须实机切。
|
||||||
|
|
||||||
→ 验收:脚本 exit 0(或仅剩 warn 且已列清单)。
|
→ 验收:脚本 exit 0(或仅剩 warn 且已列清单)。
|
||||||
|
|
||||||
@@ -104,7 +105,6 @@ die 级不过 → 修了再往下走;warn 级列进汇报。check-app.sh 只
|
|||||||
## 第 4 步:发布指引
|
## 第 4 步:发布指引
|
||||||
|
|
||||||
发布是**写操作**:默认只给命令草稿,用户显式说「发布」才执行。详见 `references/publish-deploy.md`:
|
发布是**写操作**:默认只给命令草稿,用户显式说「发布」才执行。详见 `references/publish-deploy.md`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export AIGREGO_PORTAL_URL=<平台地址> APP_KEY=<listingKey> APP_SECRET=<env注入,不落盘>
|
export AIGREGO_PORTAL_URL=<平台地址> APP_KEY=<listingKey> APP_SECRET=<env注入,不落盘>
|
||||||
aigrego-release whoami # 先验凭证(放在构建之前)
|
aigrego-release whoami # 先验凭证(放在构建之前)
|
||||||
@@ -113,7 +113,8 @@ aigrego-release publish --manifest app.manifest.json [--image <ref>] [--store] [
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **manifest 进平台是双通道、同一份文件**:CLI `publish --manifest`(上)或平台侧 console「应用中心」按向导导入——同一条 submit 管道(同一套校验、治理字段变更同样 202 进审核队列)。不管走哪条,仓库里的 `app.manifest.json` 都必须最新且合法;走手动通道时把这份文件交付给平台管理员。
|
- **manifest 进平台是双通道、同一份文件**:CLI `publish --manifest`(上)或平台侧 console「应用中心」按向导导入——同一条 submit 管道(同一套校验、治理字段变更同样 202 进审核队列)。不管走哪条,仓库里的 `app.manifest.json` 都必须最新且合法;走手动通道时把这份文件交付给平台管理员。
|
||||||
- 治理字段(`scopes`/`aclManifest`/`exchangeTargets`/`dependencies`)变更 → 202 进审核队列(exit 0 但**不生效**),批准前不影响已安装租户;首个版本直接生效。
|
- **大产物分流(先量再发)**:dist tgz ≤64 MiB → 照旧 `publish --dist`;超限(已压缩 wasm/`.data` 几乎压不动)→ 核心放对象存储 `apps/<key>/<version>/`、dist 只留壳、manifest 声明 `embedCsp.connectSrc`。只放开 `connect-src`(fetch/XHR/WebSocket/wasm 流式加载);Worker/动态 import/外链 script/样式/字体**不行**。桶必须配 CORS(含 `Allow-Headers: Range`)。细节见 `references/contract.md §7.1`。
|
||||||
|
- 治理字段(`scopes`/`aclManifest`/`exchangeTargets`/`dependencies`/`mounts`/`embedCsp`)变更 → 202 进审核队列(exit 0 但**不生效**),批准前不影响已安装租户;首个版本直接生效。
|
||||||
- 商店上架/授权/安装/启停/卸载是平台侧 console 动作;镜像 host 必须在 registries 白名单(空表=全拒)。
|
- 商店上架/授权/安装/启停/卸载是平台侧 console 动作;镜像 host 必须在 registries 白名单(空表=全拒)。
|
||||||
- 输出「待平台侧操作」清单:auto_publish 开关、registries 登记、治理审核批准、授权安装——这些 Agent 做不到,不绕道不假装完成。
|
- 输出「待平台侧操作」清单:auto_publish 开关、registries 登记、治理审核批准、授权安装——这些 Agent 做不到,不绕道不假装完成。
|
||||||
|
|
||||||
@@ -217,7 +218,7 @@ die 消息统一三段式:是什么 + 为什么(后果) + 怎么办。
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `references/contract.md` | 需要契约细节时:manifest 字段全集/注册心跳报文/四道闸 API/tdb 行为/令牌与交换/部署形态 |
|
| `references/contract.md` | 需要契约细节时:manifest 字段全集/注册心跳报文/四道闸 API/tdb 行为/令牌与交换/部署形态 |
|
||||||
| `references/publish-deploy.md` | 第 4 步发布时:release-cli 全参数/商店链路/quickstart catalog 物料/验收矩阵 |
|
| `references/publish-deploy.md` | 第 4 步发布时:release-cli 全参数/商店链路/quickstart catalog 物料/验收矩阵 |
|
||||||
| `references/checklist.md` | 审查模式必读:逐项审计清单 A–H(die/warn 分级 + 验证方法) |
|
| `references/checklist.md` | 审查模式必读:逐项审计清单 A–I(die/warn 分级 + 验证方法) |
|
||||||
| `references/ui-style.md` | 写 micro 前端或审 UI 时必读:令牌表/视觉刻度/皮肤桥机制/组件基线/换肤验证步骤 |
|
| `references/ui-style.md` | 写 micro 前端或审 UI 时必读:令牌表/视觉刻度/皮肤桥机制/组件基线/换肤验证步骤 |
|
||||||
| `references/troubleshooting.md` | 带症状来时:排障对照全表 |
|
| `references/troubleshooting.md` | 带症状来时:排障对照全表 |
|
||||||
| `references/templates/` | 新建模式必读:全部可复制模板(`web/src/skin/` 与 `web/app.css` 是 UI 层) |
|
| `references/templates/` | 新建模式必读:全部可复制模板(`web/src/skin/` 与 `web/app.css` 是 UI 层) |
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
| A8 | `navItems`/`footerNavItems` 每项含 `id/label/path`;`icon` 为 emoji/字符或 lucide 名(写错名静默兜底成字符,须眼见为实);`aclManifest.pages` 含 `key/label`,`audience` ∈ admin/viewer/all | die `[S]` | 对照契约表 §2.1 |
|
| A8 | `navItems`/`footerNavItems` 每项含 `id/label/path`;`icon` 为 emoji/字符或 lucide 名(写错名静默兜底成字符,须眼见为实);`aclManifest.pages` 含 `key/label`,`audience` ∈ admin/viewer/all | die `[S]` | 对照契约表 §2.1 |
|
||||||
| A9 | `color` 为 `#RRGGBB`;`icon`≤64 / `cat`≤64 / `tagline`≤120 / `desc`≤2000 | warn `[S]` | 长度/格式 |
|
| A9 | `color` 为 `#RRGGBB`;`icon`≤64 / `cat`≤64 / `tagline`≤120 / `desc`≤2000 | warn `[S]` | 长度/格式 |
|
||||||
| A10 | 治理字段变更知悉审核语义(202 进队列,批准前不生效) | log | 口头/文档确认 |
|
| A10 | 治理字段变更知悉审核语义(202 进队列,批准前不生效) | log | 口头/文档确认 |
|
||||||
|
| A11 | 仓库根 `AGENTS.md` 含「开工前先跑 check-app.sh」硬规则(模板 `references/templates/AGENTS.md`)——没有它,Agent 只能靠用户每次明说才会体检 | warn `[S]` | grep AGENTS.md |
|
||||||
|
|
||||||
## B. 镜像与入口
|
## B. 镜像与入口
|
||||||
|
|
||||||
@@ -104,3 +105,19 @@
|
|||||||
| H10 | 无 `!important`(`prefers-reduced-motion` 兜底除外) | warn `[S]` | grep |
|
| H10 | 无 `!important`(`prefers-reduced-motion` 兜底除外) | warn `[S]` | grep |
|
||||||
| H11 | 换肤实机验证:三套皮肤各切一次,应用当场跟随(强调色随皮肤变,青瓷下不能还是蓝色),刷新后保持 | die(上线前) | 截图留档 |
|
| H11 | 换肤实机验证:三套皮肤各切一次,应用当场跟随(强调色随皮肤变,青瓷下不能还是蓝色),刷新后保持 | die(上线前) | 截图留档 |
|
||||||
| H12 | 无障碍底线:正文/状态文字对比度 ≥ 4.5:1;`prefers-reduced-motion` 有降级 | warn | 抽查三套皮肤 |
|
| H12 | 无障碍底线:正文/状态文字对比度 ≥ 4.5:1;`prefers-reduced-motion` 有降级 | warn | 抽查三套皮肤 |
|
||||||
|
|
||||||
|
## I. 大产物与静态资源外置(dist tgz > 64 MiB 或核心为已压缩大文件时)
|
||||||
|
|
||||||
|
判定顺序:先量 dist → 超限才要求外置 → 外置必须有 `embedCsp` → 再查加载方式是否被 CSP 覆盖。全部 `[S]` 项由 `scripts/check-app.sh` 自动查。
|
||||||
|
|
||||||
|
| # | 项 | 档 | 验证方法 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| I1 | dist 打包成 tgz 后 ≤64 MiB(平台请求体硬上限);未压缩体积超限但实测 tgz 达标 → 仅提示 | die(tgz 超限)/ warn(压缩率依赖) | 脚本实算 `tar czf -` |
|
||||||
|
| I2 | dist 内无单文件 >8 MiB 的已压缩资源(`.wasm`/`.data`/`.br`/`.gz`/`.zip`/`.7z`/`.mp4`);有则必须外置 | warn,tgz 超限时 die | 文件大小扫描 |
|
||||||
|
| I3 | 需要外置时 manifest 必须声明 `embedCsp.connectSrc`(非空) | die | 脚本联动 I1/I2 |
|
||||||
|
| I4 | `embedCsp` 结构合法:仅 `type=micro`;≤8 条;每项 `scheme://host[:port]`(可 `*.`);无路径/查询/裸 `*`/分号/空白/重复 | die | 正则 + 去重 |
|
||||||
|
| I5 | 声明与用点对得上:源码有外部 origin 的 `fetch`/`instantiateStreaming`,清单未声明 → warn;声明了但源码找不到使用点 → warn(死配置) | warn | grep 源码与构建产物 |
|
||||||
|
| I6 | 不使用 connect-src 不覆盖的跨域加载:`new Worker('https://…')`、`import('https://…')`、`importScripts('https://…')`、外链 `<script src>` / stylesheet | die | grep |
|
||||||
|
| I7 | 外置路径版本化(`apps/<key>/<version>/`)+ immutable 缓存;回滚时旧版本仍在桶里 | warn | 读 CI/构建脚本 |
|
||||||
|
| I8 | 桶 CORS 已配:GET/HEAD/OPTIONS、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;匿名读自检通过 | die(上线前,人工确认) | 桶控制台 + CI 自检日志 |
|
||||||
|
| I9 | `embedCsp` 变更知悉审核语义(202 进队列,批准前不生效) | log | 阅读/口头确认 |
|
||||||
@@ -159,7 +159,22 @@ logger.info('request', { method, path, status, ms }); // 每请求一行
|
|||||||
- **ACL 前端收口**:握手 `init.acl`(admin → `bypass:true`;member → 仅 `defaultForMember` 页 PID 列表)驱动页面显隐与前端路由守卫——**只是 UX 闸,真闸是后端四道闸**
|
- **ACL 前端收口**:握手 `init.acl`(admin → `bypass:true`;member → 仅 `defaultForMember` 页 PID 列表)驱动页面显隐与前端路由守卫——**只是 UX 闸,真闸是后端四道闸**
|
||||||
- i18n:只读宿主 `localStorage['ts-lang']`(zh-CN/en-US)+ 监听 storage 事件实时切换,不反写;zh-CN 字典为唯一事实来源
|
- i18n:只读宿主 `localStorage['ts-lang']`(zh-CN/en-US)+ 监听 storage 事件实时切换,不反写;zh-CN 字典为唯一事实来源
|
||||||
- 皮肤令牌 `--sk-*` 属门户自身,不辐射 iframe 内应用;micro 应用样式自管
|
- 皮肤令牌 `--sk-*` 属门户自身,不辐射 iframe 内应用;micro 应用样式自管
|
||||||
- 产物发布:`aigrego-release publish --dist <dir>`,包根必须直接含 `index.html`(≤64MB,原子替换);index.html 用**绝对路径** `/apps/<key>/...` 引用资源
|
- 产物发布:`aigrego-release publish --dist <dir>`,包根必须直接含 `index.html`(**tgz 请求体 ≤64 MiB**,原子替换);index.html 用**绝对路径** `/apps/<key>/...` 引用资源
|
||||||
|
|
||||||
|
### 7.1 大产物外置(dist 超 64 MiB 时)
|
||||||
|
|
||||||
|
- **硬边界**:平台 `POST /api/apps/:key/artifacts` 用 `http.MaxBytesReader` 卡 64 MiB(压缩后的 tgz),网关 `client_max_body_size` 同值;超限直接 413,改配置不解决(编译常量)。已压缩的 wasm / `.data` 核心再打 tgz 几乎不缩小。
|
||||||
|
- **分流**:dist tgz ≤64 MiB → 照旧 `publish --dist`;超限 → 核心放对象存储,dist 只留壳。
|
||||||
|
- **对象存储布局**:`<bucket>/apps/<key>/<version>/...`(版本化 + `Cache-Control: public, max-age=31536000, immutable`,旧版本保留供回滚)。
|
||||||
|
- **桶 CORS(必需)**:`Allow-Origin` 平台域(或 `*`)、`Allow-Methods: GET,HEAD,OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`。带 `Range` 的 `fetch` 会触发预检,只开公共读不够。
|
||||||
|
- **manifest 声明**(仅 micro,治理字段 → 变更 202 进审核):
|
||||||
|
```json
|
||||||
|
"embedCsp": { "connectSrc": ["https://static.example.cn"] }
|
||||||
|
```
|
||||||
|
形态 `scheme://host[:port]`,可 `*.` 前缀,≤8 条,无路径/查询/裸 `*`/分号/空白。
|
||||||
|
- **CSP 语义**:平台只追加 `connect-src 'self' <origins>`,覆盖 `fetch`/XHR/WebSocket/wasm 流式加载;**不覆盖** `new Worker()`、动态 `import()`、`<script src>`、外链样式/字体——这些跨域仍会被浏览器拦(`check-app.sh` I6 会拦)。
|
||||||
|
- **生效路径**:门户 `/apps/<key>/<rest>`(PROD,platform 侧 5s 缓存)与应用子域/自定义站点(网关读 routes.lua 的 `csp` 表,2s 热载)。壳路由 `/apps/<key>` 不放宽。
|
||||||
|
- **发布**:CI 用钉版 `minio/mc` 镜像 `mc mirror` 到桶 + 匿名读自检;模板见 `references/templates/gitea-app-release.yaml` 的 `assets` job。
|
||||||
|
|
||||||
## 8. 令牌(TDT)与跨应用交换
|
## 8. 令牌(TDT)与跨应用交换
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,23 @@ curl -sSL https://code.innev.cn/images/tickshield-dist/raw/branch/main/cli/aigre
|
|||||||
`publish` 子动作:
|
`publish` 子动作:
|
||||||
|
|
||||||
1. `--manifest app.manifest.json` → `POST /api/apps/submit`。治理字段有 diff ⇒ **202 进审核队列,批准前不影响已安装租户**;无 diff ⇒ 200 当场生效;**首个版本直接生效**。版本号以 manifest.version 为准(semver)。
|
1. `--manifest app.manifest.json` → `POST /api/apps/submit`。治理字段有 diff ⇒ **202 进审核队列,批准前不影响已安装租户**;无 diff ⇒ 200 当场生效;**首个版本直接生效**。版本号以 manifest.version 为准(semver)。
|
||||||
2. `--dist <dir|tgz>` → `POST /api/apps/:key/artifacts`。产物目录根的 `index.html` 必须存在(`tar czf -C <dir> .`,套一层目录会被拒);≤64MB、路径穿越/symlink 被拒、同卷 rename 原子替换;上传后 `/apps/<key>/*` 托管立即可读(2026-10 起拍平,旧 `/_/` 子路径产物必须重新构建上传)。
|
2. `--dist <dir|tgz>` → `POST /api/apps/:key/artifacts`。产物目录根的 `index.html` 必须存在(`tar czf -C <dir> .`,套一层目录会被拒);**tgz 请求体 ≤64 MiB**(网关同值,超过直接 413)、路径穿越/symlink 被拒、同卷 rename 原子替换;上传后 `/apps/<key>/*` 托管立即可读(2026-10 起拍平,旧 `/_/` 子路径产物必须重新构建上传)。超限走下方「大产物外置」。
|
||||||
3. `--image <ref>` → `POST /api/apps/:key/deploy`。只写 `deployDescriptor.image` 并滚动重启;**镜像必须先由应用方自己 push 到平台白名单 registry**;docker.sock 不可用时降级为仅记录。
|
3. `--image <ref>` → `POST /api/apps/:key/deploy`。只写 `deployDescriptor.image` 并滚动重启;**镜像必须先由应用方自己 push 到平台白名单 registry**;docker.sock 不可用时降级为仅记录。
|
||||||
4. `--store` → `POST /api/apps/:key/publish` 自行上架。**前置:平台已给该应用开 `auto_publish`**,否则 403;已上架返回 409 视为跳过。
|
4. `--store` → `POST /api/apps/:key/publish` 自行上架。**前置:平台已给该应用开 `auto_publish`**,否则 403;已上架返回 409 视为跳过。
|
||||||
|
|
||||||
|
### 大产物外置(dist tgz > 64 MiB)
|
||||||
|
|
||||||
|
1. dist 只保留前端壳(index.html + 它直接引用的 JS/CSS),核心资源放 `assets/` 目录(不进 dist)。
|
||||||
|
2. CI 用钉版 `minio/mc` 镜像 mirror:`apps/<key>/<version>/`,版本化路径 + immutable 缓存;旧版本保留供回滚。
|
||||||
|
3. 桶配 CORS:`GET/HEAD/OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;CI 做匿名读自检。
|
||||||
|
4. manifest 声明(仅 micro,治理字段 → 202 审核):
|
||||||
|
```json
|
||||||
|
"embedCsp": { "connectSrc": ["https://static.example.cn"] }
|
||||||
|
```
|
||||||
|
5. 页面用 `fetch('https://static.example.cn/apps/<key>/<version>/…')` 加载核心——这条走 `connect-src`,已放开。
|
||||||
|
**不要**用 `new Worker(外域 URL)` / 动态 `import(外域)` / 外链 `<script>`:这些不受 `connect-src` 覆盖,平台也未放开 `script-src`/`worker-src`。
|
||||||
|
6. 模板见 `references/templates/gitea-app-release.yaml` 的 `assets` job(未配 `TS_ASSETS_BUCKET` 时自动跳过)。
|
||||||
|
|
||||||
### 凭证与配置
|
### 凭证与配置
|
||||||
|
|
||||||
复用应用服务凭证(`X-App-Key`/`X-App-Secret`),与容器自注册、`/oauth/token` 同一套,**无独立发布令牌**。来源优先级:参数 > 环境变量 > 配置文件。
|
复用应用服务凭证(`X-App-Key`/`X-App-Secret`),与容器自注册、`/oauth/token` 同一套,**无独立发布令牌**。来源优先级:参数 > 环境变量 > 配置文件。
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# AGENTS.md — TickShield 生态应用仓库
|
||||||
|
|
||||||
|
本仓库是 TickShield(刻盾云)生态应用,交付物 = **镜像 + `app.manifest.json`**。
|
||||||
|
规范源:`.agents/skills/app-dev/SKILL.md`。未安装时先执行(项目级安装):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add gitea@livebook:images/skills.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开工前硬规则(不需要用户写任务提示词)
|
||||||
|
|
||||||
|
任何任务开始前——包括用户只发一句空话、只说「看看」、或没提任何要求——**先执行**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .agents/skills/app-dev/scripts/check-app.sh .
|
||||||
|
```
|
||||||
|
|
||||||
|
- `RESULT: PASS` → 再开始用户要求的任务。
|
||||||
|
- `RESULT: FAIL` → **先把 die 级清单和修复建议汇报给用户**,未经确认不要先做别的;不要为了让检查变绿而删文件或改契约。
|
||||||
|
- 每次改完 `app.manifest.json` / `Dockerfile` / 前端产物 / CI 后,重跑一次。
|
||||||
|
|
||||||
|
## 大产物红线(dist tgz > 64 MiB)
|
||||||
|
|
||||||
|
- `publish --dist` 的 tgz 请求体硬上限 **64 MiB**;超限直接 413,改配置不解决。已压缩的 wasm / `.data` 几乎压不动。
|
||||||
|
- 超限时:核心资源放公共读对象存储(路径 `apps/<key>/<version>/`),`dist` 只留前端壳,manifest 声明:
|
||||||
|
```json
|
||||||
|
"embedCsp": { "connectSrc": ["https://static.example.cn"] }
|
||||||
|
```
|
||||||
|
该字段仅 `type=micro` 可声明,**治理字段**(变更 202 进审核队列)。
|
||||||
|
- 平台只放开 `connect-src`(`fetch`/XHR/WebSocket/wasm 流式加载);`new Worker(外域)`、动态 `import(外域)`、外链 `<script>` / 样式 / 字体**仍会被 PROD CSP 拦**。
|
||||||
|
- 桶必须配 CORS:`GET/HEAD/OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;只有公共读不够(带 `Range` 的 fetch 会触发预检)。
|
||||||
|
|
||||||
|
## 其他硬约束(详见 skill)
|
||||||
|
|
||||||
|
- 前端资源全部用绝对路径 `/apps/<key>/...`;JS 外置,禁止内联 `<script>`。
|
||||||
|
- 业务 SQL 一律走 `@aigrego/guard` 的租户数据源;路由挂四道闸。
|
||||||
|
- 颜色只用 `var(--sk-*)` 令牌,读 `localStorage['ts-skin']` 跟随平台换肤,不反写。
|
||||||
|
- 密钥不落盘:`APP_SECRET` 只从环境变量/secrets 注入。
|
||||||
@@ -48,3 +48,39 @@ jobs:
|
|||||||
# sh -c 'wget -qO /tmp/cli.mjs https://code.innev.cn/images/tickshield-dist/raw/branch/main/cli/aigrego-release.mjs && node /tmp/cli.mjs ...'
|
# sh -c 'wget -qO /tmp/cli.mjs https://code.innev.cn/images/tickshield-dist/raw/branch/main/cli/aigrego-release.mjs && node /tmp/cli.mjs ...'
|
||||||
# micro 应用前端产物(先 bun 构建 web/ 再上传,包根必须直接含 index.html):
|
# micro 应用前端产物(先 bun 构建 web/ 再上传,包根必须直接含 index.html):
|
||||||
# sh -c 'cd /src/web && bun build src/main.tsx --outfile dist/app.bundle.js && node /src/cli/aigrego-release.mjs publish --dist dist'
|
# sh -c 'cd /src/web && bun build src/main.tsx --outfile dist/app.bundle.js && node /src/cli/aigrego-release.mjs publish --dist dist'
|
||||||
|
|
||||||
|
# 可选:大产物外置(dist tgz > 64 MiB 或核心为已压缩大文件时必须)。
|
||||||
|
# 前置:secrets MINIO_ACCESS_KEY / MINIO_SECRET_KEY;vars TS_ASSETS_BUCKET /
|
||||||
|
# TS_ASSETS_PUBLIC_BASE / MINIO_ENDPOINT;桶开公共读 + CORS(GET/HEAD/OPTIONS、
|
||||||
|
# Allow-Headers: Range、Expose-Headers: Content-Length,Content-Range,ETag)。
|
||||||
|
# 未配置 TS_ASSETS_BUCKET 时整 job 跳过;manifest 需声明 embedCsp.connectSrc(仅 micro,进审核)。
|
||||||
|
assets:
|
||||||
|
if: vars.TS_ASSETS_BUCKET != ''
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout tag source
|
||||||
|
run: |
|
||||||
|
rm -rf src && git clone --depth 1 --branch "$GITHUB_REF_NAME" . src
|
||||||
|
[ -d src/assets ] || { echo "缺 src/assets(外置核心资源目录)"; exit 1; }
|
||||||
|
|
||||||
|
- name: Mirror assets to minIO(钉版 mc / 版本化路径 / 匿名读自检)
|
||||||
|
run: |
|
||||||
|
[ -n "${{ secrets.MINIO_ACCESS_KEY }}" ] && [ -n "${{ secrets.MINIO_SECRET_KEY }}" ] \
|
||||||
|
|| { echo "缺少 minIO 写凭证:secrets MINIO_ACCESS_KEY / MINIO_SECRET_KEY"; exit 1; }
|
||||||
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
MC_IMAGE="${{ vars.TS_IMAGE_PREFIX || 'livebook:8418' }}/minio/mc:RELEASE.2025-04-16T18-13-26Z"
|
||||||
|
ENDPOINT="${{ vars.MINIO_ENDPOINT }}"
|
||||||
|
ENDPOINT="${ENDPOINT#*://}"
|
||||||
|
docker run --rm -v "$PWD/src/assets:/assets:ro" \
|
||||||
|
-e MC_HOST_tsminio="http://${{ secrets.MINIO_ACCESS_KEY }}:${{ secrets.MINIO_SECRET_KEY }}@${ENDPOINT}" \
|
||||||
|
"$MC_IMAGE" mirror --overwrite /assets \
|
||||||
|
"tsminio/${{ vars.TS_ASSETS_BUCKET }}/apps/${{ secrets.APP_KEY }}/${TAG}"
|
||||||
|
|
||||||
|
- name: 匿名读自检(CORS 仍需桶侧配置,这里只验公共读)
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
PROBE="$(cd src/assets && find . -type f | head -1 | sed 's#^\./##')"
|
||||||
|
URL="${{ vars.TS_ASSETS_PUBLIC_BASE }}/apps/${{ secrets.APP_KEY }}/${TAG}/${PROBE}"
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$URL")
|
||||||
|
[ "$CODE" = 200 ] || { echo "匿名读自检失败:$URL → HTTP $CODE"; exit 1; }
|
||||||
|
echo "外置资源已发布:${{ vars.TS_ASSETS_PUBLIC_BASE }}/apps/${{ secrets.APP_KEY }}/${TAG}/"
|
||||||
@@ -70,7 +70,12 @@ die 消息统一三段式:是什么 + 为什么(后果) + 怎么办/绕过
|
|||||||
| 页面显隐不对 | acl 没接(旧宿主无 acl 字段时不设防) | 握手取 init.acl 驱动路由守卫;真闸在后端四道闸 |
|
| 页面显隐不对 | acl 没接(旧宿主无 acl 字段时不设防) | 握手取 init.acl 驱动路由守卫;真闸在后端四道闸 |
|
||||||
| 调后端跨域报错 | 直接 fetch `/svc/...` | 一律 `sdk.callService(appKey, path)` 走宿主代发 |
|
| 调后端跨域报错 | 直接 fetch `/svc/...` | 一律 `sdk.callService(appKey, path)` 走宿主代发 |
|
||||||
| 刷新丢路由 | 没接 `?r=` 深链 | initialRoute() 恢复 + routeSync 上报 |
|
| 刷新丢路由 | 没接 `?r=` 深链 | initialRoute() 恢复 + routeSync 上报 |
|
||||||
| `publish --dist` 被拒 | 包根没有直接含 index.html / 超 64MB / 含 symlink | `tar czf -C <dir> .` 形态打包 |
|
| `publish --dist` 被拒 | 包根没有直接含 index.html / **tgz 超 64 MiB** / 含 symlink | `tar czf -C <dir> .` 形态打包;超限走大产物外置 |
|
||||||
|
| 平台 413(`artifact exceeds 67108864 bytes`) | dist tgz 压后仍超 64 MiB(wasm/`.data` 几乎压不动) | 核心移出 dist,按 contract §7.1 外置 + 声明 `embedCsp` |
|
||||||
|
| 网关 413 但平台没日志 | 网关 `client_max_body_size` 先拦(同 64 MiB) | 同上,不是配置问题,是产物形态问题 |
|
||||||
|
| PROD 下 `fetch` 外部桶被 CSP 拦(`connect-src`) | manifest 未声明 `embedCsp.connectSrc`,或声明变更还在审核(202) | 补声明并等批准;确认平台侧 ≤5s / 网关 2s 已收敛 |
|
||||||
|
| 外部资源请求 OPTIONS 预检失败 | 桶没配 CORS(只开了公共读) | 配 `Allow-Headers: Range`、`Expose-Headers: Content-Range` 等 |
|
||||||
|
| `new Worker('https://…')` / 动态 `import` 外域被拦 | `connect-src` 不覆盖 Worker/模块脚本,平台也未放开 `script-src`/`worker-src` | 把这部分留在 dist 同源,或改成 `fetch` + 本地实例化 |
|
||||||
|
|
||||||
## 发布 / CI
|
## 发布 / CI
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ ROOT="${1:-.}"
|
|||||||
cd "$ROOT" || exit 2
|
cd "$ROOT" || exit 2
|
||||||
|
|
||||||
DIE=0; WARN=0
|
DIE=0; WARN=0
|
||||||
|
HAS_EMBED=0; EMBED_ORIGINS=""
|
||||||
die() { echo "DIE $*"; DIE=$((DIE + 1)); }
|
die() { echo "DIE $*"; DIE=$((DIE + 1)); }
|
||||||
warn() { echo "WARN $*"; WARN=$((WARN + 1)); }
|
warn() { echo "WARN $*"; WARN=$((WARN + 1)); }
|
||||||
ok() { echo "ok $*"; }
|
ok() { echo "ok $*"; }
|
||||||
@@ -18,7 +19,7 @@ SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
|
|||||||
# ---------- A. app.manifest.json ----------
|
# ---------- A. app.manifest.json ----------
|
||||||
MF=app.manifest.json
|
MF=app.manifest.json
|
||||||
if [ ! -f "$MF" ]; then
|
if [ ! -f "$MF" ]; then
|
||||||
die "缺 $MF(应用契约唯一事实源)"
|
die "缺 ${MF}(应用契约唯一事实源)"
|
||||||
LISTING_KEY=""
|
LISTING_KEY=""
|
||||||
else
|
else
|
||||||
TMP="$(mktemp)"
|
TMP="$(mktemp)"
|
||||||
@@ -63,6 +64,24 @@ if (pages !== undefined) {
|
|||||||
else if (p.audience !== undefined && !['admin', 'viewer', 'all'].includes(p.audience)) console.log('BAD_ACL_AUD=' + i + ':' + p.audience);
|
else if (p.audience !== undefined && !['admin', 'viewer', 'all'].includes(p.audience)) console.log('BAD_ACL_AUD=' + i + ':' + p.audience);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 大产物外置:embedCsp.connectSrc 形态与平台校验保持一致(apps/platform/internal/pkg/appcsp)。
|
||||||
|
if (o.embedCsp !== undefined) {
|
||||||
|
console.log('HAS_EMBED=yes');
|
||||||
|
if ((o.type ?? 'service') !== 'micro') console.log('BAD_EMBED=non-micro');
|
||||||
|
const arr = o.embedCsp && o.embedCsp.connectSrc;
|
||||||
|
if (!Array.isArray(arr)) { console.log('BAD_EMBED=not-array'); }
|
||||||
|
else {
|
||||||
|
console.log('EMBED_COUNT=' + arr.length);
|
||||||
|
if (arr.length > 8) console.log('BAD_EMBED=too-many:' + arr.length);
|
||||||
|
const originRe = /^https?:\/\/(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*(:[0-9]{1,5})?$/;
|
||||||
|
const seen = new Set();
|
||||||
|
arr.forEach((s, i) => {
|
||||||
|
if (typeof s !== 'string' || s.length > 253 || !originRe.test(s)) console.log('BAD_EMBED=' + i + ':' + s);
|
||||||
|
else if (seen.has(s)) console.log('BAD_EMBED=dup:' + s);
|
||||||
|
else { seen.add(s); console.log('EMBED_ORIGIN=' + s); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
EOF
|
EOF
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
python3 - "$MF" >"$TMP" <<'EOF'
|
python3 - "$MF" >"$TMP" <<'EOF'
|
||||||
@@ -110,6 +129,28 @@ if acl is not None:
|
|||||||
print(f'BAD_ACL={i}')
|
print(f'BAD_ACL={i}')
|
||||||
elif p.get('audience') is not None and p['audience'] not in ('admin', 'viewer', 'all'):
|
elif p.get('audience') is not None and p['audience'] not in ('admin', 'viewer', 'all'):
|
||||||
print(f"BAD_ACL_AUD={i}:{p['audience']}")
|
print(f"BAD_ACL_AUD={i}:{p['audience']}")
|
||||||
|
# 大产物外置:embedCsp.connectSrc 形态与平台校验保持一致(apps/platform/internal/pkg/appcsp)。
|
||||||
|
if 'embedCsp' in o:
|
||||||
|
print('HAS_EMBED=yes')
|
||||||
|
if (o.get('type') or 'service') != 'micro':
|
||||||
|
print('BAD_EMBED=non-micro')
|
||||||
|
arr = (o.get('embedCsp') or {}).get('connectSrc') if isinstance(o.get('embedCsp'), dict) else None
|
||||||
|
if not isinstance(arr, list):
|
||||||
|
print('BAD_EMBED=not-array')
|
||||||
|
else:
|
||||||
|
print(f'EMBED_COUNT={len(arr)}')
|
||||||
|
if len(arr) > 8:
|
||||||
|
print(f'BAD_EMBED=too-many:{len(arr)}')
|
||||||
|
origin_re = re.compile(r'^https?://(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*(:[0-9]{1,5})?$')
|
||||||
|
seen = set()
|
||||||
|
for i, s in enumerate(arr):
|
||||||
|
if not isinstance(s, str) or len(s) > 253 or not origin_re.match(s):
|
||||||
|
print(f'BAD_EMBED={i}:{s}')
|
||||||
|
elif s in seen:
|
||||||
|
print(f'BAD_EMBED=dup:{s}')
|
||||||
|
else:
|
||||||
|
seen.add(s)
|
||||||
|
print(f'EMBED_ORIGIN={s}')
|
||||||
EOF
|
EOF
|
||||||
else
|
else
|
||||||
warn "无 node/python3,跳过 manifest 语义检查(只查文本项)"
|
warn "无 node/python3,跳过 manifest 语义检查(只查文本项)"
|
||||||
@@ -141,6 +182,9 @@ EOF
|
|||||||
grep -q '^BAD_FNAV=' "$TMP" && die "footerNavItems 缺 id/label/path:$(grep '^BAD_FNAV=' "$TMP" | sed 's/^BAD_FNAV=//' | tr '\n' ' ')"
|
grep -q '^BAD_FNAV=' "$TMP" && die "footerNavItems 缺 id/label/path:$(grep '^BAD_FNAV=' "$TMP" | sed 's/^BAD_FNAV=//' | tr '\n' ' ')"
|
||||||
grep -q '^BAD_ACL=' "$TMP" && die "aclManifest.pages 缺 key/label 或 pages 非数组"
|
grep -q '^BAD_ACL=' "$TMP" && die "aclManifest.pages 缺 key/label 或 pages 非数组"
|
||||||
grep -q '^BAD_ACL_AUD=' "$TMP" && die "aclManifest audience 非法(仅 admin|viewer|all)"
|
grep -q '^BAD_ACL_AUD=' "$TMP" && die "aclManifest audience 非法(仅 admin|viewer|all)"
|
||||||
|
grep -q '^BAD_EMBED=' "$TMP" && die "embedCsp 非法(仅 micro;≤8 条;scheme://host[:port],可 *.;禁止路径/裸 */分号/空白/重复):$(grep '^BAD_EMBED=' "$TMP" | sed 's/^BAD_EMBED=//' | tr '\n' ' ')"
|
||||||
|
[ "$(sed -n 's/^HAS_EMBED=//p' "$TMP" | head -1)" = "yes" ] && HAS_EMBED=1
|
||||||
|
EMBED_ORIGINS=$(sed -n 's/^EMBED_ORIGIN=//p' "$TMP" | tr '\n' ' ')
|
||||||
fi
|
fi
|
||||||
rm -f "$TMP"
|
rm -f "$TMP"
|
||||||
fi
|
fi
|
||||||
@@ -175,7 +219,7 @@ else
|
|||||||
grep -q 'assertTenantTables(' "$ENTRY" || die "缺启动自检 assertTenantTables(表缺 tenant_id 列会静默变全表扫描)"
|
grep -q 'assertTenantTables(' "$ENTRY" || die "缺启动自检 assertTenantTables(表缺 tenant_id 列会静默变全表扫描)"
|
||||||
grep -q 'process.exit(1)' "$ENTRY" || warn "启动失败未 process.exit(1)(带病不启动约定)"
|
grep -q 'process.exit(1)' "$ENTRY" || warn "启动失败未 process.exit(1)(带病不启动约定)"
|
||||||
POOLS=$(grep -c 'new pg.Pool' "$ENTRY")
|
POOLS=$(grep -c 'new pg.Pool' "$ENTRY")
|
||||||
[ "$POOLS" -gt 2 ] && warn "new pg.Pool ×$POOLS:确认除 tdb createPool + dbCheck 探活池外无业务直连"
|
[ "$POOLS" -gt 2 ] && warn "new pg.Pool ×${POOLS}:确认除 tdb createPool + dbCheck 探活池外无业务直连"
|
||||||
fi
|
fi
|
||||||
grep -qE 'export (async )?function handler|export \{[^}]*handler|export default' "$ENTRY" \
|
grep -qE 'export (async )?function handler|export \{[^}]*handler|export default' "$ENTRY" \
|
||||||
|| die "$ENTRY 未导出 handler(APP_ENTRY 契约)"
|
|| die "$ENTRY 未导出 handler(APP_ENTRY 契约)"
|
||||||
@@ -222,6 +266,13 @@ if [ -n "${LISTING_KEY:-}" ]; then
|
|||||||
fi
|
fi
|
||||||
compgen -G ".gitea/workflows/*.yml" >/dev/null 2>&1 || compgen -G ".gitea/workflows/*.yaml" >/dev/null 2>&1 \
|
compgen -G ".gitea/workflows/*.yml" >/dev/null 2>&1 || compgen -G ".gitea/workflows/*.yaml" >/dev/null 2>&1 \
|
||||||
|| warn "缺 .gitea/workflows 发布流水线(模板见 references/templates/gitea-app-release.yaml)"
|
|| warn "缺 .gitea/workflows 发布流水线(模板见 references/templates/gitea-app-release.yaml)"
|
||||||
|
# A11 免提示自检入口:仓库根 AGENTS.md 里写死「开工前先跑 check-app.sh」,
|
||||||
|
# 后续任何 Agent 不依赖用户任务提示词也会先体检(模板 references/templates/AGENTS.md)。
|
||||||
|
if [ -f AGENTS.md ] && grep -q 'check-app\.sh' AGENTS.md; then
|
||||||
|
ok "AGENTS.md 含免提示自检硬规则"
|
||||||
|
else
|
||||||
|
warn "缺 AGENTS.md 自检硬规则(照 references/templates/AGENTS.md):Agent 不会自动体检,只能靠用户每次明说"
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------- H. UI 与皮肤一致性(micro 前端,基准见 references/ui-style.md) ----------
|
# ---------- H. UI 与皮肤一致性(micro 前端,基准见 references/ui-style.md) ----------
|
||||||
# 分档口径:破坏换肤能力的(写死色值、没有皮肤桥)= die;风格细节偏离 = warn。
|
# 分档口径:破坏换肤能力的(写死色值、没有皮肤桥)= die;风格细节偏离 = warn。
|
||||||
@@ -254,7 +305,7 @@ if [ "$IS_MICRO" = 1 ] && [ -d web/src ]; then
|
|||||||
# H4 令牌快照完整性
|
# H4 令牌快照完整性
|
||||||
TOK=web/src/skin/tokens.ts
|
TOK=web/src/skin/tokens.ts
|
||||||
if [ ! -f "$TOK" ]; then
|
if [ ! -f "$TOK" ]; then
|
||||||
die "缺 $TOK(令牌快照:bash scripts/sync-skin-tokens.sh <平台仓根>)"
|
die "缺 ${TOK}(令牌快照:bash scripts/sync-skin-tokens.sh <平台仓根>)"
|
||||||
else
|
else
|
||||||
SKIN_N=$(grep -c "key: '" "$TOK")
|
SKIN_N=$(grep -c "key: '" "$TOK")
|
||||||
TOK_N=$(grep -c "'--sk-" "$TOK")
|
TOK_N=$(grep -c "'--sk-" "$TOK")
|
||||||
@@ -308,8 +359,73 @@ if [ "$IS_MICRO" = 1 ] && [ -d web/src ]; then
|
|||||||
[ "$IMP" -gt 0 ] && warn "业务样式用了 $IMP 处 !important(reduced-motion 兜底之外)"
|
[ "$IMP" -gt 0 ] && warn "业务样式用了 $IMP 处 !important(reduced-motion 兜底之外)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------- I. 大产物与静态资源外置(dist tgz > 64MiB 的核心出 dist) ----------
|
||||||
|
if [ "$IS_MICRO" = 1 ]; then
|
||||||
|
DIST=""
|
||||||
|
for cand in dist web/dist build web/build; do
|
||||||
|
if [ -d "$cand" ] && [ -f "$cand/index.html" ]; then DIST="$cand"; break; fi
|
||||||
|
done
|
||||||
|
NEED_EXTERNAL=0
|
||||||
|
if [ -z "$DIST" ]; then
|
||||||
|
warn "未找到含 index.html 的构建产物(dist/ web/dist/ 等):体积与外置检查跳过,发布前构建后复跑"
|
||||||
|
else
|
||||||
|
UNCOMP_KB=$(du -sk "$DIST" 2>/dev/null | awk '{print $1}')
|
||||||
|
TGZ_TMP=$(mktemp -t appcheck.XXXXXX)
|
||||||
|
if tar czf "$TGZ_TMP" -C "$DIST" . 2>/dev/null; then
|
||||||
|
TGZ_BYTES=$(wc -c < "$TGZ_TMP" | tr -d ' ')
|
||||||
|
rm -f "$TGZ_TMP"
|
||||||
|
if [ "$TGZ_BYTES" -gt 67108864 ]; then
|
||||||
|
NEED_EXTERNAL=1
|
||||||
|
die "dist 打 tgz 后 $((TGZ_BYTES / 1048576))MiB > 平台 64MiB 上限——核心必须外置到对象存储(contract §7.1)"
|
||||||
|
elif [ "$UNCOMP_KB" -gt 65536 ]; then
|
||||||
|
warn "dist 未压缩 ${UNCOMP_KB}KB > 64MiB,但 tgz 实测 $((TGZ_BYTES / 1024))KB 达标(压缩率依赖,发布前留意)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$TGZ_TMP"
|
||||||
|
warn "dist 打包 tgz 失败,体积检查跳过"
|
||||||
|
fi
|
||||||
|
BIGFILES=$(find "$DIST" -type f \( -name '*.wasm' -o -name '*.data' -o -name '*.br' -o -name '*.gz' -o -name '*.zip' -o -name '*.7z' -o -name '*.mp4' \) -size +8192k 2>/dev/null | head -5)
|
||||||
|
[ -n "$BIGFILES" ] && warn "dist 内含 >8MiB 已压缩资源(tgz 几乎不缩小):$(echo "$BIGFILES" | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
if [ "$NEED_EXTERNAL" = 1 ] && [ "$HAS_EMBED" = 0 ]; then
|
||||||
|
die "dist 超限但 manifest 未声明 embedCsp.connectSrc——外置后必须声明桶域名(仅 micro)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# I5 声明与用点对得上;I7 外置上传 job(只提示,静态检查无法证明桶侧状态)。
|
||||||
|
if [ "$HAS_EMBED" = 1 ]; then
|
||||||
|
MISSING=0
|
||||||
|
for origin in $EMBED_ORIGINS; do
|
||||||
|
if ! grep -rqF "$origin" web/src web/index.html "$ENTRY" 2>/dev/null; then
|
||||||
|
MISSING=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$MISSING" = 1 ] && warn "embedCsp 声明了来源但源码/入口/index.html 找不到使用点(死配置,或地址来自运行时配置——人工确认)"
|
||||||
|
if compgen -G ".gitea/workflows/*.y*ml" >/dev/null 2>&1; then
|
||||||
|
grep -rqE 'TS_ASSETS_BUCKET|mc +mirror' .gitea/workflows 2>/dev/null \
|
||||||
|
|| warn "未见外置资源上传 job(assets / mc mirror)——确认 CI 把核心发到 apps/<key>/<version>/(checklist I7)"
|
||||||
|
else
|
||||||
|
warn "缺 .gitea/workflows,无法确认外置资源上传与版本化路径(checklist I7)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# I6:connect-src 不覆盖 Worker / 动态 import / 外链 script 与样式(PROD CSP 会拦)。
|
||||||
|
# 只对 micro 有意义——service 没有同源托管页面,Node 侧外呼不受浏览器 CSP 约束。
|
||||||
|
if [ "$IS_MICRO" = 1 ]; then
|
||||||
|
EXT_JS=$(grep -rnE "new Worker\([[:space:]]*['\"]https?://|import\([[:space:]]*['\"]https?://|importScripts\([[:space:]]*['\"]https?://" web/src "$ENTRY" 2>/dev/null | head -3)
|
||||||
|
HTML_JS=$(grep -nE "<script[^>]+src=['\"]https?://" web/index.html 2>/dev/null | head -3)
|
||||||
|
HTML_CSS=$(grep -nE "<link[^>]+rel=['\"]stylesheet['\"][^>]+href=['\"]https?://|<link[^>]+href=['\"]https?://[^>]+rel=['\"]stylesheet['\"]" web/index.html 2>/dev/null | head -3)
|
||||||
|
if [ -n "$EXT_JS$HTML_JS$HTML_CSS" ]; then
|
||||||
|
die "跨域加载用了 connect-src 不覆盖的方式(Worker/动态 import/外链 script|stylesheet),PROD CSP 会拦:$(printf '%s\n' "$EXT_JS" "$HTML_JS" "$HTML_CSS" | head -3 | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------- 汇总 ----------
|
# ---------- 汇总 ----------
|
||||||
echo "---"
|
echo "---"
|
||||||
echo "die=$DIE warn=$WARN"
|
echo "die=$DIE warn=$WARN"
|
||||||
[ "$DIE" -gt 0 ] && exit 1
|
if [ "$DIE" -gt 0 ]; then
|
||||||
|
echo "RESULT: FAIL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "RESULT: PASS"
|
||||||
exit 0
|
exit 0
|
||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-app.sh 自测:用临时 fixture 覆盖大产物 / embedCsp 规则,防止技能与平台校验漂移。
|
||||||
|
# 用法: bash scripts/selftest-check-app.sh 退出码 0=全部断言通过。
|
||||||
|
set -u
|
||||||
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
CHECK="$HERE/check-app.sh"
|
||||||
|
[ -f "$CHECK" ] || { echo "找不到 $CHECK" >&2; exit 2; }
|
||||||
|
|
||||||
|
ROOT=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$ROOT"' EXIT
|
||||||
|
N=0; BAD=0
|
||||||
|
|
||||||
|
# fixture <name>:建一个最小 micro 仓库骨架(manifest/Dockerfile/入口/前端)。
|
||||||
|
fixture() {
|
||||||
|
local dir="$ROOT/$1"
|
||||||
|
mkdir -p "$dir/web/src/skin" "$dir/dist"
|
||||||
|
cat >"$dir/app.manifest.json" <<'JSON'
|
||||||
|
{ "listingKey": "selftest-app", "name": "自测应用", "version": "1.0.0", "type": "micro" }
|
||||||
|
JSON
|
||||||
|
cat >"$dir/Dockerfile" <<'DOCKER'
|
||||||
|
FROM tickshield-node-runtime:base
|
||||||
|
RUN ln -s /opt/xgent/runtime/node_modules /app/node_modules
|
||||||
|
DOCKER
|
||||||
|
cat >"$dir/server.js" <<'JS'
|
||||||
|
import { createGuard } from '@aigrego/guard';
|
||||||
|
const guard = createGuard({ portalUrl: 'http://x', appKey: 'selftest-app', appSecret: 'x' });
|
||||||
|
export function handler(req, res) {}
|
||||||
|
export const routes = guard.routes([]);
|
||||||
|
JS
|
||||||
|
cat >"$dir/web/index.html" <<'HTML'
|
||||||
|
<!doctype html><html><body>
|
||||||
|
<script src="/apps/selftest-app/vendor/portal-sdk.js"></script>
|
||||||
|
<script src="/apps/selftest-app/app.js"></script>
|
||||||
|
</body></html>
|
||||||
|
HTML
|
||||||
|
cat >"$dir/web/src/main.tsx" <<'TS'
|
||||||
|
import { initSkin } from './skin/apply';
|
||||||
|
initSkin();
|
||||||
|
TS
|
||||||
|
printf "export const SKINS = [{ key: 'light' }, { key: 'dark' }, { key: 'jade' }];\n// SKIN_SNAPSHOT_DATE = '2026-10-22'\n" >"$dir/web/src/skin/tokens.ts"
|
||||||
|
printf ":root { font-family: system-ui, -apple-system, 'PingFang SC'; font-size: 13px; }\n" >"$dir/web/app.css"
|
||||||
|
echo '<!doctype html><html><body>app</body></html>' >"$dir/dist/index.html"
|
||||||
|
echo "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# set_embed <manifest> <json-array>:写入 embedCsp.connectSrc(node 优先,退化 python3)。
|
||||||
|
set_embed() {
|
||||||
|
local f="$1" origins="$2"
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
node -e 'const fs=require("fs");const [p,src]=process.argv.slice(1);const o=JSON.parse(fs.readFileSync(p));o.embedCsp={connectSrc:JSON.parse(src)};fs.writeFileSync(p,JSON.stringify(o,null,2));' "$f" "$origins"
|
||||||
|
else
|
||||||
|
python3 - "$f" "$origins" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
p, origins = sys.argv[1], json.loads(sys.argv[2])
|
||||||
|
o = json.load(open(p))
|
||||||
|
o['embedCsp'] = {'connectSrc': origins}
|
||||||
|
json.dump(o, open(p, 'w'), ensure_ascii=False, indent=2)
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# expect <name> <dir> <contains-regex> <want:1|0>
|
||||||
|
expect() {
|
||||||
|
local name="$1" dir="$2" pat="$3" want="$4" out got
|
||||||
|
out=$(bash "$CHECK" "$dir" 2>&1)
|
||||||
|
N=$((N + 1))
|
||||||
|
if printf '%s\n' "$out" | grep -qE "$pat"; then got=1; else got=0; fi
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
echo "ok $name"
|
||||||
|
else
|
||||||
|
BAD=$((BAD + 1))
|
||||||
|
echo "FAIL $name(期望命中=$want 模式=$pat)"
|
||||||
|
printf '%s\n' "$out" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) dist tgz 超限且未声明 embedCsp → 两条 die + RESULT FAIL。
|
||||||
|
D=$(fixture over-limit)
|
||||||
|
dd if=/dev/urandom of="$D/dist/core.bin" bs=1048576 count=70 2>/dev/null
|
||||||
|
expect "tgz 超限诊断" "$D" 'dist 打 tgz 后 [0-9]+MiB > 平台 64MiB' 1
|
||||||
|
expect "超限未声明外置" "$D" 'dist 超限但 manifest 未声明 embedCsp' 1
|
||||||
|
expect "超限 RESULT FAIL" "$D" '^RESULT: FAIL$' 1
|
||||||
|
|
||||||
|
# 2) embedCsp 带路径 → 结构非法。
|
||||||
|
D=$(fixture embed-bad)
|
||||||
|
set_embed "$D/app.manifest.json" '["https://cdn.example.com/assets"]'
|
||||||
|
expect "embedCsp 路径非法" "$D" 'embedCsp 非法' 1
|
||||||
|
|
||||||
|
# 3) 外域 Worker → connect-src 不覆盖。
|
||||||
|
D=$(fixture worker-ext)
|
||||||
|
echo "new Worker('https://cdn.example.com/w.js');" >>"$D/web/src/main.tsx"
|
||||||
|
expect "外域 Worker 拦截" "$D" 'connect-src 不覆盖' 1
|
||||||
|
|
||||||
|
# 4) 合规声明 + 使用点 + 小 dist → 不误报。
|
||||||
|
D=$(fixture embed-ok)
|
||||||
|
set_embed "$D/app.manifest.json" '["https://cdn.example.com"]'
|
||||||
|
echo "fetch('https://cdn.example.com/apps/selftest-app/1.0.0/core.data');" >>"$D/web/src/main.tsx"
|
||||||
|
expect "合规声明不报非法" "$D" 'embedCsp 非法' 0
|
||||||
|
expect "合规声明不报超限" "$D" 'dist 打 tgz 后' 0
|
||||||
|
expect "合规声明不报死配置" "$D" '找不到使用点' 0
|
||||||
|
expect "输出始终带结论行" "$D" '^RESULT: (PASS|FAIL)$' 1
|
||||||
|
|
||||||
|
# 5) 免提示自检入口:无 AGENTS.md → warn;有且含 check-app.sh → 不再 warn。
|
||||||
|
expect "缺 AGENTS.md 提示" "$D" '缺 AGENTS.md 自检硬规则' 1
|
||||||
|
printf '# AGENTS.md\n开工前先跑 `bash .agents/skills/app-dev/scripts/check-app.sh .`。\n' >"$D/AGENTS.md"
|
||||||
|
expect "有 AGENTS.md 不提示" "$D" '缺 AGENTS.md 自检硬规则' 0
|
||||||
|
|
||||||
|
echo "---"
|
||||||
|
echo "selftest: $N 项,失败 $BAD"
|
||||||
|
[ "$BAD" -gt 0 ] && exit 1
|
||||||
|
exit 0
|
||||||
Reference in new issue
Block a user