-
Notifications
You must be signed in to change notification settings - Fork 16
refactor(utils): command helper #1113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cbe02f2
b556bd8
4bba9cd
47bc389
695acbc
5d4fd60
052e18c
e6ac40d
311679f
8f3b7fa
e9e4a3a
a1d0f9d
f1dd383
93d1d40
41c900b
60183db
ea73523
454960b
b368ac0
d5e05a4
2041093
bebae7c
f947823
752a97e
4e90518
a180d5c
41f9af2
9dff7c4
0f3ba3a
cefe54d
d9b453f
7b307d0
a2892ba
ff74241
fc990a6
b1875d3
371e70b
ab3f5ed
aa0950a
1d006f2
fe6d69b
57c13c6
39c112a
bdf038a
be19204
1f2712a
f7d8730
ff432bf
36faf27
1d05e8b
f3c5dd5
6bec56f
40d5fb3
0973f1c
51cd8c7
476adfa
0fea136
15da0ab
f1996ad
3880c00
04e96bc
bbc10e7
d86726d
36d8c2a
afe8f89
d3781cf
9a5eb15
4268ecc
aa6363d
37f2028
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| /* COPY OF /Users/michael_hladky/WebstormProjects/cli/packages/utils/src/lib/execute-process.ts */ | ||
| import ansis from 'ansis'; | ||
| import path from 'node:path'; | ||
|
|
||
| type ArgumentValue = number | string | boolean | string[] | undefined; | ||
| export type CliArgsObject<T extends object = Record<string, ArgumentValue>> = | ||
| T extends never | ||
| ? Record<string, ArgumentValue | undefined> | { _: string } | ||
| : T; | ||
|
|
||
| /** | ||
| * Escapes command line arguments that contain spaces, quotes, or other special characters. | ||
| * | ||
| * @param {string[]} args - Array of command arguments to escape. | ||
| * @returns {string[]} - Array of escaped arguments suitable for shell execution. | ||
| */ | ||
| export function escapeCliArgs(args: string[]): string[] { | ||
| return args.map(arg => { | ||
| if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { | ||
| return `"${arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; | ||
| } | ||
| return arg; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Formats environment variable values for display by stripping quotes and then escaping. | ||
| * | ||
| * @param {string} value - Environment variable value to format. | ||
| * @returns {string} - Formatted and escaped value suitable for display. | ||
| */ | ||
| export function formatEnvValue(value: string): string { | ||
| // Strip quotes from the value for display | ||
| const cleanValue = value.replace(/"/g, ''); | ||
| return escapeCliArgs([cleanValue])[0] ?? cleanValue; | ||
| } | ||
|
|
||
| /** | ||
| * Builds a command string by escaping arguments that contain spaces, quotes, or other special characters. | ||
| * | ||
| * @param {string} command - The base command to execute. | ||
| * @param {string[]} args - Array of command arguments. | ||
| * @returns {string} - The complete command string with properly escaped arguments. | ||
| */ | ||
| export function buildCommandString( | ||
| command: string, | ||
| args: string[] = [], | ||
| ): string { | ||
| if (args.length === 0) { | ||
| return command; | ||
| } | ||
|
|
||
| return `${command} ${escapeCliArgs(args).join(' ')}`; | ||
| } | ||
|
|
||
| /** | ||
| * Options for formatting a command log. | ||
| */ | ||
| export interface FormatCommandLogOptions { | ||
| command: string; | ||
| args?: string[]; | ||
| cwd?: string; | ||
| env?: Record<string, string>; | ||
| } | ||
|
|
||
| /** | ||
| * Formats a command string with optional cwd prefix, environment variables, and ANSI colors. | ||
| * | ||
| * @param {FormatCommandLogOptions} options - Command formatting options. | ||
| * @returns {string} - ANSI-colored formatted command string. | ||
| * | ||
| * @example | ||
| * | ||
| * formatCommandLog({cwd: 'tools/api', env: {API_KEY='•••' NODE_ENV='prod'}, command: 'node', args: ['cli.js', '--do', 'thing', 'fast']}) | ||
| * ┌─────────────────────────────────────────────────────────────────────────┐ | ||
| * │ tools/api $ API_KEY="•••" NODE_ENV="prod" node cli.js --do thing fast │ | ||
| * │ │ │ │ │ │ │ | ||
| * │ └ cwd │ │ │ └ args. │ | ||
| * │ │ │ └ command │ | ||
| * │ │ └ env variables │ | ||
| * │ └ prompt symbol ($) │ | ||
| * └─────────────────────────────────────────────────────────────────────────┘ | ||
| */ | ||
| export function formatCommandLog(options: FormatCommandLogOptions): string { | ||
| const { command, args = [], cwd = process.cwd(), env } = options; | ||
| const relativeDir = path.relative(process.cwd(), cwd); | ||
|
|
||
| return [ | ||
| ...(relativeDir && relativeDir !== '.' | ||
| ? [ansis.italic(ansis.gray(relativeDir))] | ||
| : []), | ||
| ansis.yellow('$'), | ||
| ...(env && Object.keys(env).length > 0 | ||
| ? Object.entries(env).map(([key, value]) => { | ||
| return ansis.gray(`${key}=${formatEnvValue(value)}`); | ||
| }) | ||
| : []), | ||
| ansis.gray(command), | ||
| ansis.gray(args.join(' ')), | ||
| ].join(' '); | ||
| } | ||
|
|
||
| /** | ||
| * Converts an object with different types of values into an array of command-line arguments. | ||
| * | ||
| * @example | ||
| * const args = objectToCliArgs({ | ||
| * _: ['node', 'index.js'], // node index.js | ||
| * name: 'Juanita', // --name=Juanita | ||
| * formats: ['json', 'md'] // --format=json --format=md | ||
| * }); | ||
| */ | ||
| export function objectToCliArgs< | ||
| T extends object = Record<string, ArgumentValue>, | ||
| >(params?: CliArgsObject<T>): string[] { | ||
| if (!params) { | ||
| return []; | ||
| } | ||
|
|
||
| return Object.entries(params).flatMap(([key, value]) => { | ||
| // process/file/script | ||
| if (key === '_') { | ||
| return Array.isArray(value) ? value : [`${value}`]; | ||
| } | ||
| const prefix = key.length === 1 ? '-' : '--'; | ||
| // "-*" arguments (shorthands) | ||
| if (Array.isArray(value)) { | ||
| return value.map(v => `${prefix}${key}="${v}"`); | ||
| } | ||
| // "--*" arguments ========== | ||
|
|
||
| if (typeof value === 'object') { | ||
| return Object.entries(value as Record<string, ArgumentValue>).flatMap( | ||
| // transform nested objects to the dot notation `key.subkey` | ||
| ([k, v]) => objectToCliArgs({ [`${key}.${k}`]: v }), | ||
| ); | ||
| } | ||
|
|
||
| if (typeof value === 'string') { | ||
| return [`${prefix}${key}="${value}"`]; | ||
| } | ||
|
|
||
| if (typeof value === 'number') { | ||
| return [`${prefix}${key}=${value}`]; | ||
| } | ||
|
|
||
| if (typeof value === 'boolean') { | ||
| return [`${prefix}${value ? '' : 'no-'}${key}`]; | ||
| } | ||
|
|
||
| if (typeof value === 'undefined') { | ||
| return []; | ||
| } | ||
|
|
||
| throw new Error(`Unsupported type ${typeof value} for key ${key}`); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Converts a file path to a CLI argument by wrapping it in quotes to handle spaces. | ||
| * | ||
| * @param {string} filePath - The file path to convert to a CLI argument. | ||
| * @returns {string} - The quoted file path suitable for CLI usage. | ||
| */ | ||
| export function filePathToCliArg(filePath: string): string { | ||
| // needs to be escaped if spaces included | ||
| return `"${filePath}"`; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,10 +1,13 @@ | ||||||||||||||||||||||||||
| import { gray } from 'ansis'; | ||||||||||||||||||||||||||
| import { spawn } from 'node:child_process'; | ||||||||||||||||||||||||||
| import { ui } from '@code-pushup/utils'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function calcDuration(start: number, stop?: number): number { | ||||||||||||||||||||||||||
| return Math.round((stop ?? performance.now()) - start); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| /* COPY OF /Users/michael_hladky/WebstormProjects/cli/packages/utils/src/lib/execute-process.ts */ | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| type ChildProcess, | ||||||||||||||||||||||||||
| type ChildProcessByStdio, | ||||||||||||||||||||||||||
| type SpawnOptionsWithStdioTuple, | ||||||||||||||||||||||||||
| type StdioPipe, | ||||||||||||||||||||||||||
| spawn, | ||||||||||||||||||||||||||
| } from 'node:child_process'; | ||||||||||||||||||||||||||
| import type { Readable, Writable } from 'node:stream'; | ||||||||||||||||||||||||||
| import { formatCommandLog } from './command.js'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Represents the process result. | ||||||||||||||||||||||||||
|
|
@@ -82,11 +85,14 @@ | |||||||||||||||||||||||||
| * args: ['--version'] | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export type ProcessConfig = { | ||||||||||||||||||||||||||
| export type ProcessConfig = Omit< | ||||||||||||||||||||||||||
| SpawnOptionsWithStdioTuple<StdioPipe, StdioPipe, StdioPipe>, | ||||||||||||||||||||||||||
| 'stdio' | ||||||||||||||||||||||||||
| > & { | ||||||||||||||||||||||||||
| command: string; | ||||||||||||||||||||||||||
| args?: string[]; | ||||||||||||||||||||||||||
| cwd?: string; | ||||||||||||||||||||||||||
| observer?: ProcessObserver; | ||||||||||||||||||||||||||
| verbose?: boolean; | ||||||||||||||||||||||||||
| ignoreExitCode?: boolean; | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -104,7 +110,8 @@ | |||||||||||||||||||||||||
| * } | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export type ProcessObserver = { | ||||||||||||||||||||||||||
| onStdout?: (stdout: string) => void; | ||||||||||||||||||||||||||
| onStdout?: (stdout: string, sourceProcess?: ChildProcess) => void; | ||||||||||||||||||||||||||
| onStderr?: (stderr: string, sourceProcess?: ChildProcess) => void; | ||||||||||||||||||||||||||
| onError?: (error: ProcessError) => void; | ||||||||||||||||||||||||||
| onComplete?: () => void; | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
@@ -125,7 +132,7 @@ | |||||||||||||||||||||||||
| * // async process execution | ||||||||||||||||||||||||||
| * const result = await executeProcess({ | ||||||||||||||||||||||||||
| * command: 'node', | ||||||||||||||||||||||||||
| * args: ['download-data'], | ||||||||||||||||||||||||||
| * args: ['download-data.js'], | ||||||||||||||||||||||||||
| * observer: { | ||||||||||||||||||||||||||
| * onStdout: updateProgress, | ||||||||||||||||||||||||||
| * error: handleError, | ||||||||||||||||||||||||||
|
|
@@ -137,41 +144,60 @@ | |||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * @param cfg - see {@link ProcessConfig} | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export function executeProcess(cfg: ProcessConfig): Promise<ProcessResult> { | ||||||||||||||||||||||||||
| const { observer, cwd, command, args, ignoreExitCode = false } = cfg; | ||||||||||||||||||||||||||
| const { onStdout, onError, onComplete } = observer ?? {}; | ||||||||||||||||||||||||||
| export function executeProcess( | ||||||||||||||||||||||||||
| cfg: ProcessConfig, | ||||||||||||||||||||||||||
| logger: { log: (str: string) => void } = { log: console.log }, | ||||||||||||||||||||||||||
| ): Promise<ProcessResult> { | ||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||
| command, | ||||||||||||||||||||||||||
| args, | ||||||||||||||||||||||||||
| observer, | ||||||||||||||||||||||||||
| ignoreExitCode = false, | ||||||||||||||||||||||||||
| verbose, | ||||||||||||||||||||||||||
| ...options | ||||||||||||||||||||||||||
| } = cfg; | ||||||||||||||||||||||||||
| const { onStdout, onStderr, onError, onComplete } = observer ?? {}; | ||||||||||||||||||||||||||
| const date = new Date().toISOString(); | ||||||||||||||||||||||||||
| const start = performance.now(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const logCommand = [command, ...(args || [])].join(' '); | ||||||||||||||||||||||||||
| ui().logger.log( | ||||||||||||||||||||||||||
| gray( | ||||||||||||||||||||||||||
| `Executing command:\n${logCommand}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, | ||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| if (verbose === true) { | ||||||||||||||||||||||||||
| logger.log( | ||||||||||||||||||||||||||
| formatCommandLog({ | ||||||||||||||||||||||||||
| command, | ||||||||||||||||||||||||||
| args, | ||||||||||||||||||||||||||
| cwd: cfg.cwd ? String(cfg.cwd) : process.cwd(), | ||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return new Promise((resolve, reject) => { | ||||||||||||||||||||||||||
| // shell:true tells Windows to use shell command for spawning a child process | ||||||||||||||||||||||||||
| const process = spawn(command, args, { cwd, shell: true }); | ||||||||||||||||||||||||||
| const spawnedProcess = spawn(command, args ?? [], { | ||||||||||||||||||||||||||
Check warningCode scanning / CodeQL Unsafe shell command constructed from library input Medium
This shell argument which depends on
library input Error loading related location Loading shell command Error loading related location Loading
Copilot AutofixAI about 1 month ago To fix this, we should not use Thus:
Alternatively, if some processes require shell interpretation and others do not, expose
Suggested changeset
1
packages/nx-plugin/src/internal/execute-process.ts
Copilot is powered by AI and may make mistakes. Always verify output.
Positive FeedbackNegative Feedback
Refresh and try again.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we add a comment that we need it for windows (I'm not sure what exactly the reason was :D) |
||||||||||||||||||||||||||
| shell: true, | ||||||||||||||||||||||||||
| windowsHide: true, | ||||||||||||||||||||||||||
| ...options, | ||||||||||||||||||||||||||
| }) as ChildProcessByStdio<Writable, Readable, Readable>; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // eslint-disable-next-line functional/no-let | ||||||||||||||||||||||||||
| let stdout = ''; | ||||||||||||||||||||||||||
| // eslint-disable-next-line functional/no-let | ||||||||||||||||||||||||||
| let stderr = ''; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| process.stdout.on('data', data => { | ||||||||||||||||||||||||||
| spawnedProcess.stdout.on('data', data => { | ||||||||||||||||||||||||||
| stdout += String(data); | ||||||||||||||||||||||||||
| onStdout?.(String(data)); | ||||||||||||||||||||||||||
| onStdout?.(String(data), spawnedProcess); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| process.stderr.on('data', data => { | ||||||||||||||||||||||||||
| spawnedProcess.stderr.on('data', data => { | ||||||||||||||||||||||||||
| stderr += String(data); | ||||||||||||||||||||||||||
| onStderr?.(String(data), spawnedProcess); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| process.on('error', err => { | ||||||||||||||||||||||||||
| spawnedProcess.on('error', err => { | ||||||||||||||||||||||||||
| stderr += err.toString(); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| process.on('close', code => { | ||||||||||||||||||||||||||
| spawnedProcess.on('close', code => { | ||||||||||||||||||||||||||
| const timings = { date, duration: calcDuration(start) }; | ||||||||||||||||||||||||||
| if (code === 0 || ignoreExitCode) { | ||||||||||||||||||||||||||
| onComplete?.(); | ||||||||||||||||||||||||||
|
|
@@ -184,3 +210,7 @@ | |||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function calcDuration(start: number, stop?: number): number { | ||||||||||||||||||||||||||
| return Math.round((stop ?? performance.now()) - start); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.