custom-menu-cli 2.0.1 → 4.0.0

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-pt.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Custom Menu CLI
2
2
 
3
+ <img src="./docs/icon.png" alt="Custom Menu CLI Icon" width="600px" height="400px"/>
4
+
3
5
  ![Exemplo de Menu 1](./docs/example1.png)
4
6
  ![Exemplo de Menu 2](./docs/example2.png)
5
7
 
@@ -11,7 +13,9 @@ Esta é uma ferramenta de interface de linha de comando (CLI) que cria um menu i
11
13
  - Estrutura do menu definida por um arquivo JSON.
12
14
  - Fácil de configurar e usar.
13
15
  - Suporte para execução de comandos com confirmação.
14
-
16
+ - Execute sequências de ações diretamente da linha de comando.
17
+ - Estrutura interna aprimorada pela unificação da lógica de execução de ações.
18
+ - Validação de dependência aprimorada com verificações de profundidade de recursão para evitar aninhamento excessivo.
15
19
 
16
20
  ## Lançamentos
17
21
 
@@ -88,6 +92,60 @@ async function iniciarMeuMenuCustomizado() {
88
92
  iniciarMeuMenuCustomizado();
89
93
  ```
90
94
 
95
+ ### 4. Geração de Menu Baseada em Pastas
96
+
97
+ O `custom-menu-cli` agora suporta a geração de menus a partir de uma pasta estruturada contendo arquivos JSON. Isso permite uma melhor organização e modularidade das suas definições de menu.
98
+
99
+ **Estrutura de Exemplo (`test_menus/`):**
100
+ ```
101
+ test_menus/
102
+ ├── 1-project-a/
103
+ │ ├── 1.1-down-service.json
104
+ │ ├── 1.2-up-service.json
105
+ │ └── 1.3-restart-project-a.json
106
+ ├── 2-restart-all.json
107
+ └── 3-restart-project-a-nested.json
108
+ ```
109
+
110
+ Cada arquivo `.json` dentro da pasta (e suas subpastas) representa uma opção de menu. Os diretórios são automaticamente convertidos em opções do tipo `navigation`.
111
+
112
+ **Como usar:**
113
+
114
+ Basta passar o caminho para a sua pasta de menu como argumento:
115
+
116
+ ```bash
117
+ custom-menu-cli ./caminho/para/sua/pasta_de_menu
118
+ ```
119
+
120
+ O CLI irá automaticamente descobrir e combinar todos os arquivos JSON válidos em uma única estrutura de menu.
121
+
122
+ ### 5. Execução de Ações pela Linha de Comando
123
+
124
+ Você pode executar uma sequência de ações diretamente da linha de comando, sem entrar no menu interativo. Isso é útil para scripts e automação.
125
+
126
+ **Uso:**
127
+
128
+ ```bash
129
+ node index.js menu=<caminho-para-o-menu> custom-action=<id_da_acao_1>,<id_da_acao_2>,...
130
+ ```
131
+
132
+ - `menu=<caminho-para-o-menu>`: O caminho para o seu arquivo ou diretório de menu.
133
+ - `custom-action=<ids_das_acoes>`: Uma lista de IDs de ações, separados por vírgula, para executar em sequência.
134
+
135
+ **Exemplos:**
136
+
137
+ ```bash
138
+ # Executar uma única ação
139
+ node index.js menu=menu.json custom-action=1.1
140
+
141
+ # Executar uma sequência de ações
142
+ node index.js menu=./test_menus/ custom-action=1.1,1.2
143
+ ```
144
+
145
+ **Validação de Profundidade de Recursão:**
146
+
147
+ A ferramenta agora inclui validação de profundidade de recursão para evitar ações customizadas aninhadas excessivamente profundas e potenciais loops infinitos. Se uma sequência de ações customizadas exceder uma profundidade máxima de recursão predefinida, a ferramenta detectará e se recusará a executar, exibindo uma mensagem de erro. Isso ajuda a manter a estabilidade e a previsibilidade em estruturas de menu complexas.
148
+
91
149
  ## Estrutura do JSON
92
150
 
93
151
  O arquivo JSON que define o menu tem a seguinte estrutura:
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Custom Menu CLI
2
2
 
3
+ <img src="./docs/icon.png" alt="Custom Menu CLI Icon" width="600px" height="400px"/>
4
+
3
5
  [Português (Brasil)](./README-pt.md)
4
6
 
5
7
  ![Menu Example 1](./docs/example1.png)
@@ -13,6 +15,9 @@ This is a command-line interface (CLI) tool that creates an interactive menu bas
13
15
  - Menu structure defined by a JSON file.
14
16
  - Easy to configure and use.
15
17
  - Support for command execution with confirmation.
18
+ - Execute action sequences directly from the command line.
19
+ - Improved internal structure by unifying action execution logic.
20
+ - Enhanced dependency validation with recursion depth checks to prevent excessive nesting.
16
21
 
17
22
  ## Releases
18
23
 
@@ -89,6 +94,60 @@ async function startMyCustomMenu() {
89
94
  startMyCustomMenu();
90
95
  ```
91
96
 
97
+ ### 4. Folder-based Menu Generation
98
+
99
+ The `custom-menu-cli` now supports generating menus from a structured folder containing JSON files. This allows for better organization and modularity of your menu definitions.
100
+
101
+ **Example Structure (`test_menus/`):**
102
+ ```
103
+ test_menus/
104
+ ├── 1-project-a/
105
+ │ ├── 1.1-down-service.json
106
+ │ ├── 1.2-up-service.json
107
+ │ └── 1.3-restart-project-a.json
108
+ ├── 2-restart-all.json
109
+ └── 3-restart-project-a-nested.json
110
+ ```
111
+
112
+ Each `.json` file within the folder (and its subfolders) represents a menu option. Directories are automatically converted into `navigation` type options.
113
+
114
+ **How to use:**
115
+
116
+ Simply pass the path to your menu folder as an argument:
117
+
118
+ ```bash
119
+ custom-menu-cli ./path/to/your/menu_folder
120
+ ```
121
+
122
+ The CLI will automatically discover and combine all valid JSON files into a single menu structure.
123
+
124
+ ### 5. Command-Line Action Execution
125
+
126
+ You can execute a sequence of actions directly from the command line without entering the interactive menu. This is useful for scripting and automation.
127
+
128
+ **Usage:**
129
+
130
+ ```bash
131
+ node index.js menu=<path-to-menu> custom-action=<action_id_1>,<action_id_2>,...
132
+ ```
133
+
134
+ - `menu=<path-to-menu>`: The path to your menu file or directory.
135
+ - `custom-action=<action_ids>`: A comma-separated list of action IDs to execute in sequence.
136
+
137
+ **Examples:**
138
+
139
+ ```bash
140
+ # Execute a single action
141
+ node index.js menu=menu.json custom-action=1.1
142
+
143
+ # Execute a sequence of actions
144
+ node index.js menu=./test_menus/ custom-action=1.1,1.2
145
+ ```
146
+
147
+ **Recursion Depth Validation:**
148
+
149
+ The tool now includes recursion depth validation to prevent excessively deep nested custom actions and potential infinite loops. If a sequence of custom actions exceeds a predefined maximum recursion depth, the tool will detect it and refuse to execute, displaying an error message. This helps maintain stability and predictability in complex menu structures.
150
+
92
151
  ## JSON Structure
93
152
 
94
153
  The JSON file that defines the menu has the following structure:
package/docs/icon.png ADDED
Binary file
package/index.js CHANGED
@@ -1,24 +1,40 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { loadMenuConfig } = require('./src/configLoader.js');
4
- const { showMenu, buildIdMap } = require('./src/menu.js');
4
+ const { showMenu, buildIdMap, flatMap } = require('./src/menu.js');
5
5
  const { displayHeader } = require('./src/header.js');
6
+ const { parseArgs } = require('./src/args.js');
7
+ const { validateRecursionDepth } = require('./src/dependencyValidator.js');
8
+
9
+
10
+ const { validateMenu } = require('./src/menuValidator.js');
11
+
12
+ async function runCli() {
13
+ const { menuPath, customActions } = parseArgs();
14
+ const data = await loadMenuConfig(menuPath);
15
+
16
+ // 1. Validate the overall menu structure
17
+ validateMenu(data);
6
18
 
7
- async function runCli(menuPath = null) {
8
- const data = loadMenuConfig(menuPath);
9
19
  if (data.options) {
10
20
  buildIdMap(data.options);
11
21
  }
12
22
 
13
- console.clear();
14
-
15
- displayHeader(data);
23
+ validateRecursionDepth(flatMap);
16
24
 
17
- await showMenu(data);
25
+ if (customActions && customActions.length > 0) {
26
+ await executeSequence(customActions, flatMap);
27
+ } else {
28
+ console.clear();
29
+ displayHeader(data);
30
+ await showMenu(data);
31
+ }
18
32
  }
19
33
 
20
34
  if (require.main === module) {
21
- runCli();
35
+ (async () => {
36
+ await runCli();
37
+ })();
22
38
  }
23
39
 
24
40
  module.exports = { runCli };
package/menu.json CHANGED
@@ -1,5 +1,7 @@
1
1
  {
2
+ "id": "0",
2
3
  "name": "custom-menu-cli",
4
+ "type": "navigation",
3
5
  "description": "JSON-based terminal menu",
4
6
  "options": [
5
7
  {
package/package.json CHANGED
@@ -1,14 +1,22 @@
1
1
  {
2
2
  "name": "custom-menu-cli",
3
- "version": "2.0.1",
3
+ "version": "4.0.0",
4
4
  "description": "Menu interativo baseado em JSON para execução de comandos no terminal",
5
5
  "main": "index.js",
6
6
  "bin": "index.js",
7
7
  "scripts": {
8
8
  "start": "node index.js",
9
+ "test": "jest",
9
10
  "build": "pkg . --targets node16-linux-x64,node16-win-x64,node16-macos-x64 --output dist/custom-menu-v$npm_package_version"
10
11
  },
11
- "keywords": ["cli", "menu", "json", "terminal", "deploy", "custom"],
12
+ "keywords": [
13
+ "cli",
14
+ "menu",
15
+ "json",
16
+ "terminal",
17
+ "deploy",
18
+ "custom"
19
+ ],
12
20
  "author": "Mateus Medeiros <https://github.com/mateusmed>",
13
21
  "license": "MIT",
14
22
  "dependencies": {
@@ -17,6 +25,10 @@
17
25
  "inquirer": "^8.2.4"
18
26
  },
19
27
  "devDependencies": {
28
+ "jest": "^30.2.0",
20
29
  "pkg": "^5.8.1"
30
+ },
31
+ "jest": {
32
+ "testEnvironment": "node"
21
33
  }
22
34
  }
@@ -0,0 +1,92 @@
1
+ const chalk = require('chalk');
2
+ const inquirer = require('inquirer');
3
+ const { terminal } = require('./terminal.js');
4
+
5
+ async function confirmExecution(message) {
6
+ const { ok } = await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: false }]);
7
+ return ok;
8
+ }
9
+
10
+ /**
11
+ * Executes a single action or a sequence of actions from a custom-action.
12
+ * @param {object} action - The action object to execute.
13
+ * @param {object} flatMap - The map of all available actions.
14
+ * @param {boolean} [isConfirmationEnabled=true] - Whether to prompt for confirmation.
15
+ */
16
+ async function executeAction(action, flatMap, isConfirmationEnabled = true) {
17
+ if (!action) {
18
+ console.error(chalk.red('Error: Attempted to execute a null or undefined action.'));
19
+ return;
20
+ }
21
+
22
+ let proceed = true;
23
+ if (isConfirmationEnabled && action.confirm) {
24
+ const message = action.type === 'action'
25
+ ? `Execute command: [id: ${action.id} name: ${action.name} ]?`
26
+ : `Execute custom action: [id: ${action.id} name: ${action.name} ]?`;
27
+ proceed = await confirmExecution(chalk.yellow(message));
28
+ }
29
+
30
+ if (!proceed) {
31
+ console.log(chalk.yellow(`Execution of action '${action.id}' cancelled.`));
32
+ return;
33
+ }
34
+
35
+ if (action.type === 'action' && action.command) {
36
+ console.log(chalk.blue(`Executing command: [id: ${action.id} name: ${action.name} ]`));
37
+ await terminal.execCommandSync(action.command);
38
+ } else if (action.type === 'custom-action' && action.idList) {
39
+ for (const nestedId of action.idList) {
40
+ const nestedAction = flatMap[nestedId];
41
+ if (nestedAction) {
42
+ // Pass `isConfirmationEnabled=false` to prevent repeated prompts for sub-actions.
43
+ // The parent confirmation is considered sufficient.
44
+ await executeAction(nestedAction, flatMap, false);
45
+ } else {
46
+ console.error(chalk.red(`Error: Nested action with id '${nestedId}' not found within custom-action '${action.id}'.`));
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Handles a single action selected in interactive mode.
54
+ * @param {object} selected - The selected action object.
55
+ * @param {object} flatMap - The map of all available actions.
56
+ */
57
+ async function handleAction(selected, flatMap) {
58
+ // For single actions selected in interactive mode, confirmation is enabled.
59
+ await executeAction(selected, flatMap, true);
60
+ }
61
+
62
+ /**
63
+ * Handles a custom action selected in interactive mode.
64
+ * @param {object} selected - The selected custom action object.
65
+ * @param {object} flatMap - The map of all available actions.
66
+ */
67
+ async function handleCustomAction(selected, flatMap) {
68
+ // For custom actions selected in interactive mode, confirmation is enabled.
69
+ await executeAction(selected, flatMap, true);
70
+ }
71
+
72
+ /**
73
+ * Executes a sequence of actions triggered from the command line.
74
+ * @param {string[]} actionIds - An array of action IDs to execute.
75
+ * @param {object} flatMap - The map of all available actions.
76
+ */
77
+ async function executeSequence(actionIds, flatMap) {
78
+ console.log(chalk.blue(`Executing sequence from command line: ${actionIds.join(', ')}`));
79
+
80
+ for (const id of actionIds) {
81
+ const action = flatMap[id];
82
+ if (action) {
83
+ // Call the unified executor with confirmations disabled
84
+ await executeAction(action, flatMap, false);
85
+ } else {
86
+ console.error(chalk.red(`Error: Action with id '${id}' not found.`));
87
+ }
88
+ }
89
+ }
90
+
91
+
92
+ module.exports = { handleAction, handleCustomAction, executeSequence };
package/src/args.js ADDED
@@ -0,0 +1,23 @@
1
+
2
+ function parseArgs() {
3
+ const args = process.argv.slice(2);
4
+ const result = {
5
+ menuPath: null,
6
+ customActions: [],
7
+ };
8
+
9
+ for (const arg of args) {
10
+ if (arg.startsWith('menu=')) {
11
+ result.menuPath = arg.substring('menu='.length);
12
+ } else if (arg.startsWith('custom-action=')) {
13
+ result.customActions = arg.substring('custom-action='.length).split(',');
14
+ } else if (!result.menuPath) {
15
+ // For backward compatibility, if no prefix is used, assume it's a menu path.
16
+ result.menuPath = arg;
17
+ }
18
+ }
19
+
20
+ return result;
21
+ }
22
+
23
+ module.exports = { parseArgs };
@@ -1,4 +1,5 @@
1
- const fs = require('fs');
1
+ const fs = require('fs').promises;
2
+ const path = require('path');
2
3
  const chalk = require('chalk');
3
4
 
4
5
  const defaultMenu = {
@@ -15,41 +16,71 @@ const defaultMenu = {
15
16
  ]
16
17
  };
17
18
 
18
- function loadMenuConfig(menuPath = null) {
19
- let data;
20
- let finalPath = menuPath;
19
+ async function buildMenuOptions(dir) {
20
+ const entries = await fs.readdir(dir, { withFileTypes: true });
21
+ const options = [];
21
22
 
22
- // If menuPath is not provided, check command line arguments
23
- if (!finalPath) {
24
- const args = process.argv.slice(2);
25
- finalPath = args[0];
23
+ for (const entry of entries) {
24
+ const fullPath = path.join(dir, entry.name);
25
+ if (entry.isDirectory()) {
26
+ const subOptions = await buildMenuOptions(fullPath);
27
+ options.push({
28
+ id: entry.name,
29
+ name: `=> ${entry.name.toUpperCase()}`,
30
+ type: 'navigation',
31
+ options: subOptions
32
+ });
33
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
34
+ const fileContent = await fs.readFile(fullPath, 'utf-8');
35
+ try {
36
+ const option = JSON.parse(fileContent);
37
+ options.push(option);
38
+ } catch (parseError) {
39
+ console.error(chalk.red(`Error: Malformed JSON file at: ${fullPath}`));
40
+ console.error(chalk.red(`Details: ${parseError.message}`));
41
+ process.exit(1);
42
+ }
43
+ }
26
44
  }
45
+ return options;
46
+ }
27
47
 
28
- if (finalPath && fs.existsSync(finalPath)) {
29
- try {
30
- data = JSON.parse(fs.readFileSync(finalPath, 'utf-8'));
31
- } catch (error) {
32
- console.log(chalk.red(`Error parsing JSON file: ${finalPath}`));
33
- console.error(error);
34
- process.exit(1);
35
- }
36
- } else if (finalPath) {
37
- console.log(chalk.red(`File not found: ${finalPath}`));
38
- process.exit(1);
39
- } else if (fs.existsSync('./menu.json')) {
48
+ async function loadMenuConfig(menuPath = null) {
49
+ if (menuPath) {
40
50
  try {
41
- data = JSON.parse(fs.readFileSync('./menu.json', 'utf-8'));
51
+ const stats = await fs.stat(menuPath);
52
+ if (stats.isDirectory()) {
53
+ return {
54
+ name: "Dynamic Menu",
55
+ description: `Menu generated from folder: ${menuPath}`,
56
+ options: await buildMenuOptions(menuPath)
57
+ };
58
+ }
59
+ return JSON.parse(await fs.readFile(menuPath, 'utf-8'));
42
60
  } catch (error) {
43
- console.log(chalk.red(`Error parsing JSON file: ./menu.json`));
44
- console.error(error);
61
+ if (error.code === 'ENOENT') {
62
+ console.error(chalk.red(`Error: The path '${menuPath}' was not found.`));
63
+ } else {
64
+ console.error(chalk.red(`Error processing path: ${menuPath}`));
65
+ console.error(error);
66
+ }
45
67
  process.exit(1);
46
68
  }
47
- } else {
48
- console.log(chalk.yellow("No 'menu.json' found. Loading example menu."));
49
- data = defaultMenu;
50
69
  }
51
70
 
52
- return data;
71
+ // Default to menu.json or example menu
72
+ const defaultMenuPath = './menu.json';
73
+ try {
74
+ const fileContent = await fs.readFile(defaultMenuPath, 'utf-8');
75
+ return JSON.parse(fileContent);
76
+ } catch (error) {
77
+ if (error.code === 'ENOENT') {
78
+ console.log(chalk.yellow("No 'menu.json' found. Loading example menu."));
79
+ return defaultMenu;
80
+ }
81
+ console.error(chalk.red(`Error loading default menu.json: ${error.message}`));
82
+ process.exit(1);
83
+ }
53
84
  }
54
85
 
55
86
  module.exports = { loadMenuConfig };
@@ -0,0 +1,41 @@
1
+ const chalk = require('chalk');
2
+
3
+
4
+ function validateRecursionDepth(flatMap, maxDepth = 3) {
5
+ const graph = new Map();
6
+ const allActions = Object.values(flatMap);
7
+
8
+ for (const action of allActions) {
9
+ if (action.type === 'custom-action' && action.idList) {
10
+ graph.set(action.id, action.idList);
11
+ }
12
+ }
13
+
14
+ function checkDepth(actionId, currentDepth, path) {
15
+ if (currentDepth > maxDepth) {
16
+ console.error(chalk.red(`Error: Maximum recursion depth of ${maxDepth} exceeded by action '${path[0]}'. Path: ${path.join(' -> ')} -> ${actionId}`));
17
+ process.exit(1);
18
+ }
19
+
20
+ if (path.includes(actionId)) {
21
+ // This is a circular dependency. If it reached here, it didn't exceed maxDepth on previous checks
22
+ // So, for now, we just acknowledge it and don't exit.
23
+ // If the *cycle itself* is considered a depth violation (e.g., a->b->a, depth 3 for second 'a'),
24
+ // the check above (currentDepth > maxDepth) should catch it if the cycle is long enough.
25
+ return;
26
+ }
27
+
28
+ path.push(actionId);
29
+
30
+ const dependencies = graph.get(actionId) || [];
31
+ for (const depId of dependencies) {
32
+ checkDepth(depId, currentDepth + 1, [...path]); // Pass a copy of the path
33
+ }
34
+ }
35
+
36
+ for (const actionId of graph.keys()) {
37
+ checkDepth(actionId, 1, []);
38
+ }
39
+ }
40
+
41
+ module.exports = { validateRecursionDepth };
package/src/menu.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const inquirer = require('inquirer');
2
2
  const chalk = require('chalk');
3
- const { handleAction, handleCustomAction, handleNavigation } = require('./actions.js');
3
+ const { handleAction, handleCustomAction, executeSequence } = require('./actionRunner.js');
4
4
 
5
5
  const flatMap = {};
6
6
 
@@ -0,0 +1,51 @@
1
+ const chalk = require('chalk');
2
+
3
+ function _validateSingleOption(option) {
4
+ if (!option.id || typeof option.id !== 'string') {
5
+ console.error(chalk.red(`Validation Error: An option is missing a valid 'id'.`));
6
+ process.exit(1); return;
7
+ }
8
+ if (!option.name || typeof option.name !== 'string') {
9
+ console.error(chalk.red(`Validation Error in option '${option.id}': 'name' is missing or invalid.`));
10
+ process.exit(1); return;
11
+ }
12
+
13
+ const validTypes = ['action', 'navigation', 'custom-action'];
14
+ if (!option.type || !validTypes.includes(option.type)) {
15
+ console.error(chalk.red(`Validation Error in option '${option.id}': 'type' is missing or invalid.`));
16
+ process.exit(1); return;
17
+ }
18
+
19
+ if (option.type === 'action') {
20
+ if (!option.command || typeof option.command !== 'string') {
21
+ console.error(chalk.red(`Validation Error in option '${option.id}': 'command' is missing or invalid for type 'action'.`));
22
+ process.exit(1); return;
23
+ }
24
+ } else if (option.type === 'custom-action') {
25
+ if (!Array.isArray(option.idList) || option.idList.length === 0) {
26
+ console.error(chalk.red(`Validation Error in option '${option.id}': 'idList' is missing or empty for type 'custom-action'.`));
27
+ process.exit(1); return;
28
+ }
29
+ } else if (option.type === 'navigation') {
30
+ if (!Array.isArray(option.options) || option.options.length === 0) {
31
+ console.error(chalk.red(`Validation Error in option '${option.id}': 'options' is missing or empty for type 'navigation'.`));
32
+ process.exit(1); return;
33
+ }
34
+ // Recursively validate sub-options
35
+ option.options.forEach(_validateSingleOption);
36
+ }
37
+ }
38
+
39
+ function validateMenu(menuData) {
40
+ if (!menuData.name || typeof menuData.name !== 'string') {
41
+ console.error(chalk.red(`Validation Error: Top-level 'name' is missing or invalid in menu configuration.`));
42
+ process.exit(1); return;
43
+ }
44
+ if (!Array.isArray(menuData.options)) {
45
+ console.error(chalk.red(`Validation Error: Top-level 'options' array is missing or invalid.`));
46
+ process.exit(1); return;
47
+ }
48
+ menuData.options.forEach(_validateSingleOption);
49
+ }
50
+
51
+ module.exports = { validateMenu };
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "a",
3
+ "name": "Action A",
4
+ "type": "custom-action",
5
+ "idList": ["b"]
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "b",
3
+ "name": "Action B",
4
+ "type": "custom-action",
5
+ "idList": ["c"]
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "c",
3
+ "name": "Action C",
4
+ "type": "custom-action",
5
+ "idList": ["a"]
6
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "1.1",
3
+ "name": "Down Service",
4
+ "type": "action",
5
+ "command": "echo 'Down A'",
6
+ "confirm": true
7
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "1.2",
3
+ "name": "Up Service",
4
+ "type": "action",
5
+ "command": "echo 'Up A'"
6
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "1.3",
3
+ "name": "Restart Project A (from inside)",
4
+ "type": "custom-action",
5
+ "idList": ["1.1", "1.2"],
6
+ "confirm": true
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "2",
3
+ "name": "Restart All",
4
+ "type": "custom-action",
5
+ "idList": ["1.1", "1.2"],
6
+ "confirm": true
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "3",
3
+ "name": "Restart Project A (Nested)",
4
+ "type": "custom-action",
5
+ "idList": ["1.3"],
6
+ "confirm": true
7
+ }
@@ -0,0 +1,44 @@
1
+ const { parseArgs } = require('../src/args');
2
+
3
+ describe('args', () => {
4
+ const originalArgv = process.argv;
5
+
6
+ afterEach(() => {
7
+ process.argv = originalArgv;
8
+ });
9
+
10
+ test('should parse menu path and custom actions', () => {
11
+ process.argv = ['node', 'index.js', 'menu=my-menu.json', 'custom-action=1,2,3'];
12
+ const { menuPath, customActions } = parseArgs();
13
+ expect(menuPath).toBe('my-menu.json');
14
+ expect(customActions).toEqual(['1', '2', '3']);
15
+ });
16
+
17
+ test('should handle only menu path', () => {
18
+ process.argv = ['node', 'index.js', 'menu=my-menu.json'];
19
+ const { menuPath, customActions } = parseArgs();
20
+ expect(menuPath).toBe('my-menu.json');
21
+ expect(customActions).toEqual([]);
22
+ });
23
+
24
+ test('should handle only custom actions', () => {
25
+ process.argv = ['node', 'index.js', 'custom-action=a,b'];
26
+ const { menuPath, customActions } = parseArgs();
27
+ expect(menuPath).toBeNull();
28
+ expect(customActions).toEqual(['a', 'b']);
29
+ });
30
+
31
+ test('should handle backward compatibility for menu path', () => {
32
+ process.argv = ['node', 'index.js', 'my-menu.json'];
33
+ const { menuPath, customActions } = parseArgs();
34
+ expect(menuPath).toBe('my-menu.json');
35
+ expect(customActions).toEqual([]);
36
+ });
37
+
38
+ test('should return null and empty array when no args are provided', () => {
39
+ process.argv = ['node', 'index.js'];
40
+ const { menuPath, customActions } = parseArgs();
41
+ expect(menuPath).toBeNull();
42
+ expect(customActions).toEqual([]);
43
+ });
44
+ });
@@ -0,0 +1,93 @@
1
+ const { validateRecursionDepth } = require('../src/dependencyValidator');
2
+
3
+ describe('validateRecursionDepth', () => {
4
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
5
+ const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
6
+
7
+ beforeEach(() => {
8
+ mockExit.mockClear();
9
+ mockConsoleError.mockClear();
10
+ });
11
+
12
+ test('should not exit for a valid dependency graph within default recursion depth', () => {
13
+ const flatMap = {
14
+ 'a': { id: 'a', type: 'custom-action', idList: ['b'] },
15
+ 'b': { id: 'b', type: 'custom-action', idList: ['c'] },
16
+ 'c': { id: 'c', type: 'action', command: 'echo c' }
17
+ };
18
+ // Default maxDepth is 3. a -> b (depth 1), b -> c (depth 2), c (depth 3)
19
+ validateRecursionDepth(flatMap);
20
+ expect(mockExit).not.toHaveBeenCalled();
21
+ });
22
+
23
+ test('should exit when recursion depth is exceeded by default (depth > 3)', () => {
24
+ const flatMap = {
25
+ 'a': { id: 'a', type: 'custom-action', idList: ['b'] },
26
+ 'b': { id: 'b', type: 'custom-action', idList: ['c'] },
27
+ 'c': { id: 'c', type: 'custom-action', idList: ['d'] },
28
+ 'd': { id: 'd', type: 'action', command: 'echo d' }
29
+ };
30
+ // Default maxDepth is 3. a -> b (1) -> c (2) -> d (3)
31
+ // If d refers to another custom action, it would exceed depth.
32
+ // Let's create a chain that explicitly exceeds default maxDepth=3.
33
+ const deepFlatMap = {
34
+ 'action0': { id: 'action0', type: 'custom-action', idList: ['action1'] },
35
+ 'action1': { id: 'action1', type: 'custom-action', idList: ['action2'] },
36
+ 'action2': { id: 'action2', type: 'custom-action', idList: ['action3'] },
37
+ 'action3': { id: 'action3', type: 'custom-action', idList: ['action4'] },
38
+ 'action4': { id: 'action4', type: 'action', command: 'echo hello' }
39
+ };
40
+ validateRecursionDepth(deepFlatMap);
41
+ expect(mockExit).toHaveBeenCalledWith(1);
42
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Maximum recursion depth of 3 exceeded by action \'action0\'. Path: action0 -> action1 -> action2 -> action3 -> action4'));
43
+ });
44
+
45
+ test('should not exit for a valid dependency graph within custom recursion depth', () => {
46
+ const flatMap = {
47
+ 'a': { id: 'a', type: 'custom-action', idList: ['b'] },
48
+ 'b': { id: 'b', type: 'custom-action', idList: ['c'] },
49
+ 'c': { id: 'c', type: 'action', command: 'echo c' }
50
+ };
51
+ // Testing with maxDepth = 4, which should pass
52
+ validateRecursionDepth(flatMap, 4);
53
+ expect(mockExit).not.toHaveBeenCalled();
54
+ });
55
+
56
+ test('should exit when custom recursion depth is exceeded', () => {
57
+ const flatMap = {
58
+ 'a': { id: 'a', type: 'custom-action', idList: ['b'] },
59
+ 'b': { id: 'b', type: 'custom-action', idList: ['c'] },
60
+ 'c': { id: 'c', type: 'action', command: 'echo c' }
61
+ };
62
+ // Testing with maxDepth = 2, which should fail at 'b'
63
+ validateRecursionDepth(flatMap, 2);
64
+ expect(mockExit).toHaveBeenCalledWith(1);
65
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Maximum recursion depth of 2 exceeded by action \'a\'. Path: a -> b -> c'));
66
+ });
67
+
68
+ test('should handle circular dependencies that are within maxDepth without exiting', () => {
69
+ const flatMap = {
70
+ 'a': { id: 'a', type: 'custom-action', idList: ['b'] },
71
+ 'b': { id: 'b', type: 'custom-action', idList: ['a'] }
72
+ };
73
+ // A circular dependency, but if default maxDepth is 3, it should not exit based *solely* on depth
74
+ // The current implementation of validateRecursionDepth detects depth exceeding, not general cycles.
75
+ // In this case, `checkDepth` for 'a' with 'b' at depth 1, then 'b' with 'a' at depth 2,
76
+ // will not exceed the default maxDepth of 3.
77
+ validateRecursionDepth(flatMap, 3); // maxDepth 3
78
+ expect(mockExit).not.toHaveBeenCalled();
79
+ });
80
+
81
+ test('should exit for a circular dependency that exceeds maxDepth', () => {
82
+ const flatMap = {
83
+ 'a': { id: 'a', type: 'custom-action', idList: ['b'] },
84
+ 'b': { id: 'b', type: 'custom-action', idList: ['c'] },
85
+ 'c': { id: 'c', type: 'custom-action', idList: ['a'] }
86
+ };
87
+ // With maxDepth = 2, 'a' -> 'b' (depth 1), 'b' -> 'c' (depth 2). When 'c' tries to go to 'a',
88
+ // the path 'a' -> 'b' -> 'c' -> 'a' would be depth 3 for the second 'a'.
89
+ validateRecursionDepth(flatMap, 2);
90
+ expect(mockExit).toHaveBeenCalledWith(1);
91
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Maximum recursion depth of 2 exceeded by action \'a\'. Path: a -> b -> c -> a'));
92
+ });
93
+ });
@@ -0,0 +1,43 @@
1
+ // Important: We need to reset the flatMap between tests.
2
+ // Jest can't easily reset modules, so we'll handle it manually.
3
+ let menu = require('../src/menu');
4
+
5
+ describe('menu', () => {
6
+
7
+ beforeEach(() => {
8
+ // Reset the flatMap object before each test
9
+ for (const key in menu.flatMap) {
10
+ delete menu.flatMap[key];
11
+ }
12
+ });
13
+
14
+ test('buildIdMap should correctly flatten a simple menu', () => {
15
+ const options = [
16
+ { id: '1', name: 'Action 1' },
17
+ { id: '2', name: 'Action 2' },
18
+ ];
19
+ menu.buildIdMap(options);
20
+ expect(menu.flatMap).toEqual({
21
+ '1': { id: '1', name: 'Action 1' },
22
+ '2': { id: '2', name: 'Action 2' },
23
+ });
24
+ });
25
+
26
+ test('buildIdMap should correctly flatten a nested menu', () => {
27
+ const options = [
28
+ { id: '1', name: 'Nav 1', options: [
29
+ { id: '1.1', name: 'Action 1.1' },
30
+ ]},
31
+ { id: '2', name: 'Action 2' },
32
+ ];
33
+ menu.buildIdMap(options);
34
+ expect(Object.keys(menu.flatMap).length).toBe(3);
35
+ expect(menu.flatMap['1.1']).toEqual({ id: '1.1', name: 'Action 1.1' });
36
+ });
37
+
38
+ test('buildIdMap should handle an empty options array', () => {
39
+ const options = [];
40
+ menu.buildIdMap(options);
41
+ expect(menu.flatMap).toEqual({});
42
+ });
43
+ });
@@ -0,0 +1,74 @@
1
+ const { validateMenu } = require('../src/menuValidator');
2
+
3
+ describe('menuValidator', () => {
4
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
5
+ const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
6
+
7
+ beforeEach(() => {
8
+ mockExit.mockClear();
9
+ mockConsoleError.mockClear();
10
+ });
11
+
12
+ const validMenu = {
13
+ name: 'Valid Menu',
14
+ options: [
15
+ { id: '1', name: 'Action 1', type: 'action', command: 'echo 1' },
16
+ { id: '2', name: 'Nav 1', type: 'navigation', options: [
17
+ { id: '2.1', name: 'Action 2.1', type: 'action', command: 'echo 2.1' }
18
+ ]},
19
+ { id: '3', name: 'Custom 1', type: 'custom-action', idList: ['1'] }
20
+ ]
21
+ };
22
+
23
+ test('should not throw error for a valid menu', () => {
24
+ validateMenu(validMenu);
25
+ expect(mockExit).not.toHaveBeenCalled();
26
+ });
27
+
28
+ test('should exit if top-level name is missing', () => {
29
+ const invalidMenu = { ...validMenu, name: undefined };
30
+ validateMenu(invalidMenu);
31
+ expect(mockExit).toHaveBeenCalledWith(1);
32
+ });
33
+
34
+ test('should exit if top-level options is not an array', () => {
35
+ const invalidMenu = { ...validMenu, options: 'not-an-array' };
36
+ validateMenu(invalidMenu);
37
+ expect(mockExit).toHaveBeenCalledWith(1);
38
+ });
39
+
40
+ test('should exit if an option is missing an id', () => {
41
+ const invalidMenu = JSON.parse(JSON.stringify(validMenu)); // Deep copy
42
+ invalidMenu.options[0].id = undefined;
43
+ validateMenu(invalidMenu);
44
+ expect(mockExit).toHaveBeenCalledWith(1);
45
+ });
46
+
47
+ test('should exit if an option is missing a name', () => {
48
+ const invalidMenu = JSON.parse(JSON.stringify(validMenu));
49
+ invalidMenu.options[0].name = undefined;
50
+ validateMenu(invalidMenu);
51
+ expect(mockExit).toHaveBeenCalledWith(1);
52
+ });
53
+
54
+ test('should exit if an action is missing a command', () => {
55
+ const invalidMenu = JSON.parse(JSON.stringify(validMenu));
56
+ invalidMenu.options[0].command = undefined;
57
+ validateMenu(invalidMenu);
58
+ expect(mockExit).toHaveBeenCalledWith(1);
59
+ });
60
+
61
+ test('should exit if a custom-action is missing an idList', () => {
62
+ const invalidMenu = JSON.parse(JSON.stringify(validMenu));
63
+ invalidMenu.options[2].idList = undefined;
64
+ validateMenu(invalidMenu);
65
+ expect(mockExit).toHaveBeenCalledWith(1);
66
+ });
67
+
68
+ test('should exit if a navigation is missing options', () => {
69
+ const invalidMenu = JSON.parse(JSON.stringify(validMenu));
70
+ invalidMenu.options[1].options = undefined;
71
+ validateMenu(invalidMenu);
72
+ expect(mockExit).toHaveBeenCalledWith(1);
73
+ });
74
+ });
package/src/actions.js DELETED
@@ -1,45 +0,0 @@
1
- const inquirer = require('inquirer');
2
- const chalk = require('chalk');
3
- const {terminal} = require('./terminal.js');
4
-
5
- async function confirmExecution(message) {
6
- const {ok} = await inquirer.prompt({type: 'confirm', name: 'ok', message, default: false});
7
- return ok;
8
- }
9
-
10
- async function handleAction(selected) {
11
- const proceed = selected.confirm ? await confirmExecution(chalk.yellow(`Executing command: [id: ${selected.id} name: ${selected.name} ]`)) : true;
12
- if (proceed) {
13
- await terminal.execCommandSync(selected.command);
14
- }
15
- }
16
-
17
- async function handleCustomAction(selected, flatMap, depth = 0) {
18
- if (depth >= 3) {
19
- console.log(chalk.red(`Maximum recursion depth (3) exceeded for custom action: ${selected.id}`));
20
- return;
21
- }
22
- const proceed = selected.confirm ? await confirmExecution(chalk.yellow(`Execute command list ${selected.idList.join(', ')}?`)) : true;
23
- if (proceed) {
24
- for (const id of selected.idList) {
25
- const cmd = flatMap[id];
26
- if (cmd) {
27
- if (cmd.type === 'action' && cmd.command) {
28
- console.log(chalk.blue(`Executing command: [id: ${cmd.id} name: ${cmd.name} ]`));
29
- await terminal.execCommandSync(cmd.command);
30
- } else if (cmd.type === 'custom-action') {
31
- console.log(chalk.blue(`Executing custom action: [id: ${cmd.id} name: ${cmd.name} ]`));
32
- await handleCustomAction(cmd, flatMap, depth + 1);
33
- } else {
34
- console.log(chalk.red(`Unknown or unexecutable type for id: ${cmd.id}`));
35
- }
36
- }
37
- }
38
- }
39
- }
40
-
41
- async function handleNavigation(selected) {
42
- return selected;
43
- }
44
-
45
- module.exports = { handleAction, handleCustomAction, handleNavigation };