@mjh-odoo/cli 0.0.0-stage → 0.1.0
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/COPYRIGHT +3 -0
- package/LICENSE +859 -0
- package/README.md +100 -2
- package/bin/odoo.mjs +11 -0
- package/docs/validation.md +35 -0
- package/package.json +39 -4
- package/src/cli.mjs +101 -0
- package/src/commands.mjs +167 -0
- package/src/lib/environment.mjs +90 -0
- package/src/lib/image.mjs +66 -0
- package/src/lib/logs.mjs +178 -0
- package/src/project.mjs +77 -0
package/README.md
CHANGED
|
@@ -1,3 +1,101 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mjh-odoo/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
跨平台 Odoo 20 开发管理 CLI,命令名为 `odoo`。用于 mjh-odoo 社区版 base 及基于它建立的项目。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
需要 Node.js 24+、Docker Engine/Desktop、Docker Compose 和 Buildx。Windows 可在 PowerShell 中使用,无需 Make 或 Bash。Odoo/Python/PostgreSQL 在容器内运行。
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g @mjh-odoo/cli
|
|
11
|
+
odoo --help
|
|
12
|
+
odoo --version
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
CLI 不附带 Odoo 源码,也不会创建完整项目。现阶段需先准备兼容的 base 仓库:根目录包含 `.env.example`、`compose.yaml`、`odoo/`、`addons/`、`scripts/runtime.py`、`scripts/backup_utils.py` 和 `config/`。Base 当前独立验收,尚未公开发布。
|
|
16
|
+
|
|
17
|
+
进入项目后运行:
|
|
18
|
+
|
|
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
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 常用命令
|
|
35
|
+
|
|
36
|
+
```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
|
+
|
|
47
|
+
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
|
+
|
|
53
|
+
odoo db list --json
|
|
54
|
+
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 # 查看管理工具的转发说明
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
每个命令支持 `--help`,官方命令转发 `exec` 的说明使用 `odoo help exec`。
|
|
77
|
+
|
|
78
|
+
## 配置与数据
|
|
79
|
+
|
|
80
|
+
项目使用 `.env + compose.yaml + config/`。`init` 只在缺少 `.env` 时生成独立项目身份、可用端口和随机密码;重复运行保留已有文件。不同代码目录(包括 Git 工作树)默认使用不同身份、端口和数据卷。手动复制 `.env` 时需自行调整这些值。
|
|
81
|
+
|
|
82
|
+
`module install/update` 先停止 Odoo,使用一次性维护容器,再启动服务;失败时保留停止状态供排查。`-d` 只覆盖本次维护的目标库,不修改 `.env`。
|
|
83
|
+
|
|
84
|
+
备份包含数据库和 filestore,使用 Odoo 标准 ZIP;默认保存到用户数据目录的 `mjh-odoo/backups/<PROJECT_NAME>/`,位于代码目录之外,`config` 可查看实际路径。现有文件不会被覆盖。备份时应避免并发业务写入,保证数据库和附件的一致性。
|
|
85
|
+
|
|
86
|
+
恢复先校验 ZIP 路径、CRC、清单及 Odoo 20.0 主版本,再停止服务。默认中和外发任务;已有目标库需要显式 `--force`。恢复新库不会自动切换项目,切换时修改 `.env` 中的 `PGDATABASE`。
|
|
87
|
+
|
|
88
|
+
日志默认读取最近 200 行有限快照;筛选保留相邻事件与异常堆栈。`--json` 和 `--file` 不能与 `-f` 同用。已知 `.env` 密码值会被遮盖,业务内容仍需自行判断。临时模块维护容器的失败信息以维护命令本身的输出为准。
|
|
89
|
+
|
|
90
|
+
本版本包含环境、模块、ZIP 备份恢复、日志、镜像管理。桌面 App、存档目录索引、工作树自动克隆数据库和定时备份尚未实现。
|
|
91
|
+
|
|
92
|
+
## 开发与验收
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
npm ci
|
|
96
|
+
npm run check
|
|
97
|
+
npm test
|
|
98
|
+
npm pack --dry-run
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
测试覆盖跨平台参数解析、中文及空格路径、帮助无副作用、批量模块维护、日志和镜像计划。真实 Docker 验收范围见 [验证记录](docs/validation.md)。LGPL-3.0-only,见 LICENSE 和 COPYRIGHT。
|
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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# 0.1.0 验证记录
|
|
2
|
+
|
|
3
|
+
日期:2026-11-03。
|
|
4
|
+
|
|
5
|
+
## 本机与包
|
|
6
|
+
|
|
7
|
+
macOS ARM64,Node 26.8.1、Docker 29.7.2、Compose 5.4.0;容器 Odoo 20.0、Python 3.12、PostgreSQL 17。
|
|
8
|
+
|
|
9
|
+
32 项 Node 测试通过,覆盖环境隔离、重复初始化、中文和空格路径、全量帮助、参数校验、批量模块维护、日志事件与遮盖、镜像计划与离线文件。npm pack 后安装到独立临时前缀,实际 odoo --version、--help 及所有以下 Docker 检查均使用安装后的可执行文件。
|
|
10
|
+
|
|
11
|
+
发布包约 31 KB,files 白名单仅含 bin、src、docs、README、package.json 与许可;逐项检查无 Odoo 源码、真实环境文件、数据库、附件、node_modules 或本机绝对路径。
|
|
12
|
+
|
|
13
|
+
## 隔离 Docker 验收
|
|
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 字节未改,原有运行容器继续运行。
|
|
24
|
+
|
|
25
|
+
本机 Docker 默认地址池已满,验收专门创建独立、无冲突的临时内部网络,结束后删除。此测试安排没有写入通用 base 配置,也没有清理其他项目网络。
|
|
26
|
+
|
|
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 发布中上传。
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjh-odoo/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"LICENSE",
|
|
27
|
+
"COPYRIGHT"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test test/*.test.mjs",
|
|
35
|
+
"check": "node --check bin/odoo.mjs && node --check src/cli.mjs && node --check src/commands.mjs",
|
|
36
|
+
"prepublishOnly": "npm run check && npm test"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"commander": "15.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Command, Option, InvalidArgumentError } from 'commander';
|
|
2
|
+
import { findProject, openProject, databaseName, runDocker, projectVersion } from './project.mjs';
|
|
3
|
+
import { readEnv } from './lib/environment.mjs';
|
|
4
|
+
import { parseLogOptions, executeLogs } from './lib/logs.mjs';
|
|
5
|
+
import { imageBuildPlan, executeImageBuild } from './lib/image.mjs';
|
|
6
|
+
import * as commands from './commands.mjs';
|
|
7
|
+
|
|
8
|
+
function integer(value) {
|
|
9
|
+
if (!/^\d+$/.test(value) || +value > 3600) throw new InvalidArgumentError('请输入 0..3600 的整数。');
|
|
10
|
+
return +value;
|
|
11
|
+
}
|
|
12
|
+
function positive(value) {
|
|
13
|
+
const n = integer(value);
|
|
14
|
+
if (!n) throw new InvalidArgumentError('等待时间必须大于 0。');
|
|
15
|
+
return n;
|
|
16
|
+
}
|
|
17
|
+
function argumentsFor(options, names) {
|
|
18
|
+
return names.flatMap(name => {
|
|
19
|
+
const key = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
20
|
+
const value = options[key];
|
|
21
|
+
return value === true ? ['--' + name] : value === undefined || value === false ? [] : ['--' + name, String(value)];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createProgram({version, cwd = process.cwd(), runner = runDocker} = {}) {
|
|
26
|
+
const program = new Command();
|
|
27
|
+
program.name('odoo').description('Odoo 项目开发管理:环境、模块、数据库、日志与镜像。').version(version, '-V, --version', '显示管理 CLI 版本')
|
|
28
|
+
.option('-C, --project <directory>', '指定项目目录;默认从当前目录向上查找')
|
|
29
|
+
.enablePositionalOptions().addHelpCommand('help [command...]', '查看命令帮助').showHelpAfterError('(运行 odoo --help 查看命令)').showSuggestionAfterError(true);
|
|
30
|
+
program.addHelpText('after', '\n示例:\n odoo up\n odoo logs all --since 10m --json\n odoo module update my_module\n odoo db backup\n odoo image build --help\n\n全局 --project 放在命令前,例如 odoo --project /path/to/erp status。');
|
|
31
|
+
const root = () => findProject(program.opts().project || cwd);
|
|
32
|
+
const context = initialize => openProject(root(), {initialize, runner});
|
|
33
|
+
const waitOption = command => command.option('--wait-timeout <seconds>', '健康检查等待秒数', positive, 300);
|
|
34
|
+
const databaseOption = command => command.option('-d, --database <name>', '目标数据库,默认 PGDATABASE', databaseName);
|
|
35
|
+
|
|
36
|
+
program.command('version').description('显示管理 CLI 版本').action(() => console.log(version));
|
|
37
|
+
program.command('init').description('为当前 base 项目准备本机 .env').option('--json', '输出脱敏 JSON').action(async options => commands.initialize(await context(true), options));
|
|
38
|
+
program.command('doctor').description('检查 Docker、Compose、源码和配置').option('--json', '输出 JSON').action(async options => commands.doctor(await context(false), options));
|
|
39
|
+
program.command('config').description('输出项目脱敏配置及存档位置').action(async () => commands.configuration(await context(false)));
|
|
40
|
+
waitOption(program.command('up').description('后台启动项目并等待健康').option('--build', '构建开发镜像后启动(默认)', true).option('--no-build', '直接使用已有镜像,不构建')
|
|
41
|
+
.addOption(new Option('--pull <policy>', '镜像拉取策略').choices(['always', 'missing', 'never'])))
|
|
42
|
+
.action(async options => commands.up(await context(true), options));
|
|
43
|
+
program.command('down').description('停止项目,默认保留数据库和附件卷')
|
|
44
|
+
.option('-v, --volumes', '同时删除本项目数据库及附件卷').option('--timeout <seconds>', '停止等待秒数', integer, 30).option('--dry-run', '只显示操作计划,不执行停止或删除')
|
|
45
|
+
.action(async options => commands.down(await context(false), options));
|
|
46
|
+
waitOption(program.command('restart').description('重启服务,默认只重启 Odoo').argument('[services...]', 'odoo、db 或 all').option('--timeout <seconds>', '停止等待秒数', integer, 30))
|
|
47
|
+
.action(async (services, options) => commands.restart(await context(false), services || [], options));
|
|
48
|
+
program.command('status').description('查看项目容器状态,包含已停止容器').option('--json', '输出 JSON 数组').action(async options => commands.status(await context(false), options));
|
|
49
|
+
program.command('build').description('构建项目 Compose 开发镜像').option('--pull', '更新上游镜像').option('--no-cache', '不使用构建缓存').action(async options => commands.build(await context(false), options));
|
|
50
|
+
|
|
51
|
+
const logs = program.command('logs').description('默认读取最近 200 行快照;-f 才持续跟随').argument('[services...]', 'odoo、db 或 all,默认 odoo')
|
|
52
|
+
.option('-f, --follow', '持续查看,Ctrl+C 只退出查看').option('-n, --tail <lines>', '每容器行数,支持 0 或 all', '200')
|
|
53
|
+
.option('--since <time>', '起始时间,如 10m 或带时区 RFC3339').option('--until <time>', '截止时间')
|
|
54
|
+
.option('--level <level>', '最低级别:debug/info/warning/error/critical').option('--match <text>', '不区分大小写的字面筛选')
|
|
55
|
+
.option('--context <events>', '筛选结果前后事件数,默认 2').option('--json', '输出完整 JSON 快照').option('--file <path>', '保存快照,拒绝覆盖已有文件');
|
|
56
|
+
logs.addHelpText('after', '\n筛选、JSON 和文件用于有限快照,不能与 -f 同用。保留完整异常堆栈;--tail 起点仍可能截到异常中间。');
|
|
57
|
+
logs.action(async (services, options) => {
|
|
58
|
+
const parsed = parseLogOptions([...(services || []), ...argumentsFor(options, ['follow', 'tail', 'since', 'until', 'level', 'match', 'context', 'json', 'file'])]);
|
|
59
|
+
process.exitCode = await executeLogs(parsed, await context(false));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const module = program.command('module').description('查询、安装和升级模块');
|
|
63
|
+
databaseOption(module.command('list').description('列出已安装模块').option('--all', '包含未安装模块').option('--json', '输出 JSON')).action(async options => commands.listModules(await context(false), options));
|
|
64
|
+
for (const operation of ['install', 'update']) {
|
|
65
|
+
const command = databaseOption(module.command(operation).description(operation === 'install' ? '批量安装模块' : '批量升级已安装模块')
|
|
66
|
+
.argument(operation === 'install' ? '<modules...>' : '[modules...]', '用空格分隔多个模块名').option('--no-restart', '完成后保持 Odoo 停止'));
|
|
67
|
+
if (operation === 'update') command.option('--all', '升级全部已安装的项目扩展(不包含官方核心)');
|
|
68
|
+
command.action(async (names, options) => commands.maintainModules(await context(false), operation, names || [], options));
|
|
69
|
+
}
|
|
70
|
+
module.action(() => module.help());
|
|
71
|
+
|
|
72
|
+
const db = program.command('db').description('数据库查询及标准 ZIP 备份恢复');
|
|
73
|
+
db.command('list').description('列出项目 PostgreSQL 中的数据库').option('--json', '输出 JSON;current 表示当前配置的数据库').action(async options => commands.listDatabases(await context(false), options));
|
|
74
|
+
databaseOption(db.command('backup').description('导出数据库及附件 ZIP').option('--file <path>', '输出路径;默认保存到工作树之外')).action(async options => commands.backup(await context(false), options));
|
|
75
|
+
databaseOption(db.command('restore').description('恢复 ZIP,默认中和外发任务').requiredOption('--file <path>', '已有的 Odoo 20 ZIP')
|
|
76
|
+
.option('--force', '允许覆盖已有目标数据库').option('--no-restart', '恢复后保持 Odoo 停止')).action(async options => commands.restore(await context(false), options));
|
|
77
|
+
db.action(() => db.help());
|
|
78
|
+
|
|
79
|
+
const image = program.command('image').description('构建基础镜像、项目镜像和离线包');
|
|
80
|
+
image.command('build').description('默认构建本地基础镜像')
|
|
81
|
+
.option('--platform <platforms>', 'amd64、arm64 或 amd64,arm64')
|
|
82
|
+
.option('--tag <name:version>', '镜像标签;推送和 production 必填')
|
|
83
|
+
.addOption(new Option('--target <stage>', '构建阶段').choices(['base', 'production']).default('base'))
|
|
84
|
+
.option('--file <path.tar>', '导出单架构离线包').option('--push', '明确推送到镜像仓库').option('--builder <name>', '使用已有 Buildx 构建器').option('--dry-run', '仅输出 JSON 计划')
|
|
85
|
+
.action(async options => {
|
|
86
|
+
const directory = root();
|
|
87
|
+
const plan = imageBuildPlan(argumentsFor(options, ['platform', 'tag', 'target', 'file', 'push', 'builder', 'dry-run']), {root: directory, version: projectVersion(directory), mirror: readEnv(directory).APT_MIRROR_BASE});
|
|
88
|
+
if (plan.dryRun) { console.log(JSON.stringify(plan, null, 2)); return; }
|
|
89
|
+
console.log(`构建 ${plan.target}: ${plan.tag} (${plan.platforms.join(',') || '当前构建器平台'})`);
|
|
90
|
+
await executeImageBuild(plan, (argv, stdio) => runner(argv, {root: directory, env: process.env, stdio}));
|
|
91
|
+
console.log(plan.file ? '离线包已保存: ' + plan.file : plan.output === 'registry' ? '镜像已推送: ' + plan.tag : '本地镜像就绪: ' + plan.tag);
|
|
92
|
+
});
|
|
93
|
+
image.action(() => image.help());
|
|
94
|
+
|
|
95
|
+
program.command('exec').description('在运行中的 Odoo 容器里转发官方命令;本地说明用 help exec')
|
|
96
|
+
.helpOption(false).allowUnknownOption(true).passThroughOptions().argument('[args...]', '原样传给官方 Odoo CLI 的参数')
|
|
97
|
+
.addHelpText('after', '\n示例:\n odoo exec -- --version\n odoo exec -- --help\n odoo exec -- shell -c /etc/odoo/odoo.conf')
|
|
98
|
+
.action(async args => commands.native(await context(false), args || []));
|
|
99
|
+
program.action(() => program.help());
|
|
100
|
+
return program;
|
|
101
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { archiveDirectory, checkModuleNames, publicSettings } from './lib/environment.mjs';
|
|
4
|
+
import { databaseName, serviceNames, withLock } from './project.mjs';
|
|
5
|
+
|
|
6
|
+
const start = (ctx, services = ['odoo'], timeout = 300) => ctx.run(['up', '-d', '--no-build', '--wait', '--wait-timeout', String(timeout), ...services]);
|
|
7
|
+
|
|
8
|
+
export async function initialize(ctx, options) {
|
|
9
|
+
if (options.json) console.log(JSON.stringify({projectDirectory: ctx.root, ...publicSettings(ctx.values)}, null, 2));
|
|
10
|
+
else {
|
|
11
|
+
console.log('项目配置就绪: ' + ctx.values.PROJECT_NAME);
|
|
12
|
+
console.log('Odoo: http://localhost:' + ctx.values.ODOO_HTTP_PORT);
|
|
13
|
+
console.log('初始管理员密码位于本机 .env 的 ODOO_ADMIN_PASSWORD。');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function doctor(ctx, options) {
|
|
18
|
+
const result = {project: ctx.values.PROJECT_NAME, directory: ctx.root, node: process.versions.node};
|
|
19
|
+
for (const [name, argv] of [['docker', ['version', '--format', '{{.Server.Version}}']], ['compose', ['compose', 'version', '--short']], ['buildx', ['buildx', 'version']]]) result[name] = await ctx.docker(argv, {capture: true});
|
|
20
|
+
if (!fs.existsSync(path.join(ctx.root, 'odoo', 'odoo-bin'))) throw new Error('项目缺少完整 Odoo 源码: odoo/odoo-bin。');
|
|
21
|
+
if (!fs.existsSync(path.join(ctx.root, 'scripts', 'runtime.py'))) throw new Error('项目缺少容器入口 scripts/runtime.py。');
|
|
22
|
+
await ctx.run(['config', '--quiet'], {capture: true});
|
|
23
|
+
result.valid = true;
|
|
24
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
25
|
+
else for (const [name, value] of Object.entries(result)) console.log(`${name}: ${value}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function configuration(ctx) {
|
|
29
|
+
console.log(JSON.stringify({PROJECT_DIRECTORY: ctx.root, ...publicSettings(ctx.values), BACKUP_DIRECTORY: archiveDirectory(ctx.values)}, null, 2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function up(ctx, options) {
|
|
33
|
+
await withLock(ctx, async () => {
|
|
34
|
+
const args = ['up', '-d', options.build ? '--build' : '--no-build', '--wait', '--wait-timeout', String(options.waitTimeout)];
|
|
35
|
+
if (options.pull) args.push('--pull', options.pull);
|
|
36
|
+
await ctx.run(args);
|
|
37
|
+
console.log('Odoo: http://localhost:' + ctx.values.ODOO_HTTP_PORT);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function down(ctx, options) {
|
|
42
|
+
const args = ['down', '--timeout', String(options.timeout)];
|
|
43
|
+
if (options.volumes) args.push('--volumes');
|
|
44
|
+
if (options.dryRun) {
|
|
45
|
+
console.log(JSON.stringify({project: ctx.values.PROJECT_NAME, directory: ctx.root, removeVolumes: !!options.volumes, docker: [...ctx.compose, ...args]}, null, 2));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await withLock(ctx, async () => {
|
|
49
|
+
if (options.volumes) console.error('删除本项目数据库和附件卷: ' + ctx.values.PROJECT_NAME);
|
|
50
|
+
await ctx.run(args);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function restart(ctx, names, options) {
|
|
55
|
+
const services = serviceNames(names);
|
|
56
|
+
await withLock(ctx, async () => {
|
|
57
|
+
await ctx.run(['restart', '--timeout', String(options.timeout), ...services]);
|
|
58
|
+
await start(ctx, services, options.waitTimeout);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function status(ctx, options) {
|
|
63
|
+
const args = ['ps', '--all'];
|
|
64
|
+
if (options.json) {
|
|
65
|
+
const raw = await ctx.run([...args, '--format', 'json'], {capture: true});
|
|
66
|
+
let result;
|
|
67
|
+
try { result = raw ? JSON.parse(raw) : []; }
|
|
68
|
+
catch { result = raw.split('\n').filter(Boolean).map(line => JSON.parse(line)); }
|
|
69
|
+
console.log(JSON.stringify(Array.isArray(result) ? result : [result], null, 2));
|
|
70
|
+
} else await ctx.run(args);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function build(ctx, options) {
|
|
74
|
+
await withLock(ctx, () => ctx.run(['build', ...(options.pull ? ['--pull'] : []), ...(options.cache === false ? ['--no-cache'] : []), 'odoo']));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function maintainModules(ctx, operation, names, options) {
|
|
78
|
+
const modules = checkModuleNames(names);
|
|
79
|
+
if (operation === 'install' && !modules.length) throw new Error('请指定至少一个要安装的模块。');
|
|
80
|
+
if (operation === 'update' && !modules.length && !options.all) throw new Error('请指定模块名,或使用 --all 升级已安装的项目扩展。');
|
|
81
|
+
if (options.all && modules.length) throw new Error('--all 不能与模块名同时使用。');
|
|
82
|
+
if (options.database) databaseName(options.database);
|
|
83
|
+
await withLock(ctx, async () => {
|
|
84
|
+
await ctx.run(['up', '-d', '--wait', 'db']);
|
|
85
|
+
await ctx.run(['stop', 'odoo']);
|
|
86
|
+
const args = ['run', '--rm', '--no-deps', '-T'];
|
|
87
|
+
if (options.database) args.push('--env', 'PGDATABASE=' + options.database);
|
|
88
|
+
args.push('--entrypoint', 'python3', 'odoo', '/opt/odoo/scripts/runtime.py', operation === 'install' ? 'install' : 'upgrade', ...modules);
|
|
89
|
+
await ctx.run(args);
|
|
90
|
+
if (options.restart !== false) await start(ctx);
|
|
91
|
+
else console.log('模块维护完成;Odoo 保持停止,可用 odoo up 启动。');
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function sql(ctx, database, query) {
|
|
96
|
+
databaseName(database);
|
|
97
|
+
const result = await ctx.run(['exec', '-T', 'db', 'psql', '--no-psqlrc', '--username', ctx.values.PGUSER, '--dbname', database, '--tuples-only', '--no-align', '--command', query], {capture: true});
|
|
98
|
+
return JSON.parse(result || '[]');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function listDatabases(ctx, options) {
|
|
102
|
+
const data = await sql(ctx, 'postgres', "SELECT coalesce(json_agg(t), '[]'::json) FROM (SELECT datname AS name FROM pg_database WHERE NOT datistemplate AND datname <> 'postgres' ORDER BY datname) t");
|
|
103
|
+
const rows = data.map(row => ({...row, current: row.name === ctx.values.PGDATABASE}));
|
|
104
|
+
if (options.json) console.log(JSON.stringify(rows, null, 2));
|
|
105
|
+
else for (const row of rows) console.log((row.current ? '* ' : ' ') + row.name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function listModules(ctx, options) {
|
|
109
|
+
const database = options.database || ctx.values.PGDATABASE;
|
|
110
|
+
const data = await sql(ctx, database, `SELECT coalesce(json_agg(t), '[]'::json) FROM (SELECT name, state, latest_version AS version FROM ir_module_module ${options.all ? '' : "WHERE state = 'installed'"} ORDER BY name) t`);
|
|
111
|
+
if (options.json) console.log(JSON.stringify(data, null, 2));
|
|
112
|
+
else for (const row of data) console.log(`${row.name.padEnd(40)} ${row.state.padEnd(14)} ${row.version || '-'}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function validateZip(ctx, filename) {
|
|
116
|
+
const fd = fs.openSync(filename, 'r');
|
|
117
|
+
try { await ctx.run(['run', '--rm', '--no-deps', '-T', '--entrypoint', 'python3', 'odoo', '/opt/odoo/scripts/backup_utils.py', 'validate'], {stdio: [fd, 'inherit', 'inherit']}); }
|
|
118
|
+
finally { fs.closeSync(fd); }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function backup(ctx, options) {
|
|
122
|
+
const database = databaseName(options.database || ctx.values.PGDATABASE);
|
|
123
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
124
|
+
const target = path.resolve(options.file || path.join(archiveDirectory(ctx.values), database + '-' + stamp + '.zip'));
|
|
125
|
+
if (fs.existsSync(target)) throw new Error('存档已存在: ' + target);
|
|
126
|
+
await withLock(ctx, async () => {
|
|
127
|
+
fs.mkdirSync(path.dirname(target), {recursive: true});
|
|
128
|
+
const temporary = target + '.part';
|
|
129
|
+
let fd;
|
|
130
|
+
try {
|
|
131
|
+
fd = fs.openSync(temporary, 'wx', 0o600);
|
|
132
|
+
await ctx.run(['exec', '-T', 'odoo', 'odoo', 'db', '-c', '/etc/odoo/odoo.conf', 'dump', database, '-'], {stdio: ['ignore', fd, 'inherit']});
|
|
133
|
+
fs.closeSync(fd);
|
|
134
|
+
fd = undefined;
|
|
135
|
+
await validateZip(ctx, temporary);
|
|
136
|
+
fs.linkSync(temporary, target);
|
|
137
|
+
console.log('存档已保存: ' + target);
|
|
138
|
+
} finally {
|
|
139
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
140
|
+
fs.rmSync(temporary, {force: true});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function restore(ctx, options) {
|
|
146
|
+
const source = path.resolve(options.file);
|
|
147
|
+
if (!fs.statSync(source, {throwIfNoEntry: false})?.isFile()) throw new Error('请提供已有的 ZIP 存档。');
|
|
148
|
+
const database = databaseName(options.database || ctx.values.PGDATABASE);
|
|
149
|
+
await withLock(ctx, async () => {
|
|
150
|
+
await validateZip(ctx, source);
|
|
151
|
+
await ctx.run(['up', '-d', '--wait', 'db']);
|
|
152
|
+
await ctx.run(['stop', 'odoo']);
|
|
153
|
+
const args = ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'python3', 'odoo', '/opt/odoo/scripts/backup_utils.py', 'restore', database];
|
|
154
|
+
if (options.force) args.push('--force');
|
|
155
|
+
const fd = fs.openSync(source, 'r');
|
|
156
|
+
try { await ctx.run(args, {stdio: [fd, 'inherit', 'inherit']}); }
|
|
157
|
+
finally { fs.closeSync(fd); }
|
|
158
|
+
if (options.restart !== false) await start(ctx);
|
|
159
|
+
console.log('已恢复并中和外发任务: ' + database);
|
|
160
|
+
if (database !== ctx.values.PGDATABASE) console.log('使用新库时,将项目 .env 中的 PGDATABASE 改为 ' + database + '。');
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function native(ctx, args) {
|
|
165
|
+
if (!args.length) throw new Error('请指定官方 Odoo 参数,例如 odoo exec -- --version。');
|
|
166
|
+
await ctx.run(['exec', ...(!process.stdin.isTTY || !process.stdout.isTTY ? ['-T'] : []), 'odoo', 'odoo', ...args]);
|
|
167
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { parseEnv } from 'node:util';
|
|
7
|
+
|
|
8
|
+
export function readEnv(root) {
|
|
9
|
+
const filename = path.join(root, '.env');
|
|
10
|
+
return fs.existsSync(filename) ? parseEnv(fs.readFileSync(filename, 'utf8')) : {};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function projectName(root) {
|
|
14
|
+
const location = fs.realpathSync(root);
|
|
15
|
+
const prefix = path.basename(location).toLowerCase().replace(/[^a-z0-9_-]/g, '_').replace(/^[_-]+/, '') || 'odoo';
|
|
16
|
+
return prefix.slice(0, 32) + '_' + crypto.createHash('sha256').update(location).digest('hex').slice(0, 10);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function portFree(port) {
|
|
20
|
+
return new Promise(resolve => {
|
|
21
|
+
const server = net.createServer();
|
|
22
|
+
server.once('error', () => resolve(false));
|
|
23
|
+
server.listen({host: '127.0.0.1', port, exclusive: true}, () => server.close(() => resolve(true)));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateEnv(values) {
|
|
28
|
+
for (const key of ['PROJECT_NAME', 'PGDATABASE', 'PGUSER', 'PGPASSWORD', 'ODOO_MASTER_PASSWORD', 'ODOO_ADMIN_PASSWORD']) {
|
|
29
|
+
if (!values[key]) throw new Error('Missing ' + key + '. Check .env or run init in a new checkout.');
|
|
30
|
+
if (/[\r\n]/.test(values[key])) throw new Error(key + ' must be a single line.');
|
|
31
|
+
}
|
|
32
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(values.PROJECT_NAME)) throw new Error('Invalid PROJECT_NAME.');
|
|
33
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(values.PGDATABASE)) throw new Error('Invalid PGDATABASE.');
|
|
34
|
+
if (!['DEV', 'TEST', 'PROD'].includes(values.ENV)) throw new Error('ENV must be DEV, TEST or PROD.');
|
|
35
|
+
const ports = ['DB_PORT', 'ODOO_HTTP_PORT', 'ODOO_GEVENT_PORT'].map(key => {
|
|
36
|
+
const value = values[key];
|
|
37
|
+
if (!/^\d+$/.test(value || '') || +value < 1 || +value > 65535) throw new Error('Invalid ' + key + '.');
|
|
38
|
+
return +value;
|
|
39
|
+
});
|
|
40
|
+
if (new Set(ports).size !== ports.length) throw new Error('Host ports must be distinct.');
|
|
41
|
+
return values;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function prepareEnv(root) {
|
|
45
|
+
const filename = path.join(root, '.env');
|
|
46
|
+
if (fs.existsSync(filename)) return validateEnv(readEnv(root));
|
|
47
|
+
let text = fs.readFileSync(path.join(root, '.env.example'), 'utf8');
|
|
48
|
+
const values = parseEnv(text);
|
|
49
|
+
const name = projectName(root);
|
|
50
|
+
const offset = parseInt(crypto.createHash('sha256').update(fs.realpathSync(root)).digest('hex').slice(0, 6), 16) % 1000;
|
|
51
|
+
let ports;
|
|
52
|
+
for (let attempt = 0; attempt < 1000; attempt++) {
|
|
53
|
+
const n = (offset + attempt) % 1000;
|
|
54
|
+
const candidate = [15432 + n, 18069 + n, 18072 + n];
|
|
55
|
+
if ((await Promise.all(candidate.map(portFree))).every(Boolean)) { ports = candidate; break; }
|
|
56
|
+
}
|
|
57
|
+
if (!ports) throw new Error('No free host port set found.');
|
|
58
|
+
Object.assign(values, {
|
|
59
|
+
PROJECT_NAME: name, DB_PORT: String(ports[0]), ODOO_HTTP_PORT: String(ports[1]), ODOO_GEVENT_PORT: String(ports[2]),
|
|
60
|
+
PGPASSWORD: crypto.randomBytes(24).toString('hex'),
|
|
61
|
+
ODOO_MASTER_PASSWORD: crypto.randomBytes(24).toString('hex'),
|
|
62
|
+
ODOO_ADMIN_PASSWORD: crypto.randomBytes(24).toString('hex'),
|
|
63
|
+
});
|
|
64
|
+
validateEnv(values);
|
|
65
|
+
for (const key of ['PROJECT_NAME', 'DB_PORT', 'ODOO_HTTP_PORT', 'ODOO_GEVENT_PORT', 'PGPASSWORD', 'ODOO_MASTER_PASSWORD', 'ODOO_ADMIN_PASSWORD']) {
|
|
66
|
+
const pattern = new RegExp('^' + key + '=.*$', 'm');
|
|
67
|
+
text = text.replace(pattern, key + '=' + values[key]);
|
|
68
|
+
}
|
|
69
|
+
try { fs.writeFileSync(filename, text, {flag: 'wx', mode: 0o600}); }
|
|
70
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
71
|
+
return validateEnv(readEnv(root));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function archiveDirectory(values) {
|
|
75
|
+
const base = process.platform === 'win32'
|
|
76
|
+
? (process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'))
|
|
77
|
+
: process.platform === 'darwin'
|
|
78
|
+
? path.join(os.homedir(), 'Library', 'Application Support')
|
|
79
|
+
: (process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'));
|
|
80
|
+
return path.join(base, 'mjh-odoo', 'backups', values.PROJECT_NAME);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function publicSettings(values) {
|
|
84
|
+
return Object.fromEntries(Object.entries(values).filter(([key]) => !/PASSWORD|TOKEN|SECRET|KEY/.test(key)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function checkModuleNames(names) {
|
|
88
|
+
if (names.some(name => !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name))) throw new Error('Invalid module name.');
|
|
89
|
+
return [...new Set(names)];
|
|
90
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
|
|
6
|
+
export function imageBuildPlan(args, {root, version, mirror = ''}) {
|
|
7
|
+
const values = parseArgs({args, options: {
|
|
8
|
+
platform: {type: 'string'}, tag: {type: 'string'}, file: {type: 'string'},
|
|
9
|
+
target: {type: 'string', default: 'base'}, builder: {type: 'string'},
|
|
10
|
+
push: {type: 'boolean', default: false}, 'dry-run': {type: 'boolean', default: false},
|
|
11
|
+
}}).values;
|
|
12
|
+
if (!['base', 'production'].includes(values.target)) throw new Error('Use --target base or production.');
|
|
13
|
+
if (values.push && !values.tag) throw new Error('--push requires an explicit --tag.');
|
|
14
|
+
if (values.target === 'production' && !values.tag) throw new Error('production requires an explicit --tag.');
|
|
15
|
+
if (values.push && values.file) throw new Error('--push and --file cannot be combined.');
|
|
16
|
+
const tag = values.tag ?? `mjh-odoo/odoo-runtime:20.0-v${version}`;
|
|
17
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._/:-]*$/.test(tag)) throw new Error('Invalid image tag.');
|
|
18
|
+
if (values.file !== undefined && !values.file.trim()) throw new Error('--file must name an output archive.');
|
|
19
|
+
if (values.builder !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(values.builder)) throw new Error('Invalid builder name.');
|
|
20
|
+
const platforms = [];
|
|
21
|
+
if (values.platform !== undefined) {
|
|
22
|
+
for (const value of values.platform.split(',')) {
|
|
23
|
+
const platform = value.startsWith('linux/') ? value : `linux/${value}`;
|
|
24
|
+
if (!['linux/amd64', 'linux/arm64'].includes(platform)) throw new Error('Supported platforms: amd64, arm64 (or linux/amd64, linux/arm64).');
|
|
25
|
+
if (!platforms.includes(platform)) platforms.push(platform);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (platforms.length > 1 && !values.push) throw new Error('Multiple platforms require --push. Export offline archives one platform at a time.');
|
|
29
|
+
if (mirror) {
|
|
30
|
+
let url;
|
|
31
|
+
try { url = new URL(mirror); } catch { throw new Error('Invalid APT_MIRROR_BASE URL.'); }
|
|
32
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || /[\r\n]/.test(mirror)) throw new Error('APT_MIRROR_BASE must be an HTTP(S) URL without credentials.');
|
|
33
|
+
}
|
|
34
|
+
const file = values.file ? path.resolve(values.file) : undefined;
|
|
35
|
+
const argv = ['buildx', 'build', '--file', path.join(root, 'Dockerfile'), '--target', values.target, '--tag', tag];
|
|
36
|
+
if (values.builder) argv.push('--builder', values.builder);
|
|
37
|
+
if (platforms.length) argv.push('--platform', platforms.join(','));
|
|
38
|
+
if (mirror) argv.push('--build-arg', `APT_MIRROR_BASE=${mirror}`);
|
|
39
|
+
if (file) argv.push('--output', 'type=docker,dest=-');
|
|
40
|
+
else argv.push(values.push ? '--push' : '--load');
|
|
41
|
+
argv.push(root);
|
|
42
|
+
return {target: values.target, tag, platforms, output: file ? 'archive' : values.push ? 'registry' : 'local', file, dryRun: values['dry-run'], argv};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function executeImageBuild(plan, run) {
|
|
46
|
+
if (plan.file && fs.existsSync(plan.file)) throw new Error('Archive already exists: ' + plan.file);
|
|
47
|
+
await run(['buildx', 'version']);
|
|
48
|
+
if (!plan.file) {
|
|
49
|
+
await run(plan.argv);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
fs.mkdirSync(path.dirname(plan.file), {recursive: true});
|
|
53
|
+
const temporary = plan.file + '.' + randomUUID() + '.part';
|
|
54
|
+
let fd;
|
|
55
|
+
try {
|
|
56
|
+
fd = fs.openSync(temporary, 'wx', 0o600);
|
|
57
|
+
await run(plan.argv, ['ignore', fd, 'inherit']);
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
fd = undefined;
|
|
60
|
+
if (!fs.statSync(temporary).size) throw new Error('Docker produced an empty archive.');
|
|
61
|
+
fs.linkSync(temporary, plan.file);
|
|
62
|
+
} finally {
|
|
63
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
64
|
+
fs.rmSync(temporary, {force: true});
|
|
65
|
+
}
|
|
66
|
+
}
|