@wpmoo/toolkit 0.9.25 → 0.9.27

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/doctor.js CHANGED
@@ -5,6 +5,7 @@ import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './
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
9
  import { listGitmoduleSources, readSourceManifest, sourceReposFromManifest, sourceManifestPath, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
9
10
  const realCommandRunner = async (command, args, options) => {
10
11
  const result = await execa(command, args, { cwd: options.cwd });
@@ -46,128 +47,6 @@ function isMetadataError(message) {
46
47
  message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
47
48
  }
48
49
  const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
49
- const postgresDiagnosticQuery = `
50
- WITH metrics(metric, value) AS (
51
- SELECT 'database_count', count(*)::text
52
- FROM pg_database
53
- WHERE datistemplate = false
54
- UNION ALL
55
- SELECT 'active_connections', count(*)::text
56
- FROM pg_stat_activity
57
- WHERE datname IS NOT NULL
58
- AND state = 'active'
59
- UNION ALL
60
- SELECT 'connection_count', count(*)::text
61
- FROM pg_stat_activity
62
- WHERE datname IS NOT NULL
63
- UNION ALL
64
- SELECT 'max_connections', COALESCE(
65
- (SELECT setting FROM pg_settings WHERE name = 'max_connections'),
66
- 'unavailable'
67
- )
68
- UNION ALL
69
- SELECT 'total_database_size_bytes', COALESCE(sum(pg_database_size(datname)), 0)::text
70
- FROM pg_database
71
- WHERE datistemplate = false
72
- UNION ALL
73
- SELECT 'largest_database_name', COALESCE(
74
- (
75
- SELECT datname
76
- FROM pg_database
77
- WHERE datistemplate = false
78
- ORDER BY pg_database_size(datname) DESC, datname
79
- LIMIT 1
80
- ),
81
- 'unavailable'
82
- )
83
- UNION ALL
84
- SELECT 'largest_database_size_bytes', COALESCE(
85
- (
86
- SELECT pg_database_size(datname)::text
87
- FROM pg_database
88
- WHERE datistemplate = false
89
- ORDER BY pg_database_size(datname) DESC, datname
90
- LIMIT 1
91
- ),
92
- '0'
93
- )
94
- UNION ALL
95
- SELECT 'slow_query_logging', COALESCE(
96
- (SELECT setting || unit FROM pg_settings WHERE name = 'log_min_duration_statement'),
97
- 'unavailable'
98
- )
99
- UNION ALL
100
- SELECT 'pg_stat_statements',
101
- CASE
102
- WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') THEN 'installed'
103
- WHEN EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements') THEN 'available'
104
- ELSE 'unavailable'
105
- END
106
- UNION ALL
107
- SELECT 'pg_stat_statements_available_version', COALESCE(
108
- (SELECT default_version FROM pg_available_extensions WHERE name = 'pg_stat_statements'),
109
- 'unavailable'
110
- )
111
- UNION ALL
112
- SELECT 'pg_stat_statements_installed_version', COALESCE(
113
- (SELECT extversion FROM pg_extension WHERE extname = 'pg_stat_statements'),
114
- ''
115
- )
116
- UNION ALL
117
- SELECT 'shared_preload_libraries', COALESCE(
118
- (SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'),
119
- 'unavailable'
120
- )
121
- UNION ALL
122
- SELECT 'shared_buffers', COALESCE(
123
- (SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
124
- 'unavailable'
125
- )
126
- )
127
- SELECT metric || '|' || value
128
- FROM metrics
129
- ORDER BY CASE metric
130
- WHEN 'database_count' THEN 1
131
- WHEN 'active_connections' THEN 2
132
- WHEN 'connection_count' THEN 3
133
- WHEN 'max_connections' THEN 4
134
- WHEN 'total_database_size_bytes' THEN 5
135
- WHEN 'largest_database_name' THEN 6
136
- WHEN 'largest_database_size_bytes' THEN 7
137
- WHEN 'slow_query_logging' THEN 8
138
- WHEN 'pg_stat_statements' THEN 9
139
- WHEN 'pg_stat_statements_available_version' THEN 10
140
- WHEN 'pg_stat_statements_installed_version' THEN 11
141
- WHEN 'shared_preload_libraries' THEN 12
142
- WHEN 'shared_buffers' THEN 13
143
- ELSE 99
144
- END;
145
- `.trim();
146
- const postgresDiagnosticKeys = [
147
- 'database_count',
148
- 'active_connections',
149
- 'connection_count',
150
- 'max_connections',
151
- 'total_database_size_bytes',
152
- 'largest_database_name',
153
- 'largest_database_size_bytes',
154
- 'slow_query_logging',
155
- 'pg_stat_statements',
156
- 'pg_stat_statements_available_version',
157
- 'pg_stat_statements_installed_version',
158
- 'shared_preload_libraries',
159
- 'shared_buffers',
160
- ];
161
- const requiredPostgresDiagnosticKeys = [
162
- 'database_count',
163
- 'active_connections',
164
- 'connection_count',
165
- 'max_connections',
166
- 'total_database_size_bytes',
167
- 'slow_query_logging',
168
- 'pg_stat_statements',
169
- 'shared_buffers',
170
- ];
171
50
  function parsePostgresMajorFromValue(value) {
172
51
  if (!value)
173
52
  return undefined;
@@ -178,139 +57,8 @@ function parsePostgresMajorFromValue(value) {
178
57
  const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
179
58
  return match?.[1];
180
59
  }
181
- function parsePostgresDiagnostics(output) {
182
- const diagnostics = {};
183
- const allowedKeys = new Set(postgresDiagnosticKeys);
184
- for (const rawLine of output.split(/\r?\n/u)) {
185
- const line = rawLine.trim();
186
- if (!line)
187
- continue;
188
- const separatorIndex = line.indexOf('|');
189
- if (separatorIndex === -1)
190
- continue;
191
- const key = line.slice(0, separatorIndex).trim();
192
- const value = line.slice(separatorIndex + 1).trim();
193
- if (allowedKeys.has(key) && value) {
194
- diagnostics[key] = value;
195
- }
196
- }
197
- return diagnostics;
198
- }
199
- function renderPostgresDiagnostics(diagnostics) {
200
- const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
201
- const parts = postgresDiagnosticKeys.flatMap((key) => {
202
- const value = diagnostics[key];
203
- const rendered = value ? [`${key}=${value}`] : [];
204
- if (key === 'max_connections' && connectionUtilizationPct !== undefined) {
205
- rendered.push(`connection_utilization_pct=${connectionUtilizationPct}`);
206
- }
207
- return rendered;
208
- });
209
- return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
210
- }
211
- function missingPostgresDiagnosticKeys(diagnostics) {
212
- return requiredPostgresDiagnosticKeys.filter((key) => !diagnostics[key]);
213
- }
214
- function unavailablePostgresDiagnosticsWarning(diagnostics, missingKeys) {
215
- return Object.keys(diagnostics).length === 0
216
- ? 'no diagnostic rows returned'
217
- : `incomplete diagnostic rows: missing ${missingKeys.join(', ')}`;
218
- }
219
- function integerDiagnostic(value) {
220
- if (!value || !/^\d+$/u.test(value)) {
221
- return undefined;
222
- }
223
- return Number.parseInt(value, 10);
224
- }
225
- function malformedPostgresDiagnosticKeys(diagnostics) {
226
- const numericKeys = [
227
- 'database_count',
228
- 'active_connections',
229
- 'connection_count',
230
- 'max_connections',
231
- 'total_database_size_bytes',
232
- 'largest_database_size_bytes',
233
- ];
234
- return numericKeys.filter((key) => diagnostics[key] !== undefined && integerDiagnostic(diagnostics[key]) === undefined);
235
- }
236
- function postgresConnectionUtilizationPct(diagnostics) {
237
- const connectionCount = integerDiagnostic(diagnostics.connection_count);
238
- const maxConnections = integerDiagnostic(diagnostics.max_connections);
239
- if (connectionCount === undefined || maxConnections === undefined || maxConnections <= 0) {
240
- return undefined;
241
- }
242
- return Math.round((connectionCount / maxConnections) * 100);
243
- }
244
- function postgresConnectionUtilizationWarning(diagnostics) {
245
- const connectionCount = integerDiagnostic(diagnostics.connection_count);
246
- const maxConnections = integerDiagnostic(diagnostics.max_connections);
247
- const utilizationPct = postgresConnectionUtilizationPct(diagnostics);
248
- if (connectionCount === undefined ||
249
- maxConnections === undefined ||
250
- utilizationPct === undefined ||
251
- utilizationPct < 80) {
252
- return undefined;
253
- }
254
- return `PostgreSQL connection utilization is high: ${utilizationPct}% of max_connections used (${connectionCount}/${maxConnections}).`;
255
- }
256
- function postgresSlowQueryLoggingWarning(diagnostics) {
257
- const slowQueryLogging = diagnostics.slow_query_logging?.trim();
258
- if (!slowQueryLogging || (!/^-1\s*(?:ms)?$/iu.test(slowQueryLogging) && !/^off$/iu.test(slowQueryLogging))) {
259
- return undefined;
260
- }
261
- return `PostgreSQL slow-query logging is disabled (log_min_duration_statement=${slowQueryLogging}). Enable it before performance triage.`;
262
- }
263
- function postgresExtensionVisibilityWarning(diagnostics) {
264
- if (diagnostics.pg_stat_statements === 'available' &&
265
- diagnostics.pg_stat_statements_available_version &&
266
- !diagnostics.pg_stat_statements_installed_version) {
267
- return 'PostgreSQL pg_stat_statements is available but not installed. Install it before query-level performance triage.';
268
- }
269
- return undefined;
270
- }
271
- function structuredPostgresDiagnostics(diagnostics) {
272
- const structured = {};
273
- const databaseCount = integerDiagnostic(diagnostics.database_count);
274
- const activeConnections = integerDiagnostic(diagnostics.active_connections);
275
- const connectionCount = integerDiagnostic(diagnostics.connection_count);
276
- const maxConnections = integerDiagnostic(diagnostics.max_connections);
277
- const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
278
- const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
279
- const largestDatabaseSizeBytes = integerDiagnostic(diagnostics.largest_database_size_bytes);
280
- if (databaseCount !== undefined)
281
- structured.databaseCount = databaseCount;
282
- if (activeConnections !== undefined)
283
- structured.activeConnections = activeConnections;
284
- if (connectionCount !== undefined)
285
- structured.connectionCount = connectionCount;
286
- if (maxConnections !== undefined)
287
- structured.maxConnections = maxConnections;
288
- if (connectionUtilizationPct !== undefined)
289
- structured.connectionUtilizationPct = connectionUtilizationPct;
290
- if (totalDatabaseSizeBytes !== undefined)
291
- structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
292
- if (diagnostics.largest_database_name)
293
- structured.largestDatabaseName = diagnostics.largest_database_name;
294
- if (largestDatabaseSizeBytes !== undefined)
295
- structured.largestDatabaseSizeBytes = largestDatabaseSizeBytes;
296
- if (diagnostics.slow_query_logging)
297
- structured.slowQueryLogging = diagnostics.slow_query_logging;
298
- if (diagnostics.pg_stat_statements)
299
- structured.pgStatStatements = diagnostics.pg_stat_statements;
300
- if (diagnostics.pg_stat_statements_available_version) {
301
- structured.pgStatStatementsAvailableVersion = diagnostics.pg_stat_statements_available_version;
302
- }
303
- if (diagnostics.pg_stat_statements_installed_version) {
304
- structured.pgStatStatementsInstalledVersion = diagnostics.pg_stat_statements_installed_version;
305
- }
306
- if (diagnostics.shared_preload_libraries)
307
- structured.sharedPreloadLibraries = diagnostics.shared_preload_libraries;
308
- if (diagnostics.shared_buffers)
309
- structured.sharedBuffers = diagnostics.shared_buffers;
310
- return structured;
311
- }
312
60
  async function readPostgresDiagnostics(target, runner) {
313
- const queryLiteral = JSON.stringify(postgresDiagnosticQuery);
61
+ const queryLiteral = JSON.stringify(POSTGRES_DIAGNOSTICS_QUERY);
314
62
  const command = [
315
63
  `query=${queryLiteral}`,
316
64
  '. ./scripts/lib.sh >/dev/null',
@@ -716,21 +464,11 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
716
464
  }
717
465
  report.postgres = {
718
466
  requested: true,
467
+ contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
719
468
  available: true,
720
469
  diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
721
470
  };
722
- const connectionUtilizationWarning = postgresConnectionUtilizationWarning(postgresDiagnostics);
723
- if (connectionUtilizationWarning) {
724
- warnings.push(connectionUtilizationWarning);
725
- }
726
- const slowQueryLoggingWarning = postgresSlowQueryLoggingWarning(postgresDiagnostics);
727
- if (slowQueryLoggingWarning) {
728
- warnings.push(slowQueryLoggingWarning);
729
- }
730
- const extensionVisibilityWarning = postgresExtensionVisibilityWarning(postgresDiagnostics);
731
- if (extensionVisibilityWarning) {
732
- warnings.push(extensionVisibilityWarning);
733
- }
471
+ warnings.push(...postgresPostgresWarnings(postgresDiagnostics));
734
472
  }
735
473
  else {
736
474
  const warning = malformedKeys.length > 0
@@ -739,6 +477,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
739
477
  warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
740
478
  report.postgres = {
741
479
  requested: true,
480
+ contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
742
481
  available: false,
743
482
  diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
744
483
  warning,
@@ -750,6 +489,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
750
489
  warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
751
490
  report.postgres = {
752
491
  requested: true,
492
+ contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
753
493
  available: false,
754
494
  diagnostics: {},
755
495
  warning,
@@ -0,0 +1,219 @@
1
+ export const dailyActionPolicyCommands = [
2
+ 'start',
3
+ 'stop',
4
+ 'logs',
5
+ 'restart',
6
+ 'shell',
7
+ 'psql',
8
+ 'install',
9
+ 'update',
10
+ 'test',
11
+ 'resetdb',
12
+ 'snapshot',
13
+ 'restore-snapshot',
14
+ 'lint',
15
+ 'pot',
16
+ ];
17
+ function normalizeFlag(value) {
18
+ return value?.trim() ?? '';
19
+ }
20
+ function parseFlag(value) {
21
+ return normalizeFlag(value) === '1';
22
+ }
23
+ export const dailyActionPolicyTable = {
24
+ start: {
25
+ isDestructive: () => false,
26
+ isDryRunAllowed: false,
27
+ requiresStageLifecycleApproval: false,
28
+ requiresProdLifecycleApproval: false,
29
+ isAuditWorthy: () => false,
30
+ },
31
+ stop: {
32
+ isDestructive: () => false,
33
+ isDryRunAllowed: false,
34
+ requiresStageLifecycleApproval: false,
35
+ requiresProdLifecycleApproval: false,
36
+ isAuditWorthy: () => false,
37
+ },
38
+ logs: {
39
+ isDestructive: () => false,
40
+ isDryRunAllowed: false,
41
+ requiresStageLifecycleApproval: false,
42
+ requiresProdLifecycleApproval: false,
43
+ isAuditWorthy: () => false,
44
+ },
45
+ restart: {
46
+ isDestructive: () => false,
47
+ isDryRunAllowed: false,
48
+ requiresStageLifecycleApproval: false,
49
+ requiresProdLifecycleApproval: false,
50
+ isAuditWorthy: () => false,
51
+ },
52
+ shell: {
53
+ isDestructive: () => false,
54
+ isDryRunAllowed: false,
55
+ requiresStageLifecycleApproval: false,
56
+ requiresProdLifecycleApproval: false,
57
+ isAuditWorthy: () => false,
58
+ },
59
+ psql: {
60
+ isDestructive: () => false,
61
+ isDryRunAllowed: false,
62
+ requiresStageLifecycleApproval: false,
63
+ requiresProdLifecycleApproval: false,
64
+ isAuditWorthy: () => false,
65
+ },
66
+ install: {
67
+ isDestructive: () => false,
68
+ isDryRunAllowed: false,
69
+ requiresStageLifecycleApproval: true,
70
+ requiresProdLifecycleApproval: true,
71
+ isAuditWorthy: () => true,
72
+ },
73
+ update: {
74
+ isDestructive: () => false,
75
+ isDryRunAllowed: false,
76
+ requiresStageLifecycleApproval: true,
77
+ requiresProdLifecycleApproval: true,
78
+ isAuditWorthy: () => true,
79
+ },
80
+ test: {
81
+ isDestructive: () => false,
82
+ isDryRunAllowed: false,
83
+ requiresStageLifecycleApproval: false,
84
+ requiresProdLifecycleApproval: true,
85
+ isAuditWorthy: () => true,
86
+ },
87
+ resetdb: {
88
+ isDestructive: () => true,
89
+ isDryRunAllowed: false,
90
+ requiresStageLifecycleApproval: false,
91
+ requiresProdLifecycleApproval: false,
92
+ isAuditWorthy: () => true,
93
+ },
94
+ snapshot: {
95
+ isDestructive: () => false,
96
+ isDryRunAllowed: false,
97
+ requiresStageLifecycleApproval: false,
98
+ requiresProdLifecycleApproval: false,
99
+ isAuditWorthy: () => false,
100
+ },
101
+ 'restore-snapshot': {
102
+ isDestructive: (args) => args[0] !== '--dry-run',
103
+ isDryRunAllowed: true,
104
+ requiresStageLifecycleApproval: false,
105
+ requiresProdLifecycleApproval: false,
106
+ isAuditWorthy: (args) => args[0] !== '--dry-run',
107
+ },
108
+ lint: {
109
+ isDestructive: () => false,
110
+ isDryRunAllowed: false,
111
+ requiresStageLifecycleApproval: false,
112
+ requiresProdLifecycleApproval: false,
113
+ isAuditWorthy: () => false,
114
+ },
115
+ pot: {
116
+ isDestructive: () => false,
117
+ isDryRunAllowed: false,
118
+ requiresStageLifecycleApproval: false,
119
+ requiresProdLifecycleApproval: false,
120
+ isAuditWorthy: () => false,
121
+ },
122
+ };
123
+ export function parseEnvironmentKind(rawEnvName) {
124
+ const normalized = rawEnvName?.trim().toLowerCase();
125
+ if (normalized === 'stage' || normalized === 'prod') {
126
+ return normalized;
127
+ }
128
+ return 'dev';
129
+ }
130
+ export function isDailyActionPolicyCommand(value) {
131
+ return dailyActionPolicyCommands.includes(value);
132
+ }
133
+ export function isRestoreSnapshotDryRun(args) {
134
+ return args[0] === '--dry-run';
135
+ }
136
+ function denyMessage(kind, command, env) {
137
+ if (kind === 'destructive') {
138
+ return `Refusing destructive command '${command}' in WPMOO_ENV=${env}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
139
+ }
140
+ if (kind === 'stage-lifecycle') {
141
+ return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
142
+ }
143
+ return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
144
+ }
145
+ export function renderPolicyDenyMessage(deny) {
146
+ return denyMessage(deny.kind, deny.command, deny.env);
147
+ }
148
+ export function evaluateDailyActionPolicy(command, args, flags) {
149
+ const env = parseEnvironmentKind(flags.envName);
150
+ const policy = dailyActionPolicyTable[command];
151
+ const isDestructive = policy.isDestructive(args);
152
+ const isDryRunPreview = command === 'restore-snapshot' && isRestoreSnapshotDryRun(args);
153
+ const isAuditWorthy = policy.isAuditWorthy(args);
154
+ if (policy.requiresStageLifecycleApproval && env === 'stage' && !parseFlag(flags.allowStageLifecycle)) {
155
+ const deny = {
156
+ kind: 'stage-lifecycle',
157
+ command,
158
+ env,
159
+ requiredFlag: 'WPMOO_ALLOW_STAGE_LIFECYCLE',
160
+ requiredValue: '1',
161
+ };
162
+ return {
163
+ allowed: false,
164
+ command,
165
+ env,
166
+ isDestructive,
167
+ isDryRunPreview,
168
+ isAuditWorthy,
169
+ deny,
170
+ message: renderPolicyDenyMessage(deny),
171
+ };
172
+ }
173
+ if (policy.requiresProdLifecycleApproval && env === 'prod' && !parseFlag(flags.allowProdLifecycle)) {
174
+ const deny = {
175
+ kind: 'prod-lifecycle',
176
+ command,
177
+ env,
178
+ requiredFlag: 'WPMOO_ALLOW_PROD_LIFECYCLE',
179
+ requiredValue: '1',
180
+ };
181
+ return {
182
+ allowed: false,
183
+ command,
184
+ env,
185
+ isDestructive,
186
+ isDryRunPreview,
187
+ isAuditWorthy,
188
+ deny,
189
+ message: renderPolicyDenyMessage(deny),
190
+ };
191
+ }
192
+ if (isDestructive && (env === 'stage' || env === 'prod') && !parseFlag(flags.allowDestructive)) {
193
+ const deny = {
194
+ kind: 'destructive',
195
+ command,
196
+ env,
197
+ requiredFlag: 'WPMOO_ALLOW_DESTRUCTIVE',
198
+ requiredValue: '1',
199
+ };
200
+ return {
201
+ allowed: false,
202
+ command,
203
+ env,
204
+ isDestructive,
205
+ isDryRunPreview,
206
+ isAuditWorthy,
207
+ deny,
208
+ message: renderPolicyDenyMessage(deny),
209
+ };
210
+ }
211
+ return {
212
+ allowed: true,
213
+ command,
214
+ env,
215
+ isDestructive,
216
+ isDryRunPreview,
217
+ isAuditWorthy,
218
+ };
219
+ }
@@ -0,0 +1,112 @@
1
+ import { lstat, readdir } from 'node:fs/promises';
2
+ import { resolve, join } from 'node:path';
3
+ const sourceRepoTypes = ['private', 'oca', 'external'];
4
+ const sourceRepoBase = ['odoo', 'custom', 'src'];
5
+ const migrationFolders = ['migrations', 'migration'];
6
+ const versionedMigrationFiles = ['pre-migration.py', 'post-migration.py', 'end-migration.py'];
7
+ const scriptMigrationFiles = ['migrate.py', 'migration.py'];
8
+ async function isDirectory(path) {
9
+ try {
10
+ const entry = await lstat(path);
11
+ return entry.isDirectory() && !entry.isSymbolicLink();
12
+ }
13
+ catch (error) {
14
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
15
+ return false;
16
+ }
17
+ throw error;
18
+ }
19
+ }
20
+ async function isFile(path) {
21
+ try {
22
+ const entry = await lstat(path);
23
+ return entry.isFile() && !entry.isSymbolicLink();
24
+ }
25
+ catch (error) {
26
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
27
+ return false;
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+ async function listDirectoryNames(path) {
33
+ try {
34
+ const entries = await readdir(path, { withFileTypes: true });
35
+ const filtered = entries.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !entry.name.startsWith('.'));
36
+ return filtered.map((entry) => entry.name).sort();
37
+ }
38
+ catch (error) {
39
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
40
+ return [];
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+ async function listMigrationFiles(modulePath, migrationFolder) {
46
+ const found = [];
47
+ const versionRoot = join(modulePath, migrationFolder);
48
+ if (!(await isDirectory(versionRoot))) {
49
+ return found;
50
+ }
51
+ const versions = await listDirectoryNames(versionRoot);
52
+ for (const version of versions) {
53
+ const versionPath = join(versionRoot, version);
54
+ if (!(await isDirectory(versionPath))) {
55
+ continue;
56
+ }
57
+ for (const file of versionedMigrationFiles) {
58
+ const path = join(versionPath, file);
59
+ if (await isFile(path)) {
60
+ found.push(path);
61
+ }
62
+ }
63
+ }
64
+ return found;
65
+ }
66
+ async function scanModule(modulePath) {
67
+ const found = [];
68
+ for (const folder of migrationFolders) {
69
+ found.push(...(await listMigrationFiles(modulePath, folder)));
70
+ }
71
+ const scriptsPath = join(modulePath, 'scripts');
72
+ if (!(await isDirectory(scriptsPath))) {
73
+ return found.sort();
74
+ }
75
+ for (const migrationScript of scriptMigrationFiles) {
76
+ const candidate = join(scriptsPath, migrationScript);
77
+ if (await isFile(candidate)) {
78
+ found.push(candidate);
79
+ }
80
+ }
81
+ return found;
82
+ }
83
+ export async function scanMigrationRisks(target) {
84
+ const root = resolve(target);
85
+ const foundPaths = [];
86
+ const srcRoot = join(root, ...sourceRepoBase);
87
+ for (const sourceType of sourceRepoTypes) {
88
+ const typeRoot = join(srcRoot, sourceType);
89
+ if (!(await isDirectory(typeRoot))) {
90
+ continue;
91
+ }
92
+ const repoNames = await listDirectoryNames(typeRoot);
93
+ for (const repoName of repoNames) {
94
+ const repoPath = join(typeRoot, repoName);
95
+ if (!(await isDirectory(repoPath))) {
96
+ continue;
97
+ }
98
+ const moduleNames = await listDirectoryNames(repoPath);
99
+ for (const moduleName of moduleNames) {
100
+ const modulePath = join(repoPath, moduleName);
101
+ const modulePaths = await scanModule(modulePath);
102
+ foundPaths.push(...modulePaths);
103
+ }
104
+ }
105
+ }
106
+ foundPaths.sort();
107
+ return {
108
+ foundPaths,
109
+ count: foundPaths.length,
110
+ risk: foundPaths.length > 0,
111
+ };
112
+ }