From eeea527829c7ba4e7ca6f7d033659fbdee88a129 Mon Sep 17 00:00:00 2001 From: Dhwanik Panchal Date: Sat, 28 Jun 2025 13:20:23 +0530 Subject: [PATCH] feat(cli-command-editor): Added a new tool: CLI command editor --- locales/en.yml | 6 + .../cli-command-editor.e2e.spec.ts | 15 ++ .../cli-command-editor.service.test.ts | 142 ++++++++++++++++++ .../cli-command-editor.service.ts | 104 +++++++++++++ .../cli-command-editor/cli-command-editor.vue | 54 +++++++ src/tools/cli-command-editor/index.ts | 12 ++ src/tools/index.ts | 2 + 7 files changed, 335 insertions(+) create mode 100644 src/tools/cli-command-editor/cli-command-editor.e2e.spec.ts create mode 100644 src/tools/cli-command-editor/cli-command-editor.service.test.ts create mode 100644 src/tools/cli-command-editor/cli-command-editor.service.ts create mode 100644 src/tools/cli-command-editor/cli-command-editor.vue create mode 100644 src/tools/cli-command-editor/index.ts diff --git a/locales/en.yml b/locales/en.yml index d03d80d3..ca3362f6 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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 diff --git a/src/tools/cli-command-editor/cli-command-editor.e2e.spec.ts b/src/tools/cli-command-editor/cli-command-editor.e2e.spec.ts new file mode 100644 index 00000000..15255768 --- /dev/null +++ b/src/tools/cli-command-editor/cli-command-editor.e2e.spec.ts @@ -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 }) => { + + }); +}); \ No newline at end of file diff --git a/src/tools/cli-command-editor/cli-command-editor.service.test.ts b/src/tools/cli-command-editor/cli-command-editor.service.test.ts new file mode 100644 index 00000000..6d638bb7 --- /dev/null +++ b/src/tools/cli-command-editor/cli-command-editor.service.test.ts @@ -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"); + }); + }); +}); \ No newline at end of file diff --git a/src/tools/cli-command-editor/cli-command-editor.service.ts b/src/tools/cli-command-editor/cli-command-editor.service.ts new file mode 100644 index 00000000..a4ec8c07 --- /dev/null +++ b/src/tools/cli-command-editor/cli-command-editor.service.ts @@ -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 { + const optionsObject: Record = {}; + + 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, originalOptions: Record, 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, currentValue: string[]) => { + previousValue[currentValue[0]] = currentValue[1] + + return previousValue + }, originalOptions) + + const defaultValues: Record = {}; + // 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; +} \ No newline at end of file diff --git a/src/tools/cli-command-editor/cli-command-editor.vue b/src/tools/cli-command-editor/cli-command-editor.vue new file mode 100644 index 00000000..c8614b41 --- /dev/null +++ b/src/tools/cli-command-editor/cli-command-editor.vue @@ -0,0 +1,54 @@ + + + + + \ No newline at end of file diff --git a/src/tools/cli-command-editor/index.ts b/src/tools/cli-command-editor/index.ts new file mode 100644 index 00000000..a47a70e8 --- /dev/null +++ b/src/tools/cli-command-editor/index.ts @@ -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'), +}); \ No newline at end of file diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf4..de28c2ed 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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, ], }, {