@mjh-odoo/cli 0.0.0-stage → 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.
- package/.agents/skills/odoo-cli/SKILL.md +34 -0
- package/.agents/skills/odoo-cli/agents/openai.yaml +4 -0
- package/.agents/skills/odoo-cli/references/database.md +43 -0
- package/.agents/skills/odoo-cli/references/development.md +37 -0
- package/.agents/skills/odoo-cli/references/logs.md +14 -0
- package/COPYRIGHT +3 -0
- package/LICENSE +859 -0
- package/README.md +84 -2
- package/bin/odoo.mjs +11 -0
- package/docs/README.md +11 -0
- package/docs/architecture.md +34 -0
- package/docs/development.md +40 -0
- package/docs/validation.md +19 -0
- package/package.json +40 -4
- package/src/archive-library.mjs +134 -0
- package/src/archive.mjs +260 -0
- package/src/archived-worktrees.mjs +65 -0
- package/src/cli.mjs +188 -0
- package/src/commands.mjs +469 -0
- package/src/lib/environment.mjs +99 -0
- package/src/lib/image.mjs +66 -0
- package/src/lib/logs.mjs +196 -0
- package/src/project.mjs +100 -0
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mjh-odoo/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
跨平台 Odoo 20 开发管理 CLI,命令名为 odoo。提供环境、模块、日志、原生数据库 ZIP 与镜像管理。
|
|
4
|
+
|
|
5
|
+
## 安装与启动
|
|
6
|
+
|
|
7
|
+
需要 Node.js 24+、Docker、Compose 和 Buildx。Windows PowerShell 可直接使用;Python/Odoo/PostgreSQL 在目标项目的容器中运行。
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g @mjh-odoo/cli
|
|
11
|
+
odoo --help
|
|
12
|
+
odoo --version
|
|
13
|
+
```
|
|
14
|
+
|
|
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
|
+
|
|
17
|
+
从子目录也能发现项目,指定目录时全局参数放在命令之前:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
odoo --project /path/to/project status --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 常用命令
|
|
24
|
+
|
|
25
|
+
```sh
|
|
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
|
|
33
|
+
odoo module list --json
|
|
34
|
+
odoo module install first_module second_module
|
|
35
|
+
odoo module update first_module second_module -d target_db
|
|
36
|
+
odoo db list --json
|
|
37
|
+
odoo db backup
|
|
38
|
+
odoo db restore --file /path/to/backup.zip -d restored_db
|
|
39
|
+
odoo down
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
每个命令支持 --help。模块列表只读;安装和升级会停止 Odoo 并运行维护容器。-d 只改变本次目标,不改 .env。维护失败保留停止状态,先看本次错误输出再修复启动。
|
|
43
|
+
|
|
44
|
+
日志默认有限快照,持续查看才加 -f;JSON、筛选和文件导出不能与 -f 混用。异常保留上下文,空结果不代表服务无故障。
|
|
45
|
+
|
|
46
|
+
普通 down 保留卷;只有明确要删除本项目数据库与附件时使用 -v,可先 --dry-run 看计划。恢复已有目标库需要显式 --force;加 `--use` 可以在成功恢复到新库后切换。备份默认保存在工作树外,odoo config 显示位置。
|
|
47
|
+
|
|
48
|
+
## 与 Odoo Manager 共用存档
|
|
49
|
+
|
|
50
|
+
在 Odoo Manager 接入项目并设置存档目录后,可以在主目录或任意 Git 工作树中执行:
|
|
51
|
+
|
|
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
|
+
```
|
|
59
|
+
|
|
60
|
+
桌面端和 CLI 共用存档及标签。标签在命令执行时解析,也可用 `id:<存档ID>` 固定版本。发布只移动标签,已有环境的数据由该环境自行维护。`db use` 更新当前环境的 `PGDATABASE` 并重启原本运行中的 Odoo,原库保留。用 `archive delete <ID> --yes` 删除无标签的 ZIP,用 `db delete <库名> --yes` 删除已切走的数据库和附件。
|
|
61
|
+
|
|
62
|
+
在新工作树的 `.env` 配置 `ODOO_SEED=tag:latest` 与 `ODOO_SEED_MODE=first-start`,首次 `odoo up` 会从标签恢复到空库,然后启动 Odoo。可在 `.env.example` 预填,使新工作树的 `odoo init` 一起复制配置。默认 `manual`。
|
|
63
|
+
|
|
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
|
+
## 镜像与官方命令
|
|
67
|
+
|
|
68
|
+
```sh
|
|
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
|
|
75
|
+
```
|
|
76
|
+
|
|
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/bin/odoo.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { createProgram } from '../src/cli.mjs';
|
|
4
|
+
|
|
5
|
+
const {version} = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
6
|
+
try {
|
|
7
|
+
await createProgram({version}).parseAsync(process.argv);
|
|
8
|
+
} catch (error) {
|
|
9
|
+
console.error('错误: ' + error.message);
|
|
10
|
+
process.exitCode = error.exitCode || 1;
|
|
11
|
+
}
|
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 推送与镜像上传分别遵循用户已授权范围,认证由本人处理,不记录凭据。
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# 验证证据
|
|
2
|
+
|
|
3
|
+
记录日期:2026-11-03。package.json 目前为本地 0.1.1 候选;已发布版本为 0.1.0,候选尚未上传或创建发布标签。
|
|
4
|
+
|
|
5
|
+
## 已发布 0.1.0
|
|
6
|
+
|
|
7
|
+
32 项 Node 测试与 Windows/macOS/Linux Node 24 CI 曾通过;npm pack 后安装到临时前缀,实际可执行文件帮助、版本和独立 Docker 验收通过。
|
|
8
|
+
|
|
9
|
+
macOS ARM64 的临时项目覆盖空库启动、批量模块安装/升级、数据库参数不改默认配置、原生 ZIP 的真实附件逐字节恢复、有限日志、普通 down 保留数据与显式删测试卷。所有临时项目资源已清理,用户开发数据未操作。
|
|
10
|
+
|
|
11
|
+
## 本地 0.1.1 候选
|
|
12
|
+
|
|
13
|
+
40 项 Node 测试通过,涵盖模板三个密码非空优先、部分留空、重复 init、引号、缺失字段和 CRLF。真实打包安装后验证固定/随机密码及 Compose 传递;重复 init 保留现有 .env。
|
|
14
|
+
|
|
15
|
+
本轮新增文档与 CLI Skill,40 项测试及语法检查通过。npm pack 安装后的实际命令完成帮助、init 和 Compose 配置验证;分发包含 SKILL.md、agents/openai.yaml 及全部 3 个引用文件,共 5 个 Skill 文件。四份项目副本一致,格式/UI 元数据与 144 处本地链接通过检查,官方技能和现有 .env 未变。现有 CI 打包步骤已增加 Skill 入口/元数据存在检查,本候选尚未在远端执行。
|
|
16
|
+
|
|
17
|
+
## 平台范围
|
|
18
|
+
|
|
19
|
+
本机为 macOS ARM64、Node 26.8.1。0.1.1 的远端跨平台 CI 尚未执行;Windows 原生/WSL/Linux Docker 仍需对应设备验证。已有旧版本 CI 不代表当前候选已通过。
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjh-odoo/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"description": "Cross-platform Odoo development CLI: environments, modules, database archives, logs and images",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"odoo": "bin/odoo.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=24"
|
|
11
|
+
},
|
|
12
|
+
"license": "LGPL-3.0-only",
|
|
13
|
+
"author": "MaJianhang",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/mjh-odoo/odoo-cli.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/mjh-odoo/odoo-cli#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/mjh-odoo/odoo-cli/issues"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin/",
|
|
24
|
+
"src/",
|
|
25
|
+
"docs/",
|
|
26
|
+
".agents/skills/odoo-cli/",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"COPYRIGHT"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"registry": "https://registry.npmjs.org/"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test test/*.test.mjs",
|
|
36
|
+
"check": "node --check bin/odoo.mjs && node --check src/cli.mjs && node --check src/commands.mjs",
|
|
37
|
+
"prepublishOnly": "npm run check && npm test"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"commander": "15.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|
package/src/archive.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
function catalogFile() {
|
|
7
|
+
const home = os.homedir();
|
|
8
|
+
const appData = process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support')
|
|
9
|
+
: process.platform === 'win32' ? process.env.APPDATA || path.join(home, 'AppData', 'Roaming')
|
|
10
|
+
: process.env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
11
|
+
return process.env.ODOO_MANAGER_CATALOG || path.join(appData, 'Odoo Manager', 'manager.sqlite');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function gitCommonDirectory(root) {
|
|
15
|
+
try {
|
|
16
|
+
const value = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
17
|
+
{cwd: root, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore']});
|
|
18
|
+
return fs.realpathSync(value.trim());
|
|
19
|
+
} catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveProject(db, root, optional) {
|
|
23
|
+
const direct = db.prepare('SELECT * FROM projects WHERE root = ?').get(root);
|
|
24
|
+
if (direct) return direct;
|
|
25
|
+
const common = gitCommonDirectory(root);
|
|
26
|
+
if (!common && optional) return null;
|
|
27
|
+
if (!common) throw new Error('请先在 Odoo Manager 接入当前项目。');
|
|
28
|
+
const matches = db.prepare('SELECT * FROM projects').all()
|
|
29
|
+
.filter(project => gitCommonDirectory(project.root) === common);
|
|
30
|
+
if (!matches.length && optional) return null;
|
|
31
|
+
if (matches.length !== 1) throw new Error(matches.length ? '当前工作树匹配了多个项目,请从已接入的项目目录执行。' : '请先在 Odoo Manager 接入当前项目。');
|
|
32
|
+
return matches[0];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function withArchiveCatalog(root, write, action, {optional = false} = {}) {
|
|
36
|
+
const file = catalogFile();
|
|
37
|
+
if (!fs.statSync(file, {throwIfNoEntry: false})?.isFile()) {
|
|
38
|
+
if (optional) return null;
|
|
39
|
+
throw new Error('找不到本机存档库,请先在 Odoo Manager 接入项目。');
|
|
40
|
+
}
|
|
41
|
+
const {DatabaseSync} = await import('node:sqlite');
|
|
42
|
+
const db = new DatabaseSync(file, {readOnly: !write});
|
|
43
|
+
try {
|
|
44
|
+
db.exec('PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
|
|
45
|
+
const project = resolveProject(db, root, optional);
|
|
46
|
+
if (!project) return null;
|
|
47
|
+
if (write) ensureArchiveTags(db);
|
|
48
|
+
return await action(db, project);
|
|
49
|
+
} finally { db.close(); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hasTable = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
|
|
53
|
+
|
|
54
|
+
export function transaction(db, action) {
|
|
55
|
+
db.exec('BEGIN IMMEDIATE');
|
|
56
|
+
try {
|
|
57
|
+
const result = action();
|
|
58
|
+
db.exec('COMMIT');
|
|
59
|
+
return result;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
db.exec('ROLLBACK');
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ensureArchiveTags(db) {
|
|
67
|
+
transaction(db, () => {
|
|
68
|
+
db.exec(`CREATE TABLE IF NOT EXISTS archive_tags (
|
|
69
|
+
projectId TEXT NOT NULL REFERENCES projects(id),
|
|
70
|
+
tag TEXT NOT NULL,
|
|
71
|
+
archiveId TEXT NOT NULL REFERENCES archives(id),
|
|
72
|
+
PRIMARY KEY (projectId, tag)
|
|
73
|
+
);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS project_seeds (
|
|
75
|
+
projectId TEXT PRIMARY KEY REFERENCES projects(id), reference TEXT NOT NULL
|
|
76
|
+
);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS project_runtimes (
|
|
78
|
+
root TEXT PRIMARY KEY, projectId TEXT NOT NULL REFERENCES projects(id), composeProject TEXT NOT NULL
|
|
79
|
+
)`);
|
|
80
|
+
if (hasTable(db, 'archive_defaults')) {
|
|
81
|
+
db.exec(`INSERT OR IGNORE INTO archive_tags
|
|
82
|
+
SELECT projectId, 'latest', archiveId FROM archive_defaults;
|
|
83
|
+
DROP TABLE archive_defaults;`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function projectTags(db, projectId) {
|
|
89
|
+
if (hasTable(db, 'archive_tags')) {
|
|
90
|
+
return db.prepare('SELECT tag, archiveId FROM archive_tags WHERE projectId = ? ORDER BY tag').all(projectId);
|
|
91
|
+
}
|
|
92
|
+
if (hasTable(db, 'archive_defaults')) {
|
|
93
|
+
return db.prepare("SELECT 'latest' AS tag, archiveId FROM archive_defaults WHERE projectId = ?").all(projectId);
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function archiveList(root, {json}) {
|
|
99
|
+
const result = await withArchiveCatalog(root, false, (db, project) => {
|
|
100
|
+
const tags = projectTags(db, project.id);
|
|
101
|
+
const rows = db.prepare('SELECT id, title, note, environment, database, bytes, created, revision, source FROM archives WHERE projectId = ? ORDER BY created DESC').all(project.id);
|
|
102
|
+
return rows.map(archive => ({...archive, tags: tags.filter(tag => tag.archiveId === archive.id).map(tag => tag.tag)}));
|
|
103
|
+
});
|
|
104
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
105
|
+
else if (!result.length) console.log('此项目还没有数据库存档。');
|
|
106
|
+
else for (const archive of result) console.log([archive.id, archive.title, archive.database, archive.tags.join(', ')].filter(Boolean).join(' '));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findArchive(db, projectId, id, requireFile = true) {
|
|
110
|
+
const row = db.prepare('SELECT * FROM archives WHERE projectId = ? AND id = ?').get(projectId, id);
|
|
111
|
+
if (!row) throw new Error('此项目没有该存档:' + id);
|
|
112
|
+
if (requireFile && (!path.isAbsolute(row.file) || !fs.statSync(row.file, {throwIfNoEntry: false})?.isFile())) {
|
|
113
|
+
throw new Error('存档 ZIP 已丢失或不可用:' + id + '。请在 Odoo Manager 检查存档。');
|
|
114
|
+
}
|
|
115
|
+
return row;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function tagName(value) {
|
|
119
|
+
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(value)) throw new Error('标签须以小写字母开头,最多 64 字符,可包含小写字母、数字、点、下划线和短横线。');
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findReference(db, projectId, reference) {
|
|
124
|
+
if (reference.startsWith('id:')) return findArchive(db, projectId, reference.slice(3));
|
|
125
|
+
const tags = projectTags(db, projectId);
|
|
126
|
+
const explicitTag = reference.startsWith('tag:');
|
|
127
|
+
const tag = explicitTag ? tagName(reference.slice(4)) : reference === 'default' ? 'latest' : reference;
|
|
128
|
+
const selected = tags.find(row => row.tag === tag);
|
|
129
|
+
if (!selected && (explicitTag || reference === 'default' || reference === 'latest')) throw new Error('此项目尚未设置标签:' + tag);
|
|
130
|
+
return findArchive(db, projectId, selected?.archiveId || reference);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function resolveArchive(root, reference) {
|
|
134
|
+
return withArchiveCatalog(root, false, (db, project) => findReference(db, project.id, reference));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function archiveSeed(root, reference, {json} = {}) {
|
|
138
|
+
const result = await withArchiveCatalog(root, reference !== undefined, (db, project) => {
|
|
139
|
+
if (reference !== undefined) transaction(db, () => {
|
|
140
|
+
if (reference.startsWith('tag:')) tagName(reference.slice(4));
|
|
141
|
+
else if (reference.startsWith('id:')) findReference(db, project.id, reference);
|
|
142
|
+
else if (reference !== 'empty') throw new Error('数据来源使用 tag:<标签>、id:<存档ID> 或 empty。');
|
|
143
|
+
db.prepare('INSERT INTO project_seeds VALUES (?, ?) ON CONFLICT(projectId) DO UPDATE SET reference = excluded.reference')
|
|
144
|
+
.run(project.id, reference);
|
|
145
|
+
});
|
|
146
|
+
return hasTable(db, 'project_seeds') ? db.prepare('SELECT reference FROM project_seeds WHERE projectId = ?').get(project.id)?.reference : null;
|
|
147
|
+
});
|
|
148
|
+
console.log(json ? JSON.stringify({reference: result || 'empty'}) : (result || 'empty'));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function startupSeed(root, values, preview = false) {
|
|
152
|
+
if (values.ENV === 'PROD' || values.ODOO_SEED_MODE === 'empty' ||
|
|
153
|
+
(values.ODOO_SEED && values.ODOO_SEED_MODE !== 'first-start')) return {reference: null, source: 'env'};
|
|
154
|
+
const configured = values.ODOO_SEED && values.ODOO_SEED_MODE === 'first-start' ? values.ODOO_SEED : null;
|
|
155
|
+
return await withArchiveCatalog(root, false, (db, project) => {
|
|
156
|
+
const projectSeed = root !== project.root && hasTable(db, 'project_seeds') ? db.prepare('SELECT reference FROM project_seeds WHERE projectId = ?').get(project.id)?.reference : null;
|
|
157
|
+
const reference = configured || projectSeed || null;
|
|
158
|
+
const source = configured ? 'env' : 'project';
|
|
159
|
+
if (!reference || reference === 'empty') return {reference: null, source};
|
|
160
|
+
if (!preview) return {reference, source};
|
|
161
|
+
try {
|
|
162
|
+
const archive = findReference(db, project.id, reference);
|
|
163
|
+
return {reference, source, archive: {id: archive.id, title: archive.title}};
|
|
164
|
+
} catch (error) { return {reference, source, issue: error.message}; }
|
|
165
|
+
}, {optional: true}) || {reference: configured, source: configured ? 'env' : 'project',
|
|
166
|
+
...(configured && preview ? {issue: '请先在 Odoo Manager 接入当前项目。'} : {})};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function rememberRuntime(root, composeProject) {
|
|
170
|
+
return withArchiveCatalog(root, true, (db, project) => {
|
|
171
|
+
if (root === project.root) return;
|
|
172
|
+
db.prepare('INSERT INTO project_runtimes VALUES (?, ?, ?) ON CONFLICT(root) DO UPDATE SET composeProject = excluded.composeProject')
|
|
173
|
+
.run(root, project.id, composeProject);
|
|
174
|
+
}, {optional: true});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function archiveTags(root, {json}) {
|
|
178
|
+
const rows = await withArchiveCatalog(root, false, (db, project) =>
|
|
179
|
+
projectTags(db, project.id).map(row => ({...row, title: findArchive(db, project.id, row.archiveId, false).title})));
|
|
180
|
+
if (json) console.log(JSON.stringify(rows, null, 2));
|
|
181
|
+
else for (const row of rows) console.log(row.tag + ' → ' + row.title + ' (' + row.archiveId + ')');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function archiveTag(root, tag, id) {
|
|
185
|
+
tagName(tag);
|
|
186
|
+
await withArchiveCatalog(root, true, (db, project) => transaction(db, () => {
|
|
187
|
+
if (!id) {
|
|
188
|
+
db.prepare('DELETE FROM archive_tags WHERE projectId = ? AND tag = ?').run(project.id, tag);
|
|
189
|
+
console.log('已移除标签:' + tag);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const row = findArchive(db, project.id, id);
|
|
193
|
+
pointTag(db, project.id, tag, row.id);
|
|
194
|
+
console.log(tag + ' → ' + row.title + ' (' + row.id + ')');
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function archiveDefault(root, id) {
|
|
199
|
+
if (id) return archiveTag(root, 'latest', id);
|
|
200
|
+
const selected = await withArchiveCatalog(root, false, (db, project) => projectTags(db, project.id).find(row => row.tag === 'latest'));
|
|
201
|
+
console.log(selected?.archiveId || '此项目尚未设置 latest。');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function deleteArchive(root, id) {
|
|
205
|
+
await withArchiveCatalog(root, true, (db, project) => transaction(db, () => {
|
|
206
|
+
const row = findArchive(db, project.id, id, false);
|
|
207
|
+
const tags = projectTags(db, project.id).filter(tag => tag.archiveId === id);
|
|
208
|
+
if (tags.length) throw new Error('请先移动或移除标签:' + tags.map(row => row.tag).join(', '));
|
|
209
|
+
if (db.prepare('SELECT 1 FROM project_seeds WHERE projectId = ? AND reference = ?').get(project.id, 'id:' + id)) {
|
|
210
|
+
throw new Error('该存档用于新工作树首次启动,请先更改项目的数据来源。');
|
|
211
|
+
}
|
|
212
|
+
if (!path.isAbsolute(row.file) || path.basename(row.file) !== row.id + '.zip') throw new Error('无法确认存档文件位置。');
|
|
213
|
+
fs.rmSync(row.file, {force: true});
|
|
214
|
+
db.prepare('DELETE FROM archives WHERE projectId = ? AND id = ?').run(project.id, id);
|
|
215
|
+
console.log('已删除存档:' + row.title);
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function pointTag(db, projectId, tag, id) {
|
|
220
|
+
db.prepare('INSERT INTO archive_tags VALUES (?, ?, ?) ON CONFLICT(projectId, tag) DO UPDATE SET archiveId = excluded.archiveId')
|
|
221
|
+
.run(projectId, tag, id);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function insertArchive(db, archive) {
|
|
225
|
+
const {id, projectId, title, note, environment, database, file, bytes, created, revision, source} = archive;
|
|
226
|
+
db.prepare('INSERT INTO archives (id, projectId, title, note, environment, database, file, bytes, created, revision, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
227
|
+
.run(id, projectId, title, note, environment, database, file, bytes, created, revision, source);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function saveArchive(db, archive, tag) {
|
|
231
|
+
transaction(db, () => {
|
|
232
|
+
insertArchive(db, archive);
|
|
233
|
+
if (tag) pointTag(db, archive.projectId, tag, archive.id);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function archiveLocation(db, root, id) {
|
|
238
|
+
const settings = JSON.parse(db.prepare('SELECT value FROM settings WHERE id = 1').get()?.value || '{}');
|
|
239
|
+
if (typeof settings.archiveRoot !== 'string' || !path.isAbsolute(settings.archiveRoot)) {
|
|
240
|
+
throw new Error('请在 Odoo Manager 设置存档目录。');
|
|
241
|
+
}
|
|
242
|
+
fs.mkdirSync(settings.archiveRoot, {recursive: true});
|
|
243
|
+
const directory = fs.realpathSync(settings.archiveRoot);
|
|
244
|
+
const roots = [root, ...db.prepare('SELECT root FROM environments').all().map(env => env.root)];
|
|
245
|
+
if (roots.some(checkout => {
|
|
246
|
+
const relative = path.relative(checkout, directory);
|
|
247
|
+
return !relative || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
|
|
248
|
+
})) throw new Error('请选择项目和工作树之外的存档目录。');
|
|
249
|
+
return path.join(directory, id + '.zip');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function archiveSource(db, project, root) {
|
|
253
|
+
const name = db.prepare('SELECT name FROM environments WHERE projectId = ? AND root = ?').get(project.id, root)?.name;
|
|
254
|
+
let revision = '';
|
|
255
|
+
try {
|
|
256
|
+
const git = args => execFileSync('git', args, {cwd: root, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore']}).trim();
|
|
257
|
+
revision = git(['rev-parse', 'HEAD']) + (git(['status', '--porcelain']) ? '(有未提交改动)' : '');
|
|
258
|
+
} catch { /* Git metadata is optional. */ }
|
|
259
|
+
return {environment: name || path.basename(root), revision};
|
|
260
|
+
}
|