Compare commits

..

5 Commits

Author SHA1 Message Date
166007b460 release: v1.0.117 沉淀多数据源契约(SQLite 默认 + 可选 MySQL 云库)
All checks were successful
技能自动化发布 / release (push) Successful in 9s
新增 DATA_SOURCES.md、data-sources 示例与双后端 connection 骨架;CLI 与宿主 UI 可读写同一库。要求匠厂 Host ≥ 6.9.28。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 16:39:13 +08:00
78c24a515d docs: document year and year_month display_type host contract
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-09-26 10:23:06 +08:00
dfe6ade2d8 feat(config): document # @required for host config management (v1.0.115)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-09-25 21:51:05 +08:00
e32d04ec50 Release v1.0.114: add sync.ps1 so new skills can mirror plaintext locally
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-09-24 10:06:34 +08:00
a8403ce9b4 Release v1.0.113: document default_sort may use hidden columns
All checks were successful
技能自动化发布 / release (push) Successful in 13s
2026-09-23 10:14:20 +08:00
32 changed files with 1513 additions and 28 deletions

View File

@@ -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

View File

@@ -9,6 +9,33 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 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` 仍可引用隐藏的非敏感物理列(含主键)
- 元数据校验:排序列必须真实存在;引用隐藏列仅提示、不报错
## 1.0.112
- 新技能的名字不必再以平台或 `default` 结尾。没有抖音、小红书、亚马逊这类外部平台时,用动词加名词即可

View File

@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.112
version: 1.0.117
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

View File

@@ -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)。

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"sources": [
{
"id": "primary",
"kind": "sqlite",
"displayName": "本地库",
"path": "{skillSlug}.db"
}
]
}

View File

@@ -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`「勾选驱动模式」「用户可见文案」)。

View File

@@ -0,0 +1,11 @@
{
"version": 1,
"sources": [
{
"id": "primary",
"kind": "mysql",
"displayName": "业务云库",
"connectionEnv": "MYSQL_DSN"
}
]
}

View 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": "可留空,默认按月份生成"
}
}
}
}
]
}

View File

@@ -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": {

View File

@@ -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** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)
---

View File

@@ -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` 注释)

View File

@@ -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**。

View File

@@ -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` 存在

View File

@@ -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`) |

View File

@@ -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` 写明「由宿主导入面板调用」。
@@ -776,6 +776,7 @@ HINT: <next step>
- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。
- 把「默认展示列」与 `visible=0` 硬隐藏混淆——默认展示列见 [`SCHEMA.md`](SCHEMA.md) 的 `default_visible_columns_json`。
- 误以为 `visible=0` 的列不能出现在 `default_sort_json`——隐藏只影响展示/筛选/搜索;ORDER BY 仍可用非敏感物理列(含隐藏主键)。
- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。
### Action 类型 × placements(常见示例,不是限制)
@@ -991,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` |
@@ -1011,8 +1014,10 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
日期时间约定:
- Action 提交与 CRUD 写库统一用 Unix **秒**整数(与 SCHEMA 标准时间字段一致)
- `year` 只表示公历年份,值为 `YYYY`(如 `2026`)
- `year_month` 只表示年月,值为 `YYYY-MM`(如 `2026-05`);**不要**用 `date` 表示账期/月份
- `date` 只表示日历日,值为 `YYYY-MM-DD`
- 脏/无法归一的旧预设:日期控件可回退为普通文本,避免卡死表单
- 脏/无法归一的旧预设:日期/年月控件可回退为普通文本,避免卡死表单
### 本地路径选择与 `pathRoot`
@@ -1073,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`(表单标签下方灰字说明)
@@ -1155,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": "截止日期",
@@ -1200,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` 字段在提交前校验

View File

@@ -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
View 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 幂等初始化元数据。

View File

@@ -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`。

View File

