Refactor to customize shell integration per shell

This commit is contained in:
Sander Declerck 2025-07-17 16:35:03 +02:00
parent 21cdefadde
commit fe1ca396b4
No known key found for this signature in database
16 changed files with 1302 additions and 780 deletions

View file

@ -1,4 +1,8 @@
const knownAikidoTools = [
import { execSync } from "child_process";
import * as os from "os";
import fs from "fs";
export const knownAikidoTools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
@ -6,39 +10,43 @@ const knownAikidoTools = [
// and add the documentation for the new tool in the README.md
];
export function getAliases(fileName) {
const fileExtension = fileName.split(".").pop().toLowerCase();
let createAlias = pickCreateAliasFunction(fileExtension);
const aliases = knownAikidoTools.map(({ tool, aikidoCommand }) =>
createAlias(tool, aikidoCommand)
);
return aliases;
}
function pickCreateAliasFunction(fileExtension) {
let createAlias;
switch (fileExtension) {
case "ps1":
createAlias = createGeneralPowershellAlias;
break;
case "fish":
createAlias = createGeneralFishAlias;
break;
default:
createAlias = createGeneralPosixAlias;
export function doesExecutableExistOnSystem(executableName) {
try {
if (os.platform() === "win32") {
execSync(`where ${executableName}`, { stdio: "ignore" });
} else {
execSync(`which ${executableName}`, { stdio: "ignore" });
}
return true;
} catch {
return false;
}
return createAlias;
}
function createGeneralPosixAlias(tool, aikidoCommand) {
return `alias ${tool}='${aikidoCommand}'`;
export function execAndGetOutput(command, shell) {
try {
return execSync(command, { encoding: "utf8", shell }).trim();
} catch (error) {
throw new Error(`Command failed: ${command}. Error: ${error.message}`);
}
}
function createGeneralPowershellAlias(tool, aikidoCommand) {
return `Set-Alias ${tool} ${aikidoCommand}`;
export function removeLinesMatchingPattern(filePath, pattern) {
if (!fs.existsSync(filePath)) {
return;
}
const fileContent = fs.readFileSync(filePath, "utf-8");
const lines = fileContent.split(os.EOL);
const updatedLines = lines.filter((line) => !pattern.test(line));
fs.writeFileSync(filePath, updatedLines.join(os.EOL), "utf-8");
}
function createGeneralFishAlias(tool, aikidoCommand) {
return `alias ${tool} "${aikidoCommand}"`;
export function addLineToFile(filePath, line) {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
const fileContent = fs.readFileSync(filePath, "utf-8");
const updatedContent = fileContent + os.EOL + line;
fs.writeFileSync(filePath, updatedContent, "utf-8");
}

View file

@ -1,10 +1,11 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import { getAliases } from "./helpers.js";
import fs from "fs";
import { EOL } from "os";
import { knownAikidoTools } from "./helpers.js";
/**
* Loops over the detected shells and calls the setup function for each.
*/
export async function setup() {
ui.writeInformation(
chalk.bold("Setting up shell aliases.") +
@ -27,7 +28,7 @@ export async function setup() {
let updatedCount = 0;
for (const shell of shells) {
if (setupAliasesForShell(shell)) {
if (setupShell(shell)) {
updatedCount++;
}
}
@ -45,107 +46,29 @@ export async function setup() {
}
/**
* This function sets up aliases for the given shell.
* It reads the shell's startup file (eg ~/.bashrc, ~/.zshrc, etc.),
* and then appends the aliases for npm, npx, and yarn commands.
* If the aliases already exist, it will not add them again.
* If the startup file does not exist, it will create it.
*
* The shell startup script is loaded by the respective shell when it starts.
* This means that the aliases will be available in the shell after it is restarted.
* Calls the setup function for the given shell and reports the result.
*/
function setupAliasesForShell(shell) {
if (!shell.startupFile) {
ui.writeError(
`- ${chalk.bold(
shell.name
)}: no startup file found. Cannot set up aliases.`
function setupShell(shell) {
let success = false;
try {
success = shell.setup(knownAikidoTools);
} catch {
success = false;
}
if (success) {
ui.writeInformation(
`${chalk.bold("- " + shell.name + ":")} ${chalk.green(
"Setup successful"
)}`
);
} else {
ui.writeError(
`${chalk.bold("- " + shell.name + ":")} ${chalk.red(
"Setup failed"
)}. Please check your ${shell.name} configuration.`
);
return false;
}
const aliases = getAliases(shell.startupFile);
if (aliases.length === 0) {
ui.writeError(`- ${chalk.bold(shell.name)}: could not generate aliases.`);
return false;
}
const fileContent = readOrCreateStartupFile(shell.startupFile);
const { addedCount, existingCount, failedCount } = appendAliasesToFile(
aliases,
fileContent,
shell.startupFile
);
let summary = "- " + chalk.bold(shell.name) + ": ";
if (addedCount > 0) {
summary += chalk.green(`${addedCount} aliases were added`);
}
if (existingCount > 0) {
if (addedCount > 0) {
summary += ", ";
}
summary += chalk.yellow(`${existingCount} aliases were already present`);
}
if (failedCount > 0) {
if (addedCount > 0 || existingCount > 0) {
summary += ", ";
}
summary += chalk.red(`${failedCount} aliases failed to add`);
}
// write summary in a single line
ui.writeInformation(summary);
return true;
}
/**
* This reads the content of the startup file.
* If the file does not exist, it creates an empty file and returns an empty string.
* The startup file is the shell's startup script (eg: ~/.bashrc, ~/.zshrc, etc.).
* It is used to set up the shell environment when it starts.
* Some shells may not have a startup file, in which case this function will create one.
*/
export function readOrCreateStartupFile(filePath) {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
ui.writeInformation(`File ${filePath} created.`);
}
return fs.readFileSync(filePath, "utf-8");
}
/**
* This function appends the aliases to the startup file.
* eg: for bash it will append 'alias npm="aikido-npm"' for npm to ~/.bashrc
* @returns an object with the counts of added, existing, and failed aliases.
*/
export function appendAliasesToFile(aliases, fileContent, startupFilePath) {
let addedCount = 0;
let existingCount = 0;
let failedCount = 0;
for (const alias of aliases) {
try {
if (fileContent.includes(alias)) {
existingCount++;
continue;
}
fs.appendFileSync(startupFilePath, `${EOL}${alias}`, "utf-8");
addedCount++;
} catch {
failedCount++;
continue;
}
}
return {
addedCount,
existingCount,
failedCount,
};
return success;
}

View file

@ -1,304 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { EOL, tmpdir } from "node:os";
import fs from "node:fs";
import { getAliases } from "./helpers.js";
import { readOrCreateStartupFile, appendAliasesToFile } from "./setup.js";
describe("setupShell", () => {
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");
const updatedContent = readAndDeleteFile(filePath);
for (const alias of expectedAliases) {
assert.ok(updatedContent.includes(alias), `Alias "${alias}" should be added`);
}
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");
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`);
}
});
it(`should create file and add aliases if file does not exist for ${shell}`, () => {
const randomName = Math.random().toString(36).substring(2, 15);
const filePath = `${tmpdir()}/nonexistent-${randomName}${startupExtension}`;
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.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");
const updatedContent = readAndDeleteFile(filePath);
for (const alias of expectedAliases) {
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");
// 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");
const updatedContent = readAndDeleteFile(filePath);
for (const alias of expectedAliases) {
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)");
for (let i = 0; i < aliases.length; i++) {
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 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");
const updatedContent = readAndDeleteFile(filePath);
for (const alias of expectedAliases) {
assert.ok(updatedContent.includes(alias), `Alias "${alias}" should be present`);
}
assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain");
});
});
}
// Test for each shell type using real getAliases() output
runSetupTestsForEnvironment("bash", ".bashrc", [
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'"
]);
runSetupTestsForEnvironment("zsh", ".zshrc", [
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'"
]);
runSetupTestsForEnvironment("fish", ".fish", [
'alias npm "aikido-npm"',
'alias npx "aikido-npx"',
'alias yarn "aikido-yarn"'
]);
runSetupTestsForEnvironment("pwsh", ".ps1", [
"Set-Alias npm aikido-npm",
"Set-Alias npx aikido-npx",
"Set-Alias yarn aikido-yarn"
]);
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");
// 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`;
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true });
}
const content = readOrCreateStartupFile(filePath);
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`;
fs.writeFileSync(filePath, "", "utf-8");
const content = readOrCreateStartupFile(filePath);
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 });
});
});
describe("appendAliasesToFile edge cases", () => {
it("should handle empty aliases array", () => {
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.failedCount, 0, "Should have 0 failed aliases");
const updatedContent = readAndDeleteFile(filePath);
assert.ok(updatedContent.includes("alias test='echo test'"), "Original content should remain");
});
it("should handle partial substring matches correctly", () => {
const lines = [
"#!/usr/bin/env bash",
"",
"alias npmx='some-other-command'", // Contains 'npm' but shouldn't match 'alias npm='
"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.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");
});
it("should handle file with only whitespace", () => {
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.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");
// Cleanup
fs.rmSync(filePath, { force: true });
});
});
describe("appendAliasesToFile error handling", () => {
it("should handle file permission errors gracefully", () => {
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.failedCount, 1, "Should have 1 failed alias");
// Restore permissions and cleanup
fs.chmodSync(filePath, 0o644);
fs.rmSync(filePath, { force: true });
});
});
});
function createShellStartupScript(lines, fileExtension) {
const randomFileName = Math.random().toString(36).substring(2, 15);
const filePath = `${tmpdir()}/${randomFileName}${fileExtension}`;
fs.writeFileSync(filePath, lines.join(EOL), "utf-8");
return filePath;
}
function readAndDeleteFile(filePath) {
const fileContent = fs.readFileSync(filePath, "utf-8");
fs.rmSync(filePath, { force: true });
return fileContent.split(EOL);
}
function countOccurrences(lines, searchString) {
let count = 0;
for (const line of lines) {
if (line.includes(searchString)) {
count++;
}
}
return count;
}

View file

@ -1,75 +1,18 @@
import * as os from "os";
import { execSync } from "child_process";
const shellList = {
bash: {
name: "Bash",
executable: "bash",
getStartupFileCommand: "echo ~/.bashrc",
},
zsh: {
name: "Zsh",
executable: "zsh",
getStartupFileCommand: "echo ${ZDOTDIR:-$HOME}/.zshrc",
},
fish: {
name: "Fish",
executable: "fish",
getStartupFileCommand: "echo ~/.config/fish/config.fish",
},
powershell: {
name: "PowerShell Core",
executable: "pwsh",
getStartupFileCommand: "echo $PROFILE",
},
windowsPowerShell: {
name: "Windows PowerShell",
executable: "powershell",
getStartupFileCommand: "echo $PROFILE",
},
};
import zsh from "./supported-shells/zsh.js";
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";
export function detectShells() {
let possibleShells = [zsh, bash, powershell, windowsPowershell, fish];
let availableShells = [];
for (const shellName of Object.keys(shellList)) {
const shell = shellList[shellName];
if (isShellAvailable(shell)) {
const startupFile = getShellStartupFile(shell);
availableShells.push({
name: shell.name,
executable: shell.executable,
startupFile: startupFile || null,
});
for (const shell of possibleShells) {
if (shell.isInstalled()) {
availableShells.push(shell);
}
}
return availableShells;
}
function isShellAvailable(shell) {
try {
if (os.platform() === "win32") {
execSync(`where ${shell.executable}`, { stdio: "ignore" });
} else {
execSync(`which ${shell.executable}`, { stdio: "ignore" });
}
return true;
} catch {
return false;
}
}
function getShellStartupFile(shell) {
try {
const command = shell.getStartupFileCommand;
const output = execSync(command, {
encoding: "utf8",
shell: shell.executable,
}).trim();
return output;
} catch {
return null;
}
}

View file

@ -0,0 +1,45 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
execAndGetOutput,
removeLinesMatchingPattern,
} from "../helpers.js";
const shellName = "Bash";
const executableName = "bash";
const startupFileCommand = "echo ~/.bashrc";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown() {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
// 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)=/);
return true;
}
function setup(tools) {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
teardown();
for (const tool of tools) {
addLineToFile(
startupFile,
`alias ${tool}="aikido-${tool}" # Safe-chain alias for ${tool}`
);
}
return true;
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,200 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
describe("Bash shell integration", () => {
let mockStartupFile;
let bash;
beforeEach(async () => {
// Create temporary startup file for testing
mockStartupFile = path.join(tmpdir(), `test-bashrc-${Date.now()}`);
// Mock the helpers module
mock.module("../helpers.js", {
namedExports: {
execAndGetOutput: () => mockStartupFile,
doesExecutableExistOnSystem: () => true,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
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));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
},
},
});
// Import bash module after mocking
bash = (await import("./bash.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when bash is installed", () => {
assert.strictEqual(bash.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(bash.isInstalled(), true);
});
});
describe("setup", () => {
it("should add aliases for all provided tools", () => {
const tools = ["npm", "npx", "yarn"];
const result = bash.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 = ["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);
// File should be created during teardown call even if no tools are provided
if (fs.existsSync(mockStartupFile)) {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"#!/bin/bash",
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'",
"alias ls='ls --color=auto'",
"alias grep='grep --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = bash.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(!content.includes("alias npx="));
assert.ok(!content.includes("alias yarn="));
assert.ok(content.includes("alias ls="));
assert.ok(content.includes("alias grep="));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = bash.teardown();
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases", () => {
const initialContent = [
"#!/bin/bash",
"alias ls='ls --color=auto'",
"export PATH=$PATH:~/bin",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = bash.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("alias ls="));
assert.ok(content.includes("export PATH="));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(bash.name, "Bash");
});
it("should expose all required methods", () => {
assert.ok(typeof bash.isInstalled === "function");
assert.ok(typeof bash.setup === "function");
assert.ok(typeof bash.teardown === "function");
assert.ok(typeof bash.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = ["npm", "yarn"];
// Setup
bash.setup(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes('alias npm="aikido-npm"'));
assert.ok(content.includes('alias yarn="aikido-yarn"'));
// Teardown
bash.teardown();
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(!content.includes("alias yarn="));
});
it("should handle multiple setup calls", () => {
const tools = ["npm"];
bash.setup(tools);
bash.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");
});
});
});

View file

@ -0,0 +1,45 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
execAndGetOutput,
removeLinesMatchingPattern,
} from "../helpers.js";
const shellName = "Fish";
const executableName = "fish";
const startupFileCommand = "echo ~/.config/fish/config.fish";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown() {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
// 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+/);
return true;
}
function setup(tools) {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
teardown();
for (const tool of tools) {
addLineToFile(
startupFile,
`alias ${tool} "aikido-${tool}" # Safe-chain alias for ${tool}`
);
}
return true;
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,190 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
describe("Fish shell integration", () => {
let mockStartupFile;
let fish;
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)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
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));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
}
}
});
// Import fish module after mocking
fish = (await import("./fish.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when fish is installed", () => {
assert.strictEqual(fish.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(fish.isInstalled(), true);
});
});
describe("setup", () => {
it("should add aliases for all provided tools", () => {
const tools = ["npm", "npx", "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 = ["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"'));
});
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");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"#!/usr/bin/env fish",
"alias npm 'aikido-npm'",
"alias npx 'aikido-npx'",
"alias yarn 'aikido-yarn'",
"alias ls 'ls --color=auto'",
"alias grep 'grep --color=auto'"
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = fish.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm "));
assert.ok(!content.includes("alias npx "));
assert.ok(!content.includes("alias yarn "));
assert.ok(content.includes("alias ls "));
assert.ok(content.includes("alias grep "));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = fish.teardown();
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases", () => {
const initialContent = [
"#!/usr/bin/env fish",
"alias ls 'ls --color=auto'",
"set PATH $PATH ~/bin"
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = fish.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("alias ls "));
assert.ok(content.includes("set PATH "));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(fish.name, "Fish");
});
it("should expose all required methods", () => {
assert.ok(typeof fish.isInstalled === "function");
assert.ok(typeof fish.setup === "function");
assert.ok(typeof fish.teardown === "function");
assert.ok(typeof fish.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = ["npm", "yarn"];
// Setup
fish.setup(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes('alias npm "aikido-npm"'));
assert.ok(content.includes('alias yarn "aikido-yarn"'));
// Teardown
fish.teardown();
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm "));
assert.ok(!content.includes("alias yarn "));
});
it("should handle multiple setup calls", () => {
const tools = ["npm"];
fish.setup(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");
});
});
});

View file

@ -0,0 +1,45 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
execAndGetOutput,
removeLinesMatchingPattern,
} from "../helpers.js";
const shellName = "PowerShell Core";
const executableName = "pwsh";
const startupFileCommand = "echo $PROFILE";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown() {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
// 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+/);
return true;
}
function setup(tools) {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
teardown();
for (const { tool, aikidoCommand } of tools) {
addLineToFile(
startupFile,
`Set-Alias ${tool} ${aikidoCommand} # Safe-chain alias for ${tool}`
);
}
return true;
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,197 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
describe("PowerShell Core shell integration", () => {
let mockStartupFile;
let powershell;
beforeEach(async () => {
// Create temporary startup file for testing
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)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
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));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
}
}
});
// Import powershell module after mocking
powershell = (await import("./powershell.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when powershell is installed", () => {
assert.strictEqual(powershell.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(powershell.isInstalled(), true);
});
});
describe("setup", () => {
it("should add aliases for all provided tools", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" },
{ 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'));
});
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");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"# PowerShell profile",
"Set-Alias npm aikido-npm",
"Set-Alias npx aikido-npx",
"Set-Alias yarn aikido-yarn",
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String"
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = powershell.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("Set-Alias npm "));
assert.ok(!content.includes("Set-Alias npx "));
assert.ok(!content.includes("Set-Alias yarn "));
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = powershell.teardown();
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases", () => {
const initialContent = [
"# PowerShell profile",
"Set-Alias ls Get-ChildItem",
"$env:PATH += ';C:\\Tools'"
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = powershell.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("$env:PATH "));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(powershell.name, "PowerShell Core");
});
it("should expose all required methods", () => {
assert.ok(typeof powershell.isInstalled === "function");
assert.ok(typeof powershell.setup === "function");
assert.ok(typeof powershell.teardown === "function");
assert.ok(typeof powershell.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ 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'));
// Teardown
powershell.teardown();
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("Set-Alias npm "));
assert.ok(!content.includes("Set-Alias yarn "));
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
powershell.setup(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");
});
});
});

