@evantahler/mcpx 0.16.4 → 0.17.0

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.
@@ -152,6 +152,8 @@ mcpx deauth <server> # remove stored auth
152
152
  | `mcpx task get <server> <taskId>` | Get task status |
153
153
  | `mcpx task result <server> <taskId>` | Retrieve completed task result |
154
154
  | `mcpx task cancel <server> <taskId>` | Cancel a running task |
155
+ | `mcpx check-update` | Check for a newer version of mcpx |
156
+ | `mcpx upgrade` | Upgrade mcpx to the latest version|
155
157
 
156
158
  ## Global flags
157
159
 
@@ -152,6 +152,8 @@ mcpx deauth <server> # remove stored auth
152
152
  | `mcpx task get <server> <taskId>` | Get task status |
153
153
  | `mcpx task result <server> <taskId>` | Retrieve completed task result |
154
154
  | `mcpx task cancel <server> <taskId>` | Cancel a running task |
155
+ | `mcpx check-update` | Check for a newer version of mcpx |
156
+ | `mcpx upgrade` | Upgrade mcpx to the latest version|
155
157
 
156
158
  ## Global flags
157
159
 
package/README.md CHANGED
@@ -104,6 +104,8 @@ mcpx search -n 5 "manage pull requests"
104
104
  | `mcpx task get <server> <taskId>` | Get task status |
105
105
  | `mcpx task result <server> <taskId>` | Retrieve completed task result |
106
106
  | `mcpx task cancel <server> <taskId>` | Cancel a running task |
107
+ | `mcpx check-update` | Check for a newer version of mcpx |
108
+ | `mcpx upgrade` | Upgrade mcpx to the latest version |
107
109
 
108
110
  ## Options
109
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpx",
3
- "version": "0.16.4",
3
+ "version": "0.17.0",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -15,6 +15,9 @@ import { registerResourceCommand } from "./commands/resource.ts";
15
15
  import { registerPromptCommand } from "./commands/prompt.ts";
16
16
  import { registerServersCommand } from "./commands/servers.ts";
17
17
  import { registerTaskCommand } from "./commands/task.ts";
18
+ import { registerCheckUpdateCommand } from "./commands/check-update.ts";
19
+ import { registerUpgradeCommand } from "./commands/upgrade.ts";
20
+ import { maybeCheckForUpdate } from "./update/background.ts";
18
21
 
19
22
  import pkg from "../package.json";
20
23
 
@@ -50,6 +53,8 @@ registerResourceCommand(program);
50
53
  registerPromptCommand(program);
51
54
  registerServersCommand(program);
52
55
  registerTaskCommand(program);
56
+ registerCheckUpdateCommand(program);
57
+ registerUpgradeCommand(program);
53
58
 
54
59
  // Detect unknown subcommands before commander misreports them as "too many arguments"
55
60
  const knownCommands = new Set(program.commands.map((c) => c.name()));
@@ -77,4 +82,13 @@ if (firstCommand && !knownCommands.has(firstCommand)) {
77
82
  process.exit(1);
78
83
  }
79
84
 
85
+ // Fire-and-forget background update check
86
+ const updateNotice = maybeCheckForUpdate();
87
+
80
88
  program.parse();
