@wpmoo/toolkit 0.9.8 → 0.9.10

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
@@ -58,15 +58,20 @@ Run the guided wizard from the workspace where you keep Odoo projects:
58
58
  npx @wpmoo/toolkit
59
59
  ```
60
60
 
61
- Short aliases are also available:
61
+ Short alias:
62
62
 
63
63
  ```bash
64
64
  npx wpmoo
65
+ ```
66
+
67
+ Deprecated compatibility aliases:
68
+
69
+ ```bash
65
70
  npx @wpmoo/odoo
66
71
  npx @wpmoo/odoo-dev
67
72
  ```
68
73
 
69
- Legacy package paths `npx @wpmoo/odoo` and `npx @wpmoo/odoo-dev` remain available for compatibility.
74
+ Deprecated package paths `npx @wpmoo/odoo` and `npx @wpmoo/odoo-dev` remain available as compatibility aliases that redirect to `@wpmoo/toolkit`.
70
75
 
71
76
  When the current directory is not already a WPMoo environment, the CLI opens the create flow. It asks for a product slug, Odoo version, and environment folder. The default environment folder is `./<product>_dev`.
72
77
 
@@ -139,6 +144,10 @@ Every cockpit action maps to a direct command, so the same workflow can be used
139
144
  ./moo restore-snapshot --dry-run before-update devel
140
145
  ```
141
146
 
147
+ In `WPMOO_ENV=prod`, `install`, `update`, and `test` require `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
148
+ `resetdb` and real `restore-snapshot` require `WPMOO_ALLOW_DESTRUCTIVE=1` in `stage` and `prod`.
149
+ `restore-snapshot --dry-run` remains allowed for preview.
150
+
142
151
  Module source actions also have direct commands. Default is `private`; pass `--source-type oca` or `--source-type external` for non-private source repositories:
143
152
 
144
153
  ```bash
@@ -146,6 +155,8 @@ npx @wpmoo/toolkit add-module --repo sale-workflow --module sale_order_line_no_d
146
155
  npx @wpmoo/toolkit remove-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
147
156
  ```
148
157
 
158
+ `add-module` creates a minimal Odoo module skeleton with `__init__.py`, `__manifest__.py`, `models/<module>.py`, `models/__init__.py`, `security/ir.model.access.csv`, `views/<module>_views.xml`, `views/<module>_menus.xml`, and `tests/test_<module>.py`. The view XML adds list/tree and form views; the menu XML adds a basic Odoo action and menu entry; the test skeleton adds a post-install TransactionCase smoke test. Module names must be lower `snake_case`; use letters, numbers, and underscores only.
159
+
149
160
  For automation and VS Code cockpit integration, selected commands support JSON output:
150
161
 
151
162
  ```bash
@@ -153,9 +164,17 @@ npx @wpmoo/toolkit status --json
153
164
  npx @wpmoo/toolkit source list --json
154
165
  npx @wpmoo/toolkit source sync --json
155
166
  npx @wpmoo/toolkit doctor --json
167
+ npx @wpmoo/toolkit doctor --postgres
168
+ npx @wpmoo/toolkit doctor --json --postgres
156
169
  ```
157
170
 
158
171
  JSON output is optional; human-readable output remains the default.
172
+ `doctor --postgres` adds read-only PostgreSQL health and performance diagnostics
173
+ such as database size, sessions currently running queries with
174
+ `pg_stat_activity.state = 'active'`, slow-query readiness, extension visibility,
175
+ and settings.
176
+ `doctor --json --postgres` includes a structured `postgres` object for automation.
177
+ Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
159
178
 
160
179
  ## Documentation
161
180
 
package/dist/cli.js CHANGED
@@ -42,6 +42,7 @@ import { environmentStatusJson, getEnvironmentStatus, renderEnvironmentStatusFor
42
42
  import { getGitHubAccounts, getGitHubRepositoryStatus, githubRepositoryUrl, realGitHub, createGitHubRepository, } from './github.js';
43
43
  import { environmentGitHubOwner } from './environment-context.js';
44
44
  import { handlePromptCancel, handleUnavailableMenuChoice, installPromptCancelKeyTracker, isMenuBackSignal, MenuBackSignal, menuIntroTitle, menuPromptMessage, } from './menu-navigation.js';
45
+ import { validateModuleName } from './path-validation.js';
45
46
  function handleCancel(value, action) {
46
47
  handlePromptCancel(isPromptCancel(value), action);
47
48
  }
@@ -635,6 +636,15 @@ async function selectSourceRepo(target, cancelAction = 'exit') {
635
636
  function suggestedModuleName(repoPath) {
636
637
  return 'odoo_sample_module';
637
638
  }
639
+ function validateModuleNameInput(value) {
640
+ try {
641
+ validateModuleName(value);
642
+ return undefined;
643
+ }
644
+ catch (error) {
645
+ return error instanceof Error ? error.message : 'Invalid module name.';
646
+ }
647
+ }
638
648
  async function addModuleOptionsFromArgs(argv) {
639
649
  const { values } = parseArgs(argv);
640
650
  const repoPath = stringOption(values, 'repo') ?? stringOption(values, 'sourcePath');
@@ -659,7 +669,7 @@ async function addModuleOptionsFromPrompts(showIntro = true, cancelAction = 'exi
659
669
  const moduleName = asString(await textPrompt({
660
670
  message: menuPromptMessage('Module name', cancelAction),
661
671
  placeholder: suggestedModuleName(sourceRepo.repoPath),
662
- validate: (value) => (value.trim() ? undefined : 'Enter the module technical name.'),
672
+ validate: validateModuleNameInput,
663
673
  }), suggestedModuleName(sourceRepo.repoPath), cancelAction);
664
674
  return {
665
675
  target,
@@ -694,7 +704,7 @@ function resetCommandOptionsFromArgs(argv) {
694
704
  function doctorOptionsFromArgs(argv) {
695
705
  const { values } = parseArgs(argv);
696
706
  const keys = Object.keys(values);
697
- const allowedKeys = new Set(['fix', 'json']);
707
+ const allowedKeys = new Set(['fix', 'json', 'postgres']);
698
708
  if (!keys.every((key) => allowedKeys.has(key))) {
699
709
  throw new Error('Usage: wpmoo doctor');
700
710
  }
@@ -704,6 +714,9 @@ function doctorOptionsFromArgs(argv) {
704
714
  if (Object.hasOwn(values, 'fix')) {
705
715
  options.fix = booleanOption(values, 'fix', false);
706
716
  }
717
+ if (Object.hasOwn(values, 'postgres')) {
718
+ options.postgres = booleanOption(values, 'postgres', false);
719
+ }
707
720
  return options;
708
721
  }
709
722
  function sourceUsage() {
@@ -1554,12 +1567,17 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1554
1567
  if (options.fix !== undefined) {
1555
1568
  doctorOptions.fix = options.fix;
1556
1569
  }
1570
+ if (options.postgres !== undefined) {
1571
+ doctorOptions.postgres = options.postgres;
1572
+ }
1557
1573
  if (options.json) {
1558
1574
  printJson(await getDoctorReport(cwd, doctorOptions));
1559
1575
  return;
1560
1576
  }
1561
1577
  console.log(renderBanner());
1562
- console.log(options.fix === undefined ? await runDoctor(cwd) : await runDoctor(cwd, doctorOptions));
1578
+ console.log(options.fix === undefined && options.postgres === undefined
1579
+ ? await runDoctor(cwd)
1580
+ : await runDoctor(cwd, doctorOptions));
1563
1581
  return;
1564
1582
  }
1565
1583
  if (route.command === 'status') {
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { access } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
4
5
  import { markerPath } from './environment.js';
5
6
  export const dailyActionCommands = [
6
7
  'start',
@@ -149,6 +150,48 @@ function scriptArgs(command, argv) {
149
150
  return ensureNoArgs(command, argv);
150
151
  return positionalArgs(command, argv, 1, 3);
151
152
  }
153
+ function isDestructiveCommand(command, args) {
154
+ if (command === 'resetdb')
155
+ return true;
156
+ return command === 'restore-snapshot' && args[0] !== '--dry-run';
157
+ }
158
+ function isProductionLifecycleCommand(command) {
159
+ return command === 'install' || command === 'update' || command === 'test';
160
+ }
161
+ function destructiveCommandError(command, envName) {
162
+ return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
163
+ }
164
+ function productionLifecycleCommandError(command) {
165
+ return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
166
+ }
167
+ async function assertDestructiveCommandAllowed(command, args, cwd) {
168
+ if (!isDestructiveCommand(command, args)) {
169
+ return;
170
+ }
171
+ const env = await readEnvFile(cwd);
172
+ const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
173
+ if (envName !== 'stage' && envName !== 'prod') {
174
+ return;
175
+ }
176
+ const allowDestructive = process.env.WPMOO_ALLOW_DESTRUCTIVE?.trim() || env?.get('WPMOO_ALLOW_DESTRUCTIVE')?.trim();
177
+ if (allowDestructive !== '1') {
178
+ throw new Error(destructiveCommandError(command, envName));
179
+ }
180
+ }
181
+ async function assertProductionLifecycleCommandAllowed(command, cwd) {
182
+ if (!isProductionLifecycleCommand(command)) {
183
+ return;
184
+ }
185
+ const env = await readEnvFile(cwd);
186
+ const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
187
+ if (envName !== 'prod') {
188
+ return;
189
+ }
190
+ const allowProdLifecycle = process.env.WPMOO_ALLOW_PROD_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_PROD_LIFECYCLE')?.trim();
191
+ if (allowProdLifecycle !== '1') {
192
+ throw new Error(productionLifecycleCommandError(command));
193
+ }
194
+ }
152
195
  async function assertEnvironmentRoot(cwd) {
153
196
  try {
154
197
  await access(join(cwd, markerPath));
@@ -170,10 +213,13 @@ async function assertScriptExists(cwd, script) {
170
213
  export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
171
214
  await assertEnvironmentRoot(cwd);
172
215
  const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
216
+ const args = scriptArgs(command, argv);
217
+ await assertProductionLifecycleCommandAllowed(command, cwd);
218
+ await assertDestructiveCommandAllowed(command, args, cwd);
173
219
  return {
174
220
  cwd,
175
221
  scriptPath,
176
- args: scriptArgs(command, argv),
222
+ args,
177
223
  };
178
224
  }
179
225
  async function spawnDailyAction(plan) {
package/dist/doctor.js CHANGED
@@ -46,6 +46,58 @@ function isMetadataError(message) {
46
46
  message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
47
47
  }
48
48
  const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
49
+ const postgresDiagnosticQuery = `
50
+ WITH metrics(metric, value) AS (
51
+ SELECT 'database_count', count(*)::text
52
+ FROM pg_database
53
+ WHERE datistemplate = false
54
+ UNION ALL
55
+ SELECT 'active_connections', count(*)::text
56
+ FROM pg_stat_activity
57
+ WHERE datname IS NOT NULL
58
+ AND state = 'active'
59
+ UNION ALL
60
+ SELECT 'total_database_size_bytes', COALESCE(sum(pg_database_size(datname)), 0)::text
61
+ FROM pg_database
62
+ WHERE datistemplate = false
63
+ UNION ALL
64
+ SELECT 'slow_query_logging', COALESCE(
65
+ (SELECT setting || unit FROM pg_settings WHERE name = 'log_min_duration_statement'),
66
+ 'unavailable'
67
+ )
68
+ UNION ALL
69
+ SELECT 'pg_stat_statements',
70
+ CASE
71
+ WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') THEN 'installed'
72
+ WHEN EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements') THEN 'available'
73
+ ELSE 'unavailable'
74
+ END
75
+ UNION ALL
76
+ SELECT 'shared_buffers', COALESCE(
77
+ (SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
78
+ 'unavailable'
79
+ )
80
+ )
81
+ SELECT metric || '|' || value
82
+ FROM metrics
83
+ ORDER BY CASE metric
84
+ WHEN 'database_count' THEN 1
85
+ WHEN 'active_connections' THEN 2
86
+ WHEN 'total_database_size_bytes' THEN 3
87
+ WHEN 'slow_query_logging' THEN 4
88
+ WHEN 'pg_stat_statements' THEN 5
89
+ WHEN 'shared_buffers' THEN 6
90
+ ELSE 99
91
+ END;
92
+ `.trim();
93
+ const postgresDiagnosticKeys = [
94
+ 'database_count',
95
+ 'active_connections',
96
+ 'total_database_size_bytes',
97
+ 'slow_query_logging',
98
+ 'pg_stat_statements',
99
+ 'shared_buffers',
100
+ ];
49
101
  function parsePostgresMajorFromValue(value) {
50
102
  if (!value)
51
103
  return undefined;
@@ -56,6 +108,82 @@ function parsePostgresMajorFromValue(value) {
56
108
  const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
57
109
  return match?.[1];
58
110
  }
111
+ function parsePostgresDiagnostics(output) {
112
+ const diagnostics = {};
113
+ const allowedKeys = new Set(postgresDiagnosticKeys);
114
+ for (const rawLine of output.split(/\r?\n/u)) {
115
+ const line = rawLine.trim();
116
+ if (!line)
117
+ continue;
118
+ const separatorIndex = line.indexOf('|');
119
+ if (separatorIndex === -1)
120
+ continue;
121
+ const key = line.slice(0, separatorIndex).trim();
122
+ const value = line.slice(separatorIndex + 1).trim();
123
+ if (allowedKeys.has(key) && value) {
124
+ diagnostics[key] = value;
125
+ }
126
+ }
127
+ return diagnostics;
128
+ }
129
+ function renderPostgresDiagnostics(diagnostics) {
130
+ const parts = postgresDiagnosticKeys.flatMap((key) => {
131
+ const value = diagnostics[key];
132
+ return value ? [`${key}=${value}`] : [];
133
+ });
134
+ return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
135
+ }
136
+ function missingPostgresDiagnosticKeys(diagnostics) {
137
+ return postgresDiagnosticKeys.filter((key) => !diagnostics[key]);
138
+ }
139
+ function unavailablePostgresDiagnosticsWarning(diagnostics, missingKeys) {
140
+ return Object.keys(diagnostics).length === 0
141
+ ? 'no diagnostic rows returned'
142
+ : `incomplete diagnostic rows: missing ${missingKeys.join(', ')}`;
143
+ }
144
+ function integerDiagnostic(value) {
145
+ if (!value || !/^\d+$/u.test(value)) {
146
+ return undefined;
147
+ }
148
+ return Number.parseInt(value, 10);
149
+ }
150
+ function malformedPostgresDiagnosticKeys(diagnostics) {
151
+ const numericKeys = [
152
+ 'database_count',
153
+ 'active_connections',
154
+ 'total_database_size_bytes',
155
+ ];
156
+ return numericKeys.filter((key) => diagnostics[key] !== undefined && integerDiagnostic(diagnostics[key]) === undefined);
157
+ }
158
+ function structuredPostgresDiagnostics(diagnostics) {
159
+ const structured = {};
160
+ const databaseCount = integerDiagnostic(diagnostics.database_count);
161
+ const activeConnections = integerDiagnostic(diagnostics.active_connections);
162
+ const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
163
+ if (databaseCount !== undefined)
164
+ structured.databaseCount = databaseCount;
165
+ if (activeConnections !== undefined)
166
+ structured.activeConnections = activeConnections;
167
+ if (totalDatabaseSizeBytes !== undefined)
168
+ structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
169
+ if (diagnostics.slow_query_logging)
170
+ structured.slowQueryLogging = diagnostics.slow_query_logging;
171
+ if (diagnostics.pg_stat_statements)
172
+ structured.pgStatStatements = diagnostics.pg_stat_statements;
173
+ if (diagnostics.shared_buffers)
174
+ structured.sharedBuffers = diagnostics.shared_buffers;
175
+ return structured;
176
+ }
177
+ async function readPostgresDiagnostics(target, runner) {
178
+ const queryLiteral = JSON.stringify(postgresDiagnosticQuery);
179
+ const command = [
180
+ `query=${queryLiteral}`,
181
+ '. ./scripts/lib.sh >/dev/null',
182
+ 'compose exec -T db psql -X -q -t -A -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -c "$query"',
183
+ ].join(' && ');
184
+ const result = await runner('bash', ['-lc', command], { cwd: target });
185
+ return parsePostgresDiagnostics(result.stdout);
186
+ }
59
187
  function stripInlineComment(line) {
60
188
  const hashIndex = line.indexOf('#');
61
189
  if (hashIndex === -1)
@@ -441,6 +569,46 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
441
569
  checks.push(`OK .env ports HTTP_PORT=${httpPort} GEVENT_PORT=${geventPort}`);
442
570
  }
443
571
  }
572
+ if (actualOptions.postgres) {
573
+ try {
574
+ const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner);
575
+ const missingKeys = missingPostgresDiagnosticKeys(postgresDiagnostics);
576
+ const malformedKeys = malformedPostgresDiagnosticKeys(postgresDiagnostics);
577
+ if (missingKeys.length === 0 && malformedKeys.length === 0) {
578
+ const renderedPostgresDiagnostics = renderPostgresDiagnostics(postgresDiagnostics);
579
+ if (renderedPostgresDiagnostics) {
580
+ checks.push(renderedPostgresDiagnostics);
581
+ }
582
+ report.postgres = {
583
+ requested: true,
584
+ available: true,
585
+ diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
586
+ };
587
+ }
588
+ else {
589
+ const warning = malformedKeys.length > 0
590
+ ? `malformed diagnostic values: ${malformedKeys.join(', ')}`
591
+ : unavailablePostgresDiagnosticsWarning(postgresDiagnostics, missingKeys);
592
+ warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
593
+ report.postgres = {
594
+ requested: true,
595
+ available: false,
596
+ diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
597
+ warning,
598
+ };
599
+ }
600
+ }
601
+ catch (error) {
602
+ const warning = errorMessage(error);
603
+ warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
604
+ report.postgres = {
605
+ requested: true,
606
+ available: false,
607
+ diagnostics: {},
608
+ warning,
609
+ };
610
+ }
611
+ }
444
612
  try {
445
613
  await actualRunner('docker', ['version'], { cwd: target });
446
614
  checks.push('OK docker CLI');
@@ -43,6 +43,8 @@ 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.',
47
+ '# WPMOO_ALLOW_PROD_LIFECYCLE=1',
46
48
  '',
47
49
  ].join('\n');
48
50
  }
package/dist/help.js CHANGED
@@ -20,8 +20,8 @@ Usage:
20
20
  npx @wpmoo/toolkit add-module --repo <source-repo> --module <module-name> [--source-type <category>]
21
21
  npx @wpmoo/toolkit remove-module --repo <source-repo> --module <module-name> [--source-type <category>]
22
22
  npx @wpmoo/toolkit reset [--dry-run]
23
- npx @wpmoo/toolkit doctor [--fix]
24
- npx @wpmoo/toolkit doctor --json
23
+ npx @wpmoo/toolkit doctor [--fix] [--postgres]
24
+ npx @wpmoo/toolkit doctor --json [--postgres]
25
25
  npx @wpmoo/toolkit start
26
26
  npx @wpmoo/toolkit stop
27
27
  npx @wpmoo/toolkit logs [service]
@@ -58,6 +58,7 @@ Options:
58
58
  --source-type <category> Source repo category for add-repo/remove-repo/add-module/remove-module. One of private, oca, external. Default: private.
59
59
  --repo <name> Source repo folder name for repo/module actions.
60
60
  --module <name> Odoo module technical name for module actions.
61
+ Must be lower snake_case; use letters, numbers, and underscores only.
61
62
  --delete-files Also delete module files in remove-module. Default: false.
62
63
  --odoo-version <branch> Override the environment Odoo branch for add-repo/add-module.
63
64
  --source-repo-url <url> Source repo URL. Repeat for multiple repos.
@@ -67,6 +68,7 @@ Options:
67
68
  --repo-visibility <value> Visibility for created repos: private or public. Default: private.
68
69
  --init-empty-repos Initialize empty source repos with the selected branch.
69
70
  --dry-run Print planned files and commands without writing.
71
+ --postgres Include read-only PostgreSQL health/performance diagnostics in doctor.
70
72
  --stage=false Do not run git add .
71
73
  --no-update-check Skip the startup npm update check.
72
74
  --version, -v Show the package version.
@@ -75,7 +77,7 @@ Options:
75
77
  Package aliases:
76
78
  npx @wpmoo/toolkit is the official package path.
77
79
  npx wpmoo is the short alias.
78
- npx @wpmoo/odoo and npx @wpmoo/odoo-dev remain legacy compatibility paths.
80
+ npx @wpmoo/odoo and npx @wpmoo/odoo-dev remain deprecated compatibility aliases.
79
81
 
80
82
  Daily actions:
81
83
  Daily actions must be run from a generated environment root containing .wpmoo/odoo.json.
@@ -83,6 +85,11 @@ Daily actions:
83
85
  Generated environments also include ./moo for local compose commands such as ./moo start.
84
86
  Use ./moo or npx @wpmoo/toolkit with the same daily action arguments.
85
87
 
88
+ Production command guards:
89
+ In WPMOO_ENV=prod, install/update/test require WPMOO_ALLOW_PROD_LIFECYCLE=1.
90
+ resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage/prod.
91
+ restore-snapshot --dry-run remains allowed for preview.
92
+
86
93
  Cockpit:
87
94
  Run npx @wpmoo/toolkit inside a generated environment to open the cockpit.
88
95
  Use Command palette / to search slash commands across services, modules, database,
@@ -101,6 +108,10 @@ Status and doctor:
101
108
  status: fast and offline. Reads local environment metadata and files only.
102
109
  doctor: deeper health check. May check Docker CLI access and GitHub workflows.
103
110
  doctor --fix: applies safe file-level repairs. Runs doctor again after fixes.
111
+ doctor --postgres: adds read-only PostgreSQL diagnostics such as database size,
112
+ sessions currently running queries with pg_stat_activity.state = 'active',
113
+ slow-query readiness, extension visibility, and settings.
114
+ Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
104
115
 
105
116
  Task recipes:
106
117
  Create environment:
@@ -115,6 +126,9 @@ Task recipes:
115
126
  npx @wpmoo/toolkit source sync
116
127
  Add module:
117
128
  npx @wpmoo/toolkit add-module --repo <source-repo> --module <module-name> --source-type private|oca|external
129
+ Creates a minimal skeleton: __init__.py, __manifest__.py, models/<module>.py, models/__init__.py, security/ir.model.access.csv, views/<module>_views.xml, views/<module>_menus.xml, and tests/test_<module>.py.
130
+ The view XML adds list/tree and form views; the menu XML adds a basic Odoo action and menu entry; the test skeleton adds a post-install TransactionCase smoke test.
131
+ Module names must be lower snake_case; use letters, numbers, and underscores only.
118
132
  Remove module:
119
133
  npx @wpmoo/toolkit remove-module --repo <source-repo> --module <module-name> --source-type private|oca|external
120
134
  Add OCA module:
@@ -140,6 +154,8 @@ Machine-readable JSON output:
140
154
  npx @wpmoo/toolkit source list --json
141
155
  npx @wpmoo/toolkit source sync --json
142
156
  npx @wpmoo/toolkit doctor --json
157
+ doctor --json --postgres includes a structured postgres object for automation.
158
+ Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
143
159
 
144
160
  Example:
145
161
  npx @wpmoo/toolkit create \\
@@ -1,11 +1,12 @@
1
1
  import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { addModuleToSourceRepoInAddonsYaml, removeModuleFromSourceRepoInAddonsYaml, } from './addons-yaml.js';
4
- import { readEnvironmentMetadata } from './environment.js';
4
+ import { readEnvironmentMetadata, replaceSourceRepos } from './environment.js';
5
5
  import { realGit, stageAll } from './git.js';
6
6
  import { pathUnderBase, validateModuleName, validateRepoPath } from './path-validation.js';
7
7
  import { listModuleRepos, readAddonsYaml, writeAddonsYaml } from './repo-actions.js';
8
8
  import { listSources } from './source-actions.js';
9
+ import { readSourceManifest, writeSourceManifest } from './source-manifest.js';
9
10
  const sourceTypeSortOrder = ['private', 'oca', 'external'];
10
11
  const githubRepoUrlPattern = /^(?:https?:\/\/|git@)github\.com[/:]([^/]+)\/([^/.#?]+)(?:\.git)?(?:[/?#].*)?$/i;
11
12
  const validSourceTypes = ['private', 'oca', 'external'];
@@ -41,15 +42,45 @@ function titleizeModule(moduleName) {
41
42
  .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
42
43
  .join(' ');
43
44
  }
45
+ function moduleClassName(moduleName) {
46
+ const className = titleizeModule(moduleName).replace(/[^A-Za-z0-9]/g, '');
47
+ return /^[A-Za-z_]/.test(className) ? className : `X${className}`;
48
+ }
49
+ function modelTechnicalName(moduleName) {
50
+ return moduleName.replace(/[_-]+/g, '.').toLowerCase();
51
+ }
52
+ function actionViewMode(odooVersion) {
53
+ const majorVersion = Number.parseInt(odooVersion.split('.', 1)[0] ?? '', 10);
54
+ return Number.isFinite(majorVersion) && majorVersion < 18 ? 'tree,form' : 'list,form';
55
+ }
56
+ function listViewTag(odooVersion) {
57
+ const majorVersion = Number.parseInt(odooVersion.split('.', 1)[0] ?? '', 10);
58
+ return Number.isFinite(majorVersion) && majorVersion < 18 ? 'tree' : 'list';
59
+ }
60
+ function modelContent(moduleName) {
61
+ const moduleTitle = titleizeModule(moduleName);
62
+ return `from odoo import fields, models
63
+
64
+
65
+ class ${moduleClassName(moduleName)}(models.Model):
66
+ _name = "${modelTechnicalName(moduleName)}"
67
+ _description = "${moduleTitle}"
68
+
69
+ name = fields.Char(required=True, default="New")
70
+ `;
71
+ }
44
72
  function manifestContent(moduleName, odooVersion) {
73
+ const moduleTitle = titleizeModule(moduleName);
45
74
  return `{
46
- "name": "${titleizeModule(moduleName)}",
75
+ "name": "${moduleTitle}",
47
76
  "version": "${odooVersion}.1.0.0",
48
77
  "category": "Productivity",
49
- "summary": "TODO",
78
+ "summary": "${moduleTitle} module",
50
79
  "depends": ["base"],
51
80
  "data": [
52
81
  "security/ir.model.access.csv",
82
+ "views/${moduleName}_views.xml",
83
+ "views/${moduleName}_menus.xml",
53
84
  ],
54
85
  "installable": True,
55
86
  "application": False,
@@ -57,6 +88,78 @@ function manifestContent(moduleName, odooVersion) {
57
88
  }
58
89
  `;
59
90
  }
91
+ function viewXmlContent(moduleName, odooVersion) {
92
+ const moduleTitle = titleizeModule(moduleName);
93
+ const technicalName = modelTechnicalName(moduleName);
94
+ const primaryViewTag = listViewTag(odooVersion);
95
+ return `<?xml version="1.0" encoding="utf-8"?>
96
+ <odoo>
97
+ <record id="view_${moduleName}_${primaryViewTag}" model="ir.ui.view">
98
+ <field name="name">${technicalName}.${primaryViewTag}</field>
99
+ <field name="model">${technicalName}</field>
100
+ <field name="arch" type="xml">
101
+ <${primaryViewTag} string="${moduleTitle}">
102
+ <field name="name"/>
103
+ </${primaryViewTag}>
104
+ </field>
105
+ </record>
106
+
107
+ <record id="view_${moduleName}_form" model="ir.ui.view">
108
+ <field name="name">${technicalName}.form</field>
109
+ <field name="model">${technicalName}</field>
110
+ <field name="arch" type="xml">
111
+ <form string="${moduleTitle}">
112
+ <sheet>
113
+ <group>
114
+ <field name="name"/>
115
+ </group>
116
+ </sheet>
117
+ </form>
118
+ </field>
119
+ </record>
120
+ </odoo>
121
+ `;
122
+ }
123
+ function menuXmlContent(moduleName, odooVersion) {
124
+ const moduleTitle = titleizeModule(moduleName);
125
+ return `<?xml version="1.0" encoding="utf-8"?>
126
+ <odoo>
127
+ <record id="action_${moduleName}" model="ir.actions.act_window">
128
+ <field name="name">${moduleTitle}</field>
129
+ <field name="res_model">${modelTechnicalName(moduleName)}</field>
130
+ <field name="view_mode">${actionViewMode(odooVersion)}</field>
131
+ </record>
132
+
133
+ <menuitem id="menu_${moduleName}_root" name="${moduleTitle}" sequence="10"/>
134
+ <menuitem id="menu_${moduleName}" name="${moduleTitle}" parent="menu_${moduleName}_root" action="action_${moduleName}" sequence="10"/>
135
+ </odoo>
136
+ `;
137
+ }
138
+ function accessCsvContent(moduleName) {
139
+ const modelId = modelTechnicalName(moduleName).replace(/\./g, '_');
140
+ return [
141
+ 'id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink',
142
+ `access_${modelId}_user,access_${modelId}_user,model_${modelId},base.group_user,1,1,1,1`,
143
+ '',
144
+ ].join('\n');
145
+ }
146
+ function testInitContent(moduleName) {
147
+ return `from . import test_${moduleName}\n`;
148
+ }
149
+ function testContent(moduleName) {
150
+ const moduleTitle = titleizeModule(moduleName);
151
+ return `from odoo.tests import tagged
152
+ from odoo.tests.common import TransactionCase
153
+
154
+
155
+ @tagged("post_install", "-at_install")
156
+ class Test${moduleClassName(moduleName)}(TransactionCase):
157
+
158
+ def test_create_record(self):
159
+ record = self.env["${modelTechnicalName(moduleName)}"].create({"name": "Test ${moduleTitle}"})
160
+ self.assertEqual(record.name, "Test ${moduleTitle}")
161
+ `;
162
+ }
60
163
  async function writeIfMissing(path, content) {
61
164
  try {
62
165
  await readFile(path, 'utf8');
@@ -69,6 +172,59 @@ async function usesAddonsYaml(target) {
69
172
  const metadata = await readEnvironmentMetadata(target);
70
173
  return metadata?.engine !== 'compose';
71
174
  }
175
+ function updateAddonList(addons, moduleName, mode) {
176
+ if (mode === 'add') {
177
+ return [...new Set([...addons, moduleName])];
178
+ }
179
+ return addons.filter((addon) => addon !== moduleName);
180
+ }
181
+ function addonListsEqual(left, right) {
182
+ return left.length === right.length && left.every((addon, index) => addon === right[index]);
183
+ }
184
+ async function updateSourceManifestModuleRegistration(target, sourceType, repoPath, moduleName, mode) {
185
+ const manifest = await readSourceManifest(target);
186
+ if (manifest.sources.length === 0) {
187
+ return;
188
+ }
189
+ let changed = false;
190
+ const sources = manifest.sources.map((entry) => {
191
+ if (entry.type !== sourceType || entry.path !== repoPath) {
192
+ return entry;
193
+ }
194
+ const addons = updateAddonList(entry.addons, moduleName, mode);
195
+ if (!addonListsEqual(entry.addons, addons)) {
196
+ changed = true;
197
+ }
198
+ return { ...entry, addons };
199
+ });
200
+ if (changed) {
201
+ await writeSourceManifest(target, sources);
202
+ }
203
+ }
204
+ async function updateMetadataModuleRegistration(target, sourceType, repoPath, moduleName, mode) {
205
+ const metadata = await readEnvironmentMetadata(target);
206
+ if (!metadata?.sourceRepos?.length) {
207
+ return;
208
+ }
209
+ let changed = false;
210
+ const sourceRepos = metadata.sourceRepos.map((repo) => {
211
+ if (normalizeSourceType(repo.sourceType) !== sourceType || repo.path !== repoPath) {
212
+ return repo;
213
+ }
214
+ const addons = updateAddonList(repo.addons, moduleName, mode);
215
+ if (!addonListsEqual(repo.addons, addons)) {
216
+ changed = true;
217
+ }
218
+ return { ...repo, addons };
219
+ });
220
+ if (changed) {
221
+ await replaceSourceRepos(target, sourceRepos);
222
+ }
223
+ }
224
+ async function updateModuleRegistration(target, sourceType, repoPath, moduleName, mode) {
225
+ await updateSourceManifestModuleRegistration(target, sourceType, repoPath, moduleName, mode);
226
+ await updateMetadataModuleRegistration(target, sourceType, repoPath, moduleName, mode);
227
+ }
72
228
  export async function addModuleToSourceRepo(options, git = realGit) {
73
229
  const repoPath = validateRepoPath(options.repoPath);
74
230
  const moduleName = validateModuleName(options.moduleName);
@@ -76,16 +232,23 @@ export async function addModuleToSourceRepo(options, git = realGit) {
76
232
  const destination = modulePath(options.target, sourceType, repoPath, moduleName);
77
233
  await mkdir(join(destination, 'models'), { recursive: true });
78
234
  await mkdir(join(destination, 'security'), { recursive: true });
235
+ await mkdir(join(destination, 'tests'), { recursive: true });
79
236
  await mkdir(join(destination, 'views'), { recursive: true });
80
237
  await writeIfMissing(join(destination, '__init__.py'), 'from . import models\n');
81
238
  await writeIfMissing(join(destination, '__manifest__.py'), manifestContent(moduleName, options.odooVersion));
82
- await writeIfMissing(join(destination, 'models/__init__.py'), '');
83
- await writeIfMissing(join(destination, 'security/ir.model.access.csv'), 'id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\n');
239
+ await writeIfMissing(join(destination, 'models/__init__.py'), `from . import ${moduleName}\n`);
240
+ await writeIfMissing(join(destination, `models/${moduleName}.py`), modelContent(moduleName));
241
+ await writeIfMissing(join(destination, 'tests/__init__.py'), testInitContent(moduleName));
242
+ await writeIfMissing(join(destination, `tests/test_${moduleName}.py`), testContent(moduleName));
243
+ await writeIfMissing(join(destination, 'security/ir.model.access.csv'), accessCsvContent(moduleName));
244
+ await writeIfMissing(join(destination, `views/${moduleName}_views.xml`), viewXmlContent(moduleName, options.odooVersion));
245
+ await writeIfMissing(join(destination, `views/${moduleName}_menus.xml`), menuXmlContent(moduleName, options.odooVersion));
84
246
  await writeIfMissing(join(destination, 'views/.gitkeep'), '');
85
247
  if (sourceType === 'private' && (await usesAddonsYaml(options.target))) {
86
248
  const addonsYaml = await readAddonsYaml(options.target);
87
249
  await writeAddonsYaml(options.target, addModuleToSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
88
250
  }
251
+ await updateModuleRegistration(options.target, sourceType, repoPath, moduleName, 'add');
89
252
  if (options.stage) {
90
253
  await stageAll(git, sourceRepoPath(options.target, sourceType, repoPath));
91
254
  await stageAll(git, options.target);
@@ -151,6 +314,7 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
151
314
  const addonsYaml = await readAddonsYaml(options.target);
152
315
  await writeAddonsYaml(options.target, removeModuleFromSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
153
316
  }
317
+ await updateModuleRegistration(options.target, sourceType, repoPath, moduleName, 'remove');
154
318
  if (options.deleteFiles) {
155
319
  await rm(modulePath(options.target, sourceType, repoPath, moduleName), { recursive: true, force: true });
156
320
  }
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute, relative, resolve } from 'node:path';
2
2
  const windowsDrivePattern = /^[a-zA-Z]:/;
3
+ const odooModuleNamePattern = /^[a-z][a-z0-9_]*$/;
3
4
  function invalidPathError(label) {
4
5
  return new Error(`Invalid ${label}: use a single path segment without traversal.`);
5
6
  }
@@ -33,7 +34,11 @@ export function validateRepoPath(value) {
33
34
  return validatePathSegment(value, 'repo path');
34
35
  }
35
36
  export function validateModuleName(value) {
36
- return validatePathSegment(value, 'module name');
37
+ const moduleName = validatePathSegment(value, 'module name');
38
+ if (!odooModuleNamePattern.test(moduleName)) {
39
+ throw new Error('Invalid module name: use lower snake_case letters, numbers, and underscores, and start with a letter.');
40
+ }
41
+ return moduleName;
37
42
  }
38
43
  export function validateAddonName(value) {
39
44
  return validatePathSegment(value, 'addon name');
@@ -100,6 +100,7 @@ function parseSourcesBlock(content) {
100
100
  url: '',
101
101
  addons: [],
102
102
  };
103
+ let hasAddonsField = false;
103
104
  index += 1;
104
105
  while (index < sourceLines.length) {
105
106
  const rawLine = sourceLines[index];
@@ -130,8 +131,14 @@ function parseSourcesBlock(content) {
130
131
  index += 1;
131
132
  continue;
132
133
  }
133
- const addonsLine = /^\s*addons:\s*$/.exec(noComment);
134
- if (addonsLine) {
134
+ const emptyAddonsLine = /^\s*addons:\s*\[\s*\]\s*$/.exec(noComment);
135
+ if (emptyAddonsLine) {
136
+ hasAddonsField = true;
137
+ index += 1;
138
+ continue;
139
+ }
140
+ if (/^\s*addons:\s*$/.test(noComment)) {
141
+ hasAddonsField = true;
135
142
  const baseIndent = leadingSpaces(rawLine.line) + 2;
136
143
  index += 1;
137
144
  while (index < sourceLines.length) {
@@ -167,7 +174,7 @@ function parseSourcesBlock(content) {
167
174
  if (!isValidPathSegment(item.path)) {
168
175
  fail(`Invalid manifest path at line ${headerLine.lineNumber}: ${item.path}`);
169
176
  }
170
- if (item.addons.length === 0) {
177
+ if (!hasAddonsField && item.addons.length === 0) {
171
178
  item.addons.push(item.path);
172
179
  }
173
180
  item.addons = [...new Set(item.addons.map((addon) => validateRepoPath(addon)))].sort();
@@ -198,7 +205,7 @@ export function renderSourceManifest(entries) {
198
205
  path: validateRepoPath(entry.path),
199
206
  url: entry.url.trim(),
200
207
  branch: entry.branch?.trim(),
201
- addons: addons.length ? addons : [validateRepoPath(entry.path)],
208
+ addons,
202
209
  };
203
210
  });
204
211
  if (normalized.length === 0) {
@@ -212,9 +219,14 @@ export function renderSourceManifest(entries) {
212
219
  ` url: ${renderQuoted(entry.url)}`,
213
220
  ];
214
221
  lines.push(` branch: ${renderQuoted(entry.branch ?? '')}`);
215
- lines.push(' addons:');
216
- for (const addon of entry.addons) {
217
- lines.push(` - ${renderQuoted(addon)}`);
222
+ if (entry.addons.length === 0) {
223
+ lines.push(' addons: []');
224
+ }
225
+ else {
226
+ lines.push(' addons:');
227
+ for (const addon of entry.addons) {
228
+ lines.push(` - ${renderQuoted(addon)}`);
229
+ }
218
230
  }
219
231
  return lines.join('\n');
220
232
  })
@@ -333,6 +345,6 @@ export function sourceReposFromManifest(entries) {
333
345
  sourceType: entry.type,
334
346
  path: validateRepoPath(entry.path),
335
347
  url: entry.url,
336
- addons: entry.addons.length ? [...new Set(entry.addons.map((addon) => validateRepoPath(addon)))] : [validateRepoPath(entry.path)],
348
+ addons: [...new Set(entry.addons.map((addon) => validateRepoPath(addon)))],
337
349
  }));
338
350
  }
package/dist/templates.js CHANGED
@@ -208,6 +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
213
+ resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage
214
+ and prod. restore-snapshot --dry-run remains available for preview.
211
215
 
212
216
  If copied from the standalone resource, additional compose notes are in
213
217
  \`docs/compose.md\`.
@@ -539,6 +543,52 @@ validate_test_args() {
539
543
  done
540
544
  }
541
545
 
546
+ env_file_value() {
547
+ local key="$1"
548
+ if [[ -f ".env" ]]; then
549
+ grep -E "^[[:space:]]*\${key}[[:space:]]*=" ".env" | tail -n 1 | sed -E "s/^[[:space:]]*\${key}[[:space:]]*=[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^[\\"']//; s/[\\"']$//"
550
+ fi
551
+ }
552
+
553
+ selected_env() {
554
+ local value="\${WPMOO_ENV:-$(env_file_value WPMOO_ENV)}"
555
+ printf '%s\\n' "\${value:-dev}"
556
+ }
557
+
558
+ allow_destructive() {
559
+ local value="\${WPMOO_ALLOW_DESTRUCTIVE:-$(env_file_value WPMOO_ALLOW_DESTRUCTIVE)}"
560
+ [[ "$value" == "1" ]]
561
+ }
562
+
563
+ require_destructive_allowed() {
564
+ local command="$1"
565
+ local env_name
566
+ env_name="$(selected_env)"
567
+ if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
568
+ if ! allow_destructive; then
569
+ echo "Refusing destructive command '$command' in WPMOO_ENV=$env_name. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally." >&2
570
+ exit 1
571
+ fi
572
+ fi
573
+ }
574
+
575
+ allow_prod_lifecycle() {
576
+ local value="\${WPMOO_ALLOW_PROD_LIFECYCLE:-$(env_file_value WPMOO_ALLOW_PROD_LIFECYCLE)}"
577
+ [[ "$value" == "1" ]]
578
+ }
579
+
580
+ require_prod_lifecycle_allowed() {
581
+ local command="$1"
582
+ local env_name
583
+ env_name="$(selected_env)"
584
+ if [[ "$env_name" == "prod" ]]; then
585
+ if ! allow_prod_lifecycle; then
586
+ echo "Refusing production lifecycle command '$command' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally." >&2
587
+ exit 1
588
+ fi
589
+ fi
590
+ }
591
+
542
592
  run_script() {
543
593
  local script="$1"
544
594
  shift
@@ -584,21 +634,25 @@ case "$command" in
584
634
  "install")
585
635
  shift
586
636
  require_module_args "$command" "$@"
637
+ require_prod_lifecycle_allowed "$command"
587
638
  run_script ./scripts/install.sh "$@"
588
639
  ;;
589
640
  "update")
590
641
  shift
591
642
  require_module_args "$command" "$@"
643
+ require_prod_lifecycle_allowed "$command"
592
644
  run_script ./scripts/update.sh "$@"
593
645
  ;;
594
646
  "test")
595
647
  shift
596
648
  validate_test_args "$@"
649
+ require_prod_lifecycle_allowed "$command"
597
650
  run_script ./scripts/test.sh "$@"
598
651
  ;;
599
652
  "resetdb")
600
653
  shift
601
654
  positional_args "$command" 0 2 "$@"
655
+ require_destructive_allowed "$command"
602
656
  run_script ./scripts/resetdb.sh "$@"
603
657
  ;;
604
658
  "snapshot")
@@ -615,6 +669,9 @@ case "$command" in
615
669
  fi
616
670
  positional_args "$command" 1 2 "$@"
617
671
  restore_args+=("$@")
672
+ if [[ "\${restore_args[0]:-}" != "--dry-run" ]]; then
673
+ require_destructive_allowed "$command"
674
+ fi
618
675
  run_script ./scripts/restore-snapshot.sh "\${restore_args[@]}"
619
676
  ;;
620
677
  "lint")
@@ -23,9 +23,10 @@ not validate staging or production deployments.
23
23
  | Doctor checks | Metadata, compose files, scripts, source repo paths, and local tooling checks behave as expected. | `npx @wpmoo/toolkit doctor` or `./moo doctor` |
24
24
  | Doctor safe fixes | Safe file-level fixes are applied only with `--fix`, then doctor runs again and reports any remaining manual issues. | `npx @wpmoo/toolkit doctor --fix` |
25
25
  | Generated Postgres checks | For PostgreSQL 18 environments, doctor validates db mount targets avoid old PG image-specific paths and can normalize safe targets with `--fix`. | `npx @wpmoo/toolkit doctor`, `npx @wpmoo/toolkit doctor --fix` |
26
+ | PostgreSQL diagnostics | Optional read-only database health/performance diagnostics report database count, sessions currently running queries with `pg_stat_activity.state = 'active'`, total database size, slow-query readiness, extension visibility, and selected settings without failing doctor when the database is unavailable. | `npx @wpmoo/toolkit doctor --postgres`, `npx @wpmoo/toolkit doctor --json --postgres` |
26
27
  | Source repo add/remove | Source repository registration and submodule lifecycle behave correctly. | `npx @wpmoo/toolkit add-repo ...`, `npx @wpmoo/toolkit remove-repo ...` |
27
28
  | Source manifest sync | Source repo metadata, `.gitmodules`, and `odoo/custom/manifests/sources.yaml` stay aligned. | `npx @wpmoo/toolkit source list`, `npx @wpmoo/toolkit source sync` |
28
- | Module add/remove | Module registration changes are applied to the selected source repo config. | `npx @wpmoo/toolkit add-module ...`, `npx @wpmoo/toolkit remove-module ...` |
29
+ | Module add/remove | Module skeleton files include manifest, model, access CSV, explicit view XML, action/menu XML, post-install test scaffold, and selected source repo registration. Existing scaffold files are not overwritten. | `npx @wpmoo/toolkit add-module ...`, `npx @wpmoo/toolkit remove-module ...` |
29
30
  | Safe reset | Generated files are refreshed (including `compose.yaml` overlays and env example) without deleting source module code. Local runtime/data directories and custom source layout content are preserved; legacy user-editable paths from older templates may remain and are reported for manual cleanup. | `npx @wpmoo/toolkit reset --dry-run`, `npx @wpmoo/toolkit reset` |
30
31
  | Snapshot/restore and lint/pot | These actions are delegated by `./moo` to compose scripts. Restore preview, snapshot retention, and stage/prod destructive guards are preserved by the package argument layer. | `./moo snapshot ...`, `./moo restore-snapshot --dry-run ...`, `./moo restore-snapshot ...`, `./moo lint`, `./moo pot ...` |
31
32
 
@@ -46,9 +47,17 @@ Default local development uses `compose.yaml` plus `compose/dev.yaml`.
46
47
  `WPMOO_ENV=stage` or `WPMOO_ENV=prod` must only be used after production-grade
47
48
  secrets and volumes are configured.
48
49
 
49
- When `WPMOO_ENV=stage` or `WPMOO_ENV=prod`, generated compose scripts refuse
50
- destructive database actions such as `resetdb` and real `restore-snapshot`
51
- unless `.env` explicitly sets `WPMOO_ALLOW_DESTRUCTIVE=1`.
50
+ When `WPMOO_ENV=stage` or `WPMOO_ENV=prod`, WPMoo refuses destructive database
51
+ actions such as `resetdb` and real `restore-snapshot` before dispatching local
52
+ scripts unless `.env` or the process environment explicitly sets
53
+ `WPMOO_ALLOW_DESTRUCTIVE=1`. `restore-snapshot --dry-run` remains allowed for
54
+ safe preview.
55
+
56
+ When `WPMOO_ENV=prod`, WPMoo also refuses module lifecycle commands that mutate
57
+ or exercise the Odoo database (`install`, `update`, and `test`) unless `.env` or
58
+ the process environment explicitly sets `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
59
+ Staging keeps these commands available for release rehearsal while still
60
+ enforcing the destructive database guard above.
52
61
 
53
62
  For PostgreSQL 18 environments (including `POSTGRES_IMAGE=postgres:18`), ensure db
54
63
  volume and tmpfs mount targets use `/var/lib/postgresql` directly:
@@ -65,6 +74,19 @@ no longer accepted by the package `doctor` check.
65
74
  It does not upgrade existing database data; if a real PostgreSQL major upgrade
66
75
  is involved, use PostgreSQL upgrade tooling first.
67
76
 
77
+ Use `doctor --postgres` when the database container is running and you want
78
+ read-only PostgreSQL diagnostics. The check uses fixed diagnostic queries for
79
+ database count, sessions currently running queries where
80
+ `pg_stat_activity.state` is `active`, aggregate database size, slow-query
81
+ logging readiness,
82
+ `pg_stat_statements` availability, and `shared_buffers`. If the database is
83
+ unavailable, doctor reports a warning instead of failing the whole environment
84
+ check.
85
+ JSON output preserves `checks` and `warnings` while adding a structured
86
+ `postgres` object when `--postgres` is requested.
87
+ Incomplete or malformed PostgreSQL metric rows are reported as unavailable
88
+ diagnostics.
89
+
68
90
  ## Safe reset policy
69
91
 
70
92
  Safe reset intentionally avoids deleting user-editable legacy paths from old
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {