@wpmoo/toolkit 0.9.29 → 0.9.31
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/README.md +27 -4
- package/dist/approval-ledger.js +74 -0
- package/dist/cli-routes/doctor.js +20 -0
- package/dist/cli-routes/options.js +36 -0
- package/dist/cli-routes/reset.js +11 -0
- package/dist/cli-routes/source.js +123 -0
- package/dist/cli.js +31 -173
- package/dist/cockpit/command-registry.js +9 -3
- package/dist/cockpit/daily-prompts.js +33 -6
- package/dist/cockpit/menu.js +13 -8
- package/dist/cockpit/module-browser.js +79 -2
- package/dist/daily-actions.js +55 -12
- package/dist/databases.js +223 -36
- package/dist/doctor.js +188 -20
- package/dist/environment-policy.js +6 -6
- package/dist/external-templates.js +7 -1
- package/dist/github.js +11 -2
- package/dist/help.js +8 -6
- package/dist/module-actions.js +61 -16
- package/dist/module-manifest.js +6 -0
- package/dist/module-quality.js +154 -0
- package/dist/postgres-diagnostics.js +48 -10
- package/dist/repo-url.js +4 -7
- package/dist/safe-reset.js +21 -12
- package/dist/source-actions.js +90 -1
- package/dist/source-manifest.js +2 -2
- package/dist/templates.js +257 -19
- package/docs/1-0-readiness.md +37 -12
- package/docs/command-reference.md +22 -3
- package/docs/generated-environment-verification.md +23 -2
- package/docs/handoff.md +14 -2
- package/docs/lifecycle-recipes.md +6 -1
- package/docs/troubleshooting.md +29 -1
- package/package.json +1 -1
package/dist/databases.js
CHANGED
|
@@ -1,13 +1,128 @@
|
|
|
1
|
-
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
export const defaultDatabaseSnapshotMaxAgeMs = 24 * 60 * 60 * 1000;
|
|
5
|
-
export const databaseSnapshotDirectoryNames = ['backups', 'backup', 'snapshots'];
|
|
5
|
+
export const databaseSnapshotDirectoryNames = ['backups/snapshots', 'backups', 'backup', 'snapshots'];
|
|
6
6
|
export const databaseSnapshotExtensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
|
|
7
7
|
function isDatabaseSnapshotFile(fileName, extensions) {
|
|
8
8
|
const normalized = fileName.toLowerCase();
|
|
9
9
|
return extensions.some((extension) => normalized.endsWith(extension));
|
|
10
10
|
}
|
|
11
|
+
function fileExists(path) {
|
|
12
|
+
try {
|
|
13
|
+
return statSync(path).isFile();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function stripSnapshotExtension(fileName) {
|
|
20
|
+
const normalized = fileName.toLowerCase();
|
|
21
|
+
const suffixes = ['.filestore.tar.gz', '.sql.gz', '.tar.gz', '.dump', '.sql', '.zip', '.tar', '.json'];
|
|
22
|
+
const suffix = suffixes.find((candidate) => normalized.endsWith(candidate));
|
|
23
|
+
return suffix ? fileName.slice(0, -suffix.length) : undefined;
|
|
24
|
+
}
|
|
25
|
+
function snapshotComponentKind(fileName, snapshotExtensions) {
|
|
26
|
+
const normalized = fileName.toLowerCase();
|
|
27
|
+
if (normalized.endsWith('.json'))
|
|
28
|
+
return 'manifest';
|
|
29
|
+
if (normalized.endsWith('.filestore.tar.gz'))
|
|
30
|
+
return 'filestore';
|
|
31
|
+
return isDatabaseSnapshotFile(fileName, snapshotExtensions) ? 'dump' : undefined;
|
|
32
|
+
}
|
|
33
|
+
function snapshotNameFromPath(path) {
|
|
34
|
+
return stripSnapshotExtension(basename(path));
|
|
35
|
+
}
|
|
36
|
+
function snapshotComponentPriority(component) {
|
|
37
|
+
const normalized = component.fileName.toLowerCase();
|
|
38
|
+
if (normalized.endsWith('.dump'))
|
|
39
|
+
return 0;
|
|
40
|
+
if (normalized.endsWith('.sql.gz'))
|
|
41
|
+
return 1;
|
|
42
|
+
if (normalized.endsWith('.sql'))
|
|
43
|
+
return 2;
|
|
44
|
+
return 3;
|
|
45
|
+
}
|
|
46
|
+
function readSnapshotManifest(path) {
|
|
47
|
+
if (!path)
|
|
48
|
+
return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
51
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const defaultSnapshotDatabaseNameHints = ['devel', 'stage', 'staging', 'prod', 'production', 'test', 'postgres'];
|
|
58
|
+
function inferDatabaseNameFromSnapshotName(snapshotName, databaseNames) {
|
|
59
|
+
const candidates = databaseNames?.length ? databaseNames : defaultSnapshotDatabaseNameHints;
|
|
60
|
+
const match = candidates
|
|
61
|
+
.filter(isValidDatabaseName)
|
|
62
|
+
.sort((left, right) => right.length - left.length)
|
|
63
|
+
.find((database) => snapshotName.startsWith(`${database}-`) || snapshotName.startsWith(`${database}.`) || snapshotName.startsWith(`${database}_`));
|
|
64
|
+
return match;
|
|
65
|
+
}
|
|
66
|
+
function manifestDateMs(manifest) {
|
|
67
|
+
if (!manifest || typeof manifest.created_at !== 'string')
|
|
68
|
+
return undefined;
|
|
69
|
+
const parsed = Date.parse(manifest.created_at);
|
|
70
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
71
|
+
}
|
|
72
|
+
function manifestDatabaseName(manifest) {
|
|
73
|
+
return typeof manifest?.database === 'string' && isValidDatabaseName(manifest.database) ? manifest.database : undefined;
|
|
74
|
+
}
|
|
75
|
+
function snapshotGroups(targetDirectory, snapshotDirectories, snapshotExtensions) {
|
|
76
|
+
const groups = new Map();
|
|
77
|
+
for (const directoryName of snapshotDirectories) {
|
|
78
|
+
const directory = join(targetDirectory, directoryName);
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code === 'ENOENT') {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (!entry.isFile()) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const stem = stripSnapshotExtension(entry.name);
|
|
94
|
+
const kind = snapshotComponentKind(entry.name, snapshotExtensions);
|
|
95
|
+
if (!stem || !kind) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const path = join(directory, entry.name);
|
|
99
|
+
let stats;
|
|
100
|
+
try {
|
|
101
|
+
stats = statSync(path);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!stats.isFile()) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const key = `${directory}\0${stem}`;
|
|
110
|
+
const group = groups.get(key) ?? { directory, stem, dumps: [], filestores: [] };
|
|
111
|
+
const component = { path, fileName: entry.name, stem, kind, stats };
|
|
112
|
+
if (kind === 'manifest') {
|
|
113
|
+
group.manifest = component;
|
|
114
|
+
}
|
|
115
|
+
else if (kind === 'filestore') {
|
|
116
|
+
group.filestores.push(component);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
group.dumps.push(component);
|
|
120
|
+
}
|
|
121
|
+
groups.set(key, group);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...groups.values()];
|
|
125
|
+
}
|
|
11
126
|
const maintenanceDatabases = new Set(['postgres']);
|
|
12
127
|
const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
|
|
13
128
|
export function isValidDatabaseName(value) {
|
|
@@ -33,6 +148,22 @@ export function normalizeDatabaseName(value) {
|
|
|
33
148
|
}
|
|
34
149
|
return normalized;
|
|
35
150
|
}
|
|
151
|
+
export function normalizeSnapshotName(value) {
|
|
152
|
+
const normalized = value.trim();
|
|
153
|
+
if (!normalized) {
|
|
154
|
+
throw new Error('Invalid snapshot name: value is required.');
|
|
155
|
+
}
|
|
156
|
+
if (/\s/u.test(value)) {
|
|
157
|
+
throw new Error('Invalid snapshot name: whitespace is not allowed.');
|
|
158
|
+
}
|
|
159
|
+
if (normalized.startsWith('-')) {
|
|
160
|
+
throw new Error('Invalid snapshot name: leading hyphens are not allowed.');
|
|
161
|
+
}
|
|
162
|
+
if (!databaseNamePattern.test(normalized)) {
|
|
163
|
+
throw new Error('Invalid snapshot name: use letters, digits, underscores, dots, or hyphens without shell metacharacters or path characters.');
|
|
164
|
+
}
|
|
165
|
+
return normalized;
|
|
166
|
+
}
|
|
36
167
|
const listDatabasesQuery = [
|
|
37
168
|
'SELECT datname',
|
|
38
169
|
'FROM pg_database',
|
|
@@ -56,40 +187,35 @@ export function parseDatabaseListOutput(output, options = {}) {
|
|
|
56
187
|
return databases;
|
|
57
188
|
}
|
|
58
189
|
export function findDatabaseSnapshots(targetDirectory, options = {}) {
|
|
59
|
-
const { nowMs = Date.now(), snapshotDirectories = [...databaseSnapshotDirectoryNames], snapshotExtensions = [...databaseSnapshotExtensions], } = options;
|
|
60
|
-
const snapshots =
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
catch (error) {
|
|
68
|
-
if (error.code === 'ENOENT') {
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
throw error;
|
|
190
|
+
const { nowMs = Date.now(), snapshotDirectories = [...databaseSnapshotDirectoryNames], snapshotExtensions = [...databaseSnapshotExtensions], snapshotDatabaseNames, } = options;
|
|
191
|
+
const snapshots = snapshotGroups(targetDirectory, snapshotDirectories, snapshotExtensions)
|
|
192
|
+
.flatMap((group) => {
|
|
193
|
+
const primary = [...group.dumps].sort((left, right) => snapshotComponentPriority(left) - snapshotComponentPriority(right) ||
|
|
194
|
+
right.stats.mtimeMs - left.stats.mtimeMs ||
|
|
195
|
+
left.path.localeCompare(right.path))[0];
|
|
196
|
+
if (!primary) {
|
|
197
|
+
return [];
|
|
72
198
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
199
|
+
const manifest = readSnapshotManifest(group.manifest?.path);
|
|
200
|
+
const createdAtMs = manifestDateMs(manifest) ?? primary.stats.mtimeMs;
|
|
201
|
+
const databaseName = manifestDatabaseName(manifest) ?? inferDatabaseNameFromSnapshotName(group.stem, snapshotDatabaseNames);
|
|
202
|
+
const filestore = [...group.filestores].sort((left, right) => left.path.localeCompare(right.path))[0];
|
|
203
|
+
return [
|
|
204
|
+
{
|
|
205
|
+
name: typeof manifest?.name === 'string' && manifest.name.trim() ? manifest.name.trim() : group.stem,
|
|
206
|
+
path: primary.path,
|
|
207
|
+
dumpPath: primary.path,
|
|
208
|
+
manifestPath: group.manifest?.path,
|
|
209
|
+
databaseName,
|
|
210
|
+
createdAtMs,
|
|
211
|
+
createdAt: new Date(createdAtMs).toISOString(),
|
|
212
|
+
mtimeMs: primary.stats.mtimeMs,
|
|
213
|
+
ageMs: Math.max(0, nowMs - primary.stats.mtimeMs),
|
|
214
|
+
filestorePath: filestore?.path,
|
|
215
|
+
filestoreStatus: filestore ? 'found' : 'missing',
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
});
|
|
93
219
|
snapshots.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
|
|
94
220
|
const newestSnapshot = snapshots[0] ?? null;
|
|
95
221
|
return {
|
|
@@ -98,11 +224,72 @@ export function findDatabaseSnapshots(targetDirectory, options = {}) {
|
|
|
98
224
|
newestSnapshotAgeMs: newestSnapshot ? newestSnapshot.ageMs : null,
|
|
99
225
|
};
|
|
100
226
|
}
|
|
227
|
+
export function restoreSnapshotPreflight(targetDirectory, snapshotName, requestedDatabase = 'devel', options = {}) {
|
|
228
|
+
const snapshots = findDatabaseSnapshots(targetDirectory, options);
|
|
229
|
+
const snapshot = snapshots.snapshots.find((candidate) => candidate.name === snapshotName || snapshotNameFromPath(candidate.path) === snapshotName);
|
|
230
|
+
const snapshotDirectory = join(targetDirectory, 'backups', 'snapshots');
|
|
231
|
+
const manifestPath = snapshot?.manifestPath ?? join(snapshotDirectory, `${snapshotName}.json`);
|
|
232
|
+
const manifest = readSnapshotManifest(fileExists(manifestPath) ? manifestPath : undefined);
|
|
233
|
+
const manifestDatabase = snapshot?.databaseName ?? manifestDatabaseName(manifest);
|
|
234
|
+
const dumpPath = snapshot?.dumpPath ?? join(snapshotDirectory, `${snapshotName}.dump`);
|
|
235
|
+
const filestorePath = snapshot?.filestorePath ??
|
|
236
|
+
(typeof manifest?.filestore === 'string' ? join(snapshotDirectory, manifest.filestore) : join(snapshotDirectory, `${snapshotName}.filestore.tar.gz`));
|
|
237
|
+
const dumpStatus = fileExists(dumpPath) ? 'found' : 'missing';
|
|
238
|
+
const filestoreStatus = fileExists(filestorePath) ? 'found' : 'missing';
|
|
239
|
+
const databaseMatches = manifestDatabase ? manifestDatabase === requestedDatabase : undefined;
|
|
240
|
+
const issues = [];
|
|
241
|
+
if (dumpStatus === 'missing') {
|
|
242
|
+
issues.push('missing snapshot dump');
|
|
243
|
+
}
|
|
244
|
+
if (filestoreStatus === 'missing') {
|
|
245
|
+
issues.push('missing snapshot filestore');
|
|
246
|
+
}
|
|
247
|
+
if (databaseMatches === false) {
|
|
248
|
+
issues.push(`snapshot database mismatch: manifest has ${manifestDatabase}, requested ${requestedDatabase}`);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
name: snapshotName,
|
|
252
|
+
requestedDatabase,
|
|
253
|
+
dumpPath,
|
|
254
|
+
dumpStatus,
|
|
255
|
+
filestorePath,
|
|
256
|
+
filestoreStatus,
|
|
257
|
+
manifestPath: fileExists(manifestPath) ? manifestPath : undefined,
|
|
258
|
+
manifestDatabase,
|
|
259
|
+
databaseMatches,
|
|
260
|
+
issues,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
101
263
|
export function hasRecentDatabaseSnapshot(targetDirectory, options = {}) {
|
|
102
264
|
const { maxAgeMs = defaultDatabaseSnapshotMaxAgeMs, ...scanOptions } = options;
|
|
103
265
|
const result = findDatabaseSnapshots(targetDirectory, scanOptions);
|
|
104
266
|
return result.newestSnapshotAgeMs !== null && result.newestSnapshotAgeMs <= maxAgeMs;
|
|
105
267
|
}
|
|
268
|
+
export function databaseSnapshotCatalogJson(targetDirectory) {
|
|
269
|
+
return {
|
|
270
|
+
schemaVersion: 1,
|
|
271
|
+
command: 'snapshot list',
|
|
272
|
+
ok: true,
|
|
273
|
+
snapshots: findDatabaseSnapshots(targetDirectory).snapshots,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
export function renderDatabaseSnapshotCatalog(targetDirectory) {
|
|
277
|
+
const snapshots = findDatabaseSnapshots(targetDirectory).snapshots;
|
|
278
|
+
if (snapshots.length === 0) {
|
|
279
|
+
return 'No database snapshots found.\nNext: run ./moo snapshot [db] [snapshot-name].';
|
|
280
|
+
}
|
|
281
|
+
return [
|
|
282
|
+
'Database snapshots',
|
|
283
|
+
'',
|
|
284
|
+
...snapshots.flatMap((snapshot) => [
|
|
285
|
+
`- ${snapshot.name}`,
|
|
286
|
+
` Created: ${snapshot.createdAt}`,
|
|
287
|
+
` Database: ${snapshot.databaseName ?? 'unknown'}`,
|
|
288
|
+
` Dump: ${snapshot.dumpPath}`,
|
|
289
|
+
` Filestore: ${snapshot.filestorePath ?? 'missing'} (${snapshot.filestoreStatus})`,
|
|
290
|
+
]),
|
|
291
|
+
].join('\n');
|
|
292
|
+
}
|
|
106
293
|
export function normalizeDatabaseListResult(result) {
|
|
107
294
|
if (Array.isArray(result)) {
|
|
108
295
|
return { ok: true, databases: result };
|
package/dist/doctor.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { access, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { execa } from 'execa';
|
|
4
4
|
import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
|
|
5
5
|
import { dailyActionScripts } from './daily-actions.js';
|
|
6
6
|
import { defaultPostgresVersion } from './external-templates.js';
|
|
7
7
|
import { defaultOdooVersion, markerPath, replaceSourceRepos } from './environment.js';
|
|
8
|
-
import { POSTGRES_DIAGNOSTICS_CONTRACT_VERSION, POSTGRES_DIAGNOSTICS_QUERY, malformedPostgresDiagnosticKeys, missingPostgresDiagnosticKeys, parsePostgresDiagnostics, postgresPostgresWarnings, renderPostgresDiagnostics, structuredPostgresDiagnostics, unavailablePostgresDiagnosticsWarning, } from './postgres-diagnostics.js';
|
|
8
|
+
import { POSTGRES_DIAGNOSTICS_CONTRACT_VERSION, POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES, POSTGRES_DIAGNOSTICS_QUERY, malformedPostgresDiagnosticKeys, missingPostgresDiagnosticKeys, parsePostgresDiagnostics, postgresPostgresWarnings, renderPostgresDiagnostics, structuredPostgresDiagnostics, unavailablePostgresDiagnosticsWarning, } from './postgres-diagnostics.js';
|
|
9
9
|
import { listGitmoduleSources, readSourceManifest, sourceReposFromManifest, sourceManifestPath, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
|
|
10
|
+
import { emptyModuleQualitySummary, mergeModuleQualitySummaries, scanModuleQuality, } from './module-quality.js';
|
|
10
11
|
const realCommandRunner = async (command, args, options) => {
|
|
11
12
|
const result = await execa(command, args, { cwd: options.cwd });
|
|
12
13
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
@@ -20,6 +21,35 @@ async function exists(path) {
|
|
|
20
21
|
return false;
|
|
21
22
|
}
|
|
22
23
|
}
|
|
24
|
+
async function legacyGeneratedEnvironmentErrors(target) {
|
|
25
|
+
const sourceRoot = join(target, 'odoo/custom/src');
|
|
26
|
+
const hasGeneratedSourceFiles = (await exists(join(sourceRoot, 'addons.yaml'))) ||
|
|
27
|
+
(await exists(join(sourceRoot, 'repos.yaml')));
|
|
28
|
+
if (!hasGeneratedSourceFiles) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
let legacySourceDirs;
|
|
32
|
+
try {
|
|
33
|
+
const entries = await readdir(sourceRoot, { withFileTypes: true });
|
|
34
|
+
legacySourceDirs = entries
|
|
35
|
+
.filter((entry) => entry.isDirectory())
|
|
36
|
+
.map((entry) => entry.name)
|
|
37
|
+
.filter((name) => !['private', 'oca', 'external'].includes(name))
|
|
38
|
+
.filter((name) => /^[A-Za-z0-9._-]+$/.test(name))
|
|
39
|
+
.sort();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
if (legacySourceDirs.length === 0) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return [
|
|
48
|
+
`Legacy WPMoo environment is missing ${markerPath}; run ./moo reset --dry-run to preview generated metadata migration.`,
|
|
49
|
+
...legacySourceDirs.map((path) => `Legacy source layout detected: odoo/custom/src/${path} will be registered as private/${path}.`),
|
|
50
|
+
'Run ./moo reset after reviewing the preview to write current metadata and generated files.',
|
|
51
|
+
];
|
|
52
|
+
}
|
|
23
53
|
function errorMessage(error) {
|
|
24
54
|
return error instanceof Error ? error.message : String(error);
|
|
25
55
|
}
|
|
@@ -47,6 +77,13 @@ function isMetadataError(message) {
|
|
|
47
77
|
message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
|
|
48
78
|
}
|
|
49
79
|
const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
|
|
80
|
+
const defaultPostgresDiagnosticsTimeoutMs = 15_000;
|
|
81
|
+
class PostgresDiagnosticsTimeoutError extends Error {
|
|
82
|
+
constructor(timeoutMs) {
|
|
83
|
+
super(`PostgreSQL diagnostics timed out after ${timeoutMs}ms`);
|
|
84
|
+
this.name = 'PostgresDiagnosticsTimeoutError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
50
87
|
function parsePostgresMajorFromValue(value) {
|
|
51
88
|
if (!value)
|
|
52
89
|
return undefined;
|
|
@@ -57,16 +94,50 @@ function parsePostgresMajorFromValue(value) {
|
|
|
57
94
|
const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
|
|
58
95
|
return match?.[1];
|
|
59
96
|
}
|
|
60
|
-
async function
|
|
61
|
-
|
|
97
|
+
async function withPostgresDiagnosticsTimeout(promise, timeoutMs) {
|
|
98
|
+
let timeout;
|
|
99
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
100
|
+
timeout = setTimeout(() => reject(new PostgresDiagnosticsTimeoutError(timeoutMs)), timeoutMs);
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (timeout) {
|
|
107
|
+
clearTimeout(timeout);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function postgresDiagnosticsTimeoutMs(options) {
|
|
112
|
+
return typeof options.postgresTimeoutMs === 'number' &&
|
|
113
|
+
Number.isFinite(options.postgresTimeoutMs) &&
|
|
114
|
+
options.postgresTimeoutMs > 0
|
|
115
|
+
? options.postgresTimeoutMs
|
|
116
|
+
: defaultPostgresDiagnosticsTimeoutMs;
|
|
117
|
+
}
|
|
118
|
+
async function readPostgresDiagnosticQuery(target, runner, query, timeoutMs) {
|
|
119
|
+
const queryLiteral = JSON.stringify(query);
|
|
62
120
|
const command = [
|
|
63
121
|
`query=${queryLiteral}`,
|
|
64
122
|
'. ./scripts/lib.sh >/dev/null',
|
|
65
123
|
'compose exec -T db psql -X -q -t -A -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -c "$query"',
|
|
66
124
|
].join(' && ');
|
|
67
|
-
const result = await runner('bash', ['-lc', command], { cwd: target });
|
|
125
|
+
const result = await withPostgresDiagnosticsTimeout(runner('bash', ['-lc', command], { cwd: target }), timeoutMs);
|
|
68
126
|
return parsePostgresDiagnostics(result.stdout);
|
|
69
127
|
}
|
|
128
|
+
async function readPostgresDiagnostics(target, runner, timeoutMs) {
|
|
129
|
+
const diagnostics = await readPostgresDiagnosticQuery(target, runner, POSTGRES_DIAGNOSTICS_QUERY, timeoutMs);
|
|
130
|
+
for (const probe of POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES) {
|
|
131
|
+
try {
|
|
132
|
+
Object.assign(diagnostics, await readPostgresDiagnosticQuery(target, runner, probe.query, timeoutMs));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Optional probes use PostgreSQL functions that can require elevated roles.
|
|
136
|
+
// Their failure must not hide the core read-only health report.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return diagnostics;
|
|
140
|
+
}
|
|
70
141
|
function stripInlineComment(line) {
|
|
71
142
|
const hashIndex = line.indexOf('#');
|
|
72
143
|
if (hashIndex === -1)
|
|
@@ -221,6 +292,87 @@ function validatePort(name, env, errors) {
|
|
|
221
292
|
function renderFailure(errors) {
|
|
222
293
|
return ['WPMoo doctor failed:', ...errors.map((error) => `- ${error}`)].join('\n');
|
|
223
294
|
}
|
|
295
|
+
const doctorSectionDefinitions = [
|
|
296
|
+
{ id: 'generated-files', title: 'Generated files' },
|
|
297
|
+
{ id: 'compose', title: 'Compose' },
|
|
298
|
+
{ id: 'source-repositories', title: 'Source repositories' },
|
|
299
|
+
{ id: 'module-quality', title: 'Module quality' },
|
|
300
|
+
{ id: 'postgresql', title: 'PostgreSQL' },
|
|
301
|
+
{ id: 'host-tools', title: 'Host tools' },
|
|
302
|
+
];
|
|
303
|
+
function doctorSectionForLine(line) {
|
|
304
|
+
if (line.startsWith('OK module quality') || line.startsWith('Module quality advisory:')) {
|
|
305
|
+
return 'module-quality';
|
|
306
|
+
}
|
|
307
|
+
if (line.includes('PostgreSQL diagnostics') ||
|
|
308
|
+
line.includes('PostgreSQL connection') ||
|
|
309
|
+
line.includes('PostgreSQL slow-query') ||
|
|
310
|
+
line.includes('pg_stat_statements') ||
|
|
311
|
+
line.includes('long transaction') ||
|
|
312
|
+
line.includes('idle in transaction') ||
|
|
313
|
+
line.includes('table health') ||
|
|
314
|
+
line.includes('unused index') ||
|
|
315
|
+
line.includes('WAL') ||
|
|
316
|
+
line.includes('capacity risk')) {
|
|
317
|
+
return 'postgresql';
|
|
318
|
+
}
|
|
319
|
+
if (line.startsWith('OK compose files') ||
|
|
320
|
+
line.startsWith('OK .env ports') ||
|
|
321
|
+
line.startsWith('Missing compose file:') ||
|
|
322
|
+
line.startsWith('Missing compact compose overlay') ||
|
|
323
|
+
line.startsWith('Invalid Odoo version for compose file:') ||
|
|
324
|
+
line.includes('HTTP_PORT') ||
|
|
325
|
+
line.includes('GEVENT_PORT') ||
|
|
326
|
+
line.includes('PostgreSQL 18 compatibility issue')) {
|
|
327
|
+
return 'compose';
|
|
328
|
+
}
|
|
329
|
+
if (line.startsWith('OK source repos') ||
|
|
330
|
+
line.includes('source repo') ||
|
|
331
|
+
line.includes('sourceRepos') ||
|
|
332
|
+
line.includes('source manifest') ||
|
|
333
|
+
line.includes('source path') ||
|
|
334
|
+
line.includes('Git submodule') ||
|
|
335
|
+
line.includes('git submodules') ||
|
|
336
|
+
line.includes('GitHub CLI')) {
|
|
337
|
+
return 'source-repositories';
|
|
338
|
+
}
|
|
339
|
+
if (line.includes('Docker CLI') ||
|
|
340
|
+
line.includes('Docker Compose') ||
|
|
341
|
+
line.startsWith('OK docker')) {
|
|
342
|
+
return 'host-tools';
|
|
343
|
+
}
|
|
344
|
+
return 'generated-files';
|
|
345
|
+
}
|
|
346
|
+
function buildDoctorSections(checks, warnings, errors) {
|
|
347
|
+
const sections = new Map(doctorSectionDefinitions.map(({ id, title }) => [id, { id, title, checks: [], warnings: [], errors: [] }]));
|
|
348
|
+
for (const check of checks) {
|
|
349
|
+
sections.get(doctorSectionForLine(check))?.checks.push(check);
|
|
350
|
+
}
|
|
351
|
+
for (const warning of warnings) {
|
|
352
|
+
sections.get(doctorSectionForLine(warning))?.warnings.push(warning);
|
|
353
|
+
}
|
|
354
|
+
for (const error of errors) {
|
|
355
|
+
sections.get(doctorSectionForLine(error))?.errors.push(error);
|
|
356
|
+
}
|
|
357
|
+
return doctorSectionDefinitions
|
|
358
|
+
.map(({ id }) => sections.get(id))
|
|
359
|
+
.filter((section) => Boolean(section && (section.checks.length > 0 || section.warnings.length > 0 || section.errors.length > 0)));
|
|
360
|
+
}
|
|
361
|
+
function finalizeDoctorReport(report) {
|
|
362
|
+
report.sections = buildDoctorSections(report.checks, report.warnings, report.errors);
|
|
363
|
+
return report;
|
|
364
|
+
}
|
|
365
|
+
function renderSuccessfulDoctorReport(report) {
|
|
366
|
+
const lines = ['WPMoo doctor'];
|
|
367
|
+
const sections = report.sections ?? buildDoctorSections(report.checks, report.warnings, report.errors);
|
|
368
|
+
for (const section of sections) {
|
|
369
|
+
lines.push(section.title);
|
|
370
|
+
lines.push(...section.checks);
|
|
371
|
+
lines.push(...section.warnings.map((warning) => `WARN ${warning}`));
|
|
372
|
+
}
|
|
373
|
+
lines.push('Doctor checks passed.');
|
|
374
|
+
return lines.join('\n');
|
|
375
|
+
}
|
|
224
376
|
function isNotGitCheckoutError(error) {
|
|
225
377
|
return commandErrorText(error).toLowerCase().includes('not a git repository');
|
|
226
378
|
}
|
|
@@ -318,8 +470,15 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
318
470
|
metadata = await readMetadata(target);
|
|
319
471
|
}
|
|
320
472
|
catch (error) {
|
|
321
|
-
|
|
322
|
-
|
|
473
|
+
const message = errorMessage(error);
|
|
474
|
+
if (message === `Missing metadata file: ${markerPath}`) {
|
|
475
|
+
const legacyErrors = await legacyGeneratedEnvironmentErrors(target);
|
|
476
|
+
errors.push(...(legacyErrors.length > 0 ? legacyErrors : [message]));
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
errors.push(message);
|
|
480
|
+
}
|
|
481
|
+
return finalizeDoctorReport(report);
|
|
323
482
|
}
|
|
324
483
|
checks.push(`OK metadata ${markerPath}`);
|
|
325
484
|
const engine = metadataString(metadata, 'engine') ?? 'compose';
|
|
@@ -392,15 +551,30 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
392
551
|
}
|
|
393
552
|
catch (error) {
|
|
394
553
|
errors.push(errorMessage(error));
|
|
395
|
-
return report;
|
|
554
|
+
return finalizeDoctorReport(report);
|
|
396
555
|
}
|
|
397
556
|
for (const repo of sourceRepos) {
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
557
|
+
const sourceType = normalizeSourceType(repo.sourceType);
|
|
558
|
+
const relativePath = sourceRepoPath(sourceType, repo.path);
|
|
559
|
+
if ((await exists(join(target, relativePath))) || !repo.path) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const legacyPrivatePath = sourceType === 'private' ? `odoo/custom/src/${repo.path}` : undefined;
|
|
563
|
+
if (legacyPrivatePath && (await exists(join(target, legacyPrivatePath)))) {
|
|
564
|
+
warnings.push(`Legacy private source path in use: ${legacyPrivatePath}; move it to ${relativePath} when ready.`);
|
|
565
|
+
continue;
|
|
401
566
|
}
|
|
567
|
+
errors.push(`Missing source repo path: ${relativePath}`);
|
|
402
568
|
}
|
|
403
569
|
checks.push(`OK source repos ${sourceRepos.length} checked`);
|
|
570
|
+
let moduleQuality = emptyModuleQualitySummary();
|
|
571
|
+
for (const repo of sourceRepos) {
|
|
572
|
+
const sourceType = normalizeSourceType(repo.sourceType);
|
|
573
|
+
const repoRoot = join(target, sourceRepoPath(sourceType, repo.path));
|
|
574
|
+
moduleQuality = mergeModuleQualitySummaries(moduleQuality, await scanModuleQuality(repoRoot, target));
|
|
575
|
+
}
|
|
576
|
+
checks.push(`OK module quality ${moduleQuality.totalModules} module${moduleQuality.totalModules === 1 ? '' : 's'} scanned`);
|
|
577
|
+
warnings.push(...moduleQuality.issues.map((issue) => `Module quality advisory: ${issue.path}: ${issue.issue}`));
|
|
404
578
|
const manifestPath = join(target, sourceManifestPath);
|
|
405
579
|
const hasManifest = await exists(manifestPath);
|
|
406
580
|
let manifestEntries = [];
|
|
@@ -454,7 +628,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
454
628
|
}
|
|
455
629
|
if (actualOptions.postgres) {
|
|
456
630
|
try {
|
|
457
|
-
const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner);
|
|
631
|
+
const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner, postgresDiagnosticsTimeoutMs(actualOptions));
|
|
458
632
|
const missingKeys = missingPostgresDiagnosticKeys(postgresDiagnostics);
|
|
459
633
|
const malformedKeys = malformedPostgresDiagnosticKeys(postgresDiagnostics);
|
|
460
634
|
if (missingKeys.length === 0 && malformedKeys.length === 0) {
|
|
@@ -536,7 +710,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
536
710
|
warnings.push(`GitHub CLI auth: ${errorMessage(error)}`);
|
|
537
711
|
}
|
|
538
712
|
report.ok = errors.length === 0;
|
|
539
|
-
return report;
|
|
713
|
+
return finalizeDoctorReport(report);
|
|
540
714
|
}
|
|
541
715
|
export async function runDoctor(target = process.cwd(), runnerOrOptions = realCommandRunner, options = {}) {
|
|
542
716
|
const report = await getDoctorReport(target, runnerOrOptions, options);
|
|
@@ -555,12 +729,6 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
|
|
|
555
729
|
}
|
|
556
730
|
throw new Error(renderFailure(report.errors));
|
|
557
731
|
}
|
|
558
|
-
const renderedReport = [
|
|
559
|
-
'WPMoo doctor',
|
|
560
|
-
...report.checks,
|
|
561
|
-
...report.warnings.map((warning) => `WARN ${warning}`),
|
|
562
|
-
'Doctor checks passed.',
|
|
563
|
-
];
|
|
564
732
|
if (report.appliedFixes.length > 0) {
|
|
565
733
|
const postFixReport = await runDoctor(target, actualRunner, { ...actualOptions, fix: false });
|
|
566
734
|
return [
|
|
@@ -570,5 +738,5 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
|
|
|
570
738
|
postFixReport,
|
|
571
739
|
].join('\n');
|
|
572
740
|
}
|
|
573
|
-
return
|
|
741
|
+
return renderSuccessfulDoctorReport(report);
|
|
574
742
|
}
|
|
@@ -31,9 +31,9 @@ export const dailyActionPolicyTable = {
|
|
|
31
31
|
stop: {
|
|
32
32
|
isDestructive: () => false,
|
|
33
33
|
isDryRunAllowed: false,
|
|
34
|
-
requiresStageLifecycleApproval:
|
|
35
|
-
requiresProdLifecycleApproval:
|
|
36
|
-
isAuditWorthy: () =>
|
|
34
|
+
requiresStageLifecycleApproval: true,
|
|
35
|
+
requiresProdLifecycleApproval: true,
|
|
36
|
+
isAuditWorthy: () => true,
|
|
37
37
|
},
|
|
38
38
|
logs: {
|
|
39
39
|
isDestructive: () => false,
|
|
@@ -45,9 +45,9 @@ export const dailyActionPolicyTable = {
|
|
|
45
45
|
restart: {
|
|
46
46
|
isDestructive: () => false,
|
|
47
47
|
isDryRunAllowed: false,
|
|
48
|
-
requiresStageLifecycleApproval:
|
|
49
|
-
requiresProdLifecycleApproval:
|
|
50
|
-
isAuditWorthy: () =>
|
|
48
|
+
requiresStageLifecycleApproval: true,
|
|
49
|
+
requiresProdLifecycleApproval: true,
|
|
50
|
+
isAuditWorthy: () => true,
|
|
51
51
|
},
|
|
52
52
|
shell: {
|
|
53
53
|
isDestructive: () => false,
|
|
@@ -43,8 +43,14 @@ export function renderComposeEnvExample(options) {
|
|
|
43
43
|
'# Required only when intentionally running destructive database actions',
|
|
44
44
|
'# such as resetdb or restore-snapshot with WPMOO_ENV=stage or WPMOO_ENV=prod.',
|
|
45
45
|
'# WPMOO_ALLOW_DESTRUCTIVE=1',
|
|
46
|
-
'# Required only when intentionally running install/update/
|
|
46
|
+
'# Required only when intentionally running install/update/stop/restart in WPMOO_ENV=stage.',
|
|
47
|
+
'# WPMOO_ALLOW_STAGE_LIFECYCLE=1',
|
|
48
|
+
'# Required only when intentionally running install/update/test/stop/restart in WPMOO_ENV=prod.',
|
|
47
49
|
'# WPMOO_ALLOW_PROD_LIFECYCLE=1',
|
|
50
|
+
'# Required only when intentionally running a destructive stage/prod command without a recent snapshot.',
|
|
51
|
+
'# WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1',
|
|
52
|
+
'# Required only when intentionally running migration-risk lifecycle commands in stage/prod.',
|
|
53
|
+
'# WPMOO_ALLOW_MIGRATIONS=1',
|
|
48
54
|
'',
|
|
49
55
|
].join('\n');
|
|
50
56
|
}
|
package/dist/github.js
CHANGED
|
@@ -5,14 +5,23 @@ export const realGitHub = {
|
|
|
5
5
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
6
6
|
},
|
|
7
7
|
};
|
|
8
|
+
const githubSlugPartPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
|
|
9
|
+
function isSafeGitHubSlugPart(value) {
|
|
10
|
+
return Boolean(value &&
|
|
11
|
+
githubSlugPartPattern.test(value) &&
|
|
12
|
+
value !== '.' &&
|
|
13
|
+
value !== '..' &&
|
|
14
|
+
!value.startsWith('-') &&
|
|
15
|
+
!value.includes('..'));
|
|
16
|
+
}
|
|
8
17
|
export function parseGitHubRepoUrl(repoUrl) {
|
|
9
18
|
const normalized = repoUrl.trim().replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/, '');
|
|
10
19
|
const httpsMatch = normalized.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/);
|
|
11
|
-
if (httpsMatch) {
|
|
20
|
+
if (httpsMatch && isSafeGitHubSlugPart(httpsMatch[1]) && isSafeGitHubSlugPart(httpsMatch[2])) {
|
|
12
21
|
return { owner: httpsMatch[1], name: httpsMatch[2] };
|
|
13
22
|
}
|
|
14
23
|
const sshMatch = normalized.match(/^git@github\.com:([^/]+)\/([^/]+)$/);
|
|
15
|
-
if (sshMatch) {
|
|
24
|
+
if (sshMatch && isSafeGitHubSlugPart(sshMatch[1]) && isSafeGitHubSlugPart(sshMatch[2])) {
|
|
16
25
|
return { owner: sshMatch[1], name: sshMatch[2] };
|
|
17
26
|
}
|
|
18
27
|
return undefined;
|