From f10749923a34a38d827a803e06612cad005f4a66 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 17 Jul 2025 10:08:49 +0200 Subject: [PATCH] Implement pnpm and pnpx support --- bin/aikido-pnpm.js | 8 + bin/aikido-pnpx.js | 8 + package-lock.json | 2 + package.json | 5 +- src/packagemanager/_shared/matchesCommand.js | 13 + src/packagemanager/currentPackageManager.js | 8 + .../pnpm/createPackageManager.js | 46 +++ .../commandArgumentScanner.js | 28 ++ .../parsing/parsePackagesFromArguments.js | 88 +++++ .../parsePackagesFromArguments.spec.js | 138 +++++++ src/packagemanager/pnpm/runPnpmCommand.js | 24 ++ src/shell-integration/helpers.js | 2 + src/shell-integration/setup.spec.js | 350 +++++++++++++----- src/shell-integration/teardown.spec.js | 179 ++++++--- 14 files changed, 754 insertions(+), 145 deletions(-) create mode 100755 bin/aikido-pnpm.js create mode 100755 bin/aikido-pnpx.js create mode 100644 src/packagemanager/_shared/matchesCommand.js create mode 100644 src/packagemanager/pnpm/createPackageManager.js create mode 100644 src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js create mode 100644 src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js create mode 100644 src/packagemanager/pnpm/parsing/parsePackagesFromArguments.spec.js create mode 100644 src/packagemanager/pnpm/runPnpmCommand.js diff --git a/bin/aikido-pnpm.js b/bin/aikido-pnpm.js new file mode 100755 index 0000000..e7bac47 --- /dev/null +++ b/bin/aikido-pnpm.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; + +const packageManagerName = "pnpm"; +initializePackageManager(packageManagerName, process.versions.node); +await main(process.argv.slice(2)); diff --git a/bin/aikido-pnpx.js b/bin/aikido-pnpx.js new file mode 100755 index 0000000..25884ce --- /dev/null +++ b/bin/aikido-pnpx.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; + +const packageManagerName = "pnpx"; +initializePackageManager(packageManagerName, process.versions.node); +await main(process.argv.slice(2)); diff --git a/package-lock.json b/package-lock.json index 4335bd2..260ee8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "bin": { "aikido-npm": "bin/aikido-npm.js", "aikido-npx": "bin/aikido-npx.js", + "aikido-pnpm": "bin/aikido-pnpm.js", + "aikido-pnpx": "bin/aikido-pnpx.js", "aikido-yarn": "bin/aikido-yarn.js", "safe-chain": "bin/safe-chain.js" }, diff --git a/package.json b/package.json index 8a9241f..7bd518b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "aikido-npm": "bin/aikido-npm.js", "aikido-npx": "bin/aikido-npx.js", "aikido-yarn": "bin/aikido-yarn.js", + "aikido-pnpm": "bin/aikido-pnpm.js", + "aikido-pnpx": "bin/aikido-pnpx.js", "safe-chain": "bin/safe-chain.js" }, "type": "module", @@ -40,5 +42,6 @@ "bugs": { "url": "https://github.com/AikidoSec/safe-chain/issues" }, - "homepage": "https://github.com/AikidoSec/safe-chain#readme" + "homepage": "https://github.com/AikidoSec/safe-chain#readme", + "packageManager": "npm@11.4.1+sha512.fcee43884166b6f9c5d04535fb95650e9708b6948a1f797eddf40e9778646778a518dfa32651b1c62ff36f4ac42becf177ca46ca27d53f24b539190c8d91802b" } diff --git a/src/packagemanager/_shared/matchesCommand.js b/src/packagemanager/_shared/matchesCommand.js new file mode 100644 index 0000000..d72caca --- /dev/null +++ b/src/packagemanager/_shared/matchesCommand.js @@ -0,0 +1,13 @@ +export function matchesCommand(args, ...commandArgs) { + if (args.length < commandArgs.length) { + return false; + } + + for (var i = 0; i < commandArgs.length; i++) { + if (args[i].toLowerCase() !== commandArgs[i].toLowerCase()) { + return false; + } + } + + return true; +} diff --git a/src/packagemanager/currentPackageManager.js b/src/packagemanager/currentPackageManager.js index 62e365a..9497a20 100644 --- a/src/packagemanager/currentPackageManager.js +++ b/src/packagemanager/currentPackageManager.js @@ -1,5 +1,9 @@ import { createNpmPackageManager } from "./npm/createPackageManager.js"; import { createNpxPackageManager } from "./npx/createPackageManager.js"; +import { + createPnpmPackageManager, + createPnpxPackageManager, +} from "./pnpm/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js"; const state = { @@ -13,6 +17,10 @@ export function initializePackageManager(packageManagerName, version) { state.packageManagerName = createNpxPackageManager(); } else if (packageManagerName === "yarn") { state.packageManagerName = createYarnPackageManager(); + } else if (packageManagerName === "pnpm") { + state.packageManagerName = createPnpmPackageManager(); + } else if (packageManagerName === "pnpx") { + state.packageManagerName = createPnpxPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/src/packagemanager/pnpm/createPackageManager.js b/src/packagemanager/pnpm/createPackageManager.js new file mode 100644 index 0000000..763d920 --- /dev/null +++ b/src/packagemanager/pnpm/createPackageManager.js @@ -0,0 +1,46 @@ +import { matchesCommand } from "../_shared/matchesCommand.js"; +import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js"; +import { runPnpmCommand } from "./runPnpmCommand.js"; + +const scanner = commandArgumentScanner(); + +export function createPnpmPackageManager() { + return { + getWarningMessage: () => null, + runCommand: (args) => runPnpmCommand(args, "pnpm"), + isSupportedCommand: (args) => + matchesCommand(args, "add") || + matchesCommand(args, "update") || + matchesCommand(args, "upgrade") || + matchesCommand(args, "up") || + // dlx does not always come in the first position + // eg: pnpm --package=yo --package=generator-webapp dlx yo webapp + // documentation: https://pnpm.io/cli/dlx#--package-name + args.includes("dlx"), + getDependencyUpdatesForCommand: (args) => + getDependencyUpdatesForCommand(args, false), + }; +} + +export function createPnpxPackageManager() { + return { + getWarningMessage: () => null, + runCommand: (args) => runPnpmCommand(args, "pnpx"), + isSupportedCommand: () => true, + getDependencyUpdatesForCommand: (args) => + getDependencyUpdatesForCommand(args, true), + }; +} + +function getDependencyUpdatesForCommand(args, isPnpx) { + if (isPnpx) { + return scanner.scan(args); + } + if (args.includes("dlx")) { + // dlx is not always the first argument (eg: `pnpm --package=yo --package=generator-webapp dlx yo webapp`) + // so we need to filter it out instead of slicing the array + // documentation: https://pnpm.io/cli/dlx#--package-name + return scanner.scan(args.filter((arg) => arg !== "dlx")); + } + return scanner.scan(args.slice(1)); +} diff --git a/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js b/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js new file mode 100644 index 0000000..c184b38 --- /dev/null +++ b/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js @@ -0,0 +1,28 @@ +import { resolvePackageVersion } from "../../../api/npmApi.js"; +import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; + +export function commandArgumentScanner() { + return { + scan: (args) => scanDependencies(args), + shouldScan: () => true, // There's no dry run for pnpm, so we always scan + }; +} + +async function scanDependencies(args) { + const changes = []; + const packageUpdates = parsePackagesFromArguments(args); + + for (const packageUpdate of packageUpdates) { + var exactVersion = await resolvePackageVersion( + packageUpdate.name, + packageUpdate.version + ); + if (exactVersion) { + packageUpdate.version = exactVersion; + } + + changes.push({ ...packageUpdate, type: "add" }); + } + + return changes; +} diff --git a/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js b/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js new file mode 100644 index 0000000..d0383c2 --- /dev/null +++ b/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js @@ -0,0 +1,88 @@ +export function parsePackagesFromArguments(args) { + const changes = []; + let defaultTag = "latest"; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const option = getOption(arg); + + if (option) { + // If the option has a parameter, skip the next argument as well + i += option.numberOfParameters; + + continue; + } + + const packageDetails = parsePackagename(arg, defaultTag); + if (packageDetails) { + changes.push(packageDetails); + } + } + + return changes; +} + +function getOption(arg) { + if (isOptionWithParameter(arg)) { + return { + name: arg, + numberOfParameters: 1, + }; + } + + // Arguments starting with "-" or "--" are considered options + // except for "--package=" which contains the package name + if (arg.startsWith("-") && !arg.startsWith("--package=")) { + return { + name: arg, + numberOfParameters: 0, + }; + } + + return undefined; +} + +function isOptionWithParameter(arg) { + const optionsWithParameters = ["--C", "--dir"]; + + return optionsWithParameters.includes(arg); +} + +function parsePackagename(arg, defaultTag) { + // format can be --package=name@version + // in that case, we need to remove the --package= part + if (arg.startsWith("--package=")) { + arg = arg.slice(10); + } + + arg = removeAlias(arg); + + // Split at the last "@" to separate the package name and version + const lastAtIndex = arg.lastIndexOf("@"); + + let name, version; + // The index of the last "@" should be greater than 0 + // If the index is 0, it means the package name starts with "@" (eg: "@aikidosec/package-name") + if (lastAtIndex > 0) { + name = arg.slice(0, lastAtIndex); + version = arg.slice(lastAtIndex + 1); + } else { + name = arg; + version = defaultTag; // No tag specified (eg: "http-server"), use the default tag + } + + return { + name, + version, + }; +} + +function removeAlias(arg) { + // removes the alias. + // Eg.: server@npm:http-server@latest becomes http-server@latest + const aliasIndex = arg.indexOf("@npm:"); + if (aliasIndex !== -1) { + return arg.slice(aliasIndex + 5); + } + return arg; +} diff --git a/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.spec.js b/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.spec.js new file mode 100644 index 0000000..be6812b --- /dev/null +++ b/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.spec.js @@ -0,0 +1,138 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { parsePackagesFromArguments } from "./parsePackagesFromArguments.js"; + +describe("standardYarnArgumentParser", () => { + it("should return an empty array for no changes", () => { + const args = []; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, []); + }); + + it("should return an array of changes for one package", () => { + const args = ["axios@1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); + + it("should return the package with latest tag if absent", () => { + const args = ["axios"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "latest" }]); + }); + + it("should return the package with latest tag if the version is absent and package starts with @", () => { + const args = ["@aikidosec/package-name"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "@aikidosec/package-name", version: "latest" }, + ]); + }); + + it("should return the package with the specified tag if the package starts with @ and includes the version", () => { + const args = ["@aikidosec/package-name@1.0.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "@aikidosec/package-name", version: "1.0.0" }, + ]); + }); + + it("should only return all packages", () => { + const args = ["axios", "jest"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "axios", version: "latest" }, + { name: "jest", version: "latest" }, + ]); + }); + + it("should ignore options with parameters and return an array of changes", () => { + const args = ["--C", "/Users/johnsmith/dev/project", "axios@1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); + + it("should parse version even for aliased packages", () => { + const args = ["server@npm:axios@1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); + + it("should parse scoped packages", () => { + const args = ["@scope/package@1.0.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "@scope/package", version: "1.0.0" }]); + }); + + it("should parse packages with version ranges", () => { + const args = ["axios@^1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]); + }); + + it("should parse package folders", () => { + const args = ["./local-package"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]); + }); + + it("should parse tarballs", () => { + const args = ["file:./local-package.tgz"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "file:./local-package.tgz", version: "latest" }, + ]); + }); + + it("should parse tarball URLs", () => { + const args = ["https://example.com/local-package.tgz"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "https://example.com/local-package.tgz", version: "latest" }, + ]); + }); + + it("should parse git URLs", () => { + const args = ["git://github.com/http-party/http-server"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "git://github.com/http-party/http-server", version: "latest" }, + ]); + }); + + it("should parse packages with --package={packageName}", () => { + const args = ["--package=axios@1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); +}); diff --git a/src/packagemanager/pnpm/runPnpmCommand.js b/src/packagemanager/pnpm/runPnpmCommand.js new file mode 100644 index 0000000..37e7f3b --- /dev/null +++ b/src/packagemanager/pnpm/runPnpmCommand.js @@ -0,0 +1,24 @@ +import { spawnSync } from "child_process"; +import { ui } from "../../environment/userInteraction.js"; + +export function runPnpmCommand(args, toolName = "pnpm") { + try { + let result; + + if (toolName === "pnpm") { + result = spawnSync("pnpm", args, { stdio: "inherit" }); + } else if (toolName === "pnpx") { + result = spawnSync("pnpx", args, { stdio: "inherit" }); + } else { + throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`); + } + + if (result.status !== null) { + return { status: result.status }; + } + } catch (error) { + ui.writeError("Error executing command:", error.message); + return { status: 1 }; + } + return { status: 0 }; +} diff --git a/src/shell-integration/helpers.js b/src/shell-integration/helpers.js index c9b4c5e..76714ef 100644 --- a/src/shell-integration/helpers.js +++ b/src/shell-integration/helpers.js @@ -2,6 +2,8 @@ const knownAikidoTools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "npx", aikidoCommand: "aikido-npx" }, { tool: "yarn", aikidoCommand: "aikido-yarn" }, + { tool: "pnpm", aikidoCommand: "aikido-pnpm" }, + { tool: "pnpx", aikidoCommand: "aikido-pnpx" }, // When adding a new tool here, also update the expected alias in the tests (shellIntegration.spec.js) // and add the documentation for the new tool in the README.md ]; diff --git a/src/shell-integration/setup.spec.js b/src/shell-integration/setup.spec.js index cc5ae01..3c11136 100644 --- a/src/shell-integration/setup.spec.js +++ b/src/shell-integration/setup.spec.js @@ -6,45 +6,79 @@ import { getAliases } from "./helpers.js"; import { readOrCreateStartupFile, appendAliasesToFile } from "./setup.js"; describe("setupShell", () => { - function runSetupTestsForEnvironment(shell, startupExtension, expectedAliases) { + function runSetupTestsForEnvironment( + shell, + startupExtension, + expectedAliases + ) { describe(`${shell} shell setup`, () => { it(`should add aliases to ${shell} file`, () => { const lines = [`#!/usr/bin/env ${shell}`, "", "alias cls='clear'"]; const filePath = createShellStartupScript(lines, startupExtension); - + const aliases = getAliases(filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); - + const result = appendAliasesToFile(aliases, fileContent, filePath); - - assert.strictEqual(result.addedCount, 3, "Should add 3 aliases"); - assert.strictEqual(result.existingCount, 0, "Should find no existing aliases"); - assert.strictEqual(result.failedCount, 0, "Should have no failed aliases"); - + + assert.strictEqual( + result.addedCount, + expectedAliases.length, + `Should add ${expectedAliases.length} aliases` + ); + assert.strictEqual( + result.existingCount, + 0, + "Should find no existing aliases" + ); + assert.strictEqual( + result.failedCount, + 0, + "Should have no failed aliases" + ); + const updatedContent = readAndDeleteFile(filePath); for (const alias of expectedAliases) { - assert.ok(updatedContent.includes(alias), `Alias "${alias}" should be added`); + assert.ok( + updatedContent.includes(alias), + `Alias "${alias}" should be added` + ); } - assert.ok(updatedContent.includes("alias cls='clear'"), "Original aliases should remain"); + assert.ok( + updatedContent.includes("alias cls='clear'"), + "Original aliases should remain" + ); }); it(`should not add aliases if they already exist in ${shell} file`, () => { const lines = [`#!/usr/bin/env ${shell}`, "", ...expectedAliases]; const filePath = createShellStartupScript(lines, startupExtension); - + const aliases = getAliases(filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); - + const result = appendAliasesToFile(aliases, fileContent, filePath); - + assert.strictEqual(result.addedCount, 0, "Should add 0 aliases"); - assert.strictEqual(result.existingCount, 3, "Should find 3 existing aliases"); - assert.strictEqual(result.failedCount, 0, "Should have no failed aliases"); - + assert.strictEqual( + result.existingCount, + expectedAliases.length, + `Should find ${expectedAliases.length} existing aliases` + ); + assert.strictEqual( + result.failedCount, + 0, + "Should have no failed aliases" + ); + const updatedContent = readAndDeleteFile(filePath); // Count occurrences to ensure no duplicates were added for (const alias of expectedAliases) { - assert.strictEqual(countOccurrences(updatedContent, alias), 1, `Alias "${alias}" should appear exactly once`); + assert.strictEqual( + countOccurrences(updatedContent, alias), + 1, + `Alias "${alias}" should appear exactly once` + ); } }); @@ -54,78 +88,144 @@ describe("setupShell", () => { if (fs.existsSync(filePath)) { fs.rmSync(filePath, { force: true }); } - + // Test readOrCreateStartupFile function const fileContent = readOrCreateStartupFile(filePath); - assert.strictEqual(fileContent, "", "Should return empty string for new file"); + assert.strictEqual( + fileContent, + "", + "Should return empty string for new file" + ); assert.ok(fs.existsSync(filePath), "File should be created"); - + // Test adding aliases to the newly created file const aliases = getAliases(filePath); const result = appendAliasesToFile(aliases, fileContent, filePath); - - assert.strictEqual(result.addedCount, 3, "Should add 3 aliases"); - assert.strictEqual(result.existingCount, 0, "Should find no existing aliases"); - assert.strictEqual(result.failedCount, 0, "Should have no failed aliases"); - + + assert.strictEqual( + result.addedCount, + expectedAliases.length, + `Should add ${expectedAliases.length} aliases` + ); + assert.strictEqual( + result.existingCount, + 0, + "Should find no existing aliases" + ); + assert.strictEqual( + result.failedCount, + 0, + "Should have no failed aliases" + ); + const updatedContent = readAndDeleteFile(filePath); for (const alias of expectedAliases) { - assert.ok(updatedContent.includes(alias), `Alias "${alias}" should be added`); + assert.ok( + updatedContent.includes(alias), + `Alias "${alias}" should be added` + ); } }); it(`should add aliases only once when called multiple times for ${shell}`, () => { const lines = [`#!/usr/bin/env ${shell}`, ""]; const filePath = createShellStartupScript(lines, startupExtension); - + const aliases = getAliases(filePath); - + // First call - should add aliases let fileContent = fs.readFileSync(filePath, "utf-8"); const result1 = appendAliasesToFile(aliases, fileContent, filePath); - assert.strictEqual(result1.addedCount, 3, "First call should add 3 aliases"); - + assert.strictEqual( + result1.addedCount, + expectedAliases.length, + `First call should add ${expectedAliases.length} aliases` + ); + // Second call - should detect existing aliases fileContent = fs.readFileSync(filePath, "utf-8"); const result2 = appendAliasesToFile(aliases, fileContent, filePath); - assert.strictEqual(result2.addedCount, 0, "Second call should add 0 aliases"); - assert.strictEqual(result2.existingCount, 3, "Second call should find 3 existing aliases"); - + assert.strictEqual( + result2.addedCount, + 0, + "Second call should add 0 aliases" + ); + assert.strictEqual( + result2.existingCount, + expectedAliases.length, + `Second call should find ${expectedAliases.length} existing aliases` + ); + const updatedContent = readAndDeleteFile(filePath); for (const alias of expectedAliases) { - assert.strictEqual(countOccurrences(updatedContent, alias), 1, `Alias "${alias}" should appear exactly once`); + assert.strictEqual( + countOccurrences(updatedContent, alias), + 1, + `Alias "${alias}" should appear exactly once` + ); } }); it(`should use real getAliases() for ${shell} file`, () => { const filePath = `${tmpdir()}/test${startupExtension}`; const aliases = getAliases(filePath); - + // Verify we get the expected aliases for this shell type - assert.strictEqual(aliases.length, 3, "Should get 3 aliases (npm, npx, yarn)"); + assert.strictEqual( + aliases.length, + expectedAliases.length, + "Should get all aliases (npm, npx, yarn)" + ); for (let i = 0; i < aliases.length; i++) { - assert.strictEqual(aliases[i], expectedAliases[i], `Alias ${i} should match expected format`); + assert.strictEqual( + aliases[i], + expectedAliases[i], + `Alias ${i} should match expected format` + ); } }); it(`should handle mixed scenario - some existing, some new for ${shell}`, () => { - const lines = [`#!/usr/bin/env ${shell}`, "", expectedAliases[0], "alias other='command'"]; + const lines = [ + `#!/usr/bin/env ${shell}`, + "", + expectedAliases[0], + "alias other='command'", + ]; const filePath = createShellStartupScript(lines, startupExtension); - + const aliases = getAliases(filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); - + const result = appendAliasesToFile(aliases, fileContent, filePath); - - assert.strictEqual(result.addedCount, 2, "Should add 2 new aliases"); - assert.strictEqual(result.existingCount, 1, "Should find 1 existing alias"); - assert.strictEqual(result.failedCount, 0, "Should have no failed aliases"); - + + assert.strictEqual( + result.addedCount, + expectedAliases.length - 1, + `Should add ${expectedAliases.length - 1} aliases` + ); + assert.strictEqual( + result.existingCount, + 1, + "Should find 1 existing alias" + ); + assert.strictEqual( + result.failedCount, + 0, + "Should have no failed aliases" + ); + const updatedContent = readAndDeleteFile(filePath); for (const alias of expectedAliases) { - assert.ok(updatedContent.includes(alias), `Alias "${alias}" should be present`); + assert.ok( + updatedContent.includes(alias), + `Alias "${alias}" should be present` + ); } - assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain"); + assert.ok( + updatedContent.includes("alias other='command'"), + "Other aliases should remain" + ); }); }); } @@ -134,65 +234,91 @@ describe("setupShell", () => { runSetupTestsForEnvironment("bash", ".bashrc", [ "alias npm='aikido-npm'", "alias npx='aikido-npx'", - "alias yarn='aikido-yarn'" + "alias yarn='aikido-yarn'", + "alias pnpm='aikido-pnpm'", + "alias pnpx='aikido-pnpx'", ]); runSetupTestsForEnvironment("zsh", ".zshrc", [ "alias npm='aikido-npm'", "alias npx='aikido-npx'", - "alias yarn='aikido-yarn'" + "alias yarn='aikido-yarn'", + "alias pnpm='aikido-pnpm'", + "alias pnpx='aikido-pnpx'", ]); runSetupTestsForEnvironment("fish", ".fish", [ 'alias npm "aikido-npm"', 'alias npx "aikido-npx"', - 'alias yarn "aikido-yarn"' + 'alias yarn "aikido-yarn"', + 'alias pnpm "aikido-pnpm"', + 'alias pnpx "aikido-pnpx"', ]); runSetupTestsForEnvironment("pwsh", ".ps1", [ "Set-Alias npm aikido-npm", "Set-Alias npx aikido-npx", - "Set-Alias yarn aikido-yarn" + "Set-Alias yarn aikido-yarn", + "Set-Alias pnpm aikido-pnpm", + "Set-Alias pnpx aikido-pnpx", ]); describe("readOrCreateStartupFile", () => { it("should read existing file content", () => { const lines = ["#!/usr/bin/env bash", "", "alias test='echo test'"]; const filePath = createShellStartupScript(lines, ".bashrc"); - + const content = readOrCreateStartupFile(filePath); - - assert.ok(content.includes("#!/usr/bin/env bash"), "Should contain shebang"); - assert.ok(content.includes("alias test='echo test'"), "Should contain existing aliases"); - + + assert.ok( + content.includes("#!/usr/bin/env bash"), + "Should contain shebang" + ); + assert.ok( + content.includes("alias test='echo test'"), + "Should contain existing aliases" + ); + // Cleanup fs.rmSync(filePath, { force: true }); }); it("should create file if it doesn't exist", () => { - const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`; + const filePath = `${tmpdir()}/test-${Math.random() + .toString(36) + .substring(2, 15)}.bashrc`; if (fs.existsSync(filePath)) { fs.rmSync(filePath, { force: true }); } - + const content = readOrCreateStartupFile(filePath); - - assert.strictEqual(content, "", "Should return empty string for new file"); + + assert.strictEqual( + content, + "", + "Should return empty string for new file" + ); assert.ok(fs.existsSync(filePath), "File should be created"); - + // Cleanup fs.rmSync(filePath, { force: true }); }); it("should handle empty existing file", () => { - const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`; + const filePath = `${tmpdir()}/test-${Math.random() + .toString(36) + .substring(2, 15)}.bashrc`; fs.writeFileSync(filePath, "", "utf-8"); - + const content = readOrCreateStartupFile(filePath); - - assert.strictEqual(content, "", "Should return empty string for empty file"); + + assert.strictEqual( + content, + "", + "Should return empty string for empty file" + ); assert.ok(fs.existsSync(filePath), "File should still exist"); - + // Cleanup fs.rmSync(filePath, { force: true }); }); @@ -203,15 +329,22 @@ describe("setupShell", () => { const lines = ["#!/usr/bin/env bash", "", "alias test='echo test'"]; const filePath = createShellStartupScript(lines, ".bashrc"); const fileContent = fs.readFileSync(filePath, "utf-8"); - + const result = appendAliasesToFile([], fileContent, filePath); - + assert.strictEqual(result.addedCount, 0, "Should add 0 aliases"); - assert.strictEqual(result.existingCount, 0, "Should find 0 existing aliases"); + assert.strictEqual( + result.existingCount, + 0, + "Should find 0 existing aliases" + ); assert.strictEqual(result.failedCount, 0, "Should have 0 failed aliases"); - + const updatedContent = readAndDeleteFile(filePath); - assert.ok(updatedContent.includes("alias test='echo test'"), "Original content should remain"); + assert.ok( + updatedContent.includes("alias test='echo test'"), + "Original content should remain" + ); }); it("should handle partial substring matches correctly", () => { @@ -219,38 +352,57 @@ describe("setupShell", () => { "#!/usr/bin/env bash", "", "alias npmx='some-other-command'", // Contains 'npm' but shouldn't match 'alias npm=' - "alias test='echo test'" + "alias test='echo test'", ]; const filePath = createShellStartupScript(lines, ".bashrc"); const fileContent = fs.readFileSync(filePath, "utf-8"); - + const aliases = ["alias npm='aikido-npm'"]; const result = appendAliasesToFile(aliases, fileContent, filePath); - + assert.strictEqual(result.addedCount, 1, "Should add 1 alias (npm)"); - assert.strictEqual(result.existingCount, 0, "Should find 0 existing aliases"); + assert.strictEqual( + result.existingCount, + 0, + "Should find 0 existing aliases" + ); assert.strictEqual(result.failedCount, 0, "Should have 0 failed aliases"); - + const updatedContent = readAndDeleteFile(filePath); - assert.ok(updatedContent.includes("alias npm='aikido-npm'"), "npm alias should be added"); - assert.ok(updatedContent.includes("alias npmx='some-other-command'"), "npmx alias should remain"); + assert.ok( + updatedContent.includes("alias npm='aikido-npm'"), + "npm alias should be added" + ); + assert.ok( + updatedContent.includes("alias npmx='some-other-command'"), + "npmx alias should remain" + ); }); it("should handle file with only whitespace", () => { - const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`; + const filePath = `${tmpdir()}/test-${Math.random() + .toString(36) + .substring(2, 15)}.bashrc`; const fileContent = `${EOL}${EOL} ${EOL}`; fs.writeFileSync(filePath, fileContent, "utf-8"); - + const aliases = ["alias npm='aikido-npm'"]; const result = appendAliasesToFile(aliases, fileContent, filePath); - + assert.strictEqual(result.addedCount, 1, "Should add 1 alias"); - assert.strictEqual(result.existingCount, 0, "Should find 0 existing aliases"); + assert.strictEqual( + result.existingCount, + 0, + "Should find 0 existing aliases" + ); assert.strictEqual(result.failedCount, 0, "Should have 0 failed aliases"); - + const updatedContent = fs.readFileSync(filePath, "utf-8"); - assert.ok(updatedContent.includes("alias npm='aikido-npm'"), "Alias should be added"); - + assert.ok( + updatedContent.includes("alias npm='aikido-npm'"), + "Alias should be added" + ); + // Cleanup fs.rmSync(filePath, { force: true }); }); @@ -258,21 +410,31 @@ describe("setupShell", () => { describe("appendAliasesToFile error handling", () => { it("should handle file permission errors gracefully", () => { - const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`; + const filePath = `${tmpdir()}/test-${Math.random() + .toString(36) + .substring(2, 15)}.bashrc`; fs.writeFileSync(filePath, "#!/usr/bin/env bash", "utf-8"); - + // Make file read-only to simulate permission error fs.chmodSync(filePath, 0o444); - + const aliases = ["alias npm='aikido-npm'"]; const fileContent = fs.readFileSync(filePath, "utf-8"); - + const result = appendAliasesToFile(aliases, fileContent, filePath); - - assert.strictEqual(result.addedCount, 0, "Should add 0 aliases due to permission error"); - assert.strictEqual(result.existingCount, 0, "Should find 0 existing aliases"); + + assert.strictEqual( + result.addedCount, + 0, + "Should add 0 aliases due to permission error" + ); + assert.strictEqual( + result.existingCount, + 0, + "Should find 0 existing aliases" + ); assert.strictEqual(result.failedCount, 1, "Should have 1 failed alias"); - + // Restore permissions and cleanup fs.chmodSync(filePath, 0o644); fs.rmSync(filePath, { force: true }); @@ -301,4 +463,4 @@ function countOccurrences(lines, searchString) { } } return count; -} \ No newline at end of file +} diff --git a/src/shell-integration/teardown.spec.js b/src/shell-integration/teardown.spec.js index 3f301c2..5077a99 100644 --- a/src/shell-integration/teardown.spec.js +++ b/src/shell-integration/teardown.spec.js @@ -6,41 +6,64 @@ import { getAliases } from "./helpers.js"; import { removeAliasesFromFile } from "./teardown.js"; describe("teardown", () => { - function runRemovalTestsForEnvironment(shell, startupExtension, expectedAliases) { + function runRemovalTestsForEnvironment( + shell, + startupExtension, + expectedAliases + ) { describe(`${shell} shell removal`, () => { it(`should remove aliases from ${shell} file`, () => { const lines = [`#!/usr/bin/env ${shell}`, "", ...expectedAliases, ""]; const filePath = createShellStartupScript(lines, startupExtension); - + // Test the removeAliasesFromFile function directly const aliases = getAliases(filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); - + const result = removeAliasesFromFile(aliases, fileContent, filePath); - - assert.strictEqual(result.removedCount, 3, "Should remove 3 aliases"); + + assert.strictEqual( + result.removedCount, + expectedAliases.length, + "Should remove all aliases" + ); assert.strictEqual(result.notFoundCount, 0, "Should find all aliases"); - + const updatedContent = readAndDeleteFile(filePath); for (const alias of expectedAliases) { - assert.ok(!updatedContent.includes(alias), `Alias "${alias}" should be removed`); + assert.ok( + !updatedContent.includes(alias), + `Alias "${alias}" should be removed` + ); } }); it(`should handle file with no aliases for ${shell}`, () => { - const lines = [`#!/usr/bin/env ${shell}`, "", "alias other='command'", ""]; + const lines = [ + `#!/usr/bin/env ${shell}`, + "", + "alias other='command'", + "", + ]; const filePath = createShellStartupScript(lines, startupExtension); - + const aliases = getAliases(filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); - + const result = removeAliasesFromFile(aliases, fileContent, filePath); - + assert.strictEqual(result.removedCount, 0, "Should remove 0 aliases"); - assert.strictEqual(result.notFoundCount, 3, "Should report 3 aliases not found"); - + assert.strictEqual( + result.notFoundCount, + expectedAliases.length, + "Should report all aliases not found" + ); + const updatedContent = readAndDeleteFile(filePath); - assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain unchanged"); + assert.ok( + updatedContent.includes("alias other='command'"), + "Other aliases should remain unchanged" + ); }); it(`should remove duplicate aliases from ${shell} file`, () => { @@ -50,33 +73,51 @@ describe("teardown", () => { ...expectedAliases, "alias other='command'", ...expectedAliases, // duplicates - "" + "", ]; const filePath = createShellStartupScript(lines, startupExtension); - + const aliases = getAliases(filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); - + const result = removeAliasesFromFile(aliases, fileContent, filePath); - - assert.strictEqual(result.removedCount, 3, "Should remove 3 aliases (counting duplicates as single removal)"); + + assert.strictEqual( + result.removedCount, + expectedAliases.length, + "Should remove all aliases (counting duplicates as single removal)" + ); assert.strictEqual(result.notFoundCount, 0, "Should find all aliases"); - + const updatedContent = readAndDeleteFile(filePath); for (const alias of expectedAliases) { - assert.ok(!updatedContent.includes(alias), `Alias "${alias}" should be completely removed`); + assert.ok( + !updatedContent.includes(alias), + `Alias "${alias}" should be completely removed` + ); } - assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain"); + assert.ok( + updatedContent.includes("alias other='command'"), + "Other aliases should remain" + ); }); it(`should use real getAliases() for ${shell} file`, () => { const filePath = `${tmpdir()}/test${startupExtension}`; const aliases = getAliases(filePath); - + // Verify we get the expected aliases for this shell type - assert.strictEqual(aliases.length, 3, "Should get 3 aliases (npm, npx, yarn)"); + assert.strictEqual( + aliases.length, + expectedAliases.length, + "Should get all aliases (npm, npx, yarn)" + ); for (let i = 0; i < aliases.length; i++) { - assert.strictEqual(aliases[i], expectedAliases[i], `Alias ${i} should match expected format`); + assert.strictEqual( + aliases[i], + expectedAliases[i], + `Alias ${i} should match expected format` + ); } }); @@ -86,21 +127,31 @@ describe("teardown", () => { "", expectedAliases[0], // Only first alias "alias other='command'", - "" + "", ]; const filePath = createShellStartupScript(lines, startupExtension); - + const aliases = getAliases(filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); - + const result = removeAliasesFromFile(aliases, fileContent, filePath); - + assert.strictEqual(result.removedCount, 1, "Should remove 1 alias"); - assert.strictEqual(result.notFoundCount, 2, "Should report 2 aliases not found"); - + assert.strictEqual( + result.notFoundCount, + expectedAliases.length - 1, + "Should report all aliases not found" + ); + const updatedContent = readAndDeleteFile(filePath); - assert.ok(!updatedContent.includes(expectedAliases[0]), "First alias should be removed"); - assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain"); + assert.ok( + !updatedContent.includes(expectedAliases[0]), + "First alias should be removed" + ); + assert.ok( + updatedContent.includes("alias other='command'"), + "Other aliases should remain" + ); }); }); } @@ -109,39 +160,57 @@ describe("teardown", () => { runRemovalTestsForEnvironment("bash", ".bashrc", [ "alias npm='aikido-npm'", "alias npx='aikido-npx'", - "alias yarn='aikido-yarn'" + "alias yarn='aikido-yarn'", + "alias pnpm='aikido-pnpm'", + "alias pnpx='aikido-pnpx'", ]); runRemovalTestsForEnvironment("zsh", ".zshrc", [ "alias npm='aikido-npm'", "alias npx='aikido-npx'", - "alias yarn='aikido-yarn'" + "alias yarn='aikido-yarn'", + "alias pnpm='aikido-pnpm'", + "alias pnpx='aikido-pnpx'", ]); runRemovalTestsForEnvironment("fish", ".fish", [ 'alias npm "aikido-npm"', 'alias npx "aikido-npx"', - 'alias yarn "aikido-yarn"' + 'alias yarn "aikido-yarn"', + 'alias pnpm "aikido-pnpm"', + 'alias pnpx "aikido-pnpx"', ]); runRemovalTestsForEnvironment("pwsh", ".ps1", [ "Set-Alias npm aikido-npm", "Set-Alias npx aikido-npx", - "Set-Alias yarn aikido-yarn" + "Set-Alias yarn aikido-yarn", + "Set-Alias pnpm aikido-pnpm", + "Set-Alias pnpx aikido-pnpx", ]); describe("removeAliasesFromFile edge cases", () => { it("should handle empty file", () => { const aliases = ["alias npm='aikido-npm'"]; const fileContent = ""; - const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`; + const filePath = `${tmpdir()}/test-${Math.random() + .toString(36) + .substring(2, 15)}.bashrc`; fs.writeFileSync(filePath, fileContent, "utf-8"); - + const result = removeAliasesFromFile(aliases, fileContent, filePath); - - assert.strictEqual(result.removedCount, 0, "Should remove 0 aliases from empty file"); - assert.strictEqual(result.notFoundCount, 1, "Should report 1 alias not found"); - + + assert.strictEqual( + result.removedCount, + 0, + "Should remove 0 aliases from empty file" + ); + assert.strictEqual( + result.notFoundCount, + 1, + "Should report 1 alias not found" + ); + // Cleanup fs.rmSync(filePath, { force: true }); }); @@ -149,14 +218,24 @@ describe("teardown", () => { it("should handle file with only whitespace", () => { const aliases = ["alias npm='aikido-npm'"]; const fileContent = `${EOL}${EOL} ${EOL}`; - const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`; + const filePath = `${tmpdir()}/test-${Math.random() + .toString(36) + .substring(2, 15)}.bashrc`; fs.writeFileSync(filePath, fileContent, "utf-8"); - + const result = removeAliasesFromFile(aliases, fileContent, filePath); - - assert.strictEqual(result.removedCount, 0, "Should remove 0 aliases from whitespace-only file"); - assert.strictEqual(result.notFoundCount, 1, "Should report 1 alias not found"); - + + assert.strictEqual( + result.removedCount, + 0, + "Should remove 0 aliases from whitespace-only file" + ); + assert.strictEqual( + result.notFoundCount, + 1, + "Should report 1 alias not found" + ); + // Cleanup fs.rmSync(filePath, { force: true }); }); @@ -174,4 +253,4 @@ function readAndDeleteFile(filePath) { const fileContent = fs.readFileSync(filePath, "utf-8"); fs.rmSync(filePath, { force: true }); return fileContent.split(EOL); -} \ No newline at end of file +}