Move project to separate folder

This commit is contained in:
Sander Declerck 2025-07-30 16:37:37 +02:00
parent dc3a7bf6e2
commit b1ca2d2dc5
No known key found for this signature in database
62 changed files with 4 additions and 1 deletions

8
safe-chain/bin/aikido-npm.js Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
const packageManagerName = "npm";
initializePackageManager(packageManagerName, process.versions.node);
await main(process.argv.slice(2));

8
safe-chain/bin/aikido-npx.js Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
const packageManagerName = "npx";
initializePackageManager(packageManagerName, process.versions.node);
await main(process.argv.slice(2));

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

@ -0,0 +1,8 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
const packageManagerName = "pnpm";
initializePackageManager(packageManagerName, process.versions.node);
await main(process.argv.slice(2));

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

@ -0,0 +1,8 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
const packageManagerName = "pnpx";
initializePackageManager(packageManagerName, process.versions.node);
await main(process.argv.slice(2));

8
safe-chain/bin/aikido-yarn.js Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
const packageManagerName = "yarn";
initializePackageManager(packageManagerName, process.versions.node);
await main(process.argv.slice(2));

57
safe-chain/bin/safe-chain.js Executable file
View file

@ -0,0 +1,57 @@
#!/usr/bin/env node
import chalk from "chalk";
import { ui } from "../src/environment/userInteraction.js";
import { setup } from "../src/shell-integration/setup.js";
import { teardown } from "../src/shell-integration/teardown.js";
if (process.argv.length < 3) {
ui.writeError("No command provided. Please provide a command to execute.");
ui.emptyLine();
writeHelp();
process.exit(1);
}
const command = process.argv[2];
if (command === "help" || command === "--help" || command === "-h") {
writeHelp();
process.exit(0);
}
if (command === "setup") {
setup();
} else if (command === "teardown") {
teardown();
} else {
ui.writeError(`Unknown command: ${command}.`);
ui.emptyLine();
writeHelp();
process.exit(1);
}
function writeHelp() {
ui.writeInformation(
chalk.bold("Usage: ") + chalk.cyan("safe-chain <command>")
);
ui.emptyLine();
ui.writeInformation(
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
"teardown"
)}, ${chalk.cyan("help")}`
);
ui.emptyLine();
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain setup"
)}: This will setup your shell to wrap safe-chain around npm, npx and yarn.`
);
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain teardown"
)}: This will remove safe-chain aliases from your shell configuration.`
);
ui.emptyLine();
}

5815
safe-chain/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

48
safe-chain/package.json Normal file
View file

@ -0,0 +1,48 @@
{
"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:e2e": "node --test test/e2e/**/*.e2e.spec.js",
"lint": "eslint ."
},
"repository": {
"type": "git",
"url": "git+https://github.com/AikidoSec/safe-chain.git"
},
"bin": {
"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",
"keywords": [],
"author": "Aikido Security",
"license": "AGPL-3.0-or-later",
"description": "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.",
"dependencies": {
"@inquirer/prompts": "^7.4.1",
"abbrev": "^3.0.1",
"chalk": "^5.4.1",
"node-pty": "^1.0.0",
"npm-registry-fetch": "^18.0.2",
"ora": "^8.2.0",
"semver": "^7.7.2"
},
"devDependencies": {
"@eslint/js": "^9.26.0",
"eslint": "^9.26.0",
"eslint-plugin-import": "^2.31.0",
"globals": "^16.1.0",
"typescript-eslint": "^8.32.0"
},
"main": "eslint.config.js",
"bugs": {
"url": "https://github.com/AikidoSec/safe-chain/issues"
},
"homepage": "https://github.com/AikidoSec/safe-chain#readme"
}

View file

@ -0,0 +1,31 @@
const malwareDatabaseUrl =
"https://malware-list.aikido.dev/malware_predictions.json";
export async function fetchMalwareDatabase() {
const response = await fetch(malwareDatabaseUrl);
if (!response.ok) {
throw new Error(`Error fetching malware database: ${response.statusText}`);
}
try {
let malwareDatabase = await response.json();
return {
malwareDatabase: malwareDatabase,
version: response.headers.get("etag") || undefined,
};
} catch (error) {
throw new Error(`Error parsing malware database: ${error.message}`);
}
}
export async function fetchMalwareDatabaseVersion() {
const response = await fetch(malwareDatabaseUrl, {
method: "HEAD",
});
if (!response.ok) {
throw new Error(
`Error fetching malware database version: ${response.statusText}`
);
}
return response.headers.get("etag") || undefined;
}

View file

@ -0,0 +1,46 @@
import * as semver from "semver";
import * as npmFetch from "npm-registry-fetch";
export async function resolvePackageVersion(packageName, versionRange) {
if (!versionRange) {
versionRange = "latest";
}
if (semver.valid(versionRange)) {
// The version is a fixed version, no need to resolve
return versionRange;
}
const packageInfo = await getPackageInfo(packageName);
if (!packageInfo) {
// It is possible that no version is found (could be a private package, or a package that doesn't exist)
// In this case, we return null to indicate that we couldn't resolve the version
return null;
}
const distTags = packageInfo["dist-tags"];
if (distTags && distTags[versionRange]) {
// If the version range is a dist-tag, return the version associated with that tag
// e.g., "latest", "next", etc.
return distTags[versionRange];
}
// If the version range is not a dist-tag, we need to resolve the highest version matching the range.
// This is useful for ranges like "^1.0.0" or "~2.3.4".
const availableVersions = Object.keys(packageInfo.versions);
const resolvedVersion = semver.maxSatisfying(availableVersions, versionRange);
if (resolvedVersion) {
return resolvedVersion;
}
// Nothing matched the range, return null
return null;
}
async function getPackageInfo(packageName) {
try {
return await npmFetch.json(packageName);
} catch {
return null;
}
}

View file

@ -0,0 +1,91 @@
import fs from "fs";
import path from "path";
import os from "os";
import { ui } from "../environment/userInteraction.js";
export function getScanTimeout() {
const config = readConfigFile();
return (
parseInt(process.env.AIKIDO_SCAN_TIMEOUT_MS) || config.scanTimeout || 10000 // Default to 10 seconds
);
}
export function writeDatabaseToLocalCache(data, version) {
try {
const databasePath = getDatabasePath();
const versionPath = getDatabaseVersionPath();
fs.writeFileSync(databasePath, JSON.stringify(data));
fs.writeFileSync(versionPath, version.toString());
} catch {
ui.writeWarning(
"Failed to write malware database to local cache, next time the database will be fetched from the server again."
);
}
}
export function readDatabaseFromLocalCache() {
try {
const databasePath = getDatabasePath();
if (!fs.existsSync(databasePath)) {
return {
malwareDatabase: null,
version: null,
};
}
const data = fs.readFileSync(databasePath, "utf8");
const malwareDatabase = JSON.parse(data);
const versionPath = getDatabaseVersionPath();
let version = null;
if (fs.existsSync(versionPath)) {
version = fs.readFileSync(versionPath, "utf8").trim();
}
return {
malwareDatabase: malwareDatabase,
version: version,
};
} catch {
ui.writeWarning(
"Failed to read malware database from local cache. Continuing without local cache."
);
return {
malwareDatabase: null,
version: null,
};
}
}
function readConfigFile() {
const configFilePath = getConfigFilePath();
if (!fs.existsSync(configFilePath)) {
return {};
}
const data = fs.readFileSync(configFilePath, "utf8");
return JSON.parse(data);
}
function getDatabasePath() {
const aikidoDir = getAikidoDirectory();
return path.join(aikidoDir, "malwareDatabase.json");
}
function getDatabaseVersionPath() {
const aikidoDir = getAikidoDirectory();
return path.join(aikidoDir, "version.txt");
}
function getConfigFilePath() {
return path.join(getAikidoDirectory(), "config.json");
}
function getAikidoDirectory() {
const homeDir = os.homedir();
const aikidoDir = path.join(homeDir, ".aikido");
if (!fs.existsSync(aikidoDir)) {
fs.mkdirSync(aikidoDir, { recursive: true });
}
return aikidoDir;
}

View file

@ -0,0 +1,14 @@
export function isCi() {
const ciEnvironments = [
"CI",
"TF_BUILD", // Azure devops does not set CI, but TF_BUILD
];
for (const env of ciEnvironments) {
if (process.env[env]) {
return true;
}
}
return false;
}

View file

@ -0,0 +1,79 @@
import chalk from "chalk";
import ora from "ora";
import { confirm as inquirerConfirm } from "@inquirer/prompts";
import { isCi } from "./environment.js";
function emptyLine() {
writeInformation("");
}
function writeInformation(message, ...optionalParams) {
console.log(message, ...optionalParams);
}
function writeWarning(message, ...optionalParams) {
if (!isCi()) {
message = chalk.yellow(message);
}
console.warn(message, ...optionalParams);
}
function writeError(message, ...optionalParams) {
if (!isCi()) {
message = chalk.red(message);
}
console.error(message, ...optionalParams);
}
function startProcess(message) {
if (isCi()) {
return {
succeed: (message) => {
writeInformation(message);
},
fail: (message) => {
writeError(message);
},
stop: () => {},
setText: (message) => {
writeInformation(message);
},
};
} else {
const spinner = ora(message).start();
return {
succeed: (message) => {
spinner.succeed(message);
},
fail: (message) => {
spinner.fail(message);
},
stop: () => {
spinner.stop();
},
setText: (message) => {
spinner.text = message;
},
};
}
}
async function confirm(config) {
if (isCi()) {
return Promise.resolve(config.default);
} else {
return inquirerConfirm({
message: config.message,
default: config.default,
});
}
}
export const ui = {
writeInformation,
writeWarning,
writeError,
emptyLine,
startProcess,
confirm,
};

18
safe-chain/src/main.js Normal file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env node
import { scanCommand, shouldScanCommand } from "./scanning/index.js";
import { ui } from "./environment/userInteraction.js";
import { getPackageManager } from "./packagemanager/currentPackageManager.js";
export async function main(args) {
try {
if (shouldScanCommand(args)) {
await scanCommand(args);
}
} catch (error) {
ui.writeError("Failed to check for malicious packages:", error.message);
}
var result = getPackageManager().runCommand(args);
process.exit(result.status);
}

View file

@ -0,0 +1,13 @@
export function matchesCommand(args, ...commandArgs) {
if (args.length < commandArgs.length) {
return false;
}
for (var i = 0; i < commandArgs.length; i++) {
if (args[i].toLowerCase() !== commandArgs[i].toLowerCase()) {
return false;
}
}
return true;
}

View file

@ -0,0 +1,36 @@
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 = {
packageManagerName: null,
};
export function initializePackageManager(packageManagerName, version) {
if (packageManagerName === "npm") {
state.packageManagerName = createNpmPackageManager(version);
} else if (packageManagerName === "npx") {
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);
}
return state.packageManagerName;
}
export function getPackageManager() {
if (!state.packageManagerName) {
throw new Error("Package manager not initialized.");
}
return state.packageManagerName;
}

View file

@ -0,0 +1,83 @@
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
import { dryRunScanner } from "./dependencyScanner/dryRunScanner.js";
import { nullScanner } from "./dependencyScanner/nullScanner.js";
import { runNpm } from "./runNpmCommand.js";
import {
getNpmCommandForArgs,
npmInstallCommand,
npmCiCommand,
npmInstallTestCommand,
npmInstallCiTestCommand,
npmUpdateCommand,
npmAuditCommand,
npmExecCommand,
} from "./utils/npmCommands.js";
export function createNpmPackageManager(version) {
const supportedScanners =
getMajorVersion(version) >= 22
? npm22AndAboveSupportedScanners
: npm21AndBelowSupportedScanners;
function isSupportedCommand(args) {
const scanner = findDependencyScannerForCommand(supportedScanners, args);
return scanner.shouldScan(args);
}
function getDependencyUpdatesForCommand(args) {
const scanner = findDependencyScannerForCommand(supportedScanners, args);
return scanner.scan(args);
}
return {
getWarningMessage: () => warnForLimitedSupport(version),
runCommand: runNpm,
isSupportedCommand,
getDependencyUpdatesForCommand,
};
}
const npm22AndAboveSupportedScanners = {
[npmInstallCommand]: dryRunScanner(),
[npmUpdateCommand]: dryRunScanner(),
[npmCiCommand]: dryRunScanner(),
[npmAuditCommand]: dryRunScanner({
skipScanWhen: (args) => !args.includes("fix"),
}),
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
// Running dry-run on install-test and install-ci-test will install & run tests.
// We only want to know if there are changes in the dependencies.
// So we run change the dry-run command to only check the install.
[npmInstallTestCommand]: dryRunScanner({ dryRunCommand: npmInstallCommand }),
[npmInstallCiTestCommand]: dryRunScanner({ dryRunCommand: npmCiCommand }),
};
const npm21AndBelowSupportedScanners = {
[npmInstallCommand]: commandArgumentScanner(),
[npmUpdateCommand]: commandArgumentScanner(),
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
};
function warnForLimitedSupport(version) {
if (getMajorVersion(version) >= 22) {
return null;
}
return `Aikido-npm will only scan the arguments of the install command for Node.js version prior to version 22.
Please update your Node.js version to 22 or higher for full coverage. Current version: v${version}`;
}
function getMajorVersion(version) {
return parseInt(version.split(".")[0]);
}
function findDependencyScannerForCommand(scanners, args) {
const command = getNpmCommandForArgs(args);
if (!command) {
return nullScanner();
}
const scanner = scanners[command];
return scanner ? scanner : nullScanner();
}

View file

@ -0,0 +1,37 @@
import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js";
import { hasDryRunArg } from "../utils/npmCommands.js";
export function commandArgumentScanner(opts) {
const ignoreDryRun = opts?.ignoreDryRun ?? false;
return {
scan: (args) => scanDependencies(args),
shouldScan: (args) => shouldScanDependencies(args, ignoreDryRun),
};
}
function scanDependencies(args) {
return checkChangesFromArgs(args);
}
function shouldScanDependencies(args, ignoreDryRun) {
return ignoreDryRun || !hasDryRunArg(args);
}
export async function checkChangesFromArgs(args) {
const changes = [];
const packageUpdates = parsePackagesFromInstallArgs(args);
for (const packageUpdate of packageUpdates) {
var exactVersion = await resolvePackageVersion(
packageUpdate.name,
packageUpdate.version
);
if (exactVersion) {
packageUpdate.version = exactVersion;
}
changes.push({ ...packageUpdate, type: "add" });
}
return changes;
}

View file

@ -0,0 +1,50 @@
import { ui } from "../../../environment/userInteraction.js";
import { parseDryRunOutput } from "../parsing/parseNpmInstallDryRunOutput.js";
import { dryRunNpmCommandAndOutput } from "../runNpmCommand.js";
import { hasDryRunArg } from "../utils/npmCommands.js";
export function dryRunScanner(scannerOptions) {
return {
scan: (args) => scanDependencies(scannerOptions, args),
shouldScan: (args) => shouldScanDependencies(scannerOptions, args),
};
}
function scanDependencies(scannerOptions, args) {
let dryRunArgs = args;
if (scannerOptions?.dryRunCommand) {
// Replace the first argument with the dryRunCommand (eg: "install" instead of "install-test")
dryRunArgs = [scannerOptions.dryRunCommand, ...args.slice(1)];
}
return checkChangesWithDryRun(dryRunArgs);
}
function shouldScanDependencies(scannerOptions, args) {
if (hasDryRunArg(args)) {
return false;
}
if (scannerOptions?.skipScanWhen && scannerOptions.skipScanWhen(args)) {
return false;
}
return true;
}
function checkChangesWithDryRun(args) {
const dryRunOutput = dryRunNpmCommandAndOutput(args);
// Dry-run can return a non-zero status code in some cases
// e.g., when running "npm audit fix --dry-run", it returns exit code 1
// when there are vulnurabilities that can be fixed.
if (dryRunOutput.status !== 0 && !dryRunOutput.output) {
ui.writeError("Detecting changes failed.");
return [];
}
const parsedOutput = parseDryRunOutput(dryRunOutput.output);
// reverse the array to have the top-level packages first
return parsedOutput.reverse();
}

View file

@ -0,0 +1,6 @@
export function nullScanner() {
return {
scan: () => [],
shouldScan: () => false,
};
}

View file

@ -0,0 +1,57 @@
export function parseDryRunOutput(output) {
const lines = output.split(/\r?\n/);
const packageChanges = [];
for (const line of lines) {
if (line.startsWith("add ")) {
packageChanges.push(parseAdd(line));
} else if (line.startsWith("remove ")) {
packageChanges.push(parseRemove(line));
} else if (line.startsWith("change ")) {
packageChanges.push(parseChange(line));
}
}
return packageChanges;
}
function parseAdd(line) {
const splitLine = getLineParts(line);
const packageName = splitLine[1];
const packageVersion = splitLine[splitLine.length - 1];
return addedPackage(packageName, packageVersion);
}
function addedPackage(name, version) {
return { type: "add", name, version };
}
function parseRemove(line) {
const splitLine = getLineParts(line);
const packageName = splitLine[1];
const packageVersion = splitLine[splitLine.length - 1];
return removedPackage(packageName, packageVersion);
}
function removedPackage(name, version) {
return { type: "remove", name, version };
}
function parseChange(line) {
const splitLine = getLineParts(line);
const packageName = splitLine[1];
const packageVersion = splitLine[splitLine.length - 1];
const oldVersion = splitLine[2];
return changedPackage(packageName, packageVersion, oldVersion);
}
function getLineParts(line) {
return line
.split(" ")
.map((part) => part.trim())
.filter((part) => part !== "");
}
function changedPackage(name, version, oldVersion) {
return { type: "change", name, version, oldVersion };
}

View file

@ -0,0 +1,134 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parseDryRunOutput } from "./parseNpmInstallDryRunOutput.js";
describe("parseNpmInstallDryRunOutput", () => {
it("should parse added packages", () => {
const output = `
add @jest/transform 29.7.0
add @jest/test-result 29.7.0
add @jest/reporters 29.7.0
add @jest/console 29.7.0
add jest-cli 29.7.0
add import-local 3.2.0
add @jest/types 29.6.3
add @jest/core 29.7.0
add jest 29.7.0
added 267 packages in 831ms
32 packages are looking for funding
run \`npm fund\` for details`;
const expected = [
{ name: "@jest/transform", version: "29.7.0", type: "add" },
{ name: "@jest/test-result", version: "29.7.0", type: "add" },
{ name: "@jest/reporters", version: "29.7.0", type: "add" },
{ name: "@jest/console", version: "29.7.0", type: "add" },
{ name: "jest-cli", version: "29.7.0", type: "add" },
{ name: "import-local", version: "3.2.0", type: "add" },
{ name: "@jest/types", version: "29.6.3", type: "add" },
{ name: "@jest/core", version: "29.7.0", type: "add" },
{ name: "jest", version: "29.7.0", type: "add" },
];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
it("should parse removed packages", () => {
const output = `
remove react 19.1.0
removed 1 package in 115ms`;
const expected = [{ name: "react", version: "19.1.0", type: "remove" }];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
it("should parse changed packages", () => {
const output = `
change react 19.0.0 => 19.1.0
changed 1 package in 204ms`;
const expected = [
{
name: "react",
version: "19.1.0",
oldVersion: "19.0.0",
type: "change",
},
];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
it("should parse mixed package changes", () => {
const output = `
add @jest/transform 29.7.0
add @jest/test-result 29.7.0
add @jest/reporters 29.7.0
add @jest/console 29.7.0
add jest-cli 29.7.0
add import-local 3.2.0
add @jest/types 29.6.3
add @jest/core 29.7.0
add jest 29.7.0
remove react 19.1.0
change lodash 4.17.0 => 4.18.0
removed 1 package in 115ms`;
const expected = [
{ name: "@jest/transform", version: "29.7.0", type: "add" },
{ name: "@jest/test-result", version: "29.7.0", type: "add" },
{ name: "@jest/reporters", version: "29.7.0", type: "add" },
{ name: "@jest/console", version: "29.7.0", type: "add" },
{ name: "jest-cli", version: "29.7.0", type: "add" },
{ name: "import-local", version: "3.2.0", type: "add" },
{ name: "@jest/types", version: "29.6.3", type: "add" },
{ name: "@jest/core", version: "29.7.0", type: "add" },
{ name: "jest", version: "29.7.0", type: "add" },
{ name: "react", version: "19.1.0", type: "remove" },
{
name: "lodash",
version: "4.18.0",
oldVersion: "4.17.0",
type: "change",
},
];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
it("should work with npm v22.0.0", () => {
const output = `
add @jest/types 29.6.3
add @jest/core 29.7.0
add jest 29.7.0
added 257 packages in 791ms
44 packages are looking for funding
run \`npm fund\` for details`;
const expected = [
{ name: "@jest/types", version: "29.6.3", type: "add" },
{ name: "@jest/core", version: "29.7.0", type: "add" },
{ name: "jest", version: "29.7.0", type: "add" },
];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
});

View file

@ -0,0 +1,109 @@
export function parsePackagesFromInstallArgs(args) {
const changes = [];
let defaultTag = "latest";
// Skip first argument (install command)
for (let i = 1; i < args.length; i++) {
const arg = args[i];
const npmOption = getNpmOption(arg);
if (npmOption) {
// If the option has a parameter, skip the next argument as well
i += npmOption.numberOfParameters;
// it a tag is specified, set the default tag
if (npmOption.name === "--tag") {
defaultTag = args[i];
}
continue;
}
const packageDetails = parsePackagename(arg);
if (packageDetails) {
changes.push(packageDetails);
continue;
}
}
for (const change of changes) {
if (!change.version) {
change.version = defaultTag;
}
}
return changes;
}
function getNpmOption(arg) {
if (isNpmOptionWithParameter(arg)) {
return {
name: arg,
numberOfParameters: 1,
};
}
// Arguments starting with "-" or "--" are considered npm options
if (arg.startsWith("-")) {
return {
name: arg,
numberOfParameters: 0,
};
}
return undefined;
}
function isNpmOptionWithParameter(arg) {
const optionsWithParameters = [
"--access",
"--auth-type",
"--cache",
"--fetch-retries",
"--fetch-retry-mintimeout",
"--fetch-retry-maxtimeout",
"--fetch-retry-factor",
"--fetch-timeout",
"--https-proxy",
"--include",
"--location",
"--lockfile-version",
"--loglevel",
"--omit",
"--proxy",
"--registry",
"--replace-registry-host",
"--tag",
"--user-config",
"--workspace",
];
return optionsWithParameters.includes(arg);
}
function parsePackagename(arg) {
arg = removeAlias(arg);
const lastAtIndex = arg.lastIndexOf("@");
let name, version;
if (lastAtIndex !== -1) {
name = arg.slice(0, lastAtIndex);
version = arg.slice(lastAtIndex + 1);
} else {
name = arg;
version = null;
}
return {
name,
version,
};
}
function removeAlias(arg) {
const aliasIndex = arg.indexOf("@npm:");
if (aliasIndex !== -1) {
return arg.slice(aliasIndex + 5);
}
return arg;
}

View file

@ -0,0 +1,176 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parsePackagesFromInstallArgs } from "./parsePackagesFromInstallArgs.js";
describe("parsePackagesFromInstallArgs", () => {
it("should return an empty array for no changes", () => {
const args = ["install"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, []);
});
it("should return an array of changes for one package", () => {
const args = ["install", "@jest/transform@29.7.0"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "@jest/transform", version: "29.7.0" }]);
});
it("should return an array of changes for multiple packages", () => {
const args = ["install", "express@4.17.1", "lodash@4.17.21"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "4.17.1" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should ignore options and return an array of changes", () => {
const args = [
"install",
"--save-dev",
"express@4.17.1",
"--save-exact",
"lodash@4.17.21",
];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "4.17.1" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should ignore options with parameters and return an array of changes", () => {
const args = [
"install",
"--save-dev",
"express@4.17.1",
"--loglevel",
"error",
"lodash@4.17.21",
];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "4.17.1" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should not ignore the next argument if it is passed directly with the option", () => {
const args = [
"install",
"--save-dev",
"express@4.17.1",
"--loglevel=error",
"lodash@4.17.21",
];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "4.17.1" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should set the default tag for packages", () => {
const args = ["install", "express", "lodash@4.17.21"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "latest" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should set the default tag for packages with a specific tag", () => {
const args = ["install", "express", "lodash@4.17.21", "--tag", "beta"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "beta" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should ignore alias", () => {
const args = ["install", "express@npm:express@4.17.1"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "express", version: "4.17.1" }]);
});
it("should parse version even for aliased packages", () => {
const args = ["install", "express@npm:express@4.17.1"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "express", version: "4.17.1" }]);
});
it("should parse scoped packages", () => {
const args = ["install", "@scope/package@1.0.0"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "@scope/package", version: "1.0.0" }]);
});
it("should parse packages with version ranges", () => {
const args = ["install", "express@^4.17.1"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "express", version: "^4.17.1" }]);
});
it("should parse package folders", () => {
const args = ["install", "./local-package"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]);
});
it("should parse tarballs", () => {
const args = ["install", "file:./local-package.tgz"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "file:./local-package.tgz", version: "latest" },
]);
});
it("should parse tarball URLs", () => {
const args = ["install", "https://example.com/local-package.tgz"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "https://example.com/local-package.tgz", version: "latest" },
]);
});
it("should parse git URLs", () => {
const args = ["install", "git://github.com/npm/cli.git"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "git://github.com/npm/cli.git", version: "latest" },
]);
});
});

View file

@ -0,0 +1,33 @@
import { execSync } from "child_process";
import { ui } from "../../environment/userInteraction.js";
export function runNpm(args) {
try {
const npmCommand = `npm ${args.join(" ")}`;
execSync(npmCommand, { stdio: "inherit" });
} catch (error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
}
return { status: 0 };
}
export function dryRunNpmCommandAndOutput(args) {
try {
const npmCommand = `npm ${args.join(" ")} --dry-run`;
const output = execSync(npmCommand, { stdio: "pipe" });
return { status: 0, output: output.toString() };
} catch (error) {
if (error.status) {
const output = error.stdout ? error.stdout.toString() : "";
return { status: error.status, output };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
}
}

View file

@ -0,0 +1,171 @@
// Based on https://github.com/npm/cli/blob/latest/lib/utils/cmd-list.js
import abbrev from "abbrev";
const commands = [
"access",
"adduser",
"audit",
"bugs",
"cache",
"ci",
"completion",
"config",
"dedupe",
"deprecate",
"diff",
"dist-tag",
"docs",
"doctor",
"edit",
"exec",
"explain",
"explore",
"find-dupes",
"fund",
"get",
"help",
"help-search",
"init",
"install",
"install-ci-test",
"install-test",
"link",
"ll",
"login",
"logout",
"ls",
"org",
"outdated",
"owner",
"pack",
"ping",
"pkg",
"prefix",
"profile",
"prune",
"publish",
"query",
"rebuild",
"repo",
"restart",
"root",
"run",
"sbom",
"search",
"set",
"shrinkwrap",
"star",
"stars",
"start",
"stop",
"team",
"test",
"token",
"undeprecate",
"uninstall",
"unpublish",
"unstar",
"update",
"version",
"view",
"whoami",
];
// These must resolve to an entry in commands
const aliases = {
// aliases
author: "owner",
home: "docs",
issues: "bugs",
info: "view",
show: "view",
find: "search",
add: "install",
unlink: "uninstall",
remove: "uninstall",
rm: "uninstall",
r: "uninstall",
// short names for common things
un: "uninstall",
rb: "rebuild",
list: "ls",
ln: "link",
create: "init",
i: "install",
it: "install-test",
cit: "install-ci-test",
up: "update",
c: "config",
s: "search",
se: "search",
tst: "test",
t: "test",
ddp: "dedupe",
v: "view",
"run-script": "run",
"clean-install": "ci",
"clean-install-test": "install-ci-test",
x: "exec",
why: "explain",
la: "ll",
verison: "version",
ic: "ci",
// typos
innit: "init",
// manually abbrev so that install-test doesn't make insta stop working
in: "install",
ins: "install",
inst: "install",
insta: "install",
instal: "install",
isnt: "install",
isnta: "install",
isntal: "install",
isntall: "install",
"install-clean": "ci",
"isntall-clean": "ci",
hlep: "help",
"dist-tags": "dist-tag",
upgrade: "update",
udpate: "update",
rum: "run",
sit: "install-ci-test",
urn: "run",
ogr: "org",
"add-user": "adduser",
};
export function deref(c) {
if (!c) {
return;
}
// Translate camelCase to snake-case (i.e. installTest to install-test)
if (c.match(/[A-Z]/)) {
c = c.replace(/([A-Z])/g, (m) => "-" + m.toLowerCase());
}
// if they asked for something exactly we are done
if (commands.includes(c)) {
return c;
}
// if they asked for a direct alias
if (aliases[c]) {
return aliases[c];
}
const abbrevs = abbrev(commands.concat(Object.keys(aliases)));
// first deref the abbrev, if there is one
// then resolve any aliases
// so `npm install-cl` will resolve to `install-clean` then to `ci`
let a = abbrevs[c];
while (aliases[a]) {
a = aliases[a];
}
return a;
}

View file

@ -0,0 +1,26 @@
import { deref } from "./cmd-list.js";
export function getNpmCommandForArgs(args) {
if (args.length === 0) {
return null;
}
const argCommand = deref(args[0]);
if (!argCommand) {
return null;
}
return argCommand;
}
export function hasDryRunArg(args) {
return args.some((arg) => arg === "--dry-run");
}
export const npmInstallCommand = "install";
export const npmCiCommand = "ci";
export const npmInstallTestCommand = "install-test";
export const npmInstallCiTestCommand = "install-ci-test";
export const npmUpdateCommand = "update";
export const npmAuditCommand = "audit";
export const npmExecCommand = "exec";

View file

@ -0,0 +1,13 @@
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
import { runNpx } from "./runNpxCommand.js";
export function createNpxPackageManager() {
const scanner = commandArgumentScanner();
return {
getWarningMessage: () => null,
runCommand: runNpx,
isSupportedCommand: (args) => scanner.shouldScan(args),
getDependencyUpdatesForCommand: (args) => scanner.scan(args),
};
}

View file

@ -0,0 +1,31 @@
import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js";
export function commandArgumentScanner() {
return {
scan: (args) => scanDependencies(args),
shouldScan: () => true, // all npx commands need to be scanned, npx doesn't have dry-run
};
}
function scanDependencies(args) {
return checkChangesFromArgs(args);
}
export async function checkChangesFromArgs(args) {
const changes = [];
const packageUpdates = parsePackagesFromArguments(args);
for (const packageUpdate of packageUpdates) {
var exactVersion = await resolvePackageVersion(
packageUpdate.name,
packageUpdate.version
);
if (exactVersion) {
packageUpdate.version = exactVersion;
}
changes.push({ ...packageUpdate, type: "add" });
}
return changes;
}

View file

@ -0,0 +1,106 @@
export function parsePackagesFromArguments(args) {
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) {
return [packageDetails];
}
}
return [];
}
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 = [
"--access",
"--auth-type",
"--cache",
"--fetch-retries",
"--fetch-retry-mintimeout",
"--fetch-retry-maxtimeout",
"--fetch-retry-factor",
"--fetch-timeout",
"--https-proxy",
"--include",
"--location",
"--lockfile-version",
"--loglevel",
"--omit",
"--proxy",
"--registry",
"--replace-registry-host",
"--tag",
"--user-config",
"--workspace",
];
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;
if (lastAtIndex !== -1) {
name = arg.slice(0, lastAtIndex);
version = arg.slice(lastAtIndex + 1);
} else {
name = arg;
version = defaultTag; // No tag specified (eg: "http-server"), use the default tag
}
return {
name,
version,
};
}
function removeAlias(arg) {
// removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest
const aliasIndex = arg.indexOf("@npm:");
if (aliasIndex !== -1) {
return arg.slice(aliasIndex + 5);
}
return arg;
}

