@wpmoo/toolkit 0.9.15 → 0.9.16

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
@@ -144,6 +144,7 @@ Every cockpit action maps to a direct command, so the same workflow can be used
144
144
  ./moo restore-snapshot --dry-run before-update devel
145
145
  ```
146
146
 
147
+ In `WPMOO_ENV=stage`, `install` and `update` require `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
147
148
  In `WPMOO_ENV=prod`, `install`, `update`, and `test` require `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
148
149
  `resetdb` and real `restore-snapshot` require `WPMOO_ALLOW_DESTRUCTIVE=1` in `stage` and `prod`.
149
150
  `restore-snapshot --dry-run` remains allowed for preview.
@@ -155,7 +156,7 @@ npx @wpmoo/toolkit add-module --repo sale-workflow --module sale_order_line_no_d
155
156
  npx @wpmoo/toolkit remove-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
156
157
  ```
157
158
 
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
+ `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 internal-user menu entry; the test skeleton adds a post-install TransactionCase smoke test. WPMoo reports scaffold quality after generation and `status` reports installable modules, non-installable modules, and modules without actionable menus. Module names must be lower `snake_case`; use letters, numbers, and underscores only.
159
160
 
160
161
  For automation and VS Code cockpit integration, selected commands support JSON output:
161
162
 
@@ -176,6 +177,19 @@ such as database size, sessions currently running queries with
176
177
  `doctor --json --postgres` includes a structured `postgres` object for automation.
177
178
  Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
178
179
 
180
+ ## Release Artifacts
181
+
182
+ WPMoo Toolkit releases are valid when the required npm artifacts publish
183
+ successfully:
184
+
185
+ - `@wpmoo/toolkit`
186
+ - `@wpmoo/odoo`
187
+ - `@wpmoo/odoo-dev`
188
+
189
+ The unscoped `wpmoo` short alias is optional. If npm returns `E404` or rejects
190
+ that alias during the publish workflow, the workflow reports a non-blocking
191
+ warning while keeping the scoped package release valid.
192
+
179
193
  ## Documentation
180
194
 
181
195
  - [External Resources](docs/external-resources.md)
package/dist/cli.js CHANGED
@@ -1628,10 +1628,13 @@ export function isCliEntrypoint(metaUrl, argvPath = process.argv[1]) {
1628
1628
  return metaUrl === pathToFileURL(argvPath).href;
1629
1629
  }
1630
1630
  }
1631
+ export function formatCliErrorMessage(error) {
1632
+ const message = error instanceof Error ? error.message : String(error);
1633
+ return message.trim() || 'Unknown WPMoo Toolkit error';
1634
+ }
1631
1635
  if (isCliEntrypoint(import.meta.url)) {
1632
1636
  runCli().catch((error) => {
1633
- const message = error instanceof Error ? error.message : String(error);
1634
- console.error(message);
1637
+ console.error(formatCliErrorMessage(error));
1635
1638
  process.exit(1);
1636
1639
  });
1637
1640
  }
@@ -158,9 +158,15 @@ function isDestructiveCommand(command, args) {
158
158
  function isProductionLifecycleCommand(command) {
159
159
  return command === 'install' || command === 'update' || command === 'test';
160
160
  }
161
+ function isStageLifecycleCommand(command) {
162
+ return command === 'install' || command === 'update';
163
+ }
161
164
  function destructiveCommandError(command, envName) {
162
165
  return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
163
166
  }
167
+ function stageLifecycleCommandError(command) {
168
+ return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
169
+ }
164
170
  function productionLifecycleCommandError(command) {
165
171
  return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
166
172
  }
@@ -192,6 +198,20 @@ async function assertProductionLifecycleCommandAllowed(command, cwd) {
192
198
  throw new Error(productionLifecycleCommandError(command));
193
199
  }
194
200
  }
201
+ async function assertStageLifecycleCommandAllowed(command, cwd) {
202
+ if (!isStageLifecycleCommand(command)) {
203
+ return;
204
+ }
205
+ const env = await readEnvFile(cwd);
206
+ const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
207
+ if (envName !== 'stage') {
208
+ return;
209
+ }
210
+ const allowStageLifecycle = process.env.WPMOO_ALLOW_STAGE_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_STAGE_LIFECYCLE')?.trim();
211
+ if (allowStageLifecycle !== '1') {
212
+ throw new Error(stageLifecycleCommandError(command));
213
+ }
214
+ }
195
215
  async function assertEnvironmentRoot(cwd) {
196
216
  try {
197
217
  await access(join(cwd, markerPath));
@@ -214,6 +234,7 @@ export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
214
234
  await assertEnvironmentRoot(cwd);
215
235
  const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
216
236
  const args = scriptArgs(command, argv);
237
+ await assertStageLifecycleCommandAllowed(command, cwd);
217
238
  await assertProductionLifecycleCommandAllowed(command, cwd);
218
239
  await assertDestructiveCommandAllowed(command, args, cwd);
219
240
  return {
package/dist/doctor.js CHANGED
@@ -70,6 +70,28 @@ WITH metrics(metric, value) AS (
70
70
  FROM pg_database
71
71
  WHERE datistemplate = false
72
72
  UNION ALL
73
+ SELECT 'largest_database_name', COALESCE(
74
+ (
75
+ SELECT datname
76
+ FROM pg_database
77
+ WHERE datistemplate = false
78
+ ORDER BY pg_database_size(datname) DESC, datname
79
+ LIMIT 1
80
+ ),
81
+ 'unavailable'
82
+ )
83
+ UNION ALL
84
+ SELECT 'largest_database_size_bytes', COALESCE(
85
+ (
86
+ SELECT pg_database_size(datname)::text
87
+ FROM pg_database
88
+ WHERE datistemplate = false
89
+ ORDER BY pg_database_size(datname) DESC, datname
90
+ LIMIT 1
91
+ ),
92
+ '0'
93
+ )
94
+ UNION ALL
73
95
  SELECT 'slow_query_logging', COALESCE(
74
96
  (SELECT setting || unit FROM pg_settings WHERE name = 'log_min_duration_statement'),
75
97
  'unavailable'
@@ -82,6 +104,21 @@ WITH metrics(metric, value) AS (
82
104
  ELSE 'unavailable'
83
105
  END
84
106
  UNION ALL
107
+ SELECT 'pg_stat_statements_available_version', COALESCE(
108
+ (SELECT default_version FROM pg_available_extensions WHERE name = 'pg_stat_statements'),
109
+ 'unavailable'
110
+ )
111
+ UNION ALL
112
+ SELECT 'pg_stat_statements_installed_version', COALESCE(
113
+ (SELECT extversion FROM pg_extension WHERE extname = 'pg_stat_statements'),
114
+ ''
115
+ )
116
+ UNION ALL
117
+ SELECT 'shared_preload_libraries', COALESCE(
118
+ (SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'),
119
+ 'unavailable'
120
+ )
121
+ UNION ALL
85
122
  SELECT 'shared_buffers', COALESCE(
86
123
  (SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
87
124
  'unavailable'
@@ -95,13 +132,33 @@ ORDER BY CASE metric
95
132
  WHEN 'connection_count' THEN 3
96
133
  WHEN 'max_connections' THEN 4
97
134
  WHEN 'total_database_size_bytes' THEN 5
98
- WHEN 'slow_query_logging' THEN 6
99
- WHEN 'pg_stat_statements' THEN 7
100
- WHEN 'shared_buffers' THEN 8
135
+ WHEN 'largest_database_name' THEN 6
136
+ WHEN 'largest_database_size_bytes' THEN 7
137
+ WHEN 'slow_query_logging' THEN 8
138
+ WHEN 'pg_stat_statements' THEN 9
139
+ WHEN 'pg_stat_statements_available_version' THEN 10
140
+ WHEN 'pg_stat_statements_installed_version' THEN 11
141
+ WHEN 'shared_preload_libraries' THEN 12
142
+ WHEN 'shared_buffers' THEN 13
101
143
  ELSE 99
102
144
  END;
103
145
  `.trim();
104
146
  const postgresDiagnosticKeys = [
147
+ 'database_count',
148
+ 'active_connections',
149
+ 'connection_count',
150
+ 'max_connections',
151
+ 'total_database_size_bytes',
152
+ 'largest_database_name',
153
+ 'largest_database_size_bytes',
154
+ 'slow_query_logging',
155
+ 'pg_stat_statements',
156
+ 'pg_stat_statements_available_version',
157
+ 'pg_stat_statements_installed_version',
158
+ 'shared_preload_libraries',
159
+ 'shared_buffers',
160
+ ];
161
+ const requiredPostgresDiagnosticKeys = [
105
162
  'database_count',
106
163
  'active_connections',
107
164
  'connection_count',
@@ -152,7 +209,7 @@ function renderPostgresDiagnostics(diagnostics) {
152
209
  return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
153
210
  }
154
211
  function missingPostgresDiagnosticKeys(diagnostics) {
155
- return postgresDiagnosticKeys.filter((key) => !diagnostics[key]);
212
+ return requiredPostgresDiagnosticKeys.filter((key) => !diagnostics[key]);
156
213
  }
157
214
  function unavailablePostgresDiagnosticsWarning(diagnostics, missingKeys) {
158
215
  return Object.keys(diagnostics).length === 0
@@ -172,6 +229,7 @@ function malformedPostgresDiagnosticKeys(diagnostics) {
172
229
  'connection_count',
173
230
  'max_connections',
174
231
  'total_database_size_bytes',
232
+ 'largest_database_size_bytes',
175
233
  ];
176
234
  return numericKeys.filter((key) => diagnostics[key] !== undefined && integerDiagnostic(diagnostics[key]) === undefined);
177
235
  }
@@ -197,11 +255,19 @@ function postgresConnectionUtilizationWarning(diagnostics) {
197
255
  }
198
256
  function postgresSlowQueryLoggingWarning(diagnostics) {
199
257
  const slowQueryLogging = diagnostics.slow_query_logging?.trim();
200
- if (!slowQueryLogging || !/^-1\s*(?:ms)?$/iu.test(slowQueryLogging)) {
258
+ if (!slowQueryLogging || (!/^-1\s*(?:ms)?$/iu.test(slowQueryLogging) && !/^off$/iu.test(slowQueryLogging))) {
201
259
  return undefined;
202
260
  }
203
261
  return `PostgreSQL slow-query logging is disabled (log_min_duration_statement=${slowQueryLogging}). Enable it before performance triage.`;
204
262
  }
263
+ function postgresExtensionVisibilityWarning(diagnostics) {
264
+ if (diagnostics.pg_stat_statements === 'available' &&
265
+ diagnostics.pg_stat_statements_available_version &&
266
+ !diagnostics.pg_stat_statements_installed_version) {
267
+ return 'PostgreSQL pg_stat_statements is available but not installed. Install it before query-level performance triage.';
268
+ }
269
+ return undefined;
270
+ }
205
271
  function structuredPostgresDiagnostics(diagnostics) {
206
272
  const structured = {};
207
273
  const databaseCount = integerDiagnostic(diagnostics.database_count);
@@ -210,6 +276,7 @@ function structuredPostgresDiagnostics(diagnostics) {
210
276
  const maxConnections = integerDiagnostic(diagnostics.max_connections);
211
277
  const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
212
278
  const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
279
+ const largestDatabaseSizeBytes = integerDiagnostic(diagnostics.largest_database_size_bytes);
213
280
  if (databaseCount !== undefined)
214
281
  structured.databaseCount = databaseCount;
215
282
  if (activeConnections !== undefined)
@@ -222,10 +289,22 @@ function structuredPostgresDiagnostics(diagnostics) {
222
289
  structured.connectionUtilizationPct = connectionUtilizationPct;
223
290
  if (totalDatabaseSizeBytes !== undefined)
224
291
  structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
292
+ if (diagnostics.largest_database_name)
293
+ structured.largestDatabaseName = diagnostics.largest_database_name;
294
+ if (largestDatabaseSizeBytes !== undefined)
295
+ structured.largestDatabaseSizeBytes = largestDatabaseSizeBytes;
225
296
  if (diagnostics.slow_query_logging)
226
297
  structured.slowQueryLogging = diagnostics.slow_query_logging;
227
298
  if (diagnostics.pg_stat_statements)
228
299
  structured.pgStatStatements = diagnostics.pg_stat_statements;
300
+ if (diagnostics.pg_stat_statements_available_version) {
301
+ structured.pgStatStatementsAvailableVersion = diagnostics.pg_stat_statements_available_version;
302
+ }
303
+ if (diagnostics.pg_stat_statements_installed_version) {
304
+ structured.pgStatStatementsInstalledVersion = diagnostics.pg_stat_statements_installed_version;
305
+ }
306
+ if (diagnostics.shared_preload_libraries)
307
+ structured.sharedPreloadLibraries = diagnostics.shared_preload_libraries;
229
308
  if (diagnostics.shared_buffers)
230
309
  structured.sharedBuffers = diagnostics.shared_buffers;
231
310
  return structured;
@@ -648,6 +727,10 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
648
727
  if (slowQueryLoggingWarning) {
649
728
  warnings.push(slowQueryLoggingWarning);
650
729
  }
730
+ const extensionVisibilityWarning = postgresExtensionVisibilityWarning(postgresDiagnostics);
731
+ if (extensionVisibilityWarning) {
732
+ warnings.push(extensionVisibilityWarning);
733
+ }
651
734
  }
652
735
  else {
653
736
  const warning = malformedKeys.length > 0
package/dist/help.js CHANGED
@@ -85,7 +85,8 @@ Daily actions:
85
85
  Generated environments also include ./moo for local compose commands such as ./moo start.
86
86
  Use ./moo or npx @wpmoo/toolkit with the same daily action arguments.
87
87
 
88
- Production command guards:
88
+ Lifecycle command guards:
89
+ In WPMOO_ENV=stage, install/update require WPMOO_ALLOW_STAGE_LIFECYCLE=1.
89
90
  In WPMOO_ENV=prod, install/update/test require WPMOO_ALLOW_PROD_LIFECYCLE=1.
90
91
  resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage/prod.
91
92
  restore-snapshot --dry-run remains allowed for preview.
@@ -128,7 +129,8 @@ Task recipes:
128
129
  Add module:
129
130
  npx @wpmoo/toolkit add-module --repo <source-repo> --module <module-name> --source-type private|oca|external
130
131
  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.
131
- 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.
132
+ The view XML adds list/tree and form views; the menu XML adds a basic Odoo action and internal-user menu entry; the test skeleton adds a post-install TransactionCase smoke test.
133
+ WPMoo reports scaffold quality after generation and status reports installable modules, non-installable modules, and modules without actionable menus.
132
134
  Module names must be lower snake_case; use letters, numbers, and underscores only.
133
135
  Remove module:
134
136
  npx @wpmoo/toolkit remove-module --repo <source-repo> --module <module-name> --source-type private|oca|external
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { addModuleToSourceRepoInAddonsYaml, removeModuleFromSourceRepoInAddonsYaml, } from './addons-yaml.js';
4
4
  import { readEnvironmentMetadata, replaceSourceRepos } from './environment.js';
5
5
  import { realGit, stageAll } from './git.js';
6
+ import { analyzeModuleDirectory } from './module-quality.js';
6
7
  import { supportedOdooVersions } from './odoo-versions.js';
7
8
  import { pathUnderBase, validateModuleName, validateRepoPath } from './path-validation.js';
8
9
  import { listModuleRepos, readAddonsYaml, writeAddonsYaml } from './repo-actions.js';
@@ -138,8 +139,8 @@ function menuXmlContent(moduleName, odooVersion) {
138
139
  <field name="view_mode">${actionViewMode(odooVersion)}</field>
139
140
  </record>
140
141
 
141
- <menuitem id="menu_${moduleName}_root" name="${moduleTitle}" sequence="10"/>
142
- <menuitem id="menu_${moduleName}" name="${moduleTitle}" parent="menu_${moduleName}_root" action="action_${moduleName}" sequence="10"/>
142
+ <menuitem id="menu_${moduleName}_root" name="${moduleTitle}" groups="base.group_user" sequence="10"/>
143
+ <menuitem id="menu_${moduleName}" name="${moduleTitle}" parent="menu_${moduleName}_root" action="action_${moduleName}" groups="base.group_user" sequence="10"/>
143
144
  </odoo>
144
145
  `;
145
146
  }
@@ -176,6 +177,123 @@ async function writeIfMissing(path, content) {
176
177
  await writeFile(path, content, 'utf8');
177
178
  }
178
179
  }
180
+ async function fileContains(path, expected) {
181
+ try {
182
+ return (await readFile(path, 'utf8')).includes(expected);
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ async function moduleScaffoldChecks(target, sourceType, repoPath, moduleName, includeRegistration) {
189
+ const destination = modulePath(target, sourceType, repoPath, moduleName);
190
+ const technicalName = modelTechnicalName(moduleName);
191
+ const modelId = technicalName.replace(/\./g, '_');
192
+ const checks = [
193
+ {
194
+ id: 'manifest',
195
+ label: 'manifest',
196
+ ok: (await fileContains(join(destination, '__manifest__.py'), '"installable": True')) &&
197
+ (await fileContains(join(destination, '__manifest__.py'), '"security/ir.model.access.csv"')) &&
198
+ (await fileContains(join(destination, '__manifest__.py'), `"views/${moduleName}_views.xml"`)) &&
199
+ (await fileContains(join(destination, '__manifest__.py'), `"views/${moduleName}_menus.xml"`)),
200
+ details: 'missing installable flag or required data entries',
201
+ },
202
+ {
203
+ id: 'model',
204
+ label: 'model',
205
+ ok: (await fileContains(join(destination, '__init__.py'), 'from . import models')) &&
206
+ (await fileContains(join(destination, 'models/__init__.py'), `from . import ${moduleName}`)) &&
207
+ (await fileContains(join(destination, `models/${moduleName}.py`), `_name = "${technicalName}"`)),
208
+ details: `missing model import or _name ${technicalName}`,
209
+ },
210
+ {
211
+ id: 'access',
212
+ label: 'access',
213
+ ok: await fileContains(join(destination, 'security/ir.model.access.csv'), `model_${modelId}`),
214
+ details: `missing access CSV model_${modelId}`,
215
+ },
216
+ {
217
+ id: 'views',
218
+ label: 'views',
219
+ ok: (await fileContains(join(destination, `views/${moduleName}_views.xml`), `model">${technicalName}</field>`)) &&
220
+ (await fileContains(join(destination, `views/${moduleName}_views.xml`), '<form ')),
221
+ details: `missing views for ${technicalName}`,
222
+ },
223
+ {
224
+ id: 'menus',
225
+ label: 'menus',
226
+ ok: (await fileContains(join(destination, `views/${moduleName}_menus.xml`), `id="action_${moduleName}"`)) &&
227
+ (await fileContains(join(destination, `views/${moduleName}_menus.xml`), 'model="ir.actions.act_window"')) &&
228
+ (await fileContains(join(destination, `views/${moduleName}_menus.xml`), `action="action_${moduleName}"`)) &&
229
+ (await fileContains(join(destination, `views/${moduleName}_menus.xml`), 'groups="base.group_user"')),
230
+ details: `missing action menu action_${moduleName}`,
231
+ },
232
+ {
233
+ id: 'tests',
234
+ label: 'tests',
235
+ ok: (await fileContains(join(destination, 'tests/__init__.py'), `from . import test_${moduleName}`)) &&
236
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), '')),
237
+ details: 'missing generated test file',
238
+ },
239
+ ];
240
+ if (includeRegistration) {
241
+ checks.push({
242
+ id: 'registration',
243
+ label: 'registration',
244
+ ok: await moduleRegistrationPresent(target, sourceType, repoPath, moduleName),
245
+ details: 'missing module registration in addons.yaml, source manifest, or metadata',
246
+ });
247
+ }
248
+ return checks;
249
+ }
250
+ async function moduleRegistrationPresent(target, sourceType, repoPath, moduleName) {
251
+ if (sourceType === 'private' && (await usesAddonsYaml(target))) {
252
+ try {
253
+ const addonsYaml = await readAddonsYaml(target);
254
+ return addonsYaml.includes(`private/${repoPath}:`) && addonsYaml.includes(` - ${moduleName}`);
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ }
260
+ const manifest = await readSourceManifest(target);
261
+ if (manifest.sources.some((entry) => entry.type === sourceType && entry.path === repoPath && entry.addons.includes(moduleName))) {
262
+ return true;
263
+ }
264
+ const metadata = await readEnvironmentMetadata(target);
265
+ return Boolean(metadata?.sourceRepos?.some((entry) => normalizeSourceType(entry.sourceType) === sourceType &&
266
+ entry.path === repoPath &&
267
+ entry.addons.includes(moduleName)));
268
+ }
269
+ function buildModuleScaffoldReport(moduleName, repoPath, sourceType, path, checks) {
270
+ return {
271
+ moduleName,
272
+ repoPath,
273
+ sourceType,
274
+ path,
275
+ checks: checks.map(({ details, ...check }) => (check.ok ? check : { ...check, details })),
276
+ warnings: checks.filter((check) => !check.ok).map((check) => `${check.label} ${check.details ?? 'failed'}`),
277
+ summary: `Module scaffold checks passed: ${checks.map((check) => check.label).join(', ')}.`,
278
+ };
279
+ }
280
+ async function assertGeneratedModuleScaffold(target, sourceType, repoPath, moduleName) {
281
+ const quality = await analyzeModuleDirectory(modulePath(target, sourceType, repoPath, moduleName), moduleName, `odoo/custom/src/${sourceType}/${repoPath}/${moduleName}`);
282
+ const checks = await moduleScaffoldChecks(target, sourceType, repoPath, moduleName, false);
283
+ const failed = checks.filter((check) => !check.ok);
284
+ if (!quality.installable) {
285
+ failed.unshift({ id: 'manifest-installable', label: 'manifest', ok: false, details: 'missing installable=True in __manifest__.py' });
286
+ }
287
+ if (!quality.hasMenuAction) {
288
+ failed.unshift({ id: 'menu-action', label: 'menus', ok: false, details: `missing action menu action_${moduleName}` });
289
+ }
290
+ if (failed.length > 0) {
291
+ throw new Error(`Generated module scaffold validation failed for ${moduleName}: ${failed[0]?.label ?? 'unknown'} ${failed[0]?.details ?? 'failed'}`);
292
+ }
293
+ }
294
+ export function renderModuleScaffoldReport(report) {
295
+ return report.summary;
296
+ }
179
297
  async function usesAddonsYaml(target) {
180
298
  const metadata = await readEnvironmentMetadata(target);
181
299
  return metadata?.engine !== 'compose';
@@ -233,6 +351,20 @@ async function updateModuleRegistration(target, sourceType, repoPath, moduleName
233
351
  await updateSourceManifestModuleRegistration(target, sourceType, repoPath, moduleName, mode);
234
352
  await updateMetadataModuleRegistration(target, sourceType, repoPath, moduleName, mode);
235
353
  }
354
+ async function assertModuleCleanBeforeDelete(target, sourceType, repoPath, moduleName, git) {
355
+ const repoRoot = sourceRepoPath(target, sourceType, repoPath);
356
+ try {
357
+ const result = await git.run(repoRoot, ['status', '--short', '--', moduleName]);
358
+ if (result.stdout.trim()) {
359
+ throw new Error(`Refusing to delete module ${moduleName} because it has dirty git changes in source repo ${repoPath}.`);
360
+ }
361
+ }
362
+ catch (error) {
363
+ if (error instanceof Error && error.message.startsWith('Refusing to delete module ')) {
364
+ throw error;
365
+ }
366
+ }
367
+ }
236
368
  export async function addModuleToSourceRepo(options, git = realGit) {
237
369
  const repoPath = validateRepoPath(options.repoPath);
238
370
  const moduleName = validateModuleName(options.moduleName);
@@ -253,6 +385,7 @@ export async function addModuleToSourceRepo(options, git = realGit) {
253
385
  await writeIfMissing(join(destination, `views/${moduleName}_views.xml`), viewXmlContent(moduleName, odooVersion));
254
386
  await writeIfMissing(join(destination, `views/${moduleName}_menus.xml`), menuXmlContent(moduleName, odooVersion));
255
387
  await writeIfMissing(join(destination, 'views/.gitkeep'), '');
388
+ await assertGeneratedModuleScaffold(options.target, sourceType, repoPath, moduleName);
256
389
  if (sourceType === 'private' && (await usesAddonsYaml(options.target))) {
257
390
  const addonsYaml = await readAddonsYaml(options.target);
258
391
  await writeAddonsYaml(options.target, addModuleToSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
@@ -262,6 +395,7 @@ export async function addModuleToSourceRepo(options, git = realGit) {
262
395
  await stageAll(git, sourceRepoPath(options.target, sourceType, repoPath));
263
396
  await stageAll(git, options.target);
264
397
  }
398
+ return buildModuleScaffoldReport(moduleName, repoPath, sourceType, destination, await moduleScaffoldChecks(options.target, sourceType, repoPath, moduleName, true));
265
399
  }
266
400
  export async function listModulesInSourceRepo(target, repoPath, sourceType) {
267
401
  const safeRepoPath = validateRepoPath(repoPath);
@@ -319,6 +453,9 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
319
453
  const repoPath = validateRepoPath(options.repoPath);
320
454
  const moduleName = validateModuleName(options.moduleName);
321
455
  const sourceType = normalizeSourceType(options.sourceType);
456
+ if (options.deleteFiles) {
457
+ await assertModuleCleanBeforeDelete(options.target, sourceType, repoPath, moduleName, git);
458
+ }
322
459
  if (sourceType === 'private' && (await usesAddonsYaml(options.target))) {
323
460
  const addonsYaml = await readAddonsYaml(options.target);
324
461
  await writeAddonsYaml(options.target, removeModuleFromSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
@@ -0,0 +1,111 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { basename, join, relative } from 'node:path';
3
+ export function emptyModuleQualitySummary() {
4
+ return {
5
+ totalModules: 0,
6
+ installableModules: 0,
7
+ nonInstallableModules: 0,
8
+ modulesWithMenuActions: 0,
9
+ modulesMissingMenuActions: 0,
10
+ issues: [],
11
+ };
12
+ }
13
+ export function isInstallableManifest(content) {
14
+ return /["']installable["']\s*:\s*(?:True|true)\b/u.test(content);
15
+ }
16
+ export function hasActionableMenuXml(content, moduleName) {
17
+ const actionId = `action_${moduleName}`;
18
+ return (content.includes(`id="${actionId}"`) &&
19
+ content.includes('model="ir.actions.act_window"') &&
20
+ content.includes(`action="${actionId}"`));
21
+ }
22
+ async function readMenusXml(modulePath) {
23
+ try {
24
+ const entries = await readdir(join(modulePath, 'views'), { withFileTypes: true });
25
+ const menuFiles = entries
26
+ .filter((entry) => entry.isFile() && entry.name.endsWith('_menus.xml'))
27
+ .map((entry) => join(modulePath, 'views', entry.name));
28
+ return Promise.all(menuFiles.map((path) => readFile(path, 'utf8')));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
34
+ export async function analyzeModuleDirectory(modulePath, moduleName = basename(modulePath), relativePath = modulePath) {
35
+ const issues = [];
36
+ let installable = false;
37
+ try {
38
+ installable = isInstallableManifest(await readFile(join(modulePath, '__manifest__.py'), 'utf8'));
39
+ }
40
+ catch {
41
+ installable = false;
42
+ }
43
+ if (!installable) {
44
+ issues.push({
45
+ moduleName,
46
+ path: relativePath,
47
+ issue: 'missing installable=True in __manifest__.py',
48
+ });
49
+ }
50
+ const menuXml = await readMenusXml(modulePath);
51
+ const hasMenuAction = menuXml.some((content) => hasActionableMenuXml(content, moduleName));
52
+ if (!hasMenuAction) {
53
+ issues.push({
54
+ moduleName,
55
+ path: relativePath,
56
+ issue: 'missing actionable menu XML',
57
+ });
58
+ }
59
+ return { moduleName, relativePath, installable, hasMenuAction, issues };
60
+ }
61
+ export function addModuleQualityResult(summary, result) {
62
+ return {
63
+ totalModules: summary.totalModules + 1,
64
+ installableModules: summary.installableModules + (result.installable ? 1 : 0),
65
+ nonInstallableModules: summary.nonInstallableModules + (result.installable ? 0 : 1),
66
+ modulesWithMenuActions: summary.modulesWithMenuActions + (result.hasMenuAction ? 1 : 0),
67
+ modulesMissingMenuActions: summary.modulesMissingMenuActions + (result.hasMenuAction ? 0 : 1),
68
+ issues: [...summary.issues, ...result.issues],
69
+ };
70
+ }
71
+ export function mergeModuleQualitySummaries(left, right) {
72
+ return {
73
+ totalModules: left.totalModules + right.totalModules,
74
+ installableModules: left.installableModules + right.installableModules,
75
+ nonInstallableModules: left.nonInstallableModules + right.nonInstallableModules,
76
+ modulesWithMenuActions: left.modulesWithMenuActions + right.modulesWithMenuActions,
77
+ modulesMissingMenuActions: left.modulesMissingMenuActions + right.modulesMissingMenuActions,
78
+ issues: [...left.issues, ...right.issues],
79
+ };
80
+ }
81
+ export async function scanModuleQuality(root, target) {
82
+ try {
83
+ const rootStat = await stat(root);
84
+ if (!rootStat.isDirectory())
85
+ return emptyModuleQualitySummary();
86
+ }
87
+ catch {
88
+ return emptyModuleQualitySummary();
89
+ }
90
+ let summary = emptyModuleQualitySummary();
91
+ const stack = [root];
92
+ while (stack.length > 0) {
93
+ const current = stack.pop();
94
+ if (!current)
95
+ continue;
96
+ const entries = await readdir(current, { withFileTypes: true });
97
+ let hasManifest = false;
98
+ for (const entry of entries) {
99
+ if (entry.isFile() && entry.name === '__manifest__.py') {
100
+ hasManifest = true;
101
+ }
102
+ else if (entry.isDirectory()) {
103
+ stack.push(join(current, entry.name));
104
+ }
105
+ }
106
+ if (hasManifest) {
107
+ summary = addModuleQualityResult(summary, await analyzeModuleDirectory(current, basename(current), relative(target, current)));
108
+ }
109
+ }
110
+ return summary;
111
+ }
package/dist/status.js CHANGED
@@ -1,7 +1,8 @@
1
- import { access, readdir, readFile, stat } from 'node:fs/promises';
1
+ import { access, readFile, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
4
4
  import { defaultOdooVersion, markerPath } from './environment.js';
5
+ import { emptyModuleQualitySummary, mergeModuleQualitySummaries, scanModuleQuality, } from './module-quality.js';
5
6
  import { isValidPathSegment, validateRepoPath } from './path-validation.js';
6
7
  const validSourceTypes = ['private', 'oca', 'external'];
7
8
  function normalizeSourceType(sourceType) {
@@ -84,33 +85,6 @@ async function missingCoreFiles(target, odooVersion) {
84
85
  missing.push(...composeLayout.missingFiles);
85
86
  return { missing, composeFiles: composeLayout.files, composeErrors: composeLayout.errors };
86
87
  }
87
- async function countModuleCandidatesInRepoPath(path) {
88
- if (!(await pathExists(path)))
89
- return 0;
90
- const rootStat = await stat(path);
91
- if (!rootStat.isDirectory())
92
- return 0;
93
- let count = 0;
94
- const stack = [path];
95
- while (stack.length > 0) {
96
- const current = stack.pop();
97
- if (!current)
98
- continue;
99
- const entries = await readdir(current, { withFileTypes: true });
100
- let hasManifest = false;
101
- for (const entry of entries) {
102
- if (entry.isFile() && entry.name === '__manifest__.py') {
103
- hasManifest = true;
104
- }
105
- else if (entry.isDirectory()) {
106
- stack.push(join(current, entry.name));
107
- }
108
- }
109
- if (hasManifest)
110
- count += 1;
111
- }
112
- return count;
113
- }
114
88
  function summaryText(status) {
115
89
  if (status.kind === 'no_environment')
116
90
  return 'No WPMoo environment detected.';
@@ -167,10 +141,11 @@ export async function getEnvironmentStatus(target) {
167
141
  : defaultOdooVersion;
168
142
  const { sourceRepoPaths, sourceRepoLocations, invalidSourceRepoPaths } = sourceRepoPathsFromMetadata(metadata);
169
143
  const repoRoots = sourceRepoLocations.map(({ sourceType, path }) => sourceRepoPath(target, sourceType, path));
170
- let moduleCandidateCount = 0;
144
+ let moduleQuality = emptyModuleQualitySummary();
171
145
  for (const repoRoot of repoRoots) {
172
- moduleCandidateCount += await countModuleCandidatesInRepoPath(repoRoot);
146
+ moduleQuality = mergeModuleQualitySummaries(moduleQuality, await scanModuleQuality(repoRoot, target));
173
147
  }
148
+ const moduleCandidateCount = moduleQuality.totalModules;
174
149
  const { missing: missingFiles, composeFiles, composeErrors, } = await missingCoreFiles(target, odooVersion);
175
150
  let recommendedNextAction = 'Run npx @wpmoo/toolkit doctor for deep checks or ./moo start.';
176
151
  if (invalidSourceRepoPaths.length > 0) {
@@ -195,6 +170,7 @@ export async function getEnvironmentStatus(target) {
195
170
  sourceRepoPaths,
196
171
  invalidSourceRepoPaths,
197
172
  moduleCandidateCount,
173
+ moduleQuality,
198
174
  composeFiles,
199
175
  composeErrors,
200
176
  missingCoreFiles: missingFiles,
@@ -229,6 +205,12 @@ export function renderEnvironmentStatus(status) {
229
205
  lines.push(`Invalid source repo paths: ${status.invalidSourceRepoPaths.join(', ')}`);
230
206
  }
231
207
  lines.push(`Module candidates: ${status.moduleCandidateCount}`);
208
+ lines.push(`Module quality: ${status.moduleQuality.installableModules} installable, ${status.moduleQuality.nonInstallableModules} non-installable, ${status.moduleQuality.modulesMissingMenuActions} missing menu actions.`);
209
+ if (status.moduleQuality.issues.length > 0) {
210
+ lines.push(`Module quality issues: ${status.moduleQuality.issues
211
+ .map((issue) => `${issue.path}: ${issue.issue}`)
212
+ .join('; ')}`);
213
+ }
232
214
  lines.push(`Missing core files: ${status.missingCoreFiles.length > 0 ? status.missingCoreFiles.join(', ') : '(none)'}`);
233
215
  lines.push(`Next: ${status.recommendedNextAction}`);
234
216
  return lines.join('\n');
package/dist/templates.js CHANGED
@@ -596,6 +596,23 @@ allow_prod_lifecycle() {
596
596
  [[ "$value" == "1" ]]
597
597
  }
598
598
 
599
+ allow_stage_lifecycle() {
600
+ local value="\${WPMOO_ALLOW_STAGE_LIFECYCLE:-$(env_file_value WPMOO_ALLOW_STAGE_LIFECYCLE)}"
601
+ [[ "$value" == "1" ]]
602
+ }
603
+
604
+ require_stage_lifecycle_allowed() {
605
+ local command="$1"
606
+ local env_name
607
+ env_name="$(selected_env)"
608
+ if [[ "$env_name" == "stage" ]]; then
609
+ if ! allow_stage_lifecycle; then
610
+ echo "Refusing stage lifecycle command '$command' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally." >&2
611
+ exit 1
612
+ fi
613
+ fi
614
+ }
615
+
599
616
  require_prod_lifecycle_allowed() {
600
617
  local command="$1"
601
618
  local env_name
@@ -676,12 +693,14 @@ case "$command" in
676
693
  "install")
677
694
  shift
678
695
  require_module_args "$command" "$@"
696
+ require_stage_lifecycle_allowed "$command"
679
697
  require_prod_lifecycle_allowed "$command"
680
698
  run_script ./scripts/install.sh "$@"
681
699
  ;;
682
700
  "update")
683
701
  shift
684
702
  require_module_args "$command" "$@"
703
+ require_stage_lifecycle_allowed "$command"
685
704
  require_prod_lifecycle_allowed "$command"
686
705
  run_script ./scripts/update.sh "$@"
687
706
  ;;
@@ -744,7 +763,7 @@ cd "$root_dir"
744
763
 
745
764
  node --input-type=module - "$@" <<'NODE'
746
765
  import { access, readdir, readFile, stat } from 'node:fs/promises';
747
- import { isAbsolute, join } from 'node:path';
766
+ import { basename, isAbsolute, join, relative } from 'node:path';
748
767
 
749
768
  const args = process.argv.slice(2);
750
769
  if (!args.every((arg) => arg === '--json')) {
@@ -797,10 +816,100 @@ function normalizeSourceType(sourceType) {
797
816
  return typeof sourceType === 'string' && validSourceTypes.has(sourceType) ? sourceType : 'private';
798
817
  }
799
818
 
800
- async function countModuleCandidates(root) {
801
- if (!(await isDirectory(root))) return 0;
819
+ function emptyModuleQuality() {
820
+ return {
821
+ totalModules: 0,
822
+ installableModules: 0,
823
+ nonInstallableModules: 0,
824
+ modulesWithMenuActions: 0,
825
+ modulesMissingMenuActions: 0,
826
+ issues: [],
827
+ };
828
+ }
829
+
830
+ function manifestIsInstallable(content) {
831
+ return /["']installable["']\\s*:\\s*(?:True|true)\\b/.test(content);
832
+ }
833
+
834
+ function menuXmlHasAction(content, moduleName) {
835
+ const actionId = 'action_' + moduleName;
836
+ return (
837
+ content.includes('id="' + actionId + '"') &&
838
+ content.includes('model="ir.actions.act_window"') &&
839
+ content.includes('action="' + actionId + '"')
840
+ );
841
+ }
842
+
843
+ async function readMenuXmlFiles(modulePath) {
844
+ try {
845
+ const entries = await readdir(join(modulePath, 'views'), { withFileTypes: true });
846
+ return Promise.all(
847
+ entries
848
+ .filter((entry) => entry.isFile() && entry.name.endsWith('_menus.xml'))
849
+ .map((entry) => readFile(join(modulePath, 'views', entry.name), 'utf8')),
850
+ );
851
+ } catch {
852
+ return [];
853
+ }
854
+ }
855
+
856
+ async function analyzeModule(modulePath) {
857
+ const moduleName = basename(modulePath);
858
+ const moduleRelativePath = relative(target, modulePath);
859
+ const issues = [];
860
+ let installable = false;
861
+ try {
862
+ installable = manifestIsInstallable(await readFile(join(modulePath, '__manifest__.py'), 'utf8'));
863
+ } catch {
864
+ installable = false;
865
+ }
866
+ if (!installable) {
867
+ issues.push({
868
+ moduleName,
869
+ path: moduleRelativePath,
870
+ issue: 'missing installable=True in __manifest__.py',
871
+ });
872
+ }
873
+
874
+ const menuXml = await readMenuXmlFiles(modulePath);
875
+ const hasMenuAction = menuXml.some((content) => menuXmlHasAction(content, moduleName));
876
+ if (!hasMenuAction) {
877
+ issues.push({
878
+ moduleName,
879
+ path: moduleRelativePath,
880
+ issue: 'missing actionable menu XML',
881
+ });
882
+ }
883
+
884
+ return { installable, hasMenuAction, issues };
885
+ }
886
+
887
+ function addModuleQuality(summary, result) {
888
+ return {
889
+ totalModules: summary.totalModules + 1,
890
+ installableModules: summary.installableModules + (result.installable ? 1 : 0),
891
+ nonInstallableModules: summary.nonInstallableModules + (result.installable ? 0 : 1),
892
+ modulesWithMenuActions: summary.modulesWithMenuActions + (result.hasMenuAction ? 1 : 0),
893
+ modulesMissingMenuActions: summary.modulesMissingMenuActions + (result.hasMenuAction ? 0 : 1),
894
+ issues: [...summary.issues, ...result.issues],
895
+ };
896
+ }
897
+
898
+ function mergeModuleQuality(left, right) {
899
+ return {
900
+ totalModules: left.totalModules + right.totalModules,
901
+ installableModules: left.installableModules + right.installableModules,
902
+ nonInstallableModules: left.nonInstallableModules + right.nonInstallableModules,
903
+ modulesWithMenuActions: left.modulesWithMenuActions + right.modulesWithMenuActions,
904
+ modulesMissingMenuActions: left.modulesMissingMenuActions + right.modulesMissingMenuActions,
905
+ issues: [...left.issues, ...right.issues],
906
+ };
907
+ }
908
+
909
+ async function scanModuleQuality(root) {
910
+ if (!(await isDirectory(root))) return emptyModuleQuality();
802
911
  const stack = [root];
803
- let count = 0;
912
+ let summary = emptyModuleQuality();
804
913
 
805
914
  while (stack.length > 0) {
806
915
  const current = stack.pop();
@@ -815,10 +924,12 @@ async function countModuleCandidates(root) {
815
924
  }
816
925
  }
817
926
 
818
- if (hasManifest) count += 1;
927
+ if (hasManifest) {
928
+ summary = addModuleQuality(summary, await analyzeModule(current));
929
+ }
819
930
  }
820
931
 
821
- return count;
932
+ return summary;
822
933
  }
823
934
 
824
935
  function parseEnvContent(content) {
@@ -985,6 +1096,21 @@ function renderStatus(status) {
985
1096
  lines.push('Invalid source repo paths: ' + status.invalidSourceRepoPaths.join(', '));
986
1097
  }
987
1098
  lines.push('Module candidates: ' + status.moduleCandidateCount);
1099
+ lines.push(
1100
+ 'Module quality: ' +
1101
+ status.moduleQuality.installableModules +
1102
+ ' installable, ' +
1103
+ status.moduleQuality.nonInstallableModules +
1104
+ ' non-installable, ' +
1105
+ status.moduleQuality.modulesMissingMenuActions +
1106
+ ' missing menu actions.',
1107
+ );
1108
+ if (status.moduleQuality.issues.length > 0) {
1109
+ lines.push(
1110
+ 'Module quality issues: ' +
1111
+ status.moduleQuality.issues.map((issue) => issue.path + ': ' + issue.issue).join('; '),
1112
+ );
1113
+ }
988
1114
  lines.push('Missing core files: ' + (status.missingCoreFiles.length > 0 ? status.missingCoreFiles.join(', ') : '(none)'));
989
1115
  lines.push('Next: ' + status.recommendedNextAction);
990
1116
  return lines.join('\\n');
@@ -1033,10 +1159,14 @@ async function getStatus() {
1033
1159
  sourceRepoLocations.push({ sourceType, path });
1034
1160
  }
1035
1161
 
1036
- let moduleCandidateCount = 0;
1162
+ let moduleQuality = emptyModuleQuality();
1037
1163
  for (const repo of sourceRepoLocations) {
1038
- moduleCandidateCount += await countModuleCandidates(join(target, 'odoo/custom/src', repo.sourceType, repo.path));
1164
+ moduleQuality = mergeModuleQuality(
1165
+ moduleQuality,
1166
+ await scanModuleQuality(join(target, 'odoo/custom/src', repo.sourceType, repo.path)),
1167
+ );
1039
1168
  }
1169
+ const moduleCandidateCount = moduleQuality.totalModules;
1040
1170
 
1041
1171
  const { missing, composeFiles, composeErrors } = await coreFileIssues(odooVersion);
1042
1172
  let recommendedNextAction = 'Run ./moo doctor for deep checks or ./moo start.';
@@ -1059,6 +1189,7 @@ async function getStatus() {
1059
1189
  sourceRepoPaths,
1060
1190
  invalidSourceRepoPaths,
1061
1191
  moduleCandidateCount,
1192
+ moduleQuality,
1062
1193
  composeFiles,
1063
1194
  composeErrors,
1064
1195
  missingCoreFiles: missing,
package/docs/handoff.md CHANGED
@@ -23,6 +23,27 @@ Publishing is handled by the `Publish` GitHub Actions workflow through npm
23
23
  Trusted Publishing after the tag is pushed. Do not run `npm publish` manually
24
24
  unless a coordinator explicitly requests a fallback.
25
25
 
26
+ Required release artifacts:
27
+
28
+ - `@wpmoo/toolkit`
29
+ - `@wpmoo/odoo`
30
+ - `@wpmoo/odoo-dev`
31
+
32
+ The optional `wpmoo` short alias is warning-only. If npm returns `E404` or
33
+ otherwise rejects that alias, the release remains valid when the required
34
+ scoped packages publish and verify correctly.
35
+
36
+ Verify a tagged release with:
37
+
38
+ ```bash
39
+ npm view "@wpmoo/toolkit@$VERSION" version
40
+ npm view "@wpmoo/odoo@$VERSION" version
41
+ npm view "@wpmoo/odoo-dev@$VERSION" version
42
+ ```
43
+
44
+ `npm view "wpmoo@$VERSION" version` is optional and may report that the short
45
+ alias is absent.
46
+
26
47
  Current command standard:
27
48
 
28
49
  - Use `npx @wpmoo/toolkit ...` for package/operator commands.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {