@aikidosec/safe-chain 1.4.4 → 1.4.8

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 (53) hide show
  1. package/README.md +65 -8
  2. package/package.json +1 -1
  3. package/src/api/aikido.js +93 -18
  4. package/src/config/cliArguments.js +24 -1
  5. package/src/config/configFile.js +64 -6
  6. package/src/config/environmentVariables.js +13 -2
  7. package/src/config/settings.js +51 -4
  8. package/src/main.js +6 -2
  9. package/src/packagemanager/_shared/commandErrors.js +17 -0
  10. package/src/packagemanager/bun/createBunPackageManager.js +2 -7
  11. package/src/packagemanager/npm/runNpmCommand.js +2 -7
  12. package/src/packagemanager/npx/runNpxCommand.js +2 -7
  13. package/src/packagemanager/pip/runPipCommand.js +2 -7
  14. package/src/packagemanager/pipx/runPipXCommand.js +2 -7
  15. package/src/packagemanager/pnpm/runPnpmCommand.js +3 -7
  16. package/src/packagemanager/poetry/createPoetryPackageManager.js +2 -7
  17. package/src/packagemanager/uv/runUvCommand.js +2 -7
  18. package/src/packagemanager/yarn/runYarnCommand.js +2 -7
  19. package/src/registryProxy/certBundle.js +25 -3
  20. package/src/registryProxy/http-utils.js +63 -0
  21. package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +1 -1
  22. package/src/registryProxy/interceptors/interceptorBuilder.js +37 -4
  23. package/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +33 -0
  24. package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +18 -41
  25. package/src/registryProxy/interceptors/npm/npmInterceptor.js +47 -2
  26. package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +20 -3
  27. package/src/registryProxy/interceptors/pip/modifyPipInfo.js +167 -0
  28. package/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js +176 -0
  29. package/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +162 -0
  30. package/src/registryProxy/interceptors/pip/pipInterceptor.js +122 -0
  31. package/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js +27 -0
  32. package/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +131 -0
  33. package/src/registryProxy/interceptors/suppressedVersionsState.js +21 -0
  34. package/src/registryProxy/mitmRequestHandler.js +12 -6
  35. package/src/registryProxy/registryProxy.js +72 -9
  36. package/src/scanning/newPackagesDatabaseBuilder.js +71 -0
  37. package/src/scanning/newPackagesDatabaseWarnings.js +17 -0
  38. package/src/scanning/newPackagesListCache.js +126 -0
  39. package/src/scanning/packageNameVariants.js +29 -0
  40. package/src/shell-integration/setup.js +7 -3
  41. package/src/shell-integration/shellDetection.js +2 -0
  42. package/src/shell-integration/supported-shells/bash.js +19 -1
  43. package/src/shell-integration/supported-shells/fish.js +18 -0
  44. package/src/shell-integration/supported-shells/powershell.js +18 -0
  45. package/src/shell-integration/supported-shells/windowsPowershell.js +18 -0
  46. package/src/shell-integration/supported-shells/zsh.js +19 -1
  47. package/src/shell-integration/teardown.js +7 -1
  48. package/src/ultimate/ultimateTroubleshooting.js +1 -1
  49. package/src/installation/downloadAgent.js +0 -125
  50. package/src/installation/installOnMacOS.js +0 -155
  51. package/src/installation/installOnWindows.js +0 -203
  52. package/src/installation/installUltimate.js +0 -35
  53. package/src/registryProxy/interceptors/pipInterceptor.js +0 -132
