@wpmoo/toolkit 0.9.30 → 0.9.31

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 CHANGED
@@ -154,8 +154,8 @@ search so names, repositories, and source categories can be filtered quickly.
154
154
  ./moo restore-snapshot --dry-run before-update devel
155
155
  ```
156
156
 
157
- In `WPMOO_ENV=stage`, `install` and `update` require `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
158
- In `WPMOO_ENV=prod`, `install`, `update`, and `test` require `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
157
+ In `WPMOO_ENV=stage`, `install`, `update`, `stop`, and `restart` require `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
158
+ In `WPMOO_ENV=prod`, `install`, `update`, `test`, `stop`, and `restart` require `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
159
159
  `resetdb` and real `restore-snapshot` require `WPMOO_ALLOW_DESTRUCTIVE=1` in `stage` and `prod`.
160
160
  `restore-snapshot --dry-run` remains allowed for preview.
161
161
  For short-lived local approvals, add JSONL entries to `.wpmoo/approvals.jsonl`; generated `.gitignore` keeps that ledger out of Git.
@@ -4,7 +4,7 @@ import { commandOdooVersion } from '../environment-version.js';
4
4
  import { outroPrompt } from '../prompts/index.js';
5
5
  import { addModuleRepo, removeModuleRepo } from '../repo-actions.js';
6
6
  import { normalizeRepositoryUrl } from '../repo-url.js';
7
- import { listSources, renderSourceList, sourceListJson, sourceSyncJson, syncSources, } from '../source-actions.js';
7
+ import { listSources, renderSourceList, renderSourceSyncPlan, sourceListJson, sourceSyncPlan, sourceSyncPlanJson, sourceSyncJson, syncSources, } from '../source-actions.js';
8
8
  import { renderBanner } from '../templates.js';
9
9
  import { booleanOption, jsonOption, optionalSourceTypeValue, printJson, sourceTypeValue, stringOption, } from './options.js';
10
10
  export function renderedSourceRepoPath(target, sourceType, repoPath) {
@@ -52,6 +52,7 @@ export function sourceSyncOptionsFromArgs(argv) {
52
52
  target: resolve(stringOption(values, 'target') ?? process.cwd()),
53
53
  stage: booleanOption(values, 'stage', true),
54
54
  json: jsonOption(values),
55
+ dryRun: booleanOption(values, 'dryRun', false),
55
56
  };
56
57
  }
57
58
  export function sourceListOptionsFromArgs(argv) {
@@ -79,6 +80,16 @@ export async function runSourceCommand(argv) {
79
80
  }
80
81
  if (subcommand === 'sync') {
81
82
  const options = sourceSyncOptionsFromArgs(subcommandArgv);
83
+ if (options.dryRun) {
84
+ const plan = await sourceSyncPlan(options.target);
85
+ if (options.json) {
86
+ printJson(sourceSyncPlanJson(plan));
87
+ return;
88
+ }
89
+ console.log(renderBanner());
90
+ console.log(renderSourceSyncPlan(plan));
91
+ return;
92
+ }
82
93
  const sources = await syncSources({ target: options.target, stage: options.stage });
83
94
  if (options.json) {
84
95
  printJson(sourceSyncJson(sources, options.target));
package/dist/cli.js CHANGED
@@ -676,6 +676,7 @@ function removeModuleOptionsFromArgs(argv) {
676
676
  moduleName,
677
677
  sourceType: optionalSourceTypeValue(values),
678
678
  deleteFiles: booleanOption(values, 'deleteFiles', false),
679
+ dryRun: booleanOption(values, 'dryRun', false),
679
680
  stage: booleanOption(values, 'stage', true),
680
681
  };
681
682
  }
@@ -1419,14 +1420,14 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1419
1420
  const options = removeModuleOptionsFromArgs(route.argv);
1420
1421
  if (options) {
1421
1422
  console.log(renderBanner());
1422
- await removeModuleFromSourceRepo(options);
1423
- outroPrompt(`Removed module ${options.moduleName} from source repo ${options.repoPath}.`);
1423
+ const report = await removeModuleFromSourceRepo(options);
1424
+ outroPrompt(report.dryRun ? report.summary : `Removed module ${options.moduleName} from source repo ${options.repoPath}.`);
1424
1425
  return;
1425
1426
  }
1426
1427
  await showStartup(argv, skipUpdateCheck);
1427
1428
  const promptedOptions = await removeModuleOptionsFromPrompts();
1428
- await removeModuleFromSourceRepo(promptedOptions);
1429
- outroPrompt(`Removed module ${promptedOptions.moduleName} from source repo ${promptedOptions.repoPath}.`);
1429
+ const report = await removeModuleFromSourceRepo(promptedOptions);
1430
+ outroPrompt(report.dryRun ? report.summary : `Removed module ${promptedOptions.moduleName} from source repo ${promptedOptions.repoPath}.`);
1430
1431
  return;
1431
1432
  }
1432
1433
  if (route.command === 'reset') {
@@ -1451,6 +1452,9 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1451
1452
  doctorOptions.postgres = options.postgres;
1452
1453
  }
1453
1454
  if (options.json) {
1455
+ if (doctorOptions.fix) {
1456
+ throw new Error('doctor --json --fix is not supported; run doctor --fix for human-readable auto-fix output, then doctor --json to inspect the post-fix state.');
1457
+ }
1454
1458
  printJson(await getDoctorReport(cwd, doctorOptions));
1455
1459
  return;
1456
1460
  }
@@ -20,7 +20,7 @@ const topLevelCategoryOrder = [
20
20
  ];
21
21
  const topLevelCommands = topLevelCategoryOrder.flatMap((category) => cockpitCommands.filter((command) => command.category === category && command.id !== 'exit'));
22
22
  const topLevelCommandLabelWidth = Math.max(...topLevelCommands.map((command) => command.label.length));
23
- const moduleDependentCommandIds = new Set(['list-modules', 'install', 'update', 'test', 'lint', 'pot', 'remove-module']);
23
+ const moduleDependentCommandIds = new Set(['list-modules', 'install', 'update', 'test', 'pot', 'remove-module']);
24
24
  function rgb(red, green, blue, value) {
25
25
  return `\u001B[38;2;${red};${green};${blue}m${value}\u001B[39m`;
26
26
  }
package/dist/doctor.js CHANGED
@@ -5,8 +5,9 @@ import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './
5
5
  import { dailyActionScripts } from './daily-actions.js';
6
6
  import { defaultPostgresVersion } from './external-templates.js';
7
7
  import { defaultOdooVersion, markerPath, replaceSourceRepos } from './environment.js';
8
- import { POSTGRES_DIAGNOSTICS_CONTRACT_VERSION, POSTGRES_DIAGNOSTICS_QUERY, malformedPostgresDiagnosticKeys, missingPostgresDiagnosticKeys, parsePostgresDiagnostics, postgresPostgresWarnings, renderPostgresDiagnostics, structuredPostgresDiagnostics, unavailablePostgresDiagnosticsWarning, } from './postgres-diagnostics.js';
8
+ import { POSTGRES_DIAGNOSTICS_CONTRACT_VERSION, POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES, POSTGRES_DIAGNOSTICS_QUERY, malformedPostgresDiagnosticKeys, missingPostgresDiagnosticKeys, parsePostgresDiagnostics, postgresPostgresWarnings, renderPostgresDiagnostics, structuredPostgresDiagnostics, unavailablePostgresDiagnosticsWarning, } from './postgres-diagnostics.js';
9
9
  import { listGitmoduleSources, readSourceManifest, sourceReposFromManifest, sourceManifestPath, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
10
+ import { emptyModuleQualitySummary, mergeModuleQualitySummaries, scanModuleQuality, } from './module-quality.js';
10
11
  const realCommandRunner = async (command, args, options) => {
11
12
  const result = await execa(command, args, { cwd: options.cwd });
12
13
  return { stdout: result.stdout, stderr: result.stderr };
@@ -76,6 +77,13 @@ function isMetadataError(message) {
76
77
  message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
77
78
  }
78
79
  const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
80
+ const defaultPostgresDiagnosticsTimeoutMs = 15_000;
81
+ class PostgresDiagnosticsTimeoutError extends Error {
82
+ constructor(timeoutMs) {
83
+ super(`PostgreSQL diagnostics timed out after ${timeoutMs}ms`);
84
+ this.name = 'PostgresDiagnosticsTimeoutError';
85
+ }
86
+ }
79
87
  function parsePostgresMajorFromValue(value) {
80
88
  if (!value)
81
89
  return undefined;
@@ -86,16 +94,50 @@ function parsePostgresMajorFromValue(value) {
86
94
  const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
87
95
  return match?.[1];
88
96
  }
89
- async function readPostgresDiagnostics(target, runner) {
90
- const queryLiteral = JSON.stringify(POSTGRES_DIAGNOSTICS_QUERY);
97
+ async function withPostgresDiagnosticsTimeout(promise, timeoutMs) {
98
+ let timeout;
99
+ const timeoutPromise = new Promise((_, reject) => {
100
+ timeout = setTimeout(() => reject(new PostgresDiagnosticsTimeoutError(timeoutMs)), timeoutMs);
101
+ });
102
+ try {
103
+ return await Promise.race([promise, timeoutPromise]);
104
+ }
105
+ finally {
106
+ if (timeout) {
107
+ clearTimeout(timeout);
108
+ }
109
+ }
110
+ }
111
+ function postgresDiagnosticsTimeoutMs(options) {
112
+ return typeof options.postgresTimeoutMs === 'number' &&
113
+ Number.isFinite(options.postgresTimeoutMs) &&
114
+ options.postgresTimeoutMs > 0
115
+ ? options.postgresTimeoutMs
116
+ : defaultPostgresDiagnosticsTimeoutMs;
117
+ }
118
+ async function readPostgresDiagnosticQuery(target, runner, query, timeoutMs) {
119
+ const queryLiteral = JSON.stringify(query);
91
120
  const command = [
92
121
  `query=${queryLiteral}`,
93
122
  '. ./scripts/lib.sh >/dev/null',
94
123
  'compose exec -T db psql -X -q -t -A -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -c "$query"',
95
124
  ].join(' && ');
96
- const result = await runner('bash', ['-lc', command], { cwd: target });
125
+ const result = await withPostgresDiagnosticsTimeout(runner('bash', ['-lc', command], { cwd: target }), timeoutMs);
97
126
  return parsePostgresDiagnostics(result.stdout);
98
127
  }
128
+ async function readPostgresDiagnostics(target, runner, timeoutMs) {
129
+ const diagnostics = await readPostgresDiagnosticQuery(target, runner, POSTGRES_DIAGNOSTICS_QUERY, timeoutMs);
130
+ for (const probe of POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES) {
131
+ try {
132
+ Object.assign(diagnostics, await readPostgresDiagnosticQuery(target, runner, probe.query, timeoutMs));
133
+ }
134
+ catch {
135
+ // Optional probes use PostgreSQL functions that can require elevated roles.
136
+ // Their failure must not hide the core read-only health report.
137
+ }
138
+ }
139
+ return diagnostics;
140
+ }
99
141
  function stripInlineComment(line) {
100
142
  const hashIndex = line.indexOf('#');
101
143
  if (hashIndex === -1)
@@ -254,10 +296,14 @@ const doctorSectionDefinitions = [
254
296
  { id: 'generated-files', title: 'Generated files' },
255
297
  { id: 'compose', title: 'Compose' },
256
298
  { id: 'source-repositories', title: 'Source repositories' },
299
+ { id: 'module-quality', title: 'Module quality' },
257
300
  { id: 'postgresql', title: 'PostgreSQL' },
258
301
  { id: 'host-tools', title: 'Host tools' },
259
302
  ];
260
303
  function doctorSectionForLine(line) {
304
+ if (line.startsWith('OK module quality') || line.startsWith('Module quality advisory:')) {
305
+ return 'module-quality';
306
+ }
261
307
  if (line.includes('PostgreSQL diagnostics') ||
262
308
  line.includes('PostgreSQL connection') ||
263
309
  line.includes('PostgreSQL slow-query') ||
@@ -521,6 +567,14 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
521
567
  errors.push(`Missing source repo path: ${relativePath}`);
522
568
  }
523
569
  checks.push(`OK source repos ${sourceRepos.length} checked`);
570
+ let moduleQuality = emptyModuleQualitySummary();
571
+ for (const repo of sourceRepos) {
572
+ const sourceType = normalizeSourceType(repo.sourceType);
573
+ const repoRoot = join(target, sourceRepoPath(sourceType, repo.path));
574
+ moduleQuality = mergeModuleQualitySummaries(moduleQuality, await scanModuleQuality(repoRoot, target));
575
+ }
576
+ checks.push(`OK module quality ${moduleQuality.totalModules} module${moduleQuality.totalModules === 1 ? '' : 's'} scanned`);
577
+ warnings.push(...moduleQuality.issues.map((issue) => `Module quality advisory: ${issue.path}: ${issue.issue}`));
524
578
  const manifestPath = join(target, sourceManifestPath);
525
579
  const hasManifest = await exists(manifestPath);
526
580
  let manifestEntries = [];
@@ -574,7 +628,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
574
628
  }
575
629
  if (actualOptions.postgres) {
576
630
  try {
577
- const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner);
631
+ const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner, postgresDiagnosticsTimeoutMs(actualOptions));
578
632
  const missingKeys = missingPostgresDiagnosticKeys(postgresDiagnostics);
579
633
  const malformedKeys = malformedPostgresDiagnosticKeys(postgresDiagnostics);
580
634
  if (missingKeys.length === 0 && malformedKeys.length === 0) {
@@ -31,9 +31,9 @@ export const dailyActionPolicyTable = {
31
31
  stop: {
32
32
  isDestructive: () => false,
33
33
  isDryRunAllowed: false,
34
- requiresStageLifecycleApproval: false,
35
- requiresProdLifecycleApproval: false,
36
- isAuditWorthy: () => false,
34
+ requiresStageLifecycleApproval: true,
35
+ requiresProdLifecycleApproval: true,
36
+ isAuditWorthy: () => true,
37
37
  },
38
38
  logs: {
39
39
  isDestructive: () => false,
@@ -45,9 +45,9 @@ export const dailyActionPolicyTable = {
45
45
  restart: {
46
46
  isDestructive: () => false,
47
47
  isDryRunAllowed: false,
48
- requiresStageLifecycleApproval: false,
49
- requiresProdLifecycleApproval: false,
50
- isAuditWorthy: () => false,
48
+ requiresStageLifecycleApproval: true,
49
+ requiresProdLifecycleApproval: true,
50
+ isAuditWorthy: () => true,
51
51
  },
52
52
  shell: {
53
53
  isDestructive: () => false,
@@ -43,8 +43,14 @@ export function renderComposeEnvExample(options) {
43
43
  '# Required only when intentionally running destructive database actions',
44
44
  '# such as resetdb or restore-snapshot with WPMOO_ENV=stage or WPMOO_ENV=prod.',
45
45
  '# WPMOO_ALLOW_DESTRUCTIVE=1',
46
- '# Required only when intentionally running install/update/test in WPMOO_ENV=prod.',
46
+ '# Required only when intentionally running install/update/stop/restart in WPMOO_ENV=stage.',
47
+ '# WPMOO_ALLOW_STAGE_LIFECYCLE=1',
48
+ '# Required only when intentionally running install/update/test/stop/restart in WPMOO_ENV=prod.',
47
49
  '# WPMOO_ALLOW_PROD_LIFECYCLE=1',
50
+ '# Required only when intentionally running a destructive stage/prod command without a recent snapshot.',
51
+ '# WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1',
52
+ '# Required only when intentionally running migration-risk lifecycle commands in stage/prod.',
53
+ '# WPMOO_ALLOW_MIGRATIONS=1',
48
54
  '',
49
55
  ].join('\n');
50
56
  }
package/dist/help.js CHANGED
@@ -87,8 +87,8 @@ Daily actions:
87
87
  Use ./moo or npx @wpmoo/toolkit with the same daily action arguments.
88
88
 
89
89
  Lifecycle command guards:
90
- In WPMOO_ENV=stage, install/update require WPMOO_ALLOW_STAGE_LIFECYCLE=1.
91
- In WPMOO_ENV=prod, install/update/test require WPMOO_ALLOW_PROD_LIFECYCLE=1.
90
+ In WPMOO_ENV=stage, install/update/stop/restart require WPMOO_ALLOW_STAGE_LIFECYCLE=1.
91
+ In WPMOO_ENV=prod, install/update/test/stop/restart require WPMOO_ALLOW_PROD_LIFECYCLE=1.
92
92
  resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage/prod.
93
93
  restore-snapshot --dry-run remains allowed for preview.
94
94
  Time-bounded local approvals may also be recorded in .wpmoo/approvals.jsonl.
@@ -253,8 +253,12 @@ async function moduleScaffoldChecks(target, sourceType, repoPath, moduleName, in
253
253
  id: 'tests',
254
254
  label: 'tests',
255
255
  ok: (await fileContains(join(destination, 'tests/__init__.py'), `from . import test_${moduleName}`)) &&
256
- (await fileContains(join(destination, `tests/test_${moduleName}.py`), '')),
257
- details: 'missing generated test file',
256
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), 'TransactionCase')) &&
257
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), '@tagged("post_install", "-at_install")')) &&
258
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), `class Test${moduleClassName(moduleName)}(TransactionCase):`)) &&
259
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), 'def test_create_record(self):')) &&
260
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), `self.env["${technicalName}"]`)),
261
+ details: 'missing generated TransactionCase test markers',
258
262
  },
259
263
  ];
260
264
  if (includeRegistration) {
@@ -375,23 +379,20 @@ async function assertModuleCleanBeforeDelete(target, sourceType, repoPath, modul
375
379
  const repoRoot = sourceRepoPath(target, sourceType, repoPath);
376
380
  try {
377
381
  const result = await git.run(repoRoot, ['status', '--short', '--', moduleName]);
378
- if (result.stdout.trim() && (await moduleHasCommittedFiles(repoRoot, moduleName, git))) {
379
- throw new Error(`Refusing to delete module ${moduleName} because it has dirty git changes in source repo ${repoPath}.`);
382
+ const status = result.stdout.trimEnd();
383
+ if (status.trim()) {
384
+ const hasUntrackedOrStaged = status
385
+ .split(/\r?\n/u)
386
+ .some((line) => line.startsWith('??') || /^[A-Z][A-Z ]\s/u.test(line));
387
+ const reason = hasUntrackedOrStaged ? 'uncommitted git changes' : 'dirty git changes';
388
+ throw new Error(`Refusing to delete module ${moduleName} because it has ${reason} in source repo ${repoPath}.`);
380
389
  }
381
390
  }
382
391
  catch (error) {
383
392
  if (error instanceof Error && error.message.startsWith('Refusing to delete module ')) {
384
393
  throw error;
385
394
  }
386
- }
387
- }
388
- async function moduleHasCommittedFiles(repoRoot, moduleName, git) {
389
- try {
390
- const result = await git.run(repoRoot, ['ls-tree', '-r', '--name-only', 'HEAD', '--', moduleName]);
391
- return Boolean(result.stdout.trim());
392
- }
393
- catch {
394
- return false;
395
+ throw new Error(`Refusing to delete module ${moduleName} because git status could not be verified in source repo ${repoPath}.`);
395
396
  }
396
397
  }
397
398
  export async function addModuleToSourceRepo(options, git = realGit) {
@@ -483,6 +484,19 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
483
484
  const repoPath = validateRepoPath(options.repoPath);
484
485
  const moduleName = validateModuleName(options.moduleName);
485
486
  const sourceType = normalizeSourceType(options.sourceType);
487
+ const destination = modulePath(options.target, sourceType, repoPath, moduleName);
488
+ if (options.dryRun) {
489
+ return {
490
+ moduleName,
491
+ repoPath,
492
+ sourceType,
493
+ path: destination,
494
+ deleteFiles: options.deleteFiles,
495
+ dryRun: true,
496
+ ...(options.deleteFiles ? { wouldDeletePath: destination } : {}),
497
+ summary: `Previewed removal of module ${moduleName} from source repo ${repoPath}.`,
498
+ };
499
+ }
486
500
  if (options.deleteFiles) {
487
501
  await assertModuleCleanBeforeDelete(options.target, sourceType, repoPath, moduleName, git);
488
502
  }
@@ -492,7 +506,7 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
492
506
  }
493
507
  await updateModuleRegistration(options.target, sourceType, repoPath, moduleName, 'remove');
494
508
  if (options.deleteFiles) {
495
- await rm(modulePath(options.target, sourceType, repoPath, moduleName), { recursive: true, force: true });
509
+ await rm(destination, { recursive: true, force: true });
496
510
  }
497
511
  if (options.stage) {
498
512
  if (options.deleteFiles) {
@@ -500,4 +514,14 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
500
514
  }
501
515
  await stageAll(git, options.target);
502
516
  }
517
+ return {
518
+ moduleName,
519
+ repoPath,
520
+ sourceType,
521
+ path: destination,
522
+ deleteFiles: options.deleteFiles,
523
+ dryRun: false,
524
+ ...(options.deleteFiles ? { wouldDeletePath: destination } : {}),
525
+ summary: `Removed module ${moduleName} from source repo ${repoPath}.`,
526
+ };
503
527
  }
@@ -154,6 +154,49 @@ function declaredModelIds(modelFileContents) {
154
154
  }
155
155
  return modelIds;
156
156
  }
157
+ function declaredModelNames(modelFileContents) {
158
+ const modelNames = new Set();
159
+ for (const content of modelFileContents) {
160
+ for (const match of content.matchAll(/^\s*_name\s*=\s*["']([^"']+)["']/gmu)) {
161
+ const modelName = match[1]?.trim();
162
+ if (modelName) {
163
+ modelNames.add(modelName);
164
+ }
165
+ }
166
+ for (const match of content.matchAll(/^\s*_inherit\s*=\s*["']([^"']+)["']/gmu)) {
167
+ const modelName = match[1]?.trim();
168
+ if (modelName) {
169
+ modelNames.add(modelName);
170
+ }
171
+ }
172
+ }
173
+ return modelNames;
174
+ }
175
+ function escapeRegExp(value) {
176
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
177
+ }
178
+ function xmlRecordFieldValues(xmlFiles, recordModel, fieldName) {
179
+ const values = [];
180
+ const recordPattern = new RegExp(`<record\\b[^>]*\\bmodel=(["'])${escapeRegExp(recordModel)}\\1[^>]*>(.*?)</record>`, 'gsu');
181
+ const fieldPattern = new RegExp(`<field\\b[^>]*\\bname=(["'])${escapeRegExp(fieldName)}\\1[^>]*>(.*?)</field>`, 'gsu');
182
+ for (const content of xmlFiles) {
183
+ let recordMatch;
184
+ while ((recordMatch = recordPattern.exec(content))) {
185
+ const body = recordMatch[2] ?? '';
186
+ let fieldMatch;
187
+ while ((fieldMatch = fieldPattern.exec(body))) {
188
+ const value = fieldMatch[2]?.replace(/<[^>]+>/gu, '').trim();
189
+ if (value) {
190
+ values.push(value);
191
+ }
192
+ }
193
+ }
194
+ }
195
+ return values;
196
+ }
197
+ function looksLikePlainModelName(value) {
198
+ return /^[a-zA-Z_][\w]*(?:\.[a-zA-Z_][\w]*)+$/u.test(value);
199
+ }
157
200
  function accessCsvModelIds(content) {
158
201
  if (!content)
159
202
  return [];
@@ -206,6 +249,7 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
206
249
  const modelFiles = await readPythonModelFiles(modulePath);
207
250
  const viewFiles = await readViewXmlFiles(modulePath);
208
251
  const viewXml = await Promise.all(viewFiles.map(async (fileName) => (await readOptionalFile(join(modulePath, 'views', fileName))) ?? ''));
252
+ const allViewXml = [...viewXml, ...menuXml];
209
253
  const depends = manifestDepends(manifest);
210
254
  const data = manifestData(manifest);
211
255
  const demo = manifestDemo(manifest);
@@ -241,6 +285,7 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
241
285
  }
242
286
  const modelFileContents = await readModelFileContents(modulePath, modelFiles);
243
287
  const modelIds = declaredModelIds(modelFileContents);
288
+ const modelNames = declaredModelNames(modelFileContents);
244
289
  if (modelIds.size > 0) {
245
290
  for (const modelId of accessCsvModelIds(await readOptionalFile(join(modulePath, 'security/ir.model.access.csv')))) {
246
291
  if (!modelIds.has(modelId)) {
@@ -248,6 +293,18 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
248
293
  }
249
294
  }
250
295
  }
296
+ if (modelNames.size > 0) {
297
+ for (const modelName of new Set(xmlRecordFieldValues(viewXml, 'ir.ui.view', 'model'))) {
298
+ if (looksLikePlainModelName(modelName) && !modelNames.has(modelName)) {
299
+ issues.push(moduleQualityIssue(moduleName, relativePath, `view XML references unknown model name: ${modelName}`, 'error'));
300
+ }
301
+ }
302
+ for (const modelName of new Set(xmlRecordFieldValues(allViewXml, 'ir.actions.act_window', 'res_model'))) {
303
+ if (looksLikePlainModelName(modelName) && !modelNames.has(modelName)) {
304
+ issues.push(moduleQualityIssue(moduleName, relativePath, `action XML references unknown res_model: ${modelName}`, 'error'));
305
+ }
306
+ }
307
+ }
251
308
  if (hasOdooStructures && !dataIncludesAccessCsv(data)) {
252
309
  issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv in manifest data'));
253
310
  }
@@ -257,7 +314,6 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
257
314
  if (!(await directoryExists(join(modulePath, 'tests')))) {
258
315
  issues.push(moduleIssue(moduleName, relativePath, 'missing tests directory'));
259
316
  }
260
- const allViewXml = [...viewXml, ...menuXml];
261
317
  const actionIds = declaredActionIds(allViewXml);
262
318
  for (const actionRef of menuActionReferences(menuXml)) {
263
319
  if (!actionIds.has(actionRef)) {
@@ -124,13 +124,10 @@ index_health AS (
124
124
  wal_health AS (
125
125
  SELECT
126
126
  COALESCE((SELECT setting FROM pg_settings WHERE name = 'wal_level'), 'unavailable')::text AS wal_level,
127
- COALESCE((SELECT setting FROM pg_settings WHERE name = 'archive_mode'), 'unavailable')::text AS wal_archive_mode,
128
- COALESCE((SELECT COUNT(*) FROM pg_ls_waldir()), 0)::text AS wal_file_count,
129
- COALESCE((SELECT SUM(size) FROM pg_ls_waldir()), 0)::text AS wal_directory_size_bytes
127
+ COALESCE((SELECT setting FROM pg_settings WHERE name = 'archive_mode'), 'unavailable')::text AS wal_archive_mode
130
128
  ),
131
129
  capacity_health AS (
132
130
  SELECT
133
- COALESCE((SELECT pg_tablespace_size('pg_default')), 0)::text AS default_tablespace_size_bytes,
134
131
  COALESCE(
135
132
  (SELECT SUM(n_tup_ins + n_tup_upd + n_tup_del) FROM pg_stat_database WHERE datname IS NOT NULL),
136
133
  0
@@ -255,16 +252,30 @@ FROM (
255
252
  UNION ALL
256
253
  SELECT 'wal_archive_mode', wal_archive_mode FROM wal_health
257
254
  UNION ALL
258
- SELECT 'wal_file_count', wal_file_count FROM wal_health
259
- UNION ALL
260
- SELECT 'wal_directory_size_bytes', wal_directory_size_bytes FROM wal_health
261
- UNION ALL
262
- SELECT 'default_tablespace_size_bytes', default_tablespace_size_bytes FROM capacity_health
263
- UNION ALL
264
255
  SELECT 'database_write_activity_rows', database_write_activity_rows FROM capacity_health
265
256
  ) metrics
266
257
  ORDER BY metric;
267
258
  `.trim();
259
+ export const POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES = [
260
+ {
261
+ id: 'wal-directory',
262
+ query: `
263
+ SELECT metric || '|' || value
264
+ FROM (
265
+ SELECT 'wal_file_count'::text AS metric, COALESCE((SELECT COUNT(*) FROM pg_ls_waldir()), 0)::text AS value
266
+ UNION ALL
267
+ SELECT 'wal_directory_size_bytes', COALESCE((SELECT SUM(size) FROM pg_ls_waldir()), 0)::text
268
+ ) metrics
269
+ ORDER BY metric;
270
+ `.trim(),
271
+ },
272
+ {
273
+ id: 'default-tablespace',
274
+ query: `
275
+ SELECT 'default_tablespace_size_bytes'::text || '|' || COALESCE((SELECT pg_tablespace_size('pg_default')), 0)::text;
276
+ `.trim(),
277
+ },
278
+ ];
268
279
  export const POSTGRES_DIAGNOSTIC_KEYS = [
269
280
  'database_count',
270
281
  'active_connections',
@@ -1,6 +1,6 @@
1
1
  import { defaultOdooVersion, readEnvironmentMetadata, replaceSourceRepos } from './environment.js';
2
2
  import { realGit, stageAll } from './git.js';
3
- import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
3
+ import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceManifestPath, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
4
4
  function cloneSourceEntries(entries) {
5
5
  return entries.map((entry) => ({
6
6
  ...entry,
@@ -47,6 +47,95 @@ export function sourceSyncJson(entries, target) {
47
47
  sources: cloneSourceEntries(entries),
48
48
  };
49
49
  }
50
+ function sourceKey(entry) {
51
+ return `${entry.type}/${entry.path}`;
52
+ }
53
+ function sourceMap(entries) {
54
+ return new Map(entries.map((entry) => [sourceKey(entry), entry]));
55
+ }
56
+ function addonsEqual(left, right) {
57
+ return left.length === right.length && left.every((value, index) => value === right[index]);
58
+ }
59
+ function sourceDriftChanges(file, currentEntries, nextEntries) {
60
+ const changes = [];
61
+ const current = sourceMap(currentEntries);
62
+ const next = sourceMap(nextEntries);
63
+ for (const [key, nextEntry] of next) {
64
+ const currentEntry = current.get(key);
65
+ if (!currentEntry) {
66
+ changes.push({ file, kind: 'add', source: key, after: cloneSourceEntries([nextEntry])[0] });
67
+ continue;
68
+ }
69
+ for (const field of ['url', 'branch']) {
70
+ const before = currentEntry[field] ?? '';
71
+ const after = nextEntry[field] ?? '';
72
+ if (before !== after) {
73
+ changes.push({ file, kind: 'update', source: key, field, before, after });
74
+ }
75
+ }
76
+ if (!addonsEqual(currentEntry.addons, nextEntry.addons)) {
77
+ changes.push({
78
+ file,
79
+ kind: 'update',
80
+ source: key,
81
+ field: 'addons',
82
+ before: [...currentEntry.addons],
83
+ after: [...nextEntry.addons],
84
+ });
85
+ }
86
+ }
87
+ for (const [key, currentEntry] of current) {
88
+ if (!next.has(key)) {
89
+ changes.push({ file, kind: 'remove', source: key, before: cloneSourceEntries([currentEntry])[0] });
90
+ }
91
+ }
92
+ return changes;
93
+ }
94
+ export async function sourceSyncPlan(target) {
95
+ const metadata = await readEnvironmentMetadata(target);
96
+ const manifest = await readSourceManifest(target);
97
+ const gitmodules = await listGitmoduleSources(target);
98
+ const fallbackBranch = metadata?.odooVersion ?? defaultOdooVersion;
99
+ const baseRepos = metadata?.sourceRepos.length ? metadata.sourceRepos : sourceReposFromManifest(manifest.sources);
100
+ const sources = syncManifestFromMetadataAndGitmodules(baseRepos, fallbackBranch, gitmodules);
101
+ return {
102
+ target,
103
+ sources,
104
+ changes: sourceDriftChanges(sourceManifestPath, manifest.sources, sources),
105
+ };
106
+ }
107
+ function renderValue(value) {
108
+ if (Array.isArray(value))
109
+ return `[${value.join(', ')}]`;
110
+ if (typeof value === 'object' && value)
111
+ return JSON.stringify(value);
112
+ return `"${value ?? ''}"`;
113
+ }
114
+ export function renderSourceSyncPlan(plan) {
115
+ if (plan.changes.length === 0) {
116
+ return 'Source manifest already in sync.';
117
+ }
118
+ return plan.changes
119
+ .map((change) => {
120
+ if (change.kind === 'add')
121
+ return `source manifest add ${change.source}`;
122
+ if (change.kind === 'remove')
123
+ return `source manifest remove ${change.source}`;
124
+ return `source manifest update ${change.source} ${change.field}: ${renderValue(change.before)} -> ${renderValue(change.after)}`;
125
+ })
126
+ .join('\n');
127
+ }
128
+ export function sourceSyncPlanJson(plan) {
129
+ return {
130
+ schemaVersion: 1,
131
+ command: 'source sync preview',
132
+ ok: true,
133
+ target: plan.target,
134
+ dryRun: true,
135
+ sources: cloneSourceEntries(plan.sources),
136
+ changes: plan.changes.map((change) => ({ ...change })),
137
+ };
138
+ }
50
139
  export async function syncSources(options, git = realGit) {
51
140
  const metadata = await readEnvironmentMetadata(options.target);
52
141
  const manifest = await readSourceManifest(options.target);
package/dist/templates.js CHANGED
@@ -208,8 +208,10 @@ resources/odoo/entrypoint.sh
208
208
 
209
209
  Development uses compose.yaml plus compose/dev.yaml by default.
210
210
  Set WPMOO_ENV=stage or WPMOO_ENV=prod only after providing production-grade secrets and volumes.
211
- In WPMOO_ENV=prod, module lifecycle commands such as install, update, and test
212
- require WPMOO_ALLOW_PROD_LIFECYCLE=1. Destructive database commands such as
211
+ In WPMOO_ENV=stage, lifecycle commands such as install, update, stop, and
212
+ restart require WPMOO_ALLOW_STAGE_LIFECYCLE=1. In WPMOO_ENV=prod, lifecycle
213
+ commands such as install, update, test, stop, and restart require
214
+ WPMOO_ALLOW_PROD_LIFECYCLE=1. Destructive database commands such as
213
215
  resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage
214
216
  and prod. restore-snapshot --dry-run remains available for preview.
215
217
  For short-lived local approvals, add JSONL entries to \`.wpmoo/approvals.jsonl\`;
@@ -725,6 +727,84 @@ list_snapshots() {
725
727
  fi
726
728
  }
727
729
 
730
+ list_snapshots_json() {
731
+ node --input-type=module <<'NODE'
732
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
733
+ import { join } from 'node:path';
734
+
735
+ const directories = ['backups/snapshots', 'backups', 'backup', 'snapshots'];
736
+ const extensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
737
+ const now = Date.now();
738
+
739
+ function snapshotStem(file) {
740
+ return file
741
+ .replace(/\\.filestore\\.tar\\.gz$/, '')
742
+ .replace(/\\.sql\\.gz$/, '')
743
+ .replace(/\\.tar\\.gz$/, '')
744
+ .replace(/\\.dump$/, '')
745
+ .replace(/\\.sql$/, '')
746
+ .replace(/\\.zip$/, '')
747
+ .replace(/\\.tar$/, '');
748
+ }
749
+
750
+ function hasSnapshotExtension(file) {
751
+ return extensions.some((extension) => file.endsWith(extension));
752
+ }
753
+
754
+ function readManifest(path) {
755
+ if (!existsSync(path)) return undefined;
756
+ try {
757
+ return JSON.parse(readFileSync(path, 'utf8'));
758
+ } catch {
759
+ return undefined;
760
+ }
761
+ }
762
+
763
+ function manifestString(manifest, key) {
764
+ return manifest && typeof manifest[key] === 'string' ? manifest[key] : undefined;
765
+ }
766
+
767
+ const snapshots = [];
768
+ for (const directory of directories) {
769
+ if (!existsSync(directory)) continue;
770
+
771
+ for (const file of readdirSync(directory).sort()) {
772
+ if (file.endsWith('.filestore.tar.gz') || !hasSnapshotExtension(file)) continue;
773
+
774
+ const dumpPath = join(directory, file);
775
+ const stats = statSync(dumpPath);
776
+ if (!stats.isFile()) continue;
777
+
778
+ const name = snapshotStem(file);
779
+ const manifestPath = join(directory, name + '.json');
780
+ const manifest = readManifest(manifestPath);
781
+ const manifestDump = manifestString(manifest, 'dump');
782
+ const manifestFilestore = manifestString(manifest, 'filestore');
783
+ const filestorePath = join(directory, manifestFilestore || name + '.filestore.tar.gz');
784
+ const createdAtMs = Date.parse(manifestString(manifest, 'created_at') || '');
785
+ const effectiveCreatedAtMs = Number.isFinite(createdAtMs) ? createdAtMs : stats.mtimeMs;
786
+
787
+ snapshots.push({
788
+ name: manifestString(manifest, 'name') || name,
789
+ path: dumpPath,
790
+ dumpPath: manifestDump ? join(directory, manifestDump) : dumpPath,
791
+ ...(existsSync(manifestPath) ? { manifestPath } : {}),
792
+ ...(manifestString(manifest, 'database') ? { databaseName: manifestString(manifest, 'database') } : {}),
793
+ createdAtMs: effectiveCreatedAtMs,
794
+ createdAt: new Date(effectiveCreatedAtMs).toISOString(),
795
+ mtimeMs: stats.mtimeMs,
796
+ ageMs: Math.max(0, now - effectiveCreatedAtMs),
797
+ filestorePath,
798
+ filestoreStatus: existsSync(filestorePath) ? 'found' : 'missing',
799
+ });
800
+ }
801
+ }
802
+
803
+ snapshots.sort((left, right) => right.createdAtMs - left.createdAtMs || left.path.localeCompare(right.path));
804
+ console.log(JSON.stringify({ schemaVersion: 1, command: 'snapshot list', ok: true, snapshots }, null, 2));
805
+ NODE
806
+ }
807
+
728
808
  require_recent_snapshot_or_override() {
729
809
  local command="$1"
730
810
  local env_name
@@ -834,6 +914,8 @@ case "$command" in
834
914
  "stop")
835
915
  shift
836
916
  require_no_args "$command" "$@"
917
+ require_stage_lifecycle_allowed "$command"
918
+ require_prod_lifecycle_allowed "$command"
837
919
  run_script ./scripts/down.sh
838
920
  ;;
839
921
  "logs")
@@ -854,6 +936,8 @@ case "$command" in
854
936
  "restart")
855
937
  shift
856
938
  require_no_args "$command" "$@"
939
+ require_stage_lifecycle_allowed "$command"
940
+ require_prod_lifecycle_allowed "$command"
857
941
  run_script ./scripts/restart.sh
858
942
  ;;
859
943
  "shell")
@@ -898,10 +982,32 @@ case "$command" in
898
982
  ;;
899
983
  "snapshot")
900
984
  shift
901
- if [[ "\${1:-}" == "--list" ]]; then
902
- shift
903
- require_no_args "$command" "$@"
904
- list_snapshots
985
+ if [[ "\${1:-}" == "--list" || "\${1:-}" == "--json" ]]; then
986
+ list_requested=0
987
+ json_requested=0
988
+ while [[ "$#" -gt 0 ]]; do
989
+ case "$1" in
990
+ "--list")
991
+ list_requested=1
992
+ shift
993
+ ;;
994
+ "--json")
995
+ json_requested=1
996
+ shift
997
+ ;;
998
+ *)
999
+ fail_usage "$command"
1000
+ ;;
1001
+ esac
1002
+ done
1003
+ if [[ "$list_requested" -ne 1 ]]; then
1004
+ fail_usage "$command"
1005
+ fi
1006
+ if [[ "$json_requested" -eq 1 ]]; then
1007
+ list_snapshots_json
1008
+ else
1009
+ list_snapshots
1010
+ fi
905
1011
  exit 0
906
1012
  fi
907
1013
  positional_args "$command" 0 2 "$@"
@@ -65,8 +65,9 @@ Ready:
65
65
 
66
66
  Ready:
67
67
 
68
- - Stage `install` and `update` require `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
69
- - Production `install`, `update`, and `test` require
68
+ - Stage `install`, `update`, `stop`, and `restart` require
69
+ `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
70
+ - Production `install`, `update`, `test`, `stop`, and `restart` require
70
71
  `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
71
72
  - Destructive database commands require `WPMOO_ALLOW_DESTRUCTIVE=1` in stage
72
73
  and production.
@@ -79,8 +79,8 @@ Current JSON contract notes:
79
79
  | Variable | Purpose |
80
80
  | --- | --- |
81
81
  | `WPMOO_ENV=dev|stage|prod` | Selects environment safety policy. Missing value behaves like development for local workflows. |
82
- | `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Allows `install` and `update` in stage. |
83
- | `WPMOO_ALLOW_PROD_LIFECYCLE=1` | Allows `install`, `update`, and `test` in production. |
82
+ | `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Allows `install`, `update`, `stop`, and `restart` in stage. |
83
+ | `WPMOO_ALLOW_PROD_LIFECYCLE=1` | Allows `install`, `update`, `test`, `stop`, and `restart` in production. |
84
84
  | `WPMOO_ALLOW_DESTRUCTIVE=1` | Allows destructive database commands in stage/prod. |
85
85
  | `WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1` | Allows destructive commands without a recent snapshot when that extra guard applies. |
86
86
  | `WPMOO_ALLOW_MIGRATIONS=1` | Allows lifecycle commands when migration scripts are detected. |
@@ -121,6 +121,7 @@ approval. Expired, malformed, or mismatched entries are ignored.
121
121
  | Command Family | Stage | Production |
122
122
  | --- | --- | --- |
123
123
  | `install`, `update` | Requires `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
124
+ | `stop`, `restart` | Requires `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
124
125
  | `test` | Allowed | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
125
126
  | `resetdb`, real `restore-snapshot` | Requires `WPMOO_ALLOW_DESTRUCTIVE=1` | Requires `WPMOO_ALLOW_DESTRUCTIVE=1` |
126
127
  | `restore-snapshot --dry-run` | Allowed | Allowed |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.30",
3
+ "version": "0.9.31",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {