From eaf1728883ab12874c9cb78810e67cc1c08db88b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:37:50 +0000 Subject: [PATCH 1/2] feat(cli): add hunk update self-update command Add an opencode-style self-update flow: detect how Hunk was installed (npm, Homebrew, Nix, mise, or a local source build) from env and executable path, query that channel's own registry for the latest version, and delegate the upgrade to the channel's package manager. Nix, mise, and dev installs get guidance instead of a spawned upgrade. The startup update notice now shares the same detector and points users at hunk update; Homebrew installs are compared against the brew formula version instead of npm dist-tags, so they are no longer notified about versions brew cannot install yet. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sf2y1jWD9fgx7aAKYbLQC6 --- .changeset/olive-donkeys-hammer.md | 5 + README.md | 2 + src/app/cli.ts | 70 ++++- src/app/startup.ts | 12 + src/core/process/installSource.test.ts | 181 ++++++++++++ src/core/process/installSource.ts | 211 ++++++++++++++ src/core/process/latestRelease.test.ts | 98 +++++++ src/core/process/latestRelease.ts | 134 +++++++++ src/core/process/selfUpdate.test.ts | 255 +++++++++++++++++ src/core/process/selfUpdate.ts | 270 ++++++++++++++++++ src/core/process/updateNotice.test.ts | 85 +++++- src/core/process/updateNotice.ts | 218 ++++---------- src/core/run/commandInputs.ts | 14 +- src/core/run/version.ts | 30 ++ src/main.tsx | 10 + test/cli/update.test.ts | 105 +++++++ .../src/content/docs/docs/reference/cli.md | 19 ++ .../src/content/docs/docs/start/install.md | 12 + 18 files changed, 1557 insertions(+), 174 deletions(-) create mode 100644 .changeset/olive-donkeys-hammer.md create mode 100644 src/core/process/installSource.test.ts create mode 100644 src/core/process/installSource.ts create mode 100644 src/core/process/latestRelease.test.ts create mode 100644 src/core/process/latestRelease.ts create mode 100644 src/core/process/selfUpdate.test.ts create mode 100644 src/core/process/selfUpdate.ts create mode 100644 test/cli/update.test.ts diff --git a/.changeset/olive-donkeys-hammer.md b/.changeset/olive-donkeys-hammer.md new file mode 100644 index 000000000..7364ee1fd --- /dev/null +++ b/.changeset/olive-donkeys-hammer.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add `hunk update` to self-update Hunk with the package manager that installed it (npm or Homebrew), with guidance for Nix, mise, and source installs. diff --git a/README.md b/README.md index bd8d83101..9f3cedab6 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Requirements: > Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise. +Later, `hunk update` installs the newest release with whichever package manager you used (`hunk update --check` just reports the versions). mise, Nix, and source installs print the command that updates them instead. + ## Quick start ```bash diff --git a/src/app/cli.ts b/src/app/cli.ts index 43b6a8252..6a3965537 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -10,9 +10,11 @@ import type { LayoutMode, PagerCommandInput, ParsedCliInput, + SelfUpdateCommandInput, SessionCommentListType, SessionCommentApplyItemInput, } from "../core/run/commandInputs"; +import { parseUpdateMethod, UPDATE_METHOD_VALUES } from "../core/process/selfUpdate"; import { BUNDLED_SKILL_NAMES, resolveBundledSkillName, @@ -234,6 +236,21 @@ export const CLI_REFERENCE_COMMANDS = { synopsis: ["hunk extension remove "], aliases: ["hunk ext remove"], }, + update: { + path: "update", + summary: "update Hunk with the package manager that installed it", + synopsis: ["hunk update [version]", "hunk update --check", "hunk update --method "], + options: [ + { + flag: "--method ", + description: `install method instead of the detected one: ${UPDATE_METHOD_VALUES.join(", ")}`, + }, + { + flag: "--check", + description: "report the installed and available versions without installing", + }, + ], + }, "daemon-serve": { path: "daemon serve", summary: "run the local Hunk session daemon and websocket session broker", @@ -452,6 +469,7 @@ function renderCliHelp() { " hunk markup guide print the experimental STML authoring guide", " hunk skill path [name] print a bundled Hunk skill path", " hunk extension install and manage shared extensions", + " hunk update [version] update Hunk with the package manager that installed it", " hunk daemon serve run the local Hunk session daemon", "", "Global options:", @@ -876,7 +894,8 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput { input.kind === "daemon-serve" || input.kind === "markup-render" || input.kind === "markup-guide" || - input.kind === "extension-manage" + input.kind === "extension-manage" || + input.kind === "update" ) { throw new Error( "Session reload requires a Hunk review command after --, such as `diff` or `show`.", @@ -1641,6 +1660,52 @@ async function parseExtensionCommand( throw new Error("Supported extension subcommands are install, list, update, and remove."); } +/** Parse `hunk update` as the standalone self-update command. */ +async function parseUpdateCommand( + tokens: string[], +): Promise { + const command = createCliReferenceCommand("update").argument( + "[version]", + "version to install; the newest release when omitted", + ); + + let parsedVersion: string | undefined; + let parsedOptions: { method?: string; check?: boolean } = {}; + + command.action((version: string | undefined, options: { method?: string; check?: boolean }) => { + parsedVersion = version; + parsedOptions = options; + }); + + if (tokens.includes("--help") || tokens.includes("-h")) { + return { + kind: "help", + text: + [ + command.helpInformation().trimEnd(), + "", + "Hunk updates itself only for installs it owns: npm (or bun/pnpm global installs) and", + "Homebrew. Nix, mise, and local source builds print the command that updates them.", + "", + "Examples:", + " hunk update", + " hunk update 1.2.3", + " hunk update --check", + " hunk update --method brew", + ].join("\n") + "\n", + }; + } + + await parseStandaloneCommand(command, tokens); + + return { + kind: "update", + version: parsedVersion, + method: parsedOptions.method ? parseUpdateMethod(parsedOptions.method) : undefined, + check: parsedOptions.check ?? false, + }; +} + /** Parse `hunk daemon serve` as the canonical local daemon entrypoint. */ async function parseDaemonCommand(tokens: string[]): Promise { const [subcommand, ...rest] = tokens; @@ -1736,6 +1801,7 @@ const TOP_LEVEL_COMMAND_NAMES = new Set([ "skill", "extension", "ext", + "update", "daemon", "mcp", ]); @@ -1808,6 +1874,8 @@ export async function parseCli(argv: string[]): Promise { case "extension": case "ext": return parseExtensionCommand(rest); + case "update": + return parseUpdateCommand(rest); case "daemon": case "mcp": return parseDaemonCommand(rest); diff --git a/src/app/startup.ts b/src/app/startup.ts index 3303bb45d..672a2f796 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -18,6 +18,7 @@ import type { ExtensionManageCommandInput, MarkupRenderCommandInput, ParsedCliInput, + SelfUpdateCommandInput, SessionCommandInput, } from "../core/run/commandInputs"; import { canReloadInput } from "../core/run/inputReload"; @@ -79,6 +80,10 @@ export type StartupPlan = kind: "extension-manage"; input: ExtensionManageCommandInput; } + | { + kind: "self-update"; + input: SelfUpdateCommandInput; + } | { kind: "app"; bootstrap: AppBootstrap; @@ -186,6 +191,13 @@ export async function prepareStartupPlan( }; } + if (parsedCliInput.kind === "update") { + return { + kind: "self-update", + input: parsedCliInput, + }; + } + if (parsedCliInput.kind === "pager") { const stdinText = await readStdinText(); const pagerOptions = parsedCliInput.options; diff --git a/src/core/process/installSource.test.ts b/src/core/process/installSource.test.ts new file mode 100644 index 000000000..f8a421f3d --- /dev/null +++ b/src/core/process/installSource.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { detectInstallSource, detectNpmClient, resolveDevInstallDir } from "./installSource"; + +const HOME_DIR = join("/", "home", "reviewer"); + +describe("install source detection", () => { + test("honors an explicitly declared install source", () => { + expect( + detectInstallSource({ + env: { HUNK_INSTALL_SOURCE: "nix" }, + executablePath: join("/", "usr", "local", "bin", "hunk"), + homeDir: HOME_DIR, + }), + ).toBe("nix"); + }); + + test("accepts dev as a declared install source", () => { + expect( + detectInstallSource({ + env: { HUNK_INSTALL_SOURCE: "dev" }, + executablePath: join("/", "opt", "hunk", "bin", "hunk"), + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("dev"); + }); + + test("ignores unknown declared install sources", () => { + expect( + detectInstallSource({ + env: { HUNK_INSTALL_SOURCE: "chocolatey" }, + executablePath: join("/", "opt", "hunk", "bin", "hunk"), + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("npm"); + }); + + test("detects nixpkgs installs from their store path", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: "/nix/store/hash-hunk/bin/hunk", + homeDir: HOME_DIR, + }), + ).toBe("nix"); + }); + + test("detects mise installs from their install directory", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: "/home/reviewer/.local/share/mise/installs/aqua-modem-dev-hunk/1.2.3/hunk", + homeDir: HOME_DIR, + }), + ).toBe("mise"); + }); + + test("detects Homebrew installs from a resolved Cellar path", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: "/usr/local/bin/hunk", + realpath: () => "/usr/local/Cellar/hunk/1.2.3/bin/hunk", + homeDir: HOME_DIR, + }), + ).toBe("homebrew"); + }); + + test("detects Homebrew installs under the Apple silicon and Linux prefixes", () => { + for (const executablePath of [ + "/opt/homebrew/bin/hunk", + "/home/linuxbrew/.linuxbrew/bin/hunk", + ]) { + expect(detectInstallSource({ env: {}, executablePath, homeDir: HOME_DIR })).toBe("homebrew"); + } + }); + + test("does not classify a Homebrew-installed Bun running Hunk from source as Homebrew", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: "/opt/homebrew/Cellar/bun/1.1.42/bin/bun", + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("npm"); + }); + + test("keeps npm for global npm packages under a Homebrew-installed Node", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: "/opt/homebrew/lib/node_modules/hunkdiff-darwin-arm64/bin/hunk", + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("npm"); + }); + + test("detects local source builds installed into the default install directory", () => { + // Built from this platform's own default so the check tracks `scripts/install-bin.ts`. + const installDir = resolveDevInstallDir({}, HOME_DIR); + expect(installDir).toBeDefined(); + expect( + detectInstallSource({ + env: {}, + executablePath: join(installDir!, "hunk"), + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("dev"); + }); + + test("detects local source builds inside an overridden install directory", () => { + const installDir = join(HOME_DIR, "tools", "bin"); + expect( + detectInstallSource({ + env: { HUNK_INSTALL_DIR: installDir }, + executablePath: join(installDir, "hunk"), + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("dev"); + }); + + test("detects local source builds from an untagged version", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: join("/", "opt", "hunk", "bin", "hunk"), + version: "0.0.0-unknown", + homeDir: HOME_DIR, + }), + ).toBe("dev"); + }); + + test("falls back to the npm package path", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: join(HOME_DIR, ".nvm", "versions", "node", "v22", "bin", "hunk"), + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("npm"); + }); + + test("keeps npm for paths that only mention mise outside an install directory", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: "/home/mise/projects/hunk/node_modules/.bin/hunk", + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("npm"); + }); + + test("resolves the install:bin target directory from the environment", () => { + expect(resolveDevInstallDir({ HUNK_INSTALL_DIR: "/srv/bin" }, HOME_DIR)).toBe("/srv/bin"); + expect(resolveDevInstallDir({}, undefined)).toBeUndefined(); + }); +}); + +describe("npm client detection", () => { + test("picks bun for bun global installs", () => { + expect(detectNpmClient(join(HOME_DIR, ".bun", "bin", "hunk"))).toBe("bun"); + }); + + test("picks pnpm for pnpm global installs", () => { + expect(detectNpmClient(join(HOME_DIR, ".local", "share", "pnpm", "hunk"))).toBe("pnpm"); + }); + + test("picks npm for everything else", () => { + expect( + detectNpmClient(join("/", "usr", "lib", "node_modules", "hunkdiff", "bin", "hunk")), + ).toBe("npm"); + }); +}); diff --git a/src/core/process/installSource.ts b/src/core/process/installSource.ts new file mode 100644 index 000000000..de830ce14 --- /dev/null +++ b/src/core/process/installSource.ts @@ -0,0 +1,211 @@ +import { realpathSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { UNKNOWN_CLI_VERSION } from "../run/version"; + +/** + * Resolves how this Hunk binary was installed, and which package-manager client owns it. + * + * Both the startup update notice and `hunk update` ask the same question — "who is allowed to + * replace this binary?" — so the answer is derived here once. Every input the detection reads + * (environment, executable path, installed version, home directory) is injectable so tests never + * depend on the machine they run on. + */ + +const INSTALL_SOURCE_ENV = "HUNK_INSTALL_SOURCE"; +const INSTALL_DIR_ENV = "HUNK_INSTALL_DIR"; + +/** Path segments Homebrew always puts above its binaries, on macOS and Linux alike. */ +const HOMEBREW_PATH_SEGMENTS = ["cellar", "homebrew", "linuxbrew"]; + +export type InstallSource = "npm" | "homebrew" | "nix" | "mise" | "dev"; + +/** Package-manager clients that can install the global `hunkdiff` npm package. */ +export type NpmClient = "npm" | "bun" | "pnpm"; + +const INSTALL_SOURCES: readonly InstallSource[] = ["npm", "homebrew", "nix", "mise", "dev"]; + +export interface InstallSourceFacts { + env?: NodeJS.ProcessEnv; + /** Executable path to classify; defaults to the running executable. */ + executablePath?: string; + /** Installed CLI version, used only to recognize untagged source builds. */ + version?: string; + /** Symlink resolution for the executable path; injected so tests stay off the filesystem. */ + realpath?: (path: string) => string; + /** Home directory used to locate the `install:bin` target; defaults to the environment's. */ + homeDir?: string; +} + +/** Split one filesystem path into segments, tolerating either platform's separator. */ +function splitPathSegments(candidatePath: string) { + return candidatePath + .split(win32.sep) + .flatMap((segment) => segment.split(posix.sep)) + .filter((segment) => segment.length > 0); +} + +/** Resolve one executable path through its symlinks, keeping the original when that fails. */ +function resolveRealExecutablePath(executablePath: string, realpath?: (path: string) => string) { + const resolve = realpath ?? ((path: string) => realpathSync.native(path)); + try { + return resolve(executablePath); + } catch { + // A path we cannot stat still classifies on its literal segments. + return executablePath; + } +} + +/** + * Return whether this executable lives inside a mise-managed install directory. + * + * mise lays every backend out as `/mise/installs///` on all + * platforms, so the adjacent `mise/installs` segments are the one signal that survives `mise x` + * (the omarchy wrapper's launch path, which sets none of mise's shell env vars) as well as shims + * and activated shells. + */ +function isMiseManagedExecutablePath(executablePath: string) { + const segments = splitPathSegments(executablePath); + return segments.some( + (segment, index) => segment === "mise" && segments[index + 1] === "installs", + ); +} + +/** Executable names the Homebrew formula installs, lowercased and without a Windows suffix. */ +const HOMEBREW_ARTIFACT_NAMES = ["hunk", "hunkdiff"]; + +/** + * Return whether this executable is the Homebrew-installed Hunk binary. + * + * Homebrew sets no environment variable of its own, so the real path is the only signal: formula + * binaries live under `/Cellar///bin`, reached through `/opt/homebrew`, + * `/usr/local`, or `/home/linuxbrew/.linuxbrew`. The prefix alone is not enough — `bun run` from a + * source checkout reports a Homebrew-installed Bun's own path, so the executable must also be named + * like the formula's artifact. A `node_modules` segment vetoes the match, because a + * Homebrew-installed Node keeps its global npm packages — Hunk among them — inside that same + * prefix, and those are npm's to replace, not brew's. + */ +function isHomebrewExecutablePath(executablePath: string) { + const segments = splitPathSegments(executablePath).map((segment) => segment.toLowerCase()); + if (segments.includes("node_modules")) { + return false; + } + + const executableName = segments.at(-1)?.replace(/\.exe$/, ""); + if (!executableName || !HOMEBREW_ARTIFACT_NAMES.includes(executableName)) { + return false; + } + + return segments.some((segment) => HOMEBREW_PATH_SEGMENTS.includes(segment)); +} + +/** + * Resolve the directory `bun run install:bin` copies local builds into. + * + * Mirrors `scripts/install-bin.ts`: an explicit `HUNK_INSTALL_DIR` wins, Windows installs land in + * the per-user Programs directory, and everything else uses `~/.local/bin`. + */ +export function resolveDevInstallDir(env: NodeJS.ProcessEnv, homeDir: string | undefined) { + const configured = env[INSTALL_DIR_ENV]; + if (configured) { + return configured; + } + + if (process.platform === "win32") { + const base = + env.LOCALAPPDATA ?? (homeDir ? win32.join(homeDir, "AppData", "Local") : undefined); + return base ? win32.join(base, "Programs", "hunk") : undefined; + } + + return homeDir ? posix.join(homeDir, ".local", "bin") : undefined; +} + +/** Return whether one path sits inside one directory, comparing the way the platform does. */ +function isInsideDirectory(candidatePath: string, directory: string | undefined) { + if (!directory) { + return false; + } + + const normalize = (value: string) => { + const segments = splitPathSegments(value); + return process.platform === "win32" + ? segments.map((segment) => segment.toLowerCase()) + : segments; + }; + + const directorySegments = normalize(directory); + if (directorySegments.length === 0) { + return false; + } + + const candidateSegments = normalize(candidatePath); + return directorySegments.every((segment, index) => candidateSegments[index] === segment); +} + +/** Read one explicitly declared install source, ignoring values Hunk does not know. */ +function readDeclaredInstallSource(env: NodeJS.ProcessEnv): InstallSource | undefined { + const declared = env[INSTALL_SOURCE_ENV]; + return INSTALL_SOURCES.find((source) => source === declared); +} + +/** + * Resolve which package manager installed this binary, defaulting to the npm package path. + * + * Ordering is strongest-signal first: an explicit declaration (the Nix wrapper sets one), then + * store and install-directory layouts that only one manager produces, and finally local source + * builds, which are recognized either by their install directory or by an untagged version. + */ +export function detectInstallSource(facts: InstallSourceFacts = {}): InstallSource { + const env = facts.env ?? process.env; + const declared = readDeclaredInstallSource(env); + if (declared) { + return declared; + } + + const executablePath = resolveRealExecutablePath( + facts.executablePath ?? process.execPath, + facts.realpath, + ); + + if (executablePath.startsWith("/nix/store/")) { + return "nix"; + } + + if (isMiseManagedExecutablePath(executablePath)) { + return "mise"; + } + + if (isHomebrewExecutablePath(executablePath)) { + return "homebrew"; + } + + const homeDir = facts.homeDir ?? env.HOME ?? env.USERPROFILE; + if (isInsideDirectory(executablePath, resolveDevInstallDir(env, homeDir))) { + return "dev"; + } + + if ((facts.version ?? "") === UNKNOWN_CLI_VERSION) { + return "dev"; + } + + return "npm"; +} + +/** + * Resolve which client should install the global npm package. + * + * Global installs are owned by whichever client wrote them: a `bun i -g` install lives under + * `.bun`, a pnpm install under a `pnpm` directory, and everything else is npm's. Reinstalling with + * the wrong client leaves the old binary first on `PATH`, so the executable's own path picks. + */ +export function detectNpmClient(executablePath = process.execPath): NpmClient { + const segments = splitPathSegments(executablePath).map((segment) => segment.toLowerCase()); + if (segments.includes(".bun")) { + return "bun"; + } + + if (segments.includes("pnpm")) { + return "pnpm"; + } + + return "npm"; +} diff --git a/src/core/process/latestRelease.test.ts b/src/core/process/latestRelease.test.ts new file mode 100644 index 000000000..16a52a4e8 --- /dev/null +++ b/src/core/process/latestRelease.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { fetchChannelVersions } from "./latestRelease"; + +/** Build one JSON response for an injected fetch. */ +function jsonResponse(payload: unknown, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("release channel lookups", () => { + test("reads npm dist-tags for npm installs", async () => { + const requested: string[] = []; + + await expect( + fetchChannelVersions("npm", { + fetchImpl: async (input) => { + requested.push(String(input)); + return jsonResponse({ latest: "1.2.3", beta: "1.3.0-beta.1" }); + }, + }), + ).resolves.toEqual({ latest: "1.2.3", beta: "1.3.0-beta.1" }); + expect(requested).toEqual(["https://registry.npmjs.org/-/package/hunkdiff/dist-tags"]); + }); + + test("reads the Homebrew formula's stable version for Homebrew installs", async () => { + const requested: string[] = []; + + await expect( + fetchChannelVersions("homebrew", { + fetchImpl: async (input) => { + requested.push(String(input)); + return jsonResponse({ versions: { stable: "1.2.0", head: "HEAD" } }); + }, + }), + ).resolves.toEqual({ latest: "1.2.0" }); + expect(requested).toEqual(["https://formulae.brew.sh/api/formula/hunk.json"]); + }); + + test("asks no registry for install sources Hunk cannot update", async () => { + for (const source of ["nix", "mise", "dev"] as const) { + await expect( + fetchChannelVersions(source, { + fetchImpl: async () => { + throw new Error(`should not fetch for ${source} installs`); + }, + }), + ).resolves.toEqual({}); + } + }); + + test("drops versions that are not normalized semver", async () => { + await expect( + fetchChannelVersions("npm", { + fetchImpl: async () => jsonResponse({ latest: "v1.2.3", beta: "1.3.0" }), + }), + ).resolves.toEqual({ latest: undefined, beta: undefined }); + }); + + test("returns nothing for failed and non-ok responses", async () => { + await expect( + fetchChannelVersions("homebrew", { + fetchImpl: async () => jsonResponse({ versions: { stable: "1.2.0" } }, 503), + }), + ).resolves.toEqual({ latest: undefined }); + + await expect( + fetchChannelVersions("npm", { + fetchImpl: async () => { + throw new Error("network down"); + }, + }), + ).resolves.toEqual({ latest: undefined, beta: undefined }); + }); + + test("aborts hung lookups after the timeout", async () => { + let aborted = false; + + await expect( + fetchChannelVersions("npm", { + fetchImpl: async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + aborted = true; + reject(new Error("aborted")); + }, + { once: true }, + ); + }), + fetchTimeoutMs: 10, + }), + ).resolves.toEqual({ latest: undefined, beta: undefined }); + expect(aborted).toBe(true); + }); +}); diff --git a/src/core/process/latestRelease.ts b/src/core/process/latestRelease.ts new file mode 100644 index 000000000..c02b2a35c --- /dev/null +++ b/src/core/process/latestRelease.ts @@ -0,0 +1,134 @@ +import type { InstallSource } from "./installSource"; +import { isPrereleaseVersion, isStableVersion } from "../run/version"; + +/** + * Fetches the versions each install channel publishes for Hunk. + * + * One lookup per channel, asked of the registry that channel actually installs from: npm reads the + * `hunkdiff` dist-tags, Homebrew reads its formula API. Channels Hunk cannot update through — Nix, + * mise, and local source builds — report nothing rather than borrowing another channel's numbers, + * which is what made Homebrew users see releases `brew` could not yet install. + */ + +const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/hunkdiff/dist-tags"; +const HOMEBREW_FORMULA_URL = "https://formulae.brew.sh/api/formula/hunk.json"; +const DEFAULT_RELEASE_FETCH_TIMEOUT_MS = 5_000; + +export type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export type UpdateChannel = "latest" | "beta"; + +/** Versions one install source currently publishes, after validation. */ +export interface ChannelVersions { + latest?: string; + beta?: string; +} + +export interface ReleaseLookupDeps { + fetchImpl?: FetchImpl; + fetchTimeoutMs?: number; +} + +/** Build one fetch timeout signal for a release lookup, if supported by the runtime. */ +function createFetchTimeoutSignal(timeoutMs: number) { + if (typeof AbortController === "undefined") { + return { signal: undefined, dispose: () => {} }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + return { + signal: controller.signal, + dispose: () => { + clearTimeout(timeout); + }, + }; +} + +/** Fetch and parse one JSON document, returning null for any failure or timeout. */ +async function fetchJson(url: string, deps: ReleaseLookupDeps): Promise { + const fetchImpl = deps.fetchImpl ?? fetch; + const { signal, dispose } = createFetchTimeoutSignal( + deps.fetchTimeoutMs ?? DEFAULT_RELEASE_FETCH_TIMEOUT_MS, + ); + + try { + const response = await fetchImpl(url, { signal }); + if (!response.ok) { + return null; + } + + return await response.json(); + } catch { + return null; + } finally { + dispose(); + } +} + +/** Read one string field from an unknown JSON record. */ +function readStringField(payload: unknown, key: string) { + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return undefined; + } + + const value = (payload as Record)[key]; + return typeof value === "string" ? value : undefined; +} + +/** Fetch the `latest` and `beta` dist-tags published for the `hunkdiff` npm package. */ +export async function fetchNpmChannelVersions( + deps: ReleaseLookupDeps = {}, +): Promise { + const payload = await fetchJson(NPM_DIST_TAGS_URL, deps); + const latest = readStringField(payload, "latest"); + const beta = readStringField(payload, "beta"); + + return { + latest: latest && isStableVersion(latest) ? latest : undefined, + beta: beta && isPrereleaseVersion(beta) ? beta : undefined, + }; +} + +/** + * Fetch the stable version of the `hunk` formula in homebrew-core. + * + * Homebrew has no prerelease channel, so a Homebrew install only ever hears about `latest`. + */ +export async function fetchHomebrewChannelVersions( + deps: ReleaseLookupDeps = {}, +): Promise { + const payload = await fetchJson(HOMEBREW_FORMULA_URL, deps); + const versions = + typeof payload === "object" && payload !== null + ? (payload as Record).versions + : undefined; + const stable = readStringField(versions, "stable"); + + return { latest: stable && isStableVersion(stable) ? stable : undefined }; +} + +/** Return whether an install source can be updated from a published release at all. */ +export function hasPublishedReleases(installSource: InstallSource) { + return installSource === "npm" || installSource === "homebrew"; +} + +/** Fetch the versions one install source publishes, asking that channel's own registry. */ +export async function fetchChannelVersions( + installSource: InstallSource, + deps: ReleaseLookupDeps = {}, +): Promise { + if (installSource === "homebrew") { + return fetchHomebrewChannelVersions(deps); + } + + if (installSource === "npm") { + return fetchNpmChannelVersions(deps); + } + + // Nix, mise, and source builds install from somewhere Hunk cannot query or act on. + return {}; +} diff --git a/src/core/process/selfUpdate.test.ts b/src/core/process/selfUpdate.test.ts new file mode 100644 index 000000000..5a563d7df --- /dev/null +++ b/src/core/process/selfUpdate.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import type { InstallSource } from "./installSource"; +import { + parseUpdateMethod, + runSelfUpdateCommand, + type SelfUpdateInput, + type SelfUpdateProcessResult, +} from "./selfUpdate"; + +/** Build one JSON response for an injected fetch. */ +function jsonResponse(payload: unknown) { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +interface UpdateRunOptions { + input?: Partial; + installSource: InstallSource; + installedVersion?: string; + executablePath?: string; + platform?: NodeJS.Platform; + latestVersion?: string; + commandResult?: SelfUpdateProcessResult; +} + +/** Run one `hunk update` invocation offline, capturing output and the spawned command. */ +async function runUpdate(options: UpdateRunOptions) { + const stdout: string[] = []; + const stderr: string[] = []; + const commands: string[][] = []; + + const exitCode = await runSelfUpdateCommand( + { check: false, ...options.input }, + { + stdout: (text) => stdout.push(text), + stderr: (text) => stderr.push(text), + env: {}, + executablePath: options.executablePath ?? join("/", "usr", "bin", "hunk"), + platform: options.platform ?? "linux", + resolveInstalledVersion: () => options.installedVersion ?? "1.0.0", + resolveInstallSource: () => options.installSource, + // One payload carrying both registry shapes, so a `--method` override still resolves. + fetchImpl: async () => + jsonResponse({ + latest: options.latestVersion ?? "1.1.0", + versions: { stable: options.latestVersion ?? "1.1.0" }, + }), + runCommand: async (command) => { + commands.push([...command]); + return options.commandResult ?? { exitCode: 0, stderr: "" }; + }, + }, + ); + + return { exitCode, stdout: stdout.join(""), stderr: stderr.join(""), commands }; +} + +describe("update method parsing", () => { + test("normalizes brew to the Homebrew install source", () => { + expect(parseUpdateMethod("brew")).toBe("homebrew"); + expect(parseUpdateMethod("Homebrew")).toBe("homebrew"); + expect(parseUpdateMethod("npm")).toBe("npm"); + }); + + test("names the supported methods for unknown values", () => { + expect(() => parseUpdateMethod("apt")).toThrow("Unknown update method: apt"); + }); +}); + +describe("hunk update", () => { + test("installs the newest npm release with the npm client", async () => { + const result = await runUpdate({ installSource: "npm", latestVersion: "1.1.0" }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([["npm", "install", "--global", "hunkdiff@1.1.0"]]); + expect(result.stdout).toContain("Updating hunk 1.0.0 -> 1.1.0"); + expect(result.stdout).toContain("Updated hunk to 1.1.0."); + }); + + test("names the npm .cmd shim explicitly on Windows", async () => { + const result = await runUpdate({ installSource: "npm", platform: "win32" }); + + expect(result.commands).toEqual([["npm.cmd", "install", "--global", "hunkdiff@1.1.0"]]); + }); + + test("uses bun for a bun global install", async () => { + const result = await runUpdate({ + installSource: "npm", + executablePath: join("/", "home", "reviewer", ".bun", "bin", "hunk"), + }); + + expect(result.commands).toEqual([["bun", "add", "--global", "hunkdiff@1.1.0"]]); + }); + + test("uses pnpm for a pnpm global install", async () => { + const result = await runUpdate({ + installSource: "npm", + executablePath: join("/", "home", "reviewer", ".local", "share", "pnpm", "hunk"), + }); + + expect(result.commands).toEqual([["pnpm", "add", "--global", "hunkdiff@1.1.0"]]); + }); + + test("installs an explicitly requested version, including a downgrade", async () => { + const result = await runUpdate({ + installSource: "npm", + installedVersion: "1.1.0", + input: { version: "0.9.0" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([["npm", "install", "--global", "hunkdiff@0.9.0"]]); + }); + + test("upgrades Homebrew installs from the formula version", async () => { + const result = await runUpdate({ installSource: "homebrew", latestVersion: "1.1.0" }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([["brew", "upgrade", "hunk"]]); + }); + + test("refuses to pin a version on Homebrew", async () => { + await expect( + runUpdate({ installSource: "homebrew", input: { version: "1.0.5" } }), + ).rejects.toThrow("Homebrew installs cannot select a specific Hunk version."); + }); + + test("does nothing when the installed version is already current", async () => { + const result = await runUpdate({ + installSource: "npm", + installedVersion: "1.1.0", + latestVersion: "1.1.0", + }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([]); + expect(result.stdout).toContain("hunk 1.1.0 is already up to date."); + }); + + test("does nothing when the installed version is newer than the release", async () => { + const result = await runUpdate({ + installSource: "npm", + installedVersion: "1.2.0", + latestVersion: "1.1.0", + }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([]); + }); + + test("reports versions without installing for --check", async () => { + const result = await runUpdate({ installSource: "npm", input: { check: true } }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([]); + expect(result.stdout).toContain("hunk 1.0.0 (installed with npm)"); + expect(result.stdout).toContain("latest 1.1.0"); + expect(result.stdout).toContain("An update is available."); + }); + + test("reports the channel's real latest release when --check is given a version", async () => { + const result = await runUpdate({ + installSource: "npm", + installedVersion: "1.0.0", + latestVersion: "1.1.0", + input: { check: true, version: "0.0.1" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([]); + expect(result.stdout).toContain("latest 1.1.0"); + expect(result.stdout).toContain("requested 0.0.1"); + expect(result.stdout).toContain("An update is available."); + }); + + test("surfaces the package manager's stderr and exit code on failure", async () => { + const result = await runUpdate({ + installSource: "npm", + commandResult: { exitCode: 7, stderr: "npm ERR! EACCES permission denied\n" }, + }); + + expect(result.exitCode).toBe(7); + expect(result.stderr).toContain("npm ERR! EACCES permission denied"); + expect(result.stderr).toContain("failed with exit code 7"); + }); + + test("fails clearly when the release lookup returns nothing", async () => { + await expect( + runSelfUpdateCommand( + { check: false }, + { + stdout: () => {}, + stderr: () => {}, + env: {}, + resolveInstalledVersion: () => "1.0.0", + resolveInstallSource: () => "npm", + fetchImpl: async () => { + throw new Error("network down"); + }, + runCommand: async () => ({ exitCode: 0, stderr: "" }), + }, + ), + ).rejects.toThrow("Could not read the latest Hunk version from the npm registry."); + }); + + test("honors an explicit --method over the detected install source", async () => { + const result = await runUpdate({ + installSource: "dev", + input: { method: "homebrew" }, + }); + + expect(result.commands).toEqual([["brew", "upgrade", "hunk"]]); + }); + + test("points Nix installs at their own configuration and spawns nothing", async () => { + const result = await runUpdate({ installSource: "nix" }); + + expect(result.exitCode).toBe(1); + expect(result.commands).toEqual([]); + expect(result.stdout).toContain("Hunk was installed with Nix."); + }); + + test("points mise installs at mise up", async () => { + const result = await runUpdate({ installSource: "mise" }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("Run `mise up hunk` to update it."); + }); + + test("points source builds at install:bin", async () => { + const result = await runUpdate({ installSource: "dev", installedVersion: "0.0.0-unknown" }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("hunk 0.0.0-unknown (installed with a local source build)"); + expect(result.stdout).toContain( + "Run `bun run install:bin` in your Hunk checkout to update it.", + ); + }); + + test("refuses an explicit version for installs Hunk does not manage", async () => { + await expect(runUpdate({ installSource: "mise", input: { version: "1.2.3" } })).rejects.toThrow( + "Hunk installed with mise cannot update to a specific version from here.", + ); + }); + + test("reports unmanaged installs successfully for --check", async () => { + const result = await runUpdate({ installSource: "mise", input: { check: true } }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Hunk was installed with mise."); + }); +}); diff --git a/src/core/process/selfUpdate.ts b/src/core/process/selfUpdate.ts new file mode 100644 index 000000000..fdd576716 --- /dev/null +++ b/src/core/process/selfUpdate.ts @@ -0,0 +1,270 @@ +import { HunkUserError } from "../run/errors"; +import { detectInstallSource, detectNpmClient, type InstallSource } from "./installSource"; +import { fetchChannelVersions, type FetchImpl } from "./latestRelease"; +import { isComparableVersion, isNewerVersion, resolveCliVersion } from "../run/version"; + +/** + * Runs `hunk update`: replaces this Hunk install with a published release, or explains who can. + * + * The install source decides everything. npm and Homebrew installs are replaced in place by + * spawning the package manager that owns them; Nix, mise, and local source builds are owned by + * something Hunk must not run behind the user's back, so those print the one command that does + * work and stop. Every input the command reads or writes — environment, executable path, network, + * child processes, output streams — arrives through `SelfUpdateIo` so tests drive it offline. + */ + +const NPM_PACKAGE_NAME = "hunkdiff"; +const HOMEBREW_FORMULA_NAME = "hunk"; + +/** Install methods `--method` accepts, keyed by the spelling users type. */ +const UPDATE_METHOD_ALIASES: Record = { + npm: "npm", + brew: "homebrew", + homebrew: "homebrew", +}; + +/** Accepted `--method` values, in the order the help and error messages list them. */ +export const UPDATE_METHOD_VALUES = ["npm", "brew"] as const; + +export interface SelfUpdateInput { + /** Version to install; the channel's newest release when omitted. */ + version?: string; + /** Install method override from `--method`, already normalized. */ + method?: InstallSource; + /** Report the installed and available versions without installing anything. */ + check: boolean; +} + +/** Outcome of one package-manager invocation. */ +export interface SelfUpdateProcessResult { + exitCode: number; + stderr: string; +} + +export interface SelfUpdateIo { + stdout: (text: string) => void; + stderr: (text: string) => void; + env?: NodeJS.ProcessEnv; + executablePath?: string; + /** Platform used to pick package-manager executable names; defaults to the running platform. */ + platform?: NodeJS.Platform; + resolveInstalledVersion?: () => string; + resolveInstallSource?: () => InstallSource; + fetchImpl?: FetchImpl; + fetchTimeoutMs?: number; + runCommand?: (command: readonly string[]) => Promise; +} + +/** Normalize one `--method` value, or explain which values exist. */ +export function parseUpdateMethod(value: string): InstallSource { + const method = UPDATE_METHOD_ALIASES[value.toLowerCase()]; + if (!method) { + throw new HunkUserError(`Unknown update method: ${value}`, [ + `Supported methods are ${UPDATE_METHOD_VALUES.map((name) => `\`${name}\``).join(" and ")}.`, + ]); + } + + return method; +} + +/** Name one install source the way the user would say it. */ +function describeInstallSource(installSource: InstallSource) { + if (installSource === "homebrew") { + return "Homebrew"; + } + + if (installSource === "nix") { + return "Nix"; + } + + if (installSource === "dev") { + return "a local source build"; + } + + return installSource; +} + +/** Build the install command for one npm-published target version. */ +function npmUpdateCommand( + executablePath: string, + targetVersion: string, + platform: NodeJS.Platform, +) { + const spec = `${NPM_PACKAGE_NAME}@${targetVersion}`; + const client = detectNpmClient(executablePath); + if (client === "bun") { + return ["bun", "add", "--global", spec]; + } + + // npm and pnpm ship as `.cmd` batch shims on Windows, and `Bun.spawn` runs its argv directly + // without PATHEXT resolution, so the shim must be named explicitly there. + const shim = (name: string) => (platform === "win32" ? `${name}.cmd` : name); + if (client === "pnpm") { + return [shim("pnpm"), "add", "--global", spec]; + } + + return [shim("npm"), "install", "--global", spec]; +} + +/** Spawn one package-manager command, streaming its output and capturing stderr for failures. */ +async function spawnUpdateCommand(command: readonly string[]): Promise { + const [executable] = command; + try { + const child = Bun.spawn({ + cmd: [...command], + stdin: "ignore", + stdout: "inherit", + stderr: "pipe", + }); + const stderr = await new Response(child.stderr).text(); + const exitCode = await child.exited; + return { exitCode, stderr }; + } catch (error) { + throw new HunkUserError( + `Could not run ${executable}: ${error instanceof Error ? error.message : String(error)}`, + [`Updating this install needs \`${executable}\` on PATH.`], + ); + } +} + +/** Guidance lines for an install source Hunk must not update itself. */ +function unmanagedInstallGuidance(installSource: InstallSource) { + if (installSource === "nix") { + return [ + "Hunk was installed with Nix.", + "Update it through your Nix configuration, then rebuild that profile or flake.", + ]; + } + + if (installSource === "mise") { + return ["Hunk was installed with mise.", "Run `mise up hunk` to update it."]; + } + + return [ + "Hunk is running from a local source build.", + "Run `bun run install:bin` in your Hunk checkout to update it.", + ]; +} + +/** Print the guidance for an install source Hunk must not update itself. */ +function reportUnmanagedInstall( + installSource: InstallSource, + installedVersion: string, + input: SelfUpdateInput, + io: SelfUpdateIo, +) { + const guidance = unmanagedInstallGuidance(installSource); + + io.stdout(`hunk ${installedVersion} (installed with ${describeInstallSource(installSource)})\n`); + io.stdout(`${guidance.join("\n")}\n`); + // `--check` only reports, so it succeeds; an explicit update request did not happen and says so. + return input.check ? 0 : 1; +} + +/** + * Run one `hunk update` invocation and return its exit code. + * + * Returns 0 when Hunk is already current or the update succeeded, and non-zero when the update was + * requested but could not happen — including installs owned by Nix, mise, or a source checkout. + */ +export async function runSelfUpdateCommand( + input: SelfUpdateInput, + io: SelfUpdateIo, +): Promise { + const env = io.env ?? process.env; + const executablePath = io.executablePath ?? process.execPath; + const resolveInstalledVersion = io.resolveInstalledVersion ?? resolveCliVersion; + const installedVersion = resolveInstalledVersion(); + const installSource = + input.method ?? + (io.resolveInstallSource ?? (() => detectInstallSource({ env, executablePath })))(); + + if (installSource !== "npm" && installSource !== "homebrew") { + if (input.version) { + throw new HunkUserError( + `Hunk installed with ${describeInstallSource(installSource)} cannot update to a specific version from here.`, + [unmanagedInstallGuidance(installSource)[1] ?? ""], + ); + } + + return reportUnmanagedInstall(installSource, installedVersion, input, io); + } + + if (installSource === "homebrew" && input.version) { + throw new HunkUserError("Homebrew installs cannot select a specific Hunk version.", [ + "Run `hunk update` without a version to move to the newest formula release.", + ]); + } + + const channelVersions = await fetchChannelVersions(installSource, { + fetchImpl: io.fetchImpl, + fetchTimeoutMs: io.fetchTimeoutMs, + }); + const latestVersion = channelVersions.latest; + const targetVersion = input.version ?? latestVersion; + const fetchFailedMessage = + installSource === "homebrew" + ? "Could not read the latest Hunk version from the Homebrew formula API." + : "Could not read the latest Hunk version from the npm registry."; + + // `--check` always reports against the channel's real latest release; a requested version is + // named separately so it is never mislabeled as "latest". + if (input.check) { + if (!latestVersion) { + throw new HunkUserError(fetchFailedMessage, ["Check your network connection."]); + } + + io.stdout( + `hunk ${installedVersion} (installed with ${describeInstallSource(installSource)})\n`, + ); + io.stdout(`latest ${latestVersion}\n`); + if (input.version) { + io.stdout(`requested ${input.version}\n`); + } + io.stdout( + isNewerVersion(installedVersion, latestVersion) + ? "An update is available. Run `hunk update` to install it.\n" + : "Hunk is up to date.\n", + ); + return 0; + } + + if (!targetVersion) { + throw new HunkUserError(fetchFailedMessage, [ + "Check your network connection, or pass an explicit version.", + ]); + } + + // An explicit version is a request to install exactly that, including a downgrade; without one, + // any installed version that is already at or past the release means there is nothing to do. + const alreadyCurrent = input.version + ? installedVersion === targetVersion + : !isComparableVersion(installedVersion) || !isNewerVersion(installedVersion, targetVersion); + if (alreadyCurrent) { + io.stdout(`hunk ${installedVersion} is already up to date.\n`); + return 0; + } + + const command = + installSource === "homebrew" + ? ["brew", "upgrade", HOMEBREW_FORMULA_NAME] + : npmUpdateCommand(executablePath, targetVersion, io.platform ?? process.platform); + + io.stdout( + `Updating hunk ${installedVersion} -> ${targetVersion} with \`${command.join(" ")}\`\n`, + ); + + const runCommand = io.runCommand ?? spawnUpdateCommand; + const result = await runCommand(command); + if (result.exitCode !== 0) { + const details = result.stderr.trim(); + if (details.length > 0) { + io.stderr(`${details}\n`); + } + io.stderr(`hunk: \`${command.join(" ")}\` failed with exit code ${result.exitCode}.\n`); + return result.exitCode; + } + + io.stdout(`Updated hunk to ${targetVersion}.\n`); + return 0; +} diff --git a/src/core/process/updateNotice.test.ts b/src/core/process/updateNotice.test.ts index 11b53483d..546f1f4b3 100644 --- a/src/core/process/updateNotice.test.ts +++ b/src/core/process/updateNotice.test.ts @@ -12,6 +12,17 @@ function createDistTagsResponse(tags: Record, status = 200) { }); } +/** Build one JSON response that mimics the Homebrew formula API payload. */ +function createFormulaResponse(stable: string) { + return new Response(JSON.stringify({ versions: { stable } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +/** Executable path of a plain global npm install, pinned so detection never reads the host. */ +const NPM_EXECUTABLE_PATH = join("/", "usr", "lib", "node_modules", "hunkdiff", "bin", "hunk"); + async function withTempStatePath(run: (statePath: string) => Promise) { const stateDir = mkdtempSync(join(tmpdir(), "hunk-startup-notice-")); const statePath = join(stateDir, "state.json"); @@ -28,13 +39,14 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.7.1", beta: "0.8.0-beta.1" }), resolveInstalledVersion: () => "0.7.0", statePath, }), ).resolves.toEqual({ key: "latest:0.7.1", - message: "Update available: 0.7.1 (latest) • npm i -g hunkdiff", + message: "Update available: 0.7.1 (latest) • run `hunk update`", }); }); }); @@ -43,13 +55,14 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0", beta: "0.8.0-beta.1" }), resolveInstalledVersion: () => "0.7.0", statePath, }), ).resolves.toEqual({ key: "beta:0.8.0-beta.1", - message: "Update available: 0.8.0-beta.1 (beta) • npm i -g hunkdiff@beta", + message: "Update available: 0.8.0-beta.1 (beta) • run `hunk update 0.8.0-beta.1`", }); }); }); @@ -58,38 +71,45 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.8.0", beta: "0.8.1-beta.1" }), resolveInstalledVersion: () => "0.8.0-beta.1", statePath, }), ).resolves.toEqual({ key: "beta:0.8.1-beta.1", - message: "Update available: 0.8.1-beta.1 (beta) • npm i -g hunkdiff@beta", + message: "Update available: 0.8.1-beta.1 (beta) • run `hunk update 0.8.1-beta.1`", }); }); }); - test("uses the Homebrew upgrade command for Homebrew installs", async () => { + test("reads the Homebrew formula, not npm, for Homebrew installs", async () => { await withTempStatePath(async (statePath) => { + const requested: string[] = []; + await expect( resolveStartupUpdateNotice({ - fetchImpl: async () => createDistTagsResponse({ latest: "0.7.1", beta: "0.8.0-beta.1" }), + fetchImpl: async (input) => { + requested.push(String(input)); + return createFormulaResponse("0.7.1"); + }, resolveInstalledVersion: () => "0.7.0", resolveInstallSource: () => "homebrew", statePath, }), ).resolves.toEqual({ key: "latest:0.7.1", - message: "Update available: 0.7.1 (latest) • brew update && brew upgrade hunk", + message: "Update available: 0.7.1 (latest) • run `hunk update`", }); + expect(requested).toEqual(["https://formulae.brew.sh/api/formula/hunk.json"]); }); }); - test("ignores beta updates for Homebrew installs", async () => { + test("stays quiet for Homebrew installs while the formula still lags npm", async () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ - fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0", beta: "0.8.0-beta.1" }), + fetchImpl: async () => createFormulaResponse("0.7.0"), resolveInstalledVersion: () => "0.7.0", resolveInstallSource: () => "homebrew", statePath, @@ -103,17 +123,49 @@ describe("startup update notice", () => { await expect( resolveStartupUpdateNotice({ env: { HUNK_INSTALL_SOURCE: "homebrew" }, - fetchImpl: async () => createDistTagsResponse({ latest: "0.7.1" }), + fetchImpl: async () => createFormulaResponse("0.7.1"), resolveInstalledVersion: () => "0.7.0", statePath, }), ).resolves.toEqual({ key: "latest:0.7.1", - message: "Update available: 0.7.1 (latest) • brew update && brew upgrade hunk", + message: "Update available: 0.7.1 (latest) • run `hunk update`", }); }); }); + test("detects unmarked Homebrew installs from their Cellar executable", async () => { + await withTempStatePath(async (statePath) => { + await expect( + resolveStartupUpdateNotice({ + env: {}, + fetchImpl: async () => createFormulaResponse("0.7.1"), + resolveExecutablePath: () => "/opt/homebrew/Cellar/hunk/0.7.0/bin/hunk", + resolveInstalledVersion: () => "0.7.0", + statePath, + }), + ).resolves.toEqual({ + key: "latest:0.7.1", + message: "Update available: 0.7.1 (latest) • run `hunk update`", + }); + }); + }); + + test("suppresses update notices for local source builds", async () => { + await withTempStatePath(async (statePath) => { + await expect( + resolveStartupUpdateNotice({ + env: { HUNK_INSTALL_SOURCE: "dev" }, + fetchImpl: async () => { + throw new Error("should not fetch for source builds"); + }, + resolveInstalledVersion: () => "0.7.0", + statePath, + }), + ).resolves.toBeNull(); + }); + }); + test("uses a neutral Nix update instruction for Nix installs", async () => { await withTempStatePath(async (statePath) => { await expect( @@ -213,6 +265,7 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0", beta: "0.8.0-beta.1" }), resolveInstalledVersion: () => "0.7.0", resolveInstallSource: () => "mise", @@ -234,7 +287,7 @@ describe("startup update notice", () => { }), ).resolves.toEqual({ key: "latest:0.7.1", - message: "Update available: 0.7.1 (latest) • npm i -g hunkdiff", + message: "Update available: 0.7.1 (latest) • run `hunk update`", }); }); }); @@ -243,6 +296,7 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0", beta: "0.7.0-beta.1" }), resolveInstalledVersion: () => "0.7.0", statePath, @@ -258,6 +312,7 @@ describe("startup update notice", () => { try { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0" }), resolveInstalledVersion: () => "0.7.0", statePath, @@ -281,6 +336,7 @@ describe("startup update notice", () => { try { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0" }), resolveInstalledVersion: () => "0.7.0", statePath, @@ -289,6 +345,7 @@ describe("startup update notice", () => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => { fetchCalled = true; return createDistTagsResponse({ latest: "0.8.0" }); @@ -305,6 +362,7 @@ describe("startup update notice", () => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.8.0" }), resolveInstalledVersion: () => "0.8.0", statePath, @@ -319,6 +377,7 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0", beta: "0.8.0-beta.1" }), resolveInstalledVersion: () => "0.0.0-unknown", statePath, @@ -331,6 +390,7 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => createDistTagsResponse({ latest: "0.7.1" }, 503), resolveInstalledVersion: () => "0.7.0", statePath, @@ -343,6 +403,7 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => { throw new Error("network down"); }, @@ -361,6 +422,7 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async () => { throw new Error("should not fetch when disabled"); }, @@ -384,6 +446,7 @@ describe("startup update notice", () => { await withTempStatePath(async (statePath) => { await expect( resolveStartupUpdateNotice({ + resolveExecutablePath: () => NPM_EXECUTABLE_PATH, fetchImpl: async (_input, init) => new Promise((_resolve, reject) => { init?.signal?.addEventListener( diff --git a/src/core/process/updateNotice.ts b/src/core/process/updateNotice.ts index 1c5b98203..ee4cb4d2d 100644 --- a/src/core/process/updateNotice.ts +++ b/src/core/process/updateNotice.ts @@ -1,15 +1,22 @@ -import { posix, win32 } from "node:path"; import { readAppStateRecord, updateAppStateRecord } from "./appStateFile"; +import { detectInstallSource, type InstallSource } from "./installSource"; +import { + type ChannelVersions, + fetchChannelVersions, + type FetchImpl, + type UpdateChannel, +} from "./latestRelease"; import { resolveAppStatePath } from "../run/paths"; import type { StartupNotice } from "./startupNotice"; -import { resolveCliVersion, UNKNOWN_CLI_VERSION } from "../run/version"; +import { + isComparableVersion, + isNewerVersion, + isStableVersion, + resolveCliVersion, + UNKNOWN_CLI_VERSION, +} from "../run/version"; -const DIST_TAGS_URL = "https://registry.npmjs.org/-/package/hunkdiff/dist-tags"; -const STABLE_SEMVER_PATTERN = /^\d+\.\d+\.\d+$/; -const PRERELEASE_SEMVER_PATTERN = /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/; -const DEFAULT_UPDATE_NOTICE_FETCH_TIMEOUT_MS = 5_000; const DISABLE_STARTUP_UPDATE_NOTICE_ENV = "HUNK_DISABLE_UPDATE_NOTICE"; -const INSTALL_SOURCE_ENV = "HUNK_INSTALL_SOURCE"; const STARTUP_STATE_VERSION = 1; interface PersistedStartupState { @@ -17,25 +24,18 @@ interface PersistedStartupState { lastSeenCliVersion?: string; } -export type UpdateChannel = "latest" | "beta"; -export type InstallSource = "npm" | "homebrew" | "nix" | "mise"; +export type { InstallSource, UpdateChannel }; /** - * Install sources that upgrade Hunk on their own, so Hunk never surfaces an update notice for them. + * Install sources Hunk never surfaces an update notice for. * * mise owns its tool versions: omarchy's `hunk` wrapper runs `mise use -g aqua:modem-dev/hunk` * before exec'ing the binary, so the newest release is already installed by the time this session - * starts. A notice there would ask the user to fix something mise just fixed, so suppress rather - * than swap in a mise-flavored update command. + * starts. A notice there would ask the user to fix something mise just fixed. A local source build + * is replaced by rebuilding the checkout it came from, which is the developer's own workflow and + * not something a published version number should interrupt. */ -const SELF_UPDATING_INSTALL_SOURCES: readonly InstallSource[] = ["mise"]; - -type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise; - -interface ParsedDistTags { - latest?: string; - beta?: string; -} +const SILENT_INSTALL_SOURCES: readonly InstallSource[] = ["mise", "dev"]; export interface UpdateNoticeDeps { env?: NodeJS.ProcessEnv; @@ -47,98 +47,28 @@ export interface UpdateNoticeDeps { statePath?: string; } -/** Return whether one version string is a normalized stable semver. */ -function isStableVersion(version: string) { - return STABLE_SEMVER_PATTERN.test(version); -} - -/** Return whether one version string looks like a prerelease semver. */ -function isPrereleaseVersion(version: string) { - return PRERELEASE_SEMVER_PATTERN.test(version); -} - -/** Parse only the dist-tags that participate in startup update notices. */ -function parseDistTags(payload: unknown): ParsedDistTags { - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return {}; - } - - const record = payload as Record; - return { - latest: typeof record.latest === "string" ? record.latest : undefined, - beta: typeof record.beta === "string" ? record.beta : undefined, - }; -} - -/** Compare two versions and return whether the candidate is strictly newer. */ -function isNewerVersion(current: string, candidate: string) { - try { - return Bun.semver.order(current, candidate) < 0; - } catch { - return false; - } -} - -/** Split one filesystem path into segments, tolerating either platform's separator. */ -function splitPathSegments(candidatePath: string) { - return candidatePath - .split(win32.sep) - .flatMap((segment) => segment.split(posix.sep)) - .filter((segment) => segment.length > 0); -} - -/** - * Return whether this executable lives inside a mise-managed install directory. - * - * mise lays every backend out as `/mise/installs///` on all - * platforms, so the adjacent `mise/installs` segments are the one signal that survives `mise x` - * (the omarchy wrapper's launch path, which sets none of mise's shell env vars) as well as shims - * and activated shells. - */ -function isMiseManagedExecutablePath(executablePath: string) { - const segments = splitPathSegments(executablePath); - return segments.some( - (segment, index) => segment === "mise" && segments[index + 1] === "installs", - ); -} - -/** Resolve which package manager installed this binary, defaulting to the npm package path. */ -function resolveInstallSourceFromRuntime( - env: NodeJS.ProcessEnv = process.env, - executablePath = process.execPath, -): InstallSource { - const installSource = env[INSTALL_SOURCE_ENV]; - if (installSource === "homebrew" || installSource === "nix" || installSource === "mise") { - return installSource; - } - - if (executablePath.startsWith("/nix/store/")) { - return "nix"; - } - - return isMiseManagedExecutablePath(executablePath) ? "mise" : "npm"; -} - /** Return whether the install source manages its own upgrades and needs no update notice. */ -function managesOwnUpdates(installSource: InstallSource) { - return SELF_UPDATING_INSTALL_SOURCES.includes(installSource); +function suppressesNotices(installSource: InstallSource) { + return SILENT_INSTALL_SOURCES.includes(installSource); } /** * Build the install-aware update instruction shown for one release channel. * - * Self-updating sources never reach here; they are filtered out before the dist-tag lookup. + * Sources with no notice at all never reach here; they are filtered out before the release lookup. + * npm and Homebrew installs both update in place through `hunk update`, so the notice names that + * one command; a beta build names the version because `hunk update` alone tracks `latest`. */ -function updateInstructionForChannel(channel: UpdateChannel, installSource: InstallSource) { - if (installSource === "homebrew") { - return "brew update && brew upgrade hunk"; - } - +function updateInstructionForChannel( + channel: UpdateChannel, + version: string, + installSource: InstallSource, +) { if (installSource === "nix") { return "update Hunk through your Nix configuration"; } - return channel === "latest" ? "npm i -g hunkdiff" : "npm i -g hunkdiff@beta"; + return channel === "latest" ? "run `hunk update`" : `run \`hunk update ${version}\``; } /** Build the session-local notice payload for the chosen version and channel. */ @@ -147,38 +77,26 @@ function createUpdateNotice( channel: UpdateChannel, installSource: InstallSource, ): StartupNotice { - const instruction = updateInstructionForChannel(channel, installSource); + const instruction = updateInstructionForChannel(channel, version, installSource); return { key: `${channel}:${version}`, message: `Update available: ${version} (${channel}) • ${instruction}`, }; } -/** Return whether the installed version can participate in update comparisons. */ -function isComparableInstalledVersion(version: string) { - if (version === UNKNOWN_CLI_VERSION) { - return false; - } - - return isStableVersion(version) || isPrereleaseVersion(version); -} - -/** Choose the single best update notice from the fetched dist-tags and installed version. */ +/** Choose the single best update notice from the fetched channel versions and installed version. */ function selectUpdateNotice( installedVersion: string, - distTags: ParsedDistTags, + channelVersions: ChannelVersions, installSource: InstallSource, ): StartupNotice | null { - if (!isComparableInstalledVersion(installedVersion)) { + if (!isComparableVersion(installedVersion)) { return null; } - const validLatest = - distTags.latest && isStableVersion(distTags.latest) ? distTags.latest : undefined; - const validBeta = - installSource === "npm" && distTags.beta && isPrereleaseVersion(distTags.beta) - ? distTags.beta - : undefined; + const validLatest = channelVersions.latest; + // Only npm publishes prereleases, so only npm installs are ever pointed at one. + const validBeta = installSource === "npm" ? channelVersions.beta : undefined; const installedIsStable = isStableVersion(installedVersion); if (installedIsStable) { @@ -213,25 +131,6 @@ function selectUpdateNotice( return createUpdateNotice(selected.version, selected.channel, installSource); } -/** Build one fetch timeout signal for the dist-tag lookup, if supported by the runtime. */ -function createFetchTimeoutSignal(timeoutMs: number) { - if (typeof AbortController === "undefined") { - return { signal: undefined, dispose: () => {} }; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, timeoutMs); - - return { - signal: controller.signal, - dispose: () => { - clearTimeout(timeout); - }, - }; -} - /** Read the persisted startup state from disk, falling back cleanly on missing or invalid files. */ function readPersistedStartupState(path: string): PersistedStartupState { const record = readAppStateRecord(path); @@ -286,7 +185,7 @@ function resolveStartupSkillRefreshNotice(deps: UpdateNoticeDeps = {}): StartupN }; } -/** Resolve the transient startup notice directly from local state or npm dist-tags. */ +/** Resolve the transient startup notice from local state and the install source's own registry. */ export async function resolveStartupUpdateNotice( deps: UpdateNoticeDeps = {}, ): Promise { @@ -300,31 +199,28 @@ export async function resolveStartupUpdateNotice( return skillRefreshNotice; } - const fetchImpl = deps.fetchImpl ?? fetch; - const fetchTimeoutMs = deps.fetchTimeoutMs ?? DEFAULT_UPDATE_NOTICE_FETCH_TIMEOUT_MS; const resolveInstalledVersion = deps.resolveInstalledVersion ?? resolveCliVersion; - const resolveInstallSource = + const resolveInstallSourceImpl = deps.resolveInstallSource ?? - (() => resolveInstallSourceFromRuntime(env, deps.resolveExecutablePath?.())); - const installSource = resolveInstallSource(); - // Resolved before fetching so self-updating installs skip the dist-tag request entirely. - if (managesOwnUpdates(installSource)) { + (() => + detectInstallSource({ + env, + executablePath: deps.resolveExecutablePath?.(), + version: resolveInstalledVersion(), + })); + const installSource = resolveInstallSourceImpl(); + // Resolved before fetching so silent installs skip the release request entirely. + if (suppressesNotices(installSource)) { return null; } - const { signal, dispose } = createFetchTimeoutSignal(fetchTimeoutMs); + // A Nix install cannot be updated from a registry, but nixpkgs tracks the npm release stream, so + // the notice still announces upstream releases and leaves the update to the user's Nix config. + const lookupSource = installSource === "nix" ? "npm" : installSource; + const channelVersions = await fetchChannelVersions(lookupSource, { + fetchImpl: deps.fetchImpl, + fetchTimeoutMs: deps.fetchTimeoutMs, + }); - try { - const response = await fetchImpl(DIST_TAGS_URL, { signal }); - if (!response.ok) { - return null; - } - - const parsedPayload = parseDistTags(await response.json()); - return selectUpdateNotice(resolveInstalledVersion(), parsedPayload, installSource); - } catch { - return null; - } finally { - dispose(); - } + return selectUpdateNotice(resolveInstalledVersion(), channelVersions, installSource); } diff --git a/src/core/run/commandInputs.ts b/src/core/run/commandInputs.ts index 66b095ef6..759de339e 100644 --- a/src/core/run/commandInputs.ts +++ b/src/core/run/commandInputs.ts @@ -13,6 +13,7 @@ import type { ExtensionVcsShowInput, ExtensionVcsStashShowInput, } from "../../extension-api/types"; +import type { InstallSource } from "../process/installSource"; export type LayoutMode = "auto" | "split" | "stack"; export type CursorLine = "row" | "number" | "off"; @@ -313,6 +314,16 @@ export interface ExtensionRemoveCommandInput { name: string; } +export interface SelfUpdateCommandInput { + kind: "update"; + /** Version to install; the install channel's newest release when omitted. */ + version?: string; + /** Install method override from `--method`, normalized to an install source. */ + method?: InstallSource; + /** Report the installed and available versions without installing anything. */ + check: boolean; +} + /** `hunk extension ...` managed-install commands. */ export type ExtensionManageCommandInput = | ExtensionInstallCommandInput @@ -328,4 +339,5 @@ export type ParsedCliInput = | SessionCommandInput | MarkupRenderCommandInput | MarkupGuideCommandInput - | ExtensionManageCommandInput; + | ExtensionManageCommandInput + | SelfUpdateCommandInput; diff --git a/src/core/run/version.ts b/src/core/run/version.ts index 69ae8f439..2ca156893 100644 --- a/src/core/run/version.ts +++ b/src/core/run/version.ts @@ -3,6 +3,8 @@ import packageJson from "../../../package.json" with { type: "json" }; export const UNKNOWN_CLI_VERSION = "0.0.0-unknown"; const PACKAGE_CLI_VERSION = packageJson.version; +const STABLE_SEMVER_PATTERN = /^\d+\.\d+\.\d+$/; +const PRERELEASE_SEMVER_PATTERN = /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/; /** Resolve the CLI version reported by `hunk --version`. */ export function resolveCliVersion(): string { @@ -12,3 +14,31 @@ export function resolveCliVersion(): string { return PACKAGE_CLI_VERSION; } + +/** Return whether one version string is a normalized stable semver. */ +export function isStableVersion(version: string) { + return STABLE_SEMVER_PATTERN.test(version); +} + +/** Return whether one version string looks like a prerelease semver. */ +export function isPrereleaseVersion(version: string) { + return PRERELEASE_SEMVER_PATTERN.test(version); +} + +/** Return whether the installed version can participate in update comparisons. */ +export function isComparableVersion(version: string) { + if (version === UNKNOWN_CLI_VERSION) { + return false; + } + + return isStableVersion(version) || isPrereleaseVersion(version); +} + +/** Compare two versions and return whether the candidate is strictly newer. */ +export function isNewerVersion(current: string, candidate: string) { + try { + return Bun.semver.order(current, candidate) < 0; + } catch { + return false; + } +} diff --git a/src/main.tsx b/src/main.tsx index be09e1146..d875a9064 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -56,6 +56,16 @@ async function main() { ); } + if (startupPlan.kind === "self-update") { + const { runSelfUpdateCommand } = await import("./core/process/selfUpdate"); + process.exit( + await runSelfUpdateCommand(startupPlan.input, { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text), + }), + ); + } + if (startupPlan.kind === "markup-guide") { const { runMarkupGuideCommand } = await import("./ui/lib/stml/cli"); process.exit(runMarkupGuideCommand({ stdout: (text) => process.stdout.write(text) })); diff --git a/test/cli/update.test.ts b/test/cli/update.test.ts new file mode 100644 index 000000000..3fc53cadc --- /dev/null +++ b/test/cli/update.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import type { InstallSource } from "../../src/core/process/installSource"; + +/** + * Runs `hunk update` as a black box with a forced install source. + * + * Only the sources Hunk refuses to update itself are exercised here, so the command never reaches + * the network or a package manager: `HUNK_INSTALL_SOURCE` pins the source, and every one of these + * paths reports and stops before any release lookup. + */ +function runUpdate(args: string[], installSource?: InstallSource) { + const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "update", ...args], { + cwd: process.cwd(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: installSource + ? { ...process.env, HUNK_INSTALL_SOURCE: installSource } + : { ...process.env }, + }); + + return { + exitCode: proc.exitCode, + stdout: Buffer.from(proc.stdout).toString("utf8"), + stderr: Buffer.from(proc.stderr).toString("utf8"), + }; +} + +describe("hunk update CLI contract", () => { + test("top-level help lists the update command", () => { + const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "--help"], { + cwd: process.cwd(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + + expect(proc.exitCode).toBe(0); + expect(stdout).toContain("hunk update [version]"); + expect(stdout).toContain("update Hunk with the package manager that installed it"); + }); + + test("prints update help without terminal takeover sequences", () => { + const result = runUpdate(["--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Usage: update [options] [version]"); + expect(result.stdout).toContain("--method "); + expect(result.stdout).toContain("npm, brew"); + expect(result.stdout).toContain("--check"); + expect(result.stdout).toContain("hunk update --method brew"); + expect(result.stdout).not.toContain("[?1049h"); + }); + + test("points Nix installs at their own configuration", () => { + const result = runUpdate([], "nix"); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("(installed with Nix)"); + expect(result.stdout).toContain("Update it through your Nix configuration"); + expect(result.stdout).not.toContain("[?1049h"); + }); + + test("points mise installs at mise up", () => { + const result = runUpdate([], "mise"); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("Run `mise up hunk` to update it."); + }); + + test("points local source builds at install:bin", () => { + const result = runUpdate([], "dev"); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("(installed with a local source build)"); + expect(result.stdout).toContain( + "Run `bun run install:bin` in your Hunk checkout to update it.", + ); + }); + + test("reports an unmanaged install successfully for --check", () => { + const result = runUpdate(["--check"], "dev"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("(installed with a local source build)"); + }); + + test("names the supported methods for an unknown --method", () => { + const result = runUpdate(["--method", "apt"], "dev"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Unknown update method: apt"); + expect(result.stderr).toContain("Supported methods are `npm` and `brew`."); + }); + + test("rejects unknown update flags", () => { + const result = runUpdate(["--not-a-real-flag"], "dev"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("--not-a-real-flag"); + }); +}); diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 87b21a253..9de73014c 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -231,6 +231,25 @@ hunk extension remove **Aliases:** `hunk ext remove`. +## `hunk update` + +update Hunk with the package manager that installed it + +### Usage + +```bash +hunk update [version] +hunk update --check +hunk update --method +``` + +### Command-specific options + +| Option | Description | +| ------------------- | -------------------------------------------------------------- | +| `--method ` | install method instead of the detected one: npm, brew | +| `--check` | report the installed and available versions without installing | + ## `hunk daemon serve` run the local Hunk session daemon and websocket session broker diff --git a/website/src/content/docs/docs/start/install.md b/website/src/content/docs/docs/start/install.md index 1d5d95901..df4d395d4 100644 --- a/website/src/content/docs/docs/start/install.md +++ b/website/src/content/docs/docs/start/install.md @@ -62,4 +62,16 @@ hunk --help You should see `Usage: hunk [options]`. If the shell cannot find Hunk, ensure your global npm, Homebrew, or mise binary directory is on `PATH`, then open a new shell. +## Update Hunk + +`hunk update` replaces Hunk with the newest release, using the package manager that installed it: + +```bash +hunk update # install the newest release +hunk update --check # report the installed and available versions +hunk update 0.19.0 # install a specific npm release +``` + +npm installs (including `bun` and `pnpm` global installs) and Homebrew installs update in place. mise, Nix, and local source builds are owned by their own tooling, so Hunk prints the command that updates them — `mise up hunk`, your Nix configuration, or `bun run install:bin` — instead of updating itself. Pass `--method npm` or `--method brew` if Hunk detects the wrong one. + Next, [review your first working tree](/docs/start/quick-start/). From 689ce4dbcdcb7ff4c18f207be4d73c7fdd557c33 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:42:40 +0000 Subject: [PATCH 2/2] fix(cli): validate update version and classify untagged builds Pass the installed version into install-source detection so untagged local builds outside the recognized install directory get source-build guidance instead of falling back to npm, and validate the update version argument as an exact release version so arbitrary npm specs (tags, ranges, paths, aliases) never reach the package-manager spec. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sf2y1jWD9fgx7aAKYbLQC6 --- src/app/cli.ts | 8 +++++-- src/core/process/selfUpdate.test.ts | 34 +++++++++++++++++++++++++++++ src/core/process/selfUpdate.ts | 18 ++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/app/cli.ts b/src/app/cli.ts index 6a3965537..faaa493f4 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -14,7 +14,11 @@ import type { SessionCommentListType, SessionCommentApplyItemInput, } from "../core/run/commandInputs"; -import { parseUpdateMethod, UPDATE_METHOD_VALUES } from "../core/process/selfUpdate"; +import { + parseUpdateMethod, + parseUpdateVersion, + UPDATE_METHOD_VALUES, +} from "../core/process/selfUpdate"; import { BUNDLED_SKILL_NAMES, resolveBundledSkillName, @@ -1700,7 +1704,7 @@ async function parseUpdateCommand( return { kind: "update", - version: parsedVersion, + version: parsedVersion === undefined ? undefined : parseUpdateVersion(parsedVersion), method: parsedOptions.method ? parseUpdateMethod(parsedOptions.method) : undefined, check: parsedOptions.check ?? false, }; diff --git a/src/core/process/selfUpdate.test.ts b/src/core/process/selfUpdate.test.ts index 5a563d7df..2efd96bd5 100644 --- a/src/core/process/selfUpdate.test.ts +++ b/src/core/process/selfUpdate.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import type { InstallSource } from "./installSource"; import { parseUpdateMethod, + parseUpdateVersion, runSelfUpdateCommand, type SelfUpdateInput, type SelfUpdateProcessResult, @@ -70,7 +71,40 @@ describe("update method parsing", () => { }); }); +describe("update version parsing", () => { + test("accepts exact release versions, with or without a tag prefix", () => { + expect(parseUpdateVersion("0.19.0")).toBe("0.19.0"); + expect(parseUpdateVersion("v1.2.3")).toBe("1.2.3"); + expect(parseUpdateVersion("1.2.3-beta.1")).toBe("1.2.3-beta.1"); + }); + + test("rejects npm specs that are not a plain version", () => { + for (const value of ["latest", "^1.2.0", "npm:evil@1.0.0", "../local-dir", "1.2.3 --flag"]) { + expect(() => parseUpdateVersion(value)).toThrow(`Invalid version: ${value}`); + } + }); +}); + describe("hunk update", () => { + test("detects untagged source builds when no install source is injected", async () => { + const stdout: string[] = []; + const exitCode = await runSelfUpdateCommand( + { check: false }, + { + stdout: (text) => stdout.push(text), + stderr: () => {}, + env: {}, + executablePath: join("/", "opt", "somewhere", "hunk"), + resolveInstalledVersion: () => "0.0.0-unknown", + fetchImpl: async () => jsonResponse({}), + runCommand: async () => ({ exitCode: 0, stderr: "" }), + }, + ); + + expect(exitCode).toBe(1); + expect(stdout.join("")).toContain("Hunk is running from a local source build."); + }); + test("installs the newest npm release with the npm client", async () => { const result = await runUpdate({ installSource: "npm", latestVersion: "1.1.0" }); diff --git a/src/core/process/selfUpdate.ts b/src/core/process/selfUpdate.ts index fdd576716..194398c0e 100644 --- a/src/core/process/selfUpdate.ts +++ b/src/core/process/selfUpdate.ts @@ -67,6 +67,19 @@ export function parseUpdateMethod(value: string): InstallSource { return method; } +/** Validate one requested version, or explain what the argument accepts. */ +export function parseUpdateVersion(value: string): string { + // Release tags spell versions as `v1.2.3`, so tolerate that prefix before validating. + const version = value.startsWith("v") ? value.slice(1) : value; + if (!isComparableVersion(version)) { + throw new HunkUserError(`Invalid version: ${value}`, [ + "Pass an exact release version such as `0.19.0`.", + ]); + } + + return version; +} + /** Name one install source the way the user would say it. */ function describeInstallSource(installSource: InstallSource) { if (installSource === "homebrew") { @@ -177,7 +190,10 @@ export async function runSelfUpdateCommand( const installedVersion = resolveInstalledVersion(); const installSource = input.method ?? - (io.resolveInstallSource ?? (() => detectInstallSource({ env, executablePath })))(); + ( + io.resolveInstallSource ?? + (() => detectInstallSource({ env, executablePath, version: installedVersion })) + )(); if (installSource !== "npm" && installSource !== "homebrew") { if (input.version) {