View file

@ -0,0 +1,45 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
execAndGetOutput,
removeLinesMatchingPattern,
} from "../helpers.js";
const shellName = "Windows PowerShell";
const executableName = "powershell";
const startupFileCommand = "echo $PROFILE";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown() {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
// 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+/);
return true;
}
function setup(tools) {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
teardown();
for (const { tool, aikidoCommand } of tools) {
addLineToFile(
startupFile,
`Set-Alias ${tool} ${aikidoCommand} # Safe-chain alias for ${tool}`
);
}
return true;
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,197 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
describe("Windows PowerShell shell integration", () => {
let mockStartupFile;
let windowsPowershell;
beforeEach(async () => {
// Create temporary startup file for testing
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)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
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));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
}
}
});
// Import windowsPowershell module after mocking
windowsPowershell = (await import("./windowsPowershell.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when windows powershell is installed", () => {
assert.strictEqual(windowsPowershell.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(windowsPowershell.isInstalled(), true);
});
});
describe("setup", () => {
it("should add aliases for all provided tools", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" },
{ 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'));
});
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");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"# Windows PowerShell profile",
"Set-Alias npm aikido-npm",
"Set-Alias npx aikido-npx",
"Set-Alias yarn aikido-yarn",
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String"
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = windowsPowershell.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("Set-Alias npm "));
assert.ok(!content.includes("Set-Alias npx "));
assert.ok(!content.includes("Set-Alias yarn "));
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = windowsPowershell.teardown();
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases", () => {
const initialContent = [
"# Windows PowerShell profile",
"Set-Alias ls Get-ChildItem",
"$env:PATH += ';C:\\Tools'"
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = windowsPowershell.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("$env:PATH "));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(windowsPowershell.name, "Windows PowerShell");
});
it("should expose all required methods", () => {
assert.ok(typeof windowsPowershell.isInstalled === "function");
assert.ok(typeof windowsPowershell.setup === "function");
assert.ok(typeof windowsPowershell.teardown === "function");
assert.ok(typeof windowsPowershell.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ 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'));
// Teardown
windowsPowershell.teardown();
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("Set-Alias npm "));
assert.ok(!content.includes("Set-Alias yarn "));
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
windowsPowershell.setup(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");
});
});
});

