@wpmoo/odoo 0.8.57 → 0.8.59

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;
@@ -299,7 +312,10 @@ It reports whether the environment is detected, which Odoo version is selected,
299
312
  npx @wpmoo/odoo doctor
300
313
  ```
301
314
 
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.
315
+ It validates metadata, engine support, selected compose files, source repo paths,
316
+ daily scripts, `.env` settings, Docker CLI access, Docker Compose access, GitHub CLI
317
+ authentication when available, and PostgreSQL 18 compatibility in compose mount
318
+ targets (for mounts to `/var/lib/postgresql/data` or `/var/lib/postgresql/18/docker`).
303
319
 
304
320
  Safe reset refreshes generated environment files without deleting product source code:
305
321
 
@@ -309,9 +325,19 @@ npx @wpmoo/odoo reset
309
325
 
310
326
  Safe reset updates generated files such as `.wpmoo/odoo.json`, `moo`,
311
327
  `.gitignore`, `.env.example`, generated docs, compose assets, and optional
312
- Agent Skills. It does not touch source repo folders under
328
+ Agent Skills. Compose overlays like `compose.yaml` and `compose/dev.yaml` are
329
+ also refreshed from the current compose template source.
330
+
331
+ It does not touch source repo folders under
313
332
  `odoo/custom/src/private`, module source code, Git history, remotes, or
314
- branches. Legacy compose template paths from older scaffolds can remain
333
+ branches. It also preserves local runtime artifacts and custom source layout
334
+ content:
335
+
336
+ - `.env`, `data`, and `backups`
337
+ - `odoo/custom/src/oca`, `odoo/custom/src/external`, `odoo/custom/patches`,
338
+ `odoo/custom/manifests`, and their existing contents
339
+
340
+ Legacy compose template paths from older scaffolds can remain
315
341
  (`docs/assets/`, `test/`, `.github/`) until you remove them manually.
316
342
 
317
343
  Recommended recovery pattern:
package/dist/cli.js CHANGED
@@ -79,6 +79,19 @@ function stringOption(values, key) {
79
79
  const value = values[key];
80
80
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
81
81
  }
82
+ function optionalSourceTypeValue(values) {
83
+ const value = stringOption(values, 'sourceType');
84
+ if (value === undefined) {
85
+ return undefined;
86
+ }
87
+ if (value === 'private' || value === 'oca' || value === 'external') {
88
+ return value;
89
+ }
90
+ throw new Error(`Invalid value for --source-type: ${value}`);
91
+ }
92
+ function sourceTypeValue(values) {
93
+ return optionalSourceTypeValue(values) ?? 'private';
94
+ }
82
95
  function booleanOption(values, key, fallback) {
83
96
  const value = values[key];
84
97
  if (value === undefined)
@@ -102,6 +115,12 @@ function shellQuote(value) {
102
115
  return value;
103
116
  return `'${value.replaceAll("'", "'\\''")}'`;
104
117
  }
118
+ function renderedSourceRepoPath(target, sourceType, repoPath) {
119
+ if (repoPath) {
120
+ return `${target}/odoo/custom/src/${sourceType}/${repoPath}`;
121
+ }
122
+ return `${target}/odoo/custom/src/${sourceType}`;
123
+ }
105
124
  function renderPostCreateGuidance(target, cwd) {
106
125
  const relativeTarget = relative(cwd, target) || '.';
107
126
  return yellow([
@@ -309,6 +328,7 @@ async function addRepoOptionsFromArgs(argv) {
309
328
  target,
310
329
  repoUrl: normalizeRepositoryUrl(repoUrl),
311
330
  repoPath: stringOption(values, 'repo') ?? stringOption(values, 'sourcePath'),
331
+ sourceType: sourceTypeValue(values),
312
332
  odooVersion: await commandOdooVersion(target, stringOption(values, 'odooVersion')),
313
333
  initEmptyRepos: booleanOption(values, 'initEmptyRepos', false),
314
334
  stage: booleanOption(values, 'stage', true),
@@ -335,6 +355,7 @@ async function addRepoOptionsFromPrompts(showIntro = true, cancelAction = 'exit'
335
355
  return {
336
356
  target,
337
357
  repoUrl,
358
+ sourceType: 'private',
338
359
  odooVersion,
339
360
  initEmptyRepos: true,
340
361
  stage: true,
@@ -438,6 +459,7 @@ function removeRepoOptionsFromArgs(argv) {
438
459
  return {
439
460
  target: resolve(stringOption(values, 'target') ?? process.cwd()),
440
461
  repoPath,
462
+ sourceType: optionalSourceTypeValue(values),
441
463
  stage: booleanOption(values, 'stage', true),
442
464
  };
443
465
  }
@@ -633,7 +655,7 @@ async function runCockpitCommand(command, cwd) {
633
655
  const options = await addRepoOptionsFromPrompts(false, 'back');
634
656
  await ensureAddRepoGitHubRepository(options, 'back');
635
657
  await addModuleRepo(options);
636
- note(`Added source repo under ${options.target}/odoo/custom/src/private.`, 'Done');
658
+ note(`Added source repo under ${renderedSourceRepoPath(options.target, options.sourceType ?? 'private')}.`, 'Done');
637
659
  return 'continue';
638
660
  }
639
661
  if (command.id === 'remove-repo') {
@@ -724,7 +746,7 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
724
746
  if (options) {
725
747
  console.log(renderBanner());
726
748
  await addModuleRepo(options);
727
- outro(`Added source repo under ${options.target}/odoo/custom/src/private.`);
749
+ outro(`Added source repo under ${renderedSourceRepoPath(options.target, options.sourceType ?? 'private', options.repoPath)}.`);
728
750
  return;
729
751
  }
730
752
  await showStartup(argv, skipUpdateCheck);
package/dist/doctor.js CHANGED
@@ -3,6 +3,7 @@ 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';
7
8
  const realCommandRunner = async (command, args, options) => {
8
9
  const result = await execa(command, args, { cwd: options.cwd });
@@ -35,6 +36,58 @@ function commandErrorText(error) {
35
36
  function isRecord(value) {
36
37
  return typeof value === 'object' && value !== null && !Array.isArray(value);
37
38
  }
39
+ const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
40
+ function parsePostgresMajorFromValue(value) {
41
+ if (!value)
42
+ return undefined;
43
+ const trimmed = value.trim();
44
+ if (/^\d{1,3}$/.test(trimmed)) {
45
+ return trimmed;
46
+ }
47
+ const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
48
+ return match?.[1];
49
+ }
50
+ function stripInlineComment(line) {
51
+ const hashIndex = line.indexOf('#');
52
+ if (hashIndex === -1)
53
+ return line;
54
+ return line.slice(0, hashIndex);
55
+ }
56
+ function hasInvalidPostgres18Mount(line, mountTarget) {
57
+ const escaped = mountTarget.replaceAll('.', '\\.').replaceAll('/', '\\/');
58
+ const shortPatterns = [
59
+ new RegExp(`^\\s*-\\s+.+:\\s*['"]?${escaped}['"]?(?:\\s|:|$)`),
60
+ new RegExp(`^\\s*-\\s*['"]?${escaped}['"]?(?:\\s|$)`),
61
+ new RegExp(`^\\s*target:\\s*['"]?${escaped}['"]?(?:\\s|$)`),
62
+ ];
63
+ return shortPatterns.some((pattern) => pattern.test(line));
64
+ }
65
+ function invalidPostgres18MountTargetsInCompose(content) {
66
+ const badTargets = new Set();
67
+ for (const rawLine of content.split(/\r?\n/)) {
68
+ const line = stripInlineComment(rawLine).trim();
69
+ if (!line)
70
+ continue;
71
+ for (const target of incompatiblePostgres18MountTargets) {
72
+ if (hasInvalidPostgres18Mount(line, target)) {
73
+ badTargets.add(target);
74
+ }
75
+ }
76
+ }
77
+ return [...badTargets];
78
+ }
79
+ function inferPostgresVersion(metadata, odooVersion, env) {
80
+ const envPostgresImage = env?.get('POSTGRES_IMAGE')?.trim();
81
+ const envPostgresMajor = parsePostgresMajorFromValue(envPostgresImage);
82
+ if (envPostgresMajor) {
83
+ return envPostgresMajor;
84
+ }
85
+ const explicitPostgres = parsePostgresMajorFromValue(metadataString(metadata, 'postgresVersion'));
86
+ if (explicitPostgres) {
87
+ return explicitPostgres;
88
+ }
89
+ return defaultPostgresVersion(odooVersion);
90
+ }
38
91
  function sourceReposFromMetadata(metadata) {
39
92
  const sourceRepos = metadata.sourceRepos;
40
93
  if (!Array.isArray(sourceRepos))
@@ -142,6 +195,24 @@ export async function runDoctor(target = process.cwd(), runner = realCommandRunn
142
195
  }
143
196
  else {
144
197
  lines.push(`OK compose files ${composeLayout.files.join(', ')}`);
198
+ const postgresVersion = inferPostgresVersion(metadata, odooVersion, env);
199
+ if (postgresVersion === '18') {
200
+ for (const file of composeLayout.files) {
201
+ const composePath = join(target, file);
202
+ let content;
203
+ try {
204
+ content = await readFile(composePath, 'utf8');
205
+ }
206
+ catch (error) {
207
+ errors.push(`Cannot read compose file for compatibility check: ${file}: ${errorMessage(error)}`);
208
+ continue;
209
+ }
210
+ const badMounts = invalidPostgres18MountTargetsInCompose(content);
211
+ for (const badMount of badMounts) {
212
+ errors.push(`PostgreSQL 18 compatibility issue in '${file}': mount target '${badMount}' is invalid; recommend using '/var/lib/postgresql'`);
213
+ }
214
+ }
215
+ }
145
216
  }
146
217
  const scriptNames = Object.values(dailyActionScripts);
147
218
  const scriptErrorCount = errors.length;
@@ -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,90 @@ 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 || !url) {
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
+ 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 upsertSourceRepoMetadata(target, sourceRepo) {
95
+ const metadata = await readEnvironmentMetadata(target);
96
+ if (!metadata)
97
+ return;
98
+ const normalizedRepo = sourceRepoWithType(sourceRepo);
99
+ const sources = metadata.sourceRepos.filter((repo) => !(repo.path === normalizedRepo.path && normalizeSourceType(repo.sourceType) === normalizedRepo.sourceType));
100
+ sources.push(normalizedRepo);
101
+ metadata.sourceRepos = withoutPathDuplicates(sources);
102
+ await writeEnvironmentMetadata(target, metadata);
103
+ }
104
+ export async function removeSourceRepoMetadata(target, repoPath, sourceType) {
105
+ const metadata = await readEnvironmentMetadata(target);
106
+ if (!metadata)
107
+ return;
108
+ const normalizedType = normalizeSourceType(sourceType);
109
+ metadata.sourceRepos = metadata.sourceRepos.filter((repo) => !(repo.path === repoPath && normalizeSourceType(repo.sourceType) === normalizedType));
110
+ await writeEnvironmentMetadata(target, metadata);
111
+ }
46
112
  export async function detectDevelopmentEnvironment(target) {
47
113
  if (await readEnvironmentMetadata(target)) {
48
114
  return { isEnvironment: true, source: 'marker' };
49
115
  }
50
116
  const hasAddonsYaml = await exists(join(target, 'odoo/custom/src/addons.yaml'));
51
117
  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) {
118
+ const hasSourceDir = (await exists(join(target, 'odoo/custom/src/private'))) ||
119
+ (await exists(join(target, 'odoo/custom/src/oca'))) ||
120
+ (await exists(join(target, 'odoo/custom/src/external')));
121
+ if (hasAddonsYaml && hasReposYaml && hasSourceDir) {
54
122
  return { isEnvironment: true, source: 'layout' };
55
123
  }
56
124
  return { isEnvironment: false, source: 'none' };
package/dist/help.js CHANGED
@@ -7,7 +7,7 @@ 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
12
  npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
13
13
  npx @wpmoo/odoo remove-module --repo <source-repo> --module <module-name>
@@ -45,6 +45,7 @@ Options:
45
45
  --http-port <port> Host HTTP port written to .env.example.
46
46
  --gevent-port <port> Host gevent/live chat port written to .env.example.
47
47
  --repo-url <url> Source repo URL for add-repo.
48
+ --source-type <category> Source repo category for add-repo/remove-repo. One of private, oca, external. Default: private.
48
49
  --repo <name> Source repo folder name for repo/module actions.
49
50
  --module <name> Odoo module technical name for module actions.
50
51
  --delete-files Also delete module files in remove-module. Default: false.
@@ -90,7 +91,7 @@ Task recipes:
90
91
  Create local-only environment:
91
92
  npx @wpmoo/odoo
92
93
  Add source repo:
93
- npx @wpmoo/odoo add-repo --repo-url <url>
94
+ npx @wpmoo/odoo add-repo --repo-url <url> --source-type oca
94
95
  Add module:
95
96
  npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
96
97
  Run tests:
@@ -1,17 +1,53 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { addSourceRepoToAddonsYaml, removeSourceRepoFromAddonsYaml } from './addons-yaml.js';
4
- import { readEnvironmentMetadata } from './environment.js';
4
+ import { readEnvironmentMetadata, removeSourceRepoMetadata, upsertSourceRepoMetadata } from './environment.js';
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
8
  export const addonsYamlHeader = `# Addons activated from source submodules.
9
9
  #
10
- # Source repos are managed as Git submodules under odoo/custom/src/private.
10
+ # Source repos are managed as Git submodules under odoo/custom/src/private (product code).
11
+ # OCA/external source repos can be placed under odoo/custom/src/oca and odoo/custom/src/external.
11
12
  # Do not duplicate these same repos in repos.yaml.
12
13
  `;
13
- function privateSubmodulePath(repoPath) {
14
- return `odoo/custom/src/private/${validateRepoPath(repoPath)}`;
14
+ const validSourceTypes = ['private', 'oca', 'external'];
15
+ function normalizeSourceType(value) {
16
+ return validSourceTypes.includes(value) ? value : 'private';
17
+ }
18
+ function sourceSubmodulePath(sourceType, repoPath) {
19
+ return `odoo/custom/src/${sourceType}/${validateRepoPath(repoPath)}`;
20
+ }
21
+ function resolveSourceTypeFromSubmodulePath(submodulePath) {
22
+ const match = /^odoo\/custom\/src\/(private|oca|external)\//.exec(submodulePath);
23
+ if (!match)
24
+ return undefined;
25
+ return match[1];
26
+ }
27
+ async function listGitmoduleRepos(target) {
28
+ try {
29
+ const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
30
+ return [...gitmodules.matchAll(/^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/(.+)$/gm)]
31
+ .map((match) => ({ sourceType: match[1], path: match[2].trim() }))
32
+ .filter((entry) => isValidPathSegment(entry.path));
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ async function resolveSubmodulePathFromConfig(target, repoPath, sourceType) {
39
+ if (sourceType) {
40
+ return sourceSubmodulePath(sourceType, validateRepoPath(repoPath));
41
+ }
42
+ const repoMatches = (await listGitmoduleRepos(target)).filter((repo) => repo.path === repoPath);
43
+ if (repoMatches.length === 1) {
44
+ return sourceSubmodulePath(repoMatches[0].sourceType, repoPath);
45
+ }
46
+ if (repoMatches.length > 1) {
47
+ const sorted = repoMatches.map((repo) => repo.sourceType).sort();
48
+ throw new Error(`Source repo ${repoPath} exists in multiple source directories: ${sorted.join(', ')}. Provide --source-type to disambiguate.`);
49
+ }
50
+ return sourceSubmodulePath('private', repoPath);
15
51
  }
16
52
  export async function readAddonsYaml(target) {
17
53
  try {
@@ -55,20 +91,29 @@ export async function syncComposeOdooConfAddonsPath(target) {
55
91
  }
56
92
  export async function addModuleRepo(options, git = realGit) {
57
93
  const repoPath = validateRepoPath(options.repoPath?.trim() || inferRepoPath(options.repoUrl));
58
- const submodulePath = privateSubmodulePath(repoPath);
94
+ const sourceType = normalizeSourceType(options.sourceType);
95
+ const submodulePath = sourceSubmodulePath(sourceType, repoPath);
59
96
  await ensureRemoteHasBranch(git, options.target, options.repoUrl, options.odooVersion, options.initEmptyRepos);
60
- await mkdir(join(options.target, 'odoo/custom/src/private'), { recursive: true });
97
+ await mkdir(join(options.target, 'odoo/custom/src', sourceType), { recursive: true });
61
98
  await ensureSubmodule(git, options.target, options.repoUrl, options.odooVersion, submodulePath);
62
99
  const listedRepos = await listModuleRepos(options.target);
63
100
  if (!listedRepos.includes(repoPath)) {
64
101
  throw new Error(`Source repo was added but is not registered in .gitmodules: ${repoPath}`);
65
102
  }
103
+ await upsertSourceRepoMetadata(options.target, {
104
+ url: options.repoUrl,
105
+ path: repoPath,
106
+ addons: [repoPath],
107
+ sourceType,
108
+ });
66
109
  if (!(await isComposeEnvironment(options.target))) {
67
110
  const addonsYaml = await readAddonsYaml(options.target);
68
- await writeAddonsYaml(options.target, addSourceRepoToAddonsYaml(addonsYaml, {
69
- path: repoPath,
70
- addons: [repoPath],
71
- }));
111
+ if (sourceType === 'private') {
112
+ await writeAddonsYaml(options.target, addSourceRepoToAddonsYaml(addonsYaml, {
113
+ path: repoPath,
114
+ addons: [repoPath],
115
+ }));
116
+ }
72
117
  }
73
118
  await syncComposeOdooConfAddonsPath(options.target);
74
119
  if (options.stage) {
@@ -76,28 +121,24 @@ export async function addModuleRepo(options, git = realGit) {
76
121
  }
77
122
  }
78
123
  export async function listModuleRepos(target) {
79
- try {
80
- const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
81
- return [...gitmodules.matchAll(/^\s*path\s*=\s*odoo\/custom\/src\/private\/(.+)$/gm)]
82
- .map((match) => match[1].trim())
83
- .filter((repoPath) => repoPath && isValidPathSegment(repoPath))
84
- .sort();
85
- }
86
- catch {
87
- return [];
88
- }
124
+ return (await listGitmoduleRepos(target)).map((repo) => repo.path).sort();
89
125
  }
90
126
  export async function removeModuleRepo(options, git = realGit) {
91
127
  const repoPath = validateRepoPath(options.repoPath);
92
- const submodulePath = privateSubmodulePath(repoPath);
128
+ const sourceType = options.sourceType ? normalizeSourceType(options.sourceType) : undefined;
129
+ const submodulePath = await resolveSubmodulePathFromConfig(options.target, repoPath, sourceType);
93
130
  const fullSubmodulePath = join(options.target, submodulePath);
131
+ const resolvedSourceType = sourceType ?? resolveSourceTypeFromSubmodulePath(submodulePath);
94
132
  if (await hasUncommittedChanges(git, fullSubmodulePath)) {
95
133
  throw new Error(`Cannot remove ${repoPath}: submodule has uncommitted changes.`);
96
134
  }
97
135
  await removeSubmodule(git, options.target, submodulePath);
136
+ await removeSourceRepoMetadata(options.target, repoPath, resolvedSourceType);
98
137
  if (!(await isComposeEnvironment(options.target))) {
99
138
  const addonsYaml = await readAddonsYaml(options.target);
100
- await writeAddonsYaml(options.target, removeSourceRepoFromAddonsYaml(addonsYaml, repoPath));
139
+ if (resolvedSourceType === 'private') {
140
+ await writeAddonsYaml(options.target, removeSourceRepoFromAddonsYaml(addonsYaml, repoPath));
141
+ }
101
142
  }
102
143
  await syncComposeOdooConfAddonsPath(options.target);
103
144
  if (options.stage) {
@@ -1,12 +1,45 @@
1
- import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
2
  import { basename, join } from 'node:path';
3
- import { readEnvironmentMetadata } from './environment.js';
3
+ import { environmentMetadata, readEnvironmentMetadata } from './environment.js';
4
4
  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
8
  import { listModuleRepos, readAddonsYaml } from './repo-actions.js';
9
9
  import { generatedFiles } from './scaffold.js';
10
+ const safeResetProtectedPaths = [
11
+ 'data',
12
+ 'backups',
13
+ '.env',
14
+ '.gitmodules',
15
+ 'odoo/custom/src/private',
16
+ 'odoo/custom/src/oca',
17
+ 'odoo/custom/src/external',
18
+ 'odoo/custom/patches',
19
+ 'odoo/custom/manifests',
20
+ ].map((path) => path.replace(/\/$/, ''));
21
+ const safeResetProtectedGeneratedReadmes = new Set([
22
+ 'odoo/custom/src/private/README.md',
23
+ 'odoo/custom/src/oca/README.md',
24
+ 'odoo/custom/src/external/README.md',
25
+ 'odoo/custom/patches/README.md',
26
+ 'odoo/custom/manifests/README.md',
27
+ ]);
28
+ function isProtectedGeneratedFile(filePath) {
29
+ return safeResetProtectedGeneratedReadmes.has(filePath);
30
+ }
31
+ function mergeEnvironmentMetadata(target, options) {
32
+ const generated = environmentMetadata(options);
33
+ return readFile(join(target, '.wpmoo/odoo.json'), 'utf8')
34
+ .then((content) => JSON.parse(content))
35
+ .then((existing) => {
36
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
37
+ return `${JSON.stringify(generated, null, 2)}\n`;
38
+ }
39
+ return `${JSON.stringify({ ...existing, ...generated, sourceRepos: generated.sourceRepos }, null, 2)}\n`;
40
+ })
41
+ .catch(() => `${JSON.stringify(generated, null, 2)}\n`);
42
+ }
10
43
  export function renderSafeResetPreview(target, stage) {
11
44
  return [
12
45
  'Safe reset will refresh generated WPMoo environment files.',
@@ -29,8 +62,12 @@ export function renderSafeResetPreview(target, stage) {
29
62
  '- source repo folders under odoo/custom/src/private',
30
63
  '- module source code',
31
64
  '- Git history, remotes, or branches',
65
+ '- .env, data, and backups',
66
+ '- custom source layout directories (oca, external, patches, manifests)',
32
67
  '- Legacy compose template files may remain until manually removed: docs/assets/, test/, .github/',
33
68
  '',
69
+ 'Preview-only output; files are not changed until reset is executed.',
70
+ '',
34
71
  stage ? 'Generated changes will be staged with git add .' : 'Generated changes will not be staged.',
35
72
  ].join('\n');
36
73
  }
@@ -42,9 +79,7 @@ function safeResetExternalAssetOptions(options) {
42
79
  ...assetOptions,
43
80
  exclude: [
44
81
  ...(assetOptions.exclude ?? []),
45
- '.env',
46
- '.gitmodules',
47
- 'odoo/custom/src/private',
82
+ ...safeResetProtectedPaths,
48
83
  ],
49
84
  }));
50
85
  }
@@ -88,6 +123,15 @@ async function readSubmoduleUrl(target, repoPath) {
88
123
  return `odoo/custom/src/private/${safeRepoPath}`;
89
124
  }
90
125
  }
126
+ async function pathExists(path) {
127
+ try {
128
+ await stat(path);
129
+ return true;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
91
135
  async function inferOptions(target) {
92
136
  const metadata = await readEnvironmentMetadata(target);
93
137
  const addonsYaml = await readAddonsYaml(target);
@@ -130,6 +174,12 @@ export async function safeResetEnvironment(options, git = realGit) {
130
174
  const files = generatedFiles(scaffoldOptions);
131
175
  const externalAssets = safeResetExternalAssetOptions(scaffoldOptions);
132
176
  for (const file of files) {
177
+ if (file.path === '.wpmoo/odoo.json') {
178
+ continue;
179
+ }
180
+ if (isProtectedGeneratedFile(file.path) && (await pathExists(join(options.target, file.path)))) {
181
+ continue;
182
+ }
133
183
  if (file.path === 'odoo/custom/src/addons.yaml') {
134
184
  continue;
135
185
  }
@@ -143,6 +193,7 @@ export async function safeResetEnvironment(options, git = realGit) {
143
193
  for (const assetOptions of externalAssets) {
144
194
  await applyExternalAsset(assetOptions, git);
145
195
  }
196
+ await writeTextFile(join(options.target, '.wpmoo/odoo.json'), await mergeEnvironmentMetadata(options.target, scaffoldOptions));
146
197
  await writeTextFile(join(options.target, '.env.example'), renderComposeEnvExample(scaffoldOptions));
147
198
  if (options.stage) {
148
199
  await stageAll(git, options.target);
package/dist/scaffold.js CHANGED
@@ -22,6 +22,33 @@ function validateScaffoldOptions(options) {
22
22
  }
23
23
  export function generatedFiles(options) {
24
24
  const safeOptions = validateScaffoldOptions(options);
25
+ const sourceDirReadmes = [
26
+ {
27
+ path: 'odoo/custom/src/private/README.md',
28
+ title: 'private',
29
+ body: 'Project-owned/private addon repositories go here.',
30
+ },
31
+ {
32
+ path: 'odoo/custom/src/oca/README.md',
33
+ title: 'oca',
34
+ body: 'OCA repositories go here, for example server-tools, web, queue.',
35
+ },
36
+ {
37
+ path: 'odoo/custom/src/external/README.md',
38
+ title: 'external',
39
+ body: 'Non-OCA third-party, vendor, and community addon repositories go here.',
40
+ },
41
+ {
42
+ path: 'odoo/custom/patches/README.md',
43
+ title: 'patches',
44
+ body: 'Local patches for upstream/vendor/OCA repositories go here.',
45
+ },
46
+ {
47
+ path: 'odoo/custom/manifests/README.md',
48
+ title: 'manifests',
49
+ body: 'Manifest/lock/list files for external sources and pinned revisions go here.',
50
+ },
51
+ ];
25
52
  const files = [
26
53
  { path: markerPath, content: renderEnvironmentMetadata(safeOptions) },
27
54
  { path: 'moo', content: renderMooDelegationScript(), mode: 0o755 },
@@ -32,10 +59,10 @@ export function generatedFiles(options) {
32
59
  ];
33
60
  return [
34
61
  ...files,
35
- {
36
- path: 'odoo/custom/src/private/README.md',
37
- content: renderPlaceholder('private', 'WPMoo source repositories are added here as Git submodules.'),
38
- },
62
+ ...sourceDirReadmes.map((readme) => ({
63
+ path: readme.path,
64
+ content: renderPlaceholder(readme.title, readme.body),
65
+ })),
39
66
  ];
40
67
  }
41
68
  async function writeGeneratedFiles(target, files) {
package/dist/templates.js CHANGED
@@ -22,46 +22,62 @@ function hasSourceRepos(options) {
22
22
  }
23
23
  function repositoryLayout(options) {
24
24
  const sourceRepoRows = hasSourceRepos(options)
25
- ? options.sourceRepos.map((repo) => `│ ├── ${repo.path}/`).join('\n')
26
- : '│ └── (add repos with ./moo add-repo)';
27
- return `${options.devRepo}/
28
- ├── compose.yaml
29
- ├── compose/
30
- │ ├── dev.yaml
31
- ├── debug.yaml
32
- │ ├── test.yaml
33
- ├── stage.yaml
34
- ├── prod.yaml
35
- │ ├── proxy.yaml
36
- └── tools.yaml
37
- ├── config/
38
- │ ├── odoo/
39
- ├── odoo.conf
40
- │ └── requirements.txt
41
- │ └── logrotate/
42
- │ └── odoo
43
- ├── resources/
44
- └── odoo/
45
- └── entrypoint.sh
46
- ├── moo
47
- ├── scripts/
48
- ├── odoo/
49
- │ └── custom/
50
- │ └── src/
51
- │ └── private/
25
+ ? options.sourceRepos
26
+ .map((repo, index) => {
27
+ const connector = index === options.sourceRepos.length - 1 ? '└──' : '├──';
28
+ return `│ │ │ ${connector} ${repo.path}/ # Project-owned addon source repository`;
29
+ })
30
+ .join('\n')
31
+ : ' │ └── (add project-owned repos with ./moo add-repo)';
32
+ return `${options.devRepo}/ # Development environment root
33
+ ├── compose.yaml # Base Docker Compose file
34
+ ├── compose/ # Compose overlays for each workflow
35
+ │ ├── dev.yaml # Local development services
36
+ ├── debug.yaml # Debug tooling and debug-friendly settings
37
+ ├── test.yaml # Test runner services and test database setup
38
+ │ ├── stage.yaml # Staging-like deployment overlay
39
+ │ ├── prod.yaml # Production deployment overlay
40
+ ├── proxy.yaml # Reverse proxy / edge routing overlay
41
+ │ └── tools.yaml # Optional maintenance and helper tools
42
+ ├── config/ # Runtime configuration mounted into containers
43
+ ├── odoo/ # Odoo server configuration
44
+ │ ├── odoo.conf # Main Odoo configuration file
45
+ └── requirements.txt # Extra Python dependencies for the Odoo container
46
+ │ └── logrotate/ # Log rotation configuration
47
+ │ └── odoo # Logrotate rules for Odoo logs
48
+ ├── resources/ # Container-side helper resources
49
+ │ └── odoo/ # Resources specific to the Odoo service
50
+ │ └── entrypoint.sh # Container startup script that discovers addons
51
+ ├── moo # Local command hub shortcut
52
+ ├── scripts/ # Shell scripts used by the local command hub
53
+ ├── odoo/ # Odoo workspace data and custom source tree
54
+ │ └── custom/ # Custom addon layer for this environment
55
+ │ ├── src/ # Source repository checkout root
56
+ │ │ ├── private/ # Project-owned/private addon repositories
52
57
  ${sourceRepoRows}
53
- ├── docs/
54
- ├── appstore-release.md
55
- └── compose.md
56
- ├── .env.example
57
- ├── README.md
58
- └── AGENTS.md`;
58
+ │ │ ├── oca/ # OCA addon repositories
59
+ └── external/ # Non-OCA third-party addon repositories
60
+ ├── patches/ # Local patches for upstream repositories
61
+ │ └── manifests/ # Source manifests, locks, and pinned revisions
62
+ ├── docs/ # Project-specific documentation
63
+ │ ├── appstore-release.md # Odoo App Store release checklist and notes
64
+ │ └── compose.md # Compose layout and operations reference
65
+ ├── .env.example # Template for local environment variables
66
+ ├── README.md # This environment overview
67
+ └── AGENTS.md # Agent instructions for this environment`;
59
68
  }
60
69
  function sourceRepoDocs(options) {
61
70
  if (!hasSourceRepos(options)) {
62
71
  return `This environment was scaffolded without source repository submodules.
63
72
  Add source repositories later from the cockpit or with \`npx @wpmoo/odoo add-repo\`.
64
- They will be added under \`odoo/custom/src/private\`.`;
73
+ They can be organized under:
74
+
75
+ \`odoo/custom/src/private\` for project-owned/private addon repositories,
76
+ \`odoo/custom/src/oca\` for OCA repositories, and
77
+ \`odoo/custom/src/external\` for non-OCA third-party repositories.
78
+
79
+ Pinned external manifests and local patches should live under
80
+ \`odoo/custom/manifests\` and \`odoo/custom/patches\` respectively.`;
65
81
  }
66
82
  return options.sourceRepos
67
83
  .map((repo) => `### ${repo.path}
@@ -78,6 +94,9 @@ Submodule path:
78
94
  odoo/custom/src/private/${repo.path}
79
95
  \`\`\`
80
96
 
97
+ Note: If this repository is an OCA or third-party source, place it under
98
+ \`odoo/custom/src/oca\` or \`odoo/custom/src/external\` according to your policy.
99
+
81
100
  Expected addon layout:
82
101
 
83
102
  \`\`\`text
@@ -154,7 +173,7 @@ function environmentKind() {
154
173
  return 'Docker Compose';
155
174
  }
156
175
  function repoDuplicationNote() {
157
- return 'Keep these repositories under `odoo/custom/src/private`; the Compose entrypoint exposes discovered addons through `/mnt/wpmoo-addons`.';
176
+ return 'Keep source repositories under the relevant source directory (`private`, `oca`, or `external`); the Compose entrypoint exposes discovered addons through `/mnt/wpmoo-addons`.';
158
177
  }
159
178
  function verificationCommand(options) {
160
179
  const firstAddon = allAddons(options)[0] ?? options.product;
@@ -180,7 +199,8 @@ Set WPMOO_ENV=stage or WPMOO_ENV=prod only after providing production-grade secr
180
199
  If copied from the standalone resource, additional compose notes are in
181
200
  \`docs/compose.md\`.
182
201
 
183
- Source repositories stay under \`odoo/custom/src/private\` when configured. At
202
+ Source repositories stay under \`odoo/custom/src/{private,oca,external}\` when
203
+ configured. At
184
204
  container startup, \`entrypoint.sh\` scans those repositories for addons and
185
205
  exposes them through \`/mnt/wpmoo-addons\`.
186
206
 
@@ -553,7 +573,8 @@ export function renderReadme(options) {
553
573
  Private ${environmentKind()} development environment for the ${title} product.
554
574
 
555
575
  This folder owns the development environment only. Product source code lives
556
- in source repository submodules under \`odoo/custom/src/private\` when source
576
+ in source repository submodules under \`odoo/custom/src/private\`,
577
+ \`odoo/custom/src/oca\`, or \`odoo/custom/src/external\` when source
557
578
  repositories are connected.
558
579
 
559
580
  ## Repository Layout
@@ -603,7 +624,8 @@ export function renderAgents(options) {
603
624
  ? options.sourceRepos.map((repo) => `- \`${repo.path}\`: \`${repo.url}\``).join('\n')
604
625
  : '- No source repositories are configured yet.';
605
626
  const sourceLayout = hasSourceRepos(options)
606
- ? `Product repositories are Git submodules:
627
+ ? `Product repositories are Git submodules. They are listed under the private
628
+ source directory below for this environment:
607
629
 
608
630
  \`\`\`text
609
631
  ${options.sourceRepos.map((repo) => `odoo/custom/src/private/${repo.path}`).join('\n')}
@@ -21,9 +21,10 @@ 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
+ | Generated Postgres checks | For PostgreSQL 18 environments, doctor validates db mount targets avoid old PG image-specific paths. | `npx @wpmoo/odoo doctor` |
24
25
  | Source repo add/remove | Source repository registration and submodule lifecycle behave correctly. | `npx @wpmoo/odoo add-repo ...`, `npx @wpmoo/odoo remove-repo ...` |
25
26
  | Module add/remove | Module registration changes are applied to the selected source repo config. | `npx @wpmoo/odoo add-module ...`, `npx @wpmoo/odoo remove-module ...` |
26
- | Safe reset | Generated files are refreshed without deleting source module code. Legacy user-editable paths from older templates may remain and are reported for manual cleanup. | `npx @wpmoo/odoo reset` |
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` |
27
28
  | 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 ...` |
28
29
 
29
30
  ## Compact compose checks
@@ -43,6 +44,17 @@ Default local development uses `compose.yaml` plus `compose/dev.yaml`.
43
44
  `WPMOO_ENV=stage` or `WPMOO_ENV=prod` must only be used after production-grade
44
45
  secrets and volumes are configured.
45
46
 
47
+ For PostgreSQL 18 environments (including `POSTGRES_IMAGE=postgres:18`), ensure db
48
+ volume and tmpfs mount targets use `/var/lib/postgresql` directly:
49
+
50
+ ```text
51
+ - volumes:
52
+ - db_data:/var/lib/postgresql
53
+ ```
54
+
55
+ Paths such as `/var/lib/postgresql/data` and `/var/lib/postgresql/18/docker` are
56
+ no longer accepted by the package `doctor` check.
57
+
46
58
  ## Safe reset policy
47
59
 
48
60
  Safe reset intentionally avoids deleting user-editable legacy paths from old
@@ -54,6 +66,19 @@ test/
54
66
  .github/
55
67
  ```
56
68
 
69
+ In addition, safe reset preserves local runtime and source-data state while refreshing
70
+ generated and compose assets:
71
+
72
+ ```text
73
+ .env
74
+ data/
75
+ backups/
76
+ odoo/custom/src/oca/
77
+ odoo/custom/src/external/
78
+ odoo/custom/patches/
79
+ odoo/custom/manifests/
80
+ ```
81
+
57
82
  ## Local verification commands
58
83
 
59
84
  Run from the `wpmoo-odoo` repository root:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/odoo",
3
- "version": "0.8.57",
3
+ "version": "0.8.59",
4
4
  "description": "WPMoo Odoo lifecycle tooling for development, staging, and production workflows.",
5
5
  "type": "module",
6
6
  "repository": {