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