@wpmoo/toolkit 0.9.30 → 0.9.32
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 +12 -3
- package/dist/cli-routes/source.js +12 -1
- package/dist/cli.js +8 -4
- package/dist/cockpit/menu.js +1 -1
- package/dist/doctor.js +73 -5
- package/dist/environment-policy.js +6 -6
- package/dist/external-templates.js +7 -1
- package/dist/help.js +3 -2
- package/dist/module-actions.js +38 -14
- package/dist/module-quality.js +57 -1
- package/dist/postgres-diagnostics.js +21 -10
- package/dist/source-actions.js +90 -1
- package/dist/templates.js +112 -6
- package/docs/1-0-readiness.md +3 -2
- package/docs/command-reference.md +9 -2
- package/docs/handoff.md +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -154,8 +154,8 @@ search so names, repositories, and source categories can be filtered quickly.
|
|
|
154
154
|
./moo restore-snapshot --dry-run before-update devel
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
In `WPMOO_ENV=stage`, `install` and `
|
|
158
|
-
In `WPMOO_ENV=prod`, `install`, `update`, and `
|
|
157
|
+
In `WPMOO_ENV=stage`, `install`, `update`, `stop`, and `restart` require `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
|
|
158
|
+
In `WPMOO_ENV=prod`, `install`, `update`, `test`, `stop`, and `restart` require `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
|
|
159
159
|
`resetdb` and real `restore-snapshot` require `WPMOO_ALLOW_DESTRUCTIVE=1` in `stage` and `prod`.
|
|
160
160
|
`restore-snapshot --dry-run` remains allowed for preview.
|
|
161
161
|
For short-lived local approvals, add JSONL entries to `.wpmoo/approvals.jsonl`; generated `.gitignore` keeps that ledger out of Git.
|
|
@@ -181,6 +181,9 @@ npx @wpmoo/toolkit doctor --json --postgres
|
|
|
181
181
|
```
|
|
182
182
|
|
|
183
183
|
JSON output is optional; human-readable output remains the default.
|
|
184
|
+
`doctor --json --fix` is intentionally unsupported because `doctor --fix` may
|
|
185
|
+
write safe file-level repairs. Run `doctor --fix` first, then `doctor --json`
|
|
186
|
+
to inspect the post-fix state.
|
|
184
187
|
Human `doctor` output is grouped into stable sections (`Generated files`,
|
|
185
188
|
`Compose`, `Source repositories`, `PostgreSQL`, and `Host tools`) so terminal
|
|
186
189
|
operators can see which lifecycle layer needs attention first.
|
|
@@ -212,6 +215,10 @@ JSON variant exposes a versioned PostgreSQL diagnostics contract.
|
|
|
212
215
|
`doctor --json --postgres` keeps the JSON contract stable by versioning the
|
|
213
216
|
`postgres` payload; individual fields are optional so automation can safely handle
|
|
214
217
|
environments where PostgreSQL does not expose a metric.
|
|
218
|
+
Optional PostgreSQL probe permission failures appear under
|
|
219
|
+
`postgres.optionalProbeFailures` when available; they do not make
|
|
220
|
+
`postgres.available` false and can be ignored by automation that only needs core
|
|
221
|
+
diagnostics.
|
|
215
222
|
All `doctor --json` reports also include optional `sections` entries that group
|
|
216
223
|
checks, warnings, and errors without changing the legacy flat arrays.
|
|
217
224
|
|
|
@@ -244,7 +251,9 @@ warning while keeping the scoped package release valid.
|
|
|
244
251
|
rejects it, the release still succeeds as long as the three required scoped
|
|
245
252
|
packages are valid.
|
|
246
253
|
- **Smoke expectation**: run `npm run smoke:published -- "$VERSION"` after the
|
|
247
|
-
release tag workflow completes.
|
|
254
|
+
release tag workflow completes. The script prints `Smoke step:` progress lines
|
|
255
|
+
before each published CLI check so slow registry-backed `npx` resolution is
|
|
256
|
+
visible.
|
|
248
257
|
- **Deterministic smoke target**: pin the target package explicitly so smoke checks
|
|
249
258
|
are reproducible across reruns:
|
|
250
259
|
|
|
@@ -4,7 +4,7 @@ import { commandOdooVersion } from '../environment-version.js';
|
|
|
4
4
|
import { outroPrompt } from '../prompts/index.js';
|
|
5
5
|
import { addModuleRepo, removeModuleRepo } from '../repo-actions.js';
|
|
6
6
|
import { normalizeRepositoryUrl } from '../repo-url.js';
|
|
7
|
-
import { listSources, renderSourceList, sourceListJson, sourceSyncJson, syncSources, } from '../source-actions.js';
|
|
7
|
+
import { listSources, renderSourceList, renderSourceSyncPlan, sourceListJson, sourceSyncPlan, sourceSyncPlanJson, sourceSyncJson, syncSources, } from '../source-actions.js';
|
|
8
8
|
import { renderBanner } from '../templates.js';
|
|
9
9
|
import { booleanOption, jsonOption, optionalSourceTypeValue, printJson, sourceTypeValue, stringOption, } from './options.js';
|
|
10
10
|
export function renderedSourceRepoPath(target, sourceType, repoPath) {
|
|
@@ -52,6 +52,7 @@ export function sourceSyncOptionsFromArgs(argv) {
|
|
|
52
52
|
target: resolve(stringOption(values, 'target') ?? process.cwd()),
|
|
53
53
|
stage: booleanOption(values, 'stage', true),
|
|
54
54
|
json: jsonOption(values),
|
|
55
|
+
dryRun: booleanOption(values, 'dryRun', false),
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
export function sourceListOptionsFromArgs(argv) {
|
|
@@ -79,6 +80,16 @@ export async function runSourceCommand(argv) {
|
|
|
79
80
|
}
|
|
80
81
|
if (subcommand === 'sync') {
|
|
81
82
|
const options = sourceSyncOptionsFromArgs(subcommandArgv);
|
|
83
|
+
if (options.dryRun) {
|
|
84
|
+
const plan = await sourceSyncPlan(options.target);
|
|
85
|
+
if (options.json) {
|
|
86
|
+
printJson(sourceSyncPlanJson(plan));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log(renderBanner());
|
|
90
|
+
console.log(renderSourceSyncPlan(plan));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
82
93
|
const sources = await syncSources({ target: options.target, stage: options.stage });
|
|
83
94
|
if (options.json) {
|
|
84
95
|
printJson(sourceSyncJson(sources, options.target));
|
package/dist/cli.js
CHANGED
|
@@ -676,6 +676,7 @@ function removeModuleOptionsFromArgs(argv) {
|
|
|
676
676
|
moduleName,
|
|
677
677
|
sourceType: optionalSourceTypeValue(values),
|
|
678
678
|
deleteFiles: booleanOption(values, 'deleteFiles', false),
|
|
679
|
+
dryRun: booleanOption(values, 'dryRun', false),
|
|
679
680
|
stage: booleanOption(values, 'stage', true),
|
|
680
681
|
};
|
|
681
682
|
}
|
|
@@ -1419,14 +1420,14 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
1419
1420
|
const options = removeModuleOptionsFromArgs(route.argv);
|
|
1420
1421
|
if (options) {
|
|
1421
1422
|
console.log(renderBanner());
|
|
1422
|
-
await removeModuleFromSourceRepo(options);
|
|
1423
|
-
outroPrompt(`Removed module ${options.moduleName} from source repo ${options.repoPath}.`);
|
|
1423
|
+
const report = await removeModuleFromSourceRepo(options);
|
|
1424
|
+
outroPrompt(report.dryRun ? report.summary : `Removed module ${options.moduleName} from source repo ${options.repoPath}.`);
|
|
1424
1425
|
return;
|
|
1425
1426
|
}
|
|
1426
1427
|
await showStartup(argv, skipUpdateCheck);
|
|
1427
1428
|
const promptedOptions = await removeModuleOptionsFromPrompts();
|
|
1428
|
-
await removeModuleFromSourceRepo(promptedOptions);
|
|
1429
|
-
outroPrompt(`Removed module ${promptedOptions.moduleName} from source repo ${promptedOptions.repoPath}.`);
|
|
1429
|
+
const report = await removeModuleFromSourceRepo(promptedOptions);
|
|
1430
|
+
outroPrompt(report.dryRun ? report.summary : `Removed module ${promptedOptions.moduleName} from source repo ${promptedOptions.repoPath}.`);
|
|
1430
1431
|
return;
|
|
1431
1432
|
}
|
|
1432
1433
|
if (route.command === 'reset') {
|
|
@@ -1451,6 +1452,9 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
1451
1452
|
doctorOptions.postgres = options.postgres;
|
|
1452
1453
|
}
|
|
1453
1454
|
if (options.json) {
|
|
1455
|
+
if (doctorOptions.fix) {
|
|
1456
|
+
throw new Error('doctor --json --fix is not supported; run doctor --fix for human-readable auto-fix output, then doctor --json to inspect the post-fix state.');
|
|
1457
|
+
}
|
|
1454
1458
|
printJson(await getDoctorReport(cwd, doctorOptions));
|
|
1455
1459
|
return;
|
|
1456
1460
|
}
|
package/dist/cockpit/menu.js
CHANGED
|
@@ -20,7 +20,7 @@ const topLevelCategoryOrder = [
|
|
|
20
20
|
];
|
|
21
21
|
const topLevelCommands = topLevelCategoryOrder.flatMap((category) => cockpitCommands.filter((command) => command.category === category && command.id !== 'exit'));
|
|
22
22
|
const topLevelCommandLabelWidth = Math.max(...topLevelCommands.map((command) => command.label.length));
|
|
23
|
-
const moduleDependentCommandIds = new Set(['list-modules', 'install', 'update', 'test', '
|
|
23
|
+
const moduleDependentCommandIds = new Set(['list-modules', 'install', 'update', 'test', 'pot', 'remove-module']);
|
|
24
24
|
function rgb(red, green, blue, value) {
|
|
25
25
|
return `\u001B[38;2;${red};${green};${blue}m${value}\u001B[39m`;
|
|
26
26
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -5,8 +5,9 @@ import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './
|
|
|
5
5
|
import { dailyActionScripts } from './daily-actions.js';
|
|
6
6
|
import { defaultPostgresVersion } from './external-templates.js';
|
|
7
7
|
import { defaultOdooVersion, markerPath, replaceSourceRepos } from './environment.js';
|
|
8
|
-
import { POSTGRES_DIAGNOSTICS_CONTRACT_VERSION, POSTGRES_DIAGNOSTICS_QUERY, malformedPostgresDiagnosticKeys, missingPostgresDiagnosticKeys, parsePostgresDiagnostics, postgresPostgresWarnings, renderPostgresDiagnostics, structuredPostgresDiagnostics, unavailablePostgresDiagnosticsWarning, } from './postgres-diagnostics.js';
|
|
8
|
+
import { POSTGRES_DIAGNOSTICS_CONTRACT_VERSION, POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES, POSTGRES_DIAGNOSTICS_QUERY, malformedPostgresDiagnosticKeys, missingPostgresDiagnosticKeys, parsePostgresDiagnostics, postgresPostgresWarnings, renderPostgresDiagnostics, structuredPostgresDiagnostics, unavailablePostgresDiagnosticsWarning, } from './postgres-diagnostics.js';
|
|
9
9
|
import { listGitmoduleSources, readSourceManifest, sourceReposFromManifest, sourceManifestPath, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
|
|
10
|
+
import { emptyModuleQualitySummary, mergeModuleQualitySummaries, scanModuleQuality, } from './module-quality.js';
|
|
10
11
|
const realCommandRunner = async (command, args, options) => {
|
|
11
12
|
const result = await execa(command, args, { cwd: options.cwd });
|
|
12
13
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
@@ -76,6 +77,13 @@ function isMetadataError(message) {
|
|
|
76
77
|
message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
|
|
77
78
|
}
|
|
78
79
|
const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
|
|
80
|
+
const defaultPostgresDiagnosticsTimeoutMs = 15_000;
|
|
81
|
+
class PostgresDiagnosticsTimeoutError extends Error {
|
|
82
|
+
constructor(timeoutMs) {
|
|
83
|
+
super(`PostgreSQL diagnostics timed out after ${timeoutMs}ms`);
|
|
84
|
+
this.name = 'PostgresDiagnosticsTimeoutError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
79
87
|
function parsePostgresMajorFromValue(value) {
|
|
80
88
|
if (!value)
|
|
81
89
|
return undefined;
|
|
@@ -86,16 +94,62 @@ function parsePostgresMajorFromValue(value) {
|
|
|
86
94
|
const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
|
|
87
95
|
return match?.[1];
|
|
88
96
|
}
|
|
89
|
-
async function
|
|
90
|
-
|
|
97
|
+
async function withPostgresDiagnosticsTimeout(promise, timeoutMs) {
|
|
98
|
+
let timeout;
|
|
99
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
100
|
+
timeout = setTimeout(() => reject(new PostgresDiagnosticsTimeoutError(timeoutMs)), timeoutMs);
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (timeout) {
|
|
107
|
+
clearTimeout(timeout);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function postgresDiagnosticsTimeoutMs(options) {
|
|
112
|
+
return typeof options.postgresTimeoutMs === 'number' &&
|
|
113
|
+
Number.isFinite(options.postgresTimeoutMs) &&
|
|
114
|
+
options.postgresTimeoutMs > 0
|
|
115
|
+
? options.postgresTimeoutMs
|
|
116
|
+
: defaultPostgresDiagnosticsTimeoutMs;
|
|
117
|
+
}
|
|
118
|
+
function optionalPostgresProbeFailureWarning(error) {
|
|
119
|
+
const message = commandErrorText(error).trim();
|
|
120
|
+
if (!/(?:permission denied|insufficient privilege|must be superuser|not permitted|not allowed)/iu.test(message)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return message.split(/\r?\n/u).find((line) => line.trim())?.trim();
|
|
124
|
+
}
|
|
125
|
+
async function readPostgresDiagnosticQuery(target, runner, query, timeoutMs) {
|
|
126
|
+
const queryLiteral = JSON.stringify(query);
|
|
91
127
|
const command = [
|
|
92
128
|
`query=${queryLiteral}`,
|
|
93
129
|
'. ./scripts/lib.sh >/dev/null',
|
|
94
130
|
'compose exec -T db psql -X -q -t -A -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -c "$query"',
|
|
95
131
|
].join(' && ');
|
|
96
|
-
const result = await runner('bash', ['-lc', command], { cwd: target });
|
|
132
|
+
const result = await withPostgresDiagnosticsTimeout(runner('bash', ['-lc', command], { cwd: target }), timeoutMs);
|
|
97
133
|
return parsePostgresDiagnostics(result.stdout);
|
|
98
134
|
}
|
|
135
|
+
async function readPostgresDiagnostics(target, runner, timeoutMs) {
|
|
136
|
+
const diagnostics = await readPostgresDiagnosticQuery(target, runner, POSTGRES_DIAGNOSTICS_QUERY, timeoutMs);
|
|
137
|
+
const optionalProbeFailures = [];
|
|
138
|
+
for (const probe of POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES) {
|
|
139
|
+
try {
|
|
140
|
+
Object.assign(diagnostics, await readPostgresDiagnosticQuery(target, runner, probe.query, timeoutMs));
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
const warning = optionalPostgresProbeFailureWarning(error);
|
|
144
|
+
if (warning) {
|
|
145
|
+
optionalProbeFailures.push({ id: probe.id, warning });
|
|
146
|
+
}
|
|
147
|
+
// Optional probes use PostgreSQL functions that can require elevated roles.
|
|
148
|
+
// Their failure must not hide the core read-only health report.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { diagnostics, optionalProbeFailures };
|
|
152
|
+
}
|
|
99
153
|
function stripInlineComment(line) {
|
|
100
154
|
const hashIndex = line.indexOf('#');
|
|
101
155
|
if (hashIndex === -1)
|
|
@@ -254,10 +308,14 @@ const doctorSectionDefinitions = [
|
|
|
254
308
|
{ id: 'generated-files', title: 'Generated files' },
|
|
255
309
|
{ id: 'compose', title: 'Compose' },
|
|
256
310
|
{ id: 'source-repositories', title: 'Source repositories' },
|
|
311
|
+
{ id: 'module-quality', title: 'Module quality' },
|
|
257
312
|
{ id: 'postgresql', title: 'PostgreSQL' },
|
|
258
313
|
{ id: 'host-tools', title: 'Host tools' },
|
|
259
314
|
];
|
|
260
315
|
function doctorSectionForLine(line) {
|
|
316
|
+
if (line.startsWith('OK module quality') || line.startsWith('Module quality advisory:')) {
|
|
317
|
+
return 'module-quality';
|
|
318
|
+
}
|
|
261
319
|
if (line.includes('PostgreSQL diagnostics') ||
|
|
262
320
|
line.includes('PostgreSQL connection') ||
|
|
263
321
|
line.includes('PostgreSQL slow-query') ||
|
|
@@ -521,6 +579,14 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
521
579
|
errors.push(`Missing source repo path: ${relativePath}`);
|
|
522
580
|
}
|
|
523
581
|
checks.push(`OK source repos ${sourceRepos.length} checked`);
|
|
582
|
+
let moduleQuality = emptyModuleQualitySummary();
|
|
583
|
+
for (const repo of sourceRepos) {
|
|
584
|
+
const sourceType = normalizeSourceType(repo.sourceType);
|
|
585
|
+
const repoRoot = join(target, sourceRepoPath(sourceType, repo.path));
|
|
586
|
+
moduleQuality = mergeModuleQualitySummaries(moduleQuality, await scanModuleQuality(repoRoot, target));
|
|
587
|
+
}
|
|
588
|
+
checks.push(`OK module quality ${moduleQuality.totalModules} module${moduleQuality.totalModules === 1 ? '' : 's'} scanned`);
|
|
589
|
+
warnings.push(...moduleQuality.issues.map((issue) => `Module quality advisory: ${issue.path}: ${issue.issue}`));
|
|
524
590
|
const manifestPath = join(target, sourceManifestPath);
|
|
525
591
|
const hasManifest = await exists(manifestPath);
|
|
526
592
|
let manifestEntries = [];
|
|
@@ -574,7 +640,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
574
640
|
}
|
|
575
641
|
if (actualOptions.postgres) {
|
|
576
642
|
try {
|
|
577
|
-
const postgresDiagnostics = await readPostgresDiagnostics(target, actualRunner);
|
|
643
|
+
const { diagnostics: postgresDiagnostics, optionalProbeFailures } = await readPostgresDiagnostics(target, actualRunner, postgresDiagnosticsTimeoutMs(actualOptions));
|
|
578
644
|
const missingKeys = missingPostgresDiagnosticKeys(postgresDiagnostics);
|
|
579
645
|
const malformedKeys = malformedPostgresDiagnosticKeys(postgresDiagnostics);
|
|
580
646
|
if (missingKeys.length === 0 && malformedKeys.length === 0) {
|
|
@@ -587,6 +653,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
587
653
|
contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
|
|
588
654
|
available: true,
|
|
589
655
|
diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
|
|
656
|
+
...(optionalProbeFailures.length > 0 ? { optionalProbeFailures } : {}),
|
|
590
657
|
};
|
|
591
658
|
warnings.push(...postgresPostgresWarnings(postgresDiagnostics));
|
|
592
659
|
}
|
|
@@ -600,6 +667,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
600
667
|
contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
|
|
601
668
|
available: false,
|
|
602
669
|
diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
|
|
670
|
+
...(optionalProbeFailures.length > 0 ? { optionalProbeFailures } : {}),
|
|
603
671
|
warning,
|
|
604
672
|
};
|
|
605
673
|
}
|
|
@@ -31,9 +31,9 @@ export const dailyActionPolicyTable = {
|
|
|
31
31
|
stop: {
|
|
32
32
|
isDestructive: () => false,
|
|
33
33
|
isDryRunAllowed: false,
|
|
34
|
-
requiresStageLifecycleApproval:
|
|
35
|
-
requiresProdLifecycleApproval:
|
|
36
|
-
isAuditWorthy: () =>
|
|
34
|
+
requiresStageLifecycleApproval: true,
|
|
35
|
+
requiresProdLifecycleApproval: true,
|
|
36
|
+
isAuditWorthy: () => true,
|
|
37
37
|
},
|
|
38
38
|
logs: {
|
|
39
39
|
isDestructive: () => false,
|
|
@@ -45,9 +45,9 @@ export const dailyActionPolicyTable = {
|
|
|
45
45
|
restart: {
|
|
46
46
|
isDestructive: () => false,
|
|
47
47
|
isDryRunAllowed: false,
|
|
48
|
-
requiresStageLifecycleApproval:
|
|
49
|
-
requiresProdLifecycleApproval:
|
|
50
|
-
isAuditWorthy: () =>
|
|
48
|
+
requiresStageLifecycleApproval: true,
|
|
49
|
+
requiresProdLifecycleApproval: true,
|
|
50
|
+
isAuditWorthy: () => true,
|
|
51
51
|
},
|
|
52
52
|
shell: {
|
|
53
53
|
isDestructive: () => false,
|
|
@@ -43,8 +43,14 @@ 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/
|
|
46
|
+
'# Required only when intentionally running install/update/stop/restart in WPMOO_ENV=stage.',
|
|
47
|
+
'# WPMOO_ALLOW_STAGE_LIFECYCLE=1',
|
|
48
|
+
'# Required only when intentionally running install/update/test/stop/restart in WPMOO_ENV=prod.',
|
|
47
49
|
'# WPMOO_ALLOW_PROD_LIFECYCLE=1',
|
|
50
|
+
'# Required only when intentionally running a destructive stage/prod command without a recent snapshot.',
|
|
51
|
+
'# WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1',
|
|
52
|
+
'# Required only when intentionally running migration-risk lifecycle commands in stage/prod.',
|
|
53
|
+
'# WPMOO_ALLOW_MIGRATIONS=1',
|
|
48
54
|
'',
|
|
49
55
|
].join('\n');
|
|
50
56
|
}
|
package/dist/help.js
CHANGED
|
@@ -87,8 +87,8 @@ Daily actions:
|
|
|
87
87
|
Use ./moo or npx @wpmoo/toolkit with the same daily action arguments.
|
|
88
88
|
|
|
89
89
|
Lifecycle command guards:
|
|
90
|
-
In WPMOO_ENV=stage, install/update require WPMOO_ALLOW_STAGE_LIFECYCLE=1.
|
|
91
|
-
In WPMOO_ENV=prod, install/update/test require WPMOO_ALLOW_PROD_LIFECYCLE=1.
|
|
90
|
+
In WPMOO_ENV=stage, install/update/stop/restart require WPMOO_ALLOW_STAGE_LIFECYCLE=1.
|
|
91
|
+
In WPMOO_ENV=prod, install/update/test/stop/restart require WPMOO_ALLOW_PROD_LIFECYCLE=1.
|
|
92
92
|
resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage/prod.
|
|
93
93
|
restore-snapshot --dry-run remains allowed for preview.
|
|
94
94
|
Time-bounded local approvals may also be recorded in .wpmoo/approvals.jsonl.
|
|
@@ -161,6 +161,7 @@ Machine-readable JSON output:
|
|
|
161
161
|
npx @wpmoo/toolkit source sync --json
|
|
162
162
|
npx @wpmoo/toolkit doctor --json
|
|
163
163
|
doctor --json --postgres includes a structured postgres object for automation.
|
|
164
|
+
doctor --json is read-only; run doctor --fix first, then doctor --json for post-fix state.
|
|
164
165
|
Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
|
|
165
166
|
|
|
166
167
|
JSON compatibility policy:
|
package/dist/module-actions.js
CHANGED
|
@@ -253,8 +253,12 @@ async function moduleScaffoldChecks(target, sourceType, repoPath, moduleName, in
|
|
|
253
253
|
id: 'tests',
|
|
254
254
|
label: 'tests',
|
|
255
255
|
ok: (await fileContains(join(destination, 'tests/__init__.py'), `from . import test_${moduleName}`)) &&
|
|
256
|
-
(await fileContains(join(destination, `tests/test_${moduleName}.py`), ''))
|
|
257
|
-
|
|
256
|
+
(await fileContains(join(destination, `tests/test_${moduleName}.py`), 'TransactionCase')) &&
|
|
257
|
+
(await fileContains(join(destination, `tests/test_${moduleName}.py`), '@tagged("post_install", "-at_install")')) &&
|
|
258
|
+
(await fileContains(join(destination, `tests/test_${moduleName}.py`), `class Test${moduleClassName(moduleName)}(TransactionCase):`)) &&
|
|
259
|
+
(await fileContains(join(destination, `tests/test_${moduleName}.py`), 'def test_create_record(self):')) &&
|
|
260
|
+
(await fileContains(join(destination, `tests/test_${moduleName}.py`), `self.env["${technicalName}"]`)),
|
|
261
|
+
details: 'missing generated TransactionCase test markers',
|
|
258
262
|
},
|
|
259
263
|
];
|
|
260
264
|
if (includeRegistration) {
|
|
@@ -375,23 +379,20 @@ async function assertModuleCleanBeforeDelete(target, sourceType, repoPath, modul
|
|
|
375
379
|
const repoRoot = sourceRepoPath(target, sourceType, repoPath);
|
|
376
380
|
try {
|
|
377
381
|
const result = await git.run(repoRoot, ['status', '--short', '--', moduleName]);
|
|
378
|
-
|
|
379
|
-
|
|
382
|
+
const status = result.stdout.trimEnd();
|
|
383
|
+
if (status.trim()) {
|
|
384
|
+
const hasUntrackedOrStaged = status
|
|
385
|
+
.split(/\r?\n/u)
|
|
386
|
+
.some((line) => line.startsWith('??') || /^[A-Z][A-Z ]\s/u.test(line));
|
|
387
|
+
const reason = hasUntrackedOrStaged ? 'uncommitted git changes' : 'dirty git changes';
|
|
388
|
+
throw new Error(`Refusing to delete module ${moduleName} because it has ${reason} in source repo ${repoPath}.`);
|
|
380
389
|
}
|
|
381
390
|
}
|
|
382
391
|
catch (error) {
|
|
383
392
|
if (error instanceof Error && error.message.startsWith('Refusing to delete module ')) {
|
|
384
393
|
throw error;
|
|
385
394
|
}
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
async function moduleHasCommittedFiles(repoRoot, moduleName, git) {
|
|
389
|
-
try {
|
|
390
|
-
const result = await git.run(repoRoot, ['ls-tree', '-r', '--name-only', 'HEAD', '--', moduleName]);
|
|
391
|
-
return Boolean(result.stdout.trim());
|
|
392
|
-
}
|
|
393
|
-
catch {
|
|
394
|
-
return false;
|
|
395
|
+
throw new Error(`Refusing to delete module ${moduleName} because git status could not be verified in source repo ${repoPath}.`);
|
|
395
396
|
}
|
|
396
397
|
}
|
|
397
398
|
export async function addModuleToSourceRepo(options, git = realGit) {
|
|
@@ -483,6 +484,19 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
|
|
|
483
484
|
const repoPath = validateRepoPath(options.repoPath);
|
|
484
485
|
const moduleName = validateModuleName(options.moduleName);
|
|
485
486
|
const sourceType = normalizeSourceType(options.sourceType);
|
|
487
|
+
const destination = modulePath(options.target, sourceType, repoPath, moduleName);
|
|
488
|
+
if (options.dryRun) {
|
|
489
|
+
return {
|
|
490
|
+
moduleName,
|
|
491
|
+
repoPath,
|
|
492
|
+
sourceType,
|
|
493
|
+
path: destination,
|
|
494
|
+
deleteFiles: options.deleteFiles,
|
|
495
|
+
dryRun: true,
|
|
496
|
+
...(options.deleteFiles ? { wouldDeletePath: destination } : {}),
|
|
497
|
+
summary: `Previewed removal of module ${moduleName} from source repo ${repoPath}.`,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
486
500
|
if (options.deleteFiles) {
|
|
487
501
|
await assertModuleCleanBeforeDelete(options.target, sourceType, repoPath, moduleName, git);
|
|
488
502
|
}
|
|
@@ -492,7 +506,7 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
|
|
|
492
506
|
}
|
|
493
507
|
await updateModuleRegistration(options.target, sourceType, repoPath, moduleName, 'remove');
|
|
494
508
|
if (options.deleteFiles) {
|
|
495
|
-
await rm(
|
|
509
|
+
await rm(destination, { recursive: true, force: true });
|
|
496
510
|
}
|
|
497
511
|
if (options.stage) {
|
|
498
512
|
if (options.deleteFiles) {
|
|
@@ -500,4 +514,14 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
|
|
|
500
514
|
}
|
|
501
515
|
await stageAll(git, options.target);
|
|
502
516
|
}
|
|
517
|
+
return {
|
|
518
|
+
moduleName,
|
|
519
|
+
repoPath,
|
|
520
|
+
sourceType,
|
|
521
|
+
path: destination,
|
|
522
|
+
deleteFiles: options.deleteFiles,
|
|
523
|
+
dryRun: false,
|
|
524
|
+
...(options.deleteFiles ? { wouldDeletePath: destination } : {}),
|
|
525
|
+
summary: `Removed module ${moduleName} from source repo ${repoPath}.`,
|
|
526
|
+
};
|
|
503
527
|
}
|
package/dist/module-quality.js
CHANGED
|
@@ -154,6 +154,49 @@ function declaredModelIds(modelFileContents) {
|
|
|
154
154
|
}
|
|
155
155
|
return modelIds;
|
|
156
156
|
}
|
|
157
|
+
function declaredModelNames(modelFileContents) {
|
|
158
|
+
const modelNames = new Set();
|
|
159
|
+
for (const content of modelFileContents) {
|
|
160
|
+
for (const match of content.matchAll(/^\s*_name\s*=\s*["']([^"']+)["']/gmu)) {
|
|
161
|
+
const modelName = match[1]?.trim();
|
|
162
|
+
if (modelName) {
|
|
163
|
+
modelNames.add(modelName);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const match of content.matchAll(/^\s*_inherit\s*=\s*["']([^"']+)["']/gmu)) {
|
|
167
|
+
const modelName = match[1]?.trim();
|
|
168
|
+
if (modelName) {
|
|
169
|
+
modelNames.add(modelName);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return modelNames;
|
|
174
|
+
}
|
|
175
|
+
function escapeRegExp(value) {
|
|
176
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
177
|
+
}
|
|
178
|
+
function xmlRecordFieldValues(xmlFiles, recordModel, fieldName) {
|
|
179
|
+
const values = [];
|
|
180
|
+
const recordPattern = new RegExp(`<record\\b[^>]*\\bmodel=(["'])${escapeRegExp(recordModel)}\\1[^>]*>(.*?)</record>`, 'gsu');
|
|
181
|
+
const fieldPattern = new RegExp(`<field\\b[^>]*\\bname=(["'])${escapeRegExp(fieldName)}\\1[^>]*>(.*?)</field>`, 'gsu');
|
|
182
|
+
for (const content of xmlFiles) {
|
|
183
|
+
let recordMatch;
|
|
184
|
+
while ((recordMatch = recordPattern.exec(content))) {
|
|
185
|
+
const body = recordMatch[2] ?? '';
|
|
186
|
+
let fieldMatch;
|
|
187
|
+
while ((fieldMatch = fieldPattern.exec(body))) {
|
|
188
|
+
const value = fieldMatch[2]?.replace(/<[^>]+>/gu, '').trim();
|
|
189
|
+
if (value) {
|
|
190
|
+
values.push(value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return values;
|
|
196
|
+
}
|
|
197
|
+
function looksLikePlainModelName(value) {
|
|
198
|
+
return /^[a-zA-Z_][\w]*(?:\.[a-zA-Z_][\w]*)+$/u.test(value);
|
|
199
|
+
}
|
|
157
200
|
function accessCsvModelIds(content) {
|
|
158
201
|
if (!content)
|
|
159
202
|
return [];
|
|
@@ -206,6 +249,7 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
206
249
|
const modelFiles = await readPythonModelFiles(modulePath);
|
|
207
250
|
const viewFiles = await readViewXmlFiles(modulePath);
|
|
208
251
|
const viewXml = await Promise.all(viewFiles.map(async (fileName) => (await readOptionalFile(join(modulePath, 'views', fileName))) ?? ''));
|
|
252
|
+
const allViewXml = [...viewXml, ...menuXml];
|
|
209
253
|
const depends = manifestDepends(manifest);
|
|
210
254
|
const data = manifestData(manifest);
|
|
211
255
|
const demo = manifestDemo(manifest);
|
|
@@ -241,6 +285,7 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
241
285
|
}
|
|
242
286
|
const modelFileContents = await readModelFileContents(modulePath, modelFiles);
|
|
243
287
|
const modelIds = declaredModelIds(modelFileContents);
|
|
288
|
+
const modelNames = declaredModelNames(modelFileContents);
|
|
244
289
|
if (modelIds.size > 0) {
|
|
245
290
|
for (const modelId of accessCsvModelIds(await readOptionalFile(join(modulePath, 'security/ir.model.access.csv')))) {
|
|
246
291
|
if (!modelIds.has(modelId)) {
|
|
@@ -248,6 +293,18 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
248
293
|
}
|
|
249
294
|
}
|
|
250
295
|
}
|
|
296
|
+
if (modelNames.size > 0) {
|
|
297
|
+
for (const modelName of new Set(xmlRecordFieldValues(viewXml, 'ir.ui.view', 'model'))) {
|
|
298
|
+
if (looksLikePlainModelName(modelName) && !modelNames.has(modelName)) {
|
|
299
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `view XML references unknown model name: ${modelName}`, 'error'));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const modelName of new Set(xmlRecordFieldValues(allViewXml, 'ir.actions.act_window', 'res_model'))) {
|
|
303
|
+
if (looksLikePlainModelName(modelName) && !modelNames.has(modelName)) {
|
|
304
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `action XML references unknown res_model: ${modelName}`, 'error'));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
251
308
|
if (hasOdooStructures && !dataIncludesAccessCsv(data)) {
|
|
252
309
|
issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv in manifest data'));
|
|
253
310
|
}
|
|
@@ -257,7 +314,6 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
257
314
|
if (!(await directoryExists(join(modulePath, 'tests')))) {
|
|
258
315
|
issues.push(moduleIssue(moduleName, relativePath, 'missing tests directory'));
|
|
259
316
|
}
|
|
260
|
-
const allViewXml = [...viewXml, ...menuXml];
|
|
261
317
|
const actionIds = declaredActionIds(allViewXml);
|
|
262
318
|
for (const actionRef of menuActionReferences(menuXml)) {
|
|
263
319
|
if (!actionIds.has(actionRef)) {
|
|
@@ -124,13 +124,10 @@ index_health AS (
|
|
|
124
124
|
wal_health AS (
|
|
125
125
|
SELECT
|
|
126
126
|
COALESCE((SELECT setting FROM pg_settings WHERE name = 'wal_level'), 'unavailable')::text AS wal_level,
|
|
127
|
-
COALESCE((SELECT setting FROM pg_settings WHERE name = 'archive_mode'), 'unavailable')::text AS wal_archive_mode
|
|
128
|
-
COALESCE((SELECT COUNT(*) FROM pg_ls_waldir()), 0)::text AS wal_file_count,
|
|
129
|
-
COALESCE((SELECT SUM(size) FROM pg_ls_waldir()), 0)::text AS wal_directory_size_bytes
|
|
127
|
+
COALESCE((SELECT setting FROM pg_settings WHERE name = 'archive_mode'), 'unavailable')::text AS wal_archive_mode
|
|
130
128
|
),
|
|
131
129
|
capacity_health AS (
|
|
132
130
|
SELECT
|
|
133
|
-
COALESCE((SELECT pg_tablespace_size('pg_default')), 0)::text AS default_tablespace_size_bytes,
|
|
134
131
|
COALESCE(
|
|
135
132
|
(SELECT SUM(n_tup_ins + n_tup_upd + n_tup_del) FROM pg_stat_database WHERE datname IS NOT NULL),
|
|
136
133
|
0
|
|
@@ -255,16 +252,30 @@ FROM (
|
|
|
255
252
|
UNION ALL
|
|
256
253
|
SELECT 'wal_archive_mode', wal_archive_mode FROM wal_health
|
|
257
254
|
UNION ALL
|
|
258
|
-
SELECT 'wal_file_count', wal_file_count FROM wal_health
|
|
259
|
-
UNION ALL
|
|
260
|
-
SELECT 'wal_directory_size_bytes', wal_directory_size_bytes FROM wal_health
|
|
261
|
-
UNION ALL
|
|
262
|
-
SELECT 'default_tablespace_size_bytes', default_tablespace_size_bytes FROM capacity_health
|
|
263
|
-
UNION ALL
|
|
264
255
|
SELECT 'database_write_activity_rows', database_write_activity_rows FROM capacity_health
|
|
265
256
|
) metrics
|
|
266
257
|
ORDER BY metric;
|
|
267
258
|
`.trim();
|
|
259
|
+
export const POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES = [
|
|
260
|
+
{
|
|
261
|
+
id: 'wal-directory',
|
|
262
|
+
query: `
|
|
263
|
+
SELECT metric || '|' || value
|
|
264
|
+
FROM (
|
|
265
|
+
SELECT 'wal_file_count'::text AS metric, COALESCE((SELECT COUNT(*) FROM pg_ls_waldir()), 0)::text AS value
|
|
266
|
+
UNION ALL
|
|
267
|
+
SELECT 'wal_directory_size_bytes', COALESCE((SELECT SUM(size) FROM pg_ls_waldir()), 0)::text
|
|
268
|
+
) metrics
|
|
269
|
+
ORDER BY metric;
|
|
270
|
+
`.trim(),
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: 'default-tablespace',
|
|
274
|
+
query: `
|
|
275
|
+
SELECT 'default_tablespace_size_bytes'::text || '|' || COALESCE((SELECT pg_tablespace_size('pg_default')), 0)::text;
|
|
276
|
+
`.trim(),
|
|
277
|
+
},
|
|
278
|
+
];
|
|
268
279
|
export const POSTGRES_DIAGNOSTIC_KEYS = [
|
|
269
280
|
'database_count',
|
|
270
281
|
'active_connections',
|
package/dist/source-actions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defaultOdooVersion, readEnvironmentMetadata, replaceSourceRepos } from './environment.js';
|
|
2
2
|
import { realGit, stageAll } from './git.js';
|
|
3
|
-
import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
|
|
3
|
+
import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceManifestPath, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
|
|
4
4
|
function cloneSourceEntries(entries) {
|
|
5
5
|
return entries.map((entry) => ({
|
|
6
6
|
...entry,
|
|
@@ -47,6 +47,95 @@ export function sourceSyncJson(entries, target) {
|
|
|
47
47
|
sources: cloneSourceEntries(entries),
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
function sourceKey(entry) {
|
|
51
|
+
return `${entry.type}/${entry.path}`;
|
|
52
|
+
}
|
|
53
|
+
function sourceMap(entries) {
|
|
54
|
+
return new Map(entries.map((entry) => [sourceKey(entry), entry]));
|
|
55
|
+
}
|
|
56
|
+
function addonsEqual(left, right) {
|
|
57
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
58
|
+
}
|
|
59
|
+
function sourceDriftChanges(file, currentEntries, nextEntries) {
|
|
60
|
+
const changes = [];
|
|
61
|
+
const current = sourceMap(currentEntries);
|
|
62
|
+
const next = sourceMap(nextEntries);
|
|
63
|
+
for (const [key, nextEntry] of next) {
|
|
64
|
+
const currentEntry = current.get(key);
|
|
65
|
+
if (!currentEntry) {
|
|
66
|
+
changes.push({ file, kind: 'add', source: key, after: cloneSourceEntries([nextEntry])[0] });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const field of ['url', 'branch']) {
|
|
70
|
+
const before = currentEntry[field] ?? '';
|
|
71
|
+
const after = nextEntry[field] ?? '';
|
|
72
|
+
if (before !== after) {
|
|
73
|
+
changes.push({ file, kind: 'update', source: key, field, before, after });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!addonsEqual(currentEntry.addons, nextEntry.addons)) {
|
|
77
|
+
changes.push({
|
|
78
|
+
file,
|
|
79
|
+
kind: 'update',
|
|
80
|
+
source: key,
|
|
81
|
+
field: 'addons',
|
|
82
|
+
before: [...currentEntry.addons],
|
|
83
|
+
after: [...nextEntry.addons],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const [key, currentEntry] of current) {
|
|
88
|
+
if (!next.has(key)) {
|
|
89
|
+
changes.push({ file, kind: 'remove', source: key, before: cloneSourceEntries([currentEntry])[0] });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return changes;
|
|
93
|
+
}
|
|
94
|
+
export async function sourceSyncPlan(target) {
|
|
95
|
+
const metadata = await readEnvironmentMetadata(target);
|
|
96
|
+
const manifest = await readSourceManifest(target);
|
|
97
|
+
const gitmodules = await listGitmoduleSources(target);
|
|
98
|
+
const fallbackBranch = metadata?.odooVersion ?? defaultOdooVersion;
|
|
99
|
+
const baseRepos = metadata?.sourceRepos.length ? metadata.sourceRepos : sourceReposFromManifest(manifest.sources);
|
|
100
|
+
const sources = syncManifestFromMetadataAndGitmodules(baseRepos, fallbackBranch, gitmodules);
|
|
101
|
+
return {
|
|
102
|
+
target,
|
|
103
|
+
sources,
|
|
104
|
+
changes: sourceDriftChanges(sourceManifestPath, manifest.sources, sources),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function renderValue(value) {
|
|
108
|
+
if (Array.isArray(value))
|
|
109
|
+
return `[${value.join(', ')}]`;
|
|
110
|
+
if (typeof value === 'object' && value)
|
|
111
|
+
return JSON.stringify(value);
|
|
112
|
+
return `"${value ?? ''}"`;
|
|
113
|
+
}
|
|
114
|
+
export function renderSourceSyncPlan(plan) {
|
|
115
|
+
if (plan.changes.length === 0) {
|
|
116
|
+
return 'Source manifest already in sync.';
|
|
117
|
+
}
|
|
118
|
+
return plan.changes
|
|
119
|
+
.map((change) => {
|
|
120
|
+
if (change.kind === 'add')
|
|
121
|
+
return `source manifest add ${change.source}`;
|
|
122
|
+
if (change.kind === 'remove')
|
|
123
|
+
return `source manifest remove ${change.source}`;
|
|
124
|
+
return `source manifest update ${change.source} ${change.field}: ${renderValue(change.before)} -> ${renderValue(change.after)}`;
|
|
125
|
+
})
|
|
126
|
+
.join('\n');
|
|
127
|
+
}
|
|
128
|
+
export function sourceSyncPlanJson(plan) {
|
|
129
|
+
return {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
command: 'source sync preview',
|
|
132
|
+
ok: true,
|
|
133
|
+
target: plan.target,
|
|
134
|
+
dryRun: true,
|
|
135
|
+
sources: cloneSourceEntries(plan.sources),
|
|
136
|
+
changes: plan.changes.map((change) => ({ ...change })),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
50
139
|
export async function syncSources(options, git = realGit) {
|
|
51
140
|
const metadata = await readEnvironmentMetadata(options.target);
|
|
52
141
|
const manifest = await readSourceManifest(options.target);
|
package/dist/templates.js
CHANGED
|
@@ -208,8 +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=
|
|
212
|
-
require
|
|
211
|
+
In WPMOO_ENV=stage, lifecycle commands such as install, update, stop, and
|
|
212
|
+
restart require WPMOO_ALLOW_STAGE_LIFECYCLE=1. In WPMOO_ENV=prod, lifecycle
|
|
213
|
+
commands such as install, update, test, stop, and restart require
|
|
214
|
+
WPMOO_ALLOW_PROD_LIFECYCLE=1. Destructive database commands such as
|
|
213
215
|
resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage
|
|
214
216
|
and prod. restore-snapshot --dry-run remains available for preview.
|
|
215
217
|
For short-lived local approvals, add JSONL entries to \`.wpmoo/approvals.jsonl\`;
|
|
@@ -725,6 +727,84 @@ list_snapshots() {
|
|
|
725
727
|
fi
|
|
726
728
|
}
|
|
727
729
|
|
|
730
|
+
list_snapshots_json() {
|
|
731
|
+
node --input-type=module <<'NODE'
|
|
732
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
733
|
+
import { join } from 'node:path';
|
|
734
|
+
|
|
735
|
+
const directories = ['backups/snapshots', 'backups', 'backup', 'snapshots'];
|
|
736
|
+
const extensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
|
|
737
|
+
const now = Date.now();
|
|
738
|
+
|
|
739
|
+
function snapshotStem(file) {
|
|
740
|
+
return file
|
|
741
|
+
.replace(/\\.filestore\\.tar\\.gz$/, '')
|
|
742
|
+
.replace(/\\.sql\\.gz$/, '')
|
|
743
|
+
.replace(/\\.tar\\.gz$/, '')
|
|
744
|
+
.replace(/\\.dump$/, '')
|
|
745
|
+
.replace(/\\.sql$/, '')
|
|
746
|
+
.replace(/\\.zip$/, '')
|
|
747
|
+
.replace(/\\.tar$/, '');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function hasSnapshotExtension(file) {
|
|
751
|
+
return extensions.some((extension) => file.endsWith(extension));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function readManifest(path) {
|
|
755
|
+
if (!existsSync(path)) return undefined;
|
|
756
|
+
try {
|
|
757
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
758
|
+
} catch {
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function manifestString(manifest, key) {
|
|
764
|
+
return manifest && typeof manifest[key] === 'string' ? manifest[key] : undefined;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const snapshots = [];
|
|
768
|
+
for (const directory of directories) {
|
|
769
|
+
if (!existsSync(directory)) continue;
|
|
770
|
+
|
|
771
|
+
for (const file of readdirSync(directory).sort()) {
|
|
772
|
+
if (file.endsWith('.filestore.tar.gz') || !hasSnapshotExtension(file)) continue;
|
|
773
|
+
|
|
774
|
+
const dumpPath = join(directory, file);
|
|
775
|
+
const stats = statSync(dumpPath);
|
|
776
|
+
if (!stats.isFile()) continue;
|
|
777
|
+
|
|
778
|
+
const name = snapshotStem(file);
|
|
779
|
+
const manifestPath = join(directory, name + '.json');
|
|
780
|
+
const manifest = readManifest(manifestPath);
|
|
781
|
+
const manifestDump = manifestString(manifest, 'dump');
|
|
782
|
+
const manifestFilestore = manifestString(manifest, 'filestore');
|
|
783
|
+
const filestorePath = join(directory, manifestFilestore || name + '.filestore.tar.gz');
|
|
784
|
+
const createdAtMs = Date.parse(manifestString(manifest, 'created_at') || '');
|
|
785
|
+
const effectiveCreatedAtMs = Number.isFinite(createdAtMs) ? createdAtMs : stats.mtimeMs;
|
|
786
|
+
|
|
787
|
+
snapshots.push({
|
|
788
|
+
name: manifestString(manifest, 'name') || name,
|
|
789
|
+
path: dumpPath,
|
|
790
|
+
dumpPath: manifestDump ? join(directory, manifestDump) : dumpPath,
|
|
791
|
+
...(existsSync(manifestPath) ? { manifestPath } : {}),
|
|
792
|
+
...(manifestString(manifest, 'database') ? { databaseName: manifestString(manifest, 'database') } : {}),
|
|
793
|
+
createdAtMs: effectiveCreatedAtMs,
|
|
794
|
+
createdAt: new Date(effectiveCreatedAtMs).toISOString(),
|
|
795
|
+
mtimeMs: stats.mtimeMs,
|
|
796
|
+
ageMs: Math.max(0, now - effectiveCreatedAtMs),
|
|
797
|
+
filestorePath,
|
|
798
|
+
filestoreStatus: existsSync(filestorePath) ? 'found' : 'missing',
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
snapshots.sort((left, right) => right.createdAtMs - left.createdAtMs || left.path.localeCompare(right.path));
|
|
804
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: 'snapshot list', ok: true, snapshots }, null, 2));
|
|
805
|
+
NODE
|
|
806
|
+
}
|
|
807
|
+
|
|
728
808
|
require_recent_snapshot_or_override() {
|
|
729
809
|
local command="$1"
|
|
730
810
|
local env_name
|
|
@@ -834,6 +914,8 @@ case "$command" in
|
|
|
834
914
|
"stop")
|
|
835
915
|
shift
|
|
836
916
|
require_no_args "$command" "$@"
|
|
917
|
+
require_stage_lifecycle_allowed "$command"
|
|
918
|
+
require_prod_lifecycle_allowed "$command"
|
|
837
919
|
run_script ./scripts/down.sh
|
|
838
920
|
;;
|
|
839
921
|
"logs")
|
|
@@ -854,6 +936,8 @@ case "$command" in
|
|
|
854
936
|
"restart")
|
|
855
937
|
shift
|
|
856
938
|
require_no_args "$command" "$@"
|
|
939
|
+
require_stage_lifecycle_allowed "$command"
|
|
940
|
+
require_prod_lifecycle_allowed "$command"
|
|
857
941
|
run_script ./scripts/restart.sh
|
|
858
942
|
;;
|
|
859
943
|
"shell")
|
|
@@ -898,10 +982,32 @@ case "$command" in
|
|
|
898
982
|
;;
|
|
899
983
|
"snapshot")
|
|
900
984
|
shift
|
|
901
|
-
if [[ "\${1:-}" == "--list" ]]; then
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
985
|
+
if [[ "\${1:-}" == "--list" || "\${1:-}" == "--json" ]]; then
|
|
986
|
+
list_requested=0
|
|
987
|
+
json_requested=0
|
|
988
|
+
while [[ "$#" -gt 0 ]]; do
|
|
989
|
+
case "$1" in
|
|
990
|
+
"--list")
|
|
991
|
+
list_requested=1
|
|
992
|
+
shift
|
|
993
|
+
;;
|
|
994
|
+
"--json")
|
|
995
|
+
json_requested=1
|
|
996
|
+
shift
|
|
997
|
+
;;
|
|
998
|
+
*)
|
|
999
|
+
fail_usage "$command"
|
|
1000
|
+
;;
|
|
1001
|
+
esac
|
|
1002
|
+
done
|
|
1003
|
+
if [[ "$list_requested" -ne 1 ]]; then
|
|
1004
|
+
fail_usage "$command"
|
|
1005
|
+
fi
|
|
1006
|
+
if [[ "$json_requested" -eq 1 ]]; then
|
|
1007
|
+
list_snapshots_json
|
|
1008
|
+
else
|
|
1009
|
+
list_snapshots
|
|
1010
|
+
fi
|
|
905
1011
|
exit 0
|
|
906
1012
|
fi
|
|
907
1013
|
positional_args "$command" 0 2 "$@"
|
package/docs/1-0-readiness.md
CHANGED
|
@@ -65,8 +65,9 @@ Ready:
|
|
|
65
65
|
|
|
66
66
|
Ready:
|
|
67
67
|
|
|
68
|
-
- Stage `install` and `
|
|
69
|
-
|
|
68
|
+
- Stage `install`, `update`, `stop`, and `restart` require
|
|
69
|
+
`WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
|
|
70
|
+
- Production `install`, `update`, `test`, `stop`, and `restart` require
|
|
70
71
|
`WPMOO_ALLOW_PROD_LIFECYCLE=1`.
|
|
71
72
|
- Destructive database commands require `WPMOO_ALLOW_DESTRUCTIVE=1` in stage
|
|
72
73
|
and production.
|
|
@@ -66,9 +66,15 @@ Current JSON contract notes:
|
|
|
66
66
|
- `status --json` uses `schemaVersion: 1`.
|
|
67
67
|
- `source list --json` and `source sync --json` use `schemaVersion: 1`.
|
|
68
68
|
- `doctor --json` uses `schemaVersion: 1`.
|
|
69
|
+
- `doctor --json --fix` is intentionally unsupported because `doctor --fix`
|
|
70
|
+
may mutate files. Run `doctor --fix` first, then `doctor --json` to inspect
|
|
71
|
+
post-fix state.
|
|
69
72
|
- `doctor --json --postgres` adds `postgres.contractVersion` and a PostgreSQL
|
|
70
73
|
diagnostics object with its own `schemaVersion`.
|
|
71
74
|
- PostgreSQL fields are optional when a metric is unavailable.
|
|
75
|
+
- Optional privileged PostgreSQL probe failures may appear under
|
|
76
|
+
`postgres.optionalProbeFailures`; core diagnostics remain available when those
|
|
77
|
+
optional probes fail with permission errors.
|
|
72
78
|
- Automation should ignore unknown JSON fields.
|
|
73
79
|
- Minor and patch releases may add optional fields without a breaking release.
|
|
74
80
|
- Removing, renaming, or changing the meaning of a documented field requires a
|
|
@@ -79,8 +85,8 @@ Current JSON contract notes:
|
|
|
79
85
|
| Variable | Purpose |
|
|
80
86
|
| --- | --- |
|
|
81
87
|
| `WPMOO_ENV=dev|stage|prod` | Selects environment safety policy. Missing value behaves like development for local workflows. |
|
|
82
|
-
| `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Allows `install` and `
|
|
83
|
-
| `WPMOO_ALLOW_PROD_LIFECYCLE=1` | Allows `install`, `update`, and `
|
|
88
|
+
| `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Allows `install`, `update`, `stop`, and `restart` in stage. |
|
|
89
|
+
| `WPMOO_ALLOW_PROD_LIFECYCLE=1` | Allows `install`, `update`, `test`, `stop`, and `restart` in production. |
|
|
84
90
|
| `WPMOO_ALLOW_DESTRUCTIVE=1` | Allows destructive database commands in stage/prod. |
|
|
85
91
|
| `WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1` | Allows destructive commands without a recent snapshot when that extra guard applies. |
|
|
86
92
|
| `WPMOO_ALLOW_MIGRATIONS=1` | Allows lifecycle commands when migration scripts are detected. |
|
|
@@ -121,6 +127,7 @@ approval. Expired, malformed, or mismatched entries are ignored.
|
|
|
121
127
|
| Command Family | Stage | Production |
|
|
122
128
|
| --- | --- | --- |
|
|
123
129
|
| `install`, `update` | Requires `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
|
|
130
|
+
| `stop`, `restart` | Requires `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
|
|
124
131
|
| `test` | Allowed | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
|
|
125
132
|
| `resetdb`, real `restore-snapshot` | Requires `WPMOO_ALLOW_DESTRUCTIVE=1` | Requires `WPMOO_ALLOW_DESTRUCTIVE=1` |
|
|
126
133
|
| `restore-snapshot --dry-run` | Allowed | Allowed |
|
package/docs/handoff.md
CHANGED
|
@@ -70,6 +70,9 @@ pre-existing global `NPM_CONFIG_CACHE` state unless you intentionally reuse it.
|
|
|
70
70
|
|
|
71
71
|
The smoke script checks `--version`, top-level `--help`, and critical command
|
|
72
72
|
help output before optional generated-environment acceptance smoke.
|
|
73
|
+
Long registry-backed smoke runs print `Smoke step:` progress lines before each
|
|
74
|
+
published CLI check, so a slow `npx` or `npm exec` resolution is visible in the
|
|
75
|
+
terminal instead of appearing stuck.
|
|
73
76
|
|
|
74
77
|
For a 1.0.0 tag, run generated-environment acceptance smoke with
|
|
75
78
|
WPMOO_SMOKE_ENVIRONMENT=1. Treat the release as final only after that smoke
|