View file

@ -0,0 +1,45 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
execAndGetOutput,
removeLinesMatchingPattern,
} from "../helpers.js";
const shellName = "Zsh";
const executableName = "zsh";
const startupFileCommand = "echo ${ZDOTDIR:-$HOME}/.zshrc";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown() {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
// 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)=/);
return true;
}
function setup(tools) {
const startupFile = execAndGetOutput(startupFileCommand, executableName);
teardown();
for (const tool of tools) {
addLineToFile(
startupFile,
`alias ${tool}="aikido-${tool}" # Safe-chain alias for ${tool}`
);
}
return true;
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,200 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
describe("Zsh shell integration", () => {
let mockStartupFile;
let zsh;
beforeEach(async () => {
// Create temporary startup file for testing
mockStartupFile = path.join(tmpdir(), `test-zshrc-${Date.now()}`);
// Mock the helpers module
mock.module("../helpers.js", {
namedExports: {
execAndGetOutput: () => mockStartupFile,
doesExecutableExistOnSystem: () => true,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
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));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
},
},
});
// Import zsh module after mocking
zsh = (await import("./zsh.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when zsh is installed", () => {
assert.strictEqual(zsh.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(zsh.isInstalled(), true);
});
});
describe("setup", () => {
it("should add aliases for all provided tools", () => {
const tools = ["npm", "npx", "yarn"];
const result = zsh.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 = ["npm"];
zsh.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 = zsh.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");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"#!/bin/zsh",
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'",
"alias ls='ls --color=auto'",
"alias grep='grep --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = zsh.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(!content.includes("alias npx="));
assert.ok(!content.includes("alias yarn="));
assert.ok(content.includes("alias ls="));
assert.ok(content.includes("alias grep="));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = zsh.teardown();
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases", () => {
const initialContent = [
"#!/bin/zsh",
"alias ls='ls --color=auto'",
"export PATH=$PATH:~/bin",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = zsh.teardown();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("alias ls="));
assert.ok(content.includes("export PATH="));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(zsh.name, "Zsh");
});
it("should expose all required methods", () => {
assert.ok(typeof zsh.isInstalled === "function");
assert.ok(typeof zsh.setup === "function");
assert.ok(typeof zsh.teardown === "function");
assert.ok(typeof zsh.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = ["npm", "yarn"];
// Setup
zsh.setup(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes('alias npm="aikido-npm"'));
assert.ok(content.includes('alias yarn="aikido-yarn"'));
// Teardown
zsh.teardown();
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(!content.includes("alias yarn="));
});
it("should handle multiple setup calls", () => {
const tools = ["npm"];
zsh.setup(tools);
zsh.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");
});
});
});

View file

@ -1,9 +1,6 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import { getAliases } from "./helpers.js";
import fs from "fs";
import { EOL } from "os";
export async function teardown() {
ui.writeInformation(
@ -27,8 +24,26 @@ export async function teardown() {
let updatedCount = 0;
for (const shell of shells) {
if (removeAliasesForShell(shell)) {
let success = false;
try {
success = shell.teardown();
} catch {
success = false;
}
if (success) {
ui.writeInformation(
`${chalk.bold("- " + shell.name + ":")} ${chalk.green(
"Teardown successful"
)}`
);
updatedCount++;
} else {
ui.writeError(
`${chalk.bold("- " + shell.name + ":")} ${chalk.red(
"Teardown failed"
)}. Please check your ${shell.name} configuration.`
);
}
}
@ -43,98 +58,3 @@ export async function teardown() {
return;
}
}
/**
* This function removes aliases for the given shell.
* It reads the shell's startup file (eg ~/.bashrc, ~/.zshrc, etc.),
* and then removes the aliases for npm, npx, and yarn commands.
* If the aliases don't exist, it will report that they were not found.
* If the startup file does not exist, it will report that no aliases need to be removed.
*
* The shell startup script is loaded by the respective shell when it starts.
* This means that the aliases will be removed from the shell after it is restarted.
*/
function removeAliasesForShell(shell) {
if (!shell.startupFile) {
ui.writeError(
`- ${chalk.bold(
shell.name
)}: no startup file found. Cannot remove aliases.`
);
return false;
}
if (!fs.existsSync(shell.startupFile)) {
ui.writeInformation(
`- ${chalk.bold(
shell.name
)}: startup file does not exist. No aliases to remove.`
);
return false;
}
const aliases = getAliases(shell.startupFile);
const fileContent = fs.readFileSync(shell.startupFile, "utf-8");
const { removedCount, notFoundCount } = removeAliasesFromFile(
aliases,
fileContent,
shell.startupFile
);
let summary = "- " + chalk.bold(shell.name) + ": ";
if (removedCount > 0) {
summary += chalk.green(`${removedCount} aliases were removed`);
}
if (notFoundCount > 0) {
if (removedCount > 0) {
summary += ", ";
}
summary += chalk.yellow(`${notFoundCount} aliases were not found`);
}
if (removedCount === 0 && notFoundCount === 0) {
summary += chalk.yellow("no aliases found to remove");
}
ui.writeInformation(summary);
return removedCount > 0;
}
/**
* This function removes the aliases from the startup file.
* It searches for exact matches of each alias line and removes them.
* eg: for bash it will remove 'alias npm="aikido-npm"' for npm from ~/.bashrc
* @returns an object with the counts of removed and not found aliases.
*/
export function removeAliasesFromFile(aliases, fileContent, startupFilePath) {
let removedCount = 0;
let notFoundCount = 0;
let updatedContent = fileContent;
for (const alias of aliases) {
const lines = updatedContent.split(EOL);
let aliasLineIndex = lines.findIndex((line) => line.trim() === alias);
if (aliasLineIndex !== -1) {
removedCount++;
// Remove all occurrences of the alias line, in case it appears multiple times
while (aliasLineIndex !== -1) {
lines.splice(aliasLineIndex, 1);
aliasLineIndex = lines.findIndex((line) => line.trim() === alias);
}
updatedContent = lines.join(EOL);
} else {
notFoundCount++;
}
}
if (removedCount > 0) {
fs.writeFileSync(startupFilePath, updatedContent, "utf-8");
}
return {
removedCount,
notFoundCount,
};
}

View file

@ -1,177 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { EOL, tmpdir } from "node:os";
import fs from "node:fs";
import { getAliases } from "./helpers.js";
import { removeAliasesFromFile } from "./teardown.js";
describe("teardown", () => {
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.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`);
}
});
it(`should handle file with no aliases for ${shell}`, () => {
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");
const updatedContent = readAndDeleteFile(filePath);
assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain unchanged");
});
it(`should remove duplicate aliases from ${shell} file`, () => {
const lines = [
`#!/usr/bin/env ${shell}`,
"",
...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.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 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)");
for (let i = 0; i < aliases.length; i++) {
assert.strictEqual(aliases[i], expectedAliases[i], `Alias ${i} should match expected format`);
}
});
it(`should handle partial alias matches for ${shell}`, () => {
const lines = [
`#!/usr/bin/env ${shell}`,
"",
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");
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");
});
});
}
// Test for each shell type using real getAliases() output
runRemovalTestsForEnvironment("bash", ".bashrc", [
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'"
]);
runRemovalTestsForEnvironment("zsh", ".zshrc", [
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'"
]);
runRemovalTestsForEnvironment("fish", ".fish", [
'alias npm "aikido-npm"',
'alias npx "aikido-npx"',
'alias yarn "aikido-yarn"'
]);
runRemovalTestsForEnvironment("pwsh", ".ps1", [
"Set-Alias npm aikido-npm",
"Set-Alias npx aikido-npx",
"Set-Alias yarn aikido-yarn"
]);
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`;
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");
// Cleanup
fs.rmSync(filePath, { force: true });
});
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`;
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");
// Cleanup
fs.rmSync(filePath, { force: true });
});
});
});
function createShellStartupScript(lines, fileExtension) {
const randomFileName = Math.random().toString(36).substring(2, 15);
const filePath = `${tmpdir()}/${randomFileName}${fileExtension}`;
fs.writeFileSync(filePath, lines.join(EOL), "utf-8");
return filePath;
}
function readAndDeleteFile(filePath) {
const fileContent = fs.readFileSync(filePath, "utf-8");
fs.rmSync(filePath, { force: true });
return fileContent.split(EOL);
}