@mjh-odoo/cli 0.1.0 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/README.md +49 -65
- package/docs/README.md +11 -0
- package/docs/architecture.md +34 -0
- package/docs/development.md +40 -0
- package/docs/validation.md +10 -26
- package/package.json +2 -1
- package/src/archive-library.mjs +134 -0
- package/src/archive.mjs +260 -0
- package/src/archived-worktrees.mjs +65 -0
- package/src/cli.mjs +93 -6
- package/src/commands.mjs +338 -36
- package/src/lib/environment.mjs +19 -10
- package/src/lib/logs.mjs +22 -4
- package/src/project.mjs +31 -8
package/src/commands.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { parseEnv } from 'node:util';
|
|
3
5
|
import { archiveDirectory, checkModuleNames, publicSettings } from './lib/environment.mjs';
|
|
4
|
-
import { databaseName, serviceNames, withLock } from './project.mjs';
|
|
6
|
+
import { databaseName, openProject, serviceNames, withLock } from './project.mjs';
|
|
7
|
+
import { archiveLocation, archiveSource, resolveArchive, saveArchive, startupSeed, rememberRuntime, tagName, withArchiveCatalog } from './archive.mjs';
|
|
5
8
|
|
|
6
9
|
const start = (ctx, services = ['odoo'], timeout = 300) => ctx.run(['up', '-d', '--no-build', '--wait', '--wait-timeout', String(timeout), ...services]);
|
|
7
10
|
|
|
@@ -31,6 +34,15 @@ export function configuration(ctx) {
|
|
|
31
34
|
|
|
32
35
|
export async function up(ctx, options) {
|
|
33
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('当前数据库恢复尚未完成,请先处理恢复任务。');
|
|
34
46
|
const args = ['up', '-d', options.build ? '--build' : '--no-build', '--wait', '--wait-timeout', String(options.waitTimeout)];
|
|
35
47
|
if (options.pull) args.push('--pull', options.pull);
|
|
36
48
|
await ctx.run(args);
|
|
@@ -48,6 +60,7 @@ export async function down(ctx, options) {
|
|
|
48
60
|
await withLock(ctx, async () => {
|
|
49
61
|
if (options.volumes) console.error('删除本项目数据库和附件卷: ' + ctx.values.PROJECT_NAME);
|
|
50
62
|
await ctx.run(args);
|
|
63
|
+
if (options.volumes) for (const file of [originsFile(ctx), recoveryFile(ctx)]) fs.rmSync(file, {force: true});
|
|
51
64
|
});
|
|
52
65
|
}
|
|
53
66
|
|
|
@@ -98,11 +111,22 @@ async function sql(ctx, database, query) {
|
|
|
98
111
|
return JSON.parse(result || '[]');
|
|
99
112
|
}
|
|
100
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
|
+
|
|
101
118
|
export async function listDatabases(ctx, options) {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
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
|
+
}
|
|
104
128
|
if (options.json) console.log(JSON.stringify(rows, null, 2));
|
|
105
|
-
else for (const row of rows) console.log((row.current ? '* ' : ' ') + row.name);
|
|
129
|
+
else for (const row of rows) console.log((row.current ? '* ' : ' ') + row.name + (row.recovery ? ' 待恢复处理' : ''));
|
|
106
130
|
}
|
|
107
131
|
|
|
108
132
|
export async function listModules(ctx, options) {
|
|
@@ -112,52 +136,330 @@ export async function listModules(ctx, options) {
|
|
|
112
136
|
else for (const row of data) console.log(`${row.name.padEnd(40)} ${row.state.padEnd(14)} ${row.version || '-'}`);
|
|
113
137
|
}
|
|
114
138
|
|
|
115
|
-
async function validateZip(ctx, filename) {
|
|
139
|
+
async function validateZip(ctx, filename, {quiet = false, build = false} = {}) {
|
|
116
140
|
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, '
|
|
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']}); }
|
|
118
142
|
finally { fs.closeSync(fd); }
|
|
119
143
|
}
|
|
120
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
|
+
|
|
121
162
|
export async function backup(ctx, options) {
|
|
122
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
|
+
|
|
123
188
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
124
189
|
const target = path.resolve(options.file || path.join(archiveDirectory(ctx.values), database + '-' + stamp + '.zip'));
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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';
|
|
130
206
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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('恢复残留已清理,可以重试。');
|
|
142
311
|
});
|
|
143
312
|
}
|
|
144
313
|
|
|
145
|
-
|
|
146
|
-
const
|
|
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);
|
|
147
351
|
if (!fs.statSync(source, {throwIfNoEntry: false})?.isFile()) throw new Error('请提供已有的 ZIP 存档。');
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await validateZip(
|
|
151
|
-
await
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
}
|
|
159
386
|
console.log('已恢复并中和外发任务: ' + database);
|
|
160
|
-
if (
|
|
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);
|
|
161
463
|
});
|
|
162
464
|
}
|
|
163
465
|
|
package/src/lib/environment.mjs
CHANGED
|
@@ -11,7 +11,10 @@ export function readEnv(root) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function projectName(root) {
|
|
14
|
-
|
|
14
|
+
return projectNameForPath(fs.realpathSync(root));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function projectNameForPath(location) {
|
|
15
18
|
const prefix = path.basename(location).toLowerCase().replace(/[^a-z0-9_-]/g, '_').replace(/^[_-]+/, '') || 'odoo';
|
|
16
19
|
return prefix.slice(0, 32) + '_' + crypto.createHash('sha256').update(location).digest('hex').slice(0, 10);
|
|
17
20
|
}
|
|
@@ -32,6 +35,10 @@ export function validateEnv(values) {
|
|
|
32
35
|
if (!/^[a-z0-9][a-z0-9_-]*$/.test(values.PROJECT_NAME)) throw new Error('Invalid PROJECT_NAME.');
|
|
33
36
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(values.PGDATABASE)) throw new Error('Invalid PGDATABASE.');
|
|
34
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
|
+
|
|
35
42
|
const ports = ['DB_PORT', 'ODOO_HTTP_PORT', 'ODOO_GEVENT_PORT'].map(key => {
|
|
36
43
|
const value = values[key];
|
|
37
44
|
if (!/^\d+$/.test(value || '') || +value < 1 || +value > 65535) throw new Error('Invalid ' + key + '.');
|
|
@@ -55,16 +62,18 @@ export async function prepareEnv(root) {
|
|
|
55
62
|
if ((await Promise.all(candidate.map(portFree))).every(Boolean)) { ports = candidate; break; }
|
|
56
63
|
}
|
|
57
64
|
if (!ports) throw new Error('No free host port set found.');
|
|
58
|
-
|
|
65
|
+
const generated = {
|
|
59
66
|
PROJECT_NAME: name, DB_PORT: String(ports[0]), ODOO_HTTP_PORT: String(ports[1]), ODOO_GEVENT_PORT: String(ports[2]),
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
validateEnv(values);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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;
|
|
68
77
|
}
|
|
69
78
|
try { fs.writeFileSync(filename, text, {flag: 'wx', mode: 0o600}); }
|
|
70
79
|
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
package/src/lib/logs.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export function parseLogOptions(args) {
|
|
|
20
20
|
follow: {type: 'boolean', short: 'f', default: false},
|
|
21
21
|
tail: {type: 'string', short: 'n', default: '200'},
|
|
22
22
|
since: {type: 'string'}, until: {type: 'string'}, level: {type: 'string'},
|
|
23
|
-
match: {type: 'string'}, context: {type: 'string'},
|
|
23
|
+
match: {type: 'string'}, context: {type: 'string'}, 'hide-health': {type: 'boolean', default: false},
|
|
24
24
|
json: {type: 'boolean', default: false}, file: {type: 'string'},
|
|
25
25
|
}});
|
|
26
26
|
const services = [...new Set((positionals.length ? positionals : ['odoo']).flatMap(service => service === 'all' ? ['odoo', 'db'] : [service]))];
|
|
@@ -32,7 +32,7 @@ export function parseLogOptions(args) {
|
|
|
32
32
|
if (values.match !== undefined && (!values.match.trim() || /[\r\n]/.test(values.match))) throw new Error('--match 需要非空的单行文本。');
|
|
33
33
|
if (values.context !== undefined && (!/^\d+$/.test(values.context) || +values.context > 100)) throw new Error('--context 必须为 0..100。');
|
|
34
34
|
if (values.file !== undefined && !values.file.trim()) throw new Error('--file 需要输出文件路径。');
|
|
35
|
-
if (values.follow && (level || values.match || values.context !== undefined || values.json || values.file)) throw new Error('级别、关键词、上下文、JSON 和文件导出用于快照;持续跟随请单独使用 -f。');
|
|
35
|
+
if (values.follow && (level || values.match || values.context !== undefined || values['hide-health'] || values.json || values.file)) throw new Error('级别、关键词、上下文、JSON 和文件导出用于快照;持续跟随请单独使用 -f。');
|
|
36
36
|
return {...values, level, services, context: +(values.context ?? 2), file: values.file ? path.resolve(values.file) : undefined};
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -85,6 +85,23 @@ export function parseLogEvents(lines) {
|
|
|
85
85
|
return {events: events.map(({messages, ...event}) => ({...event, message: messages.join('\n')})), diagnostics};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function isSuccessfulHealthCheck(event) {
|
|
89
|
+
return event.service === 'odoo' && event.level === 'info' && event.lines.length === 1 &&
|
|
90
|
+
(event.logger === 'odoo.http.server' || event.logger === 'werkzeug') &&
|
|
91
|
+
/"(?:GET|HEAD) \/web\/health(?:\?[^"\s]*)? HTTP\/\d+(?:\.\d+)?"\s+2\d{2}(?:\s|$)/.test(event.message);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function reachedTail(events, tail) {
|
|
95
|
+
if (tail === 'all' || Number(tail) === 0) return false;
|
|
96
|
+
const counts = new Map();
|
|
97
|
+
for (const event of events) {
|
|
98
|
+
const count = (counts.get(event.container) || 0) + event.lines.length;
|
|
99
|
+
if (count >= Number(tail)) return true;
|
|
100
|
+
counts.set(event.container, count);
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
88
105
|
export function selectLogEvents(events, options) {
|
|
89
106
|
const matching = events.map(event => (!options.level || (levels[event.level] ?? 0) >= levels[options.level]) && (!options.match || event.message.toLowerCase().includes(options.match.toLowerCase())));
|
|
90
107
|
const selected = new Set();
|
|
@@ -158,14 +175,15 @@ export async function executeLogs(options, {root, env, values, compose, read = d
|
|
|
158
175
|
// must not change service attribution or severity detection.
|
|
159
176
|
const {events, diagnostics: rawDiagnostics} = parseLogEvents(result.lines.map(line => stripVTControlCharacters(line)));
|
|
160
177
|
const diagnostics = rawDiagnostics.map(mask);
|
|
161
|
-
const
|
|
178
|
+
const candidates = options['hide-health'] ? events.filter(event => !isSuccessfulHealthCheck(event)) : events;
|
|
179
|
+
const selection = selectLogEvents(candidates, options);
|
|
162
180
|
const selected = {matched: selection.matched, events: selection.events.map(event => ({
|
|
163
181
|
...event, message: mask(event.message), lines: event.lines.map(maskLine),
|
|
164
182
|
...(event.logger ? {logger: mask(event.logger)} : {}),
|
|
165
183
|
...(event.database ? {database: mask(event.database)} : {}),
|
|
166
184
|
}))};
|
|
167
185
|
const exported = selected.events.map(({lines, index, ...event}) => event);
|
|
168
|
-
const payload = {project: values.PROJECT_NAME, services: options.services, query: {tail: options.tail, since: options.since, until: options.until, level: options.level, match: options.match ? mask(options.match) : undefined, context: options.context}, scanned: events.length, matched: selected.matched, shown: exported.length, diagnostics, events: exported};
|
|
186
|
+
const payload = {project: values.PROJECT_NAME, services: options.services, query: {tail: options.tail, since: options.since, until: options.until, level: options.level, match: options.match ? mask(options.match) : undefined, context: options.context, hideHealth: options['hide-health']}, scanned: events.length, hiddenHealth: events.length - candidates.length, tailReached: reachedTail(events, options.tail), matched: selected.matched, shown: exported.length, diagnostics, events: exported};
|
|
169
187
|
const output = options.json ? JSON.stringify(payload, null, 2) + '\n' : selected.events.flatMap(event => event.lines).join('\n') + (selected.events.length ? '\n' : '');
|
|
170
188
|
if (!options.json) for (const line of diagnostics) stderr.write(line + '\n');
|
|
171
189
|
if (options.file) {
|
package/src/project.mjs
CHANGED
|
@@ -54,20 +54,43 @@ export async function openProject(root, {initialize = false, runner = runDocker}
|
|
|
54
54
|
const compose = ['compose', '--file', path.join(root, 'compose.yaml'), '--project-directory', root, '--env-file', path.join(root, '.env'), '--project-name', values.PROJECT_NAME];
|
|
55
55
|
const docker = (args, io = {}) => runner(args, {root, env, ...io});
|
|
56
56
|
const run = (args, io) => docker([...compose, ...args], io);
|
|
57
|
-
return {root, values, env, compose, docker, run};
|
|
57
|
+
return {root, values, env, compose, docker, run, runner};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deadOwner(lock) {
|
|
61
|
+
try {
|
|
62
|
+
const {pid} = JSON.parse(fs.readFileSync(path.join(lock, 'owner.json'), 'utf8'));
|
|
63
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
64
|
+
process.kill(pid, 0);
|
|
65
|
+
} catch (error) { return error.code === 'ESRCH'; }
|
|
66
|
+
return false;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
export async function withLock(ctx, action) {
|
|
61
|
-
const
|
|
62
|
-
|
|
70
|
+
const local = path.join(ctx.root, '.local');
|
|
71
|
+
const lock = path.join(local, 'operation.lock');
|
|
72
|
+
const reclaim = path.join(local, 'operation.reclaim');
|
|
73
|
+
fs.mkdirSync(local, {recursive: true});
|
|
63
74
|
try { fs.mkdirSync(lock); }
|
|
64
75
|
catch (error) {
|
|
65
|
-
if (error.code
|
|
66
|
-
|
|
76
|
+
if (error.code !== 'EEXIST') throw error;
|
|
77
|
+
const busy = '本项目有进行中的操作。若操作已中断,请检查 .local/operation.lock/owner.json。';
|
|
78
|
+
if (!deadOwner(lock)) throw new Error(busy);
|
|
79
|
+
try { fs.mkdirSync(reclaim); }
|
|
80
|
+
catch (error) { if (error.code === 'EEXIST') throw new Error(busy); throw error; }
|
|
81
|
+
try {
|
|
82
|
+
if (fs.existsSync(lock)) {
|
|
83
|
+
if (!deadOwner(lock)) throw new Error(busy);
|
|
84
|
+
fs.rmSync(lock, {recursive: true});
|
|
85
|
+
}
|
|
86
|
+
try { fs.mkdirSync(lock); }
|
|
87
|
+
catch (error) { if (error.code === 'EEXIST') throw new Error(busy); throw error; }
|
|
88
|
+
} finally { fs.rmdirSync(reclaim); }
|
|
67
89
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
try {
|
|
91
|
+
fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({pid: process.pid, started: new Date().toISOString()}), {mode: 0o600});
|
|
92
|
+
return await action();
|
|
93
|
+
} finally { fs.rmSync(lock, {recursive: true, force: true}); }
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
export function projectVersion(root) {
|