@wpmoo/toolkit 0.9.8 → 0.9.9

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
 
@@ -146,6 +151,8 @@ npx @wpmoo/toolkit add-module --repo sale-workflow --module sale_order_line_no_d
146
151
  npx @wpmoo/toolkit remove-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
147
152
  ```
148
153
 
154
+ `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.
155
+
149
156
  For automation and VS Code cockpit integration, selected commands support JSON output:
150
157
 
151
158
  ```bash
@@ -153,9 +160,15 @@ npx @wpmoo/toolkit status --json
153
160
  npx @wpmoo/toolkit source list --json
154
161
  npx @wpmoo/toolkit source sync --json
155
162
  npx @wpmoo/toolkit doctor --json
163
+ npx @wpmoo/toolkit doctor --postgres
164
+ npx @wpmoo/toolkit doctor --json --postgres
156
165
  ```
157
166
 
158
167
  JSON output is optional; human-readable output remains the default.
168
+ `doctor --postgres` adds read-only PostgreSQL health and performance diagnostics
169
+ such as database size, active connections, slow-query readiness, extension
170
+ visibility, and settings.
171
+ `doctor --json --postgres` includes a structured `postgres` object for automation.
159
172
 
160
173
  ## Documentation
161
174
 
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,28 @@ 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 destructiveCommandError(command, envName) {
159
+ return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
160
+ }
161
+ async function assertDestructiveCommandAllowed(command, args, cwd) {
162
+ if (!isDestructiveCommand(command, args)) {
163
+ return;
164
+ }
165
+ const env = await readEnvFile(cwd);
166
+ const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
167
+ if (envName !== 'stage' && envName !== 'prod') {
168
+ return;
169
+ }
170
+ const allowDestructive = process.env.WPMOO_ALLOW_DESTRUCTIVE?.trim() || env?.get('WPMOO_ALLOW_DESTRUCTIVE')?.trim();
171
+ if (allowDestructive !== '1') {
172
+ throw new Error(destructiveCommandError(command, envName));
173
+ }
174
+ }
152
175
  async function assertEnvironmentRoot(cwd) {
153
176
  try {
154
177
  await access(join(cwd, markerPath));
@@ -170,10 +193,12 @@ async function assertScriptExists(cwd, script) {
170
193
  export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
171
194
  await assertEnvironmentRoot(cwd);
172
195
  const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
196
+ const args = scriptArgs(command, argv);
197
+ await assertDestructiveCommandAllowed(command, args, cwd);
173
198
  return {
174
199
  cwd,
175
200
  scriptPath,
176
- args: scriptArgs(command, argv),
201
+ args,
177
202
  };
178
203
  }
179
204
  async function spawnDailyAction(plan) {
package/dist/doctor.js CHANGED
@@ -46,6 +46,57 @@ 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
+ UNION ALL
59
+ SELECT 'total_database_size_bytes', COALESCE(sum(pg_database_size(datname)), 0)::text
60
+ FROM pg_database
61
+ WHERE datistemplate = false
62
+ UNION ALL
63
+ SELECT 'slow_query_logging', COALESCE(
64
+ (SELECT setting || unit FROM pg_settings WHERE name = 'log_min_duration_statement'),
65
+ 'unavailable'
66
+ )
67
+ UNION ALL
68
+ SELECT 'pg_stat_statements',
69
+ CASE
70
+ WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') THEN 'installed'
71
+ WHEN EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements') THEN 'available'
72
+ ELSE 'unavailable'
73
+ END
74
+ UNION ALL
75
+ SELECT 'shared_buffers', COALESCE(
76
+ (SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
77
+ 'unavailable'
78
+ )
79
+ )
80
+ SELECT metric || '|' || value
81
+ FROM metrics
82
+ ORDER BY CASE metric
83
+ WHEN 'database_count' THEN 1
84
+ WHEN 'active_connections' THEN 2
85
+ WHEN 'total_database_size_bytes' THEN 3
86
+ WHEN 'slow_query_logging' THEN 4
87
+ WHEN 'pg_stat_statements' THEN 5
88
+ WHEN 'shared_buffers' THEN 6
89
+ ELSE 99
90
+ END;
91
+ `.trim();
92
+ const postgresDiagnosticKeys = [
93
+ 'database_count',
94
+ 'active_connections',
95
+ 'total_database_size_bytes',
96
+ 'slow_query_logging',
97
+ 'pg_stat_statements',
98
+ 'shared_buffers',
99
+ ];
49
100
  function parsePostgresMajorFromValue(value) {
50
101
  if (!value)
51
102
  return undefined;
@@ -56,6 +107,66 @@ function parsePostgresMajorFromValue(value) {
56
107
  const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
57
108
  return match?.[1];
58
109
  }
110
+ function parsePostgresDiagnostics(output) {
111
+ const diagnostics = {};
112
+ const allowedKeys = new Set(postgresDiagnosticKeys);
113
+ for (const rawLine of output.split(/\r?\n/u)) {
114
+ const line = rawLine.trim();
115
+ if (!line)
116
+ continue;
117
+ const separatorIndex = line.indexOf('|');
118
+ if (separatorIndex === -1)
119
+ continue;
120
+ const key = line.slice(0, separatorIndex).trim();
121
+ const value = line.slice(separatorIndex + 1).trim();
122
+ if (allowedKeys.has(key) && value) {
123
+ diagnostics[key] = value;
124
+ }
125
+ }
126
+ return diagnostics;
127
+ }
128
+ function renderPostgresDiagnostics(diagnostics) {
129
+ const parts = postgresDiagnosticKeys.flatMap((key) => {
130
+ const value = diagnostics[key];
131
+ return value ? [`${key}=${value}`] : [];
132
+ });
133
+ return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
134
+ }
135
+ function integerDiagnostic(value) {
136
+ if (!value || !/^\d+$/u.test(value)) {
137
+ return undefined;
138
+ }
139
+ return Number.parseInt(value, 10);
140
+ }
141
+ function structuredPostgresDiagnostics(diagnostics) {
142
+ const structured = {};
143
+ const databaseCount = integerDiagnostic(diagnostics.database_count);
144
+ const activeConnections = integerDiagnostic(diagnostics.active_connections);
145
+ const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
146
+ if (databaseCount !== undefined)
147
+ structured.databaseCount = databaseCount;
148
+ if (activeConnections !== undefined)
149
+ structured.activeConnections = activeConnections;
150
+ if (totalDatabaseSizeBytes !== undefined)
151
+ structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
152
+ if (diagnostics.slow_query_logging)
153
+ structured.slowQueryLogging = diagnostics.slow_query_logging;
154
+ if (diagnostics.pg_stat_statements)
155
+ structured.pgStatStatements = diagnostics.pg_stat_statements;
156
+ if (diagnostics.shared_buffers)
157
+ structured.sharedBuffers = diagnostics.shared_buffers;
158
+ return structured;
159
+ }
160
+ async function readPostgresDiagnostics(target, runner) {
161
+ const queryLiteral = JSON.stringify(postgresDiagnosticQuery);
162
+ const command = [
163
+ `query=${queryLiteral}`,
164
+ '. ./scripts/lib.sh >/dev/null',
165
+ 'compose exec -T db psql -X -q -t -A -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -c "$query"',
166
+ ].join(' && ');
167
+ const result = await runner('bash', ['-lc', command], { cwd: target });
168
+ return parsePostgresDiagnostics(result.stdout);
169
+ }
59
170
  function stripInlineComment(line) {
60
171
  const hashIndex = line.indexOf('#');
61
172
  if (hashIndex === -1)
@@ -441,6 +552,40 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
441
552
  checks.push(`OK .env ports HTTP_PORT=${httpPort} GEVENT_PORT=${geventPort}`);
442
553
  }
443
554
  }
555
+ if (actualOptions.postgres) {
556
+ try {
557
+ const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner);
558
+ const renderedPostgresDiagnostics = renderPostgresDiagnostics(postgresDiagnostics);
559
+ if (renderedPostgresDiagnostics) {
560
+ checks.push(renderedPostgresDiagnostics);
561
+ report.postgres = {
562
+ requested: true,
563
+ available: true,
564
+ diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
565
+ };
566
+ }
567
+ else {
568
+ const warning = 'no diagnostic rows returned';
569
+ warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
570
+ report.postgres = {
571
+ requested: true,
572
+ available: false,
573
+ diagnostics: {},
574
+ warning,
575
+ };
576
+ }
577
+ }
578
+ catch (error) {
579
+ const warning = errorMessage(error);
580
+ warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
581
+ report.postgres = {
582
+ requested: true,
583
+ available: false,
584
+ diagnostics: {},
585
+ warning,
586
+ };
587
+ }
588
+ }
444
589
  try {
445
590
  await actualRunner('docker', ['version'], { cwd: target });
446
591
  checks.push('OK docker CLI');
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.
@@ -101,6 +103,8 @@ Status and doctor:
101
103
  status: fast and offline. Reads local environment metadata and files only.
102
104
  doctor: deeper health check. May check Docker CLI access and GitHub workflows.
103
105
  doctor --fix: applies safe file-level repairs. Runs doctor again after fixes.
106
+ doctor --postgres: adds read-only PostgreSQL diagnostics such as database size,
107
+ active connections, slow-query readiness, extension visibility, and settings.
104
108
 
105
109
  Task recipes:
106
110
  Create environment:
@@ -115,6 +119,9 @@ Task recipes:
115
119
  npx @wpmoo/toolkit source sync
116
120
  Add module:
117
121
  npx @wpmoo/toolkit add-module --repo <source-repo> --module <module-name> --source-type private|oca|external
122
+ 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.
123
+ 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.
124
+ Module names must be lower snake_case; use letters, numbers, and underscores only.
118
125
  Remove module:
119
126
  npx @wpmoo/toolkit remove-module --repo <source-repo> --module <module-name> --source-type private|oca|external
120
127
  Add OCA module:
@@ -140,6 +147,7 @@ Machine-readable JSON output:
140
147
  npx @wpmoo/toolkit source list --json
141
148
  npx @wpmoo/toolkit source sync --json
142
149
  npx @wpmoo/toolkit doctor --json
150
+ doctor --json --postgres includes a structured postgres object for automation.
143
151
 
144
152
  Example:
145
153
  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
@@ -539,6 +539,35 @@ validate_test_args() {
539
539
  done
540
540
  }
541
541
 
542
+ env_file_value() {
543
+ local key="$1"
544
+ if [[ -f ".env" ]]; then
545
+ grep -E "^[[:space:]]*\${key}[[:space:]]*=" ".env" | tail -n 1 | sed -E "s/^[[:space:]]*\${key}[[:space:]]*=[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^[\\"']//; s/[\\"']$//"
546
+ fi
547
+ }
548
+
549
+ selected_env() {
550
+ local value="\${WPMOO_ENV:-$(env_file_value WPMOO_ENV)}"
551
+ printf '%s\\n' "\${value:-dev}"
552
+ }
553
+
554
+ allow_destructive() {
555
+ local value="\${WPMOO_ALLOW_DESTRUCTIVE:-$(env_file_value WPMOO_ALLOW_DESTRUCTIVE)}"
556
+ [[ "$value" == "1" ]]
557
+ }
558
+
559
+ require_destructive_allowed() {
560
+ local command="$1"
561
+ local env_name
562
+ env_name="$(selected_env)"
563
+ if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
564
+ if ! allow_destructive; then
565
+ echo "Refusing destructive command '$command' in WPMOO_ENV=$env_name. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally." >&2
566
+ exit 1
567
+ fi
568
+ fi
569
+ }
570
+
542
571
  run_script() {
543
572
  local script="$1"
544
573
  shift
@@ -599,6 +628,7 @@ case "$command" in
599
628
  "resetdb")
600
629
  shift
601
630
  positional_args "$command" 0 2 "$@"
631
+ require_destructive_allowed "$command"
602
632
  run_script ./scripts/resetdb.sh "$@"
603
633
  ;;
604
634
  "snapshot")
@@ -615,6 +645,9 @@ case "$command" in
615
645
  fi
616
646
  positional_args "$command" 1 2 "$@"
617
647
  restore_args+=("$@")
648
+ if [[ "\${restore_args[0]:-}" != "--dry-run" ]]; then
649
+ require_destructive_allowed "$command"
650
+ fi
618
651
  run_script ./scripts/restore-snapshot.sh "\${restore_args[@]}"
619
652
  ;;
620
653
  "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, active connections, 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,11 @@ 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.
52
55
 
53
56
  For PostgreSQL 18 environments (including `POSTGRES_IMAGE=postgres:18`), ensure db
54
57
  volume and tmpfs mount targets use `/var/lib/postgresql` directly:
@@ -65,6 +68,15 @@ no longer accepted by the package `doctor` check.
65
68
  It does not upgrade existing database data; if a real PostgreSQL major upgrade
66
69
  is involved, use PostgreSQL upgrade tooling first.
67
70
 
71
+ Use `doctor --postgres` when the database container is running and you want
72
+ read-only PostgreSQL diagnostics. The check uses fixed diagnostic queries for
73
+ database count, active connections, aggregate database size, slow-query logging
74
+ readiness, `pg_stat_statements` availability, and `shared_buffers`. If the
75
+ database is unavailable, doctor reports a warning instead of failing the whole
76
+ environment check.
77
+ JSON output preserves `checks` and `warnings` while adding a structured
78
+ `postgres` object when `--postgres` is requested.
79
+
68
80
  ## Safe reset policy
69
81
 
70
82
  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.9",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {