diff --git a/CHANGELOG.md b/CHANGELOG.md index 5642421..f0fea05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added: +- Command: Added `Start with Options...` to launch Dev Proxy with interactive prompts for CLI settings - Install: Added automated install and upgrade support for Linux using official setup scripts - Notification: Detect outdated Dev Proxy config files in workspace and show warning when schema versions don't match installed version - Command: `dev-proxy-toolkit.upgrade-configs` - Upgrade config files with Copilot Chat using Dev Proxy MCP tools diff --git a/README.md b/README.md index c1fa26f..f72ad5c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Control Dev Proxy directly from VS Code via the Command Palette (`Cmd+Shift+P` / | Command | When Available | |---------|----------------| | Start | Dev Proxy not running | +| Start with Options... | Dev Proxy not running | | Stop | Dev Proxy running | | Restart | Dev Proxy running | | Raise mock request | Dev Proxy running | diff --git a/package.json b/package.json index 02266d1..cd1936d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,13 @@ "icon": "$(debug-start)", "enablement": "!isDevProxyRunning" }, + { + "command": "dev-proxy-toolkit.start-with-options", + "title": "Start with Options...", + "category": "Dev Proxy Toolkit", + "icon": "$(debug-alt)", + "enablement": "!isDevProxyRunning" + }, { "command": "dev-proxy-toolkit.stop", "title": "Stop", @@ -120,6 +127,11 @@ "group": "navigation@1", "when": "!activeEditorIsDirty && isDevProxyConfigFile && !isDevProxyRunning" }, + { + "command": "dev-proxy-toolkit.start-with-options", + "group": "navigation@2", + "when": "!activeEditorIsDirty && isDevProxyConfigFile && !isDevProxyRunning" + }, { "command": "dev-proxy-toolkit.stop", "group": "navigation@2", diff --git a/src/commands/proxy.ts b/src/commands/proxy.ts index 3c207d8..7c643d5 100644 --- a/src/commands/proxy.ts +++ b/src/commands/proxy.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import * as path from 'path'; import { Commands, ContextKeys } from '../constants'; import { DevProxyApiClient } from '../services/api-client'; import { TerminalService } from '../services/terminal'; @@ -8,7 +9,7 @@ import { VersionPreference } from '../enums'; import * as logger from '../logger'; /** - * Proxy lifecycle commands: start, stop, restart. + * Proxy lifecycle commands: start, start with options, stop, restart. * * These commands control the Dev Proxy process. */ @@ -25,6 +26,12 @@ export function registerProxyCommands( vscode.commands.registerCommand(Commands.start, () => startDevProxy(configuration, devProxyExe)) ); + context.subscriptions.push( + vscode.commands.registerCommand(Commands.startWithOptions, () => + startDevProxyWithOptions(devProxyExe) + ) + ); + context.subscriptions.push( vscode.commands.registerCommand(Commands.stop, () => stopDevProxy(apiClient, devProxyExe, configuration) @@ -52,6 +59,424 @@ async function startDevProxy( terminalService.sendCommand(terminal, command); } +interface StartOption { + key: string; + label: string; + value: string; + defaultValue: string; + flag: string; + editor: 'input' | 'pick' | 'config'; + prompt?: string; + placeholder?: string; + validate?: (value: string) => string | undefined; + choices?: string[]; +} + +const startOptionDefaults: Omit[] = [ + { + key: 'configFile', + label: 'Config file', + defaultValue: '', + flag: '--config-file', + editor: 'config', + }, + { + key: 'port', + label: 'Port', + defaultValue: '8000', + flag: '--port', + editor: 'input', + prompt: 'Enter the proxy port number', + validate: validatePortNumber, + }, + { + key: 'apiPort', + label: 'API port', + defaultValue: '8897', + flag: '--api-port', + editor: 'input', + prompt: 'Enter the API port number', + validate: validatePortNumber, + }, + { + key: 'ipAddress', + label: 'IP address', + defaultValue: '127.0.0.1', + flag: '--ip-address', + editor: 'input', + prompt: 'Enter the IP address to listen on', + validate: validateIpAddress, + }, + { + key: 'asSystemProxy', + label: 'As system proxy', + defaultValue: 'Yes', + flag: '--as-system-proxy', + editor: 'pick', + choices: ['Yes', 'No'], + }, + { + key: 'installCert', + label: 'Install certificate', + defaultValue: 'Yes', + flag: '--install-cert', + editor: 'pick', + choices: ['Yes', 'No'], + }, + { + key: 'logLevel', + label: 'Log level', + defaultValue: 'information', + flag: '--log-level', + editor: 'pick', + choices: ['trace', 'debug', 'information', 'warning', 'error'], + }, + { + key: 'failureRate', + label: 'Failure rate', + defaultValue: '50', + flag: '--failure-rate', + editor: 'input', + prompt: 'Enter the failure rate (0-100)', + validate: validateFailureRate, + }, + { + key: 'urlsToWatch', + label: 'URLs to watch', + defaultValue: '', + flag: '--urls-to-watch', + editor: 'input', + prompt: 'Enter URLs to watch (space separated). Leave empty to use config file values.', + placeholder: 'https://api.example.com/* https://graph.microsoft.com/v1.0/*', + }, + { + key: 'record', + label: 'Record', + defaultValue: 'No', + flag: '--record', + editor: 'pick', + choices: ['No', 'Yes'], + }, + { + key: 'noFirstRun', + label: 'No first run', + defaultValue: 'No', + flag: '--no-first-run', + editor: 'pick', + choices: ['No', 'Yes'], + }, + { + key: 'timeout', + label: 'Timeout', + defaultValue: '', + flag: '--timeout', + editor: 'input', + prompt: 'Enter timeout in seconds. Leave empty for no timeout.', + validate: validateTimeout, + }, + { + key: 'watchPids', + label: 'Watch PIDs', + defaultValue: '', + flag: '--watch-pids', + editor: 'input', + prompt: 'Enter process IDs to watch (space separated). Leave empty to skip.', + placeholder: '1234 5678', + validate: validateWatchPids, + }, + { + key: 'watchProcessNames', + label: 'Watch process names', + defaultValue: '', + flag: '--watch-process-names', + editor: 'input', + prompt: 'Enter process names to watch (space separated). Leave empty to skip.', + placeholder: 'msedge chrome', + validate: validateProcessNames, + }, +]; + +const startItemLabel = '$(play) Start Dev Proxy'; + +async function startDevProxyWithOptions(devProxyExe: string): Promise { + const configDefault = await resolveDefaultConfigFile(); + const options: StartOption[] = startOptionDefaults.map(o => ({ + ...o, + value: o.key === 'configFile' ? configDefault : o.defaultValue, + defaultValue: o.key === 'configFile' ? configDefault : o.defaultValue, + })); + + while (true) { + const items: vscode.QuickPickItem[] = [ + { + label: startItemLabel, + description: 'Launch with the options below', + alwaysShow: true, + }, + { label: '', kind: vscode.QuickPickItemKind.Separator }, + ...options.map(o => ({ + label: o.label, + description: formatOptionValue(o), + })), + ]; + + const picked = await vscode.window.showQuickPick(items, { + title: 'Start with Options', + placeHolder: 'Select an option to change, or start Dev Proxy', + }); + + if (picked === undefined) { + return; + } + + if (picked.label === startItemLabel) { + break; + } + + const option = options.find(o => o.label === picked.label); + if (!option) { + continue; + } + + const newValue = await editOption(option); + if (newValue !== undefined) { + option.value = newValue; + } + } + + const args = buildArgs(options); + + const command = [devProxyExe, ...args].join(' '); + const terminalService = TerminalService.fromConfiguration(); + const terminal = terminalService.getOrCreateTerminal(); + terminalService.sendCommand(terminal, command); +} + +function formatOptionValue(option: StartOption): string { + if (option.key === 'configFile') { + if (!option.value) { + return 'devproxyrc.json (install folder)'; + } + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + const relativePath = vscode.workspace.asRelativePath(option.value); + return relativePath + (option.value === option.defaultValue ? ' (default)' : ''); + } + return path.basename(option.value) + (option.value === option.defaultValue ? ' (default)' : ''); + } + if (!option.value) { + return '(not set)'; + } + if (option.value === option.defaultValue) { + return `${option.value} (default)`; + } + return option.value; +} + +async function editOption(option: StartOption): Promise { + if (option.editor === 'config') { + return editConfigFile(option.value); + } + + if (option.editor === 'pick' && option.choices) { + const picked = await vscode.window.showQuickPick(option.choices, { + title: option.label, + placeHolder: `Select a value for ${option.label}`, + }); + return picked; + } + + return vscode.window.showInputBox({ + title: option.label, + prompt: option.prompt, + value: option.value, + placeHolder: option.placeholder, + validateInput: option.validate, + }); +} + +function buildArgs(options: StartOption[]): string[] { + const args: string[] = []; + + for (const option of options) { + // Config file: include if set (even if it's the default — user chose it explicitly) + if (option.key === 'configFile') { + if (option.value) { + args.push(option.flag, `"${option.value}"`); + } + continue; + } + + if (option.value === option.defaultValue) { + continue; + } + + // Boolean-style flags + if (option.key === 'asSystemProxy' && option.value === 'No') { + args.push(option.flag, 'false'); + } else if (option.key === 'installCert' && option.value === 'No') { + args.push(option.flag, 'false'); + } else if (option.key === 'record' && option.value === 'Yes') { + args.push(option.flag); + } else if (option.key === 'noFirstRun' && option.value === 'Yes') { + args.push(option.flag); + } else if (option.value.trim()) { + args.push(option.flag, option.value.trim()); + } + } + + return args; +} + +async function resolveDefaultConfigFile(): Promise { + // 1. Active editor has a config file open → use that + const activeConfig = getActiveConfigFilePath(); + if (activeConfig) { + return activeConfig; + } + + // 2. devproxyrc.json exists in the workspace → use that + const workspaceFiles = await vscode.workspace.findFiles('**/devproxyrc.json', '**/node_modules/**', 1); + if (workspaceFiles.length > 0) { + return workspaceFiles[0].fsPath; + } + + // 3. Otherwise empty — Dev Proxy will use its install folder default + return ''; +} + +async function findWorkspaceConfigFiles(): Promise { + const jsonFiles = await vscode.workspace.findFiles('**/*.{json,jsonc}', '**/node_modules/**'); + const configFiles: vscode.Uri[] = []; + + for (const uri of jsonFiles) { + try { + const doc = await vscode.workspace.openTextDocument(uri); + if (isConfigFile(doc)) { + configFiles.push(uri); + } + } catch { + // Skip files that can't be opened + } + } + + return configFiles; +} + +async function editConfigFile(currentValue: string): Promise { + const configFiles = await findWorkspaceConfigFiles(); + + const items: vscode.QuickPickItem[] = [ + { + label: '$(home) Use install folder default', + description: 'devproxyrc.json', + detail: 'Use the default config file from the Dev Proxy install folder', + }, + ]; + + if (configFiles.length > 0) { + items.push({ label: '', kind: vscode.QuickPickItemKind.Separator }); + + for (const uri of configFiles) { + const relativePath = vscode.workspace.asRelativePath(uri); + items.push({ + label: relativePath, + description: uri.fsPath === currentValue ? '(current)' : undefined, + }); + } + } + + const picked = await vscode.window.showQuickPick(items, { + title: 'Config file', + placeHolder: 'Select a config file', + }); + + if (picked === undefined) { + return undefined; + } + + if (picked.label === '$(home) Use install folder default') { + return ''; + } + + const match = configFiles.find(uri => vscode.workspace.asRelativePath(uri) === picked.label); + return match?.fsPath ?? currentValue; +} + +function validatePortNumber(value: string): string | undefined { + if (!value) { + return undefined; + } + const num = Number(value); + if (!Number.isInteger(num) || num < 1 || num > 65535) { + return 'Port must be an integer between 1 and 65535'; + } + return undefined; +} + +function validateIpAddress(value: string): string | undefined { + if (!value) { + return undefined; + } + const parts = value.split('.'); + if (parts.length !== 4) { + return 'Enter a valid IPv4 address (e.g. 127.0.0.1)'; + } + for (const part of parts) { + const num = Number(part); + if (!/^\d{1,3}$/.test(part) || num < 0 || num > 255) { + return 'Enter a valid IPv4 address (e.g. 127.0.0.1)'; + } + } + return undefined; +} + +function validateFailureRate(value: string): string | undefined { + if (!value) { + return undefined; + } + const num = Number(value); + if (!Number.isInteger(num) || num < 0 || num > 100) { + return 'Failure rate must be an integer between 0 and 100'; + } + return undefined; +} + +function validateTimeout(value: string): string | undefined { + if (!value) { + return undefined; + } + const num = Number(value); + if (!Number.isInteger(num) || num < 1) { + return 'Timeout must be a positive integer'; + } + return undefined; +} + +function validateWatchPids(value: string): string | undefined { + if (!value) { + return undefined; + } + const parts = value.trim().split(/\s+/); + for (const part of parts) { + const num = Number(part); + if (!Number.isInteger(num) || num < 1) { + return 'PIDs must be positive integers separated by spaces'; + } + } + return undefined; +} + +function validateProcessNames(value: string): string | undefined { + if (!value) { + return undefined; + } + if (!/^[a-zA-Z0-9\s]+$/.test(value)) { + return 'Process names can only contain alphanumeric characters and spaces'; + } + return undefined; +} + async function stopDevProxy( apiClient: DevProxyApiClient, devProxyExe: string, diff --git a/src/constants.ts b/src/constants.ts index f4ce5a4..764ee7f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,6 +10,7 @@ export const Commands = { // Proxy lifecycle commands start: 'dev-proxy-toolkit.start', + startWithOptions: 'dev-proxy-toolkit.start-with-options', stop: 'dev-proxy-toolkit.stop', restart: 'dev-proxy-toolkit.restart', diff --git a/src/test/commands.test.ts b/src/test/commands.test.ts index 622dafc..220dc0d 100644 --- a/src/test/commands.test.ts +++ b/src/test/commands.test.ts @@ -21,6 +21,13 @@ suite('Command Registration', () => { ); }); + test('start-with-options command should be registered', () => { + assert.ok( + registeredCommands.includes(Commands.startWithOptions), + `Command ${Commands.startWithOptions} should be registered` + ); + }); + test('stop command should be registered', () => { assert.ok( registeredCommands.includes(Commands.stop),