Undo move of files to safe-chain

This commit is contained in:
Sander Declerck 2025-08-05 13:35:40 +02:00
parent b29bc2e6dc
commit 8fe228c476
No known key found for this signature in database
68 changed files with 7 additions and 10 deletions

31
src/api/aikido.js Normal file
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;
}

46
src/api/npmApi.js Normal file
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;
}
}

91
src/config/configFile.js Normal file
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
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;
}

92
src/scanning/index.js Normal file
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,100 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import { knownAikidoTools } from "./helpers.js";
import fs from "fs";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
/**
* Loops over the detected shells and calls the setup function for each.
*/
export async function setup() {
ui.writeInformation(
chalk.bold("Setting up shell aliases.") +
" This will wrap safe-chain around npm, npx, and yarn commands."
);
ui.emptyLine();
copyStartupFiles();
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;
}
function copyStartupFiles() {
const startupFiles = ["init-posix.sh", "init-pwsh.ps1", "init-fish.fish"];
for (const file of startupFiles) {
const targetDir = path.join(os.homedir(), ".safe-chain", "scripts");
const targetPath = path.join(os.homedir(), ".safe-chain", "scripts", file);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// Use absolute path for source
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sourcePath = path.resolve(__dirname, "startup-scripts", file);
fs.copyFileSync(sourcePath, targetPath);
}
}

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

View file

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

View file

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

View file