@@ -0,0 +1,131 @@
1
+ import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
2
+
3
+ /**
4
+ * @param {any} file
5
+ * @param {string} metadataUrl
6
+ * @returns {string | undefined}
7
+ */
8
+ export function getPackageVersionFromMetadataFile(file, metadataUrl) {
9
+ const href = typeof file?.url === "string" ? file.url : undefined;
10
+ const filename = typeof file?.filename === "string" ? file.filename : undefined;
11
+
12
+ if (href) {
13
+ const resolvedHref = new URL(href, metadataUrl).toString();
14
+ return parsePipPackageFromUrl(
15
+ resolvedHref,
16
+ new URL(resolvedHref).host
17
+ ).version;
18
+ }
19
+
20
+ if (filename) {
21
+ return parsePipPackageFromUrl(
22
+ new URL(filename, metadataUrl).toString(),
23
+ new URL(metadataUrl).host
24
+ ).version;
25
+ }
26
+
27
+ return undefined;
28
+ }
29
+
30
+ /**
31
+ * @param {any} json
32
+ * @param {string} metadataUrl
33
+ * @returns {string[]}
34
+ */
35
+ export function getAvailableVersionsFromJson(json, metadataUrl) {
36
+ if (json.releases && typeof json.releases === "object") {
37
+ return Object.keys(json.releases);
38
+ }
39
+
40
+ if (!Array.isArray(json.files)) {
41
+ return [];
42
+ }
43
+
44
+ return [
45
+ ...new Set(
46
+ json.files
47
+ .map((/** @type {any} */ file) =>
48
+ getPackageVersionFromMetadataFile(file, metadataUrl)
49
+ )
50
+ .filter(isDefinedString)
51
+ ),
52
+ ];
53
+ }
54
+
55
+ /**
56
+ * @param {string | undefined} value
57
+ * @returns {value is string}
58
+ */
59
+ function isDefinedString(value) {
60
+ return typeof value === "string";
61
+ }
62
+
63
+ /**
64
+ * @param {string[]} versions
65
+ * @returns {string | undefined}
66
+ */
67
+ export function calculateLatestVersion(versions) {
68
+ const stableVersions = versions.filter((version) => !isPrerelease(version));
69
+ if (stableVersions.length > 0) {
70
+ return stableVersions.sort(comparePep440ishVersions).at(-1);
71
+ }
72
+
73
+ return versions.sort(comparePep440ishVersions).at(-1);
74
+ }
75
+
76
+ /**
77
+ * @param {string} left
78
+ * @param {string} right
79
+ * @returns {number}
80
+ */
81
+ function comparePep440ishVersions(left, right) {
82
+ const leftParts = tokenizeVersion(left);
83
+ const rightParts = tokenizeVersion(right);
84
+ const maxLength = Math.max(leftParts.length, rightParts.length);
85
+
86
+ for (let index = 0; index < maxLength; index += 1) {
87
+ const leftPart = leftParts[index];
88
+ const rightPart = rightParts[index];
89
+
90
+ if (leftPart === undefined) return -1;
91
+ if (rightPart === undefined) return 1;
92
+
93
+ if (leftPart === rightPart) {
94
+ continue;
95
+ }
96
+
97
+ const leftNumeric = typeof leftPart === "number";
98
+ const rightNumeric = typeof rightPart === "number";
99
+
100
+ if (leftNumeric && rightNumeric) {
101
+ return leftPart - rightPart;
102
+ }
103
+
104
+ if (leftNumeric) return 1;
105
+ if (rightNumeric) return -1;
106
+
107
+ return String(leftPart).localeCompare(String(rightPart));
108
+ }
109
+
110
+ return 0;
111
+ }
112
+
113
+ /**
114
+ * @param {string} version
115
+ * @returns {(string | number)[]}
116
+ */
117
+ function tokenizeVersion(version) {
118
+ return version
119
+ .toLowerCase()
120
+ .split(/[^a-z0-9]+/)
121
+ .flatMap((part) => part.match(/[a-z]+|\d+/g) || [])
122
+ .map((part) => (/^\d+$/.test(part) ? Number(part) : part));
123
+ }
124
+
125
+ /**
126
+ * @param {string} version
127
+ * @returns {boolean}
128
+ */
129
+ function isPrerelease(version) {
130
+ return /(a|b|rc|dev)\d+/i.test(version);
131
+ }
@@ -0,0 +1,21 @@
1
+ const state = {
2
+ hasSuppressedVersions: false,
3
+ };
4
+
5
+ /**
6
+ * Tracks whether any rewritten metadata response suppressed versions during the
7
+ * current process lifetime. This is intentional shared state used only for the
8
+ * end-of-run summary message exposed through the proxy API.
9
+ *
10
+ * @returns {void}
11
+ */
12
+ export function recordSuppressedVersion() {
13
+ state.hasSuppressedVersions = true;
14
+ }
15
+
16
+ /**
17
+ * @returns {boolean}
18
+ */
19
+ export function getHasSuppressedVersions() {
20
+ return state.hasSuppressedVersions;
21
+ }
@@ -2,7 +2,8 @@ import https from "https";
2
2
  import { generateCertForHost } from "./certUtils.js";
