@wpmoo/toolkit 0.9.15 → 0.9.17
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 +15 -1
- package/dist/cli.js +7 -4
- package/dist/daily-actions.js +21 -0
- package/dist/doctor.js +88 -5
- package/dist/help.js +4 -2
- package/dist/module-actions.js +148 -2
- package/dist/module-quality.js +111 -0
- package/dist/status.js +12 -30
- package/dist/templates.js +139 -8
- package/docs/handoff.md +21 -0
- package/package.json +1 -1
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
|
@@ -856,7 +856,7 @@ async function removeModuleOptionsFromPrompts(showIntro = true, cancelAction = '
|
|
|
856
856
|
message: menuPromptMessage('Delete module files too?', cancelAction),
|
|
857
857
|
active: 'Y',
|
|
858
858
|
inactive: 'n',
|
|
859
|
-
initialValue:
|
|
859
|
+
initialValue: cancelAction === 'back',
|
|
860
860
|
});
|
|
861
861
|
handleCancel(deleteFiles, cancelAction);
|
|
862
862
|
return {
|
|
@@ -1234,7 +1234,7 @@ async function runSelectedModuleAction(action, module, cwd) {
|
|
|
1234
1234
|
message: menuPromptMessage('Delete module files too?', 'back'),
|
|
1235
1235
|
active: 'Y',
|
|
1236
1236
|
inactive: 'n',
|
|
1237
|
-
initialValue:
|
|
1237
|
+
initialValue: true,
|
|
1238
1238
|
});
|
|
1239
1239
|
handleCancel(deleteFiles, 'back');
|
|
1240
1240
|
const removeCommand = cockpitCommands.find((entry) => entry.id === 'remove-module');
|
|
@@ -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
|
-
|
|
1634
|
-
console.error(message);
|
|
1637
|
+
console.error(formatCliErrorMessage(error));
|
|
1635
1638
|
process.exit(1);
|
|
1636
1639
|
});
|
|
1637
1640
|
}
|
package/dist/daily-actions.js
CHANGED
|
@@ -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 '
|
|
99
|
-
WHEN '
|
|
100
|
-
WHEN '
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/module-actions.js
CHANGED
|
@@ -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,29 @@ 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() && (await moduleHasCommittedFiles(repoRoot, moduleName, git))) {
|
|
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
|
+
}
|
|
368
|
+
async function moduleHasCommittedFiles(repoRoot, moduleName, git) {
|
|
369
|
+
try {
|
|
370
|
+
const result = await git.run(repoRoot, ['ls-tree', '-r', '--name-only', 'HEAD', '--', moduleName]);
|
|
371
|
+
return Boolean(result.stdout.trim());
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
236
377
|
export async function addModuleToSourceRepo(options, git = realGit) {
|
|
237
378
|
const repoPath = validateRepoPath(options.repoPath);
|
|
238
379
|
const moduleName = validateModuleName(options.moduleName);
|
|
@@ -253,6 +394,7 @@ export async function addModuleToSourceRepo(options, git = realGit) {
|
|
|
253
394
|
await writeIfMissing(join(destination, `views/${moduleName}_views.xml`), viewXmlContent(moduleName, odooVersion));
|
|
254
395
|
await writeIfMissing(join(destination, `views/${moduleName}_menus.xml`), menuXmlContent(moduleName, odooVersion));
|
|
255
396
|
await writeIfMissing(join(destination, 'views/.gitkeep'), '');
|
|
397
|
+
await assertGeneratedModuleScaffold(options.target, sourceType, repoPath, moduleName);
|
|
256
398
|
if (sourceType === 'private' && (await usesAddonsYaml(options.target))) {
|
|
257
399
|
const addonsYaml = await readAddonsYaml(options.target);
|
|
258
400
|
await writeAddonsYaml(options.target, addModuleToSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
|
|
@@ -262,6 +404,7 @@ export async function addModuleToSourceRepo(options, git = realGit) {
|
|
|
262
404
|
await stageAll(git, sourceRepoPath(options.target, sourceType, repoPath));
|
|
263
405
|
await stageAll(git, options.target);
|
|
264
406
|
}
|
|
407
|
+
return buildModuleScaffoldReport(moduleName, repoPath, sourceType, destination, await moduleScaffoldChecks(options.target, sourceType, repoPath, moduleName, true));
|
|
265
408
|
}
|
|
266
409
|
export async function listModulesInSourceRepo(target, repoPath, sourceType) {
|
|
267
410
|
const safeRepoPath = validateRepoPath(repoPath);
|
|
@@ -319,6 +462,9 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
|
|
|
319
462
|
const repoPath = validateRepoPath(options.repoPath);
|
|
320
463
|
const moduleName = validateModuleName(options.moduleName);
|
|
321
464
|
const sourceType = normalizeSourceType(options.sourceType);
|
|
465
|
+
if (options.deleteFiles) {
|
|
466
|
+
await assertModuleCleanBeforeDelete(options.target, sourceType, repoPath, moduleName, git);
|
|
467
|
+
}
|
|
322
468
|
if (sourceType === 'private' && (await usesAddonsYaml(options.target))) {
|
|
323
469
|
const addonsYaml = await readAddonsYaml(options.target);
|
|
324
470
|
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,
|
|
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
|
|
144
|
+
let moduleQuality = emptyModuleQualitySummary();
|
|
171
145
|
for (const repoRoot of repoRoots) {
|
|
172
|
-
|
|
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
|
-
|
|
801
|
-
|
|
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
|
|
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)
|
|
927
|
+
if (hasManifest) {
|
|
928
|
+
summary = addModuleQuality(summary, await analyzeModule(current));
|
|
929
|
+
}
|
|
819
930
|
}
|
|
820
931
|
|
|
821
|
-
return
|
|
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
|
|
1162
|
+
let moduleQuality = emptyModuleQuality();
|
|
1037
1163
|
for (const repo of sourceRepoLocations) {
|
|
1038
|
-
|
|
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.
|