From 6bdac6562543638d0f515cb720cd095c0eebe594 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Mon, 4 Mar 2024 12:17:15 -0800 Subject: [PATCH] Codespaces compatibility (#27) --- CHANGELOG.md | 2 + package.json | 13 +- src/constants.ts | 3 - src/extension.ts | 8 +- src/net-utils.ts | 122 ++++++++++++++ src/port-settings.ts | 37 ++++ src/retry-utils.ts | 25 ++- src/run.ts | 391 +++++++++++++++++++------------------------ src/shell-utils.ts | 46 ++++- 9 files changed, 405 insertions(+), 242 deletions(-) delete mode 100644 src/constants.ts create mode 100644 src/net-utils.ts create mode 100644 src/port-settings.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 257a9c6..de0c2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## 0.1.6 - "Run Shiny App" now works with Python executable paths that have spaces or other special characters. (#26) +- "Run Shiny App" now starts a fresh console for each run (and closes the last console it started), so that the app's output is not mixed with the output of previous runs. (#27) +- Improved compatibility with GitHub Codespaces. (#27) ## 0.1.5 diff --git a/package.json b/package.json index 42daf52..63da5da 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,12 @@ "shiny.python.port": { "type": "integer", "default": 0, - "description": "The port number to listen on when running an app. (Use 0 to choose a random port for each workspace.)" + "description": "The port number Shiny should listen on when running an app. (Use 0 to choose a random port.)" + }, + "shiny.python.autoreloadPort": { + "type": "integer", + "default": 0, + "description": "The port number Shiny should use for a supplemental WebSocket channel it uses to support reload-on-save. (Use 0 to choose a random port.)" }, "shiny.python.debugJustMyCode": { "type": "boolean", @@ -89,7 +94,6 @@ "@types/vscode": "^1.66.0", "@typescript-eslint/eslint-plugin": "^7.1.0", "@typescript-eslint/parser": "^7.1.0", - "@vscode/python-extension": "^1.0.5", "@vscode/test-electron": "^2.1.3", "eslint": "^8.11.0", "glob": "^10.3.10", @@ -98,5 +102,8 @@ }, "extensionDependencies": [ "ms-python.python" - ] + ], + "dependencies": { + "@vscode/python-extension": "^1.0.5" + } } diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index ac9df25..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const PYSHINY_EXEC_CMD = "PYSHINY_EXEC_CMD"; -export const PYSHINY_EXEC_SHELL = "PYSHINY_EXEC_SHELL"; -export const TERMINAL_NAME = "Shiny"; diff --git a/src/extension.ts b/src/extension.ts index d7c037c..485f0c0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,12 +4,8 @@ import { runApp, debugApp, onDidStartDebugSession } from "./run"; export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( - vscode.commands.registerCommand("shiny.python.runApp", () => - runApp(context) - ), - vscode.commands.registerCommand("shiny.python.debugApp", () => - debugApp(context) - ) + vscode.commands.registerCommand("shiny.python.runApp", runApp), + vscode.commands.registerCommand("shiny.python.debugApp", debugApp) ); const throttledUpdateContext = new Throttler(2000, updateContext); diff --git a/src/net-utils.ts b/src/net-utils.ts new file mode 100644 index 0000000..a180e3d --- /dev/null +++ b/src/net-utils.ts @@ -0,0 +1,122 @@ +import * as http from "http"; +import * as net from "net"; +import { AddressInfo } from "net"; +import * as vscode from "vscode"; +import { getRemoteSafeUrl } from "./extension-api-utils/getRemoteSafeUrl"; +import { retryUntilTimeout } from "./retry-utils"; + +/** + * Tests if a port is open on a host, by trying to connect to it with a TCP + * socket. + */ +async function isPortOpen( + host: string, + port: number, + timeout: number = 1000 +): Promise { + return new Promise((resolve, reject) => { + const client = new net.Socket(); + + client.setTimeout(timeout); + client.connect(port, host, function () { + resolve(true); + client.end(); + }); + + client.on("timeout", () => { + client.destroy(); + reject(new Error("Timed out")); + }); + + client.on("error", (err) => { + reject(err); + }); + + client.on("close", () => { + reject(new Error("Connection closed")); + }); + }); +} + +/** + * Opens a browser for the specified port, once that port is open. Handles + * translating http://localhost: into a proxy URL, if necessary. + * @param port The port to open the browser for. + * @param additionalPorts Additional ports to wait for before opening the + * browser. + */ +export async function openBrowserWhenReady( + port: number, + ...additionalPorts: number[] +): Promise { + const portsOpen = [port, ...additionalPorts].map((p) => + retryUntilTimeout(10000, () => isPortOpen("127.0.0.1", p)) + ); + const portsOpenResult = await Promise.all(portsOpen); + if (portsOpenResult.filter((p) => !p).length > 0) { + console.warn("Failed to connect to Shiny app, not launching browser"); + return; + } + + let previewUrl = await getRemoteSafeUrl(port); + await openBrowser(previewUrl); +} + +export async function openBrowser(url: string): Promise { + // if (process.env["CODESPACES"] === "true") { + // vscode.env.openExternal(vscode.Uri.parse(url)); + // } else { + await vscode.commands.executeCommand("simpleBrowser.api.open", url, { + preserveFocus: true, + viewColumn: vscode.ViewColumn.Beside, + }); + // } +} + +export async function suggestPort(): Promise { + do { + const server = http.createServer(); + + const p = new Promise((resolve, reject) => { + server.on("listening", () => + resolve((server.address() as AddressInfo).port) + ); + server.on("error", reject); + }).finally(() => { + return closeServer(server); + }); + + server.listen(0, "127.0.0.1"); + + const port = await p; + + if (!UNSAFE_PORTS.includes(port)) { + return port; + } + } while (true); +} + +async function closeServer(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.close((errClose) => { + if (errClose) { + // Don't bother logging, we don't care (e.g. if the server + // failed to listen, close() will fail) + } + // Whether close succeeded or not, we're now ready to continue + resolve(); + }); + }); +} + +// Ports that are considered unsafe by Chrome +// http://superuser.com/questions/188058/which-ports-are-considered-unsafe-on-chrome +// https://github.com/rstudio/shiny/issues/1784 +const UNSAFE_PORTS = [ + 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, + 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, + 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, + 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, + 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, + 10080, +]; diff --git a/src/port-settings.ts b/src/port-settings.ts new file mode 100644 index 0000000..621ac2b --- /dev/null +++ b/src/port-settings.ts @@ -0,0 +1,37 @@ +import * as vscode from "vscode"; +import { suggestPort } from "./net-utils"; + +const transientPorts: Record = {}; + +export async function getAppPort(reason: "run" | "debug"): Promise { + return ( + // Port can be zero, which means random assignment + vscode.workspace.getConfiguration("shiny.python").get("port") || + (await defaultPort(`app_${reason}`)) + ); +} + +export async function getAutoreloadPort( + reason: "run" | "debug" +): Promise { + return ( + // Port can be zero, which means random assignment + vscode.workspace.getConfiguration("shiny.python").get("autoreloadPort") || + (await defaultPort(`autoreload_${reason}`)) + ); +} + +async function defaultPort(portCacheKey: string): Promise { + // Retrieve most recently used port + let port: number = transientPorts[portCacheKey] ?? 0; + if (port !== 0) { + return port; + } + + port = await suggestPort(); + + // Save for next time + transientPorts[portCacheKey] = port; + + return port; +} diff --git a/src/retry-utils.ts b/src/retry-utils.ts index df44ca3..e185b6e 100644 --- a/src/retry-utils.ts +++ b/src/retry-utils.ts @@ -1,12 +1,21 @@ +/** + * Repeatedly calls callback until it executes without throwing an error, or + * until timeoutMs has passed. + * @param timeoutMs Number of milliseconds to wait before giving up. + * @param callback The function to call repeatedly. + * @returns The return value of the first successful call to the callback + * (doesn't have to be truthy, just not erroring); or undefined if the timeout + * was reached. + */ export async function retryUntilTimeout( timeoutMs: number, callback: () => Promise ): Promise { - let { result, cancel: cancelResult } = retryUntilCancel(20, callback); + const { result, cancel: cancelResult } = retryUntilCancel(20, callback); let timer: NodeJS.Timeout | undefined; - let timeoutPromise = new Promise((resolve) => { - timer = setTimeout(() => resolve, timeoutMs); + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), timeoutMs); }); try { @@ -19,6 +28,16 @@ export async function retryUntilTimeout( } } +/** + * Repeatedly calls callback until it executes without throwing an error, or + * until the operation is cancelled. + * @param intervalMs Number of milliseconds to wait between retries. + * @param callback The function to call repeatedly. + * @returns An object with two properties: `result`, a promise that resolves to + * the return value of the first successful call to the callback (doesn't have + * to be truthy, just not erroring) or rejects with Error("Cancelled") if + * cancelled; and `cancel`, a function you can call to stop the retries. + */ export function retryUntilCancel( intervalMs: number, callback: () => Promise diff --git a/src/run.ts b/src/run.ts index bec42fb..4e83c69 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,245 +1,118 @@ import { PythonExtension } from "@vscode/python-extension"; -import * as http from "http"; -import { AddressInfo } from "net"; import * as vscode from "vscode"; +import { openBrowser, openBrowserWhenReady } from "./net-utils"; import { - PYSHINY_EXEC_CMD, - PYSHINY_EXEC_SHELL, - TERMINAL_NAME, -} from "./constants"; -import { getRemoteSafeUrl } from "./extension-api-utils/getRemoteSafeUrl"; -import { retryUntilTimeout } from "./retry-utils"; -import { escapeCommandForTerminal } from "./shell-utils"; + envVarsForShell as envVarsForTerminal, + escapeCommandForTerminal, +} from "./shell-utils"; +import { getAppPort, getAutoreloadPort } from "./port-settings"; const DEBUG_NAME = "Debug Shiny app"; -export async function runApp(context: vscode.ExtensionContext) { - runAppImpl(context, async (path, port) => { - // Gather details of the current Python interpreter. We want to make sure - // only to re-use a terminal if it's using the same interpreter. - const pythonAPI: PythonExtension = await PythonExtension.api(); - - // The getActiveEnvironmentPath docstring says: "Note that this can be an - // invalid environment, use resolveEnvironment to get full details." - const unresolvedEnv = pythonAPI.environments.getActiveEnvironmentPath( - vscode.window.activeTextEditor?.document.uri - ); - const resolvedEnv = await pythonAPI.environments.resolveEnvironment( - unresolvedEnv - ); - if (!resolvedEnv) { - vscode.window.showErrorMessage( - "Unable to find Python interpreter. " + - 'Please use the "Python: Select Interpreter" command, and try again.' - ); - return false; - } - - const pythonExecCommand = resolvedEnv.path; - - const shinyTerminals = vscode.window.terminals.filter( - (term) => term.name === TERMINAL_NAME - ); - - let shinyTerm = shinyTerminals.find((x) => { - const env = (x.creationOptions as Readonly)?.env; - const canUse = env && env[PYSHINY_EXEC_CMD] === pythonExecCommand; - if (!canUse) { - // Existing Terminal windows named $TERMINAL_NAME but not using the - // correct Python interpreter are closed. - x.dispose(); - } - return canUse; - }); - - if (!shinyTerm) { - shinyTerm = vscode.window.createTerminal({ - name: TERMINAL_NAME, - env: { - // eslint-disable-next-line @typescript-eslint/naming-convention - [PYSHINY_EXEC_CMD]: pythonExecCommand, - [PYSHINY_EXEC_SHELL]: vscode.env.shell, - }, - }); - } else { - // Send Ctrl+C to interrupt any existing Shiny process - shinyTerm.sendText("\u0003"); - } - // Wait for shell to be created, or for existing process to be interrupted - await new Promise((resolve) => setTimeout(resolve, 250)); - shinyTerm.show(true); - - const args = ["-m", "shiny", "run", "--port", port + "", "--reload", path]; - const cmd = escapeCommandForTerminal(shinyTerm, pythonExecCommand, args); - shinyTerm.sendText(cmd); - - return true; - }); -} - -export async function debugApp(context: vscode.ExtensionContext) { - runAppImpl(context, async (path, port) => { - if (vscode.debug.activeDebugSession?.name === DEBUG_NAME) { - await vscode.debug.stopDebugging(vscode.debug.activeDebugSession); - } - - const justMyCode = vscode.workspace - .getConfiguration("shiny.python") - .get("debugJustMyCode", true); - - await vscode.debug.startDebugging(undefined, { - type: "python", - name: DEBUG_NAME, - request: "launch", - module: "shiny", - args: ["run", "--port", port.toString(), path], - jinja: true, - justMyCode, - stopOnEntry: false, - }); - - // Don't spawn browser. We do so in onDidStartDebugSession instead, so when - // VSCode restarts the debugger instead of us, the SimpleBrowser is still - // opened. - return false; - }); -} - -/** - * Template function for runApp and debugApp - * @param context The context - * @param launch Async function that launches the app. Returns true if - * runAppImpl should open a browser. - */ -async function runAppImpl( - context: vscode.ExtensionContext, - launch: (path: string, port: number) => Promise -) { - const port: number = - vscode.workspace.getConfiguration("shiny.python").get("port") || - (await defaultPort(context)); - - const appPath = vscode.window.activeTextEditor?.document.uri.fsPath; - if (typeof appPath !== "string") { - vscode.window.showErrorMessage("No active file"); +export async function runApp(): Promise { + const path = getActiveEditorFile(); + if (!path) { return; } - if (await launch(appPath, port)) { - await openBrowserWhenReady(port); - } -} - -async function isPortOpen(host: string, port: number): Promise { - return new Promise((resolve, reject) => { - const options = { - hostname: host, - port, - path: "/", - method: "GET", - }; - const req = http.request(options, (res) => { - resolve(true); - res.destroy(); - req.destroy(); - }); - req.on("error", (err) => { - reject(err); - }); - req.end(); - }); -} - -async function openBrowserWhenReady(port: number) { - try { - await retryUntilTimeout(10000, () => isPortOpen("127.0.0.1", port)); - } catch { - // Failed to connect. Don't bother trying to launch a browser - console.warn("Failed to connect to Shiny app, not launching browser"); + const python = await getSelectedPythonInterpreter(); + if (!python) { return; } - let previewUrl = await getRemoteSafeUrl(port); - - vscode.commands.executeCommand("simpleBrowser.api.open", previewUrl, { - preserveFocus: true, - viewColumn: vscode.ViewColumn.Beside, + const port = await getAppPort("run"); + const autoreloadPort = await getAutoreloadPort("run"); + + const terminal = await createTerminalAndCloseOthersWithSameName({ + name: "Shiny", + env: { + // We store the Python path here so we know whether the terminal can be + // reused by us in the future (yes if the selected Python interpreter has + // changed, no if it has). Currently we don't ever reuse terminals, + // instead we always close the old ones--but this could change in the + // future. + // eslint-disable-next-line @typescript-eslint/naming-convention + PYSHINY_EXEC_CMD: python, + // We save this here so escapeCommandForTerminal knows what shell + // semantics to use when escaping arguments. A bit magical, but oh well. + ...envVarsForTerminal(), + }, }); -} -// Ports that are considered unsafe by Chrome -// http://superuser.com/questions/188058/which-ports-are-considered-unsafe-on-chrome -// https://github.com/rstudio/shiny/issues/1784 -const UNSAFE_PORTS = [ - 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, - 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, - 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, - 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, - 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, - 10080, -]; - -async function defaultPort(context: vscode.ExtensionContext): Promise { - // Retrieve most recently used port - let port: number = context.workspaceState.get("transient_port", 0); - while (port === 0 || !(await verifyPort(port))) { - do { - port = await suggestPort(); - // Ports that are considered unsafe by Chrome - // http://superuser.com/questions/188058/which-ports-are-considered-unsafe-on-chrome - // https://github.com/rstudio/shiny/issues/1784 - } while (UNSAFE_PORTS.includes(port)); - await context.workspaceState.update("transient_port", port); + const args: string[] = ["-m", "shiny", "run"]; + args.push("--port", port + ""); + args.push("--reload"); + args.push("--autoreload-port", autoreloadPort + ""); + args.push(path); + const cmdline = escapeCommandForTerminal(terminal, python, args); + terminal.sendText(cmdline); + + // Clear out the browser. Without this it can be a little confusing as to + // whether the app is trying to load or not. + openBrowser("about:blank"); + // If we start too quickly, openBrowserWhenReady may detect the old Shiny + // process (in the process of shutting down), not the new one. Give it a + // second. It's a shame to wait an extra second, but it's only when the Play + // button is hit, not on autoreload. + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (process.env["CODESPACES"] === "true") { + // Codespaces has a port forwarding system that has an interesting auth + // system. By default, forwarded ports are private, and each forwarded port + // is served at a different hostname. Authentication is handled in one of + // two ways: 1) in a browser, you would attempt to navigate to a page and it + // would send you through an authentication flow, resulting in cookies being + // set; or 2) for an API call, you can add a custom header with a GitHub + // token. Our autoreload WebSocket client wants to connect, but it can't add + // custom headers (the browser WebSocket client intentionally doesn't + // support it). So we need to navigate the browser to the autoreload port to + // get the cookies set. Fortunately, Shiny's autoreload port does nothing + // but redirect you to the main port. + // + // So that's what we do on Cloudspaces: send the browser to the autoreload + // port instead of the main port. + await openBrowserWhenReady(autoreloadPort, port); + } else { + // For non-Cloudspace environments, simply go to the main port. + await openBrowserWhenReady(port, autoreloadPort); } - return port; } -async function suggestPort(): Promise { - const server = http.createServer(); - - const p = new Promise((resolve, reject) => { - server.on("listening", () => - resolve((server.address() as AddressInfo).port) - ); - server.on("error", reject); - }).finally(() => { - return closeServer(server); - }); +export async function debugApp(): Promise { + if (vscode.debug.activeDebugSession?.name === DEBUG_NAME) { + await vscode.debug.stopDebugging(vscode.debug.activeDebugSession); + } - server.listen(0, "127.0.0.1"); + const path = getActiveEditorFile(); + if (!path) { + return; + } - return p; -} + const python = await getSelectedPythonInterpreter(); + if (!python) { + return; + } -async function verifyPort( - port: number, - host: string = "127.0.0.1" -): Promise { - const server = http.createServer(); - - const p = new Promise((resolve, reject) => { - server.on("listening", () => resolve(true)); - server.on("error", () => resolve(false)); - }).finally(() => { - return closeServer(server); + const port = await getAppPort("debug"); + + const justMyCode = vscode.workspace + .getConfiguration("shiny.python") + .get("debugJustMyCode", true); + + await vscode.debug.startDebugging(undefined, { + type: "python", + name: DEBUG_NAME, + request: "launch", + module: "shiny", + args: ["run", "--port", port.toString(), path], + jinja: true, + justMyCode, + stopOnEntry: false, }); - server.listen(port, host); - - return p; -} - -async function closeServer(server: http.Server): Promise { - return new Promise((resolve, reject) => { - server.close((errClose) => { - if (errClose) { - // Don't bother logging, we don't care (e.g. if the server - // failed to listen, close() will fail) - } - // Whether close succeeded or not, we're now ready to continue - resolve(); - }); - }); + // Don't spawn browser. We do so in onDidStartDebugSession instead, so when + // VSCode restarts the debugger instead of us, the SimpleBrowser is still + // opened. } export function onDidStartDebugSession(e: vscode.DebugSession) { @@ -283,3 +156,81 @@ export function onDidStartDebugSession(e: vscode.DebugSession) { console.warn("Failed to open browser", err); }); } + +async function createTerminalAndCloseOthersWithSameName( + options: vscode.TerminalOptions +): Promise { + if (!options.name) { + throw new Error("Terminal name is required"); + } + + // Grab a list of all terminals with the same name, before we create the new + // one. We'll close them. (It'd be surprising if there was more than one, + // given that we always close the previous ones when starting a new one.) + const oldTerminals = vscode.window.terminals.filter( + (term) => term.name === options.name + ); + + const newTerm = vscode.window.createTerminal(options); + newTerm.show(true); + + // Wait until new terminal is showing before disposing the old ones. + // Otherwise we get a flicker of some other terminal in the in-between time. + + const closingTerminals = oldTerminals.map((x) => { + const p = new Promise((resolve) => { + // Resolve when the terminal is closed. We're working hard to be accurate + // BUT empirically it doesn't seem like the old Shiny processes are + // actually terminated at the time this promise is resolved, so callers + // shouldn't assume that. + const subscription = vscode.window.onDidCloseTerminal(function sub(term) { + if (term === x) { + subscription.dispose(); + resolve(); + } + }); + }); + x.dispose(); + return p; + }); + await Promise.allSettled(closingTerminals); + + return newTerm; +} + +/** + * Gets the currently selected Python interpreter, according to the Python extension. + * @returns A path, or false if no interpreter is selected. + */ +async function getSelectedPythonInterpreter(): Promise { + // Gather details of the current Python interpreter. We want to make sure + // only to re-use a terminal if it's using the same interpreter. + const pythonAPI: PythonExtension = await PythonExtension.api(); + + // The getActiveEnvironmentPath docstring says: "Note that this can be an + // invalid environment, use resolveEnvironment to get full details." + const unresolvedEnv = pythonAPI.environments.getActiveEnvironmentPath( + vscode.window.activeTextEditor?.document.uri + ); + const resolvedEnv = await pythonAPI.environments.resolveEnvironment( + unresolvedEnv + ); + if (!resolvedEnv) { + vscode.window.showErrorMessage( + "Unable to find Python interpreter. " + + 'Please use the "Python: Select Interpreter" command, and try again.' + ); + return false; + } + + return resolvedEnv.path; +} + +function getActiveEditorFile(): string | undefined { + const appPath = vscode.window.activeTextEditor?.document.uri.fsPath; + if (typeof appPath !== "string") { + vscode.window.showErrorMessage("No active file"); + return; + } + return appPath; +} diff --git a/src/shell-utils.ts b/src/shell-utils.ts index 0eb2745..794b887 100644 --- a/src/shell-utils.ts +++ b/src/shell-utils.ts @@ -1,8 +1,13 @@ import * as vscode from "vscode"; -import { PYSHINY_EXEC_SHELL } from "./constants"; type EscapeStyle = "cmd" | "ps" | "sh"; +const PYSHINY_EXEC_SHELL = "PYSHINY_EXEC_SHELL"; +/** + * Determine the escaping style to use for a terminal. + * @param terminal The terminal to determine the escaping style for. + * @returns The escaping style to use. + */ function escapeStyle(terminal: vscode.Terminal): EscapeStyle { const termEnv = (terminal.creationOptions as vscode.TerminalOptions).env || {}; @@ -17,30 +22,46 @@ function escapeStyle(terminal: vscode.Terminal): EscapeStyle { } } -export function escapeArg(filePath: string, style: EscapeStyle): string { +/** + * Escape a single argument for use in a shell command. Varies behavior + * depending on the desired escaping style. + * @param arg The arg value to escape. + * @param style One of "cmd", "ps", or "sh". + * @returns An escaped version of the argument. + */ +export function escapeArg(arg: string, style: EscapeStyle): string { switch (style) { case "cmd": // For cmd.exe, double quotes are used to handle spaces, and carets (^) are used to escape special characters. - const escaped = filePath.replace(/([()%!^"<>&|])/g, "^$1"); + const escaped = arg.replace(/([()%!^"<>&|])/g, "^$1"); return /\s/.test(escaped) ? `"${escaped}"` : escaped; case "ps": - if (!/[ '"`,;(){}|&<>@#[\]]/.test(filePath)) { - return filePath; + if (!/[ '"`,;(){}|&<>@#[\]]/.test(arg)) { + return arg; } // PowerShell accepts single quotes as literal strings and does not require escaping like cmd.exe. // Single quotes in the path itself need to be doubled though. - return `'${filePath.replace(/'/g, "''")}'`; + return `'${arg.replace(/'/g, "''")}'`; case "sh": // For bash, spaces are escaped with backslashes, and special characters are handled similarly. - return filePath.replace(/([\\ !"$`&*()|[\]{};<>?])/g, "\\$1"); + return arg.replace(/([\\ !"$`&*()|[\]{};<>?])/g, "\\$1"); default: throw new Error('Unsupported style. Use "cmd", "ps", or "sh".'); } } +/** + * + * @param terminal The terminal to escape the command for. + * @param exec The command to execute. Can be null if only args are needed (e.g. + * if you're adding additional arguments to an existing command line). + * @param args Arguments to the command, if any. + * @returns The escaped command line. If the terminal is PowerShell, the command + * is prefixed with "& ". + */ export function escapeCommandForTerminal( terminal: vscode.Terminal, exec: string | null, @@ -60,3 +81,14 @@ export function escapeCommandForTerminal( return cmd; } } + +/** + * Save metadata to the terminal's environment so we can retrieve it later, to + * determine how to escape arguments. + * @returns A map of env vars to include in the terminal's environment. + */ +export function envVarsForShell(): Record { + return { + [PYSHINY_EXEC_SHELL]: vscode.env.shell, + }; +}