@wpmoo/odoo 0.8.58 → 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);
@@ -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.58",
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": {