@wpmoo/toolkit 0.9.5 → 0.9.6
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/dist/cli.js +363 -31
- package/dist/cockpit/command-registry.js +4 -0
- package/dist/cockpit/daily-prompts.js +29 -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 +46 -0
- package/dist/help.js +2 -2
- package/dist/menu-navigation.js +2 -2
- package/dist/module-actions.js +50 -1
- package/dist/prompts/index.js +61 -12
- package/dist/templates.js +3 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,20 +2,25 @@
|
|
|
2
2
|
import { realpathSync } from 'node:fs';
|
|
3
3
|
import { rm, rename } from 'node:fs/promises';
|
|
4
4
|
import { basename, relative, resolve } from 'node:path';
|
|
5
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
5
6
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
7
|
import { commandFromArgs, isHelpRequested, isVersionRequested, optionsFromArgs, parseArgs, stripInternalFlags, } from './args.js';
|
|
8
|
+
import { cockpitCommands } from './cockpit/command-registry.js';
|
|
7
9
|
import { collectDailyActionArgs } from './cockpit/daily-prompts.js';
|
|
10
|
+
import { selectModuleAction } from './cockpit/module-action-menu.js';
|
|
11
|
+
import { selectModuleFromBrowser } from './cockpit/module-browser.js';
|
|
8
12
|
import { selectCockpitTopLevelMenu } from './cockpit/menu.js';
|
|
9
13
|
import { confirmCockpitCommandRisk } from './cockpit/safety.js';
|
|
10
14
|
import { detectDevelopmentEnvironment } from './environment.js';
|
|
11
15
|
import { commandOdooVersion } from './environment-version.js';
|
|
12
16
|
import { defaultAgentSkillsTemplateUrl } from './external-templates.js';
|
|
13
|
-
import {
|
|
17
|
+
import { listEnvironmentDatabases } from './databases.js';
|
|
18
|
+
import { isDailyActionCommand, runDailyAction, runDailyActionWithStyledOutput } from './daily-actions.js';
|
|
14
19
|
import { getDoctorReport, runDoctor } from './doctor.js';
|
|
15
20
|
import { getOriginUrl, realGit } from './git.js';
|
|
16
21
|
import { renderHelp } from './help.js';
|
|
17
22
|
import { runLocalCockpit } from './local-cockpit.js';
|
|
18
|
-
import { addModuleToSourceRepo,
|
|
23
|
+
import { addModuleToSourceRepo, removeModuleFromSourceRepo, } from './module-actions.js';
|
|
19
24
|
import { supportedOdooVersions } from './odoo-versions.js';
|
|
20
25
|
import { renderRepositorySetupNote } from './prompt-copy.js';
|
|
21
26
|
import { promptRepositoryUrl } from './prompt-repositories.js';
|
|
@@ -211,6 +216,24 @@ function clearCockpitScreen() {
|
|
|
211
216
|
process.stdout.write('\u001B[2J\u001B[H');
|
|
212
217
|
}
|
|
213
218
|
}
|
|
219
|
+
const ANSI_ACTION = '\u001B[38;2;226;184;96m';
|
|
220
|
+
const ANSI_SUCCESS = '\u001B[32m';
|
|
221
|
+
const ANSI_DEFAULT_FOREGROUND = '\u001B[39m';
|
|
222
|
+
const ANSI_DIM_INFO = '\u001B[2m\u001B[38;2;120;157;181m';
|
|
223
|
+
const ANSI_RESET = '\u001B[0m';
|
|
224
|
+
function renderActionText(value) {
|
|
225
|
+
return ansi(value, ANSI_ACTION, ANSI_DEFAULT_FOREGROUND);
|
|
226
|
+
}
|
|
227
|
+
function renderCompletedText(action) {
|
|
228
|
+
if (!supportsAnsi()) {
|
|
229
|
+
return `✓ ${action} completed.`;
|
|
230
|
+
}
|
|
231
|
+
return `${ANSI_SUCCESS}✓${ANSI_DEFAULT_FOREGROUND} ${action} ${ANSI_SUCCESS}completed${ANSI_DEFAULT_FOREGROUND}.`;
|
|
232
|
+
}
|
|
233
|
+
function renderBackHelp() {
|
|
234
|
+
return ansi('Esc to go back', ANSI_DIM_INFO, ANSI_RESET);
|
|
235
|
+
}
|
|
236
|
+
const manualDatabaseValue = '__wpmoo_manual_database_entry__';
|
|
214
237
|
async function showStartup(argv, skipUpdateCheck, details) {
|
|
215
238
|
if (skipUpdateCheck) {
|
|
216
239
|
console.log(renderStartupBanner(details));
|
|
@@ -603,9 +626,6 @@ async function selectSourceRepo(target, cancelAction = 'exit') {
|
|
|
603
626
|
}
|
|
604
627
|
return { repoPath: String(selected), sourceType: 'private' };
|
|
605
628
|
}
|
|
606
|
-
function formatSourceRepoPromptPath(target, selected) {
|
|
607
|
-
return renderedSourceRepoPath(target, selected.sourceType, selected.repoPath);
|
|
608
|
-
}
|
|
609
629
|
function suggestedModuleName(repoPath) {
|
|
610
630
|
return 'odoo_sample_module';
|
|
611
631
|
}
|
|
@@ -801,23 +821,18 @@ function removeModuleOptionsFromArgs(argv) {
|
|
|
801
821
|
};
|
|
802
822
|
}
|
|
803
823
|
async function removeModuleOptionsFromPrompts(showIntro = true, cancelAction = 'exit') {
|
|
804
|
-
|
|
824
|
+
if (showIntro) {
|
|
825
|
+
introPrompt('Remove module');
|
|
826
|
+
}
|
|
805
827
|
const target = process.cwd();
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
if (modules.length === 0) {
|
|
828
|
+
const selectedModule = await selectModuleFromBrowser(target, { cancelAction });
|
|
829
|
+
if (!selectedModule) {
|
|
809
830
|
if (cancelAction === 'back') {
|
|
810
|
-
notePrompt(
|
|
831
|
+
notePrompt('No Odoo modules found.\nNext: choose "Add module" or "Add source repo" first.', 'Nothing to remove');
|
|
811
832
|
handleUnavailableMenuChoice(cancelAction);
|
|
812
833
|
}
|
|
813
|
-
throw new Error(
|
|
834
|
+
throw new Error('No Odoo modules found');
|
|
814
835
|
}
|
|
815
|
-
const moduleName = await selectPrompt({
|
|
816
|
-
message: menuPromptMessage('Module to remove', cancelAction),
|
|
817
|
-
options: modules.map((module) => ({ value: module, label: module })),
|
|
818
|
-
initialValue: modules[0],
|
|
819
|
-
});
|
|
820
|
-
handleCancel(moduleName, cancelAction);
|
|
821
836
|
const deleteFiles = await confirmPrompt({
|
|
822
837
|
message: menuPromptMessage('Delete module files too? (y/N)', cancelAction),
|
|
823
838
|
active: 'Y',
|
|
@@ -827,9 +842,9 @@ async function removeModuleOptionsFromPrompts(showIntro = true, cancelAction = '
|
|
|
827
842
|
handleCancel(deleteFiles, cancelAction);
|
|
828
843
|
return {
|
|
829
844
|
target,
|
|
830
|
-
repoPath:
|
|
831
|
-
sourceType:
|
|
832
|
-
moduleName:
|
|
845
|
+
repoPath: selectedModule.repoPath,
|
|
846
|
+
sourceType: selectedModule.sourceType,
|
|
847
|
+
moduleName: selectedModule.moduleName,
|
|
833
848
|
deleteFiles: Boolean(deleteFiles),
|
|
834
849
|
stage: true,
|
|
835
850
|
};
|
|
@@ -968,18 +983,327 @@ async function finishCreateFlow(result, cwd, interactive) {
|
|
|
968
983
|
}
|
|
969
984
|
outroPrompt(`Created Odoo dev overlay in ${options.target}. Review staged changes, then commit.`);
|
|
970
985
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
986
|
+
function selectedModuleRemovalOptions(module, cwd, deleteFiles) {
|
|
987
|
+
return {
|
|
988
|
+
target: cwd,
|
|
989
|
+
repoPath: module.repoPath,
|
|
990
|
+
sourceType: module.sourceType,
|
|
991
|
+
moduleName: module.moduleName,
|
|
992
|
+
deleteFiles,
|
|
993
|
+
stage: true,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
function moduleDailyAction(action) {
|
|
997
|
+
if (action === 'update')
|
|
998
|
+
return 'update';
|
|
999
|
+
if (action === 'test')
|
|
1000
|
+
return 'test';
|
|
1001
|
+
if (action === 'lint')
|
|
1002
|
+
return 'lint';
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
function moduleDailyActionArgs(action, module) {
|
|
1006
|
+
if (action === 'update' || action === 'test') {
|
|
1007
|
+
return [module.moduleName];
|
|
974
1008
|
}
|
|
975
|
-
|
|
976
|
-
|
|
1009
|
+
return [];
|
|
1010
|
+
}
|
|
1011
|
+
function moduleActionTitle(action) {
|
|
1012
|
+
if (action === 'update')
|
|
1013
|
+
return 'Update module';
|
|
1014
|
+
if (action === 'test')
|
|
1015
|
+
return 'Test module';
|
|
1016
|
+
if (action === 'lint')
|
|
1017
|
+
return 'Run lint';
|
|
1018
|
+
if (action === 'delete')
|
|
1019
|
+
return 'Delete module';
|
|
1020
|
+
return 'Module action';
|
|
1021
|
+
}
|
|
1022
|
+
function moduleActionCompletedLabel(action) {
|
|
1023
|
+
if (action === 'update')
|
|
1024
|
+
return 'Update';
|
|
1025
|
+
if (action === 'test')
|
|
1026
|
+
return 'Test';
|
|
1027
|
+
if (action === 'lint')
|
|
1028
|
+
return 'Lint';
|
|
1029
|
+
return 'Action';
|
|
1030
|
+
}
|
|
1031
|
+
function commandActionTitle(command) {
|
|
1032
|
+
if (command === 'update')
|
|
1033
|
+
return 'Update module';
|
|
1034
|
+
if (command === 'test')
|
|
1035
|
+
return 'Test module';
|
|
1036
|
+
if (command === 'lint')
|
|
1037
|
+
return 'Run lint';
|
|
1038
|
+
if (command === 'pot')
|
|
1039
|
+
return 'Generate POT';
|
|
1040
|
+
return command;
|
|
1041
|
+
}
|
|
1042
|
+
function commandCompletedLabel(command) {
|
|
1043
|
+
if (command === 'install')
|
|
1044
|
+
return 'Install';
|
|
1045
|
+
if (command === 'update')
|
|
1046
|
+
return 'Update';
|
|
1047
|
+
if (command === 'test')
|
|
1048
|
+
return 'Test';
|
|
1049
|
+
if (command === 'lint')
|
|
1050
|
+
return 'Lint';
|
|
1051
|
+
if (command === 'pot')
|
|
1052
|
+
return 'Generate POT';
|
|
1053
|
+
return command;
|
|
1054
|
+
}
|
|
1055
|
+
function shouldReturnToDailySelection(command) {
|
|
1056
|
+
return ['install', 'update', 'test', 'pot'].includes(command);
|
|
1057
|
+
}
|
|
1058
|
+
function shouldUseModuleBrowserForDailySelection(command) {
|
|
1059
|
+
return ['update', 'test', 'lint', 'pot'].includes(command);
|
|
1060
|
+
}
|
|
1061
|
+
function dailyActionSelectedLabel(command, argv) {
|
|
1062
|
+
if (['install', 'update', 'test', 'pot'].includes(command)) {
|
|
1063
|
+
return argv[0];
|
|
1064
|
+
}
|
|
1065
|
+
return undefined;
|
|
1066
|
+
}
|
|
1067
|
+
async function selectDatabaseArg(cwd, message, fallback, options = {}) {
|
|
1068
|
+
const databases = await listEnvironmentDatabases(cwd, options);
|
|
1069
|
+
if (databases.length > 0) {
|
|
1070
|
+
const selected = await selectPrompt({
|
|
1071
|
+
message: menuPromptMessage(message, 'back'),
|
|
1072
|
+
options: [
|
|
1073
|
+
...databases.map((database) => ({ value: database, label: database })),
|
|
1074
|
+
{ value: manualDatabaseValue, label: 'Manual entry' },
|
|
1075
|
+
],
|
|
1076
|
+
initialValue: databases.includes(fallback) ? fallback : databases[0],
|
|
1077
|
+
});
|
|
1078
|
+
handleCancel(selected, 'back');
|
|
1079
|
+
if (selected !== manualDatabaseValue) {
|
|
1080
|
+
return String(selected);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return asString(await textPrompt({
|
|
1084
|
+
message: menuPromptMessage(message, 'back'),
|
|
1085
|
+
defaultValue: fallback,
|
|
1086
|
+
placeholder: fallback,
|
|
1087
|
+
}), fallback, 'back');
|
|
1088
|
+
}
|
|
1089
|
+
async function collectCockpitModuleDailyActionArgs(command, module, cwd) {
|
|
1090
|
+
const moduleName = module.moduleName;
|
|
1091
|
+
if (command === 'update') {
|
|
1092
|
+
return [moduleName];
|
|
1093
|
+
}
|
|
1094
|
+
if (command === 'test') {
|
|
1095
|
+
return [moduleName];
|
|
1096
|
+
}
|
|
1097
|
+
if (command === 'lint') {
|
|
1098
|
+
return [];
|
|
1099
|
+
}
|
|
1100
|
+
if (command === 'pot') {
|
|
1101
|
+
return [moduleName];
|
|
1102
|
+
}
|
|
1103
|
+
throw new Error(`Unsupported module action command: ${command}`);
|
|
1104
|
+
}
|
|
1105
|
+
async function renderDailyActionResultPageHeader(title, selectedLabel, cwd) {
|
|
1106
|
+
await renderCockpitSubmenuPage(title, cwd);
|
|
1107
|
+
if (selectedLabel) {
|
|
1108
|
+
console.log(renderActionText(selectedLabel));
|
|
1109
|
+
console.log('');
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
async function renderCockpitSubmenuPage(title, cwd) {
|
|
1113
|
+
const status = await getEnvironmentStatus(cwd);
|
|
1114
|
+
const serviceStatus = await getServiceRuntimeStatus(cwd, status);
|
|
1115
|
+
clearCockpitScreen();
|
|
1116
|
+
console.log(renderBanner(renderCockpitStatusLines(status, serviceStatus, `Last: ${title}`), { version: startupVersionLine() }));
|
|
1117
|
+
console.log();
|
|
1118
|
+
introPrompt(title);
|
|
1119
|
+
}
|
|
1120
|
+
async function waitForModuleActionBack() {
|
|
1121
|
+
console.log(renderBackHelp());
|
|
1122
|
+
if (!process.stdin.isTTY) {
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
await new Promise((resolve) => {
|
|
1126
|
+
emitKeypressEvents(process.stdin);
|
|
1127
|
+
const input = process.stdin;
|
|
1128
|
+
const wasRaw = input.isRaw;
|
|
1129
|
+
const listener = (_value, key) => {
|
|
1130
|
+
if (key.ctrl && key.name === 'c') {
|
|
1131
|
+
process.exit(1);
|
|
1132
|
+
}
|
|
1133
|
+
if (key.name === 'escape' || key.sequence === '\u001B') {
|
|
1134
|
+
cleanup();
|
|
1135
|
+
resolve();
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
const cleanup = () => {
|
|
1139
|
+
input.off('keypress', listener);
|
|
1140
|
+
if (typeof input.setRawMode === 'function') {
|
|
1141
|
+
input.setRawMode(Boolean(wasRaw));
|
|
1142
|
+
}
|
|
1143
|
+
input.pause();
|
|
1144
|
+
};
|
|
1145
|
+
if (typeof input.setRawMode === 'function') {
|
|
1146
|
+
input.setRawMode(true);
|
|
1147
|
+
}
|
|
1148
|
+
input.resume();
|
|
1149
|
+
input.on('keypress', listener);
|
|
1150
|
+
});
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
async function runDailyActionResultPage(command, argv, cwd, title = commandActionTitle(command), selectedLabel = dailyActionSelectedLabel(command, argv), completedLabel = commandCompletedLabel(command)) {
|
|
1154
|
+
await renderDailyActionResultPageHeader(title, selectedLabel, cwd);
|
|
1155
|
+
try {
|
|
1156
|
+
await runDailyActionWithStyledOutput(command, argv, cwd);
|
|
1157
|
+
notePrompt(renderCompletedText(completedLabel), 'Done');
|
|
1158
|
+
}
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1161
|
+
notePrompt(message, 'Error');
|
|
1162
|
+
await waitForModuleActionBack();
|
|
1163
|
+
throw error;
|
|
1164
|
+
}
|
|
1165
|
+
return waitForModuleActionBack();
|
|
1166
|
+
}
|
|
1167
|
+
async function runSelectedModuleDailyAction(action, module, cwd) {
|
|
1168
|
+
const command = moduleDailyAction(action);
|
|
1169
|
+
if (!command) {
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
return runDailyActionResultPage(command, moduleDailyActionArgs(action, module), cwd, moduleActionTitle(action), module.moduleName, moduleActionCompletedLabel(action));
|
|
1173
|
+
}
|
|
1174
|
+
async function runSelectedModuleAction(action, module, cwd) {
|
|
1175
|
+
if (action === 'delete') {
|
|
1176
|
+
const deleteFiles = await confirmPrompt({
|
|
1177
|
+
message: menuPromptMessage('Delete module files too? (y/N)', 'back'),
|
|
1178
|
+
active: 'Y',
|
|
1179
|
+
inactive: 'n',
|
|
1180
|
+
initialValue: false,
|
|
1181
|
+
});
|
|
1182
|
+
handleCancel(deleteFiles, 'back');
|
|
1183
|
+
const removeCommand = cockpitCommands.find((entry) => entry.id === 'remove-module');
|
|
1184
|
+
if (removeCommand && !(await confirmCockpitCommandRisk(removeCommand))) {
|
|
1185
|
+
notePrompt(`Module ${module.moduleName} was not removed.`, 'Action skipped');
|
|
1186
|
+
return false;
|
|
1187
|
+
}
|
|
1188
|
+
await removeModuleFromSourceRepo(selectedModuleRemovalOptions(module, cwd, Boolean(deleteFiles)));
|
|
1189
|
+
notePrompt(`Removed module ${module.moduleName} from source repo ${module.repoPath}.`, 'Done');
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
return runSelectedModuleDailyAction(action, module, cwd);
|
|
1193
|
+
}
|
|
1194
|
+
async function runCockpitModuleDailyCommand(command, cwd) {
|
|
1195
|
+
if (command.target.kind !== 'daily') {
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const dailyCommand = command.target.command;
|
|
1199
|
+
while (true) {
|
|
1200
|
+
let selectedModule;
|
|
1201
|
+
let argv;
|
|
1202
|
+
try {
|
|
1203
|
+
await renderCockpitSubmenuPage(command.label, cwd);
|
|
1204
|
+
const module = await selectModuleFromBrowser(cwd);
|
|
1205
|
+
if (!module) {
|
|
1206
|
+
notePrompt('No Odoo modules found.\nNext: choose "Add module" or "Add source repo" first.', command.label);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
selectedModule = module;
|
|
1210
|
+
argv = await collectCockpitModuleDailyActionArgs(dailyCommand, selectedModule, cwd);
|
|
1211
|
+
}
|
|
1212
|
+
catch (error) {
|
|
1213
|
+
if (isMenuBackSignal(error)) {
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
throw error;
|
|
1217
|
+
}
|
|
977
1218
|
if (!(await confirmCockpitCommandRisk(command))) {
|
|
978
1219
|
notePrompt(`${command.slashAlias} was not run.`, 'Action skipped');
|
|
979
|
-
return
|
|
1220
|
+
return;
|
|
980
1221
|
}
|
|
981
|
-
await
|
|
1222
|
+
const returnedByBack = await runDailyActionResultPage(dailyCommand, argv, cwd, command.label, selectedModule.moduleName, commandCompletedLabel(dailyCommand));
|
|
1223
|
+
if (!returnedByBack) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
async function runCockpitDailyCommand(command, cwd) {
|
|
1229
|
+
if (command.target.kind !== 'daily') {
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const dailyCommand = command.target.command;
|
|
1233
|
+
if (shouldUseModuleBrowserForDailySelection(dailyCommand)) {
|
|
1234
|
+
await runCockpitModuleDailyCommand(command, cwd);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
if (!shouldReturnToDailySelection(dailyCommand)) {
|
|
1238
|
+
const argv = await collectDailyActionArgs(dailyCommand, cwd);
|
|
1239
|
+
if (!(await confirmCockpitCommandRisk(command))) {
|
|
1240
|
+
notePrompt(`${command.slashAlias} was not run.`, 'Action skipped');
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
await runDailyAction(dailyCommand, argv, cwd);
|
|
982
1244
|
notePrompt(`${command.slashAlias} completed.`, 'Done');
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
while (true) {
|
|
1248
|
+
let argv;
|
|
1249
|
+
try {
|
|
1250
|
+
await renderCockpitSubmenuPage(command.label, cwd);
|
|
1251
|
+
argv = await collectDailyActionArgs(dailyCommand, cwd);
|
|
1252
|
+
}
|
|
1253
|
+
catch (error) {
|
|
1254
|
+
if (isMenuBackSignal(error)) {
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
throw error;
|
|
1258
|
+
}
|
|
1259
|
+
if (!(await confirmCockpitCommandRisk(command))) {
|
|
1260
|
+
notePrompt(`${command.slashAlias} was not run.`, 'Action skipped');
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
const returnedByBack = await runDailyActionResultPage(dailyCommand, argv, cwd, command.label);
|
|
1264
|
+
if (!returnedByBack) {
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
async function runListModulesCommand(cwd) {
|
|
1270
|
+
while (true) {
|
|
1271
|
+
await renderCockpitSubmenuPage('List modules', cwd);
|
|
1272
|
+
const selectedModule = await selectModuleFromBrowser(cwd);
|
|
1273
|
+
if (!selectedModule) {
|
|
1274
|
+
notePrompt('No Odoo modules found.\nNext: choose "Add module" or "Add source repo" first.', 'List modules');
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
while (true) {
|
|
1278
|
+
let moduleAction;
|
|
1279
|
+
try {
|
|
1280
|
+
await renderCockpitSubmenuPage('List modules', cwd);
|
|
1281
|
+
console.log(renderActionText(selectedModule.moduleName));
|
|
1282
|
+
console.log('');
|
|
1283
|
+
moduleAction = await selectModuleAction(selectedModule);
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
if (isMenuBackSignal(error)) {
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
throw error;
|
|
1290
|
+
}
|
|
1291
|
+
if (!moduleAction) {
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
const returnedByBack = await runSelectedModuleAction(moduleAction, selectedModule, cwd);
|
|
1295
|
+
if (!returnedByBack) {
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
async function runCockpitCommand(command, cwd) {
|
|
1302
|
+
if (command.id === 'exit') {
|
|
1303
|
+
return 'exit';
|
|
1304
|
+
}
|
|
1305
|
+
if (command.target.kind === 'daily') {
|
|
1306
|
+
await runCockpitDailyCommand(command, cwd);
|
|
983
1307
|
return 'continue';
|
|
984
1308
|
}
|
|
985
1309
|
if (command.id === 'status') {
|
|
@@ -990,6 +1314,10 @@ async function runCockpitCommand(command, cwd) {
|
|
|
990
1314
|
notePrompt(await runDoctor(cwd), 'Doctor');
|
|
991
1315
|
return 'continue';
|
|
992
1316
|
}
|
|
1317
|
+
if (command.id === 'list-modules') {
|
|
1318
|
+
await runListModulesCommand(cwd);
|
|
1319
|
+
return 'continue';
|
|
1320
|
+
}
|
|
993
1321
|
if (command.id === 'add-repo') {
|
|
994
1322
|
const options = await addRepoOptionsFromPrompts(false, 'back');
|
|
995
1323
|
await ensureAddRepoGitHubRepository(options, 'back');
|
|
@@ -1014,7 +1342,7 @@ async function runCockpitCommand(command, cwd) {
|
|
|
1014
1342
|
return 'continue';
|
|
1015
1343
|
}
|
|
1016
1344
|
if (command.id === 'remove-module') {
|
|
1017
|
-
const options = await removeModuleOptionsFromPrompts(
|
|
1345
|
+
const options = await removeModuleOptionsFromPrompts(true, 'back');
|
|
1018
1346
|
if (!(await confirmCockpitCommandRisk(command))) {
|
|
1019
1347
|
notePrompt(`Module ${options.moduleName} was not removed.`, 'Action skipped');
|
|
1020
1348
|
return 'continue';
|
|
@@ -1058,6 +1386,11 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
1058
1386
|
let status = await getEnvironmentStatus(cwd);
|
|
1059
1387
|
let serviceStatus = await getServiceRuntimeStatus(cwd, status);
|
|
1060
1388
|
await showStartup(argv, skipUpdateCheck, () => renderCockpitStatusLines(status, serviceStatus, lastStatus));
|
|
1389
|
+
const renderCockpitMenuShell = () => {
|
|
1390
|
+
clearCockpitScreen();
|
|
1391
|
+
console.log(renderBanner(renderCockpitStatusLines(status, serviceStatus, lastStatus), { version: startupVersionLine() }));
|
|
1392
|
+
console.log();
|
|
1393
|
+
};
|
|
1061
1394
|
while (true) {
|
|
1062
1395
|
try {
|
|
1063
1396
|
const command = await selectCockpitCommandFromMenu(serviceStatus);
|
|
@@ -1071,6 +1404,7 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
1071
1404
|
}
|
|
1072
1405
|
catch (error) {
|
|
1073
1406
|
if (isMenuBackSignal(error)) {
|
|
1407
|
+
renderCockpitMenuShell();
|
|
1074
1408
|
continue;
|
|
1075
1409
|
}
|
|
1076
1410
|
commandFailed = true;
|
|
@@ -1084,9 +1418,7 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
1084
1418
|
}
|
|
1085
1419
|
status = await getEnvironmentStatus(cwd);
|
|
1086
1420
|
serviceStatus = await getServiceRuntimeStatus(cwd, status);
|
|
1087
|
-
|
|
1088
|
-
console.log(renderBanner(renderCockpitStatusLines(status, serviceStatus, lastStatus), { version: startupVersionLine() }));
|
|
1089
|
-
console.log();
|
|
1421
|
+
renderCockpitMenuShell();
|
|
1090
1422
|
}
|
|
1091
1423
|
catch (error) {
|
|
1092
1424
|
if (isMenuBackSignal(error)) {
|
|
@@ -34,6 +34,10 @@ export const cockpitCommands = [
|
|
|
34
34
|
dailyCommand('restart', 'services', 'Restart services', 'Restart the Odoo development services.', ['reload']),
|
|
35
35
|
dailyCommand('logs', 'services', 'View logs', 'Stream logs for an Odoo environment service.', ['log', 'tail']),
|
|
36
36
|
dailyCommand('shell', 'services', 'Open shell', 'Open a shell inside the Odoo service container.', ['bash', 'terminal']),
|
|
37
|
+
internalCommand('list-modules', 'modules', 'List modules', 'Browse detected Odoo modules by source category.', [
|
|
38
|
+
'modules list',
|
|
39
|
+
'browse modules',
|
|
40
|
+
]),
|
|
37
41
|
dailyCommand('install', 'modules', 'Install module', 'Install one or more Odoo modules into a database.', ['install module']),
|
|
38
42
|
dailyCommand('update', 'modules', 'Update module', 'Update one or more Odoo modules in a database.', ['upgrade']),
|
|
39
43
|
dailyCommand('test', 'modules', 'Run tests', 'Run Odoo tests for one or more modules.', ['tests', 'pytest']),
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { listEnvironmentDatabases } 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,24 @@ 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 databases = await deps.databases(cwd, options);
|
|
88
|
+
if (databases.length > 0) {
|
|
89
|
+
const selected = await deps.list({
|
|
90
|
+
message: menuPromptMessage(message, 'back'),
|
|
91
|
+
options: [
|
|
92
|
+
...databases.map((database) => ({ value: database, label: database })),
|
|
93
|
+
{ value: manualDatabaseValue, label: 'Manual entry' },
|
|
94
|
+
],
|
|
95
|
+
initialValue: databases.includes(fallback) ? fallback : databases[0],
|
|
96
|
+
});
|
|
97
|
+
deps.handleCancel(selected, 'back');
|
|
98
|
+
if (selected !== manualDatabaseValue) {
|
|
99
|
+
return String(selected);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return optionalTextArg(deps, message, fallback);
|
|
103
|
+
}
|
|
83
104
|
async function optionalModules(cwd, deps) {
|
|
84
105
|
const modules = await detectedModules(cwd);
|
|
85
106
|
if (modules.length === 0) {
|
|
@@ -120,19 +141,16 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
|
|
|
120
141
|
return [await optionalTextArg(deps, 'Service', 'odoo')];
|
|
121
142
|
}
|
|
122
143
|
if (command === 'psql') {
|
|
123
|
-
return [await
|
|
144
|
+
return [await databaseArg(cwd, deps, 'Database', 'postgres', { includeMaintenance: true })];
|
|
124
145
|
}
|
|
125
146
|
if (command === 'install' || command === 'update') {
|
|
126
147
|
const modules = await moduleArg(cwd, deps);
|
|
127
|
-
const db =
|
|
128
|
-
|
|
129
|
-
placeholder: 'devel',
|
|
130
|
-
}), '', deps);
|
|
131
|
-
return db ? [modules, db] : [modules];
|
|
148
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
149
|
+
return [modules, db];
|
|
132
150
|
}
|
|
133
151
|
if (command === 'test') {
|
|
134
152
|
const modules = await moduleArg(cwd, deps);
|
|
135
|
-
const db = await
|
|
153
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
136
154
|
const mode = asString(await deps.list({
|
|
137
155
|
message: menuPromptMessage('Mode', 'back'),
|
|
138
156
|
options: [
|
|
@@ -151,17 +169,17 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
|
|
|
151
169
|
}
|
|
152
170
|
if (command === 'pot') {
|
|
153
171
|
const modules = await moduleArg(cwd, deps);
|
|
154
|
-
const db = await
|
|
172
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
155
173
|
const output = await optionalTextArg(deps, 'Output file', `i18n/${modules}.pot`);
|
|
156
174
|
return [modules, db, output];
|
|
157
175
|
}
|
|
158
176
|
if (command === 'resetdb') {
|
|
159
|
-
const db = await
|
|
177
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
160
178
|
const modules = await optionalModules(cwd, deps);
|
|
161
179
|
return modules ? [db, modules] : [db];
|
|
162
180
|
}
|
|
163
181
|
if (command === 'snapshot') {
|
|
164
|
-
const db = await
|
|
182
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
165
183
|
const snapshotName = await optionalTextArg(deps, 'Snapshot name', 'before-update');
|
|
166
184
|
return [db, snapshotName];
|
|
167
185
|
}
|
|
@@ -170,7 +188,7 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
|
|
|
170
188
|
message: menuPromptMessage('Snapshot name', 'back'),
|
|
171
189
|
validate: (value) => (value.trim() ? undefined : 'Enter the snapshot name.'),
|
|
172
190
|
}), 'Snapshot name is required.', deps);
|
|
173
|
-
const db = await
|
|
191
|
+
const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
|
|
174
192
|
return [snapshotName, db];
|
|
175
193
|
}
|
|
176
194
|
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: '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,46 @@
|
|
|
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 async function listEnvironmentDatabases(cwd, options = {}) {
|
|
26
|
+
const queryLiteral = JSON.stringify(listDatabasesQuery);
|
|
27
|
+
const command = [
|
|
28
|
+
`query=${queryLiteral}`,
|
|
29
|
+
'. ./scripts/lib.sh >/dev/null',
|
|
30
|
+
'compose exec -T db psql -U "${POSTGRES_USER:-odoo}" -d "${POSTGRES_DB:-postgres}" -Atc "$query"',
|
|
31
|
+
].join(' && ');
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const child = spawn('bash', ['-lc', command], {
|
|
34
|
+
cwd,
|
|
35
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
});
|
|
37
|
+
let output = '';
|
|
38
|
+
child.stdout?.on('data', (chunk) => {
|
|
39
|
+
output += chunk.toString('utf8');
|
|
40
|
+
});
|
|
41
|
+
child.on('error', () => resolve([]));
|
|
42
|
+
child.on('close', (code) => {
|
|
43
|
+
resolve(code === 0 ? parseDatabaseListOutput(output, options) : []);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
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]
|
|
@@ -118,7 +118,7 @@ Task recipes:
|
|
|
118
118
|
Add OCA module:
|
|
119
119
|
npx @wpmoo/toolkit add-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
|
|
120
120
|
Run tests:
|
|
121
|
-
npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
|
|
121
|
+
npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
|
|
122
122
|
Safe reset and recover:
|
|
123
123
|
npx @wpmoo/toolkit snapshot [db] [snapshot-name]
|
|
124
124
|
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,9 +120,17 @@ 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) {
|
|
133
|
+
const keysHelpTip = navigationHelp === 'back' ? '↑↓ navigate • ⏎ select • Esc to go back' : '↑↓ navigate • ⏎ select • Ctrl+C exit';
|
|
98
134
|
return {
|
|
99
135
|
prefix: '',
|
|
100
136
|
icon: {
|
|
@@ -104,20 +140,30 @@ function hiddenSelectTheme(disabledError) {
|
|
|
104
140
|
message: () => '',
|
|
105
141
|
highlight: (text) => text,
|
|
106
142
|
disabled: (text) => styleText('dim', text.replace(/ \(disabled\)$/u, ''), { validateStream: false }),
|
|
107
|
-
keysHelpTip: () =>
|
|
143
|
+
keysHelpTip: () => {
|
|
144
|
+
const warning = renderedNavigationWarning(navigationWarning);
|
|
145
|
+
return warning ? `${warning}\n${keysHelpTip}` : keysHelpTip;
|
|
146
|
+
},
|
|
108
147
|
},
|
|
109
148
|
i18n: disabledError ? { disabledError } : undefined,
|
|
110
149
|
};
|
|
111
150
|
}
|
|
112
151
|
function withHiddenSelectMessage(config) {
|
|
113
|
-
if (!config.hideMessage
|
|
152
|
+
if (!config.hideMessage &&
|
|
153
|
+
!config.disabledError &&
|
|
154
|
+
!config.navigationHelp &&
|
|
155
|
+
!config.navigationWarning &&
|
|
156
|
+
!config.escapeBehavior) {
|
|
114
157
|
return config;
|
|
115
158
|
}
|
|
116
|
-
const { disabledError, hideMessage: _hideMessage, ...inquirerConfig } = config;
|
|
159
|
+
const { disabledError, hideMessage: _hideMessage, navigationHelp, navigationWarning, escapeBehavior: _escapeBehavior, ...inquirerConfig } = config;
|
|
160
|
+
if (!config.hideMessage) {
|
|
161
|
+
return inquirerConfig;
|
|
162
|
+
}
|
|
117
163
|
return {
|
|
118
164
|
...inquirerConfig,
|
|
119
165
|
message: '',
|
|
120
|
-
theme: hiddenSelectTheme(disabledError),
|
|
166
|
+
theme: hiddenSelectTheme(disabledError, navigationHelp, navigationWarning),
|
|
121
167
|
};
|
|
122
168
|
}
|
|
123
169
|
function asInquirerConfirmConfig(options) {
|
|
@@ -143,10 +189,13 @@ export function isPromptCancel(value) {
|
|
|
143
189
|
return value === promptCancelled;
|
|
144
190
|
}
|
|
145
191
|
export async function selectPrompt(options) {
|
|
192
|
+
const guardOptions = {
|
|
193
|
+
escapeBehavior: options.escapeBehavior,
|
|
194
|
+
};
|
|
146
195
|
if (isClackSelectOptions(options)) {
|
|
147
|
-
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(asInquirerSelectConfig(options)), context));
|
|
196
|
+
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(asInquirerSelectConfig(options)), context), guardOptions);
|
|
148
197
|
}
|
|
149
|
-
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(options), context));
|
|
198
|
+
return withPromptCancelGuard((context) => inquirerSelect(withHiddenSelectMessage(options), context), guardOptions);
|
|
150
199
|
}
|
|
151
200
|
export async function inputPrompt(options) {
|
|
152
201
|
return withPromptCancelGuard((context) => inquirerInput(asInquirerInputConfig(options), context));
|
package/dist/templates.js
CHANGED
|
@@ -431,7 +431,7 @@ usage() {
|
|
|
431
431
|
"psql") echo "Usage: ./moo psql [db]" ;;
|
|
432
432
|
"install") echo "Usage: ./moo install <module[,module]> [db]" ;;
|
|
433
433
|
"update") echo "Usage: ./moo update <module[,module]> [db]" ;;
|
|
434
|
-
"test") echo "Usage: ./moo test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]" ;;
|
|
434
|
+
"test") echo "Usage: ./moo test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]" ;;
|
|
435
435
|
"resetdb") echo "Usage: ./moo resetdb [db] [module[,module]]" ;;
|
|
436
436
|
"snapshot") echo "Usage: ./moo snapshot [db] [snapshot-name]" ;;
|
|
437
437
|
"restore-snapshot") echo "Usage: ./moo restore-snapshot [--dry-run] <snapshot-name> [db]" ;;
|
|
@@ -506,8 +506,8 @@ validate_test_args() {
|
|
|
506
506
|
echo "Missing value for --mode" >&2
|
|
507
507
|
exit 2
|
|
508
508
|
fi
|
|
509
|
-
if [[ "$2" != "init" && "$2" != "update" ]]; then
|
|
510
|
-
echo "Invalid value for --mode: expected init or update" >&2
|
|
509
|
+
if [[ "$2" != "auto" && "$2" != "init" && "$2" != "update" ]]; then
|
|
510
|
+
echo "Invalid value for --mode: expected auto, init, or update" >&2
|
|
511
511
|
exit 2
|
|
512
512
|
fi
|
|
513
513
|
shift 2
|