diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e150972d1..9cbf7be9f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `omp update` aborting with `npm error EEXIST` on standalone binary installs whose directory coincides with the global npm/bun bin dir (for example `npm prefix -g` set to `~/.local`, which the installer also targets). The install-target resolver classified the binary as npm/bun-managed from directory containment alone, so `npm install -g` tried to replace a regular file its symlink step would clobber; it now treats a plain executable (not a symlink) in a package-manager bin dir as the standalone binary and self-updates it in place ([#6527](https://github.com/can1357/oh-my-pi/pull/6527) by [@am423](https://github.com/am423)). + ## [17.1.2] - 2026-07-24 ### Added diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 59cfdfbf2..82796901e 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -209,6 +209,13 @@ interface UpdateMethodResolutionOptions { miseBinDirs?: readonly string[]; miseDataDir?: string; npmBinDir?: string; + /** + * Whether the resolved omp path is a plain file (the standalone binary) + * rather than a package-manager symlink. Stops a binary install from being + * misrouted to npm/bun when the global bin dir overlaps the installer's + * target directory. + */ + ompIsRegularFile?: boolean; } type UpdateTarget = @@ -223,15 +230,22 @@ function resolveUpdateMethod( bunBinDir: string | undefined, options: UpdateMethodResolutionOptions = {}, ): UpdateMethod { - const { homebrewPrefix, miseBinDirs = [], miseDataDir, npmBinDir } = options; + const { homebrewPrefix, miseBinDirs = [], miseDataDir, npmBinDir, ompIsRegularFile = false } = options; const launcherExtension = path.extname(ompPath).toLowerCase(); const isWindowsScriptLauncher = launcherExtension === ".cmd" || launcherExtension === ".ps1" || launcherExtension === ".bat"; if (homebrewPrefix && isPathInDirectory(ompPath, path.join(homebrewPrefix, "bin"))) return "brew"; if (miseBinDirs.some(dir => isPathInDirectory(ompPath, dir))) return "mise"; if (miseDataDir && isPathInDirectory(ompPath, path.join(miseDataDir, "shims"))) return "mise"; - if (bunBinDir && isPathInDirectory(ompPath, bunBinDir)) return "bun"; - if ((npmBinDir && isPathInDirectory(ompPath, npmBinDir)) || isWindowsScriptLauncher) return "npm"; + // A plain executable file in a package-manager bin dir is the standalone + // binary the installer placed there, not an npm/bun-managed install (those + // symlink into node_modules). When the global bin dir overlaps the + // installer's default (~/.local/bin), classifying by directory alone routes + // a binary install through npm/bun, whose reinstall then collides with the + // existing file (npm EEXIST). Fall through to binary replacement instead. + if (bunBinDir && isPathInDirectory(ompPath, bunBinDir) && !ompIsRegularFile) return "bun"; + if ((npmBinDir && isPathInDirectory(ompPath, npmBinDir) && !ompIsRegularFile) || isWindowsScriptLauncher) + return "npm"; return "binary"; } @@ -252,7 +266,22 @@ async function resolveUpdateTarget(): Promise { const ompPath = resolveOmpPath(); if (ompPath) { - const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir, npmBinDir }); + // Package-manager installs symlink the bin entry into node_modules; the + // standalone installer writes a plain executable. When the global bin dir + // overlaps the installer's default (~/.local/bin), that file type — not + // directory containment — distinguishes a binary install from npm/bun. + let ompIsRegularFile = false; + try { + const stat = fs.lstatSync(ompPath); + ompIsRegularFile = stat.isFile() && !stat.isSymbolicLink(); + } catch {} + const method = resolveUpdateMethod(ompPath, bunBinDir, { + homebrewPrefix, + miseBinDirs, + miseDataDir, + npmBinDir, + ompIsRegularFile, + }); if (method === "binary") return { method, path: ompPath }; return { method }; } diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index 99f93c299..7d25ad0ac 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -90,6 +90,36 @@ describe("update-cli install target detection", () => { expect(method).toBe("npm"); }); + it("uses binary update when a plain file in the npm global bin dir is the standalone binary, not an npm symlink", () => { + // Regression: with `npm prefix -g` pointed at the installer's default + // (~/.local), directory containment alone misclassified the standalone + // binary as npm-managed, so `npm install -g` failed with EEXIST refusing + // to overwrite the existing executable. + const method = resolveUpdateMethodForTest("/home/u/.local/bin/omp", undefined, { + npmBinDir: "/home/u/.local/bin", + ompIsRegularFile: true, + }); + + expect(method).toBe("binary"); + }); + + it("uses binary update when a plain file in the bun global bin dir is the standalone binary", () => { + const method = resolveUpdateMethodForTest("/home/u/.local/bin/omp", "/home/u/.local/bin", { + ompIsRegularFile: true, + }); + + expect(method).toBe("binary"); + }); + + it("still uses npm update when the npm global bin entry is a package-manager symlink, not a plain file", () => { + const method = resolveUpdateMethodForTest("/home/u/.local/bin/omp", undefined, { + npmBinDir: "/home/u/.local/bin", + ompIsRegularFile: false, + }); + + expect(method).toBe("npm"); + }); + it("uses binary update when prioritized omp is outside bun global bin", () => { const method = resolveUpdateMethodForTest("/Users/test/.local/bin/omp", "/Users/test/.bun/bin");