View file

@ -0,0 +1,147 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parsePackagesFromArguments } from "./parsePackagesFromArguments.js";
describe("parsePackagesFromArguments", () => {
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 = ["http-server@14.1.1"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "14.1.1" }]);
});
it("should return the package with latest tag if absent", () => {
const args = ["http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should ignore double --", () => {
const args = ["--", "http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should only return the first package", () => {
const args = ["http-server", "jest"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should return package with -p option", () => {
const args = ["-p", "http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should return package with --package option", () => {
const args = ["--package", "http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should return package with --package=x option", () => {
const args = ["--package=http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should return package with --package=x@version option", () => {
const args = ["--package=http-server@1.0.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "1.0.0" }]);
});
it("should ignore options with parameters and return an array of changes", () => {
const args = ["--loglevel", "error", "http-server@14.1.1"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "14.1.1" }]);
});
it("should parse version even for aliased packages", () => {
const args = ["server@npm:http-server@14.1.1"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "14.1.1" }]);
});
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 = ["http-server@^14.1.1"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "^14.1.1" }]);
});
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" },
]);
});
});

View file

@ -0,0 +1,17 @@
import { execSync } from "child_process";
import { ui } from "../../environment/userInteraction.js";
export function runNpx(args) {
try {
const npxCommand = `npx ${args.join(" ")}`;
execSync(npxCommand, { stdio: "inherit" });
} catch (error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
}
return { status: 0 };
}

