From c8390992fbcb9e1435d9b18012ed7aafb45b04a9 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 31 Aug 2026 08:21:38 -0400 Subject: [PATCH 1/2] feat(cli): polish self-update progress --- .changeset/cool-hunk-update.md | 5 ++ bun.lock | 1 + package.json | 1 + src/core/install/selfUpdate.test.ts | 41 +++++++++++++++ src/core/install/selfUpdate.ts | 79 +++++++++++++++++++++++------ src/main.tsx | 2 + 6 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 .changeset/cool-hunk-update.md diff --git a/.changeset/cool-hunk-update.md b/.changeset/cool-hunk-update.md new file mode 100644 index 000000000..1b307482a --- /dev/null +++ b/.changeset/cool-hunk-update.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Render `hunk update` with a guided, animated terminal status while preserving plain output for pipes, CI, and color-disabled terminals. diff --git a/bun.lock b/bun.lock index 333ce24c8..651b24b35 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "hunk", "dependencies": { + "@clack/prompts": "1.7.0", "bun": "^1.3.14", "chokidar": "^4.0.3", "commander": "^14.0.3", diff --git a/package.json b/package.json index 53d8182bb..c3595602d 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "nix:update-lock": "nix run .#update-bun-lock" }, "dependencies": { + "@clack/prompts": "1.7.0", "bun": "^1.3.14", "chokidar": "^4.0.3", "commander": "^14.0.3", diff --git a/src/core/install/selfUpdate.test.ts b/src/core/install/selfUpdate.test.ts index c0ac160e2..e19bfeb7b 100644 --- a/src/core/install/selfUpdate.test.ts +++ b/src/core/install/selfUpdate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; +import { PassThrough } from "node:stream"; import type { InstallSource } from "./installSource"; import { parseUpdateMethod, @@ -27,6 +28,7 @@ interface UpdateRunOptions { latestVersion?: string; env?: NodeJS.ProcessEnv; commandResult?: SelfUpdateProcessResult; + interactive?: boolean; } /** Run one `hunk update` invocation offline, capturing output and the spawned command. */ @@ -35,13 +37,21 @@ async function runUpdate(options: UpdateRunOptions) { const stderr: string[] = []; const commands: string[][] = []; const commandEnvs: Array = []; + const commandCaptureModes: Array = []; const latestVersion = options.latestVersion ?? "1.1.0"; + const interactiveOutput = new PassThrough(); + let renderedOutput = ""; + interactiveOutput.on("data", (chunk) => { + renderedOutput += chunk.toString(); + }); const exitCode = await runSelfUpdateCommand( { check: false, ...options.input }, { stdout: (text) => stdout.push(text), stderr: (text) => stderr.push(text), + stdoutIsTTY: options.interactive, + output: interactiveOutput, env: options.env ?? {}, executablePath: options.executablePath ?? join("/", "usr", "bin", "hunk"), platform: options.platform ?? "linux", @@ -57,6 +67,7 @@ async function runUpdate(options: UpdateRunOptions) { runCommand: async (command, commandOptions) => { commands.push([...command]); commandEnvs.push(commandOptions?.env); + commandCaptureModes.push(commandOptions?.captureOutput); return options.commandResult ?? { exitCode: 0, stderr: "" }; }, }, @@ -68,6 +79,8 @@ async function runUpdate(options: UpdateRunOptions) { stderr: stderr.join(""), commands, commandEnvs, + commandCaptureModes, + renderedOutput, }; } @@ -153,6 +166,34 @@ describe("hunk update", () => { expect(result.stdout).toContain("Updated hunk to 1.1.0."); }); + test("renders a guided update and captures noisy child output on a TTY", async () => { + const result = await runUpdate({ + installSource: "npm", + latestVersion: "1.1.0", + interactive: true, + }); + + expect(result.stdout).toBe(""); + expect(result.renderedOutput).toContain("Hunk update"); + expect(result.renderedOutput).toContain("Current 1.0.0"); + expect(result.renderedOutput).toContain("Target 1.1.0"); + expect(result.renderedOutput).toContain("Updated to 1.1.0"); + expect(result.renderedOutput).toContain("Done"); + expect(result.commandCaptureModes).toEqual([true]); + }); + + test("keeps plain output when color is disabled", async () => { + const result = await runUpdate({ + installSource: "npm", + interactive: true, + env: { NO_COLOR: "1" }, + }); + + expect(result.renderedOutput).toBe(""); + expect(result.stdout).toContain("Updating hunk 1.0.0 -> 1.1.0"); + expect(result.commandCaptureModes).toEqual([undefined]); + }); + test("names the npm .cmd shim explicitly on Windows", async () => { const result = await runUpdate({ installSource: "npm", platform: "win32" }); diff --git a/src/core/install/selfUpdate.ts b/src/core/install/selfUpdate.ts index 429a14b67..38645e9e3 100644 --- a/src/core/install/selfUpdate.ts +++ b/src/core/install/selfUpdate.ts @@ -1,3 +1,5 @@ +import { intro, log, outro, spinner } from "@clack/prompts"; +import type { Writable } from "node:stream"; import { HunkUserError } from "../run/errors"; import { detectInstallSource, detectNpmClient, type InstallSource } from "./installSource"; import { fetchChannelVersions, type FetchImpl } from "./latestRelease"; @@ -54,6 +56,7 @@ export interface SelfUpdateInput { /** Outcome of one package-manager invocation. */ export interface SelfUpdateProcessResult { exitCode: number; + stdout?: string; stderr: string; } @@ -61,11 +64,17 @@ export interface SelfUpdateProcessResult { export interface SelfUpdateCommandOptions { /** Full environment for the child; the parent's own environment when omitted. */ env?: NodeJS.ProcessEnv; + /** Capture child output so an interactive status display remains intact. */ + captureOutput?: boolean; } export interface SelfUpdateIo { stdout: (text: string) => void; stderr: (text: string) => void; + /** Enable Clack's transient presentation when stdout is an interactive terminal. */ + stdoutIsTTY?: boolean; + /** Stream Clack writes its interactive presentation to. */ + output?: Writable; env?: NodeJS.ProcessEnv; executablePath?: string; /** Platform used to pick package-manager executable names; defaults to the running platform. */ @@ -188,7 +197,7 @@ function buildUpdateCommand( return npmUpdateCommand(executablePath, targetVersion, platform); } -/** Spawn one package-manager command, streaming its output and capturing stderr for failures. */ +/** Spawn one package-manager command, capturing output when a transient status owns stdout. */ async function spawnUpdateCommand( command: readonly string[], options: SelfUpdateCommandOptions = {}, @@ -199,12 +208,15 @@ async function spawnUpdateCommand( cmd: [...command], env: options.env, stdin: "ignore", - stdout: "inherit", + stdout: options.captureOutput ? "pipe" : "inherit", stderr: "pipe", }); - const stderr = await new Response(child.stderr).text(); - const exitCode = await child.exited; - return { exitCode, stderr }; + const [stdout, stderr, exitCode] = await Promise.all([ + options.captureOutput ? new Response(child.stdout).text() : Promise.resolve(undefined), + new Response(child.stderr).text(), + child.exited, + ]); + return { exitCode, stdout, stderr }; } catch (error) { throw new HunkUserError( `Could not run ${executable}: ${error instanceof Error ? error.message : String(error)}`, @@ -343,8 +355,15 @@ export async function runSelfUpdateCommand( const alreadyCurrent = input.version ? installedVersion === targetVersion : !isComparableVersion(installedVersion) || !isNewerVersion(installedVersion, targetVersion); + const interactive = Boolean(io.stdoutIsTTY && io.output && !env.NO_COLOR && env.TERM !== "dumb"); if (alreadyCurrent) { - io.stdout(`hunk ${installedVersion} is already up to date.\n`); + if (interactive) { + intro("Hunk update", { output: io.output }); + log.success(`hunk ${installedVersion} is already up to date`, { output: io.output }); + outro("Nothing to do", { output: io.output }); + } else { + io.stdout(`hunk ${installedVersion} is already up to date.\n`); + } return 0; } @@ -356,24 +375,54 @@ export async function runSelfUpdateCommand( ); // Only the curl installer reads a version from its environment; every other channel names the // target in its argv, so the child otherwise inherits this process's environment untouched. - const commandOptions: SelfUpdateCommandOptions = - installSource === "curl" ? { env: { ...env, [CURL_INSTALL_VERSION_ENV]: targetVersion } } : {}; - - io.stdout( - `Updating hunk ${installedVersion} -> ${targetVersion} with \`${command.join(" ")}\`\n`, - ); + const commandOptions: SelfUpdateCommandOptions = { + ...(installSource === "curl" + ? { env: { ...env, [CURL_INSTALL_VERSION_ENV]: targetVersion } } + : {}), + ...(interactive ? { captureOutput: true } : {}), + }; + + const progress = interactive ? spinner({ output: io.output }) : undefined; + if (interactive) { + intro("Hunk update", { output: io.output }); + log.info(`Current ${installedVersion}`, { output: io.output }); + log.info(`Target ${targetVersion}`, { output: io.output }); + progress?.start(`Updating with ${describeInstallSource(installSource)}`); + } else { + io.stdout( + `Updating hunk ${installedVersion} -> ${targetVersion} with \`${command.join(" ")}\`\n`, + ); + } const runCommand = io.runCommand ?? spawnUpdateCommand; - const result = await runCommand(command, commandOptions); + let result: SelfUpdateProcessResult; + try { + result = await runCommand(command, commandOptions); + } catch (error) { + progress?.error("Update failed"); + throw error; + } if (result.exitCode !== 0) { - const details = result.stderr.trim(); + progress?.error("Update failed"); + const details = [result.stdout, result.stderr] + .map((text) => text?.trim()) + .filter(Boolean) + .join("\n"); if (details.length > 0) { io.stderr(`${details}\n`); } io.stderr(`hunk: \`${command.join(" ")}\` failed with exit code ${result.exitCode}.\n`); + if (interactive) { + outro("Hunk was not updated", { output: io.output }); + } return result.exitCode; } - io.stdout(`Updated hunk to ${targetVersion}.\n`); + if (interactive) { + progress?.stop(`Updated to ${targetVersion}`); + outro("Done", { output: io.output }); + } else { + io.stdout(`Updated hunk to ${targetVersion}.\n`); + } return 0; } diff --git a/src/main.tsx b/src/main.tsx index 848f1f905..855f65986 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -66,6 +66,8 @@ async function main() { await runSelfUpdateCommand(startupPlan.input, { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text), + stdoutIsTTY: Boolean(process.stdout.isTTY), + output: process.stdout, }), ); } From 877997051158e597981e32f0da199e1ad50c52c2 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 31 Aug 2026 08:54:37 -0400 Subject: [PATCH 2/2] fix(cli): preserve update cancellation semantics --- src/core/install/selfUpdate.test.ts | 100 +++++++++++++++++++++++++++- src/core/install/selfUpdate.ts | 99 ++++++++++++++++++++++++--- 2 files changed, 188 insertions(+), 11 deletions(-) diff --git a/src/core/install/selfUpdate.test.ts b/src/core/install/selfUpdate.test.ts index e19bfeb7b..5e4da29ee 100644 --- a/src/core/install/selfUpdate.test.ts +++ b/src/core/install/selfUpdate.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough } from "node:stream"; import type { InstallSource } from "./installSource"; @@ -6,6 +8,7 @@ import { parseUpdateMethod, parseUpdateVersion, runSelfUpdateCommand, + type SelfUpdateCommandOptions, type SelfUpdateInput, type SelfUpdateProcessResult, UPDATE_METHOD_VALUES, @@ -28,6 +31,9 @@ interface UpdateRunOptions { latestVersion?: string; env?: NodeJS.ProcessEnv; commandResult?: SelfUpdateProcessResult; + commandRunner?: ( + options: SelfUpdateCommandOptions | undefined, + ) => Promise; interactive?: boolean; } @@ -68,7 +74,9 @@ async function runUpdate(options: UpdateRunOptions) { commands.push([...command]); commandEnvs.push(commandOptions?.env); commandCaptureModes.push(commandOptions?.captureOutput); - return options.commandResult ?? { exitCode: 0, stderr: "" }; + return options.commandRunner + ? options.commandRunner(commandOptions) + : (options.commandResult ?? { exitCode: 0, stderr: "" }); }, }, ); @@ -183,10 +191,24 @@ describe("hunk update", () => { }); test("keeps plain output when color is disabled", async () => { + for (const value of ["", "1"]) { + const result = await runUpdate({ + installSource: "npm", + interactive: true, + env: { NO_COLOR: value }, + }); + + expect(result.renderedOutput).toBe(""); + expect(result.stdout).toContain("Updating hunk 1.0.0 -> 1.1.0"); + expect(result.commandCaptureModes).toEqual([undefined]); + } + }); + + test("keeps plain output in CI even when stdout is a TTY", async () => { const result = await runUpdate({ installSource: "npm", interactive: true, - env: { NO_COLOR: "1" }, + env: { CI: "true" }, }); expect(result.renderedOutput).toBe(""); @@ -194,6 +216,80 @@ describe("hunk update", () => { expect(result.commandCaptureModes).toEqual([undefined]); }); + test("aborts an interactive update and returns the shell signal status", async () => { + let commandAborted = false; + const result = await runUpdate({ + installSource: "npm", + interactive: true, + commandRunner: (commandOptions) => + new Promise((resolve) => { + commandOptions?.signal?.addEventListener( + "abort", + () => { + commandAborted = true; + resolve({ exitCode: 143, stderr: "" }); + }, + { once: true }, + ); + process.emit("SIGTERM"); + }), + }); + + expect(commandAborted).toBe(true); + expect(result.exitCode).toBe(143); + expect(result.renderedOutput).toContain("Canceled"); + expect(result.renderedOutput).not.toContain("Done"); + }); + + test("kills an updater's descendant processes when canceled", async () => { + if (process.platform === "win32") { + // Windows uses taskkill for tree termination; the Unix process-group fixture cannot exercise it. + return; + } + + const fixtureDir = mkdtempSync(join(tmpdir(), "hunk-update-cancel-")); + const curlPath = join(fixtureDir, "curl"); + const installerPath = join(fixtureDir, "installer.sh"); + const survivedPath = join(fixtureDir, "descendant-survived"); + writeFileSync( + curlPath, + '#!/bin/sh\nwhile [ "$1" != "-o" ]; do shift; done\ncp "$FAKE_INSTALLER" "$2"\n', + ); + writeFileSync(installerPath, `#!/bin/sh\n(sleep 0.5; touch '${survivedPath}') &\nwait\n`); + chmodSync(curlPath, 0o755); + chmodSync(installerPath, 0o755); + + try { + const output = new PassThrough(); + const cancellation = setTimeout(() => process.emit("SIGTERM"), 100); + const exitCode = await runSelfUpdateCommand( + { check: false }, + { + stdout: () => {}, + stderr: () => {}, + stdoutIsTTY: true, + output, + env: { + PATH: `${fixtureDir}:${process.env.PATH ?? ""}`, + FAKE_INSTALLER: installerPath, + TERM: "xterm-256color", + }, + executablePath: join(fixtureDir, "hunk"), + resolveInstalledVersion: () => "1.0.0", + resolveInstallSource: () => "curl", + fetchImpl: async () => jsonResponse({ tag_name: "v1.1.0" }), + }, + ); + clearTimeout(cancellation); + + expect(exitCode).toBe(143); + await Bun.sleep(650); + expect(existsSync(survivedPath)).toBe(false); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } + }, 5_000); + test("names the npm .cmd shim explicitly on Windows", async () => { const result = await runUpdate({ installSource: "npm", platform: "win32" }); diff --git a/src/core/install/selfUpdate.ts b/src/core/install/selfUpdate.ts index 38645e9e3..fed2211ff 100644 --- a/src/core/install/selfUpdate.ts +++ b/src/core/install/selfUpdate.ts @@ -66,6 +66,8 @@ export interface SelfUpdateCommandOptions { env?: NodeJS.ProcessEnv; /** Capture child output so an interactive status display remains intact. */ captureOutput?: boolean; + /** Abort the package manager when the interactive update is canceled. */ + signal?: AbortSignal; } export interface SelfUpdateIo { @@ -197,6 +199,41 @@ function buildUpdateCommand( return npmUpdateCommand(executablePath, targetVersion, platform); } +/** Terminate an updater and its descendants so cancellation cannot leave an installer running. */ +async function terminateUpdateProcessTree(child: Bun.Subprocess, platform: NodeJS.Platform) { + if (platform === "win32") { + try { + const terminator = Bun.spawn({ + cmd: ["taskkill", "/PID", String(child.pid), "/T", "/F"], + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + const exitCode = await terminator.exited; + if (exitCode === 0) { + return; + } + } catch { + // Fall back to the direct child when taskkill is unavailable. + } + child.kill(); + return; + } + + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill(); + return; + } + await Bun.sleep(250); + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // The process group exited during its grace period. + } +} + /** Spawn one package-manager command, capturing output when a transient status owns stdout. */ async function spawnUpdateCommand( command: readonly string[], @@ -210,13 +247,29 @@ async function spawnUpdateCommand( stdin: "ignore", stdout: options.captureOutput ? "pipe" : "inherit", stderr: "pipe", + // A separate process group lets interactive cancellation terminate the whole installer tree. + // Non-interactive children stay in Hunk's foreground group so ordinary shell signals reach them. + detached: Boolean(options.signal) && process.platform !== "win32", }); - const [stdout, stderr, exitCode] = await Promise.all([ - options.captureOutput ? new Response(child.stdout).text() : Promise.resolve(undefined), - new Response(child.stderr).text(), - child.exited, - ]); - return { exitCode, stdout, stderr }; + let termination: Promise | undefined; + const abortChild = () => { + termination ??= terminateUpdateProcessTree(child, process.platform); + }; + options.signal?.addEventListener("abort", abortChild, { once: true }); + if (options.signal?.aborted) { + abortChild(); + } + try { + const [stdout, stderr, exitCode] = await Promise.all([ + options.captureOutput ? new Response(child.stdout).text() : Promise.resolve(undefined), + new Response(child.stderr).text(), + child.exited, + ]); + await termination; + return { exitCode, stdout, stderr }; + } finally { + options.signal?.removeEventListener("abort", abortChild); + } } catch (error) { throw new HunkUserError( `Could not run ${executable}: ${error instanceof Error ? error.message : String(error)}`, @@ -355,7 +408,13 @@ export async function runSelfUpdateCommand( const alreadyCurrent = input.version ? installedVersion === targetVersion : !isComparableVersion(installedVersion) || !isNewerVersion(installedVersion, targetVersion); - const interactive = Boolean(io.stdoutIsTTY && io.output && !env.NO_COLOR && env.TERM !== "dumb"); + const interactive = Boolean( + io.stdoutIsTTY && + io.output && + !Object.hasOwn(env, "NO_COLOR") && + !Object.hasOwn(env, "CI") && + env.TERM !== "dumb", + ); if (alreadyCurrent) { if (interactive) { intro("Hunk update", { output: io.output }); @@ -375,14 +434,30 @@ export async function runSelfUpdateCommand( ); // Only the curl installer reads a version from its environment; every other channel names the // target in its argv, so the child otherwise inherits this process's environment untouched. + const cancellation = interactive ? new AbortController() : undefined; + let cancelSignal: NodeJS.Signals | undefined; + const rememberSigint = () => { + cancelSignal ??= "SIGINT"; + }; + const rememberSigterm = () => { + cancelSignal ??= "SIGTERM"; + }; + if (interactive) { + // Clack renders cancellation, while these listeners preserve which exit status the shell expects. + process.once("SIGINT", rememberSigint); + process.once("SIGTERM", rememberSigterm); + } + const commandOptions: SelfUpdateCommandOptions = { ...(installSource === "curl" ? { env: { ...env, [CURL_INSTALL_VERSION_ENV]: targetVersion } } : {}), - ...(interactive ? { captureOutput: true } : {}), + ...(interactive ? { captureOutput: true, signal: cancellation?.signal } : {}), }; - const progress = interactive ? spinner({ output: io.output }) : undefined; + const progress = interactive + ? spinner({ output: io.output, onCancel: () => cancellation?.abort() }) + : undefined; if (interactive) { intro("Hunk update", { output: io.output }); log.info(`Current ${installedVersion}`, { output: io.output }); @@ -401,6 +476,12 @@ export async function runSelfUpdateCommand( } catch (error) { progress?.error("Update failed"); throw error; + } finally { + process.removeListener("SIGINT", rememberSigint); + process.removeListener("SIGTERM", rememberSigterm); + } + if (cancelSignal) { + return cancelSignal === "SIGINT" ? 130 : 143; } if (result.exitCode !== 0) { progress?.error("Update failed");