@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.
- package/LICENSE +22 -0
- package/README.md +519 -0
- package/dist/addons-yaml.js +59 -0
- package/dist/args.js +259 -0
- package/dist/cli.js +1039 -0
- package/dist/cockpit/command-palette.js +23 -0
- package/dist/cockpit/command-registry.js +91 -0
- package/dist/cockpit/daily-prompts.js +177 -0
- package/dist/cockpit/menu.js +99 -0
- package/dist/cockpit/safety.js +22 -0
- package/dist/compose-layout.js +118 -0
- package/dist/daily-actions.js +190 -0
- package/dist/doctor.js +519 -0
- package/dist/environment-context.js +10 -0
- package/dist/environment-version.js +5 -0
- package/dist/environment.js +136 -0
- package/dist/external-assets.js +153 -0
- package/dist/external-templates.js +86 -0
- package/dist/git.js +98 -0
- package/dist/github.js +87 -0
- package/dist/help.js +157 -0
- package/dist/menu-navigation.js +67 -0
- package/dist/module-actions.js +114 -0
- package/dist/odoo-versions.js +1 -0
- package/dist/path-validation.js +50 -0
- package/dist/prompt-copy.js +8 -0
- package/dist/prompt-repositories.js +34 -0
- package/dist/prompts/index.js +174 -0
- package/dist/repo-actions.js +158 -0
- package/dist/repo-url.js +27 -0
- package/dist/repository-preflight.js +46 -0
- package/dist/safe-reset.js +217 -0
- package/dist/scaffold.js +161 -0
- package/dist/source-actions.js +65 -0
- package/dist/source-manifest.js +338 -0
- package/dist/status.js +239 -0
- package/dist/templates.js +758 -0
- package/dist/types.js +1 -0
- package/dist/update-check.js +106 -0
- package/dist/version.js +19 -0
- package/docs/assets/patreon-donate.png +0 -0
- package/docs/assets/wpmoo-banner.png +0 -0
- package/docs/external-resources.md +136 -0
- package/docs/generated-environment-verification.md +140 -0
- package/docs/handoff.md +29 -0
- package/package.json +65 -0
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
const validSourceTypes = ['private', 'oca', 'external'];
|
|
9
|
+
function normalizeSourceType(value) {
|
|
10
|
+
return validSourceTypes.includes(value) ? value : 'private';
|
|
11
|
+
}
|
|
12
|
+
function sourceRepoPath(target, sourceType, repoPath) {
|
|
13
|
+
return pathUnderBase(join(target, `odoo/custom/src/${sourceType}`), repoPath, 'repo path');
|
|
14
|
+
}
|
|
15
|
+
function modulePath(target, sourceType, repoPath, moduleName) {
|
|
16
|
+
return pathUnderBase(sourceRepoPath(target, sourceType, repoPath), moduleName, 'module name');
|
|
17
|
+
}
|
|
18
|
+
function titleizeModule(moduleName) {
|
|
19
|
+
return moduleName
|
|
20
|
+
.split(/[_-]+/)
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
23
|
+
.join(' ');
|
|
24
|
+
}
|
|
25
|
+
function manifestContent(moduleName, odooVersion) {
|
|
26
|
+
return `{
|
|
27
|
+
"name": "${titleizeModule(moduleName)}",
|
|
28
|
+
"version": "${odooVersion}.1.0.0",
|
|
29
|
+
"category": "Productivity",
|
|
30
|
+
"summary": "TODO",
|
|
31
|
+
"depends": ["base"],
|
|
32
|
+
"data": [
|
|
33
|
+
"security/ir.model.access.csv",
|
|
34
|
+
],
|
|
35
|
+
"installable": True,
|
|
36
|
+
"application": False,
|
|
37
|
+
"license": "LGPL-3",
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
async function writeIfMissing(path, content) {
|
|
42
|
+
try {
|
|
43
|
+
await readFile(path, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
await writeFile(path, content, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function usesAddonsYaml(target) {
|
|
50
|
+
const metadata = await readEnvironmentMetadata(target);
|
|
51
|
+
return metadata?.engine !== 'compose';
|
|
52
|
+
}
|
|
53
|
+
export async function addModuleToSourceRepo(options, git = realGit) {
|
|
54
|
+
const repoPath = validateRepoPath(options.repoPath);
|
|
55
|
+
const moduleName = validateModuleName(options.moduleName);
|
|
56
|
+
const sourceType = normalizeSourceType(options.sourceType);
|
|
57
|
+
const destination = modulePath(options.target, sourceType, repoPath, moduleName);
|
|
58
|
+
await mkdir(join(destination, 'models'), { recursive: true });
|
|
59
|
+
await mkdir(join(destination, 'security'), { recursive: true });
|
|
60
|
+
await mkdir(join(destination, 'views'), { recursive: true });
|
|
61
|
+
await writeIfMissing(join(destination, '__init__.py'), 'from . import models\n');
|
|
62
|
+
await writeIfMissing(join(destination, '__manifest__.py'), manifestContent(moduleName, options.odooVersion));
|
|
63
|
+
await writeIfMissing(join(destination, 'models/__init__.py'), '');
|
|
64
|
+
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');
|
|
65
|
+
await writeIfMissing(join(destination, 'views/.gitkeep'), '');
|
|
66
|
+
if (sourceType === 'private' && (await usesAddonsYaml(options.target))) {
|
|
67
|
+
const addonsYaml = await readAddonsYaml(options.target);
|
|
68
|
+
await writeAddonsYaml(options.target, addModuleToSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
|
|
69
|
+
}
|
|
70
|
+
if (options.stage) {
|
|
71
|
+
await stageAll(git, sourceRepoPath(options.target, sourceType, repoPath));
|
|
72
|
+
await stageAll(git, options.target);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function listModulesInSourceRepo(target, repoPath, sourceType) {
|
|
76
|
+
const safeRepoPath = validateRepoPath(repoPath);
|
|
77
|
+
const resolvedSourceType = normalizeSourceType(sourceType);
|
|
78
|
+
try {
|
|
79
|
+
const entries = await readdir(sourceRepoPath(target, resolvedSourceType, safeRepoPath), { withFileTypes: true });
|
|
80
|
+
const modules = await Promise.all(entries
|
|
81
|
+
.filter((entry) => entry.isDirectory())
|
|
82
|
+
.map(async (entry) => {
|
|
83
|
+
try {
|
|
84
|
+
await readFile(join(sourceRepoPath(target, resolvedSourceType, safeRepoPath), entry.name, '__manifest__.py'), 'utf8');
|
|
85
|
+
return entry.name;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}));
|
|
91
|
+
return modules.filter((moduleName) => Boolean(moduleName)).sort();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function removeModuleFromSourceRepo(options, git = realGit) {
|
|
98
|
+
const repoPath = validateRepoPath(options.repoPath);
|
|
99
|
+
const moduleName = validateModuleName(options.moduleName);
|
|
100
|
+
const sourceType = normalizeSourceType(options.sourceType);
|
|
101
|
+
if (sourceType === 'private' && (await usesAddonsYaml(options.target))) {
|
|
102
|
+
const addonsYaml = await readAddonsYaml(options.target);
|
|
103
|
+
await writeAddonsYaml(options.target, removeModuleFromSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
|
|
104
|
+
}
|
|
105
|
+
if (options.deleteFiles) {
|
|
106
|
+
await rm(modulePath(options.target, sourceType, repoPath, moduleName), { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
if (options.stage) {
|
|
109
|
+
if (options.deleteFiles) {
|
|
110
|
+
await stageAll(git, sourceRepoPath(options.target, sourceType, repoPath));
|
|
111
|
+
}
|
|
112
|
+
await stageAll(git, options.target);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -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,34 @@
|
|
|
1
|
+
import { confirmPrompt, isPromptCancel, textPrompt, } from './prompts/index.js';
|
|
2
|
+
import { handlePromptCancel, menuPromptMessage } from './menu-navigation.js';
|
|
3
|
+
const defaultPrompt = {
|
|
4
|
+
confirm: confirmPrompt,
|
|
5
|
+
text: textPrompt,
|
|
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 (isPromptCancel(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 (isPromptCancel(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,174 @@
|
|
|
1
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
2
|
+
import inquirerSelect, { Separator as InquirerSeparator } from '@inquirer/select';
|
|
3
|
+
import inquirerSearch from '@inquirer/search';
|
|
4
|
+
import { confirm as inquirerConfirm, input as inquirerInput } from '@inquirer/prompts';
|
|
5
|
+
import { recordPromptCancelKey } from '../menu-navigation.js';
|
|
6
|
+
export const promptCancelled = Symbol.for('wpmoo.prompt.cancelled');
|
|
7
|
+
export function promptSeparator(label) {
|
|
8
|
+
return new InquirerSeparator(label);
|
|
9
|
+
}
|
|
10
|
+
function isPromptCancelError(error) {
|
|
11
|
+
if (!(error instanceof Error)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return ['AbortError', 'CancelPromptError', 'AbortPromptError', 'ExitPromptError'].includes(error.name);
|
|
15
|
+
}
|
|
16
|
+
function markPromptCancel(error) {
|
|
17
|
+
if (error instanceof Error && error.name === 'ExitPromptError' && /SIGINT/.test(error.message)) {
|
|
18
|
+
recordPromptCancelKey({ ctrl: true, name: 'c', sequence: '\u0003' });
|
|
19
|
+
return promptCancelled;
|
|
20
|
+
}
|
|
21
|
+
return promptCancelled;
|
|
22
|
+
}
|
|
23
|
+
function mapSearchChoice(choice) {
|
|
24
|
+
if (choice.name !== undefined || choice.description !== undefined || choice.short !== undefined) {
|
|
25
|
+
return {
|
|
26
|
+
value: choice.value,
|
|
27
|
+
name: choice.name,
|
|
28
|
+
description: choice.description,
|
|
29
|
+
short: choice.short,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
value: choice.value,
|
|
34
|
+
name: choice.label,
|
|
35
|
+
description: choice.hint,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function asInquirerSearchConfig(options) {
|
|
39
|
+
return {
|
|
40
|
+
message: options.message,
|
|
41
|
+
source: async (term, signal) => {
|
|
42
|
+
const choices = await options.source(term, signal);
|
|
43
|
+
return choices.map((choice) => mapSearchChoice(choice));
|
|
44
|
+
},
|
|
45
|
+
pageSize: options.pageSize,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function installEscapeAbortController(controller) {
|
|
49
|
+
emitKeypressEvents(process.stdin);
|
|
50
|
+
const listener = (_value, key) => {
|
|
51
|
+
if (key.name !== 'escape' && key.sequence !== '\u001B') {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
recordPromptCancelKey(key);
|
|
55
|
+
if (!controller.signal.aborted) {
|
|
56
|
+
controller.abort();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
process.stdin.on('keypress', listener);
|
|
60
|
+
return () => process.stdin.off('keypress', listener);
|
|
61
|
+
}
|
|
62
|
+
async function withPromptCancelGuard(callback) {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const removeEscapeListener = installEscapeAbortController(controller);
|
|
65
|
+
try {
|
|
66
|
+
return await callback({ signal: controller.signal });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (!isPromptCancelError(error)) {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return markPromptCancel(error);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
removeEscapeListener();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function isClackSelectOptions(options) {
|
|
79
|
+
return 'options' in options;
|
|
80
|
+
}
|
|
81
|
+
function asInquirerSelectConfig(options) {
|
|
82
|
+
return {
|
|
83
|
+
message: options.message,
|
|
84
|
+
choices: options.options.map((option) => ({
|
|
85
|
+
value: option.value,
|
|
86
|
+
name: option.label,
|
|
87
|
+
description: option.hint,
|
|
88
|
+
})),
|
|
89
|
+
default: options.initialValue,
|
|
90
|
+
pageSize: options.pageSize,
|
|
91
|
+
loop: options.loop,
|
|
92
|
+
hideMessage: options.hideMessage,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function hiddenSelectTheme() {
|
|
96
|
+
return {
|
|
97
|
+
prefix: '',
|
|
98
|
+
icon: {
|
|
99
|
+
cursor: '\u001B[38;2;226;184;96m❯\u001B[39m',
|
|
100
|
+
},
|
|
101
|
+
style: {
|
|
102
|
+
message: () => '',
|
|
103
|
+
highlight: (text) => text,
|
|
104
|
+
keysHelpTip: () => '↑↓ navigate • ⏎ select • Ctrl+C exit',
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function withHiddenSelectMessage(config) {
|
|
109
|
+
if (!config.hideMessage) {
|
|
110
|
+
return config;
|
|
111
|
+
}
|
|
112
|
+
const { hideMessage: _hideMessage, ...inquirerConfig } = config;
|
|
113
|
+
return {
|
|
114
|
+
...inquirerConfig,
|
|
115
|
+
message: '',
|
|
116
|
+
theme: hiddenSelectTheme(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function asInquirerConfirmConfig(options) {
|
|
120
|
+
const hasChoiceLabels = Boolean(options.active && options.inactive);
|
|
121
|
+
return {
|
|
122
|
+
message: hasChoiceLabels ? `${options.message} (${options.active}/${options.inactive})` : options.message,
|
|
123
|
+
default: options.initialValue,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function asInquirerInputConfig(options) {
|
|
127
|
+
return {
|
|
128
|
+
message: options.message,
|
|
129
|
+
default: options.defaultValue ?? options.initialValue,
|
|
130
|
+
validate: options.validate
|
|
131
|
+
? (value) => {
|
|
132
|
+
const result = options.validate?.(value);
|
|
133
|
+
return result === undefined ? true : result;
|
|
134
|
+
}
|
|
135
|
+
: undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function isPromptCancel(value) {
|
|
139
|
+
return value === promptCancelled;
|
|
140
|
+
}
|
|
141
|
+
export async function selectPrompt(options) {
|
|
142
|
+
if (isClackSelectOptions(options)) {
|
|
143
|
+
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(asInquirerSelectConfig(options)), context));
|
|
144
|
+
}
|
|
145
|
+
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(options), context));
|
|
146
|
+
}
|
|
147
|
+
export async function inputPrompt(options) {
|
|
148
|
+
return withPromptCancelGuard((context) => inquirerInput(asInquirerInputConfig(options), context));
|
|
149
|
+
}
|
|
150
|
+
export async function textPrompt(options) {
|
|
151
|
+
return inputPrompt(options);
|
|
152
|
+
}
|
|
153
|
+
export async function confirmPrompt(options) {
|
|
154
|
+
return withPromptCancelGuard((context) => inquirerConfirm(asInquirerConfirmConfig(options), context));
|
|
155
|
+
}
|
|
156
|
+
export async function searchPrompt(options) {
|
|
157
|
+
return withPromptCancelGuard((context) => inquirerSearch(asInquirerSearchConfig(options), context));
|
|
158
|
+
}
|
|
159
|
+
export function introPrompt(title) {
|
|
160
|
+
const rule = '-'.repeat(Math.min(80, Math.max(title.length, 3)));
|
|
161
|
+
console.log('');
|
|
162
|
+
console.log(title);
|
|
163
|
+
console.log(rule);
|
|
164
|
+
}
|
|
165
|
+
export function notePrompt(message, title = 'Note') {
|
|
166
|
+
const lines = message.split('\n');
|
|
167
|
+
console.log(`[${title}]`);
|
|
168
|
+
for (const line of lines) {
|
|
169
|
+
console.log(` ${line}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export function outroPrompt(message) {
|
|
173
|
+
console.log(`Done: ${message}`);
|
|
174
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
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, removeSourceRepoMetadata, upsertSourceRepoMetadata } 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
|
+
import { removeSourceManifestEntry, upsertSourceManifestEntry } from './source-manifest.js';
|
|
9
|
+
export const addonsYamlHeader = `# Addons activated from source submodules.
|
|
10
|
+
#
|
|
11
|
+
# Source repos are managed as Git submodules under odoo/custom/src/private (product code).
|
|
12
|
+
# OCA/external source repos can be placed under odoo/custom/src/oca and odoo/custom/src/external.
|
|
13
|
+
# Do not duplicate these same repos in repos.yaml.
|
|
14
|
+
`;
|
|
15
|
+
const validSourceTypes = ['private', 'oca', 'external'];
|
|
16
|
+
function normalizeSourceType(value) {
|
|
17
|
+
return validSourceTypes.includes(value) ? value : 'private';
|
|
18
|
+
}
|
|
19
|
+
function sourceSubmodulePath(sourceType, repoPath) {
|
|
20
|
+
return `odoo/custom/src/${sourceType}/${validateRepoPath(repoPath)}`;
|
|
21
|
+
}
|
|
22
|
+
function resolveSourceTypeFromSubmodulePath(submodulePath) {
|
|
23
|
+
const match = /^odoo\/custom\/src\/(private|oca|external)\//.exec(submodulePath);
|
|
24
|
+
if (!match)
|
|
25
|
+
return undefined;
|
|
26
|
+
return match[1];
|
|
27
|
+
}
|
|
28
|
+
async function listGitmoduleRepos(target) {
|
|
29
|
+
try {
|
|
30
|
+
const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
|
|
31
|
+
return [...gitmodules.matchAll(/^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/(.+)$/gm)]
|
|
32
|
+
.map((match) => ({ sourceType: match[1], path: match[2].trim() }))
|
|
33
|
+
.filter((entry) => isValidPathSegment(entry.path));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function resolveSubmodulePathFromConfig(target, repoPath, sourceType) {
|
|
40
|
+
if (sourceType) {
|
|
41
|
+
return sourceSubmodulePath(sourceType, validateRepoPath(repoPath));
|
|
42
|
+
}
|
|
43
|
+
const repoMatches = (await listGitmoduleRepos(target)).filter((repo) => repo.path === repoPath);
|
|
44
|
+
if (repoMatches.length === 1) {
|
|
45
|
+
return sourceSubmodulePath(repoMatches[0].sourceType, repoPath);
|
|
46
|
+
}
|
|
47
|
+
if (repoMatches.length > 1) {
|
|
48
|
+
const sorted = repoMatches.map((repo) => repo.sourceType).sort();
|
|
49
|
+
throw new Error(`Source repo ${repoPath} exists in multiple source directories: ${sorted.join(', ')}. Provide --source-type to disambiguate.`);
|
|
50
|
+
}
|
|
51
|
+
return sourceSubmodulePath('private', repoPath);
|
|
52
|
+
}
|
|
53
|
+
export async function readAddonsYaml(target) {
|
|
54
|
+
try {
|
|
55
|
+
return await readFile(join(target, 'odoo/custom/src/addons.yaml'), 'utf8');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return `${addonsYamlHeader}\n`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function writeAddonsYaml(target, content) {
|
|
62
|
+
const path = join(target, 'odoo/custom/src/addons.yaml');
|
|
63
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
64
|
+
await writeFile(path, content, 'utf8');
|
|
65
|
+
}
|
|
66
|
+
function composeAddonsPath() {
|
|
67
|
+
return '/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/wpmoo-addons';
|
|
68
|
+
}
|
|
69
|
+
async function isComposeEnvironment(target) {
|
|
70
|
+
const metadata = await readEnvironmentMetadata(target);
|
|
71
|
+
return metadata?.engine === 'compose';
|
|
72
|
+
}
|
|
73
|
+
export async function syncComposeOdooConfAddonsPath(target) {
|
|
74
|
+
if (!(await isComposeEnvironment(target))) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const configPath = join(target, 'etc/odoo.conf');
|
|
78
|
+
let content;
|
|
79
|
+
try {
|
|
80
|
+
content = await readFile(configPath, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const addonsPathLine = `addons_path = ${composeAddonsPath()}`;
|
|
86
|
+
const nextContent = /^addons_path\s*=.*$/m.test(content)
|
|
87
|
+
? content.replace(/^addons_path\s*=.*$/m, addonsPathLine)
|
|
88
|
+
: `${content.trimEnd()}\n${addonsPathLine}\n`;
|
|
89
|
+
if (nextContent !== content) {
|
|
90
|
+
await writeFile(configPath, nextContent, 'utf8');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function addModuleRepo(options, git = realGit) {
|
|
94
|
+
const repoPath = validateRepoPath(options.repoPath?.trim() || inferRepoPath(options.repoUrl));
|
|
95
|
+
const sourceType = normalizeSourceType(options.sourceType);
|
|
96
|
+
const submodulePath = sourceSubmodulePath(sourceType, repoPath);
|
|
97
|
+
await ensureRemoteHasBranch(git, options.target, options.repoUrl, options.odooVersion, options.initEmptyRepos);
|
|
98
|
+
await mkdir(join(options.target, 'odoo/custom/src', sourceType), { recursive: true });
|
|
99
|
+
await ensureSubmodule(git, options.target, options.repoUrl, options.odooVersion, submodulePath);
|
|
100
|
+
const listedRepos = await listModuleRepos(options.target);
|
|
101
|
+
if (!listedRepos.includes(repoPath)) {
|
|
102
|
+
throw new Error(`Source repo was added but is not registered in .gitmodules: ${repoPath}`);
|
|
103
|
+
}
|
|
104
|
+
await upsertSourceRepoMetadata(options.target, {
|
|
105
|
+
url: options.repoUrl,
|
|
106
|
+
path: repoPath,
|
|
107
|
+
addons: [repoPath],
|
|
108
|
+
sourceType,
|
|
109
|
+
});
|
|
110
|
+
await upsertSourceManifestEntry(options.target, {
|
|
111
|
+
type: sourceType,
|
|
112
|
+
path: repoPath,
|
|
113
|
+
url: options.repoUrl,
|
|
114
|
+
branch: options.odooVersion,
|
|
115
|
+
addons: [repoPath],
|
|
116
|
+
});
|
|
117
|
+
if (!(await isComposeEnvironment(options.target))) {
|
|
118
|
+
const addonsYaml = await readAddonsYaml(options.target);
|
|
119
|
+
if (sourceType === 'private') {
|
|
120
|
+
await writeAddonsYaml(options.target, addSourceRepoToAddonsYaml(addonsYaml, {
|
|
121
|
+
path: repoPath,
|
|
122
|
+
addons: [repoPath],
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
await syncComposeOdooConfAddonsPath(options.target);
|
|
127
|
+
if (options.stage) {
|
|
128
|
+
await stageAll(git, options.target);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export async function listModuleRepos(target) {
|
|
132
|
+
return (await listGitmoduleRepos(target)).map((repo) => repo.path).sort();
|
|
133
|
+
}
|
|
134
|
+
export async function removeModuleRepo(options, git = realGit) {
|
|
135
|
+
const repoPath = validateRepoPath(options.repoPath);
|
|
136
|
+
const sourceType = options.sourceType ? normalizeSourceType(options.sourceType) : undefined;
|
|
137
|
+
const submodulePath = await resolveSubmodulePathFromConfig(options.target, repoPath, sourceType);
|
|
138
|
+
const fullSubmodulePath = join(options.target, submodulePath);
|
|
139
|
+
const resolvedSourceType = sourceType ?? resolveSourceTypeFromSubmodulePath(submodulePath);
|
|
140
|
+
if (await hasUncommittedChanges(git, fullSubmodulePath)) {
|
|
141
|
+
throw new Error(`Cannot remove ${repoPath}: submodule has uncommitted changes.`);
|
|
142
|
+
}
|
|
143
|
+
await removeSubmodule(git, options.target, submodulePath);
|
|
144
|
+
await removeSourceRepoMetadata(options.target, repoPath, resolvedSourceType);
|
|
145
|
+
if (resolvedSourceType) {
|
|
146
|
+
await removeSourceManifestEntry(options.target, resolvedSourceType, repoPath);
|
|
147
|
+
}
|
|
148
|
+
if (!(await isComposeEnvironment(options.target))) {
|
|
149
|
+
const addonsYaml = await readAddonsYaml(options.target);
|
|
150
|
+
if (resolvedSourceType === 'private') {
|
|
151
|
+
await writeAddonsYaml(options.target, removeSourceRepoFromAddonsYaml(addonsYaml, repoPath));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
await syncComposeOdooConfAddonsPath(options.target);
|
|
155
|
+
if (options.stage) {
|
|
156
|
+
await stageAll(git, options.target);
|
|
157
|
+
}
|
|
158
|
+
}
|
package/dist/repo-url.js
ADDED
|
@@ -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
|
+
}
|