@aikidosec/safe-chain 1.0.22 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,16 +4,20 @@ The Aikido Safe Chain **prevents developers from installing malware** on their w
4
4
 
5
5
  The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), and [pnpx](https://pnpm.io/cli/dlx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm or pnpx from downloading or running the malware.
6
6
 
7
- ![demo](https://aikido-production-staticfiles-public.s3.eu-west-1.amazonaws.com/safe-pkg.gif)
7
+ ![demo](./docs/safe-package-manager-demo.png)
8
8
 
9
9
  Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers:
10
10
 
11
- - ✅ **npm**
12
- - **npx**
13
- - **yarn**
14
- - **pnpm**
15
- - **pnpx**
16
- - 🚧 **bun** Coming soon
11
+ - ✅ full coverage: **npm >= 10.4.0**:
12
+ - ⚠️ limited to scanning the install command arguments (broader scanning coming soon):
13
+ - **npm < 10.4.0**
14
+ - **npx**
15
+ - **yarn**
16
+ - **pnpm**
17
+ - **pnpx**
18
+ - 🚧 **bun**: coming soon
19
+
20
+ Note on the limited support for npm < 10.4.0, npx, yarn, pnpm and pnpx: adding **full support for these package managers is a high priority**. In the meantime, we offer limited support already, which means that the Aikido Safe Chain will scan the package names passed as arguments to the install commands. However, it will not scan the full dependency tree of these packages.
17
21
 
18
22
  # Usage
19
23
 
@@ -84,4 +88,60 @@ npm install suspicious-package --safe-chain-malware-action=prompt
84
88
 
85
89
  # Usage in CI/CD
86
90
 
87
- 🚧 Support for CI/CD environments is coming soon...
91
+ 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.
92
+
93
+ For optimal protection in CI/CD environments, we recommend using **npm >= 10.4.0** as it provides full dependency tree scanning. Other package managers currently offer limited scanning of install command arguments only.
94
+
95
+ ## Setup
96
+
97
+ To use Aikido Safe Chain in CI/CD environments, run the following command after installing the package:
98
+
99
+ ```shell
100
+ safe-chain setup-ci
101
+ ```
102
+
103
+ This automatically configures your CI environment to use Aikido Safe Chain for all package manager commands.
104
+
105
+ ## Supported Platforms
106
+
107
+ - ✅ **GitHub Actions**
108
+ - ✅ **Azure Pipelines**
109
+
110
+ ## GitHub Actions Example
111
+
112
+ ```yaml
113
+ - name: Setup Node.js
114
+ uses: actions/setup-node@v4
115
+ with:
116
+ node-version: "22"
117
+ cache: "npm"
118
+
119
+ - name: Setup safe-chain
120
+ run: |
121
+ npm i -g @aikidosec/safe-chain
122
+ safe-chain setup-ci
123
+
124
+ - name: Install dependencies
125
+ run: |
126
+ npm ci
127
+ ```
128
+
129
+ ## Azure DevOps Example
130
+
131
+ ```yaml
132
+ - task: NodeTool@0
133
+ inputs:
134
+ versionSpec: "22.x"
135
+ displayName: "Install Node.js"
136
+
137
+ - script: |
138
+ npm i -g @aikidosec/safe-chain
139
+ safe-chain setup-ci
140
+ displayName: "Install safe chain"
141
+
142
+ - script: |
143
+ npm ci
144
+ displayName: "npm install and build"
145
+ ```
146
+
147
+ After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection.
package/bin/safe-chain.js CHANGED
@@ -4,6 +4,7 @@ import chalk from "chalk";
4
4
  import { ui } from "../src/environment/userInteraction.js";
5
5
  import { setup } from "../src/shell-integration/setup.js";
6
6
  import { teardown } from "../src/shell-integration/teardown.js";
7
+ import { setupCi } from "../src/shell-integration/setup-ci.js";
7
8
 
8
9
  if (process.argv.length < 3) {
9
10
  ui.writeError("No command provided. Please provide a command to execute.");
@@ -23,6 +24,8 @@ if (command === "setup") {
23
24
  setup();
24
25
  } else if (command === "teardown") {
25
26
  teardown();
27
+ } else if (command === "setup-ci") {
28
+ setupCi();
26
29
  } else {
27
30
  ui.writeError(`Unknown command: ${command}.`);
28
31
  ui.emptyLine();
@@ -53,5 +56,10 @@ function writeHelp() {
53
56
  "safe-chain teardown"
54
57
  )}: This will remove safe-chain aliases from your shell configuration.`
55
58
  );
59
+ ui.writeInformation(
60
+ `- ${chalk.cyan(
61
+ "safe-chain setup-ci"
62
+ )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
63
+ );
56
64
  ui.emptyLine();
57
65
  }
@@ -2,21 +2,21 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`) with Aikido's security scanning functionality. This is achieved by adding shell aliases that redirect these commands to their Aikido-wrapped equivalents.
5
+ The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`) with Aikido's security scanning functionality. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
6
6
 
7
7
  ## Supported Shells
8
8
 
9
9
  Aikido Safe Chain supports integration with the following shells.
10
10
 
11
- | Shell | Startup File | Alias Format |
12
- | ---------------------- | ---------------------------- | -------------------------- |
13
- | **Bash** | `~/.bashrc` | `alias npm='aikido-npm'` |
14
- | **Zsh** | `~/.zshrc` | `alias npm='aikido-npm'` |
15
- | **Fish** | `~/.config/fish/config.fish` | `alias npm "aikido-npm"` |
16
- | **PowerShell Core** | `$PROFILE` | `Set-Alias npm aikido-npm` |
17
- | **Windows PowerShell** | `$PROFILE` | `Set-Alias npm aikido-npm` |
11
+ | Shell | Startup File |
12
+ | ---------------------- | ---------------------------- |
13
+ | **Bash** | `~/.bashrc` |
14
+ | **Zsh** | `~/.zshrc` |
15
+ | **Fish** | `~/.config/fish/config.fish` |
16
+ | **PowerShell Core** | `$PROFILE` |
17
+ | **Windows PowerShell** | `$PROFILE` |
18
18
 
19
- ## Commands
19
+ ## Setup Commands
20
20
 
21
21
  ### Setup Shell Integration
22
22
 
@@ -26,10 +26,11 @@ safe-chain setup
26
26
 
27
27
  This command:
28
28
 
29
+ - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
29
30
  - Detects all supported shells on your system
30
- - Adds aliases for `npm`, `npx`, `yarn`, `pnpm` and `pnpx` to each shell's startup file
31
+ - Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, and `pnpx`
31
32
 
32
- ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the aliases are loaded correctly.
33
+ ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
33
34
 
34
35
  ### Remove Shell Integration
35
36
 
@@ -40,13 +41,13 @@ safe-chain teardown
40
41
  This command:
41
42
 
42
43
  - Detects all supported shells on your system
43
- - Removes Aikido aliases from each shell's startup file
44
+ - Removes the Safe Chain scripts from each shell's startup file, restoring the original commands
44
45
 
45
46
  ❗ After running this command, **you must restart your terminal** to restore the original commands.
46
47
 
47
48
  ## File Locations
48
49
 
49
- The system modifies the following files based on your shell configuration:
50
+ The system modifies the following files to source Safe Chain startup scripts:
50
51
 
51
52
  ### Unix/Linux/macOS
52
53
 
@@ -64,15 +65,16 @@ The system modifies the following files based on your shell configuration:
64
65
 
65
66
  ### Common Issues
66
67
 
67
- **Aliases not working after setup:**
68
+ **Shell functions not working after setup:**
68
69
 
69
70
  - Make sure to restart your terminal
70
- - Check that the startup file was actually modified
71
+ - Check that the startup file was modified to source Safe Chain scripts
72
+ - Check the sourced file exists at `~/.safe-chain/scripts/`
71
73
  - Verify your shell is reading the correct startup file
72
74
 
73
75
  **Getting 'command not found: aikido-npm' error:**
74
76
 
75
- This means the aliases are working but the Aikido commands aren't installed or available in your PATH:
77
+ This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
76
78
 
77
79
  - Make sure Aikido Safe Chain is properly installed on your system
78
80
  - Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm` and `aikido-pnpx` commands exist
@@ -82,27 +84,40 @@ This means the aliases are working but the Aikido commands aren't installed or a
82
84
 
83
85
  To verify the integration is working, follow these steps:
84
86
 
85
- 1. **Check if aliases were added to your shell startup file:**
87
+ 1. **Check if startup scripts were sourced in your shell startup file:**
86
88
 
87
89
  - **For Bash**: Open `~/.bashrc` in your text editor
88
90
  - **For Zsh**: Open `~/.zshrc` in your text editor
89
91
  - **For Fish**: Open `~/.config/fish/config.fish` in your text editor
90
92
  - **For PowerShell**: Open your PowerShell profile file (run `$PROFILE` in PowerShell to see the path)
91
93
 
92
- Look for lines like:
94
+ Look for lines that source the Safe Chain startup scripts from `~/.safe-chain/scripts/`
93
95
 
94
- - `alias npm='aikido-npm'` (Bash/Zsh)
95
- - `alias npm "aikido-npm"` (Fish)
96
- - `Set-Alias npm aikido-npm` (PowerShell)
97
-
98
- 2. **Test that aliases are active in your terminal:**
96
+ 2. **Test that shell functions are active in your terminal:**
99
97
 
100
98
  After restarting your terminal, run these commands:
101
99
 
102
- - `which npm` - Should show the path to `aikido-npm` instead of the original npm
103
100
  - `npm --version` - Should show output from the Aikido-wrapped version
104
- - `type npm` - Alternative way to check what command `npm` resolves to
101
+ - `type npm` - Should show that `npm` is a function
102
+
103
+ 3. **If you need to remove the integration manually:**
104
+
105
+ Edit the same startup file from step 1 and delete any lines that source Safe Chain scripts from `~/.safe-chain/scripts/`.
106
+
107
+ ## Manual Setup
105
108
 
106
- 3. **If you need to remove aliases manually:**
109
+ For advanced users who prefer manual configuration, you can create wrapper functions directly in your shell's startup file. Shell functions take precedence over commands in PATH, so defining an `npm` function will intercept all `npm` calls:
110
+
111
+ ```bash
112
+ # Example for Bash/Zsh
113
+ npm() {
114
+ if command -v aikido-npm > /dev/null 2>&1; then
115
+ aikido-npm "$@"
116
+ else
117
+ echo "Warning: safe-chain is not installed. npm will run without protection."
118
+ command npm "$@"
119
+ fi
120
+ }
121
+ ```
107
122
 
108
- Edit the same startup file from step 1 and delete any lines containing `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm` or `aikido-pnpx`.
123
+ Repeat this pattern for `npx`, `yarn`, `pnpm`, and `pnpx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/safe-chain",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "scripts": {
5
5
  "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
6
6
  "test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
@@ -28,12 +28,12 @@
28
28
  "license": "AGPL-3.0-or-later",
29
29
  "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), and [pnpx](https://pnpm.io/cli/dlx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, or pnpx from downloading or running the malware.",
30
30
  "dependencies": {
31
- "abbrev": "^3.0.1",
32
- "chalk": "^5.4.1",
33
- "make-fetch-happen": "^14.0.3",
34
- "npm-registry-fetch": "^18.0.2",
35
- "ora": "^8.2.0",
36
- "semver": "^7.7.2"
31
+ "abbrev": "3.0.1",
32
+ "chalk": "5.4.1",
33
+ "make-fetch-happen": "14.0.3",
34
+ "npm-registry-fetch": "18.0.2",
35
+ "ora": "8.2.0",
36
+ "semver": "7.7.2"
37
37
  },
38
38
  "main": "src/main.js",
39
39
  "bugs": {
package/src/main.js CHANGED
@@ -15,6 +15,7 @@ export async function main(args) {
15
15
  }
16
16
  } catch (error) {
17
17
  ui.writeError("Failed to check for malicious packages:", error.message);
18
+ process.exit(1);
18
19
  }
19
20
 
20
21
  var result = getPackageManager().runCommand(args);
@@ -1,4 +1,3 @@
1
- import { ui } from "../../../environment/userInteraction.js";
2
1
  import { parseDryRunOutput } from "../parsing/parseNpmInstallDryRunOutput.js";
3
2
  import { dryRunNpmCommandAndOutput } from "../runNpmCommand.js";
4
3
  import { hasDryRunArg } from "../utils/npmCommands.js";
@@ -37,10 +36,17 @@ function checkChangesWithDryRun(args) {
37
36
 
38
37
  // Dry-run can return a non-zero status code in some cases
39
38
  // e.g., when running "npm audit fix --dry-run", it returns exit code 1
40
- // when there are vulnurabilities that can be fixed.
39
+ // when there are vulnerabilities that can be fixed.
40
+ if (dryRunOutput.status !== 0 && !canCommandReturnNonZeroOnSuccess(args)) {
41
+ throw new Error(
42
+ `Dry-run command failed with exit code ${dryRunOutput.status} and output:\n${dryRunOutput.output}`
43
+ );
44
+ }
45
+
41
46
  if (dryRunOutput.status !== 0 && !dryRunOutput.output) {
42
- ui.writeError("Detecting changes failed.");
43
- return [];
47
+ throw new Error(
48
+ `Dry-run command failed with exit code ${dryRunOutput.status} and produced no output.`
49
+ );
44
50
  }
45
51
 
46
52
  const parsedOutput = parseDryRunOutput(dryRunOutput.output);
@@ -48,3 +54,13 @@ function checkChangesWithDryRun(args) {
48
54
  // reverse the array to have the top-level packages first
49
55
  return parsedOutput.reverse();
50
56
  }
57
+
58
+ function canCommandReturnNonZeroOnSuccess(args) {
59
+ if (args.length < 2) {
60
+ return false;
61
+ }
62
+
63
+ // `npm audit fix --dry-run` can return exit code 1 when it succesfully ran and
64
+ // there were vulnerabilities that could be fixed
65
+ return args[0] === "audit" && args[1] === "fix";
66
+ }
@@ -18,7 +18,7 @@ export function runNpm(args) {
18
18
 
19
19
  export function dryRunNpmCommandAndOutput(args) {
20
20
  try {
21
- const npmCommand = `npm ${args.join(" ")} --dry-run`;
21
+ const npmCommand = `npm ${args.join(" ")} --ignore-scripts --dry-run`;
22
22
  const output = execSync(npmCommand, { stdio: "pipe" });
23
23
  return { status: 0, output: output.toString() };
24
24
  } catch (error) {
@@ -1,24 +1,24 @@
1
- import { spawnSync } from "child_process";
2
1
  import { ui } from "../../environment/userInteraction.js";
2
+ import { safeSpawnSync } from "../../utils/safeSpawn.js";
3
3
 
4
4
  export function runPnpmCommand(args, toolName = "pnpm") {
5
5
  try {
6
6
  let result;
7
-
8
7
  if (toolName === "pnpm") {
9
- result = spawnSync("pnpm", args, { stdio: "inherit" });
8
+ result = safeSpawnSync("pnpm", args, { stdio: "inherit" });
10
9
  } else if (toolName === "pnpx") {
11
- result = spawnSync("pnpx", args, { stdio: "inherit" });
10
+ result = safeSpawnSync("pnpx", args, { stdio: "inherit" });
12
11
  } else {
13
12
  throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`);
14
13
  }
15
14
 
16
- if (result.status !== null) {
17
- return { status: result.status };
18
- }
15
+ return { status: result.status };
19
16
  } catch (error) {
20
- ui.writeError("Error executing command:", error.message);
21
- return { status: 1 };
17
+ if (error.status) {
18
+ return { status: error.status };
19
+ } else {
20
+ ui.writeError("Error executing command:", error.message);
21
+ return { status: 1 };
22
+ }
22
23
  }
23
- return { status: 0 };
24
24
  }
@@ -21,7 +21,9 @@ export async function scanCommand(args) {
21
21
 
22
22
  let timedOut = false;
23
23
 
24
- const spinner = ui.startProcess("Scanning for malicious packages...");
24
+ const spinner = ui.startProcess(
25
+ "Safe-chain: Scanning for malicious packages..."
26
+ );
25
27
  let audit;
26
28
 
27
29
  await Promise.race([
@@ -37,12 +39,14 @@ export async function scanCommand(args) {
37
39
  }
38
40
 
39
41
  if (changes.length > 0) {
40
- spinner.setText(`Scanning ${changes.length} package(s)...`);
42
+ spinner.setText(
43
+ `Safe-chain: Scanning ${changes.length} package(s)...`
44
+ );
41
45
  }
42
46
 
43
47
  audit = await auditChanges(changes);
44
48
  } catch (error) {
45
- spinner.fail(`Error while scanning: ${error.message}`);
49
+ spinner.fail(`Safe-chain: Error while scanning.`);
46
50
  throw error;
47
51
  }
48
52
  })(),
@@ -52,12 +56,12 @@ export async function scanCommand(args) {
52
56
  ]);
53
57
 
54
58
  if (timedOut) {
55
- spinner.fail("Timeout exceeded while scanning.");
59
+ spinner.fail("Safe-chain: Timeout exceeded while scanning.");
56
60
  throw new Error("Timeout exceeded while scanning npm install command.");
57
61
  }
58
62
 
59
63
  if (!audit || audit.isAllowed) {
60
- spinner.succeed("No malicious packages detected.");
64
+ spinner.succeed("Safe-chain: No malicious packages detected.");
61
65
  } else {
62
66
  printMaliciousChanges(audit.disallowedChanges, spinner);
63
67
  await onMalwareFound();
@@ -65,7 +69,7 @@ export async function scanCommand(args) {
65
69
  }
66
70
 
67
71
  function printMaliciousChanges(changes, spinner) {
68
- spinner.fail(chalk.bold("Malicious changes detected:"));
72
+ spinner.fail("Safe-chain: " + chalk.bold("Malicious changes detected:"));
69
73
 
70
74
  for (const change of changes) {
71
75
  ui.writeInformation(` - ${change.name}@${change.version}`);
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "child_process";
2
2
  import * as os from "os";
3
3
  import fs from "fs";
4
+ import path from "path";
4
5
 
5
6
  export const knownAikidoTools = [
6
7
  { tool: "npm", aikidoCommand: "aikido-npm" },
@@ -12,6 +13,22 @@ export const knownAikidoTools = [
12
13
  // and add the documentation for the new tool in the README.md
13
14
  ];
14
15
 
16
+ /**
17
+ * Returns a formatted string listing all supported package managers.
18
+ * Example: "npm, npx, yarn, pnpm, and pnpx commands"
19
+ */
20
+ export function getPackageManagerList() {
21
+ const tools = knownAikidoTools.map(t => t.tool);
22
+ if (tools.length <= 1) {
23
+ return `${tools[0] || ''} commands`;
24
+ }
25
+ if (tools.length === 2) {
26
+ return `${tools[0]} and ${tools[1]} commands`;
27
+ }
28
+ const lastTool = tools.pop();
29
+ return `${tools.join(', ')}, and ${lastTool} commands`;
30
+ }
31
+
15
32
  export function doesExecutableExistOnSystem(executableName) {
16
33
  if (os.platform() === "win32") {
17
34
  const result = spawnSync("where", [executableName], { stdio: "ignore" });
@@ -22,15 +39,17 @@ export function doesExecutableExistOnSystem(executableName) {
22
39
  }
23
40
  }
24
41
 
25
- export function removeLinesMatchingPattern(filePath, pattern) {
42
+ export function removeLinesMatchingPattern(filePath, pattern, eol) {
26
43
  if (!fs.existsSync(filePath)) {
27
44
  return;
28
45
  }
29
46
 
47
+ eol = eol || os.EOL;
48
+
30
49
  const fileContent = fs.readFileSync(filePath, "utf-8");
31
- const lines = fileContent.split(/[\r\n\u2028\u2029]+/);
50
+ const lines = fileContent.split(/[\r\n\u2028\u2029]/);
32
51
  const updatedLines = lines.filter((line) => !shouldRemoveLine(line, pattern));
33
- fs.writeFileSync(filePath, updatedLines.join(os.EOL), "utf-8");
52
+ fs.writeFileSync(filePath, updatedLines.join(eol), "utf-8");
34
53
  }
35
54
 
36
55
  const maxLineLength = 100;
@@ -43,12 +62,17 @@ function shouldRemoveLine(line, pattern) {
43
62
 
44
63
  if (line.length > maxLineLength) {
45
64
  // safe-chain only adds lines shorter than maxLineLength
46
- // so if the line is longer, it must be from a different
65
+ // so if the line is longer, it must be from a different
47
66
  // source and could be dangerous to remove
48
67
  return false;
49
68
  }
50
69
 
51
- if (line.includes("\n") || line.includes("\r") || line.includes("\u2028") || line.includes("\u2029")) {
70
+ if (
71
+ line.includes("\n") ||
72
+ line.includes("\r") ||
73
+ line.includes("\u2028") ||
74
+ line.includes("\u2029")
75
+ ) {
52
76
  // If the line contains newlines, something has gone wrong in splitting
53
77
  // \u2028 and \u2029 are Unicode line separator characters (line and paragraph separators)
54
78
  return false;
@@ -57,12 +81,25 @@ function shouldRemoveLine(line, pattern) {
57
81
  return true;
58
82
  }
59
83
 
60
- export function addLineToFile(filePath, line) {
61
- if (!fs.existsSync(filePath)) {
62
- fs.writeFileSync(filePath, "", "utf-8");
63
- }
84
+ export function addLineToFile(filePath, line, eol) {
85
+ createFileIfNotExists(filePath);
86
+
87
+ eol = eol || os.EOL;
64
88
 
65
89
  const fileContent = fs.readFileSync(filePath, "utf-8");
66
- const updatedContent = fileContent + os.EOL + line;
90
+ const updatedContent = fileContent + eol + line + eol;
67
91
  fs.writeFileSync(filePath, updatedContent, "utf-8");
68
92
  }
93
+
94
+ function createFileIfNotExists(filePath) {
95
+ if (fs.existsSync(filePath)) {
96
+ return;
97
+ }
98
+
99
+ const dir = path.dirname(filePath);
100
+ if (!fs.existsSync(dir)) {
101
+ fs.mkdirSync(dir, { recursive: true });
102
+ }
103
+
104
+ fs.writeFileSync(filePath, "", "utf-8");
105
+ }
@@ -0,0 +1,22 @@
1
+ #!/bin/sh
2
+ # Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain
3
+ # This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments
4
+
5
+ # Function to remove shim from PATH (POSIX-compliant)
6
+ remove_shim_from_path() {
7
+ echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g"
8
+ }
9
+
10
+ if command -v {{AIKIDO_COMMAND}} >/dev/null 2>&1; then
11
+ # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops
12
+ PATH=$(remove_shim_from_path) exec {{AIKIDO_COMMAND}} "$@"
13
+ else
14
+ # Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory)
15
+ original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}})
16
+ if [ -n "$original_cmd" ]; then
17
+ exec "$original_cmd" "$@"
18
+ else
19
+ echo "Error: Could not find original {{PACKAGE_MANAGER}}" >&2
20
+ exit 1
21
+ fi
22
+ fi
@@ -0,0 +1,24 @@
1
+ @echo off
2
+ REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain
3
+ REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments
4
+
5
+ REM Remove shim directory from PATH to prevent infinite loops
6
+ set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims"
7
+ call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%"
8
+
9
+ REM Check if aikido command is available with clean PATH
10
+ set "PATH=%CLEAN_PATH%" & where {{AIKIDO_COMMAND}} >nul 2>&1
11
+ if %errorlevel%==0 (
12
+ REM Call aikido command with clean PATH
13
+ set "PATH=%CLEAN_PATH%" & {{AIKIDO_COMMAND}} %*
14
+ ) else (
15
+ REM Find the original command with clean PATH
16
+ for /f "tokens=*" %%i in ('set "PATH=%CLEAN_PATH%" ^& where {{PACKAGE_MANAGER}} 2^>nul') do (
17
+ "%%i" %*
18
+ goto :eof
19
+ )
20
+
21
+ REM If we get here, original command was not found
22
+ echo Error: Could not find original {{PACKAGE_MANAGER}} >&2
23
+ exit /b 1
24
+ )
@@ -0,0 +1,123 @@
1
+ import chalk from "chalk";
2
+ import { ui } from "../environment/userInteraction.js";
3
+ import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import path from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ /**
10
+ * Loops over the detected shells and calls the setup function for each.
11
+ */
12
+ export async function setupCi() {
13
+ ui.writeInformation(
14
+ chalk.bold("Setting up shell aliases.") +
15
+ ` This will wrap safe-chain around ${getPackageManagerList()}.`
16
+ );
17
+ ui.emptyLine();
18
+
19
+ const shimsDir = path.join(os.homedir(), ".safe-chain", "shims");
20
+ // Create the shims directory if it doesn't exist
21
+ if (!fs.existsSync(shimsDir)) {
22
+ fs.mkdirSync(shimsDir, { recursive: true });
23
+ }
24
+
25
+ createShims(shimsDir);
26
+ ui.writeInformation(`Created shims in ${shimsDir}`);
27
+ modifyPathForCi(shimsDir);
28
+ ui.writeInformation(`Added shims directory to PATH for CI environments.`);
29
+ }
30
+
31
+ function createUnixShims(shimsDir) {
32
+ // Read the template file
33
+ const __filename = fileURLToPath(import.meta.url);
34
+ const __dirname = path.dirname(__filename);
35
+ const templatePath = path.resolve(
36
+ __dirname,
37
+ "path-wrappers",
38
+ "templates",
39
+ "unix-wrapper.template.sh"
40
+ );
41
+
42
+ if (!fs.existsSync(templatePath)) {
43
+ ui.writeError(`Template file not found: ${templatePath}`);
44
+ return;
45
+ }
46
+
47
+ const template = fs.readFileSync(templatePath, "utf-8");
48
+
49
+ // Create a shim for each tool
50
+ for (const toolInfo of knownAikidoTools) {
51
+ const shimContent = template
52
+ .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool)
53
+ .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand);
54
+
55
+ const shimPath = path.join(shimsDir, toolInfo.tool);
56
+ fs.writeFileSync(shimPath, shimContent, "utf-8");
57
+
58
+ // Make the shim executable on Unix systems
59
+ fs.chmodSync(shimPath, 0o755);
60
+ }
61
+
62
+ ui.writeInformation(
63
+ `Created ${knownAikidoTools.length} Unix shim(s) in ${shimsDir}`
64
+ );
65
+ }
66
+
67
+ function createWindowsShims(shimsDir) {
68
+ // Read the template file
69
+ const __filename = fileURLToPath(import.meta.url);
70
+ const __dirname = path.dirname(__filename);
71
+ const templatePath = path.resolve(
72
+ __dirname,
73
+ "path-wrappers",
74
+ "templates",
75
+ "windows-wrapper.template.cmd"
76
+ );
77
+
78
+ if (!fs.existsSync(templatePath)) {
79
+ ui.writeError(`Windows template file not found: ${templatePath}`);
80
+ return;
81
+ }
82
+
83
+ const template = fs.readFileSync(templatePath, "utf-8");
84
+
85
+ // Create a shim for each tool
86
+ for (const toolInfo of knownAikidoTools) {
87
+ const shimContent = template
88
+ .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool)
89
+ .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand);
90
+
91
+ const shimPath = path.join(shimsDir, `${toolInfo.tool}.cmd`);
92
+ fs.writeFileSync(shimPath, shimContent, "utf-8");
93
+ }
94
+
95
+ ui.writeInformation(
96
+ `Created ${knownAikidoTools.length} Windows shim(s) in ${shimsDir}`
97
+ );
98
+ }
99
+
100
+ function createShims(shimsDir) {
101
+ if (os.platform() === "win32") {
102
+ createWindowsShims(shimsDir);
103
+ } else {
104
+ createUnixShims(shimsDir);
105
+ }
106
+ }
107
+
108
+ function modifyPathForCi(shimsDir) {
109
+ if (process.env.GITHUB_PATH) {
110
+ // In GitHub Actions, append the shims directory to GITHUB_PATH
111
+ fs.appendFileSync(process.env.GITHUB_PATH, shimsDir + os.EOL, "utf-8");
112
+ ui.writeInformation(
113
+ `Added shims directory to GITHUB_PATH for GitHub Actions.`
114
+ );
115
+ }
116
+
117
+ if (process.env.TF_BUILD) {
118
+ // In Azure Pipelines, prepending the path is done via a logging command:
119
+ // ##vso[task.prependpath]/path/to/add
120
+ // Logging this to stdout will cause the Azure Pipelines agent to pick it up
121
+ ui.writeInformation("##vso[task.prependpath]" + shimsDir);
122
+ }
123
+ }
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { ui } from "../environment/userInteraction.js";
3
3
  import { detectShells } from "./shellDetection.js";
4
- import { knownAikidoTools } from "./helpers.js";
4
+ import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
5
5
  import fs from "fs";
6
6
  import os from "os";
7
7
  import path from "path";
@@ -13,7 +13,7 @@ import { fileURLToPath } from "url";
13
13
  export async function setup() {
14
14
  ui.writeInformation(
15
15
  chalk.bold("Setting up shell aliases.") +
16
- " This will wrap safe-chain around npm, npx, and yarn commands."
16
+ ` This will wrap safe-chain around ${getPackageManagerList()}.`
17
17
  );
18
18
  ui.emptyLine();
19
19
 
@@ -56,11 +56,13 @@ export async function setup() {
56
56
  */
57
57
  function setupShell(shell) {
58
58
  let success = false;
59
+ let error;
59
60
  try {
60
61
  shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases
61
62
  success = shell.setup(knownAikidoTools);
62
- } catch {
63
+ } catch (err) {
63
64
  success = false;
65
+ error = err;
64
66
  }
65
67
 
66
68
  if (success) {
@@ -75,6 +77,13 @@ function setupShell(shell) {
75
77
  "Setup failed"
76
78
  )}. Please check your ${shell.name} configuration.`
77
79
  );
80
+ if (error) {
81
+ let message = ` Error: ${error.message}`;
82
+ if (error.code) {
83
+ message += ` (code: ${error.code})`;
84
+ }
85
+ ui.writeError(message);
86
+ }
78
87
  }
79
88
 
80
89
  return success;
@@ -47,11 +47,15 @@ function pnpx
47
47
  end
48
48
 
49
49
  function npm
50
- if test (count $argv) -eq 1 -a \( "$argv[1]" = "-v" -o "$argv[1]" = "--version" \)
51
- # If args is just -v or --version and nothing else, just run the npm version command
52
- # This is because nvm uses this to check the version of npm
53
- command npm $argv
54
- return
50
+ # If args is just -v or --version and nothing else, just run the `npm -v` command
51
+ # This is because nvm uses this to check the version of npm
52
+ set argc (count $argv)
53
+ if test $argc -eq 1
54
+ switch $argv[1]
55
+ case "-v" "--version"
56
+ command npm $argv
57
+ return
58
+ end
55
59
  end
56
60
 
57
61
  wrapSafeChainCommand "npm" "aikido-npm" $argv
@@ -9,6 +9,7 @@ import * as os from "os";
9
9
  const shellName = "Bash";
10
10
  const executableName = "bash";
11
11
  const startupFileCommand = "echo ~/.bashrc";
12
+ const eol = "\n"; // When bash runs on Windows (e.g., Git Bash or WSL), it expects LF line endings.
12
13
 
13
14
  function isInstalled() {
14
15
  return doesExecutableExistOnSystem(executableName);
@@ -19,13 +20,18 @@ function teardown(tools) {
19
20
 
20
21
  for (const { tool } of tools) {
21
22
  // Remove any existing alias for the tool
22
- removeLinesMatchingPattern(startupFile, new RegExp(`^alias\\s+${tool}=`));
23
+ removeLinesMatchingPattern(
24
+ startupFile,
25
+ new RegExp(`^alias\\s+${tool}=`),
26
+ eol
27
+ );
23
28
  }
24
29
 
25
30
  // Removes the line that sources the safe-chain bash initialization script (~/.aikido/scripts/init-posix.sh)
26
31
  removeLinesMatchingPattern(
27
32
  startupFile,
28
- /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/
33
+ /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/,
34
+ eol
29
35
  );
30
36
 
31
37
  return true;
@@ -36,7 +42,8 @@ function setup() {
36
42
 
37
43
  addLineToFile(
38
44
  startupFile,
39
- `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`
45
+ `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`,
46
+ eol
40
47
  );
41
48
 
42
49
  return true;
@@ -8,6 +8,7 @@ import { execSync } from "child_process";
8
8
  const shellName = "Fish";
9
9
  const executableName = "fish";
10
10
  const startupFileCommand = "echo ~/.config/fish/config.fish";
11
+ const eol = "\n"; // When fish runs on Windows (e.g., Git Bash or WSL), it expects LF line endings.
11
12
 
12
13
  function isInstalled() {
13
14
  return doesExecutableExistOnSystem(executableName);
@@ -20,14 +21,16 @@ function teardown(tools) {
20
21
  // Remove any existing alias for the tool
21
22
  removeLinesMatchingPattern(
22
23
  startupFile,
23
- new RegExp(`^alias\\s+${tool}\\s+`)
24
+ new RegExp(`^alias\\s+${tool}\\s+`),
25
+ eol
24
26
  );
25
27
  }
26
28
 
27
29
  // Removes the line that sources the safe-chain fish initialization script (~/.safe-chain/scripts/init-fish.fish)
28
30
  removeLinesMatchingPattern(
29
31
  startupFile,
30
- /^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/
32
+ /^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/,
33
+ eol
31
34
  );
32
35
 
33
36
  return true;
@@ -38,7 +41,8 @@ function setup() {
38
41
 
39
42
  addLineToFile(
40
43
  startupFile,
41
- `source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`
44
+ `source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`,
45
+ eol
42
46
  );
43
47
 
44
48
  return true;
@@ -8,6 +8,7 @@ import { execSync } from "child_process";
8
8
  const shellName = "Zsh";
9
9
  const executableName = "zsh";
10
10
  const startupFileCommand = "echo ${ZDOTDIR:-$HOME}/.zshrc";
11
+ const eol = "\n"; // When zsh runs on Windows (e.g., Git Bash or WSL), it expects LF line endings.
11
12
 
12
13
  function isInstalled() {
13
14
  return doesExecutableExistOnSystem(executableName);
@@ -18,13 +19,18 @@ function teardown(tools) {
18
19
 
19
20
  for (const { tool } of tools) {
20
21
  // Remove any existing alias for the tool
21
- removeLinesMatchingPattern(startupFile, new RegExp(`^alias\\s+${tool}=`));
22
+ removeLinesMatchingPattern(
23
+ startupFile,
24
+ new RegExp(`^alias\\s+${tool}=`),
25
+ eol
26
+ );
22
27
  }
23
28
 
24
29
  // Removes the line that sources the safe-chain zsh initialization script (~/.aikido/scripts/init-posix.sh)
25
30
  removeLinesMatchingPattern(
26
31
  startupFile,
27
- /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/
32
+ /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/,
33
+ eol
28
34
  );
29
35
 
30
36
  return true;
@@ -35,7 +41,8 @@ function setup() {
35
41
 
36
42
  addLineToFile(
37
43
  startupFile,
38
- `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`
44
+ `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`,
45
+ eol
39
46
  );
40
47
 
41
48
  return true;
@@ -1,12 +1,12 @@
1
1
  import chalk from "chalk";
2
2
  import { ui } from "../environment/userInteraction.js";
3
3
  import { detectShells } from "./shellDetection.js";
4
- import { knownAikidoTools } from "./helpers.js";
4
+ import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
5
5
 
6
6
  export async function teardown() {
7
7
  ui.writeInformation(
8
8
  chalk.bold("Removing shell aliases.") +
9
- " This will remove safe-chain aliases for npm, npx, and yarn commands."
9
+ ` This will remove safe-chain aliases for ${getPackageManagerList()}.`
10
10
  );
11
11
  ui.emptyLine();
12
12
 
@@ -0,0 +1,38 @@
1
+ import { spawnSync, spawn } from "child_process";
2
+
3
+ function escapeArg(arg) {
4
+ // If argument contains spaces or quotes, wrap in double quotes and escape double quotes
5
+ if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) {
6
+ return '"' + arg.replaceAll('"', '\\"') + '"';
7
+ }
8
+ return arg;
9
+ }
10
+
11
+ function buildCommand(command, args) {
12
+ const escapedArgs = args.map(escapeArg);
13
+ return `${command} ${escapedArgs.join(" ")}`;
14
+ }
15
+
16
+ export function safeSpawnSync(command, args, options = {}) {
17
+ const fullCommand = buildCommand(command, args);
18
+ return spawnSync(fullCommand, { ...options, shell: true });
19
+ }
20
+
21
+ export async function safeSpawn(command, args, options = {}) {
22
+ const fullCommand = buildCommand(command, args);
23
+ return new Promise((resolve, reject) => {
24
+ const child = spawn(fullCommand, { ...options, shell: true });
25
+
26
+ child.on("close", (code) => {
27
+ resolve({
28
+ status: code,
29
+ stdout: Buffer.from(""),
30
+ stderr: Buffer.from(""),
31
+ });
32
+ });
33
+
34
+ child.on("error", (error) => {
35
+ reject(error);
36
+ });
37
+ });
38
+ }