@wpmoo/toolkit 0.9.8 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/dist/cli.js +21 -3
- package/dist/daily-actions.js +26 -1
- package/dist/doctor.js +145 -0
- package/dist/help.js +11 -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 +33 -0
- package/docs/generated-environment-verification.md +16 -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
|
|
|
@@ -146,6 +151,8 @@ npx @wpmoo/toolkit add-module --repo sale-workflow --module sale_order_line_no_d
|
|
|
146
151
|
npx @wpmoo/toolkit remove-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
|
|
147
152
|
```
|
|
148
153
|
|
|
154
|
+
`add-module` creates a minimal Odoo module skeleton with `__init__.py`, `__manifest__.py`, `models/<module>.py`, `models/__init__.py`, `security/ir.model.access.csv`, `views/<module>_views.xml`, `views/<module>_menus.xml`, and `tests/test_<module>.py`. The view XML adds list/tree and form views; the menu XML adds a basic Odoo action and menu entry; the test skeleton adds a post-install TransactionCase smoke test. Module names must be lower `snake_case`; use letters, numbers, and underscores only.
|
|
155
|
+
|
|
149
156
|
For automation and VS Code cockpit integration, selected commands support JSON output:
|
|
150
157
|
|
|
151
158
|
```bash
|
|
@@ -153,9 +160,15 @@ npx @wpmoo/toolkit status --json
|
|
|
153
160
|
npx @wpmoo/toolkit source list --json
|
|
154
161
|
npx @wpmoo/toolkit source sync --json
|
|
155
162
|
npx @wpmoo/toolkit doctor --json
|
|
163
|
+
npx @wpmoo/toolkit doctor --postgres
|
|
164
|
+
npx @wpmoo/toolkit doctor --json --postgres
|
|
156
165
|
```
|
|
157
166
|
|
|
158
167
|
JSON output is optional; human-readable output remains the default.
|
|
168
|
+
`doctor --postgres` adds read-only PostgreSQL health and performance diagnostics
|
|
169
|
+
such as database size, active connections, slow-query readiness, extension
|
|
170
|
+
visibility, and settings.
|
|
171
|
+
`doctor --json --postgres` includes a structured `postgres` object for automation.
|
|
159
172
|
|
|
160
173
|
## Documentation
|
|
161
174
|
|
package/dist/cli.js
CHANGED
|
@@ -42,6 +42,7 @@ import { environmentStatusJson, getEnvironmentStatus, renderEnvironmentStatusFor
|
|
|
42
42
|
import { getGitHubAccounts, getGitHubRepositoryStatus, githubRepositoryUrl, realGitHub, createGitHubRepository, } from './github.js';
|
|
43
43
|
import { environmentGitHubOwner } from './environment-context.js';
|
|
44
44
|
import { handlePromptCancel, handleUnavailableMenuChoice, installPromptCancelKeyTracker, isMenuBackSignal, MenuBackSignal, menuIntroTitle, menuPromptMessage, } from './menu-navigation.js';
|
|
45
|
+
import { validateModuleName } from './path-validation.js';
|
|
45
46
|
function handleCancel(value, action) {
|
|
46
47
|
handlePromptCancel(isPromptCancel(value), action);
|
|
47
48
|
}
|
|
@@ -635,6 +636,15 @@ async function selectSourceRepo(target, cancelAction = 'exit') {
|
|
|
635
636
|
function suggestedModuleName(repoPath) {
|
|
636
637
|
return 'odoo_sample_module';
|
|
637
638
|
}
|
|
639
|
+
function validateModuleNameInput(value) {
|
|
640
|
+
try {
|
|
641
|
+
validateModuleName(value);
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
return error instanceof Error ? error.message : 'Invalid module name.';
|
|
646
|
+
}
|
|
647
|
+
}
|
|
638
648
|
async function addModuleOptionsFromArgs(argv) {
|
|
639
649
|
const { values } = parseArgs(argv);
|
|
640
650
|
const repoPath = stringOption(values, 'repo') ?? stringOption(values, 'sourcePath');
|
|
@@ -659,7 +669,7 @@ async function addModuleOptionsFromPrompts(showIntro = true, cancelAction = 'exi
|
|
|
659
669
|
const moduleName = asString(await textPrompt({
|
|
660
670
|
message: menuPromptMessage('Module name', cancelAction),
|
|
661
671
|
placeholder: suggestedModuleName(sourceRepo.repoPath),
|
|
662
|
-
validate:
|
|
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,28 @@ function scriptArgs(command, argv) {
|
|
|
149
150
|
return ensureNoArgs(command, argv);
|
|
150
151
|
return positionalArgs(command, argv, 1, 3);
|
|
151
152
|
}
|
|
153
|
+
function isDestructiveCommand(command, args) {
|
|
154
|
+
if (command === 'resetdb')
|
|
155
|
+
return true;
|
|
156
|
+
return command === 'restore-snapshot' && args[0] !== '--dry-run';
|
|
157
|
+
}
|
|
158
|
+
function destructiveCommandError(command, envName) {
|
|
159
|
+
return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
|
|
160
|
+
}
|
|
161
|
+
async function assertDestructiveCommandAllowed(command, args, cwd) {
|
|
162
|
+
if (!isDestructiveCommand(command, args)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const env = await readEnvFile(cwd);
|
|
166
|
+
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
167
|
+
if (envName !== 'stage' && envName !== 'prod') {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const allowDestructive = process.env.WPMOO_ALLOW_DESTRUCTIVE?.trim() || env?.get('WPMOO_ALLOW_DESTRUCTIVE')?.trim();
|
|
171
|
+
if (allowDestructive !== '1') {
|
|
172
|
+
throw new Error(destructiveCommandError(command, envName));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
152
175
|
async function assertEnvironmentRoot(cwd) {
|
|
153
176
|
try {
|
|
154
177
|
await access(join(cwd, markerPath));
|
|
@@ -170,10 +193,12 @@ async function assertScriptExists(cwd, script) {
|
|
|
170
193
|
export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
|
|
171
194
|
await assertEnvironmentRoot(cwd);
|
|
172
195
|
const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
|
|
196
|
+
const args = scriptArgs(command, argv);
|
|
197
|
+
await assertDestructiveCommandAllowed(command, args, cwd);
|
|
173
198
|
return {
|
|
174
199
|
cwd,
|
|
175
200
|
scriptPath,
|
|
176
|
-
args
|
|
201
|
+
args,
|
|
177
202
|
};
|
|
178
203
|
}
|
|
179
204
|
async function spawnDailyAction(plan) {
|
package/dist/doctor.js
CHANGED
|
@@ -46,6 +46,57 @@ function isMetadataError(message) {
|
|
|
46
46
|
message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
|
|
47
47
|
}
|
|
48
48
|
const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
|
|
49
|
+
const postgresDiagnosticQuery = `
|
|
50
|
+
WITH metrics(metric, value) AS (
|
|
51
|
+
SELECT 'database_count', count(*)::text
|
|
52
|
+
FROM pg_database
|
|
53
|
+
WHERE datistemplate = false
|
|
54
|
+
UNION ALL
|
|
55
|
+
SELECT 'active_connections', count(*)::text
|
|
56
|
+
FROM pg_stat_activity
|
|
57
|
+
WHERE datname IS NOT NULL
|
|
58
|
+
UNION ALL
|
|
59
|
+
SELECT 'total_database_size_bytes', COALESCE(sum(pg_database_size(datname)), 0)::text
|
|
60
|
+
FROM pg_database
|
|
61
|
+
WHERE datistemplate = false
|
|
62
|
+
UNION ALL
|
|
63
|
+
SELECT 'slow_query_logging', COALESCE(
|
|
64
|
+
(SELECT setting || unit FROM pg_settings WHERE name = 'log_min_duration_statement'),
|
|
65
|
+
'unavailable'
|
|
66
|
+
)
|
|
67
|
+
UNION ALL
|
|
68
|
+
SELECT 'pg_stat_statements',
|
|
69
|
+
CASE
|
|
70
|
+
WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') THEN 'installed'
|
|
71
|
+
WHEN EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements') THEN 'available'
|
|
72
|
+
ELSE 'unavailable'
|
|
73
|
+
END
|
|
74
|
+
UNION ALL
|
|
75
|
+
SELECT 'shared_buffers', COALESCE(
|
|
76
|
+
(SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
|
|
77
|
+
'unavailable'
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
SELECT metric || '|' || value
|
|
81
|
+
FROM metrics
|
|
82
|
+
ORDER BY CASE metric
|
|
83
|
+
WHEN 'database_count' THEN 1
|
|
84
|
+
WHEN 'active_connections' THEN 2
|
|
85
|
+
WHEN 'total_database_size_bytes' THEN 3
|
|
86
|
+
WHEN 'slow_query_logging' THEN 4
|
|
87
|
+
WHEN 'pg_stat_statements' THEN 5
|
|
88
|
+
WHEN 'shared_buffers' THEN 6
|
|
89
|
+
ELSE 99
|
|
90
|
+
END;
|
|
91
|
+
`.trim();
|
|
92
|
+
const postgresDiagnosticKeys = [
|
|
93
|
+
'database_count',
|
|
94
|
+
'active_connections',
|
|
95
|
+
'total_database_size_bytes',
|
|
96
|
+
'slow_query_logging',
|
|
97
|
+
'pg_stat_statements',
|
|
98
|
+
'shared_buffers',
|
|
99
|
+
];
|
|
49
100
|
function parsePostgresMajorFromValue(value) {
|
|
50
101
|
if (!value)
|
|
51
102
|
return undefined;
|
|
@@ -56,6 +107,66 @@ function parsePostgresMajorFromValue(value) {
|
|
|
56
107
|
const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
|
|
57
108
|
return match?.[1];
|
|
58
109
|
}
|
|
110
|
+
function parsePostgresDiagnostics(output) {
|
|
111
|
+
const diagnostics = {};
|
|
112
|
+
const allowedKeys = new Set(postgresDiagnosticKeys);
|
|
113
|
+
for (const rawLine of output.split(/\r?\n/u)) {
|
|
114
|
+
const line = rawLine.trim();
|
|
115
|
+
if (!line)
|
|
116
|
+
continue;
|
|
117
|
+
const separatorIndex = line.indexOf('|');
|
|
118
|
+
if (separatorIndex === -1)
|
|
119
|
+
continue;
|
|
120
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
121
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
122
|
+
if (allowedKeys.has(key) && value) {
|
|
123
|
+
diagnostics[key] = value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return diagnostics;
|
|
127
|
+
}
|
|
128
|
+
function renderPostgresDiagnostics(diagnostics) {
|
|
129
|
+
const parts = postgresDiagnosticKeys.flatMap((key) => {
|
|
130
|
+
const value = diagnostics[key];
|
|
131
|
+
return value ? [`${key}=${value}`] : [];
|
|
132
|
+
});
|
|
133
|
+
return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
|
|
134
|
+
}
|
|
135
|
+
function integerDiagnostic(value) {
|
|
136
|
+
if (!value || !/^\d+$/u.test(value)) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return Number.parseInt(value, 10);
|
|
140
|
+
}
|
|
141
|
+
function structuredPostgresDiagnostics(diagnostics) {
|
|
142
|
+
const structured = {};
|
|
143
|
+
const databaseCount = integerDiagnostic(diagnostics.database_count);
|
|
144
|
+
const activeConnections = integerDiagnostic(diagnostics.active_connections);
|
|
145
|
+
const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
|
|
146
|
+
if (databaseCount !== undefined)
|
|
147
|
+
structured.databaseCount = databaseCount;
|
|
148
|
+
if (activeConnections !== undefined)
|
|
149
|
+
structured.activeConnections = activeConnections;
|
|
150
|
+
if (totalDatabaseSizeBytes !== undefined)
|
|
151
|
+
structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
|
|
152
|
+
if (diagnostics.slow_query_logging)
|
|
153
|
+
structured.slowQueryLogging = diagnostics.slow_query_logging;
|
|
154
|
+
if (diagnostics.pg_stat_statements)
|
|
155
|
+
structured.pgStatStatements = diagnostics.pg_stat_statements;
|
|
156
|
+
if (diagnostics.shared_buffers)
|
|
157
|
+
structured.sharedBuffers = diagnostics.shared_buffers;
|
|
158
|
+
return structured;
|
|
159
|
+
}
|
|
160
|
+
async function readPostgresDiagnostics(target, runner) {
|
|
161
|
+
const queryLiteral = JSON.stringify(postgresDiagnosticQuery);
|
|
162
|
+
const command = [
|
|
163
|
+
`query=${queryLiteral}`,
|
|
164
|
+
'. ./scripts/lib.sh >/dev/null',
|
|
165
|
+
'compose exec -T db psql -X -q -t -A -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -c "$query"',
|
|
166
|
+
].join(' && ');
|
|
167
|
+
const result = await runner('bash', ['-lc', command], { cwd: target });
|
|
168
|
+
return parsePostgresDiagnostics(result.stdout);
|
|
169
|
+
}
|
|
59
170
|
function stripInlineComment(line) {
|
|
60
171
|
const hashIndex = line.indexOf('#');
|
|
61
172
|
if (hashIndex === -1)
|
|
@@ -441,6 +552,40 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
441
552
|
checks.push(`OK .env ports HTTP_PORT=${httpPort} GEVENT_PORT=${geventPort}`);
|
|
442
553
|
}
|
|
443
554
|
}
|
|
555
|
+
if (actualOptions.postgres) {
|
|
556
|
+
try {
|
|
557
|
+
const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner);
|
|
558
|
+
const renderedPostgresDiagnostics = renderPostgresDiagnostics(postgresDiagnostics);
|
|
559
|
+
if (renderedPostgresDiagnostics) {
|
|
560
|
+
checks.push(renderedPostgresDiagnostics);
|
|
561
|
+
report.postgres = {
|
|
562
|
+
requested: true,
|
|
563
|
+
available: true,
|
|
564
|
+
diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
const warning = 'no diagnostic rows returned';
|
|
569
|
+
warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
|
|
570
|
+
report.postgres = {
|
|
571
|
+
requested: true,
|
|
572
|
+
available: false,
|
|
573
|
+
diagnostics: {},
|
|
574
|
+
warning,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
const warning = errorMessage(error);
|
|
580
|
+
warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
|
|
581
|
+
report.postgres = {
|
|
582
|
+
requested: true,
|
|
583
|
+
available: false,
|
|
584
|
+
diagnostics: {},
|
|
585
|
+
warning,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
}
|
|
444
589
|
try {
|
|
445
590
|
await actualRunner('docker', ['version'], { cwd: target });
|
|
446
591
|
checks.push('OK docker CLI');
|
package/dist/help.js
CHANGED
|
@@ -20,8 +20,8 @@ Usage:
|
|
|
20
20
|
npx @wpmoo/toolkit add-module --repo <source-repo> --module <module-name> [--source-type <category>]
|
|
21
21
|
npx @wpmoo/toolkit remove-module --repo <source-repo> --module <module-name> [--source-type <category>]
|
|
22
22
|
npx @wpmoo/toolkit reset [--dry-run]
|
|
23
|
-
npx @wpmoo/toolkit doctor [--fix]
|
|
24
|
-
npx @wpmoo/toolkit doctor --json
|
|
23
|
+
npx @wpmoo/toolkit doctor [--fix] [--postgres]
|
|
24
|
+
npx @wpmoo/toolkit doctor --json [--postgres]
|
|
25
25
|
npx @wpmoo/toolkit start
|
|
26
26
|
npx @wpmoo/toolkit stop
|
|
27
27
|
npx @wpmoo/toolkit logs [service]
|
|
@@ -58,6 +58,7 @@ Options:
|
|
|
58
58
|
--source-type <category> Source repo category for add-repo/remove-repo/add-module/remove-module. One of private, oca, external. Default: private.
|
|
59
59
|
--repo <name> Source repo folder name for repo/module actions.
|
|
60
60
|
--module <name> Odoo module technical name for module actions.
|
|
61
|
+
Must be lower snake_case; use letters, numbers, and underscores only.
|
|
61
62
|
--delete-files Also delete module files in remove-module. Default: false.
|
|
62
63
|
--odoo-version <branch> Override the environment Odoo branch for add-repo/add-module.
|
|
63
64
|
--source-repo-url <url> Source repo URL. Repeat for multiple repos.
|
|
@@ -67,6 +68,7 @@ Options:
|
|
|
67
68
|
--repo-visibility <value> Visibility for created repos: private or public. Default: private.
|
|
68
69
|
--init-empty-repos Initialize empty source repos with the selected branch.
|
|
69
70
|
--dry-run Print planned files and commands without writing.
|
|
71
|
+
--postgres Include read-only PostgreSQL health/performance diagnostics in doctor.
|
|
70
72
|
--stage=false Do not run git add .
|
|
71
73
|
--no-update-check Skip the startup npm update check.
|
|
72
74
|
--version, -v Show the package version.
|
|
@@ -75,7 +77,7 @@ Options:
|
|
|
75
77
|
Package aliases:
|
|
76
78
|
npx @wpmoo/toolkit is the official package path.
|
|
77
79
|
npx wpmoo is the short alias.
|
|
78
|
-
npx @wpmoo/odoo and npx @wpmoo/odoo-dev remain
|
|
80
|
+
npx @wpmoo/odoo and npx @wpmoo/odoo-dev remain deprecated compatibility aliases.
|
|
79
81
|
|
|
80
82
|
Daily actions:
|
|
81
83
|
Daily actions must be run from a generated environment root containing .wpmoo/odoo.json.
|
|
@@ -101,6 +103,8 @@ Status and doctor:
|
|
|
101
103
|
status: fast and offline. Reads local environment metadata and files only.
|
|
102
104
|
doctor: deeper health check. May check Docker CLI access and GitHub workflows.
|
|
103
105
|
doctor --fix: applies safe file-level repairs. Runs doctor again after fixes.
|
|
106
|
+
doctor --postgres: adds read-only PostgreSQL diagnostics such as database size,
|
|
107
|
+
active connections, slow-query readiness, extension visibility, and settings.
|
|
104
108
|
|
|
105
109
|
Task recipes:
|
|
106
110
|
Create environment:
|
|
@@ -115,6 +119,9 @@ Task recipes:
|
|
|
115
119
|
npx @wpmoo/toolkit source sync
|
|
116
120
|
Add module:
|
|
117
121
|
npx @wpmoo/toolkit add-module --repo <source-repo> --module <module-name> --source-type private|oca|external
|
|
122
|
+
Creates a minimal skeleton: __init__.py, __manifest__.py, models/<module>.py, models/__init__.py, security/ir.model.access.csv, views/<module>_views.xml, views/<module>_menus.xml, and tests/test_<module>.py.
|
|
123
|
+
The view XML adds list/tree and form views; the menu XML adds a basic Odoo action and menu entry; the test skeleton adds a post-install TransactionCase smoke test.
|
|
124
|
+
Module names must be lower snake_case; use letters, numbers, and underscores only.
|
|
118
125
|
Remove module:
|
|
119
126
|
npx @wpmoo/toolkit remove-module --repo <source-repo> --module <module-name> --source-type private|oca|external
|
|
120
127
|
Add OCA module:
|
|
@@ -140,6 +147,7 @@ Machine-readable JSON output:
|
|
|
140
147
|
npx @wpmoo/toolkit source list --json
|
|
141
148
|
npx @wpmoo/toolkit source sync --json
|
|
142
149
|
npx @wpmoo/toolkit doctor --json
|
|
150
|
+
doctor --json --postgres includes a structured postgres object for automation.
|
|
143
151
|
|
|
144
152
|
Example:
|
|
145
153
|
npx @wpmoo/toolkit create \\
|
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
|
@@ -539,6 +539,35 @@ validate_test_args() {
|
|
|
539
539
|
done
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
+
env_file_value() {
|
|
543
|
+
local key="$1"
|
|
544
|
+
if [[ -f ".env" ]]; then
|
|
545
|
+
grep -E "^[[:space:]]*\${key}[[:space:]]*=" ".env" | tail -n 1 | sed -E "s/^[[:space:]]*\${key}[[:space:]]*=[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^[\\"']//; s/[\\"']$//"
|
|
546
|
+
fi
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
selected_env() {
|
|
550
|
+
local value="\${WPMOO_ENV:-$(env_file_value WPMOO_ENV)}"
|
|
551
|
+
printf '%s\\n' "\${value:-dev}"
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
allow_destructive() {
|
|
555
|
+
local value="\${WPMOO_ALLOW_DESTRUCTIVE:-$(env_file_value WPMOO_ALLOW_DESTRUCTIVE)}"
|
|
556
|
+
[[ "$value" == "1" ]]
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
require_destructive_allowed() {
|
|
560
|
+
local command="$1"
|
|
561
|
+
local env_name
|
|
562
|
+
env_name="$(selected_env)"
|
|
563
|
+
if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
|
|
564
|
+
if ! allow_destructive; then
|
|
565
|
+
echo "Refusing destructive command '$command' in WPMOO_ENV=$env_name. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally." >&2
|
|
566
|
+
exit 1
|
|
567
|
+
fi
|
|
568
|
+
fi
|
|
569
|
+
}
|
|
570
|
+
|
|
542
571
|
run_script() {
|
|
543
572
|
local script="$1"
|
|
544
573
|
shift
|
|
@@ -599,6 +628,7 @@ case "$command" in
|
|
|
599
628
|
"resetdb")
|
|
600
629
|
shift
|
|
601
630
|
positional_args "$command" 0 2 "$@"
|
|
631
|
+
require_destructive_allowed "$command"
|
|
602
632
|
run_script ./scripts/resetdb.sh "$@"
|
|
603
633
|
;;
|
|
604
634
|
"snapshot")
|
|
@@ -615,6 +645,9 @@ case "$command" in
|
|
|
615
645
|
fi
|
|
616
646
|
positional_args "$command" 1 2 "$@"
|
|
617
647
|
restore_args+=("$@")
|
|
648
|
+
if [[ "\${restore_args[0]:-}" != "--dry-run" ]]; then
|
|
649
|
+
require_destructive_allowed "$command"
|
|
650
|
+
fi
|
|
618
651
|
run_script ./scripts/restore-snapshot.sh "\${restore_args[@]}"
|
|
619
652
|
;;
|
|
620
653
|
"lint")
|
|
@@ -23,9 +23,10 @@ not validate staging or production deployments.
|
|
|
23
23
|
| Doctor checks | Metadata, compose files, scripts, source repo paths, and local tooling checks behave as expected. | `npx @wpmoo/toolkit doctor` or `./moo doctor` |
|
|
24
24
|
| Doctor safe fixes | Safe file-level fixes are applied only with `--fix`, then doctor runs again and reports any remaining manual issues. | `npx @wpmoo/toolkit doctor --fix` |
|
|
25
25
|
| Generated Postgres checks | For PostgreSQL 18 environments, doctor validates db mount targets avoid old PG image-specific paths and can normalize safe targets with `--fix`. | `npx @wpmoo/toolkit doctor`, `npx @wpmoo/toolkit doctor --fix` |
|
|
26
|
+
| PostgreSQL diagnostics | Optional read-only database health/performance diagnostics report database count, active connections, total database size, slow-query readiness, extension visibility, and selected settings without failing doctor when the database is unavailable. | `npx @wpmoo/toolkit doctor --postgres`, `npx @wpmoo/toolkit doctor --json --postgres` |
|
|
26
27
|
| Source repo add/remove | Source repository registration and submodule lifecycle behave correctly. | `npx @wpmoo/toolkit add-repo ...`, `npx @wpmoo/toolkit remove-repo ...` |
|
|
27
28
|
| Source manifest sync | Source repo metadata, `.gitmodules`, and `odoo/custom/manifests/sources.yaml` stay aligned. | `npx @wpmoo/toolkit source list`, `npx @wpmoo/toolkit source sync` |
|
|
28
|
-
| Module add/remove | Module
|
|
29
|
+
| Module add/remove | Module skeleton files include manifest, model, access CSV, explicit view XML, action/menu XML, post-install test scaffold, and selected source repo registration. Existing scaffold files are not overwritten. | `npx @wpmoo/toolkit add-module ...`, `npx @wpmoo/toolkit remove-module ...` |
|
|
29
30
|
| Safe reset | Generated files are refreshed (including `compose.yaml` overlays and env example) without deleting source module code. Local runtime/data directories and custom source layout content are preserved; legacy user-editable paths from older templates may remain and are reported for manual cleanup. | `npx @wpmoo/toolkit reset --dry-run`, `npx @wpmoo/toolkit reset` |
|
|
30
31
|
| Snapshot/restore and lint/pot | These actions are delegated by `./moo` to compose scripts. Restore preview, snapshot retention, and stage/prod destructive guards are preserved by the package argument layer. | `./moo snapshot ...`, `./moo restore-snapshot --dry-run ...`, `./moo restore-snapshot ...`, `./moo lint`, `./moo pot ...` |
|
|
31
32
|
|
|
@@ -46,9 +47,11 @@ Default local development uses `compose.yaml` plus `compose/dev.yaml`.
|
|
|
46
47
|
`WPMOO_ENV=stage` or `WPMOO_ENV=prod` must only be used after production-grade
|
|
47
48
|
secrets and volumes are configured.
|
|
48
49
|
|
|
49
|
-
When `WPMOO_ENV=stage` or `WPMOO_ENV=prod`,
|
|
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.
|
|
52
55
|
|
|
53
56
|
For PostgreSQL 18 environments (including `POSTGRES_IMAGE=postgres:18`), ensure db
|
|
54
57
|
volume and tmpfs mount targets use `/var/lib/postgresql` directly:
|
|
@@ -65,6 +68,15 @@ no longer accepted by the package `doctor` check.
|
|
|
65
68
|
It does not upgrade existing database data; if a real PostgreSQL major upgrade
|
|
66
69
|
is involved, use PostgreSQL upgrade tooling first.
|
|
67
70
|
|
|
71
|
+
Use `doctor --postgres` when the database container is running and you want
|
|
72
|
+
read-only PostgreSQL diagnostics. The check uses fixed diagnostic queries for
|
|
73
|
+
database count, active connections, aggregate database size, slow-query logging
|
|
74
|
+
readiness, `pg_stat_statements` availability, and `shared_buffers`. If the
|
|
75
|
+
database is unavailable, doctor reports a warning instead of failing the whole
|
|
76
|
+
environment check.
|
|
77
|
+
JSON output preserves `checks` and `warnings` while adding a structured
|
|
78
|
+
`postgres` object when `--postgres` is requested.
|
|
79
|
+
|
|
68
80
|
## Safe reset policy
|
|
69
81
|
|
|
70
82
|
Safe reset intentionally avoids deleting user-editable legacy paths from old
|