@wpmoo/odoo 0.8.59 → 0.8.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -296,6 +296,34 @@ npx @wpmoo/odoo remove-repo --repo odoo_sample_module_reports
296
296
 
297
297
  WPMoo refuses to remove a source repo submodule when that submodule has uncommitted changes.
298
298
 
299
+ Generated environments also keep a deterministic source manifest at
300
+ `odoo/custom/manifests/sources.yaml`. It mirrors source submodules from
301
+ `.wpmoo/odoo.json` and `.gitmodules`, including source type, path, URL, branch,
302
+ and addon boundaries.
303
+
304
+ Inspect configured sources:
305
+
306
+ ```bash
307
+ npx @wpmoo/odoo source list
308
+ ```
309
+
310
+ Regenerate the manifest and metadata from the current metadata/gitmodule state:
311
+
312
+ ```bash
313
+ npx @wpmoo/odoo source sync
314
+ ```
315
+
316
+ `source add` and `source remove` are direct aliases for the same repository
317
+ operations:
318
+
319
+ ```bash
320
+ npx @wpmoo/odoo source add \
321
+ --repo-url https://github.com/OCA/server-tools.git \
322
+ --source-type oca
323
+
324
+ npx @wpmoo/odoo source remove --repo server-tools --source-type oca
325
+ ```
326
+
299
327
  ## Status, Doctor, and Recovery
300
328
 
301
329
  `status` is fast and offline. It reads local metadata and files only:
@@ -313,9 +341,10 @@ npx @wpmoo/odoo doctor
313
341
  ```
314
342
 
315
343
  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`).
344
+ source manifest consistency, daily scripts, `.env` settings, Docker CLI access,
345
+ Docker Compose access, GitHub CLI authentication when available, and PostgreSQL
346
+ 18 compatibility in compose mount targets (for mounts to
347
+ `/var/lib/postgresql/data` or `/var/lib/postgresql/18/docker`).
319
348
 
320
349
  Safe reset refreshes generated environment files without deleting product source code:
321
350
 
