diff --git a/package-lock.json b/package-lock.json index 1f0a02351..d0307d4be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "@microsoft/vscode-azext-azureutils": "^3.4.0", "@microsoft/vscode-azext-utils": "^3.2.0", "@microsoft/vscode-azureresources-api": "^2.0.4", - "@microsoft/vscode-container-client": "^0.1.2", + "@microsoft/vscode-container-client": "^0.4.1", + "@microsoft/vscode-processutils": "^0.1.1", "cross-fetch": "^4.0.0", "escape-string-regexp": "^4.0.0", "extract-zip": "^2.0.1", @@ -1483,12 +1484,44 @@ } }, "node_modules/@microsoft/vscode-container-client": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@microsoft/vscode-container-client/-/vscode-container-client-0.1.2.tgz", - "integrity": "sha512-R90cDc2ggweJqc/jD85/rRSAHigRfKbqLWiOaKz8tpMtS+najxTSsNY5XWgg2/QhBjv4xgeWON/R0VRe0w8yiw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@microsoft/vscode-container-client/-/vscode-container-client-0.4.1.tgz", + "integrity": "sha512-u5wBlY02fFBbcAqZM4/SNgEaa/B4iVGtr/kM/h5BMfdCSnYZ9/brvfWlLYc9zsaQwH91zI2OVqiMsVij5WKI1w==", "dependencies": { + "@microsoft/vscode-processutils": "^0.1.1", "dayjs": "^1.11.2", - "tree-kill": "^1.2.2" + "zod": "^3.25.56" + } + }, + "node_modules/@microsoft/vscode-processutils": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/vscode-processutils/-/vscode-processutils-0.1.1.tgz", + "integrity": "sha512-Fr/nraEubANwZ5phHHhH8hhmIrdIOzjnqYxOoRUUX2EbR6qUNUTgkm+TM8NfO2xLYc2DqH9p5NfeHU9gwHLS6Q==", + "dependencies": { + "tree-kill": "^1.2.2", + "which": "^5.0.0" + } + }, + "node_modules/@microsoft/vscode-processutils/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "engines": { + "node": ">=16" + } + }, + "node_modules/@microsoft/vscode-processutils/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@nevware21/ts-async": { @@ -12734,6 +12767,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 38d16667a..a7d583fe2 100644 --- a/package.json +++ b/package.json @@ -1486,7 +1486,8 @@ "@microsoft/vscode-azext-azureutils": "^3.4.0", "@microsoft/vscode-azext-utils": "^3.2.0", "@microsoft/vscode-azureresources-api": "^2.0.4", - "@microsoft/vscode-container-client": "^0.1.2", + "@microsoft/vscode-container-client": "^0.4.1", + "@microsoft/vscode-processutils": "^0.1.1", "cross-fetch": "^4.0.0", "escape-string-regexp": "^4.0.0", "extract-zip": "^2.0.1", diff --git a/src/commands/appSettings/localSettings/decryptLocalSettings.ts b/src/commands/appSettings/localSettings/decryptLocalSettings.ts index ed11d78b7..3c54c36f9 100644 --- a/src/commands/appSettings/localSettings/decryptLocalSettings.ts +++ b/src/commands/appSettings/localSettings/decryptLocalSettings.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { composeArgs, withArg } from '@microsoft/vscode-processutils'; import * as path from 'path'; import { type Uri } from "vscode"; import { ext } from '../../../extensionVariables'; @@ -15,5 +16,5 @@ export async function decryptLocalSettings(context: IActionContext, uri?: Uri): const message: string = localize('selectLocalSettings', 'Select the settings file to decrypt.'); const localSettingsPath: string = uri ? uri.fsPath : await getLocalSettingsFile(context, message); ext.outputChannel.show(true); - await cpUtils.executeCommand(ext.outputChannel, path.dirname(localSettingsPath), 'func', 'settings', 'decrypt'); + await cpUtils.executeCommand(ext.outputChannel, path.dirname(localSettingsPath), 'func', composeArgs(withArg('settings', 'decrypt'))()); } diff --git a/src/commands/createFunction/openAPISteps/OpenAPICreateStep.ts b/src/commands/createFunction/openAPISteps/OpenAPICreateStep.ts index 7f088cf26..5fd19596c 100644 --- a/src/commands/createFunction/openAPISteps/OpenAPICreateStep.ts +++ b/src/commands/createFunction/openAPISteps/OpenAPICreateStep.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { AzureWizardExecuteStep, DialogResponses, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { composeArgs, withArg, type CommandLineCurryFn } from '@microsoft/vscode-processutils'; import * as path from 'path'; import { ProgressLocation, window, type Uri } from "vscode"; import { ProjectLanguage, packageJsonFileName } from "../../../constants"; @@ -28,41 +29,56 @@ export class OpenAPICreateStep extends AzureWizardExecuteStep { const uris: Uri[] = nonNullProp(wizardContext, 'openApiSpecificationFile'); const uri: Uri = uris[0]; - const args: string[] = []; - args.push(`--input-file:${cpUtils.wrapArgInQuotes(uri.fsPath)}`); - args.push(`--version:3.0.6320`); + // TODO: Need to work on this...we don't have a good answer for quoting things that are only a substring of a single argument + const generalArgsCurryFn = composeArgs( + withArg(`--input-file:${cpUtils.wrapArgInQuotes(uri.fsPath)}`), // TODO + withArg(`--version:3.0.6320`), + ); + let langArgsCurryFn: CommandLineCurryFn; switch (wizardContext.language) { case ProjectLanguage.TypeScript: - args.push('--azure-functions-typescript'); - args.push('--no-namespace-folders:True'); + langArgsCurryFn = composeArgs( + withArg('--azure-functions-typescript'), + withArg('--no-namespace-folders:True'), + ); break; case ProjectLanguage.CSharp: - args.push(`--namespace:${nonNullProp(wizardContext, 'namespace')}`); - args.push('--azure-functions-csharp'); + langArgsCurryFn = composeArgs( + withArg(`--namespace:${nonNullProp(wizardContext, 'namespace')}`), + withArg('--azure-functions-csharp'), + ); break; case ProjectLanguage.Java: - args.push(`--namespace:${nonNullProp(wizardContext, 'javaPackageName')}`); - args.push('--azure-functions-java'); + langArgsCurryFn = composeArgs( + withArg(`--namespace:${nonNullProp(wizardContext, 'javaPackageName')}`), + withArg('--azure-functions-java'), + ); break; case ProjectLanguage.Python: - args.push('--azure-functions-python'); - args.push('--no-namespace-folders:True'); - args.push('--no-async'); + langArgsCurryFn = composeArgs( + withArg('--azure-functions-python'), + withArg('--no-namespace-folders:True'), + withArg('--no-async'), + ); break; default: throw new Error(localize('notSupported', 'Not a supported language. We currently support C#, Java, Python, and Typescript')); } - args.push('--generate-metadata:false'); - args.push(`--output-folder:${cpUtils.wrapArgInQuotes(wizardContext.projectPath)}`); + const args = composeArgs( + generalArgsCurryFn, + langArgsCurryFn, + withArg('--generate-metadata:false'), + withArg(`--output-folder:${cpUtils.wrapArgInQuotes(wizardContext.projectPath)}`), // TODO + )(); ext.outputChannel.appendLog(localize('statutoryWarning', 'Using the plugin could overwrite your custom changes to the functions.\nIf autorest fails, you can run the script on your command-line, or try resetting autorest (autorest --reset) and try again.')); const title: string = localize('generatingFunctions', 'Generating from OpenAPI...Check [output window](command:{0}) for status.', ext.prefix + '.showOutputChannel'); await window.withProgress({ location: ProgressLocation.Notification, title }, async () => { - await cpUtils.executeCommand(ext.outputChannel, undefined, 'autorest', ...args); + await cpUtils.executeCommand(ext.outputChannel, undefined, 'autorest', args); }); if (wizardContext.language === ProjectLanguage.TypeScript) { @@ -78,7 +94,7 @@ export class OpenAPICreateStep extends AzureWizardExecuteStep { try { - await cpUtils.executeCommand(undefined, undefined, 'autorest', '--version'); + await cpUtils.executeCommand(undefined, undefined, 'autorest', composeArgs(withArg('--version'))()); } catch (error) { const message: string = localize('autorestNotFound', 'Failed to find "autorest" | Extension needs AutoRest to generate a function app from an OpenAPI specification. Click "Learn more" for more details on installation steps.'); if (!context.errorHandling.suppressDisplay) { diff --git a/src/commands/createNewProject/ProjectCreateStep/DotnetProjectCreateStep.ts b/src/commands/createNewProject/ProjectCreateStep/DotnetProjectCreateStep.ts index 9a980367a..5520eefa1 100644 --- a/src/commands/createNewProject/ProjectCreateStep/DotnetProjectCreateStep.ts +++ b/src/commands/createNewProject/ProjectCreateStep/DotnetProjectCreateStep.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { AzExtFsExtra, DialogResponses, nonNullValueAndProp, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { composeArgs, withArg, withNamedArg } from '@microsoft/vscode-processutils'; import * as path from 'path'; import { getMajorVersion, type FuncVersion } from '../../../FuncVersion'; import { ConnectionKey, ProjectLanguage, gitignoreFileName, hostFileName, localSettingsFileName } from '../../../constants'; @@ -38,9 +39,13 @@ export class DotnetProjectCreateStep extends ProjectCreateStepBase { // currentely the version created by func init is behind the template version if (context.containerizedProject) { const runtime = context.workerRuntime?.capabilities.includes('isolated') ? 'dotnet-isolated' : 'dotnet'; - // targetFramework is only supported for dotnet-isolated projects - const targetFramework = runtime === 'dotnet' ? '' : "--target-framework " + nonNullValueAndProp(context.workerRuntime, 'targetFramework'); - await cpUtils.executeCommand(ext.outputChannel, context.projectPath, "func", "init", "--worker-runtime", runtime, targetFramework, "--docker"); + const args = composeArgs( + withArg('init'), + withNamedArg('--worker-runtime', runtime), + withNamedArg('--target-framework', runtime === 'dotnet' ? undefined : nonNullValueAndProp(context.workerRuntime, 'targetFramework')), // targetFramework is only supported for dotnet-isolated projects // TODO: validate this is doing what I think it's doing + withArg('--docker'), + )(); + await cpUtils.executeCommand(ext.outputChannel, context.projectPath, "func", args); } else { await this.confirmOverwriteExisting(context, projName); } @@ -52,11 +57,15 @@ export class DotnetProjectCreateStep extends ProjectCreateStepBase { } const functionsVersion: string = 'v' + majorVersion; const projTemplateKey = nonNullProp(context, 'projectTemplateKey'); - const args = ['--identity', identity, '--arg:name', cpUtils.wrapArgInQuotes(projectName), '--arg:AzureFunctionsVersion', functionsVersion]; - // defaults to net6.0 if there is no targetFramework - args.push('--arg:Framework', cpUtils.wrapArgInQuotes(context.workerRuntime?.targetFramework)); - await executeDotnetTemplateCommand(context, version, projTemplateKey, context.projectPath, 'create', ...args); + const args = composeArgs( + withNamedArg('--identity', identity), + withNamedArg('--arg:name', projectName, { shouldQuote: true }), + withNamedArg('--arg:AzureFunctionsVersion', functionsVersion), + withNamedArg('--arg:Framework', context.workerRuntime?.targetFramework, { shouldQuote: true }), // defaults to net6.0 if there is no targetFramework + )(); + + await executeDotnetTemplateCommand(context, version, projTemplateKey, context.projectPath, 'create', args); await setLocalAppSetting(context, context.projectPath, ConnectionKey.Storage, '', MismatchBehavior.Overwrite); } diff --git a/src/templates/dotnet/executeDotnetTemplateCommand.ts b/src/templates/dotnet/executeDotnetTemplateCommand.ts index 5ce3495d3..5a0d8da96 100644 --- a/src/templates/dotnet/executeDotnetTemplateCommand.ts +++ b/src/templates/dotnet/executeDotnetTemplateCommand.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { composeArgs, withArg, withNamedArg, withQuotedArg, type CommandLineArgs } from '@microsoft/vscode-processutils'; import * as path from 'path'; import { coerce as semVerCoerce, type SemVer } from 'semver'; import { type FuncVersion } from '../../FuncVersion'; @@ -11,20 +12,21 @@ import { ext } from "../../extensionVariables"; import { localize } from '../../localize'; import { cpUtils } from "../../utils/cpUtils"; -export async function executeDotnetTemplateCommand(context: IActionContext, version: FuncVersion, projTemplateKey: string, workingDirectory: string | undefined, operation: 'list' | 'create', ...args: string[]): Promise { +export async function executeDotnetTemplateCommand(context: IActionContext, version: FuncVersion, projTemplateKey: string, workingDirectory: string | undefined, operation: 'list' | 'create', additionalArgs?: CommandLineArgs): Promise { const jsonDllPath: string = ext.context.asAbsolutePath(path.join('resources', 'dotnetJsonCli', 'Microsoft.TemplateEngine.JsonCli.dll')); + + const args = composeArgs( + withNamedArg('--roll-forward', 'Major'), + withQuotedArg(jsonDllPath), + withNamedArg('--templateDir', getDotnetTemplateDir(context, version, projTemplateKey), { shouldQuote: true }), + withNamedArg('--operation', operation), + withArg(...(additionalArgs ?? [])), + )(); return await cpUtils.executeCommand( undefined, workingDirectory, 'dotnet', - '--roll-forward', - 'Major', - cpUtils.wrapArgInQuotes(jsonDllPath), - '--templateDir', - cpUtils.wrapArgInQuotes(getDotnetTemplateDir(context, version, projTemplateKey)), - '--operation', - operation, - ...args); + args); } export function getDotnetItemTemplatePath(context: IActionContext, version: FuncVersion, projTemplateKey: string): string { @@ -50,13 +52,13 @@ async function getFramework(context: IActionContext, workingDirectory: string | if (!cachedFramework) { let versions: string = ''; try { - versions += await cpUtils.executeCommand(undefined, workingDirectory, 'dotnet', '--version'); + versions += await cpUtils.executeCommand(undefined, workingDirectory, 'dotnet', composeArgs(withArg('--version'))()); } catch { // ignore } try { - versions += await cpUtils.executeCommand(undefined, workingDirectory, 'dotnet', '--list-sdks'); + versions += await cpUtils.executeCommand(undefined, workingDirectory, 'dotnet', composeArgs(withArg('--list-sdks'))()); } catch { // ignore } diff --git a/src/utils/cpUtils.ts b/src/utils/cpUtils.ts index 7f7f16699..4bf4a07b0 100644 --- a/src/utils/cpUtils.ts +++ b/src/utils/cpUtils.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { type IAzExtOutputChannel } from '@microsoft/vscode-azext-utils'; -import * as cp from 'child_process'; +import { AccumulatorStream, isChildProcessError, Shell, spawnStreamAsync, type CommandLineArgs, type StreamSpawnOptions } from '@microsoft/vscode-processutils'; import * as os from 'os'; +import { Stream } from 'stream'; import { localize } from '../localize'; export namespace cpUtils { - export async function executeCommand(outputChannel: IAzExtOutputChannel | undefined, workingDirectory: string | undefined, command: string, ...args: string[]): Promise { - const result: ICommandResult = await tryExecuteCommand(outputChannel, workingDirectory, command, ...args); + export async function executeCommand(outputChannel: IAzExtOutputChannel | undefined, workingDirectory: string | undefined, command: string, args: CommandLineArgs): Promise { + const result: ICommandResult = await tryExecuteCommand(outputChannel, workingDirectory, command, args); if (result.code !== 0) { // We want to make sure the full error message is displayed to the user, not just the error code. // If outputChannel is defined, then we simply call 'outputChannel.show()' and throw a generic error telling the user to check the output window @@ -19,75 +20,83 @@ export namespace cpUtils { outputChannel.show(); throw new Error(localize('commandErrorWithOutput', 'Failed to run "{0}" command. Check output window for more details.', command)); } else { - throw new Error(localize('commandError', 'Command "{0} {1}" failed with exit code "{2}":{3}{4}', command, result.formattedArgs, result.code, os.EOL, result.cmdOutputIncludingStderr)); + throw new Error(localize('commandError', 'Command "{0}" failed with exit code "{2}":{3}{4}', result.formattedCommandLine, result.code, os.EOL, result.cmdOutputIncludingStderr)); } } else { if (outputChannel) { - outputChannel.appendLog(localize('finishedRunningCommand', 'Finished running command: "{0} {1}".', command, result.formattedArgs)); + outputChannel.appendLog(localize('finishedRunningCommand', 'Finished running command: "{0}".', result.formattedCommandLine)); } } return result.cmdOutput; } - export async function tryExecuteCommand(outputChannel: IAzExtOutputChannel | undefined, workingDirectory: string | undefined, command: string, ...args: string[]): Promise { - return await new Promise((resolve: (res: ICommandResult) => void, reject: (e: Error) => void): void => { - let cmdOutput: string = ''; - let cmdOutputIncludingStderr: string = ''; - const formattedArgs: string = args.join(' '); + export async function tryExecuteCommand(outputChannel: IAzExtOutputChannel | undefined, workingDirectory: string | undefined, command: string, args: CommandLineArgs): Promise { + const stdoutFinal = new AccumulatorStream(); + const stdoutAndErrFinal = new AccumulatorStream(); - workingDirectory = workingDirectory || os.tmpdir(); - const options: cp.SpawnOptions = { - cwd: workingDirectory, - shell: true - }; - const childProc: cp.ChildProcess = cp.spawn(command, args, options); + const stdoutIntermediate = new Stream.PassThrough(); + const stderrIntermediate = new Stream.PassThrough(); + + stdoutIntermediate.on('data', (chunk: Buffer) => { + stdoutFinal.write(chunk); + stdoutAndErrFinal.write(chunk); if (outputChannel) { - outputChannel.appendLog(localize('runningCommand', 'Running command: "{0} {1}"...', command, formattedArgs)); + outputChannel.append(bufferToString(chunk)); } + }); - childProc.stdout?.on('data', (data: string | Buffer) => { - data = data.toString(); - cmdOutput = cmdOutput.concat(data); - cmdOutputIncludingStderr = cmdOutputIncludingStderr.concat(data); - if (outputChannel) { - outputChannel.append(data); - } - }); + stderrIntermediate.on('data', (chunk: Buffer) => { + stdoutAndErrFinal.write(chunk); + + if (outputChannel) { + outputChannel.append(bufferToString(chunk)); + } + }); + + const result: Partial = {}; + + const options: StreamSpawnOptions = { + cwd: workingDirectory || os.tmpdir(), + shellProvider: Shell.getShellOrDefault(), + stdOutPipe: stdoutIntermediate, + stdErrPipe: stderrIntermediate, + onCommand: (commandLine: string) => { + result.formattedCommandLine = commandLine; - childProc.stderr?.on('data', (data: string | Buffer) => { - data = data.toString(); - cmdOutputIncludingStderr = cmdOutputIncludingStderr.concat(data); if (outputChannel) { - outputChannel.append(data); + outputChannel.appendLog(localize('runningCommand', 'Running command: "{0}"...', commandLine)); } - }); + }, + } - childProc.on('error', reject); - childProc.on('close', (code: number) => { - resolve({ - code, - cmdOutput, - cmdOutputIncludingStderr, - formattedArgs - }); - }); - }); + try { + await spawnStreamAsync(command, args, options); + result.code = 0; + } catch (error) { + if (isChildProcessError(error)) { + result.code = error.code ?? 1; + } else { + throw error; + } + } finally { + result.cmdOutput = await stdoutFinal.getString(); + result.cmdOutputIncludingStderr = await stdoutAndErrFinal.getString(); + } + + return result as ICommandResult; } export interface ICommandResult { code: number; cmdOutput: string; cmdOutputIncludingStderr: string; - formattedArgs: string; + formattedCommandLine: string; } - const quotationMark: string = process.platform === 'win32' ? '"' : '\''; - /** - * Ensures spaces and special characters (most notably $) are preserved - */ - export function wrapArgInQuotes(arg?: string | boolean | number): string { - arg ??= ''; - return typeof arg === 'string' ? quotationMark + arg + quotationMark : String(arg); + function bufferToString(buffer: Buffer): string { + // Remove non-printing control characters and trailing newlines + // eslint-disable-next-line no-control-regex + return buffer.toString().replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F]|\r?\n$/g, ''); } }