@wpmoo/toolkit 0.9.28 → 0.9.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -3
- package/dist/approval-ledger.js +74 -0
- package/dist/cli-routes/doctor.js +20 -0
- package/dist/cli-routes/options.js +36 -0
- package/dist/cli-routes/reset.js +11 -0
- package/dist/cli-routes/source.js +112 -0
- package/dist/cli.js +23 -169
- package/dist/cockpit/command-registry.js +9 -3
- package/dist/cockpit/daily-prompts.js +33 -6
- package/dist/cockpit/menu.js +12 -7
- package/dist/cockpit/module-browser.js +79 -2
- package/dist/daily-actions.js +55 -12
- package/dist/databases.js +223 -36
- package/dist/doctor.js +129 -15
- package/dist/github.js +11 -2
- package/dist/help.js +14 -6
- package/dist/module-actions.js +23 -2
- package/dist/module-manifest.js +6 -0
- package/dist/module-quality.js +98 -0
- package/dist/postgres-diagnostics.js +27 -0
- package/dist/repo-url.js +4 -7
- package/dist/safe-reset.js +21 -12
- package/dist/source-manifest.js +2 -2
- package/dist/templates.js +149 -17
- package/docs/1-0-readiness.md +64 -46
- package/docs/command-reference.md +28 -3
- package/docs/generated-environment-verification.md +23 -2
- package/docs/handoff.md +21 -1
- package/docs/lifecycle-recipes.md +6 -1
- package/docs/troubleshooting.md +29 -1
- package/package.json +1 -1
package/dist/module-quality.js
CHANGED
|
@@ -82,9 +82,15 @@ async function readOptionalFile(path) {
|
|
|
82
82
|
function moduleIssue(moduleName, path, issue) {
|
|
83
83
|
return { moduleName, path, issue };
|
|
84
84
|
}
|
|
85
|
+
function moduleQualityIssue(moduleName, path, issue, severity) {
|
|
86
|
+
return { moduleName, path, issue, severity };
|
|
87
|
+
}
|
|
85
88
|
function manifestData(manifest) {
|
|
86
89
|
return Array.isArray(manifest?.data) ? manifest.data : [];
|
|
87
90
|
}
|
|
91
|
+
function manifestDemo(manifest) {
|
|
92
|
+
return Array.isArray(manifest?.demo) ? manifest.demo : [];
|
|
93
|
+
}
|
|
88
94
|
function manifestDepends(manifest) {
|
|
89
95
|
return Array.isArray(manifest?.depends) ? manifest.depends : [];
|
|
90
96
|
}
|
|
@@ -105,6 +111,70 @@ function pythonImportPresent(content, importName) {
|
|
|
105
111
|
return false;
|
|
106
112
|
return new RegExp(`^\\s*from\\s+\\.\\s+import\\s+.*\\b${importName}\\b`, 'mu').test(content);
|
|
107
113
|
}
|
|
114
|
+
function xmlAttributeValues(content, attributeName) {
|
|
115
|
+
const values = [];
|
|
116
|
+
const pattern = new RegExp(`\\b${attributeName}=(["'])(.*?)\\1`, 'gsu');
|
|
117
|
+
let match;
|
|
118
|
+
while ((match = pattern.exec(content))) {
|
|
119
|
+
const value = match[2]?.trim();
|
|
120
|
+
if (value)
|
|
121
|
+
values.push(value);
|
|
122
|
+
}
|
|
123
|
+
return values;
|
|
124
|
+
}
|
|
125
|
+
function declaredActionIds(xmlFiles) {
|
|
126
|
+
const ids = new Set();
|
|
127
|
+
for (const content of xmlFiles) {
|
|
128
|
+
for (const record of content.matchAll(/<record\b[^>]*\bmodel=(["'])ir\.actions\.act_window\1[^>]*>/gsu)) {
|
|
129
|
+
for (const id of xmlAttributeValues(record[0], 'id')) {
|
|
130
|
+
ids.add(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return ids;
|
|
135
|
+
}
|
|
136
|
+
function menuActionReferences(xmlFiles) {
|
|
137
|
+
const refs = [];
|
|
138
|
+
for (const content of xmlFiles) {
|
|
139
|
+
for (const menu of content.matchAll(/<menuitem\b[^>]*>/gsu)) {
|
|
140
|
+
refs.push(...xmlAttributeValues(menu[0], 'action'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return refs;
|
|
144
|
+
}
|
|
145
|
+
function declaredModelIds(modelFileContents) {
|
|
146
|
+
const modelIds = new Set();
|
|
147
|
+
for (const content of modelFileContents) {
|
|
148
|
+
for (const match of content.matchAll(/^\s*_name\s*=\s*["']([^"']+)["']/gmu)) {
|
|
149
|
+
const modelName = match[1]?.trim();
|
|
150
|
+
if (modelName) {
|
|
151
|
+
modelIds.add(`model_${modelName.replace(/\./gu, '_')}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return modelIds;
|
|
156
|
+
}
|
|
157
|
+
function accessCsvModelIds(content) {
|
|
158
|
+
if (!content)
|
|
159
|
+
return [];
|
|
160
|
+
const lines = content
|
|
161
|
+
.split(/\r?\n/u)
|
|
162
|
+
.map((line) => line.trim())
|
|
163
|
+
.filter((line) => line && !line.startsWith('#'));
|
|
164
|
+
if (lines.length < 2)
|
|
165
|
+
return [];
|
|
166
|
+
const header = lines[0]?.split(',').map((value) => value.trim()) ?? [];
|
|
167
|
+
const modelColumn = header.findIndex((value) => value === 'model_id:id' || value === 'model_id');
|
|
168
|
+
if (modelColumn < 0)
|
|
169
|
+
return [];
|
|
170
|
+
return lines
|
|
171
|
+
.slice(1)
|
|
172
|
+
.map((line) => line.split(',')[modelColumn]?.trim())
|
|
173
|
+
.filter((value) => Boolean(value));
|
|
174
|
+
}
|
|
175
|
+
async function readModelFileContents(modulePath, modelFiles) {
|
|
176
|
+
return Promise.all(modelFiles.map(async (fileName) => (await readOptionalFile(join(modulePath, 'models', fileName))) ?? ''));
|
|
177
|
+
}
|
|
108
178
|
export async function analyzeModuleDirectory(modulePath, moduleName = basename(modulePath), relativePath = modulePath) {
|
|
109
179
|
const issues = [];
|
|
110
180
|
let manifest;
|
|
@@ -135,9 +205,21 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
135
205
|
const menuXml = await readMenusXml(modulePath);
|
|
136
206
|
const modelFiles = await readPythonModelFiles(modulePath);
|
|
137
207
|
const viewFiles = await readViewXmlFiles(modulePath);
|
|
208
|
+
const viewXml = await Promise.all(viewFiles.map(async (fileName) => (await readOptionalFile(join(modulePath, 'views', fileName))) ?? ''));
|
|
138
209
|
const depends = manifestDepends(manifest);
|
|
139
210
|
const data = manifestData(manifest);
|
|
211
|
+
const demo = manifestDemo(manifest);
|
|
140
212
|
const hasOdooStructures = moduleHasOdooStructures(modelFiles, viewFiles, menuXml, data);
|
|
213
|
+
for (const entry of data) {
|
|
214
|
+
if (!(await fileExists(join(modulePath, entry)))) {
|
|
215
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `missing manifest data file: ${entry}`, 'error'));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const entry of demo) {
|
|
219
|
+
if (!(await fileExists(join(modulePath, entry)))) {
|
|
220
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `missing manifest demo file: ${entry}`, 'warning'));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
141
223
|
if (hasOdooStructures && !depends.includes('base')) {
|
|
142
224
|
issues.push(moduleIssue(moduleName, relativePath, 'missing base dependency for model-based module'));
|
|
143
225
|
}
|
|
@@ -157,6 +239,15 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
157
239
|
issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv'));
|
|
158
240
|
}
|
|
159
241
|
}
|
|
242
|
+
const modelFileContents = await readModelFileContents(modulePath, modelFiles);
|
|
243
|
+
const modelIds = declaredModelIds(modelFileContents);
|
|
244
|
+
if (modelIds.size > 0) {
|
|
245
|
+
for (const modelId of accessCsvModelIds(await readOptionalFile(join(modulePath, 'security/ir.model.access.csv')))) {
|
|
246
|
+
if (!modelIds.has(modelId)) {
|
|
247
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `access CSV references unknown model id: ${modelId}`, 'error'));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
160
251
|
if (hasOdooStructures && !dataIncludesAccessCsv(data)) {
|
|
161
252
|
issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv in manifest data'));
|
|
162
253
|
}
|
|
@@ -166,6 +257,13 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
166
257
|
if (!(await directoryExists(join(modulePath, 'tests')))) {
|
|
167
258
|
issues.push(moduleIssue(moduleName, relativePath, 'missing tests directory'));
|
|
168
259
|
}
|
|
260
|
+
const allViewXml = [...viewXml, ...menuXml];
|
|
261
|
+
const actionIds = declaredActionIds(allViewXml);
|
|
262
|
+
for (const actionRef of menuActionReferences(menuXml)) {
|
|
263
|
+
if (!actionIds.has(actionRef)) {
|
|
264
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `menu XML references missing action id: ${actionRef}`, 'error'));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
169
267
|
const hasMenuAction = menuXml.some((content) => hasActionableMenuXml(content, moduleName));
|
|
170
268
|
if (!hasMenuAction) {
|
|
171
269
|
issues.push(moduleIssue(moduleName, relativePath, 'missing actionable menu XML'));
|
|
@@ -214,6 +214,21 @@ FROM (
|
|
|
214
214
|
'unavailable'
|
|
215
215
|
)
|
|
216
216
|
UNION ALL
|
|
217
|
+
SELECT 'work_mem', COALESCE(
|
|
218
|
+
(SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'work_mem'),
|
|
219
|
+
'unavailable'
|
|
220
|
+
)
|
|
221
|
+
UNION ALL
|
|
222
|
+
SELECT 'maintenance_work_mem', COALESCE(
|
|
223
|
+
(SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'maintenance_work_mem'),
|
|
224
|
+
'unavailable'
|
|
225
|
+
)
|
|
226
|
+
UNION ALL
|
|
227
|
+
SELECT 'effective_cache_size', COALESCE(
|
|
228
|
+
(SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'effective_cache_size'),
|
|
229
|
+
'unavailable'
|
|
230
|
+
)
|
|
231
|
+
UNION ALL
|
|
217
232
|
SELECT 'long_transaction_count', long_transaction_count FROM transaction_health
|
|
218
233
|
UNION ALL
|
|
219
234
|
SELECT 'oldest_long_transaction_age_seconds', oldest_long_transaction_age_seconds FROM transaction_health
|
|
@@ -264,6 +279,9 @@ export const POSTGRES_DIAGNOSTIC_KEYS = [
|
|
|
264
279
|
'pg_stat_statements_installed_version',
|
|
265
280
|
'shared_preload_libraries',
|
|
266
281
|
'shared_buffers',
|
|
282
|
+
'work_mem',
|
|
283
|
+
'maintenance_work_mem',
|
|
284
|
+
'effective_cache_size',
|
|
267
285
|
'long_transaction_count',
|
|
268
286
|
'oldest_long_transaction_age_seconds',
|
|
269
287
|
'idle_in_transaction_count',
|
|
@@ -591,6 +609,15 @@ export function structuredPostgresDiagnostics(diagnostics) {
|
|
|
591
609
|
if (diagnostics.shared_buffers) {
|
|
592
610
|
structured.sharedBuffers = diagnostics.shared_buffers;
|
|
593
611
|
}
|
|
612
|
+
if (diagnostics.work_mem) {
|
|
613
|
+
structured.workMem = diagnostics.work_mem;
|
|
614
|
+
}
|
|
615
|
+
if (diagnostics.maintenance_work_mem) {
|
|
616
|
+
structured.maintenanceWorkMem = diagnostics.maintenance_work_mem;
|
|
617
|
+
}
|
|
618
|
+
if (diagnostics.effective_cache_size) {
|
|
619
|
+
structured.effectiveCacheSize = diagnostics.effective_cache_size;
|
|
620
|
+
}
|
|
594
621
|
if (longTransactionCount !== undefined) {
|
|
595
622
|
structured.longTransactionCount = longTransactionCount;
|
|
596
623
|
}
|
package/dist/repo-url.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
+
import { validateRepoPath } from './path-validation.js';
|
|
3
|
+
import { parseGitHubRepoUrl } from './github.js';
|
|
2
4
|
export function normalizeRepositoryUrl(repoUrl) {
|
|
3
5
|
const trimmed = repoUrl.trim();
|
|
4
6
|
const withoutSuffix = trimmed.replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/, '');
|
|
@@ -15,13 +17,8 @@ export function inferRepoPath(repoUrl) {
|
|
|
15
17
|
if (!withoutGit) {
|
|
16
18
|
throw new Error(`Cannot infer repository path from URL: ${repoUrl}`);
|
|
17
19
|
}
|
|
18
|
-
return withoutGit;
|
|
20
|
+
return validateRepoPath(withoutGit);
|
|
19
21
|
}
|
|
20
22
|
export function inferGitHubOwner(repoUrl) {
|
|
21
|
-
|
|
22
|
-
const httpsMatch = normalized.match(/^https:\/\/github\.com\/([^/]+)\//);
|
|
23
|
-
if (httpsMatch)
|
|
24
|
-
return httpsMatch[1];
|
|
25
|
-
const sshMatch = normalized.match(/^git@github\.com:([^/]+)\//);
|
|
26
|
-
return sshMatch?.[1];
|
|
23
|
+
return parseGitHubRepoUrl(normalizeRepositoryUrl(repoUrl))?.owner;
|
|
27
24
|
}
|
package/dist/safe-reset.js
CHANGED
|
@@ -70,7 +70,7 @@ function parseGitmodules(target) {
|
|
|
70
70
|
const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
|
|
71
71
|
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
72
72
|
return sections.flatMap((section) => {
|
|
73
|
-
const pathMatch = section.match(/^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/([^\s]+)\s*$/m);
|
|
73
|
+
const pathMatch = section.match(/^\s*path\s*=\s*odoo\/custom\/src\/(?:(private|oca|external)\/)?([^\s/]+)\s*$/m);
|
|
74
74
|
if (!pathMatch) {
|
|
75
75
|
return [];
|
|
76
76
|
}
|
|
@@ -78,7 +78,7 @@ function parseGitmodules(target) {
|
|
|
78
78
|
if (!source) {
|
|
79
79
|
return [];
|
|
80
80
|
}
|
|
81
|
-
const sourceType = pathMatch[1];
|
|
81
|
+
const sourceType = (pathMatch[1] ?? 'private');
|
|
82
82
|
return [{ path: pathMatch[2], sourceType, url: source }];
|
|
83
83
|
});
|
|
84
84
|
}
|
|
@@ -150,7 +150,7 @@ function inferOptionsForPreviewSync(target) {
|
|
|
150
150
|
url: metadataMatch?.url?.trim() ||
|
|
151
151
|
gitmoduleMatch?.url ||
|
|
152
152
|
readSubmoduleUrlFromPath(target, path, sourceType),
|
|
153
|
-
addons: parseAddonsForRepo(addonsYaml, path),
|
|
153
|
+
addons: parseAddonsForRepo(addonsYaml, path, sourceType),
|
|
154
154
|
};
|
|
155
155
|
}),
|
|
156
156
|
engine: 'compose',
|
|
@@ -171,9 +171,12 @@ function inferOptionsForPreviewSync(target) {
|
|
|
171
171
|
function readSubmoduleUrlFromPath(target, repoPath, sourceType) {
|
|
172
172
|
try {
|
|
173
173
|
const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
|
|
174
|
-
const
|
|
174
|
+
const expectedPaths = [`odoo/custom/src/${sourceType}/${repoPath}`];
|
|
175
|
+
if (sourceType === 'private') {
|
|
176
|
+
expectedPaths.push(`odoo/custom/src/${repoPath}`);
|
|
177
|
+
}
|
|
175
178
|
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
176
|
-
const section = sections.find((value) => value.includes(`path = ${
|
|
179
|
+
const section = sections.find((value) => expectedPaths.some((path) => value.includes(`path = ${path}`)));
|
|
177
180
|
const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
178
181
|
return url || `odoo/custom/src/${sourceType}/${repoPath}`;
|
|
179
182
|
}
|
|
@@ -311,11 +314,14 @@ function safeResetExternalAssetOptions(options) {
|
|
|
311
314
|
],
|
|
312
315
|
}));
|
|
313
316
|
}
|
|
314
|
-
function parseAddonsForRepo(addonsYaml, repoPath) {
|
|
317
|
+
function parseAddonsForRepo(addonsYaml, repoPath, sourceType = 'private') {
|
|
315
318
|
const safeRepoPath = validateRepoPath(repoPath);
|
|
316
319
|
const lines = addonsYaml.split('\n');
|
|
317
|
-
const
|
|
318
|
-
|
|
320
|
+
const headers = [`${sourceType}/${safeRepoPath}:`];
|
|
321
|
+
if (sourceType === 'private') {
|
|
322
|
+
headers.push(`${safeRepoPath}:`);
|
|
323
|
+
}
|
|
324
|
+
const start = lines.findIndex((line) => headers.includes(line.trim()));
|
|
319
325
|
if (start === -1)
|
|
320
326
|
return [safeRepoPath];
|
|
321
327
|
const addons = [];
|
|
@@ -332,7 +338,7 @@ function parseAddonsForRepo(addonsYaml, repoPath) {
|
|
|
332
338
|
return addons.length ? addons : [safeRepoPath];
|
|
333
339
|
}
|
|
334
340
|
function parseRepoPathsFromAddonsYaml(addonsYaml) {
|
|
335
|
-
return [...addonsYaml.matchAll(/^private\/(
|
|
341
|
+
return [...addonsYaml.matchAll(/^(?:private\/)?([^/\s][^/:]*):$/gm)]
|
|
336
342
|
.map((match) => match[1].trim())
|
|
337
343
|
.filter((repoPath) => repoPath && isValidPathSegment(repoPath))
|
|
338
344
|
.map(validateRepoPath);
|
|
@@ -341,9 +347,12 @@ async function readSubmoduleUrl(target, repoPath, sourceType) {
|
|
|
341
347
|
const safeRepoPath = validateRepoPath(repoPath);
|
|
342
348
|
try {
|
|
343
349
|
const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
|
|
344
|
-
const
|
|
350
|
+
const expectedPaths = [`odoo/custom/src/${sourceType}/${safeRepoPath}`];
|
|
351
|
+
if (sourceType === 'private') {
|
|
352
|
+
expectedPaths.push(`odoo/custom/src/${safeRepoPath}`);
|
|
353
|
+
}
|
|
345
354
|
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
346
|
-
const section = sections.find((value) => value.includes(`path = ${
|
|
355
|
+
const section = sections.find((value) => expectedPaths.some((path) => value.includes(`path = ${path}`)));
|
|
347
356
|
const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
348
357
|
return url || `odoo/custom/src/${sourceType}/${safeRepoPath}`;
|
|
349
358
|
}
|
|
@@ -389,7 +398,7 @@ async function inferOptions(target) {
|
|
|
389
398
|
?.url.trim() ||
|
|
390
399
|
gitmoduleSources.find((repo) => repo.path === path && repo.type === sourceType)?.url ||
|
|
391
400
|
(await readSubmoduleUrl(target, path, sourceType)),
|
|
392
|
-
addons: parseAddonsForRepo(addonsYaml, path),
|
|
401
|
+
addons: parseAddonsForRepo(addonsYaml, path, sourceType),
|
|
393
402
|
})));
|
|
394
403
|
return {
|
|
395
404
|
product,
|
package/dist/source-manifest.js
CHANGED
|
@@ -273,13 +273,13 @@ export async function listGitmoduleSources(target) {
|
|
|
273
273
|
const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
|
|
274
274
|
const lines = gitmodules.split(/\r?\n/);
|
|
275
275
|
const locations = [];
|
|
276
|
-
const pathRegex = /^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/(
|
|
276
|
+
const pathRegex = /^\s*path\s*=\s*odoo\/custom\/src\/(?:(private|oca|external)\/)?([^/\s]+)\s*$/;
|
|
277
277
|
const urlRegex = /^\s*url\s*=\s*(.+)\s*$/;
|
|
278
278
|
let pending;
|
|
279
279
|
for (const line of lines) {
|
|
280
280
|
const parsedPath = line.match(pathRegex);
|
|
281
281
|
if (parsedPath) {
|
|
282
|
-
const sourceType = parsedPath[1];
|
|
282
|
+
const sourceType = (parsedPath[1] ?? 'private');
|
|
283
283
|
const repoPath = parsedPath[2]?.trim() ?? '';
|
|
284
284
|
if (!repoPath || !isValidPathSegment(repoPath)) {
|
|
285
285
|
pending = undefined;
|
package/dist/templates.js
CHANGED
|
@@ -212,6 +212,8 @@ In WPMOO_ENV=prod, module lifecycle commands such as install, update, and test
|
|
|
212
212
|
require WPMOO_ALLOW_PROD_LIFECYCLE=1. Destructive database commands such as
|
|
213
213
|
resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage
|
|
214
214
|
and prod. restore-snapshot --dry-run remains available for preview.
|
|
215
|
+
For short-lived local approvals, add JSONL entries to \`.wpmoo/approvals.jsonl\`;
|
|
216
|
+
generated \`.gitignore\` keeps that ledger out of Git.
|
|
215
217
|
|
|
216
218
|
If copied from the standalone resource, additional compose notes are in
|
|
217
219
|
\`docs/compose.md\`.
|
|
@@ -415,6 +417,7 @@ __pycache__/
|
|
|
415
417
|
.env.*
|
|
416
418
|
!.env.example
|
|
417
419
|
*.local
|
|
420
|
+
.wpmoo/approvals.jsonl
|
|
418
421
|
|
|
419
422
|
# Local generated files
|
|
420
423
|
*.code-workspace
|
|
@@ -457,7 +460,7 @@ usage() {
|
|
|
457
460
|
"update") echo "Usage: ./moo update <module[,module]> [db]" ;;
|
|
458
461
|
"test") echo "Usage: ./moo test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]" ;;
|
|
459
462
|
"resetdb") echo "Usage: ./moo resetdb [db] [module[,module]]" ;;
|
|
460
|
-
"snapshot") echo "Usage: ./moo snapshot [db] [snapshot-name]" ;;
|
|
463
|
+
"snapshot") echo "Usage: ./moo snapshot [--list] [db] [snapshot-name]" ;;
|
|
461
464
|
"restore-snapshot") echo "Usage: ./moo restore-snapshot [--dry-run] <snapshot-name> [db]" ;;
|
|
462
465
|
"lint") echo "Usage: ./moo lint" ;;
|
|
463
466
|
"pot") echo "Usage: ./moo pot <module[,module]> [db] [output]" ;;
|
|
@@ -574,9 +577,51 @@ selected_env() {
|
|
|
574
577
|
printf '%s\\n' "\${value:-dev}"
|
|
575
578
|
}
|
|
576
579
|
|
|
580
|
+
approval_active() {
|
|
581
|
+
local scope="$1"
|
|
582
|
+
local command="$2"
|
|
583
|
+
local env_name="$3"
|
|
584
|
+
[[ -f .wpmoo/approvals.jsonl ]] || return 1
|
|
585
|
+
|
|
586
|
+
node --input-type=module - "$scope" "$command" "$env_name" <<'NODE'
|
|
587
|
+
import { readFileSync } from 'node:fs';
|
|
588
|
+
|
|
589
|
+
const [scope, command, envName] = process.argv.slice(2);
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
const content = readFileSync('.wpmoo/approvals.jsonl', 'utf8');
|
|
593
|
+
for (const rawLine of content.split(/\\r?\\n/)) {
|
|
594
|
+
const line = rawLine.trim();
|
|
595
|
+
if (!line) continue;
|
|
596
|
+
|
|
597
|
+
let entry;
|
|
598
|
+
try {
|
|
599
|
+
entry = JSON.parse(line);
|
|
600
|
+
} catch {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
605
|
+
if (entry.scope !== scope || entry.environment !== envName) continue;
|
|
606
|
+
if (typeof entry.command === 'string' && entry.command !== command) continue;
|
|
607
|
+
|
|
608
|
+
const expiresAt = Date.parse(entry.expiresAt);
|
|
609
|
+
if (Number.isFinite(expiresAt) && expiresAt > Date.now()) {
|
|
610
|
+
process.exit(0);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} catch {
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
process.exit(1);
|
|
617
|
+
NODE
|
|
618
|
+
}
|
|
619
|
+
|
|
577
620
|
allow_destructive() {
|
|
621
|
+
local command="$1"
|
|
622
|
+
local env_name="\${2:-$(selected_env)}"
|
|
578
623
|
local value="\${WPMOO_ALLOW_DESTRUCTIVE:-$(env_file_value WPMOO_ALLOW_DESTRUCTIVE)}"
|
|
579
|
-
[[ "$value" == "1" ]]
|
|
624
|
+
[[ "$value" == "1" ]] || approval_active "destructive" "$command" "$env_name"
|
|
580
625
|
}
|
|
581
626
|
|
|
582
627
|
require_destructive_allowed() {
|
|
@@ -584,7 +629,7 @@ require_destructive_allowed() {
|
|
|
584
629
|
local env_name
|
|
585
630
|
env_name="$(selected_env)"
|
|
586
631
|
if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
|
|
587
|
-
if ! allow_destructive; then
|
|
632
|
+
if ! allow_destructive "$command" "$env_name"; then
|
|
588
633
|
echo "Refusing destructive command '$command' in WPMOO_ENV=$env_name. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally." >&2
|
|
589
634
|
exit 1
|
|
590
635
|
fi
|
|
@@ -592,28 +637,36 @@ require_destructive_allowed() {
|
|
|
592
637
|
}
|
|
593
638
|
|
|
594
639
|
allow_prod_lifecycle() {
|
|
640
|
+
local command="$1"
|
|
641
|
+
local env_name="\${2:-$(selected_env)}"
|
|
595
642
|
local value="\${WPMOO_ALLOW_PROD_LIFECYCLE:-$(env_file_value WPMOO_ALLOW_PROD_LIFECYCLE)}"
|
|
596
|
-
[[ "$value" == "1" ]]
|
|
643
|
+
[[ "$value" == "1" ]] || approval_active "prod-lifecycle" "$command" "$env_name"
|
|
597
644
|
}
|
|
598
645
|
|
|
599
646
|
allow_stage_lifecycle() {
|
|
647
|
+
local command="$1"
|
|
648
|
+
local env_name="\${2:-$(selected_env)}"
|
|
600
649
|
local value="\${WPMOO_ALLOW_STAGE_LIFECYCLE:-$(env_file_value WPMOO_ALLOW_STAGE_LIFECYCLE)}"
|
|
601
|
-
[[ "$value" == "1" ]]
|
|
650
|
+
[[ "$value" == "1" ]] || approval_active "stage-lifecycle" "$command" "$env_name"
|
|
602
651
|
}
|
|
603
652
|
|
|
604
653
|
allow_no_recent_snapshot() {
|
|
654
|
+
local command="$1"
|
|
655
|
+
local env_name="\${2:-$(selected_env)}"
|
|
605
656
|
local value="\${WPMOO_ALLOW_NO_RECENT_SNAPSHOT:-$(env_file_value WPMOO_ALLOW_NO_RECENT_SNAPSHOT)}"
|
|
606
|
-
[[ "$value" == "1" ]]
|
|
657
|
+
[[ "$value" == "1" ]] || approval_active "no-recent-snapshot" "$command" "$env_name"
|
|
607
658
|
}
|
|
608
659
|
|
|
609
660
|
allow_migrations() {
|
|
661
|
+
local command="$1"
|
|
662
|
+
local env_name="\${2:-$(selected_env)}"
|
|
610
663
|
local value="\${WPMOO_ALLOW_MIGRATIONS:-$(env_file_value WPMOO_ALLOW_MIGRATIONS)}"
|
|
611
|
-
[[ "$value" == "1" ]]
|
|
664
|
+
[[ "$value" == "1" ]] || approval_active "migration-risk" "$command" "$env_name"
|
|
612
665
|
}
|
|
613
666
|
|
|
614
667
|
has_recent_snapshot() {
|
|
615
668
|
local dir
|
|
616
|
-
for dir in backups backup snapshots; do
|
|
669
|
+
for dir in backups/snapshots backups backup snapshots; do
|
|
617
670
|
[[ -d "$dir" ]] || continue
|
|
618
671
|
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
672
|
return 0
|
|
@@ -622,12 +675,62 @@ has_recent_snapshot() {
|
|
|
622
675
|
return 1
|
|
623
676
|
}
|
|
624
677
|
|
|
678
|
+
snapshot_stem() {
|
|
679
|
+
local file="$1"
|
|
680
|
+
file="\${file##*/}"
|
|
681
|
+
file="\${file%.filestore.tar.gz}"
|
|
682
|
+
file="\${file%.sql.gz}"
|
|
683
|
+
file="\${file%.tar.gz}"
|
|
684
|
+
file="\${file%.dump}"
|
|
685
|
+
file="\${file%.sql}"
|
|
686
|
+
file="\${file%.zip}"
|
|
687
|
+
file="\${file%.tar}"
|
|
688
|
+
printf '%s' "$file"
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
json_string_value() {
|
|
692
|
+
local key="$1"
|
|
693
|
+
local file="$2"
|
|
694
|
+
[[ -f "$file" ]] || return 0
|
|
695
|
+
sed -n "s/.*\\"$key\\"[[:space:]]*:[[:space:]]*\\"\\([^\\"]*\\)\\".*/\\1/p" "$file" | head -n 1
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
list_snapshots() {
|
|
699
|
+
local found=0 dir dump stem manifest database created filestore
|
|
700
|
+
for dir in backups/snapshots backups backup snapshots; do
|
|
701
|
+
[[ -d "$dir" ]] || continue
|
|
702
|
+
while IFS= read -r dump; do
|
|
703
|
+
[[ -n "$dump" ]] || continue
|
|
704
|
+
found=1
|
|
705
|
+
stem="$(snapshot_stem "$dump")"
|
|
706
|
+
manifest="$dir/$stem.json"
|
|
707
|
+
database="$(json_string_value database "$manifest")"
|
|
708
|
+
created="$(json_string_value created_at "$manifest")"
|
|
709
|
+
filestore="$dir/$stem.filestore.tar.gz"
|
|
710
|
+
echo "- $stem"
|
|
711
|
+
[[ -n "$created" ]] && echo " Created: $created"
|
|
712
|
+
echo " Database: \${database:-unknown}"
|
|
713
|
+
echo " Dump: $dump"
|
|
714
|
+
if [[ -f "$filestore" ]]; then
|
|
715
|
+
echo " Filestore: $filestore (found)"
|
|
716
|
+
else
|
|
717
|
+
echo " Filestore: missing (missing)"
|
|
718
|
+
fi
|
|
719
|
+
done < <(find "$dir" -maxdepth 1 -type f \\( -name "*.dump" -o -name "*.sql" -o -name "*.sql.gz" \\) | sort)
|
|
720
|
+
done
|
|
721
|
+
|
|
722
|
+
if [[ "$found" -eq 0 ]]; then
|
|
723
|
+
echo "No database snapshots found."
|
|
724
|
+
echo "Next: run ./moo snapshot [db] [snapshot-name]."
|
|
725
|
+
fi
|
|
726
|
+
}
|
|
727
|
+
|
|
625
728
|
require_recent_snapshot_or_override() {
|
|
626
729
|
local command="$1"
|
|
627
730
|
local env_name
|
|
628
731
|
env_name="$(selected_env)"
|
|
629
732
|
if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
|
|
630
|
-
if ! allow_no_recent_snapshot && ! has_recent_snapshot; then
|
|
733
|
+
if ! allow_no_recent_snapshot "$command" "$env_name" && ! has_recent_snapshot; then
|
|
631
734
|
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
735
|
exit 1
|
|
633
736
|
fi
|
|
@@ -650,7 +753,7 @@ require_migrations_allowed() {
|
|
|
650
753
|
local env_name
|
|
651
754
|
env_name="$(selected_env)"
|
|
652
755
|
if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
|
|
653
|
-
if ! allow_migrations && has_migration_risk; then
|
|
756
|
+
if ! allow_migrations "$command" "$env_name" && has_migration_risk; then
|
|
654
757
|
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
758
|
exit 1
|
|
656
759
|
fi
|
|
@@ -662,7 +765,7 @@ require_stage_lifecycle_allowed() {
|
|
|
662
765
|
local env_name
|
|
663
766
|
env_name="$(selected_env)"
|
|
664
767
|
if [[ "$env_name" == "stage" ]]; then
|
|
665
|
-
if ! allow_stage_lifecycle; then
|
|
768
|
+
if ! allow_stage_lifecycle "$command" "$env_name"; then
|
|
666
769
|
echo "Refusing stage lifecycle command '$command' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally." >&2
|
|
667
770
|
exit 1
|
|
668
771
|
fi
|
|
@@ -674,7 +777,7 @@ require_prod_lifecycle_allowed() {
|
|
|
674
777
|
local env_name
|
|
675
778
|
env_name="$(selected_env)"
|
|
676
779
|
if [[ "$env_name" == "prod" ]]; then
|
|
677
|
-
if ! allow_prod_lifecycle; then
|
|
780
|
+
if ! allow_prod_lifecycle "$command" "$env_name"; then
|
|
678
781
|
echo "Refusing production lifecycle command '$command' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally." >&2
|
|
679
782
|
exit 1
|
|
680
783
|
fi
|
|
@@ -795,6 +898,12 @@ case "$command" in
|
|
|
795
898
|
;;
|
|
796
899
|
"snapshot")
|
|
797
900
|
shift
|
|
901
|
+
if [[ "\${1:-}" == "--list" ]]; then
|
|
902
|
+
shift
|
|
903
|
+
require_no_args "$command" "$@"
|
|
904
|
+
list_snapshots
|
|
905
|
+
exit 0
|
|
906
|
+
fi
|
|
798
907
|
positional_args "$command" 0 2 "$@"
|
|
799
908
|
run_script ./scripts/snapshot.sh "$@"
|
|
800
909
|
;;
|
|
@@ -929,12 +1038,35 @@ import { access, readdir, readFile, stat } from 'node:fs/promises';
|
|
|
929
1038
|
import { basename, isAbsolute, join, relative } from 'node:path';
|
|
930
1039
|
|
|
931
1040
|
const args = process.argv.slice(2);
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1041
|
+
function parseJsonOption(argv) {
|
|
1042
|
+
let json = false;
|
|
1043
|
+
for (const arg of argv) {
|
|
1044
|
+
if (arg === '--json') {
|
|
1045
|
+
json = true;
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (arg.startsWith('--json=')) {
|
|
1050
|
+
const value = arg.slice('--json='.length).toLowerCase().trim();
|
|
1051
|
+
if (['true', '1', 'yes', 'y'].includes(value)) {
|
|
1052
|
+
json = true;
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (['false', '0', 'no', 'n'].includes(value)) {
|
|
1056
|
+
json = false;
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
console.error('Invalid boolean value for --json: ' + arg.slice('--json='.length));
|
|
1060
|
+
process.exit(2);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
console.error('Usage: ./moo status [--json]');
|
|
1064
|
+
process.exit(2);
|
|
1065
|
+
}
|
|
1066
|
+
return json;
|
|
935
1067
|
}
|
|
936
1068
|
|
|
937
|
-
const json = args
|
|
1069
|
+
const json = parseJsonOption(args);
|
|
938
1070
|
const target = process.cwd();
|
|
939
1071
|
const metadataPath = '.wpmoo/odoo.json';
|
|
940
1072
|
const validSourceTypes = new Set(['private', 'oca', 'external']);
|
|
@@ -1515,7 +1647,7 @@ Useful maintenance commands:
|
|
|
1515
1647
|
\`\`\`bash
|
|
1516
1648
|
./moo lint
|
|
1517
1649
|
./moo resetdb [db] [module[,module]]
|
|
1518
|
-
./moo snapshot [db] [snapshot-name]
|
|
1650
|
+
./moo snapshot [--list] [db] [snapshot-name]
|
|
1519
1651
|
./moo restore-snapshot [--dry-run] <snapshot-name> [db]
|
|
1520
1652
|
./moo pot <module[,module]> [db] [output]
|
|
1521
1653
|
\`\`\`
|