package/dist/args.js CHANGED
@@ -12,6 +12,7 @@ const commandNames = new Set([
12
12
  'remove-repo',
13
13
  'add-module',
14
14
  'remove-module',
15
+ 'source',
15
16
  'reset',
16
17
  'doctor',
17
18
  ...dailyActionCommands,
package/dist/cli.js CHANGED
@@ -22,6 +22,7 @@ import { promptRepositoryUrl } from './prompt-repositories.js';
22
22
  import { inferGitHubOwner, inferRepoPath, normalizeRepositoryUrl } from './repo-url.js';
23
23
  import { addModuleRepo, listModuleRepos, removeModuleRepo } from './repo-actions.js';
24
24
  import { renderSafeResetPreview, safeResetEnvironment } from './safe-reset.js';
25
+ import { listSources, renderSourceList, syncSources } from './source-actions.js';
25
26
  import { checkGitHubRepositories, createGitHubRepositories, manualCreateCommands, repositoryPreflightAvailable, } from './repository-preflight.js';
26
27
  import { scaffold } from './scaffold.js';
27
28
  import { renderBanner } from './templates.js';
@@ -470,6 +471,60 @@ function resetOptionsFromArgs(argv) {
470
471
  stage: booleanOption(values, 'stage', true),
471
472
  };
472
473
  }
474
+ function sourceUsage() {
475
+ return 'Usage: wpmoo source <list|sync|add|remove> [options]';
476
+ }
477
+ function sourceSyncOptionsFromArgs(argv) {
478
+ const { values } = parseArgs(argv);
479
+ return {
480
+ target: resolve(stringOption(values, 'target') ?? process.cwd()),
481
+ stage: booleanOption(values, 'stage', true),
482
+ };
483
+ }
484
+ function sourceListTargetFromArgs(argv) {
485
+ const { values } = parseArgs(argv);
486
+ return resolve(stringOption(values, 'target') ?? process.cwd());
487
+ }
488
+ async function runSourceCommand(argv) {
489
+ const [subcommand, ...subcommandArgv] = argv;
490
+ if (!subcommand) {
491
+ throw new Error(sourceUsage());
492
+ }
493
+ if (subcommand === 'list') {
494
+ console.log(renderBanner());
495
+ const target = sourceListTargetFromArgs(subcommandArgv);
496
+ console.log(renderSourceList(await listSources(target)));
497
+ return;
498
+ }
499
+ if (subcommand === 'sync') {
500
+ console.log(renderBanner());
501
+ const options = sourceSyncOptionsFromArgs(subcommandArgv);
502
+ await syncSources(options);
503
+ outro(`Synced source manifest in ${options.target}.`);
504
+ return;
505
+ }
506
+ if (subcommand === 'add') {
507
+ const options = await addRepoOptionsFromArgs(subcommandArgv);
508
+ if (!options) {
509
+ throw new Error('Usage: wpmoo source add --repo-url <url> [--source-type private|oca|external]');
510
+ }
511
+ console.log(renderBanner());
512
+ await addModuleRepo(options);
513
+ outro(`Added source repo under ${renderedSourceRepoPath(options.target, options.sourceType ?? 'private', options.repoPath)}.`);
514
+ return;
515
+ }
516
+ if (subcommand === 'remove') {
517
+ const options = removeRepoOptionsFromArgs(subcommandArgv);
518
+ if (!options) {
519
+ throw new Error('Usage: wpmoo source remove --repo <name> [--source-type private|oca|external]');
520
+ }
521
+ console.log(renderBanner());
522
+ await removeModuleRepo(options);
523
+ outro(`Removed source repo ${options.repoPath} from ${options.target}.`);
524
+ return;
525
+ }
526
+ throw new Error(sourceUsage());
527
+ }
473
528
  async function confirmSafeResetFromMenu(options) {
474
529
  note(renderSafeResetPreview(options.target, options.stage), 'Safe reset preview');
475
530
  const confirmed = await confirm({
@@ -770,6 +825,10 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
770
825
  outro(`Removed source repo ${promptedOptions.repoPath} from ${promptedOptions.target}.`);
771
826
  return;
772
827
  }
828
+ if (route.command === 'source') {
829
+ await runSourceCommand(route.argv);
830
+ return;
831
+ }
773
832
  if (route.command === 'add-module') {
774
833
  const options = await addModuleOptionsFromArgs(route.argv);
775
834
  if (options) {
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@ import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './
5
5
  import { dailyActionScripts } from './daily-actions.js';
6
6
  import { defaultPostgresVersion } from './external-templates.js';
7
7
  import { defaultOdooVersion, markerPath } from './environment.js';
8
+ import { listGitmoduleSources, readSourceManifest, sourceManifestPath, } from './source-manifest.js';
8
9
  const realCommandRunner = async (command, args, options) => {
9
10
  const result = await execa(command, args, { cwd: options.cwd });
10
11
  return { stdout: result.stdout, stderr: result.stderr };
@@ -88,19 +89,42 @@ function inferPostgresVersion(metadata, odooVersion, env) {
88
89
  }
89
90
  return defaultPostgresVersion(odooVersion);
90
91
  }
92
+ function normalizeSourceType(value) {
93
+ if (value === 'oca' || value === 'external' || value === 'private') {
94
+ return value;
95
+ }
96
+ return 'private';
97
+ }
98
+ function sourceRepoPath(type, path) {
99
+ return `odoo/custom/src/${type}/${path}`;
100
+ }
101
+ function entryKey(type, path) {
102
+ return `${type}:${path}`;
103
+ }
91
104
  function sourceReposFromMetadata(metadata) {
92
105
  const sourceRepos = metadata.sourceRepos;
93
106
  if (!Array.isArray(sourceRepos))
94
107
  return [];
95
- return sourceRepos.map((repo, index) => {
108
+ return sourceRepos
109
+ .map((repo, index) => {
96
110
  if (!isRecord(repo) || typeof repo.path !== 'string' || !repo.path.trim()) {
97
111
  throw new Error(`Invalid sourceRepos entry in .wpmoo/odoo.json at index ${index}`);
98
112
  }
99
113
  return {
100
114
  url: typeof repo.url === 'string' ? repo.url : '',
101
115
  path: repo.path.trim(),
102
- addons: Array.isArray(repo.addons) ? repo.addons.filter((addon) => typeof addon === 'string') : [],
116
+ addons: Array.isArray(repo.addons)
117
+ ? repo.addons.filter((addon) => typeof addon === 'string')
118
+ : [],
119
+ sourceType: normalizeSourceType(repo.sourceType),
103
120
  };
121
+ })
122
+ .filter((repo) => repo.path)
123
+ .sort((left, right) => {
124
+ const typeOrder = left.sourceType.localeCompare(right.sourceType);
125
+ if (typeOrder !== 0)
126
+ return typeOrder;
127
+ return left.path.localeCompare(right.path);
104
128
  });
105
129
  }
106
130
  async function readMetadata(target) {
@@ -141,7 +165,7 @@ function isNotGitCheckoutError(error) {
141
165
  }
142
166
  function isSourceRepoSubmodule(path, sourceRepos) {
143
167
  return sourceRepos.some((repo) => {
144
- const sourcePath = `odoo/custom/src/private/${repo.path}`;
168
+ const sourcePath = sourceRepoPath(repo.sourceType ?? 'private', repo.path);
145
169
  return path === sourcePath || path.startsWith(`${sourcePath}/`);
146
170
  });
147
171
  }
@@ -165,6 +189,47 @@ function sourceSubmoduleStatusErrors(output, sourceRepos) {
165
189
  }
166
190
  return errors;
167
191
  }
192
+ function manifestEntryToKey(entry) {
193
+ return entryKey(entry.type, entry.path);
194
+ }
195
+ function manifestRepoToKey(repo) {
196
+ return entryKey(normalizeSourceType(repo.sourceType), repo.path);
197
+ }
198
+ function formatKeyForPath(key) {
199
+ const [sourceType, ...pathParts] = key.split(':');
200
+ return sourceRepoPath(sourceType, pathParts.join(':'));
201
+ }
202
+ function checkSourceConsistency(sourceRepos, manifestEntries, gitmoduleSources, manifestExists) {
203
+ if (!manifestExists) {
204
+ return [];
205
+ }
206
+ const errors = [];
207
+ const metadataEntries = new Map();
208
+ for (const repo of sourceRepos) {
209
+ metadataEntries.set(manifestRepoToKey(repo), repo);
210
+ }
211
+ const manifestMap = new Map();
212
+ for (const entry of manifestEntries) {
213
+ manifestMap.set(manifestEntryToKey(entry), entry);
214
+ }
215
+ const gitmoduleSet = new Set(gitmoduleSources.map((source) => manifestEntryToKey({ type: source.type, path: source.path })));
216
+ const sortedMetadataKeys = [...metadataEntries.keys()].sort();
217
+ const sortedManifestKeys = [...manifestMap.keys()].sort();
218
+ for (const key of sortedMetadataKeys) {
219
+ if (!manifestMap.has(key)) {
220
+ errors.push(`Metadata source entry missing in manifest: ${formatKeyForPath(key)}`);
221
+ }
222
+ }
223
+ for (const key of sortedManifestKeys) {
224
+ if (!metadataEntries.has(key)) {
225
+ errors.push(`Manifest source entry missing in metadata: ${formatKeyForPath(key)}`);
226
+ }
227
+ if (!gitmoduleSet.has(key)) {
228
+ errors.push(`Manifest source path missing in .gitmodules: ${formatKeyForPath(key)}`);
229
+ }
230
+ }
231
+ return errors;
232
+ }
168
233
  export async function runDoctor(target = process.cwd(), runner = realCommandRunner) {
169
234
  const lines = ['WPMoo doctor'];
170
235
  const errors = [];
@@ -227,12 +292,27 @@ export async function runDoctor(target = process.cwd(), runner = realCommandRunn
227
292
  }
228
293
  const sourceRepos = sourceReposFromMetadata(metadata);
229
294
  for (const repo of sourceRepos) {
230
- const relativePath = `odoo/custom/src/private/${repo.path}`;
231
- if (!(await exists(join(target, relativePath)))) {
295
+ const relativePath = sourceRepoPath(normalizeSourceType(repo.sourceType), repo.path);
296
+ if (!(await exists(join(target, relativePath))) && repo.path) {
232
297
  errors.push(`Missing source repo path: ${relativePath}`);
233
298
  }
234
299
  }
235
300
  lines.push(`OK source repos ${sourceRepos.length} checked`);
301
+ const manifestPath = join(target, sourceManifestPath);
302
+ const hasManifest = await exists(manifestPath);
303
+ let manifestEntries = [];
304
+ if (hasManifest) {
305
+ try {
306
+ manifestEntries = (await readSourceManifest(target)).sources;
307
+ }
308
+ catch (error) {
309
+ errors.push(`Failed to read source manifest ${sourceManifestPath}: ${errorMessage(error)}`);
310
+ }
311
+ }
312
+ const gitmoduleSources = await listGitmoduleSources(target);
313
+ if (errors.length === 0 || manifestEntries.length > 0) {
314
+ errors.push(...checkSourceConsistency(sourceRepos, manifestEntries, gitmoduleSources, hasManifest));
315
+ }
236
316
  if (env) {
237
317
  const httpPort = validatePort('HTTP_PORT', env, errors);
238
318
  const geventPort = validatePort('GEVENT_PORT', env, errors);
@@ -48,7 +48,7 @@ function normalizeMetadataSourceRepo(repo) {
48
48
  const url = typeof candidate.url === 'string' ? candidate.url : '';
49
49
  const addons = Array.isArray(candidate.addons) ? candidate.addons.filter((item) => typeof item === 'string') : [];
50
50
  const sourceType = normalizeSourceType(typeof candidate.sourceType === 'string' ? candidate.sourceType : undefined);
51
- if (!path || !url) {
51
+ if (!path) {
52
52
  return undefined;
53
53
  }
54
54
  return { ...candidate, path, url, addons, sourceType };
@@ -84,13 +84,20 @@ export async function readEnvironmentMetadata(target) {
84
84
  return undefined;
85
85
  }
86
86
  }
87
- async function writeEnvironmentMetadata(target, metadata) {
87
+ export async function writeEnvironmentMetadata(target, metadata) {
88
88
  const content = `${JSON.stringify({
89
89
  ...metadata,
90
90
  sourceRepos: metadata.sourceRepos.map(sourceRepoWithType),
91
91
  }, null, 2)}\n`;
92
92
  await writeFile(join(target, markerPath), content, 'utf8');
93
93
  }
94
+ export async function replaceSourceRepos(target, sourceRepos) {
95
+ const metadata = await readEnvironmentMetadata(target);
96
+ if (!metadata)
97
+ return;
98
+ metadata.sourceRepos = withoutPathDuplicates(sourceRepos.map((repo) => sourceRepoWithType(repo)));
99
+ await writeEnvironmentMetadata(target, metadata);
100
+ }
94
101
  export async function upsertSourceRepoMetadata(target, sourceRepo) {
95
102
  const metadata = await readEnvironmentMetadata(target);
96
103
  if (!metadata)
package/dist/help.js CHANGED
@@ -9,6 +9,10 @@ Usage:
9
9
  npx @wpmoo/odoo status
10
10
  npx @wpmoo/odoo add-repo --repo-url <url> [--source-type private|oca|external]
11
11
  npx @wpmoo/odoo remove-repo --repo <name>
12
+ npx @wpmoo/odoo source list
13
+ npx @wpmoo/odoo source sync
14
+ npx @wpmoo/odoo source add --repo-url <url> [--source-type private|oca|external]
15
+ npx @wpmoo/odoo source remove --repo <name> [--source-type private|oca|external]
12
16
  npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
13
17
  npx @wpmoo/odoo remove-module --repo <source-repo> --module <module-name>
14
18
  npx @wpmoo/odoo reset
@@ -92,6 +96,9 @@ Task recipes:
92
96
  npx @wpmoo/odoo
93
97
  Add source repo:
94
98
  npx @wpmoo/odoo add-repo --repo-url <url> --source-type oca
99
+ Inspect and sync source manifest:
100
+ npx @wpmoo/odoo source list
101
+ npx @wpmoo/odoo source sync
95
102
  Add module:
96
103
  npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
97
104
  Run tests:
@@ -5,6 +5,7 @@ import { readEnvironmentMetadata, removeSourceRepoMetadata, upsertSourceRepoMeta
5
5
  import { ensureRemoteHasBranch, ensureSubmodule, hasUncommittedChanges, realGit, removeSubmodule, stageAll, } from './git.js';
6
6
  import { isValidPathSegment, validateRepoPath } from './path-validation.js';
7
7
  import { inferRepoPath } from './repo-url.js';
8
+ import { removeSourceManifestEntry, upsertSourceManifestEntry } from './source-manifest.js';
8
9
  export const addonsYamlHeader = `# Addons activated from source submodules.
9
10
  #
10
11
  # Source repos are managed as Git submodules under odoo/custom/src/private (product code).
@@ -106,6 +107,13 @@ export async function addModuleRepo(options, git = realGit) {
106
107
  addons: [repoPath],
107
108
  sourceType,
108
109
  });
110
+ await upsertSourceManifestEntry(options.target, {
111
+ type: sourceType,
112
+ path: repoPath,
113
+ url: options.repoUrl,
114
+ branch: options.odooVersion,
115
+ addons: [repoPath],
116
+ });
109
117
  if (!(await isComposeEnvironment(options.target))) {
110
118
  const addonsYaml = await readAddonsYaml(options.target);
111
119
  if (sourceType === 'private') {
@@ -134,6 +142,9 @@ export async function removeModuleRepo(options, git = realGit) {
134
142
  }
135
143
  await removeSubmodule(git, options.target, submodulePath);
136
144
  await removeSourceRepoMetadata(options.target, repoPath, resolvedSourceType);
145
+ if (resolvedSourceType) {
146
+ await removeSourceManifestEntry(options.target, resolvedSourceType, repoPath);
147
+ }
137
148
  if (!(await isComposeEnvironment(options.target))) {
138
149
  const addonsYaml = await readAddonsYaml(options.target);
139
150
  if (resolvedSourceType === 'private') {
@@ -5,8 +5,9 @@ import { applyExternalAsset, writeTextFile } from './external-assets.js';
5
5
  import { plannedExternalAssetOptions, renderComposeEnvExample } from './external-templates.js';
6
6
  import { realGit, stageAll } from './git.js';
7
7
  import { isValidPathSegment, validateAddonName, validateRepoPath } from './path-validation.js';
8
- import { 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';
10
11
  const safeResetProtectedPaths = [
11
12
  'data',
12
13
  'backups',
@@ -109,18 +110,18 @@ function parseRepoPathsFromAddonsYaml(addonsYaml) {
109
110
  .filter((repoPath) => repoPath && isValidPathSegment(repoPath))
110
111
  .map(validateRepoPath);
111
112
  }
112
- async function readSubmoduleUrl(target, repoPath) {
113
+ async function readSubmoduleUrl(target, repoPath, sourceType) {
113
114
  const safeRepoPath = validateRepoPath(repoPath);
114
115
  try {
115
116
  const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
116
- const escapedPath = `odoo/custom/src/private/${safeRepoPath}`;
117
+ const escapedPath = `odoo/custom/src/${sourceType}/${safeRepoPath}`;
117
118
  const sections = gitmodules.split(/\n(?=\[submodule )/);
118
119
  const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
119
120
  const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
120
- return url || `odoo/custom/src/private/${safeRepoPath}`;
121
+ return url || `odoo/custom/src/${sourceType}/${safeRepoPath}`;
121
122
  }
122
123
  catch {
123
- return `odoo/custom/src/private/${safeRepoPath}`;
124
+ return `odoo/custom/src/${sourceType}/${safeRepoPath}`;
124
125
  }
125
126
  }
126
127
  async function pathExists(path) {
@@ -135,18 +136,33 @@ async function pathExists(path) {
135
136
  async function inferOptions(target) {
136
137
  const metadata = await readEnvironmentMetadata(target);
137
138
  const addonsYaml = await readAddonsYaml(target);
138
- const moduleRepos = await listModuleRepos(target);
139
+ const gitmoduleSources = await listGitmoduleSources(target);
139
140
  const addonRepos = parseRepoPathsFromAddonsYaml(addonsYaml);
140
- const metadataRepoPaths = metadata?.sourceRepos.map((repo) => repo.path).filter((repoPath) => isValidPathSegment(repoPath)).map(validateRepoPath) ??
141
- [];
142
- const repoPaths = [
143
- ...new Set([...metadataRepoPaths, ...moduleRepos, ...addonRepos]),
144
- ];
145
- const product = metadata?.product ?? repoPaths[0] ?? titleFromTarget(target);
146
- const sourceRepos = await Promise.all(repoPaths.map(async (repoPath) => ({
147
- path: repoPath,
148
- url: metadata?.sourceRepos.find((repo) => repo.path === repoPath)?.url ?? (await readSubmoduleUrl(target, repoPath)),
149
- 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),
150
166
  })));
151
167
  return {
152
168
  product,
package/dist/scaffold.js CHANGED
@@ -6,14 +6,19 @@ import { plannedExternalAssetOptions, renderComposeEnvExample } from './external
6
6
  import { cloneRepository, ensureSubmodule, ensureRemoteHasBranch, realGit, stageAll, syncSubmodules, } from './git.js';
7
7
  import { renderAgents, renderAppstoreRelease, renderGitignore, renderMooDelegationScript, renderPlaceholder, renderReadme, } from './templates.js';
8
8
  import { validateAddonName, validateRepoPath } from './path-validation.js';
9
+ import { renderSourceManifest, sourceManifestEntriesFromMetadata } from './source-manifest.js';
9
10
  function validateSourceRepo(repo) {
10
11
  const path = validateRepoPath(repo.path);
11
12
  return {
12
13
  ...repo,
13
14
  path,
15
+ sourceType: repo.sourceType ?? 'private',
14
16
  addons: repo.addons.map(validateAddonName),
15
17
  };
16
18
  }
19
+ function sourceRepoSubmodulePath(repo) {
20
+ return `odoo/custom/src/${repo.sourceType ?? 'private'}/${repo.path}`;
21
+ }
17
22
  function validateScaffoldOptions(options) {
18
23
  return {
19
24
  ...options,
@@ -56,6 +61,10 @@ export function generatedFiles(options) {
56
61
  { path: 'README.md', content: renderReadme(safeOptions) },
57
62
  { path: 'AGENTS.md', content: renderAgents(safeOptions) },
58
63
  { path: 'docs/appstore-release.md', content: renderAppstoreRelease(safeOptions) },
64
+ {
65
+ path: 'odoo/custom/manifests/sources.yaml',
66
+ content: renderSourceManifest(sourceManifestEntriesFromMetadata(safeOptions.sourceRepos, safeOptions.odooVersion)),
67
+ },
59
68
  ];
60
69
  return [
61
70
  ...files,
@@ -113,7 +122,7 @@ export async function scaffold(options, git = realGit) {
113
122
  const externalAssets = plannedExternalAssetOptions(safeOptions);
114
123
  const plannedCommands = [
115
124
  ...externalAssets.map((assetOptions) => renderExternalAssetCommand(assetOptions)),
116
- ...safeOptions.sourceRepos.map((repo) => `git submodule add -b ${safeOptions.odooVersion} ${repo.url} 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
+ }
@@ -0,0 +1,338 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isValidPathSegment, validateRepoPath } from './path-validation.js';
4
+ const validSourceTypes = ['private', 'oca', 'external'];
5
+ export const sourceManifestPath = 'odoo/custom/manifests/sources.yaml';
6
+ function fail(message) {
7
+ throw new Error(`Invalid source manifest ${sourceManifestPath}: ${message}`);
8
+ }
9
+ export function normalizeSourceType(value) {
10
+ return validSourceTypes.includes(value) ? value : 'private';
11
+ }
12
+ function dedupeAndSort(entries) {
13
+ const uniqueByTypePath = new Map();
14
+ for (const entry of entries) {
15
+ uniqueByTypePath.set(`${entry.type}:${entry.path}`, entry);
16
+ }
17
+ return [...uniqueByTypePath.values()].sort((left, right) => {
18
+ const typeOrder = left.type.localeCompare(right.type);
19
+ if (typeOrder !== 0)
20
+ return typeOrder;
21
+ return left.path.localeCompare(right.path);
22
+ });
23
+ }
24
+ function stripInlineComment(raw) {
25
+ let inSingle = false;
26
+ let inDouble = false;
27
+ let escaped = false;
28
+ for (let index = 0; index < raw.length; index += 1) {
29
+ const char = raw[index];
30
+ if (escaped) {
31
+ escaped = false;
32
+ continue;
33
+ }
34
+ if (char === '\\') {
35
+ escaped = true;
36
+ continue;
37
+ }
38
+ if (char === "'" && !inDouble) {
39
+ inSingle = !inSingle;
40
+ }
41
+ else if (char === '"' && !inSingle) {
42
+ inDouble = !inDouble;
43
+ }
44
+ else if (char === '#' && !inSingle && !inDouble) {
45
+ return raw.slice(0, index).trimEnd();
46
+ }
47
+ }
48
+ return raw;
49
+ }
50
+ function parseScalar(raw) {
51
+ const trimmed = raw.trim();
52
+ if (!trimmed)
53
+ return '';
54
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
55
+ return JSON.parse(trimmed);
56
+ }
57
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
58
+ return trimmed.slice(1, -1).replace(/\\'/g, "'");
59
+ }
60
+ return trimmed;
61
+ }
62
+ function leadingSpaces(line) {
63
+ return line.length - line.trimStart().length;
64
+ }
65
+ function parseSourcesBlock(content) {
66
+ const lines = content.split(/\r?\n/).map((line, index) => ({
67
+ lineNumber: index + 1,
68
+ line: line.replace(/\t/g, ' '),
69
+ trimmedLine: line.replace(/\t/g, ' ').trim(),
70
+ }));
71
+ const sourcesKeywordLine = lines.find((line) => /^\s*sources\s*:\s*(?:\[[^\]]*\])?\s*$/.test(stripInlineComment(line.line)));
72
+ if (!sourcesKeywordLine) {
73
+ fail('Missing top-level sources entry.');
74
+ }
75
+ const rawSourcesValue = stripInlineComment(sourcesKeywordLine.line).replace(/^\s*sources\s*:\s*/, '');
76
+ if (rawSourcesValue === '[]') {
77
+ return { sources: [] };
78
+ }
79
+ if (rawSourcesValue && rawSourcesValue !== '') {
80
+ fail(`Unexpected non-list value on line ${sourcesKeywordLine.lineNumber}: sources`);
81
+ }
82
+ const sourceLines = lines.slice(sourcesKeywordLine.lineNumber);
83
+ const parsed = [];
84
+ let index = 0;
85
+ while (index < sourceLines.length) {
86
+ const headerLine = sourceLines[index];
87
+ const noCommentHeader = stripInlineComment(headerLine.line);
88
+ if (!noCommentHeader.trim()) {
89
+ index += 1;
90
+ continue;
91
+ }
92
+ const itemMatch = /^\s*-\s*type:\s*(.+)\s*$/.exec(noCommentHeader);
93
+ if (!itemMatch) {
94
+ index += 1;
95
+ continue;
96
+ }
97
+ const item = {
98
+ type: normalizeSourceType(parseScalar(itemMatch[1])),
99
+ path: '',
100
+ url: '',
101
+ addons: [],
102
+ };
103
+ index += 1;
104
+ while (index < sourceLines.length) {
105
+ const rawLine = sourceLines[index];
106
+ const noComment = stripInlineComment(rawLine.line);
107
+ const trimmed = noComment.trim();
108
+ if (!trimmed) {
109
+ index += 1;
110
+ continue;
111
+ }
112
+ if (/^\s*-\s*type:\s*/.test(noComment)) {
113
+ break;
114
+ }
115
+ const pathMatch = /^\s*path:\s*(.+)\s*$/.exec(noComment);
116
+ if (pathMatch) {
117
+ item.path = validateRepoPath(parseScalar(pathMatch[1]));
118
+ index += 1;
119
+ continue;
120
+ }
121
+ const urlMatch = /^\s*url:\s*(.+)\s*$/.exec(noComment);
122
+ if (urlMatch) {
123
+ item.url = parseScalar(urlMatch[1]);
124
+ index += 1;
125
+ continue;
126
+ }
127
+ const branchMatch = /^\s*branch:\s*(.+)\s*$/.exec(noComment);
128
+ if (branchMatch) {
129
+ item.branch = parseScalar(branchMatch[1]);
130
+ index += 1;
131
+ continue;
132
+ }
133
+ const addonsLine = /^\s*addons:\s*$/.exec(noComment);
134
+ if (addonsLine) {
135
+ const baseIndent = leadingSpaces(rawLine.line) + 2;
136
+ index += 1;
137
+ while (index < sourceLines.length) {
138
+ const addonRaw = stripInlineComment(sourceLines[index].line);
139
+ const addonTrimmed = addonRaw.trim();
140
+ if (!addonTrimmed) {
141
+ index += 1;
142
+ continue;
143
+ }
144
+ const addonMatch = /^\s*-\s*(.+)\s*$/.exec(addonRaw);
145
+ if (!addonMatch) {
146
+ break;
147
+ }
148
+ if (leadingSpaces(addonRaw) < baseIndent) {
149
+ break;
150
+ }
151
+ const addon = parseScalar(addonMatch[1]);
152
+ if (addon) {
153
+ item.addons.push(addon);
154
+ }
155
+ index += 1;
156
+ }
157
+ continue;
158
+ }
159
+ fail(`Unexpected source entry field on line ${rawLine.lineNumber}: ${trimmed}`);
160
+ }
161
+ if (!item.path) {
162
+ fail(`Manifest entry missing path at line ${headerLine.lineNumber}`);
163
+ }
164
+ if (!item.url) {
165
+ fail(`Manifest entry missing url for ${item.type}:${item.path} at line ${headerLine.lineNumber}`);
166
+ }
167
+ if (!isValidPathSegment(item.path)) {
168
+ fail(`Invalid manifest path at line ${headerLine.lineNumber}: ${item.path}`);
169
+ }
170
+ if (item.addons.length === 0) {
171
+ item.addons.push(item.path);
172
+ }
173
+ item.addons = [...new Set(item.addons.map((addon) => validateRepoPath(addon)))].sort();
174
+ parsed.push(item);
175
+ }
176
+ return { sources: dedupeAndSort(parsed.filter((entry) => isValidPathSegment(entry.path))) };
177
+ }
178
+ export async function readSourceManifest(target) {
179
+ try {
180
+ const content = await readFile(join(target, sourceManifestPath), 'utf8');
181
+ return parseSourcesBlock(content);
182
+ }
183
+ catch (error) {
184
+ if (error.code === 'ENOENT') {
185
+ return { sources: [] };
186
+ }
187
+ throw error;
188
+ }
189
+ }
190
+ function renderQuoted(value) {
191
+ return JSON.stringify(value);
192
+ }
193
+ export function renderSourceManifest(entries) {
194
+ const normalized = dedupeAndSort(entries).map((entry) => {
195
+ const addons = [...new Set(entry.addons.map((addon) => validateRepoPath(addon)))].sort();
196
+ return {
197
+ type: entry.type,
198
+ path: validateRepoPath(entry.path),
199
+ url: entry.url.trim(),
200
+ branch: entry.branch?.trim(),
201
+ addons: addons.length ? addons : [validateRepoPath(entry.path)],
202
+ };
203
+ });
204
+ if (normalized.length === 0) {
205
+ return 'sources: []\n';
206
+ }
207
+ const body = normalized
208
+ .map((entry) => {
209
+ const lines = [
210
+ ` - type: ${renderQuoted(entry.type)}`,
211
+ ` path: ${renderQuoted(entry.path)}`,
212
+ ` url: ${renderQuoted(entry.url)}`,
213
+ ];
214
+ lines.push(` branch: ${renderQuoted(entry.branch ?? '')}`);
215
+ lines.push(' addons:');
216
+ for (const addon of entry.addons) {
217
+ lines.push(` - ${renderQuoted(addon)}`);
218
+ }
219
+ return lines.join('\n');
220
+ })
221
+ .join('\n');
222
+ return `sources:\n${body}\n`;
223
+ }
224
+ export async function writeSourceManifest(target, entries) {
225
+ const content = renderSourceManifest(entries);
226
+ const path = join(target, sourceManifestPath);
227
+ await mkdir(join(path, '..'), { recursive: true });
228
+ await writeFile(path, content, 'utf8');
229
+ }
230
+ function entryKey(type, path) {
231
+ return `${type}:${path}`;
232
+ }
233
+ export async function upsertSourceManifestEntry(target, entry) {
234
+ const manifest = await readSourceManifest(target);
235
+ const normalized = {
236
+ ...entry,
237
+ type: normalizeSourceType(entry.type),
238
+ path: validateRepoPath(entry.path),
239
+ };
240
+ const next = dedupeAndSort(manifest.sources.filter((current) => entryKey(current.type, current.path) !== entryKey(normalized.type, normalized.path)));
241
+ next.push(normalized);
242
+ await writeSourceManifest(target, next);
243
+ }
244
+ export async function removeSourceManifestEntry(target, type, path) {
245
+ const manifest = await readSourceManifest(target);
246
+ const key = entryKey(normalizeSourceType(type), validateRepoPath(path));
247
+ const next = manifest.sources.filter((entry) => entryKey(entry.type, entry.path) !== key);
248
+ await writeSourceManifest(target, next);
249
+ }
250
+ export function sourceManifestEntriesFromMetadata(sourceRepos, fallbackBranch) {
251
+ return sourceRepos.map((repo) => ({
252
+ type: normalizeSourceType(repo.sourceType),
253
+ path: validateRepoPath(repo.path),
254
+ url: repo.url.trim(),
255
+ branch: fallbackBranch,
256
+ addons: repo.addons.length ? [...new Set(repo.addons.map((addon) => validateRepoPath(addon)))] : [validateRepoPath(repo.path)],
257
+ }));
258
+ }
259
+ export async function listGitmoduleSources(target) {
260
+ try {
261
+ const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
262
+ const lines = gitmodules.split(/\r?\n/);
263
+ const locations = [];
264
+ const pathRegex = /^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/(.+)\s*$/;
265
+ const urlRegex = /^\s*url\s*=\s*(.+)\s*$/;
266
+ let pending;
267
+ for (const line of lines) {
268
+ const parsedPath = line.match(pathRegex);
269
+ if (parsedPath) {
270
+ const sourceType = parsedPath[1];
271
+ const repoPath = parsedPath[2]?.trim() ?? '';
272
+ if (!repoPath || !isValidPathSegment(repoPath)) {
273
+ pending = undefined;
274
+ continue;
275
+ }
276
+ pending = {
277
+ type: sourceType,
278
+ path: validateRepoPath(repoPath),
279
+ url: '',
280
+ };
281
+ continue;
282
+ }
283
+ const parsedUrl = line.match(urlRegex);
284
+ if (!parsedUrl || !pending) {
285
+ continue;
286
+ }
287
+ const url = parseScalar(parsedUrl[1]);
288
+ if (url) {
289
+ locations.push({ ...pending, url });
290
+ }
291
+ pending = undefined;
292
+ }
293
+ return locations;
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ }
299
+ export function syncManifestFromMetadataAndGitmodules(sourceRepos, fallbackBranch, gitmodules = []) {
300
+ const byGitmodule = new Map();
301
+ for (const location of gitmodules) {
302
+ byGitmodule.set(`${normalizeSourceType(location.type)}:${location.path}`, location);
303
+ }
304
+ const entries = [];
305
+ for (const repo of sourceRepos) {
306
+ const normalized = {
307
+ type: normalizeSourceType(repo.sourceType),
308
+ path: validateRepoPath(repo.path),
309
+ url: repo.url.trim() || byGitmodule.get(`${normalizeSourceType(repo.sourceType)}:${repo.path}`)?.url || '',
310
+ branch: fallbackBranch,
311
+ addons: repo.addons.map(validateRepoPath),
312
+ };
313
+ entries.push(normalized);
314
+ }
315
+ for (const location of gitmodules) {
316
+ const key = `${location.type}:${location.path}`;
317
+ if (entries.some((entry) => `${entry.type}:${entry.path}` === key)) {
318
+ continue;
319
+ }
320
+ entries.push({
321
+ type: location.type,
322
+ path: location.path,
323
+ url: location.url,
324
+ branch: fallbackBranch,
325
+ addons: [location.path],
326
+ });
327
+ }
328
+ return dedupeAndSort(entries);
329
+ }
330
+ export function sourceReposFromManifest(entries) {
331
+ const normalized = dedupeAndSort(entries);
332
+ return normalized.map((entry) => ({
333
+ sourceType: entry.type,
334
+ path: validateRepoPath(entry.path),
335
+ url: entry.url,
336
+ addons: entry.addons.length ? [...new Set(entry.addons.map((addon) => validateRepoPath(addon)))] : [validateRepoPath(entry.path)],
337
+ }));
338
+ }
package/dist/templates.js CHANGED
@@ -20,6 +20,12 @@ function allAddons(options) {
20
20
  function hasSourceRepos(options) {
21
21
  return options.sourceRepos.length > 0;
22
22
  }
23
+ function sourceTypeOf(repo) {
24
+ return repo.sourceType ?? 'private';
25
+ }
26
+ function sourceRepoRelativePath(repo) {
27
+ return `odoo/custom/src/${sourceTypeOf(repo)}/${repo.path}`;
28
+ }
23
29
  function repositoryLayout(options) {
24
30
  const sourceRepoRows = hasSourceRepos(options)
25
31
  ? options.sourceRepos
@@ -91,11 +97,14 @@ ${repo.url}
91
97
  Submodule path:
92
98
 
93
99
  \`\`\`text
94
- odoo/custom/src/private/${repo.path}
100
+ ${sourceRepoRelativePath(repo)}
95
101
  \`\`\`
96
102
 
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.
103
+ Source manifest entry:
104
+
105
+ \`\`\`text
106
+ odoo/custom/manifests/sources.yaml
107
+ \`\`\`
99
108
 
100
109
  Expected addon layout:
101
110
 
@@ -542,7 +551,7 @@ export function renderAddonsYaml(options) {
542
551
  # Source repos are managed as Git submodules under odoo/custom/src/private.
543
552
  # Do not duplicate these same repos in repos.yaml.
544
553
 
545
- ${options.sourceRepos.map((repo) => `private/${repo.path}:\n${yamlList(repo.addons)}`).join('\n\n')}
554
+ ${options.sourceRepos.map((repo) => `${sourceTypeOf(repo)}/${repo.path}:\n${yamlList(repo.addons)}`).join('\n\n')}
546
555
  `;
547
556
  }
548
557
  export function renderReposYaml(options) {
@@ -551,7 +560,7 @@ export function renderReposYaml(options) {
551
560
  # Project source repositories are intentionally not listed here because
552
561
  # they are pinned as Git submodules:
553
562
  #
554
- ${options.sourceRepos.map((repo) => `# - private/${repo.path}`).join('\n')}
563
+ ${options.sourceRepos.map((repo) => `# - ${sourceTypeOf(repo)}/${repo.path}`).join('\n')}
555
564
  #
556
565
  # Keep this file for upstream/OCA repositories that should be aggregated.
557
566
 
@@ -628,7 +637,7 @@ export function renderAgents(options) {
628
637
  source directory below for this environment:
629
638
 
630
639
  \`\`\`text
631
- ${options.sourceRepos.map((repo) => `odoo/custom/src/private/${repo.path}`).join('\n')}
640
+ ${options.sourceRepos.map(sourceRepoRelativePath).join('\n')}
632
641
  \`\`\`
633
642
 
634
643
  ${repoDuplicationNote()}`
@@ -23,6 +23,7 @@ not validate staging or production deployments.
23
23
  | Doctor checks | Metadata, compose files, scripts, source repo paths, and local tooling checks behave as expected. | `npx @wpmoo/odoo doctor` or `./moo doctor` |
24
24
  | Generated Postgres checks | For PostgreSQL 18 environments, doctor validates db mount targets avoid old PG image-specific paths. | `npx @wpmoo/odoo doctor` |
25
25
  | Source repo add/remove | Source repository registration and submodule lifecycle behave correctly. | `npx @wpmoo/odoo add-repo ...`, `npx @wpmoo/odoo remove-repo ...` |
26
+ | Source manifest sync | Source repo metadata, `.gitmodules`, and `odoo/custom/manifests/sources.yaml` stay aligned. | `npx @wpmoo/odoo source list`, `npx @wpmoo/odoo source sync` |
26
27
  | Module add/remove | Module registration changes are applied to the selected source repo config. | `npx @wpmoo/odoo add-module ...`, `npx @wpmoo/odoo remove-module ...` |
27
28
  | 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` |
28
29
  | 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 ...` |
@@ -79,6 +80,28 @@ odoo/custom/patches/
79
80
  odoo/custom/manifests/
80
81
  ```
81
82
 
83
+ ## Source manifest checks
84
+
85
+ Generated environments include `odoo/custom/manifests/sources.yaml`. The manifest
86
+ records each source repository's type (`private`, `oca`, or `external`), path,
87
+ URL, Odoo branch, and addon boundaries.
88
+
89
+ Use `source list` to inspect the current manifest view:
90
+
91
+ ```bash
92
+ npx @wpmoo/odoo source list
93
+ ```
94
+
95
+ Use `source sync` after manual submodule or metadata repair to regenerate the
96
+ manifest and normalize `.wpmoo/odoo.json` source entries:
97
+
98
+ ```bash
99
+ npx @wpmoo/odoo source sync
100
+ ```
101
+
102
+ `doctor` fails when manifest entries, metadata entries, and source submodule
103
+ paths diverge.
104
+
82
105
  ## Local verification commands
83
106
 
84
107
  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.59",
3
+ "version": "0.8.60",
4
4
  "description": "WPMoo Odoo lifecycle tooling for development, staging, and production workflows.",
5
5
  "type": "module",
6
6
  "repository": {