3
3
  import { HttpsProxyAgent } from "https-proxy-agent";
4
4
  import { ui } from "../environment/userInteraction.js";
5
- import { gunzipSync, gzipSync } from "zlib";
5
+ import { gunzipSync } from "zlib";
6
+ import { omitHeaders } from "./http-utils.js";
6
7
 
7
8
  /**
8
9
  * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
@@ -215,11 +216,16 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {
215
216
 
216
217
  buffer = requestHandler.modifyBody(buffer, headers);
217
218
 
218
- if (proxyRes.headers["content-encoding"] === "gzip") {
219
- buffer = gzipSync(buffer);
220
- }
221
-
222
- res.writeHead(statusCode, headers);
219
+ // For rewritten responses, send the final body uncompressed.
220
+ // This avoids mismatches between upstream compression metadata and the
221
+ // rewritten payload on the wire.
222
+ const rewrittenHeaders = omitHeaders(
223
+ headers,
224
+ ["content-length", "transfer-encoding", "content-encoding"],
225
+ { caseInsensitive: true }
226
+ ) || {};
227
+ rewrittenHeaders["content-length"] = String(buffer.byteLength);
228
+ res.writeHead(statusCode, rewrittenHeaders);
223
229
  res.end(buffer);
224
230
  });
225
231
  } else {
@@ -2,19 +2,24 @@ 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 { getCombinedCaBundlePath } from "./certBundle.js";
5
+ import { getCombinedCaBundlePath, cleanupCertBundle } 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";
9
- import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
9
+ import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js";
10
10
 
11
11
  const SERVER_STOP_TIMEOUT_MS = 1000;
12
12
  /**
13
- * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
13
+ * @type {{
14
+ * port: number | null,
15
+ * blockedRequests: {packageName: string, version: string, url: string}[],
16
+ * blockedMinimumAgeRequests: {packageName: string, version: string, url: string}[]
17
+ * }}
14
18
  */
15
19
  const state = {
16
20
  port: null,
17
21
  blockedRequests: [],
22
+ blockedMinimumAgeRequests: [],
18
23
  };
19
24
 
20
25
  export function createSafeChainProxy() {
@@ -23,7 +28,8 @@ export function createSafeChainProxy() {
23
28
  return {
24
29
  startServer: () => startServer(server),
25
30
  stopServer: () => stopServer(server),
26
- verifyNoMaliciousPackages,
31
+ hasBlockedMaliciousPackages,
32
+ hasBlockedMinimumAgeRequests,
27
33
  hasSuppressedVersions: getHasSuppressedVersions,
28
34
  };
29
35
  }
@@ -115,12 +121,16 @@ function stopServer(server) {
115
121
  return new Promise((resolve) => {
116
122
  try {
117
123
  server.close(() => {
124
+ cleanupCertBundle();
118
125
  resolve();
119
126
  });
120
127
  } catch {
121
128
  resolve();
122
129
  }
123
- setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
130
+ setTimeout(() => {
131
+ cleanupCertBundle();
132
+ resolve();
133
+ }, SERVER_STOP_TIMEOUT_MS);
124
134
  });
125
135
  }
126
136
 
@@ -147,6 +157,18 @@ function handleConnect(req, clientSocket, head) {
147
157
  onMalwareBlocked(event.packageName, event.version, event.targetUrl);
148
158
  }
149
159
  );
