Merge branch 'main' into fix/aikido-security-update-packages-5669664-mBXT

This commit is contained in:
Sander Declerck 2025-08-05 15:13:21 +02:00
commit cf6f895724
No known key found for this signature in database
43 changed files with 2524 additions and 793 deletions

36
.github/CONTRIBUTING vendored Normal file
View file

@ -0,0 +1,36 @@
# Contributing to Aikido Safe Chain
Thank you for your interest in contributing! Please discuss significant changes in an issue first to avoid duplication of work.
## Reporting Issues
Report issues on our [GitHub Issues page](https://github.com/AikidoSec/safe-chain). Check existing issues first and include:
- Steps to reproduce
- Expected vs actual behavior
- Environment details (Node.js version, OS, package manager)
- Relevant logs/errors
## Contributing Code
1. Fork and clone the repository
2. Create a descriptive branch name
3. Make your changes following the existing code style
4. Add tests for your changes
5. Run `npm test` and `npm run lint`
6. Push and create a pull request
## Development
**Setup:**
```bash
npm install
```
**Commands:**
- `npm test` - Run tests
- `npm run test:watch` - Watch mode
- `npm run lint` - Check code style
**Requirements:**
- Node.js 18+
- Follow .editorconfig formatting

58
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: Bug Report
description: Report a bug to help us improve
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below to help us understand and reproduce the issue.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
placeholder: Describe what happened and what you expected to happen
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Run command '...'
2. Install package '...'
3. See error
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Your system information
placeholder: |
- OS: [e.g. macOS 14.0, Ubuntu 22.04, Windows 11]
- Node.js version: [e.g. 18.17.0]
- Package manager: [e.g. npm 9.6.7, yarn 1.22.19, pnpm 8.6.0]
- Safe Chain version: [e.g. 1.0.0]
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error Logs
description: Relevant error messages or logs
placeholder: Paste any error messages or logs here
render: shell
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context about the problem
placeholder: Screenshots, related issues, workarounds, etc.

View file

@ -1,4 +1,4 @@
name: Run Unit Tests
name: Run tests
on:
pull_request:
@ -6,7 +6,9 @@ on:
- main
jobs:
test:
unit-test:
name: Run unit tests and linting
runs-on: ubuntu-latest
steps:
@ -21,8 +23,38 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run tests
- name: Run unit tests
run: npm test
- name: Run ESLint
run: npm run lint
e2e-tests:
name: Run E2E tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: "test/e2e"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Clean up Docker resources
if: always()
run: |
# Clean up any remaining containers and images
docker ps -aq --filter "name=safe-chain-e2e-test" | xargs -r docker rm -f
docker images -q safe-chain-e2e-test | xargs -r docker rmi -f

5
.npmignore Normal file
View file

@ -0,0 +1,5 @@
.github
.claude
test/e2e

View file

@ -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
npm install safe-chain-test
```
- 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**

8
bin/aikido-pnpm.js Executable file
View file

@ -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));

8
bin/aikido-pnpx.js Executable file
View file

@ -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));

View file

@ -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`.

View file

@ -1,5 +1,5 @@
import js from "@eslint/js";
import { defineConfig } from "@eslint/config-helpers";
import { defineConfig, globalIgnores } from "@eslint/config-helpers";
import globals from "globals";
import importPlugin from "eslint-plugin-import";
@ -22,4 +22,5 @@ export default defineConfig([
},
rules: {},
},
globalIgnores(['test/e2e']),
]);

2
package-lock.json generated
View file

@ -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"
},

View file

@ -2,8 +2,8 @@
"name": "@aikidosec/safe-chain",
"version": "1.0.0",
"scripts": {
"test": "node --test --experimental-test-module-mocks **/*.spec.js",
"test:watch": "node --test --watch --experimental-test-module-mocks **/*.spec.js",
"test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
"test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
"lint": "eslint ."
},
"repository": {
@ -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",

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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));
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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" }]);
});
});

View file

@ -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 };
}

View file

@ -1,44 +1,44 @@
const knownAikidoTools = [
import { spawnSync } 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" },
// 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 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) {
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;
}
return createAlias;
}
function createGeneralPosixAlias(tool, aikidoCommand) {
return `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 createGeneralPowershellAlias(tool, aikidoCommand) {
return `Set-Alias ${tool} ${aikidoCommand}`;
}
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,15 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import { getAliases } from "./helpers.js";
import { knownAikidoTools } from "./helpers.js";
import fs from "fs";
import { EOL } from "os";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
/**
* Loops over the detected shells and calls the setup function for each.
*/
export async function setup() {
ui.writeInformation(
chalk.bold("Setting up shell aliases.") +
@ -12,6 +17,8 @@ export async function setup() {
);
ui.emptyLine();
copyStartupFiles();
try {
const shells = detectShells();
if (shells.length === 0) {
@ -27,7 +34,7 @@ export async function setup() {
let updatedCount = 0;
for (const shell of shells) {
if (setupAliasesForShell(shell)) {
if (setupShell(shell)) {
updatedCount++;
}
}
@ -45,107 +52,49 @@ 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 {
shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases
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;
return success;
}
/**
* 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");
}
function copyStartupFiles() {
const startupFiles = ["init-posix.sh", "init-pwsh.ps1", "init-fish.fish"];
/**
* 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 file of startupFiles) {
const targetDir = path.join(os.homedir(), ".safe-chain", "scripts");
const targetPath = path.join(os.homedir(), ".safe-chain", "scripts", file);
for (const alias of aliases) {
try {
if (fileContent.includes(alias)) {
existingCount++;
continue;
}
fs.appendFileSync(startupFilePath, `${EOL}${alias}`, "utf-8");
addedCount++;
} catch {
failedCount++;
continue;
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
}
return {
addedCount,
existingCount,
failedCount,
};
// Use absolute path for source
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sourcePath = path.resolve(__dirname, "startup-scripts", file);
fs.copyFileSync(sourcePath, targetPath);
}
}

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,26 @@
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";
import { ui } from "../environment/userInteraction.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,
});
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;
}
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,58 @@
function printSafeChainWarning
set original_cmd $argv[1]
# Fish equivalent of ANSI color codes: yellow background, black text for "Warning:"
set_color -b yellow black
printf "Warning:"
set_color normal
printf " safe-chain is not available to protect you from installing malware. %s will run without it.\n" $original_cmd
# Cyan text for the install command
printf "Install safe-chain by using "
set_color cyan
printf "npm install -g @aikidosec/safe-chain"
set_color normal
printf ".\n"
end
function wrapSafeChainCommand
set original_cmd $argv[1]
set aikido_cmd $argv[2]
set cmd_args $argv[3..-1]
if type -q $aikido_cmd
# If the aikido command is available, just run it with the provided arguments
$aikido_cmd $cmd_args
else
# If the aikido command is not available, print a warning and run the original command
printSafeChainWarning $original_cmd
command $original_cmd $cmd_args
end
end
function npx
wrapSafeChainCommand "npx" "aikido-npx" $argv
end
function yarn
wrapSafeChainCommand "yarn" "aikido-yarn" $argv
end
function pnpm
wrapSafeChainCommand "pnpm" "aikido-pnpm" $argv
end
function pnpx
wrapSafeChainCommand "pnpx" "aikido-pnpx" $argv
end
function npm
if test (count $argv) -eq 1 -a \( "$argv[1]" = "-v" -o "$argv[1]" = "--version" \)
# If args is just -v or --version and nothing else, just run the npm version command
# This is because nvm uses this to check the version of npm
command npm $argv
return
end
wrapSafeChainCommand "npm" "aikido-npm" $argv
end

View file

@ -0,0 +1,54 @@
function printSafeChainWarning() {
# \033[43;30m is used to set the background color to yellow and text color to black
# \033[0m is used to reset the text formatting
printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. %s will run without it.\n" "$1"
# \033[36m is used to set the text color to cyan
printf "Install safe-chain by using \033[36mnpm install -g @aikidosec/safe-chain\033[0m.\n"
}
function wrapSafeChainCommand() {
local original_cmd="$1"
local aikido_cmd="$2"
# Remove the first 2 arguments (original_cmd and aikido_cmd) from $@
# so that "$@" now contains only the arguments passed to the original command
shift 2
if command -v "$aikido_cmd" > /dev/null 2>&1; then
# If the aikido command is available, just run it with the provided arguments
"$aikido_cmd" "$@"
else
# If the aikido command is not available, print a warning and run the original command
printSafeChainWarning "$original_cmd"
command "$original_cmd" "$@"
fi
}
function npx() {
wrapSafeChainCommand "npx" "aikido-npx" "$@"
}
function yarn() {
wrapSafeChainCommand "yarn" "aikido-yarn" "$@"
}
function pnpm() {
wrapSafeChainCommand "pnpm" "aikido-pnpm" "$@"
}
function pnpx() {
wrapSafeChainCommand "pnpx" "aikido-pnpx" "$@"
}
function npm() {
if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then
# If args is just -v or --version and nothing else, just run the npm version command
# This is because nvm uses this to check the version of npm
command npm "$@"
return
fi
wrapSafeChainCommand "npm" "aikido-npm" "$@"
}

View file

@ -0,0 +1,80 @@
function Write-SafeChainWarning {
param([string]$Command)
# PowerShell equivalent of ANSI color codes: yellow background, black text for "Warning:"
Write-Host "Warning:" -BackgroundColor Yellow -ForegroundColor Black -NoNewline
Write-Host " safe-chain is not available to protect you from installing malware. $Command will run without it."
# Cyan text for the install command
Write-Host "Install safe-chain by using " -NoNewline
Write-Host "npm install -g @aikidosec/safe-chain" -ForegroundColor Cyan -NoNewline
Write-Host "."
}
function Test-CommandAvailable {
param([string]$Command)
try {
Get-Command $Command -ErrorAction Stop | Out-Null
return $true
}
catch {
return $false
}
}
function Invoke-RealCommand {
param(
[string]$Command,
[string[]]$Arguments
)
# Find the real executable to avoid calling our wrapped functions
$realCommand = Get-Command -Name $Command -CommandType Application | Select-Object -First 1
if ($realCommand) {
& $realCommand.Source @Arguments
}
}
function Invoke-WrappedCommand {
param(
[string]$OriginalCmd,
[string]$AikidoCmd,
[string[]]$Arguments
)
if (Test-CommandAvailable $AikidoCmd) {
& $AikidoCmd @Arguments
}
else {
Write-SafeChainWarning $OriginalCmd
Invoke-RealCommand $OriginalCmd $Arguments
}
}
function npx {
Invoke-WrappedCommand "npx" "aikido-npx" $args
}
function yarn {
Invoke-WrappedCommand "yarn" "aikido-yarn" $args
}
function pnpm {
Invoke-WrappedCommand "pnpm" "aikido-pnpm" $args
}
function pnpx {
Invoke-WrappedCommand "pnpx" "aikido-pnpx" $args
}
function npm {
# If args is just -v or --version and nothing else, just run the npm version command
# This is because nvm uses this to check the version of npm
if (($args.Length -eq 1) -and (($args[0] -eq "-v") -or ($args[0] -eq "--version"))) {
Invoke-RealCommand "npm" $args
return
}
Invoke-WrappedCommand "npm" "aikido-npm" $args
}

View file

@ -0,0 +1,62 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "Bash";
const executableName = "bash";
const startupFileCommand = "echo ~/.bashrc";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
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 bash initialization script (~/.aikido/scripts/init-posix.sh)
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`
);
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,
setup,
teardown,
};

