@mjh-odoo/cli 0.1.0 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: odoo-cli
3
+ description: 使用 odoo CLI 管理 Odoo 开发项目的环境、模块、日志、镜像和原生数据库 ZIP。适用于启动排查、安装升级模块、数据库存档与恢复;CLI 自身源码开发遵循所在仓库的工程文档。
4
+ ---
5
+
6
+ # Odoo CLI 项目管理
7
+
8
+ 使用当前项目的真实命令和配置完成任务,不创建新的项目管理协议。
9
+
10
+ ## 先定位项目与入口
11
+
12
+ - 在目标项目确认 `compose.yaml`、`.env.example`;从子目录运行会向上寻找项目。CLI 源码库或纯模块库未必是可运行项目。
13
+ - 操作其他目录时用全局参数:`odoo --project /path/to/project <命令>`,`--project` 放在子命令前。先读目标项目的 AGENTS.md。
14
+ - 用 `odoo --version` 和相应 `--help` 核对能力。未知或报错的参数先查帮助,不猜测新命令。
15
+ - `.env` 已存在就保留。首次初始化可用 `odoo init`;CLI 0.1.0 会覆盖模板的显式密码,遇到该版本时用项目现有的 `node scripts/dev.mjs init`,或使用支持此规则的 0.1.1+。
16
+ - 缺少全局 CLI 时,先看项目内 `node scripts/dev.mjs --help`;仅使用它实际支持的语法。模块/数据库列表等能力需要管理 CLI。需要安装时使用 `npm install -g @mjh-odoo/cli`。
17
+
18
+ ## 根据任务读取
19
+
20
+ | 任务 | 读取与操作 |
21
+ | --- | --- |
22
+ | 查看状态、启动、应用配置、模块安装升级 | [开发操作](references/development.md) |
23
+ | 查日志、定位维护失败 | [日志排查](references/logs.md) |
24
+ | 数据库 ZIP 保存、恢复、选择目标库 | [数据库存档](references/database.md) |
25
+ | 镜像构建或离线包 | `odoo image build --help`,并读取目标项目的构建发布说明 |
26
+
27
+ ## 操作约束与交付
28
+
29
+ - 查看配置用 `odoo config`,输出已脱敏。需要判断本机密码是否配置时只在本机读取必要字段,不把整份 `.env` 或真实密码写进日志、提交、PR 或任务记录。
30
+ - 只读排查先用 `status`、`config` 和有限日志;不要为读取日志自动启动服务或重建镜像。缺少 `.env` 时说明尚未初始化,按任务需要处理。项目内兼容脚本的读取副作用可能不同,调用前检查已有配置与帮助。
31
+ - `down` 保留数据卷;仅当用户已明确要求删除该项目数据时使用 `-v/--volumes`。已有目标库的覆盖只在用户明确要求时带 `--force`;沿用已有授权,不重复索要确认。
32
+ - 安装/升级和恢复会停止该项目的 Odoo。失败时保留停止状态与维护输出,修复后再启动,不把普通服务日志当成一次性维护容器的完整记录。
33
+ - 数据库存档是 Odoo 原生 ZIP,代码和模块由项目 Git 独立维护;回退代码不会撤销数据库升级。
34
+ - 一次操作完成后报告目标项目/数据库、实际结果及未验证内容。查询为空只代表查询范围没有结果;执行成功不等于业务验收通过。
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Odoo CLI"
3
+ short_description: "使用 odoo 命令管理开发环境、模块、日志与数据库 ZIP 存档"
4
+ default_prompt: "使用 $odoo-cli 管理当前 Odoo 项目,按任务检查配置、运行状态与目标数据库。"
@@ -0,0 +1,43 @@
1
+ # 数据库与 Odoo ZIP
2
+
3
+ 先用 `odoo config` 核对当前工作树,`odoo db list --json` 查库名、当前库和恢复来源。项目存档由 Odoo Manager 保存,各工作树通过同一 Git 仓库识别项目。
4
+
5
+ ## 保存与发布
6
+
7
+ ```sh
8
+ odoo archive save "销售验收版" -d odoo --tag latest
9
+ odoo archive import /path/to/source.zip
10
+ odoo archive list --json
11
+ odoo archive tag set latest <存档ID>
12
+ odoo archive tag list
13
+ ```
14
+
15
+ 保存需要运行中的 Odoo,导入通过目标项目的 Odoo 运行镜像校验 ZIP。默认保存只生成存档。需要同时更新标签时传 `--tag`,保存成功后才移动标签。导入后可用 `archive tag set` 更新标签。可把标签指回旧 ID。只有单独交付文件时才使用 `odoo db backup --file /path/to/backup.zip`。备份包含数据库和 filestore,保留原生 Odoo ZIP。
16
+
17
+ ## 恢复与切换
18
+
19
+ ```sh
20
+ odoo db restore latest --to sales_review
21
+ odoo db use sales_review
22
+ odoo db restore id:<存档ID> --to sales_copy --use
23
+ ```
24
+
25
+ 标签在命令执行时解析,恢复记录实际 ID。恢复默认中和外发任务,切换失败恢复原数据库配置。保留旧库以便回退。用 `--file <路径>` 恢复未导入的 Odoo ZIP,只有用户明确指定要覆盖目标库才使用 `--force`。验收需要核对记录、模块状态和真实附件内容。
26
+
27
+ `ODOO_SEED=tag:latest` 或 `id:<存档ID>` 搭配 `ODOO_SEED_MODE=first-start`,使 `odoo up` 在新开发环境空库时加载对应版本。用 `manual` 手动恢复。现有工作树的数据由该环境自己维护。
28
+
29
+ 删除 ZIP 用 `odoo archive delete <存档ID> --yes`,带标签的存档须先移动或移除标签。删除环境中的数据库及附件用 `odoo db delete <数据库> --yes`,先切走当前库。分别核对目标并确认用户已授权该次删除。
30
+
31
+ ## 恢复失败与迁移
32
+
33
+ 恢复失败先查任务输出的校验、恢复、验证或切换阶段。普通恢复会清理本次新建的库。若仍有「恢复未完成」记录,确认目标环境后用 `odoo db recover --yes` 清理再重试,禁止将该库切为当前库。
34
+
35
+ ```sh
36
+ odoo archive seed tag:latest
37
+ odoo archive source
38
+ odoo archive library export /path/to/new-library-directory
39
+ odoo archive library import /path/to/library-directory
40
+ odoo worktree archived
41
+ ```
42
+
43
+ 项目级来源用于新工作树。工作树内显式的 `ODOO_SEED_MODE=first-start` 和 `ODOO_SEED` 优先,`ODOO_SEED_MODE=empty` 可选空库。存档库目录包含原生 ZIP 与索引,带走 ID、标签和备注。导入保留本机已有的同名标签。清理已归档工作树卷使用 `odoo worktree clean <Compose标识> --yes`,先核对工作树和卷并取得用户授权。
@@ -0,0 +1,37 @@
1
+ # 环境与模块
2
+
3
+ ## 常用入口
4
+
5
+ ```sh
6
+ odoo init
7
+ odoo config
8
+ odoo doctor --json
9
+ odoo status --json
10
+ odoo up --no-build --wait-timeout 600
11
+ odoo down
12
+ ```
13
+
14
+ `init` 只准备本机配置。`up` 默认构建镜像,再启动并等待健康;已有镜像用 `--no-build`,完全离线还可指定 `--pull never`。等待超时不表示数据库可丢弃,应查看实际状态和日志。
15
+
16
+ 首次初始化按代码目录分配项目身份和宿主端口,形成独立数据卷。支持模板密码规则的版本保留三个非空模板密码,空项才生成。已有 `.env` 始终保留。管理员初始密码只用于空库;改配置不会重置已有后台账号或 PostgreSQL 角色。
17
+
18
+ 修改 `.env` 后用 `odoo up --no-build` 重建服务配置;只变更配置模板或镜像依赖时需要重新构建。`restart` 默认处理 Odoo,先重启选定服务,再执行 `up --no-build --wait` 确保服务就绪;`restart all` 包含当前项目的数据库。其他项目不在 all 的范围内。配置修改仍建议明确执行 `up`。
19
+
20
+ ## 模块安装与升级
21
+
22
+ ```sh
23
+ odoo module list --json
24
+ odoo module install first_module second_module
25
+ odoo module update first_module second_module -d target_db
26
+ odoo module update first_module --no-restart
27
+ ```
28
+
29
+ 模块名用空格分隔。`-d/--database` 覆盖本次维护目标,不修改项目的 `PGDATABASE`。安装用于未安装模块,升级用于已安装模块;Python 热重载不代替字段、视图和数据结构的升级。
30
+
31
+ 无模块名时 `module update` 会拒绝执行,只有显式 `--all` 才升级已安装的项目扩展;它不代表升级整个官方核心。维护会确保数据库运行、停止 Web、使用一次性容器执行,成功后按选项启动。失败先保存本次命令的错误输出。
32
+
33
+ ## 镜像
34
+
35
+ 先用 `odoo image build --dry-run` 看本机基础镜像计划。完整项目镜像需要 `--target production --tag <项目:版本>`。单架构离线包使用 `--platform amd64 --file /path/to/image.tar`,服务器用 Docker load 导入。
36
+
37
+ 多架构推送需要已支持相应架构的 Buildx 构建器以及显式 `--push --tag`;发布目标和权限沿用当前用户授权,不从本地构建任务推导为上传任务。
@@ -0,0 +1,14 @@
1
+ # 日志排查
2
+
3
+ 先在复现窗口查询错误。CLI 0.1.2 起可用 `--hide-health` 去掉成功的健康检查,失败请求仍可查到。
4
+
5
+ ```sh
6
+ odoo logs all --since 10m --tail 1000 --level error --context 3 --hide-health --json
7
+ odoo logs all --since 2026-11-03T09:25:30+08:00 --until 2026-11-03T09:26:30+08:00 --tail 1000 --hide-health --json
8
+ ```
9
+
10
+ `matched` 是命中数,`shown` 包括命中和相邻事件,`context=true` 标记相邻事件。找到可疑错误后,用其时间戳限定前后 30 秒,查看完整堆栈和其他服务。日志先按容器行数取样再筛选,`tailReached=true` 时可扩大行数或缩小时间窗。
11
+
12
+ 需要现场复现时再用 `odoo logs -f`;Ctrl+C 只结束查看。筛选、JSON 和文件选项不能与 follow 混用。导出可用 `--file .local/logs/debug.json`,拒绝覆盖已有文件,内容中的业务数据仍需按任务处理。
13
+
14
+ 安装、升级或恢复使用的临时维护容器可能已经被移除;优先保留失败命令的输出。当前服务日志不能补回该容器的全部错误。不要因日志中只有 health 200 就判断模块已正确安装或业务正常。
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @mjh-odoo/cli
2
2
 
3
- 跨平台 Odoo 20 开发管理 CLI,命令名为 `odoo`。用于 mjh-odoo 社区版 base 及基于它建立的项目。
3
+ 跨平台 Odoo 20 开发管理 CLI,命令名为 odoo。提供环境、模块、日志、原生数据库 ZIP 与镜像管理。
4
4
 
5
- ## 安装
5
+ ## 安装与启动
6
6
 
7
- 需要 Node.js 24+、Docker Engine/Desktop、Docker Compose 和 Buildx。Windows 可在 PowerShell 中使用,无需 Make 或 Bash。Odoo/Python/PostgreSQL 在容器内运行。
7
+ 需要 Node.js 24+、Docker、Compose 和 Buildx。Windows PowerShell 可直接使用;Python/Odoo/PostgreSQL 在目标项目的容器中运行。
8
8
 
9
9
  ```sh
10
10
  npm install -g @mjh-odoo/cli
@@ -12,90 +12,74 @@ odoo --help
12
12
  odoo --version
13
13
  ```
14
14
 
15
- CLI 不附带 Odoo 源码,也不会创建完整项目。现阶段需先准备兼容的 base 仓库:根目录包含 `.env.example`、`compose.yaml`、`odoo/`、`addons/`、`scripts/runtime.py`、`scripts/backup_utils.py` 和 `config/`。Base 当前独立验收,尚未公开发布。
15
+ 进入兼容项目后执行 odoo init、odoo up。CLI 不创建完整 Odoo 项目;项目应包含 compose.yaml、.env.example、完整官方源码及容器脚本,见 [项目契约](docs/architecture.md)。模板密码的非空优先规则从 0.1.1 支持;0.1.0 先用项目的 node scripts/dev.mjs init 准备配置。
16
16
 
