@wpmoo/odoo 0.8.58 → 0.8.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -6
- package/dist/args.js +1 -0
- package/dist/cli.js +83 -2
- package/dist/doctor.js +156 -5
- package/dist/environment.js +79 -4
- package/dist/help.js +10 -2
- package/dist/repo-actions.js +74 -22
- package/dist/safe-reset.js +88 -21
- package/dist/scaffold.js +12 -3
- package/dist/source-actions.js +42 -0
- package/dist/source-manifest.js +338 -0
- package/dist/templates.js +15 -6
- package/docs/generated-environment-verification.md +49 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ It gives Odoo teams a repeatable environment layout, a guided cockpit for daily
|
|
|
16
16
|
## Why WPMoo Odoo
|
|
17
17
|
|
|
18
18
|
- Create a local Odoo development environment from a dev repository and one or more source repositories.
|
|
19
|
-
- Keep product source repositories under `odoo/custom/src/private` as Git submodules pinned to the selected Odoo branch.
|
|
19
|
+
- Keep product source repositories under `odoo/custom/src/private`, `odoo/custom/src/oca`, or `odoo/custom/src/external` as Git submodules pinned to the selected Odoo branch.
|
|
20
20
|
- Copy Docker Compose resources from the standalone `wpmoo-org/odoo-docker-compose` resource instead of embedding large runtime assets in the TypeScript package.
|
|
21
21
|
- Optionally copy project-local Agent Skills from `wpmoo-org/odoo-skills` into generated environments.
|
|
22
22
|
- Use either a guided terminal cockpit or direct CLI commands for the same lifecycle tasks.
|
|
@@ -194,8 +194,9 @@ odoo_sample_module_dev/
|
|
|
194
194
|
|-- odoo/
|
|
195
195
|
| `-- custom/
|
|
196
196
|
| `-- src/
|
|
197
|
-
|
|
|
198
|
-
|
|
|
197
|
+
| |-- private/
|
|
198
|
+
| |-- oca/
|
|
199
|
+
| `-- external/
|
|
199
200
|
`-- scripts/
|
|
200
201
|
```
|
|
201
202
|
|
|
@@ -242,6 +243,18 @@ npx @wpmoo/odoo add-repo \
|
|
|
242
243
|
--init-empty-repos
|
|
243
244
|
```
|
|
244
245
|
|
|
246
|
+
Pin source repositories to dedicated source directories:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
npx @wpmoo/odoo add-repo \
|
|
250
|
+
--repo-url https://github.com/OCA/sale-workflow.git \
|
|
251
|
+
--source-type oca
|
|
252
|
+
|
|
253
|
+
npx @wpmoo/odoo add-repo \
|
|
254
|
+
--repo-url https://github.com/example-org/odoo_external_tool.git \
|
|
255
|
+
--source-type external
|
|
256
|
+
```
|
|
257
|
+
|
|
245
258
|
GitHub CLI is optional for repository setup. When it is available and authenticated, the interactive flow can:
|
|
246
259
|
|
|
247
260
|
- detect the owner or organization from the current environment;
|
|
@@ -283,6 +296,34 @@ npx @wpmoo/odoo remove-repo --repo odoo_sample_module_reports
|
|
|
283
296
|
|
|
284
297
|
WPMoo refuses to remove a source repo submodule when that submodule has uncommitted changes.
|
|
285
298
|
|
|
299
|
+
Generated environments also keep a deterministic source manifest at
|
|
300
|
+
`odoo/custom/manifests/sources.yaml`. It mirrors source submodules from
|
|
301
|
+
`.wpmoo/odoo.json` and `.gitmodules`, including source type, path, URL, branch,
|
|
302
|
+
and addon boundaries.
|
|
303
|
+
|
|
304
|
+
Inspect configured sources:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
npx @wpmoo/odoo source list
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Regenerate the manifest and metadata from the current metadata/gitmodule state:
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
npx @wpmoo/odoo source sync
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`source add` and `source remove` are direct aliases for the same repository
|
|
317
|
+
operations:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
npx @wpmoo/odoo source add \
|
|
321
|
+
--repo-url https://github.com/OCA/server-tools.git \
|
|
322
|
+
--source-type oca
|
|
323
|
+
|
|
324
|
+
npx @wpmoo/odoo source remove --repo server-tools --source-type oca
|
|
325
|
+
```
|
|
326
|
+
|
|
286
327
|
## Status, Doctor, and Recovery
|
|
287
328
|
|
|
288
329
|
`status` is fast and offline. It reads local metadata and files only:
|
|
@@ -299,7 +340,11 @@ It reports whether the environment is detected, which Odoo version is selected,
|
|
|
299
340
|
npx @wpmoo/odoo doctor
|
|
300
341
|
```
|
|
301
342
|
|
|
302
|
-
It validates metadata, engine support, selected compose files,
|
|
343
|
+
It validates metadata, engine support, selected compose files, source repo paths,
|
|
344
|
+
source manifest consistency, daily scripts, `.env` settings, Docker CLI access,
|
|
345
|
+
Docker Compose access, GitHub CLI authentication when available, and PostgreSQL
|
|
346
|
+
18 compatibility in compose mount targets (for mounts to
|
|
347
|
+
`/var/lib/postgresql/data` or `/var/lib/postgresql/18/docker`).
|
|
303
348
|
|
|
304
349
|
Safe reset refreshes generated environment files without deleting product source code:
|
|
305
350
|
|
|
@@ -309,9 +354,19 @@ npx @wpmoo/odoo reset
|
|
|
309
354
|
|
|
310
355
|
Safe reset updates generated files such as `.wpmoo/odoo.json`, `moo`,
|
|
311
356
|
`.gitignore`, `.env.example`, generated docs, compose assets, and optional
|
|
312
|
-
Agent Skills.
|
|
357
|
+
Agent Skills. Compose overlays like `compose.yaml` and `compose/dev.yaml` are
|
|
358
|
+
also refreshed from the current compose template source.
|
|
359
|
+
|
|
360
|
+
It does not touch source repo folders under
|
|
313
361
|
`odoo/custom/src/private`, module source code, Git history, remotes, or
|
|
314
|
-
branches.
|
|
362
|
+
branches. It also preserves local runtime artifacts and custom source layout
|
|
363
|
+
content:
|
|
364
|
+
|
|
365
|
+
- `.env`, `data`, and `backups`
|
|
366
|
+
- `odoo/custom/src/oca`, `odoo/custom/src/external`, `odoo/custom/patches`,
|
|
367
|
+
`odoo/custom/manifests`, and their existing contents
|
|
368
|
+
|
|
369
|
+
Legacy compose template paths from older scaffolds can remain
|
|
315
370
|
(`docs/assets/`, `test/`, `.github/`) until you remove them manually.
|
|
316
371
|
|
|
317
372
|
Recommended recovery pattern:
|
package/dist/args.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ import { promptRepositoryUrl } from './prompt-repositories.js';
|
|
|
22
22
|
import { inferGitHubOwner, inferRepoPath, normalizeRepositoryUrl } from './repo-url.js';
|
|
23
23
|
import { addModuleRepo, listModuleRepos, removeModuleRepo } from './repo-actions.js';
|
|
24
24
|
import { renderSafeResetPreview, safeResetEnvironment } from './safe-reset.js';
|
|
25
|
+
import { listSources, renderSourceList, syncSources } from './source-actions.js';
|
|
25
26
|
import { checkGitHubRepositories, createGitHubRepositories, manualCreateCommands, repositoryPreflightAvailable, } from './repository-preflight.js';
|
|
26
27
|
import { scaffold } from './scaffold.js';
|
|
27
28
|
import { renderBanner } from './templates.js';
|
|
@@ -79,6 +80,19 @@ function stringOption(values, key) {
|
|
|
79
80
|
const value = values[key];
|
|
80
81
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
81
82
|
}
|
|
83
|
+
function optionalSourceTypeValue(values) {
|
|
84
|
+
const value = stringOption(values, 'sourceType');
|
|
85
|
+
if (value === undefined) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
if (value === 'private' || value === 'oca' || value === 'external') {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`Invalid value for --source-type: ${value}`);
|
|
92
|
+
}
|
|
93
|
+
function sourceTypeValue(values) {
|
|
94
|
+
return optionalSourceTypeValue(values) ?? 'private';
|
|
95
|
+
}
|
|
82
96
|
function booleanOption(values, key, fallback) {
|
|
83
97
|
const value = values[key];
|
|
84
98
|
if (value === undefined)
|
|
@@ -102,6 +116,12 @@ function shellQuote(value) {
|
|
|
102
116
|
return value;
|
|
103
117
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
104
118
|
}
|
|
119
|
+
function renderedSourceRepoPath(target, sourceType, repoPath) {
|
|
120
|
+
if (repoPath) {
|
|
121
|
+
return `${target}/odoo/custom/src/${sourceType}/${repoPath}`;
|
|
122
|
+
}
|
|
123
|
+
return `${target}/odoo/custom/src/${sourceType}`;
|
|
124
|
+
}
|
|
105
125
|
function renderPostCreateGuidance(target, cwd) {
|
|
106
126
|
const relativeTarget = relative(cwd, target) || '.';
|
|
107
127
|
return yellow([
|
|
@@ -309,6 +329,7 @@ async function addRepoOptionsFromArgs(argv) {
|
|
|
309
329
|
target,
|
|
310
330
|
repoUrl: normalizeRepositoryUrl(repoUrl),
|
|
311
331
|
repoPath: stringOption(values, 'repo') ?? stringOption(values, 'sourcePath'),
|
|
332
|
+
sourceType: sourceTypeValue(values),
|
|
312
333
|
odooVersion: await commandOdooVersion(target, stringOption(values, 'odooVersion')),
|
|
313
334
|
initEmptyRepos: booleanOption(values, 'initEmptyRepos', false),
|
|
314
335
|
stage: booleanOption(values, 'stage', true),
|
|
@@ -335,6 +356,7 @@ async function addRepoOptionsFromPrompts(showIntro = true, cancelAction = 'exit'
|
|
|
335
356
|
return {
|
|
336
357
|
target,
|
|
337
358
|
repoUrl,
|
|
359
|
+
sourceType: 'private',
|
|
338
360
|
odooVersion,
|
|
339
361
|
initEmptyRepos: true,
|
|
340
362
|
stage: true,
|
|
@@ -438,6 +460,7 @@ function removeRepoOptionsFromArgs(argv) {
|
|
|
438
460
|
return {
|
|
439
461
|
target: resolve(stringOption(values, 'target') ?? process.cwd()),
|
|
440
462
|
repoPath,
|
|
463
|
+
sourceType: optionalSourceTypeValue(values),
|
|
441
464
|
stage: booleanOption(values, 'stage', true),
|
|
442
465
|
};
|
|
443
466
|
}
|
|
@@ -448,6 +471,60 @@ function resetOptionsFromArgs(argv) {
|
|
|
448
471
|
stage: booleanOption(values, 'stage', true),
|
|
449
472
|
};
|
|
450
473
|
}
|
|
474
|
+
function sourceUsage() {
|
|
475
|
+
return 'Usage: wpmoo source <list|sync|add|remove> [options]';
|
|
476
|
+
}
|
|
477
|
+
function sourceSyncOptionsFromArgs(argv) {
|
|
478
|
+
const { values } = parseArgs(argv);
|
|
479
|
+
return {
|
|
480
|
+
target: resolve(stringOption(values, 'target') ?? process.cwd()),
|
|
481
|
+
stage: booleanOption(values, 'stage', true),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function sourceListTargetFromArgs(argv) {
|
|
485
|
+
const { values } = parseArgs(argv);
|
|
486
|
+
return resolve(stringOption(values, 'target') ?? process.cwd());
|
|
487
|
+
}
|
|
488
|
+
async function runSourceCommand(argv) {
|
|
489
|
+
const [subcommand, ...subcommandArgv] = argv;
|
|
490
|
+
if (!subcommand) {
|
|
491
|
+
throw new Error(sourceUsage());
|
|
492
|
+
}
|
|
493
|
+
if (subcommand === 'list') {
|
|
494
|
+
console.log(renderBanner());
|
|
495
|
+
const target = sourceListTargetFromArgs(subcommandArgv);
|
|
496
|
+
console.log(renderSourceList(await listSources(target)));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (subcommand === 'sync') {
|
|
500
|
+
console.log(renderBanner());
|
|
501
|
+
const options = sourceSyncOptionsFromArgs(subcommandArgv);
|
|
502
|
+
await syncSources(options);
|
|
503
|
+
outro(`Synced source manifest in ${options.target}.`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (subcommand === 'add') {
|
|
507
|
+
const options = await addRepoOptionsFromArgs(subcommandArgv);
|
|
508
|
+
if (!options) {
|
|
509
|
+
throw new Error('Usage: wpmoo source add --repo-url <url> [--source-type private|oca|external]');
|
|
510
|
+
}
|
|
511
|
+
console.log(renderBanner());
|
|
512
|
+
await addModuleRepo(options);
|
|
513
|
+
outro(`Added source repo under ${renderedSourceRepoPath(options.target, options.sourceType ?? 'private', options.repoPath)}.`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (subcommand === 'remove') {
|
|
517
|
+
const options = removeRepoOptionsFromArgs(subcommandArgv);
|
|
518
|
+
if (!options) {
|
|
519
|
+
throw new Error('Usage: wpmoo source remove --repo <name> [--source-type private|oca|external]');
|
|
520
|
+
}
|
|
521
|
+
console.log(renderBanner());
|
|
522
|
+
await removeModuleRepo(options);
|
|
523
|
+
outro(`Removed source repo ${options.repoPath} from ${options.target}.`);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
throw new Error(sourceUsage());
|
|
527
|
+
}
|
|
451
528
|
async function confirmSafeResetFromMenu(options) {
|
|
452
529
|
note(renderSafeResetPreview(options.target, options.stage), 'Safe reset preview');
|
|
453
530
|
const confirmed = await confirm({
|
|
@@ -633,7 +710,7 @@ async function runCockpitCommand(command, cwd) {
|
|
|
633
710
|
const options = await addRepoOptionsFromPrompts(false, 'back');
|
|
634
711
|
await ensureAddRepoGitHubRepository(options, 'back');
|
|
635
712
|
await addModuleRepo(options);
|
|
636
|
-
note(`Added source repo under ${options.target}
|
|
713
|
+
note(`Added source repo under ${renderedSourceRepoPath(options.target, options.sourceType ?? 'private')}.`, 'Done');
|
|
637
714
|
return 'continue';
|
|
638
715
|
}
|
|
639
716
|
if (command.id === 'remove-repo') {
|
|
@@ -724,7 +801,7 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
724
801
|
if (options) {
|
|
725
802
|
console.log(renderBanner());
|
|
726
803
|
await addModuleRepo(options);
|
|
727
|
-
outro(`Added source repo under ${options.target}
|
|
804
|
+
outro(`Added source repo under ${renderedSourceRepoPath(options.target, options.sourceType ?? 'private', options.repoPath)}.`);
|
|
728
805
|
return;
|
|
729
806
|
}
|
|
730
807
|
await showStartup(argv, skipUpdateCheck);
|
|
@@ -748,6 +825,10 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
748
825
|
outro(`Removed source repo ${promptedOptions.repoPath} from ${promptedOptions.target}.`);
|
|
749
826
|
return;
|
|
750
827
|
}
|
|
828
|
+
if (route.command === 'source') {
|
|
829
|
+
await runSourceCommand(route.argv);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
751
832
|
if (route.command === 'add-module') {
|
|
752
833
|
const options = await addModuleOptionsFromArgs(route.argv);
|
|
753
834
|
if (options) {
|
package/dist/doctor.js
CHANGED
|
@@ -3,7 +3,9 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { execa } from 'execa';
|
|
4
4
|
import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
|
|
5
5
|
import { dailyActionScripts } from './daily-actions.js';
|
|
6
|
+
import { defaultPostgresVersion } from './external-templates.js';
|
|
6
7
|
import { defaultOdooVersion, markerPath } from './environment.js';
|
|
8
|
+
import { listGitmoduleSources, readSourceManifest, sourceManifestPath, } from './source-manifest.js';
|
|
7
9
|
const realCommandRunner = async (command, args, options) => {
|
|
8
10
|
const result = await execa(command, args, { cwd: options.cwd });
|
|
9
11
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
@@ -35,19 +37,94 @@ function commandErrorText(error) {
|
|
|
35
37
|
function isRecord(value) {
|
|
36
38
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
37
39
|
}
|
|
40
|
+
const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
|
|
41
|
+
function parsePostgresMajorFromValue(value) {
|
|
42
|
+
if (!value)
|
|
43
|
+
return undefined;
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
if (/^\d{1,3}$/.test(trimmed)) {
|
|
46
|
+
return trimmed;
|
|
47
|
+
}
|
|
48
|
+
const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
|
|
49
|
+
return match?.[1];
|
|
50
|
+
}
|
|
51
|
+
function stripInlineComment(line) {
|
|
52
|
+
const hashIndex = line.indexOf('#');
|
|
53
|
+
if (hashIndex === -1)
|
|
54
|
+
return line;
|
|
55
|
+
return line.slice(0, hashIndex);
|
|
56
|
+
}
|
|
57
|
+
function hasInvalidPostgres18Mount(line, mountTarget) {
|
|
58
|
+
const escaped = mountTarget.replaceAll('.', '\\.').replaceAll('/', '\\/');
|
|
59
|
+
const shortPatterns = [
|
|
60
|
+
new RegExp(`^\\s*-\\s+.+:\\s*['"]?${escaped}['"]?(?:\\s|:|$)`),
|
|
61
|
+
new RegExp(`^\\s*-\\s*['"]?${escaped}['"]?(?:\\s|$)`),
|
|
62
|
+
new RegExp(`^\\s*target:\\s*['"]?${escaped}['"]?(?:\\s|$)`),
|
|
63
|
+
];
|
|
64
|
+
return shortPatterns.some((pattern) => pattern.test(line));
|
|
65
|
+
}
|
|
66
|
+
function invalidPostgres18MountTargetsInCompose(content) {
|
|
67
|
+
const badTargets = new Set();
|
|
68
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
69
|
+
const line = stripInlineComment(rawLine).trim();
|
|
70
|
+
if (!line)
|
|
71
|
+
continue;
|
|
72
|
+
for (const target of incompatiblePostgres18MountTargets) {
|
|
73
|
+
if (hasInvalidPostgres18Mount(line, target)) {
|
|
74
|
+
badTargets.add(target);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [...badTargets];
|
|
79
|
+
}
|
|
80
|
+
function inferPostgresVersion(metadata, odooVersion, env) {
|
|
81
|
+
const envPostgresImage = env?.get('POSTGRES_IMAGE')?.trim();
|
|
82
|
+
const envPostgresMajor = parsePostgresMajorFromValue(envPostgresImage);
|
|
83
|
+
if (envPostgresMajor) {
|
|
84
|
+
return envPostgresMajor;
|
|
85
|
+
}
|
|
86
|
+
const explicitPostgres = parsePostgresMajorFromValue(metadataString(metadata, 'postgresVersion'));
|
|
87
|
+
if (explicitPostgres) {
|
|
88
|
+
return explicitPostgres;
|
|
89
|
+
}
|
|
90
|
+
return defaultPostgresVersion(odooVersion);
|
|
91
|
+
}
|
|
92
|
+
function normalizeSourceType(value) {
|
|
93
|
+
if (value === 'oca' || value === 'external' || value === 'private') {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
return 'private';
|
|
97
|
+
}
|
|
98
|
+
function sourceRepoPath(type, path) {
|
|
99
|
+
return `odoo/custom/src/${type}/${path}`;
|
|
100
|
+
}
|
|
101
|
+
function entryKey(type, path) {
|
|
102
|
+
return `${type}:${path}`;
|
|
103
|
+
}
|
|
38
104
|
function sourceReposFromMetadata(metadata) {
|
|
39
105
|
const sourceRepos = metadata.sourceRepos;
|
|
40
106
|
if (!Array.isArray(sourceRepos))
|
|
41
107
|
return [];
|
|
42
|
-
return sourceRepos
|
|
108
|
+
return sourceRepos
|
|
109
|
+
.map((repo, index) => {
|
|
43
110
|
if (!isRecord(repo) || typeof repo.path !== 'string' || !repo.path.trim()) {
|
|
44
111
|
throw new Error(`Invalid sourceRepos entry in .wpmoo/odoo.json at index ${index}`);
|
|
45
112
|
}
|
|
46
113
|
return {
|
|
47
114
|
url: typeof repo.url === 'string' ? repo.url : '',
|
|
48
115
|
path: repo.path.trim(),
|
|
49
|
-
addons: Array.isArray(repo.addons)
|
|
116
|
+
addons: Array.isArray(repo.addons)
|
|
117
|
+
? repo.addons.filter((addon) => typeof addon === 'string')
|
|
118
|
+
: [],
|
|
119
|
+
sourceType: normalizeSourceType(repo.sourceType),
|
|
50
120
|
};
|
|
121
|
+
})
|
|
122
|
+
.filter((repo) => repo.path)
|
|
123
|
+
.sort((left, right) => {
|
|
124
|
+
const typeOrder = left.sourceType.localeCompare(right.sourceType);
|
|
125
|
+
if (typeOrder !== 0)
|
|
126
|
+
return typeOrder;
|
|
127
|
+
return left.path.localeCompare(right.path);
|
|
51
128
|
});
|
|
52
129
|
}
|
|
53
130
|
async function readMetadata(target) {
|
|
@@ -88,7 +165,7 @@ function isNotGitCheckoutError(error) {
|
|
|
88
165
|
}
|
|
89
166
|
function isSourceRepoSubmodule(path, sourceRepos) {
|
|
90
167
|
return sourceRepos.some((repo) => {
|
|
91
|
-
const sourcePath =
|
|
168
|
+
const sourcePath = sourceRepoPath(repo.sourceType ?? 'private', repo.path);
|
|
92
169
|
return path === sourcePath || path.startsWith(`${sourcePath}/`);
|
|
93
170
|
});
|
|
94
171
|
}
|
|
@@ -112,6 +189,47 @@ function sourceSubmoduleStatusErrors(output, sourceRepos) {
|
|
|
112
189
|
}
|
|
113
190
|
return errors;
|
|
114
191
|
}
|
|
192
|
+
function manifestEntryToKey(entry) {
|
|
193
|
+
return entryKey(entry.type, entry.path);
|
|
194
|
+
}
|
|
195
|
+
function manifestRepoToKey(repo) {
|
|
196
|
+
return entryKey(normalizeSourceType(repo.sourceType), repo.path);
|
|
197
|
+
}
|
|
198
|
+
function formatKeyForPath(key) {
|
|
199
|
+
const [sourceType, ...pathParts] = key.split(':');
|
|
200
|
+
return sourceRepoPath(sourceType, pathParts.join(':'));
|
|
201
|
+
}
|
|
202
|
+
function checkSourceConsistency(sourceRepos, manifestEntries, gitmoduleSources, manifestExists) {
|
|
203
|
+
if (!manifestExists) {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
const errors = [];
|
|
207
|
+
const metadataEntries = new Map();
|
|
208
|
+
for (const repo of sourceRepos) {
|
|
209
|
+
metadataEntries.set(manifestRepoToKey(repo), repo);
|
|
210
|
+
}
|
|
211
|
+
const manifestMap = new Map();
|
|
212
|
+
for (const entry of manifestEntries) {
|
|
213
|
+
manifestMap.set(manifestEntryToKey(entry), entry);
|
|
214
|
+
}
|
|
215
|
+
const gitmoduleSet = new Set(gitmoduleSources.map((source) => manifestEntryToKey({ type: source.type, path: source.path })));
|
|
216
|
+
const sortedMetadataKeys = [...metadataEntries.keys()].sort();
|
|
217
|
+
const sortedManifestKeys = [...manifestMap.keys()].sort();
|
|
218
|
+
for (const key of sortedMetadataKeys) {
|
|
219
|
+
if (!manifestMap.has(key)) {
|
|
220
|
+
errors.push(`Metadata source entry missing in manifest: ${formatKeyForPath(key)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const key of sortedManifestKeys) {
|
|
224
|
+
if (!metadataEntries.has(key)) {
|
|
225
|
+
errors.push(`Manifest source entry missing in metadata: ${formatKeyForPath(key)}`);
|
|
226
|
+
}
|
|
227
|
+
if (!gitmoduleSet.has(key)) {
|
|
228
|
+
errors.push(`Manifest source path missing in .gitmodules: ${formatKeyForPath(key)}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return errors;
|
|
232
|
+
}
|
|
115
233
|
export async function runDoctor(target = process.cwd(), runner = realCommandRunner) {
|
|
116
234
|
const lines = ['WPMoo doctor'];
|
|
117
235
|
const errors = [];
|
|
@@ -142,6 +260,24 @@ export async function runDoctor(target = process.cwd(), runner = realCommandRunn
|
|
|
142
260
|
}
|
|
143
261
|
else {
|
|
144
262
|
lines.push(`OK compose files ${composeLayout.files.join(', ')}`);
|
|
263
|
+
const postgresVersion = inferPostgresVersion(metadata, odooVersion, env);
|
|
264
|
+
if (postgresVersion === '18') {
|
|
265
|
+
for (const file of composeLayout.files) {
|
|
266
|
+
const composePath = join(target, file);
|
|
267
|
+
let content;
|
|
268
|
+
try {
|
|
269
|
+
content = await readFile(composePath, 'utf8');
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
errors.push(`Cannot read compose file for compatibility check: ${file}: ${errorMessage(error)}`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const badMounts = invalidPostgres18MountTargetsInCompose(content);
|
|
276
|
+
for (const badMount of badMounts) {
|
|
277
|
+
errors.push(`PostgreSQL 18 compatibility issue in '${file}': mount target '${badMount}' is invalid; recommend using '/var/lib/postgresql'`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
145
281
|
}
|
|
146
282
|
const scriptNames = Object.values(dailyActionScripts);
|
|
147
283
|
const scriptErrorCount = errors.length;
|
|
@@ -156,12 +292,27 @@ export async function runDoctor(target = process.cwd(), runner = realCommandRunn
|
|
|
156
292
|
}
|
|
157
293
|
const sourceRepos = sourceReposFromMetadata(metadata);
|
|
158
294
|
for (const repo of sourceRepos) {
|
|
159
|
-
const relativePath =
|
|
160
|
-
if (!(await exists(join(target, relativePath)))) {
|
|
295
|
+
const relativePath = sourceRepoPath(normalizeSourceType(repo.sourceType), repo.path);
|
|
296
|
+
if (!(await exists(join(target, relativePath))) && repo.path) {
|
|
161
297
|
errors.push(`Missing source repo path: ${relativePath}`);
|
|
162
298
|
}
|
|
163
299
|
}
|
|
164
300
|
lines.push(`OK source repos ${sourceRepos.length} checked`);
|
|
301
|
+
const manifestPath = join(target, sourceManifestPath);
|
|
302
|
+
const hasManifest = await exists(manifestPath);
|
|
303
|
+
let manifestEntries = [];
|
|
304
|
+
if (hasManifest) {
|
|
305
|
+
try {
|
|
306
|
+
manifestEntries = (await readSourceManifest(target)).sources;
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
errors.push(`Failed to read source manifest ${sourceManifestPath}: ${errorMessage(error)}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const gitmoduleSources = await listGitmoduleSources(target);
|
|
313
|
+
if (errors.length === 0 || manifestEntries.length > 0) {
|
|
314
|
+
errors.push(...checkSourceConsistency(sourceRepos, manifestEntries, gitmoduleSources, hasManifest));
|
|
315
|
+
}
|
|
165
316
|
if (env) {
|
|
166
317
|
const httpPort = validatePort('HTTP_PORT', env, errors);
|
|
167
318
|
const geventPort = validatePort('GEVENT_PORT', env, errors);
|
package/dist/environment.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { access, readFile } from 'node:fs/promises';
|
|
1
|
+
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { packageName, packageVersion } from './version.js';
|
|
4
|
+
const validSourceTypes = ['private', 'oca', 'external'];
|
|
4
5
|
export const markerPath = '.wpmoo/odoo.json';
|
|
5
6
|
export const defaultOdooVersion = '19.0';
|
|
6
7
|
async function exists(path) {
|
|
@@ -34,23 +35,97 @@ export function environmentMetadata(options) {
|
|
|
34
35
|
export function renderEnvironmentMetadata(options) {
|
|
35
36
|
return `${JSON.stringify(environmentMetadata(options), null, 2)}\n`;
|
|
36
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
|
+
}
|
|
37
70
|
export async function readEnvironmentMetadata(target) {
|
|
38
71
|
try {
|
|
39
72
|
const content = await readFile(join(target, markerPath), 'utf8');
|
|
40
|
-
|
|
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;
|
|
41
82
|
}
|
|
42
83
|
catch {
|
|
43
84
|
return undefined;
|
|
44
85
|
}
|
|
45
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
|
+
}
|
|
46
119
|
export async function detectDevelopmentEnvironment(target) {
|
|
47
120
|
if (await readEnvironmentMetadata(target)) {
|
|
48
121
|
return { isEnvironment: true, source: 'marker' };
|
|
49
122
|
}
|
|
50
123
|
const hasAddonsYaml = await exists(join(target, 'odoo/custom/src/addons.yaml'));
|
|
51
124
|
const hasReposYaml = await exists(join(target, 'odoo/custom/src/repos.yaml'));
|
|
52
|
-
const
|
|
53
|
-
|
|
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) {
|
|
54
129
|
return { isEnvironment: true, source: 'layout' };
|
|
55
130
|
}
|
|
56
131
|
return { isEnvironment: false, source: 'none' };
|
package/dist/help.js
CHANGED
|
@@ -7,8 +7,12 @@ Usage:
|
|
|
7
7
|
npx @wpmoo/odoo
|
|
8
8
|
npx @wpmoo/odoo create --product <slug> [--target <path>] --dev-repo-url <url> --source-repo-url <url>
|
|
9
9
|
npx @wpmoo/odoo status
|
|
10
|
-
npx @wpmoo/odoo add-repo --repo-url <url>
|
|
10
|
+
npx @wpmoo/odoo add-repo --repo-url <url> [--source-type private|oca|external]
|
|
11
11
|
npx @wpmoo/odoo remove-repo --repo <name>
|
|
12
|
+
npx @wpmoo/odoo source list
|
|
13
|
+
npx @wpmoo/odoo source sync
|
|
14
|
+
npx @wpmoo/odoo source add --repo-url <url> [--source-type private|oca|external]
|
|
15
|
+
npx @wpmoo/odoo source remove --repo <name> [--source-type private|oca|external]
|
|
12
16
|
npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
|
|
13
17
|
npx @wpmoo/odoo remove-module --repo <source-repo> --module <module-name>
|
|
14
18
|
npx @wpmoo/odoo reset
|
|
@@ -45,6 +49,7 @@ Options:
|
|
|
45
49
|
--http-port <port> Host HTTP port written to .env.example.
|
|
46
50
|
--gevent-port <port> Host gevent/live chat port written to .env.example.
|
|
47
51
|
--repo-url <url> Source repo URL for add-repo.
|
|
52
|
+
--source-type <category> Source repo category for add-repo/remove-repo. One of private, oca, external. Default: private.
|
|
48
53
|
--repo <name> Source repo folder name for repo/module actions.
|
|
49
54
|
--module <name> Odoo module technical name for module actions.
|
|
50
55
|
--delete-files Also delete module files in remove-module. Default: false.
|
|
@@ -90,7 +95,10 @@ Task recipes:
|
|
|
90
95
|
Create local-only environment:
|
|
91
96
|
npx @wpmoo/odoo
|
|
92
97
|
Add source repo:
|
|
93
|
-
npx @wpmoo/odoo add-repo --repo-url <url>
|
|
98
|
+
npx @wpmoo/odoo add-repo --repo-url <url> --source-type oca
|
|
99
|
+
Inspect and sync source manifest:
|
|
100
|
+
npx @wpmoo/odoo source list
|
|
101
|
+
npx @wpmoo/odoo source sync
|
|
94
102
|
Add module:
|
|
95
103
|
npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
|
|
96
104
|
Run tests:
|