diff --git a/README.md b/README.md index 844b637..33dcc26 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Aikido Safe Chain -The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, or yarn. +The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm and pnpx. -The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), and [yarn](https://yarnpkg.com/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, or yarn from downloading or running the malware. +The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), and [pnpx](https://pnpm.io/cli/dlx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm or pnpx from downloading or running the malware. ![demo](https://aikido-production-staticfiles-public.s3.eu-west-1.amazonaws.com/safe-pkg.gif) @@ -11,7 +11,9 @@ Aikido Safe Chain works on Node.js version 18 and above and supports the followi - βœ… **npm** - βœ… **npx** - βœ… **yarn** -- 🚧 **pnpm** Coming soon +- βœ… **pnpm** +- βœ… **pnpx** +- 🚧 **bun** Coming soon # Usage @@ -28,20 +30,20 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: safe-chain setup ``` 3. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, and yarn are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm and pnpx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 4. **Verify the installation** by running: ```shell npm install eslint-js ``` - The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware. -When running `npm`, `npx`, or `yarn` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm` or `pnpx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command. ## How it works -The Aikido Safe Chain works by intercepting the npm, npx, and yarn commands and verifying the packages against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. +The Aikido Safe Chain works by intercepting the npm, npx, yarn, pnpm and pnpx commands and verifying the packages against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, and yarn commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which perform malware checks before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm and pnpx commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which perform malware checks before executing the original commands. We currently support: - βœ… **Bash** - βœ… **Zsh** 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/docs/shell-integration.md b/docs/shell-integration.md index 715344c..3243c20 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -2,7 +2,7 @@ ## Overview -The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`) with Aikido's security scanning functionality. This is achieved by adding shell aliases that redirect these commands to their Aikido-wrapped equivalents. +The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`) with Aikido's security scanning functionality. This is achieved by adding shell aliases that redirect these commands to their Aikido-wrapped equivalents. ## Supported Shells @@ -27,7 +27,7 @@ safe-chain setup This command: - Detects all supported shells on your system -- Adds aliases for `npm`, `npx`, and `yarn` to each shell's startup file +- Adds aliases for `npm`, `npx`, `yarn`, `pnpm` and `pnpx` to each shell's startup file ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the aliases are loaded correctly. @@ -75,7 +75,7 @@ The system modifies the following files based on your shell configuration: This means the aliases are working but the Aikido commands aren't installed or available in your PATH: - Make sure Aikido Safe Chain is properly installed on your system -- Verify the `aikido-npm`, `aikido-npx`, and `aikido-yarn` commands exist +- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm` and `aikido-pnpx` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -105,4 +105,4 @@ To verify the integration is working, follow these steps: 3. **If you need to remove aliases manually:** - Edit the same startup file from step 1 and delete any lines containing `aikido-npm`, `aikido-npx`, or `aikido-yarn`. + Edit the same startup file from step 1 and delete any lines containing `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm` or `aikido-pnpx`. 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..4ab4c95 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", 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..cc9b792 --- /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("standardPnpmArgumentParser", () => { + 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 0295858..7808c7e 100644 --- a/src/shell-integration/helpers.js +++ b/src/shell-integration/helpers.js @@ -1,4 +1,4 @@ -import { execSync, spawnSync } from "child_process"; +import { spawnSync } from "child_process"; import * as os from "os"; import fs from "fs"; @@ -6,29 +6,19 @@ export const knownAikidoTools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "npx", aikidoCommand: "aikido-npx" }, { tool: "yarn", aikidoCommand: "aikido-yarn" }, - // When adding a new tool here, also update the expected alias in the tests (shellIntegration.spec.js) + { tool: "pnpm", aikidoCommand: "aikido-pnpm" }, + { tool: "pnpx", aikidoCommand: "aikido-pnpx" }, + // When adding a new tool here, also update the expected alias in the tests (setup.spec.js, teardown.spec.js) // and add the documentation for the new tool in the README.md ]; export function doesExecutableExistOnSystem(executableName) { - try { - if (os.platform() === "win32") { - const result = spawnSync("where", [executableName], { stdio: "ignore" }); - return result.status === 0; - } else { - const result = spawnSync("which", [executableName], { stdio: "ignore" }); - return result.status === 0; - } - } catch { - return false; - } -} - -export function execAndGetOutput(command, shell) { - try { - return execSync(command, { encoding: "utf8", shell }).trim(); - } catch (error) { - throw new Error(`Command failed: ${command}. Error: ${error.message}`); + if (os.platform() === "win32") { + const result = spawnSync("where", [executableName], { stdio: "ignore" }); + return result.status === 0; + } else { + const result = spawnSync("which", [executableName], { stdio: "ignore" }); + return result.status === 0; } } diff --git a/src/shell-integration/setup.js b/src/shell-integration/setup.js index ce6a638..3f9bbb3 100644 --- a/src/shell-integration/setup.js +++ b/src/shell-integration/setup.js @@ -57,6 +57,7 @@ export async function setup() { function setupShell(shell) { let success = false; try { + shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases success = shell.setup(knownAikidoTools); } catch { success = false; diff --git a/src/shell-integration/shellDetection.js b/src/shell-integration/shellDetection.js index e1bb52c..d868f6f 100644 --- a/src/shell-integration/shellDetection.js +++ b/src/shell-integration/shellDetection.js @@ -3,15 +3,23 @@ import bash from "./supported-shells/bash.js"; import powershell from "./supported-shells/powershell.js"; import windowsPowershell from "./supported-shells/windowsPowershell.js"; import fish from "./supported-shells/fish.js"; +import { ui } from "../environment/userInteraction.js"; export function detectShells() { let possibleShells = [zsh, bash, powershell, windowsPowershell, fish]; let availableShells = []; - for (const shell of possibleShells) { - if (shell.isInstalled()) { - availableShells.push(shell); + try { + for (const shell of possibleShells) { + if (shell.isInstalled()) { + availableShells.push(shell); + } } + } catch (error) { + ui.writeError( + `We were not able to detect which shells are installed on your system. Please check your shell configuration. Error: ${error.message}` + ); + return []; } return availableShells; diff --git a/src/shell-integration/supported-shells/bash.js b/src/shell-integration/supported-shells/bash.js index 35d23c1..66b844d 100644 --- a/src/shell-integration/supported-shells/bash.js +++ b/src/shell-integration/supported-shells/bash.js @@ -1,9 +1,9 @@ import { addLineToFile, doesExecutableExistOnSystem, - execAndGetOutput, removeLinesMatchingPattern, } from "../helpers.js"; +import { execSync } from "child_process"; const shellName = "Bash"; const executableName = "bash"; @@ -13,19 +13,19 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -function teardown() { - const startupFile = execAndGetOutput(startupFileCommand, executableName); +function teardown(tools) { + const startupFile = getStartupFile(); - // Removes all aliases starting with "alias npm=", "alias npx=", or "alias yarn=" - // This will remove the safe-chain aliases for npm, npx, and yarn commands. - removeLinesMatchingPattern(startupFile, /^alias\s+(npm|npx|yarn)=/); + for (const { tool } of tools) { + // Remove any existing alias for the tool + removeLinesMatchingPattern(startupFile, new RegExp(`^alias\\s+${tool}=`)); + } return true; } function setup(tools) { - const startupFile = execAndGetOutput(startupFileCommand, executableName); - teardown(); + const startupFile = getStartupFile(); for (const { tool, aikidoCommand } of tools) { addLineToFile( @@ -37,6 +37,19 @@ function setup(tools) { return true; } +function getStartupFile() { + try { + return execSync(startupFileCommand, { + encoding: "utf8", + shell: executableName, + }).trim(); + } catch (error) { + throw new Error( + `Command failed: ${startupFileCommand}. Error: ${error.message}` + ); + } +} + export default { name: shellName, isInstalled, diff --git a/src/shell-integration/supported-shells/bash.spec.js b/src/shell-integration/supported-shells/bash.spec.js index 21be84e..ce666e5 100644 --- a/src/shell-integration/supported-shells/bash.spec.js +++ b/src/shell-integration/supported-shells/bash.spec.js @@ -3,6 +3,7 @@ import assert from "node:assert"; import { tmpdir } from "node:os"; import fs from "node:fs"; import path from "path"; +import { knownAikidoTools } from "../helpers.js"; describe("Bash shell integration", () => { let mockStartupFile; @@ -15,7 +16,6 @@ describe("Bash shell integration", () => { // Mock the helpers module mock.module("../helpers.js", { namedExports: { - execAndGetOutput: () => mockStartupFile, doesExecutableExistOnSystem: () => true, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { @@ -33,6 +33,13 @@ describe("Bash shell integration", () => { }, }); + // Mock child_process execSync + mock.module("child_process", { + namedExports: { + execSync: () => mockStartupFile, + }, + }); + // Import bash module after mocking bash = (await import("./bash.js")).default; }); @@ -63,7 +70,7 @@ describe("Bash shell integration", () => { const tools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "npx", aikidoCommand: "aikido-npx" }, - { tool: "yarn", aikidoCommand: "aikido-yarn" } + { tool: "yarn", aikidoCommand: "aikido-yarn" }, ]; const result = bash.setup(tools); @@ -81,22 +88,6 @@ describe("Bash shell integration", () => { ); }); - it("should call teardown before setup", () => { - // Pre-populate file with existing aliases - fs.writeFileSync( - mockStartupFile, - 'alias npm="old-npm"\nalias npx="old-npx"\n', - "utf-8" - ); - - const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }]; - bash.setup(tools); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes('alias npm="old-npm"')); - assert.ok(content.includes('alias npm="aikido-npm"')); - }); - it("should handle empty tools array", () => { const result = bash.setup([]); assert.strictEqual(result, true); @@ -122,7 +113,7 @@ describe("Bash shell integration", () => { fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = bash.teardown(); + const result = bash.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -138,7 +129,7 @@ describe("Bash shell integration", () => { fs.unlinkSync(mockStartupFile); } - const result = bash.teardown(); + const result = bash.teardown(knownAikidoTools); assert.strictEqual(result, true); }); @@ -151,7 +142,7 @@ describe("Bash shell integration", () => { fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = bash.teardown(); + const result = bash.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -177,7 +168,7 @@ describe("Bash shell integration", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, - { tool: "yarn", aikidoCommand: "aikido-yarn" } + { tool: "yarn", aikidoCommand: "aikido-yarn" }, ]; // Setup @@ -187,7 +178,7 @@ describe("Bash shell integration", () => { assert.ok(content.includes('alias yarn="aikido-yarn"')); // Teardown - bash.teardown(); + bash.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("alias npm=")); assert.ok(!content.includes("alias yarn=")); @@ -197,6 +188,7 @@ describe("Bash shell integration", () => { const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }]; bash.setup(tools); + bash.teardown(tools); bash.setup(tools); const content = fs.readFileSync(mockStartupFile, "utf-8"); diff --git a/src/shell-integration/supported-shells/fish.js b/src/shell-integration/supported-shells/fish.js index 429d351..fc6fc85 100644 --- a/src/shell-integration/supported-shells/fish.js +++ b/src/shell-integration/supported-shells/fish.js @@ -1,9 +1,9 @@ import { addLineToFile, doesExecutableExistOnSystem, - execAndGetOutput, removeLinesMatchingPattern, } from "../helpers.js"; +import { execSync } from "child_process"; const shellName = "Fish"; const executableName = "fish"; @@ -13,19 +13,22 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -function teardown() { - const startupFile = execAndGetOutput(startupFileCommand, executableName); +function teardown(tools) { + const startupFile = getStartupFile(); - // Removes all aliases starting with "alias npm=", "alias npx=", or "alias yarn=" - // This will remove the safe-chain aliases for npm, npx, and yarn commands. - removeLinesMatchingPattern(startupFile, /^alias\s+(npm|npx|yarn)\s+/); + for (const { tool } of tools) { + // Remove any existing alias for the tool + removeLinesMatchingPattern( + startupFile, + new RegExp(`^alias\\s+${tool}\\s+`) + ); + } return true; } function setup(tools) { - const startupFile = execAndGetOutput(startupFileCommand, executableName); - teardown(); + const startupFile = getStartupFile(); for (const { tool, aikidoCommand } of tools) { addLineToFile( @@ -37,6 +40,19 @@ function setup(tools) { return true; } +function getStartupFile() { + try { + return execSync(startupFileCommand, { + encoding: "utf8", + shell: executableName, + }).trim(); + } catch (error) { + throw new Error( + `Command failed: ${startupFileCommand}. Error: ${error.message}` + ); + } +} + export default { name: shellName, isInstalled, diff --git a/src/shell-integration/supported-shells/fish.spec.js b/src/shell-integration/supported-shells/fish.spec.js index 15344a3..5f1ab64 100644 --- a/src/shell-integration/supported-shells/fish.spec.js +++ b/src/shell-integration/supported-shells/fish.spec.js @@ -3,6 +3,7 @@ import assert from "node:assert"; import { tmpdir } from "node:os"; import fs from "node:fs"; import path from "path"; +import { knownAikidoTools } from "../helpers.js"; describe("Fish shell integration", () => { let mockStartupFile; @@ -11,11 +12,10 @@ describe("Fish shell integration", () => { beforeEach(async () => { // Create temporary startup file for testing mockStartupFile = path.join(tmpdir(), `test-fish-config-${Date.now()}`); - + // Mock the helpers module mock.module("../helpers.js", { namedExports: { - execAndGetOutput: () => mockStartupFile, doesExecutableExistOnSystem: () => true, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { @@ -27,10 +27,17 @@ describe("Fish shell integration", () => { if (!fs.existsSync(filePath)) return; const content = fs.readFileSync(filePath, "utf-8"); const lines = content.split("\n"); - const filteredLines = lines.filter(line => !pattern.test(line)); + const filteredLines = lines.filter((line) => !pattern.test(line)); fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); - } - } + }, + }, + }); + + // Mock child_process execSync + mock.module("child_process", { + namedExports: { + execSync: () => mockStartupFile, + }, }); // Import fish module after mocking @@ -42,7 +49,7 @@ describe("Fish shell integration", () => { if (fs.existsSync(mockStartupFile)) { fs.unlinkSync(mockStartupFile); } - + // Reset mocks mock.reset(); }); @@ -63,34 +70,28 @@ describe("Fish shell integration", () => { const tools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "npx", aikidoCommand: "aikido-npx" }, - { tool: "yarn", aikidoCommand: "aikido-yarn" } + { tool: "yarn", aikidoCommand: "aikido-yarn" }, ]; const result = fish.setup(tools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes('alias npm "aikido-npm" # Safe-chain alias for npm')); - assert.ok(content.includes('alias npx "aikido-npx" # Safe-chain alias for npx')); - assert.ok(content.includes('alias yarn "aikido-yarn" # Safe-chain alias for yarn')); - }); - - it("should call teardown before setup", () => { - // Pre-populate file with existing aliases - fs.writeFileSync(mockStartupFile, 'alias npm "old-npm"\nalias npx "old-npx"\n', "utf-8"); - - const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }]; - fish.setup(tools); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes('alias npm "old-npm"')); - assert.ok(content.includes('alias npm "aikido-npm"')); + assert.ok( + content.includes('alias npm "aikido-npm" # Safe-chain alias for npm') + ); + assert.ok( + content.includes('alias npx "aikido-npx" # Safe-chain alias for npx') + ); + assert.ok( + content.includes('alias yarn "aikido-yarn" # Safe-chain alias for yarn') + ); }); it("should handle empty tools array", () => { const result = fish.setup([]); assert.strictEqual(result, true); - + // File should be created during teardown call even if no tools are provided if (fs.existsSync(mockStartupFile)) { const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -107,12 +108,12 @@ describe("Fish shell integration", () => { "alias npx 'aikido-npx'", "alias yarn 'aikido-yarn'", "alias ls 'ls --color=auto'", - "alias grep 'grep --color=auto'" + "alias grep 'grep --color=auto'", ].join("\n"); fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = fish.teardown(); + const result = fish.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -128,7 +129,7 @@ describe("Fish shell integration", () => { fs.unlinkSync(mockStartupFile); } - const result = fish.teardown(); + const result = fish.teardown(knownAikidoTools); assert.strictEqual(result, true); }); @@ -136,12 +137,12 @@ describe("Fish shell integration", () => { const initialContent = [ "#!/usr/bin/env fish", "alias ls 'ls --color=auto'", - "set PATH $PATH ~/bin" + "set PATH $PATH ~/bin", ].join("\n"); fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = fish.teardown(); + const result = fish.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -167,7 +168,7 @@ describe("Fish shell integration", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, - { tool: "yarn", aikidoCommand: "aikido-yarn" } + { tool: "yarn", aikidoCommand: "aikido-yarn" }, ]; // Setup @@ -177,7 +178,7 @@ describe("Fish shell integration", () => { assert.ok(content.includes('alias yarn "aikido-yarn"')); // Teardown - fish.teardown(); + fish.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("alias npm ")); assert.ok(!content.includes("alias yarn ")); @@ -187,11 +188,12 @@ describe("Fish shell integration", () => { const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }]; fish.setup(tools); + fish.teardown(tools); fish.setup(tools); - + const content = fs.readFileSync(mockStartupFile, "utf-8"); const npmMatches = (content.match(/alias npm "/g) || []).length; assert.strictEqual(npmMatches, 1, "Should not duplicate aliases"); }); }); -}); \ No newline at end of file +}); diff --git a/src/shell-integration/supported-shells/powershell.js b/src/shell-integration/supported-shells/powershell.js index f07efbf..4690bb6 100644 --- a/src/shell-integration/supported-shells/powershell.js +++ b/src/shell-integration/supported-shells/powershell.js @@ -1,9 +1,9 @@ import { addLineToFile, doesExecutableExistOnSystem, - execAndGetOutput, removeLinesMatchingPattern, } from "../helpers.js"; +import { execSync } from "child_process"; const shellName = "PowerShell Core"; const executableName = "pwsh"; @@ -13,19 +13,22 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -function teardown() { - const startupFile = execAndGetOutput(startupFileCommand, executableName); +function teardown(tools) { + const startupFile = getStartupFile(); - // Removes all aliases starting with "Set-Alias npm=", "Set-Alias npx=", or "Set-Alias yarn=" - // This will remove the safe-chain aliases for npm, npx, and yarn commands. - removeLinesMatchingPattern(startupFile, /^Set-Alias\s+(npm|npx|yarn)\s+/); + for (const { tool } of tools) { + // Remove any existing alias for the tool + removeLinesMatchingPattern( + startupFile, + new RegExp(`^Set-Alias\\s+${tool}\\s+`) + ); + } return true; } function setup(tools) { - const startupFile = execAndGetOutput(startupFileCommand, executableName); - teardown(); + const startupFile = getStartupFile(); for (const { tool, aikidoCommand } of tools) { addLineToFile( @@ -37,6 +40,19 @@ function setup(tools) { return true; } +function getStartupFile() { + try { + return execSync(startupFileCommand, { + encoding: "utf8", + shell: executableName, + }).trim(); + } catch (error) { + throw new Error( + `Command failed: ${startupFileCommand}. Error: ${error.message}` + ); + } +} + export default { name: shellName, isInstalled, diff --git a/src/shell-integration/supported-shells/powershell.spec.js b/src/shell-integration/supported-shells/powershell.spec.js index 9d71d94..9afade7 100644 --- a/src/shell-integration/supported-shells/powershell.spec.js +++ b/src/shell-integration/supported-shells/powershell.spec.js @@ -3,6 +3,7 @@ import assert from "node:assert"; import { tmpdir } from "node:os"; import fs from "node:fs"; import path from "path"; +import { knownAikidoTools } from "../helpers.js"; describe("PowerShell Core shell integration", () => { let mockStartupFile; @@ -10,12 +11,14 @@ describe("PowerShell Core shell integration", () => { beforeEach(async () => { // Create temporary startup file for testing - mockStartupFile = path.join(tmpdir(), `test-powershell-profile-${Date.now()}.ps1`); - + mockStartupFile = path.join( + tmpdir(), + `test-powershell-profile-${Date.now()}.ps1` + ); + // Mock the helpers module mock.module("../helpers.js", { namedExports: { - execAndGetOutput: () => mockStartupFile, doesExecutableExistOnSystem: () => true, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { @@ -27,10 +30,17 @@ describe("PowerShell Core shell integration", () => { if (!fs.existsSync(filePath)) return; const content = fs.readFileSync(filePath, "utf-8"); const lines = content.split("\n"); - const filteredLines = lines.filter(line => !pattern.test(line)); + const filteredLines = lines.filter((line) => !pattern.test(line)); fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); - } - } + }, + }, + }); + + // Mock child_process execSync + mock.module("child_process", { + namedExports: { + execSync: () => mockStartupFile, + }, }); // Import powershell module after mocking @@ -42,7 +52,7 @@ describe("PowerShell Core shell integration", () => { if (fs.existsSync(mockStartupFile)) { fs.unlinkSync(mockStartupFile); } - + // Reset mocks mock.reset(); }); @@ -63,34 +73,30 @@ describe("PowerShell Core shell integration", () => { const tools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "npx", aikidoCommand: "aikido-npx" }, - { tool: "yarn", aikidoCommand: "aikido-yarn" } + { tool: "yarn", aikidoCommand: "aikido-yarn" }, ]; const result = powershell.setup(tools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes('Set-Alias npm aikido-npm # Safe-chain alias for npm')); - assert.ok(content.includes('Set-Alias npx aikido-npx # Safe-chain alias for npx')); - assert.ok(content.includes('Set-Alias yarn aikido-yarn # Safe-chain alias for yarn')); - }); - - it("should call teardown before setup", () => { - // Pre-populate file with existing aliases - fs.writeFileSync(mockStartupFile, 'Set-Alias npm old-npm\nSet-Alias npx old-npx\n', "utf-8"); - - const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }]; - powershell.setup(tools); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes('Set-Alias npm old-npm')); - assert.ok(content.includes('Set-Alias npm aikido-npm')); + assert.ok( + content.includes("Set-Alias npm aikido-npm # Safe-chain alias for npm") + ); + assert.ok( + content.includes("Set-Alias npx aikido-npx # Safe-chain alias for npx") + ); + assert.ok( + content.includes( + "Set-Alias yarn aikido-yarn # Safe-chain alias for yarn" + ) + ); }); it("should handle empty tools array", () => { const result = powershell.setup([]); assert.strictEqual(result, true); - + // File should be created during teardown call even if no tools are provided if (fs.existsSync(mockStartupFile)) { const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -107,12 +113,12 @@ describe("PowerShell Core shell integration", () => { "Set-Alias npx aikido-npx", "Set-Alias yarn aikido-yarn", "Set-Alias ls Get-ChildItem", - "Set-Alias grep Select-String" + "Set-Alias grep Select-String", ].join("\n"); fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = powershell.teardown(); + const result = powershell.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -128,7 +134,7 @@ describe("PowerShell Core shell integration", () => { fs.unlinkSync(mockStartupFile); } - const result = powershell.teardown(); + const result = powershell.teardown(knownAikidoTools); assert.strictEqual(result, true); }); @@ -136,12 +142,12 @@ describe("PowerShell Core shell integration", () => { const initialContent = [ "# PowerShell profile", "Set-Alias ls Get-ChildItem", - "$env:PATH += ';C:\\Tools'" + "$env:PATH += ';C:\\Tools'", ].join("\n"); fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = powershell.teardown(); + const result = powershell.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -167,17 +173,17 @@ describe("PowerShell Core shell integration", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, - { tool: "yarn", aikidoCommand: "aikido-yarn" } + { tool: "yarn", aikidoCommand: "aikido-yarn" }, ]; // Setup powershell.setup(tools); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes('Set-Alias npm aikido-npm')); - assert.ok(content.includes('Set-Alias yarn aikido-yarn')); + assert.ok(content.includes("Set-Alias npm aikido-npm")); + assert.ok(content.includes("Set-Alias yarn aikido-yarn")); // Teardown - powershell.teardown(); + powershell.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("Set-Alias npm ")); assert.ok(!content.includes("Set-Alias yarn ")); @@ -187,11 +193,12 @@ describe("PowerShell Core shell integration", () => { const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }]; powershell.setup(tools); + powershell.teardown(tools); powershell.setup(tools); - + const content = fs.readFileSync(mockStartupFile, "utf-8"); const npmMatches = (content.match(/Set-Alias npm /g) || []).length; assert.strictEqual(npmMatches, 1, "Should not duplicate aliases"); }); }); -}); \ No newline at end of file +}); diff --git a/src/shell-integration/supported-shells/windowsPowershell.js b/src/shell-integration/supported-shells/windowsPowershell.js index 381b987..118a0b9 100644 --- a/src/shell-integration/supported-shells/windowsPowershell.js +++ b/src/shell-integration/supported-shells/windowsPowershell.js @@ -1,9 +1,9 @@ import { addLineToFile, doesExecutableExistOnSystem, - execAndGetOutput, removeLinesMatchingPattern, } from "../helpers.js"; +import { execSync } from "child_process"; const shellName = "Windows PowerShell"; const executableName = "powershell"; @@ -13,19 +13,22 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -function teardown() { - const startupFile = execAndGetOutput(startupFileCommand, executableName); +function teardown(tools) { + const startupFile = getStartupFile(); - // Removes all aliases starting with "Set-Alias npm=", "Set-Alias npx=", or "Set-Alias yarn=" - // This will remove the safe-chain aliases for npm, npx, and yarn commands. - removeLinesMatchingPattern(startupFile, /^Set-Alias\s+(npm|npx|yarn)\s+/); + for (const { tool } of tools) { + // Remove any existing alias for the tool + removeLinesMatchingPattern( + startupFile, + new RegExp(`^Set-Alias\\s+${tool}\\s+`) + ); + } return true; } function setup(tools) { - const startupFile = execAndGetOutput(startupFileCommand, executableName); - teardown(); + const startupFile = getStartupFile(); for (const { tool, aikidoCommand } of tools) { addLineToFile( @@ -37,6 +40,19 @@ function setup(tools) { return true; } +function getStartupFile() { + try { + return execSync(startupFileCommand, { + encoding: "utf8", + shell: executableName, + }).trim(); + } catch (error) { + throw new Error( + `Command failed: ${startupFileCommand}. Error: ${error.message}` + ); + } +} + export default { name: shellName, isInstalled, diff --git a/src/shell-integration/supported-shells/windowsPowershell.spec.js b/src/shell-integration/supported-shells/windowsPowershell.spec.js index fe8b64f..85da9f1 100644 --- a/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -3,6 +3,7 @@ import assert from "node:assert"; import { tmpdir } from "node:os"; import fs from "node:fs"; import path from "path"; +import { knownAikidoTools } from "../helpers.js"; describe("Windows PowerShell shell integration", () => { let mockStartupFile; @@ -10,12 +11,14 @@ describe("Windows PowerShell shell integration", () => { beforeEach(async () => { // Create temporary startup file for testing - mockStartupFile = path.join(tmpdir(), `test-windows-powershell-profile-${Date.now()}.ps1`); - + mockStartupFile = path.join( + tmpdir(), + `test-windows-powershell-profile-${Date.now()}.ps1` + ); + // Mock the helpers module mock.module("../helpers.js", { namedExports: { - execAndGetOutput: () => mockStartupFile, doesExecutableExistOnSystem: () => true, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { @@ -27,10 +30,17 @@ describe("Windows PowerShell shell integration", () => { if (!fs.existsSync(filePath)) return; const content = fs.readFileSync(filePath, "utf-8"); const lines = content.split("\n"); - const filteredLines = lines.filter(line => !pattern.test(line)); + const filteredLines = lines.filter((line) => !pattern.test(line)); fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); - } - } + }, + }, + }); + + // Mock child_process execSync + mock.module("child_process", { + namedExports: { + execSync: () => mockStartupFile, + }, }); // Import windowsPowershell module after mocking @@ -42,7 +52,7 @@ describe("Windows PowerShell shell integration", () => { if (fs.existsSync(mockStartupFile)) { fs.unlinkSync(mockStartupFile); } - + // Reset mocks mock.reset(); }); @@ -63,34 +73,30 @@ describe("Windows PowerShell shell integration", () => { const tools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "npx", aikidoCommand: "aikido-npx" }, - { tool: "yarn", aikidoCommand: "aikido-yarn" } + { tool: "yarn", aikidoCommand: "aikido-yarn" }, ]; const result = windowsPowershell.setup(tools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes('Set-Alias npm aikido-npm # Safe-chain alias for npm')); - assert.ok(content.includes('Set-Alias npx aikido-npx # Safe-chain alias for npx')); - assert.ok(content.includes('Set-Alias yarn aikido-yarn # Safe-chain alias for yarn')); - }); - - it("should call teardown before setup", () => { - // Pre-populate file with existing aliases - fs.writeFileSync(mockStartupFile, 'Set-Alias npm old-npm\nSet-Alias npx old-npx\n', "utf-8"); - - const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }]; - windowsPowershell.setup(tools); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes('Set-Alias npm old-npm')); - assert.ok(content.includes('Set-Alias npm aikido-npm')); + assert.ok( + content.includes("Set-Alias npm aikido-npm # Safe-chain alias for npm") + ); + assert.ok( + content.includes("Set-Alias npx aikido-npx # Safe-chain alias for npx") + ); + assert.ok( + content.includes( + "Set-Alias yarn aikido-yarn # Safe-chain alias for yarn" + ) + ); }); it("should handle empty tools array", () => { const result = windowsPowershell.setup([]); assert.strictEqual(result, true); - + // File should be created during teardown call even if no tools are provided if (fs.existsSync(mockStartupFile)) { const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -107,12 +113,12 @@ describe("Windows PowerShell shell integration", () => { "Set-Alias npx aikido-npx", "Set-Alias yarn aikido-yarn", "Set-Alias ls Get-ChildItem", - "Set-Alias grep Select-String" + "Set-Alias grep Select-String", ].join("\n"); fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = windowsPowershell.teardown(); + const result = windowsPowershell.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -128,7 +134,7 @@ describe("Windows PowerShell shell integration", () => { fs.unlinkSync(mockStartupFile); } - const result = windowsPowershell.teardown(); + const result = windowsPowershell.teardown(knownAikidoTools); assert.strictEqual(result, true); }); @@ -136,12 +142,12 @@ describe("Windows PowerShell shell integration", () => { const initialContent = [ "# Windows PowerShell profile", "Set-Alias ls Get-ChildItem", - "$env:PATH += ';C:\\Tools'" + "$env:PATH += ';C:\\Tools'", ].join("\n"); fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = windowsPowershell.teardown(); + const result = windowsPowershell.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -167,17 +173,17 @@ describe("Windows PowerShell shell integration", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, - { tool: "yarn", aikidoCommand: "aikido-yarn" } + { tool: "yarn", aikidoCommand: "aikido-yarn" }, ]; // Setup windowsPowershell.setup(tools); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes('Set-Alias npm aikido-npm')); - assert.ok(content.includes('Set-Alias yarn aikido-yarn')); + assert.ok(content.includes("Set-Alias npm aikido-npm")); + assert.ok(content.includes("Set-Alias yarn aikido-yarn")); // Teardown - windowsPowershell.teardown(); + windowsPowershell.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("Set-Alias npm ")); assert.ok(!content.includes("Set-Alias yarn ")); @@ -187,11 +193,12 @@ describe("Windows PowerShell shell integration", () => { const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }]; windowsPowershell.setup(tools); + windowsPowershell.teardown(tools); windowsPowershell.setup(tools); - + const content = fs.readFileSync(mockStartupFile, "utf-8"); const npmMatches = (content.match(/Set-Alias npm /g) || []).length; assert.strictEqual(npmMatches, 1, "Should not duplicate aliases"); }); }); -}); \ No newline at end of file +}); diff --git a/src/shell-integration/supported-shells/zsh.js b/src/shell-integration/supported-shells/zsh.js index d9f63b0..82f03af 100644 --- a/src/shell-integration/supported-shells/zsh.js +++ b/src/shell-integration/supported-shells/zsh.js @@ -1,9 +1,9 @@ import { addLineToFile, doesExecutableExistOnSystem, - execAndGetOutput, removeLinesMatchingPattern, } from "../helpers.js"; +import { execSync } from "child_process"; const shellName = "Zsh"; const executableName = "zsh"; @@ -13,12 +13,13 @@ function isInstalled() { return doesExecutableExistOnSystem(executableName); } -function teardown() { - const startupFile = execAndGetOutput(startupFileCommand, executableName); +function teardown(tools) { + const startupFile = getStartupFile(); - // Removes all aliases starting with "alias npm=", "alias npx=", or "alias yarn=" - // This will remove the safe-chain aliases for npm, npx, and yarn commands. - removeLinesMatchingPattern(startupFile, /^alias\s+(npm|npx|yarn)=/); + for (const { tool } of tools) { + // Remove any existing alias for the tool + removeLinesMatchingPattern(startupFile, new RegExp(`^alias\\s+${tool}=`)); + } // Removes the line that sources the safe-chain zsh initialization script (~/.aikido/scripts/init-zsh.sh) removeLinesMatchingPattern( @@ -29,9 +30,8 @@ function teardown() { return true; } -function setup() { - const startupFile = execAndGetOutput(startupFileCommand, executableName); - teardown(); +function setup(tools) { + const startupFile = getStartupFile(); addLineToFile( startupFile, @@ -41,6 +41,19 @@ function setup() { return true; } +function getStartupFile() { + try { + return execSync(startupFileCommand, { + encoding: "utf8", + shell: executableName, + }).trim(); + } catch (error) { + throw new Error( + `Command failed: ${startupFileCommand}. Error: ${error.message}` + ); + } +} + export default { name: shellName, isInstalled, diff --git a/src/shell-integration/supported-shells/zsh.spec.js b/src/shell-integration/supported-shells/zsh.spec.js index 327a914..fbe8a96 100644 --- a/src/shell-integration/supported-shells/zsh.spec.js +++ b/src/shell-integration/supported-shells/zsh.spec.js @@ -3,6 +3,7 @@ import assert from "node:assert"; import { tmpdir } from "node:os"; import fs from "node:fs"; import path from "path"; +import { knownAikidoTools } from "../helpers.js"; describe("Zsh shell integration", () => { let mockStartupFile; @@ -15,7 +16,6 @@ describe("Zsh shell integration", () => { // Mock the helpers module mock.module("../helpers.js", { namedExports: { - execAndGetOutput: () => mockStartupFile, doesExecutableExistOnSystem: () => true, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { @@ -33,6 +33,13 @@ describe("Zsh shell integration", () => { }, }); + // Mock child_process execSync + mock.module("child_process", { + namedExports: { + execSync: () => mockStartupFile, + }, + }); + // Import zsh module after mocking zsh = (await import("./zsh.js")).default; }); @@ -71,22 +78,6 @@ describe("Zsh shell integration", () => { ); }); - it("should call teardown before setup", () => { - // Pre-populate file with existing source line - fs.writeFileSync( - mockStartupFile, - "source ~/.safe-chain/scripts/init-zsh.sh\n", - "utf-8" - ); - - zsh.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - const sourceMatches = (content.match(/source.*init-zsh\.sh/g) || []) - .length; - assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); - }); - it("should handle empty startup file", () => { const result = zsh.setup(); assert.strictEqual(result, true); @@ -109,7 +100,7 @@ describe("Zsh shell integration", () => { fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = zsh.teardown(); + const result = zsh.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -142,7 +133,7 @@ describe("Zsh shell integration", () => { fs.unlinkSync(mockStartupFile); } - const result = zsh.teardown(); + const result = zsh.teardown(knownAikidoTools); assert.strictEqual(result, true); }); @@ -155,7 +146,7 @@ describe("Zsh shell integration", () => { fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - const result = zsh.teardown(); + const result = zsh.teardown(knownAikidoTools); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -179,20 +170,28 @@ describe("Zsh shell integration", () => { describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { + const tools = [ + { tool: "npm", aikidoCommand: "aikido-npm" }, + { tool: "yarn", aikidoCommand: "aikido-yarn" }, + ]; + // Setup zsh.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(content.includes("source ~/.safe-chain/scripts/init-zsh.sh")); // Teardown - zsh.teardown(); + zsh.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("source ~/.safe-chain/scripts/init-zsh.sh")); }); it("should handle multiple setup calls", () => { - zsh.setup(); - zsh.setup(); + const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }]; + + zsh.setup(tools); + zsh.teardown(tools); + zsh.setup(tools); const content = fs.readFileSync(mockStartupFile, "utf-8"); const sourceMatches = (content.match(/source.*init-zsh\.sh/g) || []) diff --git a/src/shell-integration/teardown.js b/src/shell-integration/teardown.js index 00a933f..9f74300 100644 --- a/src/shell-integration/teardown.js +++ b/src/shell-integration/teardown.js @@ -1,6 +1,7 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; +import { knownAikidoTools } from "./helpers.js"; export async function teardown() { ui.writeInformation( @@ -26,7 +27,7 @@ export async function teardown() { for (const shell of shells) { let success = false; try { - success = shell.teardown(); + success = shell.teardown(knownAikidoTools); } catch { success = false; }