From a9cecb0d329a19de90cec05b91247ccb53b56a82 Mon Sep 17 00:00:00 2001 From: David Rehmat Date: Mon, 27 May 2024 12:33:54 -0400 Subject: [PATCH] fix: resolves #118 - add command add, remove and scafolding (#125) * chore: add package manager line to pkg json * chore: save all commands in snake case * chore: auto complete to consider snake case * chore: consider snake case on command execution * chore: add command flow * chore: add remove command flow * chore: cmd now edits or creates a command * chore: improve readme for command scafolding and more * fix: lint fix --- README.md | 74 ++++++++++++++----- package.json | 3 +- src/main/framework/autocomplete.ts | 5 +- src/main/framework/commands.ts | 64 +++++++++++++++- .../framework/system-commands/commands.ts | 55 +++++++++++++- 5 files changed, 176 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0e965d3..bc36e56 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ this means commands such as `ls`, `cd` or program commands such as `node -v` or Head over to the [release page](https://github.com/mterm-io/mterm/releases/latest) to find the binary for your system type. mterm is evergreen and updates are automatically installed on your system as hey get released. Run `:v` to see your current mterm version. +### Customize + +mterm is customizable in a few ways - +- add your own [commands](#commands) to the terminal + - quick add a command with `:cmd command_name` and edit this +- change the [theme](#theme) of the terminal + - quick change the theme with `:theme` or `:css` +- change the [settings](#settings) of the terminal + - quick change the settings with `:settings edit` +- change the [profile](#configure) of the terminal to a desired interpreter + - quick change the profile with `:settings set defaultProfile wsl` (change wsl with the desired profile) + ### Autocomplete Start typing, mterm will pick up your available: programs, [system commands](#system), [custom commands](#commands) and history @@ -143,24 +155,26 @@ here is an example `~/mterm/settings.json` - mterm provided a few system commands to help control the terminal and settings. mterm settings will always start with `:` (a colon) unless the intention is to override a system command. for example, because `clear` needs to be handled in a special way for mterm windows + tabs, it is overriden in mterm. -| Command | Alias | Purpose | -| ---------------------------- | ------ | -------------------------------------------------------------------------------------- | -| `clear` | `cls` | Clear the current terminal output | -| `cd` | | Navigate the file tree on the host machine | -| `:exit` | `exit` | Exit the current tab, or mterm if only 1 tab is open | -| `:edit` | `edit` | Open the in-terminal editor with the file provided. Hit `Ctrl+S` to save in the editor | -| `:history` | | Print out terminal history for debugging in a JSON format | -| `:reload` | | Reload settings, the ui, and commands without restarting | -| `:tab` | | Open a new tab | -| `:test` | | Sample command that executes after 10 seconds. Helpful for debugging | -| `:vault` | | Open the secret management tool, the mterm vault | -| `:version` | `:v` | Print the current mterm version | -| `:workspace` | | Open the mterm workspace folder on disk: `~/mterm` | -| `:theme` | `:css` | Edit the terminal theme real time | -| `:settings` | | Open the mterm settings gui to manage `~/mterm/settings.json` | -| `:settings edit` | | Open the `~/mterm/settings.json` in the terminal editor with hot reloading | -| `:settings reload` | | Reload `~/mterm/settings.json` and all ui etc associated with the settings | -| `:settings {get\|set} {key}` | | Set the setting key matching the path in `~/mterm/settings.json` and reload | +| Command | Alias | Purpose | +|------------------------------|-------------|----------------------------------------------------------------------------------------| +| `clear` | `cls` | Clear the current terminal output | +| `cd` | | Navigate the file tree on the host machine | +| `:exit` | `exit` | Exit the current tab, or mterm if only 1 tab is open | +| `:edit` | `edit` | Open the in-terminal editor with the file provided. Hit `Ctrl+S` to save in the editor | +| `:history` | | Print out terminal history for debugging in a JSON format | +| `:reload` | | Reload settings, the ui, and commands without restarting | +| `:tab` | | Open a new tab | +| `:test` | | Sample command that executes after 10 seconds. Helpful for debugging | +| `:vault` | | Open the secret management tool, the mterm vault | +| `:version` | `:v` | Print the current mterm version | +| `:workspace` | | Open the mterm workspace folder on disk: `~/mterm` | +| `:theme` | `:css` | Edit the terminal theme real time | +| `:cmd` | `:commands` | Edit the command file | +| `:cmd {cmd_name}` | | Edit the command file for the cmd_name, creates if this doesn't exist | +| `:settings` | | Open the mterm settings gui to manage `~/mterm/settings.json` | +| `:settings edit` | | Open the `~/mterm/settings.json` in the terminal editor with hot reloading | +| `:settings reload` | | Reload `~/mterm/settings.json` and all ui etc associated with the settings | +| `:settings {get\|set} {key}` | | Set the setting key matching the path in `~/mterm/settings.json` and reload | ### Commands @@ -182,6 +196,30 @@ Now run `hello X` from mterm - > In this case, no argument was provided so the `os.userInfo().username` was the fallback. `Hello, DR` is the result! +Note: commands are in snake case always when running. `helloWorld` will be `hello_world` when running in mterm. however, mterm will still resolve `helloWorld` as `hello_world` when running - it's just important to note this when dealing with name collisions. every command is stored in snake case. + +#### add a command + +add a command to and edit this command - + +```bash +:cmd command_name +``` +![image](https://github.com/mterm-io/mterm/assets/7341502/619706da-fe9e-4a97-8786-ce294492d8af) + +`command_name` is now available to mterm (or `commandName` or `COMMAND_NAME` - note all names are resolved to `snake_case`) + +#### editing a command + +in the same fasion you add a command, edit a command with the same syntax - +```bash +:cmd command_name +``` + +this editor will save with `Ctrl+S` and reload the command every time you save. + + +#### other notes on commands Try fetching data! ```typescript diff --git a/package.json b/package.json index 765ac3c..82a6608 100644 --- a/package.json +++ b/package.json @@ -75,5 +75,6 @@ "ts-jest": "^29.1.2", "typescript": "^5.4.5", "vite": "^5.0.12" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/main/framework/autocomplete.ts b/src/main/framework/autocomplete.ts index 4a0e00c..46362f2 100644 --- a/src/main/framework/autocomplete.ts +++ b/src/main/framework/autocomplete.ts @@ -4,6 +4,7 @@ import { isAbsolute, join, parse } from 'path' import { Runtime } from './runtime' import { HistoricalExecution } from './history' import { SystemCommand, systemCommands } from './runtime-executor' +import { Commands } from './commands' export interface PathParts { path: string @@ -291,8 +292,8 @@ export class Autocomplete { (o) => o.command.startsWith(prompt) || o.alias?.find((o) => o.startsWith(prompt)) ) - const userCommandMatches = Object.keys(this.workspace.commands.lib).filter((o) => - o.startsWith(prompt) + const userCommandMatches = Object.keys(this.workspace.commands.lib).filter( + (o) => o.startsWith(prompt) || o.startsWith(Commands.toCommandName(prompt)) ) const programMatches = this.programList.filter( diff --git a/src/main/framework/commands.ts b/src/main/framework/commands.ts index 7332de8..ddec87d 100644 --- a/src/main/framework/commands.ts +++ b/src/main/framework/commands.ts @@ -1,4 +1,4 @@ -import { mkdirs, pathExists, readFile, readJson, writeFile, writeJson } from 'fs-extra' +import { mkdirs, pathExists, readFile, readJson, writeFile, writeJson, remove } from 'fs-extra' import { tmpdir } from 'node:os' import { join } from 'path' import short from 'short-uuid' @@ -9,6 +9,7 @@ import { Settings } from './settings' import { ExecuteContext } from './execute-context' import { CommandUtils } from './command-utils' import { shell } from 'electron' +import { snakeCase } from 'lodash' export class Commands { public lib: object = {} public commandFileLocation: string = '' @@ -27,12 +28,16 @@ export class Commands { fetch = global.fetch has(key: string): boolean { - return !!this.lib[key] + return !!this.lib[key] || !!this.lib[Commands.toCommandName(key)] + } + + static toCommandName(command: string = ''): string { + return snakeCase(command).toLowerCase().trim() } async run(context: ExecuteContext, key: string, ...args: string[]): Promise { let state = this.state[key] - const cmd = this.lib[key] + const cmd = this.lib[key] || this.lib[Commands.toCommandName(key)] if (!state) { state = {} @@ -115,5 +120,58 @@ export class Commands { this.lib = {} runInNewContext(`${jsFile}`, this) + + const libTranslated = {} + + Object.keys(this.lib).forEach((key) => { + libTranslated[Commands.toCommandName(key)] = this.lib[key] + }) + + this.lib = libTranslated + } + + getCommandFileLocation(cmd: string): string { + return join(this.workingDirectory, 'commands', `${Commands.toCommandName(cmd)}.ts`) + } + + async addCommand(cmd: string, cmdScript: string = ''): Promise { + cmd = Commands.toCommandName(cmd) + + const commandFolder = join(this.workingDirectory, 'commands') + const commandFolderExists = await pathExists(commandFolder) + if (!commandFolderExists) { + await mkdirs(commandFolder) + } + + const scriptFileBuffer = await readFile(this.commandFileLocation) + const scriptFile = scriptFileBuffer.toString() + const exportText = `export { ${cmd} } from './commands/${cmd}'` + const script = `${scriptFile}\n${exportText}` + + const commandFile = join(commandFolder, `${cmd}.ts`) + const commandFileContents = `export function ${cmd}() {\n\t// your code here\n\t${cmdScript}\n}\n` + + await writeFile(commandFile, commandFileContents) + await writeFile(this.commandFileLocation, script) + } + + async removeCommand(cmd: string): Promise { + cmd = Commands.toCommandName(cmd) + + const commandFolder = join(this.workingDirectory, 'commands') + const commandFolderExists = await pathExists(commandFolder) + if (commandFolderExists) { + const commandFile = join(commandFolder, `${cmd}.ts`) + const commandFileExists = await pathExists(commandFile) + if (commandFileExists) { + await remove(cmd) + } + } + + const scriptFileBuffer = await readFile(this.commandFileLocation) + const scriptFile = scriptFileBuffer.toString() + const script = scriptFile.replaceAll(`\nexport { ${cmd} } from './commands/${cmd}'`, '') + + await writeFile(this.commandFileLocation, script) } } diff --git a/src/main/framework/system-commands/commands.ts b/src/main/framework/system-commands/commands.ts index dadb9bc..f4cf95e 100644 --- a/src/main/framework/system-commands/commands.ts +++ b/src/main/framework/system-commands/commands.ts @@ -1,9 +1,41 @@ import { ExecuteContext } from '../execute-context' import { errorModal } from '../../index' +import { Commands } from '../commands' + +export async function addCommand(context: ExecuteContext, cmd: string): Promise { + if (!cmd) { + context.out('Provide a command name to add\n\nExample: :cmd add restart_explorer\n\n', true) + context.finish(1) + return + } + + if (context.workspace.commands.has(cmd)) { + context.out('Command already exists\n', true) + context.finish(1) + return + } + + context.out(`Adding command: ${cmd}\n`) + + console.log('Adding command:', cmd) + + await context.workspace.commands.addCommand(cmd, `return 'Running ${cmd}!'`) + + context.out('Command added\n') + + await context.workspace.commands.load(context.workspace.settings) + + context.out('Command reloaded\n') + + await context.edit(context.workspace.commands.getCommandFileLocation(cmd), async () => { + context.out(`${cmd} reloaded\n`) + await context.workspace.commands.load(context.workspace.settings) + }) +} export default { command: ':commands', - alias: [':commands', ':cmd'], + alias: [':commands', ':cmd', ':c'], async task(context: ExecuteContext, task?: string): Promise { context.out('') if (!task) { @@ -25,6 +57,27 @@ export default { context.out('Commands reloaded\n') }) + } else if (task === 'add') { + const cmd = Commands.toCommandName(context.prompt.args[1] || '') + + await addCommand(context, cmd) + } else if (task === 'remove') { + const cmd = Commands.toCommandName(context.prompt.args[1] || '') + + await context.workspace.commands.removeCommand(cmd) + + context.out('Command removed\n') + } else if (task.trim()) { + const cmd = Commands.toCommandName(task || '') + if (context.workspace.commands.has(cmd)) { + context.out(`Editing command: ${cmd}\n`) + await context.edit(context.workspace.commands.getCommandFileLocation(cmd), async () => { + context.out(`${cmd} reloaded\n`) + await context.workspace.commands.load(context.workspace.settings) + }) + } else { + await addCommand(context, cmd) + } } } }