@ -0,0 +1,62 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "Bash";
const executableName = "bash";
const startupFileCommand = "echo ~/.bashrc";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
for (const { tool } of tools) {
// Remove any existing alias for the tool
removeLinesMatchingPattern(startupFile, new RegExp(`^alias\\s+${tool}=`));
}
// Removes the line that sources the safe-chain bash initialization script (~/.aikido/scripts/init-posix.sh)
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`
);
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,199 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
import { knownAikidoTools } from "../helpers.js";
describe("Bash shell integration", () => {
let mockStartupFile;
let bash;
beforeEach(async () => {
// Create temporary startup file for testing
mockStartupFile = path.join(tmpdir(), `test-bashrc-${Date.now()}`);
// Mock the helpers module
mock.module("../helpers.js", {
namedExports: {
doesExecutableExistOnSystem: () => true,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
const filteredLines = lines.filter((line) => !pattern.test(line));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// Import bash module after mocking
bash = (await import("./bash.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when bash is installed", () => {
assert.strictEqual(bash.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(bash.isInstalled(), true);
});
});
describe("setup", () => {
it("should add source line for bash initialization script", () => {
const result = bash.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
)
);
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"#!/bin/bash",
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'",
"alias ls='ls --color=auto'",
"alias grep='grep --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = bash.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(!content.includes("alias npx="));
assert.ok(!content.includes("alias yarn="));
assert.ok(content.includes("alias ls="));
assert.ok(content.includes("alias grep="));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = bash.teardown(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases", () => {
const initialContent = [
"#!/bin/bash",
"alias ls='ls --color=auto'",
"export PATH=$PATH:~/bin",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = bash.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("alias ls="));
assert.ok(content.includes("export PATH="));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(bash.name, "Bash");
});
it("should expose all required methods", () => {
assert.ok(typeof bash.isInstalled === "function");
assert.ok(typeof bash.setup === "function");
assert.ok(typeof bash.teardown === "function");
assert.ok(typeof bash.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
// Setup
bash.setup(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
// Teardown
bash.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
bash.setup(tools);
bash.teardown(tools);
bash.setup(tools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (content.match(/source.*init-posix\.sh/g) || [])
.length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
});
it("should handle mixed content with aliases and source lines", () => {
const initialContent = [
"#!/bin/bash",
"alias npm='old-npm'",
"source ~/.safe-chain/scripts/init-posix.sh",
"alias ls='ls --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
// Teardown should remove both aliases and source line
bash.teardown(knownAikidoTools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
assert.ok(content.includes("alias ls="));
});
});
});

View file

@ -0,0 +1,65 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "Fish";
const executableName = "fish";
const startupFileCommand = "echo ~/.config/fish/config.fish";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
for (const { tool } of tools) {
// Remove any existing alias for the tool
removeLinesMatchingPattern(
startupFile,
new RegExp(`^alias\\s+${tool}\\s+`)
);
}
// Removes the line that sources the safe-chain fish initialization script (~/.safe-chain/scripts/init-fish.fish)
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`
);
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,183 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
import { knownAikidoTools } from "../helpers.js";
describe("Fish shell integration", () => {
let mockStartupFile;
let fish;
beforeEach(async () => {
// Create temporary startup file for testing
mockStartupFile = path.join(tmpdir(), `test-fish-config-${Date.now()}`);
// Mock the helpers module
mock.module("../helpers.js", {
namedExports: {
doesExecutableExistOnSystem: () => true,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
const filteredLines = lines.filter((line) => !pattern.test(line));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// Import fish module after mocking
fish = (await import("./fish.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when fish is installed", () => {
assert.strictEqual(fish.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(fish.isInstalled(), true);
});
});
describe("setup", () => {
it("should add source line for safe-chain fish initialization script", () => {
const result = fish.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script')
);
});
it("should not duplicate source lines on multiple calls", () => {
fish.setup();
fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
assert.strictEqual(sourceMatches, 2, "Should allow multiple source lines (helper doesn't dedupe)");
});
});
describe("teardown", () => {
it("should remove npm, npx, yarn aliases and source line", () => {
const initialContent = [
"#!/usr/bin/env fish",
"alias npm 'aikido-npm'",
"alias npx 'aikido-npx'",
"alias yarn 'aikido-yarn'",
"source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script",
"alias ls 'ls --color=auto'",
"alias grep 'grep --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = fish.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm "));
assert.ok(!content.includes("alias npx "));
assert.ok(!content.includes("alias yarn "));
assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish"));
assert.ok(content.includes("alias ls "));
assert.ok(content.includes("alias grep "));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = fish.teardown(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases or source lines", () => {
const initialContent = [
"#!/usr/bin/env fish",
"alias ls 'ls --color=auto'",
"set PATH $PATH ~/bin",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = fish.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("alias ls "));
assert.ok(content.includes("set PATH "));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(fish.name, "Fish");
});
it("should expose all required methods", () => {
assert.ok(typeof fish.isInstalled === "function");
assert.ok(typeof fish.setup === "function");
assert.ok(typeof fish.teardown === "function");
assert.ok(typeof fish.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
// Setup
fish.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes('source ~/.safe-chain/scripts/init-fish.fish'));
// Teardown
fish.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish"));
});
it("should handle multiple setup calls", () => {
fish.setup();
fish.teardown(knownAikidoTools);
fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle");
});
});
});

View file

@ -0,0 +1,65 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "PowerShell Core";
const executableName = "pwsh";
const startupFileCommand = "echo $PROFILE";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
for (const { tool } of tools) {
// Remove any existing alias for the tool
removeLinesMatchingPattern(
startupFile,
new RegExp(`^Set-Alias\\s+${tool}\\s+`)
);
}
// Remove the line that sources the safe-chain PowerShell initialization script
removeLinesMatchingPattern(
startupFile,
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`
);
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,200 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
import { knownAikidoTools } from "../helpers.js";
describe("PowerShell Core shell integration", () => {
let mockStartupFile;
let powershell;
beforeEach(async () => {
// Create temporary startup file for testing
mockStartupFile = path.join(
tmpdir(),
`test-powershell-profile-${Date.now()}.ps1`
);
// Mock the helpers module
mock.module("../helpers.js", {
namedExports: {
doesExecutableExistOnSystem: () => true,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
const filteredLines = lines.filter((line) => !pattern.test(line));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// Import powershell module after mocking
powershell = (await import("./powershell.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when powershell is installed", () => {
assert.strictEqual(powershell.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(powershell.isInstalled(), true);
});
});
describe("setup", () => {
it("should add init-pwsh.ps1 source line", () => {
const result = powershell.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script'
)
);
});
});
describe("teardown", () => {
it("should remove init-pwsh.ps1 source line", () => {
const initialContent = [
"# PowerShell profile",
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = powershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
});
it("should remove old-style aliases from earlier versions", () => {
const initialContent = [
"# PowerShell profile",
"Set-Alias npm aikido-npm # Safe-chain alias for npm",
"Set-Alias npx aikido-npx # Safe-chain alias for npx",
"Set-Alias yarn aikido-yarn # Safe-chain alias for yarn",
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = powershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("Set-Alias npm "));
assert.ok(!content.includes("Set-Alias npx "));
assert.ok(!content.includes("Set-Alias yarn "));
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = powershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant content", () => {
const initialContent = [
"# PowerShell profile",
"Set-Alias ls Get-ChildItem",
"$env:PATH += ';C:\\Tools'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = powershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("$env:PATH "));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(powershell.name, "PowerShell Core");
});
it("should expose all required methods", () => {
assert.ok(typeof powershell.isInstalled === "function");
assert.ok(typeof powershell.setup === "function");
assert.ok(typeof powershell.teardown === "function");
assert.ok(typeof powershell.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
// Setup
powershell.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
// Teardown
powershell.teardown(knownAikidoTools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
});
it("should handle multiple setup calls", () => {
powershell.setup();
powershell.teardown(knownAikidoTools);
powershell.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
[]
).length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
});
});
});

View file

@ -0,0 +1,65 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "Windows PowerShell";
const executableName = "powershell";
const startupFileCommand = "echo $PROFILE";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
for (const { tool } of tools) {
// Remove any existing alias for the tool
removeLinesMatchingPattern(
startupFile,
new RegExp(`^Set-Alias\\s+${tool}\\s+`)
);
}
// Remove the line that sources the safe-chain PowerShell initialization script
removeLinesMatchingPattern(
startupFile,
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`
);
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,200 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
import { knownAikidoTools } from "../helpers.js";
describe("Windows PowerShell shell integration", () => {
let mockStartupFile;
let windowsPowershell;
beforeEach(async () => {
// Create temporary startup file for testing
mockStartupFile = path.join(
tmpdir(),
`test-windows-powershell-profile-${Date.now()}.ps1`
);
// Mock the helpers module
mock.module("../helpers.js", {
namedExports: {
doesExecutableExistOnSystem: () => true,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
const filteredLines = lines.filter((line) => !pattern.test(line));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// Import windowsPowershell module after mocking
windowsPowershell = (await import("./windowsPowershell.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when windows powershell is installed", () => {
assert.strictEqual(windowsPowershell.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(windowsPowershell.isInstalled(), true);
});
});
describe("setup", () => {
it("should add init-pwsh.ps1 source line", () => {
const result = windowsPowershell.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script'
)
);
});
});
describe("teardown", () => {
it("should remove init-pwsh.ps1 source line", () => {
const initialContent = [
"# Windows PowerShell profile",
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = windowsPowershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
});
it("should remove old-style aliases from earlier versions", () => {
const initialContent = [
"# Windows PowerShell profile",
"Set-Alias npm aikido-npm # Safe-chain alias for npm",
"Set-Alias npx aikido-npx # Safe-chain alias for npx",
"Set-Alias yarn aikido-yarn # Safe-chain alias for yarn",
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = windowsPowershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("Set-Alias npm "));
assert.ok(!content.includes("Set-Alias npx "));
assert.ok(!content.includes("Set-Alias yarn "));
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = windowsPowershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant content", () => {
const initialContent = [
"# Windows PowerShell profile",
"Set-Alias ls Get-ChildItem",
"$env:PATH += ';C:\\Tools'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = windowsPowershell.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("$env:PATH "));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(windowsPowershell.name, "Windows PowerShell");
});
it("should expose all required methods", () => {
assert.ok(typeof windowsPowershell.isInstalled === "function");
assert.ok(typeof windowsPowershell.setup === "function");
assert.ok(typeof windowsPowershell.teardown === "function");
assert.ok(typeof windowsPowershell.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
// Setup
windowsPowershell.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
// Teardown
windowsPowershell.teardown(knownAikidoTools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
);
});
it("should handle multiple setup calls", () => {
windowsPowershell.setup();
windowsPowershell.teardown(knownAikidoTools);
windowsPowershell.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
[]
).length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
});
});
});

View file

@ -0,0 +1,62 @@
import {
addLineToFile,
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { execSync } from "child_process";
const shellName = "Zsh";
const executableName = "zsh";
const startupFileCommand = "echo ${ZDOTDIR:-$HOME}/.zshrc";
function isInstalled() {
return doesExecutableExistOnSystem(executableName);
}
function teardown(tools) {
const startupFile = getStartupFile();
for (const { tool } of tools) {
// Remove any existing alias for the tool
removeLinesMatchingPattern(startupFile, new RegExp(`^alias\\s+${tool}=`));
}
// Removes the line that sources the safe-chain zsh initialization script (~/.aikido/scripts/init-posix.sh)
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/
);
return true;
}
function setup() {
const startupFile = getStartupFile();
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`
);
return true;
}
function getStartupFile() {
try {
return execSync(startupFileCommand, {
encoding: "utf8",
shell: executableName,
}).trim();
} catch (error) {
throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}`
);
}
}
export default {
name: shellName,
isInstalled,
setup,
teardown,
};

View file

@ -0,0 +1,226 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import fs from "node:fs";
import path from "path";
import { knownAikidoTools } from "../helpers.js";
describe("Zsh shell integration", () => {
let mockStartupFile;
let zsh;
beforeEach(async () => {
// Create temporary startup file for testing
mockStartupFile = path.join(tmpdir(), `test-zshrc-${Date.now()}`);
// Mock the helpers module
mock.module("../helpers.js", {
namedExports: {
doesExecutableExistOnSystem: () => true,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
}
fs.appendFileSync(filePath, line + "\n", "utf-8");
},
removeLinesMatchingPattern: (filePath, pattern) => {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
const filteredLines = lines.filter((line) => !pattern.test(line));
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
},
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
execSync: () => mockStartupFile,
},
});
// Import zsh module after mocking
zsh = (await import("./zsh.js")).default;
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
// Reset mocks
mock.reset();
});
describe("isInstalled", () => {
it("should return true when zsh is installed", () => {
assert.strictEqual(zsh.isInstalled(), true);
});
it("should call doesExecutableExistOnSystem with correct parameter", () => {
// Test that the method calls the helper with the right executable name
assert.strictEqual(zsh.isInstalled(), true);
});
});
describe("setup", () => {
it("should add source line for zsh initialization script", () => {
const result = zsh.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script"
)
);
});
it("should handle empty startup file", () => {
const result = zsh.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
});
});
describe("teardown", () => {
it("should remove npm, npx, and yarn aliases", () => {
const initialContent = [
"#!/bin/zsh",
"alias npm='aikido-npm'",
"alias npx='aikido-npx'",
"alias yarn='aikido-yarn'",
"alias ls='ls --color=auto'",
"alias grep='grep --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = zsh.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(!content.includes("alias npx="));
assert.ok(!content.includes("alias yarn="));
assert.ok(content.includes("alias ls="));
assert.ok(content.includes("alias grep="));
});
it("should remove zsh initialization script source line", () => {
const initialContent = [
"#!/bin/zsh",
"source ~/.safe-chain/scripts/init-posix.sh",
"alias ls='ls --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = zsh.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
assert.ok(content.includes("alias ls="));
});
it("should handle file that doesn't exist", () => {
if (fs.existsSync(mockStartupFile)) {
fs.unlinkSync(mockStartupFile);
}
const result = zsh.teardown(knownAikidoTools);
assert.strictEqual(result, true);
});
it("should handle file with no relevant aliases or source lines", () => {
const initialContent = [
"#!/bin/zsh",
"alias ls='ls --color=auto'",
"export PATH=$PATH:~/bin",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
const result = zsh.teardown(knownAikidoTools);
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("alias ls="));
assert.ok(content.includes("export PATH="));
});
});
describe("shell properties", () => {
it("should have correct name", () => {
assert.strictEqual(zsh.name, "Zsh");
});
it("should expose all required methods", () => {
assert.ok(typeof zsh.isInstalled === "function");
assert.ok(typeof zsh.setup === "function");
assert.ok(typeof zsh.teardown === "function");
assert.ok(typeof zsh.name === "string");
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
];
// Setup
zsh.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
// Teardown
zsh.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
});
it("should handle multiple setup calls", () => {
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
zsh.setup(tools);
zsh.teardown(tools);
zsh.setup(tools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (content.match(/source.*init-posix\.sh/g) || [])
.length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
});
it("should handle mixed content with aliases and source lines", () => {
const initialContent = [
"#!/bin/zsh",
"alias npm='old-npm'",
"source ~/.safe-chain/scripts/init-posix.sh",
"alias ls='ls --color=auto'",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
// Teardown should remove both aliases and source line
zsh.teardown(knownAikidoTools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
);
assert.ok(content.includes("alias ls="));
});
});
});

View file

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