@wpmoo/toolkit 0.9.29 → 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.
@@ -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
- const normalized = normalizeRepositoryUrl(repoUrl);
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
  }
@@ -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 escapedPath = `odoo/custom/src/${sourceType}/${repoPath}`;
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 = ${escapedPath}`));
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 header = `private/${safeRepoPath}:`;
318
- const start = lines.findIndex((line) => line.trim() === header);
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\/(.+):$/gm)]
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 escapedPath = `odoo/custom/src/${sourceType}/${safeRepoPath}`;
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 = ${escapedPath}`));
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,
@@ -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)\/(.+)\s*$/;
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
- if (!args.every((arg) => arg === '--json')) {
933
- console.error('Usage: ./moo status [--json]');
934
- process.exit(2);
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.includes('--json');
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
  \`\`\`
@@ -74,8 +74,8 @@ Ready:
74
74
  preview/read-only paths.
75
75
  - Migration-risk lifecycle commands can require `WPMOO_ALLOW_MIGRATIONS=1`.
76
76
  - Environment-variable approvals remain supported through 1.x.
77
- - Timestamped approval files may be added as an additive safety layer, not as a
78
- replacement for env flags in 1.x.
77
+ - `.wpmoo/approvals.jsonl` adds time-bounded local approvals as an additive
78
+ safety layer, not as a replacement for env flags in 1.x.
79
79
 
80
80
  ## Release Artifact Policy
81
81
 
@@ -94,19 +94,43 @@ Ready:
94
94
 
95
95
  ## Current Audit
96
96
 
97
- Last updated: 2026-05-21.
97
+ Last updated: 2026-05-22.
98
98
 
99
99
  Completed checks:
100
100
 
101
- - Full local gate for Train 8 passed on 2026-05-21:
101
+ - Full local gate through Train 19 passed on 2026-05-22:
102
102
  `npm run typecheck`, `npm test`, `npm run build`, and `git diff --check`.
103
- - Coverage passed on 2026-05-21: 72 test files, 682 tests, 92.32%
104
- statements, 87.56% branches, 96.74% functions, and 92.32% lines.
105
- - Published CLI smoke passed against `@wpmoo/toolkit@0.9.27` with `npm exec`
106
- for `wpmoo --version`, `wpmoo --help`, and `wpmoo doctor --help`.
103
+ - Coverage passed on 2026-05-22: 75 test files, 722 tests, 92.00%
104
+ statements, 87.59% branches, 96.30% functions, and 92.00% lines.
105
+ - Local release packaging passed for the 1.0 release-candidate surface with
106
+ `npm --cache /private/tmp/wpmoo-npm-cache pack --dry-run`; the package
107
+ contained 70 files and the expected `dist`, `docs/assets`, `docs/*.md`,
108
+ `README.md`, `LICENSE`, and `package.json` entries.
109
+ - Local dist CLI smoke passed for `node dist/cli.js --version`, `--help`,
110
+ `create --help`, `doctor --help`, and `status --help`.
111
+ - The generated-environment acceptance flow remains covered by
112
+ `test/smoke-published-script.test.ts`, including create, source list/sync,
113
+ safe reset preview, `doctor --fix`, snapshot, and restore dry-run steps.
107
114
  - Placeholder review found no `TODO`, `FIXME`, `TBD`, or `coming soon`
108
115
  markers in `README.md`, `docs`, or `src`.
109
116
  - Local markdown link review passed across `README.md` and `docs/*.md`.
117
+ - Published CLI smoke previously passed against `@wpmoo/toolkit@0.9.27` with
118
+ `npm exec` for `wpmoo --version`, `wpmoo --help`, and
119
+ `wpmoo doctor --help`.
120
+
121
+ Release-candidate notes:
122
+
123
+ - Local `npm exec --package file:...` smoke against the generated tarball did
124
+ not complete in this restricted environment because package installation and
125
+ dependency resolution timed out before the CLI could run. This is not treated
126
+ as a product regression, but it prevents treating the current line as final
127
+ `1.0.0` evidence before a registry-backed smoke completes.
128
+ - The next patch release should be published as `0.9.30`. Do not tag `1.0.0`
129
+ until `WPMOO_SMOKE_ENVIRONMENT=1 npm run smoke:published -- "$VERSION"`
130
+ passes against a registry-resolved package.
131
+ - After a registry-backed generated-environment smoke passes, the remaining
132
+ 1.0 decision is procedural: bump to `1.0.0`, rerun the full gate, rerun
133
+ `npm run release:check`, tag, and verify the required scoped artifacts.
110
134
 
111
135
  Final policy decisions:
112
136
 
@@ -117,7 +141,7 @@ Final policy decisions:
117
141
  - Pre-1.0 generated environments are supported through safe reset and
118
142
  doctor-guided generated-file migration checks that preserve source code and
119
143
  local runtime data.
120
- - Environment-variable approvals remain supported through 1.x; timestamped
121
- approval files may be added only as an additive safety layer.
144
+ - Environment-variable approvals remain supported through 1.x;
145
+ `.wpmoo/approvals.jsonl` is additive and local-only.
122
146
  - Generated-environment acceptance smoke is mandatory for a `1.0.0` release
123
147
  candidate.