View file

@ -0,0 +1,199 @@
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";
import { knownAikidoTools } from "../helpers.js";
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: {
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");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// 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 source line for bash initialization script", () => {
const result = bash.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
)
);
});
});
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(knownAikidoTools);
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(knownAikidoTools);
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(knownAikidoTools);
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 = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
// Setup
bash.setup(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
// Teardown
bash.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
bash.setup(tools);
bash.teardown(tools);
bash.setup(tools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (content.match(/source.*init-posix\.sh/g) || [])
.length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
});
it("should handle mixed content with aliases and source lines", () => {
const initialContent = [
"#!/bin/bash",
"alias npm='old-npm'",
"source ~/.safe-chain/scripts/init-posix.sh",
"alias ls='ls --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
// Teardown should remove both aliases and source line
bash.teardown(knownAikidoTools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
assert.ok(content.includes("alias ls="));
});
});
});

View file

@ -0,0 +1,65 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "Fish";
const executableName = "fish";
const startupFileCommand = "echo ~/.config/fish/config.fish";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
for (const { tool } of tools) {
// Remove any existing alias for the tool
removeLinesMatchingPattern(
startupFile,
new RegExp(`^alias\\s+${tool}\\s+`)
);
}
// Removes the line that sources the safe-chain fish initialization script (~/.safe-chain/scripts/init-fish.fish)
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`
);
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,
setup,
teardown,
};

View file

@ -0,0 +1,183 @@
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";
import { knownAikidoTools } from "../helpers.js";
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: {
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");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// 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 source line for safe-chain fish initialization script", () => {
const result = fish.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script')
);
});
it("should not duplicate source lines on multiple calls", () => {
fish.setup();
fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
assert.strictEqual(sourceMatches, 2, "Should allow multiple source lines (helper doesn't dedupe)");
});
});
describe("teardown", () => {
it("should remove npm, npx, yarn aliases and source line", () => {
const initialContent = [
"#!/usr/bin/env fish",
"alias npm 'aikido-npm'",
"alias npx 'aikido-npx'",
"alias yarn 'aikido-yarn'",
"source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script",
"alias ls 'ls --color=auto'",
"alias grep 'grep --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = fish.teardown(knownAikidoTools);
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("source ~/.safe-chain/scripts/init-fish.fish"));
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(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases or source lines", () => {
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(knownAikidoTools);
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 = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
// Setup
fish.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes('source ~/.safe-chain/scripts/init-fish.fish'));
// Teardown
fish.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish"));
});
it("should handle multiple setup calls", () => {
fish.setup();
fish.teardown(knownAikidoTools);
fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle");
});
});
});

View file

@ -0,0 +1,65 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "PowerShell Core";
const executableName = "pwsh";
const startupFileCommand = "echo $PROFILE";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
for (const { tool } of tools) {
// Remove any existing alias for the tool
removeLinesMatchingPattern(
startupFile,
new RegExp(`^Set-Alias\\s+${tool}\\s+`)
);
}
// Remove the line that sources the safe-chain PowerShell initialization script
removeLinesMatchingPattern(
startupFile,
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`
);
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,
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";
import { knownAikidoTools } from "../helpers.js";
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: {
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");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// 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 init-pwsh.ps1 source line", () => {
const result = powershell.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script'
)
);
});
});
describe("teardown", () => {
it("should remove init-pwsh.ps1 source line", () => {
const initialContent = [
"# PowerShell profile",
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = powershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
});
it("should remove old-style aliases from earlier versions", () => {
const initialContent = [
"# PowerShell profile",
"Set-Alias npm aikido-npm # Safe-chain alias for npm",
"Set-Alias npx aikido-npx # Safe-chain alias for npx",
"Set-Alias yarn aikido-yarn # Safe-chain alias for yarn",
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = powershell.teardown(knownAikidoTools);
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(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant content", () => {
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(knownAikidoTools);
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", () => {
// Setup
powershell.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
// Teardown
powershell.teardown(knownAikidoTools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
});
it("should handle multiple setup calls", () => {
powershell.setup();
powershell.teardown(knownAikidoTools);
powershell.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
[]
).length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
});
});
});

View file

@ -0,0 +1,65 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "Windows PowerShell";
const executableName = "powershell";
const startupFileCommand = "echo $PROFILE";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
for (const { tool } of tools) {
// Remove any existing alias for the tool
removeLinesMatchingPattern(
startupFile,
new RegExp(`^Set-Alias\\s+${tool}\\s+`)
);
}
// Remove the line that sources the safe-chain PowerShell initialization script
removeLinesMatchingPattern(
startupFile,
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`
);
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,
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";
import { knownAikidoTools } from "../helpers.js";
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: {
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");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// 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 init-pwsh.ps1 source line", () => {
const result = windowsPowershell.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script'
)
);
});
});
describe("teardown", () => {
it("should remove init-pwsh.ps1 source line", () => {
const initialContent = [
"# Windows PowerShell profile",
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = windowsPowershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
});
it("should remove old-style aliases from earlier versions", () => {
const initialContent = [
"# Windows PowerShell profile",
"Set-Alias npm aikido-npm # Safe-chain alias for npm",
"Set-Alias npx aikido-npx # Safe-chain alias for npx",
"Set-Alias yarn aikido-yarn # Safe-chain alias for yarn",
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = windowsPowershell.teardown(knownAikidoTools);
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(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant content", () => {
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(knownAikidoTools);
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", () => {
// Setup
windowsPowershell.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
// Teardown
windowsPowershell.teardown(knownAikidoTools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
});
it("should handle multiple setup calls", () => {
windowsPowershell.setup();
windowsPowershell.teardown(knownAikidoTools);
windowsPowershell.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
[]
).length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
});
});
});

View file

@ -0,0 +1,62 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "Zsh";
const executableName = "zsh";
const startupFileCommand = "echo ${ZDOTDIR:-$HOME}/.zshrc";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
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-posix.sh)
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`
);
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,
setup,
teardown,
};

