mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge branch 'main' into fix/aikido-security-update-packages-5669664-mBXT
This commit is contained in:
commit
cf6f895724
43 changed files with 2524 additions and 793 deletions
36
.github/CONTRIBUTING
vendored
Normal file
36
.github/CONTRIBUTING
vendored
Normal 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
58
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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.
|
||||
38
.github/workflows/test-on-pr.yml
vendored
38
.github/workflows/test-on-pr.yml
vendored
|
|
@ -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
5
.npmignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
.github
|
||||
.claude
|
||||
test/e2e
|
||||
|
||||
18
README.md
18
README.md
|
|
@ -1,8 +1,8 @@
|
|||
# Aikido Safe Chain
|
||||
|
||||
The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, or yarn.
|
||||
The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm and pnpx.
|
||||
|
||||
The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), and [yarn](https://yarnpkg.com/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, or yarn from downloading or running the malware.
|
||||
The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), and [pnpx](https://pnpm.io/cli/dlx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm or pnpx from downloading or running the malware.
|
||||
|
||||

|
||||
|
||||
|
|
@ -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
8
bin/aikido-pnpm.js
Executable 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
8
bin/aikido-pnpx.js
Executable 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));
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
2
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
13
src/packagemanager/_shared/matchesCommand.js
Normal file
13
src/packagemanager/_shared/matchesCommand.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
46
src/packagemanager/pnpm/createPackageManager.js
Normal file
46
src/packagemanager/pnpm/createPackageManager.js
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
24
src/packagemanager/pnpm/runPnpmCommand.js
Normal file
24
src/packagemanager/pnpm/runPnpmCommand.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const aliases = getAliases(shell.startupFile);
|
||||
|
||||
if (aliases.length === 0) {
|
||||
ui.writeError(`- ${chalk.bold(shell.name)}: could not generate aliases.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileContent = readOrCreateStartupFile(shell.startupFile);
|
||||
const { addedCount, existingCount, failedCount } = appendAliasesToFile(
|
||||
aliases,
|
||||
fileContent,
|
||||
shell.startupFile
|
||||
);
|
||||
|
||||
let summary = "- " + chalk.bold(shell.name) + ": ";
|
||||
|
||||
if (addedCount > 0) {
|
||||
summary += chalk.green(`${addedCount} aliases were added`);
|
||||
}
|
||||
if (existingCount > 0) {
|
||||
if (addedCount > 0) {
|
||||
summary += ", ";
|
||||
}
|
||||
summary += chalk.yellow(`${existingCount} aliases were already present`);
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
if (addedCount > 0 || existingCount > 0) {
|
||||
summary += ", ";
|
||||
}
|
||||
summary += chalk.red(`${failedCount} aliases failed to add`);
|
||||
}
|
||||
|
||||
// write summary in a single line
|
||||
ui.writeInformation(summary);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This reads the content of the startup file.
|
||||
* If the file does not exist, it creates an empty file and returns an empty string.
|
||||
* The startup file is the shell's startup script (eg: ~/.bashrc, ~/.zshrc, etc.).
|
||||
* It is used to set up the shell environment when it starts.
|
||||
* Some shells may not have a startup file, in which case this function will create one.
|
||||
*/
|
||||
export function readOrCreateStartupFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, "", "utf-8");
|
||||
ui.writeInformation(`File ${filePath} created.`);
|
||||
}
|
||||
return fs.readFileSync(filePath, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* This function appends the aliases to the startup file.
|
||||
* eg: for bash it will append 'alias npm="aikido-npm"' for npm to ~/.bashrc
|
||||
* @returns an object with the counts of added, existing, and failed aliases.
|
||||
*/
|
||||
export function appendAliasesToFile(aliases, fileContent, startupFilePath) {
|
||||
let addedCount = 0;
|
||||
let existingCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const alias of aliases) {
|
||||
function setupShell(shell) {
|
||||
let success = false;
|
||||
try {
|
||||
if (fileContent.includes(alias)) {
|
||||
existingCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.appendFileSync(startupFilePath, `${EOL}${alias}`, "utf-8");
|
||||
|
||||
addedCount++;
|
||||
shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases
|
||||
success = shell.setup(knownAikidoTools);
|
||||
} catch {
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
success = false;
|
||||
}
|
||||
|
||||
return {
|
||||
addedCount,
|
||||
existingCount,
|
||||
failedCount,
|
||||
};
|
||||
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 success;
|
||||
}
|
||||
|
||||
function copyStartupFiles() {
|
||||
const startupFiles = ["init-posix.sh", "init-pwsh.ps1", "init-fish.fish"];
|
||||
|
||||
for (const file of startupFiles) {
|
||||
const targetDir = path.join(os.homedir(), ".safe-chain", "scripts");
|
||||
const targetPath = path.join(os.homedir(), ".safe-chain", "scripts", file);
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
src/shell-integration/startup-scripts/init-fish.fish
Normal file
58
src/shell-integration/startup-scripts/init-fish.fish
Normal 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
|
||||
54
src/shell-integration/startup-scripts/init-posix.sh
Normal file
54
src/shell-integration/startup-scripts/init-posix.sh
Normal 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" "$@"
|
||||
}
|
||||
80
src/shell-integration/startup-scripts/init-pwsh.ps1
Normal file
80
src/shell-integration/startup-scripts/init-pwsh.ps1
Normal 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
|
||||
}
|
||||
62
src/shell-integration/supported-shells/bash.js
Normal file
62
src/shell-integration/supported-shells/bash.js
Normal 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,
|
||||
};
|
||||
199
src/shell-integration/supported-shells/bash.spec.js
Normal file
199
src/shell-integration/supported-shells/bash.spec.js
Normal 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="));
|
||||
});
|
||||
});
|
||||
});
|
||||
65
src/shell-integration/supported-shells/fish.js
Normal file
65
src/shell-integration/supported-shells/fish.js
Normal 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,
|
||||
};
|
||||
183
src/shell-integration/supported-shells/fish.spec.js
Normal file
183
src/shell-integration/supported-shells/fish.spec.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
65
src/shell-integration/supported-shells/powershell.js
Normal file
65
src/shell-integration/supported-shells/powershell.js
Normal 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,
|
||||
};
|
||||
200
src/shell-integration/supported-shells/powershell.spec.js
Normal file
200
src/shell-integration/supported-shells/powershell.spec.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
65
src/shell-integration/supported-shells/windowsPowershell.js
Normal file
65
src/shell-integration/supported-shells/windowsPowershell.js
Normal 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,
|
||||
};
|
||||
200
src/shell-integration/supported-shells/windowsPowershell.spec.js
Normal file
200
src/shell-integration/supported-shells/windowsPowershell.spec.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
62
src/shell-integration/supported-shells/zsh.js
Normal file
62
src/shell-integration/supported-shells/zsh.js
Normal 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,
|
||||
};
|
||||
226
src/shell-integration/supported-shells/zsh.spec.js
Normal file
226
src/shell-integration/supported-shells/zsh.spec.js
Normal 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="));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
113
test/e2e/DockerTestContainer.js
Normal file
113
test/e2e/DockerTestContainer.js
Normal 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
32
test/e2e/Dockerfile
Normal 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
32
test/e2e/package-lock.json
generated
Normal 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
15
test/e2e/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
104
test/e2e/parseShellOutput.js
Normal file
104
test/e2e/parseShellOutput.js
Normal 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;
|
||||
}
|
||||
77
test/e2e/setup.teardown.e2e.spec.js
Normal file
77
test/e2e/setup.teardown.e2e.spec.js
Normal 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"
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue