@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
package/dist/doctor.js ADDED
@@ -0,0 +1,519 @@
1
+ import { access, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { execa } from 'execa';
4
+ import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
5
+ import { dailyActionScripts } from './daily-actions.js';
6
+ import { defaultPostgresVersion } from './external-templates.js';
7
+ import { defaultOdooVersion, markerPath, replaceSourceRepos } from './environment.js';
8
+ import { listGitmoduleSources, readSourceManifest, sourceReposFromManifest, sourceManifestPath, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
9
+ const realCommandRunner = async (command, args, options) => {
10
+ const result = await execa(command, args, { cwd: options.cwd });
11
+ return { stdout: result.stdout, stderr: result.stderr };
12
+ };
13
+ async function exists(path) {
14
+ try {
15
+ await access(path);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function errorMessage(error) {
23
+ return error instanceof Error ? error.message : String(error);
24
+ }
25
+ function commandErrorText(error) {
26
+ const parts = [errorMessage(error)];
27
+ if (isRecord(error)) {
28
+ for (const key of ['stderr', 'stdout']) {
29
+ const value = error[key];
30
+ if (typeof value === 'string' && value.trim()) {
31
+ parts.push(value.trim());
32
+ }
33
+ }
34
+ }
35
+ return parts.join('\n');
36
+ }
37
+ function isRecord(value) {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
39
+ }
40
+ function isDoctorOptions(value) {
41
+ return isRecord(value);
42
+ }
43
+ function isMetadataError(message) {
44
+ return (message.startsWith('Missing metadata file:') ||
45
+ message.startsWith('Invalid metadata JSON in .wpmoo/odoo.json') ||
46
+ message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
47
+ }
48
+ const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
49
+ function parsePostgresMajorFromValue(value) {
50
+ if (!value)
51
+ return undefined;
52
+ const trimmed = value.trim();
53
+ if (/^\d{1,3}$/.test(trimmed)) {
54
+ return trimmed;
55
+ }
56
+ const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
57
+ return match?.[1];
58
+ }
59
+ function stripInlineComment(line) {
60
+ const hashIndex = line.indexOf('#');
61
+ if (hashIndex === -1)
62
+ return line;
63
+ return line.slice(0, hashIndex);
64
+ }
65
+ function hasInvalidPostgres18Mount(line, mountTarget) {
66
+ const escaped = mountTarget.replaceAll('.', '\\.').replaceAll('/', '\\/');
67
+ const shortPatterns = [
68
+ new RegExp(`^\\s*-\\s+.+:\\s*['"]?${escaped}['"]?(?:\\s|:|$)`),
69
+ new RegExp(`^\\s*-\\s*['"]?${escaped}['"]?(?:\\s|$)`),
70
+ new RegExp(`^\\s*target:\\s*['"]?${escaped}['"]?(?:\\s|$)`),
71
+ ];
72
+ return shortPatterns.some((pattern) => pattern.test(line));
73
+ }
74
+ function isNonAmbiguousLineForMountFix(line, mountTarget) {
75
+ return hasInvalidPostgres18Mount(line, mountTarget);
76
+ }
77
+ function replaceMountTargetInLine(line, from, to) {
78
+ return line.split(from).join(to);
79
+ }
80
+ function normalizePostgres18MountTargetsInComposeContent(content) {
81
+ const fixedTargets = [];
82
+ const fixed = [];
83
+ const hasTrailingNewline = content.endsWith('\n');
84
+ const comparableContent = hasTrailingNewline ? content.slice(0, -1) : content;
85
+ const lines = comparableContent.split(/\r?\n/);
86
+ const nextLines = [];
87
+ for (const line of lines) {
88
+ const commentIndex = line.indexOf('#');
89
+ const comment = commentIndex === -1 ? '' : line.slice(commentIndex);
90
+ const body = commentIndex === -1 ? line : line.slice(0, commentIndex);
91
+ let nextBody = body;
92
+ let lineFixed = false;
93
+ for (const target of incompatiblePostgres18MountTargets) {
94
+ if (!isNonAmbiguousLineForMountFix(body, target))
95
+ continue;
96
+ nextBody = replaceMountTargetInLine(nextBody, target, '/var/lib/postgresql');
97
+ if (!fixedTargets.includes(target)) {
98
+ fixedTargets.push(target);
99
+ }
100
+ lineFixed = true;
101
+ }
102
+ if (lineFixed) {
103
+ fixed.push(line);
104
+ nextLines.push(`${nextBody}${comment}`);
105
+ }
106
+ else {
107
+ nextLines.push(line);
108
+ }
109
+ }
110
+ return {
111
+ content: `${nextLines.join('\n')}${hasTrailingNewline ? '\n' : ''}`,
112
+ fixed,
113
+ fixedTargets,
114
+ };
115
+ }
116
+ function invalidPostgres18MountTargetsInCompose(content) {
117
+ const badTargets = new Set();
118
+ for (const rawLine of content.split(/\r?\n/)) {
119
+ const line = stripInlineComment(rawLine).trim();
120
+ if (!line)
121
+ continue;
122
+ for (const target of incompatiblePostgres18MountTargets) {
123
+ if (hasInvalidPostgres18Mount(line, target)) {
124
+ badTargets.add(target);
125
+ }
126
+ }
127
+ }
128
+ return [...badTargets];
129
+ }
130
+ function inferPostgresVersion(metadata, odooVersion, env) {
131
+ const envPostgresImage = env?.get('POSTGRES_IMAGE')?.trim();
132
+ const envPostgresMajor = parsePostgresMajorFromValue(envPostgresImage);
133
+ if (envPostgresMajor) {
134
+ return envPostgresMajor;
135
+ }
136
+ const explicitPostgres = parsePostgresMajorFromValue(metadataString(metadata, 'postgresVersion'));
137
+ if (explicitPostgres) {
138
+ return explicitPostgres;
139
+ }
140
+ return defaultPostgresVersion(odooVersion);
141
+ }
142
+ function normalizeSourceType(value) {
143
+ if (value === 'oca' || value === 'external' || value === 'private') {
144
+ return value;
145
+ }
146
+ return 'private';
147
+ }
148
+ function sourceRepoPath(type, path) {
149
+ return `odoo/custom/src/${type}/${path}`;
150
+ }
151
+ function entryKey(type, path) {
152
+ return `${type}:${path}`;
153
+ }
154
+ function sourceReposFromMetadata(metadata) {
155
+ const sourceRepos = metadata.sourceRepos;
156
+ if (!Array.isArray(sourceRepos))
157
+ return [];
158
+ return sourceRepos
159
+ .map((repo, index) => {
160
+ if (!isRecord(repo) || typeof repo.path !== 'string' || !repo.path.trim()) {
161
+ throw new Error(`Invalid sourceRepos entry in .wpmoo/odoo.json at index ${index}`);
162
+ }
163
+ return {
164
+ url: typeof repo.url === 'string' ? repo.url : '',
165
+ path: repo.path.trim(),
166
+ addons: Array.isArray(repo.addons)
167
+ ? repo.addons.filter((addon) => typeof addon === 'string')
168
+ : [],
169
+ sourceType: normalizeSourceType(repo.sourceType),
170
+ };
171
+ })
172
+ .filter((repo) => repo.path)
173
+ .sort((left, right) => {
174
+ const typeOrder = left.sourceType.localeCompare(right.sourceType);
175
+ if (typeOrder !== 0)
176
+ return typeOrder;
177
+ return left.path.localeCompare(right.path);
178
+ });
179
+ }
180
+ async function readMetadata(target) {
181
+ let content;
182
+ try {
183
+ content = await readFile(join(target, markerPath), 'utf8');
184
+ }
185
+ catch {
186
+ throw new Error(`Missing metadata file: ${markerPath}`);
187
+ }
188
+ try {
189
+ const parsed = JSON.parse(content);
190
+ if (!isRecord(parsed)) {
191
+ throw new Error('metadata is not an object');
192
+ }
193
+ return parsed;
194
+ }
195
+ catch (error) {
196
+ throw new Error(`Invalid metadata JSON in ${markerPath}: ${errorMessage(error)}`);
197
+ }
198
+ }
199
+ function metadataString(metadata, key) {
200
+ const value = metadata[key];
201
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
202
+ }
203
+ function validatePort(name, env, errors) {
204
+ const value = env.get(name)?.trim() ?? '';
205
+ if (!/^\d+$/.test(value)) {
206
+ errors.push(`Invalid ${name} in .env: expected a non-empty numeric value`);
207
+ }
208
+ return value;
209
+ }
210
+ function renderFailure(errors) {
211
+ return ['WPMoo doctor failed:', ...errors.map((error) => `- ${error}`)].join('\n');
212
+ }
213
+ function isNotGitCheckoutError(error) {
214
+ return commandErrorText(error).toLowerCase().includes('not a git repository');
215
+ }
216
+ function isSourceRepoSubmodule(path, sourceRepos) {
217
+ return sourceRepos.some((repo) => {
218
+ const sourcePath = sourceRepoPath(repo.sourceType ?? 'private', repo.path);
219
+ return path === sourcePath || path.startsWith(`${sourcePath}/`);
220
+ });
221
+ }
222
+ function sourceSubmoduleStatusErrors(output, sourceRepos) {
223
+ const errors = [];
224
+ for (const rawLine of output.split(/\r?\n/)) {
225
+ const line = rawLine.trimEnd();
226
+ if (!line)
227
+ continue;
228
+ const status = line[0];
229
+ const parts = line.slice(1).trim().split(/\s+/);
230
+ const path = parts[1];
231
+ if (!path || !isSourceRepoSubmodule(path, sourceRepos))
232
+ continue;
233
+ if (status === '-') {
234
+ errors.push(`Uninitialized Git submodule: ${path}`);
235
+ }
236
+ else if (status === 'U') {
237
+ errors.push(`Conflicted Git submodule: ${path}`);
238
+ }
239
+ }
240
+ return errors;
241
+ }
242
+ function manifestEntryToKey(entry) {
243
+ return entryKey(entry.type, entry.path);
244
+ }
245
+ function manifestRepoToKey(repo) {
246
+ return entryKey(normalizeSourceType(repo.sourceType), repo.path);
247
+ }
248
+ function formatKeyForPath(key) {
249
+ const [sourceType, ...pathParts] = key.split(':');
250
+ return sourceRepoPath(sourceType, pathParts.join(':'));
251
+ }
252
+ function checkSourceConsistency(sourceRepos, manifestEntries, gitmoduleSources, manifestExists, gitmodulesExists) {
253
+ if (!manifestExists) {
254
+ return [];
255
+ }
256
+ const errors = [];
257
+ const metadataEntries = new Map();
258
+ for (const repo of sourceRepos) {
259
+ metadataEntries.set(manifestRepoToKey(repo), repo);
260
+ }
261
+ const manifestMap = new Map();
262
+ for (const entry of manifestEntries) {
263
+ manifestMap.set(manifestEntryToKey(entry), entry);
264
+ }
265
+ const gitmoduleSet = new Set(gitmoduleSources.map((source) => manifestEntryToKey({ type: source.type, path: source.path })));
266
+ const sortedMetadataKeys = [...metadataEntries.keys()].sort();
267
+ const sortedManifestKeys = [...manifestMap.keys()].sort();
268
+ for (const key of sortedMetadataKeys) {
269
+ if (!manifestMap.has(key)) {
270
+ errors.push(`Metadata source entry missing in manifest: ${formatKeyForPath(key)}`);
271
+ }
272
+ }
273
+ for (const key of sortedManifestKeys) {
274
+ if (!metadataEntries.has(key)) {
275
+ errors.push(`Manifest source entry missing in metadata: ${formatKeyForPath(key)}`);
276
+ }
277
+ if (gitmodulesExists && !gitmoduleSet.has(key)) {
278
+ errors.push(`Manifest source path missing in .gitmodules: ${formatKeyForPath(key)}`);
279
+ }
280
+ }
281
+ return errors;
282
+ }
283
+ async function repairSourceManifestFromDiscoveredState(target, sourceRepos, fallbackBranch, gitmoduleSources) {
284
+ const entries = syncManifestFromMetadataAndGitmodules(sourceRepos, fallbackBranch, gitmoduleSources);
285
+ await writeSourceManifest(target, entries);
286
+ await replaceSourceRepos(target, sourceReposFromManifest(entries));
287
+ }
288
+ export async function getDoctorReport(target = process.cwd(), runnerOrOptions = realCommandRunner, options = {}) {
289
+ const actualRunner = isDoctorOptions(runnerOrOptions) ? realCommandRunner : runnerOrOptions;
290
+ const actualOptions = isDoctorOptions(runnerOrOptions) ? runnerOrOptions : options;
291
+ const errors = [];
292
+ const warnings = [];
293
+ const checks = [];
294
+ const appliedFixes = [];
295
+ const report = {
296
+ schemaVersion: 1,
297
+ command: 'doctor',
298
+ ok: false,
299
+ target,
300
+ checks,
301
+ warnings,
302
+ errors,
303
+ appliedFixes,
304
+ };
305
+ let metadata;
306
+ try {
307
+ metadata = await readMetadata(target);
308
+ }
309
+ catch (error) {
310
+ errors.push(errorMessage(error));
311
+ return report;
312
+ }
313
+ checks.push(`OK metadata ${markerPath}`);
314
+ const engine = metadataString(metadata, 'engine') ?? 'compose';
315
+ if (engine !== 'compose') {
316
+ errors.push(`Unsupported environment engine: ${engine}`);
317
+ }
318
+ else {
319
+ checks.push('OK engine compose');
320
+ }
321
+ const odooVersion = metadataString(metadata, 'odooVersion') ?? defaultOdooVersion;
322
+ checks.push(`OK Odoo version ${odooVersion}`);
323
+ const env = await readEnvFile(target);
324
+ const composeVersions = new Set([odooVersion]);
325
+ const envOdooVersion = env?.get('ODOO_VERSION')?.trim();
326
+ if (envOdooVersion) {
327
+ composeVersions.add(envOdooVersion);
328
+ }
329
+ const composeLayout = await detectComposeLayout(target, {
330
+ odooVersions: [...composeVersions],
331
+ envName: selectedComposeEnvironment(env),
332
+ });
333
+ if (composeLayout.kind === 'missing') {
334
+ errors.push(...composeLayout.errors);
335
+ }
336
+ else {
337
+ checks.push(`OK compose files ${composeLayout.files.join(', ')}`);
338
+ const postgresVersion = inferPostgresVersion(metadata, odooVersion, env);
339
+ if (postgresVersion === '18') {
340
+ for (const file of composeLayout.files) {
341
+ const composePath = join(target, file);
342
+ let content;
343
+ try {
344
+ content = await readFile(composePath, 'utf8');
345
+ }
346
+ catch (error) {
347
+ errors.push(`Cannot read compose file for compatibility check: ${file}: ${errorMessage(error)}`);
348
+ continue;
349
+ }
350
+ if (actualOptions.fix) {
351
+ const normalization = normalizePostgres18MountTargetsInComposeContent(content);
352
+ if (normalization.fixed.length > 0) {
353
+ await writeFile(composePath, normalization.content, 'utf8');
354
+ for (const target of normalization.fixedTargets) {
355
+ appliedFixes.push(`Normalized PostgreSQL 18 mount target in '${file}': replaced '${target}' -> '/var/lib/postgresql'`);
356
+ }
357
+ continue;
358
+ }
359
+ }
360
+ const badMounts = invalidPostgres18MountTargetsInCompose(content);
361
+ for (const badMount of badMounts) {
362
+ errors.push(`PostgreSQL 18 compatibility issue in '${file}': mount target '${badMount}' is invalid; recommend using '/var/lib/postgresql'`);
363
+ }
364
+ }
365
+ }
366
+ }
367
+ const scriptNames = Object.values(dailyActionScripts);
368
+ const scriptErrorCount = errors.length;
369
+ for (const script of scriptNames) {
370
+ const relativePath = `scripts/${script}`;
371
+ if (!(await exists(join(target, relativePath)))) {
372
+ errors.push(`Missing daily action script: ${relativePath}`);
373
+ }
374
+ }
375
+ if (errors.length === scriptErrorCount) {
376
+ checks.push(`OK scripts ${scriptNames.length} checked`);
377
+ }
378
+ let sourceRepos;
379
+ try {
380
+ sourceRepos = sourceReposFromMetadata(metadata);
381
+ }
382
+ catch (error) {
383
+ errors.push(errorMessage(error));
384
+ return report;
385
+ }
386
+ for (const repo of sourceRepos) {
387
+ const relativePath = sourceRepoPath(normalizeSourceType(repo.sourceType), repo.path);
388
+ if (!(await exists(join(target, relativePath))) && repo.path) {
389
+ errors.push(`Missing source repo path: ${relativePath}`);
390
+ }
391
+ }
392
+ checks.push(`OK source repos ${sourceRepos.length} checked`);
393
+ const manifestPath = join(target, sourceManifestPath);
394
+ const hasManifest = await exists(manifestPath);
395
+ let manifestEntries = [];
396
+ let manifestReadError;
397
+ if (hasManifest) {
398
+ try {
399
+ manifestEntries = (await readSourceManifest(target)).sources;
400
+ }
401
+ catch (error) {
402
+ manifestReadError = `Failed to read source manifest ${sourceManifestPath}: ${errorMessage(error)}`;
403
+ if (!actualOptions.fix) {
404
+ errors.push(manifestReadError);
405
+ }
406
+ }
407
+ }
408
+ const gitmoduleSources = await listGitmoduleSources(target);
409
+ const hasGitmodules = await exists(join(target, '.gitmodules'));
410
+ const sourceConsistencyIssues = !manifestReadError
411
+ ? checkSourceConsistency(sourceRepos, manifestEntries, gitmoduleSources, hasManifest, hasGitmodules)
412
+ : [];
413
+ const shouldSyncSources = actualOptions.fix &&
414
+ (manifestReadError || sourceConsistencyIssues.length > 0 || (!hasManifest && (sourceRepos.length > 0 || gitmoduleSources.length > 0)));
415
+ if (sourceConsistencyIssues.length > 0) {
416
+ if (actualOptions.fix) {
417
+ const uniqueIssues = [...new Set(sourceConsistencyIssues)];
418
+ appliedFixes.push(...uniqueIssues.map((issue) => `Will regenerate source manifest and metadata to fix: ${issue}`));
419
+ }
420
+ else {
421
+ errors.push(...sourceConsistencyIssues);
422
+ }
423
+ }
424
+ else if (manifestReadError) {
425
+ appliedFixes.push('Will regenerate source manifest and metadata after repairing source manifest read failure.');
426
+ }
427
+ else if (shouldSyncSources) {
428
+ appliedFixes.push('Will create missing source manifest from metadata and .gitmodules state.');
429
+ }
430
+ if (shouldSyncSources && actualOptions.fix) {
431
+ await repairSourceManifestFromDiscoveredState(target, sourceRepos, odooVersion, gitmoduleSources);
432
+ appliedFixes.push('Synced source manifest and metadata with current metadata/.gitmodules state.');
433
+ }
434
+ if (env) {
435
+ const httpPort = validatePort('HTTP_PORT', env, errors);
436
+ const geventPort = validatePort('GEVENT_PORT', env, errors);
437
+ if (httpPort && geventPort && httpPort === geventPort) {
438
+ errors.push('HTTP_PORT and GEVENT_PORT in .env must not be equal');
439
+ }
440
+ if (/^\d+$/.test(httpPort) && /^\d+$/.test(geventPort) && httpPort !== geventPort) {
441
+ checks.push(`OK .env ports HTTP_PORT=${httpPort} GEVENT_PORT=${geventPort}`);
442
+ }
443
+ }
444
+ try {
445
+ await actualRunner('docker', ['version'], { cwd: target });
446
+ checks.push('OK docker CLI');
447
+ }
448
+ catch (error) {
449
+ errors.push(`Docker CLI check failed: ${errorMessage(error)}`);
450
+ }
451
+ try {
452
+ await actualRunner('docker', ['compose', 'version'], { cwd: target });
453
+ checks.push('OK docker compose');
454
+ }
455
+ catch (error) {
456
+ errors.push(`Docker Compose check failed: ${errorMessage(error)}`);
457
+ }
458
+ if (sourceRepos.length > 0) {
459
+ try {
460
+ const result = await actualRunner('git', ['submodule', 'status', '--recursive'], { cwd: target });
461
+ const submoduleErrors = sourceSubmoduleStatusErrors(result.stdout, sourceRepos);
462
+ errors.push(...submoduleErrors);
463
+ if (submoduleErrors.length === 0) {
464
+ checks.push(`OK git submodules ${sourceRepos.length} checked`);
465
+ }
466
+ }
467
+ catch (error) {
468
+ if (isNotGitCheckoutError(error)) {
469
+ checks.push('OK git submodules skipped (not a git checkout)');
470
+ }
471
+ else {
472
+ errors.push(`Git submodule status check failed: ${errorMessage(error)}`);
473
+ }
474
+ }
475
+ }
476
+ try {
477
+ await actualRunner('gh', ['auth', 'status'], { cwd: target });
478
+ checks.push('OK GitHub CLI auth');
479
+ }
480
+ catch (error) {
481
+ warnings.push(`GitHub CLI auth: ${errorMessage(error)}`);
482
+ }
483
+ report.ok = errors.length === 0;
484
+ return report;
485
+ }
486
+ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCommandRunner, options = {}) {
487
+ const report = await getDoctorReport(target, runnerOrOptions, options);
488
+ const actualRunner = isDoctorOptions(runnerOrOptions) ? realCommandRunner : runnerOrOptions;
489
+ const actualOptions = isDoctorOptions(runnerOrOptions) ? runnerOrOptions : options;
490
+ if (!report.ok) {
491
+ if (report.errors.some(isMetadataError)) {
492
+ throw new Error(report.errors[0]);
493
+ }
494
+ if (actualOptions.fix && report.appliedFixes.length > 0) {
495
+ return [
496
+ 'Doctor auto-fixes were not enough to satisfy all checks.',
497
+ ...report.appliedFixes.map((fix) => `- ${fix}`),
498
+ renderFailure(report.errors),
499
+ ].join('\n');
500
+ }
501
+ throw new Error(renderFailure(report.errors));
502
+ }
503
+ const renderedReport = [
504
+ 'WPMoo doctor',
505
+ ...report.checks,
506
+ ...report.warnings.map((warning) => `WARN ${warning}`),
507
+ 'Doctor checks passed.',
508
+ ];
509
+ if (report.appliedFixes.length > 0) {
510
+ const postFixReport = await runDoctor(target, actualRunner, { ...actualOptions, fix: false });
511
+ return [
512
+ 'Applied safe doctor fixes:',
513
+ ...report.appliedFixes.map((fix) => `- ${fix}`),
514
+ '',
515
+ postFixReport,
516
+ ].join('\n');
517
+ }
518
+ return renderedReport.join('\n');
519
+ }
@@ -0,0 +1,10 @@
1
+ import { readEnvironmentMetadata } from './environment.js';
2
+ import { inferGitHubOwner } from './repo-url.js';
3
+ export async function environmentGitHubOwner(target) {
4
+ const metadata = await readEnvironmentMetadata(target);
5
+ return inferGitHubOwner(metadata?.devRepoUrl ?? '');
6
+ }
7
+ export async function environmentProduct(target) {
8
+ const metadata = await readEnvironmentMetadata(target);
9
+ return metadata?.product;
10
+ }
@@ -0,0 +1,5 @@
1
+ import { environmentOdooVersion } from './environment.js';
2
+ export async function commandOdooVersion(target, explicitVersion) {
3
+ const normalizedVersion = explicitVersion?.trim();
4
+ return normalizedVersion || environmentOdooVersion(target);
5
+ }
@@ -0,0 +1,136 @@
1
+ import { access, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { packageName, packageVersion } from './version.js';
4
+ const validSourceTypes = ['private', 'oca', 'external'];
5
+ export const markerPath = '.wpmoo/odoo.json';
6
+ export const defaultOdooVersion = '19.0';
7
+ async function exists(path) {
8
+ try {
9
+ await access(path);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ export function environmentMetadata(options) {
17
+ return {
18
+ tool: packageName(),
19
+ version: packageVersion(),
20
+ product: options.product,
21
+ odooVersion: options.odooVersion,
22
+ devRepo: options.devRepo,
23
+ devRepoUrl: options.devRepoUrl,
24
+ sourceRepos: options.sourceRepos,
25
+ engine: options.engine ?? 'compose',
26
+ composeTemplateUrl: options.composeTemplateUrl,
27
+ composeTemplateRef: options.composeTemplateRef,
28
+ agentSkillsTemplateUrl: options.agentSkillsTemplateUrl,
29
+ agentSkillsTemplateRef: options.agentSkillsTemplateRef,
30
+ postgresVersion: options.postgresVersion,
31
+ httpPort: options.httpPort,
32
+ geventPort: options.geventPort,
33
+ };
34
+ }
35
+ export function renderEnvironmentMetadata(options) {
36
+ return `${JSON.stringify(environmentMetadata(options), null, 2)}\n`;
37
+ }
38
+ function normalizeSourceType(sourceType) {
39
+ const normalized = sourceType ?? 'private';
40
+ return validSourceTypes.includes(normalized) ? normalized : 'private';
41
+ }
42
+ function normalizeMetadataSourceRepo(repo) {
43
+ if (!repo || typeof repo !== 'object') {
44
+ return undefined;
45
+ }
46
+ const candidate = repo;
47
+ const path = typeof candidate.path === 'string' ? candidate.path : '';
48
+ const url = typeof candidate.url === 'string' ? candidate.url : '';
49
+ const addons = Array.isArray(candidate.addons) ? candidate.addons.filter((item) => typeof item === 'string') : [];
50
+ const sourceType = normalizeSourceType(typeof candidate.sourceType === 'string' ? candidate.sourceType : undefined);
51
+ if (!path) {
52
+ return undefined;
53
+ }
54
+ return { ...candidate, path, url, addons, sourceType };
55
+ }
56
+ function sourceRepoWithType(repo) {
57
+ return {
58
+ ...repo,
59
+ sourceType: normalizeSourceType(repo.sourceType),
60
+ };
61
+ }
62
+ function withoutPathDuplicates(repos) {
63
+ const byPath = new Map();
64
+ repos.forEach((repo) => {
65
+ const normalized = sourceRepoWithType(repo);
66
+ byPath.set(`${normalized.sourceType}:${normalized.path}`, normalized);
67
+ });
68
+ return Array.from(byPath.values());
69
+ }
70
+ export async function readEnvironmentMetadata(target) {
71
+ try {
72
+ const content = await readFile(join(target, markerPath), 'utf8');
73
+ const metadata = JSON.parse(content);
74
+ if (!metadata?.sourceRepos || !Array.isArray(metadata.sourceRepos)) {
75
+ return metadata;
76
+ }
77
+ metadata.sourceRepos = metadata.sourceRepos
78
+ .map(normalizeMetadataSourceRepo)
79
+ .filter((repo) => Boolean(repo));
80
+ metadata.sourceRepos = withoutPathDuplicates(metadata.sourceRepos);
81
+ return metadata;
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ export async function writeEnvironmentMetadata(target, metadata) {
88
+ const content = `${JSON.stringify({
89
+ ...metadata,
90
+ sourceRepos: metadata.sourceRepos.map(sourceRepoWithType),
91
+ }, null, 2)}\n`;
92
+ await writeFile(join(target, markerPath), content, 'utf8');
93
+ }
94
+ export async function replaceSourceRepos(target, sourceRepos) {
95
+ const metadata = await readEnvironmentMetadata(target);
96
+ if (!metadata)
97
+ return;
98
+ metadata.sourceRepos = withoutPathDuplicates(sourceRepos.map((repo) => sourceRepoWithType(repo)));
99
+ await writeEnvironmentMetadata(target, metadata);
100
+ }
101
+ export async function upsertSourceRepoMetadata(target, sourceRepo) {
102
+ const metadata = await readEnvironmentMetadata(target);
103
+ if (!metadata)
104
+ return;
105
+ const normalizedRepo = sourceRepoWithType(sourceRepo);
106
+ const sources = metadata.sourceRepos.filter((repo) => !(repo.path === normalizedRepo.path && normalizeSourceType(repo.sourceType) === normalizedRepo.sourceType));
107
+ sources.push(normalizedRepo);
108
+ metadata.sourceRepos = withoutPathDuplicates(sources);
109
+ await writeEnvironmentMetadata(target, metadata);
110
+ }
111
+ export async function removeSourceRepoMetadata(target, repoPath, sourceType) {
112
+ const metadata = await readEnvironmentMetadata(target);
113
+ if (!metadata)
114
+ return;
115
+ const normalizedType = normalizeSourceType(sourceType);
116
+ metadata.sourceRepos = metadata.sourceRepos.filter((repo) => !(repo.path === repoPath && normalizeSourceType(repo.sourceType) === normalizedType));
117
+ await writeEnvironmentMetadata(target, metadata);
118
+ }
119
+ export async function detectDevelopmentEnvironment(target) {
120
+ if (await readEnvironmentMetadata(target)) {
121
+ return { isEnvironment: true, source: 'marker' };
122
+ }
123
+ const hasAddonsYaml = await exists(join(target, 'odoo/custom/src/addons.yaml'));
124
+ const hasReposYaml = await exists(join(target, 'odoo/custom/src/repos.yaml'));
125
+ const hasSourceDir = (await exists(join(target, 'odoo/custom/src/private'))) ||
126
+ (await exists(join(target, 'odoo/custom/src/oca'))) ||
127
+ (await exists(join(target, 'odoo/custom/src/external')));
128
+ if (hasAddonsYaml && hasReposYaml && hasSourceDir) {
129
+ return { isEnvironment: true, source: 'layout' };
130
+ }
131
+ return { isEnvironment: false, source: 'none' };
132
+ }
133
+ export async function environmentOdooVersion(target) {
134
+ const metadata = await readEnvironmentMetadata(target);
135
+ return metadata?.odooVersion || defaultOdooVersion;
136
+ }