Merge branch 'main' into feat/pdm-support

This commit is contained in:
Chris Ingram 2026-04-22 14:25:32 +01:00 committed by GitHub
commit abbe0480b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1603 additions and 1348 deletions

View file

@ -0,0 +1,16 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
initializePackageManager("uvx");
(async () => {
// Pass through only user-supplied uvx args
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -16,6 +16,7 @@ import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
import { knownAikidoTools } from "../src/shell-integration/helpers.js";
import { getInstalledSafeChainDir } from "../src/installLocation.js";
/** @type {string} */
// This checks the current file's dirname in a way that's compatible with:
@ -67,6 +68,17 @@ if (tool) {
teardownDirectories();
} else if (command === "setup-ci") {
setupCi();
} else if (command === "get-install-dir") {
const installDir = getInstalledSafeChainDir();
if (!installDir) {
ui.writeError(
"Install directory is only available for packaged safe-chain binaries.",
);
process.exit(1);
}
ui.writeInformation(installDir);
process.exit(0);
} else if (command === "--version" || command === "-v" || command === "-v") {
(async () => {
ui.writeInformation(`Current safe-chain version: ${await getVersion()}`);
@ -88,7 +100,7 @@ function writeHelp() {
ui.writeInformation(
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
"teardown",
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan(
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("get-install-dir")}, ${chalk.cyan("help")}, ${chalk.cyan(
"--version",
)}`,
);
@ -108,6 +120,11 @@ function writeHelp() {
"safe-chain setup-ci",
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`,
);
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain get-install-dir",
)}: Print the install directory for packaged safe-chain binaries.`,
);
ui.writeInformation(
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
"-v",

View file

@ -16,6 +16,7 @@
"aikido-bun": "bin/aikido-bun.js",
"aikido-bunx": "bin/aikido-bunx.js",
"aikido-uv": "bin/aikido-uv.js",
"aikido-uvx": "bin/aikido-uvx.js",
"aikido-pip": "bin/aikido-pip.js",
"aikido-pip3": "bin/aikido-pip3.js",
"aikido-python": "bin/aikido-python.js",
@ -39,7 +40,6 @@
"license": "AGPL-3.0-or-later",
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), [pip](https://pip.pypa.io/), and [pdm](https://pdm-project.org/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, pip/pip3, or pdm from downloading or running the malware.",
"dependencies": {
"archiver": "^7.0.1",
"certifi": "14.5.15",
"chalk": "5.4.1",
"https-proxy-agent": "7.0.6",
@ -50,7 +50,6 @@
"semver": "7.7.2"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/ini": "^4.1.1",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^18.19.130",

View file

@ -3,6 +3,7 @@ import path from "path";
import os from "os";
import { ui } from "../environment/userInteraction.js";
import { getEcoSystem } from "./settings.js";
import { getSafeChainBaseDir } from "./safeChainDir.js";
/**
* @typedef {Object} SafeChainConfig
@ -304,8 +305,7 @@ function getConfigFilePath() {
* @returns {string}
*/
export function getSafeChainDirectory() {
const homeDir = os.homedir();
const safeChainDir = path.join(homeDir, ".safe-chain");
const safeChainDir = getSafeChainBaseDir();
if (!fs.existsSync(safeChainDir)) {
fs.mkdirSync(safeChainDir, { recursive: true });

View file

@ -0,0 +1,71 @@
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { getInstalledSafeChainDir } from "../installLocation.js";
/**
* @returns {string}
*/
export function getSafeChainBaseDir() {
return getInstalledSafeChainDir() ?? path.join(os.homedir(), ".safe-chain");
}
/**
* @returns {string}
*/
export function getBinDir() {
return path.join(getSafeChainBaseDir(), "bin");
}
/**
* @returns {string}
*/
export function getShimsDir() {
return path.join(getSafeChainBaseDir(), "shims");
}
/**
* @returns {string}
*/
export function getScriptsDir() {
return path.join(getSafeChainBaseDir(), "scripts");
}
/**
* @returns {string}
*/
export function getCertsDir() {
return path.join(getSafeChainBaseDir(), "certs");
}
/**
* Resolves the directory of the calling module.
* Falls back to __dirname when import.meta.url is unavailable (pkg CJS binary).
* @param {string | undefined} moduleUrl
* @returns {string}
*/
function resolveModuleDir(moduleUrl) {
if (moduleUrl) {
return path.dirname(fileURLToPath(moduleUrl));
}
// eslint-disable-next-line no-undef
return __dirname;
}
/**
* @param {string | undefined} moduleUrl
* @param {string} fileName
* @returns {string}
*/
export function getStartupScriptSourcePath(moduleUrl, fileName) {
return path.join(resolveModuleDir(moduleUrl), "startup-scripts", fileName);
}
/**
* @param {string | undefined} moduleUrl
* @param {string} fileName
* @returns {string}
*/
export function getPathWrapperTemplatePath(moduleUrl, fileName) {
return path.join(resolveModuleDir(moduleUrl), "path-wrappers", "templates", fileName);
}

View file

@ -0,0 +1,42 @@
import path from "path";
/** @type {NodeJS.Process & { pkg?: unknown }} */
const processWithPkg = process;
/**
* @param {string} executablePath
* @returns {string | undefined}
*/
export function deriveInstallDirFromExecutablePath(executablePath) {
if (!executablePath) {
return undefined;
}
const pathLibrary = executablePath.includes("\\") ? path.win32 : path.posix;
const executableDir = pathLibrary.dirname(executablePath);
if (pathLibrary.basename(executableDir) !== "bin") {
return undefined;
}
return pathLibrary.dirname(executableDir);
}
/**
* Returns the install directory for a packaged safe-chain binary.
* Custom installation directories only apply to packaged binary installs.
* For npm/global/dev-script executions this intentionally returns undefined,
* which causes callers to fall back to the default ~/.safe-chain layout.
*
* @param {{ isPackaged?: boolean, executablePath?: string }} [options]
* @returns {string | undefined}
*/
export function getInstalledSafeChainDir(options = {}) {
const isPackaged = options.isPackaged ?? Boolean(processWithPkg.pkg);
if (!isPackaged) {
return undefined;
}
return deriveInstallDirFromExecutablePath(
options.executablePath ?? process.execPath,
);
}

View file

@ -0,0 +1,51 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import {
deriveInstallDirFromExecutablePath,
getInstalledSafeChainDir,
} from "./installLocation.js";
describe("deriveInstallDirFromExecutablePath", () => {
it("derives the install dir from a Unix binary path", () => {
assert.strictEqual(
deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/bin/safe-chain"),
"/usr/local/.safe-chain",
);
});
it("derives the install dir from a Windows binary path", () => {
assert.strictEqual(
deriveInstallDirFromExecutablePath("C:\\ProgramData\\safe-chain\\bin\\safe-chain.exe"),
"C:\\ProgramData\\safe-chain",
);
});
it("returns undefined when the executable is not inside a bin directory", () => {
assert.strictEqual(
deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/safe-chain"),
undefined,
);
});
});
describe("getInstalledSafeChainDir", () => {
it("returns undefined for non-packaged executions", () => {
assert.strictEqual(
getInstalledSafeChainDir({
isPackaged: false,
executablePath: "/usr/local/.safe-chain/bin/safe-chain",
}),
undefined,
);
});
it("returns the install dir for packaged executions", () => {
assert.strictEqual(
getInstalledSafeChainDir({
isPackaged: true,
executablePath: "/usr/local/.safe-chain/bin/safe-chain",
}),
"/usr/local/.safe-chain",
);
});
});

View file

@ -14,6 +14,7 @@ import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js";
import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js";
/**
* @type {{packageManagerName: PackageManager | null}}
@ -61,6 +62,8 @@ export function initializePackageManager(packageManagerName, context) {
state.packageManagerName = createPipPackageManager(context);
} else if (packageManagerName === "uv") {
state.packageManagerName = createUvPackageManager();
} else if (packageManagerName === "uvx") {
state.packageManagerName = createUvxPackageManager();
} else if (packageManagerName === "poetry") {
state.packageManagerName = createPoetryPackageManager();
} else if (packageManagerName === "pipx") {

View file

@ -0,0 +1,18 @@
import { runUv } from "../uv/runUvCommand.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createUvxPackageManager() {
return {
/**
* @param {string[]} args
*/
runCommand: (args) => {
return runUv("uvx", args);
},
// For uvx, rely solely on MITM
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}

View file

@ -0,0 +1,14 @@
import { test } from "node:test";
import assert from "node:assert";
import { createUvxPackageManager } from "./createUvxPackageManager.js";
test("createUvxPackageManager returns valid package manager interface", () => {
const pm = createUvxPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
assert.strictEqual(pm.isSupportedCommand(), false);
assert.deepStrictEqual(pm.getDependencyUpdatesForCommand(), []);
});

View file

@ -1,9 +1,8 @@
import forge from "node-forge";
import path from "path";
import fs from "fs";
import os from "os";
import { getCertsDir } from "../config/safeChainDir.js";
const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
const ca = loadCa();
const certCache = new Map();
@ -20,7 +19,7 @@ function createKeyIdentifier(publicKey) {
}
export function getCaCertPath() {
return path.join(certFolder, "ca-cert.pem");
return path.join(getCertsDir(), "ca-cert.pem");
}
/**
@ -112,6 +111,7 @@ export function generateCertForHost(hostname) {
}
function loadCa() {
const certFolder = getCertsDir();
const keyPath = path.join(certFolder, "ca-key.pem");
const certPath = path.join(certFolder, "ca-cert.pem");

View file

@ -0,0 +1,71 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("certUtils", () => {
let installedSafeChainDir;
beforeEach(() => {
installedSafeChainDir = undefined;
mock.module("../config/safeChainDir.js", {
namedExports: {
getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain",
getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`,
},
});
});
afterEach(() => {
mock.reset();
});
it("stores CA certificates in the packaged install dir when available", async () => {
installedSafeChainDir = "/custom/safe-chain";
mock.module("fs", {
defaultExport: {
existsSync: () => false,
mkdirSync: () => {},
writeFileSync: () => {},
},
});
mock.module("node-forge", {
defaultExport: {
pki: {
getPublicKeyFingerprint: () => "fingerprint",
rsa: {
generateKeyPair: () => ({
publicKey: "public-key",
privateKey: "private-key",
}),
},
createCertificate: () => ({
publicKey: null,
serialNumber: "",
validity: {
notBefore: new Date(),
notAfter: new Date(),
},
setSubject: () => {},
setIssuer: () => {},
setExtensions: () => {},
sign: () => {},
}),
privateKeyToPem: () => "private-key-pem",
certificateToPem: () => "certificate-pem",
},
md: {
sha1: { create: () => "sha1" },
sha256: { create: () => "sha256" },
},
},
});
const { getCaCertPath } = await import("./certUtils.js");
assert.strictEqual(
getCaCertPath(),
"/custom/safe-chain/certs/ca-cert.pem",
);
});
});

