@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 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
- | `-- private/
198
- | `-- odoo_sample_module/
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, daily scripts, source repo paths, `.env` ports, Docker CLI access, Docker Compose access, Git submodule state, and GitHub CLI authentication when available.
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. It does not touch source repo folders under
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. Legacy compose template paths from older scaffolds can remain
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
@@ -12,6 +12,7 @@ const commandNames = new Set([
12
12
  'remove-repo',
13
13
  'add-module',
14
14
  'remove-module',
15
+ 'source',
15
16
  'reset',
16
17
  'doctor',
17
18
  ...dailyActionCommands,
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}/odoo/custom/src/private.`, 'Done');
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}/odoo/custom/src/private.`);
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.map((repo, index) => {
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) ? repo.addons.filter((addon) => typeof addon === 'string') : [],
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 = `odoo/custom/src/private/${repo.path}`;
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 = `odoo/custom/src/private/${repo.path}`;
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);
@@ -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
- return JSON.parse(content);
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 hasPrivateDir = await exists(join(target, 'odoo/custom/src/private'));
53
- if (hasAddonsYaml && hasReposYaml && hasPrivateDir) {
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: