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..faaa493f4 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -10,9 +10,15 @@ import type { LayoutMode, PagerCommandInput, ParsedCliInput, + SelfUpdateCommandInput, SessionCommentListType, SessionCommentApplyItemInput, } from "../core/run/commandInputs"; +import { + parseUpdateMethod, + parseUpdateVersion, + UPDATE_METHOD_VALUES, +} from "../core/process/selfUpdate"; import { BUNDLED_SKILL_NAMES, resolveBundledSkillName, @@ -234,6 +240,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 +473,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 +898,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 +1664,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 === undefined ? undefined : parseUpdateVersion(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 +1805,7 @@ const TOP_LEVEL_COMMAND_NAMES = new Set([ "skill", "extension", "ext", + "update", "daemon", "mcp", ]); @@ -1808,6 +1878,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..2efd96bd5 --- /dev/null +++ b/src/core/process/selfUpdate.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import type { InstallSource } from "./installSource"; +import { + parseUpdateMethod, + parseUpdateVersion, + 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("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" }); + + 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..194398c0e --- /dev/null +++ b/src/core/process/selfUpdate.ts @@ -0,0 +1,286 @@ +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; +} + +/** 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") { + 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, version: installedVersion })) + )(); + + 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/).