Merge branch 'main' into improved-shell-integration

This commit is contained in:
Sander Declerck 2025-07-18 11:02:21 +02:00
commit 8ffb0191f5
No known key found for this signature in database
14 changed files with 381 additions and 12 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

@ -1,5 +1,9 @@
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 = {
@ -13,6 +17,10 @@ export function initializePackageManager(packageManagerName, version) {
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);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,9 @@ export const knownAikidoTools = [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
// When adding a new tool here, also update the expected alias in the tests (shellIntegration.spec.js)
{ 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
];