View file

@ -0,0 +1,46 @@
import { matchesCommand } from "../_shared/matchesCommand.js";
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
import { runPnpmCommand } from "./runPnpmCommand.js";
const scanner = commandArgumentScanner();
export function createPnpmPackageManager() {
return {
getWarningMessage: () => null,
runCommand: (args) => runPnpmCommand(args, "pnpm"),
isSupportedCommand: (args) =>
matchesCommand(args, "add") ||
matchesCommand(args, "update") ||
matchesCommand(args, "upgrade") ||
matchesCommand(args, "up") ||
// dlx does not always come in the first position
// eg: pnpm --package=yo --package=generator-webapp dlx yo webapp
// documentation: https://pnpm.io/cli/dlx#--package-name
args.includes("dlx"),
getDependencyUpdatesForCommand: (args) =>
getDependencyUpdatesForCommand(args, false),
};
}
export function createPnpxPackageManager() {
return {
getWarningMessage: () => null,
runCommand: (args) => runPnpmCommand(args, "pnpx"),
isSupportedCommand: () => true,
getDependencyUpdatesForCommand: (args) =>
getDependencyUpdatesForCommand(args, true),
};
}
function getDependencyUpdatesForCommand(args, isPnpx) {
if (isPnpx) {
return scanner.scan(args);
}
if (args.includes("dlx")) {
// dlx is not always the first argument (eg: `pnpm --package=yo --package=generator-webapp dlx yo webapp`)
// so we need to filter it out instead of slicing the array
// documentation: https://pnpm.io/cli/dlx#--package-name
return scanner.scan(args.filter((arg) => arg !== "dlx"));
}
return scanner.scan(args.slice(1));
}

