Skip to content

Commit 8c40589

Browse files
committed
fix: use spawn for all the things
1 parent abe2c73 commit 8c40589

File tree

5 files changed

+138
-94
lines changed

5 files changed

+138
-94
lines changed

src/utils/datacodeBinaryChecker.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,9 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
import { exec } from 'node:child_process';
17-
import { promisify } from 'node:util';
1816
import { SfError } from '@salesforce/core';
1917
import { Messages } from '@salesforce/core';
20-
21-
const execAsync = promisify(exec);
18+
import { spawnAsync } from './spawnHelper.js';
2219

2320
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
2421
const messages = Messages.loadMessages('@salesforce/plugin-data-code-extension', 'datacodeBinaryChecker');
@@ -74,9 +71,8 @@ export class DatacodeBinaryChecker {
7471
*/
7572
private static async isCommandAvailable(command: string): Promise<boolean> {
7673
try {
77-
// Use 'which' on Unix-like systems, 'where' on Windows
7874
const checkCommand = process.platform === 'win32' ? 'where' : 'which';
79-
await execAsync(`${checkCommand} ${command}`);
75+
await spawnAsync(checkCommand, [command]);
8076
return true;
8177
} catch {
8278
return false;
@@ -91,25 +87,19 @@ export class DatacodeBinaryChecker {
9187
*/
9288
private static async getBinaryVersion(command: string): Promise<DatacodeBinaryInfo | null> {
9389
try {
94-
const { stdout } = await execAsync(`${command} version`);
90+
const { stdout } = await spawnAsync(command, ['version']);
9591

96-
// Parse the version output
97-
// Expected format might be something like "datacustomcode version 1.2.3" or just "1.2.3"
98-
// We'll handle multiple possible formats
9992
const versionMatch = stdout.match(/(\d+\.\d+(?:\.\d+)?(?:[-\w.]*)?)/);
10093

10194
if (versionMatch) {
10295
const version = versionMatch[1];
10396

104-
// Try to get the binary path (optional)
10597
let path: string | undefined;
10698
try {
107-
// On Unix-like systems use 'which', on Windows use 'where'
10899
const pathCommand = process.platform === 'win32' ? 'where' : 'which';
109-
const { stdout: pathOutput } = await execAsync(`${pathCommand} ${command}`);
110-
path = pathOutput.trim().split('\n')[0]; // Get first path if multiple
100+
const { stdout: pathOutput } = await spawnAsync(pathCommand, [command]);
101+
path = pathOutput.trim().split('\n')[0];
111102
} catch {
112-
// Path lookup is optional, don't fail if it doesn't work
113103
path = undefined;
114104
}
115105

@@ -120,14 +110,12 @@ export class DatacodeBinaryChecker {
120110
};
121111
}
122112

123-
// If we can't parse the version but the command executed, still return basic info
124113
return {
125114
command,
126115
version: 'unknown',
127116
path: undefined,
128117
};
129-
} catch (error) {
130-
// Command not found or failed to execute
118+
} catch {
131119
return null;
132120
}
133121
}

src/utils/datacodeBinaryExecutor.ts

Lines changed: 31 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
import { exec, spawn, type ExecException } from 'node:child_process';
17-
import { promisify } from 'node:util';
16+
import { spawn } from 'node:child_process';
1817
import { SfError } from '@salesforce/core';
1918
import { Messages } from '@salesforce/core';
2019
import { type PythonVersionInfo } from './pythonChecker.js';
2120
import { type PipPackageInfo } from './pipChecker.js';
2221
import { type DatacodeBinaryInfo } from './datacodeBinaryChecker.js';
23-
24-
const execAsync = promisify(exec);
22+
import { spawnAsync, type SpawnError } from './spawnHelper.js';
2523

2624
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
2725
const messages = Messages.loadMessages('@salesforce/plugin-data-code-extension', 'datacodeBinaryExecutor');
@@ -89,11 +87,9 @@ export class DatacodeBinaryExecutor {
8987
codeType: 'script' | 'function',
9088
packageDir: string
9189
): Promise<DatacodeInitExecutionResult> {
92-
const command = `datacustomcode init --code-type ${codeType} ${packageDir}`;
93-
9490
try {
95-
const { stdout, stderr } = await execAsync(command, {
96-
timeout: 30_000, // 30 second timeout
91+
const { stdout, stderr } = await spawnAsync('datacustomcode', ['init', '--code-type', codeType, packageDir], {
92+
timeout: 30_000,
9793
});
9894

9995
// Parse created files from output if available
@@ -111,8 +107,8 @@ export class DatacodeBinaryExecutor {
111107
projectPath: packageDir,
112108
};
113109
} catch (error) {
114-
const execError = error as ExecException & { stderr?: string };
115-
const binaryOutput = execError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error));
110+
const spawnError = error as SpawnError;
111+
const binaryOutput = spawnError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error));
116112
throw new SfError(
117113
messages.getMessage('error.initExecutionFailed', [packageDir, binaryOutput]),
118114
'InitExecutionFailed',
@@ -138,30 +134,26 @@ export class DatacodeBinaryExecutor {
138134
noRequirements: boolean = false,
139135
configFile?: string
140136
): Promise<DatacodeScanExecutionResult> {
141-
// Build the command with optional flags
142-
let command = 'datacustomcode scan';
137+
const args = ['scan'];
143138

144-
// Add boolean flags FIRST (before positional argument)
145139
if (dryRun) {
146-
command += ' --dry-run';
140+
args.push('--dry-run');
147141
}
148142

149143
if (noRequirements) {
150-
command += ' --no-requirements';
144+
args.push('--no-requirements');
151145
}
152146

153147
if (configFile) {
154-
command += ` --config "${configFile}"`;
148+
args.push('--config', configFile);
155149
}
156150

157-
// Add entrypoint as positional argument LAST (with proper quoting for paths with spaces)
158-
const configPath = config ?? 'payload/config.json';
159-
command += ` "${configPath}"`;
151+
args.push(config ?? 'payload/config.json');
160152

161153
try {
162-
const { stdout, stderr } = await execAsync(command, {
154+
const { stdout, stderr } = await spawnAsync('datacustomcode', args, {
163155
cwd: workingDir,
164-
timeout: 60_000, // 60 second timeout (longer than init's 30 seconds)
156+
timeout: 60_000,
165157
});
166158

167159
// Parse scan results from output
@@ -197,8 +189,8 @@ export class DatacodeBinaryExecutor {
197189
filesScanned: filesScanned.length > 0 ? filesScanned : undefined,
198190
};
199191
} catch (error) {
200-
const execError = error as ExecException & { stderr?: string };
201-
const binaryOutput = execError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error));
192+
const spawnError = error as SpawnError;
193+
const binaryOutput = spawnError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error));
202194
throw new SfError(
203195
messages.getMessage('error.scanExecutionFailed', [workingDir, binaryOutput]),
204196
'ScanExecutionFailed',
@@ -216,20 +208,17 @@ export class DatacodeBinaryExecutor {
216208
* @throws SfError if execution fails
217209
*/
218210
public static async executeBinaryZip(packageDir: string, network?: string): Promise<DatacodeZipExecutionResult> {
219-
// Build the command with optional network flag
220-
let command = 'datacustomcode zip';
211+
const args = ['zip'];
221212

222-
// Add network flag if provided (before positional argument)
223213
if (network) {
224-
command += ` --network "${network}"`;
214+
args.push('--network', network);
225215
}
226216

227-
// Add package directory as positional argument (with proper quoting for paths with spaces)
228-
command += ` "${packageDir}"`;
217+
args.push(packageDir);
229218

230219
try {
231-
const { stdout, stderr } = await execAsync(command, {
232-
timeout: 120_000, // 120 second timeout (zipping can take time for large packages)
220+
const { stdout, stderr } = await spawnAsync('datacustomcode', args, {
221+
timeout: 120_000,
233222
});
234223

235224
// Parse archive path from output
@@ -264,8 +253,8 @@ export class DatacodeBinaryExecutor {
264253
archiveSize,
265254
};
266255
} catch (error) {
267-
const execError = error as ExecException & { stderr?: string };
268-
const binaryOutput = execError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error));
256+
const spawnError = error as SpawnError;
257+
const binaryOutput = spawnError.stderr?.trim() ?? (error instanceof Error ? error.message : String(error));
269258
throw new SfError(
270259
messages.getMessage('error.zipExecutionFailed', [packageDir, binaryOutput]),
271260
'ZipExecutionFailed',
@@ -432,23 +421,21 @@ export class DatacodeBinaryExecutor {
432421
configFile?: string,
433422
dependencies?: string
434423
): Promise<DatacodeRunExecutionResult> {
435-
// Build the command — flags before the positional argument
436-
let command = 'datacustomcode run';
437-
command += ` --sf-cli-org "${targetOrg}"`;
424+
const args = ['run', '--sf-cli-org', targetOrg];
438425

439426
if (configFile) {
440-
command += ` --config-file "${configFile}"`;
427+
args.push('--config-file', configFile);
441428
}
442429

443430
if (dependencies) {
444-
command += ` --dependencies "${dependencies}"`;
431+
args.push('--dependencies', dependencies);
445432
}
446433

447-
command += ` "${packageDir}"`;
434+
args.push(packageDir);
448435

449436
try {
450-
const { stdout, stderr } = await execAsync(command, {
451-
timeout: 300_000, // 5 minute timeout
437+
const { stdout, stderr } = await spawnAsync('datacustomcode', args, {
438+
timeout: 300_000,
452439
});
453440

454441
// Parse status from output
@@ -474,8 +461,8 @@ export class DatacodeBinaryExecutor {
474461
output,
475462
};
476463
} catch (error) {
477-
const execError = error as ExecException & { stderr?: string };
478-
const errorMessage = execError.message ?? String(error);
464+
const spawnError = error as SpawnError;
465+
const errorMessage = spawnError.message ?? String(error);
479466

480467
if (errorMessage.includes('Authentication failed') || errorMessage.includes('Invalid credentials')) {
481468
throw new SfError(
@@ -488,7 +475,7 @@ export class DatacodeBinaryExecutor {
488475
// Surface the binary's stderr directly so any runtime error is shown as-is.
489476
// File-existence checks for entrypoint and config-file are already handled by
490477
// the CLI flag layer (exists: true), so those patterns are not matched here.
491-
const binaryOutput = execError.stderr?.trim() ?? errorMessage;
478+
const binaryOutput = spawnError.stderr?.trim() ?? errorMessage;
492479
throw new SfError(
493480
messages.getMessage('error.runExecutionFailed', [binaryOutput]),
494481
'RunExecutionFailed',

src/utils/pipChecker.ts

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,9 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
import { exec } from 'node:child_process';
17-
import { promisify } from 'node:util';
1816
import { SfError } from '@salesforce/core';
1917
import { Messages } from '@salesforce/core';
20-
21-
const execAsync = promisify(exec);
18+
import { spawnAsync } from './spawnHelper.js';
2219

2320
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
2421
const messages = Messages.loadMessages('@salesforce/plugin-data-code-extension', 'pipChecker');
@@ -30,7 +27,16 @@ export type PipPackageInfo = {
3027
pipCommand: string;
3128
};
3229

30+
type PipCommand = { cmd: string; args: string[] };
31+
3332
export class PipChecker {
33+
private static readonly PIP_COMMANDS: PipCommand[] = [
34+
{ cmd: 'pip3', args: [] },
35+
{ cmd: 'pip', args: [] },
36+
{ cmd: 'python3', args: ['-m', 'pip'] },
37+
{ cmd: 'python', args: ['-m', 'pip'] },
38+
];
39+
3440
/**
3541
* Checks if a specific pip package is installed on the system.
3642
*
@@ -39,36 +45,29 @@ export class PipChecker {
3945
* @throws SfError if pip is not found or package is not installed
4046
*/
4147
public static async checkPackage(packageName: string): Promise<PipPackageInfo> {
42-
// Try different pip commands in order of preference
43-
const pipCommands = ['pip3', 'pip', 'python3 -m pip', 'python -m pip'];
44-
45-
for (const command of pipCommands) {
48+
for (const pipCommand of this.PIP_COMMANDS) {
4649
try {
4750
// eslint-disable-next-line no-await-in-loop
48-
const packageInfo = await this.getPackageInfo(command, packageName);
51+
const packageInfo = await this.getPackageInfo(pipCommand, packageName);
4952

5053
if (packageInfo) {
5154
return packageInfo;
5255
}
53-
} catch (error) {
54-
// Continue to try the next command
56+
} catch {
5557
continue;
5658
}
5759
}
5860

59-
// Check if pip is available at all
60-
const pipAvailable = await this.isPipAvailable(pipCommands);
61+
const pipAvailable = await this.isPipAvailable(this.PIP_COMMANDS);
6162

6263
if (!pipAvailable) {
63-
// Pip not found with any command
6464
throw new SfError(
6565
messages.getMessage('error.pipNotFound'),
6666
'PipNotFound',
6767
messages.getMessages('actions.pipNotFound')
6868
);
6969
}
7070

71-
// Pip is available but package is not installed
7271
throw new SfError(
7372
messages.getMessage('error.packageNotInstalled', [packageName]),
7473
'PackageNotInstalled',
@@ -79,15 +78,14 @@ export class PipChecker {
7978
/**
8079
* Gets the package information for a specific pip command and package name.
8180
*
82-
* @param pipCommand The pip command to use
81+
* @param pipCommand The pip command descriptor to use
8382
* @param packageName The name of the package to check
8483
* @returns PipPackageInfo if package is found, null otherwise
8584
*/
86-
private static async getPackageInfo(pipCommand: string, packageName: string): Promise<PipPackageInfo | null> {
85+
private static async getPackageInfo(pipCommand: PipCommand, packageName: string): Promise<PipPackageInfo | null> {
8786
try {
88-
const { stdout } = await execAsync(`${pipCommand} show ${packageName}`);
87+
const { stdout } = await spawnAsync(pipCommand.cmd, [...pipCommand.args, 'show', packageName]);
8988

90-
// Parse the output to extract package information
9189
const nameMatch = stdout.match(/Name:\s+(.+)/);
9290
const versionMatch = stdout.match(/Version:\s+(.+)/);
9391
const locationMatch = stdout.match(/Location:\s+(.+)/);
@@ -97,30 +95,29 @@ export class PipChecker {
9795
name: nameMatch[1].trim(),
9896
version: versionMatch[1].trim(),
9997
location: locationMatch[1].trim(),
100-
pipCommand: pipCommand.split(' ')[0], // Extract the base command (pip3, pip, python3, python)
98+
pipCommand: pipCommand.cmd,
10199
};
102100
}
103101

104102
return null;
105-
} catch (error) {
106-
// Package not found or pip command failed
103+
} catch {
107104
return null;
108105
}
109106
}
110107

111108
/**
112109
* Checks if pip is available with any of the given commands.
113110
*
114-
* @param pipCommands List of pip commands to try
111+
* @param pipCommands List of pip command descriptors to try
115112
* @returns true if pip is available, false otherwise
116113
*/
117-
private static async isPipAvailable(pipCommands: string[]): Promise<boolean> {
118-
for (const command of pipCommands) {
114+
private static async isPipAvailable(pipCommands: PipCommand[]): Promise<boolean> {
115+
for (const { cmd, args } of pipCommands) {
119116
try {
120117
// eslint-disable-next-line no-await-in-loop
121-
await execAsync(`${command} --version`);
118+
await spawnAsync(cmd, [...args, '--version']);
122119
return true;
123-
} catch (error) {
120+
} catch {
124121
continue;
125122
}
126123
}

0 commit comments

Comments
 (0)