diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2e2ab9921..9a5eea106 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Added Homebrew and mise package-manager update paths to the self-update command so installations launched from those tools are updated through their native workflows +- Added detection of Homebrew and mise install locations so self-update chooses the manager-specific updater when the active `omp` binary comes from a package-manager-managed path - Added `astCondition` to TTSR rule frontmatter as a syntax-aware alternative to regex `condition`, enabling AST-based matching for edit/write tool snapshots - Added a built-in `ts-redundant-clear-guard` rule that flags redundant guards around `clearTimeout`, `clearInterval`, and `clearImmediate` calls - Added a built-in `ts-no-test-timers` rule that flags real timers (`Bun.sleep`, `setTimeout`, `setInterval`) in `*.test.ts` files, steering toward fake timers (`vi.useFakeTimers()` / `vi.advanceTimersByTime()`) @@ -12,6 +14,7 @@ ### Changed +- Changed forced self-updates for Homebrew installs to run `brew reinstall` and for mise installs to run `mise install --force` after `mise upgrade` when `--force` is requested - Changed TTSR rule bucketing and matching so rules with only `astCondition` are treated as TTSR rules and evaluated in the interrupt flow using reconstructed edit/write source snapshots - Normalized image content before it enters model context so attached images are downscaled and preprocessed for prompts, steering messages, follow-ups, and custom agent messages - Changed image marker format to include pixel dimensions when available (`[Image #N, WxH]`), falling back to bare `[Image #N]` when header cannot be decoded diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 3dddd8317..1d70e3255 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -2,9 +2,10 @@ * Update CLI command handler. * * Handles `omp update` to check for and install updates. - * Uses bun if available, otherwise downloads binary from GitHub releases. + * Uses the installer that owns the active omp executable when it can be detected. */ import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { pipeline } from "node:stream/promises"; import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; @@ -14,6 +15,8 @@ import { theme } from "../modes/theme/theme"; const REPO = "can1357/oh-my-pi"; const PACKAGE = "@oh-my-pi/pi-coding-agent"; +const HOMEBREW_FORMULA = "can1357/tap/omp"; +const MISE_TOOL = "github:can1357/oh-my-pi"; /** * Official npm registry origin. * @@ -102,6 +105,46 @@ async function getBunGlobalBinDir(): Promise { } } +async function getHomebrewFormulaPrefix(): Promise { + if (!$which("brew")) return undefined; + for (const formula of [HOMEBREW_FORMULA, APP_NAME]) { + try { + const result = await $`brew --prefix ${formula}`.quiet().nothrow(); + if (result.exitCode !== 0) continue; + const output = result.text().trim(); + if (output.length > 0) return output; + } catch {} + } + return undefined; +} + +async function getMiseBinDirs(): Promise { + if (!$which("mise")) return []; + try { + const result = await $`mise bin-paths ${MISE_TOOL}`.quiet().nothrow(); + if (result.exitCode !== 0) return []; + return result + .text() + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0); + } catch { + return []; + } +} + +function getMiseDataDir(): string { + const override = process.env.MISE_DATA_DIR; + if (override && override.length > 0) return override; + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA; + if (localAppData && localAppData.length > 0) return path.join(localAppData, "mise"); + } + const xdgDataHome = process.env.XDG_DATA_HOME; + if (xdgDataHome && xdgDataHome.length > 0) return path.join(xdgDataHome, "mise"); + return path.join(os.homedir(), ".local", "share", "mise"); +} + function normalizePathForComparison(filePath: string): string { const normalized = path.normalize(filePath); if (process.platform === "win32") return normalized.toLowerCase(); @@ -129,34 +172,61 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean { // is a junction when Bun is installed via Scoop, so `bun pm bin -g` and the // PATH-resolved omp path can refer to the same directory through different // strings. path.resolve does not traverse junctions/symlinks; realpath does. - // Resolve the file's parent directory to tolerate the file itself not yet - // existing (e.g. a fresh install path) while still catching link-traversed - // equality once the directory exists. - const fileDir = tryRealpath(path.dirname(path.resolve(filePath))); + // Resolve both the file and its parent directory: the file catches manager + // links like Homebrew's `bin/omp -> Cellar/.../bin/omp`; the parent fallback + // still tolerates fresh install paths where the file does not exist yet. const dirReal = tryRealpath(path.resolve(directoryPath)); - if (!fileDir || !dirReal) return false; + if (!dirReal) return false; + const fileReal = tryRealpath(path.resolve(filePath)); + if (fileReal && isPathInDirectoryLexical(fileReal, dirReal)) return true; + const fileDir = tryRealpath(path.dirname(path.resolve(filePath))); + if (!fileDir) return false; const resolvedFile = path.join(fileDir, path.basename(filePath)); return isPathInDirectoryLexical(resolvedFile, dirReal); } -type UpdateTarget = { method: "bun" } | { method: "binary"; path: string }; +type UpdateMethod = "brew" | "mise" | "bun" | "binary"; -function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { - if (!bunBinDir) return "binary"; - return isPathInDirectory(ompPath, bunBinDir) ? "bun" : "binary"; +interface UpdateMethodResolutionOptions { + homebrewPrefix?: string; + miseBinDirs?: readonly string[]; + miseDataDir?: string; } -export function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { - return resolveUpdateMethod(ompPath, bunBinDir); +type UpdateTarget = { method: "brew" } | { method: "mise" } | { method: "bun" } | { method: "binary"; path: string }; + +function resolveUpdateMethod( + ompPath: string, + bunBinDir: string | undefined, + options: UpdateMethodResolutionOptions = {}, +): UpdateMethod { + const { homebrewPrefix, miseBinDirs = [], miseDataDir } = options; + 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"; + return "binary"; +} + +export function resolveUpdateMethodForTest( + ompPath: string, + bunBinDir: string | undefined, + options: UpdateMethodResolutionOptions = {}, +): UpdateMethod { + return resolveUpdateMethod(ompPath, bunBinDir, options); } async function resolveUpdateTarget(): Promise { const bunBinDir = await getBunGlobalBinDir(); + const homebrewPrefix = await getHomebrewFormulaPrefix(); + const miseAvailable = $which("mise") !== undefined; + const miseBinDirs = miseAvailable ? await getMiseBinDirs() : []; + const miseDataDir = miseAvailable ? getMiseDataDir() : undefined; const ompPath = resolveOmpPath(); if (ompPath) { - const method = resolveUpdateMethod(ompPath, bunBinDir); - if (method === "bun") return { method }; - return { method, path: ompPath }; + const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir }); + if (method === "binary") return { method, path: ompPath }; + return { method }; } if (bunBinDir) return { method: "bun" }; @@ -376,6 +446,18 @@ export function buildBunInstallArgs(expectedVersion: string, nativeTag: string = return args; } +export function buildHomebrewUpdateArgs(force: boolean): string[] { + return [force ? "reinstall" : "upgrade", HOMEBREW_FORMULA]; +} + +export function buildMiseUpgradeArgs(): string[] { + return ["upgrade", MISE_TOOL, "--bump"]; +} + +export function buildMiseForceInstallArgs(expectedVersion: string): string[] { + return ["install", "--force", `${MISE_TOOL}@${expectedVersion}`]; +} + /** * Update via bun package manager. */ @@ -390,6 +472,42 @@ async function updateViaBun(expectedVersion: string): Promise { await printVerification(expectedVersion); } +async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise { + console.log(chalk.dim("Updating Homebrew formulae...")); + const update = await $`brew update`.nothrow(); + if (update.exitCode !== 0) { + throw new Error(`brew update failed with exit code ${update.exitCode}`); + } + + console.log(chalk.dim("Updating via Homebrew...")); + const args = buildHomebrewUpdateArgs(force); + const result = await $`brew ${args}`.nothrow(); + if (result.exitCode !== 0) { + throw new Error(`brew ${args[0]} failed with exit code ${result.exitCode}`); + } + + await printVerification(expectedVersion); +} + +async function updateViaMise(expectedVersion: string, force: boolean): Promise { + console.log(chalk.dim("Updating via mise...")); + const args = buildMiseUpgradeArgs(); + const result = await $`mise ${args}`.nothrow(); + if (result.exitCode !== 0) { + throw new Error(`mise upgrade failed with exit code ${result.exitCode}`); + } + + if (force) { + const forceArgs = buildMiseForceInstallArgs(expectedVersion); + const forceResult = await $`mise ${forceArgs}`.nothrow(); + if (forceResult.exitCode !== 0) { + throw new Error(`mise install --force failed with exit code ${forceResult.exitCode}`); + } + } + + await printVerification(expectedVersion); +} + /** * Download a release binary to a target path, replacing an existing file. */ @@ -457,7 +575,11 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean }) // Choose update method based on the prioritized omp binary in PATH try { const target = await resolveUpdateTarget(); - if (target.method === "bun") { + if (target.method === "brew") { + await updateViaHomebrew(release.version, opts.force); + } else if (target.method === "mise") { + await updateViaMise(release.version, opts.force); + } else if (target.method === "bun") { await updateViaBun(release.version); } else { await updateViaBinaryAt(target.path, release.version); diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index cbc127aa5..65ab8a95f 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -2,7 +2,14 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { buildBunInstallArgs, replaceBinaryForUpdate, resolveUpdateMethodForTest } from "../src/cli/update-cli"; +import { + buildBunInstallArgs, + buildHomebrewUpdateArgs, + buildMiseForceInstallArgs, + buildMiseUpgradeArgs, + replaceBinaryForUpdate, + resolveUpdateMethodForTest, +} from "../src/cli/update-cli"; const tempDirs: string[] = []; @@ -33,6 +40,54 @@ describe("update-cli install target detection", () => { expect(method).toBe("binary"); }); + + it("uses Homebrew update when prioritized omp resolves into the Homebrew formula", async () => { + const dir = await makeTempDir(); + const prefix = path.join(dir, "opt", "omp"); + const linkedBin = path.join(dir, "bin"); + await fs.mkdir(path.join(prefix, "bin"), { recursive: true }); + await fs.mkdir(linkedBin, { recursive: true }); + await Bun.write(path.join(prefix, "bin", "omp"), "binary"); + await fs.symlink(path.join(prefix, "bin", "omp"), path.join(linkedBin, "omp")); + + const method = resolveUpdateMethodForTest(path.join(linkedBin, "omp"), "/Users/test/.bun/bin", { + homebrewPrefix: prefix, + }); + + expect(method).toBe("brew"); + }); + + it("uses mise update when prioritized omp is in an active mise bin path", () => { + const method = resolveUpdateMethodForTest( + "/Users/test/.local/share/mise/installs/github-can1357-oh-my-pi/latest/bin/omp", + undefined, + { + miseBinDirs: ["/Users/test/.local/share/mise/installs/github-can1357-oh-my-pi/latest/bin"], + }, + ); + + expect(method).toBe("mise"); + }); + + it("uses mise update when prioritized omp is a mise shim", () => { + const method = resolveUpdateMethodForTest("/Users/test/.local/share/mise/shims/omp", undefined, { + miseDataDir: "/Users/test/.local/share/mise", + }); + + expect(method).toBe("mise"); + }); +}); + +describe("update-cli package manager commands", () => { + it("targets the Homebrew tap formula and switches to reinstall for forced updates", () => { + expect(buildHomebrewUpdateArgs(false)).toEqual(["upgrade", "can1357/tap/omp"]); + expect(buildHomebrewUpdateArgs(true)).toEqual(["reinstall", "can1357/tap/omp"]); + }); + + it("targets the mise GitHub backend tool and force-reinstalls the checked version when requested", () => { + expect(buildMiseUpgradeArgs()).toEqual(["upgrade", "github:can1357/oh-my-pi", "--bump"]); + expect(buildMiseForceInstallArgs("15.10.5")).toEqual(["install", "--force", "github:can1357/oh-my-pi@15.10.5"]); + }); }); describe("update-cli bun install command", () => {