@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.
@@ -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
- return /["']installable["']\s*:\s*(?:True|true)\b/u.test(content);
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
- try {
38
- installable = isInstallableManifest(await readFile(join(modulePath, '__manifest__.py'), 'utf8'));
112
+ const manifestContent = await readOptionalFile(join(modulePath, '__manifest__.py'));
113
+ if (!manifestContent) {
114
+ issues.push(moduleIssue(moduleName, relativePath, 'missing __manifest__.py'));
39
115
  }
40
- catch {
41
- installable = false;
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 (!installable) {
44
- issues.push({
45
- moduleName,
46
- path: relativePath,
47
- issue: 'missing installable=True in __manifest__.py',
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
- summary = addModuleQualityResult(summary, await analyzeModuleDirectory(current, basename(current), relative(target, current)));
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
- return summary;
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
+ }
@@ -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 mergeEnvironmentMetadata(target, options) {
34
+ function mergeEnvironmentMetadataSync(target, options) {
33
35
  const generated = environmentMetadata(options);
34
- return readFile(join(target, '.wpmoo/odoo.json'), 'utf8')
35
- .then((content) => JSON.parse(content))
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
- .catch(() => `${JSON.stringify(generated, null, 2)}\n`);
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 will refresh generated WPMoo environment files.',
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
- 'Will update:',
52
- '- .wpmoo/odoo.json',
53
- '- moo',
54
- '- .gitignore',
55
- '- .env.example',
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'), await mergeEnvironmentMetadata(options.target, scaffoldOptions));
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);