mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Initial commit
This commit is contained in:
parent
dd51a48435
commit
5eaf6ac3b3
51 changed files with 10087 additions and 1 deletions
31
src/api/aikido.js
Normal file
31
src/api/aikido.js
Normal 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
46
src/api/npmApi.js
Normal 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
91
src/config/configFile.js
Normal 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;
|
||||
}
|
||||
14
src/environment/environment.js
Normal file
14
src/environment/environment.js
Normal 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;
|
||||
}
|
||||
79
src/environment/userInteraction.js
Normal file
79
src/environment/userInteraction.js
Normal 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
18
src/main.js
Normal 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);
|
||||
}
|
||||
28
src/packagemanager/currentPackageManager.js
Normal file
28
src/packagemanager/currentPackageManager.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { createNpmPackageManager } from "./npm/createPackageManager.js";
|
||||
import { createNpxPackageManager } from "./npx/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 {
|
||||
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;
|
||||
}
|
||||
83
src/packagemanager/npm/createPackageManager.js
Normal file
83
src/packagemanager/npm/createPackageManager.js
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
50
src/packagemanager/npm/dependencyScanner/dryRunScanner.js
Normal file
50
src/packagemanager/npm/dependencyScanner/dryRunScanner.js
Normal 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();
|
||||
}
|
||||
6
src/packagemanager/npm/dependencyScanner/nullScanner.js
Normal file
6
src/packagemanager/npm/dependencyScanner/nullScanner.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function nullScanner() {
|
||||
return {
|
||||
scan: () => [],
|
||||
shouldScan: () => false,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
109
src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js
Normal file
109
src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
33
src/packagemanager/npm/runNpmCommand.js
Normal file
33
src/packagemanager/npm/runNpmCommand.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/packagemanager/npm/utils/cmd-list.js
Normal file
171
src/packagemanager/npm/utils/cmd-list.js
Normal 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;
|
||||
}
|
||||
26
src/packagemanager/npm/utils/npmCommands.js
Normal file
26
src/packagemanager/npm/utils/npmCommands.js
Normal 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";
|
||||
13
src/packagemanager/npx/createPackageManager.js
Normal file
13
src/packagemanager/npx/createPackageManager.js
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
106
src/packagemanager/npx/parsing/parsePackagesFromArguments.js
Normal file
106
src/packagemanager/npx/parsing/parsePackagesFromArguments.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
17
src/packagemanager/npx/runNpxCommand.js
Normal file
17
src/packagemanager/npx/runNpxCommand.js
Normal 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 };
|
||||
}
|
||||
34
src/packagemanager/yarn/createPackageManager.js
Normal file
34
src/packagemanager/yarn/createPackageManager.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
102
src/packagemanager/yarn/parsing/parsePackagesFromArguments.js
Normal file
102
src/packagemanager/yarn/parsing/parsePackagesFromArguments.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
17
src/packagemanager/yarn/runYarnCommand.js
Normal file
17
src/packagemanager/yarn/runYarnCommand.js
Normal 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 };
|
||||
}
|
||||
56
src/scanning/audit/index.js
Normal file
56
src/scanning/audit/index.js
Normal 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
92
src/scanning/index.js
Normal 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);
|
||||
}
|
||||
180
src/scanning/index.scanCommand.spec.js
Normal file
180
src/scanning/index.scanCommand.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
47
src/scanning/index.shouldScanCommand.spec.js
Normal file
47
src/scanning/index.shouldScanCommand.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
72
src/scanning/malwareDatabase.js
Normal file
72
src/scanning/malwareDatabase.js
Normal 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";
|
||||
44
src/shell-integration/helpers.js
Normal file
44
src/shell-integration/helpers.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
const knownAikidoTools = [
|
||||
{ tool: "npm", aikidoCommand: "aikido-npm" },
|
||||
{ tool: "npx", aikidoCommand: "aikido-npx" },
|
||||
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
||||
// When adding a new tool here, also update the expected alias in the tests (shellIntegration.spec.js)
|
||||
// and add the documentation for the new tool in the README.md
|
||||
];
|
||||
|
||||
export function getAliases(fileName) {
|
||||
const fileExtension = fileName.split(".").pop().toLowerCase();
|
||||
|
||||
let createAlias = pickCreateAliasFunction(fileExtension);
|
||||
|
||||
const aliases = knownAikidoTools.map(({ tool, aikidoCommand }) =>
|
||||
createAlias(tool, aikidoCommand)
|
||||
);
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function pickCreateAliasFunction(fileExtension) {
|
||||
let createAlias;
|
||||
switch (fileExtension) {
|
||||
case "ps1":
|
||||
createAlias = createGeneralPowershellAlias;
|
||||
break;
|
||||
case "fish":
|
||||
createAlias = createGeneralFishAlias;
|
||||
break;
|
||||
default:
|
||||
createAlias = createGeneralPosixAlias;
|
||||
}
|
||||
return createAlias;
|
||||
}
|
||||
|
||||
function createGeneralPosixAlias(tool, aikidoCommand) {
|
||||
return `alias ${tool}='${aikidoCommand}'`;
|
||||
}
|
||||
function createGeneralPowershellAlias(tool, aikidoCommand) {
|
||||
return `Set-Alias ${tool} ${aikidoCommand}`;
|
||||
}
|
||||
function createGeneralFishAlias(tool, aikidoCommand) {
|
||||
return `alias ${tool} "${aikidoCommand}"`;
|
||||
}
|
||||
151
src/shell-integration/setup.js
Normal file
151
src/shell-integration/setup.js
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import chalk from "chalk";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { detectShells } from "./shellDetection.js";
|
||||
import { getAliases } from "./helpers.js";
|
||||
import fs from "fs";
|
||||
import { EOL } from "os";
|
||||
|
||||
export async function setup() {
|
||||
ui.writeInformation(
|
||||
chalk.bold("Setting up shell aliases.") +
|
||||
" This will wrap safe-chain around npm, npx, and yarn commands."
|
||||
);
|
||||
ui.emptyLine();
|
||||
|
||||
try {
|
||||
const shells = detectShells();
|
||||
if (shells.length === 0) {
|
||||
ui.writeError("No supported shells detected. Cannot set up aliases.");
|
||||
return;
|
||||
}
|
||||
|
||||
ui.writeInformation(
|
||||
`Detected ${shells.length} supported shell(s): ${shells
|
||||
.map((shell) => chalk.bold(shell.name))
|
||||
.join(", ")}.`
|
||||
);
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const shell of shells) {
|
||||
if (setupAliasesForShell(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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function sets up aliases for the given shell.
|
||||
* It reads the shell's startup file (eg ~/.bashrc, ~/.zshrc, etc.),
|
||||
* and then appends the aliases for npm, npx, and yarn commands.
|
||||
* If the aliases already exist, it will not add them again.
|
||||
* If the startup file does not exist, it will create it.
|
||||
*
|
||||
* The shell startup script is loaded by the respective shell when it starts.
|
||||
* This means that the aliases will be available in the shell after it is restarted.
|
||||
*/
|
||||
function setupAliasesForShell(shell) {
|
||||
if (!shell.startupFile) {
|
||||
ui.writeError(
|
||||
`- ${chalk.bold(
|
||||
shell.name
|
||||
)}: no startup file found. Cannot set up aliases.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const aliases = getAliases(shell.startupFile);
|
||||
|
||||
if (aliases.length === 0) {
|
||||
ui.writeError(`- ${chalk.bold(shell.name)}: could not generate aliases.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileContent = readOrCreateStartupFile(shell.startupFile);
|
||||
const { addedCount, existingCount, failedCount } = appendAliasesToFile(
|
||||
aliases,
|
||||
fileContent,
|
||||
shell.startupFile
|
||||
);
|
||||
|
||||
let summary = "- " + chalk.bold(shell.name) + ": ";
|
||||
|
||||
if (addedCount > 0) {
|
||||
summary += chalk.green(`${addedCount} aliases were added`);
|
||||
}
|
||||
if (existingCount > 0) {
|
||||
if (addedCount > 0) {
|
||||
summary += ", ";
|
||||
}
|
||||
summary += chalk.yellow(`${existingCount} aliases were already present`);
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
if (addedCount > 0 || existingCount > 0) {
|
||||
summary += ", ";
|
||||
}
|
||||
summary += chalk.red(`${failedCount} aliases failed to add`);
|
||||
}
|
||||
|
||||
// write summary in a single line
|
||||
ui.writeInformation(summary);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This reads the content of the startup file.
|
||||
* If the file does not exist, it creates an empty file and returns an empty string.
|
||||
* The startup file is the shell's startup script (eg: ~/.bashrc, ~/.zshrc, etc.).
|
||||
* It is used to set up the shell environment when it starts.
|
||||
* Some shells may not have a startup file, in which case this function will create one.
|
||||
*/
|
||||
export function readOrCreateStartupFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, "", "utf-8");
|
||||
ui.writeInformation(`File ${filePath} created.`);
|
||||
}
|
||||
return fs.readFileSync(filePath, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* This function appends the aliases to the startup file.
|
||||
* eg: for bash it will append 'alias npm="aikido-npm"' for npm to ~/.bashrc
|
||||
* @returns an object with the counts of added, existing, and failed aliases.
|
||||
*/
|
||||
export function appendAliasesToFile(aliases, fileContent, startupFilePath) {
|
||||
let addedCount = 0;
|
||||
let existingCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const alias of aliases) {
|
||||
try {
|
||||
if (fileContent.includes(alias)) {
|
||||
existingCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.appendFileSync(startupFilePath, `${EOL}${alias}`, "utf-8");
|
||||
|
||||
addedCount++;
|
||||
} catch {
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
addedCount,
|
||||
existingCount,
|
||||
failedCount,
|
||||
};
|
||||
}
|
||||
304
src/shell-integration/setup.spec.js
Normal file
304
src/shell-integration/setup.spec.js
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { EOL, tmpdir } from "node:os";
|
||||
import fs from "node:fs";
|
||||
import { getAliases } from "./helpers.js";
|
||||
import { readOrCreateStartupFile, appendAliasesToFile } from "./setup.js";
|
||||
|
||||
describe("setupShell", () => {
|
||||
function runSetupTestsForEnvironment(shell, startupExtension, expectedAliases) {
|
||||
describe(`${shell} shell setup`, () => {
|
||||
it(`should add aliases to ${shell} file`, () => {
|
||||
const lines = [`#!/usr/bin/env ${shell}`, "", "alias cls='clear'"];
|
||||
const filePath = createShellStartupScript(lines, startupExtension);
|
||||
|
||||
const aliases = getAliases(filePath);
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const result = appendAliasesToFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.addedCount, 3, "Should add 3 aliases");
|
||||
assert.strictEqual(result.existingCount, 0, "Should find no existing aliases");
|
||||
assert.strictEqual(result.failedCount, 0, "Should have no failed aliases");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
for (const alias of expectedAliases) {
|
||||
assert.ok(updatedContent.includes(alias), `Alias "${alias}" should be added`);
|
||||
}
|
||||
assert.ok(updatedContent.includes("alias cls='clear'"), "Original aliases should remain");
|
||||
});
|
||||
|
||||
it(`should not add aliases if they already exist in ${shell} file`, () => {
|
||||
const lines = [`#!/usr/bin/env ${shell}`, "", ...expectedAliases];
|
||||
const filePath = createShellStartupScript(lines, startupExtension);
|
||||
|
||||
const aliases = getAliases(filePath);
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const result = appendAliasesToFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.addedCount, 0, "Should add 0 aliases");
|
||||
assert.strictEqual(result.existingCount, 3, "Should find 3 existing aliases");
|
||||
assert.strictEqual(result.failedCount, 0, "Should have no failed aliases");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
// Count occurrences to ensure no duplicates were added
|
||||
for (const alias of expectedAliases) {
|
||||
assert.strictEqual(countOccurrences(updatedContent, alias), 1, `Alias "${alias}" should appear exactly once`);
|
||||
}
|
||||
});
|
||||
|
||||
it(`should create file and add aliases if file does not exist for ${shell}`, () => {
|
||||
const randomName = Math.random().toString(36).substring(2, 15);
|
||||
const filePath = `${tmpdir()}/nonexistent-${randomName}${startupExtension}`;
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
|
||||
// Test readOrCreateStartupFile function
|
||||
const fileContent = readOrCreateStartupFile(filePath);
|
||||
assert.strictEqual(fileContent, "", "Should return empty string for new file");
|
||||
assert.ok(fs.existsSync(filePath), "File should be created");
|
||||
|
||||
// Test adding aliases to the newly created file
|
||||
const aliases = getAliases(filePath);
|
||||
const result = appendAliasesToFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.addedCount, 3, "Should add 3 aliases");
|
||||
assert.strictEqual(result.existingCount, 0, "Should find no existing aliases");
|
||||
assert.strictEqual(result.failedCount, 0, "Should have no failed aliases");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
for (const alias of expectedAliases) {
|
||||
assert.ok(updatedContent.includes(alias), `Alias "${alias}" should be added`);
|
||||
}
|
||||
});
|
||||
|
||||
it(`should add aliases only once when called multiple times for ${shell}`, () => {
|
||||
const lines = [`#!/usr/bin/env ${shell}`, ""];
|
||||
const filePath = createShellStartupScript(lines, startupExtension);
|
||||
|
||||
const aliases = getAliases(filePath);
|
||||
|
||||
// First call - should add aliases
|
||||
let fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
const result1 = appendAliasesToFile(aliases, fileContent, filePath);
|
||||
assert.strictEqual(result1.addedCount, 3, "First call should add 3 aliases");
|
||||
|
||||
// Second call - should detect existing aliases
|
||||
fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
const result2 = appendAliasesToFile(aliases, fileContent, filePath);
|
||||
assert.strictEqual(result2.addedCount, 0, "Second call should add 0 aliases");
|
||||
assert.strictEqual(result2.existingCount, 3, "Second call should find 3 existing aliases");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
for (const alias of expectedAliases) {
|
||||
assert.strictEqual(countOccurrences(updatedContent, alias), 1, `Alias "${alias}" should appear exactly once`);
|
||||
}
|
||||
});
|
||||
|
||||
it(`should use real getAliases() for ${shell} file`, () => {
|
||||
const filePath = `${tmpdir()}/test${startupExtension}`;
|
||||
const aliases = getAliases(filePath);
|
||||
|
||||
// Verify we get the expected aliases for this shell type
|
||||
assert.strictEqual(aliases.length, 3, "Should get 3 aliases (npm, npx, yarn)");
|
||||
for (let i = 0; i < aliases.length; i++) {
|
||||
assert.strictEqual(aliases[i], expectedAliases[i], `Alias ${i} should match expected format`);
|
||||
}
|
||||
});
|
||||
|
||||
it(`should handle mixed scenario - some existing, some new for ${shell}`, () => {
|
||||
const lines = [`#!/usr/bin/env ${shell}`, "", expectedAliases[0], "alias other='command'"];
|
||||
const filePath = createShellStartupScript(lines, startupExtension);
|
||||
|
||||
const aliases = getAliases(filePath);
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const result = appendAliasesToFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.addedCount, 2, "Should add 2 new aliases");
|
||||
assert.strictEqual(result.existingCount, 1, "Should find 1 existing alias");
|
||||
assert.strictEqual(result.failedCount, 0, "Should have no failed aliases");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
for (const alias of expectedAliases) {
|
||||
assert.ok(updatedContent.includes(alias), `Alias "${alias}" should be present`);
|
||||
}
|
||||
assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Test for each shell type using real getAliases() output
|
||||
runSetupTestsForEnvironment("bash", ".bashrc", [
|
||||
"alias npm='aikido-npm'",
|
||||
"alias npx='aikido-npx'",
|
||||
"alias yarn='aikido-yarn'"
|
||||
]);
|
||||
|
||||
runSetupTestsForEnvironment("zsh", ".zshrc", [
|
||||
"alias npm='aikido-npm'",
|
||||
"alias npx='aikido-npx'",
|
||||
"alias yarn='aikido-yarn'"
|
||||
]);
|
||||
|
||||
runSetupTestsForEnvironment("fish", ".fish", [
|
||||
'alias npm "aikido-npm"',
|
||||
'alias npx "aikido-npx"',
|
||||
'alias yarn "aikido-yarn"'
|
||||
]);
|
||||
|
||||
runSetupTestsForEnvironment("pwsh", ".ps1", [
|
||||
"Set-Alias npm aikido-npm",
|
||||
"Set-Alias npx aikido-npx",
|
||||
"Set-Alias yarn aikido-yarn"
|
||||
]);
|
||||
|
||||
describe("readOrCreateStartupFile", () => {
|
||||
it("should read existing file content", () => {
|
||||
const lines = ["#!/usr/bin/env bash", "", "alias test='echo test'"];
|
||||
const filePath = createShellStartupScript(lines, ".bashrc");
|
||||
|
||||
const content = readOrCreateStartupFile(filePath);
|
||||
|
||||
assert.ok(content.includes("#!/usr/bin/env bash"), "Should contain shebang");
|
||||
assert.ok(content.includes("alias test='echo test'"), "Should contain existing aliases");
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(filePath, { force: true });
|
||||
});
|
||||
|
||||
it("should create file if it doesn't exist", () => {
|
||||
const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`;
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
|
||||
const content = readOrCreateStartupFile(filePath);
|
||||
|
||||
assert.strictEqual(content, "", "Should return empty string for new file");
|
||||
assert.ok(fs.existsSync(filePath), "File should be created");
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(filePath, { force: true });
|
||||
});
|
||||
|
||||
it("should handle empty existing file", () => {
|
||||
const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`;
|
||||
fs.writeFileSync(filePath, "", "utf-8");
|
||||
|
||||
const content = readOrCreateStartupFile(filePath);
|
||||
|
||||
assert.strictEqual(content, "", "Should return empty string for empty file");
|
||||
assert.ok(fs.existsSync(filePath), "File should still exist");
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(filePath, { force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendAliasesToFile edge cases", () => {
|
||||
it("should handle empty aliases array", () => {
|
||||
const lines = ["#!/usr/bin/env bash", "", "alias test='echo test'"];
|
||||
const filePath = createShellStartupScript(lines, ".bashrc");
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const result = appendAliasesToFile([], fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.addedCount, 0, "Should add 0 aliases");
|
||||
assert.strictEqual(result.existingCount, 0, "Should find 0 existing aliases");
|
||||
assert.strictEqual(result.failedCount, 0, "Should have 0 failed aliases");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
assert.ok(updatedContent.includes("alias test='echo test'"), "Original content should remain");
|
||||
});
|
||||
|
||||
it("should handle partial substring matches correctly", () => {
|
||||
const lines = [
|
||||
"#!/usr/bin/env bash",
|
||||
"",
|
||||
"alias npmx='some-other-command'", // Contains 'npm' but shouldn't match 'alias npm='
|
||||
"alias test='echo test'"
|
||||
];
|
||||
const filePath = createShellStartupScript(lines, ".bashrc");
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const aliases = ["alias npm='aikido-npm'"];
|
||||
const result = appendAliasesToFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.addedCount, 1, "Should add 1 alias (npm)");
|
||||
assert.strictEqual(result.existingCount, 0, "Should find 0 existing aliases");
|
||||
assert.strictEqual(result.failedCount, 0, "Should have 0 failed aliases");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
assert.ok(updatedContent.includes("alias npm='aikido-npm'"), "npm alias should be added");
|
||||
assert.ok(updatedContent.includes("alias npmx='some-other-command'"), "npmx alias should remain");
|
||||
});
|
||||
|
||||
it("should handle file with only whitespace", () => {
|
||||
const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`;
|
||||
const fileContent = `${EOL}${EOL} ${EOL}`;
|
||||
fs.writeFileSync(filePath, fileContent, "utf-8");
|
||||
|
||||
const aliases = ["alias npm='aikido-npm'"];
|
||||
const result = appendAliasesToFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.addedCount, 1, "Should add 1 alias");
|
||||
assert.strictEqual(result.existingCount, 0, "Should find 0 existing aliases");
|
||||
assert.strictEqual(result.failedCount, 0, "Should have 0 failed aliases");
|
||||
|
||||
const updatedContent = fs.readFileSync(filePath, "utf-8");
|
||||
assert.ok(updatedContent.includes("alias npm='aikido-npm'"), "Alias should be added");
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(filePath, { force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendAliasesToFile error handling", () => {
|
||||
it("should handle file permission errors gracefully", () => {
|
||||
const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`;
|
||||
fs.writeFileSync(filePath, "#!/usr/bin/env bash", "utf-8");
|
||||
|
||||
// Make file read-only to simulate permission error
|
||||
fs.chmodSync(filePath, 0o444);
|
||||
|
||||
const aliases = ["alias npm='aikido-npm'"];
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const result = appendAliasesToFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.addedCount, 0, "Should add 0 aliases due to permission error");
|
||||
assert.strictEqual(result.existingCount, 0, "Should find 0 existing aliases");
|
||||
assert.strictEqual(result.failedCount, 1, "Should have 1 failed alias");
|
||||
|
||||
// Restore permissions and cleanup
|
||||
fs.chmodSync(filePath, 0o644);
|
||||
fs.rmSync(filePath, { force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createShellStartupScript(lines, fileExtension) {
|
||||
const randomFileName = Math.random().toString(36).substring(2, 15);
|
||||
const filePath = `${tmpdir()}/${randomFileName}${fileExtension}`;
|
||||
fs.writeFileSync(filePath, lines.join(EOL), "utf-8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function readAndDeleteFile(filePath) {
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
fs.rmSync(filePath, { force: true });
|
||||
return fileContent.split(EOL);
|
||||
}
|
||||
|
||||
function countOccurrences(lines, searchString) {
|
||||
let count = 0;
|
||||
for (const line of lines) {
|
||||
if (line.includes(searchString)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
75
src/shell-integration/shellDetection.js
Normal file
75
src/shell-integration/shellDetection.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import * as os from "os";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const shellList = {
|
||||
bash: {
|
||||
name: "Bash",
|
||||
executable: "bash",
|
||||
getStartupFileCommand: "echo ~/.bashrc",
|
||||
},
|
||||
zsh: {
|
||||
name: "Zsh",
|
||||
executable: "zsh",
|
||||
getStartupFileCommand: "echo ${ZDOTDIR:-$HOME}/.zshrc",
|
||||
},
|
||||
fish: {
|
||||
name: "Fish",
|
||||
executable: "fish",
|
||||
getStartupFileCommand: "echo ~/.config/fish/config.fish",
|
||||
},
|
||||
powershell: {
|
||||
name: "PowerShell Core",
|
||||
executable: "pwsh",
|
||||
getStartupFileCommand: "echo $PROFILE",
|
||||
},
|
||||
windowsPowerShell: {
|
||||
name: "Windows PowerShell",
|
||||
executable: "powershell",
|
||||
getStartupFileCommand: "echo $PROFILE",
|
||||
},
|
||||
};
|
||||
|
||||
export function detectShells() {
|
||||
let availableShells = [];
|
||||
|
||||
for (const shellName of Object.keys(shellList)) {
|
||||
const shell = shellList[shellName];
|
||||
|
||||
if (isShellAvailable(shell)) {
|
||||
const startupFile = getShellStartupFile(shell);
|
||||
availableShells.push({
|
||||
name: shell.name,
|
||||
executable: shell.executable,
|
||||
startupFile: startupFile || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return availableShells;
|
||||
}
|
||||
|
||||
function isShellAvailable(shell) {
|
||||
try {
|
||||
if (os.platform() === "win32") {
|
||||
execSync(`where ${shell.executable}`, { stdio: "ignore" });
|
||||
} else {
|
||||
execSync(`which ${shell.executable}`, { stdio: "ignore" });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getShellStartupFile(shell) {
|
||||
try {
|
||||
const command = shell.getStartupFileCommand;
|
||||
const output = execSync(command, {
|
||||
encoding: "utf8",
|
||||
shell: shell.executable,
|
||||
}).trim();
|
||||
return output;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
140
src/shell-integration/teardown.js
Normal file
140
src/shell-integration/teardown.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import chalk from "chalk";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { detectShells } from "./shellDetection.js";
|
||||
import { getAliases } from "./helpers.js";
|
||||
import fs from "fs";
|
||||
import { EOL } from "os";
|
||||
|
||||
export async function teardown() {
|
||||
ui.writeInformation(
|
||||
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) {
|
||||
if (removeAliasesForShell(shell)) {
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function removes aliases for the given shell.
|
||||
* It reads the shell's startup file (eg ~/.bashrc, ~/.zshrc, etc.),
|
||||
* and then removes the aliases for npm, npx, and yarn commands.
|
||||
* If the aliases don't exist, it will report that they were not found.
|
||||
* If the startup file does not exist, it will report that no aliases need to be removed.
|
||||
*
|
||||
* The shell startup script is loaded by the respective shell when it starts.
|
||||
* This means that the aliases will be removed from the shell after it is restarted.
|
||||
*/
|
||||
function removeAliasesForShell(shell) {
|
||||
if (!shell.startupFile) {
|
||||
ui.writeError(
|
||||
`- ${chalk.bold(
|
||||
shell.name
|
||||
)}: no startup file found. Cannot remove aliases.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(shell.startupFile)) {
|
||||
ui.writeInformation(
|
||||
`- ${chalk.bold(
|
||||
shell.name
|
||||
)}: startup file does not exist. No aliases to remove.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const aliases = getAliases(shell.startupFile);
|
||||
const fileContent = fs.readFileSync(shell.startupFile, "utf-8");
|
||||
const { removedCount, notFoundCount } = removeAliasesFromFile(
|
||||
aliases,
|
||||
fileContent,
|
||||
shell.startupFile
|
||||
);
|
||||
|
||||
let summary = "- " + chalk.bold(shell.name) + ": ";
|
||||
|
||||
if (removedCount > 0) {
|
||||
summary += chalk.green(`${removedCount} aliases were removed`);
|
||||
}
|
||||
if (notFoundCount > 0) {
|
||||
if (removedCount > 0) {
|
||||
summary += ", ";
|
||||
}
|
||||
summary += chalk.yellow(`${notFoundCount} aliases were not found`);
|
||||
}
|
||||
if (removedCount === 0 && notFoundCount === 0) {
|
||||
summary += chalk.yellow("no aliases found to remove");
|
||||
}
|
||||
|
||||
ui.writeInformation(summary);
|
||||
return removedCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function removes the aliases from the startup file.
|
||||
* It searches for exact matches of each alias line and removes them.
|
||||
* eg: for bash it will remove 'alias npm="aikido-npm"' for npm from ~/.bashrc
|
||||
* @returns an object with the counts of removed and not found aliases.
|
||||
*/
|
||||
export function removeAliasesFromFile(aliases, fileContent, startupFilePath) {
|
||||
let removedCount = 0;
|
||||
let notFoundCount = 0;
|
||||
let updatedContent = fileContent;
|
||||
|
||||
for (const alias of aliases) {
|
||||
const lines = updatedContent.split(EOL);
|
||||
let aliasLineIndex = lines.findIndex((line) => line.trim() === alias);
|
||||
|
||||
if (aliasLineIndex !== -1) {
|
||||
removedCount++;
|
||||
|
||||
// Remove all occurrences of the alias line, in case it appears multiple times
|
||||
while (aliasLineIndex !== -1) {
|
||||
lines.splice(aliasLineIndex, 1);
|
||||
aliasLineIndex = lines.findIndex((line) => line.trim() === alias);
|
||||
}
|
||||
updatedContent = lines.join(EOL);
|
||||
} else {
|
||||
notFoundCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
fs.writeFileSync(startupFilePath, updatedContent, "utf-8");
|
||||
}
|
||||
|
||||
return {
|
||||
removedCount,
|
||||
notFoundCount,
|
||||
};
|
||||
}
|
||||
177
src/shell-integration/teardown.spec.js
Normal file
177
src/shell-integration/teardown.spec.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { EOL, tmpdir } from "node:os";
|
||||
import fs from "node:fs";
|
||||
import { getAliases } from "./helpers.js";
|
||||
import { removeAliasesFromFile } from "./teardown.js";
|
||||
|
||||
describe("teardown", () => {
|
||||
function runRemovalTestsForEnvironment(shell, startupExtension, expectedAliases) {
|
||||
describe(`${shell} shell removal`, () => {
|
||||
it(`should remove aliases from ${shell} file`, () => {
|
||||
const lines = [`#!/usr/bin/env ${shell}`, "", ...expectedAliases, ""];
|
||||
const filePath = createShellStartupScript(lines, startupExtension);
|
||||
|
||||
// Test the removeAliasesFromFile function directly
|
||||
const aliases = getAliases(filePath);
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const result = removeAliasesFromFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.removedCount, 3, "Should remove 3 aliases");
|
||||
assert.strictEqual(result.notFoundCount, 0, "Should find all aliases");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
for (const alias of expectedAliases) {
|
||||
assert.ok(!updatedContent.includes(alias), `Alias "${alias}" should be removed`);
|
||||
}
|
||||
});
|
||||
|
||||
it(`should handle file with no aliases for ${shell}`, () => {
|
||||
const lines = [`#!/usr/bin/env ${shell}`, "", "alias other='command'", ""];
|
||||
const filePath = createShellStartupScript(lines, startupExtension);
|
||||
|
||||
const aliases = getAliases(filePath);
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const result = removeAliasesFromFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.removedCount, 0, "Should remove 0 aliases");
|
||||
assert.strictEqual(result.notFoundCount, 3, "Should report 3 aliases not found");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain unchanged");
|
||||
});
|
||||
|
||||
it(`should remove duplicate aliases from ${shell} file`, () => {
|
||||
const lines = [
|
||||
`#!/usr/bin/env ${shell}`,
|
||||
"",
|
||||
...expectedAliases,
|
||||
"alias other='command'",
|
||||
...expectedAliases, // duplicates
|
||||
""
|
||||
];
|
||||
const filePath = createShellStartupScript(lines, startupExtension);
|
||||
|
||||
const aliases = getAliases(filePath);
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const result = removeAliasesFromFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.removedCount, 3, "Should remove 3 aliases (counting duplicates as single removal)");
|
||||
assert.strictEqual(result.notFoundCount, 0, "Should find all aliases");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
for (const alias of expectedAliases) {
|
||||
assert.ok(!updatedContent.includes(alias), `Alias "${alias}" should be completely removed`);
|
||||
}
|
||||
assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain");
|
||||
});
|
||||
|
||||
it(`should use real getAliases() for ${shell} file`, () => {
|
||||
const filePath = `${tmpdir()}/test${startupExtension}`;
|
||||
const aliases = getAliases(filePath);
|
||||
|
||||
// Verify we get the expected aliases for this shell type
|
||||
assert.strictEqual(aliases.length, 3, "Should get 3 aliases (npm, npx, yarn)");
|
||||
for (let i = 0; i < aliases.length; i++) {
|
||||
assert.strictEqual(aliases[i], expectedAliases[i], `Alias ${i} should match expected format`);
|
||||
}
|
||||
});
|
||||
|
||||
it(`should handle partial alias matches for ${shell}`, () => {
|
||||
const lines = [
|
||||
`#!/usr/bin/env ${shell}`,
|
||||
"",
|
||||
expectedAliases[0], // Only first alias
|
||||
"alias other='command'",
|
||||
""
|
||||
];
|
||||
const filePath = createShellStartupScript(lines, startupExtension);
|
||||
|
||||
const aliases = getAliases(filePath);
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
const result = removeAliasesFromFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.removedCount, 1, "Should remove 1 alias");
|
||||
assert.strictEqual(result.notFoundCount, 2, "Should report 2 aliases not found");
|
||||
|
||||
const updatedContent = readAndDeleteFile(filePath);
|
||||
assert.ok(!updatedContent.includes(expectedAliases[0]), "First alias should be removed");
|
||||
assert.ok(updatedContent.includes("alias other='command'"), "Other aliases should remain");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Test for each shell type using real getAliases() output
|
||||
runRemovalTestsForEnvironment("bash", ".bashrc", [
|
||||
"alias npm='aikido-npm'",
|
||||
"alias npx='aikido-npx'",
|
||||
"alias yarn='aikido-yarn'"
|
||||
]);
|
||||
|
||||
runRemovalTestsForEnvironment("zsh", ".zshrc", [
|
||||
"alias npm='aikido-npm'",
|
||||
"alias npx='aikido-npx'",
|
||||
"alias yarn='aikido-yarn'"
|
||||
]);
|
||||
|
||||
runRemovalTestsForEnvironment("fish", ".fish", [
|
||||
'alias npm "aikido-npm"',
|
||||
'alias npx "aikido-npx"',
|
||||
'alias yarn "aikido-yarn"'
|
||||
]);
|
||||
|
||||
runRemovalTestsForEnvironment("pwsh", ".ps1", [
|
||||
"Set-Alias npm aikido-npm",
|
||||
"Set-Alias npx aikido-npx",
|
||||
"Set-Alias yarn aikido-yarn"
|
||||
]);
|
||||
|
||||
describe("removeAliasesFromFile edge cases", () => {
|
||||
it("should handle empty file", () => {
|
||||
const aliases = ["alias npm='aikido-npm'"];
|
||||
const fileContent = "";
|
||||
const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`;
|
||||
fs.writeFileSync(filePath, fileContent, "utf-8");
|
||||
|
||||
const result = removeAliasesFromFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.removedCount, 0, "Should remove 0 aliases from empty file");
|
||||
assert.strictEqual(result.notFoundCount, 1, "Should report 1 alias not found");
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(filePath, { force: true });
|
||||
});
|
||||
|
||||
it("should handle file with only whitespace", () => {
|
||||
const aliases = ["alias npm='aikido-npm'"];
|
||||
const fileContent = `${EOL}${EOL} ${EOL}`;
|
||||
const filePath = `${tmpdir()}/test-${Math.random().toString(36).substring(2, 15)}.bashrc`;
|
||||
fs.writeFileSync(filePath, fileContent, "utf-8");
|
||||
|
||||
const result = removeAliasesFromFile(aliases, fileContent, filePath);
|
||||
|
||||
assert.strictEqual(result.removedCount, 0, "Should remove 0 aliases from whitespace-only file");
|
||||
assert.strictEqual(result.notFoundCount, 1, "Should report 1 alias not found");
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(filePath, { force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createShellStartupScript(lines, fileExtension) {
|
||||
const randomFileName = Math.random().toString(36).substring(2, 15);
|
||||
const filePath = `${tmpdir()}/${randomFileName}${fileExtension}`;
|
||||
fs.writeFileSync(filePath, lines.join(EOL), "utf-8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function readAndDeleteFile(filePath) {
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
fs.rmSync(filePath, { force: true });
|
||||
return fileContent.split(EOL);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue