@wpmoo/odoo 0.8.47 → 0.8.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,8 @@
1
- # @wpmoo/odoo
2
-
3
1
  ![WPMoo Odoo lifecycle tooling across development, staging, and production](docs/assets/wpmoo-banner.png)
4
2
 
5
3
 
6
4
  [![CI](https://img.shields.io/github/actions/workflow/status/wpmoo-org/wpmoo-odoo/ci.yml?branch=main&label=CI&style=flat-square)](https://github.com/wpmoo-org/wpmoo-odoo/actions/workflows/ci.yml) [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&style=flat-square)](https://github.com/wpmoo-org/wpmoo-odoo) [![npm](https://img.shields.io/npm/v/@wpmoo/odoo?label=npm&logo=npm&style=flat-square&color=blue)](https://www.npmjs.com/package/@wpmoo/odoo) [![Coverage Status](https://img.shields.io/coverallsCoverage/github/wpmoo-org/wpmoo-odoo?branch=main&label=coverage&logo=coveralls&style=flat-square&color=blue)](https://coveralls.io/github/wpmoo-org/wpmoo-odoo?branch=main) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE) [![Odoo Tool](https://img.shields.io/badge/Odoo-Tool-714B67?style=flat-square)](https://github.com/wpmoo-org/wpmoo-odoo) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=000000&style=flat-square)](https://www.buymeacoffee.com/cangir)
7
5
 
8
-
9
-
10
-
11
-
12
6
  WPMoo Odoo lifecycle tooling for development, staging, and production workflows.
13
7
 
14
8
  The CLI currently creates Docker Compose based Odoo environments, adds source
@@ -58,16 +52,26 @@ npx @wpmoo/odoo
58
52
  The wizard is context-aware. If the current directory is not already a WPMoo
59
53
  Odoo development environment, it starts the create flow directly.
60
54
 
61
- Inside an existing environment, it shows maintenance actions:
55
+ Inside an existing environment, it opens the cockpit. The cockpit includes
56
+ `Command palette /` for slash-style search across services, modules, database,
57
+ diagnostics, repositories, and maintenance categories, plus guided category
58
+ menus for common workflows.
62
59
 
63
60
  ```text
64
- Add source repo
65
- Remove source repo
66
- Add module to source repo
67
- Remove module from source repo
68
- Safe reset environment
61
+ Command palette /
62
+ Services
63
+ Modules
64
+ Database
65
+ Diagnostics
66
+ Repositories
67
+ Maintenance
68
+ Exit
69
69
  ```
70
70
 
71
+ Direct commands remain available for scripts and repeatable terminal workflows,
72
+ such as `npx @wpmoo/odoo status`, `npx @wpmoo/odoo test sale --db devel`, and
73
+ `npx @wpmoo/odoo logs odoo`.
74
+
71
75
  Non-interactive:
72
76
 
73
77
  ```bash
package/dist/cli.js CHANGED
@@ -4,6 +4,10 @@ import { realpathSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { commandFromArgs, defaultTargetForProduct, isHelpRequested, isVersionRequested, optionsFromArgs, parseArgs, stripInternalFlags, } from './args.js';
7
+ import { selectCockpitCommandFromPalette } from './cockpit/command-palette.js';
8
+ import { collectDailyActionArgs } from './cockpit/daily-prompts.js';
9
+ import { selectCockpitCategoryCommand, selectCockpitTopLevelMenu } from './cockpit/menu.js';
10
+ import { confirmCockpitCommandRisk } from './cockpit/safety.js';
7
11
  import { detectDevelopmentEnvironment } from './environment.js';
8
12
  import { commandOdooVersion } from './environment-version.js';
9
13
  import { defaultAgentSkillsTemplateUrl } from './external-templates.js';
@@ -130,22 +134,15 @@ async function showStartup(argv, skipUpdateCheck) {
130
134
  }
131
135
  console.log();
132
136
  }
133
- async function selectEnvironmentActionFromMenu() {
134
- intro('WPMoo Odoo Dev');
135
- const action = await select({
136
- message: 'What do you want to do?',
137
- options: [
138
- { value: 'add-repo', label: 'Add source repo' },
139
- { value: 'remove-repo', label: 'Remove source repo' },
140
- { value: 'add-module', label: 'Add module to source repo' },
141
- { value: 'remove-module', label: 'Remove module from source repo' },
142
- { value: 'reset', label: 'Safe reset environment' },
143
- { value: 'exit', label: 'Exit' },
144
- ],
145
- initialValue: 'add-module',
146
- });
147
- handleCancel(action, 'back');
148
- return action;
137
+ async function selectCockpitCommandFromMenu() {
138
+ const selection = await selectCockpitTopLevelMenu();
139
+ if (selection.kind === 'exit') {
140
+ return 'exit';
141
+ }
142
+ if (selection.kind === 'command-palette') {
143
+ return selectCockpitCommandFromPalette();
144
+ }
145
+ return selectCockpitCategoryCommand(selection.category);
149
146
  }
150
147
  async function optionsFromPrompts(showIntro = true, cancelAction = 'exit') {
151
148
  if (showIntro) {
@@ -547,6 +544,71 @@ async function ensureGitHubRepositories(options, interactive) {
547
544
  process.exit(1);
548
545
  await createGitHubRepositories(missing, visibility);
549
546
  }
547
+ async function runCockpitCommand(command, cwd) {
548
+ if (command.id === 'exit') {
549
+ return 'exit';
550
+ }
551
+ if (command.target.kind === 'daily') {
552
+ const argv = await collectDailyActionArgs(command.target.command, cwd);
553
+ if (!(await confirmCockpitCommandRisk(command))) {
554
+ note(`${command.slashAlias} was not run.`, 'Action skipped');
555
+ return 'continue';
556
+ }
557
+ await runDailyAction(command.target.command, argv, cwd);
558
+ note(`${command.slashAlias} completed.`, 'Done');
559
+ return 'continue';
560
+ }
561
+ if (command.id === 'status') {
562
+ note(await renderEnvironmentStatusForTarget(cwd), 'Environment status');
563
+ return 'continue';
564
+ }
565
+ if (command.id === 'doctor') {
566
+ note(await runDoctor(cwd), 'Doctor');
567
+ return 'continue';
568
+ }
569
+ if (command.id === 'add-repo') {
570
+ const options = await addRepoOptionsFromPrompts(false, 'back');
571
+ await ensureAddRepoGitHubRepository(options, 'back');
572
+ await addModuleRepo(options);
573
+ note(`Added source repo under ${options.target}/odoo/custom/src/private.`, 'Done');
574
+ return 'continue';
575
+ }
576
+ if (command.id === 'remove-repo') {
577
+ const options = await removeRepoOptionsFromPrompts([], false, 'back');
578
+ if (!(await confirmCockpitCommandRisk(command))) {
579
+ note(`Source repo ${options.repoPath} was not removed.`, 'Action skipped');
580
+ return 'continue';
581
+ }
582
+ await removeModuleRepo(options);
583
+ note(`Removed source repo ${options.repoPath} from ${options.target}.`, 'Done');
584
+ return 'continue';
585
+ }
586
+ if (command.id === 'add-module') {
587
+ const options = await addModuleOptionsFromPrompts(false, 'back');
588
+ await addModuleToSourceRepo(options);
589
+ note(`Added module ${options.moduleName} under source repo ${options.repoPath}.`, 'Done');
590
+ return 'continue';
591
+ }
592
+ if (command.id === 'remove-module') {
593
+ const options = await removeModuleOptionsFromPrompts(false, 'back');
594
+ if (!(await confirmCockpitCommandRisk(command))) {
595
+ note(`Module ${options.moduleName} was not removed.`, 'Action skipped');
596
+ return 'continue';
597
+ }
598
+ await removeModuleFromSourceRepo(options);
599
+ note(`Removed module ${options.moduleName} from source repo ${options.repoPath}.`, 'Done');
600
+ return 'continue';
601
+ }
602
+ if (command.id === 'safe-reset') {
603
+ const options = { target: cwd, stage: true };
604
+ await confirmSafeResetFromMenu(options);
605
+ await safeResetEnvironment(options);
606
+ note(`Safe reset refreshed generated environment files in ${cwd}.`, 'Done');
607
+ return 'continue';
608
+ }
609
+ note(`Unknown cockpit command: ${command.slashAlias}`, 'No action');
610
+ return 'continue';
611
+ }
550
612
  export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd()) {
551
613
  installPromptCancelKeyTracker();
552
614
  const rawArgv = cliArgv;
@@ -571,44 +633,19 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
571
633
  outro(`Created Odoo dev overlay in ${resolvedOptions.target}. Review staged changes, then commit.`);
572
634
  return;
573
635
  }
636
+ intro('WPMoo Odoo Dev');
574
637
  while (true) {
575
638
  try {
576
639
  const status = await getEnvironmentStatus(cwd);
577
640
  note(renderEnvironmentStatusSummary(status), 'Environment status');
578
- const action = await selectEnvironmentActionFromMenu();
579
- if (action === 'exit') {
580
- return;
581
- }
582
- if (action === 'add-repo') {
583
- const options = await addRepoOptionsFromPrompts(false, 'back');
584
- await ensureAddRepoGitHubRepository(options, 'back');
585
- await addModuleRepo(options);
586
- outro(`Added source repo under ${options.target}/odoo/custom/src/private.`);
587
- return;
588
- }
589
- if (action === 'remove-repo') {
590
- const options = await removeRepoOptionsFromPrompts([], false, 'back');
591
- await removeModuleRepo(options);
592
- outro(`Removed source repo ${options.repoPath} from ${options.target}.`);
593
- return;
594
- }
595
- if (action === 'add-module') {
596
- const options = await addModuleOptionsFromPrompts(false, 'back');
597
- await addModuleToSourceRepo(options);
598
- outro(`Added module ${options.moduleName} under source repo ${options.repoPath}.`);
641
+ const command = await selectCockpitCommandFromMenu();
642
+ if (command === 'exit') {
599
643
  return;
600
644
  }
601
- if (action === 'remove-module') {
602
- const options = await removeModuleOptionsFromPrompts(false, 'back');
603
- await removeModuleFromSourceRepo(options);
604
- outro(`Removed module ${options.moduleName} from source repo ${options.repoPath}.`);
645
+ const outcome = await runCockpitCommand(command, cwd);
646
+ if (outcome === 'exit') {
605
647
  return;
606
648
  }
607
- const options = { target: cwd, stage: true };
608
- await confirmSafeResetFromMenu(options);
609
- await safeResetEnvironment(options);
610
- outro(`Safe reset refreshed generated environment files in ${cwd}.`);
611
- return;
612
649
  }
613
650
  catch (error) {
614
651
  if (isMenuBackSignal(error)) {
@@ -0,0 +1,19 @@
1
+ import search from '@inquirer/search';
2
+ import { searchCockpitCommands } from './command-registry.js';
3
+ const defaultSearchPrompt = (config) => search(config);
4
+ function commandChoice(command) {
5
+ return {
6
+ value: command,
7
+ name: `${command.slashAlias} ${command.label}`,
8
+ description: command.description,
9
+ short: command.id,
10
+ };
11
+ }
12
+ export async function selectCockpitCommandFromPalette(options = {}) {
13
+ const prompt = options.prompt ?? defaultSearchPrompt;
14
+ return prompt({
15
+ message: 'Search commands',
16
+ pageSize: 10,
17
+ source: (term) => searchCockpitCommands(term).map(commandChoice),
18
+ });
19
+ }
@@ -0,0 +1,91 @@
1
+ const riskyCommandIds = new Set(['stop', 'resetdb', 'restore-snapshot', 'remove-repo', 'remove-module', 'safe-reset']);
2
+ function dailyCommand(command, category, label, description, aliases = []) {
3
+ return {
4
+ id: command,
5
+ slashAlias: `/${command}`,
6
+ category,
7
+ label,
8
+ description,
9
+ isRisky: riskyCommandIds.has(command),
10
+ target: {
11
+ kind: 'daily',
12
+ command,
13
+ },
14
+ aliases,
15
+ };
16
+ }
17
+ function internalCommand(id, category, label, description, aliases = []) {
18
+ return {
19
+ id,
20
+ slashAlias: `/${id}`,
21
+ category,
22
+ label,
23
+ description,
24
+ isRisky: riskyCommandIds.has(id),
25
+ target: {
26
+ kind: 'internal',
27
+ },
28
+ aliases,
29
+ };
30
+ }
31
+ export const cockpitCommands = [
32
+ dailyCommand('start', 'services', 'Start services', 'Start the Odoo development services.', ['up', 'compose up']),
33
+ dailyCommand('stop', 'services', 'Stop services', 'Stop the Odoo development services.', ['down', 'compose down']),
34
+ dailyCommand('restart', 'services', 'Restart services', 'Restart the Odoo development services.', ['reload']),
35
+ dailyCommand('logs', 'services', 'View logs', 'Stream logs for an Odoo environment service.', ['log', 'tail']),
36
+ dailyCommand('shell', 'services', 'Open shell', 'Open a shell inside the Odoo service container.', ['bash', 'terminal']),
37
+ dailyCommand('install', 'modules', 'Install module', 'Install one or more Odoo modules into a database.', ['install module']),
38
+ dailyCommand('update', 'modules', 'Update module', 'Update one or more Odoo modules in a database.', ['upgrade']),
39
+ dailyCommand('test', 'modules', 'Run tests', 'Run Odoo tests for one or more modules.', ['tests', 'pytest']),
40
+ dailyCommand('lint', 'modules', 'Run lint', 'Run the configured module lint checks.', ['check', 'quality']),
41
+ dailyCommand('pot', 'modules', 'Generate POT', 'Generate translation template files for a module.', ['translation', 'i18n']),
42
+ dailyCommand('psql', 'database', 'Open psql', 'Open a PostgreSQL prompt for an environment database.', ['postgres', 'sql']),
43
+ dailyCommand('snapshot', 'database', 'Create snapshot', 'Create a database snapshot.', ['backup', 'dump']),
44
+ dailyCommand('restore-snapshot', 'database', 'Restore snapshot', 'Restore a database from a named snapshot.', ['restore', 'snapshot restore']),
45
+ dailyCommand('resetdb', 'database', 'Reset database', 'Reset an environment database.', ['reset db', 'database reset']),
46
+ internalCommand('status', 'diagnostics', 'Environment status', 'Show a summary of the current environment state.', [
47
+ 'state',
48
+ 'summary',
49
+ ]),
50
+ internalCommand('doctor', 'diagnostics', 'Run doctor', 'Run environment diagnostics and report actionable issues.', [
51
+ 'diagnose',
52
+ 'health',
53
+ ]),
54
+ internalCommand('add-repo', 'repositories', 'Add source repo', 'Add a source repository as an environment submodule.', [
55
+ 'repository add',
56
+ 'source add',
57
+ ]),
58
+ internalCommand('remove-repo', 'repositories', 'Remove source repo', 'Remove a source repository from the environment.', ['repository remove', 'source remove']),
59
+ internalCommand('add-module', 'modules', 'Add module', 'Add a module folder to a source repository.', ['module add']),
60
+ internalCommand('remove-module', 'modules', 'Remove module', 'Remove a module folder from a source repository.', ['module remove']),
61
+ internalCommand('safe-reset', 'maintenance', 'Safe reset environment', 'Refresh generated environment files while preserving source repositories.', ['reset', 'refresh']),
62
+ internalCommand('exit', 'maintenance', 'Exit', 'Leave the command palette.', ['quit', 'back']),
63
+ ];
64
+ const defaultCommandIds = new Set(['start', 'logs', 'test', 'status', 'doctor', 'exit']);
65
+ export function normalizeCockpitSearchTerm(term) {
66
+ return (term ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
67
+ }
68
+ function commandSearchFields(command) {
69
+ return [command.slashAlias, command.id, command.label, command.category, command.description, ...command.aliases].map(normalizeCockpitSearchTerm);
70
+ }
71
+ function exactMatchScore(command, term) {
72
+ const bareTerm = term.startsWith('/') ? term.slice(1) : term;
73
+ if (normalizeCockpitSearchTerm(command.slashAlias) === term)
74
+ return 0;
75
+ if (normalizeCockpitSearchTerm(command.id) === bareTerm)
76
+ return 0;
77
+ if (normalizeCockpitSearchTerm(command.label) === term)
78
+ return 1;
79
+ if (command.aliases.map(normalizeCockpitSearchTerm).includes(term))
80
+ return 2;
81
+ return 10;
82
+ }
83
+ export function searchCockpitCommands(term) {
84
+ const normalizedTerm = normalizeCockpitSearchTerm(term);
85
+ if (!normalizedTerm) {
86
+ return cockpitCommands.filter((command) => defaultCommandIds.has(command.id));
87
+ }
88
+ return cockpitCommands
89
+ .filter((command) => commandSearchFields(command).some((field) => field.includes(normalizedTerm)))
90
+ .sort((left, right) => exactMatchScore(left, normalizedTerm) - exactMatchScore(right, normalizedTerm));
91
+ }
@@ -0,0 +1,173 @@
1
+ import { isCancel, select, text } from '@clack/prompts';
2
+ import { listModulesInSourceRepo } from '../module-actions.js';
3
+ import { listModuleRepos } from '../repo-actions.js';
4
+ import { handlePromptCancel, menuPromptMessage, } from '../menu-navigation.js';
5
+ const manualModuleValue = '__wpmoo_manual_module_entry__';
6
+ function defaultCancelHandler(value, action) {
7
+ handlePromptCancel(isCancel(value), action);
8
+ }
9
+ function promptDeps(deps = {}) {
10
+ return {
11
+ select: deps.select ?? ((options) => select(options)),
12
+ text: deps.text ?? ((options) => text(options)),
13
+ list: deps.list ?? ((options) => select(options)),
14
+ handleCancel: deps.handleCancel ?? defaultCancelHandler,
15
+ };
16
+ }
17
+ function asString(value, fallback, deps) {
18
+ deps.handleCancel(value, 'back');
19
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
20
+ }
21
+ function requiredString(value, message, deps) {
22
+ deps.handleCancel(value, 'back');
23
+ if (typeof value === 'string' && value.trim()) {
24
+ return value.trim();
25
+ }
26
+ throw new Error(message);
27
+ }
28
+ async function detectedModules(cwd) {
29
+ try {
30
+ const repos = await listModuleRepos(cwd);
31
+ const modules = await Promise.all(repos.map(async (repo) => {
32
+ try {
33
+ return await listModulesInSourceRepo(cwd, repo);
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ }));
39
+ return [...new Set(modules.flat())].sort();
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
45
+ async function moduleArg(cwd, deps, message = 'Module(s)') {
46
+ const modules = await detectedModules(cwd);
47
+ if (modules.length === 0) {
48
+ return requiredString(await deps.text({
49
+ message: menuPromptMessage(message, 'back'),
50
+ placeholder: 'sale,stock',
51
+ validate: (value) => (value.trim() ? undefined : 'Enter one or more module technical names.'),
52
+ }), 'Module is required.', deps);
53
+ }
54
+ const selected = await deps.select({
55
+ message: menuPromptMessage(message, 'back'),
56
+ options: [
57
+ ...modules.map((moduleName) => ({ value: moduleName, label: moduleName })),
58
+ { value: manualModuleValue, label: 'Manual entry' },
59
+ ],
60
+ initialValue: modules[0],
61
+ });
62
+ deps.handleCancel(selected, 'back');
63
+ if (selected !== manualModuleValue) {
64
+ return String(selected);
65
+ }
66
+ return requiredString(await deps.text({
67
+ message: menuPromptMessage('Module(s)', 'back'),
68
+ placeholder: modules.join(','),
69
+ validate: (value) => (value.trim() ? undefined : 'Enter one or more module technical names.'),
70
+ }), 'Module is required.', deps);
71
+ }
72
+ async function optionalTextArg(deps, message, fallback) {
73
+ return asString(await deps.text({
74
+ message: menuPromptMessage(message, 'back'),
75
+ defaultValue: fallback,
76
+ placeholder: fallback,
77
+ }), fallback, deps);
78
+ }
79
+ async function optionalModules(cwd, deps) {
80
+ const modules = await detectedModules(cwd);
81
+ if (modules.length === 0) {
82
+ const manualModules = asString(await deps.text({
83
+ message: menuPromptMessage('Module(s) to include (optional)', 'back'),
84
+ placeholder: 'sale,stock',
85
+ }), '', deps);
86
+ return manualModules || undefined;
87
+ }
88
+ const selected = await deps.select({
89
+ message: menuPromptMessage('Module(s) to include (optional)', 'back'),
90
+ options: [
91
+ { value: '', label: 'All modules' },
92
+ ...modules.map((moduleName) => ({ value: moduleName, label: moduleName })),
93
+ { value: manualModuleValue, label: 'Manual entry' },
94
+ ],
95
+ initialValue: '',
96
+ });
97
+ deps.handleCancel(selected, 'back');
98
+ if (selected === '') {
99
+ return undefined;
100
+ }
101
+ if (selected !== manualModuleValue) {
102
+ return String(selected);
103
+ }
104
+ const manualModules = asString(await deps.text({
105
+ message: menuPromptMessage('Module(s) to include', 'back'),
106
+ placeholder: modules.join(','),
107
+ }), '', deps);
108
+ return manualModules || undefined;
109
+ }
110
+ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
111
+ const deps = promptDeps(promptDepsArg);
112
+ if (['start', 'restart', 'shell', 'lint', 'stop'].includes(command)) {
113
+ return [];
114
+ }
115
+ if (command === 'logs') {
116
+ return [await optionalTextArg(deps, 'Service', 'odoo')];
117
+ }
118
+ if (command === 'psql') {
119
+ return [await optionalTextArg(deps, 'Database', 'postgres')];
120
+ }
121
+ if (command === 'install' || command === 'update') {
122
+ const modules = await moduleArg(cwd, deps);
123
+ const db = asString(await deps.text({
124
+ message: menuPromptMessage('Database (optional)', 'back'),
125
+ placeholder: 'devel',
126
+ }), '', deps);
127
+ return db ? [modules, db] : [modules];
128
+ }
129
+ if (command === 'test') {
130
+ const modules = await moduleArg(cwd, deps);
131
+ const db = await optionalTextArg(deps, 'Database', 'devel');
132
+ const mode = asString(await deps.list({
133
+ message: menuPromptMessage('Mode', 'back'),
134
+ options: [
135
+ { value: 'update', label: 'update' },
136
+ { value: 'init', label: 'init' },
137
+ ],
138
+ initialValue: 'update',
139
+ }), 'update', deps);
140
+ const tags = asString(await deps.text({
141
+ message: menuPromptMessage('Tags (optional)', 'back'),
142
+ placeholder: '/sale',
143
+ }), '', deps);
144
+ return tags
145
+ ? [modules, '--db', db, '--mode', mode, '--tags', tags]
146
+ : [modules, '--db', db, '--mode', mode];
147
+ }
148
+ if (command === 'pot') {
149
+ const modules = await moduleArg(cwd, deps);
150
+ const db = await optionalTextArg(deps, 'Database', 'devel');
151
+ const output = await optionalTextArg(deps, 'Output file', `i18n/${modules}.pot`);
152
+ return [modules, db, output];
153
+ }
154
+ if (command === 'resetdb') {
155
+ const db = await optionalTextArg(deps, 'Database', 'devel');
156
+ const modules = await optionalModules(cwd, deps);
157
+ return modules ? [db, modules] : [db];
158
+ }
159
+ if (command === 'snapshot') {
160
+ const db = await optionalTextArg(deps, 'Database', 'devel');
161
+ const snapshotName = await optionalTextArg(deps, 'Snapshot name', 'before-update');
162
+ return [db, snapshotName];
163
+ }
164
+ if (command === 'restore-snapshot') {
165
+ const snapshotName = requiredString(await deps.text({
166
+ message: menuPromptMessage('Snapshot name', 'back'),
167
+ validate: (value) => (value.trim() ? undefined : 'Enter the snapshot name.'),
168
+ }), 'Snapshot name is required.', deps);
169
+ const db = await optionalTextArg(deps, 'Database', 'devel');
170
+ return [snapshotName, db];
171
+ }
172
+ return [];
173
+ }
@@ -0,0 +1,88 @@
1
+ import { isCancel, select } from '@clack/prompts';
2
+ import { cockpitCommands, } from './command-registry.js';
3
+ import { handlePromptCancel, menuPromptMessage, MenuBackSignal } from '../menu-navigation.js';
4
+ export const cockpitMenuBackValue = '__wpmoo_cockpit_menu_back__';
5
+ const categoryLabels = {
6
+ services: 'Services',
7
+ modules: 'Modules',
8
+ database: 'Database',
9
+ diagnostics: 'Diagnostics',
10
+ repositories: 'Repositories',
11
+ maintenance: 'Maintenance',
12
+ };
13
+ const topLevelOptions = [
14
+ { value: 'command-palette', label: 'Command palette /' },
15
+ { value: 'services', label: categoryLabels.services },
16
+ { value: 'modules', label: categoryLabels.modules },
17
+ { value: 'database', label: categoryLabels.database },
18
+ { value: 'diagnostics', label: categoryLabels.diagnostics },
19
+ { value: 'repositories', label: categoryLabels.repositories },
20
+ { value: 'maintenance', label: categoryLabels.maintenance },
21
+ { value: 'exit', label: 'Exit' },
22
+ ];
23
+ const categories = new Set([
24
+ 'services',
25
+ 'modules',
26
+ 'database',
27
+ 'diagnostics',
28
+ 'repositories',
29
+ 'maintenance',
30
+ ]);
31
+ function defaultSelect(options) {
32
+ return select(options);
33
+ }
34
+ function defaultCancelHandler(value, action) {
35
+ handlePromptCancel(isCancel(value), action);
36
+ }
37
+ function menuDeps(deps = {}) {
38
+ return {
39
+ select: deps.select ?? defaultSelect,
40
+ handleCancel: deps.handleCancel ?? defaultCancelHandler,
41
+ };
42
+ }
43
+ function isCockpitCommandCategory(value) {
44
+ return typeof value === 'string' && categories.has(value);
45
+ }
46
+ export async function selectCockpitTopLevelMenu(options = {}) {
47
+ const deps = menuDeps(options);
48
+ const selected = await deps.select({
49
+ message: 'What do you want to do?',
50
+ options: [...topLevelOptions],
51
+ initialValue: 'command-palette',
52
+ });
53
+ deps.handleCancel(selected, 'exit');
54
+ if (selected === 'command-palette') {
55
+ return { kind: 'command-palette' };
56
+ }
57
+ if (selected === 'exit') {
58
+ return { kind: 'exit' };
59
+ }
60
+ if (isCockpitCommandCategory(selected)) {
61
+ return {
62
+ kind: 'category',
63
+ category: selected,
64
+ };
65
+ }
66
+ return { kind: 'exit' };
67
+ }
68
+ export async function selectCockpitCategoryCommand(category, options = {}) {
69
+ const deps = menuDeps(options);
70
+ const commands = cockpitCommands.filter((command) => command.category === category);
71
+ const selected = await deps.select({
72
+ message: menuPromptMessage(categoryLabels[category], 'back'),
73
+ options: [
74
+ ...commands.map((command) => ({
75
+ value: command,
76
+ label: command.label,
77
+ hint: command.description,
78
+ })),
79
+ { value: cockpitMenuBackValue, label: 'Back' },
80
+ ],
81
+ initialValue: commands[0],
82
+ });
83
+ deps.handleCancel(selected, 'back');
84
+ if (selected === cockpitMenuBackValue) {
85
+ throw new MenuBackSignal();
86
+ }
87
+ return selected;
88
+ }
@@ -0,0 +1,22 @@
1
+ import { confirm, isCancel } from '@clack/prompts';
2
+ import { handlePromptCancel, menuPromptMessage } from '../menu-navigation.js';
3
+ function defaultHandleCancel(value, action) {
4
+ handlePromptCancel(isCancel(value), action);
5
+ }
6
+ function riskConfirmationMessage(command, action) {
7
+ return menuPromptMessage(`Run ${command.slashAlias} ${command.label}? This can change or remove environment state.`, action);
8
+ }
9
+ export async function confirmCockpitCommandRisk(command, deps = {}) {
10
+ if (!command.isRisky) {
11
+ return true;
12
+ }
13
+ const prompt = deps.confirm ?? confirm;
14
+ const cancelAction = deps.cancelAction ?? 'back';
15
+ const approved = await prompt({
16
+ message: riskConfirmationMessage(command, cancelAction),
17
+ initialValue: false,
18
+ });
19
+ const handleCancel = deps.handleCancel ?? defaultHandleCancel;
20
+ handleCancel(approved, cancelAction);
21
+ return approved === true;
22
+ }
package/dist/help.js CHANGED
@@ -67,6 +67,12 @@ Daily actions:
67
67
  Generated environments also include ./moo for local compose commands such as ./moo start.
68
68
  Use ./moo or npx @wpmoo/odoo with the same daily action arguments.
69
69
 
70
+ Cockpit:
71
+ Run npx @wpmoo/odoo inside a generated environment to open the cockpit.
72
+ Use Command palette / to search slash commands across services, modules, database,
73
+ diagnostics, repositories, and maintenance categories.
74
+ Direct commands such as npx @wpmoo/odoo status and npx @wpmoo/odoo test remain available.
75
+
70
76
  Status and doctor:
71
77
  status: fast and offline. Reads local environment metadata and files only.
72
78
  doctor: deeper health check. May check Docker CLI access and GitHub workflows.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/odoo",
3
- "version": "0.8.47",
3
+ "version": "0.8.48",
4
4
  "description": "WPMoo Odoo lifecycle tooling for development, staging, and production workflows.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "docs/assets"
35
35
  ],
36
36
  "engines": {
37
- "node": ">=20"
37
+ "node": ">=20.17"
38
38
  },
39
39
  "scripts": {
40
40
  "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@clack/prompts": "^0.11.0",
50
+ "@inquirer/search": "^4.1.9",
50
51
  "execa": "^9.6.0"
51
52
  },
52
53
  "devDependencies": {