@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/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
- for (const directoryName of snapshotDirectories) {
62
- const directory = join(targetDirectory, directoryName);
63
- let entries;
64
- try {
65
- entries = readdirSync(directory, { withFileTypes: true });
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
- for (const entry of entries) {
74
- if (!isDatabaseSnapshotFile(entry.name, snapshotExtensions)) {
75
- continue;
76
- }
77
- const path = join(directory, entry.name);
78
- let stats;
79
- try {
80
- stats = statSync(path);
81
- }
82
- catch {
83
- continue;
84
- }
85
- if (!stats.isFile()) {
86
- continue;
87
- }
88
- const mtimeMs = stats.mtimeMs;
89
- const ageMs = Math.max(0, nowMs - mtimeMs);
90
- snapshots.push({ path, mtimeMs, ageMs });
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 readPostgresDiagnostics(target, runner) {
61
- const queryLiteral = JSON.stringify(POSTGRES_DIAGNOSTICS_QUERY);
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
- errors.push(errorMessage(error));
322
- return report;
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 relativePath = sourceRepoPath(normalizeSourceType(repo.sourceType), repo.path);
399
- if (!(await exists(join(target, relativePath))) && repo.path) {
400
- errors.push(`Missing source repo path: ${relativePath}`);
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 renderedReport.join('\n');
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: false,
35
- requiresProdLifecycleApproval: false,
36
- isAuditWorthy: () => false,
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: false,
49
- requiresProdLifecycleApproval: false,
50
- isAuditWorthy: () => false,
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/test in WPMOO_ENV=prod.',
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;