@wpmoo/odoo 0.8.59 → 0.8.61
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 +45 -4
- package/dist/args.js +1 -0
- package/dist/cli.js +83 -7
- package/dist/doctor.js +199 -13
- package/dist/environment.js +9 -2
- package/dist/help.js +12 -2
- package/dist/repo-actions.js +11 -0
- package/dist/safe-reset.js +32 -16
- 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 +35 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -139,10 +139,12 @@ npx @wpmoo/odoo --version
|
|
|
139
139
|
|
|
140
140
|
npx @wpmoo/odoo status
|
|
141
141
|
npx @wpmoo/odoo doctor
|
|
142
|
+
npx @wpmoo/odoo doctor --fix
|
|
142
143
|
npx @wpmoo/odoo add-repo --repo-url https://github.com/example-org/odoo_sample_module_reports.git
|
|
143
144
|
npx @wpmoo/odoo remove-repo --repo odoo_sample_module_reports
|
|
144
145
|
npx @wpmoo/odoo add-module --repo odoo_sample_module --module odoo_sample_module_base
|
|
145
146
|
npx @wpmoo/odoo remove-module --repo odoo_sample_module --module odoo_sample_module_base
|
|
147
|
+
npx @wpmoo/odoo reset --dry-run
|
|
146
148
|
npx @wpmoo/odoo reset
|
|
147
149
|
|
|
148
150
|
npx @wpmoo/odoo start
|
|
@@ -296,6 +298,34 @@ npx @wpmoo/odoo remove-repo --repo odoo_sample_module_reports
|
|
|
296
298
|
|
|
297
299
|
WPMoo refuses to remove a source repo submodule when that submodule has uncommitted changes.
|
|
298
300
|
|
|
301
|
+
Generated environments also keep a deterministic source manifest at
|
|
302
|
+
`odoo/custom/manifests/sources.yaml`. It mirrors source submodules from
|
|
303
|
+
`.wpmoo/odoo.json` and `.gitmodules`, including source type, path, URL, branch,
|
|
304
|
+
and addon boundaries.
|
|
305
|
+
|
|
306
|
+
Inspect configured sources:
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
npx @wpmoo/odoo source list
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Regenerate the manifest and metadata from the current metadata/gitmodule state:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
npx @wpmoo/odoo source sync
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`source add` and `source remove` are direct aliases for the same repository
|
|
319
|
+
operations:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
npx @wpmoo/odoo source add \
|
|
323
|
+
--repo-url https://github.com/OCA/server-tools.git \
|
|
324
|
+
--source-type oca
|
|
325
|
+
|
|
326
|
+
npx @wpmoo/odoo source remove --repo server-tools --source-type oca
|
|
327
|
+
```
|
|
328
|
+
|
|
299
329
|
## Status, Doctor, and Recovery
|
|
300
330
|
|
|
301
331
|
`status` is fast and offline. It reads local metadata and files only:
|
|
@@ -313,13 +343,20 @@ npx @wpmoo/odoo doctor
|
|
|
313
343
|
```
|
|
314
344
|
|
|
315
345
|
It validates metadata, engine support, selected compose files, source repo paths,
|
|
316
|
-
daily scripts, `.env` settings, Docker CLI access,
|
|
317
|
-
authentication when available, and PostgreSQL
|
|
318
|
-
targets (for mounts to
|
|
346
|
+
source manifest consistency, daily scripts, `.env` settings, Docker CLI access,
|
|
347
|
+
Docker Compose access, GitHub CLI authentication when available, and PostgreSQL
|
|
348
|
+
18 compatibility in compose mount targets (for mounts to
|
|
349
|
+
`/var/lib/postgresql/data` or `/var/lib/postgresql/18/docker`).
|
|
350
|
+
|
|
351
|
+
Use `doctor --fix` for safe file-level repairs. It can normalize PostgreSQL 18
|
|
352
|
+
mount targets and regenerate `odoo/custom/manifests/sources.yaml` from
|
|
353
|
+
metadata plus `.gitmodules`, then it runs doctor again and reports any remaining
|
|
354
|
+
manual issues.
|
|
319
355
|
|
|
320
356
|
Safe reset refreshes generated environment files without deleting product source code:
|
|
321
357
|
|
|
322
358
|
```bash
|
|
359
|
+
npx @wpmoo/odoo reset --dry-run
|
|
323
360
|
npx @wpmoo/odoo reset
|
|
324
361
|
```
|
|
325
362
|
|
|
@@ -328,6 +365,9 @@ Safe reset updates generated files such as `.wpmoo/odoo.json`, `moo`,
|
|
|
328
365
|
Agent Skills. Compose overlays like `compose.yaml` and `compose/dev.yaml` are
|
|
329
366
|
also refreshed from the current compose template source.
|
|
330
367
|
|
|
368
|
+
Use `reset --dry-run` first when you want a deterministic preview of refreshed
|
|
369
|
+
files and cleanup warnings without writing to the environment.
|
|
370
|
+
|
|
331
371
|
It does not touch source repo folders under
|
|
332
372
|
`odoo/custom/src/private`, module source code, Git history, remotes, or
|
|
333
373
|
branches. It also preserves local runtime artifacts and custom source layout
|
|
@@ -344,8 +384,9 @@ Recommended recovery pattern:
|
|
|
344
384
|
|
|
345
385
|
```bash
|
|
346
386
|
./moo snapshot devel before-reset
|
|
387
|
+
npx @wpmoo/odoo reset --dry-run
|
|
347
388
|
npx @wpmoo/odoo reset
|
|
348
|
-
npx @wpmoo/odoo doctor
|
|
389
|
+
npx @wpmoo/odoo doctor --fix
|
|
349
390
|
./moo restore-snapshot before-reset devel
|
|
350
391
|
```
|
|
351
392
|
|
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';
|
|
@@ -463,13 +464,81 @@ function removeRepoOptionsFromArgs(argv) {
|
|
|
463
464
|
stage: booleanOption(values, 'stage', true),
|
|
464
465
|
};
|
|
465
466
|
}
|
|
466
|
-
function
|
|
467
|
+
function resetCommandOptionsFromArgs(argv) {
|
|
467
468
|
const { values } = parseArgs(argv);
|
|
468
469
|
return {
|
|
469
470
|
target: resolve(stringOption(values, 'target') ?? process.cwd()),
|
|
470
471
|
stage: booleanOption(values, 'stage', true),
|
|
472
|
+
dryRun: booleanOption(values, 'dryRun', false),
|
|
471
473
|
};
|
|
472
474
|
}
|
|
475
|
+
function doctorOptionsFromArgs(argv) {
|
|
476
|
+
if (argv.length === 0) {
|
|
477
|
+
return {};
|
|
478
|
+
}
|
|
479
|
+
const { values } = parseArgs(argv);
|
|
480
|
+
const keys = Object.keys(values);
|
|
481
|
+
if (keys.length !== 1 || !Object.hasOwn(values, 'fix')) {
|
|
482
|
+
throw new Error('Usage: wpmoo doctor');
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
fix: booleanOption(values, 'fix', false),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function sourceUsage() {
|
|
489
|
+
return 'Usage: wpmoo source <list|sync|add|remove> [options]';
|
|
490
|
+
}
|
|
491
|
+
function sourceSyncOptionsFromArgs(argv) {
|
|
492
|
+
const { values } = parseArgs(argv);
|
|
493
|
+
return {
|
|
494
|
+
target: resolve(stringOption(values, 'target') ?? process.cwd()),
|
|
495
|
+
stage: booleanOption(values, 'stage', true),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function sourceListTargetFromArgs(argv) {
|
|
499
|
+
const { values } = parseArgs(argv);
|
|
500
|
+
return resolve(stringOption(values, 'target') ?? process.cwd());
|
|
501
|
+
}
|
|
502
|
+
async function runSourceCommand(argv) {
|
|
503
|
+
const [subcommand, ...subcommandArgv] = argv;
|
|
504
|
+
if (!subcommand) {
|
|
505
|
+
throw new Error(sourceUsage());
|
|
506
|
+
}
|
|
507
|
+
if (subcommand === 'list') {
|
|
508
|
+
console.log(renderBanner());
|
|
509
|
+
const target = sourceListTargetFromArgs(subcommandArgv);
|
|
510
|
+
console.log(renderSourceList(await listSources(target)));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (subcommand === 'sync') {
|
|
514
|
+
console.log(renderBanner());
|
|
515
|
+
const options = sourceSyncOptionsFromArgs(subcommandArgv);
|
|
516
|
+
await syncSources(options);
|
|
517
|
+
outro(`Synced source manifest in ${options.target}.`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (subcommand === 'add') {
|
|
521
|
+
const options = await addRepoOptionsFromArgs(subcommandArgv);
|
|
522
|
+
if (!options) {
|
|
523
|
+
throw new Error('Usage: wpmoo source add --repo-url <url> [--source-type private|oca|external]');
|
|
524
|
+
}
|
|
525
|
+
console.log(renderBanner());
|
|
526
|
+
await addModuleRepo(options);
|
|
527
|
+
outro(`Added source repo under ${renderedSourceRepoPath(options.target, options.sourceType ?? 'private', options.repoPath)}.`);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (subcommand === 'remove') {
|
|
531
|
+
const options = removeRepoOptionsFromArgs(subcommandArgv);
|
|
532
|
+
if (!options) {
|
|
533
|
+
throw new Error('Usage: wpmoo source remove --repo <name> [--source-type private|oca|external]');
|
|
534
|
+
}
|
|
535
|
+
console.log(renderBanner());
|
|
536
|
+
await removeModuleRepo(options);
|
|
537
|
+
outro(`Removed source repo ${options.repoPath} from ${options.target}.`);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
throw new Error(sourceUsage());
|
|
541
|
+
}
|
|
473
542
|
async function confirmSafeResetFromMenu(options) {
|
|
474
543
|
note(renderSafeResetPreview(options.target, options.stage), 'Safe reset preview');
|
|
475
544
|
const confirmed = await confirm({
|
|
@@ -770,6 +839,10 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
770
839
|
outro(`Removed source repo ${promptedOptions.repoPath} from ${promptedOptions.target}.`);
|
|
771
840
|
return;
|
|
772
841
|
}
|
|
842
|
+
if (route.command === 'source') {
|
|
843
|
+
await runSourceCommand(route.argv);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
773
846
|
if (route.command === 'add-module') {
|
|
774
847
|
const options = await addModuleOptionsFromArgs(route.argv);
|
|
775
848
|
if (options) {
|
|
@@ -800,17 +873,20 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
800
873
|
}
|
|
801
874
|
if (route.command === 'reset') {
|
|
802
875
|
console.log(renderBanner());
|
|
803
|
-
const options =
|
|
804
|
-
|
|
876
|
+
const options = resetCommandOptionsFromArgs(route.argv);
|
|
877
|
+
if (options.dryRun) {
|
|
878
|
+
console.log(renderSafeResetPreview(options.target, options.stage));
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const resetOptions = { target: options.target, stage: options.stage };
|
|
882
|
+
await safeResetEnvironment(resetOptions);
|
|
805
883
|
outro(`Safe reset refreshed generated environment files in ${options.target}.`);
|
|
806
884
|
return;
|
|
807
885
|
}
|
|
808
886
|
if (route.command === 'doctor') {
|
|
809
|
-
|
|
810
|
-
throw new Error('Usage: wpmoo doctor');
|
|
811
|
-
}
|
|
887
|
+
const options = doctorOptionsFromArgs(route.argv);
|
|
812
888
|
console.log(renderBanner());
|
|
813
|
-
console.log(await runDoctor(cwd));
|
|
889
|
+
console.log(options.fix === undefined ? await runDoctor(cwd) : await runDoctor(cwd, options));
|
|
814
890
|
return;
|
|
815
891
|
}
|
|
816
892
|
if (route.command === 'status') {
|
package/dist/doctor.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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 { execa } from 'execa';
|
|
4
4
|
import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
|
|
5
5
|
import { dailyActionScripts } from './daily-actions.js';
|
|
6
6
|
import { defaultPostgresVersion } from './external-templates.js';
|
|
7
|
-
import { defaultOdooVersion, markerPath } from './environment.js';
|
|
7
|
+
import { defaultOdooVersion, markerPath, replaceSourceRepos } from './environment.js';
|
|
8
|
+
import { listGitmoduleSources, readSourceManifest, sourceReposFromManifest, sourceManifestPath, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
|
|
8
9
|
const realCommandRunner = async (command, args, options) => {
|
|
9
10
|
const result = await execa(command, args, { cwd: options.cwd });
|
|
10
11
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
@@ -36,6 +37,9 @@ function commandErrorText(error) {
|
|
|
36
37
|
function isRecord(value) {
|
|
37
38
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
38
39
|
}
|
|
40
|
+
function isDoctorOptions(value) {
|
|
41
|
+
return isRecord(value);
|
|
42
|
+
}
|
|
39
43
|
const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
|
|
40
44
|
function parsePostgresMajorFromValue(value) {
|
|
41
45
|
if (!value)
|
|
@@ -62,6 +66,48 @@ function hasInvalidPostgres18Mount(line, mountTarget) {
|
|
|
62
66
|
];
|
|
63
67
|
return shortPatterns.some((pattern) => pattern.test(line));
|
|
64
68
|
}
|
|
69
|
+
function isNonAmbiguousLineForMountFix(line, mountTarget) {
|
|
70
|
+
return hasInvalidPostgres18Mount(line, mountTarget);
|
|
71
|
+
}
|
|
72
|
+
function replaceMountTargetInLine(line, from, to) {
|
|
73
|
+
return line.split(from).join(to);
|
|
74
|
+
}
|
|
75
|
+
function normalizePostgres18MountTargetsInComposeContent(content) {
|
|
76
|
+
const fixedTargets = [];
|
|
77
|
+
const fixed = [];
|
|
78
|
+
const hasTrailingNewline = content.endsWith('\n');
|
|
79
|
+
const comparableContent = hasTrailingNewline ? content.slice(0, -1) : content;
|
|
80
|
+
const lines = comparableContent.split(/\r?\n/);
|
|
81
|
+
const nextLines = [];
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
const commentIndex = line.indexOf('#');
|
|
84
|
+
const comment = commentIndex === -1 ? '' : line.slice(commentIndex);
|
|
85
|
+
const body = commentIndex === -1 ? line : line.slice(0, commentIndex);
|
|
86
|
+
let nextBody = body;
|
|
87
|
+
let lineFixed = false;
|
|
88
|
+
for (const target of incompatiblePostgres18MountTargets) {
|
|
89
|
+
if (!isNonAmbiguousLineForMountFix(body, target))
|
|
90
|
+
continue;
|
|
91
|
+
nextBody = replaceMountTargetInLine(nextBody, target, '/var/lib/postgresql');
|
|
92
|
+
if (!fixedTargets.includes(target)) {
|
|
93
|
+
fixedTargets.push(target);
|
|
94
|
+
}
|
|
95
|
+
lineFixed = true;
|
|
96
|
+
}
|
|
97
|
+
if (lineFixed) {
|
|
98
|
+
fixed.push(line);
|
|
99
|
+
nextLines.push(`${nextBody}${comment}`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
nextLines.push(line);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
content: `${nextLines.join('\n')}${hasTrailingNewline ? '\n' : ''}`,
|
|
107
|
+
fixed,
|
|
108
|
+
fixedTargets,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
65
111
|
function invalidPostgres18MountTargetsInCompose(content) {
|
|
66
112
|
const badTargets = new Set();
|
|
67
113
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
@@ -88,19 +134,42 @@ function inferPostgresVersion(metadata, odooVersion, env) {
|
|
|
88
134
|
}
|
|
89
135
|
return defaultPostgresVersion(odooVersion);
|
|
90
136
|
}
|
|
137
|
+
function normalizeSourceType(value) {
|
|
138
|
+
if (value === 'oca' || value === 'external' || value === 'private') {
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
return 'private';
|
|
142
|
+
}
|
|
143
|
+
function sourceRepoPath(type, path) {
|
|
144
|
+
return `odoo/custom/src/${type}/${path}`;
|
|
145
|
+
}
|
|
146
|
+
function entryKey(type, path) {
|
|
147
|
+
return `${type}:${path}`;
|
|
148
|
+
}
|
|
91
149
|
function sourceReposFromMetadata(metadata) {
|
|
92
150
|
const sourceRepos = metadata.sourceRepos;
|
|
93
151
|
if (!Array.isArray(sourceRepos))
|
|
94
152
|
return [];
|
|
95
|
-
return sourceRepos
|
|
153
|
+
return sourceRepos
|
|
154
|
+
.map((repo, index) => {
|
|
96
155
|
if (!isRecord(repo) || typeof repo.path !== 'string' || !repo.path.trim()) {
|
|
97
156
|
throw new Error(`Invalid sourceRepos entry in .wpmoo/odoo.json at index ${index}`);
|
|
98
157
|
}
|
|
99
158
|
return {
|
|
100
159
|
url: typeof repo.url === 'string' ? repo.url : '',
|
|
101
160
|
path: repo.path.trim(),
|
|
102
|
-
addons: Array.isArray(repo.addons)
|
|
161
|
+
addons: Array.isArray(repo.addons)
|
|
162
|
+
? repo.addons.filter((addon) => typeof addon === 'string')
|
|
163
|
+
: [],
|
|
164
|
+
sourceType: normalizeSourceType(repo.sourceType),
|
|
103
165
|
};
|
|
166
|
+
})
|
|
167
|
+
.filter((repo) => repo.path)
|
|
168
|
+
.sort((left, right) => {
|
|
169
|
+
const typeOrder = left.sourceType.localeCompare(right.sourceType);
|
|
170
|
+
if (typeOrder !== 0)
|
|
171
|
+
return typeOrder;
|
|
172
|
+
return left.path.localeCompare(right.path);
|
|
104
173
|
});
|
|
105
174
|
}
|
|
106
175
|
async function readMetadata(target) {
|
|
@@ -141,7 +210,7 @@ function isNotGitCheckoutError(error) {
|
|
|
141
210
|
}
|
|
142
211
|
function isSourceRepoSubmodule(path, sourceRepos) {
|
|
143
212
|
return sourceRepos.some((repo) => {
|
|
144
|
-
const sourcePath =
|
|
213
|
+
const sourcePath = sourceRepoPath(repo.sourceType ?? 'private', repo.path);
|
|
145
214
|
return path === sourcePath || path.startsWith(`${sourcePath}/`);
|
|
146
215
|
});
|
|
147
216
|
}
|
|
@@ -165,7 +234,56 @@ function sourceSubmoduleStatusErrors(output, sourceRepos) {
|
|
|
165
234
|
}
|
|
166
235
|
return errors;
|
|
167
236
|
}
|
|
168
|
-
|
|
237
|
+
function manifestEntryToKey(entry) {
|
|
238
|
+
return entryKey(entry.type, entry.path);
|
|
239
|
+
}
|
|
240
|
+
function manifestRepoToKey(repo) {
|
|
241
|
+
return entryKey(normalizeSourceType(repo.sourceType), repo.path);
|
|
242
|
+
}
|
|
243
|
+
function formatKeyForPath(key) {
|
|
244
|
+
const [sourceType, ...pathParts] = key.split(':');
|
|
245
|
+
return sourceRepoPath(sourceType, pathParts.join(':'));
|
|
246
|
+
}
|
|
247
|
+
function checkSourceConsistency(sourceRepos, manifestEntries, gitmoduleSources, manifestExists, gitmodulesExists) {
|
|
248
|
+
if (!manifestExists) {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
const errors = [];
|
|
252
|
+
const metadataEntries = new Map();
|
|
253
|
+
for (const repo of sourceRepos) {
|
|
254
|
+
metadataEntries.set(manifestRepoToKey(repo), repo);
|
|
255
|
+
}
|
|
256
|
+
const manifestMap = new Map();
|
|
257
|
+
for (const entry of manifestEntries) {
|
|
258
|
+
manifestMap.set(manifestEntryToKey(entry), entry);
|
|
259
|
+
}
|
|
260
|
+
const gitmoduleSet = new Set(gitmoduleSources.map((source) => manifestEntryToKey({ type: source.type, path: source.path })));
|
|
261
|
+
const sortedMetadataKeys = [...metadataEntries.keys()].sort();
|
|
262
|
+
const sortedManifestKeys = [...manifestMap.keys()].sort();
|
|
263
|
+
for (const key of sortedMetadataKeys) {
|
|
264
|
+
if (!manifestMap.has(key)) {
|
|
265
|
+
errors.push(`Metadata source entry missing in manifest: ${formatKeyForPath(key)}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const key of sortedManifestKeys) {
|
|
269
|
+
if (!metadataEntries.has(key)) {
|
|
270
|
+
errors.push(`Manifest source entry missing in metadata: ${formatKeyForPath(key)}`);
|
|
271
|
+
}
|
|
272
|
+
if (gitmodulesExists && !gitmoduleSet.has(key)) {
|
|
273
|
+
errors.push(`Manifest source path missing in .gitmodules: ${formatKeyForPath(key)}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return errors;
|
|
277
|
+
}
|
|
278
|
+
async function repairSourceManifestFromDiscoveredState(target, sourceRepos, fallbackBranch, gitmoduleSources) {
|
|
279
|
+
const entries = syncManifestFromMetadataAndGitmodules(sourceRepos, fallbackBranch, gitmoduleSources);
|
|
280
|
+
await writeSourceManifest(target, entries);
|
|
281
|
+
await replaceSourceRepos(target, sourceReposFromManifest(entries));
|
|
282
|
+
}
|
|
283
|
+
export async function runDoctor(target = process.cwd(), runnerOrOptions = realCommandRunner, options = {}) {
|
|
284
|
+
const actualRunner = isDoctorOptions(runnerOrOptions) ? realCommandRunner : runnerOrOptions;
|
|
285
|
+
const actualOptions = isDoctorOptions(runnerOrOptions) ? runnerOrOptions : options;
|
|
286
|
+
const appliedFixes = [];
|
|
169
287
|
const lines = ['WPMoo doctor'];
|
|
170
288
|
const errors = [];
|
|
171
289
|
const warnings = [];
|
|
@@ -207,6 +325,16 @@ export async function runDoctor(target = process.cwd(), runner = realCommandRunn
|
|
|
207
325
|
errors.push(`Cannot read compose file for compatibility check: ${file}: ${errorMessage(error)}`);
|
|
208
326
|
continue;
|
|
209
327
|
}
|
|
328
|
+
if (actualOptions.fix) {
|
|
329
|
+
const normalization = normalizePostgres18MountTargetsInComposeContent(content);
|
|
330
|
+
if (normalization.fixed.length > 0) {
|
|
331
|
+
await writeFile(composePath, normalization.content, 'utf8');
|
|
332
|
+
for (const target of normalization.fixedTargets) {
|
|
333
|
+
appliedFixes.push(`Normalized PostgreSQL 18 mount target in '${file}': replaced '${target}' -> '/var/lib/postgresql'`);
|
|
334
|
+
}
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
210
338
|
const badMounts = invalidPostgres18MountTargetsInCompose(content);
|
|
211
339
|
for (const badMount of badMounts) {
|
|
212
340
|
errors.push(`PostgreSQL 18 compatibility issue in '${file}': mount target '${badMount}' is invalid; recommend using '/var/lib/postgresql'`);
|
|
@@ -227,12 +355,53 @@ export async function runDoctor(target = process.cwd(), runner = realCommandRunn
|
|
|
227
355
|
}
|
|
228
356
|
const sourceRepos = sourceReposFromMetadata(metadata);
|
|
229
357
|
for (const repo of sourceRepos) {
|
|
230
|
-
const relativePath =
|
|
231
|
-
if (!(await exists(join(target, relativePath)))) {
|
|
358
|
+
const relativePath = sourceRepoPath(normalizeSourceType(repo.sourceType), repo.path);
|
|
359
|
+
if (!(await exists(join(target, relativePath))) && repo.path) {
|
|
232
360
|
errors.push(`Missing source repo path: ${relativePath}`);
|
|
233
361
|
}
|
|
234
362
|
}
|
|
235
363
|
lines.push(`OK source repos ${sourceRepos.length} checked`);
|
|
364
|
+
const manifestPath = join(target, sourceManifestPath);
|
|
365
|
+
const hasManifest = await exists(manifestPath);
|
|
366
|
+
let manifestEntries = [];
|
|
367
|
+
let manifestReadError;
|
|
368
|
+
if (hasManifest) {
|
|
369
|
+
try {
|
|
370
|
+
manifestEntries = (await readSourceManifest(target)).sources;
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
manifestReadError = `Failed to read source manifest ${sourceManifestPath}: ${errorMessage(error)}`;
|
|
374
|
+
if (!actualOptions.fix) {
|
|
375
|
+
errors.push(manifestReadError);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const gitmoduleSources = await listGitmoduleSources(target);
|
|
380
|
+
const hasGitmodules = await exists(join(target, '.gitmodules'));
|
|
381
|
+
const sourceConsistencyIssues = !manifestReadError
|
|
382
|
+
? checkSourceConsistency(sourceRepos, manifestEntries, gitmoduleSources, hasManifest, hasGitmodules)
|
|
383
|
+
: [];
|
|
384
|
+
const shouldSyncSources = actualOptions.fix &&
|
|
385
|
+
(manifestReadError || sourceConsistencyIssues.length > 0 || (!hasManifest && (sourceRepos.length > 0 || gitmoduleSources.length > 0)));
|
|
386
|
+
if (sourceConsistencyIssues.length > 0) {
|
|
387
|
+
if (actualOptions.fix) {
|
|
388
|
+
const uniqueIssues = [...new Set(sourceConsistencyIssues)];
|
|
389
|
+
appliedFixes.push(...uniqueIssues.map((issue) => `Will regenerate source manifest and metadata to fix: ${issue}`));
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
errors.push(...sourceConsistencyIssues);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else if (manifestReadError) {
|
|
396
|
+
appliedFixes.push('Will regenerate source manifest and metadata after repairing source manifest read failure.');
|
|
397
|
+
}
|
|
398
|
+
else if (shouldSyncSources) {
|
|
399
|
+
appliedFixes.push('Will create missing source manifest from metadata and .gitmodules state.');
|
|
400
|
+
}
|
|
401
|
+
if (shouldSyncSources && actualOptions.fix) {
|
|
402
|
+
await repairSourceManifestFromDiscoveredState(target, sourceRepos, odooVersion, gitmoduleSources);
|
|
403
|
+
appliedFixes.push('Synced source manifest and metadata with current metadata/.gitmodules state.');
|
|
404
|
+
}
|
|
236
405
|
if (env) {
|
|
237
406
|
const httpPort = validatePort('HTTP_PORT', env, errors);
|
|
238
407
|
const geventPort = validatePort('GEVENT_PORT', env, errors);
|
|
@@ -244,14 +413,14 @@ export async function runDoctor(target = process.cwd(), runner = realCommandRunn
|
|
|
244
413
|
}
|
|
245
414
|
}
|
|
246
415
|
try {
|
|
247
|
-
await
|
|
416
|
+
await actualRunner('docker', ['version'], { cwd: target });
|
|
248
417
|
lines.push('OK docker CLI');
|
|
249
418
|
}
|
|
250
419
|
catch (error) {
|
|
251
420
|
errors.push(`Docker CLI check failed: ${errorMessage(error)}`);
|
|
252
421
|
}
|
|
253
422
|
try {
|
|
254
|
-
await
|
|
423
|
+
await actualRunner('docker', ['compose', 'version'], { cwd: target });
|
|
255
424
|
lines.push('OK docker compose');
|
|
256
425
|
}
|
|
257
426
|
catch (error) {
|
|
@@ -259,7 +428,7 @@ export async function runDoctor(target = process.cwd(), runner = realCommandRunn
|
|
|
259
428
|
}
|
|
260
429
|
if (sourceRepos.length > 0) {
|
|
261
430
|
try {
|
|
262
|
-
const result = await
|
|
431
|
+
const result = await actualRunner('git', ['submodule', 'status', '--recursive'], { cwd: target });
|
|
263
432
|
const submoduleErrors = sourceSubmoduleStatusErrors(result.stdout, sourceRepos);
|
|
264
433
|
errors.push(...submoduleErrors);
|
|
265
434
|
if (submoduleErrors.length === 0) {
|
|
@@ -276,16 +445,33 @@ export async function runDoctor(target = process.cwd(), runner = realCommandRunn
|
|
|
276
445
|
}
|
|
277
446
|
}
|
|
278
447
|
try {
|
|
279
|
-
await
|
|
448
|
+
await actualRunner('gh', ['auth', 'status'], { cwd: target });
|
|
280
449
|
lines.push('OK GitHub CLI auth');
|
|
281
450
|
}
|
|
282
451
|
catch (error) {
|
|
283
452
|
warnings.push(`WARN GitHub CLI auth: ${errorMessage(error)}`);
|
|
284
453
|
}
|
|
285
454
|
if (errors.length > 0) {
|
|
455
|
+
if (actualOptions.fix && appliedFixes.length > 0) {
|
|
456
|
+
return [
|
|
457
|
+
'Doctor auto-fixes were not enough to satisfy all checks.',
|
|
458
|
+
...appliedFixes.map((fix) => `- ${fix}`),
|
|
459
|
+
renderFailure(errors),
|
|
460
|
+
].join('\n');
|
|
461
|
+
}
|
|
286
462
|
throw new Error(renderFailure(errors));
|
|
287
463
|
}
|
|
288
464
|
lines.push(...warnings);
|
|
289
465
|
lines.push('Doctor checks passed.');
|
|
290
|
-
|
|
466
|
+
const report = lines.join('\n');
|
|
467
|
+
if (actualOptions.fix && appliedFixes.length > 0) {
|
|
468
|
+
const postFixReport = await runDoctor(target, actualRunner, { ...actualOptions, fix: false });
|
|
469
|
+
return [
|
|
470
|
+
'Applied safe doctor fixes:',
|
|
471
|
+
...appliedFixes.map((fix) => `- ${fix}`),
|
|
472
|
+
'',
|
|
473
|
+
postFixReport,
|
|
474
|
+
].join('\n');
|
|
475
|
+
}
|
|
476
|
+
return report;
|
|
291
477
|
}
|
package/dist/environment.js
CHANGED
|
@@ -48,7 +48,7 @@ function normalizeMetadataSourceRepo(repo) {
|
|
|
48
48
|
const url = typeof candidate.url === 'string' ? candidate.url : '';
|
|
49
49
|
const addons = Array.isArray(candidate.addons) ? candidate.addons.filter((item) => typeof item === 'string') : [];
|
|
50
50
|
const sourceType = normalizeSourceType(typeof candidate.sourceType === 'string' ? candidate.sourceType : undefined);
|
|
51
|
-
if (!path
|
|
51
|
+
if (!path) {
|
|
52
52
|
return undefined;
|
|
53
53
|
}
|
|
54
54
|
return { ...candidate, path, url, addons, sourceType };
|
|
@@ -84,13 +84,20 @@ export async function readEnvironmentMetadata(target) {
|
|
|
84
84
|
return undefined;
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
-
async function writeEnvironmentMetadata(target, metadata) {
|
|
87
|
+
export async function writeEnvironmentMetadata(target, metadata) {
|
|
88
88
|
const content = `${JSON.stringify({
|
|
89
89
|
...metadata,
|
|
90
90
|
sourceRepos: metadata.sourceRepos.map(sourceRepoWithType),
|
|
91
91
|
}, null, 2)}\n`;
|
|
92
92
|
await writeFile(join(target, markerPath), content, 'utf8');
|
|
93
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
|
+
}
|
|
94
101
|
export async function upsertSourceRepoMetadata(target, sourceRepo) {
|
|
95
102
|
const metadata = await readEnvironmentMetadata(target);
|
|
96
103
|
if (!metadata)
|
package/dist/help.js
CHANGED
|
@@ -9,10 +9,14 @@ Usage:
|
|
|
9
9
|
npx @wpmoo/odoo status
|
|
10
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
|
-
npx @wpmoo/odoo reset
|
|
15
|
-
npx @wpmoo/odoo doctor
|
|
18
|
+
npx @wpmoo/odoo reset [--dry-run]
|
|
19
|
+
npx @wpmoo/odoo doctor [--fix]
|
|
16
20
|
npx @wpmoo/odoo start
|
|
17
21
|
npx @wpmoo/odoo stop
|
|
18
22
|
npx @wpmoo/odoo logs [service]
|
|
@@ -83,6 +87,7 @@ Wizard local-only path:
|
|
|
83
87
|
Status and doctor:
|
|
84
88
|
status: fast and offline. Reads local environment metadata and files only.
|
|
85
89
|
doctor: deeper health check. May check Docker CLI access and GitHub workflows.
|
|
90
|
+
doctor --fix: applies safe file-level repairs. Runs doctor again after fixes.
|
|
86
91
|
|
|
87
92
|
Task recipes:
|
|
88
93
|
Create environment:
|
|
@@ -92,17 +97,22 @@ Task recipes:
|
|
|
92
97
|
npx @wpmoo/odoo
|
|
93
98
|
Add source repo:
|
|
94
99
|
npx @wpmoo/odoo add-repo --repo-url <url> --source-type oca
|
|
100
|
+
Inspect and sync source manifest:
|
|
101
|
+
npx @wpmoo/odoo source list
|
|
102
|
+
npx @wpmoo/odoo source sync
|
|
95
103
|
Add module:
|
|
96
104
|
npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
|
|
97
105
|
Run tests:
|
|
98
106
|
npx @wpmoo/odoo test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
|
|
99
107
|
Safe reset and recover:
|
|
100
108
|
npx @wpmoo/odoo snapshot [db] [snapshot-name]
|
|
109
|
+
npx @wpmoo/odoo reset --dry-run
|
|
101
110
|
npx @wpmoo/odoo reset
|
|
102
111
|
npx @wpmoo/odoo restore-snapshot <snapshot-name> [db]
|
|
103
112
|
Daily command checks:
|
|
104
113
|
npx @wpmoo/odoo status
|
|
105
114
|
npx @wpmoo/odoo doctor
|
|
115
|
+
npx @wpmoo/odoo doctor --fix
|
|
106
116
|
npx @wpmoo/odoo logs [service]
|
|
107
117
|
npx @wpmoo/odoo restart
|
|
108
118
|
|
package/dist/repo-actions.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readEnvironmentMetadata, removeSourceRepoMetadata, upsertSourceRepoMeta
|
|
|
5
5
|
import { ensureRemoteHasBranch, ensureSubmodule, hasUncommittedChanges, realGit, removeSubmodule, stageAll, } from './git.js';
|
|
6
6
|
import { isValidPathSegment, validateRepoPath } from './path-validation.js';
|
|
7
7
|
import { inferRepoPath } from './repo-url.js';
|
|
8
|
+
import { removeSourceManifestEntry, upsertSourceManifestEntry } from './source-manifest.js';
|
|
8
9
|
export const addonsYamlHeader = `# Addons activated from source submodules.
|
|
9
10
|
#
|
|
10
11
|
# Source repos are managed as Git submodules under odoo/custom/src/private (product code).
|
|
@@ -106,6 +107,13 @@ export async function addModuleRepo(options, git = realGit) {
|
|
|
106
107
|
addons: [repoPath],
|
|
107
108
|
sourceType,
|
|
108
109
|
});
|
|
110
|
+
await upsertSourceManifestEntry(options.target, {
|
|
111
|
+
type: sourceType,
|
|
112
|
+
path: repoPath,
|
|
113
|
+
url: options.repoUrl,
|
|
114
|
+
branch: options.odooVersion,
|
|
115
|
+
addons: [repoPath],
|
|
116
|
+
});
|
|
109
117
|
if (!(await isComposeEnvironment(options.target))) {
|
|
110
118
|
const addonsYaml = await readAddonsYaml(options.target);
|
|
111
119
|
if (sourceType === 'private') {
|
|
@@ -134,6 +142,9 @@ export async function removeModuleRepo(options, git = realGit) {
|
|
|
134
142
|
}
|
|
135
143
|
await removeSubmodule(git, options.target, submodulePath);
|
|
136
144
|
await removeSourceRepoMetadata(options.target, repoPath, resolvedSourceType);
|
|
145
|
+
if (resolvedSourceType) {
|
|
146
|
+
await removeSourceManifestEntry(options.target, resolvedSourceType, repoPath);
|
|
147
|
+
}
|
|
137
148
|
if (!(await isComposeEnvironment(options.target))) {
|
|
138
149
|
const addonsYaml = await readAddonsYaml(options.target);
|
|
139
150
|
if (resolvedSourceType === 'private') {
|
package/dist/safe-reset.js
CHANGED
|
@@ -5,8 +5,9 @@ import { applyExternalAsset, writeTextFile } from './external-assets.js';
|
|
|
5
5
|
import { plannedExternalAssetOptions, renderComposeEnvExample } from './external-templates.js';
|
|
6
6
|
import { realGit, stageAll } from './git.js';
|
|
7
7
|
import { isValidPathSegment, validateAddonName, validateRepoPath } from './path-validation.js';
|
|
8
|
-
import {
|
|
8
|
+
import { readAddonsYaml } from './repo-actions.js';
|
|
9
9
|
import { generatedFiles } from './scaffold.js';
|
|
10
|
+
import { listGitmoduleSources } from './source-manifest.js';
|
|
10
11
|
const safeResetProtectedPaths = [
|
|
11
12
|
'data',
|
|
12
13
|
'backups',
|
|
@@ -109,18 +110,18 @@ function parseRepoPathsFromAddonsYaml(addonsYaml) {
|
|
|
109
110
|
.filter((repoPath) => repoPath && isValidPathSegment(repoPath))
|
|
110
111
|
.map(validateRepoPath);
|
|
111
112
|
}
|
|
112
|
-
async function readSubmoduleUrl(target, repoPath) {
|
|
113
|
+
async function readSubmoduleUrl(target, repoPath, sourceType) {
|
|
113
114
|
const safeRepoPath = validateRepoPath(repoPath);
|
|
114
115
|
try {
|
|
115
116
|
const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
|
|
116
|
-
const escapedPath = `odoo/custom/src
|
|
117
|
+
const escapedPath = `odoo/custom/src/${sourceType}/${safeRepoPath}`;
|
|
117
118
|
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
118
119
|
const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
|
|
119
120
|
const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
120
|
-
return url || `odoo/custom/src
|
|
121
|
+
return url || `odoo/custom/src/${sourceType}/${safeRepoPath}`;
|
|
121
122
|
}
|
|
122
123
|
catch {
|
|
123
|
-
return `odoo/custom/src
|
|
124
|
+
return `odoo/custom/src/${sourceType}/${safeRepoPath}`;
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
async function pathExists(path) {
|
|
@@ -135,18 +136,33 @@ async function pathExists(path) {
|
|
|
135
136
|
async function inferOptions(target) {
|
|
136
137
|
const metadata = await readEnvironmentMetadata(target);
|
|
137
138
|
const addonsYaml = await readAddonsYaml(target);
|
|
138
|
-
const
|
|
139
|
+
const gitmoduleSources = await listGitmoduleSources(target);
|
|
139
140
|
const addonRepos = parseRepoPathsFromAddonsYaml(addonsYaml);
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
141
|
+
const sourceByKey = new Map();
|
|
142
|
+
for (const repo of metadata?.sourceRepos ?? []) {
|
|
143
|
+
if (isValidPathSegment(repo.path)) {
|
|
144
|
+
const sourceType = repo.sourceType ?? 'private';
|
|
145
|
+
const path = validateRepoPath(repo.path);
|
|
146
|
+
sourceByKey.set(`${sourceType}:${path}`, { sourceType, path });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const repo of gitmoduleSources) {
|
|
150
|
+
sourceByKey.set(`${repo.type}:${repo.path}`, { sourceType: repo.type, path: repo.path });
|
|
151
|
+
}
|
|
152
|
+
for (const repoPath of addonRepos) {
|
|
153
|
+
sourceByKey.set(`private:${repoPath}`, { sourceType: 'private', path: repoPath });
|
|
154
|
+
}
|
|
155
|
+
const sourceLocations = [...sourceByKey.values()];
|
|
156
|
+
const product = metadata?.product ?? sourceLocations[0]?.path ?? titleFromTarget(target);
|
|
157
|
+
const sourceRepos = await Promise.all(sourceLocations.map(async ({ sourceType, path }) => ({
|
|
158
|
+
path,
|
|
159
|
+
sourceType,
|
|
160
|
+
url: metadata?.sourceRepos
|
|
161
|
+
.find((repo) => repo.path === path && (repo.sourceType ?? 'private') === sourceType)
|
|
162
|
+
?.url.trim() ||
|
|
163
|
+
gitmoduleSources.find((repo) => repo.path === path && repo.type === sourceType)?.url ||
|
|
164
|
+
(await readSubmoduleUrl(target, path, sourceType)),
|
|
165
|
+
addons: parseAddonsForRepo(addonsYaml, path),
|
|
150
166
|
})));
|
|
151
167
|
return {
|
|
152
168
|
product,
|
package/dist/scaffold.js
CHANGED
|
@@ -6,14 +6,19 @@ import { plannedExternalAssetOptions, renderComposeEnvExample } from './external
|
|
|
6
6
|
import { cloneRepository, ensureSubmodule, ensureRemoteHasBranch, realGit, stageAll, syncSubmodules, } from './git.js';
|
|
7
7
|
import { renderAgents, renderAppstoreRelease, renderGitignore, renderMooDelegationScript, renderPlaceholder, renderReadme, } from './templates.js';
|
|
8
8
|
import { validateAddonName, validateRepoPath } from './path-validation.js';
|
|
9
|
+
import { renderSourceManifest, sourceManifestEntriesFromMetadata } from './source-manifest.js';
|
|
9
10
|
function validateSourceRepo(repo) {
|
|
10
11
|
const path = validateRepoPath(repo.path);
|
|
11
12
|
return {
|
|
12
13
|
...repo,
|
|
13
14
|
path,
|
|
15
|
+
sourceType: repo.sourceType ?? 'private',
|
|
14
16
|
addons: repo.addons.map(validateAddonName),
|
|
15
17
|
};
|
|
16
18
|
}
|
|
19
|
+
function sourceRepoSubmodulePath(repo) {
|
|
20
|
+
return `odoo/custom/src/${repo.sourceType ?? 'private'}/${repo.path}`;
|
|
21
|
+
}
|
|
17
22
|
function validateScaffoldOptions(options) {
|
|
18
23
|
return {
|
|
19
24
|
...options,
|
|
@@ -56,6 +61,10 @@ export function generatedFiles(options) {
|
|
|
56
61
|
{ path: 'README.md', content: renderReadme(safeOptions) },
|
|
57
62
|
{ path: 'AGENTS.md', content: renderAgents(safeOptions) },
|
|
58
63
|
{ path: 'docs/appstore-release.md', content: renderAppstoreRelease(safeOptions) },
|
|
64
|
+
{
|
|
65
|
+
path: 'odoo/custom/manifests/sources.yaml',
|
|
66
|
+
content: renderSourceManifest(sourceManifestEntriesFromMetadata(safeOptions.sourceRepos, safeOptions.odooVersion)),
|
|
67
|
+
},
|
|
59
68
|
];
|
|
60
69
|
return [
|
|
61
70
|
...files,
|
|
@@ -113,7 +122,7 @@ export async function scaffold(options, git = realGit) {
|
|
|
113
122
|
const externalAssets = plannedExternalAssetOptions(safeOptions);
|
|
114
123
|
const plannedCommands = [
|
|
115
124
|
...externalAssets.map((assetOptions) => renderExternalAssetCommand(assetOptions)),
|
|
116
|
-
...safeOptions.sourceRepos.map((repo) => `git submodule add -b ${safeOptions.odooVersion} ${repo.url}
|
|
125
|
+
...safeOptions.sourceRepos.map((repo) => `git submodule add -b ${safeOptions.odooVersion} ${repo.url} ${sourceRepoSubmodulePath(repo)}`),
|
|
117
126
|
];
|
|
118
127
|
if (safeOptions.stage) {
|
|
119
128
|
plannedCommands.push('git add .');
|
|
@@ -136,9 +145,9 @@ export async function scaffold(options, git = realGit) {
|
|
|
136
145
|
for (const repo of safeOptions.sourceRepos) {
|
|
137
146
|
await ensureRemoteHasBranch(git, safeOptions.target, repo.url, safeOptions.odooVersion, safeOptions.initEmptyRepos);
|
|
138
147
|
}
|
|
139
|
-
await mkdir(join(safeOptions.target, 'odoo/custom/src/private'), { recursive: true });
|
|
140
148
|
for (const repo of safeOptions.sourceRepos) {
|
|
141
|
-
await
|
|
149
|
+
await mkdir(join(safeOptions.target, 'odoo/custom/src', repo.sourceType ?? 'private'), { recursive: true });
|
|
150
|
+
await ensureSubmodule(git, safeOptions.target, repo.url, safeOptions.odooVersion, sourceRepoSubmodulePath(repo));
|
|
142
151
|
}
|
|
143
152
|
await syncSubmodules(git, safeOptions.target);
|
|
144
153
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defaultOdooVersion, readEnvironmentMetadata, replaceSourceRepos } from './environment.js';
|
|
2
|
+
import { realGit, stageAll } from './git.js';
|
|
3
|
+
import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
|
|
4
|
+
export async function listSources(target) {
|
|
5
|
+
const metadata = await readEnvironmentMetadata(target);
|
|
6
|
+
const manifest = await readSourceManifest(target);
|
|
7
|
+
if (manifest.sources.length > 0) {
|
|
8
|
+
return manifest.sources;
|
|
9
|
+
}
|
|
10
|
+
if (metadata?.sourceRepos.length) {
|
|
11
|
+
return sourceManifestEntriesFromMetadata(metadata.sourceRepos, metadata.odooVersion);
|
|
12
|
+
}
|
|
13
|
+
return syncManifestFromMetadataAndGitmodules([], metadata?.odooVersion ?? defaultOdooVersion, await listGitmoduleSources(target));
|
|
14
|
+
}
|
|
15
|
+
export function renderSourceList(entries) {
|
|
16
|
+
if (entries.length === 0) {
|
|
17
|
+
return 'No source repositories configured.';
|
|
18
|
+
}
|
|
19
|
+
return entries
|
|
20
|
+
.map((entry) => {
|
|
21
|
+
const branch = entry.branch ? ` @ ${entry.branch}` : '';
|
|
22
|
+
const addons = entry.addons.length ? ` addons: ${entry.addons.join(', ')}` : '';
|
|
23
|
+
return `${entry.type}/${entry.path}${branch} -> ${entry.url}${addons}`;
|
|
24
|
+
})
|
|
25
|
+
.join('\n');
|
|
26
|
+
}
|
|
27
|
+
export async function syncSources(options, git = realGit) {
|
|
28
|
+
const metadata = await readEnvironmentMetadata(options.target);
|
|
29
|
+
const manifest = await readSourceManifest(options.target);
|
|
30
|
+
const gitmodules = await listGitmoduleSources(options.target);
|
|
31
|
+
const fallbackBranch = metadata?.odooVersion ?? defaultOdooVersion;
|
|
32
|
+
const baseRepos = metadata?.sourceRepos.length ? metadata.sourceRepos : sourceReposFromManifest(manifest.sources);
|
|
33
|
+
const entries = syncManifestFromMetadataAndGitmodules(baseRepos, fallbackBranch, gitmodules);
|
|
34
|
+
await writeSourceManifest(options.target, entries);
|
|
35
|
+
if (metadata) {
|
|
36
|
+
await replaceSourceRepos(options.target, sourceReposFromManifest(entries));
|
|
37
|
+
}
|
|
38
|
+
if (options.stage) {
|
|
39
|
+
await stageAll(git, options.target);
|
|
40
|
+
}
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
@@ -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/templates.js
CHANGED
|
@@ -20,6 +20,12 @@ function allAddons(options) {
|
|
|
20
20
|
function hasSourceRepos(options) {
|
|
21
21
|
return options.sourceRepos.length > 0;
|
|
22
22
|
}
|
|
23
|
+
function sourceTypeOf(repo) {
|
|
24
|
+
return repo.sourceType ?? 'private';
|
|
25
|
+
}
|
|
26
|
+
function sourceRepoRelativePath(repo) {
|
|
27
|
+
return `odoo/custom/src/${sourceTypeOf(repo)}/${repo.path}`;
|
|
28
|
+
}
|
|
23
29
|
function repositoryLayout(options) {
|
|
24
30
|
const sourceRepoRows = hasSourceRepos(options)
|
|
25
31
|
? options.sourceRepos
|
|
@@ -91,11 +97,14 @@ ${repo.url}
|
|
|
91
97
|
Submodule path:
|
|
92
98
|
|
|
93
99
|
\`\`\`text
|
|
94
|
-
|
|
100
|
+
${sourceRepoRelativePath(repo)}
|
|
95
101
|
\`\`\`
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
Source manifest entry:
|
|
104
|
+
|
|
105
|
+
\`\`\`text
|
|
106
|
+
odoo/custom/manifests/sources.yaml
|
|
107
|
+
\`\`\`
|
|
99
108
|
|
|
100
109
|
Expected addon layout:
|
|
101
110
|
|
|
@@ -542,7 +551,7 @@ export function renderAddonsYaml(options) {
|
|
|
542
551
|
# Source repos are managed as Git submodules under odoo/custom/src/private.
|
|
543
552
|
# Do not duplicate these same repos in repos.yaml.
|
|
544
553
|
|
|
545
|
-
${options.sourceRepos.map((repo) =>
|
|
554
|
+
${options.sourceRepos.map((repo) => `${sourceTypeOf(repo)}/${repo.path}:\n${yamlList(repo.addons)}`).join('\n\n')}
|
|
546
555
|
`;
|
|
547
556
|
}
|
|
548
557
|
export function renderReposYaml(options) {
|
|
@@ -551,7 +560,7 @@ export function renderReposYaml(options) {
|
|
|
551
560
|
# Project source repositories are intentionally not listed here because
|
|
552
561
|
# they are pinned as Git submodules:
|
|
553
562
|
#
|
|
554
|
-
${options.sourceRepos.map((repo) => `# -
|
|
563
|
+
${options.sourceRepos.map((repo) => `# - ${sourceTypeOf(repo)}/${repo.path}`).join('\n')}
|
|
555
564
|
#
|
|
556
565
|
# Keep this file for upstream/OCA repositories that should be aggregated.
|
|
557
566
|
|
|
@@ -628,7 +637,7 @@ export function renderAgents(options) {
|
|
|
628
637
|
source directory below for this environment:
|
|
629
638
|
|
|
630
639
|
\`\`\`text
|
|
631
|
-
${options.sourceRepos.map(
|
|
640
|
+
${options.sourceRepos.map(sourceRepoRelativePath).join('\n')}
|
|
632
641
|
\`\`\`
|
|
633
642
|
|
|
634
643
|
${repoDuplicationNote()}`
|
|
@@ -21,10 +21,12 @@ not validate staging or production deployments.
|
|
|
21
21
|
| Compose resource files | Compact compose layout is present (`compose.yaml` + environment overlays under `compose/`), plus config/resources/scripts. | `npx @wpmoo/odoo create ...` |
|
|
22
22
|
| `./moo` delegation | `./moo` dispatches fixed daily actions to the matching script and preserves argument pass-through. | `./moo <action> ...` |
|
|
23
23
|
| Doctor checks | Metadata, compose files, scripts, source repo paths, and local tooling checks behave as expected. | `npx @wpmoo/odoo doctor` or `./moo doctor` |
|
|
24
|
-
|
|
|
24
|
+
| Doctor safe fixes | Safe file-level fixes are applied only with `--fix`, then doctor runs again and reports any remaining manual issues. | `npx @wpmoo/odoo doctor --fix` |
|
|
25
|
+
| Generated Postgres checks | For PostgreSQL 18 environments, doctor validates db mount targets avoid old PG image-specific paths and can normalize safe targets with `--fix`. | `npx @wpmoo/odoo doctor`, `npx @wpmoo/odoo doctor --fix` |
|
|
25
26
|
| Source repo add/remove | Source repository registration and submodule lifecycle behave correctly. | `npx @wpmoo/odoo add-repo ...`, `npx @wpmoo/odoo remove-repo ...` |
|
|
27
|
+
| Source manifest sync | Source repo metadata, `.gitmodules`, and `odoo/custom/manifests/sources.yaml` stay aligned. | `npx @wpmoo/odoo source list`, `npx @wpmoo/odoo source sync` |
|
|
26
28
|
| Module add/remove | Module registration changes are applied to the selected source repo config. | `npx @wpmoo/odoo add-module ...`, `npx @wpmoo/odoo remove-module ...` |
|
|
27
|
-
| Safe reset | Generated files are refreshed (including `compose.yaml` overlays and env example) without deleting source module code. Local runtime/data directories and custom source layout content are preserved; legacy user-editable paths from older templates may remain and are reported for manual cleanup. | `npx @wpmoo/odoo reset` |
|
|
29
|
+
| Safe reset | Generated files are refreshed (including `compose.yaml` overlays and env example) without deleting source module code. Local runtime/data directories and custom source layout content are preserved; legacy user-editable paths from older templates may remain and are reported for manual cleanup. | `npx @wpmoo/odoo reset --dry-run`, `npx @wpmoo/odoo reset` |
|
|
28
30
|
| Snapshot/restore and lint/pot | These actions are delegated by `./moo` to compose scripts without extra package-side logic. | `./moo snapshot ...`, `./moo restore-snapshot ...`, `./moo lint`, `./moo pot ...` |
|
|
29
31
|
|
|
30
32
|
## Compact compose checks
|
|
@@ -55,6 +57,10 @@ volume and tmpfs mount targets use `/var/lib/postgresql` directly:
|
|
|
55
57
|
Paths such as `/var/lib/postgresql/data` and `/var/lib/postgresql/18/docker` are
|
|
56
58
|
no longer accepted by the package `doctor` check.
|
|
57
59
|
|
|
60
|
+
`doctor --fix` may rewrite these safe mount targets to `/var/lib/postgresql`.
|
|
61
|
+
It does not upgrade existing database data; if a real PostgreSQL major upgrade
|
|
62
|
+
is involved, use PostgreSQL upgrade tooling first.
|
|
63
|
+
|
|
58
64
|
## Safe reset policy
|
|
59
65
|
|
|
60
66
|
Safe reset intentionally avoids deleting user-editable legacy paths from old
|
|
@@ -79,6 +85,33 @@ odoo/custom/patches/
|
|
|
79
85
|
odoo/custom/manifests/
|
|
80
86
|
```
|
|
81
87
|
|
|
88
|
+
Run `npx @wpmoo/odoo reset --dry-run` before writing changes when you need to
|
|
89
|
+
review the generated file refresh plan.
|
|
90
|
+
|
|
91
|
+
## Source manifest checks
|
|
92
|
+
|
|
93
|
+
Generated environments include `odoo/custom/manifests/sources.yaml`. The manifest
|
|
94
|
+
records each source repository's type (`private`, `oca`, or `external`), path,
|
|
95
|
+
URL, Odoo branch, and addon boundaries.
|
|
96
|
+
|
|
97
|
+
Use `source list` to inspect the current manifest view:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx @wpmoo/odoo source list
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Use `source sync` after manual submodule or metadata repair to regenerate the
|
|
104
|
+
manifest and normalize `.wpmoo/odoo.json` source entries:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npx @wpmoo/odoo source sync
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`doctor` fails when manifest entries, metadata entries, and registered source
|
|
111
|
+
submodule paths diverge. `doctor --fix` can regenerate
|
|
112
|
+
`odoo/custom/manifests/sources.yaml` from metadata plus `.gitmodules` when the
|
|
113
|
+
manifest is missing, unreadable, or stale.
|
|
114
|
+
|
|
82
115
|
## Local verification commands
|
|
83
116
|
|
|
84
117
|
Run from the `wpmoo-odoo` repository root:
|