fix: detect brew install when ~/.soulforge/bin shadows PATH\n\nBrew's post-install copies the binary to ~/.soulforge/bin/, which\nshadows the brew symlink at $HOMEBREW_PREFIX/bin/soulforge in PATH.\nThis caused detectInstallMethod() to fall through to \"binary\".\n\nFix: directly check if $HOMEBREW_PREFIX/bin/soulforge exists as a\nsymlink — the one artifact only brew creates.

Co-Authored-By: SoulForge <[email protected]>
This commit is contained in:
PouiiroandSoulForge committed 2026-04-04 20:47:21 +02:00
1 parent 425692cf49
commit 3a0e093899
2 files changed
+77 -9

No files matched your search

+15 -9
View File
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -30,11 +30,12 @@ export function detectInstallMethod(): InstallMethod {
const execPath = process.argv[0] ?? "";
const moduleUrl = import.meta.url;
// Homebrew — brew wraps the binary via ~/.soulforge/bin/soulforge so
// argv[0] and import.meta.url are unreliable. Use multiple signals:
// 1. `which soulforge` resolves to the brew symlink (all versions)
// Homebrew — the brew formula installs a compiled binary to Cellar with
// a symlink at $HOMEBREW_PREFIX/bin/soulforge. But ~/.soulforge/bin/ may
// shadow it in PATH, so `which` and argv[0] are unreliable. Check:
// 1. `which soulforge` contains homebrew/Cellar (works when not shadowed)
// 2. argv[0]/execPath directly contains a Cellar/homebrew path
// 3. HOMEBREW_PREFIX is set AND binary lives in ~/.soulforge/bin (brew-specific install dir)
// 3. $HOMEBREW_PREFIX/bin/soulforge exists as a symlink (bypasses PATH)
try {
const which = execFileSync("which", ["soulforge"], { encoding: "utf8" }).trim();
if (which.includes("homebrew") || which.includes("Cellar")) return "brew";
@@ -42,10 +43,15 @@ export function detectInstallMethod(): InstallMethod {
if (execPath.includes("/Cellar/") || execPath.includes("/homebrew/")) return "brew";
// HOMEBREW_PREFIX is always set for brew users; combined with the binary
// living under ~/.soulforge/bin/ it uniquely identifies a brew install.
const homebrewPrefix = process.env.HOMEBREW_PREFIX ?? process.env.HOMEBREW_CELLAR ?? "";
if (homebrewPrefix && moduleUrl.includes("/.soulforge/bin/")) return "brew";
// ~/.soulforge/bin/ can shadow the brew symlink in PATH. Check directly
// whether brew owns a `soulforge` symlink — this works regardless of PATH.
const homebrewPrefix = process.env.HOMEBREW_PREFIX ?? "";
if (homebrewPrefix) {
try {
const brewBin = `${homebrewPrefix}/bin/soulforge`;
if (existsSync(brewBin) && lstatSync(brewBin).isSymbolicLink()) return "brew";
} catch {}
}
// Compiled binary (bun --compile)
if (moduleUrl.includes("$bunfs")) return "binary";
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "bun:test";
import {
detectInstallMethod,
getUpgradeCommand,
getUpgradeArgs,
type InstallMethod,
} from "../src/core/version.js";
describe("detectInstallMethod", () => {
const VALID_METHODS: InstallMethod[] = ["npm", "pnpm", "yarn", "bun", "brew", "binary", "unknown"];
it("returns a valid install method", () => {
const method = detectInstallMethod();
expect(VALID_METHODS).toContain(method);
});
it("is deterministic across calls", () => {
const a = detectInstallMethod();
const b = detectInstallMethod();
expect(a).toBe(b);
});
});
describe("getUpgradeCommand", () => {
it("returns brew command for brew method", () => {
const cmd = getUpgradeCommand("brew");
expect(cmd).toContain("brew");
expect(cmd).toContain("upgrade");
});
it("returns npm command for npm method", () => {
const cmd = getUpgradeCommand("npm");
expect(cmd).toContain("npm");
});
it("returns GitHub message for binary method", () => {
const cmd = getUpgradeCommand("binary");
expect(cmd).toContain("GitHub");
});
});
describe("getUpgradeArgs", () => {
it("returns spawn args for brew", () => {
const args = getUpgradeArgs("brew");
expect(args).not.toBeNull();
expect(args!.command).toBe("sh");
expect(args!.args.join(" ")).toContain("brew upgrade");
});
it("returns null for binary (cannot auto-upgrade)", () => {
const args = getUpgradeArgs("binary");
expect(args).toBeNull();
});
it("returns spawn args for all package managers", () => {
for (const method of ["npm", "pnpm", "yarn", "bun"] as InstallMethod[]) {
const args = getUpgradeArgs(method);
expect(args).not.toBeNull();
expect(args!.command).toBe(method);
}
});
});