17
- 进入项目后运行:
17
+ 从子目录也能发现项目,指定目录时全局参数放在命令之前:
18
18
 
19
19
  ```sh
20
- odoo init
21
- odoo doctor
22
- odoo up
23
- odoo status
24
- ```
25
-
26
- 从子目录运行也能找到项目。指定其他目录时,全局参数放在命令前:
27
-
28
- ```sh
29
- odoo --project /path/to/erp status --json
30
- # Windows
31
- odoo --project "C:\Code\my-erp" status
20
+ odoo --project /path/to/project status --json
32
21
  ```
33
22
 
34
23
  ## 常用命令
35
24
 
36
25
  ```sh
37
- odoo up # 构建开发镜像并后台启动,等待健康
38
- odoo up --no-build --pull never # 使用已有镜像离线启动
39
- odoo restart # 重启 Odoo
40
- odoo restart all # 重启 Odoo 和数据库
41
- odoo down # 停止并移除容器,保留持久卷
42
- odoo down -v --dry-run # 预览本项目卷删除操作
43
- odoo down -v # 删除本项目数据库和附件卷
44
- odoo build --pull # 构建当前 Compose 开发镜像
45
- odoo config # 脱敏配置和默认存档目录
46
-
26
+ odoo config
27
+ odoo doctor --json
28
+ odoo up
29
+ odoo up --no-build --pull never --wait-timeout 600
30
+ odoo status --json
31
+ odoo logs all --since 10m --json
32
+ odoo logs all --since 10m --tail 1000 --level error --context 3 --hide-health --json
47
33
  odoo module list --json
48
- odoo module install module_a module_b
49
- odoo module update module_a module_b
50
- odoo module update --all # 已安装的项目扩展,不含官方核心
51
- odoo module update module_a -d test_db --no-restart
52
-
34
+ odoo module install first_module second_module
35
+ odoo module update first_module second_module -d target_db
53
36
  odoo db list --json
54
37
  odoo db backup
55
- odoo db backup -d odoo --file ./before-sale.zip
56
- odoo db restore --file ./before-sale.zip -d sale_test
57
- odoo db restore --file ./before-sale.zip -d sale_test --force
58
-
59
- odoo logs
60
- odoo logs all --since 10m --level error --context 3 --json
61
- odoo logs odoo --match sale_module --tail 1000
62
- odoo logs all --since 30m --json --file .local/logs/diagnostic.json
63
- odoo logs -f
64
-
65
- odoo image build --help
66
- odoo image build --platform amd64 --file ./runtime-amd64.tar
67
- odoo image build --platform amd64,arm64 --tag registry.example/team/runtime:v1 --push
68
- odoo image build --target production --tag my-erp:v1
69
-
70
- odoo exec -- --version # 容器内官方 Odoo CLI
71
- odoo exec -- --help
72
- odoo exec -- shell -c /etc/odoo/odoo.conf
73
- odoo help exec # 查看管理工具的转发说明
38
+ odoo db restore --file /path/to/backup.zip -d restored_db
39
+ odoo down
74
40
  ```