View file

@ -0,0 +1,226 @@
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";
import { knownAikidoTools } from "../helpers.js";
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: {
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");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// 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 source line for zsh initialization script", () => {
const result = zsh.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script"
)
);
});
it("should handle empty startup file", () => {
const result = zsh.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
});
});
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(knownAikidoTools);
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 remove zsh initialization script source line", () => {
const initialContent = [
"#!/bin/zsh",
"source ~/.safe-chain/scripts/init-posix.sh",
"alias ls='ls --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = zsh.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
assert.ok(content.includes("alias ls="));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = zsh.teardown(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases or source lines", () => {
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(knownAikidoTools);
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 = [
{ 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-posix.sh"));
// Teardown
zsh.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
});
it("should handle multiple setup calls", () => {
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-posix\.sh/g) || [])
.length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
});
it("should handle mixed content with aliases and source lines", () => {
const initialContent = [
"#!/bin/zsh",
"alias npm='old-npm'",
"source ~/.safe-chain/scripts/init-posix.sh",
"alias ls='ls --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
// Teardown should remove both aliases and source line
zsh.teardown(knownAikidoTools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
assert.ok(content.includes("alias ls="));
});
});
});

View file

@ -1,9 +1,7 @@
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";
export async function teardown() {
ui.writeInformation(
@ -27,8 +25,26 @@ export async function teardown() {
let updatedCount = 0;
for (const shell of shells) {
if (removeAliasesForShell(shell)) {
let success = false;
try {
success = shell.teardown(knownAikidoTools);
} 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 +59,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);
}

View file

@ -0,0 +1,113 @@
import { execSync } from "node:child_process";
import * as pty from "node-pty";
import { parseShellOutput } from "./parseShellOutput.js";
export class DockerTestContainer {
constructor(imageName, containerName) {
this.imageName = imageName;
this.containerName = containerName;
this.isRunning = false;
}
async start() {
if (this.isRunning) {
throw new Error("Container is already running");
}
try {
// Start a long-running container that we can exec commands into
execSync(
`docker run -d --name ${this.containerName} ${this.imageName} sleep infinity`,
{ stdio: "ignore" }
);
this.isRunning = true;
} catch (error) {
throw new Error(`Failed to start container: ${error.message}`);
}
}
async openShell(shell) {
let ptyProcess = pty.spawn(
"docker",
["exec", "-it", this.containerName, shell],
{
name: "xterm-color",
cols: 80,
rows: 30,
}
);
await new Promise((resolve, reject) => {
ptyProcess.on("data", (data) => {
if (data.includes("\u001b[?2004h")) {
// This indicates that the shell is ready
resolve();
}
});
ptyProcess.on("error", (err) => {
reject(err);
});
});
function runCommand(command) {
if (!ptyProcess) {
throw new Error("Shell is not running");
}
return new Promise((resolve) => {
let allData = [];
ptyProcess.on("data", handleInput);
const timeout = setTimeout(() => {
// Fallback in case the command doesn't finish in a reasonable time
resolve({ allData, output: parseShellOutput(allData), command });
ptyProcess.removeListener("data", handleInput);
}, 10000);
function handleInput(data) {
allData.push(data);
if (data.includes("\u001b[?2004h")) {
// This indicates that the command has finished executing
resolve({ allData, output: parseShellOutput(allData), command });
ptyProcess.removeListener("data", handleInput);
clearTimeout(timeout);
}
}
ptyProcess.write(`${command}\n`);
});
}
return { runCommand };
}
async stop() {
if (!this.isRunning) {
return; // Already stopped
}
try {
// Force stop and remove the container
execSync(`docker kill ${this.containerName}`, {
stdio: "ignore",
timeout: 10000,
});
} catch {
// Container might already be stopped
}
try {
execSync(`docker rm -f ${this.containerName}`, {
stdio: "ignore",
timeout: 5000,
});
} catch {
// Container might already be removed
}
this.isRunning = false;
}
}

32
test/e2e/Dockerfile Normal file
View file

@ -0,0 +1,32 @@
FROM node:24-bookworm as builder
ENV CI=true
# Set working directory
WORKDIR /app
# Copy package files first for better caching
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application
COPY . .
# Build the application
RUN npm --no-git-tag-version version 1.0.0 --allow-same-version
RUN npm pack
FROM mcr.microsoft.com/devcontainers/javascript-node:22-bookworm as runner
WORKDIR /app
COPY --from=builder /app/*.tgz /app/
# # Install the application package globally
RUN npm install -g /app/*.tgz
RUN mkdir /testapp
RUN cd /testapp && npm init -y

32
test/e2e/package-lock.json generated Normal file
View file

@ -0,0 +1,32 @@
{
"name": "@aikidosec/safe-chain-e2e-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@aikidosec/safe-chain-e2e-tests",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"node-pty": "^1.0.0"
}
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"nan": "^2.17.0"
}
}
}
}

15
test/e2e/package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "@aikidosec/safe-chain-e2e-tests",
"version": "1.0.0",
"description": "End-to-end tests for the Aikido Safe Chain",
"scripts": {
"test": "node --test **/*.spec.js"
},
"keywords": [],
"author": "Aikido Security",
"license": "AGPL-3.0-or-later",
"type": "module",
"dependencies": {
"node-pty": "^1.0.0"
}
}

View file

@ -0,0 +1,104 @@
const escapeChar = "\u001b";
const startMarker = `${escapeChar}[?2004l`;
const endMarker = `${escapeChar}[?2004h`;
/* eslint-disable no-control-regex */
// This module removes control characters and escape sequences from shell output.
// So it is allowed to use control characters in the regex patterns here.
export function parseShellOutput(rawData) {
const stringData = rawData.join("");
let output = getDataBetweenStartAndEndMarkers(stringData);
output = processBackspaces(output);
output = processEraseCommands(output);
output = removeOscSequences(output);
output = removeAnsiSgrSequences(output);
output = removeRemainingEscapeSequences(output);
return output.trim();
}
function getDataBetweenStartAndEndMarkers(data) {
if (!data.includes(startMarker) || !data.includes(endMarker)) {
return data;
}
const startIndex = data.indexOf(startMarker);
const endIndex = data.indexOf(endMarker, startIndex + startMarker.length);
if (startIndex === -1 || endIndex === -1) {
return "";
}
return data.slice(startIndex + startMarker.length, endIndex);
}
function processBackspaces(data) {
const result = [];
for (let i = 0; i < data.length; i++) {
const char = data[i];
if (char === "\b") {
// Backspace: remove the previous character if it exists
if (result.length > 0) {
result.pop();
}
} else {
result.push(char);
}
}
return result.join("");
}
function removeOscSequences(data) {
return data.replace(/\u001b\][0-9]*;[^\u0007\u001b]*(\u0007|\u001b\\)/g, "");
}
function removeAnsiSgrSequences(data) {
return data.replace(/\u001b\[[0-9;]*m/g, "");
}
function processEraseCommands(data) {
const lines = data.split("\n");
const result = [];
for (let line of lines) {
// Process erase in line commands
line = line.replace(/\u001b\[K/g, ""); // Erase to end of line
line = line.replace(/\u001b\[0K/g, ""); // Erase to end of line
line = line.replace(/\u001b\[1K/g, ""); // Erase from start of line to cursor
line = line.replace(/\u001b\[2K/g, ""); // Erase entire line - remove the whole line
// Skip lines that were completely erased
if (line.includes("\u001b[2K")) {
continue;
}
result.push(line);
}
// Process erase in display commands
let output = result.join("\n");
output = output.replace(/\u001b\[J/g, ""); // Erase to end of display
output = output.replace(/\u001b\[0J/g, ""); // Erase to end of display
output = output.replace(/\u001b\[1J/g, ""); // Erase from start to cursor
output = output.replace(/\u001b\[2J/g, ""); // Erase entire display
return output;
}
function removeRemainingEscapeSequences(data) {
// Remove mode setting sequences like \u001b[?1h, \u001b[?1l
data = data.replace(/\u001b\[\?[0-9]+[hl]/g, "");
// Remove any other CSI sequences we haven't handled
data = data.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, "");
// Remove incomplete or malformed escape sequences
data = data.replace(/\u001b[^\u001b]*/g, "");
return data;
}

View file

@ -0,0 +1,77 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { execSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DockerTestContainer } from "./DockerTestContainer.js";
import assert from "node:assert";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe("E2E: safe-chain setup command", () => {
const imageName = "safe-chain-e2e-test";
const containerName = "safe-chain-e2e-test-container";
let container;
before(async () => {
// Build the Docker image for the test environment
try {
const sourceDir = path.join(__dirname, "../..");
execSync(`docker build -t ${imageName} -f Dockerfile ${sourceDir}`, {
cwd: __dirname,
stdio: "ignore",
});
} catch (error) {
throw new Error(`Failed to setup test environment: ${error.message}`);
}
});
beforeEach(async () => {
// Run a new Docker container for each test
container = new DockerTestContainer(imageName, containerName);
await container.start();
});
afterEach(async () => {
// Stop and clean up the container after each test
if (container) {
await container.stop();
container = null;
}
});
for (let shell of ["bash", "zsh"]) {
it(`safe-chain setup wraps npm command after installation for ${shell}`, async () => {
// setting up the container
const installationShell = await container.openShell(shell);
await installationShell.runCommand("safe-chain setup");
const projectShell = await container.openShell(shell);
await projectShell.runCommand("cd /testapp");
const result = await projectShell.runCommand("npm i axios");
assert.ok(
result.output.includes("Scanning for malicious packages..."),
"Expected npm command to be wrapped by safe-chain"
);
});
it(`safe-chain teardown unwraps npm command after uninstallation for ${shell}`, async () => {
// setting up the container
const installationShell = await container.openShell(shell);
await installationShell.runCommand("safe-chain setup");
await installationShell.runCommand("safe-chain teardown");
const projectShell = await container.openShell(shell);
await projectShell.runCommand("cd /testapp");
await projectShell.runCommand("npm i axios");
const result = await projectShell.runCommand("npm i axios");
assert.ok(
!result.output.includes("Scanning for malicious packages..."),
"Expected npm command to not be wrapped by safe-chain after teardown"
);
});
}
});