Move safe-chain package to packages/safe-chain

This commit is contained in:
Sander Declerck 2025-09-05 11:19:37 +02:00
parent fc9a9ca129
commit 7673d32912
No known key found for this signature in database
68 changed files with 85 additions and 52 deletions

View file

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

View file

@ -0,0 +1,36 @@
import { createNpmPackageManager } from "./npm/createPackageManager.js";
import { createNpxPackageManager } from "./npx/createPackageManager.js";
import {
createPnpmPackageManager,
createPnpxPackageManager,
} from "./pnpm/createPackageManager.js";
import { createYarnPackageManager } from "./yarn/createPackageManager.js";
const state = {
packageManagerName: null,
};
export function initializePackageManager(packageManagerName, version) {
if (packageManagerName === "npm") {
state.packageManagerName = createNpmPackageManager(version);
} else if (packageManagerName === "npx") {
state.packageManagerName = createNpxPackageManager();
} else if (packageManagerName === "yarn") {
state.packageManagerName = createYarnPackageManager();
} else if (packageManagerName === "pnpm") {
state.packageManagerName = createPnpmPackageManager();
} else if (packageManagerName === "pnpx") {
state.packageManagerName = createPnpxPackageManager();
} else {
throw new Error("Unsupported package manager: " + packageManagerName);
}
return state.packageManagerName;
}
export function getPackageManager() {
if (!state.packageManagerName) {
throw new Error("Package manager not initialized.");
}
return state.packageManagerName;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,111 @@
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;
// The index of the last "@" should be greater than 0
// If the index is 0, it means the package name starts with "@" (eg: "@vercel/otel")
if (lastAtIndex > 0) {
name = arg.slice(0, lastAtIndex);
version = arg.slice(lastAtIndex + 1);
} else {
name = arg;
version = null;
}
return {
name,
version,
};
}
function removeAlias(arg) {
const aliasIndex = arg.indexOf("@npm:");
if (aliasIndex !== -1) {
return arg.slice(aliasIndex + 5);
}
return arg;
}

View file

@ -0,0 +1,184 @@
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 the package in the format @vercel/otel", () => {
const args = ["install", "@vercel/otel"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "@vercel/otel", version: "latest" }]);
});
it("should return an array of changes for multiple packages", () => {
const args = ["install", "express@4.17.1", "lodash@4.17.21"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "4.17.1" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should ignore options and return an array of changes", () => {
const args = [
"install",
"--save-dev",
"express@4.17.1",
"--save-exact",
"lodash@4.17.21",
];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "4.17.1" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should ignore options with parameters and return an array of changes", () => {
const args = [
"install",
"--save-dev",
"express@4.17.1",
"--loglevel",
"error",
"lodash@4.17.21",
];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "4.17.1" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should not ignore the next argument if it is passed directly with the option", () => {
const args = [
"install",
"--save-dev",
"express@4.17.1",
"--loglevel=error",
"lodash@4.17.21",
];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "4.17.1" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should set the default tag for packages", () => {
const args = ["install", "express", "lodash@4.17.21"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "latest" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should set the default tag for packages with a specific tag", () => {
const args = ["install", "express", "lodash@4.17.21", "--tag", "beta"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "express", version: "beta" },
{ name: "lodash", version: "4.17.21" },
]);
});
it("should ignore alias", () => {
const args = ["install", "express@npm:express@4.17.1"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "express", version: "4.17.1" }]);
});
it("should parse version even for aliased packages", () => {
const args = ["install", "express@npm:express@4.17.1"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "express", version: "4.17.1" }]);
});
it("should parse scoped packages", () => {
const args = ["install", "@scope/package@1.0.0"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "@scope/package", version: "1.0.0" }]);
});
it("should parse packages with version ranges", () => {
const args = ["install", "express@^4.17.1"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "express", version: "^4.17.1" }]);
});
it("should parse package folders", () => {
const args = ["install", "./local-package"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]);
});
it("should parse tarballs", () => {
const args = ["install", "file:./local-package.tgz"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "file:./local-package.tgz", version: "latest" },
]);
});
it("should parse tarball URLs", () => {
const args = ["install", "https://example.com/local-package.tgz"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "https://example.com/local-package.tgz", version: "latest" },
]);
});
it("should parse git URLs", () => {
const args = ["install", "git://github.com/npm/cli.git"];
const result = parsePackagesFromInstallArgs(args);
assert.deepEqual(result, [
{ name: "git://github.com/npm/cli.git", version: "latest" },
]);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,108 @@
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;
// The index of the last "@" should be greater than 0
// If the index is 0, it means the package name starts with "@" (eg: "@vercel/otel")
if (lastAtIndex > 0) {
name = arg.slice(0, lastAtIndex);
version = arg.slice(lastAtIndex + 1);
} else {
name = arg;
version = defaultTag; // No tag specified (eg: "http-server"), use the default tag
}
return {
name,
version,
};
}
function removeAlias(arg) {
// removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest
const aliasIndex = arg.indexOf("@npm:");
if (aliasIndex !== -1) {
return arg.slice(aliasIndex + 5);
}
return arg;
}

View file

@ -0,0 +1,155 @@
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 in the format @vercel/otel", () => {
const args = ["@vercel/otel"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "@vercel/otel", version: "latest" }]);
});
it("should return the package with latest tag if absent", () => {
const args = ["http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should ignore double --", () => {
const args = ["--", "http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should only return the first package", () => {
const args = ["http-server", "jest"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should return package with -p option", () => {
const args = ["-p", "http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should return package with --package option", () => {
const args = ["--package", "http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should return package with --package=x option", () => {
const args = ["--package=http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "latest" }]);
});
it("should return package with --package=x@version option", () => {
const args = ["--package=http-server@1.0.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "1.0.0" }]);
});
it("should ignore options with parameters and return an array of changes", () => {
const args = ["--loglevel", "error", "http-server@14.1.1"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "14.1.1" }]);
});
it("should parse version even for aliased packages", () => {
const args = ["server@npm:http-server@14.1.1"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "14.1.1" }]);
});
it("should parse scoped packages", () => {
const args = ["@scope/package@1.0.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "@scope/package", version: "1.0.0" }]);
});
it("should parse packages with version ranges", () => {
const args = ["http-server@^14.1.1"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "http-server", version: "^14.1.1" }]);
});
it("should parse package folders", () => {
const args = ["./local-package"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]);
});
it("should parse tarballs", () => {
const args = ["file:./local-package.tgz"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "file:./local-package.tgz", version: "latest" },
]);
});
it("should parse tarball URLs", () => {
const args = ["https://example.com/local-package.tgz"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "https://example.com/local-package.tgz", version: "latest" },
]);
});
it("should parse git URLs", () => {
const args = ["git://github.com/http-party/http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "git://github.com/http-party/http-server", version: "latest" },
]);
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,146 @@
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 in the format @vercel/otel", () => {
const args = ["@vercel/otel"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "@vercel/otel", version: "latest" }]);
});
it("should return the package with latest tag if the version is absent and package starts with @", () => {
const args = ["@aikidosec/package-name"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "@aikidosec/package-name", version: "latest" },
]);
});
it("should return the package with the specified tag if the package starts with @ and includes the version", () => {
const args = ["@aikidosec/package-name@1.0.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "@aikidosec/package-name", version: "1.0.0" },
]);
});
it("should only return all packages", () => {
const args = ["axios", "jest"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "axios", version: "latest" },
{ name: "jest", version: "latest" },
]);
});
it("should ignore options with parameters and return an array of changes", () => {
const args = ["--C", "/Users/johnsmith/dev/project", "axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
it("should parse version even for aliased packages", () => {
const args = ["server@npm:axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
it("should parse scoped packages", () => {
const args = ["@scope/package@1.0.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "@scope/package", version: "1.0.0" }]);
});
it("should parse packages with version ranges", () => {
const args = ["axios@^1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]);
});
it("should parse package folders", () => {
const args = ["./local-package"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]);
});
it("should parse tarballs", () => {
const args = ["file:./local-package.tgz"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "file:./local-package.tgz", version: "latest" },
]);
});
it("should parse tarball URLs", () => {
const args = ["https://example.com/local-package.tgz"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "https://example.com/local-package.tgz", version: "latest" },
]);
});
it("should parse git URLs", () => {
const args = ["git://github.com/http-party/http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "git://github.com/http-party/http-server", version: "latest" },
]);
});
it("should parse packages with --package={packageName}", () => {
const args = ["--package=axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
});

View file

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

View file

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

View file

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

View file

@ -0,0 +1,104 @@
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;
// The index of the last "@" should be greater than 0
// If the index is 0, it means the package name starts with "@" (eg: "@vercel/otel")
if (lastAtIndex > 0) {
name = arg.slice(0, lastAtIndex);
version = arg.slice(lastAtIndex + 1);
} else {
name = arg;
version = defaultTag; // No tag specified (eg: "http-server"), use the default tag
}
return {
name,
version,
};
}
function removeAlias(arg) {
// removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest
const aliasIndex = arg.indexOf("@npm:");
if (aliasIndex !== -1) {
return arg.slice(aliasIndex + 5);
}
return arg;
}

View file

@ -0,0 +1,134 @@
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 return the package in the format @vercel/otel", () => {
const args = ["add", "@vercel/otel"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "@vercel/otel", version: "latest" }]);
});
it("should ignore options with parameters and return an array of changes", () => {
const args = ["add", "--proxy", "http://localhost", "axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
it("should parse version even for aliased packages", () => {
const args = ["add", "server@npm:axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
it("should parse scoped packages", () => {
const args = ["add", "@scope/package@1.0.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "@scope/package", version: "1.0.0" }]);
});
it("should parse packages with version ranges", () => {
const args = ["add", "axios@^1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]);
});
it("should parse package folders", () => {
const args = ["add", "./local-package"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]);
});
it("should parse tarballs", () => {
const args = ["add", "file:./local-package.tgz"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "file:./local-package.tgz", version: "latest" },
]);
});
it("should parse tarball URLs", () => {
const args = ["add", "https://example.com/local-package.tgz"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "https://example.com/local-package.tgz", version: "latest" },
]);
});
it("should parse git URLs", () => {
const args = ["add", "git://github.com/http-party/http-server"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [
{ name: "git://github.com/http-party/http-server", version: "latest" },
]);
});
it("should parse packages with -p {packageName}", () => {
const args = ["dlx", "-p", "axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
it("should parse packages with --package {packageName}", () => {
const args = ["dlx", "--package", "axios@1.9.0"];
const result = parsePackagesFromArguments(args);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
});

View file

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