@wpmoo/odoo 0.8.30

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.
@@ -0,0 +1,107 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { addModuleToSourceRepoInAddonsYaml, removeModuleFromSourceRepoInAddonsYaml, } from './addons-yaml.js';
4
+ import { readEnvironmentMetadata } from './environment.js';
5
+ import { realGit, stageAll } from './git.js';
6
+ import { pathUnderBase, validateModuleName, validateRepoPath } from './path-validation.js';
7
+ import { readAddonsYaml, writeAddonsYaml } from './repo-actions.js';
8
+ function sourceRepoPath(target, repoPath) {
9
+ return pathUnderBase(join(target, 'odoo/custom/src/private'), repoPath, 'repo path');
10
+ }
11
+ function modulePath(target, repoPath, moduleName) {
12
+ return pathUnderBase(sourceRepoPath(target, repoPath), moduleName, 'module name');
13
+ }
14
+ function titleizeModule(moduleName) {
15
+ return moduleName
16
+ .split(/[_-]+/)
17
+ .filter(Boolean)
18
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
19
+ .join(' ');
20
+ }
21
+ function manifestContent(moduleName, odooVersion) {
22
+ return `{
23
+ "name": "${titleizeModule(moduleName)}",
24
+ "version": "${odooVersion}.1.0.0",
25
+ "category": "Productivity",
26
+ "summary": "TODO",
27
+ "depends": ["base"],
28
+ "data": [
29
+ "security/ir.model.access.csv",
30
+ ],
31
+ "installable": True,
32
+ "application": False,
33
+ "license": "LGPL-3",
34
+ }
35
+ `;
36
+ }
37
+ async function writeIfMissing(path, content) {
38
+ try {
39
+ await readFile(path, 'utf8');
40
+ }
41
+ catch {
42
+ await writeFile(path, content, 'utf8');
43
+ }
44
+ }
45
+ async function usesAddonsYaml(target) {
46
+ const metadata = await readEnvironmentMetadata(target);
47
+ return metadata?.engine !== 'compose';
48
+ }
49
+ export async function addModuleToSourceRepo(options, git = realGit) {
50
+ const repoPath = validateRepoPath(options.repoPath);
51
+ const moduleName = validateModuleName(options.moduleName);
52
+ const destination = modulePath(options.target, repoPath, moduleName);
53
+ await mkdir(join(destination, 'models'), { recursive: true });
54
+ await mkdir(join(destination, 'security'), { recursive: true });
55
+ await mkdir(join(destination, 'views'), { recursive: true });
56
+ await writeIfMissing(join(destination, '__init__.py'), 'from . import models\n');
57
+ await writeIfMissing(join(destination, '__manifest__.py'), manifestContent(moduleName, options.odooVersion));
58
+ await writeIfMissing(join(destination, 'models/__init__.py'), '');
59
+ await writeIfMissing(join(destination, 'security/ir.model.access.csv'), 'id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\n');
60
+ await writeIfMissing(join(destination, 'views/.gitkeep'), '');
61
+ if (await usesAddonsYaml(options.target)) {
62
+ const addonsYaml = await readAddonsYaml(options.target);
63
+ await writeAddonsYaml(options.target, addModuleToSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
64
+ }
65
+ if (options.stage) {
66
+ await stageAll(git, sourceRepoPath(options.target, repoPath));
67
+ await stageAll(git, options.target);
68
+ }
69
+ }
70
+ export async function listModulesInSourceRepo(target, repoPath) {
71
+ const safeRepoPath = validateRepoPath(repoPath);
72
+ try {
73
+ const entries = await readdir(sourceRepoPath(target, safeRepoPath), { withFileTypes: true });
74
+ const modules = await Promise.all(entries
75
+ .filter((entry) => entry.isDirectory())
76
+ .map(async (entry) => {
77
+ try {
78
+ await readFile(join(sourceRepoPath(target, safeRepoPath), entry.name, '__manifest__.py'), 'utf8');
79
+ return entry.name;
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }));
85
+ return modules.filter((moduleName) => Boolean(moduleName)).sort();
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ }
91
+ export async function removeModuleFromSourceRepo(options, git = realGit) {
92
+ const repoPath = validateRepoPath(options.repoPath);
93
+ const moduleName = validateModuleName(options.moduleName);
94
+ if (await usesAddonsYaml(options.target)) {
95
+ const addonsYaml = await readAddonsYaml(options.target);
96
+ await writeAddonsYaml(options.target, removeModuleFromSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
97
+ }
98
+ if (options.deleteFiles) {
99
+ await rm(modulePath(options.target, repoPath, moduleName), { recursive: true, force: true });
100
+ }
101
+ if (options.stage) {
102
+ if (options.deleteFiles) {
103
+ await stageAll(git, sourceRepoPath(options.target, repoPath));
104
+ }
105
+ await stageAll(git, options.target);
106
+ }
107
+ }
@@ -0,0 +1 @@
1
+ export const supportedOdooVersions = ['19.0', '18.0', '17.0', '16.0'];
@@ -0,0 +1,50 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
2
+ const windowsDrivePattern = /^[a-zA-Z]:/;
3
+ function invalidPathError(label) {
4
+ return new Error(`Invalid ${label}: use a single path segment without traversal.`);
5
+ }
6
+ export function validatePathSegment(value, label) {
7
+ const normalized = value.trim();
8
+ if (!normalized) {
9
+ throw new Error(`Invalid ${label}: value is required.`);
10
+ }
11
+ if (normalized === '.' ||
12
+ normalized === '..' ||
13
+ normalized.includes('/') ||
14
+ normalized.includes('\\') ||
15
+ normalized.includes('\0') ||
16
+ normalized.includes(':') ||
17
+ isAbsolute(normalized) ||
18
+ windowsDrivePattern.test(normalized)) {
19
+ throw invalidPathError(label);
20
+ }
21
+ return normalized;
22
+ }
23
+ export function isValidPathSegment(value) {
24
+ try {
25
+ validatePathSegment(value, 'path');
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export function validateRepoPath(value) {
33
+ return validatePathSegment(value, 'repo path');
34
+ }
35
+ export function validateModuleName(value) {
36
+ return validatePathSegment(value, 'module name');
37
+ }
38
+ export function validateAddonName(value) {
39
+ return validatePathSegment(value, 'addon name');
40
+ }
41
+ export function pathUnderBase(base, segment, label) {
42
+ const safeSegment = validatePathSegment(segment, label);
43
+ const resolvedBase = resolve(base);
44
+ const destination = resolve(resolvedBase, safeSegment);
45
+ const relativePath = relative(resolvedBase, destination);
46
+ if (relativePath === '' || relativePath.startsWith('..') || isAbsolute(relativePath)) {
47
+ throw invalidPathError(label);
48
+ }
49
+ return destination;
50
+ }
@@ -0,0 +1,8 @@
1
+ export function renderRepositorySetupNote(product) {
2
+ return [
3
+ `Dev repo: ${product}_dev`,
4
+ `Source repo: ${product}`,
5
+ `Local folder: ./${product}_dev`,
6
+ `Submodule path: odoo/custom/src/private/${product}`,
7
+ ].join('\n');
8
+ }
@@ -0,0 +1,34 @@
1
+ import { confirm, isCancel, text } from '@clack/prompts';
2
+ import { handlePromptCancel, menuPromptMessage } from './menu-navigation.js';
3
+ const defaultPrompt = {
4
+ confirm,
5
+ text,
6
+ };
7
+ export async function promptRepositoryUrl({ label, suggestedUrl, placeholder, prompt = defaultPrompt, cancelAction = 'exit', }) {
8
+ if (suggestedUrl) {
9
+ const useSuggested = await prompt.confirm({
10
+ message: `${menuPromptMessage(`Use ${label}? (Y/n)`, cancelAction)}\n${suggestedUrl}`,
11
+ active: 'Y',
12
+ inactive: 'n',
13
+ initialValue: true,
14
+ });
15
+ if (isCancel(useSuggested)) {
16
+ handlePromptCancel(true, cancelAction);
17
+ }
18
+ if (useSuggested) {
19
+ return suggestedUrl;
20
+ }
21
+ }
22
+ const value = await prompt.text({
23
+ message: menuPromptMessage(label, cancelAction),
24
+ placeholder,
25
+ validate: (input) => (input.trim() ? undefined : `Enter the ${label.toLowerCase()}.`),
26
+ });
27
+ if (isCancel(value)) {
28
+ handlePromptCancel(true, cancelAction);
29
+ }
30
+ if (typeof value === 'string' && value.trim()) {
31
+ return value.trim();
32
+ }
33
+ throw new Error(`${label} is required`);
34
+ }
@@ -0,0 +1,106 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { addSourceRepoToAddonsYaml, removeSourceRepoFromAddonsYaml } from './addons-yaml.js';
4
+ import { readEnvironmentMetadata } from './environment.js';
5
+ import { ensureRemoteHasBranch, ensureSubmodule, hasUncommittedChanges, realGit, removeSubmodule, stageAll, } from './git.js';
6
+ import { isValidPathSegment, validateRepoPath } from './path-validation.js';
7
+ import { inferRepoPath } from './repo-url.js';
8
+ export const addonsYamlHeader = `# Addons activated from source submodules.
9
+ #
10
+ # Source repos are managed as Git submodules under odoo/custom/src/private.
11
+ # Do not duplicate these same repos in repos.yaml.
12
+ `;
13
+ function privateSubmodulePath(repoPath) {
14
+ return `odoo/custom/src/private/${validateRepoPath(repoPath)}`;
15
+ }
16
+ export async function readAddonsYaml(target) {
17
+ try {
18
+ return await readFile(join(target, 'odoo/custom/src/addons.yaml'), 'utf8');
19
+ }
20
+ catch {
21
+ return `${addonsYamlHeader}\n`;
22
+ }
23
+ }
24
+ export async function writeAddonsYaml(target, content) {
25
+ const path = join(target, 'odoo/custom/src/addons.yaml');
26
+ await mkdir(join(path, '..'), { recursive: true });
27
+ await writeFile(path, content, 'utf8');
28
+ }
29
+ function composeAddonsPath() {
30
+ return '/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/wpmoo-addons';
31
+ }
32
+ async function isComposeEnvironment(target) {
33
+ const metadata = await readEnvironmentMetadata(target);
34
+ return metadata?.engine === 'compose';
35
+ }
36
+ export async function syncComposeOdooConfAddonsPath(target) {
37
+ if (!(await isComposeEnvironment(target))) {
38
+ return;
39
+ }
40
+ const configPath = join(target, 'etc/odoo.conf');
41
+ let content;
42
+ try {
43
+ content = await readFile(configPath, 'utf8');
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ const addonsPathLine = `addons_path = ${composeAddonsPath()}`;
49
+ const nextContent = /^addons_path\s*=.*$/m.test(content)
50
+ ? content.replace(/^addons_path\s*=.*$/m, addonsPathLine)
51
+ : `${content.trimEnd()}\n${addonsPathLine}\n`;
52
+ if (nextContent !== content) {
53
+ await writeFile(configPath, nextContent, 'utf8');
54
+ }
55
+ }
56
+ export async function addModuleRepo(options, git = realGit) {
57
+ const repoPath = validateRepoPath(options.repoPath?.trim() || inferRepoPath(options.repoUrl));
58
+ const submodulePath = privateSubmodulePath(repoPath);
59
+ await ensureRemoteHasBranch(git, options.target, options.repoUrl, options.odooVersion, options.initEmptyRepos);
60
+ await mkdir(join(options.target, 'odoo/custom/src/private'), { recursive: true });
61
+ await ensureSubmodule(git, options.target, options.repoUrl, options.odooVersion, submodulePath);
62
+ const listedRepos = await listModuleRepos(options.target);
63
+ if (!listedRepos.includes(repoPath)) {
64
+ throw new Error(`Source repo was added but is not registered in .gitmodules: ${repoPath}`);
65
+ }
66
+ if (!(await isComposeEnvironment(options.target))) {
67
+ const addonsYaml = await readAddonsYaml(options.target);
68
+ await writeAddonsYaml(options.target, addSourceRepoToAddonsYaml(addonsYaml, {
69
+ path: repoPath,
70
+ addons: [repoPath],
71
+ }));
72
+ }
73
+ await syncComposeOdooConfAddonsPath(options.target);
74
+ if (options.stage) {
75
+ await stageAll(git, options.target);
76
+ }
77
+ }
78
+ 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
+ }
89
+ }
90
+ export async function removeModuleRepo(options, git = realGit) {
91
+ const repoPath = validateRepoPath(options.repoPath);
92
+ const submodulePath = privateSubmodulePath(repoPath);
93
+ const fullSubmodulePath = join(options.target, submodulePath);
94
+ if (await hasUncommittedChanges(git, fullSubmodulePath)) {
95
+ throw new Error(`Cannot remove ${repoPath}: submodule has uncommitted changes.`);
96
+ }
97
+ await removeSubmodule(git, options.target, submodulePath);
98
+ if (!(await isComposeEnvironment(options.target))) {
99
+ const addonsYaml = await readAddonsYaml(options.target);
100
+ await writeAddonsYaml(options.target, removeSourceRepoFromAddonsYaml(addonsYaml, repoPath));
101
+ }
102
+ await syncComposeOdooConfAddonsPath(options.target);
103
+ if (options.stage) {
104
+ await stageAll(git, options.target);
105
+ }
106
+ }
@@ -0,0 +1,27 @@
1
+ import { basename } from 'node:path';
2
+ export function normalizeRepositoryUrl(repoUrl) {
3
+ const trimmed = repoUrl.trim();
4
+ const withoutSuffix = trimmed.replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/, '');
5
+ const orgPageMatch = withoutSuffix.match(/^https:\/\/github\.com\/orgs\/([^/]+)\/([^/]+)$/);
6
+ if (orgPageMatch) {
7
+ return `https://github.com/${orgPageMatch[1]}/${orgPageMatch[2]}.git`;
8
+ }
9
+ return trimmed;
10
+ }
11
+ export function inferRepoPath(repoUrl) {
12
+ const trimmed = normalizeRepositoryUrl(repoUrl).replace(/[?#].*$/, '').replace(/\/+$/, '');
13
+ const lastSegment = basename(trimmed);
14
+ const withoutGit = lastSegment.replace(/\.git$/, '');
15
+ if (!withoutGit) {
16
+ throw new Error(`Cannot infer repository path from URL: ${repoUrl}`);
17
+ }
18
+ return withoutGit;
19
+ }
20
+ export function inferGitHubOwner(repoUrl) {
21
+ const normalized = normalizeRepositoryUrl(repoUrl);
22
+ const httpsMatch = normalized.match(/^https:\/\/github\.com\/([^/]+)\//);
23
+ if (httpsMatch)
24
+ return httpsMatch[1];
25
+ const sshMatch = normalized.match(/^git@github\.com:([^/]+)\//);
26
+ return sshMatch?.[1];
27
+ }
@@ -0,0 +1,46 @@
1
+ import { createGitHubRepository, getGitHubRepositoryStatus, githubSlug, isGitHubAuthenticated, isGitHubCliAvailable, realGitHub, } from './github.js';
2
+ export function repositoryRequirements(options) {
3
+ return [
4
+ {
5
+ label: 'Dev environment repo',
6
+ url: options.devRepoUrl,
7
+ defaultVisibility: 'private',
8
+ },
9
+ ...options.sourceRepos.map((repo) => ({
10
+ label: `Source repo: ${repo.path}`,
11
+ url: repo.url,
12
+ defaultVisibility: 'private',
13
+ })),
14
+ ];
15
+ }
16
+ export async function findInaccessibleGitHubRepositories(options, runner = realGitHub) {
17
+ return (await checkGitHubRepositories(options, runner)).inaccessible;
18
+ }
19
+ export async function checkGitHubRepositories(options, runner = realGitHub) {
20
+ const accessible = [];
21
+ const inaccessible = [];
22
+ for (const requirement of repositoryRequirements(options)) {
23
+ const status = await getGitHubRepositoryStatus(runner, requirement.url);
24
+ if (status.status === 'accessible') {
25
+ accessible.push({ ...requirement, slug: status.slug });
26
+ }
27
+ if (status.status === 'inaccessible') {
28
+ inaccessible.push({ ...requirement, slug: status.slug });
29
+ }
30
+ }
31
+ return { accessible, inaccessible };
32
+ }
33
+ export async function createGitHubRepositories(repositories, visibility, runner = realGitHub) {
34
+ for (const repository of repositories) {
35
+ await createGitHubRepository(runner, repository.url, visibility);
36
+ }
37
+ }
38
+ export async function repositoryPreflightAvailable(runner = realGitHub) {
39
+ return (await isGitHubCliAvailable(runner)) && (await isGitHubAuthenticated(runner));
40
+ }
41
+ export function manualCreateCommands(repositories) {
42
+ return repositories.map((repository) => {
43
+ const slug = githubSlug(repository.url) ?? repository.url;
44
+ return `gh repo create ${slug} --${repository.defaultVisibility}`;
45
+ });
46
+ }
@@ -0,0 +1,129 @@
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { readEnvironmentMetadata } from './environment.js';
4
+ import { realGit, stageAll } from './git.js';
5
+ import { isValidPathSegment, validateAddonName, validateRepoPath } from './path-validation.js';
6
+ import { listModuleRepos, readAddonsYaml } from './repo-actions.js';
7
+ import { generatedFiles } from './scaffold.js';
8
+ export function renderSafeResetPreview(target, stage) {
9
+ return [
10
+ 'Safe reset will refresh generated WPMoo environment files.',
11
+ '',
12
+ 'Target:',
13
+ target,
14
+ '',
15
+ 'Will update:',
16
+ '- .wpmoo/odoo.json',
17
+ '- moo',
18
+ '- .gitignore',
19
+ '- README.md',
20
+ '- AGENTS.md',
21
+ '- docs/appstore-release.md',
22
+ '- Compose generated files',
23
+ '',
24
+ 'Will not touch:',
25
+ '- source repo folders under odoo/custom/src/private',
26
+ '- module source code',
27
+ '- Git history, remotes, or branches',
28
+ '',
29
+ stage ? 'Generated changes will be staged with git add .' : 'Generated changes will not be staged.',
30
+ ].join('\n');
31
+ }
32
+ function titleFromTarget(target) {
33
+ return basename(target).replace(/_dev$/, '') || 'odoo_sample_module';
34
+ }
35
+ function parseAddonsForRepo(addonsYaml, repoPath) {
36
+ const safeRepoPath = validateRepoPath(repoPath);
37
+ const lines = addonsYaml.split('\n');
38
+ const header = `private/${safeRepoPath}:`;
39
+ const start = lines.findIndex((line) => line.trim() === header);
40
+ if (start === -1)
41
+ return [safeRepoPath];
42
+ const addons = [];
43
+ for (let index = start + 1; index < lines.length; index += 1) {
44
+ const line = lines[index];
45
+ if (!line.startsWith(' '))
46
+ break;
47
+ const match = line.trim().match(/^-\s+(.+)$/);
48
+ const addon = match?.[1]?.trim();
49
+ if (addon && isValidPathSegment(addon)) {
50
+ addons.push(validateAddonName(addon));
51
+ }
52
+ }
53
+ return addons.length ? addons : [safeRepoPath];
54
+ }
55
+ function parseRepoPathsFromAddonsYaml(addonsYaml) {
56
+ return [...addonsYaml.matchAll(/^private\/(.+):$/gm)]
57
+ .map((match) => match[1].trim())
58
+ .filter((repoPath) => repoPath && isValidPathSegment(repoPath))
59
+ .map(validateRepoPath);
60
+ }
61
+ async function readSubmoduleUrl(target, repoPath) {
62
+ const safeRepoPath = validateRepoPath(repoPath);
63
+ try {
64
+ const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
65
+ const escapedPath = `odoo/custom/src/private/${safeRepoPath}`;
66
+ const sections = gitmodules.split(/\n(?=\[submodule )/);
67
+ const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
68
+ const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
69
+ return url || `odoo/custom/src/private/${safeRepoPath}`;
70
+ }
71
+ catch {
72
+ return `odoo/custom/src/private/${safeRepoPath}`;
73
+ }
74
+ }
75
+ async function inferOptions(target) {
76
+ const metadata = await readEnvironmentMetadata(target);
77
+ const addonsYaml = await readAddonsYaml(target);
78
+ const moduleRepos = await listModuleRepos(target);
79
+ const addonRepos = parseRepoPathsFromAddonsYaml(addonsYaml);
80
+ const metadataRepoPaths = metadata?.sourceRepos.map((repo) => repo.path).filter((repoPath) => isValidPathSegment(repoPath)).map(validateRepoPath) ??
81
+ [];
82
+ const repoPaths = [
83
+ ...new Set([...metadataRepoPaths, ...moduleRepos, ...addonRepos]),
84
+ ];
85
+ const product = metadata?.product ?? repoPaths[0] ?? titleFromTarget(target);
86
+ const sourceRepos = await Promise.all(repoPaths.map(async (repoPath) => ({
87
+ path: repoPath,
88
+ url: metadata?.sourceRepos.find((repo) => repo.path === repoPath)?.url ?? (await readSubmoduleUrl(target, repoPath)),
89
+ addons: parseAddonsForRepo(addonsYaml, repoPath),
90
+ })));
91
+ return {
92
+ product,
93
+ odooVersion: metadata?.odooVersion ?? '19.0',
94
+ devRepo: metadata?.devRepo ?? basename(target),
95
+ devRepoUrl: metadata?.devRepoUrl ?? target,
96
+ sourceRepos,
97
+ engine: 'compose',
98
+ composeTemplateUrl: metadata?.composeTemplateUrl,
99
+ composeTemplateRef: metadata?.composeTemplateRef,
100
+ agentSkillsTemplateUrl: metadata?.agentSkillsTemplateUrl,
101
+ agentSkillsTemplateRef: metadata?.agentSkillsTemplateRef,
102
+ postgresVersion: metadata?.postgresVersion,
103
+ httpPort: metadata?.httpPort,
104
+ geventPort: metadata?.geventPort,
105
+ target,
106
+ dryRun: false,
107
+ initEmptyRepos: false,
108
+ stage: false,
109
+ skipSubmodules: true,
110
+ };
111
+ }
112
+ export async function safeResetEnvironment(options, git = realGit) {
113
+ const scaffoldOptions = await inferOptions(options.target);
114
+ const files = generatedFiles(scaffoldOptions);
115
+ for (const file of files) {
116
+ if (file.path === 'odoo/custom/src/addons.yaml') {
117
+ continue;
118
+ }
119
+ const destination = join(options.target, file.path);
120
+ await mkdir(join(destination, '..'), { recursive: true });
121
+ await writeFile(destination, file.content, 'utf8');
122
+ if (file.mode !== undefined) {
123
+ await chmod(destination, file.mode);
124
+ }
125
+ }
126
+ if (options.stage) {
127
+ await stageAll(git, options.target);
128
+ }
129
+ }
@@ -0,0 +1,125 @@
1
+ import { chmod, mkdir, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { applyExternalAsset, renderExternalAssetCommand, writeTextFile } from './external-assets.js';
4
+ import { markerPath, renderEnvironmentMetadata } from './environment.js';
5
+ import { plannedExternalAssetOptions, renderComposeEnvExample } from './external-templates.js';
6
+ import { cloneRepository, ensureSubmodule, ensureRemoteHasBranch, realGit, stageAll, syncSubmodules, } from './git.js';
7
+ import { renderAgents, renderAppstoreRelease, renderGitignore, renderMooDelegationScript, renderPlaceholder, renderReadme, } from './templates.js';
8
+ import { validateAddonName, validateRepoPath } from './path-validation.js';
9
+ function validateSourceRepo(repo) {
10
+ const path = validateRepoPath(repo.path);
11
+ return {
12
+ ...repo,
13
+ path,
14
+ addons: repo.addons.map(validateAddonName),
15
+ };
16
+ }
17
+ function validateScaffoldOptions(options) {
18
+ return {
19
+ ...options,
20
+ sourceRepos: options.sourceRepos.map(validateSourceRepo),
21
+ };
22
+ }
23
+ export function generatedFiles(options) {
24
+ const safeOptions = validateScaffoldOptions(options);
25
+ const files = [
26
+ { path: markerPath, content: renderEnvironmentMetadata(safeOptions) },
27
+ { path: 'moo', content: renderMooDelegationScript(), mode: 0o755 },
28
+ { path: '.gitignore', content: renderGitignore() },
29
+ { path: 'README.md', content: renderReadme(safeOptions) },
30
+ { path: 'AGENTS.md', content: renderAgents(safeOptions) },
31
+ { path: 'docs/appstore-release.md', content: renderAppstoreRelease(safeOptions) },
32
+ ];
33
+ return [
34
+ ...files,
35
+ {
36
+ path: 'odoo/custom/src/private/README.md',
37
+ content: renderPlaceholder('private', 'WPMoo source repositories are added here as Git submodules.'),
38
+ },
39
+ ];
40
+ }
41
+ async function writeGeneratedFiles(target, files) {
42
+ for (const file of files) {
43
+ const destination = join(target, file.path);
44
+ await mkdir(join(destination, '..'), { recursive: true });
45
+ await writeFile(destination, file.content, 'utf8');
46
+ if (file.mode !== undefined) {
47
+ await chmod(destination, file.mode);
48
+ }
49
+ }
50
+ }
51
+ async function pathExists(path) {
52
+ try {
53
+ await stat(path);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ async function isGitRepository(git, target) {
61
+ if (!(await pathExists(target))) {
62
+ return false;
63
+ }
64
+ try {
65
+ const result = await git.run(target, ['rev-parse', '--is-inside-work-tree']);
66
+ return result.stdout.trim() === 'true';
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ async function prepareTargetRepository(options, git) {
73
+ if (await isGitRepository(git, options.target)) {
74
+ return;
75
+ }
76
+ if (await pathExists(options.target)) {
77
+ throw new Error(`Target exists but is not a Git repository: ${options.target}\n` +
78
+ 'Clone the dev environment repository first, or remove the directory and run the CLI again.');
79
+ }
80
+ await mkdir(dirname(options.target), { recursive: true });
81
+ await cloneRepository(git, dirname(options.target), options.devRepoUrl, options.target);
82
+ }
83
+ export async function scaffold(options, git = realGit) {
84
+ const safeOptions = validateScaffoldOptions(options);
85
+ const files = generatedFiles(safeOptions);
86
+ const externalAssets = plannedExternalAssetOptions(safeOptions);
87
+ const plannedCommands = [
88
+ ...externalAssets.map((assetOptions) => renderExternalAssetCommand(assetOptions)),
89
+ ...safeOptions.sourceRepos.map((repo) => `git submodule add -b ${safeOptions.odooVersion} ${repo.url} odoo/custom/src/private/${repo.path}`),
90
+ ];
91
+ if (safeOptions.stage) {
92
+ plannedCommands.push('git add .');
93
+ }
94
+ if (safeOptions.dryRun) {
95
+ return {
96
+ plannedFiles: files.map((file) => file.path),
97
+ plannedCommands,
98
+ };
99
+ }
100
+ if (!safeOptions.skipSubmodules || safeOptions.stage) {
101
+ await prepareTargetRepository(safeOptions, git);
102
+ }
103
+ await writeGeneratedFiles(safeOptions.target, files);
104
+ for (const assetOptions of externalAssets) {
105
+ await applyExternalAsset(assetOptions, git);
106
+ }
107
+ await writeTextFile(join(safeOptions.target, '.env.example'), renderComposeEnvExample(safeOptions));
108
+ if (!safeOptions.skipSubmodules) {
109
+ for (const repo of safeOptions.sourceRepos) {
110
+ await ensureRemoteHasBranch(git, safeOptions.target, repo.url, safeOptions.odooVersion, safeOptions.initEmptyRepos);
111
+ }
112
+ await mkdir(join(safeOptions.target, 'odoo/custom/src/private'), { recursive: true });
113
+ for (const repo of safeOptions.sourceRepos) {
114
+ await ensureSubmodule(git, safeOptions.target, repo.url, safeOptions.odooVersion, `odoo/custom/src/private/${repo.path}`);
115
+ }
116
+ await syncSubmodules(git, safeOptions.target);
117
+ }
118
+ if (safeOptions.stage) {
119
+ await stageAll(git, safeOptions.target);
120
+ }
121
+ return {
122
+ plannedFiles: files.map((file) => file.path),
123
+ plannedCommands,
124
+ };
125
+ }