Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf3bb9cd10 | |||
| 166007b460 | |||
| 78c24a515d | |||
| dfe6ade2d8 | |||
| e32d04ec50 |
@@ -1,6 +1,7 @@
|
||||
# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行
|
||||
OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock
|
||||
|
||||
# @required
|
||||
# 目标网站地址:技能要访问的网站或服务地址
|
||||
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可
|
||||
|
||||
@@ -52,3 +53,6 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调
|
||||
|
||||
# 临时目录:运行中临时文件的位置(不上侧栏)
|
||||
# SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp
|
||||
|
||||
# MySQL 云库连接:技能走云库时在用户数据目录配置 data-sources.json,并在此填写 DSN
|
||||
# MYSQL_DSN=mysql://user:password@192.168.0.20:3306/your_database # 留空或不写=本地 SQLite;须 mysql:// 开头且含库名;匠厂 Host 须 ≥ 6.9.28;见 references/DATA_SOURCES.md
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -9,6 +9,34 @@
|
||||
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
||||
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
||||
|
||||
## 1.0.118
|
||||
|
||||
- 沉淀浏览器 RPA 窗口/viewport 最佳实践(`RPA.md` §1.2.1):`no_viewport=True` + `--start-maximized`,禁止 `viewport=None` 与 CDP/JS 中途改窗
|
||||
- `RPA_BROWSER.md` 补充 Shadow DOM 双 input 陷阱与 Cookie 可选 dismiss 模式
|
||||
- 新增 `POLICY-RPA-007` 文档标记与 launch 禁止模式门禁;CHECKLIST A5-4 对齐
|
||||
|
||||
## 1.0.117
|
||||
|
||||
- 沉淀匠厂 Host ≥ 6.9.28 **多数据源**契约:可选 MySQL 云库 + `data-sources.json` 声明;默认仍为本地 SQLite
|
||||
- 新增 `references/DATA_SOURCES.md`、`assets/data-sources.example.json`(SQLite 默认)、`assets/examples/data-sources.mysql.example.json`
|
||||
- `scripts/db/backend.py`、`cursor_compat.py` 与双后端 `connection.py`:CLI / Action 与宿主 UI 读写同一库
|
||||
- `.env.example` 增加 `MYSQL_DSN` 说明;CHECKLIST / CONFIG / DATA_PATHS / SCHEMA / ACTIONS 口径同步
|
||||
|
||||
## 1.0.116
|
||||
|
||||
- 沉淀宿主 **年 / 年月** 表单字段契约:`display_type: year`(`YYYY`)、`year_month` / `month`(`YYYY-MM`);与 `date` / `datetime_unix_seconds` 对称,Action 与 CRUD 均适用
|
||||
- 更新 `ACTIONS.md`、`SCHEMA.md`、`skill-actions.schema.json`;新增 `assets/examples/year-month-action.example.json`;`display_metadata.py` 增加 `YEAR` / `YEAR_MONTH` 常量
|
||||
|
||||
## 1.0.115
|
||||
|
||||
- 沉淀匠厂配置管理必填项契约:`.env.example` 在行头上方用独立一行 `# @required` 声明;宿主显示红色 `*` 并在保存时校验非空
|
||||
- 模板示例已为 `TARGET_BASE_URL` 加上示范;新增 `POLICY-CONFIG-005` 门禁错误用法
|
||||
|
||||
## 1.0.114
|
||||
|
||||
- 技能根目录增加 `sync.ps1`,和 `release.ps1` 并排。在本机把明文同步到已安装的技能目录,用来在宿主里验收
|
||||
- 市场上的最新版本和加密包仍走 `release.ps1`。同步后退出资料管理再打开,不要点技能市场的「更新」
|
||||
|
||||
## 1.0.113
|
||||
|
||||
- 固化数据管理契约:`visible=0` 只藏界面;`default_sort_json` 仍可引用隐藏的非敏感物理列(含主键)
|
||||
|
||||
4
SKILL.md
4
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 技能开发模板(通用业务版)
|
||||
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
||||
version: 1.0.113
|
||||
version: 1.0.118
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
@@ -155,7 +155,7 @@ python {baseDir}/scripts/main.py init-db
|
||||
4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制;开发前先读 `development/PRODUCT_SURFACES.md` 并填写入口定位表。
|
||||
5. 用户市场四 Tab 材料写入根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;Agent 调用契约见 `references/CLI.md`、`references/SCHEMA.md`、`references/ACTIONS.md`。
|
||||
6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`。
|
||||
7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。
|
||||
7. 本地 SQLite 或可选 MySQL 云库:数据源声明见 `references/DATA_SOURCES.md`;中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。
|
||||
|
||||
## 技能命名与 slug
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
- **业务技能注意:** 若主路径在数据管理,准备/载入/导入类 Action 应挂 `toolbar` + `bind.tables`(用户看得见),**不要**只挂 `skill-detail` 却在教程里写成「技能详情点按钮」。见 `development/TUTORIAL_SURFACES.md` §5、`references/ACTIONS.md`「用户可见性」。
|
||||
- `html/example-report.html`:HTML 快照页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`。不要放进 `pages/`。
|
||||
- `html/pages/*.html`:装完就出现在侧栏的壳子页(宿主直接读安装包,不经生成)。页面用 `jiangchang.call` 调本技能已声明的命令。见 `references/ACTIONS.md`「HTML 页面报告」。模板仓库不要在这里放示例页。
|
||||
- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。
|
||||
- `data-sources.example.json`:声明式数据源默认示例(SQLite `{skillSlug}.db`);宿主安装时可复制到用户目录。MySQL 参考见 `examples/data-sources.mysql.example.json`。
|
||||
- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `data-sources.mysql.example.json`、`path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。
|
||||
- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**)。
|
||||
|
||||
`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping` / `columnMapping` / **`labelColumn`**(选择摘要标签列)/ **`selectionMode`**(`required` \| `none` \| `optional`:表勾选如何驱动 Action;省略则宿主兼容推断)。placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。数据管理 confirm/form 弹层在会应用勾选时展示「已选择」摘要;**form + 有摘要时映射 `readOnly` 默认不占参数区(仍提交)**,可选 **`revealInForm`** 强制显示。弹窗 `description` / 确认黄条 / 参数说明须**用户可见中文**(勿写 `pickCount`/`async`/`pending`/CLI 子命令;见 ACTIONS.md「用户可见文案」)。可选 `hoverHint` 为数据管理工具栏悬停(省略不显示,不回退)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
|
||||
|
||||
11
assets/data-sources.example.json
Normal file
11
assets/data-sources.example.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sources": [
|
||||
{
|
||||
"id": "primary",
|
||||
"kind": "sqlite",
|
||||
"displayName": "本地库",
|
||||
"path": "{skillSlug}.db"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
# 示例 JSON
|
||||
|
||||
- `data-sources.mysql.example.json`:MySQL 云库 `data-sources.json` 形状参考(复制到用户数据目录并配 `.env` 中 `MYSQL_DSN`;见 `references/DATA_SOURCES.md`)。
|
||||
- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。
|
||||
- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。
|
||||
- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
|
||||
- `skill-grid-presentation.example.json`:数据管理整行 / 整列 / 区域呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
|
||||
- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。
|
||||
- `lookup-action.example.json`:Action 内联 Lookup 形状参考(单选 + `multiple: true` 多选并列;跨表引用勿填编号;见 `references/ACTIONS.md`「跨表引用:Lookup」)。
|
||||
- `year-month-action.example.json`:Action / CRUD 账期月份字段 `display_type: year_month` 形状参考(见 `references/ACTIONS.md`「表单字段类型对照」)。
|
||||
- `visible-when-action.example.json`:Action 参数字段条件显隐 `visibleWhen` 形状参考(互斥模式联动;见 `references/ACTIONS.md`「字段条件显隐」;≠ Policy L3)。
|
||||
- `selection-label-column.action.example.json`:选择摘要 `bind.labelColumn` 形状参考(见 `references/ACTIONS.md`「选择摘要」)。
|
||||
- `selection-mode-optional.action.example.json`:`bind.selectionMode: "optional"` 智能勾选/按数量合并形状参考;用户可见文案为口语中文(见 `references/ACTIONS.md`「勾选驱动模式」「用户可见文案」)。
|
||||
|
||||
11
assets/examples/data-sources.mysql.example.json
Normal file
11
assets/examples/data-sources.mysql.example.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sources": [
|
||||
{
|
||||
"id": "primary",
|
||||
"kind": "mysql",
|
||||
"displayName": "业务云库",
|
||||
"connectionEnv": "MYSQL_DSN"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
assets/examples/year-month-action.example.json
Normal file
40
assets/examples/year-month-action.example.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"_comment": "形状参考:账期/月份字段用 year_month,宿主渲染年月选择器。完整契约见 references/ACTIONS.md「表单字段类型对照」与 references/SCHEMA.md。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "create-period-job",
|
||||
"label": "新建账期作业(示例)",
|
||||
"description": "演示 year_month 字段:提交值为 YYYY-MM 字符串,不要用 date。",
|
||||
"placements": ["toolbar"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["close_jobs"]
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "create-job",
|
||||
"args": ["--period", "{{period}}", "--title", "{{title}}"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["period"],
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string",
|
||||
"title": "月份",
|
||||
"description": "格式 YYYY-MM,例如 2026-05",
|
||||
"display_type": "year_month"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"title": "标题",
|
||||
"description": "可留空,默认按月份生成"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -85,7 +85,7 @@
|
||||
"mutationKind": {
|
||||
"type": "string",
|
||||
"enum": ["create", "update", "delete", "import"],
|
||||
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。"
|
||||
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写已注册数据源(默认 SQLite)。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。"
|
||||
},
|
||||
"scalarArg": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
@@ -124,12 +124,12 @@
|
||||
},
|
||||
"display_type": {
|
||||
"type": "string",
|
||||
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"description": "可选。表单控件提示;与 format 二选一即可。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。"
|
||||
"enum": ["textarea", "year", "year_month", "month", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"description": "可选。表单控件提示;与 format 二选一即可。year/year_month=年或年月选择器(YYYY / YYYY-MM;month 为 year_month 别名)。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"enum": ["textarea", "year", "year_month", "month", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"description": "可选。display_type 别名,语义相同。"
|
||||
},
|
||||
"pathRoot": {
|
||||
|
||||
@@ -120,7 +120,7 @@ checklist_version: 1.0
|
||||
- [ ] A5-1 **P0** 未准备好账号/浏览器档案时,主路径失败并提示,不会默默用本机默认浏览器身份跑起来
|
||||
- [ ] A5-2 **P0** 登录策略为 required 时:未登录不能把做不成的事当成成功
|
||||
- [ ] A5-3 **P0** 交付联调默认能看见浏览器窗口(有头);不能把无头当作唯一可验收形态
|
||||
- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入)
|
||||
- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入);`no_viewport=True` + `--start-maximized`,无「先小后大」或右侧留白/scrollbar 后遗症(见 `RPA.md` §1.2.1)
|
||||
- [ ] A5-5 **P1** 验证码或人工确认:有等待与超时;超时后任务失败可理解,不无限挂死或假成功
|
||||
- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活
|
||||
- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在
|
||||
@@ -147,9 +147,11 @@ checklist_version: 1.0
|
||||
- [ ] A6-10 **P1** 有 HTML 侧栏预览时(S0-12):预览区横向铺满、无横向滚动条;重新生成后内容与页头时间更新;布局符合 `references/ACTIONS.md`「宿主预览布局」。无 HTML 报告 → **N/A**
|
||||
- [ ] A6-11 **P1** 业务**状态 / 启用类**列若用户需要筛表或顶栏搜中文:`searchable: 1` 且 `options_json`(或 `boolean` + `options_json`)的 **value 与存库一致**;仅 `searchable: 0` 则宿主不提供列筛与全局搜索。见 `references/SCHEMA.md`「列筛选与全局搜索」
|
||||
- [ ] A6-12 **P2** `display_type: boolean` 的展示列推荐同时配置 `options_json` 中文 label(如 停用/启用),避免仅靠宿主默认「是/否」;无 boolean 列 → **N/A**
|
||||
- [ ] A6-13 **P2** 账期/月份/生效月等 `YYYY-MM` 字段用 `display_type: year_month`(或 Action 同义 `format`),**不要**只用 `description` 写格式却留纯文本框;完整日才用 `date`
|
||||
- [ ] A6-13 **P0** 新技能**不得**创建 `task_logs`;过程/进度/业务结果走运行日志、任务中心、业务表(见 `LOGGING.md`、`SCHEMA.md`)
|
||||
- [ ] A6-14 **P0** 废弃用户可见表时:`drop_deprecated_user_table` 物理删表 + 清元数据;**禁止**只删元数据留物理表;有迁移时须提供旧库回归测试(见 `SCHEMA.md`、`tests/samples/test_table_deprecation_migration.py.sample`)
|
||||
- [ ] A6-15 **P1** 若启用 `cellPresentation`:旁路表不得出现在数据管理侧栏;整行着色不得铺到详情每个字段;禁止 `all`×`all`;tone 闭集。未启用 → **N/A**(见 `references/CELL_PRESENTATION.md`)
|
||||
- [ ] A6-16 **P0** 若走 **MySQL 云库**:用户目录 `data-sources.json` + `.env` DSN 已配置;Host ≥ 6.9.28;CLI / Action 与数据管理 UI 读写**同一库**(抽测 list/create);业务 DDL 支持所选后端。纯 SQLite → **N/A**(见 `references/DATA_SOURCES.md`)
|
||||
|
||||
---
|
||||
|
||||
@@ -192,6 +194,7 @@ checklist_version: 1.0
|
||||
- [ ] A9-2 **P0** 不以「仅模拟档通过」作为交活依据(有真实档要求时必须真实跑通或书面阶段性豁免)
|
||||
- [ ] A9-3 **P0** **NEED_MANUAL** 入口定位表中定为「挂」的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过;未在定位表中规划且未声明的入口可标 N/A(须引用定位表理由),**禁止**用「未声明」掩盖「从未做入口定位」
|
||||
- [ ] A9-4 **P0** 对外版本一致(POLICY-RELEASE-001):`SKILL.md` version = `SKILL_VERSION` = 发布 git tag(无前缀);`release.ps1` 打 tag 前会硬校验,漏改不得发布;CHANGELOG 有对应小节
|
||||
- [ ] A9-4b **P1** 本机宿主验收用根目录 `.\sync.ps1` 覆盖已安装明文;市场上的最新版本和加密包仍走 `.\release.ps1`。同步后退出资料管理再打开,不要点技能市场「更新」
|
||||
- [ ] A9-5 **P1** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)
|
||||
|
||||
---
|
||||
|
||||
@@ -26,6 +26,45 @@
|
||||
|
||||
**不要**依赖 `# ── 分组 ──` 装饰线给整组做唯一说明:宿主只会把行头挂到**紧挨着的下一个活跃 key**,后面的 key 不会自动继承。分组可用空行区分;**逐 key 自带行头(+ 行尾)才是硬要求**。
|
||||
|
||||
### 必填项:`# @required`(匠厂配置管理)
|
||||
|
||||
匠厂宿主「数据管理 → 配置管理」支持把部分配置项标为**必填**:模板在**行头注释上方**加独立一行 `# @required`,宿主会在 key 前显示红色 `*`,并在键值模式保存时拦截空值。
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| **写法** | `# @required` 单独占一行,紧挨在该 key 的**中文行头**上方 |
|
||||
| **语义** | 保存时该 key 的值不得为空(trim 后);模板里可以有默认值 |
|
||||
| **展示** | `@required` 行本身**不展示**给用户,只用于打标 |
|
||||
| **声明位置** | 只在仓库 `.env.example` 声明;用户数据目录 `.env` 里的 `@required` 不算 |
|
||||
|
||||
正例:
|
||||
|
||||
```ini
|
||||
# @required
|
||||
# 目标网站地址:技能要访问的网站或服务地址
|
||||
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改
|
||||
|
||||
# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名)
|
||||
DEFAULT_LOGIN_ID= # 留空=按平台自动选用;本项未标 @required,宿主不强制
|
||||
```
|
||||
|
||||
反例(禁止):
|
||||
|
||||
```ini
|
||||
# 目标网站地址(必填)
|
||||
TARGET_BASE_URL= # @required ← 禁止写在行尾
|
||||
|
||||
# @required
|
||||
TARGET_BASE_URL= # ← 禁止:@required 下方缺少中文行头
|
||||
|
||||
# @required
|
||||
# 共用说明
|
||||
KEY_A=
|
||||
KEY_B= # ← 禁止:一个 @required 给多个 key 共用
|
||||
```
|
||||
|
||||
**机器门禁**:`POLICY-CONFIG-005` 校验 `@required` 用法;与 `POLICY-CONFIG-004`(行头/行尾中文)一并生效。
|
||||
|
||||
### 必须做到
|
||||
|
||||
1. 每个会进入配置管理的活跃 `KEY=` 都有完整中文**行头**。
|
||||
@@ -191,8 +230,25 @@ config.get_float("STEP_DELAY_MIN", 1.0)
|
||||
- `python scripts/main.py doctor`:检查浏览器、Profile/账号、`.env` 落盘、设备等。
|
||||
- `python scripts/main.py setup`:初始化演示账号、首次落盘 `.env`。
|
||||
|
||||
## MySQL 云库(可选)
|
||||
|
||||
技能若走 MySQL 云库(匠厂 Host **≥ 6.9.28**),须同时满足:
|
||||
|
||||
1. 用户数据目录有 [`data-sources.json`](../references/DATA_SOURCES.md)(声明 `kind: mysql` 与 `connectionEnv`)
|
||||
2. `.env` 配置对应 DSN,模板默认为 `MYSQL_DSN`
|
||||
|
||||
```env
|
||||
# MySQL 云库连接(留空=本地 SQLite)
|
||||
MYSQL_DSN=mysql://user:password@host:3306/database_name
|
||||
```
|
||||
|
||||
**不要**把 DSN 写进 `data-sources.json`。技能 CLI 通过 `scripts/db/backend.py` 读取同一 key,与宿主 UI 保持一致。
|
||||
|
||||
完整契约见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md)。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `DATA_SOURCES.md` — 多数据源声明(SQLite 默认 / MySQL opt-in)
|
||||
- `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释)
|
||||
- `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收
|
||||
- `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释)
|
||||
|
||||
@@ -52,8 +52,9 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` |
|
||||
| `rpa-artifacts/` | RPA 失败截图、录屏中间产物 | `get_rpa_artifacts_dir(batch_id)` |
|
||||
| `videos/` | RPA 录屏成片 MP4 | `get_videos_dir()` |
|
||||
| `{skill_slug}.db` | SQLite | `get_db_path()` |
|
||||
| `.env` | 用户配置 | `config.get_env_file_path()` |
|
||||
| `{skill_slug}.db` | SQLite(默认本地库) | `get_db_path()` |
|
||||
| `data-sources.json` | 声明式数据源(可选;不配则宿主自动扫描 `.db`) | 用户目录根;格式见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md) |
|
||||
| `.env` | 用户配置(含可选 `MYSQL_DSN`) | `config.get_env_file_path()` |
|
||||
|
||||
复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ your-skill/
|
||||
├─ CHANGELOG.md
|
||||
├─ requirements.txt
|
||||
├─ release.ps1
|
||||
├─ sync.ps1
|
||||
└─ SKILL.md
|
||||
```
|
||||
|
||||
@@ -85,6 +86,8 @@ your-skill/
|
||||
- `DEMO.md`:市场 **演示** Tab(`demo_video_url` + `demo_md`)
|
||||
- `CHANGELOG.md`:市场 **更新日志** Tab(按版本小节同步 `changelog`)
|
||||
- `SKILL.md`:面向 LLM / 平台的技能入口
|
||||
- `release.ps1`:发布到服务器(提交、tag、CI 加密包)
|
||||
- `sync.ps1`:把明文同步到本机宿主,供界面验收
|
||||
- `references/`:Agent 运行/编排/调用时渐进加载的补充上下文(如 CLI、SCHEMA)
|
||||
- `development/`:面向开发者与 AI 编程代理的开发资料、需求、测试与技术规范
|
||||
- `scripts/`:放真正的代码
|
||||
@@ -115,8 +118,8 @@ scripts/
|
||||
不写具体业务逻辑
|
||||
|
||||
- `db/`
|
||||
作用:SQLite 连接、建表、repository
|
||||
不写页面操作和业务编排
|
||||
作用:数据库连接(默认 SQLite;配 `MYSQL_DSN` 时为 MySQL)、建表、repository
|
||||
不写页面操作和业务编排;多数据源契约见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md)
|
||||
|
||||
- `service/`
|
||||
作用:核心业务逻辑
|
||||
@@ -173,6 +176,7 @@ scripts/
|
||||
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.11`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
|
||||
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。
|
||||
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
|
||||
- 根目录 `sync.ps1` 是开发者手动执行的本机同步,允许用 `uv pip install` 把本技能 `requirements.txt` 装进宿主共享 venv。业务代码和其它脚本仍然不要自己装包。
|
||||
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
|
||||
- `pkg>=1.2.0,<2`
|
||||
- 对**原生扩展 / 高风险依赖**(如 `chromadb`、`onnxruntime`)建议 pin 或窄范围,避免无上限:
|
||||
@@ -747,6 +751,33 @@ python scripts/main.py <your-command>
|
||||
|
||||
详见 `TESTING.md`。
|
||||
|
||||
## 14.6 同步到本机宿主
|
||||
|
||||
`sync.ps1` 和 `release.ps1` 并排,都在技能根目录执行。`release.ps1` 发布到服务器并触发加密打包;`sync.ps1` 把当前明文覆盖到本机已安装的技能目录,用来在宿主里验收,不必每次都走服务器构建。
|
||||
|
||||
在技能根目录:
|
||||
|
||||
```powershell
|
||||
.\sync.ps1
|
||||
.\sync.ps1 -DryRun
|
||||
.\sync.ps1 -SkipDeps
|
||||
.\sync.ps1 -ForceDeps
|
||||
.\sync.ps1 -SkipInitDb
|
||||
```
|
||||
|
||||
脚本会:
|
||||
|
||||
- 按 `SKILL.md` 的 slug 镜像到 `%USERPROFILE%\.openclaw\skills\<slug>`,排除 `.git` 等开发目录
|
||||
- 去掉安装目录里残留的 `pyarmor_runtime_*`
|
||||
- 把 `requirements.txt` 装进宿主共享 venv(与宿主安装技能同一条路径)。依赖未变时可跳过;`-SkipDeps` 只跳过安装,仍会跑 `init-db`
|
||||
- 用宿主 `settings.json` 里的当前登录用户跑 `scripts\main.py init-db`。没有登录用户时停止,不会猜用户 ID
|
||||
|
||||
同步完成后:先停掉该技能正在跑的任务,退出资料管理再打开。不要点技能市场的「更新」,那会用加密包装掉这份明文。
|
||||
|
||||
技能至少要从市场安装过一次,宿主才会启用它。`sync.ps1` 覆盖的是已经装上的那一份。市场上的「最新版本」仍只在 `release.ps1` 的 CI 成功后变化;本机「当前版本」来自这次同步写进去的 `SKILL.md`。
|
||||
|
||||
`sync.ps1` 须为 UTF-8 无 BOM,且不要写中文:Windows PowerShell 5.1 会把无 BOM 的中文脚本解析错。
|
||||
|
||||
## 15. 发布到正式环境验证
|
||||
|
||||
### release workflow 通道
|
||||
@@ -936,6 +967,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
- [ ] 没有残留旧平台名
|
||||
- [ ] `health` / `version` 可运行
|
||||
- [ ] `.env.example` 注释已按 [`CONFIG.md`](CONFIG.md)「行头 = 是什么、行尾 = 怎么填」写全(面向配置管理用户;无 `见 development/...` 等开发文档指向)
|
||||
- [ ] 业务必填项已在 `.env.example` 用独立一行 `# @required` 标记(见 [`CONFIG.md`](CONFIG.md) §必填项);未标则宿主不显示红色 `*`
|
||||
- [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去
|
||||
- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag)
|
||||
- [ ] `.github/workflows/release_skill.yaml` 存在
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
| POLICY-RUNTIME-002 | `requirements.txt` 非注释依赖行不得声明 `jiangchang-platform-kit` / `playwright` | development/RUNTIME.md §共享 Python Runtime;development/DEVELOPMENT.md §3.1 requirements.txt 依赖规范 | hard | 解析依赖行 | `tests/test_development_policy_guard.py::TestPolicyRuntime002`(另见 `tests/test_platform_import.py`) |
|
||||
| POLICY-RUNTIME-003 | Agent/CLI 文档须要求共享 Python(python-runtime / PATH 上的 `python` + `{baseDir}`);须明确**禁止**对技能脚本使用 `uv run python` | development/RUNTIME.md;references/CLI.md;SKILL.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_cli_md_requires_shared_python_not_uv_run`;`tests/test_docs_standards.py::test_skill_md_requires_shared_python_not_uv_run` |
|
||||
| POLICY-AGENT-001 | `SKILL.md` 须含「用户意图路由表」结构;`CLI.md` 须含短 CLI 白名单 / Action 强制黑名单表;用法回答须禁止无故粘贴 CLI、禁止为答用法列目录挖 references | SKILL.md;references/CLI.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_skill_md_has_intent_routing_table`;`tests/test_docs_standards.py::test_cli_md_has_agent_routing_tables`;`tests/test_docs_standards.py::test_skill_md_how_to_forbids_cli_paste` |
|
||||
| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`) | `tests/test_development_policy_guard.py::TestPolicyInstall001` |
|
||||
| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`;根目录 `sync.ps1` 除外,它是开发者手动执行的本机同步,用 `uv pip install` 装进宿主共享 venv) | `tests/test_development_policy_guard.py::TestPolicyInstall001` |
|
||||
| POLICY-CONFIG-001 | 仓库根目录必须存在 `.env.example` | development/CONFIG.md §核心原则、§标准配置项 | hard | 文件存在性 | `tests/test_development_policy_guard.py::TestPolicyConfig001` |
|
||||
| POLICY-CONFIG-002 | `.gitignore` 必须忽略 `.env` 与 `*.env.local` | development/CONFIG.md §红线:敏感信息不进 .env | hard | 解析 `.gitignore` 行 | `tests/test_development_policy_guard.py::TestPolicyConfig002` |
|
||||
| POLICY-CONFIG-003 | 业务代码不得直接 `os.environ` / `os.getenv` / `os.putenv` / `environ.get`;须经 `jiangchang_skill_core.config.get*`(允许 `main.py`、测试、config_bootstrap/runtime 边界文件) | development/CONFIG.md §配置 bootstrap;development/ADAPTER.md §档位 dispatch | hard | 扫描 `scripts/**/*.py` 并排除边界文件 | `tests/test_development_policy_guard.py::TestPolicyConfig003` |
|
||||
| POLICY-CONFIG-004 | `.env.example` 每个活跃 `KEY=` 必须有中文**行头**(是什么)与中文**行尾**(怎么填);注释禁止 `development/`、`*.md` 文档指向、`account-manager`、`adapter 档位` 等开发黑话 | development/CONFIG.md §.env.example 注释 = 配置管理用户文案 | hard | 解析 `.env.example` 活跃行与注释 | `tests/test_development_policy_guard.py::TestPolicyConfig004` |
|
||||
| POLICY-CONFIG-005 | 若 `.env.example` 使用 `# @required`,须单独占一行且在下一行中文行头之上;禁止写在行尾;禁止无中文行头或多 key 共用 | development/CONFIG.md §必填项:# @required | hard | 解析 `.env.example` 活跃行与 `@required` 标记 | `tests/test_development_policy_guard.py::TestPolicyConfig005` |
|
||||
| POLICY-TESTING-001 | 默认根目录 `tests/test_*.py` 不得硬编码 `real_api` / `real_rpa` / `OPENCLAW_TEST_TARGET`(白名单仅 `test_adapter_profile_policy.py`,用于档位 helper 自测) | development/TESTING.md §默认必跑测试要做什么、§4 测试目标档位 | hard | 扫描 `tests/test_*.py` 非注释行;白名单外禁止上述文本 | `tests/test_development_policy_guard.py::TestPolicyTesting001` |
|
||||
| POLICY-TESTING-002 | `tests/integration/` 下 Python 测试文件若存在,默认应为 `.sample`,不得进入默认套件 | development/TESTING.md §7 真实联调测试的安全约束、§8 新 skill 的最小测试清单 | hard | 检查 `tests/integration/*.py` 无 `.sample` 后缀 | `tests/test_development_policy_guard.py::TestPolicyTesting002` |
|
||||
| POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖;development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py`) |
|
||||
@@ -22,6 +23,7 @@
|
||||
| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器;实现上应走 ensure-web);登录不全局强制,但不得跳过 account-manager | development/RPA.md §0.2;development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` |
|
||||
| POLICY-RPA-005 | 必须保留 AI + RPA 总纲,明确 Agent / RPA 职责、授权范围、禁止破解/逆向/私有协议/安全验证绕过、人工介入、状态等待;真实业务默认动作节奏为 1~3 秒 | development/AI_RPA.md §4–§6;development/RPA.md §0.0/§0.3 | hard(文档与默认配置) / soft(真实行为) | 检查总纲关键边界;解析 `.env.example` 的 `STEP_DELAY_MIN=1.0` / `STEP_DELAY_MAX=3.0` | `tests/test_development_policy_guard.py::TestPolicyRpa005` |
|
||||
| POLICY-RPA-006 | 浏览器 RPA 必须保留 account-manager 生命周期标准;所有样例账号选择路径都须返回非空 `profile_dir + lease_token`,指定数字账号 ID 不得退化为无租约 `account get` | development/ACCOUNT_MANAGER.md;development/ADAPTER.md §兄弟依赖;development/RPA.md §0.2 | hard | 检查标准文档关键标记;扫描真实/仿真 `account_client.py` 的指定 ID 带租约实现并禁止空租约赋值 | `tests/test_development_policy_guard.py::TestPolicyRpa006` |
|
||||
| POLICY-RPA-007 | 网页 RPA 启动须 `no_viewport=True` + `--start-maximized`;禁止 `viewport=None`、CDP/JS 中途改窗;`RPA.md` §1.2.1 须保留窗口标准 | development/RPA.md §1.2.1;development/RPA_BROWSER.md §4;examples/real_browser_rpa | hard | 文档标记 + 扫描含 `launch_persistent_*` 的 Python 禁止模式;校验 real_browser 示例 | `tests/test_development_policy_guard.py::TestPolicyRpa007` |
|
||||
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
|
||||
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
|
||||
| POLICY-RELEASE-001 | 发布 tag(去掉前缀)必须等于 `SKILL.md` 的 `version:` 且等于 `scripts/util/constants.py` 的 `SKILL_VERSION`;`release.ps1` 打 tag 前硬失败,不自动改写字段 | development/DEVELOPMENT.md §15 发布;宿主市场「最新版本」来自 tag、「当前版本」来自包内 `SKILL.md` | hard | `release.ps1` 门禁 + 日常版本字段对齐测试 | `release.ps1`(`Assert-SkillReleaseVersionAlignment`);`tests/test_development_policy_guard.py::TestPolicyRelease001`;`tests/test_skill_metadata.py::test_skill_version_alignment` |
|
||||
|
||||
@@ -189,18 +189,35 @@ BrowserType.launch_persistent_context: Arguments can not specify page to be open
|
||||
|
||||
**正确做法(生产路径):**
|
||||
|
||||
1. `launch_persistent_context(user_data_dir=profile_dir, executable_path=chrome, args=chrome_args, ...)`
|
||||
1. `launch_persistent_context(user_data_dir=profile_dir, executable_path=chrome, args=chrome_args, locale="zh-CN", no_viewport=True, ...)`
|
||||
2. `page = await context.new_page()`
|
||||
3. `await page.goto(start_url, wait_until="domcontentloaded", timeout=60000)`
|
||||
|
||||
对强风控站点,如果需要更拟人,可以启动后通过**地址栏输入 / 真实点击进入**目标页,但**仍然不能把 URL 塞进 launch args**。
|
||||
|
||||
#### 1.2.1 窗口与 viewport(`POLICY-RPA-007`)
|
||||
|
||||
persistent profile 会记住上次窗口大小;**最大化与 layout 同步**须按下列标准,避免「先小后大、右侧留白、scrollbar 后遗症」:
|
||||
|
||||
| 必须 | 禁止 |
|
||||
|------|------|
|
||||
| `no_viewport=True`(页面 layout 跟随真实窗口) | `viewport=None` 或固定 `viewport={width,height}` 冒充全屏 |
|
||||
| launch args 含 `--start-maximized` | CDP `Browser.setWindowBounds` / JS `resizeTo` **中途改窗** |
|
||||
| 真实站:`browser_session` 内 **启动后尽快 `goto` 业务页** | 长时间停空白页再导航、goto 后再 CDP 最大化 |
|
||||
| 优先复制 `examples/real_browser_rpa/scripts/service/browser_session.py` | 自造 maximize / CDP 窗口 hack |
|
||||
|
||||
说明:
|
||||
|
||||
- **`no_viewport=True` ≠ `viewport=None`**。后者在 Playwright 中不等于「跟窗口走」,易导致 OS 窗口已最大化但页面仍按窄 viewport 排版。
|
||||
- **仿真站**(`examples/simulator_browser_rpa`)可在 `browser_session` 只做 launch、由上层 `goto`;仍须 **`no_viewport=True`**。
|
||||
- 共享库 `jiangchang_skill_core.rpa.launch_persistent_browser` 已内置 `no_viewport=True`;自写 `browser_session` 时勿漏。
|
||||
|
||||
完整参考实现见:
|
||||
|
||||
- `examples/real_browser_rpa/README.md`(**开发真实浏览器 RPA 类 skill 时必须先读**)
|
||||
- `examples/real_browser_rpa/scripts/service/browser_session.py`
|
||||
|
||||
**生产路径不要依赖 CDP 接管现有页面**;CDP 仅用于诊断或桌面宿主已打开浏览器的场景。
|
||||
**生产路径不要依赖 CDP 接管现有页面**;CDP 仅用于诊断或桌面宿主已打开浏览器的场景(**含禁止用 CDP 最大化窗口**)。
|
||||
|
||||
### 1.3 HITL / 验证码
|
||||
|
||||
|
||||
@@ -90,6 +90,19 @@ button = row.get_by_role("button", name="添加")
|
||||
|
||||
Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。closed Shadow DOM 无法正常读取时,评估目标应用是否提供可访问性属性,不能注入破解。
|
||||
|
||||
**Web Component 双 input 陷阱**(常见于 `<mc-input>` 等宿主):
|
||||
|
||||
- 轻 DOM 内可能有 **hidden 同步用 input**(`aria-hidden="true"`、`tabindex="-1"`)。
|
||||
- 裸写 `host >> input` 时 Playwright 常默认匹配**第一个** hidden input,等 `visible` 会超时。
|
||||
- 做法:等 **host 可见**作 page ready;输入用 shadow 内真实字段(如 `>> #mc-input-track-input` 或 `>> input.input:not([aria-hidden="true"])`);或 click host + keyboard。
|
||||
|
||||
### Cookie / 同意条(可选出现)
|
||||
|
||||
Profile 已点过「允许全部」后,下次可能**不再出现** Cookie 条。不要无限等待:
|
||||
|
||||
- 短超时(约 2~3s)探测可见 → 点击;
|
||||
- 超时或无元素 → **skip 继续**,不要阻塞主流程。
|
||||
|
||||
### 动态列表
|
||||
|
||||
等待列表完成加载后再读取;`locator.all()` 不会等待动态列表稳定。滚动或刷新后重新获取 locator。
|
||||
@@ -109,6 +122,7 @@ Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。close
|
||||
## 6. 禁止模式
|
||||
|
||||
- 用全页所有 `input` 的尺寸和位置猜搜索框。
|
||||
- 对 Web Component 裸用 `host >> input` 且不等 shadow 内真实可编辑 input。
|
||||
- 用 `.first` 绕过严格模式。
|
||||
- 从 DevTools 复制深层 `nth-child` 或绝对 XPath。
|
||||
- 只等 URL 含 `search` 就判定结果成功。
|
||||
|
||||
@@ -91,6 +91,7 @@ real_browser_rpa/
|
||||
- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供
|
||||
- `launch_persistent_context` 的 `args` **只放 Chrome 参数**;**不要把 URL 放进 `args`**
|
||||
- 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入
|
||||
- **必须** `no_viewport=True` + `--start-maximized`;**禁止** `viewport=None` 与 CDP/JS 中途改窗(见 `development/RPA.md` §1.2.1、`POLICY-RPA-007`)
|
||||
|
||||
## 复制边界:哪些能复制,哪些必须替换
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ async def start_browser_session(
|
||||
"""
|
||||
启动持久化 Chrome profile,new_page + goto 入口页,返回 (playwright, context, page)。
|
||||
|
||||
窗口标准(POLICY-RPA-007):no_viewport=True + --start-maximized;勿用固定 viewport 或 CDP 改窗。
|
||||
真实 skill 需要安装 playwright 与 jiangchang_skill_core。
|
||||
"""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
@@ -84,7 +84,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
| 字段 / 层 | 只决定什么 | 不决定什么 |
|
||||
|-----------|------------|------------|
|
||||
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 |
|
||||
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写 SQLite;**import 时 UI 仍用宿主「导入数据」面板** |
|
||||
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写**已注册数据源**(默认本地 SQLite);**import 时 UI 仍用宿主「导入数据」面板** |
|
||||
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
|
||||
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
|
||||
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
|
||||
@@ -231,7 +231,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
|
||||
## `replaces`(可选:接管默认新增 / 编辑 / 删除 / 导入)
|
||||
|
||||
有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。
|
||||
有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写默认 CRUD。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。
|
||||
|
||||
### 怎么选
|
||||
|
||||
@@ -250,7 +250,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
2. **写库**按表能力分流:
|
||||
- 有 `replaces: import` → 把选中文件路径 + 出错规则交给该 Action(技能解析并写入)
|
||||
- 仅有 `replaces: create` → 宿主按行调用 create(兼容)
|
||||
- 都没有 → 宿主直写 SQLite
|
||||
- 都没有 → 宿主直写已注册数据源(默认 SQLite)
|
||||
|
||||
**不要**再做一张技能自己的「开始执行」导入表单来冒充导入体验。`placements` 对 import 保持 `[]`;`description` 写明「由宿主导入面板调用」。
|
||||
|
||||
@@ -992,6 +992,8 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
|
||||
| 枚举下拉 | `enum: [...]` | `options_json` 数组 |
|
||||
| 关联下拉(Lookup) | 内联 `display_type: lookup` + `lookup`(可选 `multiple`),或绑列 | 同左;多选存 `TEXT` 逗号分隔 id |
|
||||
| 密码 | `sensitive: true` | 列 `sensitive` |
|
||||
| 年份 | `display_type` 或 `format`: `year` | `display_type: year` → 存 `YYYY` |
|
||||
| 年月 | `display_type` 或 `format`: `year_month`(别名 `month`) | `display_type: year_month` → 存 `YYYY-MM` |
|
||||
| 日期(日) | `display_type` 或 `format`: `date` | `display_type: date` → 存 `YYYY-MM-DD` |
|
||||
| 日期时间 | `display_type` 或 `format`: `datetime_unix_seconds` | 同左;**存/传 Unix 秒** |
|
||||
| URL(薄层) | `display_type` 或 `format`: `url` | `display_type: url` |
|
||||
@@ -1012,8 +1014,10 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
|
||||
日期时间约定:
|
||||
|
||||
- Action 提交与 CRUD 写库统一用 Unix **秒**整数(与 SCHEMA 标准时间字段一致)
|
||||
- `year` 只表示公历年份,值为 `YYYY`(如 `2026`)
|
||||
- `year_month` 只表示年月,值为 `YYYY-MM`(如 `2026-05`);**不要**用 `date` 表示账期/月份
|
||||
- `date` 只表示日历日,值为 `YYYY-MM-DD`
|
||||
- 脏/无法归一的旧预设:日期控件可回退为普通文本,避免卡死表单
|
||||
- 脏/无法归一的旧预设:日期/年月控件可回退为普通文本,避免卡死表单
|
||||
|
||||
### 本地路径选择与 `pathRoot`
|
||||
|
||||
@@ -1074,11 +1078,13 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
|
||||
|
||||
形状参考:[`assets/examples/path-root-file-action.example.json`](../assets/examples/path-root-file-action.example.json)。
|
||||
|
||||
账期/月份(`YYYY-MM`)字段形状参考:[`assets/examples/year-month-action.example.json`](../assets/examples/year-month-action.example.json)。
|
||||
|
||||
### 当前宿主稳定支持(摘要)
|
||||
|
||||
- 根:`object`;属性:`string` / `number` / `boolean`(`object`/`array` 仅作 JSON 多行编辑兼容,新技能慎用)
|
||||
- `enum` / `default` / `required` / `sensitive` / `readOnly` / 可选 `revealInForm`
|
||||
- `display_type` / `format`:`textarea` | `date` | `datetime_unix_seconds` | `url` | `email` | **`lookup`** | **`file`** | **`directory`**
|
||||
- `display_type` / `format`:`textarea` | **`year`** | **`year_month`** / `month` | `date` | `datetime_unix_seconds` | `url` | `email` | **`lookup`** | **`file`** | **`directory`**
|
||||
- 可选 **`pathRoot`**(仅 `file` / `directory`):相对技能数据根的起始目录;省略默认 `{skill_data}/input`
|
||||
- 字段内联 `lookup`(与 CRUD `options_json` lookup 对象同构;可选 `extra_column`、`multiple`)
|
||||
- 属性级 `description`(表单标签下方灰字说明)
|
||||
@@ -1156,6 +1162,12 @@ CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展
|
||||
"description": "最多返回多少条;填 0 表示不限制(若业务支持)",
|
||||
"default": 50
|
||||
},
|
||||
"period": {
|
||||
"type": "string",
|
||||
"title": "账期月份",
|
||||
"display_type": "year_month",
|
||||
"description": "格式 YYYY-MM,例如 2026-05"
|
||||
},
|
||||
"dueDate": {
|
||||
"type": "string",
|
||||
"title": "截止日期",
|
||||
@@ -1201,6 +1213,7 @@ CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展
|
||||
- `boolean` 在宿主中渲染为开关
|
||||
- `enum` 在宿主中渲染为选择框
|
||||
- `type: number` 渲染为数字输入
|
||||
- `display_type`/`format: year` 渲染为年份选择;`year_month` / `month` 渲染为年月选择
|
||||
- `display_type`/`format: date` 渲染为日期(日);`datetime_unix_seconds` 渲染为日期时间并按 **Unix 秒**提交
|
||||
- `display_type`/`format: url` / `email` 为薄层输入类型(校验从宽,勿当强校验网关)
|
||||
- `required` 字段在提交前校验
|
||||
|
||||
@@ -44,7 +44,7 @@ python {baseDir}/scripts/main.py init-db
|
||||
- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets,不执行业务动作**;不输出敏感值。
|
||||
- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。
|
||||
- **`version`**:输出 JSON(`version`、`skill`)。
|
||||
- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
|
||||
- **`init-db`**:幂等创建/迁移当前 active 后端(默认本地 SQLite;配 `MYSQL_DSN` 时为 MySQL 云库,见 `references/DATA_SOURCES.md`),含 `_jiangchang_*` 展示元数据。成功输出一行 JSON(`ok` / `skill` / `db_path` 或等价字段),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
|
||||
- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。
|
||||
- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。
|
||||
- **排查顺序**(由快到慢):
|
||||
|
||||
139
references/DATA_SOURCES.md
Normal file
139
references/DATA_SOURCES.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 多数据源声明(SQLite / MySQL 云库)
|
||||
|
||||
本文说明技能如何向匠厂宿主**声明**业务数据库,以及技能 CLI / Action 如何与宿主读写**同一物理库**。
|
||||
|
||||
**产品定位:** 默认仍是本地 SQLite(`{skill_slug}.db`)。需要多用户共享、集中运维或云库时,可显式声明 **MySQL**。UI / API 契约不变;`_jiangchang_*` 展示元数据仍写在同一逻辑库中。
|
||||
|
||||
**宿主版本:** 匠厂 Host **≥ 6.9.28** 才支持 `data-sources.json` 与 MySQL 连接器。
|
||||
|
||||
**完整参考实现:** [`process-work-order`](https://git.jc2009.com/client-commons/process-work-order) @ v1.0.2(工单管理 + MySQL 云库试点)。
|
||||
|
||||
---
|
||||
|
||||
## 何时用哪种后端
|
||||
|
||||
| 场景 | 推荐 |
|
||||
|------|------|
|
||||
| 单机、离线、小数据量、便携部署 | **SQLite**(默认,不配声明文件亦可) |
|
||||
| 多用户共享、运维集中管库、与外部系统共库 | **MySQL 云库**(显式 opt-in) |
|
||||
|
||||
**硬规则:一个 `sourceId` 对应一个物理库。** 禁止双写、禁止 SQLite 与 MySQL 同步镜像。
|
||||
|
||||
---
|
||||
|
||||
## 文件位置
|
||||
|
||||
均在**用户数据目录**(不是安装包目录):
|
||||
|
||||
```
|
||||
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/
|
||||
├── data-sources.json # 声明式数据源(可选;见下)
|
||||
├── .env # MYSQL_DSN 等连接配置
|
||||
└── {skill_slug}.db # SQLite 模式下的本地库文件
|
||||
```
|
||||
|
||||
安装/升级时,若用户目录尚无 `data-sources.json` 且技能包内有 `assets/data-sources.example.json`,宿主会**复制一次**示例(不覆盖已有文件)。
|
||||
|
||||
模板默认示例为 **SQLite 显式声明**(与自动扫描 `{skill_slug}.db` 等价)。改走 MySQL 时,请参照 `assets/examples/data-sources.mysql.example.json` 手工替换用户目录中的 `data-sources.json`,并在 `.env` 配置 DSN。
|
||||
|
||||
若不需要声明式接入,可删除用户目录中的 `data-sources.json`,宿主恢复自动扫描 `.db` 文件。
|
||||
|
||||
---
|
||||
|
||||
## `data-sources.json` 格式
|
||||
|
||||
根对象:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `version` | `1` | 固定为 1 |
|
||||
| `sources` | 数组 | 至少一条数据源 |
|
||||
|
||||
每条 `sources[]`:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | 是 | 英文标识,如 `primary` |
|
||||
| `kind` | 是 | `sqlite` 或 `mysql` |
|
||||
| `displayName` | 否 | 侧栏展示名;多库时建议填写 |
|
||||
| `path` | sqlite 必填 | 相对技能数据目录的路径;可用 `{skillSlug}` 占位 |
|
||||
| `connectionEnv` | mysql 必填 | `.env` 中 DSN 的环境变量名,如 `MYSQL_DSN` |
|
||||
|
||||
### SQLite 示例(模板默认)
|
||||
|
||||
见 [`../assets/data-sources.example.json`](../assets/data-sources.example.json)。
|
||||
|
||||
### MySQL 示例
|
||||
|
||||
见 [`../assets/examples/data-sources.mysql.example.json`](../assets/examples/data-sources.mysql.example.json)。
|
||||
|
||||
---
|
||||
|
||||
## `.env` 与 DSN
|
||||
|
||||
MySQL 连接串写在用户 `.env`(或进程环境变量),**不要**写进 `data-sources.json`:
|
||||
|
||||
```env
|
||||
# MySQL 云库连接(不配则走本地 SQLite)
|
||||
MYSQL_DSN=mysql://user:password@192.168.0.20:3306/your_database
|
||||
```
|
||||
|
||||
格式:`mysql://` 或 `mysql2://`,须含库名。注释写法须符合 [`../development/CONFIG.md`](../development/CONFIG.md)(配置管理会展示给用户)。
|
||||
|
||||
技能 Python 侧:`scripts/db/backend.py` 读取同一 `MYSQL_DSN`;有值则 `resolve_backend()` 为 `mysql`,否则 `sqlite`。
|
||||
|
||||
---
|
||||
|
||||
## 技能代码职责
|
||||
|
||||
### 宿主 UI(数据管理)
|
||||
|
||||
宿主读取用户目录 `data-sources.json` + `.env`,经 Host 内置连接器访问库。**不依赖**技能 `connection.py`。
|
||||
|
||||
### CLI / Action / `init-db`
|
||||
|
||||
**必须**与宿主使用同一后端策略,否则会出现「UI 看云库、CLI 写本地 `.db`」的分裂。
|
||||
|
||||
模板已提供:
|
||||
|
||||
| 模块 | 作用 |
|
||||
|------|------|
|
||||
| `scripts/db/backend.py` | `resolve_backend()`、`parse_mysql_dsn()` |
|
||||
| `scripts/db/cursor_compat.py` | 统一 `?` 占位符与 `executescript` |
|
||||
| `scripts/db/connection.py` | `get_conn()` 双分支;`init_db()` 幂等建 `_jiangchang_*` |
|
||||
|
||||
业务表 DDL / 迁移仍在技能自己的 `schema` / `init_db()` 中按 `conn.backend` 分支处理(见工单参考实现)。
|
||||
|
||||
可选:`describe_active_store()` 用于 health / 排障,输出当前连的是 sqlite 还是 mysql。
|
||||
|
||||
---
|
||||
|
||||
## MySQL 当前限制(Host)
|
||||
|
||||
- 不支持 `tableAdmin`(建表/改表 UI)
|
||||
- 不支持 `cellPresentation`
|
||||
- 元数据与 CRUD 与 SQLite 路径一致
|
||||
|
||||
表结构变更仍由技能 `init-db` / 迁移脚本负责。
|
||||
|
||||
---
|
||||
|
||||
## 开发 Checklist(云库)
|
||||
|
||||
复制新业务技能并选择 MySQL 时:
|
||||
|
||||
1. Host ≥ 6.9.28
|
||||
2. 用户目录放置 `data-sources.json`(mysql 源 + `connectionEnv`)
|
||||
3. `.env` 配置 `MYSQL_DSN`(或你在 json 里声明的 env 名)
|
||||
4. `requirements.txt` 含 `PyMySQL>=1.1.0`(模板已声明)
|
||||
5. 业务 DDL 同时支持 sqlite / mysql 分支(或仅部署 mysql)
|
||||
6. **抽测**:数据管理 UI 与 CLI(如 list / create)读写同一库
|
||||
7. 见 [`../development/CHECKLIST.md`](../development/CHECKLIST.md) A6-16
|
||||
|
||||
---
|
||||
|
||||
## 与 SCHEMA / ACTIONS 的关系
|
||||
|
||||
- [`SCHEMA.md`](SCHEMA.md):`_jiangchang_*` 契约适用于**已注册数据源**上的表,不限于本地文件。
|
||||
- [`ACTIONS.md`](ACTIONS.md):无 `replaces` 时宿主直写**已注册数据源**(默认本地 SQLite)。
|
||||
- [`CLI.md`](CLI.md):`init-db` 对当前 active backend 幂等初始化元数据。
|
||||
@@ -18,10 +18,11 @@
|
||||
| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口与触发摘要 |
|
||||
| [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 |
|
||||
| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口、按钮中文名 `label` 参考(按入口定位表挂载;先读 `development/PRODUCT_SURFACES.md`) |
|
||||
| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 |
|
||||
| [`SCHEMA.md`](SCHEMA.md) | 业务表与数据管理展示契约(SQLite / MySQL) |
|
||||
| [`DATA_SOURCES.md`](DATA_SOURCES.md) | 多数据源声明:SQLite 默认、可选 MySQL 云库(Host ≥ 6.9.28) |
|
||||
| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) |
|
||||
| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理呈现:格子 / 整行 / 整列 / 区域语义 tone(用途不限;多数技能 P0 不配) |
|
||||
| [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md) | 业务行生命周期:处理队列的可操作/进行中/终态角色与重做规则(有处理行 Action 时适用) |
|
||||
| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) |
|
||||
|
||||
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子/行/列标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。
|
||||
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;云库 / 声明式数据源读 `DATA_SOURCES.md`;权限 opt-in 读 `POLICY.md`;格子/行/列标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 数据存储与数据管理展示规范
|
||||
|
||||
本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。
|
||||
本文是 skill **已注册数据源**(默认本地 SQLite 文件;可选 MySQL 云库,见 [`DATA_SOURCES.md`](DATA_SOURCES.md))与匠厂宿主「数据管理」页面展示契约的**权威说明**。
|
||||
|
||||
**产品定位:** 数据管理既是业务数据与过程的可见面,也是与对话**平级**的 **UI 执行面**(表上 Action 走同一 CLI 闭环)。何时挂执行入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md);本文管表结构与展示元数据。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
### 技能负责
|
||||
|
||||
- 定义真实 SQLite 表结构
|
||||
- 定义真实业务表结构(SQLite 或 MySQL,按技能所选后端)
|
||||
- 使用英文 `snake_case` 表名和字段名
|
||||
- 使用 `_jiangchang_tables` 提供表级展示元数据
|
||||
- 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 表单说明、可选 `grid_help` 表格说明)
|
||||
@@ -339,6 +339,8 @@ upsert_column_metadata(
|
||||
| (枚举) | 任意 + `options_json` **数组** | 标签映射 | 下拉选择 | 选项 `value` |
|
||||
| `lookup` | 跨表引用 id;`options_json` **对象**;可选 `multiple` | 单选:标签映射;多选:多标签(如「豆包、元宝」) | 单选下拉;多选见下文 | 单选:业务 id 标量;多选:`TEXT` 逗号分隔 id(非 SQLite 外键) |
|
||||
| (敏感) | 任意;列 `sensitive=1` | 掩码/隐藏策略由宿主定 | 新建可用密码框;编辑策略与既有宿主一致 | 原文(勿日志明文) |
|
||||
| `year` | `TEXT` 推荐 `YYYY` | 原文 | **年份选择** | `YYYY` 字符串 |
|
||||
| `year_month` | `TEXT` 推荐 `YYYY-MM` | 原文 | **年月选择** | `YYYY-MM` 字符串;别名 `month` |
|
||||
| `date` | `TEXT` 推荐 `YYYY-MM-DD`,或可安全归一的值 | 日期 | **日期选择**(日) | `YYYY-MM-DD` |
|
||||
| `datetime_unix_seconds` | `INTEGER` | `YYYY-MM-DD HH:mm:ss` | **日期时间选择**;提交写回 **Unix 秒** | Unix **秒**整数;禁止改成 ISO 字符串 |
|
||||
| `url` | `TEXT` | 原文(可作链接) | 单行输入 `type=url`(薄层;校验从宽) | 原文 |
|
||||
@@ -438,7 +440,7 @@ Action 参数表单的 `title` / `description` / `default` / `display_type` 见
|
||||
| `boolean` | 下拉(有 `options_json` 用其 label;否则宿主默认可选「是/否」) | 等于 / 不等于 / 为空 | `display_type: boolean`;**推荐同时写 `options_json`** 指定中文 label |
|
||||
| `lookup` 单选 | 下拉 | 等于 / 不等于 / 为空 | `display_type: lookup` + lookup 对象 |
|
||||
| `lookup` + `multiple: true` | 列筛选 UI 演进中;**全局搜索**已支持 | 等于时按逗号分隔 id 的 token 匹配 | 多选存 `TEXT` 逗号分隔 id |
|
||||
| `date` / `datetime_unix_seconds` | 日期 / 日期时间 | 比较类 | 时间列通常 `searchable: 0` |
|
||||
| `year` / `year_month` / `date` / `datetime_unix_seconds` | 年 / 年月 / 日期 / 日期时间 | 比较类 | 账期/月份列推荐 `year_month`;时间列通常 `searchable: 0` |
|
||||
| 敏感列 | **不可筛** | — | 宿主强制排除 |
|
||||
|
||||
**全局搜索扩展(用户可搜中文)**
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。
|
||||
PyMySQL>=1.1.0
|
||||
|
||||
35
scripts/db/backend.py
Normal file
35
scripts/db/backend.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""数据库后端选择:配置了 MYSQL_DSN 时走 MySQL,否则回退本地 SQLite。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
DbBackend = Literal["sqlite", "mysql"]
|
||||
|
||||
|
||||
def get_mysql_dsn() -> str:
|
||||
return (config.get("MYSQL_DSN") or "").strip()
|
||||
|
||||
|
||||
def resolve_backend() -> DbBackend:
|
||||
return "mysql" if get_mysql_dsn() else "sqlite"
|
||||
|
||||
|
||||
def parse_mysql_dsn(dsn: str) -> dict[str, object]:
|
||||
text = dsn.strip()
|
||||
if not text.startswith(("mysql://", "mysql2://")):
|
||||
raise ValueError("MYSQL_DSN must be a mysql:// URL.")
|
||||
parsed = urlparse(text)
|
||||
database = (parsed.path or "").lstrip("/")
|
||||
if not database:
|
||||
raise ValueError("MYSQL_DSN must include a database name.")
|
||||
return {
|
||||
"host": parsed.hostname or "127.0.0.1",
|
||||
"port": parsed.port or 3306,
|
||||
"user": unquote(parsed.username or ""),
|
||||
"password": unquote(parsed.password or ""),
|
||||
"database": database,
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
"""SQLite 连接与幂等初始化模板。
|
||||
"""SQLite / MySQL 连接与幂等初始化模板。
|
||||
|
||||
模板默认只建数据管理元数据表。业务表由复制后的技能自行 CREATE,并同步展示元数据。
|
||||
配置了 MYSQL_DSN 时走 MySQL 云库,否则使用本地 {skill_slug}.db。见 references/DATA_SOURCES.md。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from db.backend import get_mysql_dsn, parse_mysql_dsn, resolve_backend
|
||||
from db.cursor_compat import AdaptedConnection
|
||||
from db.display_metadata import init_display_metadata
|
||||
from util.runtime_paths import get_db_path
|
||||
|
||||
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
return sqlite3.connect(get_db_path())
|
||||
def get_conn() -> AdaptedConnection:
|
||||
backend = resolve_backend()
|
||||
if backend == "mysql":
|
||||
import pymysql
|
||||
|
||||
params = parse_mysql_dsn(get_mysql_dsn())
|
||||
raw = pymysql.connect(
|
||||
host=str(params["host"]),
|
||||
port=int(params["port"]),
|
||||
user=str(params["user"]),
|
||||
password=str(params["password"]),
|
||||
database=str(params["database"]),
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
)
|
||||
return AdaptedConnection(raw, "mysql")
|
||||
|
||||
raw = sqlite3.connect(get_db_path())
|
||||
return AdaptedConnection(raw, "sqlite")
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
@@ -23,3 +43,18 @@ def init_db() -> None:
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def describe_active_store() -> dict[str, str]:
|
||||
backend = resolve_backend()
|
||||
if backend == "mysql":
|
||||
params = parse_mysql_dsn(get_mysql_dsn())
|
||||
return {
|
||||
"backend": "mysql",
|
||||
"host": str(params["host"]),
|
||||
"database": str(params["database"]),
|
||||
}
|
||||
return {
|
||||
"backend": "sqlite",
|
||||
"db_path": get_db_path(),
|
||||
}
|
||||
|
||||
96
scripts/db/cursor_compat.py
Normal file
96
scripts/db/cursor_compat.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""统一 SQLite / MySQL 游标:占位符 ? 与 executescript。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from db.backend import DbBackend
|
||||
|
||||
_PLACEHOLDER_RE = re.compile(r"\?")
|
||||
|
||||
|
||||
def adapt_sql(sql: str, backend: DbBackend) -> str:
|
||||
if backend != "mysql":
|
||||
return sql
|
||||
return _PLACEHOLDER_RE.sub("%s", sql)
|
||||
|
||||
|
||||
def _split_sql_script(script: str) -> list[str]:
|
||||
parts: list[str] = []
|
||||
for chunk in script.split(";"):
|
||||
stmt = chunk.strip()
|
||||
if stmt:
|
||||
parts.append(stmt)
|
||||
return parts
|
||||
|
||||
|
||||
class AdaptedCursor:
|
||||
backend: DbBackend
|
||||
|
||||
def __init__(self, cursor: Any, backend: DbBackend) -> None:
|
||||
self._cursor = cursor
|
||||
self.backend = backend
|
||||
|
||||
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> Any:
|
||||
adapted = adapt_sql(sql, self.backend)
|
||||
if params is None:
|
||||
return self._cursor.execute(adapted)
|
||||
return self._cursor.execute(adapted, params)
|
||||
|
||||
def executemany(self, sql: str, seq_of_params: list[tuple[Any, ...]]) -> Any:
|
||||
return self._cursor.executemany(adapt_sql(sql, self.backend), seq_of_params)
|
||||
|
||||
def fetchone(self) -> Any:
|
||||
return self._cursor.fetchone()
|
||||
|
||||
def fetchall(self) -> list[Any]:
|
||||
return self._cursor.fetchall()
|
||||
|
||||
@property
|
||||
def lastrowid(self) -> int:
|
||||
return int(self._cursor.lastrowid)
|
||||
|
||||
@property
|
||||
def rowcount(self) -> int:
|
||||
return int(self._cursor.rowcount)
|
||||
|
||||
def executescript(self, script: str) -> None:
|
||||
if self.backend == "sqlite":
|
||||
self._cursor.connection.executescript(script)
|
||||
return
|
||||
for stmt in _split_sql_script(script):
|
||||
self.execute(stmt)
|
||||
|
||||
|
||||
class AdaptedConnection:
|
||||
backend: DbBackend
|
||||
|
||||
def __init__(self, conn: Any, backend: DbBackend) -> None:
|
||||
self._conn = conn
|
||||
self.backend = backend
|
||||
|
||||
def cursor(self) -> AdaptedCursor:
|
||||
return AdaptedCursor(self._conn.cursor(), self.backend)
|
||||
|
||||
def commit(self) -> None:
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> Any:
|
||||
cur = self.cursor()
|
||||
return cur.execute(sql, params)
|
||||
|
||||
def executemany(self, sql: str, seq_of_params: list[tuple[Any, ...]]) -> Any:
|
||||
cur = self.cursor()
|
||||
return cur.executemany(sql, seq_of_params)
|
||||
|
||||
def executescript(self, script: str) -> None:
|
||||
if self.backend == "sqlite":
|
||||
self._conn.executescript(script)
|
||||
return
|
||||
cur = self.cursor()
|
||||
for stmt in _split_sql_script(script):
|
||||
cur.execute(stmt)
|
||||
@@ -22,6 +22,8 @@ LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
|
||||
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
|
||||
|
||||
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
|
||||
YEAR_MONTH = "year_month"
|
||||
YEAR = "year"
|
||||
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
|
||||
|
||||
_JIANGCHANG_TABLES_DDL = """
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||
|
||||
SKILL_SLUG = "your-skill-slug"
|
||||
SKILL_VERSION = "1.0.113"
|
||||
SKILL_VERSION = "1.0.118"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
668
sync.ps1
Normal file
668
sync.ps1
Normal file
@@ -0,0 +1,668 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Sync this skill's plaintext source into the local OpenClaw skills directory.
|
||||
|
||||
.DESCRIPTION
|
||||
Local development only. Does not commit, tag, push, or trigger Gitea CI.
|
||||
release.ps1 remains the formal publish path.
|
||||
Ships in skill-template beside release.ps1. Scaffold copies this file into each new skill.
|
||||
|
||||
Mirrors this repo onto ~/.openclaw/skills/<slug> and removes marketplace
|
||||
pyarmor_runtime_* leftovers. Stop any running job for this skill first;
|
||||
otherwise the copy can fail because files are in use.
|
||||
|
||||
Dependencies follow the host skill:direct-install path:
|
||||
uv pip install --python <JIANGCHANG_DATA_ROOT>/python-runtime/.venv/Scripts/python.exe -r requirements.txt
|
||||
Packages are not installed into system Python. Unchanged requirements that
|
||||
are already present in the shared venv are skipped.
|
||||
|
||||
platform_kit_min_version is provided by jiangchang-platform-kit in the shared
|
||||
runtime. This script only checks it. It does not upgrade the kit. If the
|
||||
installed kit is too old, install or update the skill once from the market,
|
||||
or upgrade the host.
|
||||
|
||||
After the copy, this script runs scripts\main.py init-db the same way the
|
||||
host does after a market install. The user id is read from
|
||||
%APPDATA%\jiangchang\settings.json (jiangchangUserId), which the host writes
|
||||
on login and logout. It is never hardcoded. An empty id or _anon stops the
|
||||
script so the anonymous data folder is not initialized by mistake.
|
||||
-SkipInitDb skips that step. -SkipDeps still runs init-db.
|
||||
|
||||
No Chinese literals in this file: the skill repo requires UTF-8 without BOM,
|
||||
and Windows PowerShell 5.1 would mis-parse a no-BOM UTF-8 script.
|
||||
|
||||
.EXAMPLE
|
||||
.\sync.ps1
|
||||
.\sync.ps1 -DryRun
|
||||
.\sync.ps1 -ForceDeps
|
||||
.\sync.ps1 -SkipDeps
|
||||
.\sync.ps1 -SkipInitDb
|
||||
.\sync.ps1 -DataRoot D:\jiangchang-data
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Destination,
|
||||
[string]$DataRoot,
|
||||
[switch]$SkipDeps,
|
||||
[switch]$ForceDeps,
|
||||
[switch]$SkipInitDb,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$SourceRoot = $PSScriptRoot
|
||||
$PypiMirrors = @(
|
||||
@{ Name = "aliyun"; Url = "https://mirrors.aliyun.com/pypi/simple/" },
|
||||
@{ Name = "tuna"; Url = "https://pypi.tuna.tsinghua.edu.cn/simple/" },
|
||||
@{ Name = "tencent"; Url = "https://mirrors.cloud.tencent.com/pypi/simple/" },
|
||||
@{ Name = "huawei"; Url = "https://repo.huaweicloud.com/repository/pypi/simple/" },
|
||||
@{ Name = "pypi"; Url = "https://pypi.org/simple/" }
|
||||
)
|
||||
|
||||
function Read-Utf8Text {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
return [System.IO.File]::ReadAllText($Path)
|
||||
}
|
||||
|
||||
function Get-SkillFrontmatter {
|
||||
param([Parameter(Mandatory = $true)][string]$SkillMdPath)
|
||||
$raw = Read-Utf8Text -Path $SkillMdPath
|
||||
if ($raw -notmatch '(?s)\A---\s*\r?\n(.*?)\r?\n---') {
|
||||
throw "SKILL.md is missing frontmatter: $SkillMdPath"
|
||||
}
|
||||
return $Matches[1]
|
||||
}
|
||||
|
||||
function Get-FrontmatterScalar {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Body,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
$pattern = '(?m)^\s*' + [regex]::Escape($Key) + ':\s*["'']?([^\s"''#]+)["'']?\s*$'
|
||||
$match = [regex]::Match($Body, $pattern)
|
||||
if (-not $match.Success) {
|
||||
return $null
|
||||
}
|
||||
return $match.Groups[1].Value.Trim()
|
||||
}
|
||||
|
||||
function Get-RequirementsLines {
|
||||
param([Parameter(Mandatory = $true)][string]$Content)
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($raw in ($Content -split '\r?\n')) {
|
||||
$trimmed = $raw.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith('#')) {
|
||||
continue
|
||||
}
|
||||
$withoutInline = ($trimmed -split '\s+#', 2)[0].Trim()
|
||||
if ($withoutInline) {
|
||||
$lines.Add($withoutInline)
|
||||
}
|
||||
}
|
||||
return @($lines)
|
||||
}
|
||||
|
||||
function Get-RequirementsHash {
|
||||
param([Parameter(Mandatory = $true)][string]$Content)
|
||||
$normalized = (@(Get-RequirementsLines -Content $Content)) -join "`n"
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
|
||||
$hash = $sha.ComputeHash($bytes)
|
||||
} finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-DistributionNames {
|
||||
param([Parameter(Mandatory = $true)][string[]]$Lines)
|
||||
$names = New-Object System.Collections.Generic.List[string]
|
||||
$seen = New-Object 'System.Collections.Generic.HashSet[string]'
|
||||
foreach ($line in $Lines) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith('#') -or $trimmed.StartsWith('-')) {
|
||||
continue
|
||||
}
|
||||
if ($trimmed -match '^(https?:|git\+|svn\+|hg\+|file:)') {
|
||||
continue
|
||||
}
|
||||
$match = [regex]::Match($trimmed, '^([A-Za-z0-9][A-Za-z0-9._-]*)')
|
||||
if (-not $match.Success) {
|
||||
continue
|
||||
}
|
||||
$name = $match.Groups[1].Value
|
||||
$key = $name.ToLowerInvariant().Replace('_', '-')
|
||||
if ($seen.Add($key)) {
|
||||
$names.Add($name)
|
||||
}
|
||||
}
|
||||
return @($names)
|
||||
}
|
||||
|
||||
function Resolve-SkillDataRoot {
|
||||
param([string]$Explicit)
|
||||
if ($Explicit -and $Explicit.Trim()) {
|
||||
return $Explicit.Trim().TrimEnd('\')
|
||||
}
|
||||
|
||||
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
|
||||
if (Test-Path -LiteralPath $settingsPath) {
|
||||
try {
|
||||
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
|
||||
$fromSettings = [string]$settings.jiangchangSkillDataRoot
|
||||
if ($fromSettings -and $fromSettings.Trim()) {
|
||||
return $fromSettings.Trim().TrimEnd('\')
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Could not read $settingsPath. Falling back to the default data root. $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidate in @("D:\jiangchang-data", (Join-Path $env:USERPROFILE ".jiangchang-data"))) {
|
||||
$python = Join-Path $candidate "python-runtime\.venv\Scripts\python.exe"
|
||||
if (Test-Path -LiteralPath $python) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
return "D:\jiangchang-data"
|
||||
}
|
||||
|
||||
function Resolve-JiangchangUserId {
|
||||
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
|
||||
if (-not (Test-Path -LiteralPath $settingsPath)) {
|
||||
throw "Jiangchang settings were not found: $settingsPath`nOpen Jiangchang and log in, then run this script again."
|
||||
}
|
||||
try {
|
||||
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Could not read the logged-in user from $settingsPath. $($_.Exception.Message)"
|
||||
}
|
||||
$userId = ""
|
||||
if ($null -ne $settings -and $settings.PSObject.Properties.Name -contains "jiangchangUserId" -and $null -ne $settings.jiangchangUserId) {
|
||||
$userId = ([string]$settings.jiangchangUserId).Trim()
|
||||
}
|
||||
if (-not $userId -or $userId -eq "_anon") {
|
||||
throw "Jiangchang has no logged-in user. Open Jiangchang and log in, then run this script again. This script does not guess a user id."
|
||||
}
|
||||
$normalized = $userId.Replace("\", "/")
|
||||
if ($normalized.Contains("..") -or $normalized.Contains("/") -or $userId.StartsWith("\") -or $userId -match '^[A-Za-z]:') {
|
||||
throw "jiangchangUserId is not a single folder name: $userId"
|
||||
}
|
||||
return $userId
|
||||
}
|
||||
|
||||
function Test-InitDbJsonOk {
|
||||
param([Parameter(Mandatory = $true)][string]$Text)
|
||||
foreach ($line in ($Text -split "`r?`n")) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed.StartsWith("{")) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
$parsed = $trimmed | ConvertFrom-Json
|
||||
if ($parsed.ok -eq $true) {
|
||||
return $true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-InitDbUnknownCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Text,
|
||||
[Parameter(Mandatory = $true)][int]$ExitCode
|
||||
)
|
||||
if ($ExitCode -eq 2) {
|
||||
return $true
|
||||
}
|
||||
$lower = $Text.ToLowerInvariant()
|
||||
if ($lower.Contains("invalid choice") -or $lower.Contains("unrecognized arguments") -or $lower.Contains("unknown command")) {
|
||||
return $true
|
||||
}
|
||||
if ($lower.Contains("invalid") -and $lower.Contains("init-db")) {
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Invoke-SkillInitDb {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string]$SkillDir,
|
||||
[Parameter(Mandatory = $true)][string]$DataRoot,
|
||||
[Parameter(Mandatory = $true)][string]$UserId,
|
||||
[switch]$DryRun
|
||||
)
|
||||
$mainPy = Join-Path $SkillDir "scripts\main.py"
|
||||
if (-not (Test-Path -LiteralPath $mainPy)) {
|
||||
Write-Host "No scripts\main.py. Skipping init-db."
|
||||
return
|
||||
}
|
||||
if ($DryRun) {
|
||||
Write-Host "DryRun: would run init-db" -ForegroundColor Yellow
|
||||
Write-Host " python $PythonExe"
|
||||
Write-Host " script $mainPy init-db"
|
||||
Write-Host " data $DataRoot"
|
||||
Write-Host " user $UserId"
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $PythonExe)) {
|
||||
throw "Shared Python was not found: $PythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot."
|
||||
}
|
||||
|
||||
Write-Host ">> Initialize the logged-in user's database (init-db)" -ForegroundColor DarkGray
|
||||
Write-Host " user=$UserId"
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $PythonExe
|
||||
$psi.WorkingDirectory = $SkillDir
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.Arguments = '"' + $mainPy + '" init-db'
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
|
||||
$psi.EnvironmentVariables["JIANGCHANG_DATA_ROOT"] = $DataRoot
|
||||
$psi.EnvironmentVariables["JIANGCHANG_USER_ID"] = $UserId
|
||||
$psi.EnvironmentVariables["JIANGCHANG_RUN_MODE"] = "cli"
|
||||
$psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"
|
||||
|
||||
$proc = New-Object System.Diagnostics.Process
|
||||
$proc.StartInfo = $psi
|
||||
[void]$proc.Start()
|
||||
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $proc.StandardError.ReadToEndAsync()
|
||||
if (-not $proc.WaitForExit(60000)) {
|
||||
try { $proc.Kill() } catch { }
|
||||
throw "init-db timed out after 60 seconds."
|
||||
}
|
||||
$proc.WaitForExit()
|
||||
$stdout = $stdoutTask.Result
|
||||
$stderr = $stderrTask.Result
|
||||
$combined = ($stdout + "`n" + $stderr).Trim()
|
||||
if ($stdout.Trim()) {
|
||||
Write-Host $stdout.Trim()
|
||||
}
|
||||
if ($stderr.Trim()) {
|
||||
Write-Host $stderr.Trim()
|
||||
}
|
||||
|
||||
if (Test-InitDbJsonOk -Text $combined) {
|
||||
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
if ($proc.ExitCode -eq 0 -and $combined -match '(?m)^OK:\s*INIT_DB\b') {
|
||||
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
if (Test-InitDbUnknownCommand -Text $combined -ExitCode $proc.ExitCode) {
|
||||
Write-Warning "This skill has no init-db command. Database initialization was skipped."
|
||||
return
|
||||
}
|
||||
|
||||
$detail = $stderr.Trim()
|
||||
if (-not $detail) {
|
||||
$detail = $stdout.Trim()
|
||||
}
|
||||
if ($detail.Length -gt 500) {
|
||||
$detail = $detail.Substring($detail.Length - 500)
|
||||
}
|
||||
if (-not $detail) {
|
||||
$detail = "init-db exited with code $($proc.ExitCode)"
|
||||
}
|
||||
throw "init-db failed for user ${UserId}: $detail"
|
||||
}
|
||||
|
||||
function Resolve-UvExecutable {
|
||||
$cmd = Get-Command uv -ErrorAction SilentlyContinue
|
||||
if ($cmd -and $cmd.Source) {
|
||||
return $cmd.Source
|
||||
}
|
||||
throw "uv was not found on PATH. The host installs skill requirements with uv pip, not pip."
|
||||
}
|
||||
|
||||
function Test-DistributionsPresent {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string[]]$Names
|
||||
)
|
||||
if ($Names.Count -eq 0) {
|
||||
return $false
|
||||
}
|
||||
$probe = @'
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
import sys
|
||||
missing = []
|
||||
for n in sys.argv[1:]:
|
||||
try:
|
||||
version(n)
|
||||
except PackageNotFoundError:
|
||||
missing.append(n)
|
||||
sys.stderr.write("missing:" + ",".join(missing) if missing else "")
|
||||
raise SystemExit(1 if missing else 0)
|
||||
'@
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
& $PythonExe -c $probe @Names 2>&1 | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InstalledDistributionVersion {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $PythonExe -c "from importlib.metadata import version, PackageNotFoundError`nimport sys`nname=sys.argv[1]`ntry:`n print(version(name))`nexcept PackageNotFoundError:`n print('')" $Name 2>$null
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
if ($code -ne 0) {
|
||||
return $null
|
||||
}
|
||||
return ([string]$output).Trim()
|
||||
}
|
||||
|
||||
function Compare-VersionPrefix {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Installed,
|
||||
[Parameter(Mandatory = $true)][string]$Required
|
||||
)
|
||||
function Convert-ToVersion([string]$Text) {
|
||||
$core = ($Text -split '[^0-9.]', 2)[0].Trim('.')
|
||||
$parts = @($core -split '\.' | Where-Object { $_ -ne '' })
|
||||
while ($parts.Count -lt 3) {
|
||||
$parts += '0'
|
||||
}
|
||||
$take = @($parts | Select-Object -First 3)
|
||||
return [version]($take -join '.')
|
||||
}
|
||||
try {
|
||||
return (Convert-ToVersion $Installed) -ge (Convert-ToVersion $Required)
|
||||
} catch {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-RobocopyMirror {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Source,
|
||||
[Parameter(Mandatory = $true)][string]$Dest,
|
||||
[switch]$ListOnly
|
||||
)
|
||||
$excludeDirs = @(
|
||||
".git", ".github", "__pycache__", ".pytest_cache", "htmlcov",
|
||||
"dist", "build", ".venv", "_fix_workspace", ".skill-requirements"
|
||||
)
|
||||
$excludeFiles = @(".env", "*.env.local", "*.pyc", "*.pyo")
|
||||
$robocopyArgs = @($Source, $Dest, "/MIR", "/R:2", "/W:1", "/XD") + $excludeDirs + @("/XF") + $excludeFiles
|
||||
if ($ListOnly) {
|
||||
$robocopyArgs += "/L"
|
||||
}
|
||||
& robocopy.exe @robocopyArgs
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ge 8) {
|
||||
throw "Copy failed (robocopy exit $code). Stop the running skill job in Task Center, then retry."
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-PyarmorRuntimes {
|
||||
param([Parameter(Mandatory = $true)][string]$Dest)
|
||||
$removed = @()
|
||||
$dirs = @(Get-ChildItem -LiteralPath $Dest -Recurse -Directory -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like "pyarmor_runtime_*" })
|
||||
foreach ($dir in $dirs) {
|
||||
Remove-Item -LiteralPath $dir.FullName -Recurse -Force
|
||||
$removed += $dir.FullName
|
||||
}
|
||||
return $removed
|
||||
}
|
||||
|
||||
function Assert-PlainMainPy {
|
||||
param([Parameter(Mandatory = $true)][string]$MainPy)
|
||||
if (-not (Test-Path -LiteralPath $MainPy)) {
|
||||
throw "scripts\main.py is missing after sync: $MainPy"
|
||||
}
|
||||
$head = Read-Utf8Text -Path $MainPy
|
||||
if ($head -match 'pyarmor_runtime_|__pyarmor__') {
|
||||
throw "scripts\main.py is still PyArmor-encrypted. Re-run .\sync.ps1 so the whole scripts tree is replaced."
|
||||
}
|
||||
}
|
||||
|
||||
function Write-RequirementsState {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RuntimeDir,
|
||||
[Parameter(Mandatory = $true)][string]$Slug,
|
||||
[Parameter(Mandatory = $true)][string]$RequirementsPath,
|
||||
[Parameter(Mandatory = $true)][string]$RequirementsHash,
|
||||
[string]$RuntimeFingerprint
|
||||
)
|
||||
$stateDir = Join-Path $RuntimeDir ".skill-requirements"
|
||||
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
|
||||
$state = [ordered]@{
|
||||
skill = $Slug
|
||||
requirementsPath = $RequirementsPath
|
||||
requirementsHash = $RequirementsHash
|
||||
runtimeFingerprint = $RuntimeFingerprint
|
||||
installedAt = [DateTime]::UtcNow.ToString("o")
|
||||
status = "ok"
|
||||
}
|
||||
$json = ($state | ConvertTo-Json -Depth 4) + "`n"
|
||||
$statePath = Join-Path $stateDir "$Slug.json"
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllText($statePath, $json, $utf8)
|
||||
}
|
||||
|
||||
$skillMd = Join-Path $SourceRoot "SKILL.md"
|
||||
$sourceMain = Join-Path $SourceRoot "scripts\main.py"
|
||||
$sourceReq = Join-Path $SourceRoot "requirements.txt"
|
||||
if (-not (Test-Path -LiteralPath $skillMd)) {
|
||||
throw "SKILL.md not found: $skillMd"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $sourceMain)) {
|
||||
throw "scripts\main.py not found: $sourceMain"
|
||||
}
|
||||
|
||||
$frontmatter = Get-SkillFrontmatter -SkillMdPath $skillMd
|
||||
$slug = Get-FrontmatterScalar -Body $frontmatter -Key "slug"
|
||||
$version = Get-FrontmatterScalar -Body $frontmatter -Key "version"
|
||||
$platformKitMin = Get-FrontmatterScalar -Body $frontmatter -Key "platform_kit_min_version"
|
||||
if (-not $slug) {
|
||||
throw "metadata.openclaw.slug in SKILL.md is empty, so the install directory cannot be determined."
|
||||
}
|
||||
|
||||
if (-not $Destination -or -not $Destination.Trim()) {
|
||||
$Destination = Join-Path $env:USERPROFILE ".openclaw\skills\$slug"
|
||||
}
|
||||
$Destination = $Destination.Trim().TrimEnd('\')
|
||||
|
||||
$sourceFull = [System.IO.Path]::GetFullPath($SourceRoot)
|
||||
$destFull = [System.IO.Path]::GetFullPath($Destination)
|
||||
if ($sourceFull.TrimEnd('\') -eq $destFull.TrimEnd('\')) {
|
||||
throw "Source and destination are the same directory: $sourceFull"
|
||||
}
|
||||
|
||||
$resolvedDataRoot = Resolve-SkillDataRoot -Explicit $DataRoot
|
||||
$runtimeDir = Join-Path $resolvedDataRoot "python-runtime"
|
||||
$pythonExe = Join-Path $runtimeDir ".venv\Scripts\python.exe"
|
||||
$resolvedUserId = $null
|
||||
if (-not $SkipInitDb) {
|
||||
$resolvedUserId = Resolve-JiangchangUserId
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Local skill sync (does not publish to the skill market)" -ForegroundColor Cyan
|
||||
Write-Host " source $sourceFull"
|
||||
Write-Host " dest $destFull"
|
||||
Write-Host " slug $slug"
|
||||
Write-Host " version $(if ($version) { $version } else { '(none)' })"
|
||||
Write-Host " data $resolvedDataRoot"
|
||||
Write-Host " python $pythonExe"
|
||||
Write-Host " user $(if ($SkipInitDb) { '(init-db skipped)' } else { $resolvedUserId })"
|
||||
if ($DryRun) {
|
||||
Write-Host " mode DryRun (no writes)" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path -LiteralPath $destFull)) {
|
||||
Write-Warning "Destination does not exist yet. Files can be created, but if this skill was never installed from the market the host may not have enabled it. Install it once, then use this script to overwrite."
|
||||
}
|
||||
|
||||
$configPath = Join-Path $env:USERPROFILE ".openclaw\openclaw.json"
|
||||
if (Test-Path -LiteralPath $configPath) {
|
||||
try {
|
||||
$config = Read-Utf8Text -Path $configPath | ConvertFrom-Json
|
||||
$entry = $null
|
||||
if ($config.PSObject.Properties.Name -contains "skills" -and $null -ne $config.skills -and $config.skills.PSObject.Properties.Name -contains "entries" -and $null -ne $config.skills.entries) {
|
||||
$entryProp = $config.skills.entries.PSObject.Properties[$slug]
|
||||
if ($entryProp) {
|
||||
$entry = $entryProp.Value
|
||||
}
|
||||
}
|
||||
if ($null -eq $entry) {
|
||||
Write-Warning "openclaw.json has no skills.entries.$slug. Install the skill once from the market, or chat and data management may not see it."
|
||||
} elseif ($entry.PSObject.Properties.Name -contains "enabled" -and $entry.enabled -eq $false) {
|
||||
Write-Warning "skills.entries.$slug is disabled. Syncing files does not re-enable it."
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Could not check openclaw.json enablement: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ">> Mirror source onto the install directory (exclude .git, drop encrypted runtime)" -ForegroundColor DarkGray
|
||||
Invoke-RobocopyMirror -Source $sourceFull -Dest $destFull -ListOnly:$DryRun
|
||||
if (-not $DryRun) {
|
||||
$removed = @(Remove-PyarmorRuntimes -Dest $destFull)
|
||||
foreach ($path in $removed) {
|
||||
Write-Host " removed $path"
|
||||
}
|
||||
Assert-PlainMainPy -MainPy (Join-Path $destFull "scripts\main.py")
|
||||
Write-Host "scripts\main.py is plaintext." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "DryRun: pyarmor_runtime_* was not deleted, and no destination file was changed." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$skipInstall = $false
|
||||
if ($SkipDeps) {
|
||||
Write-Host "Skipped dependency install (-SkipDeps)." -ForegroundColor Yellow
|
||||
$skipInstall = $true
|
||||
} elseif (-not (Test-Path -LiteralPath $pythonExe)) {
|
||||
throw "Shared Python was not found: $pythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot. If the copy already finished, install dependencies before running the skill."
|
||||
} else {
|
||||
$reqPath = if ($DryRun) { $sourceReq } else { Join-Path $destFull "requirements.txt" }
|
||||
if (-not (Test-Path -LiteralPath $reqPath)) {
|
||||
Write-Host "No requirements.txt. Skipping dependency install."
|
||||
$skipInstall = $true
|
||||
} else {
|
||||
$reqContent = Read-Utf8Text -Path $reqPath
|
||||
$reqLines = @(Get-RequirementsLines -Content $reqContent)
|
||||
if ($reqLines.Count -eq 0) {
|
||||
Write-Host "requirements.txt has no effective lines. Skipping install."
|
||||
$skipInstall = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $skipInstall) {
|
||||
|
||||
$reqHash = Get-RequirementsHash -Content $reqContent
|
||||
$stampPath = Join-Path $runtimeDir ".jiangchang-runtime-stamp.json"
|
||||
$runtimeFingerprint = ""
|
||||
if (Test-Path -LiteralPath $stampPath) {
|
||||
try {
|
||||
$stamp = Read-Utf8Text -Path $stampPath | ConvertFrom-Json
|
||||
$runtimeFingerprint = [string]$stamp.fingerprint
|
||||
} catch {
|
||||
$runtimeFingerprint = ""
|
||||
}
|
||||
}
|
||||
|
||||
$statePath = Join-Path $runtimeDir ".skill-requirements\$slug.json"
|
||||
$previousHash = ""
|
||||
$previousFingerprint = ""
|
||||
$previousStatus = ""
|
||||
if (Test-Path -LiteralPath $statePath) {
|
||||
try {
|
||||
$previous = Read-Utf8Text -Path $statePath | ConvertFrom-Json
|
||||
$previousHash = [string]$previous.requirementsHash
|
||||
$previousFingerprint = [string]$previous.runtimeFingerprint
|
||||
$previousStatus = [string]$previous.status
|
||||
} catch {
|
||||
$previousStatus = ""
|
||||
}
|
||||
}
|
||||
|
||||
$distributionNames = @(Get-DistributionNames -Lines $reqLines)
|
||||
$unchanged = (
|
||||
-not $ForceDeps -and
|
||||
$previousStatus -eq "ok" -and
|
||||
$previousHash -eq $reqHash -and
|
||||
$runtimeFingerprint -and
|
||||
$previousFingerprint -eq $runtimeFingerprint
|
||||
)
|
||||
if ($unchanged -and $distributionNames.Count -gt 0 -and (Test-DistributionsPresent -PythonExe $pythonExe -Names $distributionNames)) {
|
||||
Write-Host "Requirements unchanged and already present in the shared environment. Skipping uv pip install." -ForegroundColor Green
|
||||
} elseif ($DryRun) {
|
||||
Write-Host "DryRun: would run uv pip install -r requirements.txt" -ForegroundColor Yellow
|
||||
$distributionNames | ForEach-Object { Write-Host " - $_" }
|
||||
} else {
|
||||
$uv = Resolve-UvExecutable
|
||||
Write-Host ">> Install skill requirements into the shared environment" -ForegroundColor DarkGray
|
||||
Write-Host " uv=$uv"
|
||||
$installed = $false
|
||||
foreach ($mirror in $PypiMirrors) {
|
||||
Write-Host ">> uv pip install --index-url $($mirror.Url)" -ForegroundColor DarkGray
|
||||
& $uv pip install --index-url $mirror.Url --python $pythonExe -r $reqPath
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$installed = $true
|
||||
break
|
||||
}
|
||||
Write-Warning "Mirror $($mirror.Name) failed (exit $LASTEXITCODE). Trying the next one."
|
||||
}
|
||||
if (-not $installed) {
|
||||
throw "requirements.txt install failed. Code is already at $destFull, but the shared environment is missing dependencies."
|
||||
}
|
||||
Write-RequirementsState -RuntimeDir $runtimeDir -Slug $slug -RequirementsPath $reqPath -RequirementsHash $reqHash -RuntimeFingerprint $runtimeFingerprint
|
||||
Write-Host "Dependencies are installed in the shared environment." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if ($platformKitMin -and (Test-Path -LiteralPath $pythonExe)) {
|
||||
$kitVersion = Get-InstalledDistributionVersion -PythonExe $pythonExe -Name "jiangchang-platform-kit"
|
||||
if (-not $kitVersion) {
|
||||
Write-Warning "jiangchang-platform-kit is not installed in the shared environment, but this skill requires >= $platformKitMin."
|
||||
} elseif (-not (Compare-VersionPrefix -Installed $kitVersion -Required $platformKitMin)) {
|
||||
Write-Warning "jiangchang-platform-kit $kitVersion is below this skill's requirement >= $platformKitMin. This script does not upgrade the platform kit."
|
||||
} else {
|
||||
Write-Host "platform kit $kitVersion satisfies >= $platformKitMin." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if ($SkipInitDb) {
|
||||
Write-Host "Skipped init-db (-SkipInitDb). The database was not updated." -ForegroundColor Yellow
|
||||
} else {
|
||||
$initSkillDir = $destFull
|
||||
if ($DryRun -and -not (Test-Path -LiteralPath (Join-Path $destFull "scripts\main.py"))) {
|
||||
$initSkillDir = $sourceFull
|
||||
}
|
||||
Invoke-SkillInitDb -PythonExe $pythonExe -SkillDir $initSkillDir -DataRoot $resolvedDataRoot -UserId $resolvedUserId -DryRun:$DryRun
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Stop any running job, leave Data Management, then open it again." -ForegroundColor Cyan
|
||||
Write-Host "Do not click Update in the skill market. That replaces this source with the encrypted package." -ForegroundColor Cyan
|
||||
49
tests/test_db_backend.py
Normal file
49
tests/test_db_backend.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据库后端选择:默认 SQLite,DSN 解析。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from _support import IsolatedDataRoot
|
||||
|
||||
|
||||
class TestDbBackend(unittest.TestCase):
|
||||
def test_resolve_backend_defaults_to_sqlite(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.backend import resolve_backend
|
||||
|
||||
self.assertEqual(resolve_backend(), "sqlite")
|
||||
|
||||
def test_parse_mysql_dsn(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.backend import parse_mysql_dsn
|
||||
|
||||
params = parse_mysql_dsn("mysql://root:secret@192.168.0.20:3306/demo_db")
|
||||
self.assertEqual(params["host"], "192.168.0.20")
|
||||
self.assertEqual(params["port"], 3306)
|
||||
self.assertEqual(params["user"], "root")
|
||||
self.assertEqual(params["password"], "secret")
|
||||
self.assertEqual(params["database"], "demo_db")
|
||||
|
||||
def test_resolve_backend_uses_mysql_dsn_from_env(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.backend import resolve_backend
|
||||
|
||||
os.environ["MYSQL_DSN"] = "mysql://u:p@127.0.0.1:3306/t"
|
||||
try:
|
||||
self.assertEqual(resolve_backend(), "mysql")
|
||||
finally:
|
||||
os.environ.pop("MYSQL_DSN", None)
|
||||
|
||||
def test_describe_active_store_sqlite(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.connection import describe_active_store
|
||||
|
||||
info = describe_active_store()
|
||||
self.assertEqual(info["backend"], "sqlite")
|
||||
self.assertIn("db_path", info)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -30,6 +30,7 @@ POLICY_IDS = (
|
||||
"POLICY-CONFIG-002",
|
||||
"POLICY-CONFIG-003",
|
||||
"POLICY-CONFIG-004",
|
||||
"POLICY-CONFIG-005",
|
||||
"POLICY-TESTING-001",
|
||||
"POLICY-TESTING-002",
|
||||
"POLICY-RPA-001",
|
||||
@@ -38,6 +39,7 @@ POLICY_IDS = (
|
||||
"POLICY-RPA-004",
|
||||
"POLICY-RPA-005",
|
||||
"POLICY-RPA-006",
|
||||
"POLICY-RPA-007",
|
||||
"POLICY-PACKAGING-001",
|
||||
"POLICY-PACKAGING-002",
|
||||
"POLICY-RELEASE-001",
|
||||
@@ -96,8 +98,11 @@ CONFIG_ENV_FORBIDDEN_PATTERNS = (
|
||||
)
|
||||
|
||||
CONFIG_004_SOURCE = "development/CONFIG.md §.env.example 注释 = 配置管理用户文案"
|
||||
CONFIG_005_SOURCE = "development/CONFIG.md §必填项:# @required"
|
||||
CONFIG_004_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
CONFIG_004_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||||
CONFIG_005_REQUIRED_MARKER_RE = re.compile(r"^@required\b", re.IGNORECASE)
|
||||
CONFIG_005_INLINE_REQUIRED_RE = re.compile(r"@required\b", re.IGNORECASE)
|
||||
# 注释中禁止出现的开发向指向 / 黑话(大小写不敏感子串)
|
||||
CONFIG_004_FORBIDDEN_COMMENT_SUBSTRINGS = (
|
||||
"development/",
|
||||
@@ -201,6 +206,26 @@ RPA_006_EXAMPLE_CLIENTS = (
|
||||
"examples/simulator_browser_rpa/scripts/service/account_client.py",
|
||||
)
|
||||
|
||||
RPA_007_SOURCE = (
|
||||
"development/RPA.md §1.2.1; development/RPA_BROWSER.md §4; "
|
||||
"examples/real_browser_rpa; development/POLICY_MATRIX.md"
|
||||
)
|
||||
RPA_007_DOC_MARKERS = (
|
||||
"no_viewport=True",
|
||||
"viewport=None",
|
||||
"setWindowBounds",
|
||||
"POLICY-RPA-007",
|
||||
)
|
||||
RPA_007_LAUNCH_MARKERS = RPA_004_LAUNCH_MARKERS
|
||||
RPA_007_FORBIDDEN_PATTERNS = (
|
||||
"viewport=None",
|
||||
"setWindowBounds",
|
||||
"maximize_browser_window",
|
||||
)
|
||||
RPA_007_REAL_BROWSER_SESSION = (
|
||||
"examples/real_browser_rpa/scripts/service/browser_session.py"
|
||||
)
|
||||
|
||||
LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md"
|
||||
LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
|
||||
LOGGING_003_SOURCE = "development/LOGGING.md"
|
||||
@@ -316,6 +341,34 @@ def _config_004_comment_offenders(path: str) -> list[str]:
|
||||
return offenders
|
||||
|
||||
|
||||
def _is_env_required_marker(text: str) -> bool:
|
||||
return bool(CONFIG_005_REQUIRED_MARKER_RE.match(text.strip()))
|
||||
|
||||
|
||||
def _config_005_offenders(path: str) -> list[str]:
|
||||
offenders: list[str] = []
|
||||
for lineno, key, _value, headers, inline in _iter_env_example_active_entries(path):
|
||||
if inline and CONFIG_005_INLINE_REQUIRED_RE.search(inline):
|
||||
offenders.append(
|
||||
f".env.example:{lineno}: {key}= 禁止在行尾写 @required(须单独一行放在中文行头上方)"
|
||||
)
|
||||
|
||||
required_markers = [part for part in headers if _is_env_required_marker(part)]
|
||||
non_marker_headers = [part for part in headers if not _is_env_required_marker(part)]
|
||||
if not required_markers:
|
||||
continue
|
||||
if not non_marker_headers:
|
||||
offenders.append(
|
||||
f".env.example:{lineno}: {key}= 使用了 # @required 但缺少中文行头"
|
||||
)
|
||||
continue
|
||||
if not any(CONFIG_004_CJK_RE.search(part) for part in non_marker_headers):
|
||||
offenders.append(
|
||||
f".env.example:{lineno}: {key}= # @required 下方须有中文行头(说明「是什么」)"
|
||||
)
|
||||
return offenders
|
||||
|
||||
|
||||
def _rel(skill_root: str, path: str) -> str:
|
||||
return os.path.relpath(path, skill_root).replace("\\", "/")
|
||||
|
||||
@@ -406,6 +459,12 @@ def _scan_install_delivery_files(skill_root: str) -> list[str]:
|
||||
continue
|
||||
if suffix == ".sh" and not path.endswith(".sh"):
|
||||
continue
|
||||
# Root sync.ps1 is the manual local-host mirror. It installs
|
||||
# requirements into the shared venv the same way the host does.
|
||||
if os.path.basename(path) == "sync.ps1" and os.path.normcase(
|
||||
os.path.dirname(os.path.abspath(path))
|
||||
) == os.path.normcase(os.path.abspath(skill_root)):
|
||||
continue
|
||||
text = _read_text(path)
|
||||
for pattern in INSTALL_FORBIDDEN_PATTERNS:
|
||||
if pattern.search(text):
|
||||
@@ -569,6 +628,23 @@ class TestPolicyConfig004(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyConfig005(unittest.TestCase):
|
||||
def test_env_example_required_markers_are_well_formed(self) -> None:
|
||||
path = os.path.join(get_skill_root(), ".env.example")
|
||||
self.assertTrue(os.path.isfile(path), msg=".env.example missing")
|
||||
offenders = _config_005_offenders(path)
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-CONFIG-005",
|
||||
CONFIG_005_SOURCE,
|
||||
"\n".join(offenders)
|
||||
or "every # @required marker needs its own Chinese header line above the key",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyTesting001(unittest.TestCase):
|
||||
def test_root_tests_do_not_hardcode_real_targets(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
@@ -782,6 +858,84 @@ class TestPolicyRpa005(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyRpa007(unittest.TestCase):
|
||||
def test_rpa_md_documents_viewport_window_standard(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
path = os.path.join(skill_root, "development", "RPA.md")
|
||||
text = _read_text(path)
|
||||
missing = [marker for marker in RPA_007_DOC_MARKERS if marker not in text]
|
||||
self.assertEqual(
|
||||
missing,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-RPA-007",
|
||||
RPA_007_SOURCE,
|
||||
"RPA.md missing viewport/window markers: " + ", ".join(missing),
|
||||
),
|
||||
)
|
||||
|
||||
def test_real_browser_example_uses_no_viewport(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
path = os.path.join(skill_root, *RPA_007_REAL_BROWSER_SESSION.split("/"))
|
||||
text = _read_text(path)
|
||||
self.assertIn(
|
||||
"no_viewport=True",
|
||||
text,
|
||||
msg=_policy_msg(
|
||||
"POLICY-RPA-007",
|
||||
RPA_007_SOURCE,
|
||||
f"{RPA_007_REAL_BROWSER_SESSION} must use no_viewport=True",
|
||||
),
|
||||
)
|
||||
self.assertNotIn(
|
||||
"viewport=None",
|
||||
text,
|
||||
msg=_policy_msg(
|
||||
"POLICY-RPA-007",
|
||||
RPA_007_SOURCE,
|
||||
f"{RPA_007_REAL_BROWSER_SESSION} must not use viewport=None",
|
||||
),
|
||||
)
|
||||
|
||||
def test_launch_persistent_forbids_bad_viewport_and_cdp_maximize(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
rel_paths: list[str] = []
|
||||
for root_name in ("scripts", "examples"):
|
||||
root = os.path.join(skill_root, root_name)
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
|
||||
rel_dir = os.path.relpath(dirpath, skill_root).replace("\\", "/")
|
||||
if any(rel_dir == p or rel_dir.startswith(p + "/") for p in SKIP_REL_PREFIXES):
|
||||
continue
|
||||
for name in filenames:
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
rel = os.path.relpath(os.path.join(dirpath, name), skill_root).replace(
|
||||
"\\", "/"
|
||||
)
|
||||
rel_paths.append(rel)
|
||||
violations: list[str] = []
|
||||
for rel in rel_paths:
|
||||
text = _read_text(os.path.join(skill_root, *rel.split("/")))
|
||||
if not any(marker in text for marker in RPA_007_LAUNCH_MARKERS):
|
||||
continue
|
||||
for pattern in RPA_007_FORBIDDEN_PATTERNS:
|
||||
if pattern in text:
|
||||
violations.append(f"{rel}: forbidden {pattern!r}")
|
||||
self.assertEqual(
|
||||
violations,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-RPA-007",
|
||||
RPA_007_SOURCE,
|
||||
"launch_persistent files with forbidden viewport/window hacks: "
|
||||
+ "; ".join(violations),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyRpa006(unittest.TestCase):
|
||||
def test_account_manager_lifecycle_doc_has_required_markers(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
|
||||
@@ -358,6 +358,7 @@ class TestDocsStandards(unittest.TestCase):
|
||||
"新增/编辑表单:字段说明与默认值(宿主支持)",
|
||||
"表单与展示:稳定字段类型(宿主支持,须与 Action 对称)",
|
||||
"datetime_unix_seconds",
|
||||
"year_month",
|
||||
"display_type: \"number\"",
|
||||
"extra_column",
|
||||
"{value} · {label}",
|
||||
@@ -384,6 +385,28 @@ class TestDocsStandards(unittest.TestCase):
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
|
||||
|
||||
def test_data_sources_md_documents_mysql_and_declaration(self) -> None:
|
||||
text = self._read("references/DATA_SOURCES.md")
|
||||
for marker in (
|
||||
"data-sources.json",
|
||||
"MYSQL_DSN",
|
||||
"6.9.28",
|
||||
"resolve_backend",
|
||||
"一个 `sourceId`",
|
||||
"process-work-order",
|
||||
"tableAdmin",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"DATA_SOURCES.md missing {marker!r}")
|
||||
self.assertTrue(os.path.isfile(os.path.join(get_skill_root(), "assets/data-sources.example.json")))
|
||||
self.assertTrue(
|
||||
os.path.isfile(os.path.join(get_skill_root(), "assets/examples/data-sources.mysql.example.json"))
|
||||
)
|
||||
env_example = self._read(".env.example")
|
||||
self.assertIn("MYSQL_DSN", env_example)
|
||||
checklist = self._read("development/CHECKLIST.md")
|
||||
self.assertIn("A6-16", checklist)
|
||||
self.assertIn("DATA_SOURCES.md", checklist)
|
||||
|
||||
def test_actions_md_documents_form_schema_split(self) -> None:
|
||||
text = self._read("references/ACTIONS.md")
|
||||
for marker in (
|
||||
@@ -396,6 +419,7 @@ class TestDocsStandards(unittest.TestCase):
|
||||
"数据管理新增/编辑",
|
||||
"_jiangchang_columns.description",
|
||||
"datetime_unix_seconds",
|
||||
"year_month",
|
||||
"SCHEMA.md",
|
||||
"字段条件显隐",
|
||||
"visibleWhen",
|
||||
|
||||
@@ -51,6 +51,7 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
|
||||
- **不**自动生成业务表名,**不**给 `health` / `version` / `config-path` 自动加 `toolbar`
|
||||
- **不**生成任何具体平台业务 Action
|
||||
- **不**自动 `git init`;打印后续 Git 与测试命令
|
||||
- 根目录同时复制 `release.ps1`(发布到服务器)和 `sync.ps1`(同步明文到本机宿主)
|
||||
|
||||
目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。
|
||||
|
||||
@@ -58,6 +59,8 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `release.ps1` | 发布到服务器;保留 |
|
||||
| `sync.ps1` | 把明文同步到本机宿主;保留。用法见 `development/DEVELOPMENT.md` §14.6 |
|
||||
| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 |
|
||||
| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 |
|
||||
| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 |
|
||||
|
||||
Reference in New Issue
Block a user