feat(cli-command-editor): Added a new tool: CLI command editor
This commit is contained in:
		
							parent
							
								
									07eea0f484
								
							
						
					
					
						commit
						eeea527829
					
				| @ -392,3 +392,9 @@ tools: | ||||
|   text-to-binary: | ||||
|     title: Text to ASCII binary | ||||
|     description: Convert text to its ASCII binary representation and vice-versa. | ||||
| 
 | ||||
|   cli-command-editor: | ||||
|     title: CLI command editor | ||||
|     description: Convert CLI commands with options into an easily editable form and generate the resulting command with input values. | ||||
|     command: Command | ||||
|     placeholder: Paste command here | ||||
|  | ||||
							
								
								
									
										15
									
								
								src/tools/cli-command-editor/cli-command-editor.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/tools/cli-command-editor/cli-command-editor.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| import { test, expect } from '@playwright/test'; | ||||
| 
 | ||||
| test.describe('Tool - Cli command editor', () => { | ||||
|   test.beforeEach(async ({ page }) => { | ||||
|     await page.goto('/cli-command-editor'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Has correct title', async ({ page }) => { | ||||
|     await expect(page).toHaveTitle('Cli command editor - IT Tools'); | ||||
|   }); | ||||
| 
 | ||||
|   test('', async ({ page }) => { | ||||
| 
 | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										142
									
								
								src/tools/cli-command-editor/cli-command-editor.service.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								src/tools/cli-command-editor/cli-command-editor.service.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,142 @@ | ||||
| import { expect, describe, it } from 'vitest'; | ||||
| import { extractOptions, buildOptionsObject, sanitizeOption, buildEditedCommand, isOption } from './cli-command-editor.service'; | ||||
| 
 | ||||
| describe('cli-command-editor', () => { | ||||
|   describe("extractOptions", () => { | ||||
|     it ("extracts all the options from a command", () => { | ||||
|       expect( | ||||
|         extractOptions("aws elb describe-load-balancers --load-balancer-name my-load-balancer")[0] | ||||
|       ).toContain("--load-balancer-name"); | ||||
| 
 | ||||
|       expect( | ||||
|         extractOptions("aws elb describe-load-balancers --load-balancer-name my-load-balancer --debug --query my-queryyy")[0] | ||||
|       ).toContain("--load-balancer-name"); | ||||
| 
 | ||||
|       expect( | ||||
|         extractOptions("aws elb describe-load-balancers --load-balancer-name my-load-balancer --debug --query my-queryyy")[1] | ||||
|       ).toContain("--debug"); | ||||
| 
 | ||||
|       expect( | ||||
|         extractOptions("aws elb describe-load-balancers --load-balancer-name my-load-balancer --debug --query my-queryyy")[2] | ||||
|       ).toContain("--query"); | ||||
|     }); | ||||
| 
 | ||||
|     it("extracts all the option from a command with a mix of hyphen and double hyphens", () => { | ||||
|       expect( | ||||
|         extractOptions("npm i lodash -g --legacy-peer-deps")[0] | ||||
|       ).toContain("-g"); | ||||
| 
 | ||||
|       expect( | ||||
|         extractOptions("npm i lodash -g --legacy-peer-deps")[1] | ||||
|       ).toContain("--legacy-peer-deps"); | ||||
|     }); | ||||
| 
 | ||||
|     it("shouldn't extract any options from a command without options", () => { | ||||
|       expect( | ||||
|         extractOptions("npm i lodash") | ||||
|       ).toEqual([]); | ||||
|     }); | ||||
| 
 | ||||
|     it("shouldn't return any options if command is not passed", () => { | ||||
|       expect(extractOptions()).toEqual([]); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe("buildOptionsObject", () => { | ||||
|     it("returns a valid options object with the given options", () => { | ||||
|       expect( | ||||
|         buildOptionsObject(["--debug", "--load-balancer-names"]) | ||||
|       ).toEqual({ | ||||
|         "--debug": "", | ||||
|         "--load-balancer-names": "", | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it("returns an empty obnject with blank options array", () => { | ||||
|       expect( | ||||
|         buildOptionsObject([]) | ||||
|       ).toEqual({}); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe("sanitizeOption", () => { | ||||
|     it("returns the sanitized option without 'id' suffix", () => { | ||||
|       expect(sanitizeOption("--debug-id-1dfsj")) | ||||
|       .toEqual("--debug"); | ||||
|     }); | ||||
| 
 | ||||
|     it("returns the blank string", () => { | ||||
|       expect(sanitizeOption("")).toEqual(""); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe("isOption", () => { | ||||
|     it("returns true for a valid double hyphen option token", () => { | ||||
|       expect(isOption("--debug")).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it("returns true for a valid single hyphen option token", () => { | ||||
|       expect(isOption("-i")).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it("returns false for an non-option token", () => { | ||||
|       expect(isOption("hello-world")).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe("buildEditedCommand", () => { | ||||
|     it("returns the edited command", () => { | ||||
|       expect( | ||||
|         buildEditedCommand({ | ||||
|           "--debug-id-1dfsj": "stdin", | ||||
|           "-p": "", | ||||
|           "-m": "nahhhh", | ||||
|         }, { | ||||
|           "--debug-id-1dfsj": "stdin", | ||||
|           "-p": "", | ||||
|           "-m": "nahhhh", | ||||
|         }, "aws node --debug stdio -p -m okayyy") | ||||
|       ).toEqual("aws node --debug stdin -p -m nahhhh"); | ||||
| 
 | ||||
|       expect( | ||||
|         buildEditedCommand({ | ||||
|           "-d-id-1dfsj": "", | ||||
|           "-p-id-fdsd": "4444:3333", | ||||
|           "-p-id-fddd": "3333:4444", | ||||
|           "-e-id-ckslc": "CLICKHOUSE_PASSWORD=nopassword", | ||||
|           "--name-id-nnnn": "clickhouse-server", | ||||
|           "--ulimit-id-uuuu": "nofile=3333:4444", | ||||
|         }, { | ||||
|           "-d-id-1dfsj": "", | ||||
|           "-p-id-fdsd": "4444:3333", | ||||
|           "-p-id-fddd": "3333:4444", | ||||
|           "-e-id-ckslc": "CLICKHOUSE_PASSWORD=nopassword", | ||||
|           "--name-id-nnnn": "clickhouse-server", | ||||
|           "--ulimit-id-uuuu": "nofile=3333:4444", | ||||
|         }, "docker run -d -p 18123:8123 -p 19000:9000 -e CLICKHOUSE_PASSWORD=changeme --name some-clickhouse-server --ulimit nofile=262144:262144 clickhouse/clickhouse-server") | ||||
|       ).toEqual("docker run -d -p 4444:3333 -p 3333:4444 -e CLICKHOUSE_PASSWORD=nopassword --name clickhouse-server --ulimit nofile=3333:4444 clickhouse/clickhouse-server"); | ||||
|     }); | ||||
| 
 | ||||
|     it("returns the edited command when options object and CLI options order doesn't match", () => { | ||||
|       expect( | ||||
|         buildEditedCommand({ | ||||
|           "-d-id-t1dd3": "true", | ||||
|           "--install-id-only123": "nodemon", | ||||
|         }, { | ||||
|           "--install-id-only123": "nodem", | ||||
|           "-d-id-t1dd3": "false", | ||||
|         }, "npm --install nodem -d false") | ||||
|       ).toBe("npm --install nodemon -d true"); | ||||
|     }); | ||||
| 
 | ||||
|     it("returns the original command", () => { | ||||
|       expect( | ||||
|         buildEditedCommand({}, {}, "npm install nodemon") | ||||
|       ).toBe("npm install nodemon"); | ||||
| 
 | ||||
|       expect( | ||||
|         buildEditedCommand({}, {}, "aws load-balancer describe-load-balancers all") | ||||
|       ).toBe("aws load-balancer describe-load-balancers all"); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										104
									
								
								src/tools/cli-command-editor/cli-command-editor.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								src/tools/cli-command-editor/cli-command-editor.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,104 @@ | ||||
| import { generateRandomId } from "@/utils/random"; | ||||
| 
 | ||||
| export function isOption(token: string): boolean { | ||||
|   return token?.startsWith("--") || token?.startsWith("-") | ||||
| } | ||||
| 
 | ||||
| export function extractOptions(command: string = ""): string[] { | ||||
|   /* | ||||
|     aws elb describe-load-balancers --load-balancer-name my-load-balancer | ||||
|     npm i forever -g | ||||
|     docker run -d -p 18123:8123 -p 19000:9000 -e CLICKHOUSE_PASSWORD=changeme --name some-clickhouse-server --ulimit nofile=262144:262144 clickhouse/clickhouse-server | ||||
| 
 | ||||
|     in a CLI, the options are either written with a hyphen or double hyphens, however, | ||||
|     script names or package/library sometimes include a hyphen, too, for example 'describe-load-balancers' | ||||
|   */ | ||||
|   // split into tokens first
 | ||||
|   const tokens = command.split(" "); | ||||
| 
 | ||||
|   // map each token of the command to an option
 | ||||
|   const options = tokens.map((token: string) => { | ||||
|     // every option in a starts with either a hyphen or double hyphens
 | ||||
|     if (isOption(token)) { | ||||
|       const randomId = generateRandomId(); | ||||
|       return `${token}-${randomId}`; | ||||
|     } | ||||
| 
 | ||||
|     return ""; | ||||
|   }).filter((option: string): boolean => !!option); | ||||
|   return options; | ||||
| } | ||||
| 
 | ||||
| export function buildOptionsObject(options: string[]): Record<string, string> { | ||||
|   const optionsObject: Record<string, string> = {}; | ||||
| 
 | ||||
|   for (const option of options) { | ||||
|     optionsObject[option] = ""; | ||||
|   } | ||||
| 
 | ||||
|   return optionsObject; | ||||
| } | ||||
| 
 | ||||
| export function sanitizeOption(option: string): string { | ||||
|   return option.split("-id")?.[0]; | ||||
| } | ||||
| 
 | ||||
| export function buildEditedCommand(options: Record<string, string>, originalOptions: Record<string, string>, command: string): string { | ||||
|   if (!Object.keys(options).length) return command; | ||||
| 
 | ||||
|   const tokens = command.split(" "); | ||||
|   const editedTokens = []; | ||||
| 
 | ||||
|   // user may input the option value in any order, from the form
 | ||||
|   // preserve the original object with options in the correct
 | ||||
|   // order as they appear in the original command, this is done
 | ||||
|   // to handle the interpolation of edited option values into the
 | ||||
|   // command
 | ||||
|   originalOptions = Object.entries(options) | ||||
|     .reduce((previousValue: Record<string, string>, currentValue: string[]) => { | ||||
|       previousValue[currentValue[0]] = currentValue[1] | ||||
| 
 | ||||
|       return previousValue | ||||
|     }, originalOptions) | ||||
| 
 | ||||
|   const defaultValues: Record<string, string> = {}; | ||||
|   // replacing the options and their values (if any) with formatter ($i) to
 | ||||
|   // help in interpolation of the command
 | ||||
|   for (let i = 0, j = 0, n = tokens.length; i < n; ++i) { | ||||
|     const token = tokens[i]; | ||||
|     const nextToken = tokens[i+1]; | ||||
| 
 | ||||
|     if (isOption(token)) { | ||||
|       editedTokens.push(`$${j}`) | ||||
| 
 | ||||
|       if (!isOption(nextToken)) { | ||||
|         ++i; | ||||
|         defaultValues[`$${j}`] = nextToken; | ||||
|       } | ||||
|        | ||||
|       ++j; | ||||
|       continue; | ||||
|     } | ||||
| 
 | ||||
|     editedTokens.push(token); | ||||
|   } | ||||
| 
 | ||||
|   let editedCommand = editedTokens.join(" "); | ||||
| 
 | ||||
|   const originalOptionKeys = Object.keys(originalOptions); | ||||
| 
 | ||||
|   for (let i = 0, n = originalOptionKeys.length; i < n; ++i) { | ||||
|     const key = originalOptionKeys[i]; | ||||
|     const keyWithoutIdSuffix = key.split("-id-")[0] || key; | ||||
|      | ||||
|     if (originalOptions[key]) { | ||||
|       editedCommand = editedCommand.replace(`$${i}`, `${keyWithoutIdSuffix} ${originalOptions[key]}`); | ||||
|     } else { | ||||
|       const value = defaultValues[`$${i}`]; | ||||
|       const replaceValue = value ? `${keyWithoutIdSuffix} ${value}` : keyWithoutIdSuffix; | ||||
|       editedCommand = editedCommand.replace(`$${i}`, replaceValue); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return editedCommand; | ||||
| } | ||||
							
								
								
									
										54
									
								
								src/tools/cli-command-editor/cli-command-editor.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/tools/cli-command-editor/cli-command-editor.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | ||||
| <script setup lang="ts"> | ||||
|   import * as service from "./cli-command-editor.service" | ||||
|   const inputCommand = ref(""); | ||||
|   const options = computed(() => service.extractOptions(inputCommand.value)); | ||||
|   const optionsObject = computed(() => service.buildOptionsObject(options.value)); | ||||
|   const optionsInput = ref<{ [k: string]: string }>(optionsObject.value); | ||||
|   let command = ref(""); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <c-card> | ||||
|     <n-grid x-gap="12" y-gap="12" cols="1 600:3"> | ||||
|       <n-gi span="2"> | ||||
|         <c-input-text | ||||
|           v-model:value="inputCommand" | ||||
|           label-position="left" | ||||
|           label-width="130px" | ||||
|           :label="$t('tools.cli-command-editor.command')" | ||||
|           :aria-label="$t('tools.cli-command-editor.command')" | ||||
|           :placeholder="$t('tools.cli-command-editor.placeholder')" | ||||
|           :aria-placeholder="$t('tools.cli-command-editor.placeholder')" | ||||
|           raw-text | ||||
|            | ||||
|           @update:value="() => {command = inputCommand}" | ||||
|         /> | ||||
|         <div v-for="option in options" flex justify-center> | ||||
|           <c-input-text | ||||
|             v-model:value="optionsInput[option]" | ||||
|             label-position="left" | ||||
|             label-width="130px" | ||||
|             label-align="left" | ||||
|             :label="service.sanitizeOption(option)" | ||||
|             :aria-label="service.sanitizeOption(option)" | ||||
|             :placeholder="service.sanitizeOption(option)" | ||||
|             :aria-placeholder="service.sanitizeOption(option)" | ||||
|             @update:value="() => {command = service.buildEditedCommand(optionsInput, optionsObject, inputCommand)}" | ||||
|             mt-6 | ||||
|           /> | ||||
|         </div> | ||||
| 
 | ||||
|         <c-text-copyable | ||||
|           :value="command" | ||||
|           v-if="command" | ||||
|           font-mono | ||||
|           :show-icon="false" | ||||
|           mt-6  | ||||
|         /> | ||||
|       </n-gi> | ||||
|     </n-grid> | ||||
|   </c-card> | ||||
| </template> | ||||
| 
 | ||||
| <style lang="less" scoped> | ||||
| </style> | ||||
							
								
								
									
										12
									
								
								src/tools/cli-command-editor/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/tools/cli-command-editor/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| import { Terminal2 } from '@vicons/tabler'; | ||||
| import { defineTool } from '../tool'; | ||||
| 
 | ||||
| export const tool = defineTool({ | ||||
|   name: 'CLI command editor', | ||||
|   path: '/cli-command-editor', | ||||
|   description: '', | ||||
|   keywords: ['cli', 'command', 'editor'], | ||||
|   component: () => import('./cli-command-editor.vue'), | ||||
|   icon: Terminal2, | ||||
|   createdAt: new Date('2025-06-21'), | ||||
| }); | ||||
| @ -1,6 +1,7 @@ | ||||
| import { tool as base64FileConverter } from './base64-file-converter'; | ||||
| import { tool as base64StringConverter } from './base64-string-converter'; | ||||
| import { tool as basicAuthGenerator } from './basic-auth-generator'; | ||||
| import { tool as cliCommandEditor } from './cli-command-editor'; | ||||
| import { tool as emailNormalizer } from './email-normalizer'; | ||||
| 
 | ||||
| import { tool as asciiTextDrawer } from './ascii-text-drawer'; | ||||
| @ -160,6 +161,7 @@ export const toolsByCategory: ToolCategory[] = [ | ||||
|       emailNormalizer, | ||||
|       regexTester, | ||||
|       regexMemo, | ||||
|       cliCommandEditor, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user