@wpmoo/toolkit 0.9.29 → 0.9.31
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 +27 -4
- 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 +123 -0
- package/dist/cli.js +31 -173
- package/dist/cockpit/command-registry.js +9 -3
- package/dist/cockpit/daily-prompts.js +33 -6
- package/dist/cockpit/menu.js +13 -8
- 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 +188 -20
- package/dist/environment-policy.js +6 -6
- package/dist/external-templates.js +7 -1
- package/dist/github.js +11 -2
- package/dist/help.js +8 -6
- package/dist/module-actions.js +61 -16
- package/dist/module-manifest.js +6 -0
- package/dist/module-quality.js +154 -0
- package/dist/postgres-diagnostics.js +48 -10
- package/dist/repo-url.js +4 -7
- package/dist/safe-reset.js +21 -12
- package/dist/source-actions.js +90 -1
- package/dist/source-manifest.js +2 -2
- package/dist/templates.js +257 -19
- package/docs/1-0-readiness.md +37 -12
- package/docs/command-reference.md +22 -3
- package/docs/generated-environment-verification.md +23 -2
- package/docs/handoff.md +14 -2
- package/docs/lifecycle-recipes.md +6 -1
- package/docs/troubleshooting.md +29 -1
- package/package.json +1 -1
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]
|
|
@@ -87,15 +87,17 @@ 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
|
+
Time-bounded local approvals may also be recorded in .wpmoo/approvals.jsonl.
|
|
94
95
|
|
|
95
96
|
Cockpit:
|
|
96
97
|
Run npx @wpmoo/toolkit inside a generated environment to open the cockpit.
|
|
97
|
-
Use Command palette / to search slash commands
|
|
98
|
-
|
|
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.
|
|
99
101
|
Direct commands such as npx @wpmoo/toolkit status and npx @wpmoo/toolkit test remain available.
|
|
100
102
|
|
|
101
103
|
Wizard local-only path:
|
|
@@ -140,7 +142,7 @@ Task recipes:
|
|
|
140
142
|
Run tests:
|
|
141
143
|
npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
|
|
142
144
|
Safe reset and recover:
|
|
143
|
-
npx @wpmoo/toolkit snapshot [db] [snapshot-name]
|
|
145
|
+
npx @wpmoo/toolkit snapshot [--list] [db] [snapshot-name]
|
|
144
146
|
npx @wpmoo/toolkit reset --dry-run
|
|
145
147
|
npx @wpmoo/toolkit reset
|
|
146
148
|
npx @wpmoo/toolkit restore-snapshot --dry-run <snapshot-name> [db]
|
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
|
}
|
|
@@ -233,8 +253,12 @@ async function moduleScaffoldChecks(target, sourceType, repoPath, moduleName, in
|
|
|
233
253
|
id: 'tests',
|
|
234
254
|
label: 'tests',
|
|
235
255
|
ok: (await fileContains(join(destination, 'tests/__init__.py'), `from . import test_${moduleName}`)) &&
|
|
236
|
-
(await fileContains(join(destination, `tests/test_${moduleName}.py`), ''))
|
|
237
|
-
|
|
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',
|
|
238
262
|
},
|
|
239
263
|
];
|
|
240
264
|
if (includeRegistration) {
|
|
@@ -355,23 +379,20 @@ async function assertModuleCleanBeforeDelete(target, sourceType, repoPath, modul
|
|
|
355
379
|
const repoRoot = sourceRepoPath(target, sourceType, repoPath);
|
|
356
380
|
try {
|
|
357
381
|
const result = await git.run(repoRoot, ['status', '--short', '--', moduleName]);
|
|
358
|
-
|
|
359
|
-
|
|
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}.`);
|
|
360
389
|
}
|
|
361
390
|
}
|
|
362
391
|
catch (error) {
|
|
363
392
|
if (error instanceof Error && error.message.startsWith('Refusing to delete module ')) {
|
|
364
393
|
throw error;
|
|
365
394
|
}
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
async function moduleHasCommittedFiles(repoRoot, moduleName, git) {
|
|
369
|
-
try {
|
|
370
|
-
const result = await git.run(repoRoot, ['ls-tree', '-r', '--name-only', 'HEAD', '--', moduleName]);
|
|
371
|
-
return Boolean(result.stdout.trim());
|
|
372
|
-
}
|
|
373
|
-
catch {
|
|
374
|
-
return false;
|
|
395
|
+
throw new Error(`Refusing to delete module ${moduleName} because git status could not be verified in source repo ${repoPath}.`);
|
|
375
396
|
}
|
|
376
397
|
}
|
|
377
398
|
export async function addModuleToSourceRepo(options, git = realGit) {
|
|
@@ -410,12 +431,13 @@ export async function listModulesInSourceRepo(target, repoPath, sourceType) {
|
|
|
410
431
|
const safeRepoPath = validateRepoPath(repoPath);
|
|
411
432
|
const resolvedSourceType = normalizeSourceType(sourceType);
|
|
412
433
|
try {
|
|
413
|
-
const
|
|
434
|
+
const repoRoot = await readableSourceRepoPath(target, resolvedSourceType, safeRepoPath);
|
|
435
|
+
const entries = await readdir(repoRoot, { withFileTypes: true });
|
|
414
436
|
const modules = await Promise.all(entries
|
|
415
437
|
.filter((entry) => entry.isDirectory())
|
|
416
438
|
.map(async (entry) => {
|
|
417
439
|
try {
|
|
418
|
-
await readFile(join(
|
|
440
|
+
await readFile(join(repoRoot, entry.name, '__manifest__.py'), 'utf8');
|
|
419
441
|
return entry.name;
|
|
420
442
|
}
|
|
421
443
|
catch {
|
|
@@ -462,6 +484,19 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
|
|
|
462
484
|
const repoPath = validateRepoPath(options.repoPath);
|
|
463
485
|
const moduleName = validateModuleName(options.moduleName);
|
|
464
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
|
+
}
|
|
465
500
|
if (options.deleteFiles) {
|
|
466
501
|
await assertModuleCleanBeforeDelete(options.target, sourceType, repoPath, moduleName, git);
|
|
467
502
|
}
|
|
@@ -471,7 +506,7 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
|
|
|
471
506
|
}
|
|
472
507
|
await updateModuleRegistration(options.target, sourceType, repoPath, moduleName, 'remove');
|
|
473
508
|
if (options.deleteFiles) {
|
|
474
|
-
await rm(
|
|
509
|
+
await rm(destination, { recursive: true, force: true });
|
|
475
510
|
}
|
|
476
511
|
if (options.stage) {
|
|
477
512
|
if (options.deleteFiles) {
|
|
@@ -479,4 +514,14 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
|
|
|
479
514
|
}
|
|
480
515
|
await stageAll(git, options.target);
|
|
481
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
|
+
};
|
|
482
527
|
}
|
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) {
|
package/dist/module-quality.js
CHANGED
|
@@ -82,9 +82,15 @@ async function readOptionalFile(path) {
|
|
|
82
82
|
function moduleIssue(moduleName, path, issue) {
|
|
83
83
|
return { moduleName, path, issue };
|
|
84
84
|
}
|
|
85
|
+
function moduleQualityIssue(moduleName, path, issue, severity) {
|
|
86
|
+
return { moduleName, path, issue, severity };
|
|
87
|
+
}
|
|
85
88
|
function manifestData(manifest) {
|
|
86
89
|
return Array.isArray(manifest?.data) ? manifest.data : [];
|
|
87
90
|
}
|
|
91
|
+
function manifestDemo(manifest) {
|
|
92
|
+
return Array.isArray(manifest?.demo) ? manifest.demo : [];
|
|
93
|
+
}
|
|
88
94
|
function manifestDepends(manifest) {
|
|
89
95
|
return Array.isArray(manifest?.depends) ? manifest.depends : [];
|
|
90
96
|
}
|
|
@@ -105,6 +111,113 @@ function pythonImportPresent(content, importName) {
|
|
|
105
111
|
return false;
|
|
106
112
|
return new RegExp(`^\\s*from\\s+\\.\\s+import\\s+.*\\b${importName}\\b`, 'mu').test(content);
|
|
107
113
|
}
|
|
114
|
+
function xmlAttributeValues(content, attributeName) {
|
|
115
|
+
const values = [];
|
|
116
|
+
const pattern = new RegExp(`\\b${attributeName}=(["'])(.*?)\\1`, 'gsu');
|
|
117
|
+
let match;
|
|
118
|
+
while ((match = pattern.exec(content))) {
|
|
119
|
+
const value = match[2]?.trim();
|
|
120
|
+
if (value)
|
|
121
|
+
values.push(value);
|
|
122
|
+
}
|
|
123
|
+
return values;
|
|
124
|
+
}
|
|
125
|
+
function declaredActionIds(xmlFiles) {
|
|
126
|
+
const ids = new Set();
|
|
127
|
+
for (const content of xmlFiles) {
|
|
128
|
+
for (const record of content.matchAll(/<record\b[^>]*\bmodel=(["'])ir\.actions\.act_window\1[^>]*>/gsu)) {
|
|
129
|
+
for (const id of xmlAttributeValues(record[0], 'id')) {
|
|
130
|
+
ids.add(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return ids;
|
|
135
|
+
}
|
|
136
|
+
function menuActionReferences(xmlFiles) {
|
|
137
|
+
const refs = [];
|
|
138
|
+
for (const content of xmlFiles) {
|
|
139
|
+
for (const menu of content.matchAll(/<menuitem\b[^>]*>/gsu)) {
|
|
140
|
+
refs.push(...xmlAttributeValues(menu[0], 'action'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return refs;
|
|
144
|
+
}
|
|
145
|
+
function declaredModelIds(modelFileContents) {
|
|
146
|
+
const modelIds = new Set();
|
|
147
|
+
for (const content of modelFileContents) {
|
|
148
|
+
for (const match of content.matchAll(/^\s*_name\s*=\s*["']([^"']+)["']/gmu)) {
|
|
149
|
+
const modelName = match[1]?.trim();
|
|
150
|
+
if (modelName) {
|
|
151
|
+
modelIds.add(`model_${modelName.replace(/\./gu, '_')}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return modelIds;
|
|
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
|
+
}
|
|
200
|
+
function accessCsvModelIds(content) {
|
|
201
|
+
if (!content)
|
|
202
|
+
return [];
|
|
203
|
+
const lines = content
|
|
204
|
+
.split(/\r?\n/u)
|
|
205
|
+
.map((line) => line.trim())
|
|
206
|
+
.filter((line) => line && !line.startsWith('#'));
|
|
207
|
+
if (lines.length < 2)
|
|
208
|
+
return [];
|
|
209
|
+
const header = lines[0]?.split(',').map((value) => value.trim()) ?? [];
|
|
210
|
+
const modelColumn = header.findIndex((value) => value === 'model_id:id' || value === 'model_id');
|
|
211
|
+
if (modelColumn < 0)
|
|
212
|
+
return [];
|
|
213
|
+
return lines
|
|
214
|
+
.slice(1)
|
|
215
|
+
.map((line) => line.split(',')[modelColumn]?.trim())
|
|
216
|
+
.filter((value) => Boolean(value));
|
|
217
|
+
}
|
|
218
|
+
async function readModelFileContents(modulePath, modelFiles) {
|
|
219
|
+
return Promise.all(modelFiles.map(async (fileName) => (await readOptionalFile(join(modulePath, 'models', fileName))) ?? ''));
|
|
220
|
+
}
|
|
108
221
|
export async function analyzeModuleDirectory(modulePath, moduleName = basename(modulePath), relativePath = modulePath) {
|
|
109
222
|
const issues = [];
|
|
110
223
|
let manifest;
|
|
@@ -135,9 +248,22 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
135
248
|
const menuXml = await readMenusXml(modulePath);
|
|
136
249
|
const modelFiles = await readPythonModelFiles(modulePath);
|
|
137
250
|
const viewFiles = await readViewXmlFiles(modulePath);
|
|
251
|
+
const viewXml = await Promise.all(viewFiles.map(async (fileName) => (await readOptionalFile(join(modulePath, 'views', fileName))) ?? ''));
|
|
252
|
+
const allViewXml = [...viewXml, ...menuXml];
|
|
138
253
|
const depends = manifestDepends(manifest);
|
|
139
254
|
const data = manifestData(manifest);
|
|
255
|
+
const demo = manifestDemo(manifest);
|
|
140
256
|
const hasOdooStructures = moduleHasOdooStructures(modelFiles, viewFiles, menuXml, data);
|
|
257
|
+
for (const entry of data) {
|
|
258
|
+
if (!(await fileExists(join(modulePath, entry)))) {
|
|
259
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `missing manifest data file: ${entry}`, 'error'));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const entry of demo) {
|
|
263
|
+
if (!(await fileExists(join(modulePath, entry)))) {
|
|
264
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `missing manifest demo file: ${entry}`, 'warning'));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
141
267
|
if (hasOdooStructures && !depends.includes('base')) {
|
|
142
268
|
issues.push(moduleIssue(moduleName, relativePath, 'missing base dependency for model-based module'));
|
|
143
269
|
}
|
|
@@ -157,6 +283,28 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
157
283
|
issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv'));
|
|
158
284
|
}
|
|
159
285
|
}
|
|
286
|
+
const modelFileContents = await readModelFileContents(modulePath, modelFiles);
|
|
287
|
+
const modelIds = declaredModelIds(modelFileContents);
|
|
288
|
+
const modelNames = declaredModelNames(modelFileContents);
|
|
289
|
+
if (modelIds.size > 0) {
|
|
290
|
+
for (const modelId of accessCsvModelIds(await readOptionalFile(join(modulePath, 'security/ir.model.access.csv')))) {
|
|
291
|
+
if (!modelIds.has(modelId)) {
|
|
292
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `access CSV references unknown model id: ${modelId}`, 'error'));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
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
|
+
}
|
|
160
308
|
if (hasOdooStructures && !dataIncludesAccessCsv(data)) {
|
|
161
309
|
issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv in manifest data'));
|
|
162
310
|
}
|
|
@@ -166,6 +314,12 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
|
|
|
166
314
|
if (!(await directoryExists(join(modulePath, 'tests')))) {
|
|
167
315
|
issues.push(moduleIssue(moduleName, relativePath, 'missing tests directory'));
|
|
168
316
|
}
|
|
317
|
+
const actionIds = declaredActionIds(allViewXml);
|
|
318
|
+
for (const actionRef of menuActionReferences(menuXml)) {
|
|
319
|
+
if (!actionIds.has(actionRef)) {
|
|
320
|
+
issues.push(moduleQualityIssue(moduleName, relativePath, `menu XML references missing action id: ${actionRef}`, 'error'));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
169
323
|
const hasMenuAction = menuXml.some((content) => hasActionableMenuXml(content, moduleName));
|
|
170
324
|
if (!hasMenuAction) {
|
|
171
325
|
issues.push(moduleIssue(moduleName, relativePath, 'missing actionable menu XML'));
|
|
@@ -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
|
|
@@ -214,6 +211,21 @@ FROM (
|
|
|
214
211
|
'unavailable'
|
|
215
212
|
)
|
|
216
213
|
UNION ALL
|
|
214
|
+
SELECT 'work_mem', COALESCE(
|
|
215
|
+
(SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'work_mem'),
|
|
216
|
+
'unavailable'
|
|
217
|
+
)
|
|
218
|
+
UNION ALL
|
|
219
|
+
SELECT 'maintenance_work_mem', COALESCE(
|
|
220
|
+
(SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'maintenance_work_mem'),
|
|
221
|
+
'unavailable'
|
|
222
|
+
)
|
|
223
|
+
UNION ALL
|
|
224
|
+
SELECT 'effective_cache_size', COALESCE(
|
|
225
|
+
(SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'effective_cache_size'),
|
|
226
|
+
'unavailable'
|
|
227
|
+
)
|
|
228
|
+
UNION ALL
|
|
217
229
|
SELECT 'long_transaction_count', long_transaction_count FROM transaction_health
|
|
218
230
|
UNION ALL
|
|
219
231
|
SELECT 'oldest_long_transaction_age_seconds', oldest_long_transaction_age_seconds FROM transaction_health
|
|
@@ -240,16 +252,30 @@ FROM (
|
|
|
240
252
|
UNION ALL
|
|
241
253
|
SELECT 'wal_archive_mode', wal_archive_mode FROM wal_health
|
|
242
254
|
UNION ALL
|
|
243
|
-
SELECT 'wal_file_count', wal_file_count FROM wal_health
|
|
244
|
-
UNION ALL
|
|
245
|
-
SELECT 'wal_directory_size_bytes', wal_directory_size_bytes FROM wal_health
|
|
246
|
-
UNION ALL
|
|
247
|
-
SELECT 'default_tablespace_size_bytes', default_tablespace_size_bytes FROM capacity_health
|
|
248
|
-
UNION ALL
|
|
249
255
|
SELECT 'database_write_activity_rows', database_write_activity_rows FROM capacity_health
|
|
250
256
|
) metrics
|
|
251
257
|
ORDER BY metric;
|
|
252
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
|
+
];
|
|
253
279
|
export const POSTGRES_DIAGNOSTIC_KEYS = [
|
|
254
280
|
'database_count',
|
|
255
281
|
'active_connections',
|
|
@@ -264,6 +290,9 @@ export const POSTGRES_DIAGNOSTIC_KEYS = [
|
|
|
264
290
|
'pg_stat_statements_installed_version',
|
|
265
291
|
'shared_preload_libraries',
|
|
266
292
|
'shared_buffers',
|
|
293
|
+
'work_mem',
|
|
294
|
+
'maintenance_work_mem',
|
|
295
|
+
'effective_cache_size',
|
|
267
296
|
'long_transaction_count',
|
|
268
297
|
'oldest_long_transaction_age_seconds',
|
|
269
298
|
'idle_in_transaction_count',
|
|
@@ -591,6 +620,15 @@ export function structuredPostgresDiagnostics(diagnostics) {
|
|
|
591
620
|
if (diagnostics.shared_buffers) {
|
|
592
621
|
structured.sharedBuffers = diagnostics.shared_buffers;
|
|
593
622
|
}
|
|
623
|
+
if (diagnostics.work_mem) {
|
|
624
|
+
structured.workMem = diagnostics.work_mem;
|
|
625
|
+
}
|
|
626
|
+
if (diagnostics.maintenance_work_mem) {
|
|
627
|
+
structured.maintenanceWorkMem = diagnostics.maintenance_work_mem;
|
|
628
|
+
}
|
|
629
|
+
if (diagnostics.effective_cache_size) {
|
|
630
|
+
structured.effectiveCacheSize = diagnostics.effective_cache_size;
|
|
631
|
+
}
|
|
594
632
|
if (longTransactionCount !== undefined) {
|
|
595
633
|
structured.longTransactionCount = longTransactionCount;
|
|
596
634
|
}
|
package/dist/repo-url.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
+
import { validateRepoPath } from './path-validation.js';
|
|
3
|
+
import { parseGitHubRepoUrl } from './github.js';
|
|
2
4
|
export function normalizeRepositoryUrl(repoUrl) {
|
|
3
5
|
const trimmed = repoUrl.trim();
|
|
4
6
|
const withoutSuffix = trimmed.replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/, '');
|
|
@@ -15,13 +17,8 @@ export function inferRepoPath(repoUrl) {
|
|
|
15
17
|
if (!withoutGit) {
|
|
16
18
|
throw new Error(`Cannot infer repository path from URL: ${repoUrl}`);
|
|
17
19
|
}
|
|
18
|
-
return withoutGit;
|
|
20
|
+
return validateRepoPath(withoutGit);
|
|
19
21
|
}
|
|
20
22
|
export function inferGitHubOwner(repoUrl) {
|
|
21
|
-
|
|
22
|
-
const httpsMatch = normalized.match(/^https:\/\/github\.com\/([^/]+)\//);
|
|
23
|
-
if (httpsMatch)
|
|
24
|
-
return httpsMatch[1];
|
|
25
|
-
const sshMatch = normalized.match(/^git@github\.com:([^/]+)\//);
|
|
26
|
-
return sshMatch?.[1];
|
|
23
|
+
return parseGitHubRepoUrl(normalizeRepositoryUrl(repoUrl))?.owner;
|
|
27
24
|
}
|
package/dist/safe-reset.js
CHANGED
|
@@ -70,7 +70,7 @@ function parseGitmodules(target) {
|
|
|
70
70
|
const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
|
|
71
71
|
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
72
72
|
return sections.flatMap((section) => {
|
|
73
|
-
const pathMatch = section.match(/^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/([^\s]+)\s*$/m);
|
|
73
|
+
const pathMatch = section.match(/^\s*path\s*=\s*odoo\/custom\/src\/(?:(private|oca|external)\/)?([^\s/]+)\s*$/m);
|
|
74
74
|
if (!pathMatch) {
|
|
75
75
|
return [];
|
|
76
76
|
}
|
|
@@ -78,7 +78,7 @@ function parseGitmodules(target) {
|
|
|
78
78
|
if (!source) {
|
|
79
79
|
return [];
|
|
80
80
|
}
|
|
81
|
-
const sourceType = pathMatch[1];
|
|
81
|
+
const sourceType = (pathMatch[1] ?? 'private');
|
|
82
82
|
return [{ path: pathMatch[2], sourceType, url: source }];
|
|
83
83
|
});
|
|
84
84
|
}
|
|
@@ -150,7 +150,7 @@ function inferOptionsForPreviewSync(target) {
|
|
|
150
150
|
url: metadataMatch?.url?.trim() ||
|
|
151
151
|
gitmoduleMatch?.url ||
|
|
152
152
|
readSubmoduleUrlFromPath(target, path, sourceType),
|
|
153
|
-
addons: parseAddonsForRepo(addonsYaml, path),
|
|
153
|
+
addons: parseAddonsForRepo(addonsYaml, path, sourceType),
|
|
154
154
|
};
|
|
155
155
|
}),
|
|
156
156
|
engine: 'compose',
|
|
@@ -171,9 +171,12 @@ function inferOptionsForPreviewSync(target) {
|
|
|
171
171
|
function readSubmoduleUrlFromPath(target, repoPath, sourceType) {
|
|
172
172
|
try {
|
|
173
173
|
const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
|
|
174
|
-
const
|
|
174
|
+
const expectedPaths = [`odoo/custom/src/${sourceType}/${repoPath}`];
|
|
175
|
+
if (sourceType === 'private') {
|
|
176
|
+
expectedPaths.push(`odoo/custom/src/${repoPath}`);
|
|
177
|
+
}
|
|
175
178
|
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
176
|
-
const section = sections.find((value) => value.includes(`path = ${
|
|
179
|
+
const section = sections.find((value) => expectedPaths.some((path) => value.includes(`path = ${path}`)));
|
|
177
180
|
const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
178
181
|
return url || `odoo/custom/src/${sourceType}/${repoPath}`;
|
|
179
182
|
}
|
|
@@ -311,11 +314,14 @@ function safeResetExternalAssetOptions(options) {
|
|
|
311
314
|
],
|
|
312
315
|
}));
|
|
313
316
|
}
|
|
314
|
-
function parseAddonsForRepo(addonsYaml, repoPath) {
|
|
317
|
+
function parseAddonsForRepo(addonsYaml, repoPath, sourceType = 'private') {
|
|
315
318
|
const safeRepoPath = validateRepoPath(repoPath);
|
|
316
319
|
const lines = addonsYaml.split('\n');
|
|
317
|
-
const
|
|
318
|
-
|
|
320
|
+
const headers = [`${sourceType}/${safeRepoPath}:`];
|
|
321
|
+
if (sourceType === 'private') {
|
|
322
|
+
headers.push(`${safeRepoPath}:`);
|
|
323
|
+
}
|
|
324
|
+
const start = lines.findIndex((line) => headers.includes(line.trim()));
|
|
319
325
|
if (start === -1)
|
|
320
326
|
return [safeRepoPath];
|
|
321
327
|
const addons = [];
|
|
@@ -332,7 +338,7 @@ function parseAddonsForRepo(addonsYaml, repoPath) {
|
|
|
332
338
|
return addons.length ? addons : [safeRepoPath];
|
|
333
339
|
}
|
|
334
340
|
function parseRepoPathsFromAddonsYaml(addonsYaml) {
|
|
335
|
-
return [...addonsYaml.matchAll(/^private\/(
|
|
341
|
+
return [...addonsYaml.matchAll(/^(?:private\/)?([^/\s][^/:]*):$/gm)]
|
|
336
342
|
.map((match) => match[1].trim())
|
|
337
343
|
.filter((repoPath) => repoPath && isValidPathSegment(repoPath))
|
|
338
344
|
.map(validateRepoPath);
|
|
@@ -341,9 +347,12 @@ async function readSubmoduleUrl(target, repoPath, sourceType) {
|
|
|
341
347
|
const safeRepoPath = validateRepoPath(repoPath);
|
|
342
348
|
try {
|
|
343
349
|
const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
|
|
344
|
-
const
|
|
350
|
+
const expectedPaths = [`odoo/custom/src/${sourceType}/${safeRepoPath}`];
|
|
351
|
+
if (sourceType === 'private') {
|
|
352
|
+
expectedPaths.push(`odoo/custom/src/${safeRepoPath}`);
|
|
353
|
+
}
|
|
345
354
|
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
346
|
-
const section = sections.find((value) => value.includes(`path = ${
|
|
355
|
+
const section = sections.find((value) => expectedPaths.some((path) => value.includes(`path = ${path}`)));
|
|
347
356
|
const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
348
357
|
return url || `odoo/custom/src/${sourceType}/${safeRepoPath}`;
|
|
349
358
|
}
|
|
@@ -389,7 +398,7 @@ async function inferOptions(target) {
|
|
|
389
398
|
?.url.trim() ||
|
|
390
399
|
gitmoduleSources.find((repo) => repo.path === path && repo.type === sourceType)?.url ||
|
|
391
400
|
(await readSubmoduleUrl(target, path, sourceType)),
|
|
392
|
-
addons: parseAddonsForRepo(addonsYaml, path),
|
|
401
|
+
addons: parseAddonsForRepo(addonsYaml, path, sourceType),
|
|
393
402
|
})));
|
|
394
403
|
return {
|
|
395
404
|
product,
|