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: |   text-to-binary: | ||||||
|     title: Text to ASCII binary |     title: Text to ASCII binary | ||||||
|     description: Convert text to its ASCII binary representation and vice-versa. |     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 base64FileConverter } from './base64-file-converter'; | ||||||
| import { tool as base64StringConverter } from './base64-string-converter'; | import { tool as base64StringConverter } from './base64-string-converter'; | ||||||
| import { tool as basicAuthGenerator } from './basic-auth-generator'; | 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 emailNormalizer } from './email-normalizer'; | ||||||
| 
 | 
 | ||||||
| import { tool as asciiTextDrawer } from './ascii-text-drawer'; | import { tool as asciiTextDrawer } from './ascii-text-drawer'; | ||||||
| @ -160,6 +161,7 @@ export const toolsByCategory: ToolCategory[] = [ | |||||||
|       emailNormalizer, |       emailNormalizer, | ||||||
|       regexTester, |       regexTester, | ||||||
|       regexMemo, |       regexMemo, | ||||||
|  |       cliCommandEditor, | ||||||
|     ], |     ], | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user