75
41
 
76
- 每个命令支持 `--help`,官方命令转发 `exec` 的说明使用 `odoo help exec`。
42
+ 每个命令支持 --help。模块列表只读;安装和升级会停止 Odoo 并运行维护容器。-d 只改变本次目标,不改 .env。维护失败保留停止状态,先看本次错误输出再修复启动。
77
43
 
78
- ## 配置与数据
44
+ 日志默认有限快照,持续查看才加 -f;JSON、筛选和文件导出不能与 -f 混用。异常保留上下文,空结果不代表服务无故障。
79
45
 
80
- 项目使用 `.env + compose.yaml + config/`。`init` 只在缺少 `.env` 时生成独立项目身份、可用端口和随机密码;重复运行保留已有文件。不同代码目录(包括 Git 工作树)默认使用不同身份、端口和数据卷。手动复制 `.env` 时需自行调整这些值。
46
+ 普通 down 保留卷;只有明确要删除本项目数据库与附件时使用 -v,可先 --dry-run 看计划。恢复已有目标库需要显式 --force;加 `--use` 可以在成功恢复到新库后切换。备份默认保存在工作树外,odoo config 显示位置。
81
47
 
82
- `module install/update` 先停止 Odoo,使用一次性维护容器,再启动服务;失败时保留停止状态供排查。`-d` 只覆盖本次维护的目标库,不修改 `.env`。
48
+ ## 与 Odoo Manager 共用存档
83
49
 
84
- 备份包含数据库和 filestore,使用 Odoo 标准 ZIP;默认保存到用户数据目录的 `mjh-odoo/backups/<PROJECT_NAME>/`,位于代码目录之外,`config` 可查看实际路径。现有文件不会被覆盖。备份时应避免并发业务写入,保证数据库和附件的一致性。
50
+ 在 Odoo Manager 接入项目并设置存档目录后,可以在主目录或任意 Git 工作树中执行:
85
51
 
86
- 恢复先校验 ZIP 路径、CRC、清单及 Odoo 20.0 主版本,再停止服务。默认中和外发任务;已有目标库需要显式 `--force`。恢复新库不会自动切换项目,切换时修改 `.env` 中的 `PGDATABASE`。
52
+ ```sh
53
+ odoo archive save "销售验收版" --tag latest
54
+ odoo archive import /path/to/backup.zip
55
+ odoo archive list --json
56
+ odoo archive tag set latest <archive-id>
57
+ odoo db restore latest --to sales_review --use
58
+ ```
87
59
 
88
- 日志默认读取最近 200 行有限快照;筛选保留相邻事件与异常堆栈。`--json` 和 `--file` 不能与 `-f` 同用。已知 `.env` 密码值会被遮盖,业务内容仍需自行判断。临时模块维护容器的失败信息以维护命令本身的输出为准。
60
+ 桌面端和 CLI 共用存档及标签。标签在命令执行时解析,也可用 `id:<存档ID>` 固定版本。发布只移动标签,已有环境的数据由该环境自行维护。`db use` 更新当前环境的 `PGDATABASE` 并重启原本运行中的 Odoo,原库保留。用 `archive delete <ID> --yes` 删除无标签的 ZIP,用 `db delete <库名> --yes` 删除已切走的数据库和附件。
89
61
 
90
- 本版本包含环境、模块、ZIP 备份恢复、日志、镜像管理。桌面 App、存档目录索引、工作树自动克隆数据库和定时备份尚未实现。
62
+ 在新工作树的 `.env` 配置 `ODOO_SEED=tag:latest` 与 `ODOO_SEED_MODE=first-start`,首次 `odoo up` 会从标签恢复到空库,然后启动 Odoo。可在 `.env.example` 预填,使新工作树的 `odoo init` 一起复制配置。默认 `manual`。
91
63
 
92
- ## 开发与验收
64
+ `odoo archive seed tag:latest` 为已接入项目设置工作树的首次数据来源,`odoo archive source` 预览。恢复被中断后可用 `odoo db recover --yes` 清理本次创建的残留库。用 `odoo archive library export <新目录>` 和 `odoo archive library import <目录>` 迁移存档及标签。用 `odoo worktree archived` 查看已归档工作树的数据卷。
65
+
66
+ ## 镜像与官方命令
93
67
 
94
68
  ```sh
95
- npm ci
96
- npm run check
97
- npm test
98
- npm pack --dry-run
69
+ odoo image build --dry-run
70
+ odoo image build --platform amd64 --file /path/to/runtime.tar
71
+ odoo image build --target production --tag my-erp:v1
72
+ odoo exec -- --version
73
+ odoo exec -- --help
74
+ odoo help exec
99
75
  ```
100
76
 
101
- 测试覆盖跨平台参数解析、中文及空格路径、帮助无副作用、批量模块维护、日志和镜像计划。真实 Docker 验收范围见 [验证记录](docs/validation.md)。LGPL-3.0-only,见 LICENSE 和 COPYRIGHT。
77
+ 多架构和推送参数见 image build --help;生产镜像需要明确项目标签。exec 将后续参数原样传给容器内官方 Odoo,管理工具本身的说明用 help exec。
78
+
79
+ ## Agent Skill
80
+
81
+ 本仓库和 npm 包携带 [.agents/skills/odoo-cli](.agents/skills/odoo-cli/SKILL.md)。目标项目把完整目录放入自身 .agents/skills/ 后,可使用 `$odoo-cli` 管理该项目;Skill 按需要读取开发、日志或 ZIP 引用。
82
+
83
+ 例如:使用 `$odoo-cli` 为当前项目导出默认数据库的原生 ZIP;或检查最近十分钟错误日志并保留上下文。
84
+
85
+ 开发入口见 仓库根 AGENTS.md、[文档地图](docs/README.md)、[架构](docs/architecture.md)、[开发](docs/development.md) 和 [验证范围](docs/validation.md)。实际版本以 odoo --version 与包发布记录为准。
package/docs/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # 开发文档地图
2
+
3
+ | 任务 | 优先阅读 |
4
+ | --- | --- |
5
+ | 安装和日常命令 | [项目 README](../README.md)、odoo --help |
6
+ | 定位命令、配置和 Base 交互 | [架构与项目契约](architecture.md) |
7
+ | 修改、测试、打包与交付 | [开发流程](development.md) |
8
+ | 使用 CLI 管理目标项目 | [odoo-cli Skill](../.agents/skills/odoo-cli/SKILL.md) |
9
+ | 核对实际验证范围 | [验证证据](validation.md) |
10
+
11
+ 按当前任务定位实现;不要默认把整个历史验证或所有 Odoo 源码读入上下文。代码行为与已确认约束不一致时明确指出并修正。大任务按需建立 docs/tasks/<任务>.md,记录范围、相关提交、有效证据、未完成项和下一步;不创建空目录。
@@ -0,0 +1,34 @@
1
+ # 架构与目标项目契约
2
+
3
+ ## 管理 CLI 的代码
4
+
5
+ | 文件 | 职责 |
6
+ | --- | --- |
7
+ | bin/odoo.mjs | 读取包版本,启动命令解析和错误出口 |
8
+ | src/cli.mjs | Commander 命令树、帮助、参数校验与命令分派 |
9
+ | src/project.mjs | 向上查找目标、Compose 上下文、数据库/服务名检查、进程执行和操作锁 |
10
+ | src/commands.mjs | 环境、服务、模块、数据库操作 |
11
+ | src/lib/environment.mjs | .env 解析、初始化、项目身份、端口、存档位置与脱敏配置 |
12
+ | src/lib/logs.mjs | 有限日志、事件、上下文、脱敏和导出 |
13
+ | src/lib/image.mjs | Buildx 镜像计划、平台约束和离线输出 |
14
+ | .agents/skills/odoo-cli/ | 管理操作的可复用 Agent Skill,随 npm 包提供 |
15
+
16
+ 全局 --project 放在子命令之前;否则从调用目录向上寻找同时含 compose.yaml 与 .env.example 的项目。npm 包安装位置不影响项目定位。
17
+
18
+ ## 目标项目提供什么
19
+
20
+ | 目标文件/目录 | 用途 |
21
+ | --- | --- |
22
+ | compose.yaml、.env.example | 项目识别与运行配置 |
23
+ | .env | 本机配置,真实值不入 Git |
24
+ | odoo/odoo-bin、odoo/ | 官方进程和完整源码 |
25
+ | addons/ | 项目、外部和非生产模块 |
26
+ | scripts/runtime.py | serve、install、upgrade 等容器运行逻辑 |
27
+ | scripts/backup_utils.py | ZIP validate、restore,调用官方 load |
28
+ | config/、Dockerfile | 运行配置与镜像构建 |
29
+
30
+ Compose 服务名为 odoo 和 db。CLI 显式指定主 Compose、环境文件及项目身份。模块维护通过 runtime.py 的一次性容器执行;ZIP 备份调用运行容器中的官方 odoo db dump,恢复通过 backup_utils.py 调用官方 db load --neutralize。管理命令的 -d 默认读取 PGDATABASE,不自动切换项目默认库。
31
+
32
+ 只读命令通过 validateEnv 读取已有配置,init/up 可准备缺失的 .env。支持固定模板密码的版本为 0.1.1+;已有环境不改写。项目内兼容脚本有自己的语法和副作用,不能假设与全部 CLI 选项相同。
33
+
34
+ 环境、日志、镜像能力也可能存在于项目内兼容入口。修改共享行为时检查对应项目实现和测试,任务记录列出需要同步的消费方;不自动改无关仓库。
@@ -0,0 +1,40 @@
1
+ # CLI 开发与交付
2
+
3
+ ## 准备与检查
4
+
5
+ 需要 Node.js 24+。首次准备依赖用 npm ci --ignore-scripts;依赖和安装状态未变时不重复安装。
6
+
7
+ ```sh
8
+ npm run check
9
+ npm test
10
+ ```
11
+
12
+ 参数、帮助与项目副作用检查由 test/cli.test.mjs 覆盖;环境、日志和镜像各有独立测试文件。验证与改动相符,文档或 Skill 变化只补链接、语法、命令帮助和打包检查,不为此启动用户数据库。
13
+
14
+ ## 任务和合并
15
+
16
+ 从最新 main 或包含所需前置改动的明确基线创建 codex/ 任务分支。工作树由原生工具管理;同一检出一个写入任务,审查优先只读。小而完整的变更及时集成,单一逻辑 PR 可 squash,有独立意义的历史保留。
17
+
18
+ 行为变化更新 README 或对应专题。PR 写清问题、最终行为、验证和限制;跨会话复杂任务才维护 docs/tasks/<任务>.md,不复制聊天记录或凭据。
19
+
20
+ ## 独立集成验收
21
+
22
+ test/docker-acceptance.py 需要 Python、Docker、兼容 Base 与已有基础镜像,脚本创建可丢弃测试项目。使用前阅读脚本和帮助,明确临时资源;不能在用户开发库运行删除卷或覆盖恢复。
23
+
24
+ ```sh
25
+ python3 test/docker-acceptance.py /absolute/path/to/base /absolute/path/to/installed/odoo
26
+ ```
27
+
28
+ 单元测试不等于真实 Docker/平台验收。新增数据库或运行行为需在独立实例验证;没有对应设备时明确未测试,不把 CI 配置当结果。
29
+
30
+ ## Skill 维护
31
+
32
+ Skill 源码为 .agents/skills/odoo-cli/,SKILL.md 只保留定位、路由和必要边界,开发、日志、数据库的细节在 references/。修改后使用可用的 skill-creator 校验器检查 frontmatter 与 agents/openai.yaml,并核对文中命令的真实帮助。
33
+
34
+ 消费项目把完整目录放到自己的 .agents/skills/odoo-cli,保持同一内容。复制时保留官方技能与许可,不把真实项目路径和配置写入 Skill。npm 包中的同一路径可作为分发来源;项目内 .agents/skills 是 Agent 的发现位置。
35
+
36
+ ## 打包与发布
37
+
38
+ 使用 npm pack 生成候选,检查 files 白名单只包含可发布文件;安装到独立临时前缀,运行实际 odoo --version、--help、init 和相应功能,确认 Skill 的入口与引用一同存在。
39
+
40
+ main 是可集成主线,发布标签 v<版本> 固定验收结果。package.json 的候选版本不等于已在 npm 发布;发布状态以实际 registry/Release 结果为准。包发布、Git 推送与镜像上传分别遵循用户已授权范围,认证由本人处理,不记录凭据。
@@ -1,35 +1,19 @@
1
- # 0.1.0 验证记录
1
+ # 验证证据
2
2
 
