@wpmoo/toolkit 0.9.4 → 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 CHANGED
@@ -2,26 +2,32 @@
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 { isDailyActionCommand, runDailyAction } from './daily-actions.js';
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, listModulesInSourceRepo, removeModuleFromSourceRepo, } from './module-actions.js';
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';
22
27
  import { inferGitHubOwner, inferRepoPath, normalizeRepositoryUrl } from './repo-url.js';
23
28
  import { addModuleRepo, listModuleRepos, removeModuleRepo } from './repo-actions.js';
24
29
  import { renderSafeResetPreview, safeResetEnvironment } from './safe-reset.js';
30
+ import { getServiceRuntimeStatus, renderServiceRuntimeStatusLine, } from './service-runtime-status.js';
25
31
  import { listSources, renderSourceList, sourceListJson, sourceSyncJson, syncSources, } from './source-actions.js';
26
32
  import { backupTargetPath, expectedTargetConfirmation, inspectEnvironmentTarget, renderExistingEnvironmentSummary, renderForeignEnvironmentTargetWarning, } from './environment-target-preflight.js';
27
33
  import { getGitHubPrerequisiteStatus, renderGitHubPrerequisiteGuidance, } from './github-prerequisites.js';
@@ -195,17 +201,39 @@ function renderStartupBanner(details, latestVersion) {
195
201
  const versionLine = startupVersionLine(latestVersion);
196
202
  return renderBanner(details?.(versionLine), details ? { version: versionLine } : undefined);
197
203
  }
