@wpmoo/toolkit 0.9.24 → 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.
- package/dist/audit-log.js +78 -0
- package/dist/cli.js +11 -1
- package/dist/cockpit/daily-prompts.js +15 -1
- package/dist/daily-actions.js +162 -76
- package/dist/databases.js +81 -0
- package/dist/environment-policy.js +219 -0
- package/dist/help.js +2 -2
- package/dist/migrations.js +112 -0
- package/dist/safe-reset.js +244 -17
- package/dist/scaffold.js +2 -1
- package/dist/service-runtime-status.js +65 -3
- package/dist/templates.js +169 -6
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -271,7 +271,17 @@ async function showStartup(argv, skipUpdateCheck, details) {
|
|
|
271
271
|
console.log();
|
|
272
272
|
}
|
|
273
273
|
async function selectCockpitCommandFromMenu(serviceStatus, moduleCount, sourceRepoCount) {
|
|
274
|
-
const
|
|
274
|
+
const legacyServiceStatus = serviceStatus.kind === 'services-running' ||
|
|
275
|
+
serviceStatus.kind === 'db-ready' ||
|
|
276
|
+
serviceStatus.kind === 'odoo-not-ready' ||
|
|
277
|
+
serviceStatus.kind === 'fully-ready'
|
|
278
|
+
? { kind: 'running' }
|
|
279
|
+
: serviceStatus;
|
|
280
|
+
const selection = await selectCockpitTopLevelMenu({
|
|
281
|
+
serviceStatus: legacyServiceStatus,
|
|
282
|
+
moduleCount,
|
|
283
|
+
sourceRepoCount,
|
|
284
|
+
});
|
|
275
285
|
if (selection.kind === 'exit') {
|
|
276
286
|
return 'exit';
|
|
277
287
|
}
|
|
@@ -83,6 +83,18 @@ async function optionalTextArg(deps, message, fallback) {
|
|
|
83
83
|
placeholder: fallback,
|
|
84
84
|
}), fallback, deps);
|
|
85
85
|
}
|
|
86
|
+
async function optionalTextArgOrUndefined(deps, message, placeholder) {
|
|
87
|
+
const value = await deps.text({
|
|
88
|
+
message: menuPromptMessage(message, 'back'),
|
|
89
|
+
placeholder,
|
|
90
|
+
});
|
|
91
|
+
deps.handleCancel(value, 'back');
|
|
92
|
+
if (typeof value !== 'string') {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const trimmed = value.trim();
|
|
96
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
97
|
+
}
|
|
86
98
|
async function databaseArg(cwd, deps, message, fallback, options = {}) {
|
|
87
99
|
const databaseResult = normalizeDatabaseListResult(await deps.databases(cwd, options));
|
|
88
100
|
const databases = databaseResult.databases;
|
|
@@ -139,7 +151,9 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
|
|
|
139
151
|
return [];
|
|
140
152
|
}
|
|
141
153
|
if (command === 'logs') {
|
|
142
|
-
|
|
154
|
+
const service = await optionalTextArg(deps, 'Service', 'odoo');
|
|
155
|
+
const tail = await optionalTextArgOrUndefined(deps, 'Tail line count (optional)', '100');
|
|
156
|
+
return tail ? [service, tail] : [service];
|
|
143
157
|
}
|
|
144
158
|
if (command === 'psql') {
|
|
145
159
|
return [await databaseArg(cwd, deps, 'Database', 'postgres', { includeMaintenance: true })];
|
package/dist/daily-actions.js
CHANGED
|
@@ -1,8 +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';
|
|
6
|
+
import { defaultDatabaseSnapshotMaxAgeMs, findDatabaseSnapshots, normalizeDatabaseName } from './databases.js';
|
|
7
|
+
import { evaluateDailyActionPolicy, } from './environment-policy.js';
|
|
5
8
|
import { markerPath } from './environment.js';
|
|
9
|
+
import { scanMigrationRisks } from './migrations.js';
|
|
6
10
|
export const dailyActionCommands = [
|
|
7
11
|
'start',
|
|
8
12
|
'stop',
|
|
@@ -49,7 +53,7 @@ function usage(command) {
|
|
|
49
53
|
if (command === 'stop')
|
|
50
54
|
return 'Usage: wpmoo stop';
|
|
51
55
|
if (command === 'logs')
|
|
52
|
-
return 'Usage: wpmoo logs [service]';
|
|
56
|
+
return 'Usage: wpmoo logs [service] [tail-lines]';
|
|
53
57
|
if (command === 'restart')
|
|
54
58
|
return 'Usage: wpmoo restart';
|
|
55
59
|
if (command === 'shell')
|
|
@@ -86,7 +90,7 @@ function moduleArgs(command, argv) {
|
|
|
86
90
|
const [modules, db, ...rest] = argv;
|
|
87
91
|
if (!modules || modules.startsWith('-') || rest.length > 0)
|
|
88
92
|
throw new Error(usage(command));
|
|
89
|
-
return db ? [modules, db] : [modules];
|
|
93
|
+
return db ? [modules, normalizeDatabaseName(db)] : [modules];
|
|
90
94
|
}
|
|
91
95
|
function positionalArgs(command, argv, min, max) {
|
|
92
96
|
if (argv.length < min || argv.length > max || argv.some((arg) => arg.startsWith('-'))) {
|
|
@@ -94,6 +98,32 @@ function positionalArgs(command, argv, min, max) {
|
|
|
94
98
|
}
|
|
95
99
|
return argv;
|
|
96
100
|
}
|
|
101
|
+
function logsArgs(argv) {
|
|
102
|
+
if (argv.length > 2 || argv.some((arg) => arg.startsWith('-'))) {
|
|
103
|
+
throw new Error(usage('logs'));
|
|
104
|
+
}
|
|
105
|
+
const [service = 'odoo', tail] = argv;
|
|
106
|
+
if (tail === undefined) {
|
|
107
|
+
return [service];
|
|
108
|
+
}
|
|
109
|
+
if (!/^[1-9][0-9]*$/u.test(tail)) {
|
|
110
|
+
throw new Error('Invalid logs tail count: expected a positive integer.');
|
|
111
|
+
}
|
|
112
|
+
return [service, tail];
|
|
113
|
+
}
|
|
114
|
+
function validateDatabaseArg(args, index) {
|
|
115
|
+
if (args[index] === undefined) {
|
|
116
|
+
return args;
|
|
117
|
+
}
|
|
118
|
+
const nextArgs = [...args];
|
|
119
|
+
nextArgs[index] = normalizeDatabaseName(nextArgs[index]);
|
|
120
|
+
return nextArgs;
|
|
121
|
+
}
|
|
122
|
+
function rejectLeadingHyphenDatabaseArg(args) {
|
|
123
|
+
if (args[0]?.startsWith('-')) {
|
|
124
|
+
normalizeDatabaseName(args[0]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
97
127
|
function restoreSnapshotArgs(argv) {
|
|
98
128
|
const args = [...argv];
|
|
99
129
|
const dryRun = args[0] === '--dry-run';
|
|
@@ -103,7 +133,8 @@ function restoreSnapshotArgs(argv) {
|
|
|
103
133
|
if (args.length < 1 || args.length > 2 || args.some((arg) => arg.startsWith('-'))) {
|
|
104
134
|
throw new Error(usage('restore-snapshot'));
|
|
105
135
|
}
|
|
106
|
-
|
|
136
|
+
const validatedArgs = args.length === 2 ? validateDatabaseArg(args, 1) : args;
|
|
137
|
+
return dryRun ? ['--dry-run', ...validatedArgs] : validatedArgs;
|
|
107
138
|
}
|
|
108
139
|
function testArgs(argv) {
|
|
109
140
|
const [modules, ...rest] = argv;
|
|
@@ -119,6 +150,9 @@ function testArgs(argv) {
|
|
|
119
150
|
if (option === '--mode' && value !== 'auto' && value !== 'init' && value !== 'update') {
|
|
120
151
|
throw new Error('Invalid value for --mode: expected auto, init, or update');
|
|
121
152
|
}
|
|
153
|
+
if (option === '--db') {
|
|
154
|
+
normalizeDatabaseName(value);
|
|
155
|
+
}
|
|
122
156
|
index += 1;
|
|
123
157
|
}
|
|
124
158
|
return argv;
|
|
@@ -129,88 +163,30 @@ function scriptArgs(command, argv) {
|
|
|
129
163
|
if (command === 'stop')
|
|
130
164
|
return ensureNoArgs(command, argv);
|
|
131
165
|
if (command === 'logs')
|
|
132
|
-
return
|
|
166
|
+
return logsArgs(argv);
|
|
133
167
|
if (command === 'restart')
|
|
134
168
|
return ensureNoArgs(command, argv);
|
|
135
169
|
if (command === 'shell')
|
|
136
170
|
return ensureNoArgs(command, argv);
|
|
137
171
|
if (command === 'psql')
|
|
138
|
-
return optionalSingleArg(command, argv, 'postgres');
|
|
172
|
+
return optionalSingleArg(command, argv, 'postgres').map(normalizeDatabaseName);
|
|
139
173
|
if (command === 'install' || command === 'update')
|
|
140
174
|
return moduleArgs(command, argv);
|
|
141
175
|
if (command === 'test')
|
|
142
176
|
return testArgs(argv);
|
|
143
|
-
if (command === 'resetdb')
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
177
|
+
if (command === 'resetdb') {
|
|
178
|
+
rejectLeadingHyphenDatabaseArg(argv);
|
|
179
|
+
return validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
|
|
180
|
+
}
|
|
181
|
+
if (command === 'snapshot') {
|
|
182
|
+
rejectLeadingHyphenDatabaseArg(argv);
|
|
183
|
+
return validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
|
|
184
|
+
}
|
|
147
185
|
if (command === 'restore-snapshot')
|
|
148
186
|
return restoreSnapshotArgs(argv);
|
|
149
187
|
if (command === 'lint')
|
|
150
188
|
return ensureNoArgs(command, argv);
|
|
151
|
-
return positionalArgs(command, argv, 1, 3);
|
|
152
|
-
}
|
|
153
|
-
function isDestructiveCommand(command, args) {
|
|
154
|
-
if (command === 'resetdb')
|
|
155
|
-
return true;
|
|
156
|
-
return command === 'restore-snapshot' && args[0] !== '--dry-run';
|
|
157
|
-
}
|
|
158
|
-
function isProductionLifecycleCommand(command) {
|
|
159
|
-
return command === 'install' || command === 'update' || command === 'test';
|
|
160
|
-
}
|
|
161
|
-
function isStageLifecycleCommand(command) {
|
|
162
|
-
return command === 'install' || command === 'update';
|
|
163
|
-
}
|
|
164
|
-
function destructiveCommandError(command, envName) {
|
|
165
|
-
return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
|
|
166
|
-
}
|
|
167
|
-
function stageLifecycleCommandError(command) {
|
|
168
|
-
return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
|
|
169
|
-
}
|
|
170
|
-
function productionLifecycleCommandError(command) {
|
|
171
|
-
return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
|
|
172
|
-
}
|
|
173
|
-
async function assertDestructiveCommandAllowed(command, args, cwd) {
|
|
174
|
-
if (!isDestructiveCommand(command, args)) {
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const env = await readEnvFile(cwd);
|
|
178
|
-
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
179
|
-
if (envName !== 'stage' && envName !== 'prod') {
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
const allowDestructive = process.env.WPMOO_ALLOW_DESTRUCTIVE?.trim() || env?.get('WPMOO_ALLOW_DESTRUCTIVE')?.trim();
|
|
183
|
-
if (allowDestructive !== '1') {
|
|
184
|
-
throw new Error(destructiveCommandError(command, envName));
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
async function assertProductionLifecycleCommandAllowed(command, cwd) {
|
|
188
|
-
if (!isProductionLifecycleCommand(command)) {
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
const env = await readEnvFile(cwd);
|
|
192
|
-
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
193
|
-
if (envName !== 'prod') {
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
const allowProdLifecycle = process.env.WPMOO_ALLOW_PROD_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_PROD_LIFECYCLE')?.trim();
|
|
197
|
-
if (allowProdLifecycle !== '1') {
|
|
198
|
-
throw new Error(productionLifecycleCommandError(command));
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
async function assertStageLifecycleCommandAllowed(command, cwd) {
|
|
202
|
-
if (!isStageLifecycleCommand(command)) {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const env = await readEnvFile(cwd);
|
|
206
|
-
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
207
|
-
if (envName !== 'stage') {
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const allowStageLifecycle = process.env.WPMOO_ALLOW_STAGE_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_STAGE_LIFECYCLE')?.trim();
|
|
211
|
-
if (allowStageLifecycle !== '1') {
|
|
212
|
-
throw new Error(stageLifecycleCommandError(command));
|
|
213
|
-
}
|
|
189
|
+
return validateDatabaseArg(positionalArgs(command, argv, 1, 3), 1);
|
|
214
190
|
}
|
|
215
191
|
async function assertEnvironmentRoot(cwd) {
|
|
216
192
|
try {
|
|
@@ -230,17 +206,121 @@ async function assertScriptExists(cwd, script) {
|
|
|
230
206
|
}
|
|
231
207
|
return scriptPath;
|
|
232
208
|
}
|
|
233
|
-
|
|
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()) {
|
|
234
248
|
await assertEnvironmentRoot(cwd);
|
|
235
249
|
const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
|
|
236
250
|
const args = scriptArgs(command, argv);
|
|
237
|
-
await
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|
|
240
285
|
return {
|
|
241
286
|
cwd,
|
|
242
287
|
scriptPath,
|
|
243
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,
|
|
244
324
|
};
|
|
245
325
|
}
|
|
246
326
|
async function spawnDailyAction(plan) {
|
|
@@ -263,6 +343,12 @@ function renderDailyActionOutputLine(line) {
|
|
|
263
343
|
if (line === "Running as user 'root' is a security risk.") {
|
|
264
344
|
return `${ANSI_DIM_INFO}${line}${ANSI_RESET}`;
|
|
265
345
|
}
|
|
346
|
+
if (line.includes('psycopg2.OperationalError')) {
|
|
347
|
+
return [
|
|
348
|
+
line,
|
|
349
|
+
`${ANSI_DIM_INFO}NOTE: PostgreSQL connection failed. Check ./moo status, database service readiness, and credentials before retrying.${ANSI_RESET}`,
|
|
350
|
+
].join('\n');
|
|
351
|
+
}
|
|
266
352
|
return line;
|
|
267
353
|
}
|
|
268
354
|
export function renderDailyActionOutput(output) {
|
package/dist/databases.js
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
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']);
|
|
12
|
+
const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
|
|
13
|
+
export function isValidDatabaseName(value) {
|
|
14
|
+
const normalized = value.trim();
|
|
15
|
+
return (normalized.length > 0 &&
|
|
16
|
+
!normalized.startsWith('-') &&
|
|
17
|
+
databaseNamePattern.test(normalized) &&
|
|
18
|
+
!/\s/u.test(value));
|
|
19
|
+
}
|
|
20
|
+
export function normalizeDatabaseName(value) {
|
|
21
|
+
const normalized = value.trim();
|
|
22
|
+
if (!normalized) {
|
|
23
|
+
throw new Error('Invalid database name: value is required.');
|
|
24
|
+
}
|
|
25
|
+
if (/\s/u.test(value)) {
|
|
26
|
+
throw new Error('Invalid database name: whitespace is not allowed.');
|
|
27
|
+
}
|
|
28
|
+
if (normalized.startsWith('-')) {
|
|
29
|
+
throw new Error('Invalid database name: leading hyphens are not allowed.');
|
|
30
|
+
}
|
|
31
|
+
if (!databaseNamePattern.test(normalized)) {
|
|
32
|
+
throw new Error('Invalid database name: use letters, digits, underscores, dots, or hyphens without shell metacharacters or path characters.');
|
|
33
|
+
}
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
3
36
|
const listDatabasesQuery = [
|
|
4
37
|
'SELECT datname',
|
|
5
38
|
'FROM pg_database',
|
|
@@ -22,6 +55,54 @@ export function parseDatabaseListOutput(output, options = {}) {
|
|
|
22
55
|
}
|
|
23
56
|
return databases;
|
|
24
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
|
+
}
|
|
25
106
|
export function normalizeDatabaseListResult(result) {
|
|
26
107
|
if (Array.isArray(result)) {
|
|
27
108
|
return { ok: true, databases: result };
|