3
- 日期:2026-11-03。
3
+ 记录日期:2026-11-03。package.json 目前为本地 0.1.1 候选;已发布版本为 0.1.0,候选尚未上传或创建发布标签。
4
4
 
5
- ## 本机与包
5
+ ## 已发布 0.1.0
6
6
 
7
- macOS ARM64,Node 26.8.1、Docker 29.7.2、Compose 5.4.0;容器 Odoo 20.0、Python 3.12、PostgreSQL 17。
7
+ 32 项 Node 测试与 Windows/macOS/Linux Node 24 CI 曾通过;npm pack 后安装到临时前缀,实际可执行文件帮助、版本和独立 Docker 验收通过。
8
8
 
9
- 32 项 Node 测试通过,覆盖环境隔离、重复初始化、中文和空格路径、全量帮助、参数校验、批量模块维护、日志事件与遮盖、镜像计划与离线文件。npm pack 后安装到独立临时前缀,实际 odoo --version、--help 及所有以下 Docker 检查均使用安装后的可执行文件。
9
+ macOS ARM64 的临时项目覆盖空库启动、批量模块安装/升级、数据库参数不改默认配置、原生 ZIP 的真实附件逐字节恢复、有限日志、普通 down 保留数据与显式删测试卷。所有临时项目资源已清理,用户开发数据未操作。
10
10
 
11
- 发布包约 31 KB,files 白名单仅含 bin、src、docs、README、package.json 与许可;逐项检查无 Odoo 源码、真实环境文件、数据库、附件、node_modules 或本机绝对路径。
11
+ ## 本地 0.1.1 候选
12
12
 
13
- ## 隔离 Docker 验收
13
+ 40 项 Node 测试通过,涵盖模板三个密码非空优先、部分留空、重复 init、引号、缺失字段和 CRLF。真实打包安装后验证固定/随机密码及 Compose 传递;重复 init 保留现有 .env。
14
14
 
15
- - 全新临时项目使用本机已构建的基础镜像启动并通过健康检查。
16
- - 单条命令安装两个模块,列出安装状态;批量升级及 --no-restart 保持停止通过。
17
- - 通过官方 shell 写入真实 raw 附件,先验证 raw.content 和 filestore 文件。
18
- - ZIP 同时包含 manifest.json、dump.sql、filestore;已有备份文件拒绝覆盖。
19
- - 恢复到独立新库,列表正确区分 current;恢复后逐字节读取附件一致。
20
- - module update -d 维护新库,原 .env 字节未改。
21
- - 有限 JSON 日志快照读取真实 Docker 日志事件。
22
- - 普通 down 保留两个卷,重新 up 后数据库仍在;显式 down -v 删除两个测试卷。
23
- - 所有临时容器、卷和网络已清理,原 base .env 字节未改,原有运行容器继续运行。
15
+ 本轮新增文档与 CLI Skill,40 项测试及语法检查通过。npm pack 安装后的实际命令完成帮助、init 和 Compose 配置验证;分发包含 SKILL.md、agents/openai.yaml 及全部 3 个引用文件,共 5 个 Skill 文件。四份项目副本一致,格式/UI 元数据与 144 处本地链接通过检查,官方技能和现有 .env 未变。现有 CI 打包步骤已增加 Skill 入口/元数据存在检查,本候选尚未在远端执行。
24
16
 
25
- 本机 Docker 默认地址池已满,验收专门创建独立、无冲突的临时内部网络,结束后删除。此测试安排没有写入通用 base 配置,也没有清理其他项目网络。
17
+ ## 平台范围
26
18
 
27
- 复查脚本 test/docker-acceptance.py 仅用于可丢弃的新项目,在 macOS 实测;需要 Python 3、本机已有兼容 base 和 mjh-odoo/odoo-runtime:20.0-v0.1.0 镜像:
28
-
29
- ```sh
30
- python3 test/docker-acceptance.py /absolute/path/to/base /absolute/path/to/installed/odoo
31
- ```
32
-
33
- ## 平台边界
34
-
35
- GitHub CI 在 Windows、macOS、Linux 的 Node 24 上运行单元测试及打包安装检查;最终结果以发布提交的 Actions 为准。Windows 原生 Docker、WSL 及 Linux Docker 尚未实测。基础镜像与完整 base 均未在本次 CLI 发布中上传。
19
+ 本机为 macOS ARM64、Node 26.8.1。0.1.1 的远端跨平台 CI 尚未执行;Windows 原生/WSL/Linux Docker 仍需对应设备验证。已有旧版本 CI 不代表当前候选已通过。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjh-odoo/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.6",
4
4
  "description": "Cross-platform Odoo development CLI: environments, modules, database archives, logs and images",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "bin/",
24
24
  "src/",
25
25
  "docs/",
26
+ ".agents/skills/odoo-cli/",
26
27
  "LICENSE",
27
28
  "COPYRIGHT"
28
29
  ],
