mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Undo move of files to safe-chain
This commit is contained in:
parent
b29bc2e6dc
commit
8fe228c476
68 changed files with 7 additions and 10 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);
|
||||
}
|
||||
13
src/packagemanager/_shared/matchesCommand.js
Normal file
13
src/packagemanager/_shared/matchesCommand.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function matchesCommand(args, ...commandArgs) {
|
||||
if (args.length < commandArgs.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < commandArgs.length; i++) {
|
||||
if (args[i].toLowerCase() !== commandArgs[i].toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
36
src/packagemanager/currentPackageManager.js
Normal file
36
src/packagemanager/currentPackageManager.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { createNpmPackageManager } from "./npm/createPackageManager.js";
|
||||
import { createNpxPackageManager } from "./npx/createPackageManager.js";
|
||||
import {
|
||||
createPnpmPackageManager,
|
||||
createPnpxPackageManager,
|
||||
} from "./pnpm/createPackageManager.js";
|
||||
import { createYarnPackageManager } from "./yarn/createPackageManager.js";
|
||||
|
||||
const state = {
|
||||
packageManagerName: null,
|
||||
};
|
||||
|
||||
export function initializePackageManager(packageManagerName, version) {
|
||||
if (packageManagerName === "npm") {
|
||||
state.packageManagerName = createNpmPackageManager(version);
|
||||
} else if (packageManagerName === "npx") {
|
||||
state.packageManagerName = createNpxPackageManager();
|
||||
} else if (packageManagerName === "yarn") {
|
||||
state.packageManagerName = createYarnPackageManager();
|
||||
} else if (packageManagerName === "pnpm") {
|
||||
state.packageManagerName = createPnpmPackageManager();
|
||||
} else if (packageManagerName === "pnpx") {
|
||||
state.packageManagerName = createPnpxPackageManager();
|
||||
} else {
|
||||
throw new Error("Unsupported package manager: " + packageManagerName);
|
||||
}
|
||||
|
||||
return state.packageManagerName;
|
||||
}
|
||||
|
||||
export function getPackageManager() {
|
||||
if (!state.packageManagerName) {
|
||||
throw new Error("Package manager not initialized.");
|
||||
}
|
||||
return state.packageManagerName;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
46
src/packagemanager/pnpm/createPackageManager.js
Normal file
46
src/packagemanager/pnpm/createPackageManager.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { matchesCommand } from "../_shared/matchesCommand.js";
|
||||
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
|
||||
import { runPnpmCommand } from "./runPnpmCommand.js";
|
||||
|
||||
const scanner = commandArgumentScanner();
|
||||
|
||||
export function createPnpmPackageManager() {
|
||||
return {
|
||||
getWarningMessage: () => null,
|
||||
runCommand: (args) => runPnpmCommand(args, "pnpm"),
|
||||
isSupportedCommand: (args) =>
|
||||
matchesCommand(args, "add") ||
|
||||
matchesCommand(args, "update") ||
|
||||
matchesCommand(args, "upgrade") ||
|
||||
matchesCommand(args, "up") ||
|
||||
// dlx does not always come in the first position
|
||||
// eg: pnpm --package=yo --package=generator-webapp dlx yo webapp
|
||||
// documentation: https://pnpm.io/cli/dlx#--package-name
|
||||
args.includes("dlx"),
|
||||
getDependencyUpdatesForCommand: (args) =>
|
||||
getDependencyUpdatesForCommand(args, false),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPnpxPackageManager() {
|
||||
return {
|
||||
getWarningMessage: () => null,
|
||||
runCommand: (args) => runPnpmCommand(args, "pnpx"),
|
||||
isSupportedCommand: () => true,
|
||||
getDependencyUpdatesForCommand: (args) =>
|
||||
getDependencyUpdatesForCommand(args, true),
|
||||
};
|
||||
}
|
||||
|
||||
function getDependencyUpdatesForCommand(args, isPnpx) {
|
||||
if (isPnpx) {
|
||||
return scanner.scan(args);
|
||||
}
|
||||
if (args.includes("dlx")) {
|
||||
// dlx is not always the first argument (eg: `pnpm --package=yo --package=generator-webapp dlx yo webapp`)
|
||||
// so we need to filter it out instead of slicing the array
|
||||
// documentation: https://pnpm.io/cli/dlx#--package-name
|
||||
return scanner.scan(args.filter((arg) => arg !== "dlx"));
|
||||
}
|
||||
return scanner.scan(args.slice(1));
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { resolvePackageVersion } from "../../../api/npmApi.js";
|
||||
import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js";
|
||||
|
||||
export function commandArgumentScanner() {
|
||||
return {
|
||||
scan: (args) => scanDependencies(args),
|
||||
shouldScan: () => true, // There's no dry run for pnpm, so we always scan
|
||||
};
|
||||
}
|
||||
|
||||
async function scanDependencies(args) {
|
||||
const changes = [];
|
||||
const packageUpdates = parsePackagesFromArguments(args);
|
||||
|
||||
for (const packageUpdate of packageUpdates) {
|
||||
var exactVersion = await resolvePackageVersion(
|
||||
packageUpdate.name,
|
||||
packageUpdate.version
|
||||
);
|
||||
if (exactVersion) {
|
||||
packageUpdate.version = exactVersion;
|
||||
}
|
||||
|
||||
changes.push({ ...packageUpdate, type: "add" });
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
export function parsePackagesFromArguments(args) {
|
||||
const changes = [];
|
||||
let defaultTag = "latest";
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
const option = getOption(arg);
|
||||
|
||||
if (option) {
|
||||
// If the option has a parameter, skip the next argument as well
|
||||
i += option.numberOfParameters;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageDetails = parsePackagename(arg, defaultTag);
|
||||
if (packageDetails) {
|
||||
changes.push(packageDetails);
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
function getOption(arg) {
|
||||
if (isOptionWithParameter(arg)) {
|
||||
return {
|
||||
name: arg,
|
||||
numberOfParameters: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Arguments starting with "-" or "--" are considered options
|
||||
// except for "--package=" which contains the package name
|
||||
if (arg.startsWith("-") && !arg.startsWith("--package=")) {
|
||||
return {
|
||||
name: arg,
|
||||
numberOfParameters: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isOptionWithParameter(arg) {
|
||||
const optionsWithParameters = ["--C", "--dir"];
|
||||
|
||||
return optionsWithParameters.includes(arg);
|
||||
}
|
||||
|
||||
function parsePackagename(arg, defaultTag) {
|
||||
// format can be --package=name@version
|
||||
// in that case, we need to remove the --package= part
|
||||
if (arg.startsWith("--package=")) {
|
||||
arg = arg.slice(10);
|
||||
}
|
||||
|
||||
arg = removeAlias(arg);
|
||||
|
||||
// Split at the last "@" to separate the package name and version
|
||||
const lastAtIndex = arg.lastIndexOf("@");
|
||||
|
||||
let name, version;
|
||||
// The index of the last "@" should be greater than 0
|
||||
// If the index is 0, it means the package name starts with "@" (eg: "@aikidosec/package-name")
|
||||
if (lastAtIndex > 0) {
|
||||
name = arg.slice(0, lastAtIndex);
|
||||
version = arg.slice(lastAtIndex + 1);
|
||||
} else {
|
||||
name = arg;
|
||||
version = defaultTag; // No tag specified (eg: "http-server"), use the default tag
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
function removeAlias(arg) {
|
||||
// removes the alias.
|
||||
// Eg.: server@npm:http-server@latest becomes http-server@latest
|
||||
const aliasIndex = arg.indexOf("@npm:");
|
||||
if (aliasIndex !== -1) {
|
||||
return arg.slice(aliasIndex + 5);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { parsePackagesFromArguments } from "./parsePackagesFromArguments.js";
|
||||
|
||||
describe("standardPnpmArgumentParser", () => {
|
||||
it("should return an empty array for no changes", () => {
|
||||
const args = [];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it("should return an array of changes for one package", () => {
|
||||
const args = ["axios@1.9.0"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
|
||||
});
|
||||
|
||||
it("should return the package with latest tag if absent", () => {
|
||||
const args = ["axios"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [{ name: "axios", version: "latest" }]);
|
||||
});
|
||||
|
||||
it("should return the package with latest tag if the version is absent and package starts with @", () => {
|
||||
const args = ["@aikidosec/package-name"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ name: "@aikidosec/package-name", version: "latest" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return the package with the specified tag if the package starts with @ and includes the version", () => {
|
||||
const args = ["@aikidosec/package-name@1.0.0"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ name: "@aikidosec/package-name", version: "1.0.0" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should only return all packages", () => {
|
||||
const args = ["axios", "jest"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ name: "axios", version: "latest" },
|
||||
{ name: "jest", version: "latest" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should ignore options with parameters and return an array of changes", () => {
|
||||
const args = ["--C", "/Users/johnsmith/dev/project", "axios@1.9.0"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
|
||||
});
|
||||
|
||||
it("should parse version even for aliased packages", () => {
|
||||
const args = ["server@npm:axios@1.9.0"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
|
||||
});
|
||||
|
||||
it("should parse scoped packages", () => {
|
||||
const args = ["@scope/package@1.0.0"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [{ name: "@scope/package", version: "1.0.0" }]);
|
||||
});
|
||||
|
||||
it("should parse packages with version ranges", () => {
|
||||
const args = ["axios@^1.9.0"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]);
|
||||
});
|
||||
|
||||
it("should parse package folders", () => {
|
||||
const args = ["./local-package"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]);
|
||||
});
|
||||
|
||||
it("should parse tarballs", () => {
|
||||
const args = ["file:./local-package.tgz"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ name: "file:./local-package.tgz", version: "latest" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse tarball URLs", () => {
|
||||
const args = ["https://example.com/local-package.tgz"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ name: "https://example.com/local-package.tgz", version: "latest" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse git URLs", () => {
|
||||
const args = ["git://github.com/http-party/http-server"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ name: "git://github.com/http-party/http-server", version: "latest" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse packages with --package={packageName}", () => {
|
||||
const args = ["--package=axios@1.9.0"];
|
||||
|
||||
const result = parsePackagesFromArguments(args);
|
||||
|
||||
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
|
||||
});
|
||||
});
|
||||
24
src/packagemanager/pnpm/runPnpmCommand.js
Normal file
24
src/packagemanager/pnpm/runPnpmCommand.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { spawnSync } from "child_process";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
|
||||
export function runPnpmCommand(args, toolName = "pnpm") {
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (toolName === "pnpm") {
|
||||
result = spawnSync("pnpm", args, { stdio: "inherit" });
|
||||
} else if (toolName === "pnpx") {
|
||||
result = spawnSync("pnpx", args, { stdio: "inherit" });
|
||||
} else {
|
||||
throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`);
|
||||
}
|
||||
|
||||
if (result.status !== null) {
|
||||
return { status: result.status };
|
||||
}
|
||||
} catch (error) {
|
||||
ui.writeError("Error executing command:", error.message);
|
||||
return { status: 1 };
|
||||
}
|
||||
return { status: 0 };
|
||||
}
|
||||
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 @@
|
|||
import { spawnSync } from "child_process";
|
||||
import * as os from "os";
|
||||
import fs from "fs";
|
||||
|
||||
export const knownAikidoTools = [
|
||||
{ tool: "npm", aikidoCommand: "aikido-npm" },
|
||||
{ tool: "npx", aikidoCommand: "aikido-npx" },
|
||||
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
||||
{ tool: "pnpm", aikidoCommand: "aikido-pnpm" },
|
||||
{ tool: "pnpx", aikidoCommand: "aikido-pnpx" },
|
||||
// When adding a new tool here, also update the expected alias in the tests (setup.spec.js, teardown.spec.js)
|
||||
// and add the documentation for the new tool in the README.md
|
||||
];
|
||||
|
||||
export function doesExecutableExistOnSystem(executableName) {
|
||||
if (os.platform() === "win32") {
|
||||
const result = spawnSync("where", [executableName], { stdio: "ignore" });
|
||||
return result.status === 0;
|
||||
} else {
|
||||
const result = spawnSync("which", [executableName], { stdio: "ignore" });
|
||||
return result.status === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeLinesMatchingPattern(filePath, pattern) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = fileContent.split(os.EOL);
|
||||
const updatedLines = lines.filter((line) => !pattern.test(line));
|
||||
fs.writeFileSync(filePath, updatedLines.join(os.EOL), "utf-8");
|
||||
}
|
||||
|
||||
export function addLineToFile(filePath, line) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, "", "utf-8");
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
const updatedContent = fileContent + os.EOL + line;
|
||||
fs.writeFileSync(filePath, updatedContent, "utf-8");
|
||||
}
|
||||
100
src/shell-integration/setup.js
Normal file
100
src/shell-integration/setup.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import chalk from "chalk";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { detectShells } from "./shellDetection.js";
|
||||
import { knownAikidoTools } from "./helpers.js";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
/**
|
||||
* Loops over the detected shells and calls the setup function for each.
|
||||
*/
|
||||
export async function setup() {
|
||||
ui.writeInformation(
|
||||
chalk.bold("Setting up shell aliases.") +
|
||||
" This will wrap safe-chain around npm, npx, and yarn commands."
|
||||
);
|
||||
ui.emptyLine();
|
||||
|
||||
copyStartupFiles();
|
||||
|
||||
try {
|
||||
const shells = detectShells();
|
||||
if (shells.length === 0) {
|
||||
ui.writeError("No supported shells detected. Cannot set up aliases.");
|
||||
return;
|
||||
}
|
||||
|
||||
ui.writeInformation(
|
||||
`Detected ${shells.length} supported shell(s): ${shells
|
||||
.map((shell) => chalk.bold(shell.name))
|
||||
.join(", ")}.`
|
||||
);
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const shell of shells) {
|
||||
if (setupShell(shell)) {
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
ui.emptyLine();
|
||||
ui.writeInformation(`Please restart your terminal to apply the changes.`);
|
||||
}
|
||||
} catch (error) {
|
||||
ui.writeError(
|
||||
`Failed to set up shell aliases: ${error.message}. Please check your shell configuration.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the setup function for the given shell and reports the result.
|
||||
*/
|
||||
function setupShell(shell) {
|
||||
let success = false;
|
||||
try {
|
||||
shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases
|
||||
success = shell.setup(knownAikidoTools);
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
ui.writeInformation(
|
||||
`${chalk.bold("- " + shell.name + ":")} ${chalk.green(
|
||||
"Setup successful"
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`${chalk.bold("- " + shell.name + ":")} ${chalk.red(
|
||||
"Setup failed"
|
||||
)}. Please check your ${shell.name} configuration.`
|
||||
);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
function copyStartupFiles() {
|
||||
const startupFiles = ["init-posix.sh", "init-pwsh.ps1", "init-fish.fish"];
|
||||
|
||||
for (const file of startupFiles) {
|
||||
const targetDir = path.join(os.homedir(), ".safe-chain", "scripts");
|
||||
const targetPath = path.join(os.homedir(), ".safe-chain", "scripts", file);
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Use absolute path for source
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const sourcePath = path.resolve(__dirname, "startup-scripts", file);
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
26
src/shell-integration/shellDetection.js
Normal file
26
src/shell-integration/shellDetection.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import zsh from "./supported-shells/zsh.js";
|
||||
import bash from "./supported-shells/bash.js";
|
||||
import powershell from "./supported-shells/powershell.js";
|
||||
import windowsPowershell from "./supported-shells/windowsPowershell.js";
|
||||
import fish from "./supported-shells/fish.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
|
||||
export function detectShells() {
|
||||
let possibleShells = [zsh, bash, powershell, windowsPowershell, fish];
|
||||
let availableShells = [];
|
||||
|
||||
try {
|
||||
for (const shell of possibleShells) {
|
||||
if (shell.isInstalled()) {
|
||||
availableShells.push(shell);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ui.writeError(
|
||||
`We were not able to detect which shells are installed on your system. Please check your shell configuration. Error: ${error.message}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return availableShells;
|
||||
}
|
||||
58
src/shell-integration/startup-scripts/init-fish.fish
Normal file
58
src/shell-integration/startup-scripts/init-fish.fish
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
function printSafeChainWarning
|
||||
set original_cmd $argv[1]
|
||||
|
||||
# Fish equivalent of ANSI color codes: yellow background, black text for "Warning:"
|
||||
set_color -b yellow black
|
||||
printf "Warning:"
|
||||
set_color normal
|
||||
printf " safe-chain is not available to protect you from installing malware. %s will run without it.\n" $original_cmd
|
||||
|
||||
# Cyan text for the install command
|
||||
printf "Install safe-chain by using "
|
||||
set_color cyan
|
||||
printf "npm install -g @aikidosec/safe-chain"
|
||||
set_color normal
|
||||
printf ".\n"
|
||||
end
|
||||
|
||||
function wrapSafeChainCommand
|
||||
set original_cmd $argv[1]
|
||||
set aikido_cmd $argv[2]
|
||||
set cmd_args $argv[3..-1]
|
||||
|
||||
if type -q $aikido_cmd
|
||||
# If the aikido command is available, just run it with the provided arguments
|
||||
$aikido_cmd $cmd_args
|
||||
else
|
||||
# If the aikido command is not available, print a warning and run the original command
|
||||
printSafeChainWarning $original_cmd
|
||||
command $original_cmd $cmd_args
|
||||
end
|
||||
end
|
||||
|
||||
function npx
|
||||
wrapSafeChainCommand "npx" "aikido-npx" $argv
|
||||
end
|
||||
|
||||
function yarn
|
||||
wrapSafeChainCommand "yarn" "aikido-yarn" $argv
|
||||
end
|
||||
|
||||
function pnpm
|
||||
wrapSafeChainCommand "pnpm" "aikido-pnpm" $argv
|
||||
end
|
||||
|
||||
function pnpx
|
||||
wrapSafeChainCommand "pnpx" "aikido-pnpx" $argv
|
||||
end
|
||||
|
||||
function npm
|
||||
if test (count $argv) -eq 1 -a \( "$argv[1]" = "-v" -o "$argv[1]" = "--version" \)
|
||||
# If args is just -v or --version and nothing else, just run the npm version command
|
||||
# This is because nvm uses this to check the version of npm
|
||||
command npm $argv
|
||||
return
|
||||
end
|
||||
|
||||
wrapSafeChainCommand "npm" "aikido-npm" $argv
|
||||
end
|
||||
54
src/shell-integration/startup-scripts/init-posix.sh
Normal file
54
src/shell-integration/startup-scripts/init-posix.sh
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
|
||||
function printSafeChainWarning() {
|
||||
# \033[43;30m is used to set the background color to yellow and text color to black
|
||||
# \033[0m is used to reset the text formatting
|
||||
printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. %s will run without it.\n" "$1"
|
||||
# \033[36m is used to set the text color to cyan
|
||||
printf "Install safe-chain by using \033[36mnpm install -g @aikidosec/safe-chain\033[0m.\n"
|
||||
}
|
||||
|
||||
function wrapSafeChainCommand() {
|
||||
local original_cmd="$1"
|
||||
local aikido_cmd="$2"
|
||||
|
||||
# Remove the first 2 arguments (original_cmd and aikido_cmd) from $@
|
||||
# so that "$@" now contains only the arguments passed to the original command
|
||||
shift 2
|
||||
|
||||
if command -v "$aikido_cmd" > /dev/null 2>&1; then
|
||||
# If the aikido command is available, just run it with the provided arguments
|
||||
"$aikido_cmd" "$@"
|
||||
else
|
||||
# If the aikido command is not available, print a warning and run the original command
|
||||
printSafeChainWarning "$original_cmd"
|
||||
|
||||
command "$original_cmd" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
function npx() {
|
||||
wrapSafeChainCommand "npx" "aikido-npx" "$@"
|
||||
}
|
||||
|
||||
function yarn() {
|
||||
wrapSafeChainCommand "yarn" "aikido-yarn" "$@"
|
||||
}
|
||||
|
||||
function pnpm() {
|
||||
wrapSafeChainCommand "pnpm" "aikido-pnpm" "$@"
|
||||
}
|
||||
|
||||
function pnpx() {
|
||||
wrapSafeChainCommand "pnpx" "aikido-pnpx" "$@"
|
||||
}
|
||||
|
||||
function npm() {
|
||||
if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then
|
||||
# If args is just -v or --version and nothing else, just run the npm version command
|
||||
# This is because nvm uses this to check the version of npm
|
||||
command npm "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
wrapSafeChainCommand "npm" "aikido-npm" "$@"
|
||||
}
|
||||
80
src/shell-integration/startup-scripts/init-pwsh.ps1
Normal file
80
src/shell-integration/startup-scripts/init-pwsh.ps1
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
function Write-SafeChainWarning {
|
||||
param([string]$Command)
|
||||
|
||||
# PowerShell equivalent of ANSI color codes: yellow background, black text for "Warning:"
|
||||
Write-Host "Warning:" -BackgroundColor Yellow -ForegroundColor Black -NoNewline
|
||||
Write-Host " safe-chain is not available to protect you from installing malware. $Command will run without it."
|
||||
|
||||
# Cyan text for the install command
|
||||
Write-Host "Install safe-chain by using " -NoNewline
|
||||
Write-Host "npm install -g @aikidosec/safe-chain" -ForegroundColor Cyan -NoNewline
|
||||
Write-Host "."
|
||||
}
|
||||
|
||||
function Test-CommandAvailable {
|
||||
param([string]$Command)
|
||||
|
||||
try {
|
||||
Get-Command $Command -ErrorAction Stop | Out-Null
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-RealCommand {
|
||||
param(
|
||||
[string]$Command,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
# Find the real executable to avoid calling our wrapped functions
|
||||
$realCommand = Get-Command -Name $Command -CommandType Application | Select-Object -First 1
|
||||
if ($realCommand) {
|
||||
& $realCommand.Source @Arguments
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WrappedCommand {
|
||||
param(
|
||||
[string]$OriginalCmd,
|
||||
[string]$AikidoCmd,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
if (Test-CommandAvailable $AikidoCmd) {
|
||||
& $AikidoCmd @Arguments
|
||||
}
|
||||
else {
|
||||
Write-SafeChainWarning $OriginalCmd
|
||||
Invoke-RealCommand $OriginalCmd $Arguments
|
||||
}
|
||||
}
|
||||
|
||||
function npx {
|
||||
Invoke-WrappedCommand "npx" "aikido-npx" $args
|
||||
}
|
||||
|
||||
function yarn {
|
||||
Invoke-WrappedCommand "yarn" "aikido-yarn" $args
|
||||
}
|
||||
|
||||
function pnpm {
|
||||
Invoke-WrappedCommand "pnpm" "aikido-pnpm" $args
|
||||
}
|
||||
|
||||
function pnpx {
|
||||
Invoke-WrappedCommand "pnpx" "aikido-pnpx" $args
|
||||
}
|
||||
|
||||
function npm {
|
||||
# If args is just -v or --version and nothing else, just run the npm version command
|
||||
# This is because nvm uses this to check the version of npm
|
||||
if (($args.Length -eq 1) -and (($args[0] -eq "-v") -or ($args[0] -eq "--version"))) {
|
||||
Invoke-RealCommand "npm" $args
|
||||
return
|
||||
}
|
||||
|
||||
Invoke-WrappedCommand "npm" "aikido-npm" $args
|
||||
}
|
||||
62
src/shell-integration/supported-shells/bash.js
Normal file
62
src/shell-integration/supported-shells/bash.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
addLineToFile,
|
||||
doesExecutableExistOnSystem,
|
||||
removeLinesMatchingPattern,
|
||||
} from "../helpers.js";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const shellName = "Bash";
|
||||
const executableName = "bash";
|
||||
const startupFileCommand = "echo ~/.bashrc";
|
||||
|
||||
function isInstalled() {
|
||||
return doesExecutableExistOnSystem(executableName);
|
||||
}
|
||||
|
||||
function teardown(tools) {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
for (const { tool } of tools) {
|
||||
// Remove any existing alias for the tool
|
||||
removeLinesMatchingPattern(startupFile, new RegExp(`^alias\\s+${tool}=`));
|
||||
}
|
||||
|
||||
// Removes the line that sources the safe-chain bash initialization script (~/.aikido/scripts/init-posix.sh)
|
||||
removeLinesMatchingPattern(
|
||||
startupFile,
|
||||
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
addLineToFile(
|
||||
startupFile,
|
||||
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getStartupFile() {
|
||||
try {
|
||||
return execSync(startupFileCommand, {
|
||||
encoding: "utf8",
|
||||
shell: executableName,
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Command failed: ${startupFileCommand}. Error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: shellName,
|
||||
isInstalled,
|
||||
setup,
|
||||
teardown,
|
||||
};
|
||||
199
src/shell-integration/supported-shells/bash.spec.js
Normal file
199
src/shell-integration/supported-shells/bash.spec.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { tmpdir } from "node:os";
|
||||
import fs from "node:fs";
|
||||
import path from "path";
|
||||
import { knownAikidoTools } from "../helpers.js";
|
||||
|
||||
describe("Bash shell integration", () => {
|
||||
let mockStartupFile;
|
||||
let bash;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary startup file for testing
|
||||
mockStartupFile = path.join(tmpdir(), `test-bashrc-${Date.now()}`);
|
||||
|
||||
// Mock the helpers module
|
||||
mock.module("../helpers.js", {
|
||||
namedExports: {
|
||||
doesExecutableExistOnSystem: () => true,
|
||||
addLineToFile: (filePath, line) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, "", "utf-8");
|
||||
}
|
||||
fs.appendFileSync(filePath, line + "\n", "utf-8");
|
||||
},
|
||||
removeLinesMatchingPattern: (filePath, pattern) => {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const filteredLines = lines.filter((line) => !pattern.test(line));
|
||||
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock child_process execSync
|
||||
mock.module("child_process", {
|
||||
namedExports: {
|
||||
execSync: () => mockStartupFile,
|
||||
},
|
||||
});
|
||||
|
||||
// Import bash module after mocking
|
||||
bash = (await import("./bash.js")).default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test files
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("isInstalled", () => {
|
||||
it("should return true when bash is installed", () => {
|
||||
assert.strictEqual(bash.isInstalled(), true);
|
||||
});
|
||||
|
||||
it("should call doesExecutableExistOnSystem with correct parameter", () => {
|
||||
// Test that the method calls the helper with the right executable name
|
||||
assert.strictEqual(bash.isInstalled(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
it("should add source line for bash initialization script", () => {
|
||||
const result = bash.setup();
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
content.includes(
|
||||
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teardown", () => {
|
||||
it("should remove npm, npx, and yarn aliases", () => {
|
||||
const initialContent = [
|
||||
"#!/bin/bash",
|
||||
"alias npm='aikido-npm'",
|
||||
"alias npx='aikido-npx'",
|
||||
"alias yarn='aikido-yarn'",
|
||||
"alias ls='ls --color=auto'",
|
||||
"alias grep='grep --color=auto'",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = bash.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(!content.includes("alias npm="));
|
||||
assert.ok(!content.includes("alias npx="));
|
||||
assert.ok(!content.includes("alias yarn="));
|
||||
assert.ok(content.includes("alias ls="));
|
||||
assert.ok(content.includes("alias grep="));
|
||||
});
|
||||
|
||||
it("should handle file that doesn't exist", () => {
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
const result = bash.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it("should handle file with no relevant aliases", () => {
|
||||
const initialContent = [
|
||||
"#!/bin/bash",
|
||||
"alias ls='ls --color=auto'",
|
||||
"export PATH=$PATH:~/bin",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = bash.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(content.includes("alias ls="));
|
||||
assert.ok(content.includes("export PATH="));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell properties", () => {
|
||||
it("should have correct name", () => {
|
||||
assert.strictEqual(bash.name, "Bash");
|
||||
});
|
||||
|
||||
it("should expose all required methods", () => {
|
||||
assert.ok(typeof bash.isInstalled === "function");
|
||||
assert.ok(typeof bash.setup === "function");
|
||||
assert.ok(typeof bash.teardown === "function");
|
||||
assert.ok(typeof bash.name === "string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration tests", () => {
|
||||
it("should handle complete setup and teardown cycle", () => {
|
||||
const tools = [
|
||||
{ tool: "npm", aikidoCommand: "aikido-npm" },
|
||||
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
||||
];
|
||||
|
||||
// Setup
|
||||
bash.setup(tools);
|
||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
|
||||
|
||||
// Teardown
|
||||
bash.teardown(tools);
|
||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple setup calls", () => {
|
||||
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
|
||||
|
||||
bash.setup(tools);
|
||||
bash.teardown(tools);
|
||||
bash.setup(tools);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
const sourceMatches = (content.match(/source.*init-posix\.sh/g) || [])
|
||||
.length;
|
||||
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
|
||||
});
|
||||
|
||||
it("should handle mixed content with aliases and source lines", () => {
|
||||
const initialContent = [
|
||||
"#!/bin/bash",
|
||||
"alias npm='old-npm'",
|
||||
"source ~/.safe-chain/scripts/init-posix.sh",
|
||||
"alias ls='ls --color=auto'",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
// Teardown should remove both aliases and source line
|
||||
bash.teardown(knownAikidoTools);
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(!content.includes("alias npm="));
|
||||
assert.ok(
|
||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
||||
);
|
||||
assert.ok(content.includes("alias ls="));
|
||||
});
|
||||
});
|
||||
});
|
||||
65
src/shell-integration/supported-shells/fish.js
Normal file
65
src/shell-integration/supported-shells/fish.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
addLineToFile,
|
||||
doesExecutableExistOnSystem,
|
||||
removeLinesMatchingPattern,
|
||||
} from "../helpers.js";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const shellName = "Fish";
|
||||
const executableName = "fish";
|
||||
const startupFileCommand = "echo ~/.config/fish/config.fish";
|
||||
|
||||
function isInstalled() {
|
||||
return doesExecutableExistOnSystem(executableName);
|
||||
}
|
||||
|
||||
function teardown(tools) {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
for (const { tool } of tools) {
|
||||
// Remove any existing alias for the tool
|
||||
removeLinesMatchingPattern(
|
||||
startupFile,
|
||||
new RegExp(`^alias\\s+${tool}\\s+`)
|
||||
);
|
||||
}
|
||||
|
||||
// Removes the line that sources the safe-chain fish initialization script (~/.safe-chain/scripts/init-fish.fish)
|
||||
removeLinesMatchingPattern(
|
||||
startupFile,
|
||||
/^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
addLineToFile(
|
||||
startupFile,
|
||||
`source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getStartupFile() {
|
||||
try {
|
||||
return execSync(startupFileCommand, {
|
||||
encoding: "utf8",
|
||||
shell: executableName,
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Command failed: ${startupFileCommand}. Error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: shellName,
|
||||
isInstalled,
|
||||
setup,
|
||||
teardown,
|
||||
};
|
||||
183
src/shell-integration/supported-shells/fish.spec.js
Normal file
183
src/shell-integration/supported-shells/fish.spec.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { tmpdir } from "node:os";
|
||||
import fs from "node:fs";
|
||||
import path from "path";
|
||||
import { knownAikidoTools } from "../helpers.js";
|
||||
|
||||
describe("Fish shell integration", () => {
|
||||
let mockStartupFile;
|
||||
let fish;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary startup file for testing
|
||||
mockStartupFile = path.join(tmpdir(), `test-fish-config-${Date.now()}`);
|
||||
|
||||
// Mock the helpers module
|
||||
mock.module("../helpers.js", {
|
||||
namedExports: {
|
||||
doesExecutableExistOnSystem: () => true,
|
||||
addLineToFile: (filePath, line) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, "", "utf-8");
|
||||
}
|
||||
fs.appendFileSync(filePath, line + "\n", "utf-8");
|
||||
},
|
||||
removeLinesMatchingPattern: (filePath, pattern) => {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const filteredLines = lines.filter((line) => !pattern.test(line));
|
||||
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock child_process execSync
|
||||
mock.module("child_process", {
|
||||
namedExports: {
|
||||
execSync: () => mockStartupFile,
|
||||
},
|
||||
});
|
||||
|
||||
// Import fish module after mocking
|
||||
fish = (await import("./fish.js")).default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test files
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("isInstalled", () => {
|
||||
it("should return true when fish is installed", () => {
|
||||
assert.strictEqual(fish.isInstalled(), true);
|
||||
});
|
||||
|
||||
it("should call doesExecutableExistOnSystem with correct parameter", () => {
|
||||
// Test that the method calls the helper with the right executable name
|
||||
assert.strictEqual(fish.isInstalled(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
it("should add source line for safe-chain fish initialization script", () => {
|
||||
const result = fish.setup();
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
content.includes('source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script')
|
||||
);
|
||||
});
|
||||
|
||||
it("should not duplicate source lines on multiple calls", () => {
|
||||
fish.setup();
|
||||
fish.setup();
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
|
||||
assert.strictEqual(sourceMatches, 2, "Should allow multiple source lines (helper doesn't dedupe)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("teardown", () => {
|
||||
it("should remove npm, npx, yarn aliases and source line", () => {
|
||||
const initialContent = [
|
||||
"#!/usr/bin/env fish",
|
||||
"alias npm 'aikido-npm'",
|
||||
"alias npx 'aikido-npx'",
|
||||
"alias yarn 'aikido-yarn'",
|
||||
"source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script",
|
||||
"alias ls 'ls --color=auto'",
|
||||
"alias grep 'grep --color=auto'",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = fish.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(!content.includes("alias npm "));
|
||||
assert.ok(!content.includes("alias npx "));
|
||||
assert.ok(!content.includes("alias yarn "));
|
||||
assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish"));
|
||||
assert.ok(content.includes("alias ls "));
|
||||
assert.ok(content.includes("alias grep "));
|
||||
});
|
||||
|
||||
it("should handle file that doesn't exist", () => {
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
const result = fish.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it("should handle file with no relevant aliases or source lines", () => {
|
||||
const initialContent = [
|
||||
"#!/usr/bin/env fish",
|
||||
"alias ls 'ls --color=auto'",
|
||||
"set PATH $PATH ~/bin",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = fish.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(content.includes("alias ls "));
|
||||
assert.ok(content.includes("set PATH "));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell properties", () => {
|
||||
it("should have correct name", () => {
|
||||
assert.strictEqual(fish.name, "Fish");
|
||||
});
|
||||
|
||||
it("should expose all required methods", () => {
|
||||
assert.ok(typeof fish.isInstalled === "function");
|
||||
assert.ok(typeof fish.setup === "function");
|
||||
assert.ok(typeof fish.teardown === "function");
|
||||
assert.ok(typeof fish.name === "string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration tests", () => {
|
||||
it("should handle complete setup and teardown cycle", () => {
|
||||
const tools = [
|
||||
{ tool: "npm", aikidoCommand: "aikido-npm" },
|
||||
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
||||
];
|
||||
|
||||
// Setup
|
||||
fish.setup();
|
||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(content.includes('source ~/.safe-chain/scripts/init-fish.fish'));
|
||||
|
||||
// Teardown
|
||||
fish.teardown(tools);
|
||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish"));
|
||||
});
|
||||
|
||||
it("should handle multiple setup calls", () => {
|
||||
fish.setup();
|
||||
fish.teardown(knownAikidoTools);
|
||||
fish.setup();
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
|
||||
assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle");
|
||||
});
|
||||
});
|
||||
});
|
||||
65
src/shell-integration/supported-shells/powershell.js
Normal file
65
src/shell-integration/supported-shells/powershell.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
addLineToFile,
|
||||
doesExecutableExistOnSystem,
|
||||
removeLinesMatchingPattern,
|
||||
} from "../helpers.js";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const shellName = "PowerShell Core";
|
||||
const executableName = "pwsh";
|
||||
const startupFileCommand = "echo $PROFILE";
|
||||
|
||||
function isInstalled() {
|
||||
return doesExecutableExistOnSystem(executableName);
|
||||
}
|
||||
|
||||
function teardown(tools) {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
for (const { tool } of tools) {
|
||||
// Remove any existing alias for the tool
|
||||
removeLinesMatchingPattern(
|
||||
startupFile,
|
||||
new RegExp(`^Set-Alias\\s+${tool}\\s+`)
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the line that sources the safe-chain PowerShell initialization script
|
||||
removeLinesMatchingPattern(
|
||||
startupFile,
|
||||
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
addLineToFile(
|
||||
startupFile,
|
||||
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getStartupFile() {
|
||||
try {
|
||||
return execSync(startupFileCommand, {
|
||||
encoding: "utf8",
|
||||
shell: executableName,
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Command failed: ${startupFileCommand}. Error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: shellName,
|
||||
isInstalled,
|
||||
setup,
|
||||
teardown,
|
||||
};
|
||||
200
src/shell-integration/supported-shells/powershell.spec.js
Normal file
200
src/shell-integration/supported-shells/powershell.spec.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { tmpdir } from "node:os";
|
||||
import fs from "node:fs";
|
||||
import path from "path";
|
||||
import { knownAikidoTools } from "../helpers.js";
|
||||
|
||||
describe("PowerShell Core shell integration", () => {
|
||||
let mockStartupFile;
|
||||
let powershell;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary startup file for testing
|
||||
mockStartupFile = path.join(
|
||||
tmpdir(),
|
||||
`test-powershell-profile-${Date.now()}.ps1`
|
||||
);
|
||||
|
||||
// Mock the helpers module
|
||||
mock.module("../helpers.js", {
|
||||
namedExports: {
|
||||
doesExecutableExistOnSystem: () => true,
|
||||
addLineToFile: (filePath, line) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, "", "utf-8");
|
||||
}
|
||||
fs.appendFileSync(filePath, line + "\n", "utf-8");
|
||||
},
|
||||
removeLinesMatchingPattern: (filePath, pattern) => {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const filteredLines = lines.filter((line) => !pattern.test(line));
|
||||
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock child_process execSync
|
||||
mock.module("child_process", {
|
||||
namedExports: {
|
||||
execSync: () => mockStartupFile,
|
||||
},
|
||||
});
|
||||
|
||||
// Import powershell module after mocking
|
||||
powershell = (await import("./powershell.js")).default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test files
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("isInstalled", () => {
|
||||
it("should return true when powershell is installed", () => {
|
||||
assert.strictEqual(powershell.isInstalled(), true);
|
||||
});
|
||||
|
||||
it("should call doesExecutableExistOnSystem with correct parameter", () => {
|
||||
// Test that the method calls the helper with the right executable name
|
||||
assert.strictEqual(powershell.isInstalled(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
it("should add init-pwsh.ps1 source line", () => {
|
||||
const result = powershell.setup();
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
content.includes(
|
||||
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teardown", () => {
|
||||
it("should remove init-pwsh.ps1 source line", () => {
|
||||
const initialContent = [
|
||||
"# PowerShell profile",
|
||||
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
||||
"Set-Alias ls Get-ChildItem",
|
||||
"Set-Alias grep Select-String",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = powershell.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
|
||||
);
|
||||
assert.ok(content.includes("Set-Alias ls "));
|
||||
assert.ok(content.includes("Set-Alias grep "));
|
||||
});
|
||||
|
||||
it("should remove old-style aliases from earlier versions", () => {
|
||||
const initialContent = [
|
||||
"# PowerShell profile",
|
||||
"Set-Alias npm aikido-npm # Safe-chain alias for npm",
|
||||
"Set-Alias npx aikido-npx # Safe-chain alias for npx",
|
||||
"Set-Alias yarn aikido-yarn # Safe-chain alias for yarn",
|
||||
"Set-Alias ls Get-ChildItem",
|
||||
"Set-Alias grep Select-String",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = powershell.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(!content.includes("Set-Alias npm "));
|
||||
assert.ok(!content.includes("Set-Alias npx "));
|
||||
assert.ok(!content.includes("Set-Alias yarn "));
|
||||
assert.ok(content.includes("Set-Alias ls "));
|
||||
assert.ok(content.includes("Set-Alias grep "));
|
||||
});
|
||||
|
||||
it("should handle file that doesn't exist", () => {
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
const result = powershell.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it("should handle file with no relevant content", () => {
|
||||
const initialContent = [
|
||||
"# PowerShell profile",
|
||||
"Set-Alias ls Get-ChildItem",
|
||||
"$env:PATH += ';C:\\Tools'",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = powershell.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(content.includes("Set-Alias ls "));
|
||||
assert.ok(content.includes("$env:PATH "));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell properties", () => {
|
||||
it("should have correct name", () => {
|
||||
assert.strictEqual(powershell.name, "PowerShell Core");
|
||||
});
|
||||
|
||||
it("should expose all required methods", () => {
|
||||
assert.ok(typeof powershell.isInstalled === "function");
|
||||
assert.ok(typeof powershell.setup === "function");
|
||||
assert.ok(typeof powershell.teardown === "function");
|
||||
assert.ok(typeof powershell.name === "string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration tests", () => {
|
||||
it("should handle complete setup and teardown cycle", () => {
|
||||
// Setup
|
||||
powershell.setup();
|
||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
|
||||
);
|
||||
|
||||
// Teardown
|
||||
powershell.teardown(knownAikidoTools);
|
||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple setup calls", () => {
|
||||
powershell.setup();
|
||||
powershell.teardown(knownAikidoTools);
|
||||
powershell.setup();
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
const sourceMatches = (
|
||||
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
|
||||
[]
|
||||
).length;
|
||||
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
|
||||
});
|
||||
});
|
||||
});
|
||||
65
src/shell-integration/supported-shells/windowsPowershell.js
Normal file
65
src/shell-integration/supported-shells/windowsPowershell.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
addLineToFile,
|
||||
doesExecutableExistOnSystem,
|
||||
removeLinesMatchingPattern,
|
||||
} from "../helpers.js";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const shellName = "Windows PowerShell";
|
||||
const executableName = "powershell";
|
||||
const startupFileCommand = "echo $PROFILE";
|
||||
|
||||
function isInstalled() {
|
||||
return doesExecutableExistOnSystem(executableName);
|
||||
}
|
||||
|
||||
function teardown(tools) {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
for (const { tool } of tools) {
|
||||
// Remove any existing alias for the tool
|
||||
removeLinesMatchingPattern(
|
||||
startupFile,
|
||||
new RegExp(`^Set-Alias\\s+${tool}\\s+`)
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the line that sources the safe-chain PowerShell initialization script
|
||||
removeLinesMatchingPattern(
|
||||
startupFile,
|
||||
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
addLineToFile(
|
||||
startupFile,
|
||||
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getStartupFile() {
|
||||
try {
|
||||
return execSync(startupFileCommand, {
|
||||
encoding: "utf8",
|
||||
shell: executableName,
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Command failed: ${startupFileCommand}. Error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: shellName,
|
||||
isInstalled,
|
||||
setup,
|
||||
teardown,
|
||||
};
|
||||
200
src/shell-integration/supported-shells/windowsPowershell.spec.js
Normal file
200
src/shell-integration/supported-shells/windowsPowershell.spec.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { tmpdir } from "node:os";
|
||||
import fs from "node:fs";
|
||||
import path from "path";
|
||||
import { knownAikidoTools } from "../helpers.js";
|
||||
|
||||
describe("Windows PowerShell shell integration", () => {
|
||||
let mockStartupFile;
|
||||
let windowsPowershell;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary startup file for testing
|
||||
mockStartupFile = path.join(
|
||||
tmpdir(),
|
||||
`test-windows-powershell-profile-${Date.now()}.ps1`
|
||||
);
|
||||
|
||||
// Mock the helpers module
|
||||
mock.module("../helpers.js", {
|
||||
namedExports: {
|
||||
doesExecutableExistOnSystem: () => true,
|
||||
addLineToFile: (filePath, line) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, "", "utf-8");
|
||||
}
|
||||
fs.appendFileSync(filePath, line + "\n", "utf-8");
|
||||
},
|
||||
removeLinesMatchingPattern: (filePath, pattern) => {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const filteredLines = lines.filter((line) => !pattern.test(line));
|
||||
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock child_process execSync
|
||||
mock.module("child_process", {
|
||||
namedExports: {
|
||||
execSync: () => mockStartupFile,
|
||||
},
|
||||
});
|
||||
|
||||
// Import windowsPowershell module after mocking
|
||||
windowsPowershell = (await import("./windowsPowershell.js")).default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test files
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("isInstalled", () => {
|
||||
it("should return true when windows powershell is installed", () => {
|
||||
assert.strictEqual(windowsPowershell.isInstalled(), true);
|
||||
});
|
||||
|
||||
it("should call doesExecutableExistOnSystem with correct parameter", () => {
|
||||
// Test that the method calls the helper with the right executable name
|
||||
assert.strictEqual(windowsPowershell.isInstalled(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
it("should add init-pwsh.ps1 source line", () => {
|
||||
const result = windowsPowershell.setup();
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
content.includes(
|
||||
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teardown", () => {
|
||||
it("should remove init-pwsh.ps1 source line", () => {
|
||||
const initialContent = [
|
||||
"# Windows PowerShell profile",
|
||||
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
||||
"Set-Alias ls Get-ChildItem",
|
||||
"Set-Alias grep Select-String",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = windowsPowershell.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
|
||||
);
|
||||
assert.ok(content.includes("Set-Alias ls "));
|
||||
assert.ok(content.includes("Set-Alias grep "));
|
||||
});
|
||||
|
||||
it("should remove old-style aliases from earlier versions", () => {
|
||||
const initialContent = [
|
||||
"# Windows PowerShell profile",
|
||||
"Set-Alias npm aikido-npm # Safe-chain alias for npm",
|
||||
"Set-Alias npx aikido-npx # Safe-chain alias for npx",
|
||||
"Set-Alias yarn aikido-yarn # Safe-chain alias for yarn",
|
||||
"Set-Alias ls Get-ChildItem",
|
||||
"Set-Alias grep Select-String",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = windowsPowershell.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(!content.includes("Set-Alias npm "));
|
||||
assert.ok(!content.includes("Set-Alias npx "));
|
||||
assert.ok(!content.includes("Set-Alias yarn "));
|
||||
assert.ok(content.includes("Set-Alias ls "));
|
||||
assert.ok(content.includes("Set-Alias grep "));
|
||||
});
|
||||
|
||||
it("should handle file that doesn't exist", () => {
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
const result = windowsPowershell.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it("should handle file with no relevant content", () => {
|
||||
const initialContent = [
|
||||
"# Windows PowerShell profile",
|
||||
"Set-Alias ls Get-ChildItem",
|
||||
"$env:PATH += ';C:\\Tools'",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = windowsPowershell.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(content.includes("Set-Alias ls "));
|
||||
assert.ok(content.includes("$env:PATH "));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell properties", () => {
|
||||
it("should have correct name", () => {
|
||||
assert.strictEqual(windowsPowershell.name, "Windows PowerShell");
|
||||
});
|
||||
|
||||
it("should expose all required methods", () => {
|
||||
assert.ok(typeof windowsPowershell.isInstalled === "function");
|
||||
assert.ok(typeof windowsPowershell.setup === "function");
|
||||
assert.ok(typeof windowsPowershell.teardown === "function");
|
||||
assert.ok(typeof windowsPowershell.name === "string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration tests", () => {
|
||||
it("should handle complete setup and teardown cycle", () => {
|
||||
// Setup
|
||||
windowsPowershell.setup();
|
||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
|
||||
);
|
||||
|
||||
// Teardown
|
||||
windowsPowershell.teardown(knownAikidoTools);
|
||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"')
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple setup calls", () => {
|
||||
windowsPowershell.setup();
|
||||
windowsPowershell.teardown(knownAikidoTools);
|
||||
windowsPowershell.setup();
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
const sourceMatches = (
|
||||
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
|
||||
[]
|
||||
).length;
|
||||
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
|
||||
});
|
||||
});
|
||||
});
|
||||
62
src/shell-integration/supported-shells/zsh.js
Normal file
62
src/shell-integration/supported-shells/zsh.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
addLineToFile,
|
||||
doesExecutableExistOnSystem,
|
||||
removeLinesMatchingPattern,
|
||||
} from "../helpers.js";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const shellName = "Zsh";
|
||||
const executableName = "zsh";
|
||||
const startupFileCommand = "echo ${ZDOTDIR:-$HOME}/.zshrc";
|
||||
|
||||
function isInstalled() {
|
||||
return doesExecutableExistOnSystem(executableName);
|
||||
}
|
||||
|
||||
function teardown(tools) {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
for (const { tool } of tools) {
|
||||
// Remove any existing alias for the tool
|
||||
removeLinesMatchingPattern(startupFile, new RegExp(`^alias\\s+${tool}=`));
|
||||
}
|
||||
|
||||
// Removes the line that sources the safe-chain zsh initialization script (~/.aikido/scripts/init-posix.sh)
|
||||
removeLinesMatchingPattern(
|
||||
startupFile,
|
||||
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const startupFile = getStartupFile();
|
||||
|
||||
addLineToFile(
|
||||
startupFile,
|
||||
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getStartupFile() {
|
||||
try {
|
||||
return execSync(startupFileCommand, {
|
||||
encoding: "utf8",
|
||||
shell: executableName,
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Command failed: ${startupFileCommand}. Error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: shellName,
|
||||
isInstalled,
|
||||
setup,
|
||||
teardown,
|
||||
};
|
||||
226
src/shell-integration/supported-shells/zsh.spec.js
Normal file
226
src/shell-integration/supported-shells/zsh.spec.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { tmpdir } from "node:os";
|
||||
import fs from "node:fs";
|
||||
import path from "path";
|
||||
import { knownAikidoTools } from "../helpers.js";
|
||||
|
||||
describe("Zsh shell integration", () => {
|
||||
let mockStartupFile;
|
||||
let zsh;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary startup file for testing
|
||||
mockStartupFile = path.join(tmpdir(), `test-zshrc-${Date.now()}`);
|
||||
|
||||
// Mock the helpers module
|
||||
mock.module("../helpers.js", {
|
||||
namedExports: {
|
||||
doesExecutableExistOnSystem: () => true,
|
||||
addLineToFile: (filePath, line) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, "", "utf-8");
|
||||
}
|
||||
fs.appendFileSync(filePath, line + "\n", "utf-8");
|
||||
},
|
||||
removeLinesMatchingPattern: (filePath, pattern) => {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const filteredLines = lines.filter((line) => !pattern.test(line));
|
||||
fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock child_process execSync
|
||||
mock.module("child_process", {
|
||||
namedExports: {
|
||||
execSync: () => mockStartupFile,
|
||||
},
|
||||
});
|
||||
|
||||
// Import zsh module after mocking
|
||||
zsh = (await import("./zsh.js")).default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test files
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
describe("isInstalled", () => {
|
||||
it("should return true when zsh is installed", () => {
|
||||
assert.strictEqual(zsh.isInstalled(), true);
|
||||
});
|
||||
|
||||
it("should call doesExecutableExistOnSystem with correct parameter", () => {
|
||||
// Test that the method calls the helper with the right executable name
|
||||
assert.strictEqual(zsh.isInstalled(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
it("should add source line for zsh initialization script", () => {
|
||||
const result = zsh.setup();
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
content.includes(
|
||||
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty startup file", () => {
|
||||
const result = zsh.setup();
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("teardown", () => {
|
||||
it("should remove npm, npx, and yarn aliases", () => {
|
||||
const initialContent = [
|
||||
"#!/bin/zsh",
|
||||
"alias npm='aikido-npm'",
|
||||
"alias npx='aikido-npx'",
|
||||
"alias yarn='aikido-yarn'",
|
||||
"alias ls='ls --color=auto'",
|
||||
"alias grep='grep --color=auto'",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = zsh.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(!content.includes("alias npm="));
|
||||
assert.ok(!content.includes("alias npx="));
|
||||
assert.ok(!content.includes("alias yarn="));
|
||||
assert.ok(content.includes("alias ls="));
|
||||
assert.ok(content.includes("alias grep="));
|
||||
});
|
||||
|
||||
it("should remove zsh initialization script source line", () => {
|
||||
const initialContent = [
|
||||
"#!/bin/zsh",
|
||||
"source ~/.safe-chain/scripts/init-posix.sh",
|
||||
"alias ls='ls --color=auto'",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = zsh.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
||||
);
|
||||
assert.ok(content.includes("alias ls="));
|
||||
});
|
||||
|
||||
it("should handle file that doesn't exist", () => {
|
||||
if (fs.existsSync(mockStartupFile)) {
|
||||
fs.unlinkSync(mockStartupFile);
|
||||
}
|
||||
|
||||
const result = zsh.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it("should handle file with no relevant aliases or source lines", () => {
|
||||
const initialContent = [
|
||||
"#!/bin/zsh",
|
||||
"alias ls='ls --color=auto'",
|
||||
"export PATH=$PATH:~/bin",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
const result = zsh.teardown(knownAikidoTools);
|
||||
assert.strictEqual(result, true);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(content.includes("alias ls="));
|
||||
assert.ok(content.includes("export PATH="));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell properties", () => {
|
||||
it("should have correct name", () => {
|
||||
assert.strictEqual(zsh.name, "Zsh");
|
||||
});
|
||||
|
||||
it("should expose all required methods", () => {
|
||||
assert.ok(typeof zsh.isInstalled === "function");
|
||||
assert.ok(typeof zsh.setup === "function");
|
||||
assert.ok(typeof zsh.teardown === "function");
|
||||
assert.ok(typeof zsh.name === "string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration tests", () => {
|
||||
it("should handle complete setup and teardown cycle", () => {
|
||||
const tools = [
|
||||
{ tool: "npm", aikidoCommand: "aikido-npm" },
|
||||
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
||||
];
|
||||
|
||||
// Setup
|
||||
zsh.setup();
|
||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
|
||||
|
||||
// Teardown
|
||||
zsh.teardown(tools);
|
||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(
|
||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple setup calls", () => {
|
||||
const tools = [{ tool: "npm", aikidoCommand: "aikido-npm" }];
|
||||
|
||||
zsh.setup(tools);
|
||||
zsh.teardown(tools);
|
||||
zsh.setup(tools);
|
||||
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
const sourceMatches = (content.match(/source.*init-posix\.sh/g) || [])
|
||||
.length;
|
||||
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
|
||||
});
|
||||
|
||||
it("should handle mixed content with aliases and source lines", () => {
|
||||
const initialContent = [
|
||||
"#!/bin/zsh",
|
||||
"alias npm='old-npm'",
|
||||
"source ~/.safe-chain/scripts/init-posix.sh",
|
||||
"alias ls='ls --color=auto'",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
|
||||
|
||||
// Teardown should remove both aliases and source line
|
||||
zsh.teardown(knownAikidoTools);
|
||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||
assert.ok(!content.includes("alias npm="));
|
||||
assert.ok(
|
||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
||||
);
|
||||
assert.ok(content.includes("alias ls="));
|
||||
});
|
||||
});
|
||||
});
|
||||
61
src/shell-integration/teardown.js
Normal file
61
src/shell-integration/teardown.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import chalk from "chalk";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { detectShells } from "./shellDetection.js";
|
||||
import { knownAikidoTools } from "./helpers.js";
|
||||
|
||||
export async function teardown() {
|
||||
ui.writeInformation(
|
||||
chalk.bold("Removing shell aliases.") +
|
||||
" This will remove safe-chain aliases for npm, npx, and yarn commands."
|
||||
);
|
||||
ui.emptyLine();
|
||||
|
||||
try {
|
||||
const shells = detectShells();
|
||||
if (shells.length === 0) {
|
||||
ui.writeError("No supported shells detected. Cannot remove aliases.");
|
||||
return;
|
||||
}
|
||||
|
||||
ui.writeInformation(
|
||||
`Detected ${shells.length} supported shell(s): ${shells
|
||||
.map((shell) => chalk.bold(shell.name))
|
||||
.join(", ")}.`
|
||||
);
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const shell of shells) {
|
||||
let success = false;
|
||||
try {
|
||||
success = shell.teardown(knownAikidoTools);
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
ui.writeInformation(
|
||||
`${chalk.bold("- " + shell.name + ":")} ${chalk.green(
|
||||
"Teardown successful"
|
||||
)}`
|
||||
);
|
||||
updatedCount++;
|
||||
} else {
|
||||
ui.writeError(
|
||||
`${chalk.bold("- " + shell.name + ":")} ${chalk.red(
|
||||
"Teardown failed"
|
||||
)}. Please check your ${shell.name} configuration.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
ui.emptyLine();
|
||||
ui.writeInformation(`Please restart your terminal to apply the changes.`);
|
||||
}
|
||||
} catch (error) {
|
||||
ui.writeError(
|
||||
`Failed to remove shell aliases: ${error.message}. Please check your shell configuration.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue