From 8c40589e12d1cb61349e48258bb507ef3f294891 Mon Sep 17 00:00:00 2001 From: Jesus Orosco Date: Wed, 25 Mar 2026 09:17:17 -0700 Subject: [PATCH 1/3] fix: use spawn for all the things --- src/utils/datacodeBinaryChecker.ts | 24 +++------ src/utils/datacodeBinaryExecutor.ts | 75 ++++++++++++----------------- src/utils/pipChecker.ts | 51 +++++++++----------- src/utils/pythonChecker.ts | 7 +-- src/utils/spawnHelper.ts | 75 +++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 94 deletions(-) create mode 100644 src/utils/spawnHelper.ts diff --git a/src/utils/datacodeBinaryChecker.ts b/src/utils/datacodeBinaryChecker.ts index 29e1ad2..6deac42 100644 --- a/src/utils/datacodeBinaryChecker.ts +++ b/src/utils/datacodeBinaryChecker.ts @@ -13,12 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; import { SfError } from '@salesforce/core'; import { Messages } from '@salesforce/core'; - -const execAsync = promisify(exec); +import { spawnAsync } from './spawnHelper.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-data-code-extension', 'datacodeBinaryChecker'); @@ -74,9 +71,8 @@ export class DatacodeBinaryChecker { */ private static async isCommandAvailable(command: string): Promise { try { - // Use 'which' on Unix-like systems, 'where' on Windows const checkCommand = process.platform === 'win32' ? 'where' : 'which'; - await execAsync(`${checkCommand} ${command}`); + await spawnAsync(checkCommand, [command]); return true; } catch { return false; @@ -91,25 +87,19 @@ export class DatacodeBinaryChecker { */ private static async getBinaryVersion(command: string): Promise { try { - const { stdout } = await execAsync(`${command} version`); + const { stdout } = await spawnAsync(command, ['version']); - // Parse the version output - // Expected format might be something like "datacustomcode version 1.2.3" or just "1.2.3" - // We'll handle multiple possible formats const versionMatch = stdout.match(/(\d+\.\d+(?:\.\d+)?(?:[-\w.]*)?)/); if (versionMatch) { const version = versionMatch[1]; - // Try to get the binary path (optional) let path: string | undefined; try { - // On Unix-like systems use 'which', on Windows use 'where' const pathCommand = process.platform === 'win32' ? 'where' : 'which'; - const { stdout: pathOutput } = await execAsync(`${pathCommand} ${command}`); - path = pathOutput.trim().split('\n')[0]; // Get first path if multiple + const { stdout: pathOutput } = await spawnAsync(pathCommand, [command]); + path = pathOutput.trim().split('\n')[0]; } catch { - // Path lookup is optional, don't fail if it doesn't work path = undefined; } @@ -120,14 +110,12 @@ export class DatacodeBinaryChecker { }; } - // If we can't parse the version but the command executed, still return basic info return { command, version: 'unknown', path: undefined, }; - } catch (error) { - // Command not found or failed to execute + } catch { return null; } } diff --git a/src/utils/datacodeBinaryExecutor.ts b/src/utils/datacodeBinaryExecutor.ts index 28215e0..b89b6ea 100644 --- a/src/utils/datacodeBinaryExecutor.ts +++ b/src/utils/datacodeBinaryExecutor.ts @@ -13,15 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { exec, spawn, type ExecException } from 'node:child_process'; -import { promisify } from 'node:util'; +import { spawn } from 'node:child_process'; import { SfError } from '@salesforce/core'; import { Messages } from '@salesforce/core'; import { type PythonVersionInfo } from './pythonChecker.js'; import { type PipPackageInfo } from './pipChecker.js'; import { type DatacodeBinaryInfo } from './datacodeBinaryChecker.js'; - -const execAsync = promisify(exec); +import { spawnAsync, type SpawnError } from './spawnHelper.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-data-code-extension', 'datacodeBinaryExecutor'); @@ -89,11 +87,9 @@ export class DatacodeBinaryExecutor { codeType: 'script' | 'function', packageDir: string ): Promise { - const command = `datacustomcode init --code-type ${codeType} ${packageDir}`; - try { - const { stdout, stderr } = await execAsync(command, { - timeout: 30_000, // 30 second timeout + const { stdout, stderr } = await spawnAsync('datacustomcode', ['init', '--code-type', codeType, packageDir], { + timeout: 30_000, }); // Parse created files from output if available @@ -111,8 +107,8 @@ export class DatacodeBinaryExecutor { projectPath: packageDir, }; } catch (error) { - const execError = error as ExecException & { stderr?: string }; - const binaryOutput = execError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error)); + const spawnError = error as SpawnError; + const binaryOutput = spawnError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error)); throw new SfError( messages.getMessage('error.initExecutionFailed', [packageDir, binaryOutput]), 'InitExecutionFailed', @@ -138,30 +134,26 @@ export class DatacodeBinaryExecutor { noRequirements: boolean = false, configFile?: string ): Promise { - // Build the command with optional flags - let command = 'datacustomcode scan'; + const args = ['scan']; - // Add boolean flags FIRST (before positional argument) if (dryRun) { - command += ' --dry-run'; + args.push('--dry-run'); } if (noRequirements) { - command += ' --no-requirements'; + args.push('--no-requirements'); } if (configFile) { - command += ` --config "${configFile}"`; + args.push('--config', configFile); } - // Add entrypoint as positional argument LAST (with proper quoting for paths with spaces) - const configPath = config ?? 'payload/config.json'; - command += ` "${configPath}"`; + args.push(config ?? 'payload/config.json'); try { - const { stdout, stderr } = await execAsync(command, { + const { stdout, stderr } = await spawnAsync('datacustomcode', args, { cwd: workingDir, - timeout: 60_000, // 60 second timeout (longer than init's 30 seconds) + timeout: 60_000, }); // Parse scan results from output @@ -197,8 +189,8 @@ export class DatacodeBinaryExecutor { filesScanned: filesScanned.length > 0 ? filesScanned : undefined, }; } catch (error) { - const execError = error as ExecException & { stderr?: string }; - const binaryOutput = execError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error)); + const spawnError = error as SpawnError; + const binaryOutput = spawnError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error)); throw new SfError( messages.getMessage('error.scanExecutionFailed', [workingDir, binaryOutput]), 'ScanExecutionFailed', @@ -216,20 +208,17 @@ export class DatacodeBinaryExecutor { * @throws SfError if execution fails */ public static async executeBinaryZip(packageDir: string, network?: string): Promise { - // Build the command with optional network flag - let command = 'datacustomcode zip'; + const args = ['zip']; - // Add network flag if provided (before positional argument) if (network) { - command += ` --network "${network}"`; + args.push('--network', network); } - // Add package directory as positional argument (with proper quoting for paths with spaces) - command += ` "${packageDir}"`; + args.push(packageDir); try { - const { stdout, stderr } = await execAsync(command, { - timeout: 120_000, // 120 second timeout (zipping can take time for large packages) + const { stdout, stderr } = await spawnAsync('datacustomcode', args, { + timeout: 120_000, }); // Parse archive path from output @@ -264,8 +253,8 @@ export class DatacodeBinaryExecutor { archiveSize, }; } catch (error) { - const execError = error as ExecException & { stderr?: string }; - const binaryOutput = execError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error)); + const spawnError = error as SpawnError; + const binaryOutput = spawnError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error)); throw new SfError( messages.getMessage('error.zipExecutionFailed', [packageDir, binaryOutput]), 'ZipExecutionFailed', @@ -432,23 +421,21 @@ export class DatacodeBinaryExecutor { configFile?: string, dependencies?: string ): Promise { - // Build the command — flags before the positional argument - let command = 'datacustomcode run'; - command += ` --sf-cli-org "${targetOrg}"`; + const args = ['run', '--sf-cli-org', targetOrg]; if (configFile) { - command += ` --config-file "${configFile}"`; + args.push('--config-file', configFile); } if (dependencies) { - command += ` --dependencies "${dependencies}"`; + args.push('--dependencies', dependencies); } - command += ` "${packageDir}"`; + args.push(packageDir); try { - const { stdout, stderr } = await execAsync(command, { - timeout: 300_000, // 5 minute timeout + const { stdout, stderr } = await spawnAsync('datacustomcode', args, { + timeout: 300_000, }); // Parse status from output @@ -474,8 +461,8 @@ export class DatacodeBinaryExecutor { output, }; } catch (error) { - const execError = error as ExecException & { stderr?: string }; - const errorMessage = execError.message ?? String(error); + const spawnError = error as SpawnError; + const errorMessage = spawnError.message ?? String(error); if (errorMessage.includes('Authentication failed') || errorMessage.includes('Invalid credentials')) { throw new SfError( @@ -488,7 +475,7 @@ export class DatacodeBinaryExecutor { // Surface the binary's stderr directly so any runtime error is shown as-is. // File-existence checks for entrypoint and config-file are already handled by // the CLI flag layer (exists: true), so those patterns are not matched here. - const binaryOutput = execError.stderr?.trim() ?? errorMessage; + const binaryOutput = spawnError.stderr?.trim() ?? errorMessage; throw new SfError( messages.getMessage('error.runExecutionFailed', [binaryOutput]), 'RunExecutionFailed', diff --git a/src/utils/pipChecker.ts b/src/utils/pipChecker.ts index 3db683d..29d82b6 100644 --- a/src/utils/pipChecker.ts +++ b/src/utils/pipChecker.ts @@ -13,12 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; import { SfError } from '@salesforce/core'; import { Messages } from '@salesforce/core'; - -const execAsync = promisify(exec); +import { spawnAsync } from './spawnHelper.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-data-code-extension', 'pipChecker'); @@ -30,7 +27,16 @@ export type PipPackageInfo = { pipCommand: string; }; +type PipCommand = { cmd: string; args: string[] }; + export class PipChecker { + private static readonly PIP_COMMANDS: PipCommand[] = [ + { cmd: 'pip3', args: [] }, + { cmd: 'pip', args: [] }, + { cmd: 'python3', args: ['-m', 'pip'] }, + { cmd: 'python', args: ['-m', 'pip'] }, + ]; + /** * Checks if a specific pip package is installed on the system. * @@ -39,28 +45,22 @@ export class PipChecker { * @throws SfError if pip is not found or package is not installed */ public static async checkPackage(packageName: string): Promise { - // Try different pip commands in order of preference - const pipCommands = ['pip3', 'pip', 'python3 -m pip', 'python -m pip']; - - for (const command of pipCommands) { + for (const pipCommand of this.PIP_COMMANDS) { try { // eslint-disable-next-line no-await-in-loop - const packageInfo = await this.getPackageInfo(command, packageName); + const packageInfo = await this.getPackageInfo(pipCommand, packageName); if (packageInfo) { return packageInfo; } - } catch (error) { - // Continue to try the next command + } catch { continue; } } - // Check if pip is available at all - const pipAvailable = await this.isPipAvailable(pipCommands); + const pipAvailable = await this.isPipAvailable(this.PIP_COMMANDS); if (!pipAvailable) { - // Pip not found with any command throw new SfError( messages.getMessage('error.pipNotFound'), 'PipNotFound', @@ -68,7 +68,6 @@ export class PipChecker { ); } - // Pip is available but package is not installed throw new SfError( messages.getMessage('error.packageNotInstalled', [packageName]), 'PackageNotInstalled', @@ -79,15 +78,14 @@ export class PipChecker { /** * Gets the package information for a specific pip command and package name. * - * @param pipCommand The pip command to use + * @param pipCommand The pip command descriptor to use * @param packageName The name of the package to check * @returns PipPackageInfo if package is found, null otherwise */ - private static async getPackageInfo(pipCommand: string, packageName: string): Promise { + private static async getPackageInfo(pipCommand: PipCommand, packageName: string): Promise { try { - const { stdout } = await execAsync(`${pipCommand} show ${packageName}`); + const { stdout } = await spawnAsync(pipCommand.cmd, [...pipCommand.args, 'show', packageName]); - // Parse the output to extract package information const nameMatch = stdout.match(/Name:\s+(.+)/); const versionMatch = stdout.match(/Version:\s+(.+)/); const locationMatch = stdout.match(/Location:\s+(.+)/); @@ -97,13 +95,12 @@ export class PipChecker { name: nameMatch[1].trim(), version: versionMatch[1].trim(), location: locationMatch[1].trim(), - pipCommand: pipCommand.split(' ')[0], // Extract the base command (pip3, pip, python3, python) + pipCommand: pipCommand.cmd, }; } return null; - } catch (error) { - // Package not found or pip command failed + } catch { return null; } } @@ -111,16 +108,16 @@ export class PipChecker { /** * Checks if pip is available with any of the given commands. * - * @param pipCommands List of pip commands to try + * @param pipCommands List of pip command descriptors to try * @returns true if pip is available, false otherwise */ - private static async isPipAvailable(pipCommands: string[]): Promise { - for (const command of pipCommands) { + private static async isPipAvailable(pipCommands: PipCommand[]): Promise { + for (const { cmd, args } of pipCommands) { try { // eslint-disable-next-line no-await-in-loop - await execAsync(`${command} --version`); + await spawnAsync(cmd, [...args, '--version']); return true; - } catch (error) { + } catch { continue; } } diff --git a/src/utils/pythonChecker.ts b/src/utils/pythonChecker.ts index b0aeb32..2b67e71 100644 --- a/src/utils/pythonChecker.ts +++ b/src/utils/pythonChecker.ts @@ -13,12 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; import { SfError } from '@salesforce/core'; import { Messages } from '@salesforce/core'; - -const execAsync = promisify(exec); +import { spawnAsync } from './spawnHelper.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-data-code-extension', 'pythonChecker'); @@ -89,7 +86,7 @@ export class PythonChecker { */ private static async getPythonVersion(command: string): Promise { try { - const { stdout } = await execAsync(`${command} --version`); + const { stdout } = await spawnAsync(command, ['--version']); const versionMatch = stdout.match(/Python (\d+)\.(\d+)\.(\d+)/); if (!versionMatch) { diff --git a/src/utils/spawnHelper.ts b/src/utils/spawnHelper.ts new file mode 100644 index 0000000..82e07b3 --- /dev/null +++ b/src/utils/spawnHelper.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { spawn } from 'node:child_process'; + +export type SpawnResult = { stdout: string; stderr: string }; + +export type SpawnError = Error & SpawnResult & { exitCode: number | null }; + +export type SpawnOptions = { + timeout?: number; + cwd?: string; + env?: NodeJS.ProcessEnv; +}; + +/** + * Spawns a child process without a shell, accumulates stdout/stderr, and resolves + * when the process exits with code 0. Rejects with a SpawnError (carrying stdout, + * stderr, and exitCode) on non-zero exit or spawn failure. + * + * Using an args array instead of a shell string eliminates shell injection risk — + * the OS passes each element directly to the process's argv without interpretation. + */ +export async function spawnAsync(command: string, args: string[], options?: SpawnOptions): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + timeout: options?.timeout, + cwd: options?.cwd, + env: options?.env ?? process.env, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on('close', (code) => { + if (code !== 0) { + const err = new Error(`Process exited with code ${code ?? 'unknown'}`) as SpawnError; + err.stdout = stdout; + err.stderr = stderr; + err.exitCode = code; + reject(err); + return; + } + resolve({ stdout, stderr }); + }); + + child.on('error', (err) => { + const spawnErr = err as SpawnError; + spawnErr.stdout = stdout; + spawnErr.stderr = stderr; + spawnErr.exitCode = null; + reject(spawnErr); + }); + }); +} From 5179d1e554527637d4c2b8efbbc8b4fd385daef5 Mon Sep 17 00:00:00 2001 From: Jesus Orosco Date: Wed, 25 Mar 2026 11:20:33 -0700 Subject: [PATCH 2/3] fix: missing config.json --- .git2gus/config.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .git2gus/config.json diff --git a/.git2gus/config.json b/.git2gus/config.json new file mode 100644 index 0000000..4a52dbb --- /dev/null +++ b/.git2gus/config.json @@ -0,0 +1,9 @@ +{ + "productTag": "a1aB00000004Bx8IAE", + "defaultBuild": "offcore.tooling.56", + "issueTypeLabels": { + "feature": "USER STORY", + "regression": "BUG P1", + "bug": "BUG P3" + } +} From 688a63b13b3edac97261a7fba42e99b876062a6a Mon Sep 17 00:00:00 2001 From: Jesus Orosco Date: Wed, 25 Mar 2026 11:41:15 -0700 Subject: [PATCH 3/3] fix: use byoc product tag --- .git2gus/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.git2gus/config.json b/.git2gus/config.json index 4a52dbb..f10024e 100644 --- a/.git2gus/config.json +++ b/.git2gus/config.json @@ -1,5 +1,5 @@ { - "productTag": "a1aB00000004Bx8IAE", + "productTag": "a1aEE000000pCaDYAU", "defaultBuild": "offcore.tooling.56", "issueTypeLabels": { "feature": "USER STORY",