@mjh-odoo/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COPYRIGHT +3 -0
- package/LICENSE +859 -0
- package/README.md +100 -2
- package/bin/odoo.mjs +11 -0
- package/docs/validation.md +35 -0
- package/package.json +39 -4
- package/src/cli.mjs +101 -0
- package/src/commands.mjs +167 -0
- package/src/lib/environment.mjs +90 -0
- package/src/lib/image.mjs +66 -0
- package/src/lib/logs.mjs +178 -0
- package/src/project.mjs +77 -0
package/src/lib/logs.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
import { parseArgs, stripVTControlCharacters } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const levels = {debug: 10, info: 20, warning: 30, error: 40, critical: 50};
|
|
8
|
+
const sourceLimit = 16 * 1024 * 1024;
|
|
9
|
+
const lineLimit = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function validTime(value) {
|
|
12
|
+
if (!value || /[\r\n]/.test(value)) return false;
|
|
13
|
+
if (/^\d+(?:\.\d+)?$/.test(value)) return true;
|
|
14
|
+
if (/^(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+$/.test(value)) return true;
|
|
15
|
+
return /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/i.test(value) && Number.isFinite(Date.parse(value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseLogOptions(args) {
|
|
19
|
+
const {values, positionals} = parseArgs({args, allowPositionals: true, options: {
|
|
20
|
+
follow: {type: 'boolean', short: 'f', default: false},
|
|
21
|
+
tail: {type: 'string', short: 'n', default: '200'},
|
|
22
|
+
since: {type: 'string'}, until: {type: 'string'}, level: {type: 'string'},
|
|
23
|
+
match: {type: 'string'}, context: {type: 'string'},
|
|
24
|
+
json: {type: 'boolean', default: false}, file: {type: 'string'},
|
|
25
|
+
}});
|
|
26
|
+
const services = [...new Set((positionals.length ? positionals : ['odoo']).flatMap(service => service === 'all' ? ['odoo', 'db'] : [service]))];
|
|
27
|
+
if (services.some(service => !['odoo', 'db'].includes(service))) throw new Error('日志服务只能是 odoo、db 或 all。');
|
|
28
|
+
if (values.tail !== 'all' && (!/^\d+$/.test(values.tail) || +values.tail > 100000)) throw new Error('--tail 必须为 0..100000 或 all。');
|
|
29
|
+
for (const key of ['since', 'until']) if (values[key] !== undefined && !validTime(values[key])) throw new Error(`--${key} 需要时长、Unix 时间戳或带时区的 RFC3339 时间。`);
|
|
30
|
+
const level = values.level?.toLowerCase();
|
|
31
|
+
if (level !== undefined && !Object.hasOwn(levels, level)) throw new Error('无效的 --level。');
|
|
32
|
+
if (values.match !== undefined && (!values.match.trim() || /[\r\n]/.test(values.match))) throw new Error('--match 需要非空的单行文本。');
|
|
33
|
+
if (values.context !== undefined && (!/^\d+$/.test(values.context) || +values.context > 100)) throw new Error('--context 必须为 0..100。');
|
|
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。');
|
|
36
|
+
return {...values, level, services, context: +(values.context ?? 2), file: values.file ? path.resolve(values.file) : undefined};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function logArguments(options) {
|
|
40
|
+
const argv = ['logs', '--no-color', '--timestamps', '--tail', options.tail];
|
|
41
|
+
if (options.follow) argv.push('--follow');
|
|
42
|
+
if (options.since) argv.push('--since', options.since);
|
|
43
|
+
if (options.until) argv.push('--until', options.until);
|
|
44
|
+
return [...argv, ...options.services];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function secretMask(values) {
|
|
48
|
+
const secrets = [...new Set(Object.entries(values).filter(([key, value]) => /PASSWORD|TOKEN|SECRET|KEY/.test(key) && value).flatMap(([, value]) => [value, encodeURIComponent(value)]))].sort((a, b) => b.length - a.length);
|
|
49
|
+
return input => secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), stripVTControlCharacters(input));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function severity(message) {
|
|
53
|
+
const odoo = message.match(/^\d{4}-\d{2}-\d{2} \S+\s+(\d+)\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+)\s+([^:]+):/);
|
|
54
|
+
if (odoo) return {header: true, level: odoo[2].toLowerCase(), database: odoo[3], logger: odoo[4], pid: +odoo[1]};
|
|
55
|
+
const postgres = message.match(/^\d{4}-\d{2}-\d{2} .*?\[(\d+)\]\s+(DEBUG\d?|INFO|NOTICE|LOG|WARNING|ERROR|FATAL|PANIC):/);
|
|
56
|
+
if (postgres) {
|
|
57
|
+
const key = postgres[2];
|
|
58
|
+
return {header: true, level: /^(FATAL|PANIC)$/.test(key) ? 'critical' : /^(LOG|NOTICE)$/.test(key) ? 'info' : key.startsWith('DEBUG') ? 'debug' : key.toLowerCase(), pid: +postgres[1]};
|
|
59
|
+
}
|
|
60
|
+
if (/^Traceback \(most recent call last\):/.test(message) || /^(?:[\w]+\.)*[\w]*(?:Error|Exception):/.test(message)) return {level: 'error'};
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseLogEvents(lines) {
|
|
65
|
+
const pending = new Map(), events = [], diagnostics = [];
|
|
66
|
+
const flush = key => { if (pending.has(key)) { events.push(pending.get(key)); pending.delete(key); } };
|
|
67
|
+
for (const [index, raw] of lines.entries()) {
|
|
68
|
+
const match = raw.match(/^(\S+)\s+\|\s?(\d{4}-\d{2}-\d{2}T\S+)\s?(.*)$/);
|
|
69
|
+
if (!match) { diagnostics.push(raw); continue; }
|
|
70
|
+
const [, container, timestamp, message] = match;
|
|
71
|
+
const meta = severity(message);
|
|
72
|
+
const current = pending.get(container);
|
|
73
|
+
if (meta.header || (meta.level === 'error' && !current?.level)) flush(container);
|
|
74
|
+
if (!pending.has(container)) {
|
|
75
|
+
const service = container.match(/(?:^|[-_])(odoo|db)(?:[-_]\d+)?$/)?.[1] || container;
|
|
76
|
+
const {header, ...fields} = meta;
|
|
77
|
+
pending.set(container, {service, container, timestamp, level: null, ...fields, messages: [], lines: [], index});
|
|
78
|
+
}
|
|
79
|
+
const event = pending.get(container);
|
|
80
|
+
event.messages.push(message);
|
|
81
|
+
event.lines.push(raw);
|
|
82
|
+
}
|
|
83
|
+
for (const key of pending.keys()) flush(key);
|
|
84
|
+
events.sort((a, b) => (Date.parse(a.timestamp) - Date.parse(b.timestamp)) || a.index - b.index);
|
|
85
|
+
return {events: events.map(({messages, ...event}) => ({...event, message: messages.join('\n')})), diagnostics};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function selectLogEvents(events, options) {
|
|
89
|
+
const matching = events.map(event => (!options.level || (levels[event.level] ?? 0) >= levels[options.level]) && (!options.match || event.message.toLowerCase().includes(options.match.toLowerCase())));
|
|
90
|
+
const selected = new Set();
|
|
91
|
+
matching.forEach((matched, index) => {
|
|
92
|
+
if (matched) for (let n = Math.max(0, index - options.context); n <= Math.min(events.length - 1, index + options.context); n++) selected.add(n);
|
|
93
|
+
});
|
|
94
|
+
return {matched: matching.filter(Boolean).length, events: events.flatMap((event, index) => selected.has(index) ? [{...event, context: !matching[index]}] : [])};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function dockerLines(argv, {root, env, follow, onLine}) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const child = spawn('docker', argv, {cwd: root, env, shell: false, stdio: ['ignore', 'pipe', 'pipe']});
|
|
100
|
+
const lines = [], diagnostics = [];
|
|
101
|
+
let bytes = 0, failure, interrupted = 0;
|
|
102
|
+
const signal = number => { interrupted = number; child.kill('SIGINT'); };
|
|
103
|
+
const onInterrupt = () => signal(130), onTerminate = () => signal(143);
|
|
104
|
+
if (follow) { process.once('SIGINT', onInterrupt); process.once('SIGTERM', onTerminate); }
|
|
105
|
+
const cleanup = () => { process.removeListener('SIGINT', onInterrupt); process.removeListener('SIGTERM', onTerminate); };
|
|
106
|
+
for (const [kind, stream] of [['stdout', child.stdout], ['stderr', child.stderr]]) {
|
|
107
|
+
let partial = 0;
|
|
108
|
+
stream.on('data', chunk => {
|
|
109
|
+
bytes += chunk.length;
|
|
110
|
+
const segments = chunk.toString().split('\n');
|
|
111
|
+
for (const [index, segment] of segments.entries()) {
|
|
112
|
+
partial += Buffer.byteLength(segment);
|
|
113
|
+
if (partial > lineLimit) failure = new Error('日志单行超过 1 MiB,请缩小日志范围或降低源日志详细程度。');
|
|
114
|
+
if (index < segments.length - 1) partial = 0;
|
|
115
|
+
}
|
|
116
|
+
if (!follow && bytes > sourceLimit) failure = new Error('日志快照超过 16 MiB,请缩小 --tail 或 --since 范围。');
|
|
117
|
+
if (failure) child.kill();
|
|
118
|
+
});
|
|
119
|
+
const reader = createInterface({input: stream, crlfDelay: Infinity});
|
|
120
|
+
reader.on('line', line => {
|
|
121
|
+
if (failure) return;
|
|
122
|
+
if (follow) onLine(line, kind);
|
|
123
|
+
else lines.push(line);
|
|
124
|
+
if (kind === 'stderr') {
|
|
125
|
+
diagnostics.push(line);
|
|
126
|
+
if (diagnostics.length > 15) diagnostics.shift();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
child.once('error', error => { cleanup(); reject(new Error('无法读取 Docker 日志: ' + error.message)); });
|
|
131
|
+
child.once('close', code => {
|
|
132
|
+
cleanup();
|
|
133
|
+
if (failure) reject(failure);
|
|
134
|
+
else if (interrupted) resolve({lines, interrupted});
|
|
135
|
+
else if (code !== 0) reject(new Error(`Docker 日志读取失败 (${code}): ${diagnostics.slice(-15).join('\n')}`));
|
|
136
|
+
else resolve({lines, interrupted: 0});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function executeLogs(options, {root, env, values, compose, read = dockerLines, stdout = process.stdout, stderr = process.stderr}) {
|
|
142
|
+
if (options.file && fs.existsSync(options.file)) throw new Error('日志文件已存在: ' + options.file);
|
|
143
|
+
const mask = secretMask(values);
|
|
144
|
+
const maskLine = input => {
|
|
145
|
+
const clean = stripVTControlCharacters(input);
|
|
146
|
+
const envelope = clean.match(/^(\S+\s+\|\s?\d{4}-\d{2}-\d{2}T\S+\s?)(.*)$/);
|
|
147
|
+
return envelope ? envelope[1] + mask(envelope[2]) : mask(clean);
|
|
148
|
+
};
|
|
149
|
+
let result;
|
|
150
|
+
try {
|
|
151
|
+
result = await read([...compose, ...logArguments(options)], {root, env, follow: options.follow, onLine: (line, kind) => {
|
|
152
|
+
const stream = kind === 'stderr' && !line.includes(' |') ? stderr : stdout;
|
|
153
|
+
stream.write(maskLine(line) + '\n');
|
|
154
|
+
}});
|
|
155
|
+
} catch (error) { throw new Error(mask(error.message)); }
|
|
156
|
+
if (options.follow) return result.interrupted;
|
|
157
|
+
// Parse original structure before redaction: a password such as "odoo"
|
|
158
|
+
// must not change service attribution or severity detection.
|
|
159
|
+
const {events, diagnostics: rawDiagnostics} = parseLogEvents(result.lines.map(line => stripVTControlCharacters(line)));
|
|
160
|
+
const diagnostics = rawDiagnostics.map(mask);
|
|
161
|
+
const selection = selectLogEvents(events, options);
|
|
162
|
+
const selected = {matched: selection.matched, events: selection.events.map(event => ({
|
|
163
|
+
...event, message: mask(event.message), lines: event.lines.map(maskLine),
|
|
164
|
+
...(event.logger ? {logger: mask(event.logger)} : {}),
|
|
165
|
+
...(event.database ? {database: mask(event.database)} : {}),
|
|
166
|
+
}))};
|
|
167
|
+
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};
|
|
169
|
+
const output = options.json ? JSON.stringify(payload, null, 2) + '\n' : selected.events.flatMap(event => event.lines).join('\n') + (selected.events.length ? '\n' : '');
|
|
170
|
+
if (!options.json) for (const line of diagnostics) stderr.write(line + '\n');
|
|
171
|
+
if (options.file) {
|
|
172
|
+
fs.mkdirSync(path.dirname(options.file), {recursive: true});
|
|
173
|
+
fs.writeFileSync(options.file, output, {flag: 'wx', mode: 0o600});
|
|
174
|
+
stderr.write('日志快照已保存: ' + options.file + '\n');
|
|
175
|
+
} else stdout.write(output);
|
|
176
|
+
if (!options.json && !selected.events.length) stderr.write('没有匹配的日志。\n');
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
package/src/project.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { prepareEnv, readEnv, validateEnv } from './lib/environment.mjs';
|
|
5
|
+
|
|
6
|
+
export function findProject(start = process.cwd()) {
|
|
7
|
+
let root = fs.realpathSync(path.resolve(start));
|
|
8
|
+
if (!fs.statSync(root).isDirectory()) throw new Error('项目路径必须是目录。');
|
|
9
|
+
while (true) {
|
|
10
|
+
if (fs.existsSync(path.join(root, 'compose.yaml')) && fs.existsSync(path.join(root, '.env.example'))) return root;
|
|
11
|
+
const parent = path.dirname(root);
|
|
12
|
+
if (parent === root) throw new Error('没有找到兼容的 Odoo base 项目。请进入项目目录,或使用 odoo --project <目录>。');
|
|
13
|
+
root = parent;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function databaseName(name) {
|
|
18
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name || '') || Buffer.byteLength(name) > 63) throw new Error('数据库名必须是 1..63 字节的字母、数字或下划线,且不能以数字开头。');
|
|
19
|
+
return name;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function serviceNames(names, fallback = ['odoo']) {
|
|
23
|
+
const result = [...new Set((names.length ? names : fallback).flatMap(value => value === 'all' ? ['odoo', 'db'] : [value]))];
|
|
24
|
+
if (result.some(name => !['odoo', 'db'].includes(name))) throw new Error('服务只能是 odoo、db 或 all。');
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runDocker(argv, {root, env, stdio = 'inherit', capture = false}) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const child = spawn('docker', argv, {cwd: root, env, shell: false, stdio: capture ? ['ignore', 'pipe', 'pipe'] : stdio});
|
|
31
|
+
const out = [], err = [];
|
|
32
|
+
let size = 0, failure;
|
|
33
|
+
if (capture) for (const [stream, chunks] of [[child.stdout, out], [child.stderr, err]]) stream.on('data', chunk => {
|
|
34
|
+
size += chunk.length;
|
|
35
|
+
if (size > 8 * 1024 * 1024) { failure = new Error('Docker 输出超过 8 MiB,请缩小查询范围。'); child.kill(); }
|
|
36
|
+
else chunks.push(chunk);
|
|
37
|
+
});
|
|
38
|
+
child.once('error', error => reject(new Error('无法执行 Docker: ' + error.message)));
|
|
39
|
+
child.once('close', (code, signal) => {
|
|
40
|
+
if (failure) reject(failure);
|
|
41
|
+
else if (code === 0) resolve(capture ? Buffer.concat(out).toString('utf8').trim() : '');
|
|
42
|
+
else {
|
|
43
|
+
const error = new Error(`Docker 操作失败 (${signal || code})${capture ? ': ' + Buffer.concat(err).toString('utf8').trim() : ',请查看上方输出。'}`);
|
|
44
|
+
error.exitCode = Number.isInteger(code) && code > 0 ? code : 1;
|
|
45
|
+
reject(error);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function openProject(root, {initialize = false, runner = runDocker} = {}) {
|
|
52
|
+
const values = initialize ? await prepareEnv(root) : validateEnv(readEnv(root));
|
|
53
|
+
const env = {...process.env, ...values};
|
|
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
|
+
const docker = (args, io = {}) => runner(args, {root, env, ...io});
|
|
56
|
+
const run = (args, io) => docker([...compose, ...args], io);
|
|
57
|
+
return {root, values, env, compose, docker, run};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function withLock(ctx, action) {
|
|
61
|
+
const lock = path.join(ctx.root, '.local', 'operation.lock');
|
|
62
|
+
fs.mkdirSync(path.dirname(lock), {recursive: true});
|
|
63
|
+
try { fs.mkdirSync(lock); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === 'EEXIST') throw new Error('本项目存在进行中的维护操作。若操作曾中断,请先核对 .local/operation.lock/owner.json 中的进程,再清理过期锁。');
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
fs.writeFileSync(path.join(lock, 'owner.json'), JSON.stringify({pid: process.pid, started: new Date().toISOString()}), {mode: 0o600});
|
|
69
|
+
try { return await action(); }
|
|
70
|
+
finally { fs.rmSync(lock, {recursive: true, force: true}); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function projectVersion(root) {
|
|
74
|
+
const filename = path.join(root, 'package.json');
|
|
75
|
+
if (!fs.existsSync(filename)) return '0.1.0';
|
|
76
|
+
return JSON.parse(fs.readFileSync(filename, 'utf8')).version || '0.1.0';
|
|
77
|
+
}
|