@@ -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` 表格说明)
@@ -180,11 +180,36 @@ JSON 数组(可多字段,按顺序):
规则:
- `column` 必须是该表真实列名;敏感列会被宿主忽略
- `column` 必须是该表**真实物理列名**(存在于 SQLite 表);不存在则宿主忽略该段或技能校验报错
- **可以**引用 `visible=0` 的列(含隐藏的主键 `id`)。`visible=0` 只表示界面不展示 / 不进默认 SELECT·筛选·搜索;**不表示不能 ORDER BY**
- 敏感列(宿主标为 sensitive)不得用于排序;技能侧勿把密钥类字段写进 `default_sort_json`
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
与 `visible` 的分工(勿再踩坑):
| 能力 | `visible=0` 是否仍可用 |
|------|------------------------|
| 表格列展示 / 「列」面板 | 否(硬隐藏) |
| 默认 SELECT、列筛选、顶栏搜索 | 否 |
| **`default_sort_json` / ORDER BY** | **是**(非敏感列) |
| 行主键定位(`$row.$pk` 等) | 是(主键即使隐藏仍应可排序、可定位;宿主查询层保留) |
示例(编号硬隐藏,但仍按编号排序——合法):
```python
upsert_table_metadata(
cursor,
table_name="period_rule_overrides",
display_name="本月默认金额",
readonly=1,
default_sort_json='[{"column":"period","direction":"desc"},{"column":"id","direction":"asc"}]',
)
# id 列元数据可见性:
# upsert_column_metadata(..., column_name="id", visible=0, ...)
```
示例(消息时间线):
```python
@@ -277,6 +302,8 @@ upsert_column_metadata(
- 只写英文物理名而不写中文 `display_name`
- 让元数据引用不存在的表
- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来)
- 误以为「列 `visible=0` 就不能写进 `default_sort_json`」——排序与展示已拆开;隐藏编号/主键仍可排序(须列真实存在且非敏感)
- 把不存在的列名写进 `default_sort_json`(技能校验应报错;宿主也会忽略无效段)
### `_jiangchang_columns` 字段级标准
@@ -312,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`(薄层;校验从宽) | 原文 |
@@ -411,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` |
| 敏感列 | **不可筛** | — | 宿主强制排除 |
**全局搜索扩展(用户可搜中文)**

View File

@@ -1 +1,2 @@
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。
PyMySQL>=1.1.0

35
scripts/db/backend.py Normal file
View 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,
}

View File

@@ -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(),
}

View 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)

View File