View file

@ -6,6 +6,23 @@ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.j
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
/**
* Strip conditional GET headers so PyPI always returns a full 200 response
* with a body we can rewrite. Without this, pip sends If-None-Match /
* If-Modified-Since, PyPI responds 304 Not Modified (empty body), and
* safe-chain cannot rewrite it leaving pip with a cached index that still
* lists too-young versions. Those versions are then blocked at direct-download
* time with a hard 403, preventing dependency resolution from completing.
*
* @param {NodeJS.Dict<string | string[]>} headers
* @returns {NodeJS.Dict<string | string[]>}
*/
export function modifyPipInfoRequestHeaders(headers) {
delete headers["if-none-match"];
delete headers["if-modified-since"];
return headers;
}
// Match simple-index anchor tags and capture their href so we can suppress
// individual distribution links from PyPI HTML metadata responses.
const HTML_ANCHOR_HREF_RE =

View file

@ -9,6 +9,7 @@ import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.
import { interceptRequests } from "../interceptorBuilder.js";
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
import {
modifyPipInfoRequestHeaders,
modifyPipInfoResponse,
parsePipMetadataUrl,
} from "./modifyPipInfo.js";
@ -61,6 +62,7 @@ function createPipRequestHandler(registry) {
!isExcludedFromMinimumPackageAge(metadataPackageName)
) {
const newPackagesDatabase = await openNewPackagesDatabase();
reqContext.modifyRequestHeaders(modifyPipInfoRequestHeaders);
reqContext.modifyBody((body, headers) =>
modifyPipInfoResponse(
body,

View file

@ -129,6 +129,28 @@ describe("pipInterceptor minimum package age", async () => {
newlyReleasedPackageResponse = false;
});
it("strips If-None-Match and If-Modified-Since from metadata requests to prevent 304 cache bypass", async () => {
const url = "https://pypi.org/simple/foo-bar/";
newlyReleasedPackageResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
const headers = {
"if-none-match": '"some-etag"',
"if-modified-since": "Thu, 01 Jan 2026 00:00:00 GMT",
accept: "*/*",
};
result.modifyRequestHeaders(headers);
assert.equal(headers["if-none-match"], undefined, "If-None-Match must be stripped");
assert.equal(headers["if-modified-since"], undefined, "If-Modified-Since must be stripped");
assert.equal(headers.accept, "*/*", "unrelated headers must be preserved");
newlyReleasedPackageResponse = false;
});
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";

View file

@ -66,6 +66,12 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "uv",
},
{
tool: "uvx",
aikidoCommand: "aikido-uvx",
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "uvx",
},
{
tool: "pip",
aikidoCommand: "aikido-pip",
@ -127,20 +133,6 @@ export function getPackageManagerList() {
return `${tools.join(", ")}, and ${lastTool} commands`;
}
/**
* @returns {string}
*/
export function getShimsDir() {
return path.join(os.homedir(), ".safe-chain", "shims");
}
/**
* @returns {string}
*/
export function getScriptsDir() {
return path.join(os.homedir(), ".safe-chain", "scripts");
}
/**
* @param {string} executableName
*

View file

@ -1,6 +1,6 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import { tmpdir, homedir } from "node:os";
import fs from "node:fs";
import path from "path";
@ -15,6 +15,7 @@ describe("removeLinesMatchingPatternTests", () => {
mock.module("node:os", {
namedExports: {
EOL: "\r\n", // Simulate Windows line endings
homedir,
tmpdir: tmpdir,
platform: () => "linux",
},
@ -182,3 +183,30 @@ describe("removeLinesMatchingPatternTests", () => {
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines");
});
});
describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => {
it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => {
const { getSafeChainBaseDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain"));
});
it("getBinDir returns ~/.safe-chain/bin by default", async () => {
const { getBinDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin"));
});
it("getShimsDir returns ~/.safe-chain/shims by default", async () => {
const { getShimsDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims"));
});
it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => {
const { getScriptsDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts"));
});
it("getCertsDir returns ~/.safe-chain/certs by default", async () => {
const { getCertsDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getCertsDir(), path.join(homedir(), ".safe-chain", "certs"));
});
});

View file

@ -4,13 +4,28 @@
# Function to remove shim from PATH (POSIX-compliant)
remove_shim_from_path() {
echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g"
_safe_chain_phys=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P)
if [ -z "$_safe_chain_phys" ]; then
echo "$PATH"
return
fi
_path=$(echo "$PATH" | sed "s|${_safe_chain_phys}:||g")
# Also remove via dirname of $0 directly — on macOS /tmp is a symlink to /private/tmp,
# so pwd -P resolves to /private/tmp/… but PATH may still contain /tmp/….
_dir=$(dirname -- "$0")
case "$_dir" in
/*) [ "$_dir" != "$_safe_chain_phys" ] && _path=$(echo "$_path" | sed "s|${_dir}:||g") ;;
esac
echo "$_path"
}
if command -v safe-chain >/dev/null 2>&1; then
# Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops
PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@"
else
# safe-chain is not reachable — warn the user so they know protection is inactive
printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. {{PACKAGE_MANAGER}} will run without it.\n" >&2
# Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory)
original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}})
if [ -n "$original_cmd" ]; then

View file

@ -3,7 +3,8 @@ REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain
REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments
REM Remove shim directory from PATH to prevent infinite loops
set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims"
set "SHIM_DIR=%~dp0"
if "%SHIM_DIR:~-1%"=="\" set "SHIM_DIR=%SHIM_DIR:~0,-1%"
call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%"
REM Check if aikido command is available with clean PATH
@ -21,4 +22,4 @@ if %errorlevel%==0 (
REM If we get here, original command was not found
echo Error: Could not find original {{PACKAGE_MANAGER}} >&2
exit /b 1
)
)

View file

@ -1,24 +1,14 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js";
import { getPackageManagerList, knownAikidoTools } from "./helpers.js";
import {
getShimsDir,
getBinDir,
getPathWrapperTemplatePath,
} from "../config/safeChainDir.js";
import fs from "fs";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
/** @type {string} */
// This checks the current file's dirname in a way that's compatible with:
// - Modulejs (import.meta.url)
// - ES modules (__dirname)
// This is needed because safe-chain's npm package is built using ES modules,
// but building the binaries requires commonjs.
let dirname;
if (import.meta.url) {
const filename = fileURLToPath(import.meta.url);
dirname = path.dirname(filename);
} else {
dirname = __dirname;
}
/**
* Loops over the detected shells and calls the setup function for each.
@ -31,7 +21,7 @@ export async function setupCi() {
ui.emptyLine();
const shimsDir = getShimsDir();
const binDir = path.join(os.homedir(), ".safe-chain", "bin");
const binDir = getBinDir();
// Create the shims directory if it doesn't exist
if (!fs.existsSync(shimsDir)) {
fs.mkdirSync(shimsDir, { recursive: true });
@ -50,12 +40,7 @@ export async function setupCi() {
*/
function createUnixShims(shimsDir) {
// Read the template file
const templatePath = path.resolve(
dirname,
"path-wrappers",
"templates",
"unix-wrapper.template.sh"
);
const templatePath = getPathWrapperTemplatePath(import.meta.url, "unix-wrapper.template.sh");
if (!fs.existsSync(templatePath)) {
ui.writeError(`Template file not found: ${templatePath}`);
@ -89,12 +74,7 @@ function createUnixShims(shimsDir) {
*/
function createWindowsShims(shimsDir) {
// Read the template file
const templatePath = path.resolve(
dirname,
"path-wrappers",
"templates",
"windows-wrapper.template.cmd"
);
const templatePath = getPathWrapperTemplatePath(import.meta.url, "windows-wrapper.template.cmd");
if (!fs.existsSync(templatePath)) {
ui.writeError(`Windows template file not found: ${templatePath}`);

View file

@ -22,12 +22,12 @@ describe("Setup CI shell integration", () => {
fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true });
fs.writeFileSync(
path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"),
"#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\nexec {{AIKIDO_COMMAND}} \"$@\"\n",
"#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\n_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)\nexec {{AIKIDO_COMMAND}} \"$@\"\n",
"utf-8"
);
fs.writeFileSync(
path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"),
"@echo off\nREM Template for {{PACKAGE_MANAGER}}\n{{AIKIDO_COMMAND}} %*\n",
"@echo off\nset \"SHIM_DIR=%~dp0\"\n{{AIKIDO_COMMAND}} %*\n",
"utf-8"
);
@ -50,7 +50,15 @@ describe("Setup CI shell integration", () => {
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
],
getPackageManagerList: () => "npm, yarn",
},
});
mock.module("../config/safeChainDir.js", {
namedExports: {
getShimsDir: () => mockShimsDir,
getBinDir: () => path.join(mockHomeDir, ".safe-chain", "bin"),
getPathWrapperTemplatePath: (_moduleUrl, fileName) =>
path.join(mockTemplateDir, "path-wrappers", "templates", fileName),
},
});
@ -63,22 +71,6 @@ describe("Setup CI shell integration", () => {
},
});
// Mock path module to resolve templates correctly
mock.module("path", {
namedExports: {
join: path.join,
dirname: () => mockTemplateDir,
resolve: (...args) => path.resolve(mockTemplateDir, ...args.slice(1)),
},
});
// Mock fileURLToPath
mock.module("url", {
namedExports: {
fileURLToPath: () => path.join(mockTemplateDir, "setup-ci.js"),
},
});
// Import setupCi module after mocking
setupCi = (await import("./setup-ci.js")).setupCi;
});
@ -119,6 +111,10 @@ describe("Setup CI shell integration", () => {
const npmShimContent = fs.readFileSync(npmShimPath, "utf-8");
assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm");
assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang");
assert.ok(
npmShimContent.includes("_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)"),
"npm shim should derive the shims directory from its own location",
);
});
it("should create Windows .cmd shims on win32 platform", async () => {
@ -142,6 +138,10 @@ describe("Setup CI shell integration", () => {
assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm");
assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header");
assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing");
assert.ok(
npmShimContent.includes('set "SHIM_DIR=%~dp0"'),
"npm.cmd should derive the shims directory from its own location",
);
// Verify Unix shims were NOT created
const unixNpmShim = path.join(mockShimsDir, "npm");

View file

@ -1,28 +1,10 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import {
knownAikidoTools,
getPackageManagerList,
getScriptsDir,
} from "./helpers.js";
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
import { getScriptsDir, getStartupScriptSourcePath } from "../config/safeChainDir.js";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
/** @type {string} */
// This checks the current file's dirname in a way that's compatible with:
// - Modulejs (import.meta.url)
// - ES modules (__dirname)
// This is needed because safe-chain's npm package is built using ES modules,
// but building the binaries requires commonjs.
let dirname;
if (import.meta.url) {
const filename = fileURLToPath(import.meta.url);
dirname = path.dirname(filename);
} else {
dirname = __dirname;
}
/**
* Loops over the detected shells and calls the setup function for each.
@ -122,8 +104,7 @@ function copyStartupFiles() {
fs.mkdirSync(targetDir, { recursive: true });
}
// Use absolute path for source
const sourcePath = path.join(dirname, "startup-scripts", file);
const sourcePath = getStartupScriptSourcePath(import.meta.url, file);
fs.copyFileSync(sourcePath, targetPath);
}
}

View file

@ -1,4 +1,7 @@
set -gx PATH $PATH $HOME/.safe-chain/bin
set -l safe_chain_script (status filename)
set -l safe_chain_scripts_dir (dirname $safe_chain_script)
set -l safe_chain_base (dirname $safe_chain_scripts_dir)
set -gx PATH $PATH $safe_chain_base/bin
function npx
wrapSafeChainCommand "npx" $argv
@ -51,6 +54,10 @@ function uv
wrapSafeChainCommand "uv" $argv
end
function uvx
wrapSafeChainCommand "uvx" $argv
end
function poetry
wrapSafeChainCommand "poetry" $argv
end

View file

@ -1,4 +1,16 @@
export PATH="$PATH:$HOME/.safe-chain/bin"
if [ -n "${BASH_SOURCE[0]:-}" ]; then
_sc_script_path="${BASH_SOURCE[0]}"
elif [ -n "${ZSH_VERSION:-}" ]; then
# ${(%):-%x} uses Zsh prompt expansion to get the sourced file's path.
# eval is required so other shells don't try to parse the Zsh-specific syntax.
eval '_sc_script_path="${(%):-%x}"'
else
_sc_script_path="$0"
fi
_sc_scripts_dir=$(CDPATH= cd -- "$(dirname -- "$_sc_script_path")" 2>/dev/null && pwd -P)
_sc_base=$(dirname -- "$_sc_scripts_dir")
export PATH="$PATH:${_sc_base}/bin"
unset _sc_base _sc_script_path _sc_scripts_dir
function npx() {
wrapSafeChainCommand "npx" "$@"
@ -47,6 +59,10 @@ function uv() {
wrapSafeChainCommand "uv" "$@"
}
function uvx() {
wrapSafeChainCommand "uvx" "$@"
}
function poetry() {
wrapSafeChainCommand "poetry" "$@"
}

View file

@ -2,7 +2,8 @@
# $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell
$isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true }
$pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' }
$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
$safeChainBase = Split-Path -Parent $PSScriptRoot
$safeChainBin = Join-Path $safeChainBase 'bin'
$env:PATH = "$env:PATH$pathSeparator$safeChainBin"
function npx {
@ -52,6 +53,10 @@ function uv {
Invoke-WrappedCommand "uv" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function uvx {
Invoke-WrappedCommand "uvx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function poetry {
Invoke-WrappedCommand "poetry" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}

View file

@ -3,8 +3,10 @@ import {
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync, spawnSync } from "child_process";
import * as os from "os";
import path from "path";
const shellName = "Bash";
const executableName = "bash";
@ -32,10 +34,10 @@ function teardown(tools) {
);
}
// Removes the line that sources the safe-chain bash initialization script (~/.safe-chain/scripts/init-posix.sh)
// Remove sourcing line to disable safe-chain shell integration
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/,
/^source\s+.*init-posix\.sh.*#\s*Safe-chain/,
eol
);
@ -44,10 +46,11 @@ function teardown(tools) {
function setup() {
const startupFile = getStartupFile();
const scriptsDir = getShellScriptsDir();
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`,
`source ${path.posix.join(scriptsDir, "init-posix.sh")} # Safe-chain bash initialization script`,
eol
);
@ -94,6 +97,51 @@ function windowsFixPath(path) {
}
}
function getShellScriptsDir() {
return toBashPath(getScriptsDir());
}
/**
* @param {string} path
*
* @returns {string}
*/
function toBashPath(path) {
try {
if (os.platform() !== "win32") {
return path.replace(/\\/g, "/");
}
const directWindowsPath = windowsPathToBashPath(path);
if (directWindowsPath) {
return directWindowsPath;
}
if (hasCygpath()) {
return convertCygwinPathToUnix(path);
}
return path.replace(/\\/g, "/");
} catch {
return path.replace(/\\/g, "/");
}
}
/**
* @param {string} path
*
* @returns {string | undefined}
*/
function windowsPathToBashPath(path) {
const match = /^([A-Za-z]):[\\/](.*)$/.exec(path);
if (!match) {
return undefined;
}
const [, driveLetter, rest] = match;
return `/${driveLetter.toLowerCase()}/${rest.replace(/\\/g, "/")}`;
}
function hasCygpath() {
try {
var result = spawnSync("where", ["cygpath"], { shell: executableName });
@ -123,18 +171,40 @@ function cygpathw(path) {
}
}
/**
* @param {string} path
*
* @returns {string}
*/
function convertCygwinPathToUnix(path) {
try {
var result = spawnSync("cygpath", ["-u", path], {
encoding: "utf8",
shell: executableName,
});
if (result.status === 0) {
return result.stdout.trim();
}
return path.replace(/\\/g, "/");
} catch {
return path.replace(/\\/g, "/");
}
}
function getManualTeardownInstructions() {
const scriptsDir = getShellScriptsDir();
return [
`Remove the following line from your ~/.bashrc file:`,
` source ~/.safe-chain/scripts/init-posix.sh`,
` source ${path.posix.join(scriptsDir, "init-posix.sh")}`,
`Then restart your terminal or run: source ~/.bashrc`,
];
}
function getManualSetupInstructions() {
const scriptsDir = getShellScriptsDir();
return [
`Add the following line to your ~/.bashrc file:`,
` source ~/.safe-chain/scripts/init-posix.sh`,
` source ${path.posix.join(scriptsDir, "init-posix.sh")}`,
`Then restart your terminal or run: source ~/.bashrc`,
];
}

View file

@ -9,6 +9,7 @@ describe("Bash shell integration", () => {
let mockStartupFile;
let bash;
let windowsCygwinPath = "";
let mockScriptsDir = "/test-home/.safe-chain/scripts";
let platform = "linux";
beforeEach(async () => {
@ -35,6 +36,12 @@ describe("Bash shell integration", () => {
},
});
mock.module("../../config/safeChainDir.js", {
namedExports: {
getScriptsDir: () => mockScriptsDir,
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
@ -61,6 +68,17 @@ describe("Bash shell integration", () => {
stdout: windowsCygwinPath + "\n",
};
}
if (
command === "cygpath" &&
args[0] === "-u" &&
args[1] === mockScriptsDir
) {
return {
status: 0,
stdout: "/c/test-home/.safe-chain/scripts\n",
};
}
},
},
});
@ -87,6 +105,7 @@ describe("Bash shell integration", () => {
// Reset mocks
mock.reset();
mockScriptsDir = "/test-home/.safe-chain/scripts";
platform = "linux";
});
@ -109,7 +128,7 @@ describe("Bash shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
)
);
});
@ -129,7 +148,24 @@ describe("Bash shell integration", () => {
const content = fs.readFileSync(windowsCygwinPath, "utf-8");
assert.ok(
content.includes(
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
"source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
)
);
});
it("should write a bash-compatible scripts path on Windows", () => {
platform = "win32";
windowsCygwinPath = mockStartupFile;
mockScriptsDir = "C:\\test-home\\.safe-chain\\scripts";
mockStartupFile = "DUMMY";
const result = bash.setup();
assert.strictEqual(result, true);
const content = fs.readFileSync(windowsCygwinPath, "utf-8");
assert.ok(
content.includes(
"source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
)
);
});
@ -209,13 +245,13 @@ describe("Bash shell integration", () => {
// Setup
bash.setup(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh"));
// Teardown
bash.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")
);
});
@ -236,7 +272,7 @@ describe("Bash shell integration", () => {
const initialContent = [
"#!/bin/bash",
"alias npm='old-npm'",
"source ~/.safe-chain/scripts/init-posix.sh",
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script",
"alias ls='ls --color=auto'",
].join("\n");
@ -247,7 +283,7 @@ describe("Bash shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script")
);
assert.ok(content.includes("alias ls="));
});

View file

@ -3,7 +3,9 @@ import {
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync } from "child_process";
import path from "path";
const shellName = "Fish";
const executableName = "fish";
@ -31,10 +33,10 @@ function teardown(tools) {
);
}
// Removes the line that sources the safe-chain fish initialization script (~/.safe-chain/scripts/init-fish.fish)
// Remove sourcing line to prevent safe-chain initialization in future shell sessions
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/,
/^source\s+.*init-fish\.fish.*#\s*Safe-chain/,
eol
);
@ -46,7 +48,7 @@ function setup() {
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`,
`source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`,
eol
);
@ -69,7 +71,7 @@ function getStartupFile() {
function getManualTeardownInstructions() {
return [
`Remove the following line from your ~/.config/fish/config.fish file:`,
` source ~/.safe-chain/scripts/init-fish.fish`,
` source ${path.join(getScriptsDir(), "init-fish.fish")}`,
`Then restart your terminal or run: source ~/.config/fish/config.fish`,
];
}
@ -77,7 +79,7 @@ function getManualTeardownInstructions() {
function getManualSetupInstructions() {
return [
`Add the following line to your ~/.config/fish/config.fish file:`,
` source ~/.safe-chain/scripts/init-fish.fish`,
` source ${path.join(getScriptsDir(), "init-fish.fish")}`,
`Then restart your terminal or run: source ~/.config/fish/config.fish`,
];
}

View file

@ -33,6 +33,12 @@ describe("Fish shell integration", () => {
},
});
mock.module("../../config/safeChainDir.js", {
namedExports: {
getScriptsDir: () => "/test-home/.safe-chain/scripts",
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
@ -72,7 +78,7 @@ describe("Fish shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script')
content.includes('source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script')
);
});
@ -81,7 +87,7 @@ describe("Fish shell integration", () => {
fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
assert.strictEqual(sourceMatches, 2, "Should allow multiple source lines (helper doesn't dedupe)");
});
});
@ -93,7 +99,7 @@ describe("Fish shell integration", () => {
"alias npm 'aikido-npm'",
"alias npx 'aikido-npx'",
"alias yarn 'aikido-yarn'",
"source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script",
"source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script",
"alias ls 'ls --color=auto'",
"alias grep 'grep --color=auto'",
].join("\n");
@ -107,7 +113,7 @@ describe("Fish shell integration", () => {
assert.ok(!content.includes("alias npm "));
assert.ok(!content.includes("alias npx "));
assert.ok(!content.includes("alias yarn "));
assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish"));
assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish"));
assert.ok(content.includes("alias ls "));
assert.ok(content.includes("alias grep "));
});
@ -162,12 +168,12 @@ describe("Fish shell integration", () => {
// Setup
fish.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes('source ~/.safe-chain/scripts/init-fish.fish'));
assert.ok(content.includes('source /test-home/.safe-chain/scripts/init-fish.fish'));
// Teardown
fish.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish"));
assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish"));
});
it("should handle multiple setup calls", () => {
@ -176,7 +182,7 @@ describe("Fish shell integration", () => {
fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle");
});
});

View file

@ -4,7 +4,9 @@ import {
removeLinesMatchingPattern,
validatePowerShellExecutionPolicy,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync } from "child_process";
import path from "path";
const shellName = "PowerShell Core";
const executableName = "pwsh";
@ -30,10 +32,10 @@ function teardown(tools) {
);
}
// Remove the line that sources the safe-chain PowerShell initialization script
// Remove sourcing line to prevent shell from loading safe-chain after uninstallation
removeLinesMatchingPattern(
startupFile,
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/,
/^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/,
);
return true;
@ -52,7 +54,7 @@ async function setup() {
addLineToFile(
startupFile,
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`,
`. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`,
);
return true;
@ -74,7 +76,7 @@ function getStartupFile() {
function getManualTeardownInstructions() {
return [
`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`,
` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`,
`Then restart your terminal or run: . $PROFILE`,
];
}
@ -82,7 +84,7 @@ function getManualTeardownInstructions() {
function getManualSetupInstructions() {
return [
`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`,
` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`,
`Then restart your terminal or run: . $PROFILE`,
];
}

View file

@ -43,6 +43,12 @@ describe("PowerShell Core shell integration", () => {
},
});
mock.module("../../config/safeChainDir.js", {
namedExports: {
getScriptsDir: () => "/test-home/.safe-chain/scripts",
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
@ -83,7 +89,7 @@ describe("PowerShell Core shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
),
);
});
@ -93,7 +99,7 @@ describe("PowerShell Core shell integration", () => {
it("should remove init-pwsh.ps1 source line", () => {
const initialContent = [
"# PowerShell profile",
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
@ -105,7 +111,7 @@ describe("PowerShell Core shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
!content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
);
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
@ -180,14 +186,14 @@ describe("PowerShell Core shell integration", () => {
await powershell.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
);
// Teardown
powershell.teardown(knownAikidoTools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
!content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
);
});
@ -198,7 +204,7 @@ describe("PowerShell Core shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) ||
[]
).length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");

View file

@ -4,7 +4,9 @@ import {
removeLinesMatchingPattern,
validatePowerShellExecutionPolicy,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync } from "child_process";
import path from "path";
const shellName = "Windows PowerShell";
const executableName = "powershell";
@ -30,10 +32,10 @@ function teardown(tools) {
);
}
// Remove the line that sources the safe-chain PowerShell initialization script
// Remove sourcing line to clean up safe-chain integration from the shell profile
removeLinesMatchingPattern(
startupFile,
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/,
/^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/,
);
return true;
@ -52,7 +54,7 @@ async function setup() {
addLineToFile(
startupFile,
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`,
`. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`,
);
return true;
@ -74,7 +76,7 @@ function getStartupFile() {
function getManualTeardownInstructions() {
return [
`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`,
` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`,
`Then restart your terminal or run: . $PROFILE`,
];
}
@ -82,7 +84,7 @@ function getManualTeardownInstructions() {
function getManualSetupInstructions() {
return [
`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`,
` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`,
`Then restart your terminal or run: . $PROFILE`,
];
}

View file

@ -43,6 +43,12 @@ describe("Windows PowerShell shell integration", () => {
},
});
mock.module("../../config/safeChainDir.js", {
namedExports: {
getScriptsDir: () => "/test-home/.safe-chain/scripts",
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
@ -83,7 +89,7 @@ describe("Windows PowerShell shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
),
);
});
@ -93,7 +99,7 @@ describe("Windows PowerShell shell integration", () => {
it("should remove init-pwsh.ps1 source line", () => {
const initialContent = [
"# Windows PowerShell profile",
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
"Set-Alias ls Get-ChildItem",
"Set-Alias grep Select-String",
].join("\n");
@ -105,7 +111,7 @@ describe("Windows PowerShell shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
!content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
);
assert.ok(content.includes("Set-Alias ls "));
assert.ok(content.includes("Set-Alias grep "));
@ -180,14 +186,14 @@ describe("Windows PowerShell shell integration", () => {
await windowsPowershell.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
);
// Teardown
windowsPowershell.teardown(knownAikidoTools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
!content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
);
});
@ -198,7 +204,7 @@ describe("Windows PowerShell shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = (
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) ||
[]
).length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");

View file

@ -3,7 +3,9 @@ import {
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync } from "child_process";
import path from "path";
const shellName = "Zsh";
const executableName = "zsh";
@ -31,10 +33,10 @@ function teardown(tools) {
);
}
// Removes the line that sources the safe-chain zsh initialization script (~/.safe-chain/scripts/init-posix.sh)
// Remove sourcing line to complete shell integration cleanup
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/,
/^source\s+.*init-posix\.sh.*#\s*Safe-chain/,
eol
);
@ -46,7 +48,7 @@ function setup() {
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`,
`source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`,
eol
);
@ -69,7 +71,7 @@ function getStartupFile() {
function getManualTeardownInstructions() {
return [
`Remove the following line from your ~/.zshrc file:`,
` source ~/.safe-chain/scripts/init-posix.sh`,
` source ${path.join(getScriptsDir(), "init-posix.sh")}`,
`Then restart your terminal or run: source ~/.zshrc`,
];
}
@ -77,7 +79,7 @@ function getManualTeardownInstructions() {
function getManualSetupInstructions() {
return [
`Add the following line to your ~/.zshrc file:`,
` source ~/.safe-chain/scripts/init-posix.sh`,
` source ${path.join(getScriptsDir(), "init-posix.sh")}`,
`Then restart your terminal or run: source ~/.zshrc`,
];
}

View file

@ -33,6 +33,12 @@ describe("Zsh shell integration", () => {
},
});
mock.module("../../config/safeChainDir.js", {
namedExports: {
getScriptsDir: () => "/test-home/.safe-chain/scripts",
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
@ -73,7 +79,7 @@ describe("Zsh shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes(
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script"
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script"
)
);
});
@ -83,7 +89,7 @@ describe("Zsh shell integration", () => {
assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh"));
});
});
@ -114,7 +120,7 @@ describe("Zsh shell integration", () => {
it("should remove zsh initialization script source line", () => {
const initialContent = [
"#!/bin/zsh",
"source ~/.safe-chain/scripts/init-posix.sh",
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script",
"alias ls='ls --color=auto'",
].join("\n");
@ -125,7 +131,7 @@ describe("Zsh shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script")
);
assert.ok(content.includes("alias ls="));
});
@ -180,13 +186,13 @@ describe("Zsh shell integration", () => {
// Setup
zsh.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh"));
// Teardown
zsh.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")
);
});
@ -207,7 +213,7 @@ describe("Zsh shell integration", () => {
const initialContent = [
"#!/bin/zsh",
"alias npm='old-npm'",
"source ~/.safe-chain/scripts/init-posix.sh",
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script",
"alias ls='ls --color=auto'",
].join("\n");
@ -218,7 +224,7 @@ describe("Zsh shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm="));
assert.ok(
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script")
);
assert.ok(content.includes("alias ls="));
});

View file

@ -1,7 +1,8 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js";
import { knownAikidoTools, getPackageManagerList, getShimsDir, getScriptsDir } from "./helpers.js";
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
import { getShimsDir, getScriptsDir } from "../config/safeChainDir.js";
import fs from "fs";
/**
@ -109,4 +110,5 @@ export async function teardownDirectories() {
);
}
}
}

View file

@ -1,111 +0,0 @@
import { platform } from 'os';
import { ui } from "../environment/userInteraction.js";
import { readFileSync, existsSync } from "node:fs";
import {randomUUID} from "node:crypto";
import {createWriteStream} from "fs";
import archiver from 'archiver';
import path from "node:path";
export async function printUltimateLogs() {
const { proxyLogPath, ultimateLogPath, proxyErrLogPath, ultimateErrLogPath } = getPathsPerPlatform();
await printLogs(
"SafeChain Proxy",
proxyLogPath,
proxyErrLogPath
);
await printLogs(
"SafeChain Ultimate",
ultimateLogPath,
ultimateErrLogPath
);
}
export async function troubleshootingExport() {
const { logDir } = getPathsPerPlatform();
return new Promise((resolve, reject) => {
if (!existsSync(logDir)) {
ui.writeError(`Log directory not found: ${logDir}`);
reject(new Error(`Log directory not found: ${logDir}`));
return;
}
const date = new Date().toISOString().split('T')[0];
const uuid = randomUUID();
const zipFileName = `safechain-ultimate-${date}-${uuid}.zip`;
const output = createWriteStream(zipFileName);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => {
ui.writeInformation(`Logs collected and zipped as: ${path.resolve(zipFileName)}`);
resolve(zipFileName);
});
archive.on('error', (/** @type {Error} */ err) => {
ui.writeError(`Failed to zip logs: ${err.message}`);
reject(err);
});
archive.pipe(output);
archive.directory(logDir, false);
archive.finalize();
});
}
function getPathsPerPlatform() {
const os = platform();
if (os === 'win32') {
const logDir = `C:\\ProgramData\\AikidoSecurity\\SafeChainUltimate\\logs`;
return {
logDir,
proxyLogPath: `${logDir}\\SafeChainProxy.log`,
ultimateLogPath: `${logDir}\\SafeChainUltimate.log`,
proxyErrLogPath: `${logDir}\\SafeChainProxy.err`,
ultimateErrLogPath: `${logDir}\\SafeChainUltimate.err`,
};
} else if (os === 'darwin') {
const logDir = `/Library/Logs/AikidoSecurity/SafeChainUltimate`;
return {
logDir,
proxyLogPath: `${logDir}/safechain-proxy.log`,
ultimateLogPath: `${logDir}/safechain-ultimate.log`,
proxyErrLogPath: `${logDir}/safechain-proxy.error.log`,
ultimateErrLogPath: `${logDir}/safechain-ultimate.error.log`,
};
} else {
throw new Error('Unsupported platform for log printing.');
}
}
/**
* @param {string} appName
* @param {string} logPath
* @param {string} errLogPath
*/
async function printLogs(appName, logPath, errLogPath) {
ui.writeInformation(`=== ${appName} Logs ===`);
try {
if (existsSync(logPath)) {
const logs = readFileSync(logPath, "utf-8");
ui.writeInformation(logs);
} else {
ui.writeWarning(`${appName} log file not found: ${logPath}`);
}
} catch (error) {
ui.writeError(`Failed to read ${appName} logs: ${error}`);
}
ui.writeInformation(`=== ${appName} Error Logs ===`);
try {
if (existsSync(errLogPath)) {
const errLogs = readFileSync(errLogPath, "utf-8");
ui.writeInformation(errLogs);
} else {
ui.writeInformation(`No error log file found for ${appName}.`);
}
} catch (error) {
ui.writeError(`Failed to read ${appName} error logs: ${error}`);
}
}