@wpmoo/toolkit 0.9.30 → 0.9.32

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.
@@ -181,6 +181,9 @@ npx @wpmoo/toolkit doctor --json --postgres
181
181
  ```
182
182
 
183
183
  JSON output is optional; human-readable output remains the default.
184
+ `doctor --json --fix` is intentionally unsupported because `doctor --fix` may
185
+ write safe file-level repairs. Run `doctor --fix` first, then `doctor --json`
186
+ to inspect the post-fix state.
184
187
  Human `doctor` output is grouped into stable sections (`Generated files`,
185
188
  `Compose`, `Source repositories`, `PostgreSQL`, and `Host tools`) so terminal
186
189
  operators can see which lifecycle layer needs attention first.
@@ -212,6 +215,10 @@ JSON variant exposes a versioned PostgreSQL diagnostics contract.
212
215
  `doctor --json --postgres` keeps the JSON contract stable by versioning the
213
216
  `postgres` payload; individual fields are optional so automation can safely handle
214
217
  environments where PostgreSQL does not expose a metric.
218
+ Optional PostgreSQL probe permission failures appear under
219
+ `postgres.optionalProbeFailures` when available; they do not make
220
+ `postgres.available` false and can be ignored by automation that only needs core
221
+ diagnostics.
215
222
  All `doctor --json` reports also include optional `sections` entries that group
216
223
  checks, warnings, and errors without changing the legacy flat arrays.
217
224
 
@@ -244,7 +251,9 @@ warning while keeping the scoped package release valid.
244
251
  rejects it, the release still succeeds as long as the three required scoped
245
252
  packages are valid.
246
253
  - **Smoke expectation**: run `npm run smoke:published -- "$VERSION"` after the
247
- release tag workflow completes.
254
+ release tag workflow completes. The script prints `Smoke step:` progress lines
255
+ before each published CLI check so slow registry-backed `npx` resolution is
256
+ visible.
248
257
  - **Deterministic smoke target**: pin the target package explicitly so smoke checks
249
258
  are reproducible across reruns:
250
259
 
@@ -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,62 @@ 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
+ function optionalPostgresProbeFailureWarning(error) {
119
+ const message = commandErrorText(error).trim();
120
+ if (!/(?:permission denied|insufficient privilege|must be superuser|not permitted|not allowed)/iu.test(message)) {
121
+ return undefined;
122
+ }
123
+ return message.split(/\r?\n/u).find((line) => line.trim())?.trim();
124
+ }
125
+ async function readPostgresDiagnosticQuery(target, runner, query, timeoutMs) {
126
+ const queryLiteral = JSON.stringify(query);
91
127
  const command = [
92
128
  `query=${queryLiteral}`,
93
129
  '. ./scripts/lib.sh >/dev/null',
94
130
  'compose exec -T db psql -X -q -t -A -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -c "$query"',
95
131
  ].join(' && ');
96
- const result = await runner('bash', ['-lc', command], { cwd: target });
132
+ const result = await withPostgresDiagnosticsTimeout(runner('bash', ['-lc', command], { cwd: target }), timeoutMs);
97
133
  return parsePostgresDiagnostics(result.stdout);
98
134
  }
135
+ async function readPostgresDiagnostics(target, runner, timeoutMs) {
136
+ const diagnostics = await readPostgresDiagnosticQuery(target, runner, POSTGRES_DIAGNOSTICS_QUERY, timeoutMs);
137
+ const optionalProbeFailures = [];
138
+ for (const probe of POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES) {
139
+ try {
140
+ Object.assign(diagnostics, await readPostgresDiagnosticQuery(target, runner, probe.query, timeoutMs));
141
+ }
142
+ catch (error) {
143
+ const warning = optionalPostgresProbeFailureWarning(error);
144
+ if (warning) {
145
+ optionalProbeFailures.push({ id: probe.id, warning });
146
+ }
147
+ // Optional probes use PostgreSQL functions that can require elevated roles.
148
+ // Their failure must not hide the core read-only health report.
149
+ }
150
+ }
151
+ return { diagnostics, optionalProbeFailures };
152
+ }
99
153
  function stripInlineComment(line) {
100
154
  const hashIndex = line.indexOf('#');
101
155
  if (hashIndex === -1)
@@ -254,10 +308,14 @@ const doctorSectionDefinitions = [
254
308
  { id: 'generated-files', title: 'Generated files' },
255
309
  { id: 'compose', title: 'Compose' },
256
310
  { id: 'source-repositories', title: 'Source repositories' },
311
+ { id: 'module-quality', title: 'Module quality' },
257
312
  { id: 'postgresql', title: 'PostgreSQL' },
258
313
  { id: 'host-tools', title: 'Host tools' },
259
314
  ];
260
315
  function doctorSectionForLine(line) {
316
+ if (line.startsWith('OK module quality') || line.startsWith('Module quality advisory:')) {
317
+ return 'module-quality';
318
+ }
261
319
  if (line.includes('PostgreSQL diagnostics') ||
262
320
  line.includes('PostgreSQL connection') ||
263
321
  line.includes('PostgreSQL slow-query') ||
@@ -521,6 +579,14 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
521
579
  errors.push(`Missing source repo path: ${relativePath}`);
522
580
  }
523
581
  checks.push(`OK source repos ${sourceRepos.length} checked`);
582
+ let moduleQuality = emptyModuleQualitySummary();
583
+ for (const repo of sourceRepos) {
584
+ const sourceType = normalizeSourceType(repo.sourceType);
585
+ const repoRoot = join(target, sourceRepoPath(sourceType, repo.path));
586
+ moduleQuality = mergeModuleQualitySummaries(moduleQuality, await scanModuleQuality(repoRoot, target));
587
+ }
588
+ checks.push(`OK module quality ${moduleQuality.totalModules} module${moduleQuality.totalModules === 1 ? '' : 's'} scanned`);
589
+ warnings.push(...moduleQuality.issues.map((issue) => `Module quality advisory: ${issue.path}: ${issue.issue}`));
524
590
  const manifestPath = join(target, sourceManifestPath);
525
591
  const hasManifest = await exists(manifestPath);
526
592
  let manifestEntries = [];
@@ -574,7 +640,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
574
640
  }
575
641
  if (actualOptions.postgres) {
576
642
  try {
577
- const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner);
643
+ const { diagnostics: postgresDiagnostics, optionalProbeFailures } = await readPostgresDiagnostics(target, actualRunner, postgresDiagnosticsTimeoutMs(actualOptions));
578
644
  const missingKeys = missingPostgresDiagnosticKeys(postgresDiagnostics);
579
645
  const malformedKeys = malformedPostgresDiagnosticKeys(postgresDiagnostics);
580
646
  if (missingKeys.length === 0 && malformedKeys.length === 0) {
@@ -587,6 +653,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
587
653
  contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
588
654
  available: true,
589
655
  diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
656
+ ...(optionalProbeFailures.length > 0 ? { optionalProbeFailures } : {}),
590
657
  };
591
658
  warnings.push(...postgresPostgresWarnings(postgresDiagnostics));
592
659
  }
@@ -600,6 +667,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
600
667
  contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
601
668
  available: false,
602
669
  diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
670
+ ...(optionalProbeFailures.length > 0 ? { optionalProbeFailures } : {}),
603
671
  warning,
604
672
  };
605
673
  }
@@ -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.
@@ -161,6 +161,7 @@ Machine-readable JSON output:
161
161
  npx @wpmoo/toolkit source sync --json
162
162
  npx @wpmoo/toolkit doctor --json
163
163
  doctor --json --postgres includes a structured postgres object for automation.
164
+ doctor --json is read-only; run doctor --fix first, then doctor --json for post-fix state.
164
165
  Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
165
166
 
166
167
  JSON compatibility policy:
@@ -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.
@@ -66,9 +66,15 @@ Current JSON contract notes:
66
66
  - `status --json` uses `schemaVersion: 1`.
67
67
  - `source list --json` and `source sync --json` use `schemaVersion: 1`.
68
68
  - `doctor --json` uses `schemaVersion: 1`.
69
+ - `doctor --json --fix` is intentionally unsupported because `doctor --fix`
70
+ may mutate files. Run `doctor --fix` first, then `doctor --json` to inspect
71
+ post-fix state.
69
72
  - `doctor --json --postgres` adds `postgres.contractVersion` and a PostgreSQL
70
73
  diagnostics object with its own `schemaVersion`.
71
74
  - PostgreSQL fields are optional when a metric is unavailable.
75
+ - Optional privileged PostgreSQL probe failures may appear under
76
+ `postgres.optionalProbeFailures`; core diagnostics remain available when those
77
+ optional probes fail with permission errors.
72
78
  - Automation should ignore unknown JSON fields.
73
79
  - Minor and patch releases may add optional fields without a breaking release.
74
80
  - Removing, renaming, or changing the meaning of a documented field requires a
@@ -79,8 +85,8 @@ Current JSON contract notes:
79
85
  | Variable | Purpose |
80
86
  | --- | --- |
81
87
  | `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. |
