From a0fb8d6b3d88f6a467e1a566e5802fa671c9e9b8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 08:57:08 -0700 Subject: [PATCH 01/40] Add env var support for home dir --- .../src/config/environmentVariables.js | 11 ++++ .../src/config/environmentVariables.spec.js | 30 +++++++++ .../src/shell-integration/helpers.js | 21 +++++- .../src/shell-integration/helpers.spec.js | 66 ++++++++++++++++++- 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 packages/safe-chain/src/config/environmentVariables.spec.js diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 932eff7..b76a413 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -55,3 +55,14 @@ export function getMinimumPackageAgeExclusions() { export function getMalwareListBaseUrl() { return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL; } + +/** + * Gets the safe-chain base directory from environment variable. + * When set, all safe-chain data (bin, shims, scripts) will be placed under this directory + * instead of the default ~/.safe-chain, enabling system-wide installations. + * Example: "/usr/local/.safe-chain" + * @returns {string | undefined} + */ +export function getSafeChainDir() { + return process.env.SAFE_CHAIN_DIR; +} diff --git a/packages/safe-chain/src/config/environmentVariables.spec.js b/packages/safe-chain/src/config/environmentVariables.spec.js new file mode 100644 index 0000000..2cbdd0f --- /dev/null +++ b/packages/safe-chain/src/config/environmentVariables.spec.js @@ -0,0 +1,30 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; + +const { getSafeChainDir } = await import("./environmentVariables.js"); + +describe("getSafeChainDir", () => { + let original; + + beforeEach(() => { + original = process.env.SAFE_CHAIN_DIR; + }); + + afterEach(() => { + if (original !== undefined) { + process.env.SAFE_CHAIN_DIR = original; + } else { + delete process.env.SAFE_CHAIN_DIR; + } + }); + + it("returns undefined when SAFE_CHAIN_DIR is not set", () => { + delete process.env.SAFE_CHAIN_DIR; + assert.strictEqual(getSafeChainDir(), undefined); + }); + + it("returns the value of SAFE_CHAIN_DIR when set", () => { + process.env.SAFE_CHAIN_DIR = "/usr/local/.safe-chain"; + assert.strictEqual(getSafeChainDir(), "/usr/local/.safe-chain"); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 18ba52e..7ccfd99 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -3,6 +3,7 @@ import * as os from "os"; import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { getSafeChainDir } from "../config/environmentVariables.js"; import { safeSpawn } from "../utils/safeSpawn.js"; import { ui } from "../environment/userInteraction.js"; @@ -121,18 +122,34 @@ export function getPackageManagerList() { return `${tools.join(", ")}, and ${lastTool} commands`; } +/** + * Returns the safe-chain base directory. + * Uses SAFE_CHAIN_DIR environment variable when set, otherwise defaults to ~/.safe-chain. + * @returns {string} + */ +export function getSafeChainBaseDir() { + return getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); +} + +/** + * @returns {string} + */ +export function getBinDir() { + return path.join(getSafeChainBaseDir(), "bin"); +} + /** * @returns {string} */ export function getShimsDir() { - return path.join(os.homedir(), ".safe-chain", "shims"); + return path.join(getSafeChainBaseDir(), "shims"); } /** * @returns {string} */ export function getScriptsDir() { - return path.join(os.homedir(), ".safe-chain", "scripts"); + return path.join(getSafeChainBaseDir(), "scripts"); } /** diff --git a/packages/safe-chain/src/shell-integration/helpers.spec.js b/packages/safe-chain/src/shell-integration/helpers.spec.js index 4f18c36..8fd172b 100644 --- a/packages/safe-chain/src/shell-integration/helpers.spec.js +++ b/packages/safe-chain/src/shell-integration/helpers.spec.js @@ -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,66 @@ describe("removeLinesMatchingPatternTests", () => { assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines"); }); }); + +describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => { + const customDir = "/usr/local/.safe-chain"; + + let originalSafeChainDir; + + beforeEach(() => { + originalSafeChainDir = process.env.SAFE_CHAIN_DIR; + delete process.env.SAFE_CHAIN_DIR; + }); + + afterEach(() => { + if (originalSafeChainDir !== undefined) { + process.env.SAFE_CHAIN_DIR = originalSafeChainDir; + } else { + delete process.env.SAFE_CHAIN_DIR; + } + }); + + it("defaults base dir to ~/.safe-chain when SAFE_CHAIN_DIR is not set", async () => { + const { getSafeChainBaseDir } = await import("./helpers.js"); + assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain")); + }); + + it("uses SAFE_CHAIN_DIR as base dir when set", async () => { + process.env.SAFE_CHAIN_DIR = customDir; + const { getSafeChainBaseDir } = await import("./helpers.js"); + assert.strictEqual(getSafeChainBaseDir(), customDir); + }); + + it("getBinDir returns ~/.safe-chain/bin by default", async () => { + const { getBinDir } = await import("./helpers.js"); + assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin")); + }); + + it("getBinDir returns custom dir + /bin when SAFE_CHAIN_DIR is set", async () => { + process.env.SAFE_CHAIN_DIR = customDir; + const { getBinDir } = await import("./helpers.js"); + assert.strictEqual(getBinDir(), `${customDir}/bin`); + }); + + it("getShimsDir returns ~/.safe-chain/shims by default", async () => { + const { getShimsDir } = await import("./helpers.js"); + assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims")); + }); + + it("getShimsDir returns custom dir + /shims when SAFE_CHAIN_DIR is set", async () => { + process.env.SAFE_CHAIN_DIR = customDir; + const { getShimsDir } = await import("./helpers.js"); + assert.strictEqual(getShimsDir(), `${customDir}/shims`); + }); + + it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => { + const { getScriptsDir } = await import("./helpers.js"); + assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts")); + }); + + it("getScriptsDir returns custom dir + /scripts when SAFE_CHAIN_DIR is set", async () => { + process.env.SAFE_CHAIN_DIR = customDir; + const { getScriptsDir } = await import("./helpers.js"); + assert.strictEqual(getScriptsDir(), `${customDir}/scripts`); + }); +}); From 422963b38a3f279ac8282430830973c677f650ec Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 09:05:29 -0700 Subject: [PATCH 02/40] Do not hardcode path in setup-ci --- packages/safe-chain/src/shell-integration/setup-ci.js | 4 ++-- packages/safe-chain/src/shell-integration/setup-ci.spec.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 762bd9b..1986bba 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -1,6 +1,6 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; -import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js"; +import { getPackageManagerList, knownAikidoTools, getShimsDir, getBinDir } from "./helpers.js"; import fs from "fs"; import os from "os"; import path from "path"; @@ -31,7 +31,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 }); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index b437157..c0a5ca1 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -51,6 +51,7 @@ describe("Setup CI shell integration", () => { ], getPackageManagerList: () => "npm, yarn", getShimsDir: () => mockShimsDir, + getBinDir: () => path.join(mockHomeDir, ".safe-chain", "bin"), }, }); From 1635bee387f72bfb126ae8a0f5854ae4b65b7d8e Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 10:18:49 -0700 Subject: [PATCH 03/40] Add support for setup-ci with custom install dir --- .../templates/unix-wrapper.template.sh | 10 +- .../startup-scripts/init-fish.fish | 3 +- .../startup-scripts/init-posix.sh | 2 +- .../startup-scripts/init-pwsh.ps1 | 3 +- test/e2e/DockerTestContainer.js | 8 +- test/e2e/safe-chain-dir.e2e.spec.js | 115 ++++++++++++++++++ 6 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 test/e2e/safe-chain-dir.e2e.spec.js diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index d6c9efd..94ed364 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,13 +4,21 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" + _safe_chain_shims="${SAFE_CHAIN_DIR:-$HOME/.safe-chain}/shims" + echo "$PATH" | sed "s|${_safe_chain_shims}:||g" } 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 + if [ -n "$SAFE_CHAIN_DIR" ]; then + printf "\033[43;30mWarning:\033[0m safe-chain is not accessible. Check that '%s/bin' is readable and executable by the current user.\n" "$SAFE_CHAIN_DIR" >&2 + else + 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 + fi + # 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 diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 13463f6..a705634 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,4 +1,5 @@ -set -gx PATH $PATH $HOME/.safe-chain/bin +set -l safe_chain_base (if set -q SAFE_CHAIN_DIR; echo $SAFE_CHAIN_DIR; else; echo $HOME/.safe-chain; end) +set -gx PATH $PATH $safe_chain_base/bin function npx wrapSafeChainCommand "npx" $argv diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index ebaaf3c..b567902 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,4 +1,4 @@ -export PATH="$PATH:$HOME/.safe-chain/bin" +export PATH="$PATH:${SAFE_CHAIN_DIR:-$HOME/.safe-chain}/bin" function npx() { wrapSafeChainCommand "npx" "$@" diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index f82d0fc..bcdd1c6 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -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 = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HOME '.safe-chain' } +$safeChainBin = Join-Path $safeChainBase 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" function npx { diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index 95a467c..cd48c4e 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -84,10 +84,14 @@ export class DockerTestContainer { } } - async openShell(shell) { + async openShell(shell, { user } = {}) { + const execArgs = user + ? ["exec", "-it", "-u", user, this.containerName, shell] + : ["exec", "-it", this.containerName, shell]; + let ptyProcess = pty.spawn( "docker", - ["exec", "-it", this.containerName, shell], + execArgs, { name: "xterm-color", cols: 80, diff --git a/test/e2e/safe-chain-dir.e2e.spec.js b/test/e2e/safe-chain-dir.e2e.spec.js new file mode 100644 index 0000000..e28bd72 --- /dev/null +++ b/test/e2e/safe-chain-dir.e2e.spec.js @@ -0,0 +1,115 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +const CUSTOM_DIR = "/usr/local/.safe-chain"; + +describe("E2E: SAFE_CHAIN_DIR support", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it("setup-ci installs shims in the custom directory when SAFE_CHAIN_DIR is set", async () => { + const shell = await container.openShell("bash"); + await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await shell.runCommand("safe-chain setup-ci"); + + // Shims should be in the custom dir + const customShimResult = await shell.runCommand( + `test -f ${CUSTOM_DIR}/shims/npm && echo "EXISTS"` + ); + assert.ok( + customShimResult.output.includes("EXISTS"), + `Expected npm shim at ${CUSTOM_DIR}/shims/npm. Output:\n${customShimResult.output}` + ); + + // Default location should NOT have been created + const defaultShimResult = await shell.runCommand( + `test -d $HOME/.safe-chain/shims && echo "EXISTS" || echo "ABSENT"` + ); + assert.ok( + defaultShimResult.output.includes("ABSENT"), + `Expected default shims dir to be absent. Output:\n${defaultShimResult.output}` + ); + }); + + it("setup-ci writes the custom directory path to GITHUB_PATH when SAFE_CHAIN_DIR is set", async () => { + const shell = await container.openShell("bash"); + await shell.runCommand("export GITHUB_PATH=/tmp/github_path"); + await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await shell.runCommand("safe-chain setup-ci"); + + const result = await shell.runCommand("cat /tmp/github_path"); + assert.ok( + result.output.includes(`${CUSTOM_DIR}/shims`), + `Expected GITHUB_PATH to contain custom shims dir. Output:\n${result.output}` + ); + assert.ok( + result.output.includes(`${CUSTOM_DIR}/bin`), + `Expected GITHUB_PATH to contain custom bin dir. Output:\n${result.output}` + ); + }); + + it("safe-chain protects a non-root user when installed to a shared dir with SAFE_CHAIN_DIR", async () => { + // Step 1: create a non-root user inside the container + container.dockerExec("useradd -m safeuser"); + + // Step 2: as root, run setup-ci with the shared SAFE_CHAIN_DIR + const rootShell = await container.openShell("bash"); + await rootShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await rootShell.runCommand("safe-chain setup-ci"); + + // Step 3: simulate what install-safe-chain.sh does — place the safe-chain binary + // in SAFE_CHAIN_DIR/bin. In Docker tests safe-chain is installed via npm/Volta, + // so we symlink it there. + container.dockerExec(`mkdir -p ${CUSTOM_DIR}/bin`); + container.dockerExec( + `ln -sf \\$(which safe-chain) ${CUSTOM_DIR}/bin/safe-chain` + ); + + // Step 4: make npm accessible to all users (in real Dockerfiles npm is installed + // before the user switch; here Volta manages it for root, so we symlink it). + container.dockerExec("ln -sf \\$(which npm) /usr/local/bin/npm"); + + // Step 5: make the shared safe-chain dir readable + executable by all users + container.dockerExec(`chmod -R a+rx ${CUSTOM_DIR}`); + + // Step 6: Volta installs under /root/.volta which is only accessible to root by + // default. /root/ itself is mode 700, so safeuser can't traverse into it even + // if .volta/ is world-readable. Fix both levels. Safe in a throw-away container. + container.dockerExec("chmod a+x /root && chmod -R a+rX /root/.volta"); + + // Step 7: as the non-root user, set SAFE_CHAIN_DIR and PATH, then run npm. + // SAFE_CHAIN_DIR must be set so the shim knows which dir to strip from PATH + // when invoking the real npm (prevents infinite loop). + const userShell = await container.openShell("bash", { user: "safeuser" }); + await userShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + // Reuse root's Volta dir so safeuser doesn't trigger a slow first-run setup + await userShell.runCommand("export VOLTA_HOME=/root/.volta"); + await userShell.runCommand( + `export PATH="${CUSTOM_DIR}/shims:${CUSTOM_DIR}/bin:$PATH"` + ); + const result = await userShell.runCommand( + "npm i axios@1.13.0 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("Safe-chain: Scanned"), + `Expected safe-chain to protect non-root user. Output:\n${result.output}` + ); + }); +}); From 24af6f21eb4d05b84331dcb75d88d9c4a0db732c Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 12:09:40 -0700 Subject: [PATCH 04/40] Add regular setup support --- .../supported-shells/bash.js | 8 +-- .../supported-shells/bash.spec.js | 13 ++--- .../supported-shells/fish.js | 8 +-- .../supported-shells/fish.spec.js | 15 +++--- .../supported-shells/powershell.js | 8 +-- .../supported-shells/powershell.spec.js | 13 ++--- .../supported-shells/windowsPowershell.js | 8 +-- .../windowsPowershell.spec.js | 13 ++--- .../shell-integration/supported-shells/zsh.js | 8 +-- .../supported-shells/zsh.spec.js | 17 ++++--- test/e2e/bun.e2e.spec.js | 35 +++++++++++++ test/e2e/npm-ci.e2e.spec.js | 43 ++++++++++++++++ test/e2e/npm.e2e.spec.js | 35 +++++++++++++ test/e2e/pip-ci.e2e.spec.js | 40 +++++++++++++++ test/e2e/pip.e2e.spec.js | 36 +++++++++++++ test/e2e/pipx.e2e.spec.js | 35 +++++++++++++ test/e2e/pnpm-ci.e2e.spec.js | 41 +++++++++++++++ test/e2e/pnpm.e2e.spec.js | 35 +++++++++++++ test/e2e/poetry.e2e.spec.js | 42 +++++++++++++++ test/e2e/safe-chain-dir.e2e.spec.js | 51 +++++++++++++++++++ test/e2e/uv.e2e.spec.js | 39 ++++++++++++++ test/e2e/yarn-ci.e2e.spec.js | 41 +++++++++++++++ test/e2e/yarn.e2e.spec.js | 39 ++++++++++++++ 23 files changed, 575 insertions(+), 48 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index cc50223..364323e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -2,9 +2,11 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, + getScriptsDir, } from "../helpers.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) + // Removes the line that sources the safe-chain bash initialization script (any path, requires safe-chain comment) removeLinesMatchingPattern( startupFile, - /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, + /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, eol ); @@ -47,7 +49,7 @@ function setup() { addLineToFile( startupFile, - `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`, + `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain bash initialization script`, eol ); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index aa7159f..f0a56d2 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -19,6 +19,7 @@ describe("Bash shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -109,7 +110,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 +130,7 @@ 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 /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" ) ); }); @@ -209,13 +210,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 +237,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 +248,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=")); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index a623d0b..5f59826 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -2,8 +2,10 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, + getScriptsDir, } from "../helpers.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) + // Removes the line that sources the safe-chain fish initialization script (any path, requires safe-chain comment) 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 ); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index e138957..0933b6e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -17,6 +17,7 @@ describe("Fish shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -72,7 +73,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 +82,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 +94,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 +108,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 +163,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 +177,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"); }); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 4bbc332..59aee41 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -3,8 +3,10 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, validatePowerShellExecutionPolicy, + getScriptsDir, } from "../helpers.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 the line that sources the safe-chain PowerShell initialization script (any path, requires safe-chain comment) 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; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index de2c14b..1d9f65c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -40,6 +40,7 @@ describe("PowerShell Core shell integration", () => { fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, validatePowerShellExecutionPolicy: () => executionPolicyResult, + getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); @@ -83,7 +84,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 +94,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 +106,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 +181,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 +199,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"); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 3e81da7..36ab114 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -3,8 +3,10 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, validatePowerShellExecutionPolicy, + getScriptsDir, } from "../helpers.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 the line that sources the safe-chain PowerShell initialization script (any path, requires safe-chain comment) 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; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 561d0d4..621b380 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -40,6 +40,7 @@ describe("Windows PowerShell shell integration", () => { fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, validatePowerShellExecutionPolicy: () => executionPolicyResult, + getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); @@ -83,7 +84,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 +94,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 +106,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 +181,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 +199,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"); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index f187af3..369b445 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -2,8 +2,10 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, + getScriptsDir, } from "../helpers.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) + // Removes the line that sources the safe-chain zsh initialization script (any path, requires safe-chain comment) 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 ); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 99106ec..41e1bd1 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -17,6 +17,7 @@ describe("Zsh shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -73,7 +74,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 +84,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 +115,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 +126,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 +181,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 +208,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 +219,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=")); }); diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index fb6e99a..1de6100 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -78,4 +78,39 @@ describe("E2E: bun coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("bash"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious bun packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("bash"); + const result = await shell.runCommand("bunx safe-chain-test"); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index 1698759..cc3349b 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -102,4 +102,47 @@ describe("E2E: npm coverage using PATH", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup-ci"); + // Persist SAFE_CHAIN_DIR and the custom shims dir in .zshrc so new shells + // inherit both (shims need SAFE_CHAIN_DIR to strip themselves from PATH) + await setupShell.runCommand( + `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` + ); + await setupShell.runCommand( + `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` + ); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious npm packages when shims are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("npm i safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index e8ba7c8..d86af3c 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -119,4 +119,39 @@ describe("E2E: npm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious npm packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("npm i safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index 49db6ce..e1a7aed 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -204,4 +204,44 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); }); } + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand("pip3 cache purge"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup-ci"); + await setupShell.runCommand( + `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` + ); + await setupShell.runCommand( + `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` + ); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("intercepts pip3 install when shims are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand( + "pip3 install --break-system-packages certifi --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Expected pip3 to be protected with SAFE_CHAIN_DIR. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index b06978f..684ee4f 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -844,4 +844,40 @@ describe("E2E: pip coverage", () => { `python -m pip SHOULD go through safe-chain. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + await setupShell.runCommand("pip3 cache purge"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("intercepts pip3 install when scripts are in a custom directory", async () => { + // New shell sources ~/.zshrc → sources init-posix.sh from custom dir + // → defines pip3() shell function that routes through safe-chain + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand( + "pip3 install --break-system-packages requests --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Expected pip3 to be protected with SAFE_CHAIN_DIR. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index a554aa6..489d8c6 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -197,4 +197,39 @@ describe("E2E: pipx coverage", () => { `Expected exit message. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious pipx packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("pipx install safe-chain-pi-test"); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index a56bb77..391001e 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -122,4 +122,45 @@ describe("E2E: pnpm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup-ci"); + await setupShell.runCommand( + `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` + ); + await setupShell.runCommand( + `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` + ); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious pnpm packages when shims are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("pnpm add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index a15250a..90ef57c 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -139,4 +139,39 @@ describe("E2E: pnpm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious pnpm packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("pnpm add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js index 58b74fd..072d1b6 100644 --- a/test/e2e/poetry.e2e.spec.js +++ b/test/e2e/poetry.e2e.spec.js @@ -422,4 +422,46 @@ describe("E2E: poetry coverage", () => { `Expected env list output. Output was:\n${envListResult.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + await setupShell.runCommand("command poetry cache clear pypi --all -n"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious poetry packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + await shell.runCommand("mkdir /tmp/test-poetry-custom-dir"); + await shell.runCommand( + "cd /tmp/test-poetry-custom-dir && poetry init --no-interaction" + ); + const result = await shell.runCommand( + "cd /tmp/test-poetry-custom-dir && poetry add safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/safe-chain-dir.e2e.spec.js b/test/e2e/safe-chain-dir.e2e.spec.js index e28bd72..e738949 100644 --- a/test/e2e/safe-chain-dir.e2e.spec.js +++ b/test/e2e/safe-chain-dir.e2e.spec.js @@ -64,6 +64,57 @@ describe("E2E: SAFE_CHAIN_DIR support", () => { ); }); + it("setup writes the custom path to ~/.bashrc when SAFE_CHAIN_DIR is set", async () => { + const shell = await container.openShell("bash"); + await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await shell.runCommand("safe-chain setup"); + + const result = await shell.runCommand("cat ~/.bashrc"); + + assert.ok( + result.output.includes(`source ${CUSTOM_DIR}/scripts/init-posix.sh`), + `Expected ~/.bashrc to contain custom scripts path. Output:\n${result.output}` + ); + assert.ok( + !result.output.includes("source ~/.safe-chain/scripts/init-posix.sh"), + `Expected ~/.bashrc to NOT contain default path. Output:\n${result.output}` + ); + }); + + it("setup with SAFE_CHAIN_DIR still protects npm in a new shell session", async () => { + // Run setup with the custom dir + const setupShell = await container.openShell("bash"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + + // Open a fresh shell — it will source ~/.bashrc which sources init-posix.sh + // from the custom dir, defining the npm wrapper function + const projectShell = await container.openShell("bash"); + await projectShell.runCommand("cd /testapp"); + const result = await projectShell.runCommand( + "npm i axios@1.13.0 --safe-chain-logging=verbose" + ); + + // "Safe-chain: Package" appears before npm downloads — confirms interception happened + assert.ok( + result.output.includes("Safe-chain: Package"), + `Expected npm to be protected after setup with SAFE_CHAIN_DIR. Output:\n${result.output}` + ); + }); + + it("teardown removes the custom SAFE_CHAIN_DIR source line from ~/.bashrc", async () => { + const shell = await container.openShell("bash"); + await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await shell.runCommand("safe-chain setup"); + await shell.runCommand("safe-chain teardown"); + + const result = await shell.runCommand("cat ~/.bashrc"); + assert.ok( + !result.output.includes(`source ${CUSTOM_DIR}/scripts/init-posix.sh`), + `Expected custom source line to be removed from ~/.bashrc. Output:\n${result.output}` + ); + }); + it("safe-chain protects a non-root user when installed to a shared dir with SAFE_CHAIN_DIR", async () => { // Step 1: create a non-root user inside the container container.dockerExec("useradd -m safeuser"); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index 9d5f3b9..ad24f6e 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -569,4 +569,43 @@ describe("E2E: uv coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + await setupShell.runCommand("uv cache clean"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious uv packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + await shell.runCommand("uv init test-project-custom-dir"); + const result = await shell.runCommand( + "cd test-project-custom-dir && uv add safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 47e2120..35047c1 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -84,4 +84,45 @@ describe("E2E: yarn coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup-ci"); + await setupShell.runCommand( + `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` + ); + await setupShell.runCommand( + `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` + ); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious yarn packages when shims are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("yarn add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 5e56d12..5b677d6 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -125,4 +125,43 @@ describe("E2E: yarn coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + // Run setup with the custom dir — init-posix.sh is copied to the custom + // scripts dir, and ~/.zshrc gets a source line pointing there + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious yarn packages when scripts are in a custom directory", async () => { + // New shell sources ~/.zshrc → sources init-posix.sh from custom dir + // → defines yarn() shell function that routes through safe-chain + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("yarn add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); From b0f392522b78164926377559770aee6fc68675d6 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 14:08:59 -0700 Subject: [PATCH 05/40] Some cleanup --- README.md | 16 +++++++- install-scripts/install-safe-chain.ps1 | 3 +- install-scripts/install-safe-chain.sh | 3 +- install-scripts/uninstall-safe-chain.ps1 | 2 +- install-scripts/uninstall-safe-chain.sh | 3 +- packages/safe-chain/src/config/configFile.js | 4 +- .../src/shell-integration/helpers.js | 1 + .../safe-chain/src/shell-integration/setup.js | 1 - .../supported-shells/bash.js | 16 ++++++++ .../supported-shells/bash.spec.js | 37 +++++++++++++++++++ .../supported-shells/fish.js | 16 ++++++++ .../supported-shells/fish.spec.js | 36 ++++++++++++++++++ .../supported-shells/powershell.js | 14 +++++++ .../supported-shells/powershell.spec.js | 37 +++++++++++++++++++ .../supported-shells/windowsPowershell.js | 14 +++++++ .../windowsPowershell.spec.js | 37 +++++++++++++++++++ .../shell-integration/supported-shells/zsh.js | 16 ++++++++ .../supported-shells/zsh.spec.js | 37 +++++++++++++++++++ .../src/shell-integration/teardown.js | 1 + 19 files changed, 286 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3e73137..ba3ec47 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,19 @@ The base URL should point to a server that mirrors the structure of `https://mal - `/releases/npm.json` (JavaScript new packages list) - `/releases/pypi.json` (Python new packages list) +## Custom Install Directory + +By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by setting `SAFE_CHAIN_DIR` before running the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. + +When set, all Safe Chain data (binary, shims, scripts) is placed under the custom directory instead of `~/.safe-chain`. + +```shell +export SAFE_CHAIN_DIR=/usr/local/.safe-chain +curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh +``` + +> **Note:** CLI argument and config file options are not supported for `SAFE_CHAIN_DIR`. The config file lives inside the Safe Chain directory itself, creating a chicken-and-egg problem, and passing a directory path as a flag to package manager commands (e.g. `npm install express --safe-chain-dir=...`) does not make sense. + # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. @@ -406,6 +419,7 @@ pipeline { environment { // Jenkins does not automatically persist PATH updates from setup-ci, // so add the shims + binary directory explicitly for all stages. + // If you set SAFE_CHAIN_DIR, replace ~/.safe-chain with that path here. PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}" } @@ -461,7 +475,7 @@ To add safe-chain in GitLab pipelines, you need to install it in the image runni # Install safe-chain RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - # Add safe-chain to PATH + # Add safe-chain to PATH (update paths if you set SAFE_CHAIN_DIR during install) ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}" ``` diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index ffe2505..f95fdfd 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -8,7 +8,8 @@ param( ) $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set -$InstallDir = Join-Path $env:USERPROFILE ".safe-chain\bin" +$SafeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $env:USERPROFILE ".safe-chain" } +$InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" # Ensure TLS 1.2 is enabled for downloads diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 182cdad..f65b1d7 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -8,7 +8,8 @@ set -e # Exit on error # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set -INSTALL_DIR="${HOME}/.safe-chain/bin" +SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" +INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" # Colors for output diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 3292cdd..32a27a5 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -4,7 +4,7 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } -$DotSafeChain = Join-Path $HomeDir ".safe-chain" +$DotSafeChain = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HomeDir ".safe-chain" } $InstallDir = Join-Path $DotSafeChain "bin" # Helper functions diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index dff6f31..fcb5153 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -7,7 +7,7 @@ set -e # Exit on error # Configuration -DOT_SAFE_CHAIN="${HOME}/.safe-chain" +DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" # Colors for output RED='\033[0;31m' @@ -163,6 +163,7 @@ main() { else info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove." fi + } main "$@" diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 3fb0f21..1b978ea 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -3,6 +3,7 @@ import path from "path"; import os from "os"; import { ui } from "../environment/userInteraction.js"; import { getEcoSystem } from "./settings.js"; +import { getSafeChainDir } from "./environmentVariables.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 = getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); if (!fs.existsSync(safeChainDir)) { fs.mkdirSync(safeChainDir, { recursive: true }); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 7ccfd99..2d66d1d 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -4,6 +4,7 @@ import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; import { getSafeChainDir } from "../config/environmentVariables.js"; +export { getSafeChainDir }; import { safeSpawn } from "../utils/safeSpawn.js"; import { ui } from "../environment/userInteraction.js"; diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 66c6533..120723a 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -122,7 +122,6 @@ function copyStartupFiles() { fs.mkdirSync(targetDir, { recursive: true }); } - // Use absolute path for source const sourcePath = path.join(dirname, "startup-scripts", file); fs.copyFileSync(sourcePath, targetPath); } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 364323e..4f04c5e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -3,6 +3,7 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync, spawnSync } from "child_process"; import * as os from "os"; @@ -41,12 +42,27 @@ function teardown(tools) { eol ); + removeLinesMatchingPattern( + startupFile, + /^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/, + eol + ); + return true; } function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`, + eol + ); + } + addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain bash initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index f0a56d2..a8cd067 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -10,6 +10,7 @@ describe("Bash shell integration", () => { let bash; let windowsCygwinPath = ""; let platform = "linux"; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -20,6 +21,7 @@ describe("Bash shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -89,6 +91,7 @@ describe("Bash shell integration", () => { // Reset mocks mock.reset(); platform = "linux"; + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -200,6 +203,40 @@ describe("Bash shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write export line to rc file when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + bash.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory') + ); + }); + + it("should not write export line when no custom dir is set", () => { + getSafeChainDirResult = undefined; + bash.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove export line on teardown", () => { + const initialContent = [ + '#!/bin/bash', + 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', + 'source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script', + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + bash.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 5f59826..bac8e7b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -3,6 +3,7 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -40,12 +41,27 @@ function teardown(tools) { eol ); + removeLinesMatchingPattern( + startupFile, + /^set\s+-gx\s+SAFE_CHAIN_DIR\s+.*#\s*Safe-chain/, + eol + ); + return true; } function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `set -gx SAFE_CHAIN_DIR "${customDir}" # Safe-chain installation directory`, + eol + ); + } + addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index 0933b6e..c9918c5 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -8,6 +8,7 @@ import { knownAikidoTools } from "../helpers.js"; describe("Fish shell integration", () => { let mockStartupFile; let fish; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -18,6 +19,7 @@ describe("Fish shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -53,6 +55,7 @@ describe("Fish shell integration", () => { // Reset mocks mock.reset(); + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -153,6 +156,39 @@ describe("Fish shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write set line to config file when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + fish.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes('set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory') + ); + }); + + it("should not write set line when no custom dir is set", () => { + getSafeChainDirResult = undefined; + fish.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove set line on teardown", () => { + const initialContent = [ + 'set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory', + "source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + fish.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 59aee41..38b0b42 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -4,6 +4,7 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -38,6 +39,11 @@ function teardown(tools) { /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); + removeLinesMatchingPattern( + startupFile, + /^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/, + ); + return true; } @@ -52,6 +58,14 @@ async function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`, + ); + } + addLineToFile( startupFile, `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 1d9f65c..97901f1 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -9,6 +9,7 @@ describe("PowerShell Core shell integration", () => { let mockStartupFile; let powershell; let executionPolicyResult; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -26,6 +27,7 @@ describe("PowerShell Core shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -63,6 +65,7 @@ describe("PowerShell Core shell integration", () => { // Reset mocks mock.reset(); + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -206,6 +209,40 @@ describe("PowerShell Core shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => { + getSafeChainDirResult = "C:\\custom\\safe-chain"; + await powershell.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory") + ); + }); + + it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => { + getSafeChainDirResult = undefined; + await powershell.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => { + const initialContent = [ + "# PowerShell profile", + "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + powershell.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("execution policy", () => { it(`should throw for restricted policies`, async () => { executionPolicyResult = { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 36ab114..506b891 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -4,6 +4,7 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -38,6 +39,11 @@ function teardown(tools) { /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); + removeLinesMatchingPattern( + startupFile, + /^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/, + ); + return true; } @@ -52,6 +58,14 @@ async function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`, + ); + } + addLineToFile( startupFile, `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 621b380..efb5cc3 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -9,6 +9,7 @@ describe("Windows PowerShell shell integration", () => { let mockStartupFile; let windowsPowershell; let executionPolicyResult; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -26,6 +27,7 @@ describe("Windows PowerShell shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -63,6 +65,7 @@ describe("Windows PowerShell shell integration", () => { // Reset mocks mock.reset(); + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -206,6 +209,40 @@ describe("Windows PowerShell shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => { + getSafeChainDirResult = "C:\\custom\\safe-chain"; + await windowsPowershell.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory") + ); + }); + + it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => { + getSafeChainDirResult = undefined; + await windowsPowershell.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => { + const initialContent = [ + "# Windows PowerShell profile", + "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + windowsPowershell.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("execution policy", () => { it(`should throw for restricted policies`, async () => { executionPolicyResult = { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index 369b445..a340424 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -3,6 +3,7 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -40,12 +41,27 @@ function teardown(tools) { eol ); + removeLinesMatchingPattern( + startupFile, + /^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/, + eol + ); + return true; } function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`, + eol + ); + } + addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 41e1bd1..4f1ca88 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -8,6 +8,7 @@ import { knownAikidoTools } from "../helpers.js"; describe("Zsh shell integration", () => { let mockStartupFile; let zsh; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -18,6 +19,7 @@ describe("Zsh shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -53,6 +55,7 @@ describe("Zsh shell integration", () => { // Reset mocks mock.reset(); + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -171,6 +174,40 @@ describe("Zsh shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write export line to rc file when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + zsh.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory') + ); + }); + + it("should not write export line when no custom dir is set", () => { + getSafeChainDirResult = undefined; + zsh.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove export line on teardown", () => { + const initialContent = [ + "#!/bin/zsh", + 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + zsh.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/teardown.js b/packages/safe-chain/src/shell-integration/teardown.js index bcf6346..e5f149d 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -109,4 +109,5 @@ export async function teardownDirectories() { ); } } + } From 1aef941d1cde594a21ccb7d8c912e3c6a8351e35 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 14:13:34 -0700 Subject: [PATCH 06/40] Update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba3ec47..0dc3f40 100644 --- a/README.md +++ b/README.md @@ -320,14 +320,14 @@ The base URL should point to a server that mirrors the structure of `https://mal By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by setting `SAFE_CHAIN_DIR` before running the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. -When set, all Safe Chain data (binary, shims, scripts) is placed under the custom directory instead of `~/.safe-chain`. +When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`. ```shell export SAFE_CHAIN_DIR=/usr/local/.safe-chain curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh ``` -> **Note:** CLI argument and config file options are not supported for `SAFE_CHAIN_DIR`. The config file lives inside the Safe Chain directory itself, creating a chicken-and-egg problem, and passing a directory path as a flag to package manager commands (e.g. `npm install express --safe-chain-dir=...`) does not make sense. +This is a **one-time setting**. `safe-chain setup` automatically persists `SAFE_CHAIN_DIR` to your shell rc files (e.g. `~/.bashrc`, `~/.zshrc`) so that subsequent `safe-chain` commands (including teardown and re-setup) find the correct directory without needing the variable set again. # Usage in CI/CD From 32c95dbb9d3156c1d8229679313352cf12ea9f93 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 14:27:55 -0700 Subject: [PATCH 07/40] Fix WIndows shell + unit tests --- .../safe-chain/src/registryProxy/certUtils.js | 10 ++- .../src/registryProxy/certUtils.spec.js | 71 +++++++++++++++++++ .../templates/windows-wrapper.template.cmd | 8 ++- .../src/shell-integration/setup-ci.spec.js | 6 +- .../supported-shells/bash.js | 38 +++++++--- .../supported-shells/bash.spec.js | 11 +++ .../supported-shells/fish.js | 38 ++++++++-- .../supported-shells/fish.spec.js | 11 +++ .../supported-shells/powershell.js | 34 +++++++-- .../supported-shells/powershell.spec.js | 11 +++ .../supported-shells/windowsPowershell.js | 34 +++++++-- .../windowsPowershell.spec.js | 11 +++ .../shell-integration/supported-shells/zsh.js | 38 +++++++--- .../supported-shells/zsh.spec.js | 11 +++ 14 files changed, 289 insertions(+), 43 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/certUtils.spec.js diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index 3c8790c..a4bc0b1 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -2,12 +2,17 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; import os from "os"; +import { getSafeChainDir } from "../config/environmentVariables.js"; -const certFolder = path.join(os.homedir(), ".safe-chain", "certs"); const ca = loadCa(); const certCache = new Map(); +function getCertFolder() { + const safeChainDir = getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); + return path.join(safeChainDir, "certs"); +} + /** * @param {forge.pki.PublicKey} publicKey * @returns {string} @@ -20,7 +25,7 @@ function createKeyIdentifier(publicKey) { } export function getCaCertPath() { - return path.join(certFolder, "ca-cert.pem"); + return path.join(getCertFolder(), "ca-cert.pem"); } /** @@ -112,6 +117,7 @@ export function generateCertForHost(hostname) { } function loadCa() { + const certFolder = getCertFolder(); const keyPath = path.join(certFolder, "ca-key.pem"); const certPath = path.join(certFolder, "ca-cert.pem"); diff --git a/packages/safe-chain/src/registryProxy/certUtils.spec.js b/packages/safe-chain/src/registryProxy/certUtils.spec.js new file mode 100644 index 0000000..ebf8dab --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certUtils.spec.js @@ -0,0 +1,71 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("certUtils", () => { + let originalSafeChainDir; + + beforeEach(() => { + originalSafeChainDir = process.env.SAFE_CHAIN_DIR; + }); + + afterEach(() => { + if (originalSafeChainDir === undefined) { + delete process.env.SAFE_CHAIN_DIR; + } else { + process.env.SAFE_CHAIN_DIR = originalSafeChainDir; + } + + mock.reset(); + }); + + it("stores CA certificates in SAFE_CHAIN_DIR when configured", async () => { + process.env.SAFE_CHAIN_DIR = "/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", + ); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd index 082d553..959b700 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd @@ -3,7 +3,11 @@ 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" +if defined SAFE_CHAIN_DIR ( + set "SHIM_DIR=%SAFE_CHAIN_DIR%\shims" +) else ( + set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" +) call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" REM Check if aikido command is available with clean PATH @@ -21,4 +25,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 -) \ No newline at end of file +) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index c0a5ca1..1156173 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -27,7 +27,7 @@ describe("Setup CI shell integration", () => { ); 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\nif defined SAFE_CHAIN_DIR (\n set \"SHIM_DIR=%SAFE_CHAIN_DIR%\\shims\"\n) else (\n set \"SHIM_DIR=%USERPROFILE%\\.safe-chain\\shims\"\n)\n{{AIKIDO_COMMAND}} %*\n", "utf-8" ); @@ -143,6 +143,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("if defined SAFE_CHAIN_DIR"), + "npm.cmd should honor SAFE_CHAIN_DIR when removing shim dir from PATH", + ); // Verify Unix shims were NOT created const unixNpmShim = path.join(mockShimsDir, "npm"); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 4f04c5e..bcf0bc6 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -142,19 +142,37 @@ function cygpathw(path) { } function getManualTeardownInstructions() { - return [ - `Remove the following line from your ~/.bashrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, - `Then restart your terminal or run: source ~/.bashrc`, - ]; + const customDir = getSafeChainDir(); + const instructions = [`Remove the following line from your ~/.bashrc file:`]; + + if (customDir) { + instructions.push( + ` export SAFE_CHAIN_DIR="${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); + } + + instructions.push(`Then restart your terminal or run: source ~/.bashrc`); + return instructions; } function getManualSetupInstructions() { - return [ - `Add the following line to your ~/.bashrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, - `Then restart your terminal or run: source ~/.bashrc`, - ]; + const customDir = getSafeChainDir(); + const instructions = [`Add the following line to your ~/.bashrc file:`]; + + if (customDir) { + instructions.push( + ` export SAFE_CHAIN_DIR="${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); + } + + instructions.push(`Then restart your terminal or run: source ~/.bashrc`); + return instructions; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index a8cd067..4b25d4b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -235,6 +235,17 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual setup instructions when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + + assert.deepStrictEqual(bash.getManualSetupInstructions(), [ + "Add the following line to your ~/.bashrc file:", + ' export SAFE_CHAIN_DIR="/custom/safe-chain"', + " source /test-home/.safe-chain/scripts/init-posix.sh", + "Then restart your terminal or run: source ~/.bashrc", + ]); + }); }); describe("integration tests", () => { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index bac8e7b..33aa48c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -85,19 +85,45 @@ function getStartupFile() { } function getManualTeardownInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Remove the following line from your ~/.config/fish/config.fish file:`, - ` source ~/.safe-chain/scripts/init-fish.fish`, - `Then restart your terminal or run: source ~/.config/fish/config.fish`, ]; + + if (customDir) { + instructions.push( + ` set -gx SAFE_CHAIN_DIR "${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-fish.fish`); + } + + instructions.push( + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ); + return instructions; } function getManualSetupInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Add the following line to your ~/.config/fish/config.fish file:`, - ` source ~/.safe-chain/scripts/init-fish.fish`, - `Then restart your terminal or run: source ~/.config/fish/config.fish`, ]; + + if (customDir) { + instructions.push( + ` set -gx SAFE_CHAIN_DIR "${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-fish.fish`); + } + + instructions.push( + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ); + return instructions; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index c9918c5..29b6d6e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -187,6 +187,17 @@ describe("Fish shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual setup instructions when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + + assert.deepStrictEqual(fish.getManualSetupInstructions(), [ + "Add the following line to your ~/.config/fish/config.fish file:", + ' set -gx SAFE_CHAIN_DIR "/custom/safe-chain"', + " source /test-home/.safe-chain/scripts/init-fish.fish", + "Then restart your terminal or run: source ~/.config/fish/config.fish", + ]); + }); }); describe("integration tests", () => { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 38b0b42..44fbfe9 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -88,19 +88,41 @@ function getStartupFile() { } function getManualTeardownInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, - `Then restart your terminal or run: . $PROFILE`, ]; + + if (customDir) { + instructions.push( + ` $env:SAFE_CHAIN_DIR = '${customDir}'`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + ); + } else { + instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); + } + + instructions.push(`Then restart your terminal or run: . $PROFILE`); + return instructions; } function getManualSetupInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, - `Then restart your terminal or run: . $PROFILE`, ]; + + if (customDir) { + instructions.push( + ` $env:SAFE_CHAIN_DIR = '${customDir}'`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + ); + } else { + instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); + } + + instructions.push(`Then restart your terminal or run: . $PROFILE`); + return instructions; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 97901f1..296abfa 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -241,6 +241,17 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual setup instructions when custom dir is set", () => { + getSafeChainDirResult = "C:\\custom\\safe-chain"; + + assert.deepStrictEqual(powershell.getManualSetupInstructions(), [ + 'Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):', + " $env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain'", + ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', + "Then restart your terminal or run: . $PROFILE", + ]); + }); }); describe("execution policy", () => { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 506b891..e3ed236 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -88,19 +88,41 @@ function getStartupFile() { } function getManualTeardownInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, - `Then restart your terminal or run: . $PROFILE`, ]; + + if (customDir) { + instructions.push( + ` $env:SAFE_CHAIN_DIR = '${customDir}'`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + ); + } else { + instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); + } + + instructions.push(`Then restart your terminal or run: . $PROFILE`); + return instructions; } function getManualSetupInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, - `Then restart your terminal or run: . $PROFILE`, ]; + + if (customDir) { + instructions.push( + ` $env:SAFE_CHAIN_DIR = '${customDir}'`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + ); + } else { + instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); + } + + instructions.push(`Then restart your terminal or run: . $PROFILE`); + return instructions; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index efb5cc3..840f585 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -241,6 +241,17 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual teardown instructions when custom dir is set", () => { + getSafeChainDirResult = "C:\\custom\\safe-chain"; + + assert.deepStrictEqual(windowsPowershell.getManualTeardownInstructions(), [ + 'Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):', + " $env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain'", + ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', + "Then restart your terminal or run: . $PROFILE", + ]); + }); }); describe("execution policy", () => { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index a340424..b2c29e4 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -85,19 +85,37 @@ function getStartupFile() { } function getManualTeardownInstructions() { - return [ - `Remove the following line from your ~/.zshrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, - `Then restart your terminal or run: source ~/.zshrc`, - ]; + const customDir = getSafeChainDir(); + const instructions = [`Remove the following line from your ~/.zshrc file:`]; + + if (customDir) { + instructions.push( + ` export SAFE_CHAIN_DIR="${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); + } + + instructions.push(`Then restart your terminal or run: source ~/.zshrc`); + return instructions; } function getManualSetupInstructions() { - return [ - `Add the following line to your ~/.zshrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, - `Then restart your terminal or run: source ~/.zshrc`, - ]; + const customDir = getSafeChainDir(); + const instructions = [`Add the following line to your ~/.zshrc file:`]; + + if (customDir) { + instructions.push( + ` export SAFE_CHAIN_DIR="${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); + } + + instructions.push(`Then restart your terminal or run: source ~/.zshrc`); + return instructions; } export default { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 4f1ca88..52e790f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -206,6 +206,17 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual teardown instructions when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + + assert.deepStrictEqual(zsh.getManualTeardownInstructions(), [ + "Remove the following line from your ~/.zshrc file:", + ' export SAFE_CHAIN_DIR="/custom/safe-chain"', + " source /test-home/.safe-chain/scripts/init-posix.sh", + "Then restart your terminal or run: source ~/.zshrc", + ]); + }); }); describe("integration tests", () => { From 6628e1d4fd30eea169dece4479458fd6ac25295b Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 14:57:45 -0700 Subject: [PATCH 08/40] Some cleanup --- .../path-wrappers/templates/unix-wrapper.template.sh | 2 +- .../templates/windows-wrapper.template.cmd | 6 +----- .../safe-chain/src/shell-integration/setup-ci.js | 6 ++++-- .../src/shell-integration/setup-ci.spec.js | 12 ++++++++---- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 94ed364..5635b1a 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,7 +4,7 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - _safe_chain_shims="${SAFE_CHAIN_DIR:-$HOME/.safe-chain}/shims" + _safe_chain_shims="{{SHIMS_DIR}}" echo "$PATH" | sed "s|${_safe_chain_shims}:||g" } diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd index 959b700..89f538f 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd @@ -3,11 +3,7 @@ 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 -if defined SAFE_CHAIN_DIR ( - set "SHIM_DIR=%SAFE_CHAIN_DIR%\shims" -) else ( - set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" -) +set "SHIM_DIR={{SHIMS_DIR}}" call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" REM Check if aikido command is available with clean PATH diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 1986bba..0dc32cf 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -69,7 +69,8 @@ function createUnixShims(shimsDir) { for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) - .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand) + .replaceAll("{{SHIMS_DIR}}", shimsDir); const shimPath = path.join(shimsDir, toolInfo.tool); fs.writeFileSync(shimPath, shimContent, "utf-8"); @@ -108,7 +109,8 @@ function createWindowsShims(shimsDir) { for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) - .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand) + .replaceAll("{{SHIMS_DIR}}", shimsDir); const shimPath = `${shimsDir}/${toolInfo.tool}.cmd`; fs.writeFileSync(shimPath, shimContent, "utf-8"); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index 1156173..7d092ab 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -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}}\nSHIM_DIR=\"{{SHIMS_DIR}}\"\nexec {{AIKIDO_COMMAND}} \"$@\"\n", "utf-8" ); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"), - "@echo off\nif defined SAFE_CHAIN_DIR (\n set \"SHIM_DIR=%SAFE_CHAIN_DIR%\\shims\"\n) else (\n set \"SHIM_DIR=%USERPROFILE%\\.safe-chain\\shims\"\n)\n{{AIKIDO_COMMAND}} %*\n", + "@echo off\nset \"SHIM_DIR={{SHIMS_DIR}}\"\n{{AIKIDO_COMMAND}} %*\n", "utf-8" ); @@ -120,6 +120,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(`SHIM_DIR="${mockShimsDir}"`), + "npm shim should embed the generated shims directory", + ); }); it("should create Windows .cmd shims on win32 platform", async () => { @@ -144,8 +148,8 @@ describe("Setup CI shell integration", () => { 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("if defined SAFE_CHAIN_DIR"), - "npm.cmd should honor SAFE_CHAIN_DIR when removing shim dir from PATH", + npmShimContent.includes(`set "SHIM_DIR=${mockShimsDir}"`), + "npm.cmd should embed the generated shims directory", ); // Verify Unix shims were NOT created From eb9d0bba3ef4153d45684ab3be5b446b0a21f8d1 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:16:33 -0700 Subject: [PATCH 09/40] Code Quality --- install-scripts/install-safe-chain.ps1 | 13 +++++++++++++ install-scripts/install-safe-chain.sh | 14 ++++++++++++++ install-scripts/uninstall-safe-chain.ps1 | 13 +++++++++++++ install-scripts/uninstall-safe-chain.sh | 14 ++++++++++++++ .../startup-scripts/init-fish.fish | 6 +++++- .../startup-scripts/init-posix.sh | 8 +++++++- .../startup-scripts/init-pwsh.ps1 | 3 ++- 7 files changed, 68 insertions(+), 3 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index f95fdfd..fac897b 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -150,6 +150,19 @@ function Remove-VoltaInstallation { # Main installation function Install-SafeChain { + # Validate SAFE_CHAIN_DIR before using it to write files + if ($env:SAFE_CHAIN_DIR) { + if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { + Write-Error-Custom "SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" + } + if ($env:SAFE_CHAIN_DIR -match '\.\.') { + Write-Error-Custom "SAFE_CHAIN_DIR must not contain path traversal (..)" + } + if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { + Write-Error-Custom "SAFE_CHAIN_DIR cannot be a root or drive-root directory" + } + } + # Show deprecation warning if SAFE_CHAIN_VERSION is set if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index f65b1d7..57b06d3 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -247,6 +247,20 @@ parse_arguments() { # Main installation main() { + # Validate SAFE_CHAIN_DIR before using it to write files + if [ -n "${SAFE_CHAIN_DIR}" ]; then + case "${SAFE_CHAIN_DIR}" in + /*) ;; # absolute path — OK + *) error "SAFE_CHAIN_DIR must be an absolute path, got: ${SAFE_CHAIN_DIR}" ;; + esac + case "${SAFE_CHAIN_DIR}" in + *../*|*/..*|..) error "SAFE_CHAIN_DIR must not contain path traversal (..)" ;; + esac + if [ "${SAFE_CHAIN_DIR}" = "/" ]; then + error "SAFE_CHAIN_DIR cannot be the root directory" + fi + fi + # Initialize argument flags USE_CI_SETUP=false diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 32a27a5..a4f1fc1 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -75,6 +75,19 @@ function Remove-VoltaInstallation { # Main uninstallation function Uninstall-SafeChain { + # Validate SAFE_CHAIN_DIR before using it to delete files + if ($env:SAFE_CHAIN_DIR) { + if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { + Write-Error-Custom "SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" + } + if ($env:SAFE_CHAIN_DIR -match '\.\.') { + Write-Error-Custom "SAFE_CHAIN_DIR must not contain path traversal (..)" + } + if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { + Write-Error-Custom "SAFE_CHAIN_DIR cannot be a root or drive-root directory" + } + } + Write-Info "Uninstalling safe-chain..." # Run teardown if safe-chain is available diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index fcb5153..5440730 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -139,6 +139,20 @@ remove_nvm_installation() { # Main uninstallation main() { + # Validate SAFE_CHAIN_DIR before using it to delete files + if [ -n "${SAFE_CHAIN_DIR}" ]; then + case "${SAFE_CHAIN_DIR}" in + /*) ;; # absolute path — OK + *) error "SAFE_CHAIN_DIR must be an absolute path, got: ${SAFE_CHAIN_DIR}" ;; + esac + case "${SAFE_CHAIN_DIR}" in + *../*|*/..*|..) error "SAFE_CHAIN_DIR must not contain path traversal (..)" ;; + esac + if [ "${SAFE_CHAIN_DIR}" = "/" ]; then + error "SAFE_CHAIN_DIR cannot be the root directory" + fi + fi + SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" if [ -x "$SAFE_CHAIN_LOCATION" ]; then diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index a705634..11d1d55 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,4 +1,8 @@ -set -l safe_chain_base (if set -q SAFE_CHAIN_DIR; echo $SAFE_CHAIN_DIR; else; echo $HOME/.safe-chain; end) +# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing ':' +set -l safe_chain_base $HOME/.safe-chain +if set -q SAFE_CHAIN_DIR; and not string match -q '*:*' -- $SAFE_CHAIN_DIR + set safe_chain_base $SAFE_CHAIN_DIR +end set -gx PATH $PATH $safe_chain_base/bin function npx diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index b567902..45c6fd9 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,4 +1,10 @@ -export PATH="$PATH:${SAFE_CHAIN_DIR:-$HOME/.safe-chain}/bin" +# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing ':' +case "${SAFE_CHAIN_DIR}" in + *:*) _sc_base="${HOME}/.safe-chain" ;; + *) _sc_base="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" ;; +esac +export PATH="$PATH:${_sc_base}/bin" +unset _sc_base function npx() { wrapSafeChainCommand "npx" "$@" diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index bcdd1c6..f814917 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -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 { ':' } -$safeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HOME '.safe-chain' } +# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing the path separator +$safeChainBase = if ($env:SAFE_CHAIN_DIR -and -not $env:SAFE_CHAIN_DIR.Contains($pathSeparator)) { $env:SAFE_CHAIN_DIR } else { Join-Path $HOME '.safe-chain' } $safeChainBin = Join-Path $safeChainBase 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" From d7400a0bc0beeb10fd1b63b0b105296d8fb7389f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:20:37 -0700 Subject: [PATCH 10/40] Update packages/safe-chain/src/shell-integration/supported-shells/zsh.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/zsh.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index b2c29e4..c9be67f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -34,7 +34,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain zsh initialization script (any path, requires safe-chain comment) + // Remove init script source line to uninstall shell integration; marker ensures only safe-chain-added lines are removed removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, From 8cf41dc4a65ed0d8c0c325604b484513b08a1b8e Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:20:53 -0700 Subject: [PATCH 11/40] Update packages/safe-chain/src/shell-integration/supported-shells/bash.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/bash.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index bcf0bc6..3491bc7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -35,7 +35,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain bash initialization script (any path, requires safe-chain comment) + // Marker comment ensures only safe-chain-added lines are removed, not user's own source statements removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, From e5c79e5bd6e4a15f490ad802e1aa6d65e0a408d1 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:21:05 -0700 Subject: [PATCH 12/40] Update packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../src/shell-integration/supported-shells/windowsPowershell.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index e3ed236..041cca7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -33,7 +33,7 @@ function teardown(tools) { ); } - // Remove the line that sources the safe-chain PowerShell initialization script (any path, requires safe-chain comment) + // Match any installation path but require the Safe-chain marker to avoid removing unrelated user scripts removeLinesMatchingPattern( startupFile, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, From 94f77e1330769b2181029091514118d6e965bb35 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:25:50 -0700 Subject: [PATCH 13/40] Address more code quality issues --- install-scripts/install-safe-chain.ps1 | 25 +++++++++++----------- install-scripts/install-safe-chain.sh | 27 ++++++++++++------------ install-scripts/uninstall-safe-chain.ps1 | 25 +++++++++++----------- install-scripts/uninstall-safe-chain.sh | 26 +++++++++++------------ 4 files changed, 49 insertions(+), 54 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index fac897b..2635528 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -9,6 +9,18 @@ param( $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set $SafeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $env:USERPROFILE ".safe-chain" } + +# Validate $SafeChainBase before any filesystem operations +if (-not [System.IO.Path]::IsPathRooted($SafeChainBase)) { + Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $SafeChainBase" -ForegroundColor Red; exit 1 +} +if ($SafeChainBase -match '\.\.') { + Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 +} +if ($SafeChainBase -match '^[A-Za-z]:[/\\]?$' -or $SafeChainBase -eq '/') { + Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 +} + $InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" @@ -150,19 +162,6 @@ function Remove-VoltaInstallation { # Main installation function Install-SafeChain { - # Validate SAFE_CHAIN_DIR before using it to write files - if ($env:SAFE_CHAIN_DIR) { - if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { - Write-Error-Custom "SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" - } - if ($env:SAFE_CHAIN_DIR -match '\.\.') { - Write-Error-Custom "SAFE_CHAIN_DIR must not contain path traversal (..)" - } - if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { - Write-Error-Custom "SAFE_CHAIN_DIR cannot be a root or drive-root directory" - } - } - # Show deprecation warning if SAFE_CHAIN_VERSION is set if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 57b06d3..e371183 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -9,6 +9,19 @@ set -e # Exit on error # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" + +# Validate SAFE_CHAIN_BASE before any filesystem operations +case "${SAFE_CHAIN_BASE}" in + /*) ;; + *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_BASE}" >&2; exit 1 ;; +esac +case "${SAFE_CHAIN_BASE}" in + *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; +esac +if [ "${SAFE_CHAIN_BASE}" = "/" ]; then + printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 +fi + INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" @@ -247,20 +260,6 @@ parse_arguments() { # Main installation main() { - # Validate SAFE_CHAIN_DIR before using it to write files - if [ -n "${SAFE_CHAIN_DIR}" ]; then - case "${SAFE_CHAIN_DIR}" in - /*) ;; # absolute path — OK - *) error "SAFE_CHAIN_DIR must be an absolute path, got: ${SAFE_CHAIN_DIR}" ;; - esac - case "${SAFE_CHAIN_DIR}" in - *../*|*/..*|..) error "SAFE_CHAIN_DIR must not contain path traversal (..)" ;; - esac - if [ "${SAFE_CHAIN_DIR}" = "/" ]; then - error "SAFE_CHAIN_DIR cannot be the root directory" - fi - fi - # Initialize argument flags USE_CI_SETUP=false diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index a4f1fc1..f342377 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -5,6 +5,18 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } $DotSafeChain = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HomeDir ".safe-chain" } + +# Validate $DotSafeChain before any filesystem operations +if (-not [System.IO.Path]::IsPathRooted($DotSafeChain)) { + Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $DotSafeChain" -ForegroundColor Red; exit 1 +} +if ($DotSafeChain -match '\.\.') { + Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 +} +if ($DotSafeChain -match '^[A-Za-z]:[/\\]?$' -or $DotSafeChain -eq '/') { + Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 +} + $InstallDir = Join-Path $DotSafeChain "bin" # Helper functions @@ -75,19 +87,6 @@ function Remove-VoltaInstallation { # Main uninstallation function Uninstall-SafeChain { - # Validate SAFE_CHAIN_DIR before using it to delete files - if ($env:SAFE_CHAIN_DIR) { - if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { - Write-Error-Custom "SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" - } - if ($env:SAFE_CHAIN_DIR -match '\.\.') { - Write-Error-Custom "SAFE_CHAIN_DIR must not contain path traversal (..)" - } - if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { - Write-Error-Custom "SAFE_CHAIN_DIR cannot be a root or drive-root directory" - } - } - Write-Info "Uninstalling safe-chain..." # Run teardown if safe-chain is available diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 5440730..1cd8f9b 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -9,6 +9,18 @@ set -e # Exit on error # Configuration DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" +# Validate DOT_SAFE_CHAIN before any filesystem operations +case "${DOT_SAFE_CHAIN}" in + /*) ;; + *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${DOT_SAFE_CHAIN}" >&2; exit 1 ;; +esac +case "${DOT_SAFE_CHAIN}" in + *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; +esac +if [ "${DOT_SAFE_CHAIN}" = "/" ]; then + printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 +fi + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -139,20 +151,6 @@ remove_nvm_installation() { # Main uninstallation main() { - # Validate SAFE_CHAIN_DIR before using it to delete files - if [ -n "${SAFE_CHAIN_DIR}" ]; then - case "${SAFE_CHAIN_DIR}" in - /*) ;; # absolute path — OK - *) error "SAFE_CHAIN_DIR must be an absolute path, got: ${SAFE_CHAIN_DIR}" ;; - esac - case "${SAFE_CHAIN_DIR}" in - *../*|*/..*|..) error "SAFE_CHAIN_DIR must not contain path traversal (..)" ;; - esac - if [ "${SAFE_CHAIN_DIR}" = "/" ]; then - error "SAFE_CHAIN_DIR cannot be the root directory" - fi - fi - SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" if [ -x "$SAFE_CHAIN_LOCATION" ]; then From 98dcda78da096296268f21d3d6916931c7b9bc2f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:33:30 -0700 Subject: [PATCH 14/40] Some more cleanup --- .../supported-shells/bash.js | 24 +++++---------- .../supported-shells/fish.js | 30 +++++-------------- .../supported-shells/powershell.js | 28 +++++------------ .../supported-shells/windowsPowershell.js | 28 +++++------------ .../shell-integration/supported-shells/zsh.js | 24 +++++---------- 5 files changed, 40 insertions(+), 94 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 3491bc7..ff2266b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -141,9 +141,10 @@ function cygpathw(path) { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [`Remove the following line from your ~/.bashrc file:`]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -158,21 +159,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your ~/.bashrc file:`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [`Add the following line to your ~/.bashrc file:`]; - - if (customDir) { - instructions.push( - ` export SAFE_CHAIN_DIR="${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); - } - - instructions.push(`Then restart your terminal or run: source ~/.bashrc`); - return instructions; + return buildManualInstructions(`Add the following line to your ~/.bashrc file:`); } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 33aa48c..a6ffe1e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -84,11 +84,10 @@ function getStartupFile() { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [ - `Remove the following line from your ~/.config/fish/config.fish file:`, - ]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -105,25 +104,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your ~/.config/fish/config.fish file:`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [ - `Add the following line to your ~/.config/fish/config.fish file:`, - ]; - - if (customDir) { - instructions.push( - ` set -gx SAFE_CHAIN_DIR "${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-fish.fish`); - } - - instructions.push( - `Then restart your terminal or run: source ~/.config/fish/config.fish`, - ); - return instructions; + return buildManualInstructions(`Add the following line to your ~/.config/fish/config.fish file:`); } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 44fbfe9..906bedd 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -87,11 +87,10 @@ function getStartupFile() { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [ - `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -106,23 +105,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [ - `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ]; - - if (customDir) { - instructions.push( - ` $env:SAFE_CHAIN_DIR = '${customDir}'`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - ); - } else { - instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); - } - - instructions.push(`Then restart your terminal or run: . $PROFILE`); - return instructions; + return buildManualInstructions(`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`); } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 041cca7..e53891e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -87,11 +87,10 @@ function getStartupFile() { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [ - `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -106,23 +105,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [ - `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ]; - - if (customDir) { - instructions.push( - ` $env:SAFE_CHAIN_DIR = '${customDir}'`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - ); - } else { - instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); - } - - instructions.push(`Then restart your terminal or run: . $PROFILE`); - return instructions; + return buildManualInstructions(`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`); } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index c9be67f..9b87d86 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -84,9 +84,10 @@ function getStartupFile() { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [`Remove the following line from your ~/.zshrc file:`]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -101,21 +102,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your ~/.zshrc file:`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [`Add the following line to your ~/.zshrc file:`]; - - if (customDir) { - instructions.push( - ` export SAFE_CHAIN_DIR="${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); - } - - instructions.push(`Then restart your terminal or run: source ~/.zshrc`); - return instructions; + return buildManualInstructions(`Add the following line to your ~/.zshrc file:`); } export default { From df8be031cb92ab175443b04846ba9cd3e7934ac7 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:38:51 -0700 Subject: [PATCH 15/40] Validate ENV VAR --- install-scripts/install-safe-chain.ps1 | 26 +++++++++++++----------- install-scripts/install-safe-chain.sh | 24 ++++++++++++---------- install-scripts/uninstall-safe-chain.ps1 | 26 +++++++++++++----------- install-scripts/uninstall-safe-chain.sh | 25 +++++++++++++---------- 4 files changed, 55 insertions(+), 46 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 2635528..4e77df4 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -8,19 +8,21 @@ param( ) $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set + +# Validate SAFE_CHAIN_DIR before use +if ($env:SAFE_CHAIN_DIR) { + if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { + Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" -ForegroundColor Red; exit 1 + } + if ($env:SAFE_CHAIN_DIR -match '\.\.') { + Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 + } + if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { + Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 + } +} + $SafeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $env:USERPROFILE ".safe-chain" } - -# Validate $SafeChainBase before any filesystem operations -if (-not [System.IO.Path]::IsPathRooted($SafeChainBase)) { - Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $SafeChainBase" -ForegroundColor Red; exit 1 -} -if ($SafeChainBase -match '\.\.') { - Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 -} -if ($SafeChainBase -match '^[A-Za-z]:[/\\]?$' -or $SafeChainBase -eq '/') { - Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 -} - $InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index e371183..03923d8 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -8,20 +8,22 @@ set -e # Exit on error # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set -SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" -# Validate SAFE_CHAIN_BASE before any filesystem operations -case "${SAFE_CHAIN_BASE}" in - /*) ;; - *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_BASE}" >&2; exit 1 ;; -esac -case "${SAFE_CHAIN_BASE}" in - *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; -esac -if [ "${SAFE_CHAIN_BASE}" = "/" ]; then - printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 +# Validate SAFE_CHAIN_DIR before use +if [ -n "${SAFE_CHAIN_DIR}" ]; then + case "${SAFE_CHAIN_DIR}" in + /*) ;; + *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_DIR}" >&2; exit 1 ;; + esac + case "${SAFE_CHAIN_DIR}" in + *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; + esac + if [ "${SAFE_CHAIN_DIR}" = "/" ]; then + printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 + fi fi +SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index f342377..785e58a 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -4,19 +4,21 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } + +# Validate SAFE_CHAIN_DIR before use +if ($env:SAFE_CHAIN_DIR) { + if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { + Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" -ForegroundColor Red; exit 1 + } + if ($env:SAFE_CHAIN_DIR -match '\.\.') { + Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 + } + if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { + Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 + } +} + $DotSafeChain = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HomeDir ".safe-chain" } - -# Validate $DotSafeChain before any filesystem operations -if (-not [System.IO.Path]::IsPathRooted($DotSafeChain)) { - Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $DotSafeChain" -ForegroundColor Red; exit 1 -} -if ($DotSafeChain -match '\.\.') { - Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 -} -if ($DotSafeChain -match '^[A-Za-z]:[/\\]?$' -or $DotSafeChain -eq '/') { - Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 -} - $InstallDir = Join-Path $DotSafeChain "bin" # Helper functions diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 1cd8f9b..abde7ca 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -7,20 +7,23 @@ set -e # Exit on error # Configuration -DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" -# Validate DOT_SAFE_CHAIN before any filesystem operations -case "${DOT_SAFE_CHAIN}" in - /*) ;; - *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${DOT_SAFE_CHAIN}" >&2; exit 1 ;; -esac -case "${DOT_SAFE_CHAIN}" in - *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; -esac -if [ "${DOT_SAFE_CHAIN}" = "/" ]; then - printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 +# Validate SAFE_CHAIN_DIR before use +if [ -n "${SAFE_CHAIN_DIR}" ]; then + case "${SAFE_CHAIN_DIR}" in + /*) ;; + *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_DIR}" >&2; exit 1 ;; + esac + case "${SAFE_CHAIN_DIR}" in + *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; + esac + if [ "${SAFE_CHAIN_DIR}" = "/" ]; then + printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 + fi fi +DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' From 2ea5362b072dbc2e797875c8a5a8e22af72856ea Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:47:21 -0700 Subject: [PATCH 16/40] Increase timeout for tests --- test/e2e/DockerTestContainer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index cd48c4e..4e831d3 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -128,7 +128,7 @@ export class DockerTestContainer { console.log("Command timeout reached"); resolve({ allData, output: parseShellOutput(allData), command }); ptyProcess.removeListener("data", handleInput); - }, 15000); + }, 30000); function handleInput(data) { allData.push(data); From d064d46668e2cfc16beca460842a90ddadb6a81f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:01:45 -0700 Subject: [PATCH 17/40] Cleanup --- README.md | 17 ++-- install-scripts/install-safe-chain.ps1 | 46 +++++++--- install-scripts/install-safe-chain.sh | 76 ++++++++++++---- install-scripts/uninstall-safe-chain.ps1 | 76 ++++++++++++---- install-scripts/uninstall-safe-chain.sh | 89 +++++++++++++++---- packages/safe-chain/bin/safe-chain.js | 19 +++- packages/safe-chain/src/config/configFile.js | 4 +- .../src/config/environmentVariables.js | 11 --- .../src/config/environmentVariables.spec.js | 30 ------- .../safe-chain/src/config/safeChainDir.js | 10 +++ packages/safe-chain/src/installLocation.js | 39 ++++++++ .../safe-chain/src/installLocation.spec.js | 51 +++++++++++ .../safe-chain/src/registryProxy/certUtils.js | 6 +- .../src/registryProxy/certUtils.spec.js | 19 ++-- .../src/shell-integration/helpers.js | 9 +- .../src/shell-integration/helpers.spec.js | 43 +-------- .../templates/unix-wrapper.template.sh | 8 +- .../templates/windows-wrapper.template.cmd | 3 +- .../src/shell-integration/setup-ci.js | 6 +- .../startup-scripts/init-fish.fish | 7 +- .../startup-scripts/init-posix.sh | 24 +++-- .../startup-scripts/init-pwsh.ps1 | 3 +- .../supported-shells/bash.js | 23 +---- .../supported-shells/bash.spec.js | 24 ++--- .../supported-shells/fish.js | 23 +---- .../supported-shells/fish.spec.js | 24 ++--- .../supported-shells/powershell.js | 22 +---- .../supported-shells/powershell.spec.js | 24 ++--- .../supported-shells/windowsPowershell.js | 22 +---- .../windowsPowershell.spec.js | 24 ++--- .../shell-integration/supported-shells/zsh.js | 23 +---- .../supported-shells/zsh.spec.js | 24 ++--- 32 files changed, 429 insertions(+), 400 deletions(-) delete mode 100644 packages/safe-chain/src/config/environmentVariables.spec.js create mode 100644 packages/safe-chain/src/config/safeChainDir.js create mode 100644 packages/safe-chain/src/installLocation.js create mode 100644 packages/safe-chain/src/installLocation.spec.js diff --git a/README.md b/README.md index 0dc3f40..6a39aea 100644 --- a/README.md +++ b/README.md @@ -318,16 +318,21 @@ The base URL should point to a server that mirrors the structure of `https://mal ## Custom Install Directory -By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by setting `SAFE_CHAIN_DIR` before running the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. +By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by passing an explicit install directory to the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`. ```shell -export SAFE_CHAIN_DIR=/usr/local/.safe-chain -curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh +curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --install-dir /usr/local/.safe-chain ``` -This is a **one-time setting**. `safe-chain setup` automatically persists `SAFE_CHAIN_DIR` to your shell rc files (e.g. `~/.bashrc`, `~/.zshrc`) so that subsequent `safe-chain` commands (including teardown and re-setup) find the correct directory without needing the variable set again. +On Windows, use `-InstallDir`: + +```powershell +iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -InstallDir 'C:\ProgramData\safe-chain'" +``` + +This is a one-time installer choice. Runtime shell integration and uninstall now discover the installation from the installed scripts or binary and do not rely on an environment variable. # Usage in CI/CD @@ -419,7 +424,7 @@ pipeline { environment { // Jenkins does not automatically persist PATH updates from setup-ci, // so add the shims + binary directory explicitly for all stages. - // If you set SAFE_CHAIN_DIR, replace ~/.safe-chain with that path here. + // If you installed into a custom directory, replace ~/.safe-chain with that path here. PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}" } @@ -475,7 +480,7 @@ To add safe-chain in GitLab pipelines, you need to install it in the image runni # Install safe-chain RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - # Add safe-chain to PATH (update paths if you set SAFE_CHAIN_DIR during install) + # Add safe-chain to PATH (update paths if you used a custom install dir) ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}" ``` diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 4e77df4..ec0dcd6 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -4,25 +4,49 @@ param( [switch]$ci, - [switch]$includepython + [switch]$includepython, + [string]$InstallDir ) -$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set +function Test-InstallDir { + param([string]$Dir) -# Validate SAFE_CHAIN_DIR before use -if ($env:SAFE_CHAIN_DIR) { - if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { - Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" -ForegroundColor Red; exit 1 + if ([string]::IsNullOrWhiteSpace($Dir)) { + return @{ Ok = $true; Normalized = $null } } - if ($env:SAFE_CHAIN_DIR -match '\.\.') { - Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 + + if (-not [System.IO.Path]::IsPathRooted($Dir)) { + return @{ Ok = $false; Reason = "-InstallDir must be an absolute path, got: $Dir" } } - if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { - Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 + + if ($Dir.Contains([System.IO.Path]::PathSeparator)) { + return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" } } + + $normalized = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetPathRoot($normalized) + if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) { + return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" } + } + + $segments = $normalized.Substring($root.Length).Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) + if ($segments -contains "..") { + return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" } + } + + return @{ Ok = $true; Normalized = $normalized } } -$SafeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $env:USERPROFILE ".safe-chain" } +$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set +$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $env:USERPROFILE ".safe-chain" } + +$installDirValidation = Test-InstallDir -Dir $SafeChainBase +if (-not $installDirValidation.Ok) { + Write-Host "[ERROR] $($installDirValidation.Reason)" -ForegroundColor Red + exit 1 +} + +$SafeChainBase = $installDirValidation.Normalized $InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 03923d8..6a586e7 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -6,24 +6,50 @@ set -e # Exit on error +validate_install_dir() { + dir="$1" + + if [ -z "$dir" ]; then + return 0 + fi + + case "$dir" in + /*) ;; + *) + printf '[ERROR] --install-dir must be an absolute path, got: %s\n' "$dir" >&2 + exit 1 + ;; + esac + + case "$dir" in + *:*) + printf '[ERROR] --install-dir must not contain the PATH separator (:)\n' >&2 + exit 1 + ;; + esac + + if [ "$dir" = "/" ]; then + printf '[ERROR] --install-dir cannot be a root or drive-root directory\n' >&2 + exit 1 + fi + + old_ifs=$IFS + IFS='/' + set -- $dir + IFS=$old_ifs + + for segment in "$@"; do + if [ "$segment" = ".." ]; then + printf '[ERROR] --install-dir must not contain path traversal segments\n' >&2 + exit 1 + fi + done +} + # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set +SAFE_CHAIN_BASE="${HOME}/.safe-chain" -# Validate SAFE_CHAIN_DIR before use -if [ -n "${SAFE_CHAIN_DIR}" ]; then - case "${SAFE_CHAIN_DIR}" in - /*) ;; - *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_DIR}" >&2; exit 1 ;; - esac - case "${SAFE_CHAIN_DIR}" in - *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; - esac - if [ "${SAFE_CHAIN_DIR}" = "/" ]; then - printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 - fi -fi - -SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" @@ -245,19 +271,33 @@ remove_nvm_installation() { # Parse command-line arguments parse_arguments() { - for arg in "$@"; do - case "$arg" in + while [ $# -gt 0 ]; do + case "$1" in --ci) USE_CI_SETUP=true ;; + --install-dir) + shift + if [ $# -eq 0 ]; then + error "Missing value for --install-dir" + fi + SAFE_CHAIN_BASE="$1" + ;; + --install-dir=*) + SAFE_CHAIN_BASE="${1#--install-dir=}" + ;; --include-python) warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." ;; *) - error "Unknown argument: $arg" + error "Unknown argument: $1" ;; esac + shift done + + validate_install_dir "${SAFE_CHAIN_BASE}" + INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" } # Main installation diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 785e58a..2aa3798 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -5,22 +5,6 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } -# Validate SAFE_CHAIN_DIR before use -if ($env:SAFE_CHAIN_DIR) { - if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { - Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" -ForegroundColor Red; exit 1 - } - if ($env:SAFE_CHAIN_DIR -match '\.\.') { - Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 - } - if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { - Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 - } -} - -$DotSafeChain = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HomeDir ".safe-chain" } -$InstallDir = Join-Path $DotSafeChain "bin" - # Helper functions function Write-Info { param([string]$Message) @@ -38,6 +22,64 @@ function Write-Error-Custom { exit 1 } +function Get-InstallDirFromBinaryPath { + param([string]$BinaryPath) + + if ([string]::IsNullOrWhiteSpace($BinaryPath)) { + return $null + } + + try { + $resolvedPath = (Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop).Path + } + catch { + $resolvedPath = [System.IO.Path]::GetFullPath($BinaryPath) + } + + $fileName = [System.IO.Path]::GetFileName($resolvedPath) + if (($fileName -ne "safe-chain") -and ($fileName -ne "safe-chain.exe")) { + return $null + } + + if ($resolvedPath -match '\.(js|cjs|mjs|cmd|ps1)$') { + return $null + } + + $binDir = Split-Path -Parent $resolvedPath + if ((Split-Path -Leaf $binDir) -ne "bin") { + return $null + } + + return (Split-Path -Parent $binDir) +} + +function Get-SafeChainInstallDir { + $command = Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + try { + $reportedInstallDir = & safe-chain get-install-dir 2>$null | Select-Object -First 1 + if ($reportedInstallDir) { + $reportedInstallDir = $reportedInstallDir.Trim() + } + if ($reportedInstallDir) { + return $reportedInstallDir + } + } + catch { + # Fall back to deriving the install dir from the discovered command path + } + } + + if ($command -and $command.Path) { + $discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path + if ($discoveredInstallDir) { + return $discoveredInstallDir + } + } + + return (Join-Path $HomeDir ".safe-chain") +} + # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -90,6 +132,8 @@ function Remove-VoltaInstallation { # Main uninstallation function Uninstall-SafeChain { Write-Info "Uninstalling safe-chain..." + $DotSafeChain = Get-SafeChainInstallDir + $InstallDir = Join-Path $DotSafeChain "bin" # Run teardown if safe-chain is available # Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index abde7ca..4169e1e 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -8,22 +8,6 @@ set -e # Exit on error # Configuration -# Validate SAFE_CHAIN_DIR before use -if [ -n "${SAFE_CHAIN_DIR}" ]; then - case "${SAFE_CHAIN_DIR}" in - /*) ;; - *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_DIR}" >&2; exit 1 ;; - esac - case "${SAFE_CHAIN_DIR}" in - *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; - esac - if [ "${SAFE_CHAIN_DIR}" = "/" ]; then - printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 - fi -fi - -DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" - # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -49,6 +33,78 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +resolve_path() { + target="$1" + + while [ -L "$target" ]; do + link_target=$(readlink "$target" 2>/dev/null || echo "") + if [ -z "$link_target" ]; then + break + fi + + case "$link_target" in + /*) target="$link_target" ;; + *) + target="$(dirname "$target")/$link_target" + ;; + esac + done + + target_dir=$(dirname "$target") + target_name=$(basename "$target") + + if cd "$target_dir" 2>/dev/null; then + printf '%s/%s\n' "$(pwd -P)" "$target_name" + else + printf '%s\n' "$target" + fi +} + +derive_install_dir_from_binary() { + binary_path="$1" + + if [ -z "$binary_path" ]; then + return 1 + fi + + resolved_path=$(resolve_path "$binary_path") + binary_name=$(basename "$resolved_path") + case "$binary_name" in + safe-chain|safe-chain.exe) ;; + *) return 1 ;; + esac + + case "$resolved_path" in + *.js|*.cjs|*.mjs|*.cmd|*.ps1) return 1 ;; + esac + + binary_dir=$(dirname "$resolved_path") + if [ "$(basename "$binary_dir")" != "bin" ]; then + return 1 + fi + + dirname "$binary_dir" +} + +get_install_dir() { + if command_exists safe-chain; then + install_dir=$(safe-chain get-install-dir 2>/dev/null || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 + fi + + command_path=$(command -v safe-chain) + install_dir=$(derive_install_dir_from_binary "$command_path" || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 + fi + fi + + printf '%s\n' "${HOME}/.safe-chain" +} + # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -154,6 +210,7 @@ remove_nvm_installation() { # Main uninstallation main() { + DOT_SAFE_CHAIN=$(get_install_dir) SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" if [ -x "$SAFE_CHAIN_LOCATION" ]; then diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 8d942e4..43819b9 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -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", diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 1b978ea..d340130 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -3,7 +3,7 @@ import path from "path"; import os from "os"; import { ui } from "../environment/userInteraction.js"; import { getEcoSystem } from "./settings.js"; -import { getSafeChainDir } from "./environmentVariables.js"; +import { getSafeChainBaseDir } from "./safeChainDir.js"; /** * @typedef {Object} SafeChainConfig @@ -305,7 +305,7 @@ function getConfigFilePath() { * @returns {string} */ export function getSafeChainDirectory() { - const safeChainDir = getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); + const safeChainDir = getSafeChainBaseDir(); if (!fs.existsSync(safeChainDir)) { fs.mkdirSync(safeChainDir, { recursive: true }); diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index b76a413..932eff7 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -55,14 +55,3 @@ export function getMinimumPackageAgeExclusions() { export function getMalwareListBaseUrl() { return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL; } - -/** - * Gets the safe-chain base directory from environment variable. - * When set, all safe-chain data (bin, shims, scripts) will be placed under this directory - * instead of the default ~/.safe-chain, enabling system-wide installations. - * Example: "/usr/local/.safe-chain" - * @returns {string | undefined} - */ -export function getSafeChainDir() { - return process.env.SAFE_CHAIN_DIR; -} diff --git a/packages/safe-chain/src/config/environmentVariables.spec.js b/packages/safe-chain/src/config/environmentVariables.spec.js deleted file mode 100644 index 2cbdd0f..0000000 --- a/packages/safe-chain/src/config/environmentVariables.spec.js +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, beforeEach, afterEach } from "node:test"; -import assert from "node:assert"; - -const { getSafeChainDir } = await import("./environmentVariables.js"); - -describe("getSafeChainDir", () => { - let original; - - beforeEach(() => { - original = process.env.SAFE_CHAIN_DIR; - }); - - afterEach(() => { - if (original !== undefined) { - process.env.SAFE_CHAIN_DIR = original; - } else { - delete process.env.SAFE_CHAIN_DIR; - } - }); - - it("returns undefined when SAFE_CHAIN_DIR is not set", () => { - delete process.env.SAFE_CHAIN_DIR; - assert.strictEqual(getSafeChainDir(), undefined); - }); - - it("returns the value of SAFE_CHAIN_DIR when set", () => { - process.env.SAFE_CHAIN_DIR = "/usr/local/.safe-chain"; - assert.strictEqual(getSafeChainDir(), "/usr/local/.safe-chain"); - }); -}); diff --git a/packages/safe-chain/src/config/safeChainDir.js b/packages/safe-chain/src/config/safeChainDir.js new file mode 100644 index 0000000..595300a --- /dev/null +++ b/packages/safe-chain/src/config/safeChainDir.js @@ -0,0 +1,10 @@ +import os from "os"; +import path from "path"; +import { getInstalledSafeChainDir } from "../installLocation.js"; + +/** + * @returns {string} + */ +export function getSafeChainBaseDir() { + return getInstalledSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); +} diff --git a/packages/safe-chain/src/installLocation.js b/packages/safe-chain/src/installLocation.js new file mode 100644 index 0000000..efe687a --- /dev/null +++ b/packages/safe-chain/src/installLocation.js @@ -0,0 +1,39 @@ +import path from "path"; + +/** + * @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(process.pkg); + if (!isPackaged) { + return undefined; + } + + return deriveInstallDirFromExecutablePath( + options.executablePath ?? process.execPath, + ); +} diff --git a/packages/safe-chain/src/installLocation.spec.js b/packages/safe-chain/src/installLocation.spec.js new file mode 100644 index 0000000..558a05f --- /dev/null +++ b/packages/safe-chain/src/installLocation.spec.js @@ -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", + ); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index a4bc0b1..50fad7b 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -1,16 +1,14 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; -import os from "os"; -import { getSafeChainDir } from "../config/environmentVariables.js"; +import { getSafeChainBaseDir } from "../config/safeChainDir.js"; const ca = loadCa(); const certCache = new Map(); function getCertFolder() { - const safeChainDir = getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); - return path.join(safeChainDir, "certs"); + return path.join(getSafeChainBaseDir(), "certs"); } /** diff --git a/packages/safe-chain/src/registryProxy/certUtils.spec.js b/packages/safe-chain/src/registryProxy/certUtils.spec.js index ebf8dab..c715c8c 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.spec.js +++ b/packages/safe-chain/src/registryProxy/certUtils.spec.js @@ -2,24 +2,23 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; describe("certUtils", () => { - let originalSafeChainDir; + let installedSafeChainDir; beforeEach(() => { - originalSafeChainDir = process.env.SAFE_CHAIN_DIR; + installedSafeChainDir = undefined; + mock.module("../config/safeChainDir.js", { + namedExports: { + getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain", + }, + }); }); afterEach(() => { - if (originalSafeChainDir === undefined) { - delete process.env.SAFE_CHAIN_DIR; - } else { - process.env.SAFE_CHAIN_DIR = originalSafeChainDir; - } - mock.reset(); }); - it("stores CA certificates in SAFE_CHAIN_DIR when configured", async () => { - process.env.SAFE_CHAIN_DIR = "/custom/safe-chain"; + it("stores CA certificates in the packaged install dir when available", async () => { + installedSafeChainDir = "/custom/safe-chain"; mock.module("fs", { defaultExport: { diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 2d66d1d..3dd73aa 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -3,8 +3,7 @@ import * as os from "os"; import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; -import { getSafeChainDir } from "../config/environmentVariables.js"; -export { getSafeChainDir }; +import { getSafeChainBaseDir } from "../config/safeChainDir.js"; import { safeSpawn } from "../utils/safeSpawn.js"; import { ui } from "../environment/userInteraction.js"; @@ -125,12 +124,10 @@ export function getPackageManagerList() { /** * Returns the safe-chain base directory. - * Uses SAFE_CHAIN_DIR environment variable when set, otherwise defaults to ~/.safe-chain. + * Uses the packaged binary location when available, otherwise defaults to ~/.safe-chain. * @returns {string} */ -export function getSafeChainBaseDir() { - return getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); -} +export { getSafeChainBaseDir }; /** * @returns {string} diff --git a/packages/safe-chain/src/shell-integration/helpers.spec.js b/packages/safe-chain/src/shell-integration/helpers.spec.js index 8fd172b..8870451 100644 --- a/packages/safe-chain/src/shell-integration/helpers.spec.js +++ b/packages/safe-chain/src/shell-integration/helpers.spec.js @@ -185,64 +185,23 @@ describe("removeLinesMatchingPatternTests", () => { }); describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => { - const customDir = "/usr/local/.safe-chain"; - - let originalSafeChainDir; - - beforeEach(() => { - originalSafeChainDir = process.env.SAFE_CHAIN_DIR; - delete process.env.SAFE_CHAIN_DIR; - }); - - afterEach(() => { - if (originalSafeChainDir !== undefined) { - process.env.SAFE_CHAIN_DIR = originalSafeChainDir; - } else { - delete process.env.SAFE_CHAIN_DIR; - } - }); - - it("defaults base dir to ~/.safe-chain when SAFE_CHAIN_DIR is not set", async () => { + it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => { const { getSafeChainBaseDir } = await import("./helpers.js"); assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain")); }); - it("uses SAFE_CHAIN_DIR as base dir when set", async () => { - process.env.SAFE_CHAIN_DIR = customDir; - const { getSafeChainBaseDir } = await import("./helpers.js"); - assert.strictEqual(getSafeChainBaseDir(), customDir); - }); - it("getBinDir returns ~/.safe-chain/bin by default", async () => { const { getBinDir } = await import("./helpers.js"); assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin")); }); - it("getBinDir returns custom dir + /bin when SAFE_CHAIN_DIR is set", async () => { - process.env.SAFE_CHAIN_DIR = customDir; - const { getBinDir } = await import("./helpers.js"); - assert.strictEqual(getBinDir(), `${customDir}/bin`); - }); - it("getShimsDir returns ~/.safe-chain/shims by default", async () => { const { getShimsDir } = await import("./helpers.js"); assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims")); }); - it("getShimsDir returns custom dir + /shims when SAFE_CHAIN_DIR is set", async () => { - process.env.SAFE_CHAIN_DIR = customDir; - const { getShimsDir } = await import("./helpers.js"); - assert.strictEqual(getShimsDir(), `${customDir}/shims`); - }); - it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => { const { getScriptsDir } = await import("./helpers.js"); assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts")); }); - - it("getScriptsDir returns custom dir + /scripts when SAFE_CHAIN_DIR is set", async () => { - process.env.SAFE_CHAIN_DIR = customDir; - const { getScriptsDir } = await import("./helpers.js"); - assert.strictEqual(getScriptsDir(), `${customDir}/scripts`); - }); }); diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 5635b1a..9275230 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,7 +4,7 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - _safe_chain_shims="{{SHIMS_DIR}}" + _safe_chain_shims=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) echo "$PATH" | sed "s|${_safe_chain_shims}:||g" } @@ -13,11 +13,7 @@ if command -v safe-chain >/dev/null 2>&1; then 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 - if [ -n "$SAFE_CHAIN_DIR" ]; then - printf "\033[43;30mWarning:\033[0m safe-chain is not accessible. Check that '%s/bin' is readable and executable by the current user.\n" "$SAFE_CHAIN_DIR" >&2 - else - 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 - fi + 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}}) diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd index 89f538f..b41fcfb 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd @@ -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={{SHIMS_DIR}}" +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 diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 0dc32cf..1986bba 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -69,8 +69,7 @@ function createUnixShims(shimsDir) { for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) - .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand) - .replaceAll("{{SHIMS_DIR}}", shimsDir); + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); const shimPath = path.join(shimsDir, toolInfo.tool); fs.writeFileSync(shimPath, shimContent, "utf-8"); @@ -109,8 +108,7 @@ function createWindowsShims(shimsDir) { for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) - .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand) - .replaceAll("{{SHIMS_DIR}}", shimsDir); + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); const shimPath = `${shimsDir}/${toolInfo.tool}.cmd`; fs.writeFileSync(shimPath, shimContent, "utf-8"); diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 11d1d55..e0cc9ec 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,8 +1,5 @@ -# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing ':' -set -l safe_chain_base $HOME/.safe-chain -if set -q SAFE_CHAIN_DIR; and not string match -q '*:*' -- $SAFE_CHAIN_DIR - set safe_chain_base $SAFE_CHAIN_DIR -end +set -l safe_chain_script (status filename) +set -l safe_chain_base (path dirname (path dirname $safe_chain_script)) set -gx PATH $PATH $safe_chain_base/bin function npx diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index 45c6fd9..4235276 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,10 +1,22 @@ -# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing ':' -case "${SAFE_CHAIN_DIR}" in - *:*) _sc_base="${HOME}/.safe-chain" ;; - *) _sc_base="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" ;; -esac +_get_safe_chain_script_path() { + if [ -n "${BASH_SOURCE[0]:-}" ]; then + printf '%s\n' "${BASH_SOURCE[0]}" + return + fi + + if [ -n "${ZSH_VERSION:-}" ]; then + eval 'printf "%s\n" "${(%):-%N}"' + return + fi + + printf '%s\n' "$0" +} + +_sc_script_path="$(_get_safe_chain_script_path)" +_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 +unset _sc_base _sc_script_path _sc_scripts_dir function npx() { wrapSafeChainCommand "npx" "$@" diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index f814917..167e5d8 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -2,8 +2,7 @@ # $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 { ':' } -# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing the path separator -$safeChainBase = if ($env:SAFE_CHAIN_DIR -and -not $env:SAFE_CHAIN_DIR.Contains($pathSeparator)) { $env:SAFE_CHAIN_DIR } else { Join-Path $HOME '.safe-chain' } +$safeChainBase = Split-Path -Parent $PSScriptRoot $safeChainBin = Join-Path $safeChainBase 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index ff2266b..fc56025 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -3,7 +3,6 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync, spawnSync } from "child_process"; import * as os from "os"; @@ -54,15 +53,6 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`, - eol - ); - } - addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain bash initialization script`, @@ -143,18 +133,7 @@ function cygpathw(path) { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` export SAFE_CHAIN_DIR="${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); - } - + const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-posix.sh")}`]; instructions.push(`Then restart your terminal or run: source ~/.bashrc`); return instructions; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index 4b25d4b..4eaaa6f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -10,7 +10,6 @@ describe("Bash shell integration", () => { let bash; let windowsCygwinPath = ""; let platform = "linux"; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -21,7 +20,6 @@ describe("Bash shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -91,7 +89,6 @@ describe("Bash shell integration", () => { // Reset mocks mock.reset(); platform = "linux"; - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -203,26 +200,18 @@ describe("Bash shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write export line to rc file when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the rc file", () => { bash.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory') + content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") ); - }); - - it("should not write export line when no custom dir is set", () => { - getSafeChainDirResult = undefined; - bash.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove export line on teardown", () => { + it("removes legacy export lines on teardown", () => { const initialContent = [ '#!/bin/bash', 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', @@ -236,12 +225,9 @@ describe("Bash shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual setup instructions when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; - + it("shows source-only manual setup instructions", () => { assert.deepStrictEqual(bash.getManualSetupInstructions(), [ "Add the following line to your ~/.bashrc file:", - ' export SAFE_CHAIN_DIR="/custom/safe-chain"', " source /test-home/.safe-chain/scripts/init-posix.sh", "Then restart your terminal or run: source ~/.bashrc", ]); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index a6ffe1e..d5ea308 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -3,7 +3,6 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -53,15 +52,6 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `set -gx SAFE_CHAIN_DIR "${customDir}" # Safe-chain installation directory`, - eol - ); - } - addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`, @@ -86,18 +76,7 @@ function getStartupFile() { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` set -gx SAFE_CHAIN_DIR "${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-fish.fish`); - } - + const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-fish.fish")}`]; instructions.push( `Then restart your terminal or run: source ~/.config/fish/config.fish`, ); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index 29b6d6e..9a30f11 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -8,7 +8,6 @@ import { knownAikidoTools } from "../helpers.js"; describe("Fish shell integration", () => { let mockStartupFile; let fish; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -19,7 +18,6 @@ describe("Fish shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -55,7 +53,6 @@ describe("Fish shell integration", () => { // Reset mocks mock.reset(); - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -156,26 +153,18 @@ describe("Fish shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write set line to config file when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the config file", () => { fish.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory') + content.includes("source /test-home/.safe-chain/scripts/init-fish.fish") ); - }); - - it("should not write set line when no custom dir is set", () => { - getSafeChainDirResult = undefined; - fish.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove set line on teardown", () => { + it("removes legacy set lines on teardown", () => { const initialContent = [ 'set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory', "source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", @@ -188,12 +177,9 @@ describe("Fish shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual setup instructions when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; - + it("shows source-only manual setup instructions", () => { assert.deepStrictEqual(fish.getManualSetupInstructions(), [ "Add the following line to your ~/.config/fish/config.fish file:", - ' set -gx SAFE_CHAIN_DIR "/custom/safe-chain"', " source /test-home/.safe-chain/scripts/init-fish.fish", "Then restart your terminal or run: source ~/.config/fish/config.fish", ]); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 906bedd..becc3db 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -4,7 +4,6 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -58,14 +57,6 @@ async function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`, - ); - } - addLineToFile( startupFile, `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, @@ -89,18 +80,7 @@ function getStartupFile() { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` $env:SAFE_CHAIN_DIR = '${customDir}'`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - ); - } else { - instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); - } - + const instructions = [preamble, ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`]; instructions.push(`Then restart your terminal or run: . $PROFILE`); return instructions; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 296abfa..16023b5 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -9,7 +9,6 @@ describe("PowerShell Core shell integration", () => { let mockStartupFile; let powershell; let executionPolicyResult; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -27,7 +26,6 @@ describe("PowerShell Core shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -65,7 +63,6 @@ describe("PowerShell Core shell integration", () => { // Reset mocks mock.reset(); - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -209,26 +206,18 @@ describe("PowerShell Core shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => { - getSafeChainDirResult = "C:\\custom\\safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the profile", async () => { await powershell.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory") + content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"') ); - }); - - it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => { - getSafeChainDirResult = undefined; - await powershell.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => { + it("removes legacy env lines on teardown", () => { const initialContent = [ "# PowerShell profile", "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", @@ -242,12 +231,9 @@ describe("PowerShell Core shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual setup instructions when custom dir is set", () => { - getSafeChainDirResult = "C:\\custom\\safe-chain"; - + it("shows source-only manual setup instructions", () => { assert.deepStrictEqual(powershell.getManualSetupInstructions(), [ 'Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):', - " $env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain'", ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', "Then restart your terminal or run: . $PROFILE", ]); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index e53891e..4a27fe9 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -4,7 +4,6 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -58,14 +57,6 @@ async function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`, - ); - } - addLineToFile( startupFile, `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, @@ -89,18 +80,7 @@ function getStartupFile() { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` $env:SAFE_CHAIN_DIR = '${customDir}'`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - ); - } else { - instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); - } - + const instructions = [preamble, ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`]; instructions.push(`Then restart your terminal or run: . $PROFILE`); return instructions; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 840f585..ac26ca7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -9,7 +9,6 @@ describe("Windows PowerShell shell integration", () => { let mockStartupFile; let windowsPowershell; let executionPolicyResult; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -27,7 +26,6 @@ describe("Windows PowerShell shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -65,7 +63,6 @@ describe("Windows PowerShell shell integration", () => { // Reset mocks mock.reset(); - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -209,26 +206,18 @@ describe("Windows PowerShell shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => { - getSafeChainDirResult = "C:\\custom\\safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the profile", async () => { await windowsPowershell.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory") + content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"') ); - }); - - it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => { - getSafeChainDirResult = undefined; - await windowsPowershell.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => { + it("removes legacy env lines on teardown", () => { const initialContent = [ "# Windows PowerShell profile", "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", @@ -242,12 +231,9 @@ describe("Windows PowerShell shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual teardown instructions when custom dir is set", () => { - getSafeChainDirResult = "C:\\custom\\safe-chain"; - + it("shows source-only manual teardown instructions", () => { assert.deepStrictEqual(windowsPowershell.getManualTeardownInstructions(), [ 'Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):', - " $env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain'", ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', "Then restart your terminal or run: . $PROFILE", ]); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index 9b87d86..3fa775c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -3,7 +3,6 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -53,15 +52,6 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`, - eol - ); - } - addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`, @@ -86,18 +76,7 @@ function getStartupFile() { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` export SAFE_CHAIN_DIR="${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); - } - + const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-posix.sh")}`]; instructions.push(`Then restart your terminal or run: source ~/.zshrc`); return instructions; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 52e790f..caa85f4 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -8,7 +8,6 @@ import { knownAikidoTools } from "../helpers.js"; describe("Zsh shell integration", () => { let mockStartupFile; let zsh; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -19,7 +18,6 @@ describe("Zsh shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -55,7 +53,6 @@ describe("Zsh shell integration", () => { // Reset mocks mock.reset(); - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -174,26 +171,18 @@ describe("Zsh shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write export line to rc file when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the rc file", () => { zsh.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory') + content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") ); - }); - - it("should not write export line when no custom dir is set", () => { - getSafeChainDirResult = undefined; - zsh.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove export line on teardown", () => { + it("removes legacy export lines on teardown", () => { const initialContent = [ "#!/bin/zsh", 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', @@ -207,12 +196,9 @@ describe("Zsh shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual teardown instructions when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; - + it("shows source-only manual teardown instructions", () => { assert.deepStrictEqual(zsh.getManualTeardownInstructions(), [ "Remove the following line from your ~/.zshrc file:", - ' export SAFE_CHAIN_DIR="/custom/safe-chain"', " source /test-home/.safe-chain/scripts/init-posix.sh", "Then restart your terminal or run: source ~/.zshrc", ]); From 031c9683b1ed71325e9119283206fe324934be63 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:10:16 -0700 Subject: [PATCH 18/40] Some more cleanup --- .../supported-shells/bash.js | 27 ++- .../supported-shells/bash.spec.js | 34 ---- .../supported-shells/fish.js | 29 ++- .../supported-shells/fish.spec.js | 33 ---- .../supported-shells/powershell.js | 26 ++- .../supported-shells/powershell.spec.js | 34 ---- .../supported-shells/windowsPowershell.js | 26 ++- .../windowsPowershell.spec.js | 34 ---- .../shell-integration/supported-shells/zsh.js | 27 ++- .../supported-shells/zsh.spec.js | 34 ---- test/e2e/bun.e2e.spec.js | 34 ---- test/e2e/npm-ci.e2e.spec.js | 42 ----- test/e2e/npm.e2e.spec.js | 34 ---- test/e2e/pip-ci.e2e.spec.js | 39 ---- test/e2e/pip.e2e.spec.js | 35 ---- test/e2e/pipx.e2e.spec.js | 34 ---- test/e2e/pnpm-ci.e2e.spec.js | 40 ----- test/e2e/pnpm.e2e.spec.js | 34 ---- test/e2e/poetry.e2e.spec.js | 41 ----- test/e2e/safe-chain-dir.e2e.spec.js | 166 ------------------ test/e2e/uv.e2e.spec.js | 38 ---- test/e2e/yarn-ci.e2e.spec.js | 40 ----- test/e2e/yarn.e2e.spec.js | 38 ---- 23 files changed, 55 insertions(+), 864 deletions(-) delete mode 100644 test/e2e/safe-chain-dir.e2e.spec.js diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index fc56025..5e113bd 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -34,19 +34,13 @@ function teardown(tools) { ); } - // Marker comment ensures only safe-chain-added lines are removed, not user's own source statements + // Removes the line that sources the safe-chain bash initialization script. removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, eol ); - removeLinesMatchingPattern( - startupFile, - /^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/, - eol - ); - return true; } @@ -131,19 +125,20 @@ function cygpathw(path) { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-posix.sh")}`]; - instructions.push(`Then restart your terminal or run: source ~/.bashrc`); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your ~/.bashrc file:`); + return [ + `Remove the following line from your ~/.bashrc file:`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + `Then restart your terminal or run: source ~/.bashrc`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your ~/.bashrc file:`); + return [ + `Add the following line to your ~/.bashrc file:`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + `Then restart your terminal or run: source ~/.bashrc`, + ]; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index 4eaaa6f..f0a56d2 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -200,40 +200,6 @@ describe("Bash shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the rc file", () => { - bash.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy export lines on teardown", () => { - const initialContent = [ - '#!/bin/bash', - 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', - 'source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script', - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - bash.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual setup instructions", () => { - assert.deepStrictEqual(bash.getManualSetupInstructions(), [ - "Add the following line to your ~/.bashrc file:", - " source /test-home/.safe-chain/scripts/init-posix.sh", - "Then restart your terminal or run: source ~/.bashrc", - ]); - }); - }); - describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index d5ea308..28323bf 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -33,19 +33,13 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain fish initialization script (any path, requires safe-chain comment) + // Removes the line that sources the safe-chain fish initialization script. removeLinesMatchingPattern( startupFile, /^source\s+.*init-fish\.fish.*#\s*Safe-chain/, eol ); - removeLinesMatchingPattern( - startupFile, - /^set\s+-gx\s+SAFE_CHAIN_DIR\s+.*#\s*Safe-chain/, - eol - ); - return true; } @@ -74,21 +68,20 @@ function getStartupFile() { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-fish.fish")}`]; - instructions.push( - `Then restart your terminal or run: source ~/.config/fish/config.fish`, - ); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your ~/.config/fish/config.fish file:`); + return [ + `Remove the following line from your ~/.config/fish/config.fish file:`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your ~/.config/fish/config.fish file:`); + return [ + `Add the following line to your ~/.config/fish/config.fish file:`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ]; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index 9a30f11..0933b6e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -153,39 +153,6 @@ describe("Fish shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the config file", () => { - fish.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes("source /test-home/.safe-chain/scripts/init-fish.fish") - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy set lines on teardown", () => { - const initialContent = [ - 'set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory', - "source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - fish.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual setup instructions", () => { - assert.deepStrictEqual(fish.getManualSetupInstructions(), [ - "Add the following line to your ~/.config/fish/config.fish file:", - " source /test-home/.safe-chain/scripts/init-fish.fish", - "Then restart your terminal or run: source ~/.config/fish/config.fish", - ]); - }); - }); - describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index becc3db..d0f5eed 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -32,17 +32,12 @@ function teardown(tools) { ); } - // Remove the line that sources the safe-chain PowerShell initialization script (any path, requires safe-chain comment) + // Removes the line that sources the safe-chain PowerShell initialization script. removeLinesMatchingPattern( startupFile, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); - removeLinesMatchingPattern( - startupFile, - /^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/, - ); - return true; } @@ -78,19 +73,20 @@ function getStartupFile() { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`]; - instructions.push(`Then restart your terminal or run: . $PROFILE`); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`); + return [ + `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + `Then restart your terminal or run: . $PROFILE`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`); + return [ + `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + `Then restart your terminal or run: . $PROFILE`, + ]; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 16023b5..1d9f65c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -206,40 +206,6 @@ describe("PowerShell Core shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the profile", async () => { - await powershell.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"') - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy env lines on teardown", () => { - const initialContent = [ - "# PowerShell profile", - "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", - '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - powershell.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual setup instructions", () => { - assert.deepStrictEqual(powershell.getManualSetupInstructions(), [ - 'Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):', - ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', - "Then restart your terminal or run: . $PROFILE", - ]); - }); - }); - describe("execution policy", () => { it(`should throw for restricted policies`, async () => { executionPolicyResult = { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 4a27fe9..87c2fae 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -32,17 +32,12 @@ function teardown(tools) { ); } - // Match any installation path but require the Safe-chain marker to avoid removing unrelated user scripts + // Removes the line that sources the safe-chain PowerShell initialization script. removeLinesMatchingPattern( startupFile, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); - removeLinesMatchingPattern( - startupFile, - /^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/, - ); - return true; } @@ -78,19 +73,20 @@ function getStartupFile() { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`]; - instructions.push(`Then restart your terminal or run: . $PROFILE`); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`); + return [ + `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + `Then restart your terminal or run: . $PROFILE`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`); + return [ + `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + `Then restart your terminal or run: . $PROFILE`, + ]; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index ac26ca7..621b380 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -206,40 +206,6 @@ describe("Windows PowerShell shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the profile", async () => { - await windowsPowershell.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"') - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy env lines on teardown", () => { - const initialContent = [ - "# Windows PowerShell profile", - "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", - '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - windowsPowershell.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual teardown instructions", () => { - assert.deepStrictEqual(windowsPowershell.getManualTeardownInstructions(), [ - 'Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):', - ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', - "Then restart your terminal or run: . $PROFILE", - ]); - }); - }); - describe("execution policy", () => { it(`should throw for restricted policies`, async () => { executionPolicyResult = { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index 3fa775c..c1c1232 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -33,19 +33,13 @@ function teardown(tools) { ); } - // Remove init script source line to uninstall shell integration; marker ensures only safe-chain-added lines are removed + // Removes the line that sources the safe-chain zsh initialization script. removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, eol ); - removeLinesMatchingPattern( - startupFile, - /^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/, - eol - ); - return true; } @@ -74,19 +68,20 @@ function getStartupFile() { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-posix.sh")}`]; - instructions.push(`Then restart your terminal or run: source ~/.zshrc`); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your ~/.zshrc file:`); + return [ + `Remove the following line from your ~/.zshrc file:`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + `Then restart your terminal or run: source ~/.zshrc`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your ~/.zshrc file:`); + return [ + `Add the following line to your ~/.zshrc file:`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + `Then restart your terminal or run: source ~/.zshrc`, + ]; } export default { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index caa85f4..41e1bd1 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -171,40 +171,6 @@ describe("Zsh shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the rc file", () => { - zsh.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy export lines on teardown", () => { - const initialContent = [ - "#!/bin/zsh", - 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', - "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - zsh.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual teardown instructions", () => { - assert.deepStrictEqual(zsh.getManualTeardownInstructions(), [ - "Remove the following line from your ~/.zshrc file:", - " source /test-home/.safe-chain/scripts/init-posix.sh", - "Then restart your terminal or run: source ~/.zshrc", - ]); - }); - }); - describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index 1de6100..27a8923 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -79,38 +79,4 @@ describe("E2E: bun coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("bash"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious bun packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("bash"); - const result = await shell.runCommand("bunx safe-chain-test"); - - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index cc3349b..9cb0886 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -103,46 +103,4 @@ describe("E2E: npm coverage using PATH", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup-ci"); - // Persist SAFE_CHAIN_DIR and the custom shims dir in .zshrc so new shells - // inherit both (shims need SAFE_CHAIN_DIR to strip themselves from PATH) - await setupShell.runCommand( - `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` - ); - await setupShell.runCommand( - `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` - ); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious npm packages when shims are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("npm i safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index d86af3c..c07b648 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -120,38 +120,4 @@ describe("E2E: npm coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious npm packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("npm i safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index e1a7aed..7857ef2 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -205,43 +205,4 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { }); } - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand("pip3 cache purge"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup-ci"); - await setupShell.runCommand( - `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` - ); - await setupShell.runCommand( - `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` - ); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("intercepts pip3 install when shims are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand( - "pip3 install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Expected pip3 to be protected with SAFE_CHAIN_DIR. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index 684ee4f..c86e1cd 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -845,39 +845,4 @@ describe("E2E: pip coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - await setupShell.runCommand("pip3 cache purge"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("intercepts pip3 install when scripts are in a custom directory", async () => { - // New shell sources ~/.zshrc → sources init-posix.sh from custom dir - // → defines pip3() shell function that routes through safe-chain - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand( - "pip3 install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Expected pip3 to be protected with SAFE_CHAIN_DIR. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index 489d8c6..8278bb4 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -198,38 +198,4 @@ describe("E2E: pipx coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious pipx packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("pipx install safe-chain-pi-test"); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index 391001e..edba881 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -123,44 +123,4 @@ describe("E2E: pnpm coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup-ci"); - await setupShell.runCommand( - `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` - ); - await setupShell.runCommand( - `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` - ); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious pnpm packages when shims are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("pnpm add safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index 90ef57c..1c8d5ab 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -140,38 +140,4 @@ describe("E2E: pnpm coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious pnpm packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("pnpm add safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js index 072d1b6..96761bc 100644 --- a/test/e2e/poetry.e2e.spec.js +++ b/test/e2e/poetry.e2e.spec.js @@ -423,45 +423,4 @@ describe("E2E: poetry coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - await setupShell.runCommand("command poetry cache clear pypi --all -n"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious poetry packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - await shell.runCommand("mkdir /tmp/test-poetry-custom-dir"); - await shell.runCommand( - "cd /tmp/test-poetry-custom-dir && poetry init --no-interaction" - ); - const result = await shell.runCommand( - "cd /tmp/test-poetry-custom-dir && poetry add safe-chain-pi-test" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/safe-chain-dir.e2e.spec.js b/test/e2e/safe-chain-dir.e2e.spec.js deleted file mode 100644 index e738949..0000000 --- a/test/e2e/safe-chain-dir.e2e.spec.js +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -const CUSTOM_DIR = "/usr/local/.safe-chain"; - -describe("E2E: SAFE_CHAIN_DIR support", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it("setup-ci installs shims in the custom directory when SAFE_CHAIN_DIR is set", async () => { - const shell = await container.openShell("bash"); - await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await shell.runCommand("safe-chain setup-ci"); - - // Shims should be in the custom dir - const customShimResult = await shell.runCommand( - `test -f ${CUSTOM_DIR}/shims/npm && echo "EXISTS"` - ); - assert.ok( - customShimResult.output.includes("EXISTS"), - `Expected npm shim at ${CUSTOM_DIR}/shims/npm. Output:\n${customShimResult.output}` - ); - - // Default location should NOT have been created - const defaultShimResult = await shell.runCommand( - `test -d $HOME/.safe-chain/shims && echo "EXISTS" || echo "ABSENT"` - ); - assert.ok( - defaultShimResult.output.includes("ABSENT"), - `Expected default shims dir to be absent. Output:\n${defaultShimResult.output}` - ); - }); - - it("setup-ci writes the custom directory path to GITHUB_PATH when SAFE_CHAIN_DIR is set", async () => { - const shell = await container.openShell("bash"); - await shell.runCommand("export GITHUB_PATH=/tmp/github_path"); - await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await shell.runCommand("safe-chain setup-ci"); - - const result = await shell.runCommand("cat /tmp/github_path"); - assert.ok( - result.output.includes(`${CUSTOM_DIR}/shims`), - `Expected GITHUB_PATH to contain custom shims dir. Output:\n${result.output}` - ); - assert.ok( - result.output.includes(`${CUSTOM_DIR}/bin`), - `Expected GITHUB_PATH to contain custom bin dir. Output:\n${result.output}` - ); - }); - - it("setup writes the custom path to ~/.bashrc when SAFE_CHAIN_DIR is set", async () => { - const shell = await container.openShell("bash"); - await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await shell.runCommand("safe-chain setup"); - - const result = await shell.runCommand("cat ~/.bashrc"); - - assert.ok( - result.output.includes(`source ${CUSTOM_DIR}/scripts/init-posix.sh`), - `Expected ~/.bashrc to contain custom scripts path. Output:\n${result.output}` - ); - assert.ok( - !result.output.includes("source ~/.safe-chain/scripts/init-posix.sh"), - `Expected ~/.bashrc to NOT contain default path. Output:\n${result.output}` - ); - }); - - it("setup with SAFE_CHAIN_DIR still protects npm in a new shell session", async () => { - // Run setup with the custom dir - const setupShell = await container.openShell("bash"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - - // Open a fresh shell — it will source ~/.bashrc which sources init-posix.sh - // from the custom dir, defining the npm wrapper function - const projectShell = await container.openShell("bash"); - await projectShell.runCommand("cd /testapp"); - const result = await projectShell.runCommand( - "npm i axios@1.13.0 --safe-chain-logging=verbose" - ); - - // "Safe-chain: Package" appears before npm downloads — confirms interception happened - assert.ok( - result.output.includes("Safe-chain: Package"), - `Expected npm to be protected after setup with SAFE_CHAIN_DIR. Output:\n${result.output}` - ); - }); - - it("teardown removes the custom SAFE_CHAIN_DIR source line from ~/.bashrc", async () => { - const shell = await container.openShell("bash"); - await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await shell.runCommand("safe-chain setup"); - await shell.runCommand("safe-chain teardown"); - - const result = await shell.runCommand("cat ~/.bashrc"); - assert.ok( - !result.output.includes(`source ${CUSTOM_DIR}/scripts/init-posix.sh`), - `Expected custom source line to be removed from ~/.bashrc. Output:\n${result.output}` - ); - }); - - it("safe-chain protects a non-root user when installed to a shared dir with SAFE_CHAIN_DIR", async () => { - // Step 1: create a non-root user inside the container - container.dockerExec("useradd -m safeuser"); - - // Step 2: as root, run setup-ci with the shared SAFE_CHAIN_DIR - const rootShell = await container.openShell("bash"); - await rootShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await rootShell.runCommand("safe-chain setup-ci"); - - // Step 3: simulate what install-safe-chain.sh does — place the safe-chain binary - // in SAFE_CHAIN_DIR/bin. In Docker tests safe-chain is installed via npm/Volta, - // so we symlink it there. - container.dockerExec(`mkdir -p ${CUSTOM_DIR}/bin`); - container.dockerExec( - `ln -sf \\$(which safe-chain) ${CUSTOM_DIR}/bin/safe-chain` - ); - - // Step 4: make npm accessible to all users (in real Dockerfiles npm is installed - // before the user switch; here Volta manages it for root, so we symlink it). - container.dockerExec("ln -sf \\$(which npm) /usr/local/bin/npm"); - - // Step 5: make the shared safe-chain dir readable + executable by all users - container.dockerExec(`chmod -R a+rx ${CUSTOM_DIR}`); - - // Step 6: Volta installs under /root/.volta which is only accessible to root by - // default. /root/ itself is mode 700, so safeuser can't traverse into it even - // if .volta/ is world-readable. Fix both levels. Safe in a throw-away container. - container.dockerExec("chmod a+x /root && chmod -R a+rX /root/.volta"); - - // Step 7: as the non-root user, set SAFE_CHAIN_DIR and PATH, then run npm. - // SAFE_CHAIN_DIR must be set so the shim knows which dir to strip from PATH - // when invoking the real npm (prevents infinite loop). - const userShell = await container.openShell("bash", { user: "safeuser" }); - await userShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - // Reuse root's Volta dir so safeuser doesn't trigger a slow first-run setup - await userShell.runCommand("export VOLTA_HOME=/root/.volta"); - await userShell.runCommand( - `export PATH="${CUSTOM_DIR}/shims:${CUSTOM_DIR}/bin:$PATH"` - ); - const result = await userShell.runCommand( - "npm i axios@1.13.0 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("Safe-chain: Scanned"), - `Expected safe-chain to protect non-root user. Output:\n${result.output}` - ); - }); -}); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index ad24f6e..d7254c2 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -570,42 +570,4 @@ describe("E2E: uv coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - await setupShell.runCommand("uv cache clean"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious uv packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - await shell.runCommand("uv init test-project-custom-dir"); - const result = await shell.runCommand( - "cd test-project-custom-dir && uv add safe-chain-pi-test" - ); - - assert.ok( - result.output.includes("blocked 1 malicious package downloads:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 35047c1..3740207 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -85,44 +85,4 @@ describe("E2E: yarn coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup-ci"); - await setupShell.runCommand( - `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` - ); - await setupShell.runCommand( - `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` - ); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious yarn packages when shims are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("yarn add safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 5b677d6..7fe2533 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -126,42 +126,4 @@ describe("E2E: yarn coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - // Run setup with the custom dir — init-posix.sh is copied to the custom - // scripts dir, and ~/.zshrc gets a source line pointing there - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious yarn packages when scripts are in a custom directory", async () => { - // New shell sources ~/.zshrc → sources init-posix.sh from custom dir - // → defines yarn() shell function that routes through safe-chain - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("yarn add safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); From 72dc7dcf3acfa2bd3f3dd9e88860325f74064f52 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:13:03 -0700 Subject: [PATCH 19/40] Fix spacing --- test/e2e/bun.e2e.spec.js | 1 - test/e2e/npm-ci.e2e.spec.js | 1 - test/e2e/npm.e2e.spec.js | 1 - test/e2e/pip-ci.e2e.spec.js | 1 - test/e2e/pip.e2e.spec.js | 1 - test/e2e/pipx.e2e.spec.js | 1 - test/e2e/pnpm-ci.e2e.spec.js | 1 - test/e2e/pnpm.e2e.spec.js | 1 - test/e2e/poetry.e2e.spec.js | 1 - test/e2e/uv.e2e.spec.js | 1 - test/e2e/yarn-ci.e2e.spec.js | 1 - test/e2e/yarn.e2e.spec.js | 1 - 12 files changed, 12 deletions(-) diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index 27a8923..fb6e99a 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -78,5 +78,4 @@ describe("E2E: bun coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index 9cb0886..1698759 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -102,5 +102,4 @@ describe("E2E: npm coverage using PATH", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index c07b648..e8ba7c8 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -119,5 +119,4 @@ describe("E2E: npm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index 7857ef2..49db6ce 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -204,5 +204,4 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); }); } - }); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index c86e1cd..b06978f 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -844,5 +844,4 @@ describe("E2E: pip coverage", () => { `python -m pip SHOULD go through safe-chain. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index 8278bb4..a554aa6 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -197,5 +197,4 @@ describe("E2E: pipx coverage", () => { `Expected exit message. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index edba881..a56bb77 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -122,5 +122,4 @@ describe("E2E: pnpm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index 1c8d5ab..a15250a 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -139,5 +139,4 @@ describe("E2E: pnpm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js index 96761bc..58b74fd 100644 --- a/test/e2e/poetry.e2e.spec.js +++ b/test/e2e/poetry.e2e.spec.js @@ -422,5 +422,4 @@ describe("E2E: poetry coverage", () => { `Expected env list output. Output was:\n${envListResult.output}` ); }); - }); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index d7254c2..9d5f3b9 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -569,5 +569,4 @@ describe("E2E: uv coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 3740207..47e2120 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -84,5 +84,4 @@ describe("E2E: yarn coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 7fe2533..5e56d12 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -125,5 +125,4 @@ describe("E2E: yarn coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); From f07d0ea888288893ca2939ae52840b21d2f8beca Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:02 -0700 Subject: [PATCH 20/40] Update packages/safe-chain/src/shell-integration/supported-shells/bash.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/bash.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 5e113bd..4c3334c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -34,7 +34,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain bash initialization script. + // Remove sourcing line to disable safe-chain shell integration removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, From 5bbf3da576b708dd548a779846e759c12a6e6dae Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:15 -0700 Subject: [PATCH 21/40] Update packages/safe-chain/src/shell-integration/supported-shells/fish.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/fish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 28323bf..29bc485 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -33,7 +33,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain fish initialization script. + // Remove sourcing line to prevent safe-chain initialization in future shell sessions removeLinesMatchingPattern( startupFile, /^source\s+.*init-fish\.fish.*#\s*Safe-chain/, From f2bdd28ae69a161b45c2a3abdaafff7b90988451 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:27 -0700 Subject: [PATCH 22/40] Update packages/safe-chain/src/shell-integration/supported-shells/powershell.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../src/shell-integration/supported-shells/powershell.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index d0f5eed..3340bb4 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -32,7 +32,7 @@ function teardown(tools) { ); } - // Removes 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+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, From 32408c65830bd233675cafcf5316439bc79a0bf8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:39 -0700 Subject: [PATCH 23/40] Update packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../src/shell-integration/supported-shells/windowsPowershell.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 87c2fae..d458027 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -32,7 +32,7 @@ function teardown(tools) { ); } - // Removes 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+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, From 56a54b8683acbd0442ba776f896b04e7ebdfa5ad Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:51 -0700 Subject: [PATCH 24/40] Update packages/safe-chain/src/shell-integration/supported-shells/zsh.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/zsh.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index c1c1232..18917fd 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -33,7 +33,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain zsh initialization script. + // Remove sourcing line to complete shell integration cleanup removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, From dec9e82ee9783931c6774609508af12620cdc38c Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:32:51 -0700 Subject: [PATCH 25/40] Some more improvements --- install-scripts/install-safe-chain.ps1 | 89 ++++++++------- install-scripts/install-safe-chain.sh | 109 +++++++++++------- install-scripts/uninstall-safe-chain.ps1 | 135 +++++++++++++---------- install-scripts/uninstall-safe-chain.sh | 88 +++++++++++---- 4 files changed, 257 insertions(+), 164 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index ec0dcd6..3c43861 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -137,6 +137,53 @@ function Get-Architecture { } } +function Write-VersionDeprecationWarning { + if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { + return + } + + Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." + Write-Warn "" + Write-Warn "Please use direct download URLs for version pinning instead:" + Write-Warn "" + if ($ci) { + Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" + } else { + Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" + } + Write-Warn "" +} + +function Get-BinaryName { + param([string]$Architecture) + + return "safe-chain-win-$Architecture.exe" +} + +function Invoke-SafeChainSetup { + param( + [string]$BinaryPath, + [string]$InstallDirectory + ) + + $setupCmd = if ($ci) { "setup-ci" } else { "setup" } + + Write-Info "Running safe-chain $setupCmd..." + try { + $env:Path = "$env:Path;$InstallDirectory" + & $BinaryPath $setupCmd + + if ($LASTEXITCODE -ne 0) { + Write-Warn "safe-chain was installed but setup encountered issues." + Write-Warn "You can run 'safe-chain $setupCmd' manually later." + } + } + catch { + Write-Warn "safe-chain was installed but setup encountered issues: $_" + Write-Warn "You can run 'safe-chain $setupCmd' manually later." + } +} + # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -188,19 +235,7 @@ function Remove-VoltaInstallation { # Main installation function Install-SafeChain { - # Show deprecation warning if SAFE_CHAIN_VERSION is set - if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { - Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." - Write-Warn "" - Write-Warn "Please use direct download URLs for version pinning instead:" - Write-Warn "" - if ($ci) { - Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" - } else { - Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" - } - Write-Warn "" - } + Write-VersionDeprecationWarning # Fetch latest version if VERSION is not set if ([string]::IsNullOrWhiteSpace($Version)) { @@ -231,7 +266,7 @@ function Install-SafeChain { # Detect platform $arch = Get-Architecture - $binaryName = "safe-chain-win-$arch.exe" + $binaryName = Get-BinaryName -Architecture $arch Write-Info "Detected architecture: $arch" @@ -277,31 +312,7 @@ function Install-SafeChain { Write-Info "Binary installed to: $finalFile" - # Build setup command based on parameters - $setupCmd = if ($ci) { "setup-ci" } else { "setup" } - $setupArgs = @() - - # Execute safe-chain setup - Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..." - try { - $env:Path = "$env:Path;$InstallDir" - - if ($setupArgs) { - & $finalFile $setupCmd $setupArgs - } - else { - & $finalFile $setupCmd - } - - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain was installed but setup encountered issues." - Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later." - } - } - catch { - Write-Warn "safe-chain was installed but setup encountered issues: $_" - Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later." - } + Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir } # Run installation diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 6a586e7..242dcf2 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -168,6 +168,68 @@ download() { fi } +warn_deprecated_version_env() { + if [ -z "$SAFE_CHAIN_VERSION" ]; then + return + fi + + warn "SAFE_CHAIN_VERSION environment variable is deprecated." + warn "" + warn "Please use direct download URLs for version pinning instead:" + warn "" + if [ "$USE_CI_SETUP" = "true" ]; then + warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci" + else + warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh" + fi + warn "" +} + +ensure_version() { + if [ -n "$VERSION" ]; then + return + fi + + info "Fetching latest release version..." + VERSION=$(fetch_latest_version) +} + +get_binary_name() { + os="$1" + arch="$2" + + if [ "$os" = "win" ]; then + printf 'safe-chain-%s-%s.exe\n' "$os" "$arch" + else + printf 'safe-chain-%s-%s\n' "$os" "$arch" + fi +} + +get_final_binary_path() { + os="$1" + + if [ "$os" = "win" ]; then + printf '%s/safe-chain.exe\n' "$INSTALL_DIR" + else + printf '%s/safe-chain\n' "$INSTALL_DIR" + fi +} + +run_setup_command() { + final_file="$1" + + setup_cmd="setup" + if [ "$USE_CI_SETUP" = "true" ]; then + setup_cmd="setup-ci" + fi + + info "Running safe-chain $setup_cmd..." + if ! "$final_file" "$setup_cmd"; then + warn "safe-chain was installed but setup encountered issues." + warn "You can run 'safe-chain $setup_cmd' manually later." + fi +} + # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -308,25 +370,9 @@ main() { # Parse command-line arguments parse_arguments "$@" - # Show deprecation warning if SAFE_CHAIN_VERSION is set - if [ -n "$SAFE_CHAIN_VERSION" ]; then - warn "SAFE_CHAIN_VERSION environment variable is deprecated." - warn "" - warn "Please use direct download URLs for version pinning instead:" - warn "" - if [ "$USE_CI_SETUP" = "true" ]; then - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci" - else - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh" - fi - warn "" - fi + warn_deprecated_version_env - # Fetch latest version if VERSION is not set - if [ -z "$VERSION" ]; then - info "Fetching latest release version..." - VERSION=$(fetch_latest_version) - fi + ensure_version # Check if the requested version is already installed if is_version_installed "$VERSION"; then @@ -350,11 +396,7 @@ main() { # Detect platform OS=$(detect_os) ARCH=$(detect_arch) - if [ "$OS" = "win" ]; then - BINARY_NAME="safe-chain-${OS}-${ARCH}.exe" - else - BINARY_NAME="safe-chain-${OS}-${ARCH}" - fi + BINARY_NAME=$(get_binary_name "$OS" "$ARCH") info "Detected platform: ${OS}-${ARCH}" @@ -372,11 +414,7 @@ main() { download "$DOWNLOAD_URL" "$TEMP_FILE" # Rename and make executable - if [ "$OS" = "win" ]; then - FINAL_FILE="${INSTALL_DIR}/safe-chain.exe" - else - FINAL_FILE="${INSTALL_DIR}/safe-chain" - fi + FINAL_FILE=$(get_final_binary_path "$OS") mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" if [ "$OS" != "win" ]; then chmod +x "$FINAL_FILE" || error "Failed to make binary executable" @@ -384,20 +422,7 @@ main() { info "Binary installed to: $FINAL_FILE" - # Build setup command based on arguments - SETUP_CMD="setup" - SETUP_ARGS="" - - if [ "$USE_CI_SETUP" = "true" ]; then - SETUP_CMD="setup-ci" - fi - - # Execute safe-chain setup - info "Running safe-chain $SETUP_CMD $SETUP_ARGS..." - if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then - warn "safe-chain was installed but setup encountered issues." - warn "You can run 'safe-chain $SETUP_CMD $SETUP_ARGS' manually later." - fi + run_setup_command "$FINAL_FILE" } main "$@" diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 2aa3798..fea98f2 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -53,23 +53,39 @@ function Get-InstallDirFromBinaryPath { return (Split-Path -Parent $binDir) } -function Get-SafeChainInstallDir { - $command = Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($command) { - try { - $reportedInstallDir = & safe-chain get-install-dir 2>$null | Select-Object -First 1 - if ($reportedInstallDir) { - $reportedInstallDir = $reportedInstallDir.Trim() - } - if ($reportedInstallDir) { - return $reportedInstallDir - } - } - catch { - # Fall back to deriving the install dir from the discovered command path - } +function Get-SafeChainCommand { + return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 +} + +function Get-ReportedInstallDir { + $command = Get-SafeChainCommand + if (-not $command) { + return $null } + try { + $reportedInstallDir = & safe-chain get-install-dir 2>$null | Select-Object -First 1 + if ($reportedInstallDir) { + $reportedInstallDir = $reportedInstallDir.Trim() + } + if ($reportedInstallDir) { + return $reportedInstallDir + } + } + catch { + return $null + } + + return $null +} + +function Get-SafeChainInstallDir { + $reportedInstallDir = Get-ReportedInstallDir + if ($reportedInstallDir) { + return $reportedInstallDir + } + + $command = Get-SafeChainCommand if ($command -and $command.Path) { $discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path if ($discoveredInstallDir) { @@ -80,6 +96,49 @@ function Get-SafeChainInstallDir { return (Join-Path $HomeDir ".safe-chain") } +function Find-SafeChainBinary { + param([string]$DotSafeChain) + + $safeChainExe = Join-Path $DotSafeChain "bin/safe-chain.exe" + $safeChainBin = Join-Path $DotSafeChain "bin/safe-chain" + + if (Test-Path $safeChainExe) { + return $safeChainExe + } + + if (Test-Path $safeChainBin) { + return $safeChainBin + } + + $command = Get-SafeChainCommand + if ($command) { + return $command.Source + } + + return $null +} + +function Invoke-SafeChainTeardown { + param([string]$SafeChainPath) + + if (-not $SafeChainPath) { + Write-Warn "safe-chain command not found. Proceeding with uninstallation." + return + } + + Write-Info "Running safe-chain teardown..." + try { + & $SafeChainPath teardown + if ($LASTEXITCODE -ne 0) { + Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." + } + } + catch { + Write-Warn "safe-chain teardown encountered issues: $_" + Write-Warn "Continuing with uninstallation..." + } +} + # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -133,50 +192,8 @@ function Remove-VoltaInstallation { function Uninstall-SafeChain { Write-Info "Uninstalling safe-chain..." $DotSafeChain = Get-SafeChainInstallDir - $InstallDir = Join-Path $DotSafeChain "bin" - - # Run teardown if safe-chain is available - # Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms - $safeChainExe = Join-Path $InstallDir "safe-chain.exe" - $safeChainBin = Join-Path $InstallDir "safe-chain" - - $safeChainPath = $null - if (Test-Path $safeChainExe) { - $safeChainPath = $safeChainExe - } - elseif (Test-Path $safeChainBin) { - $safeChainPath = $safeChainBin - } - - if ($safeChainPath) { - Write-Info "Running safe-chain teardown..." - try { - & $safeChainPath teardown - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." - } - } - catch { - Write-Warn "safe-chain teardown encountered issues: $_" - Write-Warn "Continuing with uninstallation..." - } - } - elseif (Get-Command safe-chain -ErrorAction SilentlyContinue) { - Write-Info "Running safe-chain teardown..." - try { - safe-chain teardown - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." - } - } - catch { - Write-Warn "safe-chain teardown encountered issues: $_" - Write-Warn "Continuing with uninstallation..." - } - } - else { - Write-Warn "safe-chain command not found. Proceeding with uninstallation." - } + $safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain + Invoke-SafeChainTeardown -SafeChainPath $safeChainPath # Remove npm and Volta installations Remove-NpmInstallation diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 4169e1e..89bb270 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -87,24 +87,74 @@ derive_install_dir_from_binary() { } get_install_dir() { - if command_exists safe-chain; then - install_dir=$(safe-chain get-install-dir 2>/dev/null || true) - if [ -n "$install_dir" ]; then - printf '%s\n' "$install_dir" - return 0 - fi + reported_install_dir=$(get_reported_install_dir) + if [ -n "$reported_install_dir" ]; then + printf '%s\n' "$reported_install_dir" + return 0 + fi - command_path=$(command -v safe-chain) - install_dir=$(derive_install_dir_from_binary "$command_path" || true) - if [ -n "$install_dir" ]; then - printf '%s\n' "$install_dir" - return 0 - fi + command_path=$(get_safe_chain_command_path) + install_dir=$(derive_install_dir_from_binary "$command_path" || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 fi printf '%s\n' "${HOME}/.safe-chain" } +get_safe_chain_command_path() { + if ! command_exists safe-chain; then + return 1 + fi + + command -v safe-chain +} + +get_reported_install_dir() { + if ! command_exists safe-chain; then + return 1 + fi + + install_dir=$(safe-chain get-install-dir 2>/dev/null || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 + fi + + return 1 +} + +find_installed_safe_chain_binary() { + dot_safe_chain="$1" + + safe_chain_location="$dot_safe_chain/bin/safe-chain" + if [ -x "$safe_chain_location" ]; then + printf '%s\n' "$safe_chain_location" + return 0 + fi + + command_path=$(get_safe_chain_command_path || true) + if [ -n "$command_path" ]; then + printf '%s\n' "$command_path" + return 0 + fi + + return 1 +} + +run_safe_chain_teardown() { + safe_chain_command="$1" + + if [ -z "$safe_chain_command" ]; then + warn "safe-chain command not found. Proceeding with uninstallation." + return + fi + + info "Running safe-chain teardown..." + "$safe_chain_command" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." +} + # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -211,17 +261,8 @@ remove_nvm_installation() { # Main uninstallation main() { DOT_SAFE_CHAIN=$(get_install_dir) - SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" - - if [ -x "$SAFE_CHAIN_LOCATION" ]; then - info "Running safe-chain teardown..." - "$SAFE_CHAIN_LOCATION" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." - elif command_exists safe-chain; then - info "Running safe-chain teardown..." - safe-chain teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." - else - warn "safe-chain command not found. Proceeding with uninstallation." - fi + SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true) + run_safe_chain_teardown "$SAFE_CHAIN_COMMAND" # Check for existing safe-chain installation through nvm, volta, or npm remove_npm_installation @@ -235,7 +276,6 @@ main() { else info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove." fi - } main "$@" From 60732c5b6aba0283551c8b80bc21bea628c33b25 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 12:21:31 -0700 Subject: [PATCH 26/40] Test --- .../src/shell-integration/setup-ci.spec.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index 7d092ab..de570f5 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -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}}\nSHIM_DIR=\"{{SHIMS_DIR}}\"\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\nset \"SHIM_DIR={{SHIMS_DIR}}\"\n{{AIKIDO_COMMAND}} %*\n", + "@echo off\nset \"SHIM_DIR=%~dp0\"\n{{AIKIDO_COMMAND}} %*\n", "utf-8" ); @@ -121,8 +121,8 @@ describe("Setup CI shell integration", () => { 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(`SHIM_DIR="${mockShimsDir}"`), - "npm shim should embed the generated shims directory", + 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", ); }); @@ -148,8 +148,8 @@ describe("Setup CI shell integration", () => { 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=${mockShimsDir}"`), - "npm.cmd should embed the generated shims directory", + npmShimContent.includes('set "SHIM_DIR=%~dp0"'), + "npm.cmd should derive the shims directory from its own location", ); // Verify Unix shims were NOT created From 38a8130f4a331e4d9e5171202b899bf28dddddc0 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 13:32:55 -0700 Subject: [PATCH 27/40] Some fixes --- packages/safe-chain/src/installLocation.js | 5 ++++- .../src/shell-integration/startup-scripts/init-posix.sh | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/installLocation.js b/packages/safe-chain/src/installLocation.js index efe687a..52125be 100644 --- a/packages/safe-chain/src/installLocation.js +++ b/packages/safe-chain/src/installLocation.js @@ -1,5 +1,8 @@ import path from "path"; +/** @type {NodeJS.Process & { pkg?: unknown }} */ +const processWithPkg = process; + /** * @param {string} executablePath * @returns {string | undefined} @@ -28,7 +31,7 @@ export function deriveInstallDirFromExecutablePath(executablePath) { * @returns {string | undefined} */ export function getInstalledSafeChainDir(options = {}) { - const isPackaged = options.isPackaged ?? Boolean(process.pkg); + const isPackaged = options.isPackaged ?? Boolean(processWithPkg.pkg); if (!isPackaged) { return undefined; } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index 4235276..ebc10c4 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -5,7 +5,7 @@ _get_safe_chain_script_path() { fi if [ -n "${ZSH_VERSION:-}" ]; then - eval 'printf "%s\n" "${(%):-%N}"' + eval 'printf "%s\n" "${(%):-%x}"' return fi From 8dbeab8dac6c2528c2a39c85954f1cb141fa4b27 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 13:45:20 -0700 Subject: [PATCH 28/40] Address code quality --- install-scripts/uninstall-safe-chain.ps1 | 27 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index fea98f2..1304247 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -57,14 +57,28 @@ function Get-SafeChainCommand { return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 } -function Get-ReportedInstallDir { +function Get-ValidatedSafeChainCommandPath { $command = Get-SafeChainCommand - if (-not $command) { + if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) { + return $null + } + + $installDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path + if (-not $installDir) { + return $null + } + + return $command.Path +} + +function Get-ReportedInstallDir { + $safeChainPath = Get-ValidatedSafeChainCommandPath + if (-not $safeChainPath) { return $null } try { - $reportedInstallDir = & safe-chain get-install-dir 2>$null | Select-Object -First 1 + $reportedInstallDir = & $safeChainPath get-install-dir 2>$null | Select-Object -First 1 if ($reportedInstallDir) { $reportedInstallDir = $reportedInstallDir.Trim() } @@ -110,12 +124,7 @@ function Find-SafeChainBinary { return $safeChainBin } - $command = Get-SafeChainCommand - if ($command) { - return $command.Source - } - - return $null + return Get-ValidatedSafeChainCommandPath } function Invoke-SafeChainTeardown { From 1076d6bea820b643b43303ea6f0918c6f46d0eb4 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 14:05:02 -0700 Subject: [PATCH 29/40] Undo timeout change --- test/e2e/DockerTestContainer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index 4e831d3..cd48c4e 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -128,7 +128,7 @@ export class DockerTestContainer { console.log("Command timeout reached"); resolve({ allData, output: parseShellOutput(allData), command }); ptyProcess.removeListener("data", handleInput); - }, 30000); + }, 15000); function handleInput(data) { allData.push(data); From e54869ddd054658f3d6c694d600a048b1ce87dcb Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 14:40:42 -0700 Subject: [PATCH 30/40] Code Quality --- install-scripts/install-safe-chain.ps1 | 2 +- .../templates/unix-wrapper.template.sh | 4 ++++ .../startup-scripts/init-fish.fish | 3 ++- .../startup-scripts/init-posix.sh | 24 +++++++------------ 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 3c43861..f870123 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -38,7 +38,7 @@ function Test-InstallDir { } $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set -$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $env:USERPROFILE ".safe-chain" } +$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $HOME ".safe-chain" } $installDirValidation = Test-InstallDir -Dir $SafeChainBase if (-not $installDirValidation.Ok) { diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 9275230..2547a01 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -5,6 +5,10 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { _safe_chain_shims=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) + if [ -z "$_safe_chain_shims" ]; then + echo "$PATH" + return + fi echo "$PATH" | sed "s|${_safe_chain_shims}:||g" } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index e0cc9ec..4469d3f 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,5 +1,6 @@ set -l safe_chain_script (status filename) -set -l safe_chain_base (path dirname (path dirname $safe_chain_script)) +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 diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index ebc10c4..b79a31c 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,18 +1,12 @@ -_get_safe_chain_script_path() { - if [ -n "${BASH_SOURCE[0]:-}" ]; then - printf '%s\n' "${BASH_SOURCE[0]}" - return - fi - - if [ -n "${ZSH_VERSION:-}" ]; then - eval 'printf "%s\n" "${(%):-%x}"' - return - fi - - printf '%s\n' "$0" -} - -_sc_script_path="$(_get_safe_chain_script_path)" +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" From 50623cfc9a69cc7f1c1691fd79a5adf77e343126 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 15:02:41 -0700 Subject: [PATCH 31/40] Fix empty arg --- install-scripts/install-safe-chain.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 242dcf2..2335ae3 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -343,10 +343,16 @@ parse_arguments() { if [ $# -eq 0 ]; then error "Missing value for --install-dir" fi + if [ -z "$1" ]; then + error "--install-dir must not be empty" + fi SAFE_CHAIN_BASE="$1" ;; --install-dir=*) SAFE_CHAIN_BASE="${1#--install-dir=}" + if [ -z "$SAFE_CHAIN_BASE" ]; then + error "--install-dir must not be empty" + fi ;; --include-python) warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." From 7dd68cea12e3fb10d57fa8f2110eb4fc165a52c5 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 15:10:52 -0700 Subject: [PATCH 32/40] Clean up readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6a39aea..a7e504f 100644 --- a/README.md +++ b/README.md @@ -322,18 +322,18 @@ By default, Safe Chain installs itself into `~/.safe-chain`. You can change this When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`. +### Unix/Linux/macOS + ```shell curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --install-dir /usr/local/.safe-chain ``` -On Windows, use `-InstallDir`: +### Windows ```powershell iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -InstallDir 'C:\ProgramData\safe-chain'" ``` -This is a one-time installer choice. Runtime shell integration and uninstall now discover the installation from the installed scripts or binary and do not rely on an environment variable. - # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. From f3ae77f12acd33eeb0a5abb8f20f128afb49a5ce Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 15:21:49 -0700 Subject: [PATCH 33/40] Quality issue --- install-scripts/uninstall-safe-chain.sh | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 89bb270..7a7cb7d 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -111,12 +111,27 @@ get_safe_chain_command_path() { command -v safe-chain } -get_reported_install_dir() { - if ! command_exists safe-chain; then +get_validated_safe_chain_command_path() { + command_path=$(get_safe_chain_command_path || true) + if [ -z "$command_path" ]; then return 1 fi - install_dir=$(safe-chain get-install-dir 2>/dev/null || true) + install_dir=$(derive_install_dir_from_binary "$command_path" || true) + if [ -z "$install_dir" ]; then + return 1 + fi + + printf '%s\n' "$command_path" +} + +get_reported_install_dir() { + safe_chain_path=$(get_validated_safe_chain_command_path || true) + if [ -z "$safe_chain_path" ]; then + return 1 + fi + + install_dir=$("$safe_chain_path" get-install-dir 2>/dev/null || true) if [ -n "$install_dir" ]; then printf '%s\n' "$install_dir" return 0 @@ -134,7 +149,7 @@ find_installed_safe_chain_binary() { return 0 fi - command_path=$(get_safe_chain_command_path || true) + command_path=$(get_validated_safe_chain_command_path || true) if [ -n "$command_path" ]; then printf '%s\n' "$command_path" return 0 From 63b7a5ee5ef94b31e1504bb07680760881aed774 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 21:40:53 -0700 Subject: [PATCH 34/40] Add better doc --- install-scripts/install-safe-chain.ps1 | 8 ++++++++ install-scripts/install-safe-chain.sh | 9 +++++++++ install-scripts/uninstall-safe-chain.ps1 | 14 ++++++++++++++ install-scripts/uninstall-safe-chain.sh | 16 ++++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index f870123..0d7b745 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -8,6 +8,8 @@ param( [string]$InstallDir ) +# Validates and normalizes the requested install directory. +# Rejects non-absolute, root, PATH-like, and traversal-containing paths. function Test-InstallDir { param([string]$Dir) @@ -137,6 +139,8 @@ function Get-Architecture { } } +# Emits the deprecation warning for SAFE_CHAIN_VERSION and prints the version-pinned install command. +# Returns immediately when no version was provided through the environment. function Write-VersionDeprecationWarning { if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { return @@ -154,12 +158,16 @@ function Write-VersionDeprecationWarning { Write-Warn "" } +# Builds the Windows release binary filename for the detected architecture. +# Centralizes binary name generation for the download step. function Get-BinaryName { param([string]$Architecture) return "safe-chain-win-$Architecture.exe" } +# Runs safe-chain setup or setup-ci after the binary is installed. +# Temporarily appends the install directory to PATH and downgrades setup failures to warnings. function Invoke-SafeChainSetup { param( [string]$BinaryPath, diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 2335ae3..763dab6 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -6,6 +6,8 @@ set -e # Exit on error +# Validates a user-provided install dir and exits on unsafe values. +# Rejects relative paths, root paths, PATH separators, and traversal segments. validate_install_dir() { dir="$1" @@ -168,6 +170,8 @@ download() { fi } +# Prints the deprecation warning for SAFE_CHAIN_VERSION and the replacement install command. +# Returns immediately when no version was pinned through the environment. warn_deprecated_version_env() { if [ -z "$SAFE_CHAIN_VERSION" ]; then return @@ -185,6 +189,8 @@ warn_deprecated_version_env() { warn "" } +# Ensures VERSION is populated before installation continues. +# Fetches the latest release only when no explicit version was provided. ensure_version() { if [ -n "$VERSION" ]; then return @@ -194,6 +200,7 @@ ensure_version() { VERSION=$(fetch_latest_version) } +# Returns the release binary filename for the detected OS and architecture. get_binary_name() { os="$1" arch="$2" @@ -205,6 +212,8 @@ get_binary_name() { fi } +# Returns the final installation path for the downloaded safe-chain binary. +# Uses INSTALL_DIR and the platform-specific executable name. get_final_binary_path() { os="$1" diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 1304247..6e24d5d 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -22,6 +22,8 @@ function Write-Error-Custom { exit 1 } +# Derives the safe-chain base install directory from a resolved binary path. +# Rejects wrapper scripts and paths that do not match the packaged bin layout. function Get-InstallDirFromBinaryPath { param([string]$BinaryPath) @@ -53,10 +55,14 @@ function Get-InstallDirFromBinaryPath { return (Split-Path -Parent $binDir) } +# Returns the first safe-chain command found on PATH, if any. +# Used as the starting point for install-dir discovery. function Get-SafeChainCommand { return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 } +# Returns the safe-chain command path only when it points to a valid packaged binary install. +# Prevents teardown from invoking arbitrary wrappers or scripts from PATH. function Get-ValidatedSafeChainCommandPath { $command = Get-SafeChainCommand if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) { @@ -71,6 +77,8 @@ function Get-ValidatedSafeChainCommandPath { return $command.Path } +# Invokes the validated safe-chain binary with get-install-dir and returns the reported base directory. +# Safely returns $null when the command is unavailable or the lookup fails. function Get-ReportedInstallDir { $safeChainPath = Get-ValidatedSafeChainCommandPath if (-not $safeChainPath) { @@ -93,6 +101,8 @@ function Get-ReportedInstallDir { return $null } +# Determines the safe-chain base install directory for uninstall. +# Prefers the binary-reported location, then derives it from PATH, then falls back to the default home-dir layout. function Get-SafeChainInstallDir { $reportedInstallDir = Get-ReportedInstallDir if ($reportedInstallDir) { @@ -110,6 +120,8 @@ function Get-SafeChainInstallDir { return (Join-Path $HomeDir ".safe-chain") } +# Finds the installed safe-chain binary inside the resolved install directory. +# Falls back to a validated safe-chain command when the expected file is missing. function Find-SafeChainBinary { param([string]$DotSafeChain) @@ -127,6 +139,8 @@ function Find-SafeChainBinary { return Get-ValidatedSafeChainCommandPath } +# Runs safe-chain teardown before removing the installation directory. +# Converts teardown failures into warnings so uninstall can still complete. function Invoke-SafeChainTeardown { param([string]$SafeChainPath) diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 7a7cb7d..abe235f 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -33,6 +33,8 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +# Resolves a path to its canonical filesystem location when possible. +# Follows symlinks so binary validation can inspect the real installed path. resolve_path() { target="$1" @@ -60,6 +62,8 @@ resolve_path() { fi } +# Derives the safe-chain base install directory from a packaged binary path. +# Rejects wrapper scripts and paths that do not match the expected bin layout. derive_install_dir_from_binary() { binary_path="$1" @@ -86,6 +90,8 @@ derive_install_dir_from_binary() { dirname "$binary_dir" } +# Determines the installed safe-chain base directory for uninstall. +# Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain. get_install_dir() { reported_install_dir=$(get_reported_install_dir) if [ -n "$reported_install_dir" ]; then @@ -103,6 +109,8 @@ get_install_dir() { printf '%s\n' "${HOME}/.safe-chain" } +# Returns the current safe-chain command path from PATH. +# Fails when safe-chain is not currently resolvable. get_safe_chain_command_path() { if ! command_exists safe-chain; then return 1 @@ -111,6 +119,8 @@ get_safe_chain_command_path() { command -v safe-chain } +# Returns the safe-chain command path only when it resolves to a valid packaged binary install. +# Prevents the uninstaller from invoking arbitrary PATH entries. get_validated_safe_chain_command_path() { command_path=$(get_safe_chain_command_path || true) if [ -z "$command_path" ]; then @@ -125,6 +135,8 @@ get_validated_safe_chain_command_path() { printf '%s\n' "$command_path" } +# Asks the validated safe-chain binary for its install directory via get-install-dir. +# Returns nothing if the command is unavailable or the lookup fails. get_reported_install_dir() { safe_chain_path=$(get_validated_safe_chain_command_path || true) if [ -z "$safe_chain_path" ]; then @@ -140,6 +152,8 @@ get_reported_install_dir() { return 1 } +# Locates the installed safe-chain binary to use for teardown. +# Checks the discovered install dir first, then falls back to a validated PATH entry. find_installed_safe_chain_binary() { dot_safe_chain="$1" @@ -158,6 +172,8 @@ find_installed_safe_chain_binary() { return 1 } +# Runs safe-chain teardown before removing files. +# Continues with uninstall even if teardown is unavailable or fails. run_safe_chain_teardown() { safe_chain_command="$1" From 43fe715b088c95f054e5cff49dac615420461069 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 14 Apr 2026 11:08:04 -0700 Subject: [PATCH 35/40] Update install-scripts/install-safe-chain.sh Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- install-scripts/install-safe-chain.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 763dab6..da7d3c0 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -200,7 +200,7 @@ ensure_version() { VERSION=$(fetch_latest_version) } -# Returns the release binary filename for the detected OS and architecture. +# Constructs platform-specific binary filename to match GitHub release asset naming convention. get_binary_name() { os="$1" arch="$2" From 6ff2ee33674e6a4f64422aad4e56ec6ffef89bd7 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 14 Apr 2026 11:30:29 -0700 Subject: [PATCH 36/40] Adapt per review --- install-scripts/install-safe-chain.ps1 | 10 ++-- .../safe-chain/src/config/safeChainDir.js | 47 +++++++++++++++++++ .../safe-chain/src/registryProxy/certUtils.js | 10 ++-- .../src/registryProxy/certUtils.spec.js | 1 + .../src/shell-integration/helpers.js | 29 ------------ .../src/shell-integration/helpers.spec.js | 13 +++-- .../src/shell-integration/setup-ci.js | 36 ++++---------- .../src/shell-integration/setup-ci.spec.js | 23 +++------ .../safe-chain/src/shell-integration/setup.js | 24 ++-------- .../supported-shells/bash.js | 2 +- .../supported-shells/bash.spec.js | 7 ++- .../supported-shells/fish.js | 2 +- .../supported-shells/fish.spec.js | 7 ++- .../supported-shells/powershell.js | 2 +- .../supported-shells/powershell.spec.js | 5 ++ .../supported-shells/windowsPowershell.js | 2 +- .../windowsPowershell.spec.js | 5 ++ .../shell-integration/supported-shells/zsh.js | 2 +- .../supported-shells/zsh.spec.js | 7 ++- .../src/shell-integration/teardown.js | 3 +- 20 files changed, 118 insertions(+), 119 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 0d7b745..a11edf6 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -25,17 +25,17 @@ function Test-InstallDir { return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" } } + $inputSegments = $Dir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) + if ($inputSegments -contains "..") { + return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" } + } + $normalized = [System.IO.Path]::GetFullPath($Dir) $root = [System.IO.Path]::GetPathRoot($normalized) if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) { return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" } } - $segments = $normalized.Substring($root.Length).Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) - if ($segments -contains "..") { - return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" } - } - return @{ Ok = $true; Normalized = $normalized } } diff --git a/packages/safe-chain/src/config/safeChainDir.js b/packages/safe-chain/src/config/safeChainDir.js index 595300a..6762d0b 100644 --- a/packages/safe-chain/src/config/safeChainDir.js +++ b/packages/safe-chain/src/config/safeChainDir.js @@ -1,5 +1,6 @@ import os from "os"; import path from "path"; +import { fileURLToPath } from "url"; import { getInstalledSafeChainDir } from "../installLocation.js"; /** @@ -8,3 +9,49 @@ import { getInstalledSafeChainDir } from "../installLocation.js"; 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"); +} + +/** + * @param {string} moduleUrl + * @param {string} fileName + * @returns {string} + */ +export function getStartupScriptSourcePath(moduleUrl, fileName) { + return path.join(path.dirname(fileURLToPath(moduleUrl)), "startup-scripts", fileName); +} + +/** + * @param {string} moduleUrl + * @param {string} fileName + * @returns {string} + */ +export function getPathWrapperTemplatePath(moduleUrl, fileName) { + return path.join(path.dirname(fileURLToPath(moduleUrl)), "path-wrappers", "templates", fileName); +} diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index 50fad7b..3918177 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -1,16 +1,12 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; -import { getSafeChainBaseDir } from "../config/safeChainDir.js"; +import { getCertsDir } from "../config/safeChainDir.js"; const ca = loadCa(); const certCache = new Map(); -function getCertFolder() { - return path.join(getSafeChainBaseDir(), "certs"); -} - /** * @param {forge.pki.PublicKey} publicKey * @returns {string} @@ -23,7 +19,7 @@ function createKeyIdentifier(publicKey) { } export function getCaCertPath() { - return path.join(getCertFolder(), "ca-cert.pem"); + return path.join(getCertsDir(), "ca-cert.pem"); } /** @@ -115,7 +111,7 @@ export function generateCertForHost(hostname) { } function loadCa() { - const certFolder = getCertFolder(); + const certFolder = getCertsDir(); const keyPath = path.join(certFolder, "ca-key.pem"); const certPath = path.join(certFolder, "ca-cert.pem"); diff --git a/packages/safe-chain/src/registryProxy/certUtils.spec.js b/packages/safe-chain/src/registryProxy/certUtils.spec.js index c715c8c..4bf8c95 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.spec.js +++ b/packages/safe-chain/src/registryProxy/certUtils.spec.js @@ -9,6 +9,7 @@ describe("certUtils", () => { mock.module("../config/safeChainDir.js", { namedExports: { getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain", + getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`, }, }); }); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 3dd73aa..e763a5f 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -3,7 +3,6 @@ import * as os from "os"; import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; -import { getSafeChainBaseDir } from "../config/safeChainDir.js"; import { safeSpawn } from "../utils/safeSpawn.js"; import { ui } from "../environment/userInteraction.js"; @@ -122,34 +121,6 @@ export function getPackageManagerList() { return `${tools.join(", ")}, and ${lastTool} commands`; } -/** - * Returns the safe-chain base directory. - * Uses the packaged binary location when available, otherwise defaults to ~/.safe-chain. - * @returns {string} - */ -export { getSafeChainBaseDir }; - -/** - * @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"); -} - /** * @param {string} executableName * diff --git a/packages/safe-chain/src/shell-integration/helpers.spec.js b/packages/safe-chain/src/shell-integration/helpers.spec.js index 8870451..e93a690 100644 --- a/packages/safe-chain/src/shell-integration/helpers.spec.js +++ b/packages/safe-chain/src/shell-integration/helpers.spec.js @@ -186,22 +186,27 @@ describe("removeLinesMatchingPatternTests", () => { describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => { it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => { - const { getSafeChainBaseDir } = await import("./helpers.js"); + 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("./helpers.js"); + 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("./helpers.js"); + 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("./helpers.js"); + 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")); + }); }); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 1986bba..f9e6767 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -1,24 +1,14 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; -import { getPackageManagerList, knownAikidoTools, getShimsDir, getBinDir } 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. @@ -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}`); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index de570f5..7af41d6 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -50,8 +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), }, }); @@ -64,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; }); diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 120723a..04534df 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -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,7 +104,7 @@ function copyStartupFiles() { fs.mkdirSync(targetDir, { recursive: true }); } - const sourcePath = path.join(dirname, "startup-scripts", file); + const sourcePath = getStartupScriptSourcePath(import.meta.url, file); fs.copyFileSync(sourcePath, targetPath); } } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 4c3334c..e106928 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -2,8 +2,8 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync, spawnSync } from "child_process"; import * as os from "os"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index f0a56d2..a6b09a0 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -19,7 +19,6 @@ describe("Bash shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -36,6 +35,12 @@ describe("Bash shell integration", () => { }, }); + mock.module("../../config/safeChainDir.js", { + namedExports: { + getScriptsDir: () => "/test-home/.safe-chain/scripts", + }, + }); + // Mock child_process execSync mock.module("child_process", { namedExports: { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 29bc485..95c867b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -2,8 +2,8 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index 0933b6e..c1c5715 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -17,7 +17,6 @@ describe("Fish shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -34,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: { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 3340bb4..2717e36 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -3,8 +3,8 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, validatePowerShellExecutionPolicy, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 1d9f65c..b14c73f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -40,6 +40,11 @@ describe("PowerShell Core shell integration", () => { fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, validatePowerShellExecutionPolicy: () => executionPolicyResult, + }, + }); + + mock.module("../../config/safeChainDir.js", { + namedExports: { getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index d458027..7213d38 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -3,8 +3,8 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, validatePowerShellExecutionPolicy, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 621b380..277a3f7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -40,6 +40,11 @@ describe("Windows PowerShell shell integration", () => { fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, validatePowerShellExecutionPolicy: () => executionPolicyResult, + }, + }); + + mock.module("../../config/safeChainDir.js", { + namedExports: { getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index 18917fd..c3e8d73 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -2,8 +2,8 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 41e1bd1..50af5ca 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -17,7 +17,6 @@ describe("Zsh shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -34,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: { diff --git a/packages/safe-chain/src/shell-integration/teardown.js b/packages/safe-chain/src/shell-integration/teardown.js index e5f149d..cdeeae2 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -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"; /** From bafa997a701a2f29da9a5e00536758c4bf2aac85 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 14 Apr 2026 16:02:46 -0700 Subject: [PATCH 37/40] Some fixes --- install-scripts/uninstall-safe-chain.sh | 4 ++-- .../safe-chain/src/config/safeChainDir.js | 22 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index abe235f..d215405 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -93,13 +93,13 @@ derive_install_dir_from_binary() { # Determines the installed safe-chain base directory for uninstall. # Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain. get_install_dir() { - reported_install_dir=$(get_reported_install_dir) + reported_install_dir=$(get_reported_install_dir || true) if [ -n "$reported_install_dir" ]; then printf '%s\n' "$reported_install_dir" return 0 fi - command_path=$(get_safe_chain_command_path) + command_path=$(get_safe_chain_command_path || true) install_dir=$(derive_install_dir_from_binary "$command_path" || true) if [ -n "$install_dir" ]; then printf '%s\n' "$install_dir" diff --git a/packages/safe-chain/src/config/safeChainDir.js b/packages/safe-chain/src/config/safeChainDir.js index 6762d0b..4d4f013 100644 --- a/packages/safe-chain/src/config/safeChainDir.js +++ b/packages/safe-chain/src/config/safeChainDir.js @@ -39,19 +39,33 @@ export function getCertsDir() { } /** - * @param {string} moduleUrl + * 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(path.dirname(fileURLToPath(moduleUrl)), "startup-scripts", fileName); + return path.join(resolveModuleDir(moduleUrl), "startup-scripts", fileName); } /** - * @param {string} moduleUrl + * @param {string | undefined} moduleUrl * @param {string} fileName * @returns {string} */ export function getPathWrapperTemplatePath(moduleUrl, fileName) { - return path.join(path.dirname(fileURLToPath(moduleUrl)), "path-wrappers", "templates", fileName); + return path.join(resolveModuleDir(moduleUrl), "path-wrappers", "templates", fileName); } From a68cf97f89776918654f4981aac83b2eeb7968d2 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 14 Apr 2026 16:14:05 -0700 Subject: [PATCH 38/40] One more fix --- .../templates/unix-wrapper.template.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 2547a01..5b318ff 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,12 +4,19 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - _safe_chain_shims=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) - if [ -z "$_safe_chain_shims" ]; then + _safe_chain_phys=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) + if [ -z "$_safe_chain_phys" ]; then echo "$PATH" return fi - echo "$PATH" | sed "s|${_safe_chain_shims}:||g" + _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 From 7ed943d46f6e6ee6b86a0e37ff96dbeb68f6ccab Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 15 Apr 2026 09:19:20 -0700 Subject: [PATCH 39/40] Fix Windows bash --- .../supported-shells/bash.js | 74 ++++++++++++++++++- .../supported-shells/bash.spec.js | 34 ++++++++- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index e106928..34dcde7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -46,10 +46,11 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); + const scriptsDir = getShellScriptsDir(); addLineToFile( startupFile, - `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain bash initialization script`, + `source ${path.posix.join(scriptsDir, "init-posix.sh")} # Safe-chain bash initialization script`, eol ); @@ -96,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 cygpathu(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 }); @@ -125,18 +171,40 @@ function cygpathw(path) { } } +/** + * @param {string} path + * + * @returns {string} + */ +function cygpathu(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 ${path.join(getScriptsDir(), "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 ${path.join(getScriptsDir(), "init-posix.sh")}`, + ` source ${path.posix.join(scriptsDir, "init-posix.sh")}`, `Then restart your terminal or run: source ~/.bashrc`, ]; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index a6b09a0..ac80d1f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -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 () => { @@ -37,7 +38,7 @@ describe("Bash shell integration", () => { mock.module("../../config/safeChainDir.js", { namedExports: { - getScriptsDir: () => "/test-home/.safe-chain/scripts", + getScriptsDir: () => mockScriptsDir, }, }); @@ -67,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", + }; + } }, }, }); @@ -93,6 +105,7 @@ describe("Bash shell integration", () => { // Reset mocks mock.reset(); + mockScriptsDir = "/test-home/.safe-chain/scripts"; platform = "linux"; }); @@ -135,7 +148,24 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(windowsCygwinPath, "utf-8"); assert.ok( content.includes( - "source /test-home/.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" ) ); }); From b3372cc50ebee04ec690709a6c56c5bfa0b4dda6 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 15 Apr 2026 15:33:37 -0700 Subject: [PATCH 40/40] Rename function --- .../safe-chain/src/shell-integration/supported-shells/bash.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 34dcde7..956429d 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -118,7 +118,7 @@ function toBashPath(path) { } if (hasCygpath()) { - return cygpathu(path); + return convertCygwinPathToUnix(path); } return path.replace(/\\/g, "/"); @@ -176,7 +176,7 @@ function cygpathw(path) { * * @returns {string} */ -function cygpathu(path) { +function convertCygwinPathToUnix(path) { try { var result = spawnSync("cygpath", ["-u", path], { encoding: "utf8",