89
+
90
+ // Print update notice after command output completes
91
+ process.on("beforeExit", async () => {
92
+ const notice = await updateNotice;
93
+ if (notice) process.stderr.write(notice);
94
+ });
@@ -0,0 +1,70 @@
1
+ import { green, yellow, cyan, dim } from "ansis";
2
+ import type { Command } from "commander";
3
+ import { createSpinner } from "nanospinner";
4
+ import pkg from "../../package.json";
5
+ import { checkForUpdate } from "../update/checker.ts";
6
+ import { saveUpdateCache } from "../update/cache.ts";
7
+ import type { UpdateCache } from "../update/checker.ts";
8
+
9
+ export function registerCheckUpdateCommand(program: Command) {
10
+ program
11
+ .command("check-update")
12
+ .description("Check for a newer version of mcpx")
13
+ .action(async () => {
14
+ const opts = program.opts();
15
+ const json = !!(opts.json as boolean | undefined);
16
+ const isTTY = process.stderr.isTTY ?? false;
17
+
18
+ const spinner =
19
+ !json && isTTY
20
+ ? createSpinner("Checking for updates...", { stream: process.stderr }).start()
21
+ : null;
22
+
23
+ try {
24
+ const info = await checkForUpdate(pkg.version);
25
+
26
+ // Save to cache
27
+ const cache: UpdateCache = {
28
+ lastCheckAt: new Date().toISOString(),
29
+ latestVersion: info.latestVersion,
30
+ hasUpdate: info.hasUpdate,
31
+ changelog: info.changelog,
32
+ };
33
+ await saveUpdateCache(cache);
34
+
35
+ spinner?.stop();
36
+
37
+ if (json) {
38
+ console.log(JSON.stringify(info, null, 2));
39
+ return;
40
+ }
41
+
42
+ if (!info.hasUpdate) {
43
+ if (info.aheadOfLatest) {
44
+ console.log(
45
+ yellow(
46
+ `mcpx v${info.currentVersion} is ahead of latest published release (v${info.latestVersion})`,
47
+ ),
48
+ );
49
+ } else {
50
+ console.log(green(`mcpx is up to date (v${info.currentVersion})`));
51
+ }
52
+ return;
53
+ }
54
+
55
+ console.log(yellow(`Update available: ${info.currentVersion} → ${info.latestVersion}`));
56
+
57
+ if (info.changelog) {
58
+ console.log("");
59
+ console.log(dim(info.changelog));
60
+ }
61
+
62
+ console.log("");
63
+ console.log(cyan(`Run \`mcpx upgrade\` to update`));
64
+ } catch (err) {
65
+ spinner?.error({ text: "Failed to check for updates" });
66
+ console.error(String(err));
67
+ process.exit(1);
68
+ }
69
+ });
70
+ }
@@ -0,0 +1,226 @@
1
+ import { $ } from "bun";
2
+ import { green, yellow, red, cyan, dim } from "ansis";
3
+ import type { Command } from "commander";
4
+ import { createSpinner } from "nanospinner";
5
+ import { tmpdir } from "os";
6
+ import { join } from "path";
7
+ import pkg from "../../package.json";
8
+ import {
9
+ checkForUpdate,
10
+ detectInstallMethod,
11
+ needsCheck,
12
+ type InstallMethod,
13
+ } from "../update/checker.ts";
14
+ import { loadUpdateCache, saveUpdateCache, clearUpdateCache } from "../update/cache.ts";
15
+ import type { UpdateCache } from "../update/checker.ts";
16
+ import pkgMeta from "../../package.json";
17
+
18
+ const GITHUB_REPO = pkgMeta.repository.url
19
+ .replace(/^https:\/\/github\.com\//, "")
20
+ .replace(/\.git$/, "");
21
+
22
+ function platformArtifactName(): string {
23
+ let os: string;
24
+ let ext = "";
25
+ switch (process.platform) {
26
+ case "darwin":
27
+ os = "darwin";
28
+ break;
29
+ case "win32":
30
+ os = "windows";
31
+ ext = ".exe";
32
+ break;
33
+ default:
34
+ os = "linux";
35
+ break;
36
+ }
37
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
38
+ return `mcpx-${os}-${arch}${ext}`;
39
+ }
40
+
41
+ async function upgradeWithPackageManager(command: string, args: string[]): Promise<boolean> {
42
+ const result = await $`${command} ${args}`.nothrow();
43
+ return result.exitCode === 0;
44
+ }
45
+
46
+ async function upgradeFromBinary(latestVersion: string): Promise<boolean> {
47
+ const artifact = platformArtifactName();
48
+ const tag = `v${latestVersion}`;
49
+ const url = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/${artifact}`;
50
+
51
+ const tmpPath = join(tmpdir(), `mcpx-upgrade-${Date.now()}`);
52
+ const targetPath = process.execPath;
53
+
54
+ try {
55
+ const res = await fetch(url);
56
+ if (!res.ok) {
57
+ console.error(red(`Failed to download binary: HTTP ${res.status}`));
58
+ return false;
59
+ }
60
+
61
+ const bytes = await res.arrayBuffer();
62
+ await Bun.write(tmpPath, bytes);
63
+
64
+ await $`chmod +x ${tmpPath}`.quiet();
65
+
66
+ // Try to move into place
67
+ const mv = await $`mv ${tmpPath} ${targetPath}`.quiet().nothrow();
68
+
69
+ if (mv.exitCode !== 0) {
70
+ // Try with sudo
71
+ console.log(dim("Requires elevated permissions..."));
72
+ const sudo = await $`sudo mv ${tmpPath} ${targetPath}`.nothrow();
73
+ if (sudo.exitCode !== 0) {
74
+ console.error(red("Failed to install binary. Try running with sudo."));
75
+ return false;
76
+ }
77
+ }
78
+
79
+ return true;
80
+ } catch (err) {
81
+ console.error(red(`Failed to upgrade binary: ${err}`));
82
+ // Clean up temp file
83
+ await $`rm -f ${tmpPath}`.quiet().nothrow();
84
+ return false;
85
+ }
86
+ }
87
+
88
+ export function registerUpgradeCommand(program: Command) {
89
+ program
90
+ .command("upgrade")
91
+ .description("Upgrade mcpx to the latest version")
92
+ .action(async () => {
93
+ const opts = program.opts();
94
+ const json = !!(opts.json as boolean | undefined);
95
+ const isTTY = process.stderr.isTTY ?? false;
96
+
97
+ const spinner =
98
+ !json && isTTY
99
+ ? createSpinner("Checking for updates...", { stream: process.stderr }).start()
100
+ : null;
101
+
102
+ try {
103
+ // Check for update (use cache if fresh)
104
+ const cache = await loadUpdateCache();
105
+ let latestVersion: string;
106
+ let hasUpdate: boolean;
107
+
108
+ if (!needsCheck(cache) && cache) {
109
+ latestVersion = cache.latestVersion;
110
+ hasUpdate = cache.hasUpdate;
111
+ } else {
112
+ const info = await checkForUpdate(pkg.version);
113
+ latestVersion = info.latestVersion;
114
+ hasUpdate = info.hasUpdate;
115
+
116
+ const newCache: UpdateCache = {
117
+ lastCheckAt: new Date().toISOString(),
118
+ latestVersion,
119
+ hasUpdate,
120
+ changelog: info.changelog,
121
+ };
122
+ await saveUpdateCache(newCache);
123
+ }
124
+
125
+ if (!hasUpdate) {
126
+ spinner?.stop();
127
+ if (json) {
128
+ console.log(
129
+ JSON.stringify({
130
+ upgraded: false,
131
+ currentVersion: pkg.version,
132
+ message: "Already up to date",
133
+ }),
134
+ );
135
+ } else {
136
+ console.log(green(`mcpx is already up to date (v${pkg.version})`));
137
+ }
138
+ return;
139
+ }
140
+
141
+ const method: InstallMethod = detectInstallMethod();
142
+ spinner?.update({
143
+ text: `Upgrading from v${pkg.version} to v${latestVersion} (${method})...`,
144
+ });
145
+
146
+ let success = false;
147
+
148
+ switch (method) {
149
+ case "bun":
150
+ spinner?.stop();
151
+ success = await upgradeWithPackageManager("bun", [
152
+ "install",
153
+ "-g",
154
+ `@evantahler/mcpx@${latestVersion}`,
155
+ ]);
156
+ break;
157
+
158
+ case "npm":
159
+ spinner?.stop();
160
+ success = await upgradeWithPackageManager("npm", [
161
+ "install",
162
+ "-g",
163
+ `@evantahler/mcpx@${latestVersion}`,
164
+ ]);
165
+ break;
166
+
167
+ case "binary":
168
+ spinner?.stop();
169
+ success = await upgradeFromBinary(latestVersion);
170
+ break;
171
+
172
+ case "local-dev":
173
+ spinner?.stop();
174
+ if (json) {
175
+ console.log(
176
+ JSON.stringify({
177
+ upgraded: false,
178
+ currentVersion: pkg.version,
179
+ latestVersion,
180
+ installMethod: "local-dev",
181
+ message: "Running from source. Use `git pull && bun install` to update.",
182
+ }),
183
+ );
184
+ } else {
185
+ console.log(yellow("Running from source. Use `git pull && bun install` to update."));
186
+ }
187
+ return;
188
+ }
189
+
190
+ if (success) {
191
+ await clearUpdateCache();
192
+ if (json) {
193
+ console.log(
194
+ JSON.stringify({
195
+ upgraded: true,
196
+ previousVersion: pkg.version,
197
+ newVersion: latestVersion,
198
+ installMethod: method,
199
+ }),
200
+ );
201
+ } else {
202
+ console.log(green(`Successfully upgraded mcpx: v${pkg.version} → v${latestVersion}`));
203
+ }
204
+ } else {
205
+ if (json) {
206
+ console.log(
207
+ JSON.stringify({
208
+ upgraded: false,
209
+ currentVersion: pkg.version,
210
+ latestVersion,
211
+ installMethod: method,
212
+ message: "Upgrade failed",
213
+ }),
214
+ );
215
+ } else {
216
+ console.error(red("Upgrade failed. See errors above."));
217
+ }
218
+ process.exit(1);
219
+ }
220
+ } catch (err) {
221
+ spinner?.error({ text: "Upgrade failed" });
222
+ console.error(String(err));
223
+ process.exit(1);
224
+ }
225
+ });
226
+ }
@@ -1,7 +1,6 @@
1
1
  import { join, resolve } from "path";
2
- import { homedir } from "os";
3
2
  import { interpolateEnv } from "./env.ts";
4
- import { ENV } from "../constants.ts";
3
+ import { DEFAULT_CONFIG_DIR, ENV } from "../constants.ts";
5
4
  import {
6
5
  type Config,
7
6
  type ServersFile,
@@ -12,8 +11,6 @@ import {
12
11
  validateSearchIndex,
13
12
  } from "./schemas.ts";
14
13
 
15
- const DEFAULT_CONFIG_DIR = join(homedir(), ".mcpx");
16
-
17
14
  const EMPTY_SERVERS: ServersFile = { mcpServers: {} };
18
15
  const EMPTY_AUTH: AuthFile = {};
19
16
  const EMPTY_SEARCH_INDEX: SearchIndex = {
package/src/constants.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+
4
+ /** Default config directory (~/.mcpx) */
5
+ export const DEFAULT_CONFIG_DIR = join(homedir(), ".mcpx");
6
+
1
7
  /** Environment variable names used by mcpx */
2
8
  export const ENV = {
3
9
  DEBUG: "MCP_DEBUG",
@@ -6,6 +12,7 @@ export const ENV = {
6
12
  MAX_RETRIES: "MCP_MAX_RETRIES",
7
13
  STRICT_ENV: "MCP_STRICT_ENV",
8
14
  CONFIG_PATH: "MCP_CONFIG_PATH",
15
+ NO_UPDATE_CHECK: "MCPX_NO_UPDATE_CHECK",
9
16
  } as const;
10
17
 
11
18
  /** Default values for configurable options */
@@ -16,4 +23,6 @@ export const DEFAULTS = {
16
23
  TASK_TTL_MS: 60_000,
17
24
  SEARCH_TOP_K: 10,
18
25
  LOG_LEVEL: "warning",
26
+ UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000,
27
+ UPDATE_CHECK_TIMEOUT_MS: 5_000,
19
28
  } as const;
@@ -0,0 +1,76 @@
1
+ import { yellow, cyan, dim } from "ansis";
2
+ import pkg from "../../package.json";
3
+ import { ENV, DEFAULTS } from "../constants.ts";
4
+ import { checkForUpdate, needsCheck, type UpdateCache } from "./checker.ts";
5
+ import { loadUpdateCache, saveUpdateCache } from "./cache.ts";
6
+
7
+ /** Format an update notice for stderr output. */
8
+ function formatNotice(currentVersion: string, latestVersion: string, changelog?: string): string {
9
+ const lines: string[] = ["", yellow(`Update available: ${currentVersion} → ${latestVersion}`)];
10
+
11
+ if (changelog) {
12
+ lines.push("");
13
+ lines.push(dim(changelog));
14
+ }
15
+
16
+ lines.push("");
17
+ lines.push(cyan(`Run \`mcpx upgrade\` to update`));
18
+ lines.push("");
19
+
20
+ return lines.join("\n");
21
+ }
22
+
23
+ /**
24
+ * Non-blocking background update check. Returns a formatted notice string
25
+ * if an update is available, or null otherwise. Never throws.
26
+ */
27
+ export async function maybeCheckForUpdate(): Promise<string | null> {
28
+ try {
29
+ // Opt-out via env var
30
+ if (process.env[ENV.NO_UPDATE_CHECK] === "1") return null;
31
+
32
+ // Skip if this is the check-update or upgrade command
33
+ const args = process.argv.slice(2);
34
+ const command = args.find((a) => !a.startsWith("-"));
35
+ if (command === "check-update" || command === "upgrade") return null;
36
+
37
+ // Only show in TTY
38
+ if (!(process.stderr.isTTY ?? false)) return null;
39
+
40
+ const cache = await loadUpdateCache();
41
+
42
+ if (!needsCheck(cache)) {
43
+ // Cache is fresh — use cached result
44
+ if (cache?.hasUpdate) {
45
+ return formatNotice(pkg.version, cache.latestVersion, cache.changelog);
46
+ }
47
+ return null;
48
+ }
49
+
50
+ // Cache is stale or missing — check with timeout
51
+ const controller = new AbortController();
52
+ const timeout = setTimeout(() => controller.abort(), DEFAULTS.UPDATE_CHECK_TIMEOUT_MS);
53
+
54
+ try {
55
+ const info = await checkForUpdate(pkg.version, controller.signal);
56
+
57
+ const newCache: UpdateCache = {
58
+ lastCheckAt: new Date().toISOString(),
59
+ latestVersion: info.latestVersion,
60
+ hasUpdate: info.hasUpdate,
61
+ changelog: info.changelog,
62
+ };
63
+ await saveUpdateCache(newCache);
64
+
65
+ if (info.hasUpdate) {
66
+ return formatNotice(pkg.version, info.latestVersion, info.changelog);
67
+ }
68
+ } finally {
69
+ clearTimeout(timeout);
70
+ }
71
+
72
+ return null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,37 @@
1
+ import { join } from "path";
2
+ import { DEFAULT_CONFIG_DIR } from "../constants.ts";
3
+ import type { UpdateCache } from "./checker.ts";
4
+
5
+ const UPDATE_CACHE_PATH = join(DEFAULT_CONFIG_DIR, "update.json");
6
+
7
+ /** Load the cached update check result, if it exists. */
8
+ export async function loadUpdateCache(): Promise<UpdateCache | undefined> {
9
+ try {
10
+ const file = Bun.file(UPDATE_CACHE_PATH);
11
+ if (!(await file.exists())) return undefined;
12
+ return JSON.parse(await file.text()) as UpdateCache;
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ /** Save update check result to the cache file. */
19
+ export async function saveUpdateCache(cache: UpdateCache): Promise<void> {
20
+ try {
21
+ await Bun.write(UPDATE_CACHE_PATH, JSON.stringify(cache, null, 2) + "\n");
22
+ } catch {
23
+ // Ignore write failures (e.g. permissions)
24
+ }
25
+ }
26
+
27
+ /** Remove the cached update check result. */
28
+ export async function clearUpdateCache(): Promise<void> {
29
+ try {
30
+ const file = Bun.file(UPDATE_CACHE_PATH);
31
+ if (await file.exists()) {
32
+ await Bun.write(UPDATE_CACHE_PATH, "");
33
+ }
34
+ } catch {
35
+ // Ignore
36
+ }
37
+ }
@@ -0,0 +1,122 @@
1
+ import pkg from "../../package.json";
2
+ import { DEFAULTS } from "../constants.ts";
3
+
4
+ const NPM_REGISTRY_URL = `https://registry.npmjs.org/${pkg.name}/latest`;
5
+ const GITHUB_REPO = pkg.repository.url
6
+ .replace(/^https:\/\/github\.com\//, "")
7
+ .replace(/\.git$/, "");
8
+
9
+ export interface UpdateInfo {
10
+ currentVersion: string;
11
+ latestVersion: string;
12
+ hasUpdate: boolean;
13
+ aheadOfLatest: boolean;
14
+ changelog?: string;
15
+ }
16
+
17
+ export interface UpdateCache {
18
+ lastCheckAt: string;
19
+ latestVersion: string;
20
+ hasUpdate: boolean;
21
+ changelog?: string;
22
+ }
23
+
24
+ export type InstallMethod = "npm" | "bun" | "binary" | "local-dev";
25
+
26
+ /** Compare two semver strings. Returns true if latest > current. */
27
+ export function isNewerVersion(current: string, latest: string): boolean {
28
+ return Bun.semver.order(current, latest) === -1;
29
+ }
30
+
31
+ /** Fetch the latest version from the npm registry. */
32
+ export async function fetchLatestVersion(signal?: AbortSignal): Promise<string> {
33
+ try {
34
+ const res = await fetch(NPM_REGISTRY_URL, { signal });
35
+ if (!res.ok) return pkg.version;
36
+ const data = (await res.json()) as { version: string };
37
+ return data.version;
38
+ } catch {
39
+ return pkg.version;
40
+ }
41
+ }
42
+
43
+ /** Fetch changelog from GitHub releases between two versions. */
44
+ export async function fetchChangelog(
45
+ fromVersion: string,
46
+ toVersion: string,
47
+ signal?: AbortSignal,
48
+ ): Promise<string | undefined> {
49
+ try {
50
+ const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=20`, {
51
+ signal,
52
+ headers: { Accept: "application/vnd.github.v3+json" },
53
+ });
54
+ if (!res.ok) return undefined;
55
+
56
+ const releases = (await res.json()) as Array<{
57
+ tag_name: string;
58
+ body: string | null;
59
+ }>;
60
+
61
+ const relevant = releases.filter((r) => {
62
+ const v = r.tag_name.replace(/^v/, "");
63
+ return isNewerVersion(fromVersion, v) && !isNewerVersion(toVersion, v);
64
+ });
65
+
66
+ if (relevant.length === 0) return undefined;
67
+
68
+ return relevant
69
+ .map((r) => `## ${r.tag_name}\n${r.body ?? ""}`)
70
+ .join("\n\n")
71
+ .trim();
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /** Check npm for a newer version and fetch changelog if available. */
78
+ export async function checkForUpdate(
79
+ currentVersion: string,
80
+ signal?: AbortSignal,
81
+ ): Promise<UpdateInfo> {
82
+ const latestVersion = await fetchLatestVersion(signal);
83
+ const hasUpdate = isNewerVersion(currentVersion, latestVersion);
84
+ const aheadOfLatest = isNewerVersion(latestVersion, currentVersion);
85
+
86
+ let changelog: string | undefined;
87
+ if (hasUpdate) {
88
+ changelog = await fetchChangelog(currentVersion, latestVersion, signal);
89
+ }
90
+
91
+ return { currentVersion, latestVersion, hasUpdate, aheadOfLatest, changelog };
92
+ }
93
+
94
+ /** Returns true if the cache is missing or older than 24 hours. */
95
+ export function needsCheck(cache?: UpdateCache): boolean {
96
+ if (!cache?.lastCheckAt) return true;
97
+ return Date.now() - new Date(cache.lastCheckAt).getTime() > DEFAULTS.UPDATE_CHECK_INTERVAL_MS;
98
+ }
99
+
100
+ /** Detect how mcpx was installed. */
101
+ export function detectInstallMethod(): InstallMethod {
102
+ const script = process.argv[1] ?? "";
103
+ const execPath = process.execPath;
104
+
105
+ // Local dev: running src/cli.ts directly outside node_modules
106
+ if (script.includes("src/cli.ts") && !script.includes("node_modules")) {
107
+ return "local-dev";
108
+ }
109
+
110
+ // Compiled binary: execPath is the binary itself (not bun/node)
111
+ if (!execPath.includes("bun") && !execPath.includes("node")) {
112
+ return "binary";
113
+ }
114
+
115
+ // Bun global install: path contains .bun/install
116
+ if (script.includes(".bun/install") || script.includes(".bun/bin")) {
117
+ return "bun";
118
+ }
119
+
120
+ // npm global install: fallback for node_modules paths
121
+ return "npm";
122
+ }