View file

@ -0,0 +1,28 @@
import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js";
export function commandArgumentScanner() {
return {
scan: (args) => scanDependencies(args),
shouldScan: () => true, // There's no dry run for pnpm, so we always scan
};
}
async function scanDependencies(args) {
const changes = [];
const packageUpdates = parsePackagesFromArguments(args);
for (const packageUpdate of packageUpdates) {
var exactVersion = await resolvePackageVersion(
packageUpdate.name,
packageUpdate.version
);
if (exactVersion) {
packageUpdate.version = exactVersion;
}
changes.push({ ...packageUpdate, type: "add" });
}
return changes;
}

View file

@ -0,0 +1,88 @@
export function parsePackagesFromArguments(args) {
const changes = [];
let defaultTag = "latest";
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const option = getOption(arg);
if (option) {
// If the option has a parameter, skip the next argument as well
i += option.numberOfParameters;
continue;
}
const packageDetails = parsePackagename(arg, defaultTag);
if (packageDetails) {
changes.push(packageDetails);
}
}
return changes;
}
function getOption(arg) {
if (isOptionWithParameter(arg)) {
return {
name: arg,
numberOfParameters: 1,
};
}
// Arguments starting with "-" or "--" are considered options
// except for "--package=" which contains the package name
if (arg.startsWith("-") && !arg.startsWith("--package=")) {
return {
name: arg,
numberOfParameters: 0,
};
}
return undefined;
}
function isOptionWithParameter(arg) {
const optionsWithParameters = ["--C", "--dir"];
return optionsWithParameters.includes(arg);
}
function parsePackagename(arg, defaultTag) {
// format can be --package=name@version
// in that case, we need to remove the --package= part
if (arg.startsWith("--package=")) {
arg = arg.slice(10);
}
arg = removeAlias(arg);
// Split at the last "@" to separate the package name and version
const lastAtIndex = arg.lastIndexOf("@");
let name, version;
// The index of the last "@" should be greater than 0
// If the index is 0, it means the package name starts with "@" (eg: "@aikidosec/package-name")
if (lastAtIndex > 0) {
name = arg.slice(0, lastAtIndex);
version = arg.slice(lastAtIndex + 1);
} else {
name = arg;
version = defaultTag; // No tag specified (eg: "http-server"), use the default tag
}
return {
name,
version,
};
}
function removeAlias(arg) {
// removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest
const aliasIndex = arg.indexOf("@npm:");
if (aliasIndex !== -1) {
return arg.slice(aliasIndex + 5);
}
return arg;
}

View file

