@aikidosec/safe-chain 1.2.1 → 1.2.4

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.
Files changed (32) hide show
  1. package/README.md +20 -15
  2. package/bin/aikido-pip.js +2 -5
  3. package/bin/aikido-pip3.js +2 -6
  4. package/bin/aikido-poetry.js +13 -0
  5. package/bin/aikido-python.js +5 -16
  6. package/bin/aikido-python3.js +5 -16
  7. package/bin/safe-chain.js +7 -60
  8. package/package.json +2 -1
  9. package/src/config/configFile.js +1 -1
  10. package/src/config/settings.js +1 -1
  11. package/src/main.js +4 -2
  12. package/src/packagemanager/currentPackageManager.js +6 -2
  13. package/src/packagemanager/pip/createPackageManager.js +9 -5
  14. package/src/packagemanager/pip/pipSettings.js +4 -28
  15. package/src/packagemanager/pip/runPipCommand.js +41 -2
  16. package/src/packagemanager/poetry/createPoetryPackageManager.js +77 -0
  17. package/src/registryProxy/certBundle.js +99 -13
  18. package/src/registryProxy/certUtils.js +55 -5
  19. package/src/registryProxy/getConnectTimeout.js +13 -0
  20. package/src/registryProxy/interceptors/interceptorBuilder.js +6 -0
  21. package/src/registryProxy/interceptors/pipInterceptor.js +23 -9
  22. package/src/registryProxy/isImdsEndpoint.js +13 -0
  23. package/src/registryProxy/registryProxy.js +15 -7
  24. package/src/registryProxy/tunnelRequestHandler.js +55 -3
  25. package/src/shell-integration/helpers.js +20 -0
  26. package/src/shell-integration/setup-ci.js +2 -2
  27. package/src/shell-integration/setup.js +3 -4
  28. package/src/shell-integration/startup-scripts/include-python/init-fish.fish +4 -0
  29. package/src/shell-integration/startup-scripts/include-python/init-posix.sh +4 -0
  30. package/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 +5 -1
  31. package/src/shell-integration/startup-scripts/init-pwsh.ps1 +1 -1
  32. package/src/shell-integration/teardown.js +43 -1
@@ -6,6 +6,7 @@ import certifi from "certifi";
6
6
  import tls from "node:tls";
7
7
  import { X509Certificate } from "node:crypto";
8
8
  import { getCaCertPath } from "./certUtils.js";
9
+ import { ui } from "../environment/userInteraction.js";
9
10
 
10
11
  /**
11
12
  * Check if a PEM string contains only parsable cert blocks.
@@ -14,6 +15,7 @@ import { getCaCertPath } from "./certUtils.js";
14
15
  */
15
16
  function isParsable(pem) {
16
17
  if (!pem || typeof pem !== "string") return false;
18
+ pem = normalizeLineEndings(pem);
17
19
  const begin = "-----BEGIN CERTIFICATE-----";
18
20
  const end = "-----END CERTIFICATE-----";
19
21
  const blocks = [];
@@ -41,20 +43,17 @@ function isParsable(pem) {
41
43
  }
42
44
  }
43
45
 
44
- /** @type {string | null} */
45
- let cachedPath = null;
46
-
47
46
  /**
48
- * Build a combined CA bundle for Python and Node HTTPS flows.
49
- * - Includes Safe Chain CA (for MITM of known registries)
50
- * - Includes Mozilla roots via npm `certifi` (public HTTPS)
51
- * - Includes Node's built-in root certificates as a portable fallback
47
+ * Build a combined CA bundle.
48
+ * Automatically includes:
49
+ * - Safe Chain CA (for MITM of known registries)
50
+ * - Mozilla roots via certifi (for public HTTPS)
51
+ * - Node's built-in root certificates (fallback)
52
+ * - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set)
53
+ *
52
54
  * @returns {string} Path to the combined CA bundle PEM file
53
55
  */
54
56
  export function getCombinedCaBundlePath() {
55
- if (cachedPath && fs.existsSync(cachedPath)) return cachedPath;
56
-
57
- // Concatenate PEM files
58
57
  const parts = [];
59
58
 
60
59
  // 1) Safe Chain CA (for MITM'd registries)
@@ -87,9 +86,96 @@ export function getCombinedCaBundlePath() {
87
86
  // Ignore if unavailable
88
87
  }
89
88
 
89
+ // 4) User's NODE_EXTRA_CA_CERTS (if set)
90
+ const userCertPath = process.env.NODE_EXTRA_CA_CERTS;
91
+ if (userCertPath) {
92
+ const userPem = readUserCertificateFile(userCertPath);
93
+ if (userPem) {
94
+ parts.push(userPem.trim());
95
+ ui.writeVerbose(`Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
96
+ } else {
97
+ ui.writeWarning(`Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
98
+ }
99
+ }
100
+
90
101
  const combined = parts.filter(Boolean).join("\n");
91
- const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem");
102
+ const target = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
92
103
  fs.writeFileSync(target, combined, { encoding: "utf8" });
93
- cachedPath = target;
94
- return cachedPath;
104
+ return target;
105
+ }
106
+
107
+ /**
108
+ * Normalize path
109
+ * @param {string} p - Path to normalize
110
+ * @returns {string}
111
+ */
112
+ function normalizePathF(p) {
113
+ return p.replace(/\\/g, "/");
114
+ }
115
+
116
+ /**
117
+ * Normalize line endings to LF
118
+ * @param {string} text - Text with mixed line endings
119
+ * @returns {string}
120
+ */
121
+ function normalizeLineEndings(text) {
122
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
123
+ }
124
+
125
+ /**
126
+ * Read and validate user certificate file
127
+ * @param {string} certPath - Path to certificate file
128
+ * @returns {string | null} Certificate PEM content or null if invalid/unreadable
129
+ */
130
+ function readUserCertificateFile(certPath) {
131
+ try {
132
+ // 1) Basic validation
133
+ if (typeof certPath !== "string" || certPath.trim().length === 0) {
134
+ return null;
135
+ }
136
+
137
+ // 2) Reject path traversal attempts (normalize backslashes first for Windows paths)
138
+ const normalizedPath = normalizePathF(certPath);
139
+ if (normalizedPath.includes("..")) {
140
+ return null;
141
+ }
142
+
143
+ // 3) Check if file exists and is not a directory or symlink
144
+ let stats;
145
+ try {
146
+ stats = fs.lstatSync(certPath);
147
+ } catch {
148
+ // File doesn't exist or can't be accessed
149
+ return null;
150
+ }
151
+
152
+ if (!stats.isFile()) {
153
+ // Reject directories and symlinks
154
+ return null;
155
+ }
156
+
157
+ // 4) Read file content
158
+ let content;
159
+ try {
160
+ content = fs.readFileSync(certPath, "utf8");
161
+ } catch {
162
+ return null;
163
+ }
164
+
165
+ if (!content || typeof content !== "string") {
166
+ return null;
167
+ }
168
+
169
+ // 5) Validate PEM format
170
+ if (!isParsable(content)) {
171
+ return null;
172
+ }
173
+
174
+ return content;
175
+ } catch {
176
+ // Silently fail on any errors
177
+ return null;
178
+ }
95
179
  }
180
+
181
+
@@ -8,6 +8,17 @@ const ca = loadCa();
8
8
 
9
9
  const certCache = new Map();
10
10
 
11
+ /**
12
+ * @param {forge.pki.PublicKey} publicKey
13
+ * @returns {string}
14
+ */
15
+ function createKeyIdentifier(publicKey) {
16
+ return forge.pki.getPublicKeyFingerprint(publicKey, {
17
+ encoding: "binary",
18
+ md: forge.md.sha1.create(),
19
+ });
20
+ }
21
+
11
22
  export function getCaCertPath() {
12
23
  return path.join(certFolder, "ca-cert.pem");
13
24
  }
@@ -33,6 +44,7 @@ export function generateCertForHost(hostname) {
33
44
  const attrs = [{ name: "commonName", value: hostname }];
34
45
  cert.setSubject(attrs);
35
46
  cert.setIssuer(ca.certificate.subject.attributes);
47
+ const authorityKeyIdentifier = createKeyIdentifier(ca.certificate.publicKey);
36
48
  cert.setExtensions([
37
49
  {
38
50
  name: "subjectAltName",
@@ -50,14 +62,42 @@ export function generateCertForHost(hostname) {
50
62
  },
51
63
  {
52
64
  /*
53
- extKeyUsage serverAuth is required for TLS server authentication.
54
- This is especially important for Python venv environments, which use their own
55
- certificate validation logic and will reject certificates lacking the serverAuth EKU.
56
- Adding serverAuth does not impact other usages
65
+ Extended Key Usage (EKU) serverAuth extension
66
+
67
+ Needed for TLS server authentication. This extension indicates the certificate's
68
+ public key may be used for TLS WWW server authentication.
69
+ Python virtualenv environments (like pipx-installed Poetry) enforce this strictly
70
+ https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12
57
71
  */
58
72
  name: "extKeyUsage",
59
73
  serverAuth: true,
60
74
  },
75
+ {
76
+ /*
77
+ Subject Key Identifier (SKI)
78
+
79
+ Needed for Python virtualenv SSL validation and certificate chain building.
80
+ This extension provides a means of identifying certificates containing a particular public key.
81
+ Python virtualenv environments require this for proper certificate chain validation.
82
+ System Python installations may be more lenient.
83
+ https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.2
84
+ */
85
+ name: "subjectKeyIdentifier",
86
+ subjectKeyIdentifier: createKeyIdentifier(cert.publicKey),
87
+ },
88
+ {
89
+ /*
90
+ Authority Key Identifier (AKI)
91
+
92
+ Needed for Python virtualenv SSL validation and certificate path validation.
93
+ This extension identifies the public key corresponding to the private key used to sign
94
+ this certificate. It links this certificate to its issuing CA certificate.
95
+ Without this, Python virtualenv certificate validation might fail (for instance for Poetry)
96
+ https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.1
97
+ */
98
+ name: "authorityKeyIdentifier",
99
+ keyIdentifier: authorityKeyIdentifier,
100
+ },
61
101
  ]);
62
102
  cert.sign(ca.privateKey, forge.md.sha256.create());
63
103
 
@@ -106,11 +146,13 @@ function generateCa() {
106
146
 
107
147
  const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
108
148
  cert.setSubject(attrs);
109
- cert.setIssuer(attrs);
149
+ cert.setIssuer(attrs); // Self-signed: issuer === subject
150
+ const keyIdentifier = createKeyIdentifier(cert.publicKey);
110
151
  cert.setExtensions([
111
152
  {
112
153
  name: "basicConstraints",
113
154
  cA: true,
155
+ critical: true, // Marking basicConstraints as critical is required for CA certificates so clients must process it to trust the cert as a CA
114
156
  },
115
157
  {
116
158
  name: "keyUsage",
@@ -118,6 +160,14 @@ function generateCa() {
118
160
  digitalSignature: true,
119
161
  keyEncipherment: true,
120
162
  },
163
+ {
164
+ name: "subjectKeyIdentifier",
165
+ subjectKeyIdentifier: keyIdentifier,
166
+ },
167
+ {
168
+ name: "authorityKeyIdentifier",
169
+ keyIdentifier,
170
+ },
121
171
  ]);
122
172
  cert.sign(keys.privateKey, forge.md.sha256.create());
123
173
 
@@ -0,0 +1,13 @@
1
+ import { isImdsEndpoint } from "./isImdsEndpoint.js";
2
+
3
+ /**
4
+ * Returns appropriate connection timeout for a host.
5
+ * - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s)
6
+ * - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs)
7
+ */
8
+ export function getConnectTimeout(/** @type {string} */ host) {
9
+ if (isImdsEndpoint(host)) {
10
+ return 3000;
11
+ }
12
+ return 30000;
13
+ }
@@ -20,6 +20,12 @@ import { EventEmitter } from "events";
20
20
  * @property {(headers: NodeJS.Dict<string | string[]> | undefined) => NodeJS.Dict<string | string[]> | undefined} modifyRequestHeaders
21
21
  * @property {() => boolean} modifiesResponse
22
22
  * @property {(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer} modifyBody
23
+ *
24
+ * @typedef {Object} MalwareBlockedEvent
25
+ * @property {string} packageName
26
+ * @property {string} version
27
+ * @property {string} targetUrl
28
+ * @property {number} timestamp
23
29
  */
24
30
 
25
31
  /**
@@ -32,7 +32,16 @@ function buildPipInterceptor(registry) {
32
32
  reqContext.targetUrl,
33
33
  registry
34
34
  );
35
- if (await isMalwarePackage(packageName, version)) {
35
+
36
+ // Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names.
37
+ // Per python, packages that differ only by hyphen vs underscore are considered the same.
38
+ const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName;
39
+
40
+ const isMalicious =
41
+ await isMalwarePackage(packageName, version)
42
+ || await isMalwarePackage(hyphenName, version);
43
+
44
+ if (isMalicious) {
36
45
  reqContext.blockMalware(packageName, version);
37
46
  }
38
47
  });
@@ -71,16 +80,21 @@ function parsePipPackageFromUrl(url, registry) {
71
80
  // Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
72
81
  // Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
73
82
 
74
- // Wheel (.whl)
75
- if (filename.endsWith(".whl")) {
76
- const base = filename.slice(0, -4); // remove ".whl"
83
+ // Wheel (.whl) and Poetry's preflight metadata (.whl.metadata)
84
+ // Examples:
85
+ // foo_bar-2.0.0-py3-none-any.whl
86
+ // foo_bar-2.0.0-py3-none-any.whl.metadata
87
+ const wheelExtRe = /\.whl(?:\.metadata)?$/;
88
+ const wheelExtMatch = filename.match(wheelExtRe);
89
+ if (wheelExtMatch) {
90
+ const base = filename.replace(wheelExtRe, "");
77
91
  const firstDash = base.indexOf("-");
78
92
  if (firstDash > 0) {
79
93
  const dist = base.slice(0, firstDash); // may contain underscores
80
94
  const rest = base.slice(firstDash + 1); // version + the rest of tags
81
95
  const secondDash = rest.indexOf("-");
82
96
  const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
83
- packageName = dist; // preserve underscores
97
+ packageName = dist;
84
98
  version = rawVersion;
85
99
  // Reject "latest" as it's a placeholder, not a real version
86
100
  // When version is "latest", this signals the URL doesn't contain actual version info
@@ -92,10 +106,11 @@ function parsePipPackageFromUrl(url, registry) {
92
106
  }
93
107
  }
94
108
 
95
- // Source dist (sdist)
96
- const sdistExtMatch = filename.match(/\.(tar\.gz|zip|tar\.bz2|tar\.xz)$/i);
109
+ // Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata)
110
+ const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
111
+ const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
97
112
  if (sdistExtMatch) {
98
- const base = filename.slice(0, -sdistExtMatch[0].length);
113
+ const base = filename.replace(sdistExtWithMetadataRe, "");
99
114
  const lastDash = base.lastIndexOf("-");
100
115
  if (lastDash > 0 && lastDash < base.length - 1) {
101
116
  packageName = base.slice(0, lastDash);
@@ -109,7 +124,6 @@ function parsePipPackageFromUrl(url, registry) {
109
124
  return { packageName, version };
110
125
  }
111
126
  }
112
-
113
127
  // Unknown file type or invalid
114
128
  return { packageName: undefined, version: undefined };
115
129
  }
@@ -0,0 +1,13 @@
1
+ // Instance Metadata Service (IMDS) endpoints used by cloud providers.
2
+ // Cloud SDK tools probe these to detect environment and retrieve credentials.
3
+ // When outside cloud environments, connections timeout - we reduce timeout (3s vs 30s)
4
+ // and suppress error logging since this is expected behavior.
5
+ const imdsEndpoints = [
6
+ "metadata.google.internal",
7
+ "metadata.goog",
8
+ "169.254.169.254", // AWS, Azure, Oracle Cloud, GCP
9
+ ];
10
+
11
+ export function isImdsEndpoint(/** @type {string} */ host) {
12
+ return imdsEndpoints.includes(host);
13
+ }
@@ -2,7 +2,7 @@ import * as http from "http";
2
2
  import { tunnelRequest } from "./tunnelRequestHandler.js";
3
3
  import { mitmConnect } from "./mitmRequestHandler.js";
4
4
  import { handleHttpProxyRequest } from "./plainHttpProxy.js";
5
- import { getCaCertPath } from "./certUtils.js";
5
+ import { getCombinedCaBundlePath } from "./certBundle.js";
6
6
  import { ui } from "../environment/userInteraction.js";
7
7
  import chalk from "chalk";
8
8
  import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
@@ -36,10 +36,13 @@ function getSafeChainProxyEnvironmentVariables() {
36
36
  return {};
37
37
  }
38
38
 
39
+ const proxyUrl = `http://localhost:${state.port}`;
40
+ const caCertPath = getCombinedCaBundlePath();
41
+
39
42
  return {
40
- HTTPS_PROXY: `http://localhost:${state.port}`,
41
- GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`,
42
- NODE_EXTRA_CA_CERTS: getCaCertPath(),
43
+ HTTPS_PROXY: proxyUrl,
44
+ GLOBAL_AGENT_HTTP_PROXY: proxyUrl,
45
+ NODE_EXTRA_CA_CERTS: caCertPath,
43
46
  };
44
47
  }
45
48
 
@@ -136,9 +139,14 @@ function handleConnect(req, clientSocket, head) {
136
139
 
137
140
  if (interceptor) {
138
141
  // Subscribe to malware blocked events
139
- interceptor.on("malwareBlocked", (event) => {
140
- onMalwareBlocked(event.packageName, event.version, event.url);
141
- });
142
+ interceptor.on(
143
+ "malwareBlocked",
144
+ (
145
+ /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event
146
+ ) => {
147
+ onMalwareBlocked(event.packageName, event.version, event.targetUrl);
148
+ }
149
+ );
142
150
 
143
151
  mitmConnect(req, clientSocket, interceptor);
144
152
  } else {
@@ -1,5 +1,10 @@
1
1
  import * as net from "net";
2
2
  import { ui } from "../environment/userInteraction.js";
3
+ import { isImdsEndpoint } from "./isImdsEndpoint.js";
4
+ import { getConnectTimeout } from "./getConnectTimeout.js";
5
+
6
+ /** @type {string[]} */
7
+ let timedoutImdsEndpoints = [];
3
8
 
4
9
  /**
5
10
  * @param {import("http").IncomingMessage} req
@@ -37,6 +42,21 @@ export function tunnelRequest(req, clientSocket, head) {
37
42
  */
38
43
  function tunnelRequestToDestination(req, clientSocket, head) {
39
44
  const { port, hostname } = new URL(`http://${req.url}`);
45
+ const isImds = isImdsEndpoint(hostname);
46
+
47
+ if (timedoutImdsEndpoints.includes(hostname)) {
48
+ clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
49
+ if (isImds) {
50
+ ui.writeVerbose(
51
+ `Safe-chain: Closing connection because previously timedout connect to ${hostname}`
52
+ );
53
+ } else {
54
+ ui.writeError(
55
+ `Safe-chain: Closing connection because previously timedout connect to ${hostname}`
56
+ );
57
+ }
58
+ return;
59
+ }
40
60
 
41
61
  const serverSocket = net.connect(
42
62
  Number.parseInt(port) || 443,
@@ -49,6 +69,31 @@ function tunnelRequestToDestination(req, clientSocket, head) {
49
69
  }
50
70
  );
51
71
 
72
+ // Set explicit connection timeout to avoid waiting for OS default (~2 minutes).
73
+ // IMDS endpoints get shorter timeout (3s) since they're commonly unreachable outside cloud environments.
74
+ const connectTimeout = getConnectTimeout(hostname);
75
+ serverSocket.setTimeout(connectTimeout);
76
+
77
+ serverSocket.on("timeout", () => {
78
+ // Suppress error logging for IMDS endpoints - timeouts are expected when not in cloud
79
+ if (isImds) {
80
+ timedoutImdsEndpoints.push(hostname);
81
+ ui.writeVerbose(
82
+ `Safe-chain: connect to ${hostname}:${
83
+ port || 443
84
+ } timed out after ${connectTimeout}ms`
85
+ );
86
+ } else {
87
+ ui.writeError(
88
+ `Safe-chain: connect to ${hostname}:${
89
+ port || 443
90
+ } timed out after ${connectTimeout}ms`
91
+ );
92
+ }
93
+ serverSocket.destroy(); // Clean up socket to prevent event loop hanging
94
+ clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
95
+ });
96
+
52
97
  clientSocket.on("error", () => {
53
98
  // This can happen if the client TCP socket sends RST instead of FIN.
54
99
  // Not subscribing to 'error' event will cause node to throw and crash.
@@ -58,9 +103,15 @@ function tunnelRequestToDestination(req, clientSocket, head) {
58
103
  });
59
104
 
60
105
  serverSocket.on("error", (err) => {
61
- ui.writeError(
62
- `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
63
- );
106
+ if (isImds) {
107
+ ui.writeVerbose(
108
+ `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
109
+ );
110
+ } else {
111
+ ui.writeError(
112
+ `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
113
+ );
114
+ }
64
115
  if (clientSocket.writable) {
65
116
  clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
66
117
  }
@@ -145,3 +196,4 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
145
196
  }
146
197
  });
147
198
  }
199
+
@@ -76,6 +76,12 @@ export const knownAikidoTools = [
76
76
  ecoSystem: ECOSYSTEM_PY,
77
77
  internalPackageManagerName: "pip",
78
78
  },
79
+ {
80
+ tool: "poetry",
81
+ aikidoCommand: "aikido-poetry",
82
+ ecoSystem: ECOSYSTEM_PY,
83
+ internalPackageManagerName: "poetry",
84
+ },
79
85
  {
80
86
  tool: "python",
81
87
  aikidoCommand: "aikido-python",
@@ -107,6 +113,20 @@ export function getPackageManagerList() {
107
113
  return `${tools.join(", ")}, and ${lastTool} commands`;
108
114
  }
109
115
 
116
+ /**
117
+ * @returns {string}
118
+ */
119
+ export function getShimsDir() {
120
+ return path.join(os.homedir(), ".safe-chain", "shims");
121
+ }
122
+
123
+ /**
124
+ * @returns {string}
125
+ */
126
+ export function getScriptsDir() {
127
+ return path.join(os.homedir(), ".safe-chain", "scripts");
128
+ }
129
+
110
130
  /**
111
131
  * @param {string} executableName
112
132
  *
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { ui } from "../environment/userInteraction.js";
3
- import { getPackageManagerList, knownAikidoTools } from "./helpers.js";
3
+ import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js";
4
4
  import fs from "fs";
5
5
  import os from "os";
6
6
  import path from "path";
@@ -32,7 +32,7 @@ export async function setupCi() {
32
32
  );
33
33
  ui.emptyLine();
34
34
 
35
- const shimsDir = path.join(os.homedir(), ".safe-chain", "shims");
35
+ const shimsDir = getShimsDir();
36
36
  const binDir = path.join(os.homedir(), ".safe-chain", "bin");
37
37
  // Create the shims directory if it doesn't exist
38
38
  if (!fs.existsSync(shimsDir)) {
@@ -1,9 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { ui } from "../environment/userInteraction.js";
3
3
  import { detectShells } from "./shellDetection.js";
4
- import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
4
+ import { knownAikidoTools, getPackageManagerList, getScriptsDir } from "./helpers.js";
5
5
  import fs from "fs";
6
- import os from "os";
7
6
  import path from "path";
8
7
  import { includePython } from "../config/cliArguments.js";
9
8
  import { fileURLToPath } from "url";
@@ -107,10 +106,10 @@ function setupShell(shell) {
107
106
 
108
107
  function copyStartupFiles() {
109
108
  const startupFiles = ["init-posix.sh", "init-pwsh.ps1", "init-fish.fish"];
109
+ const targetDir = getScriptsDir();
110
110
 
111
111
  for (const file of startupFiles) {
112
- const targetDir = path.join(os.homedir(), ".safe-chain", "scripts");
113
- const targetPath = path.join(os.homedir(), ".safe-chain", "scripts", file);
112
+ const targetPath = path.join(targetDir, file);
114
113
 
115
114
  if (!fs.existsSync(targetDir)) {
116
115
  fs.mkdirSync(targetDir, { recursive: true });
@@ -52,6 +52,10 @@ function uv
52
52
  wrapSafeChainCommand "uv" $argv
53
53
  end
54
54
 
55
+ function poetry
56
+ wrapSafeChainCommand "poetry" $argv
57
+ end
58
+
55
59
  # `python -m pip`, `python -m pip3`.
56
60
  function python
57
61
  wrapSafeChainCommand "python" $argv
@@ -48,6 +48,10 @@ function uv() {
48
48
  wrapSafeChainCommand "uv" "$@"
49
49
  }
50
50
 
51
+ function poetry() {
52
+ wrapSafeChainCommand "poetry" "$@"
53
+ }
54
+
51
55
  # `python -m pip`, `python -m pip3`.
52
56
  function python() {
53
57
  wrapSafeChainCommand "python" "$@"
@@ -1,6 +1,6 @@
1
1
  # Use cross-platform path separator (: on Unix, ; on Windows)
2
2
  $pathSeparator = if ($IsWindows) { ';' } else { ':' }
3
- $safeChainBin = Join-Path $HOME '.safe-chain' 'bin'
3
+ $safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
4
4
  $env:PATH = "$env:PATH$pathSeparator$safeChainBin"
5
5
 
6
6
  function npx {
@@ -50,6 +50,10 @@ function uv {
50
50
  Invoke-WrappedCommand "uv" $args
51
51
  }
52
52
 
53
+ function poetry {
54
+ Invoke-WrappedCommand "poetry" $args
55
+ }
56
+
53
57
  # `python -m pip`, `python -m pip3`.
54
58
  function python {
55
59
  Invoke-WrappedCommand 'python' $args
@@ -1,6 +1,6 @@
1
1
  # Use cross-platform path separator (: on Unix, ; on Windows)
2
2
  $pathSeparator = if ($IsWindows) { ';' } else { ':' }
3
- $safeChainBin = Join-Path $HOME '.safe-chain' 'bin'
3
+ $safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
4
4
  $env:PATH = "$env:PATH$pathSeparator$safeChainBin"
5
5
 
6
6
  function npx {
@@ -1,7 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { ui } from "../environment/userInteraction.js";
3
3
  import { detectShells } from "./shellDetection.js";
4
- import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
4
+ import { knownAikidoTools, getPackageManagerList, getShimsDir, getScriptsDir } from "./helpers.js";
5
+ import fs from "fs";
5
6
 
6
7
  /**
7
8
  * @returns {Promise<void>}
@@ -62,3 +63,44 @@ export async function teardown() {
62
63
  return;
63
64
  }
64
65
  }
66
+
67
+ /**
68
+ * Removes directories created by setup-ci and setup commands
69
+ * @returns {Promise<void>}
70
+ */
71
+ export async function teardownDirectories() {
72
+ const shimsDir = getShimsDir();
73
+ const scriptsDir = getScriptsDir();
74
+
75
+ // Remove CI shims directory
76
+ if (fs.existsSync(shimsDir)) {
77
+ try {
78
+ fs.rmSync(shimsDir, { recursive: true, force: true });
79
+ ui.writeInformation(
80
+ `${chalk.bold("- CI Shims:")} ${chalk.green("Removed successfully")}`
81
+ );
82
+ } catch (/** @type {any} */ error) {
83
+ ui.writeError(
84
+ `${chalk.bold("- CI Shims:")} ${chalk.red(
85
+ "Failed to remove"
86
+ )}. Error: ${error.message}`
87
+ );
88
+ }
89
+ }
90
+
91
+ // Remove scripts directory
92
+ if (fs.existsSync(scriptsDir)) {
93
+ try {
94
+ fs.rmSync(scriptsDir, { recursive: true, force: true });
95
+ ui.writeInformation(
96
+ `${chalk.bold("- Scripts:")} ${chalk.green("Removed successfully")}`
97
+ );
98
+ } catch (/** @type {any} */ error) {
99
+ ui.writeError(
100
+ `${chalk.bold("- Scripts:")} ${chalk.red(
101
+ "Failed to remove"
102
+ )}. Error: ${error.message}`
103
+ );
104
+ }
105
+ }
106
+ }