@@ -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 = """
@@ -103,6 +105,7 @@ def upsert_table_metadata(
default_sort_json: 宿主数据管理默认排序,JSON 数组,例如
'[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]'
为空则宿主回退到 updated_at → created_at → 主键。
排序列须为真实物理列;**允许**引用 visible=0 的非敏感列(隐藏≠不能 ORDER BY)。
default_visible_columns_json: 宿主数据管理默认展示列,JSON 字符串数组,例如
'["stat_type","period","stage"]'

View File

@@ -279,6 +279,95 @@ def validate_filter_search_metadata(
return [], warnings
def parse_default_sort_json(raw: object) -> tuple[list[dict[str, str]], list[str]]:
"""解析 default_sort_json;返回 (specs, errors)。specs 项含 column / direction。"""
errors: list[str] = []
if raw is None or str(raw).strip() == "":
return [], []
text = str(raw).strip()
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return [], ["default_sort_json 不是合法 JSON"]
if not isinstance(parsed, list):
return [], ["default_sort_json 必须是 JSON 数组"]
out: list[dict[str, str]] = []
for index, item in enumerate(parsed):
if not isinstance(item, dict):
errors.append(f"default_sort_json[{index}] 必须是对象")
continue
column = str(
item.get("column")
or item.get("columnName")
or item.get("column_name")
or ""
).strip()
direction_raw = str(item.get("direction") or "desc").strip().lower()
if not column:
errors.append(f"default_sort_json[{index}] 缺少 column")
continue
if direction_raw not in {"asc", "desc"}:
errors.append(f"default_sort_json[{index}].direction 只能是 asc 或 desc")
continue
out.append({"column": column, "direction": direction_raw})
return out, errors
def validate_default_sort_metadata(
cursor,
table_name: str,
*,
physical_columns: list[str] | None = None,
) -> tuple[list[str], list[str]]:
"""校验表级 default_sort_json:列须真实存在;允许 visible=0;不存在则报错。
契约:visible=0 只藏界面,ORDER BY 仍可用非敏感物理列(含隐藏主键)。
"""
errors: list[str] = []
warnings: list[str] = []
table_cols = {
str(row[1]) for row in cursor.execute("PRAGMA table_info(_jiangchang_tables)").fetchall()
}
if "default_sort_json" not in table_cols:
return [], []
cursor.execute(
"SELECT default_sort_json FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cursor.fetchone()
if not row:
return [], []
specs, parse_errors = parse_default_sort_json(row[0])
for msg in parse_errors:
errors.append(f"{table_name}: {msg}")
if parse_errors:
return errors, warnings
physical = set(physical_columns or list_physical_column_names(cursor, table_name))
for spec in specs:
column = spec["column"]
if column not in physical:
errors.append(
f"{table_name}: default_sort_json 引用了不存在的列 {column!r}"
)
continue
# visible=0 合法:不报错。仅提示契约,避免作者误删排序列。
cursor.execute(
"""
SELECT visible FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column),
)
meta = cursor.fetchone()
if meta is not None and meta[0] is not None and int(meta[0]) == 0:
warnings.append(
f"{table_name}.{column}: default_sort_json 引用了 visible=0 列"
"(合法:隐藏列仍可 ORDER BY;见 references/SCHEMA.md)"
)
return errors, warnings
def validate_column_permissions(
cursor,
table_name: str,
@@ -558,6 +647,14 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b
_, filter_warnings = validate_filter_search_metadata(cur, table_name)
report.warnings.extend(filter_warnings)
sort_errors, sort_warnings = validate_default_sort_metadata(
cur,
table_name,
physical_columns=physical_columns,
)
report.errors.extend(sort_errors)
report.warnings.extend(sort_warnings)
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
for table_name, column_name in cur.fetchall():
if table_name in METADATA_TABLES:

View File

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.112"
SKILL_VERSION = "1.0.117"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.2.11"

668
sync.ps1 Normal file
View 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
View 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()

View File

@@ -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",
@@ -96,8 +97,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/",
@@ -316,6 +320,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 +438,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 +607,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()

View File

@@ -595,6 +595,53 @@ metadata:
self.assertIn("readonly = 1", text)
self.assertIn("seed_column_metadata_batch", text)
self.assertIn("valid_column_names", text)
self.assertIn("可以**引用 `visible=0` 的列", text)
self.assertIn("default_sort_json` / ORDER BY", text)
def test_default_sort_json_allows_hidden_column_and_rejects_missing(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import (
init_display_metadata_tables,
seed_column_metadata_batch,
upsert_table_metadata,
)
from db.display_metadata_validator import validate_default_sort_metadata
cur = conn.cursor()
init_display_metadata_tables(cur)
cur.execute(
"CREATE TABLE demo_sort (id INTEGER PRIMARY KEY, title TEXT NOT NULL)"
)
upsert_table_metadata(
cur,
table_name="demo_sort",
display_name="排序示例",
default_sort_json='[{"column":"title","direction":"asc"},{"column":"id","direction":"desc"}]',
)
seed_column_metadata_batch(
cur,
table_name="demo_sort",
columns=(
{"column_name": "id", "display_name": "编号", "visible": 0, "editable": 0},
{"column_name": "title", "display_name": "标题", "visible": 1, "editable": 1},
),
valid_column_names=["id", "title"],
)
errors, warnings = validate_default_sort_metadata(cur, "demo_sort")
self.assertEqual(errors, [])
self.assertTrue(any("visible=0" in w and "id" in w for w in warnings))
upsert_table_metadata(
cur,
table_name="demo_sort",
display_name="排序示例",
default_sort_json='[{"column":"missing_col","direction":"asc"}]',
)
errors, _ = validate_default_sort_metadata(cur, "demo_sort")
self.assertTrue(any("missing_col" in e for e in errors))
finally:
conn.close()
def test_default_visible_columns_json_upsert_and_migrate(self) -> None:
conn = sqlite3.connect(":memory:")

View File

@@ -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",

View File

@@ -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 时可保留 |