@wpmoo/toolkit 0.9.0

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.
Files changed (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +519 -0
  3. package/dist/addons-yaml.js +59 -0
  4. package/dist/args.js +259 -0
  5. package/dist/cli.js +1039 -0
  6. package/dist/cockpit/command-palette.js +23 -0
  7. package/dist/cockpit/command-registry.js +91 -0
  8. package/dist/cockpit/daily-prompts.js +177 -0
  9. package/dist/cockpit/menu.js +99 -0
  10. package/dist/cockpit/safety.js +22 -0
  11. package/dist/compose-layout.js +118 -0
  12. package/dist/daily-actions.js +190 -0
  13. package/dist/doctor.js +519 -0
  14. package/dist/environment-context.js +10 -0
  15. package/dist/environment-version.js +5 -0
  16. package/dist/environment.js +136 -0
  17. package/dist/external-assets.js +153 -0
  18. package/dist/external-templates.js +86 -0
  19. package/dist/git.js +98 -0
  20. package/dist/github.js +87 -0
  21. package/dist/help.js +157 -0
  22. package/dist/menu-navigation.js +67 -0
  23. package/dist/module-actions.js +114 -0
  24. package/dist/odoo-versions.js +1 -0
  25. package/dist/path-validation.js +50 -0
  26. package/dist/prompt-copy.js +8 -0
  27. package/dist/prompt-repositories.js +34 -0
  28. package/dist/prompts/index.js +174 -0
  29. package/dist/repo-actions.js +158 -0
  30. package/dist/repo-url.js +27 -0
  31. package/dist/repository-preflight.js +46 -0
  32. package/dist/safe-reset.js +217 -0
  33. package/dist/scaffold.js +161 -0
  34. package/dist/source-actions.js +65 -0
  35. package/dist/source-manifest.js +338 -0
  36. package/dist/status.js +239 -0
  37. package/dist/templates.js +758 -0
  38. package/dist/types.js +1 -0
  39. package/dist/update-check.js +106 -0
  40. package/dist/version.js +19 -0
  41. package/docs/assets/patreon-donate.png +0 -0
  42. package/docs/assets/wpmoo-banner.png +0 -0
  43. package/docs/external-resources.md +136 -0
  44. package/docs/generated-environment-verification.md +140 -0
  45. package/docs/handoff.md +29 -0
  46. package/package.json +65 -0
@@ -0,0 +1,23 @@
1
+ import { isPromptCancel, searchPrompt } from '../prompts/index.js';
2
+ import { searchCockpitCommands } from './command-registry.js';
3
+ const defaultSearchPrompt = (config) => searchPrompt(config);
4
+ function commandChoice(command) {
5
+ return {
6
+ value: command,
7
+ name: `${command.slashAlias} ${command.label}`,
8
+ description: command.description,
9
+ short: command.id,
10
+ };
11
+ }
12
+ export async function selectCockpitCommandFromPalette(options = {}) {
13
+ const prompt = options.prompt ?? defaultSearchPrompt;
14
+ const selected = await prompt({
15
+ message: 'Search commands',
16
+ pageSize: 10,
17
+ source: (term) => searchCockpitCommands(term).map(commandChoice),
18
+ });
19
+ if (isPromptCancel(selected)) {
20
+ throw new Error('Prompt was canceled.');
21
+ }
22
+ return selected;
23
+ }
@@ -0,0 +1,91 @@
1
+ const riskyCommandIds = new Set(['stop', 'resetdb', 'restore-snapshot', 'remove-repo', 'remove-module', 'safe-reset']);
2
+ function dailyCommand(command, category, label, description, aliases = []) {
3
+ return {
4
+ id: command,
5
+ slashAlias: `/${command}`,
6
+ category,
7
+ label,
8
+ description,
9
+ isRisky: riskyCommandIds.has(command),
10
+ target: {
11
+ kind: 'daily',
12
+ command,
13
+ },
14
+ aliases,
15
+ };
16
+ }
17
+ function internalCommand(id, category, label, description, aliases = []) {
18
+ return {
19
+ id,
20
+ slashAlias: `/${id}`,
21
+ category,
22
+ label,
23
+ description,
24
+ isRisky: riskyCommandIds.has(id),
25
+ target: {
26
+ kind: 'internal',
27
+ },
28
+ aliases,
29
+ };
30
+ }
31
+ export const cockpitCommands = [
32
+ dailyCommand('start', 'services', 'Start services', 'Start the Odoo development services.', ['up', 'compose up']),
33
+ dailyCommand('stop', 'services', 'Stop services', 'Stop the Odoo development services.', ['down', 'compose down']),
34
+ dailyCommand('restart', 'services', 'Restart services', 'Restart the Odoo development services.', ['reload']),
35
+ dailyCommand('logs', 'services', 'View logs', 'Stream logs for an Odoo environment service.', ['log', 'tail']),
36
+ dailyCommand('shell', 'services', 'Open shell', 'Open a shell inside the Odoo service container.', ['bash', 'terminal']),
37
+ dailyCommand('install', 'modules', 'Install module', 'Install one or more Odoo modules into a database.', ['install module']),
38
+ dailyCommand('update', 'modules', 'Update module', 'Update one or more Odoo modules in a database.', ['upgrade']),
39
+ dailyCommand('test', 'modules', 'Run tests', 'Run Odoo tests for one or more modules.', ['tests', 'pytest']),
40
+ dailyCommand('lint', 'modules', 'Run lint', 'Run the configured module lint checks.', ['check', 'quality']),
41
+ dailyCommand('pot', 'modules', 'Generate POT', 'Generate translation template files for a module.', ['translation', 'i18n']),
42
+ dailyCommand('psql', 'database', 'Open psql', 'Open a PostgreSQL prompt for an environment database.', ['postgres', 'sql']),
43
+ dailyCommand('snapshot', 'database', 'Create snapshot', 'Create a database snapshot.', ['backup', 'dump']),
44
+ dailyCommand('restore-snapshot', 'database', 'Restore snapshot', 'Restore a database from a named snapshot.', ['restore', 'snapshot restore']),
45
+ dailyCommand('resetdb', 'database', 'Reset database', 'Reset an environment database.', ['reset db', 'database reset']),
46
+ internalCommand('status', 'diagnostics', 'Environment status', 'Show a summary of the current environment state.', [
47
+ 'state',
48
+ 'summary',
49
+ ]),
50
+ internalCommand('doctor', 'diagnostics', 'Run doctor', 'Run environment diagnostics and report actionable issues.', [
51
+ 'diagnose',
52
+ 'health',
53
+ ]),
54
+ internalCommand('add-repo', 'repositories', 'Add source repo', 'Add a source repository as an environment submodule.', [
55
+ 'repository add',
56
+ 'source add',
57
+ ]),
58
+ internalCommand('remove-repo', 'repositories', 'Remove source repo', 'Remove a source repository from the environment.', ['repository remove', 'source remove']),
59
+ internalCommand('add-module', 'modules', 'Add module', 'Add a module folder to a source repository.', ['module add']),
60
+ internalCommand('remove-module', 'modules', 'Remove module', 'Remove a module folder from a source repository.', ['module remove']),
61
+ internalCommand('safe-reset', 'maintenance', 'Safe reset environment', 'Refresh generated environment files while preserving source repositories.', ['reset', 'refresh']),
62
+ internalCommand('exit', 'maintenance', 'Exit', 'Leave the command palette.', ['quit', 'back']),
63
+ ];
64
+ const defaultCommandIds = new Set(['start', 'logs', 'test', 'status', 'doctor', 'exit']);
65
+ export function normalizeCockpitSearchTerm(term) {
66
+ return (term ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
67
+ }
68
+ function commandSearchFields(command) {
69
+ return [command.slashAlias, command.id, command.label, command.category, command.description, ...command.aliases].map(normalizeCockpitSearchTerm);
70
+ }
71
+ function exactMatchScore(command, term) {
72
+ const bareTerm = term.startsWith('/') ? term.slice(1) : term;
73
+ if (normalizeCockpitSearchTerm(command.slashAlias) === term)
74
+ return 0;
75
+ if (normalizeCockpitSearchTerm(command.id) === bareTerm)
76
+ return 0;
77
+ if (normalizeCockpitSearchTerm(command.label) === term)
78
+ return 1;
79
+ if (command.aliases.map(normalizeCockpitSearchTerm).includes(term))
80
+ return 2;
81
+ return 10;
82
+ }
83
+ export function searchCockpitCommands(term) {
84
+ const normalizedTerm = normalizeCockpitSearchTerm(term);
85
+ if (!normalizedTerm) {
86
+ return cockpitCommands.filter((command) => defaultCommandIds.has(command.id));
87
+ }
88
+ return cockpitCommands
89
+ .filter((command) => commandSearchFields(command).some((field) => field.includes(normalizedTerm)))
90
+ .sort((left, right) => exactMatchScore(left, normalizedTerm) - exactMatchScore(right, normalizedTerm));
91
+ }
@@ -0,0 +1,177 @@
1
+ import { listModulesInSourceRepo } from '../module-actions.js';
2
+ import { listModuleRepos } from '../repo-actions.js';
3
+ import { listSources } from '../source-actions.js';
4
+ import { handlePromptCancel, menuPromptMessage, } from '../menu-navigation.js';
5
+ import { isPromptCancel, selectPrompt, textPrompt } from '../prompts/index.js';
6
+ const manualModuleValue = '__wpmoo_manual_module_entry__';
7
+ function defaultCancelHandler(value, action) {
8
+ handlePromptCancel(isPromptCancel(value), action);
9
+ }
10
+ function promptDeps(deps = {}) {
11
+ return {
12
+ select: deps.select ?? ((options) => selectPrompt(options)),
13
+ text: deps.text ?? ((options) => textPrompt(options)),
14
+ list: deps.list ?? ((options) => selectPrompt(options)),
15
+ handleCancel: deps.handleCancel ?? defaultCancelHandler,
16
+ };
17
+ }
18
+ function asString(value, fallback, deps) {
19
+ deps.handleCancel(value, 'back');
20
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
21
+ }
22
+ function requiredString(value, message, deps) {
23
+ deps.handleCancel(value, 'back');
24
+ if (typeof value === 'string' && value.trim()) {
25
+ return value.trim();
26
+ }
27
+ throw new Error(message);
28
+ }
29
+ async function detectedModules(cwd) {
30
+ try {
31
+ const sources = await listSources(cwd);
32
+ const repos = sources.length > 0
33
+ ? sources.map((source) => ({ path: source.path, sourceType: source.type }))
34
+ : (await listModuleRepos(cwd)).map((path) => ({ path, sourceType: 'private' }));
35
+ const modules = await Promise.all(repos.map(async (repo) => {
36
+ try {
37
+ return await listModulesInSourceRepo(cwd, repo.path, repo.sourceType);
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ }));
43
+ return [...new Set(modules.flat())].sort();
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ }
49
+ async function moduleArg(cwd, deps, message = 'Module(s)') {
50
+ const modules = await detectedModules(cwd);
51
+ if (modules.length === 0) {
52
+ return requiredString(await deps.text({
53
+ message: menuPromptMessage(message, 'back'),
54
+ placeholder: 'sale,stock',
55
+ validate: (value) => (value.trim() ? undefined : 'Enter one or more module technical names.'),
56
+ }), 'Module is required.', deps);
57
+ }
58
+ const selected = await deps.select({
59
+ message: menuPromptMessage(message, 'back'),
60
+ options: [
61
+ ...modules.map((moduleName) => ({ value: moduleName, label: moduleName })),
62
+ { value: manualModuleValue, label: 'Manual entry' },
63
+ ],
64
+ initialValue: modules[0],
65
+ });
66
+ deps.handleCancel(selected, 'back');
67
+ if (selected !== manualModuleValue) {
68
+ return String(selected);
69
+ }
70
+ return requiredString(await deps.text({
71
+ message: menuPromptMessage('Module(s)', 'back'),
72
+ placeholder: modules.join(','),
73
+ validate: (value) => (value.trim() ? undefined : 'Enter one or more module technical names.'),
74
+ }), 'Module is required.', deps);
75
+ }
76
+ async function optionalTextArg(deps, message, fallback) {
77
+ return asString(await deps.text({
78
+ message: menuPromptMessage(message, 'back'),
79
+ defaultValue: fallback,
80
+ placeholder: fallback,
81
+ }), fallback, deps);
82
+ }
83
+ async function optionalModules(cwd, deps) {
84
+ const modules = await detectedModules(cwd);
85
+ if (modules.length === 0) {
86
+ const manualModules = asString(await deps.text({
87
+ message: menuPromptMessage('Module(s) to include (optional)', 'back'),
88
+ placeholder: 'sale,stock',
89
+ }), '', deps);
90
+ return manualModules || undefined;
91
+ }
92
+ const selected = await deps.select({
93
+ message: menuPromptMessage('Module(s) to include (optional)', 'back'),
94
+ options: [
95
+ { value: '', label: 'All modules' },
96
+ ...modules.map((moduleName) => ({ value: moduleName, label: moduleName })),
97
+ { value: manualModuleValue, label: 'Manual entry' },
98
+ ],
99
+ initialValue: '',
100
+ });
101
+ deps.handleCancel(selected, 'back');
102
+ if (selected === '') {
103
+ return undefined;
104
+ }
105
+ if (selected !== manualModuleValue) {
106
+ return String(selected);
107
+ }
108
+ const manualModules = asString(await deps.text({
109
+ message: menuPromptMessage('Module(s) to include', 'back'),
110
+ placeholder: modules.join(','),
111
+ }), '', deps);
112
+ return manualModules || undefined;
113
+ }
114
+ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
115
+ const deps = promptDeps(promptDepsArg);
116
+ if (['start', 'restart', 'shell', 'lint', 'stop'].includes(command)) {
117
+ return [];
118
+ }
119
+ if (command === 'logs') {
120
+ return [await optionalTextArg(deps, 'Service', 'odoo')];
121
+ }
122
+ if (command === 'psql') {
123
+ return [await optionalTextArg(deps, 'Database', 'postgres')];
124
+ }
125
+ if (command === 'install' || command === 'update') {
126
+ const modules = await moduleArg(cwd, deps);
127
+ const db = asString(await deps.text({
128
+ message: menuPromptMessage('Database (optional)', 'back'),
129
+ placeholder: 'devel',
130
+ }), '', deps);
131
+ return db ? [modules, db] : [modules];
132
+ }
133
+ if (command === 'test') {
134
+ const modules = await moduleArg(cwd, deps);
135
+ const db = await optionalTextArg(deps, 'Database', 'devel');
136
+ const mode = asString(await deps.list({
137
+ message: menuPromptMessage('Mode', 'back'),
138
+ options: [
139
+ { value: 'update', label: 'update' },
140
+ { value: 'init', label: 'init' },
141
+ ],
142
+ initialValue: 'update',
143
+ }), 'update', deps);
144
+ const tags = asString(await deps.text({
145
+ message: menuPromptMessage('Tags (optional)', 'back'),
146
+ placeholder: '/sale',
147
+ }), '', deps);
148
+ return tags
149
+ ? [modules, '--db', db, '--mode', mode, '--tags', tags]
150
+ : [modules, '--db', db, '--mode', mode];
151
+ }
152
+ if (command === 'pot') {
153
+ const modules = await moduleArg(cwd, deps);
154
+ const db = await optionalTextArg(deps, 'Database', 'devel');
155
+ const output = await optionalTextArg(deps, 'Output file', `i18n/${modules}.pot`);
156
+ return [modules, db, output];
157
+ }
158
+ if (command === 'resetdb') {
159
+ const db = await optionalTextArg(deps, 'Database', 'devel');
160
+ const modules = await optionalModules(cwd, deps);
161
+ return modules ? [db, modules] : [db];
162
+ }
163
+ if (command === 'snapshot') {
164
+ const db = await optionalTextArg(deps, 'Database', 'devel');
165
+ const snapshotName = await optionalTextArg(deps, 'Snapshot name', 'before-update');
166
+ return [db, snapshotName];
167
+ }
168
+ if (command === 'restore-snapshot') {
169
+ const snapshotName = requiredString(await deps.text({
170
+ message: menuPromptMessage('Snapshot name', 'back'),
171
+ validate: (value) => (value.trim() ? undefined : 'Enter the snapshot name.'),
172
+ }), 'Snapshot name is required.', deps);
173
+ const db = await optionalTextArg(deps, 'Database', 'devel');
174
+ return [snapshotName, db];
175
+ }
176
+ return [];
177
+ }
@@ -0,0 +1,99 @@
1
+ import { styleText } from 'node:util';
2
+ import { cockpitCommands, } from './command-registry.js';
3
+ import { handlePromptCancel } from '../menu-navigation.js';
4
+ import { isPromptCancel, promptSeparator, selectPrompt, } from '../prompts/index.js';
5
+ const categoryLabels = {
6
+ services: 'Services',
7
+ modules: 'Modules',
8
+ database: 'Database',
9
+ diagnostics: 'Diagnostics',
10
+ repositories: 'Repositories',
11
+ maintenance: 'Maintenance',
12
+ };
13
+ const topLevelCategoryOrder = [
14
+ 'services',
15
+ 'modules',
16
+ 'database',
17
+ 'diagnostics',
18
+ 'repositories',
19
+ 'maintenance',
20
+ ];
21
+ const topLevelCommands = topLevelCategoryOrder.flatMap((category) => cockpitCommands.filter((command) => command.category === category && command.id !== 'exit'));
22
+ const topLevelCommandLabelWidth = Math.max(...topLevelCommands.map((command) => command.label.length));
23
+ function rgb(red, green, blue, value) {
24
+ return `\u001B[38;2;${red};${green};${blue}m${value}\u001B[39m`;
25
+ }
26
+ function dim(value) {
27
+ return styleText('dim', value, { validateStream: false });
28
+ }
29
+ function categoryHeading(category) {
30
+ return `\u001B[1D${rgb(143, 211, 255, categoryLabels[category])}`;
31
+ }
32
+ function commandName(command) {
33
+ return `${rgb(226, 184, 96, ` ${command.label.padEnd(topLevelCommandLabelWidth)}`)}${dim(` ${command.description}`)}`;
34
+ }
35
+ function categoryChoices(category, index) {
36
+ const choices = [
37
+ promptSeparator(categoryHeading(category)),
38
+ ...topLevelCommands
39
+ .filter((command) => command.category === category)
40
+ .map((command) => ({
41
+ value: command,
42
+ name: commandName(command),
43
+ short: command.label,
44
+ })),
45
+ ];
46
+ if (index < topLevelCategoryOrder.length - 1) {
47
+ choices.push(promptSeparator(' '));
48
+ }
49
+ return choices;
50
+ }
51
+ const topLevelChoices = [
52
+ ...topLevelCategoryOrder.flatMap(categoryChoices),
53
+ ];
54
+ const minimumTopLevelPageSize = 8;
55
+ const startupViewportReservedRows = 11;
56
+ function topLevelPageSize(choiceCount) {
57
+ const terminalRows = process.stdout.rows;
58
+ if (!terminalRows || terminalRows <= 0) {
59
+ return Math.min(choiceCount, 12);
60
+ }
61
+ return Math.min(choiceCount, Math.max(minimumTopLevelPageSize, terminalRows - startupViewportReservedRows));
62
+ }
63
+ function defaultSelect(options) {
64
+ return selectPrompt(options);
65
+ }
66
+ function defaultCancelHandler(value, action) {
67
+ handlePromptCancel(isPromptCancel(value), action);
68
+ }
69
+ function menuDeps(deps = {}) {
70
+ return {
71
+ select: deps.select ?? defaultSelect,
72
+ handleCancel: deps.handleCancel ?? defaultCancelHandler,
73
+ };
74
+ }
75
+ function isCockpitCommand(value) {
76
+ return typeof value === 'object' && value !== null && 'id' in value && 'slashAlias' in value;
77
+ }
78
+ export async function selectCockpitTopLevelMenu(options = {}) {
79
+ const deps = menuDeps(options);
80
+ const selected = await deps.select({
81
+ message: '',
82
+ choices: [...topLevelChoices],
83
+ default: topLevelCommands[0],
84
+ pageSize: topLevelPageSize(topLevelChoices.length),
85
+ loop: false,
86
+ hideMessage: true,
87
+ });
88
+ deps.handleCancel(selected, 'exit');
89
+ if (selected === 'exit') {
90
+ return { kind: 'exit' };
91
+ }
92
+ if (isCockpitCommand(selected)) {
93
+ return {
94
+ kind: 'command',
95
+ command: selected,
96
+ };
97
+ }
98
+ return { kind: 'exit' };
99
+ }
@@ -0,0 +1,22 @@
1
+ import { handlePromptCancel, menuPromptMessage } from '../menu-navigation.js';
2
+ import { confirmPrompt, isPromptCancel } from '../prompts/index.js';
3
+ function defaultHandleCancel(value, action) {
4
+ handlePromptCancel(isPromptCancel(value), action);
5
+ }
6
+ function riskConfirmationMessage(command, action) {
7
+ return menuPromptMessage(`Run ${command.slashAlias} ${command.label}? This can change or remove environment state.`, action);
8
+ }
9
+ export async function confirmCockpitCommandRisk(command, deps = {}) {
10
+ if (!command.isRisky) {
11
+ return true;
12
+ }
13
+ const prompt = deps.confirm ?? confirmPrompt;
14
+ const cancelAction = deps.cancelAction ?? 'back';
15
+ const approved = await prompt({
16
+ message: riskConfirmationMessage(command, cancelAction),
17
+ initialValue: false,
18
+ });
19
+ const handleCancel = deps.handleCancel ?? defaultHandleCancel;
20
+ handleCancel(approved, cancelAction);
21
+ return approved === true;
22
+ }
@@ -0,0 +1,118 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ async function exists(path) {
4
+ try {
5
+ await access(path);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export function parseEnvContent(content) {
13
+ const values = new Map();
14
+ for (const rawLine of content.split(/\r?\n/)) {
15
+ const line = rawLine.trim();
16
+ if (!line || line.startsWith('#'))
17
+ continue;
18
+ const separator = line.indexOf('=');
19
+ if (separator === -1)
20
+ continue;
21
+ const key = line.slice(0, separator).trim();
22
+ let value = line.slice(separator + 1).trim();
23
+ if ((value.startsWith('"') && value.endsWith('"')) ||
24
+ (value.startsWith("'") && value.endsWith("'"))) {
25
+ value = value.slice(1, -1);
26
+ }
27
+ values.set(key, value);
28
+ }
29
+ return values;
30
+ }
31
+ export async function readEnvFile(target) {
32
+ const path = join(target, '.env');
33
+ if (!(await exists(path)))
34
+ return undefined;
35
+ return parseEnvContent(await readFile(path, 'utf8'));
36
+ }
37
+ export function selectedComposeEnvironment(env) {
38
+ return env?.get('WPMOO_ENV')?.trim() || 'dev';
39
+ }
40
+ function uniqueStrings(values) {
41
+ return [...new Set(values.filter((value) => value.trim()).map((value) => value.trim()))];
42
+ }
43
+ function isValidComposeEnvironmentName(value) {
44
+ return /^[A-Za-z0-9_-]+$/.test(value);
45
+ }
46
+ function isValidOdooVersion(value) {
47
+ return /^\d+\.\d+$/.test(value);
48
+ }
49
+ function compactOverlayError(envName, overlayFile) {
50
+ if (envName === 'dev')
51
+ return `Missing compact compose overlay: ${overlayFile}`;
52
+ return `Missing compact compose overlay for WPMOO_ENV=${envName}: ${overlayFile}`;
53
+ }
54
+ export async function detectComposeLayout(target, options) {
55
+ const envName = options.envName?.trim() || 'dev';
56
+ if (!isValidComposeEnvironmentName(envName)) {
57
+ return {
58
+ kind: 'missing',
59
+ files: [],
60
+ missingFiles: [],
61
+ errors: [`Invalid WPMOO_ENV in .env: expected a simple compose overlay name, got ${envName}`],
62
+ };
63
+ }
64
+ const compactBase = 'compose.yaml';
65
+ const compactOverlay = `compose/${envName}.yaml`;
66
+ const hasCompactBase = await exists(join(target, compactBase));
67
+ const hasCompactOverlay = await exists(join(target, compactOverlay));
68
+ if (hasCompactBase && hasCompactOverlay) {
69
+ return {
70
+ kind: 'compact',
71
+ envName,
72
+ files: [compactBase, compactOverlay],
73
+ missingFiles: [],
74
+ errors: [],
75
+ };
76
+ }
77
+ if (hasCompactBase || hasCompactOverlay) {
78
+ const errors = [];
79
+ const missingFiles = [];
80
+ if (!hasCompactBase) {
81
+ missingFiles.push(compactBase);
82
+ errors.push(`Missing compact compose base: ${compactBase}`);
83
+ }
84
+ if (!hasCompactOverlay) {
85
+ missingFiles.push(compactOverlay);
86
+ errors.push(compactOverlayError(envName, compactOverlay));
87
+ }
88
+ return { kind: 'missing', files: [], missingFiles, errors };
89
+ }
90
+ const odooVersions = uniqueStrings(options.odooVersions);
91
+ const invalidOdooVersions = odooVersions.filter((version) => !isValidOdooVersion(version));
92
+ if (invalidOdooVersions.length > 0) {
93
+ return {
94
+ kind: 'missing',
95
+ files: [],
96
+ missingFiles: [],
97
+ errors: invalidOdooVersions.map((version) => `Invalid Odoo version for compose file: ${version}`),
98
+ };
99
+ }
100
+ const legacyFiles = odooVersions.map((version) => `docker-compose_${version}.yml`);
101
+ const missingLegacyFiles = [];
102
+ for (const file of legacyFiles) {
103
+ if (!(await exists(join(target, file)))) {
104
+ missingLegacyFiles.push(file);
105
+ }
106
+ }
107
+ if (legacyFiles.length > 0 && missingLegacyFiles.length === 0) {
108
+ return { kind: 'legacy', files: legacyFiles, missingFiles: [], errors: [] };
109
+ }
110
+ return {
111
+ kind: 'missing',
112
+ files: [],
113
+ missingFiles: missingLegacyFiles,
114
+ errors: legacyFiles.length > 0
115
+ ? missingLegacyFiles.map((file) => `Missing compose file: ${file}`)
116
+ : ['Missing compose layout: expected compose.yaml with compose/dev.yaml or a versioned docker-compose file'],
117
+ };
118
+ }