@wpmoo/toolkit 0.9.23 → 0.9.25
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/dist/cli.js +54 -3
- package/dist/cockpit/daily-prompts.js +15 -1
- package/dist/daily-actions.js +51 -10
- package/dist/databases.js +24 -0
- package/dist/help.js +2 -2
- package/dist/module-manifest.js +298 -0
- package/dist/module-quality.js +213 -19
- package/dist/module-target-resolver.js +91 -0
- package/dist/safe-reset.js +244 -17
- package/dist/scaffold.js +2 -1
- package/dist/service-runtime-status.js +65 -3
- package/dist/templates.js +110 -8
- package/package.json +1 -1
package/dist/module-quality.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { parseOdooManifest } from './module-manifest.js';
|
|
3
4
|
export function emptyModuleQualitySummary() {
|
|
4
5
|
return {
|
|
5
6
|
totalModules: 0,
|
|
@@ -11,7 +12,8 @@ export function emptyModuleQualitySummary() {
|
|
|
11
12
|
};
|
|
12
13
|
}
|
|
13
14
|
export function isInstallableManifest(content) {
|
|
14
|
-
|
|
15
|
+
const parsed = parseOdooManifest(content);
|
|
16
|
+
return parsed.ok && parsed.manifest.installable !== false;
|
|
15
17
|
}
|
|
16
18
|
export function hasActionableMenuXml(content, moduleName) {
|
|
17
19
|
const actionId = `action_${moduleName}`;
|
|
@@ -31,32 +33,144 @@ async function readMenusXml(modulePath) {
|
|
|
31
33
|
return [];
|
|
32
34
|
}
|
|
33
35
|
}
|
|
36
|
+
async function readPythonModelFiles(modulePath) {
|
|
37
|
+
try {
|
|
38
|
+
const entries = await readdir(join(modulePath, 'models'), { withFileTypes: true });
|
|
39
|
+
return entries
|
|
40
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.py') && entry.name !== '__init__.py')
|
|
41
|
+
.map((entry) => entry.name);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function readViewXmlFiles(modulePath) {
|
|
48
|
+
try {
|
|
49
|
+
const entries = await readdir(join(modulePath, 'views'), { withFileTypes: true });
|
|
50
|
+
return entries
|
|
51
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.xml') && !entry.name.endsWith('_menus.xml'))
|
|
52
|
+
.map((entry) => entry.name);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function directoryExists(path) {
|
|
59
|
+
try {
|
|
60
|
+
return (await stat(path)).isDirectory();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function fileExists(path) {
|
|
67
|
+
try {
|
|
68
|
+
return (await stat(path)).isFile();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function readOptionalFile(path) {
|
|
75
|
+
try {
|
|
76
|
+
return await readFile(path, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function moduleIssue(moduleName, path, issue) {
|
|
83
|
+
return { moduleName, path, issue };
|
|
84
|
+
}
|
|
85
|
+
function manifestData(manifest) {
|
|
86
|
+
return Array.isArray(manifest?.data) ? manifest.data : [];
|
|
87
|
+
}
|
|
88
|
+
function manifestDepends(manifest) {
|
|
89
|
+
return Array.isArray(manifest?.depends) ? manifest.depends : [];
|
|
90
|
+
}
|
|
91
|
+
function dataIncludesAccessCsv(data) {
|
|
92
|
+
return data.includes('security/ir.model.access.csv');
|
|
93
|
+
}
|
|
94
|
+
function dataIncludesViewXml(data) {
|
|
95
|
+
return data.some((entry) => entry.startsWith('views/') && entry.endsWith('.xml') && !entry.endsWith('_menus.xml'));
|
|
96
|
+
}
|
|
97
|
+
function moduleHasOdooStructures(modelFiles, viewFiles, menuXml, data) {
|
|
98
|
+
return (modelFiles.length > 0 ||
|
|
99
|
+
viewFiles.length > 0 ||
|
|
100
|
+
menuXml.length > 0 ||
|
|
101
|
+
data.some((entry) => entry.startsWith('security/') || entry.startsWith('views/')));
|
|
102
|
+
}
|
|
103
|
+
function pythonImportPresent(content, importName) {
|
|
104
|
+
if (!content)
|
|
105
|
+
return false;
|
|
106
|
+
return new RegExp(`^\\s*from\\s+\\.\\s+import\\s+.*\\b${importName}\\b`, 'mu').test(content);
|
|
107
|
+
}
|
|
34
108
|
export async function analyzeModuleDirectory(modulePath, moduleName = basename(modulePath), relativePath = modulePath) {
|
|
35
109
|
const issues = [];
|
|
110
|
+
let manifest;
|
|
36
111
|
let installable = false;
|
|
37
|
-
|
|
38
|
-
|
|
112
|
+
const manifestContent = await readOptionalFile(join(modulePath, '__manifest__.py'));
|
|
113
|
+
if (!manifestContent) {
|
|
114
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing __manifest__.py'));
|
|
39
115
|
}
|
|
40
|
-
|
|
41
|
-
|
|
116
|
+
else {
|
|
117
|
+
const parsedManifest = parseOdooManifest(manifestContent);
|
|
118
|
+
if (parsedManifest.ok) {
|
|
119
|
+
manifest = parsedManifest.manifest;
|
|
120
|
+
installable = manifest.installable !== false;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
issues.push(moduleIssue(moduleName, relativePath, `invalid manifest syntax: ${parsedManifest.error}`));
|
|
124
|
+
}
|
|
42
125
|
}
|
|
43
|
-
if (
|
|
44
|
-
issues.push(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
126
|
+
if (manifest?.installable === false) {
|
|
127
|
+
issues.push(moduleIssue(moduleName, relativePath, 'installable is false in __manifest__.py'));
|
|
128
|
+
}
|
|
129
|
+
if (manifest && !manifest.license) {
|
|
130
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing license in __manifest__.py'));
|
|
131
|
+
}
|
|
132
|
+
if (manifest && manifest.depends === undefined) {
|
|
133
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing depends in __manifest__.py'));
|
|
49
134
|
}
|
|
50
135
|
const menuXml = await readMenusXml(modulePath);
|
|
136
|
+
const modelFiles = await readPythonModelFiles(modulePath);
|
|
137
|
+
const viewFiles = await readViewXmlFiles(modulePath);
|
|
138
|
+
const depends = manifestDepends(manifest);
|
|
139
|
+
const data = manifestData(manifest);
|
|
140
|
+
const hasOdooStructures = moduleHasOdooStructures(modelFiles, viewFiles, menuXml, data);
|
|
141
|
+
if (hasOdooStructures && !depends.includes('base')) {
|
|
142
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing base dependency for model-based module'));
|
|
143
|
+
}
|
|
144
|
+
if (modelFiles.length > 0) {
|
|
145
|
+
const rootInit = await readOptionalFile(join(modulePath, '__init__.py'));
|
|
146
|
+
if (!pythonImportPresent(rootInit, 'models')) {
|
|
147
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing __init__.py models import'));
|
|
148
|
+
}
|
|
149
|
+
const modelsInit = await readOptionalFile(join(modulePath, 'models/__init__.py'));
|
|
150
|
+
const missingModelImport = modelFiles
|
|
151
|
+
.map((fileName) => fileName.replace(/\.py$/u, ''))
|
|
152
|
+
.some((modelImport) => !pythonImportPresent(modelsInit, modelImport));
|
|
153
|
+
if (missingModelImport) {
|
|
154
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing models/__init__.py model import'));
|
|
155
|
+
}
|
|
156
|
+
if (!(await fileExists(join(modulePath, 'security/ir.model.access.csv')))) {
|
|
157
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (hasOdooStructures && !dataIncludesAccessCsv(data)) {
|
|
161
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv in manifest data'));
|
|
162
|
+
}
|
|
163
|
+
if (hasOdooStructures && viewFiles.length === 0 && !dataIncludesViewXml(data)) {
|
|
164
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing views XML under views/'));
|
|
165
|
+
}
|
|
166
|
+
if (!(await directoryExists(join(modulePath, 'tests')))) {
|
|
167
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing tests directory'));
|
|
168
|
+
}
|
|
51
169
|
const hasMenuAction = menuXml.some((content) => hasActionableMenuXml(content, moduleName));
|
|
52
170
|
if (!hasMenuAction) {
|
|
53
|
-
issues.push(
|
|
54
|
-
moduleName,
|
|
55
|
-
path: relativePath,
|
|
56
|
-
issue: 'missing actionable menu XML',
|
|
57
|
-
});
|
|
171
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing actionable menu XML'));
|
|
58
172
|
}
|
|
59
|
-
return { moduleName, relativePath, installable, hasMenuAction, issues };
|
|
173
|
+
return { moduleName, relativePath, installable, hasMenuAction, depends, ...(manifest ? { manifest } : {}), issues };
|
|
60
174
|
}
|
|
61
175
|
export function addModuleQualityResult(summary, result) {
|
|
62
176
|
return {
|
|
@@ -69,15 +183,86 @@ export function addModuleQualityResult(summary, result) {
|
|
|
69
183
|
};
|
|
70
184
|
}
|
|
71
185
|
export function mergeModuleQualitySummaries(left, right) {
|
|
186
|
+
const missingDependencies = [
|
|
187
|
+
...(left.dependencyGraph?.missingDependencies ?? []),
|
|
188
|
+
...(right.dependencyGraph?.missingDependencies ?? []),
|
|
189
|
+
];
|
|
190
|
+
const dependencies = [...(left.dependencyGraph?.dependencies ?? []), ...(right.dependencyGraph?.dependencies ?? [])];
|
|
191
|
+
const cycles = [...(left.dependencyGraph?.cycles ?? []), ...(right.dependencyGraph?.cycles ?? [])];
|
|
192
|
+
const dependencyGraph = dependencies.length > 0 || missingDependencies.length > 0 || cycles.length > 0
|
|
193
|
+
? { dependencies, missingDependencies, cycles }
|
|
194
|
+
: undefined;
|
|
72
195
|
return {
|
|
73
196
|
totalModules: left.totalModules + right.totalModules,
|
|
74
197
|
installableModules: left.installableModules + right.installableModules,
|
|
75
198
|
nonInstallableModules: left.nonInstallableModules + right.nonInstallableModules,
|
|
76
199
|
modulesWithMenuActions: left.modulesWithMenuActions + right.modulesWithMenuActions,
|
|
77
200
|
modulesMissingMenuActions: left.modulesMissingMenuActions + right.modulesMissingMenuActions,
|
|
201
|
+
...(dependencyGraph ? { dependencyGraph } : {}),
|
|
78
202
|
issues: [...left.issues, ...right.issues],
|
|
79
203
|
};
|
|
80
204
|
}
|
|
205
|
+
function firstToken(value) {
|
|
206
|
+
return value.split(/[_-]+/u, 1)[0] ?? value;
|
|
207
|
+
}
|
|
208
|
+
function looksLikeMissingLocalDependency(moduleName, dependency) {
|
|
209
|
+
if (dependency === 'base')
|
|
210
|
+
return false;
|
|
211
|
+
const namespace = firstToken(moduleName);
|
|
212
|
+
const generatedOrProjectNamespaces = new Set(['custom', 'demo', 'module', 'odoo', 'wpmoo']);
|
|
213
|
+
return Boolean(namespace) && generatedOrProjectNamespaces.has(namespace) && dependency.startsWith(`${namespace}_`);
|
|
214
|
+
}
|
|
215
|
+
function findCycleFrom(start, current, graph, path = [start]) {
|
|
216
|
+
for (const dependency of graph.get(current) ?? []) {
|
|
217
|
+
if (dependency === start) {
|
|
218
|
+
return [...path, start];
|
|
219
|
+
}
|
|
220
|
+
if (path.includes(dependency)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const found = findCycleFrom(start, dependency, graph, [...path, dependency]);
|
|
224
|
+
if (found)
|
|
225
|
+
return found;
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
function moduleDependencyGraph(results) {
|
|
230
|
+
const byName = new Map(results.map((result) => [result.moduleName, result]));
|
|
231
|
+
const localModuleNames = new Set(byName.keys());
|
|
232
|
+
const dependencyEdges = new Map();
|
|
233
|
+
const missingDependencies = [];
|
|
234
|
+
const dependencies = [];
|
|
235
|
+
const issues = [];
|
|
236
|
+
for (const result of results) {
|
|
237
|
+
const localDependencies = result.depends.filter((dependency) => localModuleNames.has(dependency));
|
|
238
|
+
dependencyEdges.set(result.moduleName, localDependencies);
|
|
239
|
+
for (const dependency of result.depends) {
|
|
240
|
+
if (localModuleNames.has(dependency)) {
|
|
241
|
+
dependencies.push({ moduleName: result.moduleName, dependency, kind: 'local' });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (looksLikeMissingLocalDependency(result.moduleName, dependency)) {
|
|
245
|
+
dependencies.push({ moduleName: result.moduleName, dependency, kind: 'unresolved' });
|
|
246
|
+
missingDependencies.push({ moduleName: result.moduleName, dependency });
|
|
247
|
+
issues.push(moduleIssue(result.moduleName, result.relativePath, `missing local dependency ${dependency}`));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
dependencies.push({ moduleName: result.moduleName, dependency, kind: 'external' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const cycles = [];
|
|
254
|
+
for (const moduleName of localModuleNames) {
|
|
255
|
+
const cycle = findCycleFrom(moduleName, moduleName, dependencyEdges);
|
|
256
|
+
if (cycle) {
|
|
257
|
+
cycles.push(cycle);
|
|
258
|
+
const result = byName.get(moduleName);
|
|
259
|
+
if (result) {
|
|
260
|
+
issues.push(moduleIssue(moduleName, result.relativePath, `dependency cycle detected: ${cycle.join(' -> ')}`));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { graph: { dependencies, missingDependencies, cycles }, issues };
|
|
265
|
+
}
|
|
81
266
|
export async function scanModuleQuality(root, target) {
|
|
82
267
|
try {
|
|
83
268
|
const rootStat = await stat(root);
|
|
@@ -89,6 +274,7 @@ export async function scanModuleQuality(root, target) {
|
|
|
89
274
|
}
|
|
90
275
|
let summary = emptyModuleQualitySummary();
|
|
91
276
|
const stack = [root];
|
|
277
|
+
const results = [];
|
|
92
278
|
while (stack.length > 0) {
|
|
93
279
|
const current = stack.pop();
|
|
94
280
|
if (!current)
|
|
@@ -104,8 +290,16 @@ export async function scanModuleQuality(root, target) {
|
|
|
104
290
|
}
|
|
105
291
|
}
|
|
106
292
|
if (hasManifest) {
|
|
107
|
-
|
|
293
|
+
const result = await analyzeModuleDirectory(current, basename(current), relative(target, current));
|
|
294
|
+
results.push(result);
|
|
295
|
+
summary = addModuleQualityResult(summary, result);
|
|
108
296
|
}
|
|
109
297
|
}
|
|
110
|
-
|
|
298
|
+
const dependencyGraph = moduleDependencyGraph(results);
|
|
299
|
+
const hasDependencyGraphIssues = dependencyGraph.graph.missingDependencies.length > 0 || dependencyGraph.graph.cycles.length > 0;
|
|
300
|
+
return {
|
|
301
|
+
...summary,
|
|
302
|
+
...(hasDependencyGraphIssues ? { dependencyGraph: dependencyGraph.graph } : {}),
|
|
303
|
+
issues: [...summary.issues, ...dependencyGraph.issues],
|
|
304
|
+
};
|
|
111
305
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
function normalizeQuery(query) {
|
|
2
|
+
return query.trim().toLowerCase();
|
|
3
|
+
}
|
|
4
|
+
function moduleMatchesExact(query, module) {
|
|
5
|
+
return module.moduleName.toLowerCase() === query;
|
|
6
|
+
}
|
|
7
|
+
function moduleMatchesPartial(query, module) {
|
|
8
|
+
return module.moduleName.toLowerCase().includes(query);
|
|
9
|
+
}
|
|
10
|
+
function tokenizeModuleName(moduleName) {
|
|
11
|
+
return moduleName.toLowerCase().split(/[_-]+/g).filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
function levenshteinDistance(a, b) {
|
|
14
|
+
if (a === b) {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
if (!a) {
|
|
18
|
+
return b.length;
|
|
19
|
+
}
|
|
20
|
+
if (!b) {
|
|
21
|
+
return a.length;
|
|
22
|
+
}
|
|
23
|
+
const rows = a.length + 1;
|
|
24
|
+
const cols = b.length + 1;
|
|
25
|
+
const matrix = Array.from({ length: rows }, (_, rowIndex) => {
|
|
26
|
+
const row = new Array(cols);
|
|
27
|
+
if (rowIndex === 0) {
|
|
28
|
+
for (let col = 0; col < cols; col += 1) {
|
|
29
|
+
row[col] = col;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
row[0] = rowIndex;
|
|
34
|
+
}
|
|
35
|
+
return row;
|
|
36
|
+
});
|
|
37
|
+
for (let row = 1; row < rows; row += 1) {
|
|
38
|
+
for (let col = 1; col < cols; col += 1) {
|
|
39
|
+
const cost = a[row - 1] === b[col - 1] ? 0 : 1;
|
|
40
|
+
const substitutions = matrix[row - 1][col - 1] + cost;
|
|
41
|
+
const insertions = matrix[row][col - 1] + 1;
|
|
42
|
+
const deletions = matrix[row - 1][col] + 1;
|
|
43
|
+
matrix[row][col] = Math.min(substitutions, insertions, deletions);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return matrix[a.length][b.length];
|
|
47
|
+
}
|
|
48
|
+
function nearestCandidates(query, modules, maxItems = 3) {
|
|
49
|
+
const queryNormalized = query;
|
|
50
|
+
const scoredModules = modules
|
|
51
|
+
.map((module) => {
|
|
52
|
+
const fullMatchDistance = levenshteinDistance(module.moduleName.toLowerCase(), queryNormalized);
|
|
53
|
+
const tokenMatchDistance = tokenizeModuleName(module.moduleName).reduce((best, token) => Math.min(best, levenshteinDistance(token, queryNormalized)), Number.POSITIVE_INFINITY);
|
|
54
|
+
const distance = Math.min(fullMatchDistance, tokenMatchDistance);
|
|
55
|
+
return { module, distance };
|
|
56
|
+
})
|
|
57
|
+
.filter((entry) => entry.distance <= 4);
|
|
58
|
+
const scoredWithIndex = scoredModules.map((entry, index) => ({ ...entry, index }));
|
|
59
|
+
scoredWithIndex.sort((left, right) => {
|
|
60
|
+
if (left.distance !== right.distance) {
|
|
61
|
+
return left.distance - right.distance;
|
|
62
|
+
}
|
|
63
|
+
return left.index - right.index;
|
|
64
|
+
});
|
|
65
|
+
const topDistance = scoredWithIndex[0]?.distance ?? Number.POSITIVE_INFINITY;
|
|
66
|
+
return scoredWithIndex
|
|
67
|
+
.filter((entry) => entry.distance <= topDistance + 1)
|
|
68
|
+
.slice(0, maxItems)
|
|
69
|
+
.map((entry) => entry.module);
|
|
70
|
+
}
|
|
71
|
+
export function resolveModuleTarget(query, modules) {
|
|
72
|
+
const normalizedQuery = normalizeQuery(query);
|
|
73
|
+
if (!normalizedQuery) {
|
|
74
|
+
return { kind: 'no-match', query, candidates: [] };
|
|
75
|
+
}
|
|
76
|
+
const exactMatches = modules.filter((module) => moduleMatchesExact(normalizedQuery, module));
|
|
77
|
+
if (exactMatches.length === 1) {
|
|
78
|
+
return { kind: 'exact', query, module: exactMatches[0] };
|
|
79
|
+
}
|
|
80
|
+
if (exactMatches.length > 1) {
|
|
81
|
+
return { kind: 'ambiguous', query, candidates: exactMatches };
|
|
82
|
+
}
|
|
83
|
+
const partialMatches = normalizedQuery.length >= 3 ? modules.filter((module) => moduleMatchesPartial(normalizedQuery, module)) : [];
|
|
84
|
+
if (partialMatches.length === 1) {
|
|
85
|
+
return { kind: 'exact', query, module: partialMatches[0] };
|
|
86
|
+
}
|
|
87
|
+
if (partialMatches.length > 1) {
|
|
88
|
+
return { kind: 'ambiguous', query, candidates: partialMatches };
|
|
89
|
+
}
|
|
90
|
+
return { kind: 'no-match', query, candidates: nearestCandidates(normalizedQuery, modules) };
|
|
91
|
+
}
|
package/dist/safe-reset.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
1
3
|
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
4
|
import { basename, join } from 'node:path';
|
|
3
5
|
import { environmentMetadata, readEnvironmentMetadata } from './environment.js';
|
|
@@ -29,35 +31,258 @@ const safeResetProtectedGeneratedReadmes = new Set([
|
|
|
29
31
|
function isProtectedGeneratedFile(filePath) {
|
|
30
32
|
return safeResetProtectedGeneratedReadmes.has(filePath);
|
|
31
33
|
}
|
|
32
|
-
function
|
|
34
|
+
function mergeEnvironmentMetadataSync(target, options) {
|
|
33
35
|
const generated = environmentMetadata(options);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.then((existing) => {
|
|
36
|
+
try {
|
|
37
|
+
const existing = parseMetadataFromPath(target);
|
|
37
38
|
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
|
38
39
|
return `${JSON.stringify(generated, null, 2)}\n`;
|
|
39
40
|
}
|
|
40
41
|
return `${JSON.stringify({ ...existing, ...generated, sourceRepos: generated.sourceRepos }, null, 2)}\n`;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return `${JSON.stringify(generated, null, 2)}\n`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function parseMetadataFromPath(target) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(join(target, '.wpmoo/odoo.json'), 'utf8');
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function parseAddonsFile(target) {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(join(target, 'odoo/custom/src/addons.yaml'), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function parseGitmodules(target) {
|
|
69
|
+
try {
|
|
70
|
+
const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
|
|
71
|
+
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
72
|
+
return sections.flatMap((section) => {
|
|
73
|
+
const pathMatch = section.match(/^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/([^\s]+)\s*$/m);
|
|
74
|
+
if (!pathMatch) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const source = section.match(/^\s*url\s*=\s*(.+)\s*$/m)?.[1]?.trim();
|
|
78
|
+
if (!source) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const sourceType = pathMatch[1];
|
|
82
|
+
return [{ path: pathMatch[2], sourceType, url: source }];
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function inferOptionsForPreviewSync(target) {
|
|
90
|
+
const metadata = parseMetadataFromPath(target);
|
|
91
|
+
const addonsYaml = parseAddonsFile(target);
|
|
92
|
+
const gitmoduleSources = parseGitmodules(target);
|
|
93
|
+
const addonRepos = parseRepoPathsFromAddonsYaml(addonsYaml);
|
|
94
|
+
const sourceByKey = new Map();
|
|
95
|
+
const sourceReposFromMetadata = Array.isArray(metadata?.sourceRepos)
|
|
96
|
+
? metadata.sourceRepos
|
|
97
|
+
: [];
|
|
98
|
+
for (const repo of sourceReposFromMetadata) {
|
|
99
|
+
if (!repo?.path || !isValidPathSegment(repo.path)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
sourceByKey.set(`${repo.sourceType ?? 'private'}:${repo.path}`, {
|
|
103
|
+
sourceType: repo.sourceType ?? 'private',
|
|
104
|
+
path: validateRepoPath(repo.path),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const repo of gitmoduleSources) {
|
|
108
|
+
sourceByKey.set(`${repo.sourceType}:${repo.path}`, repo);
|
|
109
|
+
}
|
|
110
|
+
for (const repoPath of addonRepos) {
|
|
111
|
+
sourceByKey.set(`private:${repoPath}`, {
|
|
112
|
+
sourceType: 'private',
|
|
113
|
+
path: repoPath,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const sourceLocations = [...sourceByKey.values()].sort((left, right) => {
|
|
117
|
+
if (left.sourceType !== right.sourceType) {
|
|
118
|
+
return left.sourceType.localeCompare(right.sourceType);
|
|
119
|
+
}
|
|
120
|
+
return left.path.localeCompare(right.path);
|
|
121
|
+
});
|
|
122
|
+
const productFromMetadata = typeof metadata?.product === 'string' ? metadata.product : undefined;
|
|
123
|
+
const composeTemplateUrl = typeof metadata?.composeTemplateUrl === 'string' ? metadata.composeTemplateUrl : undefined;
|
|
124
|
+
const composeTemplateRef = typeof metadata?.composeTemplateRef === 'string' ? metadata.composeTemplateRef : undefined;
|
|
125
|
+
const agentSkillsTemplateUrl = typeof metadata?.agentSkillsTemplateUrl === 'string'
|
|
126
|
+
? metadata.agentSkillsTemplateUrl
|
|
127
|
+
: undefined;
|
|
128
|
+
const agentSkillsTemplateRef = typeof metadata?.agentSkillsTemplateRef === 'string'
|
|
129
|
+
? metadata.agentSkillsTemplateRef
|
|
130
|
+
: undefined;
|
|
131
|
+
const odooVersion = typeof metadata?.odooVersion === 'string' ? metadata.odooVersion : undefined;
|
|
132
|
+
const devRepo = typeof metadata?.devRepo === 'string' ? metadata.devRepo : undefined;
|
|
133
|
+
const devRepoUrl = typeof metadata?.devRepoUrl === 'string' ? metadata.devRepoUrl : undefined;
|
|
134
|
+
const postgresVersion = typeof metadata?.postgresVersion === 'string' ? metadata.postgresVersion : undefined;
|
|
135
|
+
const httpPort = typeof metadata?.httpPort === 'string' ? metadata.httpPort : undefined;
|
|
136
|
+
const geventPort = typeof metadata?.geventPort === 'string' ? metadata.geventPort : undefined;
|
|
137
|
+
const product = productFromMetadata && isValidPathSegment(productFromMetadata) ? productFromMetadata
|
|
138
|
+
: sourceLocations[0]?.path ?? titleFromTarget(target);
|
|
139
|
+
return {
|
|
140
|
+
product,
|
|
141
|
+
odooVersion: odooVersion ?? '19.0',
|
|
142
|
+
devRepo: devRepo ?? basename(target),
|
|
143
|
+
devRepoUrl: devRepoUrl ?? target,
|
|
144
|
+
sourceRepos: sourceLocations.map(({ sourceType, path }) => {
|
|
145
|
+
const metadataMatch = sourceReposFromMetadata.find((repo) => isValidPathSegment(repo.path ?? '') && validateRepoPath(repo.path ?? '') === path && (repo.sourceType ?? 'private') === sourceType);
|
|
146
|
+
const gitmoduleMatch = gitmoduleSources.find((repo) => repo.path === path && repo.sourceType === sourceType);
|
|
147
|
+
return {
|
|
148
|
+
sourceType,
|
|
149
|
+
path,
|
|
150
|
+
url: metadataMatch?.url?.trim() ||
|
|
151
|
+
gitmoduleMatch?.url ||
|
|
152
|
+
readSubmoduleUrlFromPath(target, path, sourceType),
|
|
153
|
+
addons: parseAddonsForRepo(addonsYaml, path),
|
|
154
|
+
};
|
|
155
|
+
}),
|
|
156
|
+
engine: 'compose',
|
|
157
|
+
composeTemplateUrl,
|
|
158
|
+
composeTemplateRef,
|
|
159
|
+
agentSkillsTemplateUrl,
|
|
160
|
+
agentSkillsTemplateRef,
|
|
161
|
+
postgresVersion,
|
|
162
|
+
httpPort,
|
|
163
|
+
geventPort,
|
|
164
|
+
target,
|
|
165
|
+
dryRun: false,
|
|
166
|
+
initEmptyRepos: false,
|
|
167
|
+
stage: false,
|
|
168
|
+
skipSubmodules: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function readSubmoduleUrlFromPath(target, repoPath, sourceType) {
|
|
172
|
+
try {
|
|
173
|
+
const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
|
|
174
|
+
const escapedPath = `odoo/custom/src/${sourceType}/${repoPath}`;
|
|
175
|
+
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
176
|
+
const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
|
|
177
|
+
const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
178
|
+
return url || `odoo/custom/src/${sourceType}/${repoPath}`;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return `odoo/custom/src/${sourceType}/${repoPath}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function safeResetTargetFileDiffs(scaffoldOptions, target) {
|
|
185
|
+
const files = generatedFiles(scaffoldOptions);
|
|
186
|
+
const candidates = [
|
|
187
|
+
...files,
|
|
188
|
+
{ path: '.env.example', content: renderComposeEnvExample(scaffoldOptions) },
|
|
189
|
+
{ path: '.wpmoo/odoo.json', content: mergeEnvironmentMetadataSync(target, scaffoldOptions) },
|
|
190
|
+
];
|
|
191
|
+
const changedPaths = candidates
|
|
192
|
+
.filter((file) => {
|
|
193
|
+
if (file.path === 'odoo/custom/src/addons.yaml' || isProtectedGeneratedFile(file.path)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const existing = readTextForPreview(target, file.path);
|
|
197
|
+
if (existing === undefined) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return existing !== file.content;
|
|
41
201
|
})
|
|
42
|
-
.
|
|
202
|
+
.map((file) => file.path);
|
|
203
|
+
return [...new Set(changedPaths)].sort();
|
|
204
|
+
}
|
|
205
|
+
function buildSafeResetSourceRepoLines(sourceRepos) {
|
|
206
|
+
const lines = [...new Set(sourceRepos.map((repo) => `${repo.sourceType ?? 'private'}/${repo.path}`))].sort();
|
|
207
|
+
if (lines.length === 0) {
|
|
208
|
+
return ['- (none detected)'];
|
|
209
|
+
}
|
|
210
|
+
return lines.map((repo) => `- ${repo}`);
|
|
211
|
+
}
|
|
212
|
+
function detectDirtyGeneratedFiles(target, candidatePaths) {
|
|
213
|
+
try {
|
|
214
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
215
|
+
cwd: target,
|
|
216
|
+
encoding: 'utf8',
|
|
217
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
218
|
+
});
|
|
219
|
+
if (!status.trim()) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
const dirty = new Set();
|
|
223
|
+
const trackedCandidates = [...candidatePaths];
|
|
224
|
+
for (const entry of status.split(/\r?\n/)) {
|
|
225
|
+
if (!entry.trim()) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const pathSection = entry.slice(3).trim();
|
|
229
|
+
const normalized = pathSection.includes(' -> ') ? pathSection.split(' -> ')[1] ?? '' : pathSection;
|
|
230
|
+
if (!normalized) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (trackedCandidates.some((candidate) => normalized === candidate || normalized.startsWith(`${candidate}/`))) {
|
|
234
|
+
dirty.add(normalized);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return [...dirty].sort();
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function describeDirtyWarning(target, candidatePaths) {
|
|
244
|
+
const dirtyFiles = detectDirtyGeneratedFiles(target, candidatePaths);
|
|
245
|
+
if (dirtyFiles.length === 0) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
return [
|
|
249
|
+
'Warning: the following generated files are dirty and may be overwritten by safe reset:',
|
|
250
|
+
...dirtyFiles.map((path) => `- ${path}`),
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
function readTextForPreview(target, path) {
|
|
254
|
+
try {
|
|
255
|
+
return readFileSync(join(target, path), 'utf8');
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
43
260
|
}
|
|
44
261
|
export function renderSafeResetPreview(target, stage) {
|
|
262
|
+
const options = inferOptionsForPreviewSync(target);
|
|
263
|
+
const externalAssets = safeResetExternalAssetOptions(options);
|
|
264
|
+
const changedPaths = safeResetTargetFileDiffs(options, target);
|
|
265
|
+
const externalAssetLines = [];
|
|
266
|
+
if (externalAssets.some((asset) => asset.label === 'compose')) {
|
|
267
|
+
externalAssetLines.push('- External compose template assets');
|
|
268
|
+
}
|
|
269
|
+
if (externalAssets.some((asset) => asset.label === 'agent-skills')) {
|
|
270
|
+
externalAssetLines.push('- External agent skill assets when configured');
|
|
271
|
+
}
|
|
272
|
+
const generatedSection = changedPaths.length
|
|
273
|
+
? ['Generated files that would change:', ...changedPaths.map((file) => `- ${file}`)]
|
|
274
|
+
: ['Generated files that would change:', '- No generated files differ from the rendered safe-reset output.'];
|
|
45
275
|
return [
|
|
46
|
-
'Safe reset
|
|
276
|
+
'Safe reset preview (dry-run): generated WPMoo files and source repo protections are listed.',
|
|
47
277
|
'',
|
|
48
278
|
'Target:',
|
|
49
279
|
target,
|
|
50
280
|
'',
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
|
|
56
|
-
'- README.md',
|
|
57
|
-
'- AGENTS.md',
|
|
58
|
-
'- docs/appstore-release.md',
|
|
59
|
-
'- External compose template assets',
|
|
60
|
-
'- External agent skill assets when configured',
|
|
281
|
+
...generatedSection,
|
|
282
|
+
...externalAssetLines,
|
|
283
|
+
'',
|
|
284
|
+
'Source repositories that will remain untouched:',
|
|
285
|
+
...buildSafeResetSourceRepoLines(options.sourceRepos),
|
|
61
286
|
'',
|
|
62
287
|
'Will not touch:',
|
|
63
288
|
'- source repo folders under odoo/custom/src/private',
|
|
@@ -67,6 +292,8 @@ export function renderSafeResetPreview(target, stage) {
|
|
|
67
292
|
'- custom source layout directories (oca, external, patches, manifests)',
|
|
68
293
|
'- Legacy compose template files may remain until manually removed: docs/assets/, test/, .github/',
|
|
69
294
|
'',
|
|
295
|
+
...describeDirtyWarning(target, changedPaths),
|
|
296
|
+
'',
|
|
70
297
|
'Preview-only output; files are not changed until reset is executed.',
|
|
71
298
|
'',
|
|
72
299
|
stage ? 'Generated changes will be staged with git add .' : 'Generated changes will not be staged.',
|
|
@@ -209,7 +436,7 @@ export async function safeResetEnvironment(options, git = realGit) {
|
|
|
209
436
|
for (const assetOptions of externalAssets) {
|
|
210
437
|
await applyExternalAsset(assetOptions, git);
|
|
211
438
|
}
|
|
212
|
-
await writeTextFile(join(options.target, '.wpmoo/odoo.json'),
|
|
439
|
+
await writeTextFile(join(options.target, '.wpmoo/odoo.json'), mergeEnvironmentMetadataSync(options.target, scaffoldOptions));
|
|
213
440
|
await writeTextFile(join(options.target, '.env.example'), renderComposeEnvExample(scaffoldOptions));
|
|
214
441
|
if (options.stage) {
|
|
215
442
|
await stageAll(git, options.target);
|