88
+ | `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Allows `install`, `update`, `stop`, and `restart` in stage. |
89
+ | `WPMOO_ALLOW_PROD_LIFECYCLE=1` | Allows `install`, `update`, `test`, `stop`, and `restart` in production. |
84
90
  | `WPMOO_ALLOW_DESTRUCTIVE=1` | Allows destructive database commands in stage/prod. |
85
91
  | `WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1` | Allows destructive commands without a recent snapshot when that extra guard applies. |
86
92
  | `WPMOO_ALLOW_MIGRATIONS=1` | Allows lifecycle commands when migration scripts are detected. |
@@ -121,6 +127,7 @@ approval. Expired, malformed, or mismatched entries are ignored.
121
127
  | Command Family | Stage | Production |
122
128
  | --- | --- | --- |
123
129
  | `install`, `update` | Requires `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
130
+ | `stop`, `restart` | Requires `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
124
131
  | `test` | Allowed | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
125
132
  | `resetdb`, real `restore-snapshot` | Requires `WPMOO_ALLOW_DESTRUCTIVE=1` | Requires `WPMOO_ALLOW_DESTRUCTIVE=1` |
126
133
  | `restore-snapshot --dry-run` | Allowed | Allowed |
package/docs/handoff.md CHANGED
@@ -70,6 +70,9 @@ pre-existing global `NPM_CONFIG_CACHE` state unless you intentionally reuse it.
70
70
 
71
71
  The smoke script checks `--version`, top-level `--help`, and critical command
72
72
  help output before optional generated-environment acceptance smoke.
73
+ Long registry-backed smoke runs print `Smoke step:` progress lines before each
74
+ published CLI check, so a slow `npx` or `npm exec` resolution is visible in the
75
+ terminal instead of appearing stuck.
73
76
 
74
77
  For a 1.0.0 tag, run generated-environment acceptance smoke with
75
78
  WPMOO_SMOKE_ENVIRONMENT=1. Treat the release as final only after that smoke
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.30",
3
+ "version": "0.9.32",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {