Merge remote-tracking branch 'origin/main' into feature/pypi

This commit is contained in:
Reinier Criel 2025-11-03 06:49:53 -08:00
commit 548d416996
64 changed files with 1689 additions and 381 deletions

View file

@ -31,6 +31,9 @@ jobs:
- name: Run linting - name: Run linting
run: npm run lint run: npm run lint
- name: Type check
run: npm run typecheck --workspace=packages/safe-chain
- name: Create package tarball - name: Create package tarball
run: npm pack --workspace=packages/safe-chain run: npm pack --workspace=packages/safe-chain

1004
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,7 @@ export const scanner = {
}); });
} }
} }
} catch (error) { } catch (/** @type any */ error) {
console.warn(`Safe-Chain security scan failed: ${error.message}`); console.warn(`Safe-Chain security scan failed: ${error.message}`);
} }

View file

@ -9,4 +9,5 @@ const packageManagerName = "bun";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2)); var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode); process.exit(exitCode);

View file

@ -9,4 +9,5 @@ const packageManagerName = "bunx";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2)); var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode); process.exit(exitCode);

View file

@ -9,4 +9,5 @@ const packageManagerName = "npm";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2)); var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode); process.exit(exitCode);

View file

@ -9,4 +9,5 @@ const packageManagerName = "npx";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2)); var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode); process.exit(exitCode);

View file

@ -9,4 +9,5 @@ const packageManagerName = "pnpm";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2)); var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode); process.exit(exitCode);

View file

@ -9,4 +9,5 @@ const packageManagerName = "pnpx";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2)); var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode); process.exit(exitCode);

View file

@ -9,4 +9,5 @@ const packageManagerName = "yarn";
initializePackageManager(packageManagerName); initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2)); var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode); process.exit(exitCode);

View file

@ -4,7 +4,8 @@
"scripts": { "scripts": {
"test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'", "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
"test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'", "test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
"lint": "oxlint --deny-warnings" "lint": "oxlint --deny-warnings",
"typecheck": "tsc --noEmit"
}, },
"bin": { "bin": {
"aikido-npm": "bin/aikido-npm.js", "aikido-npm": "bin/aikido-npm.js",
@ -41,6 +42,14 @@
"ora": "8.2.0", "ora": "8.2.0",
"semver": "7.7.2" "semver": "7.7.2"
}, },
"devDependencies": {
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^18.19.130",
"@types/npm-registry-fetch": "^8.0.9",
"@types/semver": "^7.7.1",
"@types/node-forge": "^1.3.14",
"typescript": "^5.9.3"
},
"main": "src/main.js", "main": "src/main.js",
"bugs": { "bugs": {
"url": "https://github.com/AikidoSec/safe-chain/issues" "url": "https://github.com/AikidoSec/safe-chain/issues"

View file

@ -6,9 +6,19 @@ const malwareDatabaseUrls = {
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json",
}; };
/**
* @typedef {Object} MalwarePackage
* @property {string} package_name
* @property {string} version
* @property {string} reason
*/
/**
* @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>}
*/
export async function fetchMalwareDatabase() { export async function fetchMalwareDatabase() {
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
const malwareDatabaseUrl = malwareDatabaseUrls[ecosystem]; const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
const response = await fetch(malwareDatabaseUrl); const response = await fetch(malwareDatabaseUrl);
if (!response.ok) { if (!response.ok) {
throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`); throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`);
@ -20,14 +30,17 @@ export async function fetchMalwareDatabase() {
malwareDatabase: malwareDatabase, malwareDatabase: malwareDatabase,
version: response.headers.get("etag") || undefined, version: response.headers.get("etag") || undefined,
}; };
} catch (error) { } catch (/** @type {any} */ error) {
throw new Error(`Error parsing ${ecosystem} malware database: ${error.message}`); throw new Error(`Error parsing malware database: ${error.message}`);
} }
} }
/**
* @returns {Promise<string | undefined>}
*/
export async function fetchMalwareDatabaseVersion() { export async function fetchMalwareDatabaseVersion() {
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
const malwareDatabaseUrl = malwareDatabaseUrls[ecosystem]; const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
const response = await fetch(malwareDatabaseUrl, { const response = await fetch(malwareDatabaseUrl, {
method: "HEAD", method: "HEAD",
}); });

View file

@ -1,6 +1,11 @@
import * as semver from "semver"; import * as semver from "semver";
import * as npmFetch from "npm-registry-fetch"; import * as npmFetch from "npm-registry-fetch";
/**
* @param {string} packageName
* @param {string | null} [versionRange]
* @returns {Promise<string | null>}
*/
export async function resolvePackageVersion(packageName, versionRange) { export async function resolvePackageVersion(packageName, versionRange) {
if (!versionRange) { if (!versionRange) {
versionRange = "latest"; versionRange = "latest";
@ -11,7 +16,10 @@ export async function resolvePackageVersion(packageName, versionRange) {
return versionRange; return versionRange;
} }
const packageInfo = await getPackageInfo(packageName); const packageInfo = (
/** @type {{"dist-tags"?: Record<string, string>, versions?: Record<string, unknown>} | null} */
await getPackageInfo(packageName)
);
if (!packageInfo) { if (!packageInfo) {
// It is possible that no version is found (could be a private package, or a package that doesn't exist) // 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 // In this case, we return null to indicate that we couldn't resolve the version
@ -19,7 +27,7 @@ export async function resolvePackageVersion(packageName, versionRange) {
} }
const distTags = packageInfo["dist-tags"]; const distTags = packageInfo["dist-tags"];
if (distTags && distTags[versionRange]) { if (distTags && isDistTags(distTags) && distTags[versionRange]) {
// If the version range is a dist-tag, return the version associated with that tag // If the version range is a dist-tag, return the version associated with that tag
// e.g., "latest", "next", etc. // e.g., "latest", "next", etc.
return distTags[versionRange]; return distTags[versionRange];
@ -41,6 +49,19 @@ export async function resolvePackageVersion(packageName, versionRange) {
return null; return null;
} }
/**
*
* @param {unknown} distTags
* @returns {distTags is Record<string, string>}
*/
function isDistTags(distTags) {
return typeof distTags === "object";
}
/**
* @param {string} packageName
* @returns {Promise<Record<string, unknown> | null>}
*/
async function getPackageInfo(packageName) { async function getPackageInfo(packageName) {
try { try {
return await npmFetch.json(packageName); return await npmFetch.json(packageName);

View file

@ -1,9 +1,16 @@
/**
* @type {{loggingLevel: string | undefined}}
*/
const state = { const state = {
loggingLevel: undefined, loggingLevel: undefined,
}; };
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
/**
* @param {string[]} args
* @returns {string[]}
*/
export function initializeCliArguments(args) { export function initializeCliArguments(args) {
// Reset state on each call // Reset state on each call
state.loggingLevel = undefined; state.loggingLevel = undefined;
@ -24,6 +31,11 @@ export function initializeCliArguments(args) {
return remainingArgs; return remainingArgs;
} }
/**
* @param {string[]} args
* @param {string} prefix
* @returns {string | undefined}
*/
function getLastArgEqualsValue(args, prefix) { function getLastArgEqualsValue(args, prefix) {
for (var i = args.length - 1; i >= 0; i--) { for (var i = args.length - 1; i >= 0; i--) {
const arg = args[i]; const arg = args[i];
@ -35,6 +47,10 @@ function getLastArgEqualsValue(args, prefix) {
return undefined; return undefined;
} }
/**
* @param {string[]} args
* @returns {void}
*/
function setLoggingLevel(args) { function setLoggingLevel(args) {
const safeChainLoggingArg = SAFE_CHAIN_ARG_PREFIX + "logging="; const safeChainLoggingArg = SAFE_CHAIN_ARG_PREFIX + "logging=";

View file

@ -4,13 +4,56 @@ import os from "os";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { getEcoSystem } from "./settings.js"; import { getEcoSystem } from "./settings.js";
/**
* @typedef {Object} SafeChainConfig
*
* This should be a number, but can be anything because it is user-input.
* We cannot trust the input and should add the necessary validations.
* @property {any} scanTimeout
*/
/**
* @returns {number}
*/
export function getScanTimeout() { export function getScanTimeout() {
const config = readConfigFile(); const config = readConfigFile();
return (
parseInt(process.env.AIKIDO_SCAN_TIMEOUT_MS) || config.scanTimeout || 10000 // Default to 10 seconds if (process.env.AIKIDO_SCAN_TIMEOUT_MS) {
); const scanTimeout = validateTimeout(process.env.AIKIDO_SCAN_TIMEOUT_MS);
if (scanTimeout != null) {
return scanTimeout;
}
}
if (config.scanTimeout) {
const scanTimeout = validateTimeout(config.scanTimeout);
if (scanTimeout != null) {
return scanTimeout;
}
}
return 10000; // Default to 10 seconds
} }
/**
*
* @param {any} value
* @returns {number?}
*/
function validateTimeout(value) {
const timeout = Number(value);
if (!Number.isNaN(timeout) && timeout > 0) {
return timeout;
}
return null;
}
/**
* @param {import("../api/aikido.js").MalwarePackage[]} data
* @param {string | number} version
*
* @returns {void}
*/
export function writeDatabaseToLocalCache(data, version) { export function writeDatabaseToLocalCache(data, version) {
try { try {
const databasePath = getDatabasePath(); const databasePath = getDatabasePath();
@ -25,6 +68,9 @@ export function writeDatabaseToLocalCache(data, version) {
} }
} }
/**
* @returns {{malwareDatabase: import("../api/aikido.js").MalwarePackage[] | null, version: string | null}}
*/
export function readDatabaseFromLocalCache() { export function readDatabaseFromLocalCache() {
try { try {
const databasePath = getDatabasePath(); const databasePath = getDatabasePath();
@ -56,17 +102,31 @@ export function readDatabaseFromLocalCache() {
} }
} }
/**
* @returns {SafeChainConfig}
*/
function readConfigFile() { function readConfigFile() {
const configFilePath = getConfigFilePath(); const configFilePath = getConfigFilePath();
if (!fs.existsSync(configFilePath)) { if (!fs.existsSync(configFilePath)) {
return {}; return {
scanTimeout: undefined,
};
} }
const data = fs.readFileSync(configFilePath, "utf8"); try {
return JSON.parse(data); const data = fs.readFileSync(configFilePath, "utf8");
return JSON.parse(data);
} catch {
return {
scanTimeout: undefined,
};
}
} }
/**
* @returns {string}
*/
function getDatabasePath() { function getDatabasePath() {
const aikidoDir = getAikidoDirectory(); const aikidoDir = getAikidoDirectory();
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
@ -79,10 +139,16 @@ function getDatabaseVersionPath() {
return path.join(aikidoDir, `version_${ecosystem}.txt`); return path.join(aikidoDir, `version_${ecosystem}.txt`);
} }
/**
* @returns {string}
*/
function getConfigFilePath() { function getConfigFilePath() {
return path.join(getAikidoDirectory(), "config.json"); return path.join(getAikidoDirectory(), "config.json");
} }
/**
* @returns {string}
*/
function getAikidoDirectory() { function getAikidoDirectory() {
const homeDir = os.homedir(); const homeDir = os.homedir();
const aikidoDir = path.join(homeDir, ".aikido"); const aikidoDir = path.join(homeDir, ".aikido");

View file

@ -0,0 +1,172 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("getScanTimeout", () => {
let originalEnv;
let fsMock;
let getScanTimeout;
beforeEach(async () => {
// Save original environment
originalEnv = process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock fs module
fsMock = {
existsSync: mock.fn(() => false),
readFileSync: mock.fn(() => "{}"),
writeFileSync: mock.fn(),
mkdirSync: mock.fn(),
};
mock.module("fs", {
namedExports: fsMock,
});
// Re-import the module to get the mocked version
const configFileModule = await import(
`./configFile.js?update=${Date.now()}`
);
getScanTimeout = configFileModule.getScanTimeout;
});
afterEach(() => {
// Restore original environment
if (originalEnv !== undefined) {
process.env.AIKIDO_SCAN_TIMEOUT_MS = originalEnv;
} else {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
}
// Reset all mocks
mock.restoreAll();
});
it("should return default timeout of 10000ms when no config or env var is set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock: config file doesn't exist
fsMock.existsSync.mock.mockImplementation(() => false);
const timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
it("should return timeout from config file when set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock: config file exists with scanTimeout: 5000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 5000 })
);
const timeout = getScanTimeout();
assert.strictEqual(timeout, 5000);
});
it("should prioritize environment variable over config file", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000";
// Mock: config file exists with scanTimeout: 5000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 5000 })
);
const timeout = getScanTimeout();
assert.strictEqual(timeout, 20000);
});
it("should handle invalid environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid";
// Mock: config file exists with scanTimeout: 7000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 7000 })
);
const timeout = getScanTimeout();
assert.strictEqual(timeout, 7000);
});
it("should ignore zero and negative values and fall back to default", () => {
// Mock: config file doesn't exist
fsMock.existsSync.mock.mockImplementation(() => false);
process.env.AIKIDO_SCAN_TIMEOUT_MS = "0";
let timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
process.env.AIKIDO_SCAN_TIMEOUT_MS = "-5000";
timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
it("should ignore textual non-numeric values in environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast";
// Mock: config file exists with scanTimeout: 8000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 8000 })
);
const timeout = getScanTimeout();
assert.strictEqual(timeout, 8000);
});
it("should ignore textual non-numeric values in config file and fall back to default", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock: config file exists with scanTimeout: "slow"
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: "slow" })
);
const timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
it("should ignore textual non-numeric values in both env and config, fall back to default", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick";
// Mock: config file exists with scanTimeout: "medium"
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: "medium" })
);
const timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
it("should ignore mixed alphanumeric strings in environment variable", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms";
// Mock: config file exists with scanTimeout: 6000
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: 6000 })
);
const timeout = getScanTimeout();
assert.strictEqual(timeout, 6000);
});
it("should ignore mixed alphanumeric strings in config file", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
// Mock: config file exists with scanTimeout: "3000ms"
fsMock.existsSync.mock.mockImplementation(() => true);
fsMock.readFileSync.mock.mockImplementation(() =>
JSON.stringify({ scanTimeout: "3000ms" })
);
const timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
});

View file

@ -24,6 +24,9 @@ const ecosystemSettings = {
export function getEcoSystem() { export function getEcoSystem() {
return ecosystemSettings.ecoSystem; return ecosystemSettings.ecoSystem;
} }
/**
* @param {string} setting - The ecosystem to set (ECOSYSTEM_JS or ECOSYSTEM_PY)
*/
export function setEcoSystem(setting) { export function setEcoSystem(setting) {
ecosystemSettings.ecoSystem = setting; ecosystemSettings.ecoSystem = setting;
} }

View file

@ -14,12 +14,22 @@ function emptyLine() {
writeInformation(""); writeInformation("");
} }
/**
* @param {string} message
* @param {...any} optionalParams
* @returns {void}
*/
function writeInformation(message, ...optionalParams) { function writeInformation(message, ...optionalParams) {
if (isSilentMode()) return; if (isSilentMode()) return;
console.log(message, ...optionalParams); console.log(message, ...optionalParams);
} }
/**
* @param {string} message
* @param {...any} optionalParams
* @returns {void}
*/
function writeWarning(message, ...optionalParams) { function writeWarning(message, ...optionalParams) {
if (isSilentMode()) return; if (isSilentMode()) return;
@ -29,6 +39,11 @@ function writeWarning(message, ...optionalParams) {
console.warn(message, ...optionalParams); console.warn(message, ...optionalParams);
} }
/**
* @param {string} message
* @param {...any} optionalParams
* @returns {void}
*/
function writeError(message, ...optionalParams) { function writeError(message, ...optionalParams) {
if (!isCi()) { if (!isCi()) {
message = chalk.red(message); message = chalk.red(message);
@ -44,6 +59,19 @@ function writeExitWithoutInstallingMaliciousPackages() {
console.error(message); console.error(message);
} }
/**
* @typedef {Object} Spinner
* @property {(message: string) => void} succeed
* @property {(message: string) => void} fail
* @property {() => void} stop
* @property {(message: string) => void} setText
*/
/**
* @param {string} message
*
* @returns {Spinner}
*/
function startProcess(message) { function startProcess(message) {
if (isSilentMode()) { if (isSilentMode()) {
return { return {

View file

@ -7,6 +7,10 @@ import { initializeCliArguments } from "./config/cliArguments.js";
import { createSafeChainProxy } from "./registryProxy/registryProxy.js"; import { createSafeChainProxy } from "./registryProxy/registryProxy.js";
import chalk from "chalk"; import chalk from "chalk";
/**
* @param {string[]} args
* @returns {Promise<number | never[]>}
*/
export async function main(args) { export async function main(args) {
const proxy = createSafeChainProxy(); const proxy = createSafeChainProxy();
await proxy.startServer(); await proxy.startServer();
@ -14,6 +18,7 @@ export async function main(args) {
// Global error handlers to log unhandled errors // Global error handlers to log unhandled errors
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`); ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`);
// @ts-expect-error writeVerbose will be added in a future PR
ui.writeVerbose(`Stack trace: ${error.stack}`); ui.writeVerbose(`Stack trace: ${error.stack}`);
process.exit(1); process.exit(1);
}); });
@ -21,6 +26,7 @@ export async function main(args) {
process.on("unhandledRejection", (reason) => { process.on("unhandledRejection", (reason) => {
ui.writeError(`Safe-chain: Unhandled promise rejection: ${reason}`); ui.writeError(`Safe-chain: Unhandled promise rejection: ${reason}`);
if (reason instanceof Error) { if (reason instanceof Error) {
// @ts-expect-error writeVerbose will be added in a future PR
ui.writeVerbose(`Stack trace: ${reason.stack}`); ui.writeVerbose(`Stack trace: ${reason.stack}`);
} }
process.exit(1); process.exit(1);
@ -56,7 +62,7 @@ export async function main(args) {
// Returning the exit code back to the caller allows the promise // Returning the exit code back to the caller allows the promise
// to be awaited in the bin files and return the correct exit code // to be awaited in the bin files and return the correct exit code
return packageManagerResult.status; return packageManagerResult.status;
} catch (error) { } catch (/** @type any */ error) {
ui.writeError("Failed to check for malicious packages:", error.message); ui.writeError("Failed to check for malicious packages:", error.message);
// Returning the exit code back to the caller allows the promise // Returning the exit code back to the caller allows the promise

View file

@ -1,3 +1,8 @@
/**
* @param {string[]} args
* @param {...string} commandArgs
* @returns {boolean}
*/
export function matchesCommand(args, ...commandArgs) { export function matchesCommand(args, ...commandArgs) {
if (args.length < commandArgs.length) { if (args.length < commandArgs.length) {
return false; return false;

View file

@ -2,6 +2,9 @@ import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createBunPackageManager() { export function createBunPackageManager() {
return { return {
runCommand: (args) => runBunCommand("bun", args), runCommand: (args) => runBunCommand("bun", args),
@ -13,6 +16,9 @@ export function createBunPackageManager() {
}; };
} }
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createBunxPackageManager() { export function createBunxPackageManager() {
return { return {
runCommand: (args) => runBunCommand("bunx", args), runCommand: (args) => runBunCommand("bunx", args),
@ -24,14 +30,20 @@ export function createBunxPackageManager() {
}; };
} }
/**
* @param {string} command
* @param {string[]} args
* @returns {Promise<{status: number}>}
*/
async function runBunCommand(command, args) { async function runBunCommand(command, args) {
try { try {
const result = await safeSpawn(command, args, { const result = await safeSpawn(command, args, {
stdio: "inherit", stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env), env: mergeSafeChainProxyEnvironmentVariables(process.env),
}); });
return { status: result.status }; return { status: result.status };
} catch (error) { } catch (/** @type any */ error) {
if (error.status) { if (error.status) {
return { status: error.status }; return { status: error.status };
} else { } else {

View file

@ -11,10 +11,32 @@ import {
import { createYarnPackageManager } from "./yarn/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js";
import { createPipPackageManager } from "./pip/createPackageManager.js"; import { createPipPackageManager } from "./pip/createPackageManager.js";
/**
* @type {{packageManagerName: PackageManager | null}}
*/
const state = { const state = {
packageManagerName: null, packageManagerName: null,
}; };
/**
* @typedef {Object} GetDependencyUpdatesResult
* @property {string} name
* @property {string} version
* @property {string} type
*/
/**
* @typedef {Object} PackageManager
* @property {(args: string[]) => Promise<{ status: number }>} runCommand
* @property {(args: string[]) => boolean} isSupportedCommand
* @property {(args: string[]) => Promise<GetDependencyUpdatesResult[]> | GetDependencyUpdatesResult[]} getDependencyUpdatesForCommand
*/
/**
* @param {string} packageManagerName
*
* @return {PackageManager}
*/
export function initializePackageManager(packageManagerName) { export function initializePackageManager(packageManagerName) {
if (packageManagerName === "npm") { if (packageManagerName === "npm") {
state.packageManagerName = createNpmPackageManager(); state.packageManagerName = createNpmPackageManager();

View file

@ -8,7 +8,15 @@ import {
npmExecCommand, npmExecCommand,
} from "./utils/npmCommands.js"; } from "./utils/npmCommands.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createNpmPackageManager() { export function createNpmPackageManager() {
/**
* @param {string[]} args
*
* @returns {boolean}
*/
function isSupportedCommand(args) { function isSupportedCommand(args) {
const scanner = findDependencyScannerForCommand( const scanner = findDependencyScannerForCommand(
commandScannerMapping, commandScannerMapping,
@ -17,6 +25,11 @@ export function createNpmPackageManager() {
return scanner.shouldScan(args); return scanner.shouldScan(args);
} }
/**
* @param {string[]} args
*
* @returns {ReturnType<import("../currentPackageManager.js").PackageManager["getDependencyUpdatesForCommand"]>}
*/
function getDependencyUpdatesForCommand(args) { function getDependencyUpdatesForCommand(args) {
const scanner = findDependencyScannerForCommand( const scanner = findDependencyScannerForCommand(
commandScannerMapping, commandScannerMapping,
@ -32,12 +45,22 @@ export function createNpmPackageManager() {
}; };
} }
/**
* @type {Record<string, import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner>}
*/
const commandScannerMapping = { const commandScannerMapping = {
[npmInstallCommand]: commandArgumentScanner(), [npmInstallCommand]: commandArgumentScanner(),
[npmUpdateCommand]: commandArgumentScanner(), [npmUpdateCommand]: commandArgumentScanner(),
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run [npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
}; };
/**
*
* @param {Record<string, import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner>} scanners
* @param {string[]} args
*
* @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
*/
function findDependencyScannerForCommand(scanners, args) { function findDependencyScannerForCommand(scanners, args) {
const command = getNpmCommandForArgs(args); const command = getNpmCommandForArgs(args);
if (!command) { if (!command) {

View file

@ -2,6 +2,29 @@ import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js"; import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js";
import { hasDryRunArg } from "../utils/npmCommands.js"; import { hasDryRunArg } from "../utils/npmCommands.js";
/**
* @typedef {Object} ScanResult
* @property {string} name
* @property {string} version
* @property {string} type
*/
/**
* @typedef {Object} ScannerOptions
* @property {boolean} [ignoreDryRun]
*/
/**
* @typedef {Object} CommandArgumentScanner
* @property {(args: string[]) => Promise<ScanResult[]> | ScanResult[]} scan
* @property {(args: string[]) => boolean} shouldScan
*/
/**
* @param {ScannerOptions} [opts]
*
* @returns {CommandArgumentScanner}
*/
export function commandArgumentScanner(opts) { export function commandArgumentScanner(opts) {
const ignoreDryRun = opts?.ignoreDryRun ?? false; const ignoreDryRun = opts?.ignoreDryRun ?? false;
@ -10,14 +33,28 @@ export function commandArgumentScanner(opts) {
shouldScan: (args) => shouldScanDependencies(args, ignoreDryRun), shouldScan: (args) => shouldScanDependencies(args, ignoreDryRun),
}; };
} }
/**
* @param {string[]} args
* @returns {Promise<ScanResult[]>}
*/
function scanDependencies(args) { function scanDependencies(args) {
return checkChangesFromArgs(args); return checkChangesFromArgs(args);
} }
/**
* @param {string[]} args
* @param {boolean} ignoreDryRun
* @returns {boolean}
*/
function shouldScanDependencies(args, ignoreDryRun) { function shouldScanDependencies(args, ignoreDryRun) {
return ignoreDryRun || !hasDryRunArg(args); return ignoreDryRun || !hasDryRunArg(args);
} }
/**
* @param {string[]} args
* @returns {Promise<ScanResult[]>}
*/
export async function checkChangesFromArgs(args) { export async function checkChangesFromArgs(args) {
const changes = []; const changes = [];
const packageUpdates = parsePackagesFromInstallArgs(args); const packageUpdates = parsePackagesFromInstallArgs(args);

View file

@ -1,3 +1,6 @@
/**
* @returns {import("./commandArgumentScanner.js").CommandArgumentScanner}
*/
export function nullScanner() { export function nullScanner() {
return { return {
scan: () => [], scan: () => [],

View file

@ -1,5 +1,22 @@
/**
* @typedef {Object} PackageDetail
* @property {string} name
* @property {string} version
*/
/**
* @typedef {Object} NpmOption
* @property {string} name
* @property {number} numberOfParameters
*/
/**
* @param {string[]} args
* @returns {PackageDetail[]}
*/
export function parsePackagesFromInstallArgs(args) { export function parsePackagesFromInstallArgs(args) {
const changes = []; /** @type {{name: string, version: string | null}[]} */
const changes = [];
let defaultTag = "latest"; let defaultTag = "latest";
// Skip first argument (install command) // Skip first argument (install command)
@ -32,9 +49,13 @@ export function parsePackagesFromInstallArgs(args) {
} }
} }
return changes; return /** @type {PackageDetail[]} */ (changes);
} }
/**
* @param {string} arg
* @returns {NpmOption | undefined}
*/
function getNpmOption(arg) { function getNpmOption(arg) {
if (isNpmOptionWithParameter(arg)) { if (isNpmOptionWithParameter(arg)) {
return { return {
@ -54,6 +75,10 @@ function getNpmOption(arg) {
return undefined; return undefined;
} }
/**
* @param {string} arg
* @returns {boolean}
*/
function isNpmOptionWithParameter(arg) { function isNpmOptionWithParameter(arg) {
const optionsWithParameters = [ const optionsWithParameters = [
"--access", "--access",
@ -81,6 +106,10 @@ function isNpmOptionWithParameter(arg) {
return optionsWithParameters.includes(arg); return optionsWithParameters.includes(arg);
} }
/**
* @param {string} arg
* @returns {{name: string, version: string | null}}
*/
function parsePackagename(arg) { function parsePackagename(arg) {
arg = removeAlias(arg); arg = removeAlias(arg);
const lastAtIndex = arg.lastIndexOf("@"); const lastAtIndex = arg.lastIndexOf("@");
@ -102,6 +131,10 @@ function parsePackagename(arg) {
}; };
} }
/**
* @param {string} arg
* @returns {string}
*/
function removeAlias(arg) { function removeAlias(arg) {
const aliasIndex = arg.indexOf("@npm:"); const aliasIndex = arg.indexOf("@npm:");
if (aliasIndex !== -1) { if (aliasIndex !== -1) {

View file

@ -2,14 +2,20 @@ import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
/**
* @param {string[]} args
*
* @returns {Promise<{status: number}>}
*/
export async function runNpm(args) { export async function runNpm(args) {
try { try {
const result = await safeSpawn("npm", args, { const result = await safeSpawn("npm", args, {
stdio: "inherit", stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env), env: mergeSafeChainProxyEnvironmentVariables(process.env),
}); });
return { status: result.status }; return { status: result.status };
} catch (error) { } catch (/** @type any */ error) {
if (error.status) { if (error.status) {
return { status: error.status }; return { status: error.status };
} else { } else {
@ -19,6 +25,10 @@ export async function runNpm(args) {
} }
} }
/**
* @param {string[]} args
* @returns {Promise<{status: number, output?: string}>}
*/
export async function dryRunNpmCommandAndOutput(args) { export async function dryRunNpmCommandAndOutput(args) {
try { try {
const result = await safeSpawn( const result = await safeSpawn(
@ -26,6 +36,7 @@ export async function dryRunNpmCommandAndOutput(args) {
[...args, "--ignore-scripts", "--dry-run"], [...args, "--ignore-scripts", "--dry-run"],
{ {
stdio: "pipe", stdio: "pipe",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env), env: mergeSafeChainProxyEnvironmentVariables(process.env),
} }
); );
@ -33,7 +44,7 @@ export async function dryRunNpmCommandAndOutput(args) {
status: result.status, status: result.status,
output: result.status === 0 ? result.stdout : result.stderr, output: result.status === 0 ? result.stdout : result.stderr,
}; };
} catch (error) { } catch (/** @type any */ error) {
if (error.status) { if (error.status) {
const output = const output =
error.stdout?.toString() ?? error.stdout?.toString() ??

View file

@ -1,5 +1,6 @@
// This was ran with the abbrev package to generate the abbrevs object below // This was ran with the abbrev package to generate the abbrevs object below
// console.log(abbrev(commands.concat(Object.keys(aliases)))); // console.log(abbrev(commands.concat(Object.keys(aliases))));
/** @type {Record<string, string>} */
export const abbrevs = { export const abbrevs = {
ac: "access", ac: "access",
acc: "access", acc: "access",

View file

@ -73,6 +73,7 @@ const commands = [
]; ];
// These must resolve to an entry in commands // These must resolve to an entry in commands
/** @type {Record<string, string>} */
const aliases = { const aliases = {
// aliases // aliases
author: "owner", author: "owner",
@ -138,6 +139,10 @@ const aliases = {
"add-user": "adduser", "add-user": "adduser",
}; };
/**
* @param {string} c
* @returns {string | undefined}
*/
export function deref(c) { export function deref(c) {
if (!c) { if (!c) {
return; return;

View file

@ -1,5 +1,9 @@
import { deref } from "./cmd-list.js"; import { deref } from "./cmd-list.js";
/**
* @param {string[]} args
* @returns {string | null}
*/
export function getNpmCommandForArgs(args) { export function getNpmCommandForArgs(args) {
if (args.length === 0) { if (args.length === 0) {
return null; return null;
@ -13,6 +17,10 @@ export function getNpmCommandForArgs(args) {
return argCommand; return argCommand;
} }
/**
* @param {string[]} args
* @returns {boolean}
*/
export function hasDryRunArg(args) { export function hasDryRunArg(args) {
return args.some((arg) => arg === "--dry-run"); return args.some((arg) => arg === "--dry-run");
} }

View file

@ -1,6 +1,9 @@
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js"; import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
import { runNpx } from "./runNpxCommand.js"; import { runNpx } from "./runNpxCommand.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createNpxPackageManager() { export function createNpxPackageManager() {
const scanner = commandArgumentScanner(); const scanner = commandArgumentScanner();

View file

@ -1,16 +1,28 @@
import { resolvePackageVersion } from "../../../api/npmApi.js"; import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js";
/**
* @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
*/
export function commandArgumentScanner() { export function commandArgumentScanner() {
return { return {
scan: (args) => scanDependencies(args), scan: (args) => scanDependencies(args),
shouldScan: () => true, // all npx commands need to be scanned, npx doesn't have dry-run shouldScan: () => true, // all npx commands need to be scanned, npx doesn't have dry-run
}; };
} }
/**
* @param {string[]} args
* @returns {Promise<import("../../npm/dependencyScanner/commandArgumentScanner.js").ScanResult[]>}
*/
function scanDependencies(args) { function scanDependencies(args) {
return checkChangesFromArgs(args); return checkChangesFromArgs(args);
} }
/**
* @param {string[]} args
* @returns {Promise<import("../../npm/dependencyScanner/commandArgumentScanner.js").ScanResult[]>}
*/
export async function checkChangesFromArgs(args) { export async function checkChangesFromArgs(args) {
const changes = []; const changes = [];
const packageUpdates = parsePackagesFromArguments(args); const packageUpdates = parsePackagesFromArguments(args);

View file

@ -1,3 +1,8 @@
/**
* @param {string[]} args
*
* @returns {{name: string, version: string}[]}
*/
export function parsePackagesFromArguments(args) { export function parsePackagesFromArguments(args) {
let defaultTag = "latest"; let defaultTag = "latest";
@ -21,6 +26,10 @@ export function parsePackagesFromArguments(args) {
return []; return [];
} }
/**
* @param {string} arg
* @returns {{name: string, numberOfParameters: number} | undefined}
*/
function getOption(arg) { function getOption(arg) {
if (isOptionWithParameter(arg)) { if (isOptionWithParameter(arg)) {
return { return {
@ -41,6 +50,10 @@ function getOption(arg) {
return undefined; return undefined;
} }
/**
* @param {string} arg
* @returns {boolean}
*/
function isOptionWithParameter(arg) { function isOptionWithParameter(arg) {
const optionsWithParameters = [ const optionsWithParameters = [
"--access", "--access",
@ -68,6 +81,11 @@ function isOptionWithParameter(arg) {
return optionsWithParameters.includes(arg); return optionsWithParameters.includes(arg);
} }
/**
* @param {string} arg
* @param {string} defaultTag
* @returns {{name: string, version: string}}
*/
function parsePackagename(arg, defaultTag) { function parsePackagename(arg, defaultTag) {
// format can be --package=name@version // format can be --package=name@version
// in that case, we need to remove the --package= part // in that case, we need to remove the --package= part
@ -97,6 +115,10 @@ function parsePackagename(arg, defaultTag) {
}; };
} }
/**
* @param {string} arg
* @returns {string}
*/
function removeAlias(arg) { function removeAlias(arg) {
// removes the alias. // removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest // Eg.: server@npm:http-server@latest becomes http-server@latest

View file

@ -2,14 +2,20 @@ import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
/**
* @param {string[]} args
*
* @returns {Promise<{status: number}>}
*/
export async function runNpx(args) { export async function runNpx(args) {
try { try {
const result = await safeSpawn("npx", args, { const result = await safeSpawn("npx", args, {
stdio: "inherit", stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env), env: mergeSafeChainProxyEnvironmentVariables(process.env),
}); });
return { status: result.status }; return { status: result.status };
} catch (error) { } catch (/** @type any */ error) {
if (error.status) { if (error.status) {
return { status: error.status }; return { status: error.status };
} else { } else {

View file

@ -4,6 +4,9 @@ import { runPnpmCommand } from "./runPnpmCommand.js";
const scanner = commandArgumentScanner(); const scanner = commandArgumentScanner();
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPnpmPackageManager() { export function createPnpmPackageManager() {
return { return {
runCommand: (args) => runPnpmCommand(args, "pnpm"), runCommand: (args) => runPnpmCommand(args, "pnpm"),
@ -23,6 +26,9 @@ export function createPnpmPackageManager() {
}; };
} }
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPnpxPackageManager() { export function createPnpxPackageManager() {
return { return {
runCommand: (args) => runPnpmCommand(args, "pnpx"), runCommand: (args) => runPnpmCommand(args, "pnpx"),
@ -32,6 +38,11 @@ export function createPnpxPackageManager() {
}; };
} }
/**
* @param {string[]} args
* @param {boolean} isPnpx
* @returns {ReturnType<import("../currentPackageManager.js").PackageManager["getDependencyUpdatesForCommand"]>}
*/
function getDependencyUpdatesForCommand(args, isPnpx) { function getDependencyUpdatesForCommand(args, isPnpx) {
if (isPnpx) { if (isPnpx) {
return scanner.scan(args); return scanner.scan(args);

View file

@ -1,6 +1,9 @@
import { resolvePackageVersion } from "../../../api/npmApi.js"; import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js";
/**
* @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
*/
export function commandArgumentScanner() { export function commandArgumentScanner() {
return { return {
scan: (args) => scanDependencies(args), scan: (args) => scanDependencies(args),
@ -8,6 +11,10 @@ export function commandArgumentScanner() {
}; };
} }
/**
* @param {string[]} args
* @returns {Promise<import("../../npm/dependencyScanner/commandArgumentScanner.js").ScanResult[]>}
*/
async function scanDependencies(args) { async function scanDependencies(args) {
const changes = []; const changes = [];
const packageUpdates = parsePackagesFromArguments(args); const packageUpdates = parsePackagesFromArguments(args);

View file

@ -1,3 +1,7 @@
/**
* @param {string[]} args
* @returns {{name: string, version: string}[]}
*/
export function parsePackagesFromArguments(args) { export function parsePackagesFromArguments(args) {
const changes = []; const changes = [];
let defaultTag = "latest"; let defaultTag = "latest";
@ -22,6 +26,10 @@ export function parsePackagesFromArguments(args) {
return changes; return changes;
} }
/**
* @param {string} arg
* @returns {{name: string, numberOfParameters: number} | undefined}
*/
function getOption(arg) { function getOption(arg) {
if (isOptionWithParameter(arg)) { if (isOptionWithParameter(arg)) {
return { return {
@ -42,12 +50,21 @@ function getOption(arg) {
return undefined; return undefined;
} }
/**
* @param {string} arg
* @returns {boolean}
*/
function isOptionWithParameter(arg) { function isOptionWithParameter(arg) {
const optionsWithParameters = ["--C", "--dir"]; const optionsWithParameters = ["--C", "--dir"];
return optionsWithParameters.includes(arg); return optionsWithParameters.includes(arg);
} }
/**
* @param {string} arg
* @param {string} defaultTag
* @returns {{name: string, version: string}}
*/
function parsePackagename(arg, defaultTag) { function parsePackagename(arg, defaultTag) {
// format can be --package=name@version // format can be --package=name@version
// in that case, we need to remove the --package= part // in that case, we need to remove the --package= part
@ -77,6 +94,10 @@ function parsePackagename(arg, defaultTag) {
}; };
} }
/**
* @param {string} arg
* @returns {string}
*/
function removeAlias(arg) { function removeAlias(arg) {
// removes the alias. // removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest // Eg.: server@npm:http-server@latest becomes http-server@latest

View file

@ -2,17 +2,24 @@ import { ui } from "../../environment/userInteraction.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
/**
* @param {string[]} args
* @param {string} [toolName]
* @returns {Promise<{status: number}>}
*/
export async function runPnpmCommand(args, toolName = "pnpm") { export async function runPnpmCommand(args, toolName = "pnpm") {
try { try {
let result; let result;
if (toolName === "pnpm") { if (toolName === "pnpm") {
result = await safeSpawn("pnpm", args, { result = await safeSpawn("pnpm", args, {
stdio: "inherit", stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env), env: mergeSafeChainProxyEnvironmentVariables(process.env),
}); });
} else if (toolName === "pnpx") { } else if (toolName === "pnpx") {
result = await safeSpawn("pnpx", args, { result = await safeSpawn("pnpx", args, {
stdio: "inherit", stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env), env: mergeSafeChainProxyEnvironmentVariables(process.env),
}); });
} else { } else {
@ -20,7 +27,7 @@ export async function runPnpmCommand(args, toolName = "pnpm") {
} }
return { status: result.status }; return { status: result.status };
} catch (error) { } catch (/** @type any */ error) {
if (error.status) { if (error.status) {
return { status: error.status }; return { status: error.status };
} else { } else {

View file

@ -3,6 +3,9 @@ import { runYarnCommand } from "./runYarnCommand.js";
const scanner = commandArgumentScanner(); const scanner = commandArgumentScanner();
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createYarnPackageManager() { export function createYarnPackageManager() {
return { return {
runCommand: runYarnCommand, runCommand: runYarnCommand,
@ -18,6 +21,11 @@ export function createYarnPackageManager() {
}; };
} }
/**
* @param {string[]} args
* @param {...string} commandArgs
* @returns {boolean}
*/
function matchesCommand(args, ...commandArgs) { function matchesCommand(args, ...commandArgs) {
if (args.length < commandArgs.length) { if (args.length < commandArgs.length) {
return false; return false;

View file

@ -1,6 +1,9 @@
import { resolvePackageVersion } from "../../../api/npmApi.js"; import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js";
/**
* @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
*/
export function commandArgumentScanner() { export function commandArgumentScanner() {
return { return {
scan: (args) => scanDependencies(args), scan: (args) => scanDependencies(args),
@ -8,6 +11,10 @@ export function commandArgumentScanner() {
}; };
} }
/**
* @param {string[]} args
* @returns {Promise<import("../../npm/dependencyScanner/commandArgumentScanner.js").ScanResult[]>}
*/
async function scanDependencies(args) { async function scanDependencies(args) {
const changes = []; const changes = [];
const packageUpdates = parsePackagesFromArguments(args); const packageUpdates = parsePackagesFromArguments(args);

View file

@ -1,3 +1,7 @@
/**
* @param {string[]} args
* @returns {{name: string, version: string}[]}
*/
export function parsePackagesFromArguments(args) { export function parsePackagesFromArguments(args) {
const changes = []; const changes = [];
let defaultTag = "latest"; let defaultTag = "latest";
@ -22,6 +26,11 @@ export function parsePackagesFromArguments(args) {
return changes; return changes;
} }
/**
* @param {string} arg
*
* @returns {{name: string, numberOfParameters: number} | undefined}
*/
function getOption(arg) { function getOption(arg) {
if (isOptionWithParameter(arg)) { if (isOptionWithParameter(arg)) {
return { return {
@ -42,6 +51,11 @@ function getOption(arg) {
return undefined; return undefined;
} }
/**
* @param {string} arg
*
* @returns {boolean}
*/
function isOptionWithParameter(arg) { function isOptionWithParameter(arg) {
const optionsWithParameters = [ const optionsWithParameters = [
"--use-yarnrc", "--use-yarnrc",
@ -64,6 +78,12 @@ function isOptionWithParameter(arg) {
return optionsWithParameters.includes(arg); return optionsWithParameters.includes(arg);
} }
/**
* @param {string} arg
* @param {string} defaultTag
*
* @returns {{name: string, version: string}}
*/
function parsePackagename(arg, defaultTag) { function parsePackagename(arg, defaultTag) {
// format can be --package=name@version // format can be --package=name@version
// in that case, we need to remove the --package= part // in that case, we need to remove the --package= part
@ -93,6 +113,10 @@ function parsePackagename(arg, defaultTag) {
}; };
} }
/**
* @param {string} arg
* @returns {string}
*/
function removeAlias(arg) { function removeAlias(arg) {
// removes the alias. // removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest // Eg.: server@npm:http-server@latest becomes http-server@latest

View file

@ -2,8 +2,14 @@ import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js"; import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
/**
* @param {string[]} args
*
* @returns {Promise<{status: number}>}
*/
export async function runYarnCommand(args) { export async function runYarnCommand(args) {
try { try {
// @ts-expect-error values of process.env can be string | undefined
const env = mergeSafeChainProxyEnvironmentVariables(process.env); const env = mergeSafeChainProxyEnvironmentVariables(process.env);
await fixYarnProxyEnvironmentVariables(env); await fixYarnProxyEnvironmentVariables(env);
@ -12,7 +18,7 @@ export async function runYarnCommand(args) {
env, env,
}); });
return { status: result.status }; return { status: result.status };
} catch (error) { } catch (/** @type any */ error) {
if (error.status) { if (error.status) {
return { status: error.status }; return { status: error.status };
} else { } else {
@ -22,6 +28,11 @@ export async function runYarnCommand(args) {
} }
} }
/**
* @param {Record<string, string>} env
*
* @returns {Promise<void>}
*/
async function fixYarnProxyEnvironmentVariables(env) { async function fixYarnProxyEnvironmentVariables(env) {
// Yarn ignores standard proxy environment variable HTTPS_PROXY // Yarn ignores standard proxy environment variable HTTPS_PROXY
// It does respect NODE_EXTRA_CA_CERTS for custom CA certificates though. // It does respect NODE_EXTRA_CA_CERTS for custom CA certificates though.

View file

@ -1,6 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
// @ts-ignore - certifi has no type definitions
import certifi from "certifi"; import certifi from "certifi";
import tls from "node:tls"; import tls from "node:tls";
import { X509Certificate } from "node:crypto"; import { X509Certificate } from "node:crypto";
@ -8,6 +9,8 @@ import { getCaCertPath } from "./certUtils.js";
/** /**
* Check if a PEM string contains only parsable cert blocks. * Check if a PEM string contains only parsable cert blocks.
* @param {string} pem - PEM-encoded certificate string
* @returns {boolean}
*/ */
function isParsable(pem) { function isParsable(pem) {
if (!pem || typeof pem !== "string") return false; if (!pem || typeof pem !== "string") return false;
@ -38,6 +41,7 @@ function isParsable(pem) {
} }
} }
/** @type {string | null} */
let cachedPath = null; let cachedPath = null;
/** /**

View file

@ -12,6 +12,10 @@ export function getCaCertPath() {
return path.join(certFolder, "ca-cert.pem"); return path.join(certFolder, "ca-cert.pem");
} }
/**
* @param {string} hostname
* @returns {{privateKey: string, certificate: string}}
*/
export function generateCertForHost(hostname) { export function generateCertForHost(hostname) {
let existingCert = certCache.get(hostname); let existingCert = certCache.get(hostname);
if (existingCert) { if (existingCert) {

View file

@ -3,6 +3,11 @@ import { generateCertForHost } from "./certUtils.js";
import { HttpsProxyAgent } from "https-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {(target: string) => Promise<boolean>} isAllowed
*/
export function mitmConnect(req, clientSocket, isAllowed) { export function mitmConnect(req, clientSocket, isAllowed) {
const { hostname } = new URL(`http://${req.url}`); const { hostname } = new URL(`http://${req.url}`);
@ -16,6 +21,7 @@ export function mitmConnect(req, clientSocket, isAllowed) {
server.on("error", (err) => { server.on("error", (err) => {
ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`); ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
// @ts-expect-error Property 'headersSent' does not exist on type 'Socket'
if (!clientSocket.headersSent) { if (!clientSocket.headersSent) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
} else if (clientSocket.writable) { } else if (clientSocket.writable) {
@ -30,10 +36,22 @@ export function mitmConnect(req, clientSocket, isAllowed) {
server.emit("connection", clientSocket); server.emit("connection", clientSocket);
} }
/**
* @param {string} hostname
* @param {(target: string) => Promise<boolean>} isAllowed
* @returns {import("https").Server}
*/
function createHttpsServer(hostname, isAllowed) { function createHttpsServer(hostname, isAllowed) {
const cert = generateCertForHost(hostname); const cert = generateCertForHost(hostname);
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
*
* @returns {Promise<void>}
*/
async function handleRequest(req, res) { async function handleRequest(req, res) {
// @ts-expect-error req.url might be undefined
const pathAndQuery = getRequestPathAndQuery(req.url); const pathAndQuery = getRequestPathAndQuery(req.url);
const targetUrl = `https://${hostname}${pathAndQuery}`; const targetUrl = `https://${hostname}${pathAndQuery}`;
@ -58,6 +76,10 @@ function createHttpsServer(hostname, isAllowed) {
return server; return server;
} }
/**
* @param {string} url
* @returns {string}
*/
function getRequestPathAndQuery(url) { function getRequestPathAndQuery(url) {
if (url.startsWith("http://") || url.startsWith("https://")) { if (url.startsWith("http://") || url.startsWith("https://")) {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
@ -66,6 +88,11 @@ function getRequestPathAndQuery(url) {
return url; return url;
} }
/**
* @param {import("http").IncomingMessage} req
* @param {string} hostname
* @param {import("http").ServerResponse} res
*/
function forwardRequest(req, hostname, res) { function forwardRequest(req, hostname, res) {
const proxyReq = createProxyRequest(hostname, req, res); const proxyReq = createProxyRequest(hostname, req, res);
@ -88,7 +115,15 @@ function forwardRequest(req, hostname, res) {
}); });
} }
/**
* @param {string} hostname
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
*
* @returns {import("http").ClientRequest}
*/
function createProxyRequest(hostname, req, res) { function createProxyRequest(hostname, req, res) {
/** @type {import("http").RequestOptions} */
const options = { const options = {
hostname: hostname, hostname: hostname,
port: 443, port: 443,
@ -97,7 +132,9 @@ function createProxyRequest(hostname, req, res) {
headers: { ...req.headers }, headers: { ...req.headers },
}; };
delete options.headers.host; if (options.headers && "host" in options.headers) {
delete options.headers.host;
}
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
if (httpsProxy) { if (httpsProxy) {
@ -115,6 +152,7 @@ function createProxyRequest(hostname, req, res) {
} }
}); });
// @ts-expect-error statusCode might be undefined
res.writeHead(proxyRes.statusCode, proxyRes.headers); res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res); proxyRes.pipe(res);
}); });

View file

@ -3,6 +3,10 @@ import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"
export const knownJsRegistries = ["registry.npmjs.org","registry.yarnpkg.com"]; export const knownJsRegistries = ["registry.npmjs.org","registry.yarnpkg.com"];
export const knownPipRegistries = ["files.pythonhosted.org", "pypi.org", "pypi.python.org", "pythonhosted.org"]; export const knownPipRegistries = ["files.pythonhosted.org", "pypi.org", "pypi.python.org", "pythonhosted.org"];
/**
* @param {string} url
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
export function parsePackageFromUrl(url) { export function parsePackageFromUrl(url) {
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
let registry; let registry;

View file

@ -1,7 +1,14 @@
import * as http from "http"; import * as http from "http";
import * as https from "https"; import * as https from "https";
/**
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
*
* @returns {void}
*/
export function handleHttpProxyRequest(req, res) { export function handleHttpProxyRequest(req, res) {
// @ts-expect-error req.url might be undefined
const url = new URL(req.url); const url = new URL(req.url);
// The protocol for the plainHttpProxy should usually only be http: // The protocol for the plainHttpProxy should usually only be http:
@ -20,9 +27,11 @@ export function handleHttpProxyRequest(req, res) {
const proxyRequest = protocol const proxyRequest = protocol
.request( .request(
// @ts-expect-error req.url might be undefined
req.url, req.url,
{ method: req.method, headers: req.headers }, { method: req.method, headers: req.headers },
(proxyRes) => { (proxyRes) => {
// @ts-expect-error statusCode might be undefined
res.writeHead(proxyRes.statusCode, proxyRes.headers); res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res); proxyRes.pipe(res);

View file

@ -10,6 +10,9 @@ import { ui } from "../environment/userInteraction.js";
import chalk from "chalk"; import chalk from "chalk";
const SERVER_STOP_TIMEOUT_MS = 1000; const SERVER_STOP_TIMEOUT_MS = 1000;
/**
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
*/
const state = { const state = {
port: null, port: null,
blockedRequests: [], blockedRequests: [],
@ -25,6 +28,9 @@ export function createSafeChainProxy() {
}; };
} }
/**
* @returns {Record<string, string>}
*/
function getSafeChainProxyEnvironmentVariables() { function getSafeChainProxyEnvironmentVariables() {
if (!state.port) { if (!state.port) {
return {}; return {};
@ -37,6 +43,11 @@ function getSafeChainProxyEnvironmentVariables() {
}; };
} }
/**
* @param {Record<string, string>} env
*
* @returns {Record<string, string>}
*/
export function mergeSafeChainProxyEnvironmentVariables(env) { export function mergeSafeChainProxyEnvironmentVariables(env) {
const proxyEnv = getSafeChainProxyEnvironmentVariables(); const proxyEnv = getSafeChainProxyEnvironmentVariables();
@ -68,6 +79,11 @@ function createProxyServer() {
return server; return server;
} }
/**
* @param {import("http").Server} server
*
* @returns {Promise<void>}
*/
function startServer(server) { function startServer(server) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Passing port 0 makes the OS assign an available port // Passing port 0 makes the OS assign an available port
@ -87,6 +103,11 @@ function startServer(server) {
}); });
} }
/**
* @param {import("http").Server} server
*
* @returns {Promise<void>}
*/
function stopServer(server) { function stopServer(server) {
return new Promise((resolve) => { return new Promise((resolve) => {
try { try {
@ -100,6 +121,13 @@ function stopServer(server) {
}); });
} }
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {Buffer} head
*
* @returns {void}
*/
function handleConnect(req, clientSocket, head) { function handleConnect(req, clientSocket, head) {
// CONNECT method is used for HTTPS requests // CONNECT method is used for HTTPS requests
// It establishes a tunnel to the server identified by the request URL // It establishes a tunnel to the server identified by the request URL
@ -122,6 +150,10 @@ function handleConnect(req, clientSocket, head) {
} }
} }
/**
* @param {string} url
* @returns {Promise<boolean>}
*/
async function isAllowedUrl(url) { async function isAllowedUrl(url) {
const { packageName, version } = parsePackageFromUrl(url); const { packageName, version } = parsePackageFromUrl(url);

View file

@ -1,6 +1,13 @@
import * as net from "net"; import * as net from "net";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {Buffer} head
*
* @returns {void}
*/
export function tunnelRequest(req, clientSocket, head) { export function tunnelRequest(req, clientSocket, head) {
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
@ -21,9 +28,17 @@ export function tunnelRequest(req, clientSocket, head) {
} }
} }
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {Buffer} head
*
* @returns {void}
*/
function tunnelRequestToDestination(req, clientSocket, head) { function tunnelRequestToDestination(req, clientSocket, head) {
const { port, hostname } = new URL(`http://${req.url}`); const { port, hostname } = new URL(`http://${req.url}`);
// @ts-expect-error port from URL is a string but net.connect accepts number
const serverSocket = net.connect(port || 443, hostname, () => { const serverSocket = net.connect(port || 443, hostname, () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
serverSocket.write(head); serverSocket.write(head);
@ -49,11 +64,18 @@ function tunnelRequestToDestination(req, clientSocket, head) {
}); });
} }
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {Buffer} head
* @param {string} proxyUrl
*/
function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) { function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
const { port, hostname } = new URL(`http://${req.url}`); const { port, hostname } = new URL(`http://${req.url}`);
const proxy = new URL(proxyUrl); const proxy = new URL(proxyUrl);
// Connect to proxy server // Connect to proxy server
// @ts-expect-error net.connect wants port as number but proxy.port is string
const proxySocket = net.connect({ const proxySocket = net.connect({
host: proxy.hostname, host: proxy.hostname,
port: proxy.port, port: proxy.port,

View file

@ -3,6 +3,25 @@ import {
openMalwareDatabase, openMalwareDatabase,
} from "../malwareDatabase.js"; } from "../malwareDatabase.js";
/**
* @typedef {Object} PackageChange
* @property {string} name
* @property {string} version
* @property {string} type
*/
/**
* @typedef {Object} AuditResult
* @property {PackageChange[]} allowedChanges
* @property {(PackageChange & {reason: string})[]} disallowedChanges
* @property {boolean} isAllowed
*/
/**
* @param {PackageChange[]} changes
*
* @returns {Promise<AuditResult>}
*/
export async function auditChanges(changes) { export async function auditChanges(changes) {
const allowedChanges = []; const allowedChanges = [];
const disallowedChanges = []; const disallowedChanges = [];
@ -36,6 +55,10 @@ export async function auditChanges(changes) {
return auditResults; return auditResults;
} }
/**
* @param {{name: string, version: string, type: string}[]} changes
* @returns {Promise<{name: string, version: string, status: string}[]>}
*/
async function getPackagesWithMalware(changes) { async function getPackagesWithMalware(changes) {
if (changes.length === 0) { if (changes.length === 0) {
return []; return [];

View file

@ -5,6 +5,11 @@ import chalk from "chalk";
import { getPackageManager } from "../packagemanager/currentPackageManager.js"; import { getPackageManager } from "../packagemanager/currentPackageManager.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
/**
* @param {string[]} args
*
* @returns {boolean}
*/
export function shouldScanCommand(args) { export function shouldScanCommand(args) {
if (!args || args.length === 0) { if (!args || args.length === 0) {
return false; return false;
@ -13,6 +18,11 @@ export function shouldScanCommand(args) {
return getPackageManager().isSupportedCommand(args); return getPackageManager().isSupportedCommand(args);
} }
/**
* @param {string[]} args
*
* @returns {Promise<number | never[]>}
*/
export async function scanCommand(args) { export async function scanCommand(args) {
if (!shouldScanCommand(args)) { if (!shouldScanCommand(args)) {
return []; return [];
@ -23,6 +33,7 @@ export async function scanCommand(args) {
const spinner = ui.startProcess( const spinner = ui.startProcess(
"Safe-chain: Scanning for malicious packages..." "Safe-chain: Scanning for malicious packages..."
); );
/** @type {import("./audit/index.js").AuditResult | undefined} */
let audit; let audit;
await Promise.race([ await Promise.race([
@ -44,7 +55,7 @@ export async function scanCommand(args) {
} }
audit = await auditChanges(changes); audit = await auditChanges(changes);
} catch (error) { } catch (/** @type any */ error) {
spinner.fail(`Safe-chain: Error while scanning.`); spinner.fail(`Safe-chain: Error while scanning.`);
throw error; throw error;
} }
@ -69,6 +80,12 @@ export async function scanCommand(args) {
} }
} }
/**
* @param {import("./audit/index.js").PackageChange[]} changes
* @param spinner {import("../environment/userInteraction.js").Spinner}
*
* @return {void}
*/
function printMaliciousChanges(changes, spinner) { function printMaliciousChanges(changes, spinner) {
spinner.fail("Safe-chain: " + chalk.bold("Malicious changes detected:")); spinner.fail("Safe-chain: " + chalk.bold("Malicious changes detected:"));

View file

@ -9,6 +9,13 @@ import {
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js"; import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
/**
* @typedef {Object} MalwareDatabase
* @property {function(string, string): string} getPackageStatus
* @property {function(string, string): boolean} isMalware
*/
/** @type {MalwareDatabase | null} */
let cachedMalwareDatabase = null; let cachedMalwareDatabase = null;
/** /**
@ -32,6 +39,11 @@ export async function openMalwareDatabase() {
const malwareDatabase = await getMalwareDatabase(); const malwareDatabase = await getMalwareDatabase();
/**
* @param {string} name
* @param {string} version
* @returns {string}
*/
function getPackageStatus(name, version) { function getPackageStatus(name, version) {
const normalizedName = normalizePackageName(name); const normalizedName = normalizePackageName(name);
const packageData = malwareDatabase.find( const packageData = malwareDatabase.find(
@ -49,7 +61,7 @@ export async function openMalwareDatabase() {
return packageData.reason; return packageData.reason;
} }
// This implicitely caches the malware database // This implicitly caches the malware database
// that's closed over by the getPackageStatus function // that's closed over by the getPackageStatus function
cachedMalwareDatabase = { cachedMalwareDatabase = {
getPackageStatus, getPackageStatus,
@ -61,6 +73,9 @@ export async function openMalwareDatabase() {
return cachedMalwareDatabase; return cachedMalwareDatabase;
} }
/**
* @returns {Promise<import("../api/aikido.js").MalwarePackage[]>}
*/
async function getMalwareDatabase() { async function getMalwareDatabase() {
const { malwareDatabase: cachedDatabase, version: cachedVersion } = const { malwareDatabase: cachedDatabase, version: cachedVersion } =
readDatabaseFromLocalCache(); readDatabaseFromLocalCache();
@ -74,10 +89,11 @@ async function getMalwareDatabase() {
} }
const { malwareDatabase, version } = await fetchMalwareDatabase(); const { malwareDatabase, version } = await fetchMalwareDatabase();
// @ts-expect-error version can be undefined
writeDatabaseToLocalCache(malwareDatabase, version); writeDatabaseToLocalCache(malwareDatabase, version);
return malwareDatabase; return malwareDatabase;
} catch (error) { } catch (/** @type any */ error) {
if (cachedDatabase) { if (cachedDatabase) {
ui.writeWarning( ui.writeWarning(
"Failed to fetch the latest malware database. Using cached version." "Failed to fetch the latest malware database. Using cached version."
@ -88,6 +104,11 @@ async function getMalwareDatabase() {
} }
} }
/**
* @param {string} status
*
* @returns {boolean}
*/
function isMalwareStatus(status) { function isMalwareStatus(status) {
let malwareStatus = status.toUpperCase(); let malwareStatus = status.toUpperCase();
return malwareStatus === MALWARE_STATUS_MALWARE; return malwareStatus === MALWARE_STATUS_MALWARE;

View file

@ -3,6 +3,15 @@ import * as os from "os";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
/**
* @typedef {Object} AikidoTool
* @property {string} tool
* @property {string} aikidoCommand
*/
/**
* @type {AikidoTool[]}
*/
export const knownAikidoTools = [ export const knownAikidoTools = [
{ tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "npx", aikidoCommand: "aikido-npx" }, { tool: "npx", aikidoCommand: "aikido-npx" },
@ -32,6 +41,11 @@ export function getPackageManagerList() {
return `${tools.join(", ")}, and ${lastTool} commands`; return `${tools.join(", ")}, and ${lastTool} commands`;
} }
/**
* @param {string} executableName
*
* @returns {boolean}
*/
export function doesExecutableExistOnSystem(executableName) { export function doesExecutableExistOnSystem(executableName) {
if (os.platform() === "win32") { if (os.platform() === "win32") {
const result = spawnSync("where", [executableName], { stdio: "ignore" }); const result = spawnSync("where", [executableName], { stdio: "ignore" });
@ -42,6 +56,13 @@ export function doesExecutableExistOnSystem(executableName) {
} }
} }
/**
* @param {string} filePath
* @param {RegExp} pattern
* @param {string} [eol]
*
* @returns {void}
*/
export function removeLinesMatchingPattern(filePath, pattern, eol) { export function removeLinesMatchingPattern(filePath, pattern, eol) {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return; return;
@ -56,6 +77,12 @@ export function removeLinesMatchingPattern(filePath, pattern, eol) {
} }
const maxLineLength = 100; const maxLineLength = 100;
/**
* @param {string} line
* @param {RegExp} pattern
* @returns {boolean}
*/
function shouldRemoveLine(line, pattern) { function shouldRemoveLine(line, pattern) {
const isPatternMatch = pattern.test(line); const isPatternMatch = pattern.test(line);
@ -84,6 +111,13 @@ function shouldRemoveLine(line, pattern) {
return true; return true;
} }
/**
* @param {string} filePath
* @param {string} line
* @param {string} [eol]
*
* @returns {void}
*/
export function addLineToFile(filePath, line, eol) { export function addLineToFile(filePath, line, eol) {
createFileIfNotExists(filePath); createFileIfNotExists(filePath);
@ -94,6 +128,11 @@ export function addLineToFile(filePath, line, eol) {
fs.writeFileSync(filePath, updatedContent, "utf-8"); fs.writeFileSync(filePath, updatedContent, "utf-8");
} }
/**
* @param {string} filePath
*
* @returns {void}
*/
function createFileIfNotExists(filePath) { function createFileIfNotExists(filePath) {
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
return; return;

View file

@ -28,6 +28,11 @@ export async function setupCi() {
ui.writeInformation(`Added shims directory to PATH for CI environments.`); ui.writeInformation(`Added shims directory to PATH for CI environments.`);
} }
/**
* @param {string} shimsDir
*
* @returns {void}
*/
function createUnixShims(shimsDir) { function createUnixShims(shimsDir) {
// Read the template file // Read the template file
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -70,6 +75,11 @@ function createUnixShims(shimsDir) {
); );
} }
/**
* @param {string} shimsDir
*
* @returns {void}
*/
function createWindowsShims(shimsDir) { function createWindowsShims(shimsDir) {
// Read the template file // Read the template file
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -109,6 +119,11 @@ function createWindowsShims(shimsDir) {
); );
} }
/**
* @param {string} shimsDir
*
* @returns {void}
*/
function createShims(shimsDir) { function createShims(shimsDir) {
if (os.platform() === "win32") { if (os.platform() === "win32") {
createWindowsShims(shimsDir); createWindowsShims(shimsDir);
@ -117,6 +132,11 @@ function createShims(shimsDir) {
} }
} }
/**
* @param {string} shimsDir
*
* @returns {void}
*/
function modifyPathForCi(shimsDir) { function modifyPathForCi(shimsDir) {
if (process.env.GITHUB_PATH) { if (process.env.GITHUB_PATH) {
// In GitHub Actions, append the shims directory to GITHUB_PATH // In GitHub Actions, append the shims directory to GITHUB_PATH

View file

@ -43,7 +43,7 @@ export async function setup() {
ui.emptyLine(); ui.emptyLine();
ui.writeInformation(`Please restart your terminal to apply the changes.`); ui.writeInformation(`Please restart your terminal to apply the changes.`);
} }
} catch (error) { } catch (/** @type {any} */ error) {
ui.writeError( ui.writeError(
`Failed to set up shell aliases: ${error.message}. Please check your shell configuration.` `Failed to set up shell aliases: ${error.message}. Please check your shell configuration.`
); );
@ -53,6 +53,7 @@ export async function setup() {
/** /**
* Calls the setup function for the given shell and reports the result. * Calls the setup function for the given shell and reports the result.
* @param {import("./shellDetection.js").Shell} shell
*/ */
function setupShell(shell) { function setupShell(shell) {
let success = false; let success = false;
@ -60,7 +61,7 @@ function setupShell(shell) {
try { try {
shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases
success = shell.setup(knownAikidoTools); success = shell.setup(knownAikidoTools);
} catch (err) { } catch (/** @type {any} */ err) {
success = false; success = false;
error = err; error = err;
} }

View file

@ -5,6 +5,17 @@ import windowsPowershell from "./supported-shells/windowsPowershell.js";
import fish from "./supported-shells/fish.js"; import fish from "./supported-shells/fish.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
/**
* @typedef {Object} Shell
* @property {string} name
* @property {() => boolean} isInstalled
* @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} setup
* @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} teardown
*/
/**
* @returns {Shell[]}
*/
export function detectShells() { export function detectShells() {
let possibleShells = [zsh, bash, powershell, windowsPowershell, fish]; let possibleShells = [zsh, bash, powershell, windowsPowershell, fish];
let availableShells = []; let availableShells = [];
@ -15,7 +26,7 @@ export function detectShells() {
availableShells.push(shell); availableShells.push(shell);
} }
} }
} catch (error) { } catch (/** @type {any} */ error) {
ui.writeError( ui.writeError(
`We were not able to detect which shells are installed on your system. Please check your shell configuration. Error: ${error.message}` `We were not able to detect which shells are installed on your system. Please check your shell configuration. Error: ${error.message}`
); );

View file

@ -15,6 +15,11 @@ function isInstalled() {
return doesExecutableExistOnSystem(executableName); return doesExecutableExistOnSystem(executableName);
} }
/**
* @param {import("../helpers.js").AikidoTool[]} tools
*
* @returns {boolean}
*/
function teardown(tools) { function teardown(tools) {
const startupFile = getStartupFile(); const startupFile = getStartupFile();
@ -57,13 +62,18 @@ function getStartupFile() {
}).trim(); }).trim();
return windowsFixPath(path); return windowsFixPath(path);
} catch (error) { } catch (/** @type {any} */ error) {
throw new Error( throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}` `Command failed: ${startupFileCommand}. Error: ${error.message}`
); );
} }
} }
/**
* @param {string} path
*
* @returns {string}
*/
function windowsFixPath(path) { function windowsFixPath(path) {
try { try {
if (os.platform() !== "win32") { if (os.platform() !== "win32") {
@ -93,6 +103,11 @@ function hasCygpath() {
} }
} }
/**
* @param {string} path
*
* @returns {string}
*/
function cygpathw(path) { function cygpathw(path) {
try { try {
var result = spawnSync("cygpath", ["-w", path], { var result = spawnSync("cygpath", ["-w", path], {
@ -108,6 +123,9 @@ function cygpathw(path) {
} }
} }
/**
* @type {import("../shellDetection.js").Shell}
*/
export default { export default {
name: shellName, name: shellName,
isInstalled, isInstalled,

View file

@ -14,6 +14,11 @@ function isInstalled() {
return doesExecutableExistOnSystem(executableName); return doesExecutableExistOnSystem(executableName);
} }
/**
* @param {import("../helpers.js").AikidoTool[]} tools
*
* @returns {boolean}
*/
function teardown(tools) { function teardown(tools) {
const startupFile = getStartupFile(); const startupFile = getStartupFile();
@ -54,13 +59,16 @@ function getStartupFile() {
encoding: "utf8", encoding: "utf8",
shell: executableName, shell: executableName,
}).trim(); }).trim();
} catch (error) { } catch (/** @type {any} */ error) {
throw new Error( throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}` `Command failed: ${startupFileCommand}. Error: ${error.message}`
); );
} }
} }
/**
* @type {import("../shellDetection.js").Shell}
*/
export default { export default {
name: shellName, name: shellName,
isInstalled, isInstalled,

View file

@ -13,6 +13,11 @@ function isInstalled() {
return doesExecutableExistOnSystem(executableName); return doesExecutableExistOnSystem(executableName);
} }
/**
* @param {import("../helpers.js").AikidoTool[]} tools
*
* @returns {boolean}
*/
function teardown(tools) { function teardown(tools) {
const startupFile = getStartupFile(); const startupFile = getStartupFile();
@ -50,13 +55,16 @@ function getStartupFile() {
encoding: "utf8", encoding: "utf8",
shell: executableName, shell: executableName,
}).trim(); }).trim();
} catch (error) { } catch (/** @type {any} */ error) {
throw new Error( throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}` `Command failed: ${startupFileCommand}. Error: ${error.message}`
); );
} }
} }
/**
* @type {import("../shellDetection.js").Shell}
*/
export default { export default {
name: shellName, name: shellName,
isInstalled, isInstalled,

View file

@ -13,6 +13,11 @@ function isInstalled() {
return doesExecutableExistOnSystem(executableName); return doesExecutableExistOnSystem(executableName);
} }
/**
* @param {import("../helpers.js").AikidoTool[]} tools
*
* @returns {boolean}
*/
function teardown(tools) { function teardown(tools) {
const startupFile = getStartupFile(); const startupFile = getStartupFile();
@ -50,13 +55,16 @@ function getStartupFile() {
encoding: "utf8", encoding: "utf8",
shell: executableName, shell: executableName,
}).trim(); }).trim();
} catch (error) { } catch (/** @type {any} */ error) {
throw new Error( throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}` `Command failed: ${startupFileCommand}. Error: ${error.message}`
); );
} }
} }
/**
* @type {import("../shellDetection.js").Shell}
*/
export default { export default {
name: shellName, name: shellName,
isInstalled, isInstalled,

View file

@ -14,6 +14,11 @@ function isInstalled() {
return doesExecutableExistOnSystem(executableName); return doesExecutableExistOnSystem(executableName);
} }
/**
* @param {import("../helpers.js").AikidoTool[]} tools
*
* @returns {boolean}
*/
function teardown(tools) { function teardown(tools) {
const startupFile = getStartupFile(); const startupFile = getStartupFile();
@ -54,7 +59,7 @@ function getStartupFile() {
encoding: "utf8", encoding: "utf8",
shell: executableName, shell: executableName,
}).trim(); }).trim();
} catch (error) { } catch (/** @type {any} */ error) {
throw new Error( throw new Error(
`Command failed: ${startupFileCommand}. Error: ${error.message}` `Command failed: ${startupFileCommand}. Error: ${error.message}`
); );

View file

@ -3,6 +3,9 @@ import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js"; import { detectShells } from "./shellDetection.js";
import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
/**
* @returns {Promise<void>}
*/
export async function teardown() { export async function teardown() {
ui.writeInformation( ui.writeInformation(
chalk.bold("Removing shell aliases.") + chalk.bold("Removing shell aliases.") +
@ -52,7 +55,7 @@ export async function teardown() {
ui.emptyLine(); ui.emptyLine();
ui.writeInformation(`Please restart your terminal to apply the changes.`); ui.writeInformation(`Please restart your terminal to apply the changes.`);
} }
} catch (error) { } catch (/** @type {any} */ error) {
ui.writeError( ui.writeError(
`Failed to remove shell aliases: ${error.message}. Please check your shell configuration.` `Failed to remove shell aliases: ${error.message}. Please check your shell configuration.`
); );

View file

@ -1,6 +1,11 @@
import { spawn, execSync } from "child_process"; import { spawn, execSync } from "child_process";
import os from "os"; import os from "os";
/**
* @param {string} arg
*
* @returns {string}
*/
function sanitizeShellArgument(arg) { function sanitizeShellArgument(arg) {
// If argument contains shell metacharacters, wrap in double quotes // If argument contains shell metacharacters, wrap in double quotes
// and escape characters that are special even inside double quotes // and escape characters that are special even inside double quotes
@ -11,6 +16,11 @@ function sanitizeShellArgument(arg) {
return arg; return arg;
} }
/**
* @param {string} arg
*
* @returns {boolean}
*/
function hasShellMetaChars(arg) { function hasShellMetaChars(arg) {
// Shell metacharacters that need escaping // Shell metacharacters that need escaping
// These characters have special meaning in shells and need to be quoted // These characters have special meaning in shells and need to be quoted
@ -20,12 +30,23 @@ function hasShellMetaChars(arg) {
return shellMetaChars.test(arg); return shellMetaChars.test(arg);
} }
/**
* @param {string} arg
*
* @returns {string}
*/
function escapeDoubleQuoteContent(arg) { function escapeDoubleQuoteContent(arg) {
// Escape special characters for shell safety // Escape special characters for shell safety
// This escapes ", $, `, and \ by prefixing them with a backslash // This escapes ", $, `, and \ by prefixing them with a backslash
return arg.replace(/(["`$\\])/g, "\\$1"); return arg.replace(/(["`$\\])/g, "\\$1");
} }
/**
* @param {string} command
* @param {string[]} args
*
* @returns {string}
*/
function buildCommand(command, args) { function buildCommand(command, args) {
if (args.length === 0) { if (args.length === 0) {
return command; return command;
@ -36,11 +57,17 @@ function buildCommand(command, args) {
return `${command} ${escapedArgs.join(" ")}`; return `${command} ${escapedArgs.join(" ")}`;
} }
/**
* @param {string} command
*
* @returns {string}
*/
function resolveCommandPath(command) { function resolveCommandPath(command) {
// command will be "npm", "yarn", etc. // command will be "npm", "yarn", etc.
// Use 'command -v' to find the full path // Use 'command -v' to find the full path
const fullPath = execSync(`command -v ${command}`, { const fullPath = execSync(`command -v ${command}`, {
encoding: "utf8", encoding: "utf8",
// @ts-expect-error shell is a string option
shell: true, shell: true,
}).trim(); }).trim();
@ -51,6 +78,13 @@ function resolveCommandPath(command) {
return fullPath; return fullPath;
} }
/**
* @param {string} command
* @param {string[]} args
* @param {import("child_process").SpawnOptions} options
*
* @returns {Promise<{status: number, stdout: string, stderr: string}>}
*/
export async function safeSpawn(command, args, options = {}) { export async function safeSpawn(command, args, options = {}) {
// The command is always one of our supported package managers. // The command is always one of our supported package managers.
// It should always be alphanumeric or _ or - // It should always be alphanumeric or _ or -
@ -87,6 +121,7 @@ export async function safeSpawn(command, args, options = {}) {
child.on("close", (code) => { child.on("close", (code) => {
resolve({ resolve({
// @ts-expect-error code can be null
status: code, status: code,
stdout: stdout, stdout: stdout,
stderr: stderr, stderr: stderr,

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"lib": ["es2023"],
"module": "node16",
"strict": true,
"skipLibCheck": true,
"moduleResolution": "node16",
"allowJs": true,
"checkJs": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": [
"src/**/*.js",
"bin/**/*.js"
],
"exclude": [
"node_modules",
"src/**/*.spec.js"
]
}