198
- function renderCockpitStatusLines(status, lastStatus) {
199
- return [renderStartupEnvironmentLine(status), lastStatus];
204
+ function renderCockpitStatusLines(status, serviceStatus, lastStatus) {
205
+ return [renderStartupEnvironmentLine(status), renderServiceRuntimeStatusLine(serviceStatus), lastStatus];
200
206
  }
201
207
  function renderLastCommandStatus(command) {
202
208
  return `Last: ${command.label} ✓ completed`;
203
209
  }
210
+ function renderLastCommandError(command, error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ return `Last: ${command.label} ✗ Error: ${message}`;
213
+ }
204
214
  function clearCockpitScreen() {
205
215
  if (process.stdout.isTTY) {
206
216
  process.stdout.write('\u001B[2J\u001B[H');
207
217
  }
208
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__';
209
237
  async function showStartup(argv, skipUpdateCheck, details) {
210
238
  if (skipUpdateCheck) {
211
239
  console.log(renderStartupBanner(details));
@@ -245,8 +273,8 @@ async function showStartup(argv, skipUpdateCheck, details) {
245
273
  }
246
274
  console.log();
247
275
  }
248
- async function selectCockpitCommandFromMenu() {
249
- const selection = await selectCockpitTopLevelMenu();
276
+ async function selectCockpitCommandFromMenu(serviceStatus) {
277
+ const selection = await selectCockpitTopLevelMenu({ serviceStatus });
250
278
  if (selection.kind === 'exit') {
251
279
  return 'exit';
252
280
  }
@@ -598,9 +626,6 @@ async function selectSourceRepo(target, cancelAction = 'exit') {
598
626
  }
599
627
  return { repoPath: String(selected), sourceType: 'private' };
600
628
  }
601
- function formatSourceRepoPromptPath(target, selected) {
602
- return renderedSourceRepoPath(target, selected.sourceType, selected.repoPath);
603
- }
604
629
  function suggestedModuleName(repoPath) {
605
630
  return 'odoo_sample_module';
606
631
  }
@@ -796,23 +821,18 @@ function removeModuleOptionsFromArgs(argv) {
796
821
  };
797
822
  }
798
823
  async function removeModuleOptionsFromPrompts(showIntro = true, cancelAction = 'exit') {
799
- showSubmenuIntro('Remove module from source repo', showIntro, cancelAction);
824
+ if (showIntro) {
825
+ introPrompt('Remove module');
826
+ }
800
827
  const target = process.cwd();
801
- const sourceRepo = await selectSourceRepo(target, cancelAction);
802
- const modules = await listModulesInSourceRepo(target, sourceRepo.repoPath, sourceRepo.sourceType);
803
- if (modules.length === 0) {
828
+ const selectedModule = await selectModuleFromBrowser(target, { cancelAction });
829
+ if (!selectedModule) {
804
830
  if (cancelAction === 'back') {
805
- notePrompt(`No Odoo modules found under ${formatSourceRepoPromptPath(target, sourceRepo)}.\nNext: choose "Add module to source repo" first.`, 'Nothing to remove');
831
+ notePrompt('No Odoo modules found.\nNext: choose "Add module" or "Add source repo" first.', 'Nothing to remove');
806
832
  handleUnavailableMenuChoice(cancelAction);
807
833
  }
808
- throw new Error(`No Odoo modules found under ${formatSourceRepoPromptPath(target, sourceRepo)}`);
834
+ throw new Error('No Odoo modules found');
809
835
  }
810
- const moduleName = await selectPrompt({
811
- message: menuPromptMessage('Module to remove', cancelAction),
812
- options: modules.map((module) => ({ value: module, label: module })),
813
- initialValue: modules[0],
814
- });
815
- handleCancel(moduleName, cancelAction);
816
836
  const deleteFiles = await confirmPrompt({
817
837
  message: menuPromptMessage('Delete module files too? (y/N)', cancelAction),
818
838
  active: 'Y',
@@ -822,9 +842,9 @@ async function removeModuleOptionsFromPrompts(showIntro = true, cancelAction = '
822
842
  handleCancel(deleteFiles, cancelAction);
823
843
  return {
824
844
  target,
825
- repoPath: sourceRepo.repoPath,
826
- sourceType: sourceRepo.sourceType,
827
- moduleName: String(moduleName),
845
+ repoPath: selectedModule.repoPath,
846
+ sourceType: selectedModule.sourceType,
847
+ moduleName: selectedModule.moduleName,
828
848
  deleteFiles: Boolean(deleteFiles),
829
849
  stage: true,
830
850
  };
@@ -963,18 +983,327 @@ async function finishCreateFlow(result, cwd, interactive) {
963
983
  }
964
984
  outroPrompt(`Created Odoo dev overlay in ${options.target}. Review staged changes, then commit.`);
965
985
  }
966
- async function runCockpitCommand(command, cwd) {
967
- if (command.id === 'exit') {
968
- return 'exit';
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];
969
1008
  }
970
- if (command.target.kind === 'daily') {
971
- const argv = await collectDailyActionArgs(command.target.command, cwd);
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
+ }
972
1218
  if (!(await confirmCockpitCommandRisk(command))) {
973
1219
  notePrompt(`${command.slashAlias} was not run.`, 'Action skipped');
974
- return 'continue';
1220
+ return;
1221
+ }
1222
+ const returnedByBack = await runDailyActionResultPage(dailyCommand, argv, cwd, command.label, selectedModule.moduleName, commandCompletedLabel(dailyCommand));
1223
+ if (!returnedByBack) {
1224
+ return;
975
1225
  }
976
- await runDailyAction(command.target.command, argv, cwd);
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);
977
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);
978
1307
  return 'continue';
979
1308
  }
980
1309
  if (command.id === 'status') {
@@ -985,6 +1314,10 @@ async function runCockpitCommand(command, cwd) {
985
1314
  notePrompt(await runDoctor(cwd), 'Doctor');
986
1315
  return 'continue';
987
1316
  }
1317
+ if (command.id === 'list-modules') {
1318
+ await runListModulesCommand(cwd);
1319
+ return 'continue';
1320
+ }
988
1321
  if (command.id === 'add-repo') {
989
1322
  const options = await addRepoOptionsFromPrompts(false, 'back');
990
1323
  await ensureAddRepoGitHubRepository(options, 'back');
@@ -1009,7 +1342,7 @@ async function runCockpitCommand(command, cwd) {
1009
1342
  return 'continue';
1010
1343
  }
1011
1344
  if (command.id === 'remove-module') {
1012
- const options = await removeModuleOptionsFromPrompts(false, 'back');
1345
+ const options = await removeModuleOptionsFromPrompts(true, 'back');
1013
1346
  if (!(await confirmCockpitCommandRisk(command))) {
1014
1347
  notePrompt(`Module ${options.moduleName} was not removed.`, 'Action skipped');
1015
1348
  return 'continue';
@@ -1050,23 +1383,42 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1050
1383
  return;
1051
1384
  }
1052
1385
  let lastStatus = 'Last: Ready';
1053
- const initialStatus = await getEnvironmentStatus(cwd);
1054
- await showStartup(argv, skipUpdateCheck, () => renderCockpitStatusLines(initialStatus, lastStatus));
1386
+ let status = await getEnvironmentStatus(cwd);
1387
+ let serviceStatus = await getServiceRuntimeStatus(cwd, status);
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
+ };
1055
1394
  while (true) {
1056
1395
  try {
1057
- const command = await selectCockpitCommandFromMenu();
1396
+ const command = await selectCockpitCommandFromMenu(serviceStatus);
1058
1397
  if (command === 'exit') {
1059
1398
  return;
1060
1399
  }
1061
- const outcome = await runCockpitCommand(command, cwd);
1400
+ let outcome = 'continue';
1401
+ let commandFailed = false;
1402
+ try {
1403
+ outcome = await runCockpitCommand(command, cwd);
1404
+ }
1405
+ catch (error) {
1406
+ if (isMenuBackSignal(error)) {
1407
+ renderCockpitMenuShell();
1408
+ continue;
1409
+ }
1410
+ commandFailed = true;
1411
+ lastStatus = renderLastCommandError(command, error);
1412
+ }
1062
1413
  if (outcome === 'exit') {
1063
1414
  return;
1064
1415
  }
1065
- lastStatus = renderLastCommandStatus(command);
1066
- const status = await getEnvironmentStatus(cwd);
1067
- clearCockpitScreen();
1068
- console.log(renderBanner(renderCockpitStatusLines(status, lastStatus), { version: startupVersionLine() }));
1069
- console.log();
1416
+ if (!commandFailed) {
1417
+ lastStatus = renderLastCommandStatus(command);
1418
+ }
1419
+ status = await getEnvironmentStatus(cwd);
1420
+ serviceStatus = await getServiceRuntimeStatus(cwd, status);
1421
+ renderCockpitMenuShell();
1070
1422
  }
1071
1423
  catch (error) {
1072
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 optionalTextArg(deps, 'Database', 'postgres')];
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 = asString(await deps.text({
128
- message: menuPromptMessage('Database (optional)', 'back'),
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 optionalTextArg(deps, 'Database', 'devel');
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 optionalTextArg(deps, 'Database', 'devel');
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 optionalTextArg(deps, 'Database', 'devel');
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 optionalTextArg(deps, 'Database', 'devel');
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 optionalTextArg(deps, 'Database', 'devel');
191
+ const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
174
192
  return [snapshotName, db];
175
193
  }
176
194
  return [];