@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.
@@ -1,17 +1,54 @@
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
+ import { removeSourceManifestEntry, upsertSourceManifestEntry } from './source-manifest.js';
8
9
  export const addonsYamlHeader = `# Addons activated from source submodules.
9
10
  #
10
- # Source repos are managed as Git submodules under odoo/custom/src/private.
11
+ # Source repos are managed as Git submodules under odoo/custom/src/private (product code).
12
+ # OCA/external source repos can be placed under odoo/custom/src/oca and odoo/custom/src/external.
11
13
  # Do not duplicate these same repos in repos.yaml.
12
14
  `;
13
- function privateSubmodulePath(repoPath) {
14
- return `odoo/custom/src/private/${validateRepoPath(repoPath)}`;
15
+ const validSourceTypes = ['private', 'oca', 'external'];
16
+ function normalizeSourceType(value) {
17
+ return validSourceTypes.includes(value) ? value : 'private';
18
+ }
19
+ function sourceSubmodulePath(sourceType, repoPath) {
20
+ return `odoo/custom/src/${sourceType}/${validateRepoPath(repoPath)}`;
21
+ }
22
+ function resolveSourceTypeFromSubmodulePath(submodulePath) {
23
+ const match = /^odoo\/custom\/src\/(private|oca|external)\//.exec(submodulePath);
24
+ if (!match)
25
+ return undefined;
26
+ return match[1];
27
+ }
28
+ async function listGitmoduleRepos(target) {
29
+ try {
30
+ const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
31
+ return [...gitmodules.matchAll(/^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/(.+)$/gm)]
32
+ .map((match) => ({ sourceType: match[1], path: match[2].trim() }))
33
+ .filter((entry) => isValidPathSegment(entry.path));
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ }
39
+ async function resolveSubmodulePathFromConfig(target, repoPath, sourceType) {
40
+ if (sourceType) {
41
+ return sourceSubmodulePath(sourceType, validateRepoPath(repoPath));
42
+ }
43
+ const repoMatches = (await listGitmoduleRepos(target)).filter((repo) => repo.path === repoPath);
44
+ if (repoMatches.length === 1) {
45
+ return sourceSubmodulePath(repoMatches[0].sourceType, repoPath);
46
+ }
47
+ if (repoMatches.length > 1) {
48
+ const sorted = repoMatches.map((repo) => repo.sourceType).sort();
49
+ throw new Error(`Source repo ${repoPath} exists in multiple source directories: ${sorted.join(', ')}. Provide --source-type to disambiguate.`);
50
+ }
51
+ return sourceSubmodulePath('private', repoPath);
15
52
  }
16
53
  export async function readAddonsYaml(target) {
17
54
  try {
@@ -55,20 +92,36 @@ export async function syncComposeOdooConfAddonsPath(target) {
55
92
  }
56
93
  export async function addModuleRepo(options, git = realGit) {
57
94
  const repoPath = validateRepoPath(options.repoPath?.trim() || inferRepoPath(options.repoUrl));
58
- const submodulePath = privateSubmodulePath(repoPath);
95
+ const sourceType = normalizeSourceType(options.sourceType);
96
+ const submodulePath = sourceSubmodulePath(sourceType, repoPath);
59
97
  await ensureRemoteHasBranch(git, options.target, options.repoUrl, options.odooVersion, options.initEmptyRepos);
60
- await mkdir(join(options.target, 'odoo/custom/src/private'), { recursive: true });
98
+ await mkdir(join(options.target, 'odoo/custom/src', sourceType), { recursive: true });
61
99
  await ensureSubmodule(git, options.target, options.repoUrl, options.odooVersion, submodulePath);
62
100
  const listedRepos = await listModuleRepos(options.target);
63
101
  if (!listedRepos.includes(repoPath)) {
64
102
  throw new Error(`Source repo was added but is not registered in .gitmodules: ${repoPath}`);
65
103
  }
104
+ await upsertSourceRepoMetadata(options.target, {
105
+ url: options.repoUrl,
106
+ path: repoPath,
107
+ addons: [repoPath],
108
+ sourceType,
109
+ });
110
+ await upsertSourceManifestEntry(options.target, {
111
+ type: sourceType,
112
+ path: repoPath,
113
+ url: options.repoUrl,
114
+ branch: options.odooVersion,
115
+ addons: [repoPath],
116
+ });
66
117
  if (!(await isComposeEnvironment(options.target))) {
67
118
  const addonsYaml = await readAddonsYaml(options.target);
68
- await writeAddonsYaml(options.target, addSourceRepoToAddonsYaml(addonsYaml, {
69
- path: repoPath,
70
- addons: [repoPath],
71
- }));
119
+ if (sourceType === 'private') {
120
+ await writeAddonsYaml(options.target, addSourceRepoToAddonsYaml(addonsYaml, {
121
+ path: repoPath,
122
+ addons: [repoPath],
123
+ }));
124
+ }
72
125
  }
73
126
  await syncComposeOdooConfAddonsPath(options.target);
74
127
  if (options.stage) {
@@ -76,28 +129,27 @@ export async function addModuleRepo(options, git = realGit) {
76
129
  }
77
130
  }
78
131
  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
- }
132
+ return (await listGitmoduleRepos(target)).map((repo) => repo.path).sort();
89
133
  }
90
134
  export async function removeModuleRepo(options, git = realGit) {
91
135
  const repoPath = validateRepoPath(options.repoPath);
92
- const submodulePath = privateSubmodulePath(repoPath);
136
+ const sourceType = options.sourceType ? normalizeSourceType(options.sourceType) : undefined;
137
+ const submodulePath = await resolveSubmodulePathFromConfig(options.target, repoPath, sourceType);
93
138
  const fullSubmodulePath = join(options.target, submodulePath);
139
+ const resolvedSourceType = sourceType ?? resolveSourceTypeFromSubmodulePath(submodulePath);
94
140
  if (await hasUncommittedChanges(git, fullSubmodulePath)) {
95
141
  throw new Error(`Cannot remove ${repoPath}: submodule has uncommitted changes.`);
96
142
  }
97
143
  await removeSubmodule(git, options.target, submodulePath);
144
+ await removeSourceRepoMetadata(options.target, repoPath, resolvedSourceType);
145
+ if (resolvedSourceType) {
146
+ await removeSourceManifestEntry(options.target, resolvedSourceType, repoPath);
147
+ }
98
148
  if (!(await isComposeEnvironment(options.target))) {
99
149
  const addonsYaml = await readAddonsYaml(options.target);
100
- await writeAddonsYaml(options.target, removeSourceRepoFromAddonsYaml(addonsYaml, repoPath));
150
+ if (resolvedSourceType === 'private') {
151
+ await writeAddonsYaml(options.target, removeSourceRepoFromAddonsYaml(addonsYaml, repoPath));
152
+ }
101
153
  }
102
154
  await syncComposeOdooConfAddonsPath(options.target);
103
155
  if (options.stage) {
@@ -1,12 +1,46 @@
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
- import { listModuleRepos, readAddonsYaml } from './repo-actions.js';
8
+ import { readAddonsYaml } from './repo-actions.js';
9
9
  import { generatedFiles } from './scaffold.js';
10
+ import { listGitmoduleSources } from './source-manifest.js';
11
+ const safeResetProtectedPaths = [
12
+ 'data',
13
+ 'backups',
14
+ '.env',
15
+ '.gitmodules',
16
+ 'odoo/custom/src/private',
17
+ 'odoo/custom/src/oca',
18
+ 'odoo/custom/src/external',
19
+ 'odoo/custom/patches',
20
+ 'odoo/custom/manifests',
21
+ ].map((path) => path.replace(/\/$/, ''));
22
+ const safeResetProtectedGeneratedReadmes = new Set([
23
+ 'odoo/custom/src/private/README.md',
24
+ 'odoo/custom/src/oca/README.md',
25
+ 'odoo/custom/src/external/README.md',
26
+ 'odoo/custom/patches/README.md',
27
+ 'odoo/custom/manifests/README.md',
28
+ ]);
29
+ function isProtectedGeneratedFile(filePath) {
30
+ return safeResetProtectedGeneratedReadmes.has(filePath);
31
+ }
32
+ function mergeEnvironmentMetadata(target, options) {
33
+ const generated = environmentMetadata(options);
34
+ return readFile(join(target, '.wpmoo/odoo.json'), 'utf8')
35
+ .then((content) => JSON.parse(content))
36
+ .then((existing) => {
37
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
38
+ return `${JSON.stringify(generated, null, 2)}\n`;
39
+ }
40
+ return `${JSON.stringify({ ...existing, ...generated, sourceRepos: generated.sourceRepos }, null, 2)}\n`;
41
+ })
42
+ .catch(() => `${JSON.stringify(generated, null, 2)}\n`);
43
+ }
10
44
  export function renderSafeResetPreview(target, stage) {
11
45
  return [
12
46
  'Safe reset will refresh generated WPMoo environment files.',
@@ -29,8 +63,12 @@ export function renderSafeResetPreview(target, stage) {
29
63
  '- source repo folders under odoo/custom/src/private',
30
64
  '- module source code',
31
65
  '- Git history, remotes, or branches',
66
+ '- .env, data, and backups',
67
+ '- custom source layout directories (oca, external, patches, manifests)',
32
68
  '- Legacy compose template files may remain until manually removed: docs/assets/, test/, .github/',
33
69
  '',
70
+ 'Preview-only output; files are not changed until reset is executed.',
71
+ '',
34
72
  stage ? 'Generated changes will be staged with git add .' : 'Generated changes will not be staged.',
35
73
  ].join('\n');
36
74
  }
@@ -42,9 +80,7 @@ function safeResetExternalAssetOptions(options) {
42
80
  ...assetOptions,
43
81
  exclude: [
44
82
  ...(assetOptions.exclude ?? []),
45
- '.env',
46
- '.gitmodules',
47
- 'odoo/custom/src/private',
83
+ ...safeResetProtectedPaths,
48
84
  ],
49
85
  }));
50
86
  }
@@ -74,35 +110,59 @@ function parseRepoPathsFromAddonsYaml(addonsYaml) {
74
110
  .filter((repoPath) => repoPath && isValidPathSegment(repoPath))
75
111
  .map(validateRepoPath);
76
112
  }
77
- async function readSubmoduleUrl(target, repoPath) {
113
+ async function readSubmoduleUrl(target, repoPath, sourceType) {
78
114
  const safeRepoPath = validateRepoPath(repoPath);
79
115
  try {
80
116
  const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
81
- const escapedPath = `odoo/custom/src/private/${safeRepoPath}`;
117
+ const escapedPath = `odoo/custom/src/${sourceType}/${safeRepoPath}`;
82
118
  const sections = gitmodules.split(/\n(?=\[submodule )/);
83
119
  const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
84
120
  const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
85
- return url || `odoo/custom/src/private/${safeRepoPath}`;
121
+ return url || `odoo/custom/src/${sourceType}/${safeRepoPath}`;
122
+ }
123
+ catch {
124
+ return `odoo/custom/src/${sourceType}/${safeRepoPath}`;
125
+ }
126
+ }
127
+ async function pathExists(path) {
128
+ try {
129
+ await stat(path);
130
+ return true;
86
131
  }
87
132
  catch {
88
- return `odoo/custom/src/private/${safeRepoPath}`;
133
+ return false;
89
134
  }
90
135
  }
91
136
  async function inferOptions(target) {
92
137
  const metadata = await readEnvironmentMetadata(target);
93
138
  const addonsYaml = await readAddonsYaml(target);
94
- const moduleRepos = await listModuleRepos(target);
139
+ const gitmoduleSources = await listGitmoduleSources(target);
95
140
  const addonRepos = parseRepoPathsFromAddonsYaml(addonsYaml);
96
- const metadataRepoPaths = metadata?.sourceRepos.map((repo) => repo.path).filter((repoPath) => isValidPathSegment(repoPath)).map(validateRepoPath) ??
97
- [];
98
- const repoPaths = [
99
- ...new Set([...metadataRepoPaths, ...moduleRepos, ...addonRepos]),
100
- ];
101
- const product = metadata?.product ?? repoPaths[0] ?? titleFromTarget(target);
102
- const sourceRepos = await Promise.all(repoPaths.map(async (repoPath) => ({
103
- path: repoPath,
104
- url: metadata?.sourceRepos.find((repo) => repo.path === repoPath)?.url ?? (await readSubmoduleUrl(target, repoPath)),
105
- addons: parseAddonsForRepo(addonsYaml, repoPath),
141
+ const sourceByKey = new Map();
142
+ for (const repo of metadata?.sourceRepos ?? []) {
143
+ if (isValidPathSegment(repo.path)) {
144
+ const sourceType = repo.sourceType ?? 'private';
145
+ const path = validateRepoPath(repo.path);
146
+ sourceByKey.set(`${sourceType}:${path}`, { sourceType, path });
147
+ }
148
+ }
149
+ for (const repo of gitmoduleSources) {
150
+ sourceByKey.set(`${repo.type}:${repo.path}`, { sourceType: repo.type, path: repo.path });
151
+ }
152
+ for (const repoPath of addonRepos) {
153
+ sourceByKey.set(`private:${repoPath}`, { sourceType: 'private', path: repoPath });
154
+ }
155
+ const sourceLocations = [...sourceByKey.values()];
156
+ const product = metadata?.product ?? sourceLocations[0]?.path ?? titleFromTarget(target);
157
+ const sourceRepos = await Promise.all(sourceLocations.map(async ({ sourceType, path }) => ({
158
+ path,
159
+ sourceType,
160
+ url: metadata?.sourceRepos
161
+ .find((repo) => repo.path === path && (repo.sourceType ?? 'private') === sourceType)
162
+ ?.url.trim() ||
163
+ gitmoduleSources.find((repo) => repo.path === path && repo.type === sourceType)?.url ||
164
+ (await readSubmoduleUrl(target, path, sourceType)),
165
+ addons: parseAddonsForRepo(addonsYaml, path),
106
166
  })));
107
167
  return {
108
168
  product,
@@ -130,6 +190,12 @@ export async function safeResetEnvironment(options, git = realGit) {
130
190
  const files = generatedFiles(scaffoldOptions);
131
191
  const externalAssets = safeResetExternalAssetOptions(scaffoldOptions);
132
192
  for (const file of files) {
193
+ if (file.path === '.wpmoo/odoo.json') {
194
+ continue;
195
+ }
196
+ if (isProtectedGeneratedFile(file.path) && (await pathExists(join(options.target, file.path)))) {
197
+ continue;
198
+ }
133
199
  if (file.path === 'odoo/custom/src/addons.yaml') {
134
200
  continue;
135
201
  }
@@ -143,6 +209,7 @@ export async function safeResetEnvironment(options, git = realGit) {
143
209
  for (const assetOptions of externalAssets) {
144
210
  await applyExternalAsset(assetOptions, git);
145
211
  }
212
+ await writeTextFile(join(options.target, '.wpmoo/odoo.json'), await mergeEnvironmentMetadata(options.target, scaffoldOptions));
146
213
  await writeTextFile(join(options.target, '.env.example'), renderComposeEnvExample(scaffoldOptions));
147
214
  if (options.stage) {
148
215
  await stageAll(git, options.target);
package/dist/scaffold.js CHANGED
@@ -6,14 +6,19 @@ import { plannedExternalAssetOptions, renderComposeEnvExample } from './external
6
6
  import { cloneRepository, ensureSubmodule, ensureRemoteHasBranch, realGit, stageAll, syncSubmodules, } from './git.js';
7
7
  import { renderAgents, renderAppstoreRelease, renderGitignore, renderMooDelegationScript, renderPlaceholder, renderReadme, } from './templates.js';
8
8
  import { validateAddonName, validateRepoPath } from './path-validation.js';
9
+ import { renderSourceManifest, sourceManifestEntriesFromMetadata } from './source-manifest.js';
9
10
  function validateSourceRepo(repo) {
10
11
  const path = validateRepoPath(repo.path);
11
12
  return {
12
13
  ...repo,
13
14
  path,
15
+ sourceType: repo.sourceType ?? 'private',
14
16
  addons: repo.addons.map(validateAddonName),
15
17
  };
16
18
  }
19
+ function sourceRepoSubmodulePath(repo) {
20
+ return `odoo/custom/src/${repo.sourceType ?? 'private'}/${repo.path}`;
21
+ }
17
22
  function validateScaffoldOptions(options) {
18
23
  return {
19
24
  ...options,
@@ -56,6 +61,10 @@ export function generatedFiles(options) {
56
61
  { path: 'README.md', content: renderReadme(safeOptions) },
57
62
  { path: 'AGENTS.md', content: renderAgents(safeOptions) },
58
63
  { path: 'docs/appstore-release.md', content: renderAppstoreRelease(safeOptions) },
64
+ {
65
+ path: 'odoo/custom/manifests/sources.yaml',
66
+ content: renderSourceManifest(sourceManifestEntriesFromMetadata(safeOptions.sourceRepos, safeOptions.odooVersion)),
67
+ },
59
68
  ];
60
69
  return [
61
70
  ...files,
@@ -113,7 +122,7 @@ export async function scaffold(options, git = realGit) {
113
122
  const externalAssets = plannedExternalAssetOptions(safeOptions);
114
123
  const plannedCommands = [
115
124
  ...externalAssets.map((assetOptions) => renderExternalAssetCommand(assetOptions)),
116
- ...safeOptions.sourceRepos.map((repo) => `git submodule add -b ${safeOptions.odooVersion} ${repo.url} odoo/custom/src/private/${repo.path}`),
125
+ ...safeOptions.sourceRepos.map((repo) => `git submodule add -b ${safeOptions.odooVersion} ${repo.url} ${sourceRepoSubmodulePath(repo)}`),
117
126
  ];
118
127
  if (safeOptions.stage) {
119
128
  plannedCommands.push('git add .');
@@ -136,9 +145,9 @@ export async function scaffold(options, git = realGit) {
136
145
  for (const repo of safeOptions.sourceRepos) {
137
146
  await ensureRemoteHasBranch(git, safeOptions.target, repo.url, safeOptions.odooVersion, safeOptions.initEmptyRepos);
138
147
  }
139
- await mkdir(join(safeOptions.target, 'odoo/custom/src/private'), { recursive: true });
140
148
  for (const repo of safeOptions.sourceRepos) {
141
- await ensureSubmodule(git, safeOptions.target, repo.url, safeOptions.odooVersion, `odoo/custom/src/private/${repo.path}`);
149
+ await mkdir(join(safeOptions.target, 'odoo/custom/src', repo.sourceType ?? 'private'), { recursive: true });
150
+ await ensureSubmodule(git, safeOptions.target, repo.url, safeOptions.odooVersion, sourceRepoSubmodulePath(repo));
142
151
  }
143
152
  await syncSubmodules(git, safeOptions.target);
144
153
  }
@@ -0,0 +1,42 @@
1
+ import { defaultOdooVersion, readEnvironmentMetadata, replaceSourceRepos } from './environment.js';
2
+ import { realGit, stageAll } from './git.js';
3
+ import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
4
+ export async function listSources(target) {
5
+ const metadata = await readEnvironmentMetadata(target);
6
+ const manifest = await readSourceManifest(target);
7
+ if (manifest.sources.length > 0) {
8
+ return manifest.sources;
9
+ }
10
+ if (metadata?.sourceRepos.length) {
11
+ return sourceManifestEntriesFromMetadata(metadata.sourceRepos, metadata.odooVersion);
12
+ }
13
+ return syncManifestFromMetadataAndGitmodules([], metadata?.odooVersion ?? defaultOdooVersion, await listGitmoduleSources(target));
14
+ }
15
+ export function renderSourceList(entries) {
16
+ if (entries.length === 0) {
17
+ return 'No source repositories configured.';
18
+ }
19
+ return entries
20
+ .map((entry) => {
21
+ const branch = entry.branch ? ` @ ${entry.branch}` : '';
22
+ const addons = entry.addons.length ? ` addons: ${entry.addons.join(', ')}` : '';
23
+ return `${entry.type}/${entry.path}${branch} -> ${entry.url}${addons}`;
24
+ })
25
+ .join('\n');
26
+ }
27
+ export async function syncSources(options, git = realGit) {
28
+ const metadata = await readEnvironmentMetadata(options.target);
29
+ const manifest = await readSourceManifest(options.target);
30
+ const gitmodules = await listGitmoduleSources(options.target);
31
+ const fallbackBranch = metadata?.odooVersion ?? defaultOdooVersion;
32
+ const baseRepos = metadata?.sourceRepos.length ? metadata.sourceRepos : sourceReposFromManifest(manifest.sources);
33
+ const entries = syncManifestFromMetadataAndGitmodules(baseRepos, fallbackBranch, gitmodules);
34
+ await writeSourceManifest(options.target, entries);
35
+ if (metadata) {
36
+ await replaceSourceRepos(options.target, sourceReposFromManifest(entries));
37
+ }
38
+ if (options.stage) {
39
+ await stageAll(git, options.target);
40
+ }
41
+ return entries;
42
+ }