@wpmoo/toolkit 0.9.25 → 0.9.26

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.
@@ -0,0 +1,78 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ const secretFlagNames = new Set([
4
+ 'password',
5
+ 'api-key',
6
+ 'token',
7
+ 'secret',
8
+ 'api_key',
9
+ ]);
10
+ function isSecretFlagToken(token) {
11
+ return token === '--password'
12
+ ? '--password'
13
+ : token === '--api-key'
14
+ ? '--api-key'
15
+ : token === '--token'
16
+ ? '--token'
17
+ : token === '--secret'
18
+ ? '--secret'
19
+ : undefined;
20
+ }
21
+ function isSecretKVToken(token) {
22
+ const firstEquals = token.indexOf('=');
23
+ if (firstEquals < 0) {
24
+ return undefined;
25
+ }
26
+ const [name] = [token.slice(0, firstEquals), token.slice(firstEquals + 1)];
27
+ return secretFlagNames.has(name.replace(/^--+/, '').toLowerCase()) ? name : undefined;
28
+ }
29
+ function normalizeFlag(flag) {
30
+ return flag.startsWith('--') ? flag : `--${flag}`;
31
+ }
32
+ /**
33
+ * Redacts values for common secret-like arguments.
34
+ */
35
+ export function sanitizeCommandArgs(args) {
36
+ const sanitized = [];
37
+ for (let index = 0; index < args.length; index += 1) {
38
+ const arg = args[index];
39
+ const secretKV = isSecretKVToken(arg);
40
+ if (secretKV) {
41
+ sanitized.push(`${secretKV}=***`);
42
+ continue;
43
+ }
44
+ const secretFlag = isSecretFlagToken(arg);
45
+ if (secretFlag && index + 1 < args.length && !args[index + 1].startsWith('--')) {
46
+ sanitized.push(arg);
47
+ sanitized.push('***');
48
+ index += 1;
49
+ continue;
50
+ }
51
+ sanitized.push(arg);
52
+ }
53
+ return sanitized;
54
+ }
55
+ export function extractApprovedFlags(args, approvedFlagNames) {
56
+ const present = [];
57
+ const argsByIndex = args.map((arg) => arg.toLowerCase());
58
+ approvedFlagNames.forEach((name) => {
59
+ const normalized = normalizeFlag(name).toLowerCase();
60
+ if (argsByIndex.some((arg) => arg === normalized || arg.startsWith(`${normalized}=`))) {
61
+ present.push(name);
62
+ }
63
+ });
64
+ return present;
65
+ }
66
+ export async function appendAuditLog(options) {
67
+ const logPath = join(options.environmentPath, '.wpmoo', 'audit.log');
68
+ const event = {
69
+ timestamp: (options.timestamp ?? new Date()).toISOString(),
70
+ command: options.command,
71
+ environment: options.environment,
72
+ dryRun: options.dryRun,
73
+ approvedFlags: [...(options.approvedFlags ?? extractApprovedFlags(options.args, options.approvedFlagNames))],
74
+ args: sanitizeCommandArgs(options.args),
75
+ };
76
+ await mkdir(dirname(logPath), { recursive: true });
77
+ await appendFile(logPath, `${JSON.stringify(event)}\n`, 'utf8');
78
+ }
@@ -1,9 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { access } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { appendAuditLog } from './audit-log.js';
4
5
  import { readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
5
- import { normalizeDatabaseName } from './databases.js';
6
+ import { defaultDatabaseSnapshotMaxAgeMs, findDatabaseSnapshots, normalizeDatabaseName } from './databases.js';
7
+ import { evaluateDailyActionPolicy, } from './environment-policy.js';
6
8
  import { markerPath } from './environment.js';
9
+ import { scanMigrationRisks } from './migrations.js';
7
10
  export const dailyActionCommands = [
8
11
  'start',
9
12
  'stop',
@@ -185,68 +188,6 @@ function scriptArgs(command, argv) {
185
188
  return ensureNoArgs(command, argv);
186
189
  return validateDatabaseArg(positionalArgs(command, argv, 1, 3), 1);
187
190
  }
188
- function isDestructiveCommand(command, args) {
189
- if (command === 'resetdb')
190
- return true;
191
- return command === 'restore-snapshot' && args[0] !== '--dry-run';
192
- }
193
- function isProductionLifecycleCommand(command) {
194
- return command === 'install' || command === 'update' || command === 'test';
195
- }
196
- function isStageLifecycleCommand(command) {
197
- return command === 'install' || command === 'update';
198
- }
199
- function destructiveCommandError(command, envName) {
200
- return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
201
- }
202
- function stageLifecycleCommandError(command) {
203
- return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
204
- }
205
- function productionLifecycleCommandError(command) {
206
- return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
207
- }
208
- async function assertDestructiveCommandAllowed(command, args, cwd) {
209
- if (!isDestructiveCommand(command, args)) {
210
- return;
211
- }
212
- const env = await readEnvFile(cwd);
213
- const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
214
- if (envName !== 'stage' && envName !== 'prod') {
215
- return;
216
- }
217
- const allowDestructive = process.env.WPMOO_ALLOW_DESTRUCTIVE?.trim() || env?.get('WPMOO_ALLOW_DESTRUCTIVE')?.trim();
218
- if (allowDestructive !== '1') {
219
- throw new Error(destructiveCommandError(command, envName));
220
- }
221
- }
222
- async function assertProductionLifecycleCommandAllowed(command, cwd) {
223
- if (!isProductionLifecycleCommand(command)) {
224
- return;
225
- }
226
- const env = await readEnvFile(cwd);
227
- const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
228
- if (envName !== 'prod') {
229
- return;
230
- }
231
- const allowProdLifecycle = process.env.WPMOO_ALLOW_PROD_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_PROD_LIFECYCLE')?.trim();
232
- if (allowProdLifecycle !== '1') {
233
- throw new Error(productionLifecycleCommandError(command));
234
- }
235
- }
236
- async function assertStageLifecycleCommandAllowed(command, cwd) {
237
- if (!isStageLifecycleCommand(command)) {
238
- return;
239
- }
240
- const env = await readEnvFile(cwd);
241
- const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
242
- if (envName !== 'stage') {
243
- return;
244
- }
245
- const allowStageLifecycle = process.env.WPMOO_ALLOW_STAGE_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_STAGE_LIFECYCLE')?.trim();
246
- if (allowStageLifecycle !== '1') {
247
- throw new Error(stageLifecycleCommandError(command));
248
- }
249
- }
250
191
  async function assertEnvironmentRoot(cwd) {
251
192
  try {
252
193
  await access(join(cwd, markerPath));
@@ -265,17 +206,121 @@ async function assertScriptExists(cwd, script) {
265
206
  }
266
207
  return scriptPath;
267
208
  }
268
- export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
209
+ function envValue(env, key) {
210
+ return process.env[key]?.trim() || env?.get(key)?.trim();
211
+ }
212
+ function flagEnabled(env, key) {
213
+ return envValue(env, key) === '1';
214
+ }
215
+ function approvedFlags(env) {
216
+ return [
217
+ 'WPMOO_ALLOW_DESTRUCTIVE',
218
+ 'WPMOO_ALLOW_STAGE_LIFECYCLE',
219
+ 'WPMOO_ALLOW_PROD_LIFECYCLE',
220
+ 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT',
221
+ 'WPMOO_ALLOW_MIGRATIONS',
222
+ ].filter((key) => flagEnabled(env, key));
223
+ }
224
+ function requiresMigrationApproval(command) {
225
+ return command === 'install' || command === 'update' || command === 'test';
226
+ }
227
+ function noRecentSnapshotMessage(command, environment) {
228
+ return `Refusing destructive command '${command}' in WPMOO_ENV=${environment} without a recent database snapshot. Create a snapshot first or set WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1 to run it intentionally.`;
229
+ }
230
+ function migrationRiskMessage(command, environment) {
231
+ return `Refusing migration-risk command '${command}' in WPMOO_ENV=${environment}. Review detected migration scripts or set WPMOO_ALLOW_MIGRATIONS=1 to run it intentionally.`;
232
+ }
233
+ async function auditDailyActionPreview(preview) {
234
+ if (preview.environment !== 'prod' || !preview.auditWorthy) {
235
+ return;
236
+ }
237
+ await appendAuditLog({
238
+ environmentPath: preview.cwd,
239
+ command: preview.command,
240
+ environment: preview.environment,
241
+ dryRun: preview.dryRun,
242
+ args: preview.args,
243
+ approvedFlagNames: [],
244
+ approvedFlags: preview.approvedFlags,
245
+ });
246
+ }
247
+ export async function dailyActionSafetyPreview(command, argv, cwd = process.cwd()) {
269
248
  await assertEnvironmentRoot(cwd);
270
249
  const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
271
250
  const args = scriptArgs(command, argv);
272
- await assertStageLifecycleCommandAllowed(command, cwd);
273
- await assertProductionLifecycleCommandAllowed(command, cwd);
274
- await assertDestructiveCommandAllowed(command, args, cwd);
251
+ const env = await readEnvFile(cwd);
252
+ const envName = envValue(env, 'WPMOO_ENV') || selectedComposeEnvironment(env);
253
+ const policy = evaluateDailyActionPolicy(command, args, {
254
+ envName,
255
+ allowDestructive: envValue(env, 'WPMOO_ALLOW_DESTRUCTIVE'),
256
+ allowStageLifecycle: envValue(env, 'WPMOO_ALLOW_STAGE_LIFECYCLE'),
257
+ allowProdLifecycle: envValue(env, 'WPMOO_ALLOW_PROD_LIFECYCLE'),
258
+ });
259
+ const warnings = [];
260
+ const snapshotRequired = policy.isDestructive && (policy.env === 'stage' || policy.env === 'prod');
261
+ const snapshot = snapshotRequired ? findDatabaseSnapshots(cwd) : undefined;
262
+ const noRecentSnapshot = snapshotRequired &&
263
+ snapshot &&
264
+ (snapshot.newestSnapshotAgeMs === null || snapshot.newestSnapshotAgeMs > defaultDatabaseSnapshotMaxAgeMs) &&
265
+ !flagEnabled(env, 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT');
266
+ if (noRecentSnapshot) {
267
+ warnings.push({
268
+ kind: 'no-recent-snapshot',
269
+ requiredFlag: 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT',
270
+ blocking: true,
271
+ message: noRecentSnapshotMessage(command, policy.env),
272
+ });
273
+ }
274
+ const migrations = requiresMigrationApproval(command) && (policy.env === 'stage' || policy.env === 'prod')
275
+ ? await scanMigrationRisks(cwd)
276
+ : undefined;
277
+ if (migrations?.risk && !flagEnabled(env, 'WPMOO_ALLOW_MIGRATIONS')) {
278
+ warnings.push({
279
+ kind: 'migration-risk',
280
+ requiredFlag: 'WPMOO_ALLOW_MIGRATIONS',
281
+ blocking: true,
282
+ message: migrationRiskMessage(command, policy.env),
283
+ });
284
+ }
275
285
  return {
276
286
  cwd,
277
287
  scriptPath,
278
288
  args,
289
+ command,
290
+ environment: policy.env,
291
+ dryRun: policy.isDryRunPreview,
292
+ destructive: policy.isDestructive,
293
+ auditWorthy: policy.isAuditWorthy,
294
+ allowed: policy.allowed,
295
+ deny: policy.allowed ? undefined : { ...policy.deny, message: policy.message },
296
+ refusalMessage: policy.allowed ? undefined : policy.message,
297
+ requiredFlag: policy.allowed ? undefined : policy.deny.requiredFlag,
298
+ warnings,
299
+ snapshot: snapshot
300
+ ? {
301
+ requiredRecent: true,
302
+ newestSnapshotAgeMs: snapshot.newestSnapshotAgeMs,
303
+ snapshotPaths: snapshot.snapshotPaths,
304
+ }
305
+ : undefined,
306
+ migrations,
307
+ approvedFlags: approvedFlags(env),
308
+ };
309
+ }
310
+ export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
311
+ const preview = await dailyActionSafetyPreview(command, argv, cwd);
312
+ await auditDailyActionPreview(preview);
313
+ if (!preview.allowed) {
314
+ throw new Error(preview.refusalMessage);
315
+ }
316
+ const blockingWarning = preview.warnings.find((warning) => warning.blocking);
317
+ if (blockingWarning) {
318
+ throw new Error(blockingWarning.message);
319
+ }
320
+ return {
321
+ cwd: preview.cwd,
322
+ scriptPath: preview.scriptPath,
323
+ args: preview.args,
279
324
  };
280
325
  }
281
326
  async function spawnDailyAction(plan) {
package/dist/databases.js CHANGED
@@ -1,4 +1,13 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { spawn } from 'node:child_process';
4
+ export const defaultDatabaseSnapshotMaxAgeMs = 24 * 60 * 60 * 1000;
5
+ export const databaseSnapshotDirectoryNames = ['backups', 'backup', 'snapshots'];
6
+ export const databaseSnapshotExtensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
7
+ function isDatabaseSnapshotFile(fileName, extensions) {
8
+ const normalized = fileName.toLowerCase();
9
+ return extensions.some((extension) => normalized.endsWith(extension));
10
+ }
2
11
  const maintenanceDatabases = new Set(['postgres']);
3
12
  const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
4
13
  export function isValidDatabaseName(value) {
@@ -46,6 +55,54 @@ export function parseDatabaseListOutput(output, options = {}) {
46
55
  }
47
56
  return databases;
48
57
  }
58
+ 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;
72
+ }
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
+ }
93
+ snapshots.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
94
+ const newestSnapshot = snapshots[0] ?? null;
95
+ return {
96
+ snapshots,
97
+ snapshotPaths: snapshots.map((snapshot) => snapshot.path),
98
+ newestSnapshotAgeMs: newestSnapshot ? newestSnapshot.ageMs : null,
99
+ };
100
+ }
101
+ export function hasRecentDatabaseSnapshot(targetDirectory, options = {}) {
102
+ const { maxAgeMs = defaultDatabaseSnapshotMaxAgeMs, ...scanOptions } = options;
103
+ const result = findDatabaseSnapshots(targetDirectory, scanOptions);
104
+ return result.newestSnapshotAgeMs !== null && result.newestSnapshotAgeMs <= maxAgeMs;
105
+ }
49
106
  export function normalizeDatabaseListResult(result) {
50
107
  if (Array.isArray(result)) {
51
108
  return { ok: true, databases: result };
@@ -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
+ }
package/dist/templates.js CHANGED
@@ -601,6 +601,62 @@ allow_stage_lifecycle() {
601
601
  [[ "$value" == "1" ]]
602
602
  }
603
603
 
604
+ allow_no_recent_snapshot() {
605
+ local value="\${WPMOO_ALLOW_NO_RECENT_SNAPSHOT:-$(env_file_value WPMOO_ALLOW_NO_RECENT_SNAPSHOT)}"
606
+ [[ "$value" == "1" ]]
607
+ }
608
+
609
+ allow_migrations() {
610
+ local value="\${WPMOO_ALLOW_MIGRATIONS:-$(env_file_value WPMOO_ALLOW_MIGRATIONS)}"
611
+ [[ "$value" == "1" ]]
612
+ }
613
+
614
+ has_recent_snapshot() {
615
+ local dir
616
+ for dir in backups backup snapshots; do
617
+ [[ -d "$dir" ]] || continue
618
+ if find "$dir" -type f \\( -name "*.dump" -o -name "*.sql" -o -name "*.sql.gz" -o -name "*.zip" -o -name "*.tar" -o -name "*.tar.gz" \\) -mtime -1 -print -quit 2>/dev/null | grep -q .; then
619
+ return 0
620
+ fi
621
+ done
622
+ return 1
623
+ }
624
+
625
+ require_recent_snapshot_or_override() {
626
+ local command="$1"
627
+ local env_name
628
+ env_name="$(selected_env)"
629
+ if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
630
+ if ! allow_no_recent_snapshot && ! has_recent_snapshot; then
631
+ echo "Refusing destructive command '$command' in WPMOO_ENV=$env_name without a recent database snapshot. Create a snapshot first or set WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1 to run it intentionally." >&2
632
+ exit 1
633
+ fi
634
+ fi
635
+ }
636
+
637
+ has_migration_risk() {
638
+ local base
639
+ for base in odoo/custom/src/private odoo/custom/src/oca odoo/custom/src/external; do
640
+ [[ -d "$base" ]] || continue
641
+ if find "$base" -type f \\( -path "*/migrations/*/pre-migration.py" -o -path "*/migrations/*/post-migration.py" -o -path "*/migrations/*/end-migration.py" -o -path "*/migration/*/pre-migration.py" -o -path "*/migration/*/post-migration.py" -o -path "*/migration/*/end-migration.py" -o -path "*/scripts/migrate.py" -o -path "*/scripts/migration.py" \\) -print -quit 2>/dev/null | grep -q .; then
642
+ return 0
643
+ fi
644
+ done
645
+ return 1
646
+ }
647
+
648
+ require_migrations_allowed() {
649
+ local command="$1"
650
+ local env_name
651
+ env_name="$(selected_env)"
652
+ if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
653
+ if ! allow_migrations && has_migration_risk; then
654
+ echo "Refusing migration-risk command '$command' in WPMOO_ENV=$env_name. Review detected migration scripts or set WPMOO_ALLOW_MIGRATIONS=1 to run it intentionally." >&2
655
+ exit 1
656
+ fi
657
+ fi
658
+ }
659
+
604
660
  require_stage_lifecycle_allowed() {
605
661
  local command="$1"
606
662
  local env_name
@@ -712,6 +768,7 @@ case "$command" in
712
768
  require_module_args "$command" "$@"
713
769
  require_stage_lifecycle_allowed "$command"
714
770
  require_prod_lifecycle_allowed "$command"
771
+ require_migrations_allowed "$command"
715
772
  run_script ./scripts/install.sh "$@"
716
773
  ;;
717
774
  "update")
@@ -719,18 +776,21 @@ case "$command" in
719
776
  require_module_args "$command" "$@"
720
777
  require_stage_lifecycle_allowed "$command"
721
778
  require_prod_lifecycle_allowed "$command"
779
+ require_migrations_allowed "$command"
722
780
  run_script ./scripts/update.sh "$@"
723
781
  ;;
724
782
  "test")
725
783
  shift
726
784
  validate_test_args "$@"
727
785
  require_prod_lifecycle_allowed "$command"
786
+ require_migrations_allowed "$command"
728
787
  run_script ./scripts/test.sh "$@"
729
788
  ;;
730
789
  "resetdb")
731
790
  shift
732
791
  positional_args "$command" 0 2 "$@"
733
792
  require_destructive_allowed "$command"
793
+ require_recent_snapshot_or_override "$command"
734
794
  run_script ./scripts/resetdb.sh "$@"
735
795
  ;;
736
796
  "snapshot")
@@ -749,6 +809,7 @@ case "$command" in
749
809
  restore_args+=("$@")
750
810
  if [[ "\${restore_args[0]:-}" != "--dry-run" ]]; then
751
811
  require_destructive_allowed "$command"
812
+ require_recent_snapshot_or_override "$command"
752
813
  fi
753
814
  run_script ./scripts/restore-snapshot.sh "\${restore_args[@]}"
754
815
  ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.25",
3
+ "version": "0.9.26",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {