Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-donkeys-hammer.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 73 additions & 1 deletion src/app/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -234,6 +240,21 @@ export const CLI_REFERENCE_COMMANDS = {
synopsis: ["hunk extension remove <name>"],
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 <npm|brew>"],
options: [
{
flag: "--method <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",
Expand Down Expand Up @@ -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 <subcommand> 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:",
Expand Down Expand Up @@ -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`.",
Expand Down Expand Up @@ -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<SelfUpdateCommandInput | HelpCommandInput> {
const command = createCliReferenceCommand("update").argument(
"[version]",
"version to install; the newest release when omitted",
);
Comment thread
benvinegar marked this conversation as resolved.

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<ParsedCliInput> {
const [subcommand, ...rest] = tokens;
Expand Down Expand Up @@ -1736,6 +1805,7 @@ const TOP_LEVEL_COMMAND_NAMES = new Set([
"skill",
"extension",
"ext",
"update",
"daemon",
"mcp",
]);
Expand Down Expand Up @@ -1808,6 +1878,8 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
case "extension":
case "ext":
return parseExtensionCommand(rest);
case "update":
return parseUpdateCommand(rest);
case "daemon":
case "mcp":
return parseDaemonCommand(rest);
Expand Down
12 changes: 12 additions & 0 deletions src/app/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
ExtensionManageCommandInput,
MarkupRenderCommandInput,
ParsedCliInput,
SelfUpdateCommandInput,
SessionCommandInput,
} from "../core/run/commandInputs";
import { canReloadInput } from "../core/run/inputReload";
Expand Down Expand Up @@ -79,6 +80,10 @@ export type StartupPlan =
kind: "extension-manage";
input: ExtensionManageCommandInput;
}
| {
kind: "self-update";
input: SelfUpdateCommandInput;
}
| {
kind: "app";
bootstrap: AppBootstrap;
Expand Down Expand Up @@ -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;
Expand Down
181 changes: 181 additions & 0 deletions src/core/process/installSource.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading