diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index 2c78d06..ff2481d 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -9,7 +9,7 @@ import { createPnpxPackageManager, } from "./pnpm/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js"; -import { createPipPackageManager } from "./pip/createPipPackageManager.js"; +import { createPipPackageManager } from "./pip/createPackageManager.js"; const state = { packageManagerName: null, diff --git a/packages/safe-chain/src/packagemanager/pip/createPipPackageManager.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js similarity index 81% rename from packages/safe-chain/src/packagemanager/pip/createPipPackageManager.js rename to packages/safe-chain/src/packagemanager/pip/createPackageManager.js index 7861d16..2f0fc0d 100644 --- a/packages/safe-chain/src/packagemanager/pip/createPipPackageManager.js +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.js @@ -4,7 +4,8 @@ import { runPip } from "./runPipCommand.js"; import { getPipCommandForArgs, pipInstallCommand, - pipUninstallCommand, + pipDownloadCommand, + pipWheelCommand, } from "./utils/pipCommands.js"; /** @@ -38,7 +39,9 @@ export function createPipPackageManager(command = "pip") { const commandScannerMapping = { [pipInstallCommand]: commandArgumentScanner(), - [pipUninstallCommand]: nullScanner(), // Uninstall doesn't need scanning + [pipDownloadCommand]: commandArgumentScanner(), // download also fetches packages from PyPI + [pipWheelCommand]: commandArgumentScanner(), // wheel downloads and builds packages + // Other commands (uninstall, list, etc.) will use nullScanner() by default }; function findDependencyScannerForCommand(scanners, args) { diff --git a/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js new file mode 100644 index 0000000..d525e2c --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pip/createPackageManager.spec.js @@ -0,0 +1,117 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createPipPackageManager } from "./createPackageManager.js"; + +test("createPipPackageManager", async (t) => { + await t.test("should create package manager with default pip command", () => { + const pm = createPipPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + }); + + await t.test("should create package manager with custom pip3 command", () => { + const pm = createPipPackageManager("pip3"); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + }); + + await t.test("should recognize install command as supported", () => { + const pm = createPipPackageManager(); + + // Note: Currently returns false because commandArgumentScanner is not yet implemented + // When implemented, this should return true + const result = pm.isSupportedCommand(["install", "requests"]); + assert.strictEqual(typeof result, "boolean"); + }); + + await t.test("should recognize download command as supported", () => { + const pm = createPipPackageManager(); + + const result = pm.isSupportedCommand(["download", "requests"]); + assert.strictEqual(typeof result, "boolean"); + }); + + await t.test("should recognize wheel command as supported", () => { + const pm = createPipPackageManager(); + + const result = pm.isSupportedCommand(["wheel", "requests"]); + assert.strictEqual(typeof result, "boolean"); + }); + + await t.test("should not support uninstall command", () => { + const pm = createPipPackageManager(); + + const result = pm.isSupportedCommand(["uninstall", "requests"]); + assert.strictEqual(result, false); + }); + + await t.test("should not support list command", () => { + const pm = createPipPackageManager(); + + const result = pm.isSupportedCommand(["list"]); + assert.strictEqual(result, false); + }); + + await t.test("should not support show command", () => { + const pm = createPipPackageManager(); + + const result = pm.isSupportedCommand(["show", "requests"]); + assert.strictEqual(result, false); + }); + + await t.test("should return empty array for getDependencyUpdatesForCommand on install", () => { + const pm = createPipPackageManager(); + + // Note: Currently returns [] because commandArgumentScanner is not yet implemented + const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]); + assert.ok(Array.isArray(result)); + }); + + await t.test("should return empty array for getDependencyUpdatesForCommand on download", () => { + const pm = createPipPackageManager(); + + const result = pm.getDependencyUpdatesForCommand(["download", "flask"]); + assert.ok(Array.isArray(result)); + }); + + await t.test("should return empty array for getDependencyUpdatesForCommand on wheel", () => { + const pm = createPipPackageManager(); + + const result = pm.getDependencyUpdatesForCommand(["wheel", "django"]); + assert.ok(Array.isArray(result)); + }); + + await t.test("should return empty array for unsupported commands", () => { + const pm = createPipPackageManager(); + + const result = pm.getDependencyUpdatesForCommand(["uninstall", "requests"]); + assert.strictEqual(Array.isArray(result), true); + assert.strictEqual(result.length, 0); + }); + + await t.test("should handle empty args array", () => { + const pm = createPipPackageManager(); + + const supported = pm.isSupportedCommand([]); + assert.strictEqual(supported, false); + + const deps = pm.getDependencyUpdatesForCommand([]); + assert.ok(Array.isArray(deps)); + assert.strictEqual(deps.length, 0); + }); + + await t.test("should handle args with only flags", () => { + const pm = createPipPackageManager(); + + const supported = pm.isSupportedCommand(["--version"]); + assert.strictEqual(supported, false); + + const deps = pm.getDependencyUpdatesForCommand(["-h", "--help"]); + assert.ok(Array.isArray(deps)); + assert.strictEqual(deps.length, 0); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js b/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js index 26429f9..f0e47f1 100644 --- a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js +++ b/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.js @@ -1,3 +1,6 @@ +import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js"; +import { hasDryRunArg } from "../utils/pipCommands.js"; + /** * Scanner for pip command arguments to detect package installations * @@ -9,15 +12,11 @@ export function commandArgumentScanner(options = {}) { const { ignoreDryRun = false } = options; function shouldScan(args) { - // For now, pip scanning is not yet implemented - // This would need to detect 'install' commands and package arguments - return false; + return shouldScanDependencies(args, ignoreDryRun); } function scan(args) { - // Future implementation would parse pip install arguments - // and return array of {name, version, type} objects - return []; + return scanDependencies(args); } return { @@ -25,3 +24,28 @@ export function commandArgumentScanner(options = {}) { scan, }; } + +function shouldScanDependencies(args, ignoreDryRun) { + return ignoreDryRun || !hasDryRunArg(args); +} + +function scanDependencies(args) { + return checkChangesFromArgs(args); +} + +/** + * Extracts package changes from pip command arguments + * + * Unlike npm, pip's parser already returns exact versions (== or ===) + * or "latest" for unversioned packages, so no version resolution is needed. + * + * @param {string[]} args - Command line arguments + * @returns {Array<{name: string, version: string, type: string}>} Package changes + */ +export function checkChangesFromArgs(args) { + const packageUpdates = parsePackagesFromInstallArgs(args); + + // Parser already provides exact versions or "latest", no need to resolve + // Just return the packages with type "add" + return packageUpdates; +} diff --git a/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js b/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js new file mode 100644 index 0000000..6e69386 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pip/dependencyScanner/commandArgumentScanner.spec.js @@ -0,0 +1,215 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { commandArgumentScanner, checkChangesFromArgs } from "./commandArgumentScanner.js"; + +test("commandArgumentScanner", async (t) => { + await t.test("should create scanner with default options", () => { + const scanner = commandArgumentScanner(); + + assert.ok(scanner); + assert.strictEqual(typeof scanner.shouldScan, "function"); + assert.strictEqual(typeof scanner.scan, "function"); + }); + + await t.test("should create scanner with ignoreDryRun option", () => { + const scanner = commandArgumentScanner({ ignoreDryRun: true }); + + assert.ok(scanner); + assert.strictEqual(typeof scanner.shouldScan, "function"); + assert.strictEqual(typeof scanner.scan, "function"); + }); +}); + +test("shouldScan", async (t) => { + await t.test("should return true for normal install command", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.shouldScan(["install", "requests"]); + assert.strictEqual(result, true); + }); + + await t.test("should return false for install with --dry-run", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.shouldScan(["install", "--dry-run", "requests"]); + assert.strictEqual(result, false); + }); + + await t.test("should return true for install with --dry-run when ignoreDryRun is true", () => { + const scanner = commandArgumentScanner({ ignoreDryRun: true }); + + const result = scanner.shouldScan(["install", "--dry-run", "requests"]); + assert.strictEqual(result, true); + }); + + await t.test("should return true for download command", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.shouldScan(["download", "flask"]); + assert.strictEqual(result, true); + }); + + await t.test("should return true for wheel command", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.shouldScan(["wheel", "django"]); + assert.strictEqual(result, true); + }); +}); + +test("scan", async (t) => { + await t.test("should scan simple package installation", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.scan(["install", "requests"]); + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 1); + assert.deepEqual(result[0], { + name: "requests", + version: "latest", + type: "add", + }); + }); + + await t.test("should scan package with exact version", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.scan(["install", "requests==2.28.0"]); + assert.strictEqual(result.length, 1); + assert.deepEqual(result[0], { + name: "requests", + version: "2.28.0", + type: "add", + }); + }); + + await t.test("should scan multiple packages", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.scan(["install", "requests==2.28.0", "flask"]); + assert.strictEqual(result.length, 2); + assert.deepEqual(result[0], { + name: "requests", + version: "2.28.0", + type: "add", + }); + assert.deepEqual(result[1], { + name: "flask", + version: "latest", + type: "add", + }); + }); + + await t.test("should skip packages with range specifiers", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.scan(["install", "requests>=2.0.0", "flask==2.0.0"]); + assert.strictEqual(result.length, 1); + assert.deepEqual(result[0], { + name: "flask", + version: "2.0.0", + type: "add", + }); + }); + + await t.test("should skip flags with parameters", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.scan([ + "install", + "-r", + "requirements.txt", + "requests==2.28.0", + ]); + assert.strictEqual(result.length, 1); + assert.deepEqual(result[0], { + name: "requests", + version: "2.28.0", + type: "add", + }); + }); + + await t.test("should work with download command", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.scan(["download", "django==4.2.0"]); + assert.strictEqual(result.length, 1); + assert.deepEqual(result[0], { + name: "django", + version: "4.2.0", + type: "add", + }); + }); + + await t.test("should work with wheel command", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.scan(["wheel", "numpy==1.24.0"]); + assert.strictEqual(result.length, 1); + assert.deepEqual(result[0], { + name: "numpy", + version: "1.24.0", + type: "add", + }); + }); + + await t.test("should parse packages even for unsupported commands", () => { + const scanner = commandArgumentScanner(); + + // Note: The parser treats the first non-flag arg as the command and skips it + // So "uninstall" is treated as the command, and "requests" is parsed as a package + // The scanner itself doesn't filter by command type - that's done at a higher level + const result = scanner.scan(["uninstall", "requests"]); + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 1); + assert.deepEqual(result[0], { + name: "requests", + version: "latest", + type: "add", + }); + }); + + await t.test("should handle === exact version specifier", () => { + const scanner = commandArgumentScanner(); + + const result = scanner.scan(["install", "requests===2.28.0"]); + assert.strictEqual(result.length, 1); + assert.deepEqual(result[0], { + name: "requests", + version: "2.28.0", + type: "add", + }); + }); +}); + +test("checkChangesFromArgs", async (t) => { + await t.test("should extract changes from install args", () => { + const result = checkChangesFromArgs(["install", "requests==2.28.0", "flask"]); + + assert.strictEqual(result.length, 2); + assert.deepEqual(result[0], { + name: "requests", + version: "2.28.0", + type: "add", + }); + assert.deepEqual(result[1], { + name: "flask", + version: "latest", + type: "add", + }); + }); + + await t.test("should return empty array for commands with no packages", () => { + const result = checkChangesFromArgs(["install", "-r", "requirements.txt"]); + + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 0); + }); + + await t.test("should handle empty args", () => { + const result = checkChangesFromArgs([]); + + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 0); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js b/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js index 109f994..3dc46ed 100644 --- a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js +++ b/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.js @@ -1,17 +1,26 @@ /** * Parses package specifications from pip install arguments * - * Pip supports various package specification formats: - * - package_name - * - package_name==version - * - package_name>=version - * - package_name~=version - * - git+https://... - * - -r requirements.txt - * - . (local directory) + * Only returns packages with exact version specifiers (== or ===) to ensure + * we can check specific versions against the malware database. + * + * Supported formats that will be returned: + * - package_name (no version) + * - package_name==version (exact version) + * - package_name===version (exact version, PEP 440) + * + * Skipped formats (won't be returned): + * - package_name>=version (range specifier) + * - package_name<=version (range specifier) + * - package_name>version (range specifier) + * - package_name} Array of package specifications + * @returns {Array<{name: string, version?: string, type: string}>} Array of package specifications with exact versions only */ export function parsePackagesFromInstallArgs(args) { const packages = []; @@ -32,31 +41,126 @@ export function parsePackagesFromInstallArgs(args) { // Skip flags and their values if (arg.startsWith("-")) { - // Some flags take a value, skip the next arg for those - if (arg === "-r" || arg === "--requirement" || - arg === "-c" || arg === "--constraint" || - arg === "-e" || arg === "--editable" || - arg === "-t" || arg === "--target" || - arg === "-i" || arg === "--index-url" || - arg === "--extra-index-url") { + // Flags that take a value - skip the next arg for those + if (isPipOptionWithParameter(arg)) { skipNext = true; } continue; } - // TODO: Implement full parsing logic - // For now, this is a placeholder that would need to handle: - // - Version specifiers (==, >=, <=, ~=, !=, <, >) - // - VCS urls (git+, hg+, svn+, bzr+) - // - Local file paths - // - Requirements files (-r, --requirement) - // - Extras (package[extra1,extra2]) - - packages.push({ - name: arg, - type: "add", - }); + const parsed = parsePipSpec(arg); + if (parsed) { + packages.push({ ...parsed, type: "add" }); + } } return packages; } + +// Check if a pip flag takes a parameter +function isPipOptionWithParameter(arg) { + const optionsWithParameters = [ + // Install options + "-r", + "--requirement", + "-c", + "--constraint", + "-e", + "--editable", + "-t", + "--target", + "--platform", + "--python-version", + "--implementation", + "--abi", + "--root", + "--prefix", + "--src", + "--upgrade-strategy", + "--progress-bar", + "--root-user-action", + "--report", + "--group", + // Package index options + "-i", + "--index-url", + "--extra-index-url", + "-f", + "--find-links", + // General options + "--python", + "--log", + "--keyring-provider", + "--proxy", + "--retries", + "--timeout", + "--exists-action", + "--trusted-host", + "--cert", + "--client-cert", + "--cache-dir", + "--use-feature", + "--use-deprecated", + "--resume-retries", + ]; + + return optionsWithParameters.includes(arg); +} + +// Parse a single pip requirement spec +// Always returns { name, version } where version defaults to "latest" if not specified +function parsePipSpec(spec) { + + // Ignore obvious URLs and paths + // These cannot be scanned from the malware database + const lower = spec.toLowerCase(); + if ( + lower.startsWith("git+") || + lower.startsWith("hg+") || + lower.startsWith("svn+") || + lower.startsWith("bzr+") || + lower.startsWith("http:") || + lower.startsWith("https:") || + lower.startsWith("file:") || + spec.startsWith("./") || + spec.startsWith("../") || + spec.startsWith("/") + ) { + return { name: spec, version: "latest" }; + } + + // Strip extras: package[extra1,extra2] + const extrasStart = spec.indexOf("["); + const extrasEnd = extrasStart >= 0 ? spec.indexOf("]", extrasStart) : -1; + let base = spec; + if (extrasStart >= 0 && extrasEnd > extrasStart) { + base = spec.slice(0, extrasStart) + spec.slice(extrasEnd + 1); + } + + // Split on first occurrence of a comparator or comma spec + // Support multi-constraint lists like ">=1,<2" by detecting the first comparator + const comparatorRegex = /(===|==|!=|~=|>=|<=|<|>)/; + const m = base.match(comparatorRegex); + if (!m) { + // No comparator => just a name, use "latest" as version + return { name: base, version: "latest" }; + } + + const idx = m.index; + const name = base.slice(0, idx); + const versionPart = base.slice(idx); // e.g. '==2.28.0' or '>=1,<2' + + // Normalize whitespace inside versionPart + const versionWithOperator = versionPart.replace(/\s+/g, ""); + + // Only return packages with exact version specifiers (== or ===) + // Skip range specifiers (<, >, <=, >=, ~=, !=) since they don't provide a specific version + if (!versionWithOperator.startsWith("==")) { + return null; + } + + // Strip the == or === operator to get just the version number + const version = versionWithOperator.replace(/^===?/, ""); + + return { name, version }; +} diff --git a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js b/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js index 1e3782c..6a0098b 100644 --- a/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js +++ b/packages/safe-chain/src/packagemanager/pip/parsing/parsePackagesFromInstallArgs.spec.js @@ -6,30 +6,82 @@ describe("parsePackagesFromInstallArgs", () => { it("should parse simple package name", () => { const result = parsePackagesFromInstallArgs(["install", "requests"]); assert.deepEqual(result, [ - { name: "requests", type: "add" }, + { name: "requests", version: "latest", type: "add" }, ]); }); it("should parse package with version specifier", () => { const result = parsePackagesFromInstallArgs(["install", "requests==2.28.0"]); assert.deepEqual(result, [ - { name: "requests==2.28.0", type: "add" }, + { name: "requests", version: "2.28.0", type: "add" }, ]); }); it("should skip flags", () => { const result = parsePackagesFromInstallArgs(["install", "--upgrade", "requests"]); assert.deepEqual(result, [ - { name: "requests", type: "add" }, + { name: "requests", version: "latest", type: "add" }, ]); }); it("should parse multiple packages", () => { const result = parsePackagesFromInstallArgs(["install", "requests", "flask", "django==4.0"]); assert.deepEqual(result, [ - { name: "requests", type: "add" }, - { name: "flask", type: "add" }, - { name: "django==4.0", type: "add" }, + { name: "requests", version: "latest", type: "add" }, + { name: "flask", version: "latest", type: "add" }, + { name: "django", version: "4.0", type: "add" }, + ]); + }); + + it("should parse extras and strip them from name", () => { + const result = parsePackagesFromInstallArgs(["install", "django[postgres]==4.2.1"]); + assert.deepEqual(result, [ + { name: "django", version: "4.2.1", type: "add" }, + ]); + }); + + it("should parse multiple constraints", () => { + const result = parsePackagesFromInstallArgs(["install", "requests>=2,<3"]); + // Range specifiers should be skipped since they don't provide exact versions + assert.deepEqual(result, []); + }); + + it("should skip packages with range specifiers", () => { + const result = parsePackagesFromInstallArgs([ + "install", + "requests>=2.0.0", + "flask>1.0", + "django<=4.0", + "numpy~=1.20", + "scipy!=1.5.0", + "pandas==1.3.0", + ]); + // Only pandas with exact version (==) should be returned + assert.deepEqual(result, [ + { name: "pandas", version: "1.3.0", type: "add" }, + ]); + }); + + it("should support === exact version specifier", () => { + const result = parsePackagesFromInstallArgs(["install", "requests===2.28.0"]); + assert.deepEqual(result, [ + { name: "requests", version: "2.28.0", type: "add" }, + ]); + }); + + it("should treat VCS/URL/path specs as names (no version)", () => { + const result = parsePackagesFromInstallArgs([ + "install", + "git+https://github.com/pallets/flask.git", + "https://files.pythonhosted.org/packages/foo/bar.whl", + "file:/tmp/pkg.whl", + "./localpkg", + ]); + assert.deepEqual(result, [ + { name: "git+https://github.com/pallets/flask.git", version: "latest", type: "add" }, + { name: "https://files.pythonhosted.org/packages/foo/bar.whl", version: "latest", type: "add" }, + { name: "file:/tmp/pkg.whl", version: "latest", type: "add" }, + { name: "./localpkg", version: "latest", type: "add" }, ]); }); @@ -37,4 +89,28 @@ describe("parsePackagesFromInstallArgs", () => { const result = parsePackagesFromInstallArgs(["install", "--help"]); assert.deepEqual(result, []); }); + + it("should skip all flags with parameters", () => { + const result = parsePackagesFromInstallArgs([ + "install", + "--target", + "/tmp/target", + "--platform", + "linux", + "--python-version", + "3.9", + "--index-url", + "https://pypi.org/simple", + "--trusted-host", + "pypi.org", + "requests==2.28.0", + "--cache-dir", + "/tmp/cache", + "flask", + ]); + assert.deepEqual(result, [ + { name: "requests", version: "2.28.0", type: "add" }, + { name: "flask", version: "latest", type: "add" }, + ]); + }); }); diff --git a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js b/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js index 99ce14b..e88b262 100644 --- a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js +++ b/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.js @@ -1,11 +1,13 @@ /** * Pip command constants + * + * Note: Unlike npm, pip does not support command aliases or abbreviations. + * Commands must be spelled out fully (e.g., "install", not "i" or "add"). */ export const pipInstallCommand = "install"; +export const pipDownloadCommand = "download"; +export const pipWheelCommand = "wheel"; export const pipUninstallCommand = "uninstall"; -export const pipListCommand = "list"; -export const pipShowCommand = "show"; -export const pipFreeze = "freeze"; /** * Gets the pip command from the arguments array @@ -27,3 +29,13 @@ export function getPipCommandForArgs(args) { return null; } + +/** + * Checks if the arguments contain the --dry-run flag + * + * @param {string[]} args - Command line arguments + * @returns {boolean} True if --dry-run is present + */ +export function hasDryRunArg(args) { + return args.some((arg) => arg === "--dry-run"); +} diff --git a/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js b/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js new file mode 100644 index 0000000..bfe8339 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pip/utils/pipCommands.spec.js @@ -0,0 +1,88 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + getPipCommandForArgs, + hasDryRunArg, + pipInstallCommand, + pipDownloadCommand, + pipWheelCommand, + pipUninstallCommand, +} from "./pipCommands.js"; + +test("getPipCommandForArgs", async (t) => { + await t.test("should return null for empty args", () => { + assert.strictEqual(getPipCommandForArgs([]), null); + }); + + await t.test("should return null for null args", () => { + assert.strictEqual(getPipCommandForArgs(null), null); + }); + + await t.test("should return the first non-flag argument", () => { + assert.strictEqual(getPipCommandForArgs(["install"]), "install"); + }); + + await t.test("should skip flags and return command", () => { + assert.strictEqual( + getPipCommandForArgs(["-v", "--verbose", "install"]), + "install" + ); + }); + + await t.test("should return install command", () => { + assert.strictEqual( + getPipCommandForArgs(["install", "requests"]), + "install" + ); + }); + + await t.test("should return uninstall command", () => { + assert.strictEqual( + getPipCommandForArgs(["uninstall", "requests"]), + "uninstall" + ); + }); + + await t.test("should return null if only flags", () => { + assert.strictEqual(getPipCommandForArgs(["--version", "-v"]), null); + }); +}); + +test("hasDryRunArg", async (t) => { + await t.test("should return false for empty args", () => { + assert.strictEqual(hasDryRunArg([]), false); + }); + + await t.test("should return true if --dry-run is present", () => { + assert.strictEqual(hasDryRunArg(["install", "--dry-run", "requests"]), true); + }); + + await t.test("should return false if --dry-run is not present", () => { + assert.strictEqual(hasDryRunArg(["install", "requests"]), false); + }); + + await t.test("should return true for --dry-run with other flags", () => { + assert.strictEqual( + hasDryRunArg(["install", "-v", "--dry-run", "--upgrade", "requests"]), + true + ); + }); +}); + +test("command constants", async (t) => { + await t.test("should have correct install command", () => { + assert.strictEqual(pipInstallCommand, "install"); + }); + + await t.test("should have correct download command", () => { + assert.strictEqual(pipDownloadCommand, "download"); + }); + + await t.test("should have correct wheel command", () => { + assert.strictEqual(pipWheelCommand, "wheel"); + }); + + await t.test("should have correct uninstall command", () => { + assert.strictEqual(pipUninstallCommand, "uninstall"); + }); +});