Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
cbe02f2
feat(nx-plugin): add support for local code execution
BioPhoton Sep 5, 2025
b556bd8
refactor: revert plugin options
BioPhoton Sep 5, 2025
4bba9cd
test: update e2e tests
BioPhoton Sep 5, 2025
47bc389
refactor: adjust target options
BioPhoton Sep 5, 2025
695acbc
refactor: add int test for env vars
BioPhoton Sep 5, 2025
5d4fd60
refactor: remove dependencies on packages, use latest
BioPhoton Sep 5, 2025
052e18c
refactor: fix missing bin in command
BioPhoton Sep 5, 2025
e6ac40d
refactor: add colored formatting
BioPhoton Sep 5, 2025
311679f
refactor: fix tests for colored formatting
BioPhoton Sep 5, 2025
8f3b7fa
chore: cache nxv-e2e-setup
BioPhoton Sep 5, 2025
e9e4a3a
refactor: wip
BioPhoton Sep 5, 2025
a1d0f9d
refactor: wip 2
BioPhoton Sep 5, 2025
f1dd383
refactor: wip 3
BioPhoton Sep 5, 2025
93d1d40
refactor: fix e2e
BioPhoton Sep 5, 2025
41c900b
refactor: fix unit test
BioPhoton Sep 5, 2025
60183db
refactor: fix lint
BioPhoton Sep 5, 2025
ea73523
refactor: fix lint
BioPhoton Sep 5, 2025
454960b
refactor: fix lint
BioPhoton Sep 5, 2025
b368ac0
refactor: fix lint
BioPhoton Sep 6, 2025
d5e05a4
refactor: adjust command logic
BioPhoton Sep 6, 2025
2041093
refactor: adjust command logic
BioPhoton Sep 6, 2025
bebae7c
refactor: wip
BioPhoton Sep 6, 2025
f947823
refactor: wip
BioPhoton Sep 6, 2025
752a97e
refactor: wip
BioPhoton Sep 6, 2025
4e90518
refactor: wip
BioPhoton Sep 6, 2025
a180d5c
test(nx-plugin): add tests for buildCommandString
BioPhoton Sep 6, 2025
41f9af2
refactor: wip
BioPhoton Sep 6, 2025
9dff7c4
refactor: revert versions change
BioPhoton Sep 6, 2025
0f3ba3a
Update CONTRIBUTING.md
BioPhoton Sep 8, 2025
cefe54d
Update packages/nx-plugin/src/executors/cli/executor.ts
BioPhoton Sep 8, 2025
d9b453f
Update CONTRIBUTING.md
BioPhoton Sep 8, 2025
7b307d0
Update CONTRIBUTING.md
BioPhoton Sep 8, 2025
a2892ba
refactor: move logic into command.ts
BioPhoton Sep 8, 2025
ff74241
refactor: adjust tests
BioPhoton Sep 8, 2025
fc990a6
refactor: fix build
BioPhoton Sep 8, 2025
b1875d3
refactor: adjust models
BioPhoton Sep 8, 2025
371e70b
refactor: adjust comments
BioPhoton Sep 8, 2025
ab3f5ed
refactor: adjust comments
BioPhoton Sep 8, 2025
aa0950a
refactor: remove comments
BioPhoton Sep 8, 2025
1d006f2
refactor: fix e2e
BioPhoton Sep 8, 2025
fe6d69b
refactor: fix int
BioPhoton Sep 8, 2025
57c13c6
refactor: fix int 2
BioPhoton Sep 8, 2025
39c112a
refactor: fix int 3
BioPhoton Sep 8, 2025
bdf038a
refactor: revert out of scope
BioPhoton Sep 8, 2025
be19204
Merge branch 'main' into refactor/utils/command-helper
BioPhoton Sep 8, 2025
1f2712a
refactor: fix ci int test
BioPhoton Sep 8, 2025
f7d8730
refactor: wip
BioPhoton Sep 10, 2025
ff432bf
refactor: wip 2
BioPhoton Sep 10, 2025
36faf27
refactor: revert to spawn
BioPhoton Sep 10, 2025
1d05e8b
refactor: revert to spawn
BioPhoton Sep 10, 2025
f3c5dd5
refactor: revert escape
BioPhoton Sep 15, 2025
6bec56f
refactor: revert escape 2
BioPhoton Sep 15, 2025
40d5fb3
refactor: fix tests
BioPhoton Sep 15, 2025
0973f1c
refactor: revert all
BioPhoton Sep 16, 2025
51cd8c7
refactor: adjust execute-process logic
BioPhoton Sep 16, 2025
476adfa
refactor: fix tests
BioPhoton Sep 16, 2025
0fea136
Merge branch 'main' into refactor/utils/command-helper
BioPhoton Oct 4, 2025
15da0ab
refactor: fix unit tests
BioPhoton Oct 4, 2025
f1996ad
refactor: fix create-cli unit tests
BioPhoton Oct 4, 2025
3880c00
refactor: fix nx-plugin unit tests
BioPhoton Oct 4, 2025
04e96bc
Potential fix for code scanning alert no. 20: Incomplete string escap…
BioPhoton Oct 4, 2025
bbc10e7
refactor: fix ci int tests
Oct 4, 2025
d86726d
Merge remote-tracking branch 'origin/refactor/utils/command-helper' i…
Oct 4, 2025
36d8c2a
refactor: remove unused import
Oct 4, 2025
afe8f89
refactor: use copy of command and execute-process implementation from…
Oct 4, 2025
d3781cf
refactor: fix logger usage
Oct 4, 2025
9a5eb15
Apply suggestion from @BioPhoton
BioPhoton Oct 4, 2025
4268ecc
Merge branch 'refs/heads/main' into refactor/utils/command-helper
Oct 23, 2025
aa6363d
refactor: revert verbose changes
Oct 23, 2025
37f2028
refactor: fix revert options
Oct 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe('runAutorunExecutor', () => {
onError: expect.any(Function),
onStdout: expect.any(Function),
},
verbose: true,
});
});
});
2 changes: 2 additions & 0 deletions packages/nx-plugin/src/executors/cli/executor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ExecutorContext, logger } from '@nx/devkit';
import { isVerbose } from '@code-pushup/utils';
import { executeProcess } from '../../internal/execute-process.js';
import {
createCliCommandObject,
Expand Down Expand Up @@ -43,6 +44,7 @@ export default async function runAutorunExecutor(
await executeProcess({
...createCliCommandObject({ command, args: cliArgumentObject }),
...(context.cwd ? { cwd: context.cwd } : {}),
...(isVerbose() || verbose ? { verbose: true } : {}),
});
} catch (error) {
logger.error(error);
Expand Down
168 changes: 168 additions & 0 deletions packages/nx-plugin/src/internal/command.ts
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}"`;
}
82 changes: 56 additions & 26 deletions packages/nx-plugin/src/internal/execute-process.ts
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.
Expand Down Expand Up @@ -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;
};

Expand All @@ -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;
};
Expand All @@ -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,
Expand All @@ -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 warning

Code scanning / CodeQL

Unsafe shell command constructed from library input Medium

This shell argument which depends on
library input
is later used in a
shell command
.

Copilot Autofix

AI about 1 month ago

To fix this, we should not use { shell: true } with arguments provided directly by untrusted/library input. Instead, we should use { shell: false } (the default), allowing spawn to pass the command and arguments directly to the process without interpretation by the shell. If there are legitimate use cases requiring shell features (such as pipes, I/O redirection), then inputs embedded in the shell command line must be safely escaped using a library like shell-quote. But for generic process execution, switching to non-shell mode is safest.

Thus:

  • In packages/nx-plugin/src/internal/execute-process.ts, at line 175, remove or change shell: true to either omit it or set shell: false.
  • No other code needs changing, unless shell features are known to be required for all usage (which is not demonstrated here).

Alternatively, if some processes require shell interpretation and others do not, expose { shell: true/false } as an explicit configurable option, and document the dangers, but set default to false.

Suggested changeset 1
packages/nx-plugin/src/internal/execute-process.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts
--- a/packages/nx-plugin/src/internal/execute-process.ts
+++ b/packages/nx-plugin/src/internal/execute-process.ts
@@ -173,7 +173,6 @@
   return new Promise((resolve, reject) => {
     // shell:true tells Windows to use shell command for spawning a child process
     const spawnedProcess = spawn(command, args ?? [], {
-      shell: true,
       windowsHide: true,
       ...options,
     }) as ChildProcessByStdio<Writable, Readable, Readable>;
EOF
@@ -173,7 +173,6 @@
return new Promise((resolve, reject) => {
// shell:true tells Windows to use shell command for spawning a child process
const spawnedProcess = spawn(command, args ?? [], {
shell: true,
windowsHide: true,
...options,
}) as ChildProcessByStdio<Writable, Readable, Readable>;
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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?.();
Expand All @@ -184,3 +210,7 @@
});
});
}

export function calcDuration(start: number, stop?: number): number {
return Math.round((stop ?? performance.now()) - start);
}
Loading