custom-menu-cli 3.0.0 → 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 +32 -1
- package/README.md +32 -0
- package/docs/icon.png +0 -0
- package/index.js +20 -6
- package/menu.json +2 -0
- package/package.json +14 -2
- package/src/actionRunner.js +92 -0
- package/src/args.js +23 -0
- package/src/configLoader.js +29 -41
- package/src/dependencyValidator.js +41 -0
- package/src/menu.js +1 -1
- package/src/menuValidator.js +30 -23
- package/test_menu_circular/a.json +6 -0
- package/test_menu_circular/b.json +6 -0
- package/test_menu_circular/c.json +6 -0
- package/tests/args.test.js +44 -0
- package/tests/dependencyValidator.test.js +93 -0
- package/tests/menu.test.js +43 -0
- package/tests/menuValidator.test.js +74 -0
- package/src/actions.js +0 -45
- /package/{test_menus → test_menu_example}/1-project-a/1.1-down-service.json +0 -0
- /package/{test_menus → test_menu_example}/1-project-a/1.2-up-service.json +0 -0
- /package/{test_menus → test_menu_example}/1-project-a/1.3-restart-project-a.json +0 -0
- /package/{test_menus → test_menu_example}/2-restart-all.json +0 -0
- /package/{test_menus → test_menu_example}/3-restart-project-a-nested.json +0 -0
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
|

|
|
4
6
|

|
|
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
|
|
|
@@ -115,6 +119,33 @@ custom-menu-cli ./caminho/para/sua/pasta_de_menu
|
|
|
115
119
|
|
|
116
120
|
O CLI irá automaticamente descobrir e combinar todos os arquivos JSON válidos em uma única estrutura de menu.
|
|
117
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
|
+
|
|
118
149
|
## Estrutura do JSON
|
|
119
150
|
|
|
120
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
|

|
|
@@ -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
|
|
|
@@ -116,6 +121,33 @@ custom-menu-cli ./path/to/your/menu_folder
|
|
|
116
121
|
|
|
117
122
|
The CLI will automatically discover and combine all valid JSON files into a single menu structure.
|
|
118
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
|
+
|
|
119
151
|
## JSON Structure
|
|
120
152
|
|
|
121
153
|
The JSON file that defines the menu has the following structure:
|
package/docs/icon.png
ADDED
|
Binary file
|
package/index.js
CHANGED
|
@@ -1,20 +1,34 @@
|
|
|
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');
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
|
|
10
|
+
const { validateMenu } = require('./src/menuValidator.js');
|
|
11
|
+
|
|
12
|
+
async function runCli() {
|
|
13
|
+
const { menuPath, customActions } = parseArgs();
|
|
8
14
|
const data = await loadMenuConfig(menuPath);
|
|
15
|
+
|
|
16
|
+
// 1. Validate the overall menu structure
|
|
17
|
+
validateMenu(data);
|
|
18
|
+
|
|
9
19
|
if (data.options) {
|
|
10
20
|
buildIdMap(data.options);
|
|
11
21
|
}
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
displayHeader(data);
|
|
23
|
+
validateRecursionDepth(flatMap);
|
|
16
24
|
|
|
17
|
-
|
|
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) {
|
package/menu.json
CHANGED
package/package.json
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "custom-menu-cli",
|
|
3
|
-
"version": "
|
|
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": [
|
|
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 };
|
package/src/configLoader.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const fs = require('fs').promises;
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const chalk = require('chalk');
|
|
4
|
-
const { validateMenuOption } = require('./menuValidator.js'); // Importar o validador
|
|
5
4
|
|
|
6
5
|
const defaultMenu = {
|
|
7
6
|
"name": "Example Menu",
|
|
@@ -33,66 +32,55 @@ async function buildMenuOptions(dir) {
|
|
|
33
32
|
});
|
|
34
33
|
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
35
34
|
const fileContent = await fs.readFile(fullPath, 'utf-8');
|
|
36
|
-
let option;
|
|
37
35
|
try {
|
|
38
|
-
option = JSON.parse(fileContent);
|
|
36
|
+
const option = JSON.parse(fileContent);
|
|
37
|
+
options.push(option);
|
|
39
38
|
} catch (parseError) {
|
|
40
|
-
console.error(chalk.red(`
|
|
41
|
-
console.error(chalk.red(`
|
|
39
|
+
console.error(chalk.red(`Error: Malformed JSON file at: ${fullPath}`));
|
|
40
|
+
console.error(chalk.red(`Details: ${parseError.message}`));
|
|
42
41
|
process.exit(1);
|
|
43
42
|
}
|
|
44
|
-
|
|
45
|
-
validateMenuOption(option, fullPath); // Chamar o validador
|
|
46
|
-
|
|
47
|
-
options.push(option);
|
|
48
43
|
}
|
|
49
44
|
}
|
|
50
45
|
return options;
|
|
51
46
|
}
|
|
52
47
|
|
|
53
48
|
async function loadMenuConfig(menuPath = null) {
|
|
54
|
-
|
|
55
|
-
let finalPath = menuPath;
|
|
56
|
-
|
|
57
|
-
// If menuPath is not provided, check command line arguments
|
|
58
|
-
if (!finalPath) {
|
|
59
|
-
const args = process.argv.slice(2);
|
|
60
|
-
finalPath = args[0];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (finalPath) {
|
|
49
|
+
if (menuPath) {
|
|
64
50
|
try {
|
|
65
|
-
const stats = await fs.stat(
|
|
51
|
+
const stats = await fs.stat(menuPath);
|
|
66
52
|
if (stats.isDirectory()) {
|
|
67
|
-
|
|
53
|
+
return {
|
|
68
54
|
name: "Dynamic Menu",
|
|
69
|
-
description: `Menu generated from folder: ${
|
|
70
|
-
options: await buildMenuOptions(
|
|
55
|
+
description: `Menu generated from folder: ${menuPath}`,
|
|
56
|
+
options: await buildMenuOptions(menuPath)
|
|
71
57
|
};
|
|
72
|
-
} else { // It's a file
|
|
73
|
-
data = JSON.parse(await fs.readFile(finalPath, 'utf-8'));
|
|
74
|
-
validateMenuOption(data, finalPath); // Validar menu de arquivo único também
|
|
75
58
|
}
|
|
59
|
+
return JSON.parse(await fs.readFile(menuPath, 'utf-8'));
|
|
76
60
|
} catch (error) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
data = JSON.parse(fs.readFileSync('./menu.json', 'utf-8'));
|
|
84
|
-
validateMenuOption(data, './menu.json'); // Validar menu.json padrão
|
|
85
|
-
} catch (error) {
|
|
86
|
-
console.log(chalk.red(`Erro ao analisar o arquivo JSON: ./menu.json`));
|
|
87
|
-
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
|
+
}
|
|
88
67
|
process.exit(1);
|
|
89
68
|
}
|
|
90
|
-
} else {
|
|
91
|
-
console.log(chalk.yellow("Nenhum 'menu.json' encontrado. Carregando menu de exemplo."));
|
|
92
|
-
data = defaultMenu;
|
|
93
69
|
}
|
|
94
70
|
|
|
95
|
-
|
|
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
|
+
}
|
|
96
84
|
}
|
|
97
85
|
|
|
98
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,
|
|
3
|
+
const { handleAction, handleCustomAction, executeSequence } = require('./actionRunner.js');
|
|
4
4
|
|
|
5
5
|
const flatMap = {};
|
|
6
6
|
|
package/src/menuValidator.js
CHANGED
|
@@ -1,44 +1,51 @@
|
|
|
1
1
|
const chalk = require('chalk');
|
|
2
2
|
|
|
3
|
-
function
|
|
4
|
-
// --- Validation Logic ---
|
|
3
|
+
function _validateSingleOption(option) {
|
|
5
4
|
if (!option.id || typeof option.id !== 'string') {
|
|
6
|
-
console.error(chalk.red(`
|
|
7
|
-
|
|
8
|
-
process.exit(1);
|
|
5
|
+
console.error(chalk.red(`Validation Error: An option is missing a valid 'id'.`));
|
|
6
|
+
process.exit(1); return;
|
|
9
7
|
}
|
|
10
8
|
if (!option.name || typeof option.name !== 'string') {
|
|
11
|
-
console.error(chalk.red(`
|
|
12
|
-
|
|
13
|
-
process.exit(1);
|
|
9
|
+
console.error(chalk.red(`Validation Error in option '${option.id}': 'name' is missing or invalid.`));
|
|
10
|
+
process.exit(1); return;
|
|
14
11
|
}
|
|
12
|
+
|
|
15
13
|
const validTypes = ['action', 'navigation', 'custom-action'];
|
|
16
|
-
if (!option.type ||
|
|
17
|
-
console.error(chalk.red(`
|
|
18
|
-
|
|
19
|
-
process.exit(1);
|
|
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;
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
if (option.type === 'action') {
|
|
23
20
|
if (!option.command || typeof option.command !== 'string') {
|
|
24
|
-
console.error(chalk.red(`
|
|
25
|
-
|
|
26
|
-
process.exit(1);
|
|
21
|
+
console.error(chalk.red(`Validation Error in option '${option.id}': 'command' is missing or invalid for type 'action'.`));
|
|
22
|
+
process.exit(1); return;
|
|
27
23
|
}
|
|
28
24
|
} else if (option.type === 'custom-action') {
|
|
29
25
|
if (!Array.isArray(option.idList) || option.idList.length === 0) {
|
|
30
|
-
console.error(chalk.red(`
|
|
31
|
-
|
|
32
|
-
process.exit(1);
|
|
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;
|
|
33
28
|
}
|
|
34
29
|
} else if (option.type === 'navigation') {
|
|
35
30
|
if (!Array.isArray(option.options) || option.options.length === 0) {
|
|
36
|
-
console.error(chalk.red(`
|
|
37
|
-
|
|
38
|
-
process.exit(1);
|
|
31
|
+
console.error(chalk.red(`Validation Error in option '${option.id}': 'options' is missing or empty for type 'navigation'.`));
|
|
32
|
+
process.exit(1); return;
|
|
39
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;
|
|
40
47
|
}
|
|
41
|
-
|
|
48
|
+
menuData.options.forEach(_validateSingleOption);
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
module.exports = {
|
|
51
|
+
module.exports = { validateMenu };
|
|
@@ -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 };
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|