@wpmoo/toolkit 0.9.28 → 0.9.30
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 +41 -3
- package/dist/approval-ledger.js +74 -0
- package/dist/cli-routes/doctor.js +20 -0
- package/dist/cli-routes/options.js +36 -0
- package/dist/cli-routes/reset.js +11 -0
- package/dist/cli-routes/source.js +112 -0
- package/dist/cli.js +23 -169
- package/dist/cockpit/command-registry.js +9 -3
- package/dist/cockpit/daily-prompts.js +33 -6
- package/dist/cockpit/menu.js +12 -7
- package/dist/cockpit/module-browser.js +79 -2
- package/dist/daily-actions.js +55 -12
- package/dist/databases.js +223 -36
- package/dist/doctor.js +129 -15
- package/dist/github.js +11 -2
- package/dist/help.js +14 -6
- package/dist/module-actions.js +23 -2
- package/dist/module-manifest.js +6 -0
- package/dist/module-quality.js +98 -0
- package/dist/postgres-diagnostics.js +27 -0
- package/dist/repo-url.js +4 -7
- package/dist/safe-reset.js +21 -12
- package/dist/source-manifest.js +2 -2
- package/dist/templates.js +149 -17
- package/docs/1-0-readiness.md +64 -46
- package/docs/command-reference.md +28 -3
- package/docs/generated-environment-verification.md +23 -2
- package/docs/handoff.md +21 -1
- package/docs/lifecycle-recipes.md +6 -1
- package/docs/troubleshooting.md +29 -1
- package/package.json +1 -1
package/dist/databases.js
CHANGED
|
@@ -1,13 +1,128 @@
|
|
|
1
|
-
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
export const defaultDatabaseSnapshotMaxAgeMs = 24 * 60 * 60 * 1000;
|
|
5
|
-
export const databaseSnapshotDirectoryNames = ['backups', 'backup', 'snapshots'];
|
|
5
|
+
export const databaseSnapshotDirectoryNames = ['backups/snapshots', 'backups', 'backup', 'snapshots'];
|
|
6
6
|
export const databaseSnapshotExtensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
|
|
7
7
|
function isDatabaseSnapshotFile(fileName, extensions) {
|
|
8
8
|
const normalized = fileName.toLowerCase();
|
|
9
9
|
return extensions.some((extension) => normalized.endsWith(extension));
|
|
10
10
|
}
|
|
11
|
+
function fileExists(path) {
|
|
12
|
+
try {
|
|
13
|
+
return statSync(path).isFile();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function stripSnapshotExtension(fileName) {
|
|
20
|
+
const normalized = fileName.toLowerCase();
|
|
21
|
+
const suffixes = ['.filestore.tar.gz', '.sql.gz', '.tar.gz', '.dump', '.sql', '.zip', '.tar', '.json'];
|
|
22
|
+
const suffix = suffixes.find((candidate) => normalized.endsWith(candidate));
|
|
23
|
+
return suffix ? fileName.slice(0, -suffix.length) : undefined;
|
|
24
|
+
}
|
|
25
|
+
function snapshotComponentKind(fileName, snapshotExtensions) {
|
|
26
|
+
const normalized = fileName.toLowerCase();
|
|
27
|
+
if (normalized.endsWith('.json'))
|
|
28
|
+
return 'manifest';
|
|
29
|
+
if (normalized.endsWith('.filestore.tar.gz'))
|
|
30
|
+
return 'filestore';
|
|
31
|
+
return isDatabaseSnapshotFile(fileName, snapshotExtensions) ? 'dump' : undefined;
|
|
32
|
+
}
|
|
33
|
+
function snapshotNameFromPath(path) {
|
|
34
|
+
return stripSnapshotExtension(basename(path));
|
|
35
|
+
}
|
|
36
|
+
function snapshotComponentPriority(component) {
|
|
37
|
+
const normalized = component.fileName.toLowerCase();
|
|
38
|
+
if (normalized.endsWith('.dump'))
|
|
39
|
+
return 0;
|
|
40
|
+
if (normalized.endsWith('.sql.gz'))
|
|
41
|
+
return 1;
|
|
42
|
+
if (normalized.endsWith('.sql'))
|
|
43
|
+
return 2;
|
|
44
|
+
return 3;
|
|
45
|
+
}
|
|
46
|
+
function readSnapshotManifest(path) {
|
|
47
|
+
if (!path)
|
|
48
|
+
return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
51
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const defaultSnapshotDatabaseNameHints = ['devel', 'stage', 'staging', 'prod', 'production', 'test', 'postgres'];
|
|
58
|
+
function inferDatabaseNameFromSnapshotName(snapshotName, databaseNames) {
|
|
59
|
+
const candidates = databaseNames?.length ? databaseNames : defaultSnapshotDatabaseNameHints;
|
|
60
|
+
const match = candidates
|
|
61
|
+
.filter(isValidDatabaseName)
|
|
62
|
+
.sort((left, right) => right.length - left.length)
|
|
63
|
+
.find((database) => snapshotName.startsWith(`${database}-`) || snapshotName.startsWith(`${database}.`) || snapshotName.startsWith(`${database}_`));
|
|
64
|
+
return match;
|
|
65
|
+
}
|
|
66
|
+
function manifestDateMs(manifest) {
|
|
67
|
+
if (!manifest || typeof manifest.created_at !== 'string')
|
|
68
|
+
return undefined;
|
|
69
|
+
const parsed = Date.parse(manifest.created_at);
|
|
70
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
71
|
+
}
|
|
72
|
+
function manifestDatabaseName(manifest) {
|
|
73
|
+
return typeof manifest?.database === 'string' && isValidDatabaseName(manifest.database) ? manifest.database : undefined;
|
|
74
|
+
}
|
|
75
|
+
function snapshotGroups(targetDirectory, snapshotDirectories, snapshotExtensions) {
|
|
76
|
+
const groups = new Map();
|
|
77
|
+
for (const directoryName of snapshotDirectories) {
|
|
78
|
+
const directory = join(targetDirectory, directoryName);
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code === 'ENOENT') {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (!entry.isFile()) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const stem = stripSnapshotExtension(entry.name);
|
|
94
|
+
const kind = snapshotComponentKind(entry.name, snapshotExtensions);
|
|
95
|
+
if (!stem || !kind) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const path = join(directory, entry.name);
|
|
99
|
+
let stats;
|
|
100
|
+
try {
|
|
101
|
+
stats = statSync(path);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!stats.isFile()) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const key = `${directory}\0${stem}`;
|
|
110
|
+
const group = groups.get(key) ?? { directory, stem, dumps: [], filestores: [] };
|
|
111
|
+
const component = { path, fileName: entry.name, stem, kind, stats };
|
|
112
|
+
if (kind === 'manifest') {
|
|
113
|
+
group.manifest = component;
|
|
114
|
+
}
|
|
115
|
+
else if (kind === 'filestore') {
|
|
116
|
+
group.filestores.push(component);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
group.dumps.push(component);
|
|
120
|
+
}
|
|
121
|
+
groups.set(key, group);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...groups.values()];
|
|
125
|
+
}
|
|
11
126
|
const maintenanceDatabases = new Set(['postgres']);
|
|
12
127
|
const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
|
|
13
128
|
export function isValidDatabaseName(value) {
|
|
@@ -33,6 +148,22 @@ export function normalizeDatabaseName(value) {
|
|
|
33
148
|
}
|
|
34
149
|
return normalized;
|
|
35
150
|
}
|
|
151
|
+
export function normalizeSnapshotName(value) {
|
|
152
|
+
const normalized = value.trim();
|
|
153
|
+
if (!normalized) {
|
|
154
|
+
throw new Error('Invalid snapshot name: value is required.');
|
|
155
|
+
}
|
|
156
|
+
if (/\s/u.test(value)) {
|
|
157
|
+
throw new Error('Invalid snapshot name: whitespace is not allowed.');
|
|
158
|
+
}
|
|
159
|
+
if (normalized.startsWith('-')) {
|
|
160
|
+
throw new Error('Invalid snapshot name: leading hyphens are not allowed.');
|
|
161
|
+
}
|
|
162
|
+
if (!databaseNamePattern.test(normalized)) {
|
|
163
|
+
throw new Error('Invalid snapshot name: use letters, digits, underscores, dots, or hyphens without shell metacharacters or path characters.');
|
|
164
|
+
}
|
|
165
|
+
return normalized;
|
|
166
|
+
}
|
|
36
167
|
const listDatabasesQuery = [
|
|
37
168
|
'SELECT datname',
|
|
38
169
|
'FROM pg_database',
|
|
@@ -56,40 +187,35 @@ export function parseDatabaseListOutput(output, options = {}) {
|
|
|
56
187
|
return databases;
|
|
57
188
|
}
|
|
58
189
|
export function findDatabaseSnapshots(targetDirectory, options = {}) {
|
|
59
|
-
const { nowMs = Date.now(), snapshotDirectories = [...databaseSnapshotDirectoryNames], snapshotExtensions = [...databaseSnapshotExtensions], } = options;
|
|
60
|
-
const snapshots =
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
catch (error) {
|
|
68
|
-
if (error.code === 'ENOENT') {
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
throw error;
|
|
190
|
+
const { nowMs = Date.now(), snapshotDirectories = [...databaseSnapshotDirectoryNames], snapshotExtensions = [...databaseSnapshotExtensions], snapshotDatabaseNames, } = options;
|
|
191
|
+
const snapshots = snapshotGroups(targetDirectory, snapshotDirectories, snapshotExtensions)
|
|
192
|
+
.flatMap((group) => {
|
|
193
|
+
const primary = [...group.dumps].sort((left, right) => snapshotComponentPriority(left) - snapshotComponentPriority(right) ||
|
|
194
|
+
right.stats.mtimeMs - left.stats.mtimeMs ||
|
|
195
|
+
left.path.localeCompare(right.path))[0];
|
|
196
|
+
if (!primary) {
|
|
197
|
+
return [];
|
|
72
198
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
199
|
+
const manifest = readSnapshotManifest(group.manifest?.path);
|
|
200
|
+
const createdAtMs = manifestDateMs(manifest) ?? primary.stats.mtimeMs;
|
|
201
|
+
const databaseName = manifestDatabaseName(manifest) ?? inferDatabaseNameFromSnapshotName(group.stem, snapshotDatabaseNames);
|
|
202
|
+
const filestore = [...group.filestores].sort((left, right) => left.path.localeCompare(right.path))[0];
|
|
203
|
+
return [
|
|
204
|
+
{
|
|
205
|
+
name: typeof manifest?.name === 'string' && manifest.name.trim() ? manifest.name.trim() : group.stem,
|
|
206
|
+
path: primary.path,
|
|
207
|
+
dumpPath: primary.path,
|
|
208
|
+
manifestPath: group.manifest?.path,
|
|
209
|
+
databaseName,
|
|
210
|
+
createdAtMs,
|
|
211
|
+
createdAt: new Date(createdAtMs).toISOString(),
|
|
212
|
+
mtimeMs: primary.stats.mtimeMs,
|
|
213
|
+
ageMs: Math.max(0, nowMs - primary.stats.mtimeMs),
|
|
214
|
+
filestorePath: filestore?.path,
|
|
215
|
+
filestoreStatus: filestore ? 'found' : 'missing',
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
});
|
|
93
219
|
snapshots.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
|
|
94
220
|
const newestSnapshot = snapshots[0] ?? null;
|
|
95
221
|
return {
|
|
@@ -98,11 +224,72 @@ export function findDatabaseSnapshots(targetDirectory, options = {}) {
|
|
|
98
224
|
newestSnapshotAgeMs: newestSnapshot ? newestSnapshot.ageMs : null,
|
|
99
225
|
};
|
|
100
226
|
}
|
|
227
|
+
export function restoreSnapshotPreflight(targetDirectory, snapshotName, requestedDatabase = 'devel', options = {}) {
|
|
228
|
+
const snapshots = findDatabaseSnapshots(targetDirectory, options);
|
|
229
|
+
const snapshot = snapshots.snapshots.find((candidate) => candidate.name === snapshotName || snapshotNameFromPath(candidate.path) === snapshotName);
|
|
230
|
+
const snapshotDirectory = join(targetDirectory, 'backups', 'snapshots');
|
|
231
|
+
const manifestPath = snapshot?.manifestPath ?? join(snapshotDirectory, `${snapshotName}.json`);
|
|
232
|
+
const manifest = readSnapshotManifest(fileExists(manifestPath) ? manifestPath : undefined);
|
|
233
|
+
const manifestDatabase = snapshot?.databaseName ?? manifestDatabaseName(manifest);
|
|
234
|
+
const dumpPath = snapshot?.dumpPath ?? join(snapshotDirectory, `${snapshotName}.dump`);
|
|
235
|
+
const filestorePath = snapshot?.filestorePath ??
|
|
236
|
+
(typeof manifest?.filestore === 'string' ? join(snapshotDirectory, manifest.filestore) : join(snapshotDirectory, `${snapshotName}.filestore.tar.gz`));
|
|
237
|
+
const dumpStatus = fileExists(dumpPath) ? 'found' : 'missing';
|
|
238
|
+
const filestoreStatus = fileExists(filestorePath) ? 'found' : 'missing';
|
|
239
|
+
const databaseMatches = manifestDatabase ? manifestDatabase === requestedDatabase : undefined;
|
|
240
|
+
const issues = [];
|
|
241
|
+
if (dumpStatus === 'missing') {
|
|
242
|
+
issues.push('missing snapshot dump');
|
|
243
|
+
}
|
|
244
|
+
if (filestoreStatus === 'missing') {
|
|
245
|
+
issues.push('missing snapshot filestore');
|
|
246
|
+
}
|
|
247
|
+
if (databaseMatches === false) {
|
|
248
|
+
issues.push(`snapshot database mismatch: manifest has ${manifestDatabase}, requested ${requestedDatabase}`);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
name: snapshotName,
|
|
252
|
+
requestedDatabase,
|
|
253
|
+
dumpPath,
|
|
254
|
+
dumpStatus,
|
|
255
|
+
filestorePath,
|
|
256
|
+
filestoreStatus,
|
|
257
|
+
manifestPath: fileExists(manifestPath) ? manifestPath : undefined,
|
|
258
|
+
manifestDatabase,
|
|
259
|
+
databaseMatches,
|
|
260
|
+
issues,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
101
263
|
export function hasRecentDatabaseSnapshot(targetDirectory, options = {}) {
|
|
102
264
|
const { maxAgeMs = defaultDatabaseSnapshotMaxAgeMs, ...scanOptions } = options;
|
|
103
265
|
const result = findDatabaseSnapshots(targetDirectory, scanOptions);
|
|
104
266
|
return result.newestSnapshotAgeMs !== null && result.newestSnapshotAgeMs <= maxAgeMs;
|
|
105
267
|
}
|
|
268
|
+
export function databaseSnapshotCatalogJson(targetDirectory) {
|
|
269
|
+
return {
|
|
270
|
+
schemaVersion: 1,
|
|
271
|
+
command: 'snapshot list',
|
|
272
|
+
ok: true,
|
|
273
|
+
snapshots: findDatabaseSnapshots(targetDirectory).snapshots,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
export function renderDatabaseSnapshotCatalog(targetDirectory) {
|
|
277
|
+
const snapshots = findDatabaseSnapshots(targetDirectory).snapshots;
|
|
278
|
+
if (snapshots.length === 0) {
|
|
279
|
+
return 'No database snapshots found.\nNext: run ./moo snapshot [db] [snapshot-name].';
|
|
280
|
+
}
|
|
281
|
+
return [
|
|
282
|
+
'Database snapshots',
|
|
283
|
+
'',
|
|
284
|
+
...snapshots.flatMap((snapshot) => [
|
|
285
|
+
`- ${snapshot.name}`,
|
|
286
|
+
` Created: ${snapshot.createdAt}`,
|
|
287
|
+
` Database: ${snapshot.databaseName ?? 'unknown'}`,
|
|
288
|
+
` Dump: ${snapshot.dumpPath}`,
|
|
289
|
+
` Filestore: ${snapshot.filestorePath ?? 'missing'} (${snapshot.filestoreStatus})`,
|
|
290
|
+
]),
|
|
291
|
+
].join('\n');
|
|
292
|
+
}
|
|
106
293
|
export function normalizeDatabaseListResult(result) {
|
|
107
294
|
if (Array.isArray(result)) {
|
|
108
295
|
return { ok: true, databases: result };
|
package/dist/doctor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { access, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { execa } from 'execa';
|
|
4
4
|
import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
|
|
@@ -20,6 +20,35 @@ async function exists(path) {
|
|
|
20
20
|
return false;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
async function legacyGeneratedEnvironmentErrors(target) {
|
|
24
|
+
const sourceRoot = join(target, 'odoo/custom/src');
|
|
25
|
+
const hasGeneratedSourceFiles = (await exists(join(sourceRoot, 'addons.yaml'))) ||
|
|
26
|
+
(await exists(join(sourceRoot, 'repos.yaml')));
|
|
27
|
+
if (!hasGeneratedSourceFiles) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
let legacySourceDirs;
|
|
31
|
+
try {
|
|
32
|
+
const entries = await readdir(sourceRoot, { withFileTypes: true });
|
|
33
|
+
legacySourceDirs = entries
|
|
34
|
+
.filter((entry) => entry.isDirectory())
|
|
35
|
+
.map((entry) => entry.name)
|
|
36
|
+
.filter((name) => !['private', 'oca', 'external'].includes(name))
|
|
37
|
+
.filter((name) => /^[A-Za-z0-9._-]+$/.test(name))
|
|
38
|
+
.sort();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
if (legacySourceDirs.length === 0) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return [
|
|
47
|
+
`Legacy WPMoo environment is missing ${markerPath}; run ./moo reset --dry-run to preview generated metadata migration.`,
|
|
48
|
+
...legacySourceDirs.map((path) => `Legacy source layout detected: odoo/custom/src/${path} will be registered as private/${path}.`),
|
|
49
|
+
'Run ./moo reset after reviewing the preview to write current metadata and generated files.',
|
|
50
|
+
];
|
|
51
|
+
}
|
|
23
52
|
function errorMessage(error) {
|
|
24
53
|
return error instanceof Error ? error.message : String(error);
|
|
25
54
|
}
|
|
@@ -221,6 +250,83 @@ function validatePort(name, env, errors) {
|
|
|
221
250
|
function renderFailure(errors) {
|
|
222
251
|
return ['WPMoo doctor failed:', ...errors.map((error) => `- ${error}`)].join('\n');
|
|
223
252
|
}
|
|
253
|
+
const doctorSectionDefinitions = [
|
|
254
|
+
{ id: 'generated-files', title: 'Generated files' },
|
|
255
|
+
{ id: 'compose', title: 'Compose' },
|
|
256
|
+
{ id: 'source-repositories', title: 'Source repositories' },
|
|
257
|
+
{ id: 'postgresql', title: 'PostgreSQL' },
|
|
258
|
+
{ id: 'host-tools', title: 'Host tools' },
|
|
259
|
+
];
|
|
260
|
+
function doctorSectionForLine(line) {
|
|
261
|
+
if (line.includes('PostgreSQL diagnostics') ||
|
|
262
|
+
line.includes('PostgreSQL connection') ||
|
|
263
|
+
line.includes('PostgreSQL slow-query') ||
|
|
264
|
+
line.includes('pg_stat_statements') ||
|
|
265
|
+
line.includes('long transaction') ||
|
|
266
|
+
line.includes('idle in transaction') ||
|
|
267
|
+
line.includes('table health') ||
|
|
268
|
+
line.includes('unused index') ||
|
|
269
|
+
line.includes('WAL') ||
|
|
270
|
+
line.includes('capacity risk')) {
|
|
271
|
+
return 'postgresql';
|
|
272
|
+
}
|
|
273
|
+
if (line.startsWith('OK compose files') ||
|
|
274
|
+
line.startsWith('OK .env ports') ||
|
|
275
|
+
line.startsWith('Missing compose file:') ||
|
|
276
|
+
line.startsWith('Missing compact compose overlay') ||
|
|
277
|
+
line.startsWith('Invalid Odoo version for compose file:') ||
|
|
278
|
+
line.includes('HTTP_PORT') ||
|
|
279
|
+
line.includes('GEVENT_PORT') ||
|
|
280
|
+
line.includes('PostgreSQL 18 compatibility issue')) {
|
|
281
|
+
return 'compose';
|
|
282
|
+
}
|
|
283
|
+
if (line.startsWith('OK source repos') ||
|
|
284
|
+
line.includes('source repo') ||
|
|
285
|
+
line.includes('sourceRepos') ||
|
|
286
|
+
line.includes('source manifest') ||
|
|
287
|
+
line.includes('source path') ||
|
|
288
|
+
line.includes('Git submodule') ||
|
|
289
|
+
line.includes('git submodules') ||
|
|
290
|
+
line.includes('GitHub CLI')) {
|
|
291
|
+
return 'source-repositories';
|
|
292
|
+
}
|
|
293
|
+
if (line.includes('Docker CLI') ||
|
|
294
|
+
line.includes('Docker Compose') ||
|
|
295
|
+
line.startsWith('OK docker')) {
|
|
296
|
+
return 'host-tools';
|
|
297
|
+
}
|
|
298
|
+
return 'generated-files';
|
|
299
|
+
}
|
|
300
|
+
function buildDoctorSections(checks, warnings, errors) {
|
|
301
|
+
const sections = new Map(doctorSectionDefinitions.map(({ id, title }) => [id, { id, title, checks: [], warnings: [], errors: [] }]));
|
|
302
|
+
for (const check of checks) {
|
|
303
|
+
sections.get(doctorSectionForLine(check))?.checks.push(check);
|
|
304
|
+
}
|
|
305
|
+
for (const warning of warnings) {
|
|
306
|
+
sections.get(doctorSectionForLine(warning))?.warnings.push(warning);
|
|
307
|
+
}
|
|
308
|
+
for (const error of errors) {
|
|
309
|
+
sections.get(doctorSectionForLine(error))?.errors.push(error);
|
|
310
|
+
}
|
|
311
|
+
return doctorSectionDefinitions
|
|
312
|
+
.map(({ id }) => sections.get(id))
|
|
313
|
+
.filter((section) => Boolean(section && (section.checks.length > 0 || section.warnings.length > 0 || section.errors.length > 0)));
|
|
314
|
+
}
|
|
315
|
+
function finalizeDoctorReport(report) {
|
|
316
|
+
report.sections = buildDoctorSections(report.checks, report.warnings, report.errors);
|
|
317
|
+
return report;
|
|
318
|
+
}
|
|
319
|
+
function renderSuccessfulDoctorReport(report) {
|
|
320
|
+
const lines = ['WPMoo doctor'];
|
|
321
|
+
const sections = report.sections ?? buildDoctorSections(report.checks, report.warnings, report.errors);
|
|
322
|
+
for (const section of sections) {
|
|
323
|
+
lines.push(section.title);
|
|
324
|
+
lines.push(...section.checks);
|
|
325
|
+
lines.push(...section.warnings.map((warning) => `WARN ${warning}`));
|
|
326
|
+
}
|
|
327
|
+
lines.push('Doctor checks passed.');
|
|
328
|
+
return lines.join('\n');
|
|
329
|
+
}
|
|
224
330
|
function isNotGitCheckoutError(error) {
|
|
225
331
|
return commandErrorText(error).toLowerCase().includes('not a git repository');
|
|
226
332
|
}
|
|
@@ -318,8 +424,15 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
318
424
|
metadata = await readMetadata(target);
|
|
319
425
|
}
|
|
320
426
|
catch (error) {
|
|
321
|
-
|
|
322
|
-
|
|
427
|
+
const message = errorMessage(error);
|
|
428
|
+
if (message === `Missing metadata file: ${markerPath}`) {
|
|
429
|
+
const legacyErrors = await legacyGeneratedEnvironmentErrors(target);
|
|
430
|
+
errors.push(...(legacyErrors.length > 0 ? legacyErrors : [message]));
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
errors.push(message);
|
|
434
|
+
}
|
|
435
|
+
return finalizeDoctorReport(report);
|
|
323
436
|
}
|
|
324
437
|
checks.push(`OK metadata ${markerPath}`);
|
|
325
438
|
const engine = metadataString(metadata, 'engine') ?? 'compose';
|
|
@@ -392,13 +505,20 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
392
505
|
}
|
|
393
506
|
catch (error) {
|
|
394
507
|
errors.push(errorMessage(error));
|
|
395
|
-
return report;
|
|
508
|
+
return finalizeDoctorReport(report);
|
|
396
509
|
}
|
|
397
510
|
for (const repo of sourceRepos) {
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
511
|
+
const sourceType = normalizeSourceType(repo.sourceType);
|
|
512
|
+
const relativePath = sourceRepoPath(sourceType, repo.path);
|
|
513
|
+
if ((await exists(join(target, relativePath))) || !repo.path) {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const legacyPrivatePath = sourceType === 'private' ? `odoo/custom/src/${repo.path}` : undefined;
|
|
517
|
+
if (legacyPrivatePath && (await exists(join(target, legacyPrivatePath)))) {
|
|
518
|
+
warnings.push(`Legacy private source path in use: ${legacyPrivatePath}; move it to ${relativePath} when ready.`);
|
|
519
|
+
continue;
|
|
401
520
|
}
|
|
521
|
+
errors.push(`Missing source repo path: ${relativePath}`);
|
|
402
522
|
}
|
|
403
523
|
checks.push(`OK source repos ${sourceRepos.length} checked`);
|
|
404
524
|
const manifestPath = join(target, sourceManifestPath);
|
|
@@ -536,7 +656,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
536
656
|
warnings.push(`GitHub CLI auth: ${errorMessage(error)}`);
|
|
537
657
|
}
|
|
538
658
|
report.ok = errors.length === 0;
|
|
539
|
-
return report;
|
|
659
|
+
return finalizeDoctorReport(report);
|
|
540
660
|
}
|
|
541
661
|
export async function runDoctor(target = process.cwd(), runnerOrOptions = realCommandRunner, options = {}) {
|
|
542
662
|
const report = await getDoctorReport(target, runnerOrOptions, options);
|
|
@@ -555,12 +675,6 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
|
|
|
555
675
|
}
|
|
556
676
|
throw new Error(renderFailure(report.errors));
|
|
557
677
|
}
|
|
558
|
-
const renderedReport = [
|
|
559
|
-
'WPMoo doctor',
|
|
560
|
-
...report.checks,
|
|
561
|
-
...report.warnings.map((warning) => `WARN ${warning}`),
|
|
562
|
-
'Doctor checks passed.',
|
|
563
|
-
];
|
|
564
678
|
if (report.appliedFixes.length > 0) {
|
|
565
679
|
const postFixReport = await runDoctor(target, actualRunner, { ...actualOptions, fix: false });
|
|
566
680
|
return [
|
|
@@ -570,5 +684,5 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
|
|
|
570
684
|
postFixReport,
|
|
571
685
|
].join('\n');
|
|
572
686
|
}
|
|
573
|
-
return
|
|
687
|
+
return renderSuccessfulDoctorReport(report);
|
|
574
688
|
}
|
package/dist/github.js
CHANGED
|
@@ -5,14 +5,23 @@ export const realGitHub = {
|
|
|
5
5
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
6
6
|
},
|
|
7
7
|
};
|
|
8
|
+
const githubSlugPartPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
|
|
9
|
+
function isSafeGitHubSlugPart(value) {
|
|
10
|
+
return Boolean(value &&
|
|
11
|
+
githubSlugPartPattern.test(value) &&
|
|
12
|
+
value !== '.' &&
|
|
13
|
+
value !== '..' &&
|
|
14
|
+
!value.startsWith('-') &&
|
|
15
|
+
!value.includes('..'));
|
|
16
|
+
}
|
|
8
17
|
export function parseGitHubRepoUrl(repoUrl) {
|
|
9
18
|
const normalized = repoUrl.trim().replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/, '');
|
|
10
19
|
const httpsMatch = normalized.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/);
|
|
11
|
-
if (httpsMatch) {
|
|
20
|
+
if (httpsMatch && isSafeGitHubSlugPart(httpsMatch[1]) && isSafeGitHubSlugPart(httpsMatch[2])) {
|
|
12
21
|
return { owner: httpsMatch[1], name: httpsMatch[2] };
|
|
13
22
|
}
|
|
14
23
|
const sshMatch = normalized.match(/^git@github\.com:([^/]+)\/([^/]+)$/);
|
|
15
|
-
if (sshMatch) {
|
|
24
|
+
if (sshMatch && isSafeGitHubSlugPart(sshMatch[1]) && isSafeGitHubSlugPart(sshMatch[2])) {
|
|
16
25
|
return { owner: sshMatch[1], name: sshMatch[2] };
|
|
17
26
|
}
|
|
18
27
|
return undefined;
|
package/dist/help.js
CHANGED
|
@@ -32,7 +32,7 @@ Usage:
|
|
|
32
32
|
npx @wpmoo/toolkit update <module[,module]> [db]
|
|
33
33
|
npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
|
|
34
34
|
npx @wpmoo/toolkit resetdb [db] [module[,module]]
|
|
35
|
-
npx @wpmoo/toolkit snapshot [db] [snapshot-name]
|
|
35
|
+
npx @wpmoo/toolkit snapshot [--list] [db] [snapshot-name]
|
|
36
36
|
npx @wpmoo/toolkit restore-snapshot [--dry-run] <snapshot-name> [db]
|
|
37
37
|
npx @wpmoo/toolkit lint
|
|
38
38
|
npx @wpmoo/toolkit pot <module[,module]> [db] [output]
|
|
@@ -76,8 +76,9 @@ Options:
|
|
|
76
76
|
|
|
77
77
|
Package aliases:
|
|
78
78
|
npx @wpmoo/toolkit is the official package path.
|
|
79
|
-
npx wpmoo is
|
|
80
|
-
npx @wpmoo/odoo and npx @wpmoo/odoo-dev remain deprecated compatibility aliases.
|
|
79
|
+
npx wpmoo is an optional best-effort short alias; use @wpmoo/toolkit for automation.
|
|
80
|
+
npx @wpmoo/odoo and npx @wpmoo/odoo-dev remain deprecated compatibility aliases through 1.x.
|
|
81
|
+
Removing a compatibility alias requires a future major release and prior notice.
|
|
81
82
|
|
|
82
83
|
Daily actions:
|
|
83
84
|
Daily actions must be run from a generated environment root containing .wpmoo/odoo.json.
|
|
@@ -90,11 +91,13 @@ Lifecycle command guards:
|
|
|
90
91
|
In WPMOO_ENV=prod, install/update/test require WPMOO_ALLOW_PROD_LIFECYCLE=1.
|
|
91
92
|
resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage/prod.
|
|
92
93
|
restore-snapshot --dry-run remains allowed for preview.
|
|
94
|
+
Time-bounded local approvals may also be recorded in .wpmoo/approvals.jsonl.
|
|
93
95
|
|
|
94
96
|
Cockpit:
|
|
95
97
|
Run npx @wpmoo/toolkit inside a generated environment to open the cockpit.
|
|
96
|
-
Use Command palette / to search slash commands
|
|
97
|
-
|
|
98
|
+
Use Command palette / to search slash commands such as /test, /modules,
|
|
99
|
+
/install-module, /doctor, and /safe-reset.
|
|
100
|
+
Large module lists switch to searchable selection by module, repo, or source type.
|
|
98
101
|
Direct commands such as npx @wpmoo/toolkit status and npx @wpmoo/toolkit test remain available.
|
|
99
102
|
|
|
100
103
|
Wizard local-only path:
|
|
@@ -139,7 +142,7 @@ Task recipes:
|
|
|
139
142
|
Run tests:
|
|
140
143
|
npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
|
|
141
144
|
Safe reset and recover:
|
|
142
|
-
npx @wpmoo/toolkit snapshot [db] [snapshot-name]
|
|
145
|
+
npx @wpmoo/toolkit snapshot [--list] [db] [snapshot-name]
|
|
143
146
|
npx @wpmoo/toolkit reset --dry-run
|
|
144
147
|
npx @wpmoo/toolkit reset
|
|
145
148
|
npx @wpmoo/toolkit restore-snapshot --dry-run <snapshot-name> [db]
|
|
@@ -160,6 +163,11 @@ Machine-readable JSON output:
|
|
|
160
163
|
doctor --json --postgres includes a structured postgres object for automation.
|
|
161
164
|
Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics.
|
|
162
165
|
|
|
166
|
+
JSON compatibility policy:
|
|
167
|
+
Automation should ignore unknown fields.
|
|
168
|
+
Minor and patch releases may add optional fields.
|
|
169
|
+
Removing, renaming, or changing the meaning of a documented field requires a major release or schemaVersion bump.
|
|
170
|
+
|
|
163
171
|
Example:
|
|
164
172
|
npx @wpmoo/toolkit create \\
|
|
165
173
|
--product odoo_sample_module \\
|
package/dist/module-actions.js
CHANGED
|
@@ -41,6 +41,26 @@ function validateSupportedOdooVersion(value) {
|
|
|
41
41
|
function sourceRepoPath(target, sourceType, repoPath) {
|
|
42
42
|
return pathUnderBase(join(target, `odoo/custom/src/${sourceType}`), repoPath, 'repo path');
|
|
43
43
|
}
|
|
44
|
+
async function readableSourceRepoPath(target, sourceType, repoPath) {
|
|
45
|
+
const primary = sourceRepoPath(target, sourceType, repoPath);
|
|
46
|
+
try {
|
|
47
|
+
await readdir(primary);
|
|
48
|
+
return primary;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
if (sourceType !== 'private') {
|
|
52
|
+
return primary;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const legacy = pathUnderBase(join(target, 'odoo/custom/src'), repoPath, 'repo path');
|
|
56
|
+
try {
|
|
57
|
+
await readdir(legacy);
|
|
58
|
+
return legacy;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return primary;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
44
64
|
function modulePath(target, sourceType, repoPath, moduleName) {
|
|
45
65
|
return pathUnderBase(sourceRepoPath(target, sourceType, repoPath), moduleName, 'module name');
|
|
46
66
|
}
|
|
@@ -410,12 +430,13 @@ export async function listModulesInSourceRepo(target, repoPath, sourceType) {
|
|
|
410
430
|
const safeRepoPath = validateRepoPath(repoPath);
|
|
411
431
|
const resolvedSourceType = normalizeSourceType(sourceType);
|
|
412
432
|
try {
|
|
413
|
-
const
|
|
433
|
+
const repoRoot = await readableSourceRepoPath(target, resolvedSourceType, safeRepoPath);
|
|
434
|
+
const entries = await readdir(repoRoot, { withFileTypes: true });
|
|
414
435
|
const modules = await Promise.all(entries
|
|
415
436
|
.filter((entry) => entry.isDirectory())
|
|
416
437
|
.map(async (entry) => {
|
|
417
438
|
try {
|
|
418
|
-
await readFile(join(
|
|
439
|
+
await readFile(join(repoRoot, entry.name, '__manifest__.py'), 'utf8');
|
|
419
440
|
return entry.name;
|
|
420
441
|
}
|
|
421
442
|
catch {
|
package/dist/module-manifest.js
CHANGED
|
@@ -262,12 +262,18 @@ function validateManifest(manifest) {
|
|
|
262
262
|
if (manifest.data !== undefined && !Array.isArray(manifest.data)) {
|
|
263
263
|
throw new Error('invalid manifest: data must be a list of strings');
|
|
264
264
|
}
|
|
265
|
+
if (manifest.demo !== undefined && !Array.isArray(manifest.demo)) {
|
|
266
|
+
throw new Error('invalid manifest: demo must be a list of strings');
|
|
267
|
+
}
|
|
265
268
|
if (Array.isArray(manifest.depends) && !manifest.depends.every((entry) => typeof entry === 'string')) {
|
|
266
269
|
throw new Error('invalid manifest: depends must be a list of strings');
|
|
267
270
|
}
|
|
268
271
|
if (Array.isArray(manifest.data) && !manifest.data.every((entry) => typeof entry === 'string')) {
|
|
269
272
|
throw new Error('invalid manifest: data must be a list of strings');
|
|
270
273
|
}
|
|
274
|
+
if (Array.isArray(manifest.demo) && !manifest.demo.every((entry) => typeof entry === 'string')) {
|
|
275
|
+
throw new Error('invalid manifest: demo must be a list of strings');
|
|
276
|
+
}
|
|
271
277
|
return manifest;
|
|
272
278
|
}
|
|
273
279
|
export function parseOdooManifest(content) {
|