160
+ interceptor.on(
161
+ "minimumAgeRequestBlocked",
162
+ (
163
+ /** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event
164
+ ) => {
165
+ onMinimumAgeRequestBlocked(
166
+ event.packageName,
167
+ event.version,
168
+ event.targetUrl
169
+ );
170
+ }
171
+ );
150
172
 
151
173
  mitmConnect(req, clientSocket, interceptor);
152
174
  } else {
@@ -166,10 +188,19 @@ function onMalwareBlocked(packageName, version, url) {
166
188
  state.blockedRequests.push({ packageName, version, url });
167
189
  }
168
190
 
169
- function verifyNoMaliciousPackages() {
191
+ /**
192
+ *
193
+ * @param {string} packageName
194
+ * @param {string} version
195
+ * @param {string} url
196
+ */
197
+ function onMinimumAgeRequestBlocked(packageName, version, url) {
198
+ state.blockedMinimumAgeRequests.push({ packageName, version, url });
199
+ }
200
+
201
+ function hasBlockedMaliciousPackages() {
170
202
  if (state.blockedRequests.length === 0) {
171
- // No malicious packages were blocked, so nothing to block
172
- return true;
203
+ return false;
173
204
  }
174
205
 
175
206
  ui.emptyLine();
@@ -188,5 +219,37 @@ function verifyNoMaliciousPackages() {
188
219
  ui.writeExitWithoutInstallingMaliciousPackages();
189
220
  ui.emptyLine();
190
221
 
191
- return false;
222
+ return true;
223
+ }
224
+
225
+ function hasBlockedMinimumAgeRequests() {
226
+ if (state.blockedMinimumAgeRequests.length === 0) {
227
+ return false;
228
+ }
229
+
230
+ ui.emptyLine();
231
+
232
+ ui.writeInformation(
233
+ `Safe-chain: ${chalk.bold(
234
+ `blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age`
235
+ )}:`
236
+ );
237
+
238
+ for (const req of state.blockedMinimumAgeRequests) {
239
+ ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
240
+ }
241
+
242
+ ui.writeInformation(
243
+ ` To disable this check, use: ${chalk.cyan(
244
+ "--safe-chain-skip-minimum-package-age"
245
+ )}`
246
+ );
247
+
248
+ ui.emptyLine();
249
+ ui.writeError(
250
+ "Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check."
251
+ );
252
+ ui.emptyLine();
253
+
254
+ return true;
192
255
  }
@@ -0,0 +1,71 @@
1
+ import {
2
+ getMinimumPackageAgeHours,
3
+ getEcoSystem,
4
+ ECOSYSTEM_JS,
5
+ ECOSYSTEM_PY,
6
+ } from "../config/settings.js";
7
+ import { getEquivalentPackageNames } from "./packageNameVariants.js";
8
+
9
+ /**
10
+ * @typedef {Object} NewPackagesDatabase
11
+ * @property {function(string | undefined, string | undefined): boolean} isNewlyReleasedPackage
12
+ */
13
+
14
+ /**
15
+ * Returns the ecosystem identifier expected in upstream/core release feeds.
16
+ * @returns {string}
17
+ */
18
+ function getCurrentFeedSource() {
19
+ const ecosystem = getEcoSystem();
20
+
21
+ if (ecosystem === ECOSYSTEM_JS) {
22
+ return "npm";
23
+ }
24
+
25
+ if (ecosystem === ECOSYSTEM_PY) {
26
+ return "pypi";
27
+ }
28
+
29
+ return ecosystem;
30
+ }
31
+
32
+ /**
33
+ * @param {import("../api/aikido.js").NewPackageEntry[]} newPackagesList
34
+ * @returns {NewPackagesDatabase}
35
+ */
36
+ export function buildNewPackagesDatabase(newPackagesList) {
37
+ const ecosystem = getEcoSystem();
38
+
39
+ /**
40
+ * @param {string | undefined} name
41
+ * @param {string | undefined} version
42
+ * @returns {boolean}
43
+ */
44
+ function isNewlyReleasedPackage(name, version) {
45
+ if (!name || !version) {
46
+ return false;
47
+ }
48
+
49
+ const cutOff = new Date(
50
+ new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
51
+ );
52
+ const expectedSource = getCurrentFeedSource();
53
+ const candidateNames = getEquivalentPackageNames(name, ecosystem);
54
+
55
+ const entry = newPackagesList.find(
56
+ (pkg) =>
57
+ (!pkg.source || pkg.source.toLowerCase() === expectedSource) &&
58
+ candidateNames.includes(pkg.package_name) &&
59
+ pkg.version === version
60
+ );
61
+
62
+ if (!entry) {
63
+ return false;
64
+ }
65
+
66
+ const releasedOn = new Date(entry.released_on * 1000);
67
+ return releasedOn > cutOff;
68
+ }
69
+
70
+ return { isNewlyReleasedPackage };
71
+ }
@@ -0,0 +1,17 @@
1
+ import { ui } from "../environment/userInteraction.js";
2
+
3
+ let hasWarnedAboutUnavailableNewPackagesDatabase = false;
4
+
5
+ /** @param {Error} error */
6
+ export function warnOnceAboutUnavailableDatabase(error) {
7
+ if (!hasWarnedAboutUnavailableNewPackagesDatabase) {
8
+ ui.writeWarning(
9
+ `Failed to load the new packages list used for direct package download request blocking. Continuing with metadata-based minimum age checks only. ${error.message}`
10
+ );
11
+ hasWarnedAboutUnavailableNewPackagesDatabase = true;
12
+ }
13
+ }
14
+
15
+ export function resetWarningState() {
16
+ hasWarnedAboutUnavailableNewPackagesDatabase = false;
17
+ }
@@ -0,0 +1,126 @@
1
+ import fs from "fs";
2
+ import {
3
+ fetchNewPackagesList,
4
+ fetchNewPackagesListVersion,
5
+ } from "../api/aikido.js";
6
+ import {
7
+ getNewPackagesListPath,
8
+ getNewPackagesListVersionPath,
9
+ } from "../config/configFile.js";
10
+ import { ui } from "../environment/userInteraction.js";
11
+ import { buildNewPackagesDatabase } from "./newPackagesDatabaseBuilder.js";
12
+ import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.js";
13
+
14
+ /**
15
+ * @typedef {import("./newPackagesDatabaseBuilder.js").NewPackagesDatabase} NewPackagesDatabase
16
+ */
17
+
18
+ // Shared per-process cache to avoid rebuilding the same feed-backed database on each request.
19
+ /** @type {NewPackagesDatabase | null} */
20
+ let cachedNewPackagesDatabase = null;
21
+
22
+ /**
23
+ * @returns {Promise<NewPackagesDatabase>}
24
+ */
25
+ export async function openNewPackagesDatabase() {
26
+ if (cachedNewPackagesDatabase) {
27
+ return cachedNewPackagesDatabase;
28
+ }
29
+
30
+ /** @type {import("../api/aikido.js").NewPackageEntry[]} */
31
+ let newPackagesList;
32
+
33
+ try {
34
+ newPackagesList = await getNewPackagesList();
35
+ } catch (/** @type {any} */ error) {
36
+ warnOnceAboutUnavailableDatabase(error);
37
+ cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false };
38
+ return cachedNewPackagesDatabase;
39
+ }
40
+
41
+ cachedNewPackagesDatabase = buildNewPackagesDatabase(newPackagesList);
42
+ return cachedNewPackagesDatabase;
43
+ }
44
+
45
+ /**
46
+ * @returns {Promise<import("../api/aikido.js").NewPackageEntry[]>}
47
+ */
48
+ async function getNewPackagesList() {
49
+ const { newPackagesList: cachedList, version: cachedVersion } =
50
+ readNewPackagesListFromLocalCache();
51
+
52
+ try {
53
+ if (cachedList) {
54
+ const currentVersion = await fetchNewPackagesListVersion();
55
+ if (cachedVersion === currentVersion) {
56
+ return cachedList;
57
+ }
58
+ }
59
+
60
+ const { newPackagesList, version } = await fetchNewPackagesList();
61
+
62
+ if (version) {
63
+ writeNewPackagesListToLocalCache(newPackagesList, version);
64
+ return newPackagesList;
65
+ } else {
66
+ ui.writeWarning(
67
+ "The new packages list for direct package download request blocking was downloaded, but could not be cached due to a missing version."
68
+ );
69
+ return newPackagesList;
70
+ }
71
+ } catch (/** @type {any} */ error) {
72
+ if (cachedList) {
73
+ ui.writeWarning(
74
+ "Failed to fetch the latest new packages list for direct package download request blocking. Using cached version."
75
+ );
76
+ return cachedList;
77
+ }
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @param {import("../api/aikido.js").NewPackageEntry[]} data
84
+ * @param {string | number} version
85
+ *
86
+ * @returns {void}
87
+ */
88
+ export function writeNewPackagesListToLocalCache(data, version) {
89
+ try {
90
+ const listPath = getNewPackagesListPath();
91
+ const versionPath = getNewPackagesListVersionPath();
92
+
93
+ fs.writeFileSync(listPath, JSON.stringify(data));
94
+ fs.writeFileSync(versionPath, version.toString());
95
+ } catch {
96
+ ui.writeWarning(
97
+ "Failed to write new packages list to local cache, next time the list will be fetched from the server again."
98
+ );
99
+ }
100
+ }
101
+
102
+ /**
103
+ * @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}}
104
+ */
105
+ export function readNewPackagesListFromLocalCache() {
106
+ try {
107
+ const listPath = getNewPackagesListPath();
108
+ if (!fs.existsSync(listPath)) {
109
+ return { newPackagesList: null, version: null };
110
+ }
111
+
112
+ const data = fs.readFileSync(listPath, "utf8");
113
+ const newPackagesList = JSON.parse(data);
114
+ const versionPath = getNewPackagesListVersionPath();
115
+ let version = null;
116
+ if (fs.existsSync(versionPath)) {
117
+ version = fs.readFileSync(versionPath, "utf8").trim();
118
+ }
119
+ return { newPackagesList, version };
120
+ } catch {
121
+ ui.writeWarning(
122
+ "Failed to read new packages list from local cache. Continuing without local cache."
123
+ );
124
+ return { newPackagesList: null, version: null };
125
+ }
126
+ }
@@ -0,0 +1,29 @@
1
+ import { ECOSYSTEM_PY } from "../config/settings.js";
2
+
3
+ /**
4
+ * Normalises a Python package name per PEP 503: lowercase and collapse any
5
+ * run of `.`, `_`, or `-` into a single hyphen.
6
+ * @param {string} packageName
7
+ * @returns {string}
8
+ */
9
+ export function normalizePipPackageName(packageName) {
10
+ return packageName.toLowerCase().replace(/[._-]+/g, "-");
11
+ }
12
+
13
+ /**
14
+ * @param {string} packageName
15
+ * @param {string} ecosystem
16
+ * @returns {string[]}
17
+ */
18
+ export function getEquivalentPackageNames(packageName, ecosystem) {
19
+ if (ecosystem !== ECOSYSTEM_PY) {
20
+ return [packageName];
21
+ }
22
+
23
+ const pythonSeparatorPattern = /[._-]/g;
24
+ const hyphenName = packageName.replaceAll(pythonSeparatorPattern, "-");
25
+ const underscoreName = packageName.replaceAll(pythonSeparatorPattern, "_");
26
+ const dotName = packageName.replaceAll(pythonSeparatorPattern, ".");
27
+
28
+ return [...new Set([packageName, hyphenName, underscoreName, dotName])];
29
+ }
@@ -91,9 +91,7 @@ async function setupShell(shell) {
91
91
  );
92
92
  } else {
93
93
  ui.writeError(
94
- `${chalk.bold("- " + shell.name + ":")} ${chalk.red(
95
- "Setup failed",
96
- )}. Please check your ${shell.name} configuration.`,
94
+ `${chalk.bold("- " + shell.name + ":")} ${chalk.red("Setup failed")}`,
97
95
  );
98
96
  if (error) {
99
97
  let message = ` Error: ${error.message}`;
@@ -102,6 +100,12 @@ async function setupShell(shell) {
102
100
  }
103
101
  ui.writeError(message);
104
102
  }
103
+ ui.emptyLine();
104
+ ui.writeInformation(` ${chalk.bold("To set up manually:")}`);
105
+ for (const instruction of shell.getManualSetupInstructions()) {
106
+ ui.writeInformation(` ${instruction}`);
107
+ }
108
+ ui.emptyLine();
105
109
  }
106
110
 
107
111
  return success;
@@ -11,6 +11,8 @@ import { ui } from "../environment/userInteraction.js";
11
11
  * @property {() => boolean} isInstalled
12
12
  * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean|Promise<boolean>} setup
13
13
  * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} teardown
14
+ * @property {() => string[]} getManualSetupInstructions
15
+ * @property {() => string[]} getManualTeardownInstructions
14
16
  */
15
17
 
16
18
  /**
@@ -32,7 +32,7 @@ function teardown(tools) {
32
32
  );
33
33
  }
34
34
 
35
- // Removes the line that sources the safe-chain bash initialization script (~/.aikido/scripts/init-posix.sh)
35
+ // Removes the line that sources the safe-chain bash initialization script (~/.safe-chain/scripts/init-posix.sh)
36
36
  removeLinesMatchingPattern(
37
37
  startupFile,
38
38
  /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/,
@@ -123,6 +123,22 @@ function cygpathw(path) {
123
123
  }
124
124
  }
125
125
 
126
+ function getManualTeardownInstructions() {
127
+ return [
128
+ `Remove the following line from your ~/.bashrc file:`,
129
+ ` source ~/.safe-chain/scripts/init-posix.sh`,
130
+ `Then restart your terminal or run: source ~/.bashrc`,
131
+ ];
132
+ }
133
+
134
+ function getManualSetupInstructions() {
135
+ return [
136
+ `Add the following line to your ~/.bashrc file:`,
137
+ ` source ~/.safe-chain/scripts/init-posix.sh`,
138
+ `Then restart your terminal or run: source ~/.bashrc`,
139
+ ];
140
+ }
141
+
126
142
  /**
127
143
  * @type {import("../shellDetection.js").Shell}
128
144
  */
@@ -131,4 +147,6 @@ export default {
131
147
  isInstalled,
132
148
  setup,
133
149
  teardown,
150
+ getManualSetupInstructions,
151
+ getManualTeardownInstructions,
134
152
  };
@@ -66,6 +66,22 @@ function getStartupFile() {
66
66
  }
67
67
  }
68
68
 
69
+ function getManualTeardownInstructions() {
70
+ return [
71
+ `Remove the following line from your ~/.config/fish/config.fish file:`,
72
+ ` source ~/.safe-chain/scripts/init-fish.fish`,
73
+ `Then restart your terminal or run: source ~/.config/fish/config.fish`,
74
+ ];
75
+ }
76
+
77
+ function getManualSetupInstructions() {
78
+ return [
79
+ `Add the following line to your ~/.config/fish/config.fish file:`,
80
+ ` source ~/.safe-chain/scripts/init-fish.fish`,
81
+ `Then restart your terminal or run: source ~/.config/fish/config.fish`,
82
+ ];
83
+ }
84
+
69
85
  /**
70
86
  * @type {import("../shellDetection.js").Shell}
71
87
  */
@@ -74,4 +90,6 @@ export default {
74
90
  isInstalled,
75
91
  setup,
76
92
  teardown,
93
+ getManualSetupInstructions,
94
+ getManualTeardownInstructions,
77
95
  };
@@ -71,6 +71,22 @@ function getStartupFile() {
71
71
  }
72
72
  }
73
73
 
74
+ function getManualTeardownInstructions() {
75
+ return [
76
+ `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`,
77
+ ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
78
+ `Then restart your terminal or run: . $PROFILE`,
79
+ ];
80
+ }
81
+
82
+ function getManualSetupInstructions() {
83
+ return [
84
+ `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`,
85
+ ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
86
+ `Then restart your terminal or run: . $PROFILE`,
87
+ ];
88
+ }
89
+
74
90
  /**
75
91
  * @type {import("../shellDetection.js").Shell}
76
92
  */
@@ -79,4 +95,6 @@ export default {
79
95
  isInstalled,
80
96
  setup,
81
97
  teardown,
98
+ getManualSetupInstructions,
99
+ getManualTeardownInstructions,
82
100
  };