@ -0,0 +1,138 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parsePackagesFromArguments } from "./parsePackagesFromArguments.js";
describe("standardPnpmArgumentParser", () => {
it("should return an empty array for no changes", () => {
const args = [];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, []);
});
it("should return an array of changes for one package", () => {
const args = ["axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
it("should return the package with latest tag if absent", () => {
const args = ["axios"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "latest" }]);
});
it("should return the package with latest tag if the version is absent and package starts with @", () => {
const args = ["@aikidosec/package-name"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "@aikidosec/package-name", version: "latest" },
]);
});
it("should return the package with the specified tag if the package starts with @ and includes the version", () => {
const args = ["@aikidosec/package-name@1.0.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "@aikidosec/package-name", version: "1.0.0" },
]);
});
it("should only return all packages", () => {
const args = ["axios", "jest"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "axios", version: "latest" },
{ name: "jest", version: "latest" },
]);
});
it("should ignore options with parameters and return an array of changes", () => {
const args = ["--C", "/Users/johnsmith/dev/project", "axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
it("should parse version even for aliased packages", () => {
const args = ["server@npm:axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
it("should parse scoped packages", () => {
const args = ["@scope/package@1.0.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "@scope/package", version: "1.0.0" }]);
});
it("should parse packages with version ranges", () => {
const args = ["axios@^1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]);
});
it("should parse package folders", () => {
const args = ["./local-package"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]);
});
it("should parse tarballs", () => {
const args = ["file:./local-package.tgz"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "file:./local-package.tgz", version: "latest" },
]);
});
it("should parse tarball URLs", () => {
const args = ["https://example.com/local-package.tgz"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "https://example.com/local-package.tgz", version: "latest" },
]);
});
it("should parse git URLs", () => {
const args = ["git://github.com/http-party/http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "git://github.com/http-party/http-server", version: "latest" },
]);
});
it("should parse packages with --package={packageName}", () => {
const args = ["--package=axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
});

View file

@ -0,0 +1,24 @@
import { spawnSync } from "child_process";
import { ui } from "../../environment/userInteraction.js";
export function runPnpmCommand(args, toolName = "pnpm") {
try {
let result;
if (toolName === "pnpm") {
result = spawnSync("pnpm", args, { stdio: "inherit" });
} else if (toolName === "pnpx") {
result = spawnSync("pnpx", args, { stdio: "inherit" });
} else {
throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`);
}
if (result.status !== null) {
return { status: result.status };
}
} catch (error) {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
return { status: 0 };
}

View file

@ -0,0 +1,34 @@
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
import { runYarnCommand } from "./runYarnCommand.js";
const scanner = commandArgumentScanner();
export function createYarnPackageManager() {
return {
getWarningMessage: () => null,
runCommand: runYarnCommand,
isSupportedCommand: (args) =>
matchesCommand(args, "add") ||
matchesCommand(args, "global", "add") ||
matchesCommand(args, "install") ||
matchesCommand(args, "up") ||
matchesCommand(args, "upgrade") ||
matchesCommand(args, "global", "upgrade") ||
matchesCommand(args, "dlx"),
getDependencyUpdatesForCommand: (args) => scanner.scan(args),
};
}
function matchesCommand(args, ...commandArgs) {
if (args.length < commandArgs.length) {
return false;
}
for (var i = 0; i < commandArgs.length; i++) {
if (args[i].toLowerCase() !== commandArgs[i].toLowerCase()) {
return false;
}
}
return true;
}

View file

@ -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 yarn, so we always scan
};
}
async function scanDependencies(args) {
const changes = [];
const packageUpdates = parsePackagesFromArguments(args);
for (const packageUpdate of packageUpdates) {
var exactVersion = await resolvePackageVersion(
packageUpdate.name,
packageUpdate.version
);
if (exactVersion) {
packageUpdate.version = exactVersion;
}
changes.push({ ...packageUpdate, type: "add" });
}
return changes;
}

View file

@ -0,0 +1,102 @@
export function parsePackagesFromArguments(args) {
const changes = [];
let defaultTag = "latest";
for (let i = 1; 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("-")) {
return {
name: arg,
numberOfParameters: 0,
};
}
return undefined;
}
function isOptionWithParameter(arg) {
const optionsWithParameters = [
"--use-yarnrc",
"--link-folder",
"--global-folder",
"--modules-folder",
"--preferred-cache-folder",
"--cache-folder",
"--mutex",
"--cwd",
"--proxy",
"--https-proxy",
"--registry",
"--network-concurrency",
"--network-timeout",
"--scripts-prepend-node-path",
"--otp",
];
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;
if (lastAtIndex !== -1) {
name = arg.slice(0, lastAtIndex);
version = arg.slice(lastAtIndex + 1);
} else {
name = arg;
version = defaultTag; // No tag specified (eg: "http-server"), use the default tag
}
return {
name,
version,
};
}
function removeAlias(arg) {
// removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest
const aliasIndex = arg.indexOf("@npm:");
if (aliasIndex !== -1) {
return arg.slice(aliasIndex + 5);
}
return arg;
}

View file

@ -0,0 +1,126 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parsePackagesFromArguments } from "./parsePackagesFromArguments.js";
describe("standardYarnArgumentParser", () => {
it("should return an empty array for no changes", () => {
const args = ["add"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, []);
});
it("should return an array of changes for one package", () => {
const args = ["add", "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 = ["add", "axios"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "latest" }]);
});
it("should only return all packages", () => {
const args = ["add", "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 = ["add", "--proxy", "http://localhost", "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 = ["add", "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 = ["add", "@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 = ["add", "axios@^1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]);
});
it("should parse package folders", () => {
const args = ["add", "./local-package"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]);
});
it("should parse tarballs", () => {
const args = ["add", "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 = ["add", "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 = ["add", "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 -p {packageName}", () => {
const args = ["dlx", "-p", "axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
it("should parse packages with --package {packageName}", () => {
const args = ["dlx", "--package", "axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
});

View file

@ -0,0 +1,17 @@
import { execSync } from "child_process";
import { ui } from "../../environment/userInteraction.js";
export function runYarnCommand(args) {
try {
const npxCommand = `yarn ${args.join(" ")}`;
execSync(npxCommand, { stdio: "inherit" });
} catch (error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
}
return { status: 0 };
}

View file

@ -0,0 +1,56 @@
import {
MALWARE_STATUS_MALWARE,
openMalwareDatabase,
} from "../malwareDatabase.js";
export async function auditChanges(changes) {
const allowedChanges = [];
const disallowedChanges = [];
var malwarePackages = await getPackagesWithMalware(
changes.filter(
(change) => change.type === "add" || change.type === "change"
)
);
for (const change of changes) {
const malwarePackage = malwarePackages.find(
(pkg) => pkg.name === change.name && pkg.version === change.version
);
if (malwarePackage) {
disallowedChanges.push({ ...change, reason: malwarePackage.status });
} else {
allowedChanges.push(change);
}
}
const auditResults = {
allowedChanges,
disallowedChanges,
isAllowed: disallowedChanges.length === 0,
};
return auditResults;
}
async function getPackagesWithMalware(changes) {
if (changes.length === 0) {
return [];
}
const malwareDb = await openMalwareDatabase();
let allVulnerablePackages = [];
for (const change of changes) {
if (malwareDb.isMalware(change.name, change.version)) {
allVulnerablePackages.push({
name: change.name,
version: change.version,
status: MALWARE_STATUS_MALWARE,
});
}
}
return allVulnerablePackages;
}

View file

@ -0,0 +1,92 @@
import { auditChanges } from "./audit/index.js";
import { getScanTimeout } from "../config/configFile.js";
import { setTimeout } from "timers/promises";
import chalk from "chalk";
import { getPackageManager } from "../packagemanager/currentPackageManager.js";
import { ui } from "../environment/userInteraction.js";
export function shouldScanCommand(args) {
if (!args || args.length === 0) {
return false;
}
return getPackageManager().isSupportedCommand(args);
}
export async function scanCommand(args) {
if (!shouldScanCommand(args)) {
return [];
}
let timedOut = false;
const spinner = ui.startProcess("Scanning for malicious packages...");
let audit;
await Promise.race([
(async () => {
try {
const packageManager = getPackageManager();
const changes = await packageManager.getDependencyUpdatesForCommand(
args
);
if (timedOut) {
return;
}
if (changes.length > 0) {
spinner.setText(`Scanning ${changes.length} package(s)...`);
}
audit = await auditChanges(changes);
} catch (error) {
spinner.fail(`Error while scanning: ${error.message}`);
throw error;
}
})(),
setTimeout(getScanTimeout()).then(() => {
timedOut = true;
}),
]);
if (timedOut) {
spinner.fail("Timeout exceeded while scanning.");
throw new Error("Timeout exceeded while scanning npm install command.");
}
if (!audit || audit.isAllowed) {
spinner.succeed("No malicious packages detected.");
} else {
printMaliciousChanges(audit.disallowedChanges, spinner);
await acceptRiskOrExit(
"Do you want to continue with the installation despite the risks?",
false
);
}
}
function printMaliciousChanges(changes, spinner) {
spinner.fail(chalk.bold("Malicious changes detected:"));
for (const change of changes) {
ui.writeInformation(` - ${change.name}@${change.version}`);
}
}
async function acceptRiskOrExit(message, defaultValue) {
ui.emptyLine();
const continueInstall = await ui.confirm({
message: message,
default: defaultValue,
});
if (continueInstall) {
ui.writeInformation("Continuing with the installation...");
return;
}
ui.writeInformation("Exiting without installing packages.");
ui.emptyLine();
process.exit(1);
}

View file

@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
import { setTimeout } from "node:timers/promises";
describe("scanCommand", async () => {
const getScanTimeoutMock = mock.fn(() => 1000);
const mockGetDependencyUpdatesForCommand = mock.fn();
const mockStartProcess = mock.fn(() => ({
setText: () => {},
succeed: () => {},
fail: () => {},
}));
const mockConfirm = mock.fn(() => true);
// import { getPackageManager } from "../packagemanager/currentPackageManager.js";
mock.module("../packagemanager/currentPackageManager.js", {
namedExports: {
getPackageManager: () => {
return {
isSupportedCommand: () => true,
getDependencyUpdatesForCommand: mockGetDependencyUpdatesForCommand,
};
},
},
});
// import { getScanTimeout } from "../config/configFile.js";
mock.module("../config/configFile.js", {
namedExports: {
getScanTimeout: getScanTimeoutMock,
getBaseUrl: () => undefined,
},
});
// import { ui } from "../environment/userInteraction.js";
mock.module("../environment/userInteraction.js", {
namedExports: {
ui: {
startProcess: mockStartProcess,
writeError: () => {},
writeInformation: () => {},
writeWarning: () => {},
emptyLine: () => {},
confirm: mockConfirm,
},
},
});
// import { auditChanges, MAX_LENGTH_EXCEEDED } from "./audit/index.js";
mock.module("./audit/index.js", {
namedExports: {
auditChanges: (changes) => {
const malisciousChangeName = "malicious";
const allowedChanges = changes.filter(
(change) => change.name !== malisciousChangeName
);
const disallowedChanges = changes
.filter((change) => change.name === malisciousChangeName)
.map((change) => ({
...change,
reason: "malicious",
}));
const auditResults = {
allowedChanges,
disallowedChanges,
isAllowed: disallowedChanges.length === 0,
};
return auditResults;
},
MAX_LENGTH_EXCEEDED: "MAX_LENGTH_EXCEEDED",
},
});
const { scanCommand } = await import("./index.js");
it("should succeed when there are no changes", async () => {
let successMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {
successMessageWasSet = true;
},
fail: () => {},
}));
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []);
await scanCommand(["install", "lodash"]);
assert.equal(successMessageWasSet, true);
});
it("should succeed when changes are not malicious", async () => {
let successMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {
successMessageWasSet = true;
},
fail: () => {},
}));
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
{ name: "lodash", version: "4.17.21" },
]);
await scanCommand(["install", "lodash"]);
assert.equal(successMessageWasSet, true);
});
it("should throw an error when timing out", async () => {
let failureMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {},
fail: () => {
failureMessageWasSet = true;
},
}));
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
await setTimeout(150);
return [{ name: "lodash", version: "4.17.21" }];
});
await assert.rejects(scanCommand(["install", "lodash"]));
assert.equal(failureMessageWasSet, true);
});
it("should fail and prompt the user when malicious changes are detected", async () => {
let failureMessageWasSet = false;
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {},
fail: () => {
failureMessageWasSet = true;
},
}));
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
{ name: "malicious", version: "1.0.0" },
]);
let userWasPrompted = false;
mockConfirm.mock.mockImplementationOnce(() => {
userWasPrompted = true;
return true; // Simulate user accepting the risk, otherwise the process would exit
});
await scanCommand(["install", "malicious"]);
assert.equal(failureMessageWasSet, true);
assert.equal(userWasPrompted, true);
});
it("should not report a timeout when the user takes a long time to respond (it should not affect the timeout)", async () => {
let failureMessages = [];
mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {},
succeed: () => {},
fail: (message) => {
failureMessages.push(message);
},
}));
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
return [{ name: "malicious", version: "4.17.21" }];
});
mockConfirm.mock.mockImplementationOnce(async () => {
await setTimeout(200);
return true; // Simulate user accepting the risk, otherwise the process would exit
});
await scanCommand(["install", "malicious"]);
assert.equal(failureMessages.length, 1);
const failureMessage = failureMessages[0];
assert.equal(failureMessage.toLowerCase().includes("timeout"), false);
assert.equal(failureMessage.toLowerCase().includes("malicious"), true);
});
});

View file

@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
describe("shouldScanCommand", async () => {
const isSupportedCommandMock = mock.fn(() => undefined);
mock.module("../packagemanager/currentPackageManager.js", {
namedExports: {
getPackageManager: () => {
return {
isSupportedCommand: isSupportedCommandMock,
getDependencyUpdatesForCommand: () => [],
};
},
},
});
const { shouldScanCommand } = await import("./index.js");
it("should return false if the argument is an empty array", () => {
const result = shouldScanCommand([]);
assert.strictEqual(result, false);
});
it("should return false if the argument is undefined", () => {
const result = shouldScanCommand(undefined);
assert.strictEqual(result, false);
});
it("should return true if the package manager supports the command", () => {
isSupportedCommandMock.mock.mockImplementation(() => true);
const result = shouldScanCommand(["install", "lodash"]);
assert.strictEqual(result, true);
});
it("should return false if the package manager does not support the command", () => {
isSupportedCommandMock.mock.mockImplementation(() => false);
const result = shouldScanCommand(["unknown", "command"]);
assert.strictEqual(result, false);
});
});

View file

@ -0,0 +1,72 @@
import {
fetchMalwareDatabase,
fetchMalwareDatabaseVersion,
} from "../api/aikido.js";
import {
readDatabaseFromLocalCache,
writeDatabaseToLocalCache,
} from "../config/configFile.js";
import { ui } from "../environment/userInteraction.js";
export async function openMalwareDatabase() {
const malwareDatabase = await getMalwareDatabase();
function getPackageStatus(name, version) {
const packageData = malwareDatabase.find(
(pkg) =>
pkg.package_name === name &&
(pkg.version === version || pkg.version === "*")
);
if (!packageData) {
return MALWARE_STATUS_OK;
}
return packageData.reason;
}
return {
getPackageStatus,
isMalware: (name, version) => {
const status = getPackageStatus(name, version);
return isMalwareStatus(status);
},
};
}
async function getMalwareDatabase() {
const { malwareDatabase: cachedDatabase, version: cachedVersion } =
readDatabaseFromLocalCache();
try {
if (cachedDatabase) {
const currentVersion = await fetchMalwareDatabaseVersion();
if (cachedVersion === currentVersion) {
return cachedDatabase;
}
}
const { malwareDatabase, version } = await fetchMalwareDatabase();
writeDatabaseToLocalCache(malwareDatabase, version);
return malwareDatabase;
} catch (error) {
if (cachedDatabase) {
ui.writeWarning(
"Failed to fetch the latest malware database. Using cached version."
);
return cachedDatabase;
}
throw error;
}
}
function isMalwareStatus(status) {
let malwareStatus = status.toUpperCase();
return malwareStatus === MALWARE_STATUS_MALWARE;
}
export const MALWARE_STATUS_OK = "OK";
export const MALWARE_STATUS_MALWARE = "MALWARE";
export const MALWARE_STATUS_TELEMETRY = "TELEMETRY";
export const MALWARE_STATUS_PROTESTWARE = "PROTESTWARE";

View file

@ -0,0 +1,44 @@
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" },
{ tool: "pnpm", aikidoCommand: "aikido-pnpm" },
{ tool: "pnpx", aikidoCommand: "aikido-pnpx" },
// When adding a new tool here, also update the expected alias in the tests (setup.spec.js, teardown.spec.js)
// and add the documentation for the new tool in the README.md
];
export function doesExecutableExistOnSystem(executableName) {
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;
}
}
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");
}
export function addLineToFile(filePath, line) {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
const fileContent = fs.readFileSync(filePath, "utf-8");
const updatedContent = fileContent + os.EOL + line;
fs.writeFileSync(filePath, updatedContent, "utf-8");
}

View file

@ -0,0 +1,75 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import { knownAikidoTools } from "./helpers.js";
/**
* Loops over the detected shells and calls the setup function for each.
*/
export async function setup() {
ui.writeInformation(
chalk.bold("Setting up shell aliases.") +
" This will wrap safe-chain around npm, npx, and yarn commands."
);
ui.emptyLine();
try {
const shells = detectShells();
if (shells.length === 0) {
ui.writeError("No supported shells detected. Cannot set up aliases.");
return;
}
ui.writeInformation(
`Detected ${shells.length} supported shell(s): ${shells
.map((shell) => chalk.bold(shell.name))
.join(", ")}.`
);
let updatedCount = 0;
for (const shell of shells) {
if (setupShell(shell)) {
updatedCount++;
}
}
if (updatedCount > 0) {
ui.emptyLine();
ui.writeInformation(`Please restart your terminal to apply the changes.`);
}
} catch (error) {
ui.writeError(
`Failed to set up shell aliases: ${error.message}. Please check your shell configuration.`
);
return;
}
}
/**
* Calls the setup function for the given shell and reports the result.
*/
function setupShell(shell) {
let success = false;
try {
shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases
success = shell.setup(knownAikidoTools);
} catch {
success = false;
}
if (success) {
ui.writeInformation(
`${chalk.bold("- " + shell.name + ":")} ${chalk.green(
"Setup successful"
)}`
);
} else {
ui.writeError(
`${chalk.bold("- " + shell.name + ":")} ${chalk.red(
"Setup failed"
)}. Please check your ${shell.name} configuration.`
);
}
return success;
}

View file

@ -0,0 +1,26 @@
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 = [];
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;
}

View file

@ -0,0 +1,58 @@
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}=`));
}
return true;
}
function setup(tools) {
const startupFile = getStartupFile();
for (const { tool, aikidoCommand } of tools) {
addLineToFile(
startupFile,
`alias ${tool}="${aikidoCommand}" # Safe-chain alias for ${tool}`
);
}
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,199 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
import { knownAikidoTools } from "../helpers.js";
describe("Bash shell integration", () => {
let mockStartupFile;
let bash;
beforeEach(async () => {
// Create temporary startup file for testing
mockStartupFile = path.join(tmpdir(), `test-bashrc-${Date.now()}`);
// Mock the helpers module
mock.module("../helpers.js", {
namedExports: {
doesExecutableExistOnSystem: () => true,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
const filteredLines = lines.filter((line) => !pattern.test(line));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// Import bash module after mocking
bash = (await import("./bash.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when bash is installed", () => {
assert.strictEqual(bash.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(bash.isInstalled(), true);
});
});
describe("setup", () => {
it("should add aliases for all provided tools", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
const result = bash.setup(tools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('alias npm="aikido-npm" # Safe-chain alias for npm')
);
assert.ok(
content.includes('alias npx="aikido-npx" # Safe-chain alias for npx')
);
assert.ok(
content.includes('alias yarn="aikido-yarn" # Safe-chain alias for yarn')
);
});
it("should handle empty tools array", () => {
const result = bash.setup([]);
assert.strictEqual(result, true);
// File should be created during teardown call even if no tools are provided
if (fs.existsSync(mockStartupFile)) {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"#!/bin/bash",
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'",
"alias ls='ls --color=auto'",
"alias grep='grep --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = bash.teardown(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('alias npm="aikido-npm"'));
assert.ok(content.includes('alias yarn="aikido-yarn"'));
// Teardown
bash.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(!content.includes("alias yarn="));
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
bash.setup(tools);
bash.teardown(tools);
bash.setup(tools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
const npmMatches = (content.match(/alias npm="/g) || []).length;
assert.strictEqual(npmMatches, 1, "Should not duplicate aliases");
});
});
});

View file

@ -0,0 +1,61 @@
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+`)
);
}
return true;
}
function setup(tools) {
const startupFile = getStartupFile();
for (const { tool, aikidoCommand } of tools) {
addLineToFile(
startupFile,
`alias ${tool} "${aikidoCommand}" # Safe-chain alias for ${tool}`
);
}
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,199 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
import { knownAikidoTools } from "../helpers.js";
describe("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 aliases for all provided tools", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
const result = fish.setup(tools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('alias npm "aikido-npm" # Safe-chain alias for npm')
);
assert.ok(
content.includes('alias npx "aikido-npx" # Safe-chain alias for npx')
);
assert.ok(
content.includes('alias yarn "aikido-yarn" # Safe-chain alias for yarn')
);
});
it("should handle empty tools array", () => {
const result = fish.setup([]);
assert.strictEqual(result, true);
// File should be created during teardown call even if no tools are provided
if (fs.existsSync(mockStartupFile)) {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"#!/usr/bin/env fish",
"alias npm 'aikido-npm'",
"alias npx 'aikido-npx'",
"alias yarn 'aikido-yarn'",
"alias ls 'ls --color=auto'",
"alias grep 'grep --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = fish.teardown(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 = fish.teardown(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases", () => {
const initialContent = [
"#!/usr/bin/env fish",
"alias ls 'ls --color=auto'",
"set PATH $PATH ~/bin",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = fish.teardown(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(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes('alias npm "aikido-npm"'));
assert.ok(content.includes('alias yarn "aikido-yarn"'));
// Teardown
fish.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm "));
assert.ok(!content.includes("alias yarn "));
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
fish.setup(tools);
fish.teardown(tools);
fish.setup(tools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
const npmMatches = (content.match(/alias npm "/g) || []).length;
assert.strictEqual(npmMatches, 1, "Should not duplicate aliases");
});
});
});

View file

@ -0,0 +1,61 @@
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+`)
);
}
return true;
}
function setup(tools) {
const startupFile = getStartupFile();
for (const { tool, aikidoCommand } of tools) {
addLineToFile(
startupFile,
`Set-Alias ${tool} ${aikidoCommand} # Safe-chain alias for ${tool}`
);
}
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,204 @@
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 aliases for all provided tools", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
const result = powershell.setup(tools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes("Set-Alias npm aikido-npm # Safe-chain alias for npm")
);
assert.ok(
content.includes("Set-Alias npx aikido-npx # Safe-chain alias for npx")
);
assert.ok(
content.includes(
"Set-Alias yarn aikido-yarn # Safe-chain alias for yarn"
)
);
});
it("should handle empty tools array", () => {
const result = powershell.setup([]);
assert.strictEqual(result, true);
// File should be created during teardown call even if no tools are provided
if (fs.existsSync(mockStartupFile)) {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"# PowerShell profile",
"Set-Alias npm aikido-npm",
"Set-Alias npx aikido-npx",
"Set-Alias yarn aikido-yarn",
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = powershell.teardown(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 aliases", () => {
const initialContent = [
"# PowerShell profile",
"Set-Alias ls Get-ChildItem",
"$env:PATH += ';C:\\Tools'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = powershell.teardown(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", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
// Setup
powershell.setup(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("Set-Alias npm aikido-npm"));
assert.ok(content.includes("Set-Alias yarn aikido-yarn"));
// Teardown
powershell.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("Set-Alias npm "));
assert.ok(!content.includes("Set-Alias yarn "));
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
powershell.setup(tools);
powershell.teardown(tools);
powershell.setup(tools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
const npmMatches = (content.match(/Set-Alias npm /g) || []).length;
assert.strictEqual(npmMatches, 1, "Should not duplicate aliases");
});
});
});

View file

@ -0,0 +1,61 @@
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+`)
);
}
return true;
}
function setup(tools) {
const startupFile = getStartupFile();
for (const { tool, aikidoCommand } of tools) {
addLineToFile(
startupFile,
`Set-Alias ${tool} ${aikidoCommand} # Safe-chain alias for ${tool}`
);
}
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,204 @@
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 aliases for all provided tools", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
const result = windowsPowershell.setup(tools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes("Set-Alias npm aikido-npm # Safe-chain alias for npm")
);
assert.ok(
content.includes("Set-Alias npx aikido-npx # Safe-chain alias for npx")
);
assert.ok(
content.includes(
"Set-Alias yarn aikido-yarn # Safe-chain alias for yarn"
)
);
});
it("should handle empty tools array", () => {
const result = windowsPowershell.setup([]);
assert.strictEqual(result, true);
// File should be created during teardown call even if no tools are provided
if (fs.existsSync(mockStartupFile)) {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"# Windows PowerShell profile",
"Set-Alias npm aikido-npm",
"Set-Alias npx aikido-npx",
"Set-Alias yarn aikido-yarn",
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = windowsPowershell.teardown(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 aliases", () => {
const initialContent = [
"# Windows PowerShell profile",
"Set-Alias ls Get-ChildItem",
"$env:PATH += ';C:\\Tools'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = windowsPowershell.teardown(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", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
// Setup
windowsPowershell.setup(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("Set-Alias npm aikido-npm"));
assert.ok(content.includes("Set-Alias yarn aikido-yarn"));
// Teardown
windowsPowershell.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("Set-Alias npm "));
assert.ok(!content.includes("Set-Alias yarn "));
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
windowsPowershell.setup(tools);
windowsPowershell.teardown(tools);
windowsPowershell.setup(tools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
const npmMatches = (content.match(/Set-Alias npm /g) || []).length;
assert.strictEqual(npmMatches, 1, "Should not duplicate aliases");
});
});
});

View file

@ -0,0 +1,58 @@
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}=`));
}
return true;
}
function setup(tools) {
const startupFile = getStartupFile();
for (const { tool, aikidoCommand } of tools) {
addLineToFile(
startupFile,
`alias ${tool}="${aikidoCommand}" # Safe-chain alias for ${tool}`
);
}
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,199 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
import { knownAikidoTools } from "../helpers.js";
describe("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 aliases for all provided tools", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
const result = zsh.setup(tools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('alias npm="aikido-npm" # Safe-chain alias for npm')
);
assert.ok(
content.includes('alias npx="aikido-npx" # Safe-chain alias for npx')
);
assert.ok(
content.includes('alias yarn="aikido-yarn" # Safe-chain alias for yarn')
);
});
it("should handle empty tools array", () => {
const result = zsh.setup([]);
assert.strictEqual(result, true);
// File should be created during teardown call even if no tools are provided
if (fs.existsSync(mockStartupFile)) {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.strictEqual(content.trim(), "");
}
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"#!/bin/zsh",
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'",
"alias ls='ls --color=auto'",
"alias grep='grep --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = zsh.teardown(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 = zsh.teardown(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases", () => {
const initialContent = [
"#!/bin/zsh",
"alias ls='ls --color=auto'",
"export PATH=$PATH:~/bin",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = zsh.teardown(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(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes('alias npm="aikido-npm"'));
assert.ok(content.includes('alias yarn="aikido-yarn"'));
// Teardown
zsh.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(!content.includes("alias yarn="));
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
zsh.setup(tools);
zsh.teardown(tools);
zsh.setup(tools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
const npmMatches = (content.match(/alias npm="/g) || []).length;
assert.strictEqual(npmMatches, 1, "Should not duplicate aliases");
});
});
});

View file

@ -0,0 +1,61 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import { knownAikidoTools } from "./helpers.js";
export async function teardown() {
ui.writeInformation(
chalk.bold("Removing shell aliases.") +
" This will remove safe-chain aliases for npm, npx, and yarn commands."
);
ui.emptyLine();
try {
const shells = detectShells();
if (shells.length === 0) {
ui.writeError("No supported shells detected. Cannot remove aliases.");
return;
}
ui.writeInformation(
`Detected ${shells.length} supported shell(s): ${shells
.map((shell) => chalk.bold(shell.name))
.join(", ")}.`
);
let updatedCount = 0;
for (const shell of shells) {
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.`
);
}
}
if (updatedCount > 0) {
ui.emptyLine();
ui.writeInformation(`Please restart your terminal to apply the changes.`);
}
} catch (error) {
ui.writeError(
`Failed to remove shell aliases: ${error.message}. Please check your shell configuration.`
);
return;
}
}