@@ -0,0 +1,134 @@
1
+ import fs from 'node:fs';
2
+ import { copyFile, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { archiveLocation, insertArchive, pointTag, projectTags, tagName, transaction, withArchiveCatalog } from './archive.mjs';
6
+
7
+ const filename = 'odoo-archives.json';
8
+ const validId = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/;
9
+ const textFields = ['title', 'note', 'environment', 'database', 'created', 'revision'];
10
+
11
+ async function digest(file) {
12
+ const hash = createHash('sha256');
13
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
14
+ return hash.digest('hex');
15
+ }
16
+
17
+ async function checkedCopy(source, target, size, hash) {
18
+ const info = await lstat(source);
19
+ if (!info.isFile() || info.size !== size) throw new Error('ZIP 不存在或大小不符:' + path.basename(source));
20
+ await copyFile(source, target, fs.constants.COPYFILE_EXCL);
21
+ if (process.platform !== 'win32') fs.chmodSync(target, 0o600);
22
+ const result = await digest(target);
23
+ if (hash && result !== hash) throw new Error('ZIP 校验失败:' + path.basename(source));
24
+ return result;
25
+ }
26
+
27
+ export async function exportLibrary(root, destination) {
28
+ const inventory = await withArchiveCatalog(root, false, (db, project) => {
29
+ db.exec('BEGIN');
30
+ try {
31
+ const archives = db.prepare('SELECT * FROM archives WHERE projectId = ? ORDER BY created').all(project.id);
32
+ const tags = projectTags(db, project.id);
33
+ const seed = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'project_seeds'").get()
34
+ ? db.prepare('SELECT reference FROM project_seeds WHERE projectId = ?').get(project.id)?.reference : undefined;
35
+ return {project: project.name, archives, tags, seed};
36
+ } finally { db.exec('COMMIT'); }
37
+ });
38
+ if (!inventory.archives.length) throw new Error('当前项目没有可导出的存档。');
39
+ const folder = path.resolve(destination);
40
+ const parent = fs.realpathSync(path.dirname(folder));
41
+ const checkout = path.relative(root, parent);
42
+ if (!checkout || (!path.isAbsolute(checkout) && checkout !== '..' && !checkout.startsWith('..' + path.sep))) {
43
+ throw new Error('请将存档库导出到项目目录之外。');
44
+ }
45
+ await mkdir(folder, {mode: 0o700});
46
+ try {
47
+ const archives = [];
48
+ for (const archive of inventory.archives) {
49
+ if (!validId.test(archive.id)) throw new Error('不支持的存档 ID:' + archive.id);
50
+ const sha256 = await checkedCopy(archive.file, path.join(folder, archive.id + '.zip'), archive.bytes);
51
+ archives.push({id: archive.id, ...Object.fromEntries(textFields.map(key => [key, archive[key]])), bytes: archive.bytes, source: archive.source, sha256});
52
+ }
53
+ await writeFile(path.join(folder, filename), JSON.stringify({format: 1, ...inventory, archives}, null, 2) + '\n', {flag: 'wx', mode: 0o600});
54
+ return {directory: folder, archives: archives.length, tags: inventory.tags.length};
55
+ } catch (error) {
56
+ await rm(folder, {recursive: true, force: true});
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ async function readInventory(folder) {
62
+ const file = path.join(folder, filename);
63
+ const info = await lstat(file);
64
+ if (!info.isFile() || info.size > 16 * 1024 * 1024) throw new Error('存档库索引文件无效。');
65
+ const data = JSON.parse(await readFile(file, 'utf8'));
66
+ if (data?.format !== 1 || !Array.isArray(data.archives) || !data.archives.length || !Array.isArray(data.tags)) {
67
+ throw new Error('请选择包含 odoo-archives.json 的存档库目录。');
68
+ }
69
+ const ids = new Set();
70
+ for (const row of data.archives) {
71
+ if (!row || typeof row.id !== 'string' || !validId.test(row.id) || ids.has(row.id) ||
72
+ !Number.isSafeInteger(row.bytes) || row.bytes <= 0 || !/^[a-f0-9]{64}$/.test(row.sha256) ||
73
+ !['backup', 'import'].includes(row.source) || textFields.some(key => typeof row[key] !== 'string') ||
74
+ !row.title.trim() || row.title.length > 120 || row.note.length > 2000 || !Number.isFinite(Date.parse(row.created))) {
75
+ throw new Error('存档库的存档记录无效。');
76
+ }
77
+ ids.add(row.id);
78
+ }
79
+ const tags = new Set();
80
+ for (const row of data.tags) {
81
+ if (!row || typeof row.tag !== 'string' || tags.has(tagName(row.tag)) || !ids.has(row.archiveId)) throw new Error('存档库的标签记录无效。');
82
+ tags.add(row.tag);
83
+ }
84
+ if (data.seed !== undefined && data.seed !== 'empty' &&
85
+ !(typeof data.seed === 'string' && (data.seed.startsWith('tag:') ? tagName(data.seed.slice(4)) :
86
+ data.seed.startsWith('id:') && ids.has(data.seed.slice(3))))) throw new Error('存档库的首次启动来源无效。');
87
+ return data;
88
+ }
89
+
90
+ export async function importLibrary(root, source) {
91
+ const folder = fs.realpathSync(path.resolve(source));
92
+ const manifest = await readInventory(folder);
93
+ return withArchiveCatalog(root, true, async (db, project) => {
94
+ const existing = db.prepare('SELECT * FROM archives WHERE id = ?');
95
+ const staged = [], added = [], skippedTags = [];
96
+ let retained = 0;
97
+ try {
98
+ for (const row of manifest.archives) {
99
+ const file = path.join(folder, row.id + '.zip');
100
+ const prior = existing.get(row.id);
101
+ if (prior) {
102
+ if (prior.projectId !== project.id || prior.bytes !== row.bytes || await digest(prior.file) !== row.sha256) {
103
+ throw new Error('本机已有内容不同或属于其他项目的存档:' + row.id);
104
+ }
105
+ retained++;
106
+ continue;
107
+ }
108
+ const target = archiveLocation(db, root, row.id);
109
+ const temporary = target + '.' + randomUUID() + '.part';
110
+ staged.push({temporary, archive: {...row, projectId: project.id, file: target}});
111
+ await checkedCopy(file, temporary, row.bytes, row.sha256);
112
+ }
113
+ transaction(db, () => {
114
+ for (const {temporary, archive} of staged) {
115
+ fs.linkSync(temporary, archive.file);
116
+ added.push(archive.file);
117
+ insertArchive(db, archive);
118
+ }
119
+ const tags = new Map(projectTags(db, project.id).map(tag => [tag.tag, tag.archiveId]));
120
+ for (const row of manifest.tags) {
121
+ if (!tags.has(row.tag)) pointTag(db, project.id, row.tag, row.archiveId);
122
+ else if (tags.get(row.tag) !== row.archiveId) skippedTags.push(row.tag);
123
+ }
124
+ if (manifest.seed) db.prepare('INSERT INTO project_seeds VALUES (?, ?) ON CONFLICT(projectId) DO NOTHING').run(project.id, manifest.seed);
125
+ });
126
+ return {imported: staged.length, retained, skippedTags};
127
+ } catch (error) {
128
+ for (const file of added) fs.rmSync(file, {force: true});
129
+ throw error;
130
+ } finally {
131
+ for (const {temporary} of staged) fs.rmSync(temporary, {force: true});
132
+ }
133
+ });
134
+ }