From 5f44920c82b433d3ae84292647b76ff7ea954588 Mon Sep 17 00:00:00 2001 From: Sangbin Lee Date: Sat, 18 Jul 2026 14:19:43 +0900 Subject: [PATCH] feat: sync Herdr plugin during update --- .changeset/sync-herdr-plugin-on-update.md | 5 + README.md | 5 + src/app/use-cases/update-installation.test.ts | 51 +++++++ src/app/use-cases/update-installation.ts | 66 ++++++++- src/commands/update.ts | 29 ++++ src/infra/herdr/client.test.ts | 106 ++++++++++++++- src/infra/herdr/client.ts | 125 ++++++++++++++++++ src/infra/update/installed-version.test.ts | 24 ++++ src/infra/update/installed-version.ts | 25 ++++ 9 files changed, 430 insertions(+), 6 deletions(-) create mode 100644 .changeset/sync-herdr-plugin-on-update.md create mode 100644 src/infra/update/installed-version.test.ts create mode 100644 src/infra/update/installed-version.ts diff --git a/.changeset/sync-herdr-plugin-on-update.md b/.changeset/sync-herdr-plugin-on-update.md new file mode 100644 index 0000000..719089a --- /dev/null +++ b/.changeset/sync-herdr-plugin-on-update.md @@ -0,0 +1,5 @@ +--- +"@leesangb/wt": minor +--- + +Keep an installed wt Herdr plugin in sync with the wt release selected by `wt update`. diff --git a/README.md b/README.md index e17676b..b54b1d4 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,11 @@ Skip removing quarantine attribute for standalone binary installs: wt update --no-remove-quarantine ``` +If the `wt.herdr` plugin was installed from `leesangb/wt`, `wt update` also +syncs it to the same release tag. Locally linked plugins and plugins from other +repositories are left unchanged. Use `wt update --force` to reinstall an +already matching plugin version. + ### List all worktrees ```bash diff --git a/src/app/use-cases/update-installation.test.ts b/src/app/use-cases/update-installation.test.ts index b43517e..e0ccf1f 100644 --- a/src/app/use-cases/update-installation.test.ts +++ b/src/app/use-cases/update-installation.test.ts @@ -18,6 +18,8 @@ describe("update installation use case", () => { const delegatedCommands: string[] = []; const executedPlans: Array<{ args: string[]; displayCommand: string }> = []; const refreshedBinaries: string[] = []; + const versionedBinaries: string[] = []; + const syncedVersions: string[] = []; const result = await updateInstallation("0.3.0", {}, { processInfo: { @@ -26,6 +28,10 @@ describe("update installation use case", () => { }, onBeforeHomebrewUpdate: (command) => delegatedCommands.push(command), runHomebrewUpdate: (plan) => executedPlans.push(plan), + readInstalledVersion: (binaryPath) => { + versionedBinaries.push(binaryPath); + return "0.4.0"; + }, refreshShellWrappers: (binaryPath) => { refreshedBinaries.push(binaryPath); return { @@ -33,6 +39,10 @@ describe("update installation use case", () => { warnings: [], }; }, + syncHerdrPlugin: async (version) => { + syncedVersions.push(version); + return { status: "updated", version }; + }, }); expect(result).toEqual({ @@ -42,6 +52,7 @@ describe("update installation use case", () => { refreshedShells: ["zsh"], warnings: [], }, + herdrPlugin: { status: "updated", version: "0.4.0" }, }); expect(delegatedCommands).toEqual(["brew upgrade wt"]); expect(executedPlans).toEqual([ @@ -51,6 +62,8 @@ describe("update installation use case", () => { }, ]); expect(refreshedBinaries).toEqual(["/opt/homebrew/bin/wt"]); + expect(versionedBinaries).toEqual(["/opt/homebrew/bin/wt"]); + expect(syncedVersions).toEqual(["0.4.0"]); }); test("refreshes existing shell wrappers after a standalone binary update", async () => { @@ -58,6 +71,7 @@ describe("update installation use case", () => { const replacements: Array<{ tempPath: string; execPath: string }> = []; const quarantines: string[] = []; const refreshedBinaries: string[] = []; + const syncedHerdrPlugins: Array<{ version: string; force: boolean }> = []; const result = await updateInstallation( "0.6.0", @@ -86,6 +100,10 @@ describe("update installation use case", () => { warnings: [], }; }, + syncHerdrPlugin: async (version, { force }) => { + syncedHerdrPlugins.push({ version, force }); + return { status: "updated", version }; + }, } ); @@ -100,6 +118,7 @@ describe("update installation use case", () => { ]); expect(quarantines).toEqual(["/Users/test/.local/bin/wt"]); expect(refreshedBinaries).toEqual(["/Users/test/.local/bin/wt"]); + expect(syncedHerdrPlugins).toEqual([{ version: "0.6.1", force: false }]); expect(result).toEqual({ strategy: "standalone", currentVersion: "0.6.0", @@ -110,6 +129,38 @@ describe("update installation use case", () => { refreshedShells: ["bash", "zsh"], warnings: [], }, + herdrPlugin: { + status: "updated", + version: "0.6.1", + }, + }); + }); + + test("syncs the Herdr plugin when the standalone binary is already current", async () => { + const syncedVersions: string[] = []; + + const result = await updateInstallation( + "0.6.1", + { version: "0.6.1" }, + { + arch: "arm64", + platform: "darwin", + processInfo: { + argv0: "wt", + execPath: "/Users/test/.local/bin/wt", + }, + syncHerdrPlugin: async (version) => { + syncedVersions.push(version); + return { status: "up-to-date", version }; + }, + } + ); + + expect(syncedVersions).toEqual(["0.6.1"]); + expect(result).toMatchObject({ + strategy: "standalone", + updated: false, + herdrPlugin: { status: "up-to-date", version: "0.6.1" }, }); }); }); diff --git a/src/app/use-cases/update-installation.ts b/src/app/use-cases/update-installation.ts index 990b7b8..a41eae8 100644 --- a/src/app/use-cases/update-installation.ts +++ b/src/app/use-cases/update-installation.ts @@ -23,6 +23,11 @@ import { type HomebrewUpdatePlan, } from "../../infra/update/homebrew.js"; import { compareVersions } from "../../infra/update/version.js"; +import { + syncInstalledWtHerdrPlugin, + type HerdrPluginSyncResult, +} from "../../infra/herdr/client.js"; +import { readInstalledWtVersion } from "../../infra/update/installed-version.js"; const BINARY_NAME = "wt"; const RUNTIME_LAUNCHERS = new Set(["bun", "node"]); @@ -40,12 +45,14 @@ export interface StandaloneUpdateInstallationResult { updated: boolean; assetName?: string; shellIntegration?: RefreshShellWrappersResult; + herdrPlugin?: HerdrPluginSyncResult; } export interface HomebrewDelegatedUpdateResult { strategy: "homebrew"; delegatedCommand: string; shellIntegration: RefreshShellWrappersResult; + herdrPlugin?: HerdrPluginSyncResult; } export type UpdateInstallationResult = @@ -61,9 +68,14 @@ export interface UpdateInstallationContext { downloadBinary?: (url: string) => Promise; onBeforeHomebrewUpdate?: (command: string) => void; refreshShellWrappers?: (binaryPath: string) => RefreshShellWrappersResult; + readInstalledVersion?: (binaryPath: string) => string; removeMacosQuarantine?: (execPath: string) => Promise; replaceCurrentBinary?: (tempPath: string, execPath: string) => void; runHomebrewUpdate?: (plan: HomebrewUpdatePlan) => void; + syncHerdrPlugin?: ( + version: string, + options: { force: boolean } + ) => Promise; } function assertVersionFormat(version: string, label: string): void { @@ -132,6 +144,24 @@ function refreshShellWrappers( )(binaryPath); } +async function syncHerdrPlugin( + version: string, + options: UpdateInstallationOptions, + context: UpdateInstallationContext +): Promise { + try { + return await (context.syncHerdrPlugin ?? syncInstalledWtHerdrPlugin)( + version, + { force: options.force === true } + ); + } catch (error) { + return { + status: "failed", + warning: error instanceof Error ? error.message : String(error), + }; + } +} + async function updateStandaloneInstallation( currentVersion: string, options: UpdateInstallationOptions, @@ -174,12 +204,18 @@ async function updateStandaloneInstallation( assertVersionFormat(targetVersion, "release"); if (compareVersions(targetVersion, currentVersion) <= 0 && !options.force) { + const herdrPlugin = await syncHerdrPlugin( + currentVersion, + options, + context + ); return { strategy: "standalone", currentVersion, targetVersion, updated: false, assetName, + ...(herdrPlugin ? { herdrPlugin } : {}), }; } @@ -196,12 +232,18 @@ async function updateStandaloneInstallation( assertVersionFormat(targetVersion, "requested"); if (compareVersions(targetVersion, currentVersion) <= 0 && !options.force) { + const herdrPlugin = await syncHerdrPlugin( + currentVersion, + options, + context + ); return { strategy: "standalone", currentVersion, targetVersion, updated: false, assetName, + ...(herdrPlugin ? { herdrPlugin } : {}), }; } @@ -223,6 +265,7 @@ async function updateStandaloneInstallation( } const shellIntegration = refreshShellWrappers(binaryPath, context); + const herdrPlugin = await syncHerdrPlugin(targetVersion, options, context); return { strategy: "standalone", @@ -231,6 +274,7 @@ async function updateStandaloneInstallation( updated: true, assetName, shellIntegration, + ...(herdrPlugin ? { herdrPlugin } : {}), }; } @@ -246,15 +290,29 @@ export async function updateInstallation( context.onBeforeHomebrewUpdate?.(plan.displayCommand); (context.runHomebrewUpdate ?? runHomebrewUpdate)(plan); - const shellIntegration = refreshShellWrappers( - resolveHomebrewShellBinaryPath(context.processInfo), - context - ); + const binaryPath = resolveHomebrewShellBinaryPath(context.processInfo); + const shellIntegration = refreshShellWrappers(binaryPath, context); + let installedVersion: string | undefined; + let herdrPlugin: HerdrPluginSyncResult | undefined; + + try { + installedVersion = ( + context.readInstalledVersion ?? readInstalledWtVersion + )(binaryPath); + assertVersionFormat(installedVersion, "installed"); + herdrPlugin = await syncHerdrPlugin(installedVersion, options, context); + } catch (error) { + herdrPlugin = { + status: "failed", + warning: `Could not sync the wt Herdr plugin: ${error instanceof Error ? error.message : String(error)}`, + }; + } return { strategy: "homebrew", delegatedCommand: plan.displayCommand, shellIntegration, + ...(herdrPlugin ? { herdrPlugin } : {}), }; } diff --git a/src/commands/update.ts b/src/commands/update.ts index 3613253..9e0ed81 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -38,6 +38,32 @@ function printShellIntegrationRefresh(result: UpdateInstallationResult): void { } } +function printHerdrPluginSync(result: UpdateInstallationResult): void { + const plugin = result.herdrPlugin; + + if (!plugin) { + return; + } + + if (plugin.status === "updated") { + console.log( + chalk.green(`✓ Updated wt Herdr plugin to version ${plugin.version}.`) + ); + return; + } + + if (plugin.status === "up-to-date") { + console.log( + chalk.dim(`wt Herdr plugin is up to date (${plugin.version}).`) + ); + return; + } + + if (plugin.warning) { + console.log(chalk.yellow(`Warning: ${plugin.warning}`)); + } +} + export async function updateCommand( options: UpdateInstallationOptions ): Promise { @@ -55,6 +81,7 @@ export async function updateCommand( }, }); printShellIntegrationRefresh(result); + printHerdrPluginSync(result); return; } @@ -82,11 +109,13 @@ export async function updateCommand( ); console.log(chalk.dim("Use --force to re-download the current version.")); } + printHerdrPluginSync(result); return; } console.log(chalk.green(`✓ Updated wt to version ${result.targetVersion}`)); printShellIntegrationRefresh(result); + printHerdrPluginSync(result); console.log(chalk.dim("Run: wt --version to verify.")); }); } diff --git a/src/infra/herdr/client.test.ts b/src/infra/herdr/client.test.ts index b801fde..77be99d 100644 --- a/src/infra/herdr/client.test.ts +++ b/src/infra/herdr/client.test.ts @@ -1,5 +1,19 @@ -import { describe, expect, test } from "bun:test"; -import { openHerdrWorktree } from "./client.js"; +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + openHerdrWorktree, + syncInstalledWtHerdrPlugin, +} from "./client.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); describe("Herdr client", () => { test("does nothing outside a Herdr-managed pane", async () => { @@ -8,4 +22,92 @@ describe("Herdr client", () => { opened: false, }); }); + + test("updates a GitHub-managed wt plugin to the matching release tag", async () => { + const dir = mkdtempSync(join(tmpdir(), "wt-herdr-sync-")); + tempDirs.push(dir); + const fakeHerdr = join(dir, "herdr"); + const logPath = join(dir, "args.log"); + writeFileSync( + fakeHerdr, + [ + "#!/bin/sh", + 'if [ "$1 $2" = "plugin list" ]; then', + ` printf '%s\\n' '{"result":{"plugins":[{"plugin_id":"wt.herdr","source":{"kind":"github","owner":"leesangb","repo":"wt","requested_ref":"v0.8.0"}}]}}'`, + " exit 0", + "fi", + `printf '%s\\n' "$*" > "${logPath}"`, + "", + ].join("\n"), + { mode: 0o755 } + ); + + expect( + await syncInstalledWtHerdrPlugin("0.9.0", {}, { + HERDR_BIN_PATH: fakeHerdr, + }) + ).toEqual({ status: "updated", version: "0.9.0" }); + expect(readFileSync(logPath, "utf-8")).toBe( + "plugin install leesangb/wt --ref v0.9.0 --yes\n" + ); + }); + + test("does not replace a locally linked wt plugin", async () => { + const dir = mkdtempSync(join(tmpdir(), "wt-herdr-sync-")); + tempDirs.push(dir); + const fakeHerdr = join(dir, "herdr"); + writeFileSync( + fakeHerdr, + [ + "#!/bin/sh", + `printf '%s\\n' '{"result":{"plugins":[{"plugin_id":"wt.herdr","source":{"kind":"linked"}}]}}'`, + "", + ].join("\n"), + { mode: 0o755 } + ); + + expect( + await syncInstalledWtHerdrPlugin("0.9.0", {}, { + HERDR_BIN_PATH: fakeHerdr, + }) + ).toEqual({ + status: "skipped-linked", + warning: "The wt Herdr plugin is locally linked; leaving it unchanged.", + }); + }); + + test("skips an exact plugin ref unless force is enabled", async () => { + const dir = mkdtempSync(join(tmpdir(), "wt-herdr-sync-")); + tempDirs.push(dir); + const fakeHerdr = join(dir, "herdr"); + const logPath = join(dir, "args.log"); + writeFileSync( + fakeHerdr, + [ + "#!/bin/sh", + 'if [ "$1 $2" = "plugin list" ]; then', + ` printf '%s\\n' '{"result":{"plugins":[{"plugin_id":"wt.herdr","source":{"kind":"github","owner":"leesangb","repo":"wt","requested_ref":"v0.9.0"}}]}}'`, + " exit 0", + "fi", + `printf '%s\\n' "$*" > "${logPath}"`, + "", + ].join("\n"), + { mode: 0o755 } + ); + + expect( + await syncInstalledWtHerdrPlugin("0.9.0", {}, { + HERDR_BIN_PATH: fakeHerdr, + }) + ).toEqual({ status: "up-to-date", version: "0.9.0" }); + + expect( + await syncInstalledWtHerdrPlugin("0.9.0", { force: true }, { + HERDR_BIN_PATH: fakeHerdr, + }) + ).toEqual({ status: "updated", version: "0.9.0" }); + expect(readFileSync(logPath, "utf-8")).toBe( + "plugin install leesangb/wt --ref v0.9.0 --yes\n" + ); + }); }); diff --git a/src/infra/herdr/client.ts b/src/infra/herdr/client.ts index 2a4bf79..50ef8b2 100644 --- a/src/infra/herdr/client.ts +++ b/src/infra/herdr/client.ts @@ -36,6 +36,23 @@ export interface InstallHerdrPluginResult { error?: string; } +export interface HerdrPluginSyncResult { + status: + | "updated" + | "up-to-date" + | "not-installed" + | "unavailable" + | "skipped-linked" + | "skipped-foreign" + | "failed"; + version?: string; + warning?: string; +} + +export interface SyncHerdrPluginOptions { + force?: boolean; +} + function isHerdrEnvironment( env: HerdrEnvironment ): env is HerdrEnvironment & { HERDR_WORKSPACE_ID: string } { @@ -79,6 +96,114 @@ export async function installHerdrPlugin( } } +export async function syncInstalledWtHerdrPlugin( + version: string, + options: SyncHerdrPluginOptions = {}, + env: HerdrEnvironment = process.env +): Promise { + const herdr = env.HERDR_BIN_PATH ?? "herdr"; + const normalizedVersion = version.replace(/^v/, ""); + const ref = `v${normalizedVersion}`; + + try { + const listProc = spawn( + [herdr, "plugin", "list", "--plugin", "wt.herdr", "--json"], + { env, stdout: "pipe", stderr: "pipe" } + ); + const [stdout, stderr, listExitCode] = await Promise.all([ + new Response(listProc.stdout).text(), + new Response(listProc.stderr).text(), + listProc.exited, + ]); + + if (listExitCode !== 0) { + return { + status: "failed", + warning: + stderr.trim() || `herdr plugin list exited with code ${listExitCode}`, + }; + } + + const response = JSON.parse(stdout) as { + result?: { + plugins?: Array<{ + plugin_id?: string; + source?: { + kind?: string; + owner?: string; + repo?: string; + requested_ref?: string; + }; + }>; + }; + }; + const plugin = response.result?.plugins?.find( + ({ plugin_id }) => plugin_id === "wt.herdr" + ); + + if (!plugin) { + return { status: "not-installed" }; + } + + if (!plugin.source || plugin.source.kind !== "github") { + return { + status: "skipped-linked", + warning: "The wt Herdr plugin is locally linked; leaving it unchanged.", + }; + } + + if (plugin.source.owner !== "leesangb" || plugin.source.repo !== "wt") { + return { + status: "skipped-foreign", + warning: "The installed wt.herdr plugin comes from a different repository; leaving it unchanged.", + }; + } + + if (plugin.source.requested_ref === ref && !options.force) { + return { status: "up-to-date", version: normalizedVersion }; + } + + const installProc = spawn( + [ + herdr, + "plugin", + "install", + "leesangb/wt", + "--ref", + ref, + "--yes", + ], + { env, stdout: "pipe", stderr: "pipe" } + ); + const [, installStderr, installExitCode] = await Promise.all([ + new Response(installProc.stdout).text(), + new Response(installProc.stderr).text(), + installProc.exited, + ]); + + if (installExitCode !== 0) { + return { + status: "failed", + warning: + installStderr.trim() || + `herdr plugin install exited with code ${installExitCode}`, + }; + } + + return { status: "updated", version: normalizedVersion }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return { status: "unavailable" }; + } + + return { + status: "failed", + warning: error instanceof Error ? error.message : String(error), + }; + } +} + export async function openHerdrWorktree( path: string, label: string, diff --git a/src/infra/update/installed-version.test.ts b/src/infra/update/installed-version.test.ts new file mode 100644 index 0000000..81a3f5e --- /dev/null +++ b/src/infra/update/installed-version.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { readInstalledWtVersion } from "./installed-version.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("readInstalledWtVersion", () => { + test("reads the semantic version from the installed binary", () => { + const dir = mkdtempSync(join(tmpdir(), "wt-version-")); + tempDirs.push(dir); + const binary = join(dir, "wt"); + writeFileSync(binary, "#!/bin/sh\nprintf 'wt 1.2.3\\n'\n", { mode: 0o755 }); + + expect(readInstalledWtVersion(binary)).toBe("1.2.3"); + }); +}); diff --git a/src/infra/update/installed-version.ts b/src/infra/update/installed-version.ts new file mode 100644 index 0000000..fceaa4c --- /dev/null +++ b/src/infra/update/installed-version.ts @@ -0,0 +1,25 @@ +import { spawnSync } from "node:child_process"; + +export function readInstalledWtVersion(binaryPath: string): string { + const result = spawnSync(binaryPath, ["--version"], { + encoding: "utf8", + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + throw new Error( + result.stderr.trim() || + `${binaryPath} --version exited with code ${result.status ?? "unknown"}` + ); + } + + const match = result.stdout.trim().match(/(?:^|\s)v?(\d+\.\d+\.\d+)(?:\s|$)/); + if (!match?.[1]) { + throw new Error(`Could not read the installed wt version from ${binaryPath}`); + } + + return match[1]; +}