@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/src/commands.mjs
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { parseEnv } from 'node:util';
|
|
5
|
+
import { archiveDirectory, checkModuleNames, publicSettings } from './lib/environment.mjs';
|
|
6
|
+
import { databaseName, openProject, serviceNames, withLock } from './project.mjs';
|
|
7
|
+
import { archiveLocation, archiveSource, resolveArchive, saveArchive, startupSeed, rememberRuntime, tagName, withArchiveCatalog } from './archive.mjs';
|
|
8
|
+
|
|
9
|
+
const start = (ctx, services = ['odoo'], timeout = 300) => ctx.run(['up', '-d', '--no-build', '--wait', '--wait-timeout', String(timeout), ...services]);
|
|
10
|
+
|
|
11
|
+
export async function initialize(ctx, options) {
|
|
12
|
+
if (options.json) console.log(JSON.stringify({projectDirectory: ctx.root, ...publicSettings(ctx.values)}, null, 2));
|
|
13
|
+
else {
|
|
14
|
+
console.log('项目配置就绪: ' + ctx.values.PROJECT_NAME);
|
|
15
|
+
console.log('Odoo: http://localhost:' + ctx.values.ODOO_HTTP_PORT);
|
|
16
|
+
console.log('初始管理员密码位于本机 .env 的 ODOO_ADMIN_PASSWORD。');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function doctor(ctx, options) {
|
|
21
|
+
const result = {project: ctx.values.PROJECT_NAME, directory: ctx.root, node: process.versions.node};
|
|
22
|
+
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});
|
|
23
|
+
if (!fs.existsSync(path.join(ctx.root, 'odoo', 'odoo-bin'))) throw new Error('项目缺少完整 Odoo 源码: odoo/odoo-bin。');
|
|
24
|
+
if (!fs.existsSync(path.join(ctx.root, 'scripts', 'runtime.py'))) throw new Error('项目缺少容器入口 scripts/runtime.py。');
|
|
25
|
+
await ctx.run(['config', '--quiet'], {capture: true});
|
|
26
|
+
result.valid = true;
|
|
27
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
28
|
+
else for (const [name, value] of Object.entries(result)) console.log(`${name}: ${value}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function configuration(ctx) {
|
|
32
|
+
console.log(JSON.stringify({PROJECT_DIRECTORY: ctx.root, ...publicSettings(ctx.values), BACKUP_DIRECTORY: archiveDirectory(ctx.values)}, null, 2));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function up(ctx, options) {
|
|
36
|
+
await withLock(ctx, async () => {
|
|
37
|
+
const seed = await startupSeed(ctx.root, ctx.values);
|
|
38
|
+
await rememberRuntime(ctx.root, ctx.values.PROJECT_NAME);
|
|
39
|
+
if (seed.reference) {
|
|
40
|
+
await seedDatabase(ctx, options, seed.reference);
|
|
41
|
+
await start(ctx, ['odoo'], options.waitTimeout);
|
|
42
|
+
console.log('Odoo: http://localhost:' + ctx.values.ODOO_HTTP_PORT);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (readRecovery(ctx)?.database === ctx.values.PGDATABASE) throw new Error('当前数据库恢复尚未完成,请先处理恢复任务。');
|
|
46
|
+
const args = ['up', '-d', options.build ? '--build' : '--no-build', '--wait', '--wait-timeout', String(options.waitTimeout)];
|
|
47
|
+
if (options.pull) args.push('--pull', options.pull);
|
|
48
|
+
await ctx.run(args);
|
|
49
|
+
console.log('Odoo: http://localhost:' + ctx.values.ODOO_HTTP_PORT);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function down(ctx, options) {
|
|
54
|
+
const args = ['down', '--timeout', String(options.timeout)];
|
|
55
|
+
if (options.volumes) args.push('--volumes');
|
|
56
|
+
if (options.dryRun) {
|
|
57
|
+
console.log(JSON.stringify({project: ctx.values.PROJECT_NAME, directory: ctx.root, removeVolumes: !!options.volumes, docker: [...ctx.compose, ...args]}, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await withLock(ctx, async () => {
|
|
61
|
+
if (options.volumes) console.error('删除本项目数据库和附件卷: ' + ctx.values.PROJECT_NAME);
|
|
62
|
+
await ctx.run(args);
|
|
63
|
+
if (options.volumes) for (const file of [originsFile(ctx), recoveryFile(ctx)]) fs.rmSync(file, {force: true});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function restart(ctx, names, options) {
|
|
68
|
+
const services = serviceNames(names);
|
|
69
|
+
await withLock(ctx, async () => {
|
|
70
|
+
await ctx.run(['restart', '--timeout', String(options.timeout), ...services]);
|
|
71
|
+
await start(ctx, services, options.waitTimeout);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function status(ctx, options) {
|
|
76
|
+
const args = ['ps', '--all'];
|
|
77
|
+
if (options.json) {
|
|
78
|
+
const raw = await ctx.run([...args, '--format', 'json'], {capture: true});
|
|
79
|
+
let result;
|
|
80
|
+
try { result = raw ? JSON.parse(raw) : []; }
|
|
81
|
+
catch { result = raw.split('\n').filter(Boolean).map(line => JSON.parse(line)); }
|
|
82
|
+
console.log(JSON.stringify(Array.isArray(result) ? result : [result], null, 2));
|
|
83
|
+
} else await ctx.run(args);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function build(ctx, options) {
|
|
87
|
+
await withLock(ctx, () => ctx.run(['build', ...(options.pull ? ['--pull'] : []), ...(options.cache === false ? ['--no-cache'] : []), 'odoo']));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function maintainModules(ctx, operation, names, options) {
|
|
91
|
+
const modules = checkModuleNames(names);
|
|
92
|
+
if (operation === 'install' && !modules.length) throw new Error('请指定至少一个要安装的模块。');
|
|
93
|
+
if (operation === 'update' && !modules.length && !options.all) throw new Error('请指定模块名,或使用 --all 升级已安装的项目扩展。');
|
|
94
|
+
if (options.all && modules.length) throw new Error('--all 不能与模块名同时使用。');
|
|
95
|
+
if (options.database) databaseName(options.database);
|
|
96
|
+
await withLock(ctx, async () => {
|
|
97
|
+
await ctx.run(['up', '-d', '--wait', 'db']);
|
|
98
|
+
await ctx.run(['stop', 'odoo']);
|
|
99
|
+
const args = ['run', '--rm', '--no-deps', '-T'];
|
|
100
|
+
if (options.database) args.push('--env', 'PGDATABASE=' + options.database);
|
|
101
|
+
args.push('--entrypoint', 'python3', 'odoo', '/opt/odoo/scripts/runtime.py', operation === 'install' ? 'install' : 'upgrade', ...modules);
|
|
102
|
+
await ctx.run(args);
|
|
103
|
+
if (options.restart !== false) await start(ctx);
|
|
104
|
+
else console.log('模块维护完成;Odoo 保持停止,可用 odoo up 启动。');
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function sql(ctx, database, query) {
|
|
109
|
+
databaseName(database);
|
|
110
|
+
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});
|
|
111
|
+
return JSON.parse(result || '[]');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function databases(ctx) {
|
|
115
|
+
return 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");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function listDatabases(ctx, options) {
|
|
119
|
+
const origins = readOrigins(ctx);
|
|
120
|
+
const pending = readRecovery(ctx);
|
|
121
|
+
const rows = (await databases(ctx)).map(row => ({...row, current: row.name === ctx.values.PGDATABASE, origin: origins[row.name]}));
|
|
122
|
+
if (pending) {
|
|
123
|
+
const row = rows.find(row => row.name === pending.database);
|
|
124
|
+
const recovery = {recoverable: pending.disposable};
|
|
125
|
+
if (row) row.recovery = recovery;
|
|
126
|
+
else rows.push({name: pending.database, current: pending.database === ctx.values.PGDATABASE, recovery});
|
|
127
|
+
}
|
|
128
|
+
if (options.json) console.log(JSON.stringify(rows, null, 2));
|
|
129
|
+
else for (const row of rows) console.log((row.current ? '* ' : ' ') + row.name + (row.recovery ? ' 待恢复处理' : ''));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function listModules(ctx, options) {
|
|
133
|
+
const database = options.database || ctx.values.PGDATABASE;
|
|
134
|
+
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`);
|
|
135
|
+
if (options.json) console.log(JSON.stringify(data, null, 2));
|
|
136
|
+
else for (const row of data) console.log(`${row.name.padEnd(40)} ${row.state.padEnd(14)} ${row.version || '-'}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function validateZip(ctx, filename, {quiet = false, build = false} = {}) {
|
|
140
|
+
const fd = fs.openSync(filename, 'r');
|
|
141
|
+
try { await ctx.run(['run', '--rm', '--no-deps', '-T', ...(build ? ['--build'] : []), '--entrypoint', 'python3', 'odoo', '/opt/odoo/scripts/backup_utils.py', 'validate'], {stdio: [fd, quiet ? 'ignore' : 2, 'inherit']}); }
|
|
142
|
+
finally { fs.closeSync(fd); }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function writeBackup(ctx, database, target) {
|
|
146
|
+
if (fs.existsSync(target)) throw new Error('存档已存在: ' + target);
|
|
147
|
+
fs.mkdirSync(path.dirname(target), {recursive: true});
|
|
148
|
+
const temporary = target + '.' + randomUUID() + '.part';
|
|
149
|
+
let fd = fs.openSync(temporary, 'wx', 0o600);
|
|
150
|
+
try {
|
|
151
|
+
await ctx.run(['exec', '-T', 'odoo', 'odoo', 'db', '-c', '/etc/odoo/odoo.conf', 'dump', database, '-'], {stdio: ['ignore', fd, 'inherit']});
|
|
152
|
+
fs.closeSync(fd);
|
|
153
|
+
fd = undefined;
|
|
154
|
+
await validateZip(ctx, temporary);
|
|
155
|
+
fs.linkSync(temporary, target);
|
|
156
|
+
} finally {
|
|
157
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
158
|
+
fs.rmSync(temporary, {force: true});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function backup(ctx, options) {
|
|
163
|
+
const database = databaseName(options.database || ctx.values.PGDATABASE);
|
|
164
|
+
if (options.name !== undefined) {
|
|
165
|
+
const title = options.name.trim();
|
|
166
|
+
const note = (options.note || '').trim();
|
|
167
|
+
const tag = options.tag === undefined ? undefined : tagName(options.tag);
|
|
168
|
+
if (!title || title.length > 120 || note.length > 2000) throw new Error('请填写有效存档名称,名称最多 120 字,备注最多 2000 字。');
|
|
169
|
+
return withArchiveCatalog(ctx.root, true, (db, project) => withLock(ctx, async () => {
|
|
170
|
+
if (readRecovery(ctx)?.database === database) throw new Error('该数据库恢复尚未完成,无法保存存档。');
|
|
171
|
+
const id = randomUUID();
|
|
172
|
+
const file = archiveLocation(db, ctx.root, id);
|
|
173
|
+
const {environment, revision} = archiveSource(db, project, ctx.root);
|
|
174
|
+
await writeBackup(ctx, database, file);
|
|
175
|
+
let archive;
|
|
176
|
+
try {
|
|
177
|
+
archive = {id, projectId: project.id, title, note, environment, database, file,
|
|
178
|
+
bytes: fs.statSync(file).size, created: new Date().toISOString(), revision, source: 'backup', tags: tag ? [tag] : []};
|
|
179
|
+
saveArchive(db, archive, tag);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
fs.rmSync(file, {force: true});
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
console.log(options.json ? JSON.stringify(archive) : '存档已保存:' + title + ' (' + id + ')' + (tag ? ' · ' + tag : ''));
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
189
|
+
const target = path.resolve(options.file || path.join(archiveDirectory(ctx.values), database + '-' + stamp + '.zip'));
|
|
190
|
+
await withLock(ctx, () => {
|
|
191
|
+
if (readRecovery(ctx)?.database === database) throw new Error('该数据库恢复尚未完成,无法导出。');
|
|
192
|
+
return writeBackup(ctx, database, target);
|
|
193
|
+
});
|
|
194
|
+
console.log('存档已保存: ' + target);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function importArchive(ctx, filename, {name, json}) {
|
|
198
|
+
const original = path.resolve(filename);
|
|
199
|
+
const title = (name ?? path.basename(original).replace(/\.zip$/i, '').slice(0, 120)).trim();
|
|
200
|
+
if (!title || title.length > 120) throw new Error('请填写存档名称,最多 120 字。');
|
|
201
|
+
if (!fs.statSync(original, {throwIfNoEntry: false})?.isFile()) throw new Error('请提供已有的 Odoo ZIP 存档。');
|
|
202
|
+
await withArchiveCatalog(ctx.root, true, async (db, project) => {
|
|
203
|
+
const id = randomUUID();
|
|
204
|
+
const file = archiveLocation(db, ctx.root, id);
|
|
205
|
+
const temporary = file + '.part';
|
|
206
|
+
try {
|
|
207
|
+
fs.copyFileSync(original, temporary, fs.constants.COPYFILE_EXCL);
|
|
208
|
+
if (process.platform !== 'win32') fs.chmodSync(temporary, 0o600);
|
|
209
|
+
await validateZip(ctx, temporary, {quiet: json, build: true});
|
|
210
|
+
fs.linkSync(temporary, file);
|
|
211
|
+
const row = {id, projectId: project.id, title, note: '', environment: '本机导入', database: '',
|
|
212
|
+
file, bytes: fs.statSync(file).size, created: new Date().toISOString(), revision: '', source: 'import'};
|
|
213
|
+
try { saveArchive(db, row); }
|
|
214
|
+
catch (error) { fs.rmSync(file, {force: true}); throw error; }
|
|
215
|
+
console.log(json ? JSON.stringify(row) : '已导入存档:' + title + ' (' + id + ')');
|
|
216
|
+
} finally { fs.rmSync(temporary, {force: true}); }
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const originsFile = ctx => path.join(ctx.root, '.local', 'db-origins.json');
|
|
221
|
+
|
|
222
|
+
function readOrigins(ctx) {
|
|
223
|
+
try { return Object.assign(Object.create(null), JSON.parse(fs.readFileSync(originsFile(ctx), 'utf8'))); }
|
|
224
|
+
catch (error) { if (error.code === 'ENOENT') return Object.create(null); throw error; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function recordOrigin(ctx, database, archive, reference) {
|
|
228
|
+
const origins = readOrigins(ctx);
|
|
229
|
+
if (archive) origins[database] = {id: archive.id, title: archive.title, reference, created: archive.created};
|
|
230
|
+
else if (Object.hasOwn(origins, database)) delete origins[database];
|
|
231
|
+
else return;
|
|
232
|
+
writeEnv(originsFile(ctx), JSON.stringify(origins, null, 2) + '\n', 0o600);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const dbMaintenance = ['run', '--rm', '--no-deps', '-T'];
|
|
236
|
+
const dbReady = ['up', '-d', '--wait', 'db'];
|
|
237
|
+
|
|
238
|
+
async function runningServices(ctx) {
|
|
239
|
+
return (await ctx.run(['ps', '--status', 'running', '--services'], {capture: true})).split(/\r?\n/);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function loadZip(ctx, source, database, force, container) {
|
|
243
|
+
const args = [...dbMaintenance, '--name', container, '--entrypoint', 'python3', 'odoo', '/opt/odoo/scripts/backup_utils.py', 'restore', database];
|
|
244
|
+
if (force) args.push('--force');
|
|
245
|
+
const fd = fs.openSync(source, 'r');
|
|
246
|
+
try { await ctx.run(args, {stdio: [fd, 'inherit', 'inherit']}); }
|
|
247
|
+
finally { fs.closeSync(fd); }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function dropDatabase(ctx, database) {
|
|
251
|
+
return ctx.run([...dbMaintenance, '--entrypoint', 'odoo', 'odoo', 'db', '-c', '/etc/odoo/odoo.conf', 'drop', database]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const recoveryFile = ctx => path.join(ctx.root, '.local', 'db-recovery.json');
|
|
255
|
+
|
|
256
|
+
function readRecovery(ctx) {
|
|
257
|
+
let record;
|
|
258
|
+
try { record = JSON.parse(fs.readFileSync(recoveryFile(ctx), 'utf8')); }
|
|
259
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
260
|
+
if (!record || typeof record.disposable !== 'boolean' || typeof record.resume !== 'boolean' ||
|
|
261
|
+
!/^mjh-restore-[0-9a-f-]{36}$/.test(record.container)) throw new Error('恢复记录无效,请检查 .local/db-recovery.json。');
|
|
262
|
+
databaseName(record.database);
|
|
263
|
+
return record;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function removeInterrupted(ctx, record) {
|
|
267
|
+
const running = await ctx.docker(['ps', '-a', '--filter', 'name=^/' + record.container + '$', '--format', '{{.Names}}'], {capture: true});
|
|
268
|
+
if (running) throw new Error('恢复容器仍在运行,请等它退出后再重试。');
|
|
269
|
+
if (!record.disposable) throw new Error('上一次覆盖恢复尚未完成。请使用可信 ZIP 重新执行恢复并显式指定 --force。');
|
|
270
|
+
const rows = await databases(ctx);
|
|
271
|
+
if (rows.some(row => row.name === record.database)) await dropDatabase(ctx, record.database);
|
|
272
|
+
recordOrigin(ctx, record.database);
|
|
273
|
+
fs.rmSync(recoveryFile(ctx), {force: true});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function verifyDatabase(ctx, database) {
|
|
277
|
+
const installedTable = await sql(ctx, database, "SELECT to_json(to_regclass('public.ir_module_module') IS NOT NULL)");
|
|
278
|
+
if (installedTable !== true ||
|
|
279
|
+
await sql(ctx, database, "SELECT to_json(EXISTS (SELECT 1 FROM ir_module_module WHERE name = 'base' AND state = 'installed'))") !== true) {
|
|
280
|
+
throw new Error('目标库未完成 Odoo 初始化:' + database);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function restoreChecked(ctx, source, database, {force, disposable, resume, archive, reference}) {
|
|
285
|
+
const record = {database, disposable, resume, container: 'mjh-restore-' + randomUUID()};
|
|
286
|
+
writeEnv(recoveryFile(ctx), JSON.stringify(record), 0o600);
|
|
287
|
+
try {
|
|
288
|
+
console.log('正在恢复数据库和附件:' + database);
|
|
289
|
+
await loadZip(ctx, source, database, force, record.container);
|
|
290
|
+
console.log('正在验证 Odoo 数据库:' + database);
|
|
291
|
+
await verifyDatabase(ctx, database);
|
|
292
|
+
recordOrigin(ctx, database, archive, reference);
|
|
293
|
+
fs.rmSync(recoveryFile(ctx), {force: true});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (!disposable) throw error;
|
|
296
|
+
try { await removeInterrupted(ctx, record); }
|
|
297
|
+
catch (cleanup) { throw new Error('恢复失败,目标库已隔离。清理失败:' + cleanup.message, {cause: error}); }
|
|
298
|
+
throw new Error('恢复失败,本次创建的数据库已清理。' + error.message, {cause: error});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function recoverDatabase(project) {
|
|
303
|
+
await withLock(project, async () => {
|
|
304
|
+
const ctx = await openProject(project.root, {runner: project.runner});
|
|
305
|
+
const record = readRecovery(ctx);
|
|
306
|
+
if (!record) throw new Error('没有待清理的恢复任务。');
|
|
307
|
+
await ctx.run(dbReady);
|
|
308
|
+
await removeInterrupted(ctx, record);
|
|
309
|
+
if (record.resume && record.database !== ctx.values.PGDATABASE) await start(ctx);
|
|
310
|
+
console.log('恢复残留已清理,可以重试。');
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function seedDatabase(ctx, {build, pull, waitTimeout}, reference) {
|
|
315
|
+
const database = databaseName(ctx.values.PGDATABASE);
|
|
316
|
+
if (database === 'postgres' || database === 'template0' || database === 'template1') throw new Error('请选择普通开发数据库作为首次加载的目标。');
|
|
317
|
+
const pullArgs = pull ? ['--pull', pull] : [];
|
|
318
|
+
await ctx.run([...dbReady, '--wait-timeout', String(waitTimeout), ...pullArgs]);
|
|
319
|
+
const previous = readRecovery(ctx);
|
|
320
|
+
if (previous?.database === database) await removeInterrupted(ctx, previous);
|
|
321
|
+
const running = await runningServices(ctx);
|
|
322
|
+
const rows = await databases(ctx);
|
|
323
|
+
if (running.includes('odoo') || rows.some(row => row.name !== database)) {
|
|
324
|
+
if (build) await ctx.run(['build', 'odoo']);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const exists = rows.some(row => row.name === database);
|
|
328
|
+
if (exists) {
|
|
329
|
+
const relations = await sql(ctx, database, `SELECT to_json(EXISTS (
|
|
330
|
+
SELECT FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
331
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
332
|
+
AND c.relkind IN ('r','p','m','v','S','f')))`);
|
|
333
|
+
if (relations === true) {
|
|
334
|
+
if (build) await ctx.run(['build', 'odoo']);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (relations !== false) throw new Error('无法确认目标数据库为空。');
|
|
338
|
+
}
|
|
339
|
+
if (Object.keys(readOrigins(ctx)).length) throw new Error('环境已有恢复记录。请手动恢复数据库,或用 down -v 显式重置环境。');
|
|
340
|
+
const archive = await resolveArchive(ctx.root, reference);
|
|
341
|
+
if (build) await ctx.run(['build', 'odoo']);
|
|
342
|
+
console.log('首次加载存档:' + archive.title + ' (' + archive.id + ')');
|
|
343
|
+
if (readRecovery(ctx)) throw new Error('请先清理上一次恢复。');
|
|
344
|
+
await validateZip(ctx, archive.file);
|
|
345
|
+
await restoreChecked(ctx, archive.file, database, {force: exists, disposable: true, resume: false, archive, reference});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function restore(project, options) {
|
|
349
|
+
const selected = options.archive ? await resolveArchive(project.root, options.archive) : null;
|
|
350
|
+
const source = selected?.file || path.resolve(options.file);
|
|
351
|
+
if (!fs.statSync(source, {throwIfNoEntry: false})?.isFile()) throw new Error('请提供已有的 ZIP 存档。');
|
|
352
|
+
await withLock(project, async () => {
|
|
353
|
+
if (selected) console.log('恢复存档:' + selected.title + ' (' + selected.id + ')');
|
|
354
|
+
await validateZip(project, source);
|
|
355
|
+
const ctx = await openProject(project.root, {runner: project.runner});
|
|
356
|
+
const database = databaseName(options.database || ctx.values.PGDATABASE);
|
|
357
|
+
if (['postgres', 'template0', 'template1'].includes(database)) throw new Error('请选择普通开发数据库作为恢复目标。');
|
|
358
|
+
await ctx.run(dbReady);
|
|
359
|
+
const previous = readRecovery(ctx);
|
|
360
|
+
if (previous) {
|
|
361
|
+
if (previous.database !== database) throw new Error('请先清理上一次恢复,再恢复另一份数据库。');
|
|
362
|
+
if (previous.disposable) await removeInterrupted(ctx, previous);
|
|
363
|
+
else {
|
|
364
|
+
if (!options.force) throw new Error('上一次覆盖恢复尚未完成,请明确指定 --force 后重试。');
|
|
365
|
+
if (await ctx.docker(['ps', '-a', '--filter', 'name=^/' + previous.container + '$', '--format', '{{.Names}}'], {capture: true})) {
|
|
366
|
+
throw new Error('上一次恢复容器仍在运行。');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const exists = (await databases(ctx)).some(row => row.name === database);
|
|
371
|
+
if (exists && !options.force) throw new Error('目标数据库已存在,请使用新名称:' + database);
|
|
372
|
+
if (options.use && database === ctx.values.PGDATABASE) throw new Error('恢复并切换需要使用与当前库不同的新名称。');
|
|
373
|
+
const running = (await runningServices(ctx)).includes('odoo');
|
|
374
|
+
const resume = options.restart !== false && (running || previous?.resume || options.legacy === true);
|
|
375
|
+
if (running) await ctx.run(['stop', 'odoo']);
|
|
376
|
+
try {
|
|
377
|
+
await restoreChecked(ctx, source, database, {force: options.force, disposable: !exists && previous?.disposable !== false,
|
|
378
|
+
resume, archive: selected, reference: options.archive});
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (resume && (database !== ctx.values.PGDATABASE || !exists)) {
|
|
381
|
+
try { await start(ctx); }
|
|
382
|
+
catch (restartError) { throw new Error(error.message + ' 原库重新启动失败:' + restartError.message, {cause: error}); }
|
|
383
|
+
}
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
console.log('已恢复并中和外发任务: ' + database);
|
|
387
|
+
if (options.use) {
|
|
388
|
+
try { await selectDatabase(ctx, database, resume); }
|
|
389
|
+
catch (error) {
|
|
390
|
+
if (resume && !(await runningServices(ctx)).includes('odoo')) {
|
|
391
|
+
const latest = await openProject(ctx.root, {runner: ctx.runner});
|
|
392
|
+
if (latest.values.PGDATABASE === ctx.values.PGDATABASE) await start(latest);
|
|
393
|
+
}
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
else if (resume) await start(ctx);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function writeEnv(filename, content, mode) {
|
|
402
|
+
const temporary = filename + '.' + randomUUID() + '.part';
|
|
403
|
+
fs.writeFileSync(temporary, content, {mode, flag: 'wx'});
|
|
404
|
+
try { fs.renameSync(temporary, filename); }
|
|
405
|
+
finally { fs.rmSync(temporary, {force: true}); }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function selectDatabase(ctx, database, running) {
|
|
409
|
+
const filename = path.join(ctx.root, '.env');
|
|
410
|
+
const original = fs.readFileSync(filename, 'utf8');
|
|
411
|
+
const setting = /^[ \t]*(?:export[ \t]+)?PGDATABASE[ \t]*=[^\r\n]*/gm;
|
|
412
|
+
if ([...original.matchAll(setting)].length !== 1) throw new Error('.env 中需要有且仅有一行 PGDATABASE。');
|
|
413
|
+
const next = original.replace(setting, 'PGDATABASE=' + database);
|
|
414
|
+
const values = parseEnv(next);
|
|
415
|
+
if (values.PGDATABASE !== database || Object.entries(ctx.values).some(([key, value]) => key !== 'PGDATABASE' && values[key] !== value)) {
|
|
416
|
+
throw new Error('无法安全更新 .env 中的 PGDATABASE。');
|
|
417
|
+
}
|
|
418
|
+
if (ctx.values.PGDATABASE === database) {
|
|
419
|
+
console.log('当前数据库:' + database);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const mode = fs.statSync(filename).mode & 0o777;
|
|
423
|
+
if (fs.readFileSync(filename, 'utf8') !== original) throw new Error('.env 已被其他操作修改,请重试。');
|
|
424
|
+
writeEnv(filename, next, mode);
|
|
425
|
+
try {
|
|
426
|
+
if (running) await start(await openProject(ctx.root, {runner: ctx.runner}));
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (fs.readFileSync(filename, 'utf8') !== next) throw new Error('切库失败,且 .env 已有其他改动,请检查当前环境。', {cause: error});
|
|
429
|
+
writeEnv(filename, original, mode);
|
|
430
|
+
try { await start(ctx); }
|
|
431
|
+
catch (rollbackError) { throw new Error('已恢复原数据库配置,但 Odoo 未能重新启动:' + rollbackError.message, {cause: error}); }
|
|
432
|
+
throw new Error('切库失败,已恢复原数据库:' + error.message, {cause: error});
|
|
433
|
+
}
|
|
434
|
+
console.log('数据库已切换到:' + database);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function useDatabase(project, name) {
|
|
438
|
+
const database = databaseName(name);
|
|
439
|
+
await withLock(project, async () => {
|
|
440
|
+
const ctx = await openProject(project.root, {runner: project.runner});
|
|
441
|
+
const running = await runningServices(ctx);
|
|
442
|
+
if (!running.includes('db')) await ctx.run(dbReady);
|
|
443
|
+
if (readRecovery(ctx)?.database === database) throw new Error('该数据库恢复尚未完成,请先处理恢复任务。');
|
|
444
|
+
if (!(await databases(ctx)).some(row => row.name === database)) throw new Error('目标数据库不存在:' + database);
|
|
445
|
+
await verifyDatabase(ctx, database);
|
|
446
|
+
await selectDatabase(ctx, database, running.includes('odoo'));
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export async function deleteDatabase(project, name) {
|
|
451
|
+
const database = databaseName(name);
|
|
452
|
+
await withLock(project, async () => {
|
|
453
|
+
const ctx = await openProject(project.root, {runner: project.runner});
|
|
454
|
+
const running = await runningServices(ctx);
|
|
455
|
+
if (readRecovery(ctx)?.database === database) throw new Error('请通过 db recover 清理恢复残留。');
|
|
456
|
+
const live = running.includes('odoo') ? await ctx.run(['exec', '-T', 'odoo', 'printenv', 'PGDATABASE'], {capture: true}) : '';
|
|
457
|
+
if (database === ctx.values.PGDATABASE || database === live) throw new Error('请先切换到另一个数据库。');
|
|
458
|
+
if (!running.includes('db')) await ctx.run(dbReady);
|
|
459
|
+
if (!(await databases(ctx)).some(row => row.name === database)) throw new Error('目标数据库不存在:' + database);
|
|
460
|
+
await dropDatabase(ctx, database);
|
|
461
|
+
recordOrigin(ctx, database);
|
|
462
|
+
console.log('已删除数据库及附件:' + database);
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export async function native(ctx, args) {
|
|
467
|
+
if (!args.length) throw new Error('请指定官方 Odoo 参数,例如 odoo exec -- --version。');
|
|
468
|
+
await ctx.run(['exec', ...(!process.stdin.isTTY || !process.stdout.isTTY ? ['-T'] : []), 'odoo', 'odoo', ...args]);
|
|
469
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
return projectNameForPath(fs.realpathSync(root));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function projectNameForPath(location) {
|
|
18
|
+
const prefix = path.basename(location).toLowerCase().replace(/[^a-z0-9_-]/g, '_').replace(/^[_-]+/, '') || 'odoo';
|
|
19
|
+
return prefix.slice(0, 32) + '_' + crypto.createHash('sha256').update(location).digest('hex').slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function portFree(port) {
|
|
23
|
+
return new Promise(resolve => {
|
|
24
|
+
const server = net.createServer();
|
|
25
|
+
server.once('error', () => resolve(false));
|
|
26
|
+
server.listen({host: '127.0.0.1', port, exclusive: true}, () => server.close(() => resolve(true)));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validateEnv(values) {
|
|
31
|
+
for (const key of ['PROJECT_NAME', 'PGDATABASE', 'PGUSER', 'PGPASSWORD', 'ODOO_MASTER_PASSWORD', 'ODOO_ADMIN_PASSWORD']) {
|
|
32
|
+
if (!values[key]) throw new Error('Missing ' + key + '. Check .env or run init in a new checkout.');
|
|
33
|
+
if (/[\r\n]/.test(values[key])) throw new Error(key + ' must be a single line.');
|
|
34
|
+
}
|
|
35
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(values.PROJECT_NAME)) throw new Error('Invalid PROJECT_NAME.');
|
|
36
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(values.PGDATABASE)) throw new Error('Invalid PGDATABASE.');
|
|
37
|
+
if (!['DEV', 'TEST', 'PROD'].includes(values.ENV)) throw new Error('ENV must be DEV, TEST or PROD.');
|
|
38
|
+
if (values.ODOO_SEED_MODE && !['manual', 'first-start', 'empty'].includes(values.ODOO_SEED_MODE)) throw new Error('ODOO_SEED_MODE 仅支持 manual、first-start 或 empty。');
|
|
39
|
+
if (values.ODOO_SEED && !/^(?:tag:[a-z][a-z0-9._-]{0,63}|id:[a-zA-Z0-9_-]{1,100})$/.test(values.ODOO_SEED)) throw new Error('ODOO_SEED 请填写 tag:latest 或 id:<存档ID>。');
|
|
40
|
+
if (values.ENV === 'PROD' && values.ODOO_SEED && values.ODOO_SEED_MODE === 'first-start') throw new Error('首次加载存档仅用于开发和测试环境。');
|
|
41
|
+
|
|
42
|
+
const ports = ['DB_PORT', 'ODOO_HTTP_PORT', 'ODOO_GEVENT_PORT'].map(key => {
|
|
43
|
+
const value = values[key];
|
|
44
|
+
if (!/^\d+$/.test(value || '') || +value < 1 || +value > 65535) throw new Error('Invalid ' + key + '.');
|
|
45
|
+
return +value;
|
|
46
|
+
});
|
|
47
|
+
if (new Set(ports).size !== ports.length) throw new Error('Host ports must be distinct.');
|
|
48
|
+
return values;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function prepareEnv(root) {
|
|
52
|
+
const filename = path.join(root, '.env');
|
|
53
|
+
if (fs.existsSync(filename)) return validateEnv(readEnv(root));
|
|
54
|
+
let text = fs.readFileSync(path.join(root, '.env.example'), 'utf8');
|
|
55
|
+
const values = parseEnv(text);
|
|
56
|
+
const name = projectName(root);
|
|
57
|
+
const offset = parseInt(crypto.createHash('sha256').update(fs.realpathSync(root)).digest('hex').slice(0, 6), 16) % 1000;
|
|
58
|
+
let ports;
|
|
59
|
+
for (let attempt = 0; attempt < 1000; attempt++) {
|
|
60
|
+
const n = (offset + attempt) % 1000;
|
|
61
|
+
const candidate = [15432 + n, 18069 + n, 18072 + n];
|
|
62
|
+
if ((await Promise.all(candidate.map(portFree))).every(Boolean)) { ports = candidate; break; }
|
|
63
|
+
}
|
|
64
|
+
if (!ports) throw new Error('No free host port set found.');
|
|
65
|
+
const generated = {
|
|
66
|
+
PROJECT_NAME: name, DB_PORT: String(ports[0]), ODOO_HTTP_PORT: String(ports[1]), ODOO_GEVENT_PORT: String(ports[2]),
|
|
67
|
+
};
|
|
68
|
+
for (const key of ['PGPASSWORD', 'ODOO_MASTER_PASSWORD', 'ODOO_ADMIN_PASSWORD']) {
|
|
69
|
+
if (!values[key]) generated[key] = crypto.randomBytes(24).toString('hex');
|
|
70
|
+
}
|
|
71
|
+
validateEnv({...values, ...generated});
|
|
72
|
+
const newline = text.includes('\r\n') ? '\r\n' : '\n';
|
|
73
|
+
for (const [key, value] of Object.entries(generated)) {
|
|
74
|
+
const pattern = new RegExp('^[ \t]*(?:export[ \t]+)?' + key + '[ \t]*=[^\r\n]*', 'gm');
|
|
75
|
+
if (pattern.test(text)) text = text.replace(pattern, () => key + '=' + value);
|
|
76
|
+
else text += (text && !text.endsWith('\n') ? newline : '') + key + '=' + value + newline;
|
|
77
|
+
}
|
|
78
|
+
try { fs.writeFileSync(filename, text, {flag: 'wx', mode: 0o600}); }
|
|
79
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
80
|
+
return validateEnv(readEnv(root));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function archiveDirectory(values) {
|
|
84
|
+
const base = process.platform === 'win32'
|
|
85
|
+
? (process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'))
|
|
86
|
+
: process.platform === 'darwin'
|
|
87
|
+
? path.join(os.homedir(), 'Library', 'Application Support')
|
|
88
|
+
: (process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'));
|
|
89
|
+
return path.join(base, 'mjh-odoo', 'backups', values.PROJECT_NAME);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function publicSettings(values) {
|
|
93
|
+
return Object.fromEntries(Object.entries(values).filter(([key]) => !/PASSWORD|TOKEN|SECRET|KEY/.test(key)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function checkModuleNames(names) {
|
|
97
|
+
if (names.some(name => !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name))) throw new Error('Invalid module name.');
|
|
98
|
+
return [...new Set(names)];
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|