@wpmoo/toolkit 0.9.0

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.
Files changed (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +519 -0
  3. package/dist/addons-yaml.js +59 -0
  4. package/dist/args.js +259 -0
  5. package/dist/cli.js +1039 -0
  6. package/dist/cockpit/command-palette.js +23 -0
  7. package/dist/cockpit/command-registry.js +91 -0
  8. package/dist/cockpit/daily-prompts.js +177 -0
  9. package/dist/cockpit/menu.js +99 -0
  10. package/dist/cockpit/safety.js +22 -0
  11. package/dist/compose-layout.js +118 -0
  12. package/dist/daily-actions.js +190 -0
  13. package/dist/doctor.js +519 -0
  14. package/dist/environment-context.js +10 -0
  15. package/dist/environment-version.js +5 -0
  16. package/dist/environment.js +136 -0
  17. package/dist/external-assets.js +153 -0
  18. package/dist/external-templates.js +86 -0
  19. package/dist/git.js +98 -0
  20. package/dist/github.js +87 -0
  21. package/dist/help.js +157 -0
  22. package/dist/menu-navigation.js +67 -0
  23. package/dist/module-actions.js +114 -0
  24. package/dist/odoo-versions.js +1 -0
  25. package/dist/path-validation.js +50 -0
  26. package/dist/prompt-copy.js +8 -0
  27. package/dist/prompt-repositories.js +34 -0
  28. package/dist/prompts/index.js +174 -0
  29. package/dist/repo-actions.js +158 -0
  30. package/dist/repo-url.js +27 -0
  31. package/dist/repository-preflight.js +46 -0
  32. package/dist/safe-reset.js +217 -0
  33. package/dist/scaffold.js +161 -0
  34. package/dist/source-actions.js +65 -0
  35. package/dist/source-manifest.js +338 -0
  36. package/dist/status.js +239 -0
  37. package/dist/templates.js +758 -0
  38. package/dist/types.js +1 -0
  39. package/dist/update-check.js +106 -0
  40. package/dist/version.js +19 -0
  41. package/docs/assets/patreon-donate.png +0 -0
  42. package/docs/assets/wpmoo-banner.png +0 -0
  43. package/docs/external-resources.md +136 -0
  44. package/docs/generated-environment-verification.md +140 -0
  45. package/docs/handoff.md +29 -0
  46. package/package.json +65 -0
@@ -0,0 +1,338 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isValidPathSegment, validateRepoPath } from './path-validation.js';
4
+ const validSourceTypes = ['private', 'oca', 'external'];
5
+ export const sourceManifestPath = 'odoo/custom/manifests/sources.yaml';
6
+ function fail(message) {
7
+ throw new Error(`Invalid source manifest ${sourceManifestPath}: ${message}`);
8
+ }
9
+ export function normalizeSourceType(value) {
10
+ return validSourceTypes.includes(value) ? value : 'private';
11
+ }
12
+ function dedupeAndSort(entries) {
13
+ const uniqueByTypePath = new Map();
14
+ for (const entry of entries) {
15
+ uniqueByTypePath.set(`${entry.type}:${entry.path}`, entry);
16
+ }
17
+ return [...uniqueByTypePath.values()].sort((left, right) => {
18
+ const typeOrder = left.type.localeCompare(right.type);
19
+ if (typeOrder !== 0)
20
+ return typeOrder;
21
+ return left.path.localeCompare(right.path);
22
+ });
23
+ }
24
+ function stripInlineComment(raw) {
25
+ let inSingle = false;
26
+ let inDouble = false;
27
+ let escaped = false;
28
+ for (let index = 0; index < raw.length; index += 1) {
29
+ const char = raw[index];
30
+ if (escaped) {
31
+ escaped = false;
32
+ continue;
33
+ }
34
+ if (char === '\\') {
35
+ escaped = true;
36
+ continue;
37
+ }
38
+ if (char === "'" && !inDouble) {
39
+ inSingle = !inSingle;
40
+ }
41
+ else if (char === '"' && !inSingle) {
42
+ inDouble = !inDouble;
43
+ }
44
+ else if (char === '#' && !inSingle && !inDouble) {
45
+ return raw.slice(0, index).trimEnd();
46
+ }
47
+ }
48
+ return raw;
49
+ }
50
+ function parseScalar(raw) {
51
+ const trimmed = raw.trim();
52
+ if (!trimmed)
53
+ return '';
54
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
55
+ return JSON.parse(trimmed);
56
+ }
57
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
58
+ return trimmed.slice(1, -1).replace(/\\'/g, "'");
59
+ }
60
+ return trimmed;
61
+ }
62
+ function leadingSpaces(line) {
63
+ return line.length - line.trimStart().length;
64
+ }
65
+ function parseSourcesBlock(content) {
66
+ const lines = content.split(/\r?\n/).map((line, index) => ({
67
+ lineNumber: index + 1,
68
+ line: line.replace(/\t/g, ' '),
69
+ trimmedLine: line.replace(/\t/g, ' ').trim(),
70
+ }));
71
+ const sourcesKeywordLine = lines.find((line) => /^\s*sources\s*:\s*(?:\[[^\]]*\])?\s*$/.test(stripInlineComment(line.line)));
72
+ if (!sourcesKeywordLine) {
73
+ fail('Missing top-level sources entry.');
74
+ }
75
+ const rawSourcesValue = stripInlineComment(sourcesKeywordLine.line).replace(/^\s*sources\s*:\s*/, '');
76
+ if (rawSourcesValue === '[]') {
77
+ return { sources: [] };
78
+ }
79
+ if (rawSourcesValue && rawSourcesValue !== '') {
80
+ fail(`Unexpected non-list value on line ${sourcesKeywordLine.lineNumber}: sources`);
81
+ }
82
+ const sourceLines = lines.slice(sourcesKeywordLine.lineNumber);
83
+ const parsed = [];
84
+ let index = 0;
85
+ while (index < sourceLines.length) {
86
+ const headerLine = sourceLines[index];
87
+ const noCommentHeader = stripInlineComment(headerLine.line);
88
+ if (!noCommentHeader.trim()) {
89
+ index += 1;
90
+ continue;
91
+ }
92
+ const itemMatch = /^\s*-\s*type:\s*(.+)\s*$/.exec(noCommentHeader);
93
+ if (!itemMatch) {
94
+ index += 1;
95
+ continue;
96
+ }
97
+ const item = {
98
+ type: normalizeSourceType(parseScalar(itemMatch[1])),
99
+ path: '',
100
+ url: '',
101
+ addons: [],
102
+ };
103
+ index += 1;
104
+ while (index < sourceLines.length) {
105
+ const rawLine = sourceLines[index];
106
+ const noComment = stripInlineComment(rawLine.line);
107
+ const trimmed = noComment.trim();
108
+ if (!trimmed) {
109
+ index += 1;
110
+ continue;
111
+ }
112
+ if (/^\s*-\s*type:\s*/.test(noComment)) {
113
+ break;
114
+ }
115
+ const pathMatch = /^\s*path:\s*(.+)\s*$/.exec(noComment);
116
+ if (pathMatch) {
117
+ item.path = validateRepoPath(parseScalar(pathMatch[1]));
118
+ index += 1;
119
+ continue;
120
+ }
121
+ const urlMatch = /^\s*url:\s*(.+)\s*$/.exec(noComment);
122
+ if (urlMatch) {
123
+ item.url = parseScalar(urlMatch[1]);
124
+ index += 1;
125
+ continue;
126
+ }
127
+ const branchMatch = /^\s*branch:\s*(.+)\s*$/.exec(noComment);
128
+ if (branchMatch) {
129
+ item.branch = parseScalar(branchMatch[1]);
130
+ index += 1;
131
+ continue;
132
+ }
133
+ const addonsLine = /^\s*addons:\s*$/.exec(noComment);
134
+ if (addonsLine) {
135
+ const baseIndent = leadingSpaces(rawLine.line) + 2;
136
+ index += 1;
137
+ while (index < sourceLines.length) {
138
+ const addonRaw = stripInlineComment(sourceLines[index].line);
139
+ const addonTrimmed = addonRaw.trim();
140
+ if (!addonTrimmed) {
141
+ index += 1;
142
+ continue;
143
+ }
144
+ const addonMatch = /^\s*-\s*(.+)\s*$/.exec(addonRaw);
145
+ if (!addonMatch) {
146
+ break;
147
+ }
148
+ if (leadingSpaces(addonRaw) < baseIndent) {
149
+ break;
150
+ }
151
+ const addon = parseScalar(addonMatch[1]);
152
+ if (addon) {
153
+ item.addons.push(addon);
154
+ }
155
+ index += 1;
156
+ }
157
+ continue;
158
+ }
159
+ fail(`Unexpected source entry field on line ${rawLine.lineNumber}: ${trimmed}`);
160
+ }
161
+ if (!item.path) {
162
+ fail(`Manifest entry missing path at line ${headerLine.lineNumber}`);
163
+ }
164
+ if (!item.url) {
165
+ fail(`Manifest entry missing url for ${item.type}:${item.path} at line ${headerLine.lineNumber}`);
166
+ }
167
+ if (!isValidPathSegment(item.path)) {
168
+ fail(`Invalid manifest path at line ${headerLine.lineNumber}: ${item.path}`);
169
+ }
170
+ if (item.addons.length === 0) {
171
+ item.addons.push(item.path);
172
+ }
173
+ item.addons = [...new Set(item.addons.map((addon) => validateRepoPath(addon)))].sort();
174
+ parsed.push(item);
175
+ }
176
+ return { sources: dedupeAndSort(parsed.filter((entry) => isValidPathSegment(entry.path))) };
177
+ }
178
+ export async function readSourceManifest(target) {
179
+ try {
180
+ const content = await readFile(join(target, sourceManifestPath), 'utf8');
181
+ return parseSourcesBlock(content);
182
+ }
183
+ catch (error) {
184
+ if (error.code === 'ENOENT') {
185
+ return { sources: [] };
186
+ }
187
+ throw error;
188
+ }
189
+ }
190
+ function renderQuoted(value) {
191
+ return JSON.stringify(value);
192
+ }
193
+ export function renderSourceManifest(entries) {
194
+ const normalized = dedupeAndSort(entries).map((entry) => {
195
+ const addons = [...new Set(entry.addons.map((addon) => validateRepoPath(addon)))].sort();
196
+ return {
197
+ type: entry.type,
198
+ path: validateRepoPath(entry.path),
199
+ url: entry.url.trim(),
200
+ branch: entry.branch?.trim(),
201
+ addons: addons.length ? addons : [validateRepoPath(entry.path)],
202
+ };
203
+ });
204
+ if (normalized.length === 0) {
205
+ return 'sources: []\n';
206
+ }
207
+ const body = normalized
208
+ .map((entry) => {
209
+ const lines = [
210
+ ` - type: ${renderQuoted(entry.type)}`,
211
+ ` path: ${renderQuoted(entry.path)}`,
212
+ ` url: ${renderQuoted(entry.url)}`,
213
+ ];
214
+ lines.push(` branch: ${renderQuoted(entry.branch ?? '')}`);
215
+ lines.push(' addons:');
216
+ for (const addon of entry.addons) {
217
+ lines.push(` - ${renderQuoted(addon)}`);
218
+ }
219
+ return lines.join('\n');
220
+ })
221
+ .join('\n');
222
+ return `sources:\n${body}\n`;
223
+ }
224
+ export async function writeSourceManifest(target, entries) {
225
+ const content = renderSourceManifest(entries);
226
+ const path = join(target, sourceManifestPath);
227
+ await mkdir(join(path, '..'), { recursive: true });
228
+ await writeFile(path, content, 'utf8');
229
+ }
230
+ function entryKey(type, path) {
231
+ return `${type}:${path}`;
232
+ }
233
+ export async function upsertSourceManifestEntry(target, entry) {
234
+ const manifest = await readSourceManifest(target);
235
+ const normalized = {
236
+ ...entry,
237
+ type: normalizeSourceType(entry.type),
238
+ path: validateRepoPath(entry.path),
239
+ };
240
+ const next = dedupeAndSort(manifest.sources.filter((current) => entryKey(current.type, current.path) !== entryKey(normalized.type, normalized.path)));
241
+ next.push(normalized);
242
+ await writeSourceManifest(target, next);
243
+ }
244
+ export async function removeSourceManifestEntry(target, type, path) {
245
+ const manifest = await readSourceManifest(target);
246
+ const key = entryKey(normalizeSourceType(type), validateRepoPath(path));
247
+ const next = manifest.sources.filter((entry) => entryKey(entry.type, entry.path) !== key);
248
+ await writeSourceManifest(target, next);
249
+ }
250
+ export function sourceManifestEntriesFromMetadata(sourceRepos, fallbackBranch) {
251
+ return sourceRepos.map((repo) => ({
252
+ type: normalizeSourceType(repo.sourceType),
253
+ path: validateRepoPath(repo.path),
254
+ url: repo.url.trim(),
255
+ branch: fallbackBranch,
256
+ addons: repo.addons.length ? [...new Set(repo.addons.map((addon) => validateRepoPath(addon)))] : [validateRepoPath(repo.path)],
257
+ }));
258
+ }
259
+ export async function listGitmoduleSources(target) {
260
+ try {
261
+ const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
262
+ const lines = gitmodules.split(/\r?\n/);
263
+ const locations = [];
264
+ const pathRegex = /^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/(.+)\s*$/;
265
+ const urlRegex = /^\s*url\s*=\s*(.+)\s*$/;
266
+ let pending;
267
+ for (const line of lines) {
268
+ const parsedPath = line.match(pathRegex);
269
+ if (parsedPath) {
270
+ const sourceType = parsedPath[1];
271
+ const repoPath = parsedPath[2]?.trim() ?? '';
272
+ if (!repoPath || !isValidPathSegment(repoPath)) {
273
+ pending = undefined;
274
+ continue;
275
+ }
276
+ pending = {
277
+ type: sourceType,
278
+ path: validateRepoPath(repoPath),
279
+ url: '',
280
+ };
281
+ continue;
282
+ }
283
+ const parsedUrl = line.match(urlRegex);
284
+ if (!parsedUrl || !pending) {
285
+ continue;
286
+ }
287
+ const url = parseScalar(parsedUrl[1]);
288
+ if (url) {
289
+ locations.push({ ...pending, url });
290
+ }
291
+ pending = undefined;
292
+ }
293
+ return locations;
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ }
299
+ export function syncManifestFromMetadataAndGitmodules(sourceRepos, fallbackBranch, gitmodules = []) {
300
+ const byGitmodule = new Map();
301
+ for (const location of gitmodules) {
302
+ byGitmodule.set(`${normalizeSourceType(location.type)}:${location.path}`, location);
303
+ }
304
+ const entries = [];
305
+ for (const repo of sourceRepos) {
306
+ const normalized = {
307
+ type: normalizeSourceType(repo.sourceType),
308
+ path: validateRepoPath(repo.path),
309
+ url: repo.url.trim() || byGitmodule.get(`${normalizeSourceType(repo.sourceType)}:${repo.path}`)?.url || '',
310
+ branch: fallbackBranch,
311
+ addons: repo.addons.map(validateRepoPath),
312
+ };
313
+ entries.push(normalized);
314
+ }
315
+ for (const location of gitmodules) {
316
+ const key = `${location.type}:${location.path}`;
317
+ if (entries.some((entry) => `${entry.type}:${entry.path}` === key)) {
318
+ continue;
319
+ }
320
+ entries.push({
321
+ type: location.type,
322
+ path: location.path,
323
+ url: location.url,
324
+ branch: fallbackBranch,
325
+ addons: [location.path],
326
+ });
327
+ }
328
+ return dedupeAndSort(entries);
329
+ }
330
+ export function sourceReposFromManifest(entries) {
331
+ const normalized = dedupeAndSort(entries);
332
+ return normalized.map((entry) => ({
333
+ sourceType: entry.type,
334
+ path: validateRepoPath(entry.path),
335
+ url: entry.url,
336
+ addons: entry.addons.length ? [...new Set(entry.addons.map((addon) => validateRepoPath(addon)))] : [validateRepoPath(entry.path)],
337
+ }));
338
+ }
package/dist/status.js ADDED
@@ -0,0 +1,239 @@
1
+ import { access, readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
4
+ import { defaultOdooVersion, markerPath } from './environment.js';
5
+ import { isValidPathSegment, validateRepoPath } from './path-validation.js';
6
+ const validSourceTypes = ['private', 'oca', 'external'];
7
+ function normalizeSourceType(sourceType) {
8
+ if (typeof sourceType === 'string' && validSourceTypes.includes(sourceType)) {
9
+ return sourceType;
10
+ }
11
+ return 'private';
12
+ }
13
+ function sourceRepoPath(target, sourceType, path) {
14
+ return join(target, 'odoo/custom/src', sourceType, path);
15
+ }
16
+ async function pathExists(path) {
17
+ try {
18
+ await access(path);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ function errorMessage(error) {
26
+ return error instanceof Error ? error.message : String(error);
27
+ }
28
+ function isRecord(value) {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+ function parseMetadata(content) {
32
+ const parsed = JSON.parse(content);
33
+ if (!isRecord(parsed)) {
34
+ throw new Error('metadata is not an object');
35
+ }
36
+ return parsed;
37
+ }
38
+ function sourceRepoPathsFromMetadata(metadata) {
39
+ const sourceRepoPaths = [];
40
+ const sourceRepoLocations = [];
41
+ const invalidSourceRepoPaths = [];
42
+ if (!Array.isArray(metadata.sourceRepos)) {
43
+ return { sourceRepoPaths, sourceRepoLocations, invalidSourceRepoPaths };
44
+ }
45
+ for (const repo of metadata.sourceRepos) {
46
+ const path = repo && typeof repo.path === 'string' ? repo.path.trim() : '';
47
+ if (!path)
48
+ continue;
49
+ if (!isValidPathSegment(path)) {
50
+ invalidSourceRepoPaths.push(path);
51
+ continue;
52
+ }
53
+ const sourceType = normalizeSourceType(typeof repo.sourceType === 'string' ? repo.sourceType : undefined);
54
+ const normalizedPath = validateRepoPath(path);
55
+ sourceRepoPaths.push(normalizedPath);
56
+ sourceRepoLocations.push({ sourceType, path: normalizedPath });
57
+ }
58
+ return { sourceRepoPaths, sourceRepoLocations, invalidSourceRepoPaths };
59
+ }
60
+ async function missingCoreFiles(target, odooVersion) {
61
+ const missing = [];
62
+ const checks = [
63
+ { label: 'moo', path: join(target, 'moo') },
64
+ { label: 'README.md', path: join(target, 'README.md') },
65
+ { label: 'AGENTS.md', path: join(target, 'AGENTS.md') },
66
+ { label: 'scripts/', path: join(target, 'scripts'), mustBeDirectory: true },
67
+ ];
68
+ for (const check of checks) {
69
+ if (!(await pathExists(check.path))) {
70
+ missing.push(check.label);
71
+ continue;
72
+ }
73
+ if (check.mustBeDirectory) {
74
+ const fileStat = await stat(check.path);
75
+ if (!fileStat.isDirectory())
76
+ missing.push(check.label);
77
+ }
78
+ }
79
+ const env = await readEnvFile(target);
80
+ const composeLayout = await detectComposeLayout(target, {
81
+ odooVersions: [odooVersion],
82
+ envName: selectedComposeEnvironment(env),
83
+ });
84
+ missing.push(...composeLayout.missingFiles);
85
+ return { missing, composeFiles: composeLayout.files, composeErrors: composeLayout.errors };
86
+ }
87
+ async function countModuleCandidatesInRepoPath(path) {
88
+ if (!(await pathExists(path)))
89
+ return 0;
90
+ const rootStat = await stat(path);
91
+ if (!rootStat.isDirectory())
92
+ return 0;
93
+ let count = 0;
94
+ const stack = [path];
95
+ while (stack.length > 0) {
96
+ const current = stack.pop();
97
+ if (!current)
98
+ continue;
99
+ const entries = await readdir(current, { withFileTypes: true });
100
+ let hasManifest = false;
101
+ for (const entry of entries) {
102
+ if (entry.isFile() && entry.name === '__manifest__.py') {
103
+ hasManifest = true;
104
+ }
105
+ else if (entry.isDirectory()) {
106
+ stack.push(join(current, entry.name));
107
+ }
108
+ }
109
+ if (hasManifest)
110
+ count += 1;
111
+ }
112
+ return count;
113
+ }
114
+ function summaryText(status) {
115
+ if (status.kind === 'no_environment')
116
+ return 'No WPMoo environment detected.';
117
+ if (status.kind === 'invalid_metadata')
118
+ return 'Environment metadata is invalid.';
119
+ const prefix = status.missingCoreFiles.length > 0 ||
120
+ status.invalidSourceRepoPaths.length > 0 ||
121
+ status.composeErrors.length > 0
122
+ ? 'Environment needs attention'
123
+ : 'Environment ready';
124
+ return `${prefix}: Odoo ${status.odooVersion}, source repos ${status.sourceRepoCount}, module candidates ${status.moduleCandidateCount}.`;
125
+ }
126
+ function isStatusHealthy(status) {
127
+ if (status.kind !== 'environment')
128
+ return false;
129
+ return (status.missingCoreFiles.length === 0 &&
130
+ status.invalidSourceRepoPaths.length === 0 &&
131
+ status.composeErrors.length === 0);
132
+ }
133
+ export function environmentStatusJson(status) {
134
+ return {
135
+ schemaVersion: 1,
136
+ command: 'status',
137
+ ok: isStatusHealthy(status),
138
+ status,
139
+ };
140
+ }
141
+ export async function getEnvironmentStatus(target) {
142
+ const metadataFullPath = join(target, markerPath);
143
+ if (!(await pathExists(metadataFullPath))) {
144
+ return {
145
+ kind: 'no_environment',
146
+ target,
147
+ metadataPath: markerPath,
148
+ recommendedNextAction: 'Run npx @wpmoo/toolkit create ...',
149
+ };
150
+ }
151
+ let metadata;
152
+ try {
153
+ const content = await readFile(metadataFullPath, 'utf8');
154
+ metadata = parseMetadata(content);
155
+ }
156
+ catch (error) {
157
+ return {
158
+ kind: 'invalid_metadata',
159
+ target,
160
+ metadataPath: markerPath,
161
+ metadataError: errorMessage(error),
162
+ recommendedNextAction: 'Fix .wpmoo/odoo.json or run npx @wpmoo/toolkit reset from a valid environment.',
163
+ };
164
+ }
165
+ const odooVersion = typeof metadata.odooVersion === 'string' && metadata.odooVersion.trim()
166
+ ? metadata.odooVersion.trim()
167
+ : defaultOdooVersion;
168
+ const { sourceRepoPaths, sourceRepoLocations, invalidSourceRepoPaths } = sourceRepoPathsFromMetadata(metadata);
169
+ const repoRoots = sourceRepoLocations.map(({ sourceType, path }) => sourceRepoPath(target, sourceType, path));
170
+ let moduleCandidateCount = 0;
171
+ for (const repoRoot of repoRoots) {
172
+ moduleCandidateCount += await countModuleCandidatesInRepoPath(repoRoot);
173
+ }
174
+ const { missing: missingFiles, composeFiles, composeErrors, } = await missingCoreFiles(target, odooVersion);
175
+ let recommendedNextAction = 'Run npx @wpmoo/toolkit doctor for deep checks or ./moo start.';
176
+ if (invalidSourceRepoPaths.length > 0) {
177
+ recommendedNextAction =
178
+ 'Fix invalid source repo paths in .wpmoo/odoo.json, then run npx @wpmoo/toolkit doctor.';
179
+ }
180
+ else if (missingFiles.length > 0) {
181
+ recommendedNextAction = 'Run npx @wpmoo/toolkit reset, then npx @wpmoo/toolkit doctor.';
182
+ }
183
+ else if (composeErrors.length > 0) {
184
+ recommendedNextAction = 'Fix compose layout errors, then run npx @wpmoo/toolkit doctor.';
185
+ }
186
+ else if (sourceRepoPaths.length === 0) {
187
+ recommendedNextAction = 'Run npx @wpmoo/toolkit add-repo ...';
188
+ }
189
+ return {
190
+ kind: 'environment',
191
+ target,
192
+ metadataPath: markerPath,
193
+ odooVersion,
194
+ sourceRepoCount: sourceRepoPaths.length,
195
+ sourceRepoPaths,
196
+ invalidSourceRepoPaths,
197
+ moduleCandidateCount,
198
+ composeFiles,
199
+ composeErrors,
200
+ missingCoreFiles: missingFiles,
201
+ recommendedNextAction,
202
+ };
203
+ }
204
+ export function renderEnvironmentStatusSummary(status) {
205
+ return summaryText(status);
206
+ }
207
+ export function renderEnvironmentStatus(status) {
208
+ const lines = [`Status: ${summaryText(status)}`];
209
+ if (status.kind === 'no_environment') {
210
+ lines.push(`Metadata: missing ${status.metadataPath}`);
211
+ lines.push(`Next: ${status.recommendedNextAction}`);
212
+ return lines.join('\n');
213
+ }
214
+ if (status.kind === 'invalid_metadata') {
215
+ lines.push(`Metadata: invalid ${status.metadataPath}`);
216
+ lines.push(`Error: ${status.metadataError}`);
217
+ lines.push(`Next: ${status.recommendedNextAction}`);
218
+ return lines.join('\n');
219
+ }
220
+ lines.push(`Metadata: ${status.metadataPath}`);
221
+ lines.push(`Odoo: ${status.odooVersion}`);
222
+ lines.push(`Compose files: ${status.composeFiles.length > 0 ? status.composeFiles.join(', ') : '(missing)'}`);
223
+ if (status.composeErrors.length > 0) {
224
+ lines.push(`Compose errors: ${status.composeErrors.join(', ')}`);
225
+ }
226
+ lines.push(`Source repos: ${status.sourceRepoCount}`);
227
+ lines.push(`Source repo paths: ${status.sourceRepoPaths.length > 0 ? status.sourceRepoPaths.join(', ') : '(none configured)'}`);
228
+ if (status.invalidSourceRepoPaths.length > 0) {
229
+ lines.push(`Invalid source repo paths: ${status.invalidSourceRepoPaths.join(', ')}`);
230
+ }
231
+ lines.push(`Module candidates: ${status.moduleCandidateCount}`);
232
+ lines.push(`Missing core files: ${status.missingCoreFiles.length > 0 ? status.missingCoreFiles.join(', ') : '(none)'}`);
233
+ lines.push(`Next: ${status.recommendedNextAction}`);
234
+ return lines.join('\n');
235
+ }
236
+ export async function renderEnvironmentStatusForTarget(target) {
237
+ const status = await getEnvironmentStatus(target);
238
+ return renderEnvironmentStatus(status);
239
+ }