@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 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 { 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';
@@ -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
- showSubmenuIntro('Remove module from source repo', showIntro, cancelAction);
824
+ if (showIntro) {
825
+ introPrompt('Remove module');
826
+ }
805
827
  const target = process.cwd();
806
- const sourceRepo = await selectSourceRepo(target, cancelAction);
807
- const modules = await listModulesInSourceRepo(target, sourceRepo.repoPath, sourceRepo.sourceType);
808
- if (modules.length === 0) {
828
+ const selectedModule = await selectModuleFromBrowser(target, { cancelAction });
829
+ if (!selectedModule) {
809
830
  if (cancelAction === 'back') {
810
- 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');
811
832
  handleUnavailableMenuChoice(cancelAction);
812
833
  }
813
- throw new Error(`No Odoo modules found under ${formatSourceRepoPromptPath(target, sourceRepo)}`);
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: sourceRepo.repoPath,
831
- sourceType: sourceRepo.sourceType,
832
- moduleName: String(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
- async function runCockpitCommand(command, cwd) {
972
- if (command.id === 'exit') {
973
- 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];
974
1008
  }
975
- if (command.target.kind === 'daily') {
976
- 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
+ }
977
1218
  if (!(await confirmCockpitCommandRisk(command))) {
978
1219
  notePrompt(`${command.slashAlias} was not run.`, 'Action skipped');
979
- return 'continue';
1220
+ return;
980
1221
  }
981
- await runDailyAction(command.target.command, argv, cwd);
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(false, 'back');
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
- clearCockpitScreen();
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 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 [];
@@ -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, 'exit');
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
+ }
@@ -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
@@ -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 action === 'back' ? `${title} · Back (Esc)` : title;
13
+ return title;
14
14
  }
15
15
  export function menuPromptMessage(message, action) {
16
- return action === 'back' ? `${message} · Esc to go back` : message;
16
+ return message;
17
17
  }
18
18
  export function promptCancelOutcome(cancelled, action, key) {
19
19
  if (!cancelled) {
@@ -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);
@@ -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 installEscapeAbortController(controller) {
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.name !== 'escape' && key.sequence !== '\u001B') {
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 hiddenSelectTheme(disabledError) {
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: () => '↑↓ navigate • ⏎ select • Ctrl+C exit',
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {