@wpmoo/toolkit 0.9.5 → 0.9.7
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 +95 -443
- package/dist/cli.js +417 -35
- package/dist/cockpit/command-registry.js +5 -1
- package/dist/cockpit/daily-prompts.js +30 -11
- package/dist/cockpit/menu.js +4 -1
- package/dist/cockpit/module-action-menu.js +40 -0
- package/dist/cockpit/module-browser.js +117 -0
- package/dist/daily-actions.js +40 -3
- package/dist/databases.js +58 -0
- package/dist/help.js +4 -2
- package/dist/menu-navigation.js +2 -2
- package/dist/module-actions.js +50 -1
- package/dist/prompts/index.js +69 -18
- package/dist/system-prerequisites.js +189 -0
- package/dist/templates.js +44 -25
- package/package.json +1 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { listEnvironmentDatabases, normalizeDatabaseListResult, } from '../databases.js';
|
|
1
2
|
import { listModulesInSourceRepo } from '../module-actions.js';
|
|
2
3
|
import { listModuleRepos } from '../repo-actions.js';
|
|
3
4
|
import { listSources } from '../source-actions.js';
|
|
4
5
|
import { handlePromptCancel, menuPromptMessage, } from '../menu-navigation.js';
|
|
5
6
|
import { isPromptCancel, selectPrompt, textPrompt } from '../prompts/index.js';
|
|
6
7
|
const manualModuleValue = '__wpmoo_manual_module_entry__';
|
|
8
|
+
const manualDatabaseValue = '__wpmoo_manual_database_entry__';
|
|
7
9
|
function defaultCancelHandler(value, action) {
|
|
8
10
|
handlePromptCancel(isPromptCancel(value), action);
|
|
9
11
|
}
|
|
@@ -12,6 +14,7 @@ function promptDeps(deps = {}) {
|
|
|
12
14
|
select: deps.select ?? ((options) => selectPrompt(options)),
|
|
13
15
|
text: deps.text ?? ((options) => textPrompt(options)),
|
|
14
16
|
list: deps.list ?? ((options) => selectPrompt(options)),
|
|
17
|
+
databases: deps.databases ?? ((cwd, options) => listEnvironmentDatabases(cwd, options)),
|
|
15
18
|
handleCancel: deps.handleCancel ?? defaultCancelHandler,
|
|
16
19
|
};
|
|
17
20
|
}
|
|
@@ -80,6 +83,25 @@ async function optionalTextArg(deps, message, fallback) {
|
|
|
80
83
|
placeholder: fallback,
|
|
81
84
|
}), fallback, deps);
|
|
82
85
|
}
|
|
86
|
+
async function databaseArg(cwd, deps, message, fallback, options = {}) {
|
|
87
|
+
const databaseResult = normalizeDatabaseListResult(await deps.databases(cwd, options));
|
|
88
|
+
const databases = databaseResult.databases;
|
|
89
|
+
if (databases.length > 0) {
|
|
90
|
+
const selected = await deps.list({
|
|
91
|
+
message: menuPromptMessage(message, 'back'),
|
|
92
|
+
options: [
|
|
93
|
+
...databases.map((database) => ({ value: database, label: database })),
|
|
94
|
+
{ value: manualDatabaseValue, label: 'Manual entry' },
|
|
95
|
+
],
|
|
96
|
+
initialValue: databases.includes(fallback) ? fallback : databases[0],
|
|
97
|
+
});
|
|
98
|
+
deps.handleCancel(selected, 'back');
|
|
99
|
+
if (selected !== manualDatabaseValue) {
|
|
100
|
+
return String(selected);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return optionalTextArg(deps, databaseResult.ok ? message : `${message} (database list unavailable; enter manually)`, fallback);
|
|
104
|
+
}
|
|
83
105
|
async function optionalModules(cwd, deps) {
|
|
84
106
|
const modules = await detectedModules(cwd);
|
|
85
107
|
if (modules.length === 0) {
|
|
@@ -120,19 +142,16 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
|
|
|
120
142
|
return [await optionalTextArg(deps, 'Service', 'odoo')];
|
|
121
143
|
}
|
|
122
144
|
if (command === 'psql') {
|
|
123
|
-
return [await
|
|
145
|
+
return [await databaseArg(cwd, deps, 'Database', 'postgres', { includeMaintenance: true })];
|
|
124
146
|
}
|
|
125
147
|
if (command === 'install' || command === 'update') {
|
|
126
148
|
const modules = await moduleArg(cwd, deps);
|
|
127
|
-
const db =
|
|
128
|
-
|
|
129
|
-
placeholder: 'devel',
|
|
130
|
-
}), '', deps);
|
|
131
|
-
return db ? [modules, db] : [modules];
|
|
149
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
150
|
+
return [modules, db];
|
|
132
151
|
}
|
|
133
152
|
if (command === 'test') {
|
|
134
153
|
const modules = await moduleArg(cwd, deps);
|
|
135
|
-
const db = await
|
|
154
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
136
155
|
const mode = asString(await deps.list({
|
|
137
156
|
message: menuPromptMessage('Mode', 'back'),
|
|
138
157
|
options: [
|
|
@@ -151,17 +170,17 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
|
|
|
151
170
|
}
|
|
152
171
|
if (command === 'pot') {
|
|
153
172
|
const modules = await moduleArg(cwd, deps);
|
|
154
|
-
const db = await
|
|
173
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
155
174
|
const output = await optionalTextArg(deps, 'Output file', `i18n/${modules}.pot`);
|
|
156
175
|
return [modules, db, output];
|
|
157
176
|
}
|
|
158
177
|
if (command === 'resetdb') {
|
|
159
|
-
const db = await
|
|
178
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
160
179
|
const modules = await optionalModules(cwd, deps);
|
|
161
180
|
return modules ? [db, modules] : [db];
|
|
162
181
|
}
|
|
163
182
|
if (command === 'snapshot') {
|
|
164
|
-
const db = await
|
|
183
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
165
184
|
const snapshotName = await optionalTextArg(deps, 'Snapshot name', 'before-update');
|
|
166
185
|
return [db, snapshotName];
|
|
167
186
|
}
|
|
@@ -170,7 +189,7 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
|
|
|
170
189
|
message: menuPromptMessage('Snapshot name', 'back'),
|
|
171
190
|
validate: (value) => (value.trim() ? undefined : 'Enter the snapshot name.'),
|
|
172
191
|
}), 'Snapshot name is required.', deps);
|
|
173
|
-
const db = await
|
|
192
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
174
193
|
return [snapshotName, db];
|
|
175
194
|
}
|
|
176
195
|
return [];
|
package/dist/cockpit/menu.js
CHANGED
|
@@ -116,6 +116,7 @@ function defaultCommand(serviceStatus) {
|
|
|
116
116
|
export async function selectCockpitTopLevelMenu(options = {}) {
|
|
117
117
|
const deps = menuDeps(options);
|
|
118
118
|
const choices = topLevelChoices(options.serviceStatus);
|
|
119
|
+
const cancelAction = 'back';
|
|
119
120
|
const selected = await deps.select({
|
|
120
121
|
message: '',
|
|
121
122
|
choices: [...choices],
|
|
@@ -124,8 +125,10 @@ export async function selectCockpitTopLevelMenu(options = {}) {
|
|
|
124
125
|
loop: false,
|
|
125
126
|
hideMessage: true,
|
|
126
127
|
disabledError: disabledError(options.serviceStatus),
|
|
128
|
+
navigationWarning: options.navigationWarning,
|
|
129
|
+
escapeBehavior: 'ignore',
|
|
127
130
|
});
|
|
128
|
-
deps.handleCancel(selected,
|
|
131
|
+
deps.handleCancel(selected, cancelAction);
|
|
129
132
|
if (selected === 'exit') {
|
|
130
133
|
return { kind: 'exit' };
|
|
131
134
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { handlePromptCancel, } from '../menu-navigation.js';
|
|
2
|
+
import { isPromptCancel, selectPrompt, } from '../prompts/index.js';
|
|
3
|
+
const moduleActions = [
|
|
4
|
+
{ id: 'delete', label: 'Delete module' },
|
|
5
|
+
{ id: 'update', label: 'Update' },
|
|
6
|
+
{ id: 'test', label: 'Test' },
|
|
7
|
+
{ id: 'lint', label: 'Run environment lint' },
|
|
8
|
+
];
|
|
9
|
+
function defaultCancelHandler(value, action) {
|
|
10
|
+
handlePromptCancel(isPromptCancel(value), action);
|
|
11
|
+
}
|
|
12
|
+
function deps(options = {}) {
|
|
13
|
+
return {
|
|
14
|
+
select: options.select ?? ((options) => selectPrompt(options)),
|
|
15
|
+
handleCancel: options.handleCancel ?? defaultCancelHandler,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function moduleActionChoices() {
|
|
19
|
+
return moduleActions.map(({ id, label }) => ({ value: id, name: label }));
|
|
20
|
+
}
|
|
21
|
+
function isModuleAction(value) {
|
|
22
|
+
return typeof value === 'string' && moduleActions.some((action) => action.id === value);
|
|
23
|
+
}
|
|
24
|
+
export async function selectModuleAction(module, options = {}) {
|
|
25
|
+
const promptDeps = deps(options);
|
|
26
|
+
const cancelAction = options.cancelAction ?? 'back';
|
|
27
|
+
const selected = await promptDeps.select({
|
|
28
|
+
message: `Module: ${module.moduleName}`,
|
|
29
|
+
choices: moduleActionChoices(),
|
|
30
|
+
default: 'update',
|
|
31
|
+
loop: false,
|
|
32
|
+
hideMessage: true,
|
|
33
|
+
navigationHelp: cancelAction === 'back' ? 'back' : 'exit',
|
|
34
|
+
});
|
|
35
|
+
promptDeps.handleCancel(selected, cancelAction);
|
|
36
|
+
if (isModuleAction(selected)) {
|
|
37
|
+
return selected;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
import { listModulesInEnvironment, } from '../module-actions.js';
|
|
3
|
+
import { handlePromptCancel, } from '../menu-navigation.js';
|
|
4
|
+
import { isPromptCancel, promptSeparator, selectPrompt, } from '../prompts/index.js';
|
|
5
|
+
const sourceTypeLabels = {
|
|
6
|
+
private: 'Private',
|
|
7
|
+
oca: 'OCA',
|
|
8
|
+
external: 'External',
|
|
9
|
+
};
|
|
10
|
+
const sourceTypeOrder = ['private', 'oca', 'external'];
|
|
11
|
+
const minimumPageSize = 8;
|
|
12
|
+
const reservedRows = 7;
|
|
13
|
+
function rgb(red, green, blue, value) {
|
|
14
|
+
return `\u001B[38;2;${red};${green};${blue}m${value}\u001B[39m`;
|
|
15
|
+
}
|
|
16
|
+
function dim(value) {
|
|
17
|
+
return styleText('dim', value, { validateStream: false });
|
|
18
|
+
}
|
|
19
|
+
function categoryHeading(label) {
|
|
20
|
+
return `\u001B[1D${rgb(143, 211, 255, label)}`;
|
|
21
|
+
}
|
|
22
|
+
function repositoryHeading(repoLabel, repoContext, width) {
|
|
23
|
+
return `\u001B[1D${rgb(143, 211, 255, `📁 ${repoLabel.padEnd(width)}`)}${dim(` ${repoContext}`)}`;
|
|
24
|
+
}
|
|
25
|
+
function repositoryContext(module) {
|
|
26
|
+
return module.repoSlug ?? module.repoPath;
|
|
27
|
+
}
|
|
28
|
+
function sourceContext(module) {
|
|
29
|
+
return `${module.sourceType}/${module.repoPath}`;
|
|
30
|
+
}
|
|
31
|
+
export function renderModuleDetails(module) {
|
|
32
|
+
return [
|
|
33
|
+
`Name: ${module.moduleName}`,
|
|
34
|
+
`Source: ${sourceContext(module)}`,
|
|
35
|
+
`Path: odoo/custom/src/${module.sourceType}/${module.repoPath}/${module.moduleName}`,
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
function moduleChoiceName(module, width) {
|
|
39
|
+
return `${rgb(226, 184, 96, ` ${module.moduleName.padEnd(width)}`)}${dim(` ${sourceContext(module)}`)}`;
|
|
40
|
+
}
|
|
41
|
+
function pageSize(choiceCount) {
|
|
42
|
+
const terminalRows = process.stdout.rows;
|
|
43
|
+
if (!terminalRows || terminalRows <= 0) {
|
|
44
|
+
return Math.min(choiceCount, 12);
|
|
45
|
+
}
|
|
46
|
+
return Math.min(choiceCount, Math.max(minimumPageSize, terminalRows - reservedRows));
|
|
47
|
+
}
|
|
48
|
+
function defaultCancelHandler(value, action) {
|
|
49
|
+
handlePromptCancel(isPromptCancel(value), action);
|
|
50
|
+
}
|
|
51
|
+
function deps(options = {}) {
|
|
52
|
+
return {
|
|
53
|
+
select: options.select ?? ((selectOptions) => selectPrompt(selectOptions)),
|
|
54
|
+
handleCancel: options.handleCancel ?? defaultCancelHandler,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function moduleBrowserChoices(modules) {
|
|
58
|
+
const moduleWidth = Math.max(...modules.map((module) => module.moduleName.length), 1);
|
|
59
|
+
const repositoryWidth = Math.max(...modules.map((module) => module.repoPath.length), 1);
|
|
60
|
+
const choices = [];
|
|
61
|
+
for (const sourceType of sourceTypeOrder) {
|
|
62
|
+
const sourceModules = modules
|
|
63
|
+
.filter((module) => module.sourceType === sourceType)
|
|
64
|
+
.sort((left, right) => left.repoPath.localeCompare(right.repoPath) || left.moduleName.localeCompare(right.moduleName));
|
|
65
|
+
if (sourceModules.length === 0) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (choices.length > 0) {
|
|
69
|
+
choices.push(promptSeparator(' '));
|
|
70
|
+
}
|
|
71
|
+
choices.push(promptSeparator(categoryHeading(sourceTypeLabels[sourceType])));
|
|
72
|
+
const modulesByRepo = new Map();
|
|
73
|
+
for (const module of sourceModules) {
|
|
74
|
+
const bucket = modulesByRepo.get(module.repoPath);
|
|
75
|
+
if (bucket) {
|
|
76
|
+
bucket.push(module);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
modulesByRepo.set(module.repoPath, [module]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const [repoPath, repoModules] of modulesByRepo) {
|
|
83
|
+
const sortedRepoModules = [...repoModules].sort((left, right) => left.moduleName.localeCompare(right.moduleName));
|
|
84
|
+
const headingLabel = repositoryHeading(repoPath, repositoryContext(sortedRepoModules[0]), repositoryWidth);
|
|
85
|
+
choices.push(promptSeparator(headingLabel));
|
|
86
|
+
choices.push(...sortedRepoModules.map((module) => ({
|
|
87
|
+
value: module,
|
|
88
|
+
name: moduleChoiceName(module, moduleWidth),
|
|
89
|
+
short: module.moduleName,
|
|
90
|
+
})));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return choices;
|
|
94
|
+
}
|
|
95
|
+
export async function selectModuleFromBrowser(target, options = {}) {
|
|
96
|
+
const modules = await listModulesInEnvironment(target);
|
|
97
|
+
if (modules.length === 0) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const moduleChoices = moduleBrowserChoices(modules);
|
|
101
|
+
const promptDeps = deps(options);
|
|
102
|
+
const cancelAction = options.cancelAction ?? 'back';
|
|
103
|
+
const selected = await promptDeps.select({
|
|
104
|
+
message: '',
|
|
105
|
+
choices: moduleChoices,
|
|
106
|
+
default: modules[0],
|
|
107
|
+
pageSize: pageSize(moduleChoices.length),
|
|
108
|
+
loop: false,
|
|
109
|
+
hideMessage: true,
|
|
110
|
+
navigationHelp: cancelAction === 'back' ? 'back' : 'exit',
|
|
111
|
+
});
|
|
112
|
+
promptDeps.handleCancel(selected, cancelAction);
|
|
113
|
+
if (typeof selected === 'object' && selected !== null && 'moduleName' in selected) {
|
|
114
|
+
return selected;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
package/dist/daily-actions.js
CHANGED
|
@@ -18,6 +18,10 @@ export const dailyActionCommands = [
|
|
|
18
18
|
'lint',
|
|
19
19
|
'pot',
|
|
20
20
|
];
|
|
21
|
+
const ANSI_DIM_INFO = '\u001B[2m\u001B[38;2;120;157;181m';
|
|
22
|
+
const ANSI_WARNING = '\u001B[33m';
|
|
23
|
+
const ANSI_DEFAULT_FOREGROUND = '\u001B[39m';
|
|
24
|
+
const ANSI_RESET = '\u001B[0m';
|
|
21
25
|
const dailyActionCommandSet = new Set(dailyActionCommands);
|
|
22
26
|
export const dailyActionScripts = {
|
|
23
27
|
start: 'up.sh',
|
|
@@ -56,7 +60,7 @@ function usage(command) {
|
|
|
56
60
|
if (command === 'update')
|
|
57
61
|
return 'Usage: wpmoo update <module[,module]> [db]';
|
|
58
62
|
if (command === 'test')
|
|
59
|
-
return 'Usage: wpmoo test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]';
|
|
63
|
+
return 'Usage: wpmoo test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]';
|
|
60
64
|
if (command === 'resetdb')
|
|
61
65
|
return 'Usage: wpmoo resetdb [db] [module[,module]]';
|
|
62
66
|
if (command === 'snapshot')
|
|
@@ -111,8 +115,8 @@ function testArgs(argv) {
|
|
|
111
115
|
const value = rest[index + 1];
|
|
112
116
|
if (!value || value.startsWith('--'))
|
|
113
117
|
throw new Error(`Missing value for ${option}`);
|
|
114
|
-
if (option === '--mode' && value !== 'init' && value !== 'update') {
|
|
115
|
-
throw new Error('Invalid value for --mode: expected init or update');
|
|
118
|
+
if (option === '--mode' && value !== 'auto' && value !== 'init' && value !== 'update') {
|
|
119
|
+
throw new Error('Invalid value for --mode: expected auto, init, or update');
|
|
116
120
|
}
|
|
117
121
|
index += 1;
|
|
118
122
|
}
|
|
@@ -185,6 +189,39 @@ async function spawnDailyAction(plan) {
|
|
|
185
189
|
throw new Error(`Daily action script exited with code ${exitCode ?? 'unknown'}: ${plan.scriptPath}`);
|
|
186
190
|
}
|
|
187
191
|
}
|
|
192
|
+
function renderDailyActionOutputLine(line) {
|
|
193
|
+
if (line.startsWith('WARNING:')) {
|
|
194
|
+
return `${ANSI_WARNING}WARNING:${ANSI_DEFAULT_FOREGROUND}${ANSI_DIM_INFO}${line.slice('WARNING:'.length)}${ANSI_RESET}`;
|
|
195
|
+
}
|
|
196
|
+
if (line === "Running as user 'root' is a security risk.") {
|
|
197
|
+
return `${ANSI_DIM_INFO}${line}${ANSI_RESET}`;
|
|
198
|
+
}
|
|
199
|
+
return line;
|
|
200
|
+
}
|
|
201
|
+
export function renderDailyActionOutput(output) {
|
|
202
|
+
return output
|
|
203
|
+
.split(/(\r?\n)/u)
|
|
204
|
+
.map((part) => (part === '\n' || part === '\r\n' ? part : renderDailyActionOutputLine(part)))
|
|
205
|
+
.join('');
|
|
206
|
+
}
|
|
207
|
+
async function spawnDailyActionWithStyledOutput(plan, writer) {
|
|
208
|
+
const child = spawn(plan.scriptPath, plan.args, {
|
|
209
|
+
cwd: plan.cwd,
|
|
210
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
211
|
+
});
|
|
212
|
+
child.stdout?.on('data', (chunk) => writer(renderDailyActionOutput(chunk.toString('utf8'))));
|
|
213
|
+
child.stderr?.on('data', (chunk) => writer(renderDailyActionOutput(chunk.toString('utf8'))));
|
|
214
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
215
|
+
child.on('error', reject);
|
|
216
|
+
child.on('close', resolve);
|
|
217
|
+
});
|
|
218
|
+
if (exitCode !== 0) {
|
|
219
|
+
throw new Error(`Daily action script exited with code ${exitCode ?? 'unknown'}: ${plan.scriptPath}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
188
222
|
export async function runDailyAction(command, argv, cwd = process.cwd(), runner = spawnDailyAction) {
|
|
189
223
|
await runner(await dailyActionPlan(command, argv, cwd));
|
|
190
224
|
}
|
|
225
|
+
export async function runDailyActionWithStyledOutput(command, argv, cwd = process.cwd(), writer = (chunk) => process.stdout.write(chunk)) {
|
|
226
|
+
await spawnDailyActionWithStyledOutput(await dailyActionPlan(command, argv, cwd), writer);
|
|
227
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const maintenanceDatabases = new Set(['postgres']);
|
|
3
|
+
const listDatabasesQuery = [
|
|
4
|
+
'SELECT datname',
|
|
5
|
+
'FROM pg_database',
|
|
6
|
+
'WHERE datistemplate = false',
|
|
7
|
+
"ORDER BY CASE WHEN datname = 'devel' THEN 0 WHEN datname = current_database() THEN 1 ELSE 2 END, datname;",
|
|
8
|
+
].join(' ');
|
|
9
|
+
export function parseDatabaseListOutput(output, options = {}) {
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
const databases = [];
|
|
12
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
13
|
+
const database = line.trim();
|
|
14
|
+
if (!/^[A-Za-z0-9_.-]+$/u.test(database) ||
|
|
15
|
+
database.startsWith('-') ||
|
|
16
|
+
seen.has(database) ||
|
|
17
|
+
(!options.includeMaintenance && maintenanceDatabases.has(database))) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
seen.add(database);
|
|
21
|
+
databases.push(database);
|
|
22
|
+
}
|
|
23
|
+
return databases;
|
|
24
|
+
}
|
|
25
|
+
export function normalizeDatabaseListResult(result) {
|
|
26
|
+
if (Array.isArray(result)) {
|
|
27
|
+
return { ok: true, databases: result };
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
export async function listEnvironmentDatabases(cwd, options = {}) {
|
|
32
|
+
const queryLiteral = JSON.stringify(listDatabasesQuery);
|
|
33
|
+
const command = [
|
|
34
|
+
`query=${queryLiteral}`,
|
|
35
|
+
'. ./scripts/lib.sh >/dev/null',
|
|
36
|
+
'compose exec -T db psql -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -Atc "$query"',
|
|
37
|
+
].join(' && ');
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const child = spawn('bash', ['-lc', command], {
|
|
40
|
+
cwd,
|
|
41
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
42
|
+
});
|
|
43
|
+
let output = '';
|
|
44
|
+
let errorOutput = '';
|
|
45
|
+
child.stdout?.on('data', (chunk) => {
|
|
46
|
+
output += chunk.toString('utf8');
|
|
47
|
+
});
|
|
48
|
+
child.stderr?.on('data', (chunk) => {
|
|
49
|
+
errorOutput += chunk.toString('utf8');
|
|
50
|
+
});
|
|
51
|
+
child.on('error', (error) => resolve({ ok: false, databases: [], error: error.message }));
|
|
52
|
+
child.on('close', (code) => {
|
|
53
|
+
resolve(code === 0
|
|
54
|
+
? { ok: true, databases: parseDatabaseListOutput(output, options) }
|
|
55
|
+
: { ok: false, databases: [], error: errorOutput.trim() || `Database list command exited with ${code}` });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -30,7 +30,7 @@ Usage:
|
|
|
30
30
|
npx @wpmoo/toolkit psql [db]
|
|
31
31
|
npx @wpmoo/toolkit install <module[,module]> [db]
|
|
32
32
|
npx @wpmoo/toolkit update <module[,module]> [db]
|
|
33
|
-
npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
|
|
33
|
+
npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
|
|
34
34
|
npx @wpmoo/toolkit resetdb [db] [module[,module]]
|
|
35
35
|
npx @wpmoo/toolkit snapshot [db] [snapshot-name]
|
|
36
36
|
npx @wpmoo/toolkit restore-snapshot [--dry-run] <snapshot-name> [db]
|
|
@@ -91,6 +91,8 @@ Cockpit:
|
|
|
91
91
|
|
|
92
92
|
Wizard local-only path:
|
|
93
93
|
Run npx @wpmoo/toolkit from a workspace directory to open the create wizard.
|
|
94
|
+
Before setup starts, WPMoo checks Git, Docker, Docker Compose, and Docker Engine.
|
|
95
|
+
If required tools are missing, WPMoo offers installer guidance before writing files.
|
|
94
96
|
Choose any environment folder; the default is ./<product>_dev.
|
|
95
97
|
Skip Git/GitHub connection to create a local-only environment.
|
|
96
98
|
Add source repos later from the cockpit or with add-repo.
|
|
@@ -118,7 +120,7 @@ Task recipes:
|
|
|
118
120
|
Add OCA module:
|
|
119
121
|
npx @wpmoo/toolkit add-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
|
|
120
122
|
Run tests:
|
|
121
|
-
npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
|
|
123
|
+
npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
|
|
122
124
|
Safe reset and recover:
|
|
123
125
|
npx @wpmoo/toolkit snapshot [db] [snapshot-name]
|
|
124
126
|
npx @wpmoo/toolkit reset --dry-run
|
package/dist/menu-navigation.js
CHANGED
|
@@ -10,10 +10,10 @@ export function isMenuBackSignal(error) {
|
|
|
10
10
|
return error instanceof MenuBackSignal;
|
|
11
11
|
}
|
|
12
12
|
export function menuIntroTitle(title, action) {
|
|
13
|
-
return
|
|
13
|
+
return title;
|
|
14
14
|
}
|
|
15
15
|
export function menuPromptMessage(message, action) {
|
|
16
|
-
return
|
|
16
|
+
return message;
|
|
17
17
|
}
|
|
18
18
|
export function promptCancelOutcome(cancelled, action, key) {
|
|
19
19
|
if (!cancelled) {
|
package/dist/module-actions.js
CHANGED
|
@@ -4,8 +4,27 @@ import { addModuleToSourceRepoInAddonsYaml, removeModuleFromSourceRepoInAddonsYa
|
|
|
4
4
|
import { readEnvironmentMetadata } from './environment.js';
|
|
5
5
|
import { realGit, stageAll } from './git.js';
|
|
6
6
|
import { pathUnderBase, validateModuleName, validateRepoPath } from './path-validation.js';
|
|
7
|
-
import { readAddonsYaml, writeAddonsYaml } from './repo-actions.js';
|
|
7
|
+
import { listModuleRepos, readAddonsYaml, writeAddonsYaml } from './repo-actions.js';
|
|
8
|
+
import { listSources } from './source-actions.js';
|
|
9
|
+
const sourceTypeSortOrder = ['private', 'oca', 'external'];
|
|
10
|
+
const githubRepoUrlPattern = /^(?:https?:\/\/|git@)github\.com[/:]([^/]+)\/([^/.#?]+)(?:\.git)?(?:[/?#].*)?$/i;
|
|
8
11
|
const validSourceTypes = ['private', 'oca', 'external'];
|
|
12
|
+
function deriveRepoSlug(repoUrl) {
|
|
13
|
+
if (!repoUrl) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const normalized = repoUrl.trim().replace(/[?#].*$/, '');
|
|
17
|
+
const match = githubRepoUrlPattern.exec(normalized);
|
|
18
|
+
if (!match) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const owner = match[1]?.trim();
|
|
22
|
+
const repo = match[2]?.trim();
|
|
23
|
+
if (!owner || !repo) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return `${owner}/${repo}`;
|
|
27
|
+
}
|
|
9
28
|
function normalizeSourceType(value) {
|
|
10
29
|
return validSourceTypes.includes(value) ? value : 'private';
|
|
11
30
|
}
|
|
@@ -94,6 +113,36 @@ export async function listModulesInSourceRepo(target, repoPath, sourceType) {
|
|
|
94
113
|
return [];
|
|
95
114
|
}
|
|
96
115
|
}
|
|
116
|
+
export async function listModulesInEnvironment(target) {
|
|
117
|
+
const sources = await listSources(target);
|
|
118
|
+
const sourceRepos = sources.length > 0
|
|
119
|
+
? sources.map((source) => ({
|
|
120
|
+
repoPath: source.path,
|
|
121
|
+
sourceType: source.type,
|
|
122
|
+
repoUrl: source.url,
|
|
123
|
+
}))
|
|
124
|
+
: (await listModuleRepos(target)).map((repoPath) => ({ repoPath, sourceType: 'private' }));
|
|
125
|
+
const listedModules = await Promise.all(sourceRepos.map(async ({ repoPath, sourceType, repoUrl }) => {
|
|
126
|
+
try {
|
|
127
|
+
const moduleNames = await listModulesInSourceRepo(target, repoPath, sourceType);
|
|
128
|
+
const repoSlug = deriveRepoSlug(repoUrl);
|
|
129
|
+
return moduleNames.map((moduleName) => ({
|
|
130
|
+
moduleName,
|
|
131
|
+
repoPath,
|
|
132
|
+
sourceType,
|
|
133
|
+
...(repoUrl ? { repoUrl } : {}),
|
|
134
|
+
...(repoSlug ? { repoSlug } : {}),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}));
|
|
141
|
+
const sourceTypeOrder = new Map(sourceTypeSortOrder.map((sourceType, index) => [sourceType, index]));
|
|
142
|
+
return listedModules.flat().sort((left, right) => (sourceTypeOrder.get(left.sourceType) ?? 0) - (sourceTypeOrder.get(right.sourceType) ?? 0) ||
|
|
143
|
+
left.repoPath.localeCompare(right.repoPath) ||
|
|
144
|
+
left.moduleName.localeCompare(right.moduleName));
|
|
145
|
+
}
|
|
97
146
|
export async function removeModuleFromSourceRepo(options, git = realGit) {
|
|
98
147
|
const repoPath = validateRepoPath(options.repoPath);
|
|
99
148
|
const moduleName = validateModuleName(options.moduleName);
|
package/dist/prompts/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { styleText } from 'node:util';
|
|
|
3
3
|
import inquirerSelect, { Separator as InquirerSeparator } from '@inquirer/select';
|
|
4
4
|
import inquirerSearch from '@inquirer/search';
|
|
5
5
|
import { confirm as inquirerConfirm, input as inquirerInput } from '@inquirer/prompts';
|
|
6
|
-
import { recordPromptCancelKey } from '../menu-navigation.js';
|
|
6
|
+
import { consumePromptCancelKey, recordPromptCancelKey } from '../menu-navigation.js';
|
|
7
7
|
export const promptCancelled = Symbol.for('wpmoo.prompt.cancelled');
|
|
8
8
|
export function promptSeparator(label) {
|
|
9
9
|
return new InquirerSeparator(label);
|
|
@@ -46,10 +46,38 @@ function asInquirerSearchConfig(options) {
|
|
|
46
46
|
pageSize: options.pageSize,
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
|
-
function
|
|
49
|
+
function isEscapeKey(key) {
|
|
50
|
+
if (typeof key !== 'object' || key === null) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const candidate = key;
|
|
54
|
+
return candidate.name === 'escape' || candidate.sequence === '\u001B';
|
|
55
|
+
}
|
|
56
|
+
function installIgnoredEscapeFilter(options) {
|
|
50
57
|
emitKeypressEvents(process.stdin);
|
|
58
|
+
const input = process.stdin;
|
|
59
|
+
const originalEmit = input.emit;
|
|
60
|
+
const patchedEmit = function patchedEmit(eventName, ...args) {
|
|
61
|
+
if (eventName === 'keypress' && isEscapeKey(args[1])) {
|
|
62
|
+
consumePromptCancelKey();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return Reflect.apply(originalEmit, this, [eventName, ...args]);
|
|
66
|
+
};
|
|
67
|
+
input.emit = patchedEmit;
|
|
68
|
+
return () => {
|
|
69
|
+
if (input.emit === patchedEmit) {
|
|
70
|
+
input.emit = originalEmit;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function installEscapeAbortController(controller, options = {}) {
|
|
75
|
+
emitKeypressEvents(process.stdin);
|
|
76
|
+
if (options.escapeBehavior === 'ignore') {
|
|
77
|
+
return installIgnoredEscapeFilter(options);
|
|
78
|
+
}
|
|
51
79
|
const listener = (_value, key) => {
|
|
52
|
-
if (key
|
|
80
|
+
if (!isEscapeKey(key)) {
|
|
53
81
|
return;
|
|
54
82
|
}
|
|
55
83
|
recordPromptCancelKey(key);
|
|
@@ -60,9 +88,9 @@ function installEscapeAbortController(controller) {
|
|
|
60
88
|
process.stdin.on('keypress', listener);
|
|
61
89
|
return () => process.stdin.off('keypress', listener);
|
|
62
90
|
}
|
|
63
|
-
async function withPromptCancelGuard(callback) {
|
|
91
|
+
async function withPromptCancelGuard(callback, options = {}) {
|
|
64
92
|
const controller = new AbortController();
|
|
65
|
-
const removeEscapeListener = installEscapeAbortController(controller);
|
|
93
|
+
const removeEscapeListener = installEscapeAbortController(controller, options);
|
|
66
94
|
try {
|
|
67
95
|
return await callback({ signal: controller.signal });
|
|
68
96
|
}
|
|
@@ -92,32 +120,52 @@ function asInquirerSelectConfig(options) {
|
|
|
92
120
|
loop: options.loop,
|
|
93
121
|
hideMessage: options.hideMessage,
|
|
94
122
|
disabledError: options.disabledError,
|
|
123
|
+
navigationHelp: options.navigationHelp,
|
|
124
|
+
navigationWarning: options.navigationWarning,
|
|
125
|
+
escapeBehavior: options.escapeBehavior,
|
|
95
126
|
};
|
|
96
127
|
}
|
|
97
|
-
function
|
|
128
|
+
function renderedNavigationWarning(navigationWarning) {
|
|
129
|
+
const warning = typeof navigationWarning === 'function' ? navigationWarning() : navigationWarning;
|
|
130
|
+
return warning ? `\u001B[2m\u001B[38;2;226;184;96m${warning}\u001B[0m` : undefined;
|
|
131
|
+
}
|
|
132
|
+
function hiddenSelectTheme(disabledError, navigationHelp = 'exit', navigationWarning, hideMessage = true) {
|
|
133
|
+
const keysHelpTip = navigationHelp === 'back'
|
|
134
|
+
? '↑↓ navigate • ⏎ select • Esc to go back'
|
|
135
|
+
: '↑↓ navigate • ⏎ select • Ctrl+C exit';
|
|
136
|
+
const style = {
|
|
137
|
+
highlight: (text) => text,
|
|
138
|
+
disabled: (text) => styleText('dim', text.replace(/ \(disabled\)$/u, ''), { validateStream: false }),
|
|
139
|
+
keysHelpTip: () => {
|
|
140
|
+
const warning = renderedNavigationWarning(navigationWarning);
|
|
141
|
+
return warning ? `${warning}\n${keysHelpTip}` : keysHelpTip;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
if (hideMessage) {
|
|
145
|
+
style.message = () => '';
|
|
146
|
+
}
|
|
98
147
|
return {
|
|
99
148
|
prefix: '',
|
|
100
149
|
icon: {
|
|
101
150
|
cursor: '\u001B[38;2;226;184;96m❯\u001B[39m',
|
|
102
151
|
},
|
|
103
|
-
style
|
|
104
|
-
message: () => '',
|
|
105
|
-
highlight: (text) => text,
|
|
106
|
-
disabled: (text) => styleText('dim', text.replace(/ \(disabled\)$/u, ''), { validateStream: false }),
|
|
107
|
-
keysHelpTip: () => '↑↓ navigate • ⏎ select • Ctrl+C exit',
|
|
108
|
-
},
|
|
152
|
+
style,
|
|
109
153
|
i18n: disabledError ? { disabledError } : undefined,
|
|
110
154
|
};
|
|
111
155
|
}
|
|
112
156
|
function withHiddenSelectMessage(config) {
|
|
113
|
-
if (!config.hideMessage
|
|
157
|
+
if (!config.hideMessage &&
|
|
158
|
+
!config.disabledError &&
|
|
159
|
+
!config.navigationHelp &&
|
|
160
|
+
!config.navigationWarning &&
|
|
161
|
+
!config.escapeBehavior) {
|
|
114
162
|
return config;
|
|
115
163
|
}
|
|
116
|
-
const { disabledError, hideMessage: _hideMessage, ...inquirerConfig } = config;
|
|
164
|
+
const { disabledError, hideMessage: _hideMessage, navigationHelp, navigationWarning, escapeBehavior: _escapeBehavior, ...inquirerConfig } = config;
|
|
117
165
|
return {
|
|
118
166
|
...inquirerConfig,
|
|
119
|
-
message: '',
|
|
120
|
-
theme: hiddenSelectTheme(disabledError),
|
|
167
|
+
message: config.hideMessage ? '' : inquirerConfig.message,
|
|
168
|
+
theme: hiddenSelectTheme(disabledError, navigationHelp, navigationWarning, Boolean(config.hideMessage)),
|
|
121
169
|
};
|
|
122
170
|
}
|
|
123
171
|
function asInquirerConfirmConfig(options) {
|
|
@@ -143,10 +191,13 @@ export function isPromptCancel(value) {
|
|
|
143
191
|
return value === promptCancelled;
|
|
144
192
|
}
|
|
145
193
|
export async function selectPrompt(options) {
|
|
194
|
+
const guardOptions = {
|
|
195
|
+
escapeBehavior: options.escapeBehavior,
|
|
196
|
+
};
|
|
146
197
|
if (isClackSelectOptions(options)) {
|
|
147
|
-
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(asInquirerSelectConfig(options)), context));
|
|
198
|
+
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(asInquirerSelectConfig(options)), context), guardOptions);
|
|
148
199
|
}
|
|
149
|
-
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(options), context));
|
|
200
|
+
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(options), context), guardOptions);
|
|
150
201
|
}
|
|
151
202
|
export async function inputPrompt(options) {
|
|
152
203
|
return withPromptCancelGuard((context) => inquirerInput(asInquirerInputConfig(options), context));
|