@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/archive.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
function catalogFile() {
|
|
7
|
+
const home = os.homedir();
|
|
8
|
+
const appData = process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support')
|
|
9
|
+
: process.platform === 'win32' ? process.env.APPDATA || path.join(home, 'AppData', 'Roaming')
|
|
10
|
+
: process.env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
11
|
+
return process.env.ODOO_MANAGER_CATALOG || path.join(appData, 'Odoo Manager', 'manager.sqlite');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function gitCommonDirectory(root) {
|
|
15
|
+
try {
|
|
16
|
+
const value = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
17
|
+
{cwd: root, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore']});
|
|
18
|
+
return fs.realpathSync(value.trim());
|
|
19
|
+
} catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveProject(db, root, optional) {
|
|
23
|
+
const direct = db.prepare('SELECT * FROM projects WHERE root = ?').get(root);
|
|
24
|
+
if (direct) return direct;
|
|
25
|
+
const common = gitCommonDirectory(root);
|
|
26
|
+
if (!common && optional) return null;
|
|
27
|
+
if (!common) throw new Error('请先在 Odoo Manager 接入当前项目。');
|
|
28
|
+
const matches = db.prepare('SELECT * FROM projects').all()
|
|
29
|
+
.filter(project => gitCommonDirectory(project.root) === common);
|
|
30
|
+
if (!matches.length && optional) return null;
|
|
31
|
+
if (matches.length !== 1) throw new Error(matches.length ? '当前工作树匹配了多个项目,请从已接入的项目目录执行。' : '请先在 Odoo Manager 接入当前项目。');
|
|
32
|
+
return matches[0];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function withArchiveCatalog(root, write, action, {optional = false} = {}) {
|
|
36
|
+
const file = catalogFile();
|
|
37
|
+
if (!fs.statSync(file, {throwIfNoEntry: false})?.isFile()) {
|
|
38
|
+
if (optional) return null;
|
|
39
|
+
throw new Error('找不到本机存档库,请先在 Odoo Manager 接入项目。');
|
|
40
|
+
}
|
|
41
|
+
const {DatabaseSync} = await import('node:sqlite');
|
|
42
|
+
const db = new DatabaseSync(file, {readOnly: !write});
|
|
43
|
+
try {
|
|
44
|
+
db.exec('PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
|
|
45
|
+
const project = resolveProject(db, root, optional);
|
|
46
|
+
if (!project) return null;
|
|
47
|
+
if (write) ensureArchiveTags(db);
|
|
48
|
+
return await action(db, project);
|
|
49
|
+
} finally { db.close(); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hasTable = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
|
|
53
|
+
|
|
54
|
+
export function transaction(db, action) {
|
|
55
|
+
db.exec('BEGIN IMMEDIATE');
|
|
56
|
+
try {
|
|
57
|
+
const result = action();
|
|
58
|
+
db.exec('COMMIT');
|
|
59
|
+
return result;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
db.exec('ROLLBACK');
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ensureArchiveTags(db) {
|
|
67
|
+
transaction(db, () => {
|
|
68
|
+
db.exec(`CREATE TABLE IF NOT EXISTS archive_tags (
|
|
69
|
+
projectId TEXT NOT NULL REFERENCES projects(id),
|
|
70
|
+
tag TEXT NOT NULL,
|
|
71
|
+
archiveId TEXT NOT NULL REFERENCES archives(id),
|
|
72
|
+
PRIMARY KEY (projectId, tag)
|
|
73
|
+
);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS project_seeds (
|
|
75
|
+
projectId TEXT PRIMARY KEY REFERENCES projects(id), reference TEXT NOT NULL
|
|
76
|
+
);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS project_runtimes (
|
|
78
|
+
root TEXT PRIMARY KEY, projectId TEXT NOT NULL REFERENCES projects(id), composeProject TEXT NOT NULL
|
|
79
|
+
)`);
|
|
80
|
+
if (hasTable(db, 'archive_defaults')) {
|
|
81
|
+
db.exec(`INSERT OR IGNORE INTO archive_tags
|
|
82
|
+
SELECT projectId, 'latest', archiveId FROM archive_defaults;
|
|
83
|
+
DROP TABLE archive_defaults;`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function projectTags(db, projectId) {
|
|
89
|
+
if (hasTable(db, 'archive_tags')) {
|
|
90
|
+
return db.prepare('SELECT tag, archiveId FROM archive_tags WHERE projectId = ? ORDER BY tag').all(projectId);
|
|
91
|
+
}
|
|
92
|
+
if (hasTable(db, 'archive_defaults')) {
|
|
93
|
+
return db.prepare("SELECT 'latest' AS tag, archiveId FROM archive_defaults WHERE projectId = ?").all(projectId);
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function archiveList(root, {json}) {
|
|
99
|
+
const result = await withArchiveCatalog(root, false, (db, project) => {
|
|
100
|
+
const tags = projectTags(db, project.id);
|
|
101
|
+
const rows = db.prepare('SELECT id, title, note, environment, database, bytes, created, revision, source FROM archives WHERE projectId = ? ORDER BY created DESC').all(project.id);
|
|
102
|
+
return rows.map(archive => ({...archive, tags: tags.filter(tag => tag.archiveId === archive.id).map(tag => tag.tag)}));
|
|
103
|
+
});
|
|
104
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
105
|
+
else if (!result.length) console.log('此项目还没有数据库存档。');
|
|
106
|
+
else for (const archive of result) console.log([archive.id, archive.title, archive.database, archive.tags.join(', ')].filter(Boolean).join(' '));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findArchive(db, projectId, id, requireFile = true) {
|
|
110
|
+
const row = db.prepare('SELECT * FROM archives WHERE projectId = ? AND id = ?').get(projectId, id);
|
|
111
|
+
if (!row) throw new Error('此项目没有该存档:' + id);
|
|
112
|
+
if (requireFile && (!path.isAbsolute(row.file) || !fs.statSync(row.file, {throwIfNoEntry: false})?.isFile())) {
|
|
113
|
+
throw new Error('存档 ZIP 已丢失或不可用:' + id + '。请在 Odoo Manager 检查存档。');
|
|
114
|
+
}
|
|
115
|
+
return row;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function tagName(value) {
|
|
119
|
+
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(value)) throw new Error('标签须以小写字母开头,最多 64 字符,可包含小写字母、数字、点、下划线和短横线。');
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findReference(db, projectId, reference) {
|
|
124
|
+
if (reference.startsWith('id:')) return findArchive(db, projectId, reference.slice(3));
|
|
125
|
+
const tags = projectTags(db, projectId);
|
|
126
|
+
const explicitTag = reference.startsWith('tag:');
|
|
127
|
+
const tag = explicitTag ? tagName(reference.slice(4)) : reference === 'default' ? 'latest' : reference;
|
|
128
|
+
const selected = tags.find(row => row.tag === tag);
|
|
129
|
+
if (!selected && (explicitTag || reference === 'default' || reference === 'latest')) throw new Error('此项目尚未设置标签:' + tag);
|
|
130
|
+
return findArchive(db, projectId, selected?.archiveId || reference);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function resolveArchive(root, reference) {
|
|
134
|
+
return withArchiveCatalog(root, false, (db, project) => findReference(db, project.id, reference));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function archiveSeed(root, reference, {json} = {}) {
|
|
138
|
+
const result = await withArchiveCatalog(root, reference !== undefined, (db, project) => {
|
|
139
|
+
if (reference !== undefined) transaction(db, () => {
|
|
140
|
+
if (reference.startsWith('tag:')) tagName(reference.slice(4));
|
|
141
|
+
else if (reference.startsWith('id:')) findReference(db, project.id, reference);
|
|
142
|
+
else if (reference !== 'empty') throw new Error('数据来源使用 tag:<标签>、id:<存档ID> 或 empty。');
|
|
143
|
+
db.prepare('INSERT INTO project_seeds VALUES (?, ?) ON CONFLICT(projectId) DO UPDATE SET reference = excluded.reference')
|
|
144
|
+
.run(project.id, reference);
|
|
145
|
+
});
|
|
146
|
+
return hasTable(db, 'project_seeds') ? db.prepare('SELECT reference FROM project_seeds WHERE projectId = ?').get(project.id)?.reference : null;
|
|
147
|
+
});
|
|
148
|
+
console.log(json ? JSON.stringify({reference: result || 'empty'}) : (result || 'empty'));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function startupSeed(root, values, preview = false) {
|
|
152
|
+
if (values.ENV === 'PROD' || values.ODOO_SEED_MODE === 'empty' ||
|
|
153
|
+
(values.ODOO_SEED && values.ODOO_SEED_MODE !== 'first-start')) return {reference: null, source: 'env'};
|
|
154
|
+
const configured = values.ODOO_SEED && values.ODOO_SEED_MODE === 'first-start' ? values.ODOO_SEED : null;
|
|
155
|
+
return await withArchiveCatalog(root, false, (db, project) => {
|
|
156
|
+
const projectSeed = root !== project.root && hasTable(db, 'project_seeds') ? db.prepare('SELECT reference FROM project_seeds WHERE projectId = ?').get(project.id)?.reference : null;
|
|
157
|
+
const reference = configured || projectSeed || null;
|
|
158
|
+
const source = configured ? 'env' : 'project';
|
|
159
|
+
if (!reference || reference === 'empty') return {reference: null, source};
|
|
160
|
+
if (!preview) return {reference, source};
|
|
161
|
+
try {
|
|
162
|
+
const archive = findReference(db, project.id, reference);
|
|
163
|
+
return {reference, source, archive: {id: archive.id, title: archive.title}};
|
|
164
|
+
} catch (error) { return {reference, source, issue: error.message}; }
|
|
165
|
+
}, {optional: true}) || {reference: configured, source: configured ? 'env' : 'project',
|
|
166
|
+
...(configured && preview ? {issue: '请先在 Odoo Manager 接入当前项目。'} : {})};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function rememberRuntime(root, composeProject) {
|
|
170
|
+
return withArchiveCatalog(root, true, (db, project) => {
|
|
171
|
+
if (root === project.root) return;
|
|
172
|
+
db.prepare('INSERT INTO project_runtimes VALUES (?, ?, ?) ON CONFLICT(root) DO UPDATE SET composeProject = excluded.composeProject')
|
|
173
|
+
.run(root, project.id, composeProject);
|
|
174
|
+
}, {optional: true});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function archiveTags(root, {json}) {
|
|
178
|
+
const rows = await withArchiveCatalog(root, false, (db, project) =>
|
|
179
|
+
projectTags(db, project.id).map(row => ({...row, title: findArchive(db, project.id, row.archiveId, false).title})));
|
|
180
|
+
if (json) console.log(JSON.stringify(rows, null, 2));
|
|
181
|
+
else for (const row of rows) console.log(row.tag + ' → ' + row.title + ' (' + row.archiveId + ')');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function archiveTag(root, tag, id) {
|
|
185
|
+
tagName(tag);
|
|
186
|
+
await withArchiveCatalog(root, true, (db, project) => transaction(db, () => {
|
|
187
|
+
if (!id) {
|
|
188
|
+
db.prepare('DELETE FROM archive_tags WHERE projectId = ? AND tag = ?').run(project.id, tag);
|
|
189
|
+
console.log('已移除标签:' + tag);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const row = findArchive(db, project.id, id);
|
|
193
|
+
pointTag(db, project.id, tag, row.id);
|
|
194
|
+
console.log(tag + ' → ' + row.title + ' (' + row.id + ')');
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function archiveDefault(root, id) {
|
|
199
|
+
if (id) return archiveTag(root, 'latest', id);
|
|
200
|
+
const selected = await withArchiveCatalog(root, false, (db, project) => projectTags(db, project.id).find(row => row.tag === 'latest'));
|
|
201
|
+
console.log(selected?.archiveId || '此项目尚未设置 latest。');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function deleteArchive(root, id) {
|
|
205
|
+
await withArchiveCatalog(root, true, (db, project) => transaction(db, () => {
|
|
206
|
+
const row = findArchive(db, project.id, id, false);
|
|
207
|
+
const tags = projectTags(db, project.id).filter(tag => tag.archiveId === id);
|
|
208
|
+
if (tags.length) throw new Error('请先移动或移除标签:' + tags.map(row => row.tag).join(', '));
|
|
209
|
+
if (db.prepare('SELECT 1 FROM project_seeds WHERE projectId = ? AND reference = ?').get(project.id, 'id:' + id)) {
|
|
210
|
+
throw new Error('该存档用于新工作树首次启动,请先更改项目的数据来源。');
|
|
211
|
+
}
|
|
212
|
+
if (!path.isAbsolute(row.file) || path.basename(row.file) !== row.id + '.zip') throw new Error('无法确认存档文件位置。');
|
|
213
|
+
fs.rmSync(row.file, {force: true});
|
|
214
|
+
db.prepare('DELETE FROM archives WHERE projectId = ? AND id = ?').run(project.id, id);
|
|
215
|
+
console.log('已删除存档:' + row.title);
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function pointTag(db, projectId, tag, id) {
|
|
220
|
+
db.prepare('INSERT INTO archive_tags VALUES (?, ?, ?) ON CONFLICT(projectId, tag) DO UPDATE SET archiveId = excluded.archiveId')
|
|
221
|
+
.run(projectId, tag, id);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function insertArchive(db, archive) {
|
|
225
|
+
const {id, projectId, title, note, environment, database, file, bytes, created, revision, source} = archive;
|
|
226
|
+
db.prepare('INSERT INTO archives (id, projectId, title, note, environment, database, file, bytes, created, revision, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
227
|
+
.run(id, projectId, title, note, environment, database, file, bytes, created, revision, source);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function saveArchive(db, archive, tag) {
|
|
231
|
+
transaction(db, () => {
|
|
232
|
+
insertArchive(db, archive);
|
|
233
|
+
if (tag) pointTag(db, archive.projectId, tag, archive.id);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function archiveLocation(db, root, id) {
|
|
238
|
+
const settings = JSON.parse(db.prepare('SELECT value FROM settings WHERE id = 1').get()?.value || '{}');
|
|
239
|
+
if (typeof settings.archiveRoot !== 'string' || !path.isAbsolute(settings.archiveRoot)) {
|
|
240
|
+
throw new Error('请在 Odoo Manager 设置存档目录。');
|
|
241
|
+
}
|
|
242
|
+
fs.mkdirSync(settings.archiveRoot, {recursive: true});
|
|
243
|
+
const directory = fs.realpathSync(settings.archiveRoot);
|
|
244
|
+
const roots = [root, ...db.prepare('SELECT root FROM environments').all().map(env => env.root)];
|
|
245
|
+
if (roots.some(checkout => {
|
|
246
|
+
const relative = path.relative(checkout, directory);
|
|
247
|
+
return !relative || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
|
|
248
|
+
})) throw new Error('请选择项目和工作树之外的存档目录。');
|
|
249
|
+
return path.join(directory, id + '.zip');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function archiveSource(db, project, root) {
|
|
253
|
+
const name = db.prepare('SELECT name FROM environments WHERE projectId = ? AND root = ?').get(project.id, root)?.name;
|
|
254
|
+
let revision = '';
|
|
255
|
+
try {
|
|
256
|
+
const git = args => execFileSync('git', args, {cwd: root, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore']}).trim();
|
|
257
|
+
revision = git(['rev-parse', 'HEAD']) + (git(['status', '--porcelain']) ? '(有未提交改动)' : '');
|
|
258
|
+
} catch { /* Git metadata is optional. */ }
|
|
259
|
+
return {environment: name || path.basename(root), revision};
|
|
260
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { projectNameForPath } from './lib/environment.mjs';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { withArchiveCatalog } from './archive.mjs';
|
|
6
|
+
|
|
7
|
+
const managedVolumes = ['db-data', 'odoo-data'];
|
|
8
|
+
|
|
9
|
+
async function retired(root) {
|
|
10
|
+
return await withArchiveCatalog(root, false, (db, project) => {
|
|
11
|
+
const roots = db.prepare("SELECT root, name FROM environments WHERE projectId = ? AND kind = 'worktree'").all(project.id);
|
|
12
|
+
const names = new Map(roots.map(row => [row.root, row.name]));
|
|
13
|
+
const runtimes = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'project_runtimes'").get()
|
|
14
|
+
? db.prepare('SELECT root, composeProject FROM project_runtimes WHERE projectId = ?').all(project.id) : [];
|
|
15
|
+
const rows = new Map(roots.map(row => [row.root, projectNameForPath(row.root)]));
|
|
16
|
+
for (const row of runtimes) rows.set(row.root, row.composeProject);
|
|
17
|
+
return [...rows].filter(([directory]) => directory !== project.root && !fs.existsSync(directory))
|
|
18
|
+
.map(([directory, composeProject]) => ({
|
|
19
|
+
root: directory, composeProject, name: names.get(directory) || path.basename(directory),
|
|
20
|
+
managed: composeProject === projectNameForPath(directory), repository: project.root,
|
|
21
|
+
}));
|
|
22
|
+
}) || [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function exactVolumes(docker, composeProject) {
|
|
26
|
+
const raw = await docker(['volume', 'ls', '--quiet', '--filter', 'label=com.docker.compose.project=' + composeProject]);
|
|
27
|
+
return managedVolumes.map(kind => composeProject + '_' + kind).filter(name => raw.split(/\r?\n/).includes(name));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function archivedWorktrees(root, docker) {
|
|
31
|
+
const rows = await retired(root);
|
|
32
|
+
if (!rows.length) return [];
|
|
33
|
+
const usage = await docker(['system', 'df', '-v', '--format', 'json']).then(raw => JSON.parse(raw).Volumes || []).catch(() => []);
|
|
34
|
+
const sizes = new Map(usage.map(row => [row.Name, row.Size]));
|
|
35
|
+
return Promise.all(rows.map(async row => {
|
|
36
|
+
const volumes = row.managed ? await exactVolumes(docker, row.composeProject) : [];
|
|
37
|
+
return {...row, volumes: volumes.map(name => ({name, size: sizes.get(name) || null}))};
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function cleanArchivedWorktree(root, composeProject, docker) {
|
|
42
|
+
const row = (await retired(root)).find(row => row.composeProject === composeProject);
|
|
43
|
+
if (!row?.managed) throw new Error('仅可清理路径已移除且使用自动 Compose 标识的已归档工作树。');
|
|
44
|
+
const check = () => {
|
|
45
|
+
if (fs.existsSync(row.root)) throw new Error('工作树已恢复,请刷新列表。');
|
|
46
|
+
const registered = execFileSync('git', ['worktree', 'list', '--porcelain', '-z'], {
|
|
47
|
+
cwd: row.repository, timeout: 5000, encoding: 'utf8',
|
|
48
|
+
}).split('\0');
|
|
49
|
+
if (registered.includes('worktree ' + row.root)) throw new Error('Git 仍登记此工作树,请先完成归档。');
|
|
50
|
+
};
|
|
51
|
+
check();
|
|
52
|
+
const containers = await docker(['ps', '-a', '-q', '--filter', 'label=com.docker.compose.project=' + composeProject]);
|
|
53
|
+
if (containers) throw new Error('该环境仍有 Docker 容器,请先完成停止和归档。');
|
|
54
|
+
const volumes = await exactVolumes(docker, composeProject);
|
|
55
|
+
if (volumes.length) {
|
|
56
|
+
const inspected = JSON.parse(await docker(['volume', 'inspect', ...volumes]));
|
|
57
|
+
if (inspected.length !== volumes.length || inspected.some(row =>
|
|
58
|
+
row.Labels?.['com.docker.compose.project'] !== composeProject ||
|
|
59
|
+
row.Name !== composeProject + '_' + row.Labels?.['com.docker.compose.volume'] ||
|
|
60
|
+
!volumes.includes(row.Name))) throw new Error('卷归属发生变化,请刷新后再试。');
|
|
61
|
+
check();
|
|
62
|
+
await docker(['volume', 'rm', ...volumes]);
|
|
63
|
+
}
|
|
64
|
+
return volumes;
|
|
65
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseEnv } from 'node:util';
|
|
1
4
|
import { Command, Option, InvalidArgumentError } from 'commander';
|
|
2
5
|
import { findProject, openProject, databaseName, runDocker, projectVersion } from './project.mjs';
|
|
3
6
|
import { readEnv } from './lib/environment.mjs';
|
|
4
7
|
import { parseLogOptions, executeLogs } from './lib/logs.mjs';
|
|
5
8
|
import { imageBuildPlan, executeImageBuild } from './lib/image.mjs';
|
|
9
|
+
import { archivedWorktrees, cleanArchivedWorktree } from './archived-worktrees.mjs';
|
|
10
|
+
import { exportLibrary, importLibrary } from './archive-library.mjs';
|
|
6
11
|
import * as commands from './commands.mjs';
|
|
12
|
+
import { archiveDefault, archiveList, archiveTag, archiveTags, deleteArchive, tagName, archiveSeed, startupSeed } from './archive.mjs';
|
|
7
13
|
|
|
8
14
|
function integer(value) {
|
|
9
15
|
if (!/^\d+$/.test(value) || +value > 3600) throw new InvalidArgumentError('请输入 0..3600 的整数。');
|
|
@@ -18,7 +24,7 @@ function argumentsFor(options, names) {
|
|
|
18
24
|
return names.flatMap(name => {
|
|
19
25
|
const key = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
20
26
|
const value = options[key];
|
|
21
|
-
return value === true ? ['--' + name] : value === undefined || value === false ? [] : ['--' + name
|
|
27
|
+
return value === true ? ['--' + name] : value === undefined || value === false ? [] : ['--' + name + '=' + String(value)];
|
|
22
28
|
});
|
|
23
29
|
}
|
|
24
30
|
|
|
@@ -52,10 +58,10 @@ export function createProgram({version, cwd = process.cwd(), runner = runDocker}
|
|
|
52
58
|
.option('-f, --follow', '持续查看,Ctrl+C 只退出查看').option('-n, --tail <lines>', '每容器行数,支持 0 或 all', '200')
|
|
53
59
|
.option('--since <time>', '起始时间,如 10m 或带时区 RFC3339').option('--until <time>', '截止时间')
|
|
54
60
|
.option('--level <level>', '最低级别:debug/info/warning/error/critical').option('--match <text>', '不区分大小写的字面筛选')
|
|
55
|
-
.option('--context <events>', '筛选结果前后事件数,默认 2').option('--json', '输出完整 JSON 快照').option('--file <path>', '保存快照,拒绝覆盖已有文件');
|
|
61
|
+
.option('--hide-health', '隐藏成功的 Odoo 健康检查').option('--context <events>', '筛选结果前后事件数,默认 2').option('--json', '输出完整 JSON 快照').option('--file <path>', '保存快照,拒绝覆盖已有文件');
|
|
56
62
|
logs.addHelpText('after', '\n筛选、JSON 和文件用于有限快照,不能与 -f 同用。保留完整异常堆栈;--tail 起点仍可能截到异常中间。');
|
|
57
63
|
logs.action(async (services, options) => {
|
|
58
|
-
const parsed = parseLogOptions([...(services || []), ...argumentsFor(options, ['follow', 'tail', 'since', 'until', 'level', 'match', 'context', 'json', 'file'])]);
|
|
64
|
+
const parsed = parseLogOptions([...(services || []), ...argumentsFor(options, ['follow', 'tail', 'since', 'until', 'level', 'match', 'context', 'hide-health', 'json', 'file'])]);
|
|
59
65
|
process.exitCode = await executeLogs(parsed, await context(false));
|
|
60
66
|
});
|
|
61
67
|
|
|
@@ -71,11 +77,92 @@ export function createProgram({version, cwd = process.cwd(), runner = runDocker}
|
|
|
71
77
|
|
|
72
78
|
const db = program.command('db').description('数据库查询及标准 ZIP 备份恢复');
|
|
73
79
|
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')
|
|
75
|
-
|
|
76
|
-
.option('--
|
|
80
|
+
databaseOption(db.command('backup').description('导出数据库及附件 ZIP')
|
|
81
|
+
.addOption(new Option('--file <path>', '输出到指定路径').conflicts('name'))
|
|
82
|
+
.option('--name <title>', '按名称保存到 Odoo Manager 存档库').option('--note <text>', '存档备注(与 --name 一起使用)')
|
|
83
|
+
.option('--json', '输出存档 JSON(与 --name 一起使用)')).action(async options => {
|
|
84
|
+
if (options.name === undefined && (options.note !== undefined || options.json)) throw new Error('--note 和 --json 需要 --name。');
|
|
85
|
+
await commands.backup(await context(false), options);
|
|
86
|
+
});
|
|
87
|
+
databaseOption(db.command('restore').description('恢复 Odoo ZIP,默认中和外发任务')
|
|
88
|
+
.argument('[reference]', '本项目的标签或存档 ID')
|
|
89
|
+
.option('--to <name>', '恢复目标库', databaseName)
|
|
90
|
+
.option('--use', '恢复成功后切换到新库')
|
|
91
|
+
.addOption(new Option('--file <path>', '已有的 Odoo 20 ZIP').conflicts('archive'))
|
|
92
|
+
.option('--archive <reference>', '兼容入口,指定项目存档')
|
|
93
|
+
.option('--force', '允许覆盖已有目标数据库').option('--no-restart', '恢复后保持 Odoo 停止')).action(async (reference, options) => {
|
|
94
|
+
if ([reference, options.file, options.archive].filter(Boolean).length !== 1) throw new Error('请指定标签、存档 ID 或 --file <path>。');
|
|
95
|
+
if (options.to && options.database) throw new Error('请选择 --to 或 --database 中的一个。');
|
|
96
|
+
const database = options.to || options.database;
|
|
97
|
+
if ((reference || options.use) && !database) throw new Error('请用 --to 指定新的目标数据库名称。');
|
|
98
|
+
if (options.use && options.force) throw new Error('恢复并切换需要一个新库,不能同时指定 --force。');
|
|
99
|
+
await commands.restore(await context(false), {...options, archive: reference || options.archive, database, legacy: !reference});
|
|
100
|
+
});
|
|
101
|
+
db.command('recover').description('清理本次恢复产生的残留库').option('--yes', '确认清理')
|
|
102
|
+
.action(async options => {
|
|
103
|
+
if (!options.yes) throw new Error('清理恢复残留需要 --yes。');
|
|
104
|
+
await commands.recoverDatabase(await context(false));
|
|
105
|
+
});
|
|
106
|
+
db.command('use').description('切换当前环境使用的数据库').argument('<database>', '已有数据库', databaseName)
|
|
107
|
+
.action(async name => commands.useDatabase(await context(false), name));
|
|
108
|
+
db.command('delete').description('删除指定数据库及其附件')
|
|
109
|
+
.argument('<database>', '目标数据库', databaseName).option('--yes', '确认删除')
|
|
110
|
+
.action(async (name, options) => {
|
|
111
|
+
if (!options.yes) throw new Error('删除数据库及附件需要 --yes。');
|
|
112
|
+
await commands.deleteDatabase(await context(false), name);
|
|
113
|
+
});
|
|
77
114
|
db.action(() => db.help());
|
|
78
115
|
|
|
116
|
+
const archive = program.command('archive').description('管理项目数据库存档');
|
|
117
|
+
archive.command('seed').description('查看或设置项目的首次启动数据来源')
|
|
118
|
+
.argument('[reference]', 'tag:<标签>、id:<存档ID> 或 empty').option('--json', '输出 JSON')
|
|
119
|
+
.action((reference, options) => archiveSeed(root(), reference, options));
|
|
120
|
+
archive.command('source').description('预览当前环境首次启动的数据来源')
|
|
121
|
+
.action(async () => {
|
|
122
|
+
const directory = root();
|
|
123
|
+
const values = fs.existsSync(path.join(directory, '.env')) ? readEnv(directory) : parseEnv(fs.readFileSync(path.join(directory, '.env.example'), 'utf8'));
|
|
124
|
+
console.log(JSON.stringify(await startupSeed(directory, values, true)));
|
|
125
|
+
});
|
|
126
|
+
archive.command('list').description('列出本项目的存档、标签和 ID').option('--json', '输出 JSON').action(options => archiveList(root(), options));
|
|
127
|
+
databaseOption(archive.command('save').description('保存当前环境的 Odoo ZIP').argument('<title>', '存档名称')
|
|
128
|
+
.option('--note <text>', '存档备注').option('--tag <name>', '保存成功后创建或移动标签', tagName).option('--json', '输出 JSON'))
|
|
129
|
+
.action(async (name, options) => commands.backup(await context(false), {...options, name}));
|
|
130
|
+
archive.command('import').description('校验并导入 Odoo ZIP').argument('<file>', 'ZIP 文件')
|
|
131
|
+
.option('--name <title>', '存档名称').option('--json', '输出 JSON')
|
|
132
|
+
.action(async (file, options) => commands.importArchive(await context(true), file, options));
|
|
133
|
+
archive.command('delete').description('删除存档 ZIP').argument('<id>', '存档 ID').option('--yes', '确认删除')
|
|
134
|
+
.action((id, options) => {
|
|
135
|
+
if (!options.yes) throw new Error('删除存档 ZIP 需要 --yes。');
|
|
136
|
+
return deleteArchive(root(), id);
|
|
137
|
+
});
|
|
138
|
+
archive.command('default', {hidden: true}).argument('[id]').action(id => archiveDefault(root(), id));
|
|
139
|
+
const library = archive.command('library').description('迁移项目存档、标签和备注');
|
|
140
|
+
library.command('export').argument('<directory>', '新建的导出目录').description('导出项目存档库')
|
|
141
|
+
.action(async directory => console.log(JSON.stringify(await exportLibrary(root(), directory))));
|
|
142
|
+
library.command('import').argument('<directory>', '存档库目录').description('导入项目存档库')
|
|
143
|
+
.action(async directory => console.log(JSON.stringify(await importLibrary(root(), directory))));
|
|
144
|
+
library.action(() => library.help());
|
|
145
|
+
const tag = archive.command('tag').description('管理项目存档标签');
|
|
146
|
+
tag.command('list').description('列出标签及指向的存档').option('--json', '输出 JSON').action(options => archiveTags(root(), options));
|
|
147
|
+
tag.command('set').description('创建或移动标签').argument('<tag>').argument('<id>', '存档 ID').action((name, id) => archiveTag(root(), name, id));
|
|
148
|
+
tag.command('remove').description('移除标签').argument('<tag>').action(name => archiveTag(root(), name));
|
|
149
|
+
tag.action(() => tag.help());
|
|
150
|
+
archive.action(() => archive.help());
|
|
151
|
+
|
|
152
|
+
const worktree = program.command('worktree').description('查看已归档工作树的本地数据');
|
|
153
|
+
const runLocalDocker = (directory, args) => runner(args, {root: directory, env: process.env, capture: true});
|
|
154
|
+
worktree.command('archived').description('列出已归档工作树和数据卷').action(async () => {
|
|
155
|
+
const directory = root();
|
|
156
|
+
console.log(JSON.stringify(await archivedWorktrees(directory, args => runLocalDocker(directory, args))));
|
|
157
|
+
});
|
|
158
|
+
worktree.command('clean').description('删除已归档工作树的数据库和附件卷')
|
|
159
|
+
.argument('<compose-project>').option('--yes', '确认删除').action(async (project, options) => {
|
|
160
|
+
if (!options.yes) throw new Error('清理归档数据卷需要 --yes。');
|
|
161
|
+
const directory = root();
|
|
162
|
+
console.log(JSON.stringify(await cleanArchivedWorktree(directory, project, args => runLocalDocker(directory, args))));
|
|
163
|
+
});
|
|
164
|
+
worktree.action(() => worktree.help());
|
|
165
|
+
|
|
79
166
|
const image = program.command('image').description('构建基础镜像、项目镜像和离线包');
|
|
80
167
|
image.command('build').description('默认构建本地基础镜像')
|
|
81
168
|
.option('--platform <platforms>', 'amd64、arm64 或 amd64,arm64')
|