From 68b4a8b4789fd7288815c03c3ae847b8eae2971b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:07:16 +0000 Subject: [PATCH 1/8] feat(extensions): gate manifest apiVersion requirements at load Shared extensions need a way to state the minimum extension API they were built against, so an older Hunk refuses them with one actionable startup notice instead of failing somewhere inside the factory. The manifest's hunk field now accepts apiVersion alongside extensions, discovery carries it on the candidate, and the host gates it with the id checks before anything is imported. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb --- docs/extensions.md | 22 +++++++++ src/extensions/discovery.test.ts | 60 +++++++++++++++++++++++ src/extensions/discovery.ts | 82 ++++++++++++++++++++++++-------- src/extensions/host.test.ts | 68 +++++++++++++++++++++++++- src/extensions/host.ts | 28 +++++++++-- src/extensions/types.ts | 6 +++ 6 files changed, 242 insertions(+), 24 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index e28f8bf2e..54e2548d2 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -73,6 +73,28 @@ Because the manifest is a real `package.json`, a folder extension may depend on npm packages: declare them, install them into the folder's own `node_modules`, and imports resolve from the entry file the way they do in any other package. +The `hunk` field may also state the minimum extension API version the folder +needs: + +```json +{ + "name": "my-ext", + "version": "1.0.0", + "description": "What the extension does", + "hunk": { "extensions": ["./src/index.ts"], "apiVersion": 3 } +} +``` + +A Hunk whose extension API is older than `apiVersion` refuses the folder with a +startup notice naming the version it would need, instead of failing somewhere +inside the factory with whatever error the missing surface happens to produce. +Omit it while you only use surface that has been around a while; declare it when +you depend on something recent (the current version is exported as +`HUNK_EXTENSION_API_VERSION` from `hunkdiff/extension` and handed to factories +as `hunk.apiVersion`). The standard `name`, `version`, and `description` fields +are how tooling and humans identify a shared extension, so fill them in on +anything you publish. + Pointing `--extension` or `[extensions] paths` straight at a directory works either way: a directory that is itself a folder extension loads as that one extension, so its helper modules stay helpers. A directory that is not is diff --git a/src/extensions/discovery.test.ts b/src/extensions/discovery.test.ts index fb36d077c..9999ad731 100644 --- a/src/extensions/discovery.test.ts +++ b/src/extensions/discovery.test.ts @@ -469,3 +469,63 @@ describe("tilde paths", () => { expect(candidates[0]?.path).toBe(resolve("/somewhere/else", "~someone/ext.ts")); }); }); + +describe("manifest api version requirements", () => { + test("attaches hunk.apiVersion to every entry the manifest declares", () => { + const root = createTempDir("hunk-ext-manifest-api-"); + const folder = join(root, "api-ext"); + writeExtensionManifest( + folder, + JSON.stringify({ hunk: { extensions: ["./entry.ts"], apiVersion: 9 } }), + ); + const entry = writeExtensionFile(folder, "entry.ts"); + + const candidates = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [folder], + }); + + expect(candidates).toEqual([ + { id: "api-ext", path: entry, origin: "flag", requiresApiVersion: 9 }, + ]); + }); + + test("applies hunk.apiVersion to the index fallback when no entries are declared", () => { + const root = createTempDir("hunk-ext-manifest-api-index-"); + const folder = join(root, "api-index-ext"); + writeExtensionManifest(folder, JSON.stringify({ hunk: { apiVersion: 2 } })); + const index = writeExtensionFile(folder, "index.ts"); + + const candidates = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [folder], + }); + + expect(candidates).toEqual([ + { id: "api-index-ext", path: index, origin: "flag", requiresApiVersion: 2 }, + ]); + }); + + test("ignores a malformed apiVersion instead of dropping the folder", () => { + const root = createTempDir("hunk-ext-manifest-api-bad-"); + const folder = join(root, "bad-api-ext"); + writeExtensionManifest( + folder, + JSON.stringify({ hunk: { extensions: ["./entry.ts"], apiVersion: "4" } }), + ); + const entry = writeExtensionFile(folder, "entry.ts"); + + const candidates = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [folder], + }); + + expect(candidates).toEqual([{ id: "bad-api-ext", path: entry, origin: "flag" }]); + }); +}); diff --git a/src/extensions/discovery.ts b/src/extensions/discovery.ts index 7c8987742..33bb5861c 100644 --- a/src/extensions/discovery.ts +++ b/src/extensions/discovery.ts @@ -22,6 +22,8 @@ interface DiscoveredExtensionEntry { id: string; path: string; sortKey: string; + /** Minimum extension API version the folder's manifest declared, if any. */ + requiresApiVersion?: number; } /** Describe one standalone entry file, which sorts and is named by its own path. */ @@ -77,19 +79,30 @@ function findFolderExtensionIndex(dir: string) { return indexBasename ? join(dir, indexBasename) : undefined; } +/** What one folder extension's `package.json` manifest declares. */ +interface ExtensionManifest { + /** Absolute entry paths from `hunk.extensions`, or nothing when undeclared. */ + entryPaths?: string[]; + /** Minimum extension API version from `hunk.apiVersion`, or nothing when undeclared. */ + requiresApiVersion?: number; +} + /** - * Read the entry paths one folder extension declares in its `package.json`. + * Read one folder extension's `package.json` manifest. * * The manifest field is `"hunk": { "extensions": ["./src/index.ts"] }`, and each * declared path resolves against the folder. A declared path that does not exist * is kept rather than filtered out, matching the posture for explicit paths: the * host reports it as a load issue instead of the entry silently vanishing. + * `"hunk": { "apiVersion": 3 }` states the minimum extension API version the + * folder needs; the host refuses to load it on an older Hunk with a clear issue + * instead of failing partway through the factory. * * Anything that goes wrong — no `package.json`, an unreadable one, malformed * JSON, or a field of the wrong shape — means "no manifest", so a folder that * merely happens to ship a `package.json` still falls back to its index entry. */ -function readManifestEntryPaths(dir: string) { +function readExtensionManifest(dir: string): ExtensionManifest | undefined { let manifest: unknown; try { manifest = JSON.parse(fs.readFileSync(join(dir, "package.json"), "utf8")); @@ -107,15 +120,25 @@ function readManifestEntryPaths(dir: string) { } const declared = (section as Record).extensions; - if (!Array.isArray(declared)) { - return undefined; - } - // Non-string items are skipped rather than fatal; one bad array item should // not cost the folder the entries it declared correctly. - return declared - .filter((entry): entry is string => typeof entry === "string") - .map((entry) => resolve(dir, entry)); + const entryPaths = Array.isArray(declared) + ? declared + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => resolve(dir, entry)) + : undefined; + + // A malformed apiVersion is ignored rather than fatal, matching the "no + // manifest" posture for every other malformed field. + const declaredApiVersion = (section as Record).apiVersion; + const requiresApiVersion = + typeof declaredApiVersion === "number" && + Number.isInteger(declaredApiVersion) && + declaredApiVersion > 0 + ? declaredApiVersion + : undefined; + + return { entryPaths, requiresApiVersion }; } /** Assign deterministic, distinct ids to every entry in one manifest. */ @@ -159,23 +182,33 @@ function deriveManifestEntryIds(paths: readonly string[]) { * Returns an empty list when the folder is not an extension at all. */ function resolveFolderExtensionEntries(dir: string): DiscoveredExtensionEntry[] { - const manifestPaths = readManifestEntryPaths(dir); + const manifest = readExtensionManifest(dir); + const manifestPaths = manifest?.entryPaths; + /** Attach the manifest's api requirement so the host can gate before importing. */ + const withApiVersion = (entry: DiscoveredExtensionEntry): DiscoveredExtensionEntry => + manifest?.requiresApiVersion !== undefined + ? { ...entry, requiresApiVersion: manifest.requiresApiVersion } + : entry; if (manifestPaths && manifestPaths.length > 0) { const folderName = basename(dir); const manifestIds = deriveManifestEntryIds(manifestPaths); - return manifestPaths.map((path, index) => ({ - id: - manifestPaths.length === 1 && folderName.length > 0 - ? folderName - : (manifestIds[index] ?? deriveExtensionId(path)), - path, - sortKey: dir, - })); + return manifestPaths.map((path, index) => + withApiVersion({ + id: + manifestPaths.length === 1 && folderName.length > 0 + ? folderName + : (manifestIds[index] ?? deriveExtensionId(path)), + path, + sortKey: dir, + }), + ); } + // The apiVersion requirement still applies to the index fallback: a manifest + // may state compatibility without redeclaring the entry file. const folderIndex = findFolderExtensionIndex(dir); - return folderIndex ? [toStandaloneEntry(folderIndex)] : []; + return folderIndex ? [withApiVersion(toStandaloneEntry(folderIndex))] : []; } /** @@ -306,7 +339,16 @@ export function discoverExtensions(options: DiscoverExtensionsOptions = {}): Ext } seenPaths.add(entry.path); - candidates.push({ id: entry.id, path: entry.path, origin: group.origin }); + candidates.push({ + id: entry.id, + path: entry.path, + origin: group.origin, + // Attached only when declared so candidate equality stays byte-stable + // for the common manifest-less case. + ...(entry.requiresApiVersion !== undefined + ? { requiresApiVersion: entry.requiresApiVersion } + : {}), + }); } } diff --git a/src/extensions/host.test.ts b/src/extensions/host.test.ts index a26362514..167d525b8 100644 --- a/src/extensions/host.test.ts +++ b/src/extensions/host.test.ts @@ -8,7 +8,12 @@ import type { VcsAdapter } from "../core/vcs/types"; import { discoverExtensions } from "./discovery"; import { loadExtensions } from "./host"; import { createExtensionNotificationHub } from "./notifications"; -import { deriveExtensionId, type ExtensionCandidate, type ExtensionOrigin } from "./types"; +import { + deriveExtensionId, + HUNK_EXTENSION_API_VERSION, + type ExtensionCandidate, + type ExtensionOrigin, +} from "./types"; const tempDirs: string[] = []; @@ -561,3 +566,64 @@ export default function (hunk: { registerSidebarView: (view: unknown) => void }) expect(seen).toEqual(["ignored"]); }); }); + +describe("manifest api version gating", () => { + test("refuses a candidate requiring a newer extension API without importing it", async () => { + const dir = createTempDir("hunk-host-api-gate-"); + // A syntax error proves refusal happens before the module is ever imported. + const path = writeTestFile(dir, "future.ts", "this is not valid typescript {{{"); + const candidate: ExtensionCandidate = { + id: "future", + path, + origin: "global", + requiresApiVersion: 999, + }; + + const result = await loadExtensions({ candidates: [candidate], cwd: dir }); + + expect(result.loaded).toHaveLength(0); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]?.extensionId).toBe("future"); + expect(result.issues[0]?.message).toContain("requires Hunk extension API v999"); + expect(result.issues[0]?.message).toContain("upgrade Hunk"); + }); + + test("loads a candidate whose requirement matches the current extension API", async () => { + const dir = createTempDir("hunk-host-api-ok-"); + const candidate = { + ...createTestExtension( + dir, + "current.ts", + `export default (hunk) => { hunk.registerFileLanguage("xyz", "xml"); };\n`, + ), + requiresApiVersion: HUNK_EXTENSION_API_VERSION, + }; + + const result = await loadExtensions({ candidates: [candidate], cwd: dir }); + + expect(result.issues).toHaveLength(0); + expect(result.loaded.map((extension) => extension.id)).toEqual(["current"]); + }); + + test("lets a later compatible source claim an id refused for its api requirement", async () => { + const dir = createTempDir("hunk-host-api-dup-"); + const refusedPath = writeTestFile(dir, "newer/tool.ts", "export default () => {};\n"); + const compatible = createTestExtension( + join(dir, "older"), + "tool.ts", + `export default (hunk) => { hunk.registerFileLanguage("abc", "xml"); };\n`, + ); + + const result = await loadExtensions({ + candidates: [ + { id: "tool", path: refusedPath, origin: "global", requiresApiVersion: 999 }, + compatible, + ], + cwd: dir, + }); + + expect(result.issues).toHaveLength(1); + expect(result.issues[0]?.path).toBe(refusedPath); + expect(result.loaded.map((extension) => extension.id)).toEqual(["tool"]); + }); +}); diff --git a/src/extensions/host.ts b/src/extensions/host.ts index aa2a16023..57702116e 100644 --- a/src/extensions/host.ts +++ b/src/extensions/host.ts @@ -9,6 +9,7 @@ import { resolveRepoTrust, type ExtensionTrustOptions, type ExtensionTrustState import { createEmptyExtensionRegistry, createExtensionContext, + HUNK_EXTENSION_API_VERSION, type ExtensionCandidate, type ExtensionFactory, type ExtensionLoadIssue, @@ -88,7 +89,25 @@ interface AcceptedCandidates { } /** - * Gate every candidate's id before anything is imported. + * State why one candidate's manifest is incompatible, or nothing when it loads. + * + * A manifest that requires a newer extension API than this Hunk provides would + * fail somewhere inside its factory with whatever error the missing surface + * happens to produce; refusing it here turns that into one actionable message. + */ +function describeApiVersionRefusal(candidate: ExtensionCandidate): string | undefined { + if ( + candidate.requiresApiVersion === undefined || + candidate.requiresApiVersion <= HUNK_EXTENSION_API_VERSION + ) { + return undefined; + } + + return `requires Hunk extension API v${candidate.requiresApiVersion}, but this Hunk provides v${HUNK_EXTENSION_API_VERSION} • upgrade Hunk to load ${candidate.path}`; +} + +/** + * Gate every candidate before anything is imported. * * This is the one enforcement point: discovery stays a pure filesystem walk * with no issue channel, and every way an id can be produced — file stem, @@ -96,7 +115,9 @@ interface AcceptedCandidates { * arrives here as `candidate.id`, so one gate covers all of them. Duplicates * across discovery sources resolve first-wins, the same tiebreak the registry * uses everywhere else, with the loser reported rather than silently sharing - * the winner's config table, command ids, and view keys. + * the winner's config table, command ids, and view keys. Manifest API-version + * requirements gate here too; a refused candidate does not claim its id, so a + * compatible same-id candidate from a later source may still load. */ function acceptCandidateIds( candidates: readonly ExtensionCandidate[], @@ -108,7 +129,8 @@ function acceptCandidateIds( const claimedBy = new Map(initialClaims); for (const candidate of candidates) { - const refusal = describeIdRefusal(candidate, claimedBy, reservedIds); + const refusal = + describeIdRefusal(candidate, claimedBy, reservedIds) ?? describeApiVersionRefusal(candidate); if (refusal !== undefined) { issues.push({ extensionId: candidate.id, diff --git a/src/extensions/types.ts b/src/extensions/types.ts index d67f0a9a1..062a90e57 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -120,6 +120,12 @@ export interface ExtensionCandidate { /** Absolute, resolved path to the entry file. */ path: string; origin: ExtensionOrigin; + /** + * Minimum extension API version the folder's manifest requires + * (`"hunk": { "apiVersion": N }`). The host refuses the candidate before + * importing it when this Hunk's API is older. + */ + requiresApiVersion?: number; } export interface RegisteredTheme { From 826e8be5f97e867615674d9fad24c22ed0e5b570 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:41:04 +0000 Subject: [PATCH 2/8] feat(cli): add hunk extension install/list/update/remove Sharing an extension previously meant telling users to clone and cp by hand. hunk extension install clones a git source (owner/repo shorthand, git:host/path, full URLs, or a local path, each with an optional @ref) into a managed directory under the global extensions dir, validates it actually contains an extension, installs its npm dependencies when it declares any, and records the source and resolved commit so list, update, and remove can operate on exactly what Hunk installed. Installs are confirmed interactively (or with --yes) because extensions run with full user permissions, and discovery loads the managed directory through the existing global origin so no new trust surface is introduced. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb --- src/app/startup.ts | 12 + src/core/cli.test.ts | 75 ++++ src/core/cli.ts | 139 ++++++- src/core/paths.ts | 14 + src/core/types.ts | 37 +- src/extensions/discovery.ts | 63 ++- src/extensions/manage/cli.ts | 146 +++++++ src/extensions/manage/install.test.ts | 272 +++++++++++++ src/extensions/manage/install.ts | 374 ++++++++++++++++++ src/extensions/manage/records.ts | 84 ++++ src/extensions/manage/source.test.ts | 68 ++++ src/extensions/manage/source.ts | 128 ++++++ src/main.tsx | 29 ++ .../src/content/docs/docs/reference/cli.md | 48 +++ 14 files changed, 1483 insertions(+), 6 deletions(-) create mode 100644 src/extensions/manage/cli.ts create mode 100644 src/extensions/manage/install.test.ts create mode 100644 src/extensions/manage/install.ts create mode 100644 src/extensions/manage/records.ts create mode 100644 src/extensions/manage/source.test.ts create mode 100644 src/extensions/manage/source.ts diff --git a/src/app/startup.ts b/src/app/startup.ts index 2056eff2a..4b927e1e2 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -22,6 +22,7 @@ import { import type { AppBootstrap } from "./types"; import type { CliInput, + ExtensionManageCommandInput, MarkupRenderCommandInput, ParsedCliInput, SessionCommandInput, @@ -64,6 +65,10 @@ export type StartupPlan = | { kind: "markup-guide"; } + | { + kind: "extension-manage"; + input: ExtensionManageCommandInput; + } | { kind: "app"; bootstrap: AppBootstrap; @@ -163,6 +168,13 @@ export async function prepareStartupPlan( }; } + if (parsedCliInput.kind === "extension-manage") { + return { + kind: "extension-manage", + input: parsedCliInput, + }; + } + if (parsedCliInput.kind === "pager") { const stdinText = await readStdinText(); const pagerOptions = parsedCliInput.options; diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 8c786ec12..e9e553fe5 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -1564,3 +1564,78 @@ describe("parseCli extension flags", () => { expect(parsed.text).toContain("--no-extensions"); }); }); + +describe("parseCli extension management commands", () => { + test("parses install with its source and confirmation flag", async () => { + const parsed = await parseCli(["bun", "hunk", "extension", "install", "acme/hunk-ext@v1"]); + expect(parsed).toEqual({ + kind: "extension-manage", + action: "install", + source: "acme/hunk-ext@v1", + yes: false, + }); + + const confirmed = await parseCli([ + "bun", + "hunk", + "extension", + "install", + "acme/hunk-ext", + "--yes", + ]); + expect(confirmed).toEqual({ + kind: "extension-manage", + action: "install", + source: "acme/hunk-ext", + yes: true, + }); + }); + + test("parses list, update, and remove with their targets", async () => { + expect(await parseCli(["bun", "hunk", "extension", "list"])).toEqual({ + kind: "extension-manage", + action: "list", + }); + expect(await parseCli(["bun", "hunk", "extension", "update"])).toEqual({ + kind: "extension-manage", + action: "update", + name: undefined, + }); + expect(await parseCli(["bun", "hunk", "extension", "update", "hunk-ext"])).toEqual({ + kind: "extension-manage", + action: "update", + name: "hunk-ext", + }); + expect(await parseCli(["bun", "hunk", "extension", "remove", "hunk-ext"])).toEqual({ + kind: "extension-manage", + action: "remove", + name: "hunk-ext", + }); + // Familiar spellings from other package managers resolve to remove. + expect(await parseCli(["bun", "hunk", "extension", "uninstall", "hunk-ext"])).toEqual({ + kind: "extension-manage", + action: "remove", + name: "hunk-ext", + }); + }); + + test("shows extension help for the bare command and rejects unknown subcommands", async () => { + const parsed = await parseCli(["bun", "hunk", "extension"]); + expect(parsed.kind).toBe("help"); + if (parsed.kind === "help") { + expect(parsed.text).toContain("hunk extension install "); + expect(parsed.text).toContain("only install repositories you trust"); + expect(parsed.text).toContain("hunk-extension"); + } + + expect(parseCli(["bun", "hunk", "extension", "publish"])).rejects.toThrow( + /Supported extension subcommands/, + ); + }); + + test("session reload refuses to nest an extension management command", async () => { + expect( + parseCli(["bun", "hunk", "session", "reload", "abc123", "--", "extension", "list"]), + ).rejects.toThrow(/review command/); + }); +}); diff --git a/src/core/cli.ts b/src/core/cli.ts index 098f6c5d3..704f81724 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -5,6 +5,7 @@ import type { CliInput, CommonOptions, CursorLine, + ExtensionManageCommandInput, HelpCommandInput, LayoutMode, PagerCommandInput, @@ -188,6 +189,36 @@ export const CLI_REFERENCE_COMMANDS = { summary: "print a bundled Hunk skill path", synopsis: ["hunk skill path [name]"], }, + "extension-install": { + path: "extension install", + summary: "install a shared extension from a git repository", + synopsis: [ + "hunk extension install /[@ref]", + "hunk extension install git:/[@ref]", + "hunk extension install [@ref]", + ], + options: [ + { + flag: "--yes", + description: "skip the confirmation prompt (required without a TTY)", + }, + ], + }, + "extension-list": { + path: "extension list", + summary: "list extensions installed with `hunk extension install`", + synopsis: ["hunk extension list"], + }, + "extension-update": { + path: "extension update", + summary: "re-clone managed extension installs from their recorded sources", + synopsis: ["hunk extension update [name]"], + }, + "extension-remove": { + path: "extension remove", + summary: "remove one managed extension install", + synopsis: ["hunk extension remove "], + }, "daemon-serve": { path: "daemon serve", summary: "run the local Hunk session daemon and websocket session broker", @@ -386,6 +417,7 @@ function renderCliHelp() { " hunk markup render ( | -) preview experimental STML note markup", " 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 daemon serve run the local Hunk session daemon", "", "Global options:", @@ -807,7 +839,8 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput { input.kind === "pager" || input.kind === "daemon-serve" || input.kind === "markup-render" || - input.kind === "markup-guide" + input.kind === "markup-guide" || + input.kind === "extension-manage" ) { throw new Error( "Session reload requires a Hunk review command after --, such as `diff` or `show`.", @@ -1381,6 +1414,108 @@ async function parseSkillCommand(tokens: string[]): Promise { }; } +const EXTENSION_MANAGE_HELP = [ + "Usage:", + " hunk extension install [--yes]", + " hunk extension list", + " hunk extension update [name]", + " hunk extension remove ", + "", + "Install and manage shared extensions.", + "", + "install clone an extension repository into Hunk's managed install directory;", + " sources are /[@ref], git:/[@ref], a git URL,", + " or a local path. Installed extensions run with your full user", + " permissions — only install repositories you trust.", + "list show every managed install with its version, commit, and source", + "update re-clone one managed install (or all of them) from its source", + "remove delete one managed install and its record", + "", + "Find community extensions by browsing the hunk-extension topic on GitHub:", + "https://github.com/topics/hunk-extension", + "", +].join("\n"); + +/** Parse `hunk extension ...` managed-install commands. */ +async function parseExtensionCommand( + tokens: string[], +): Promise { + const [subcommand, ...rest] = tokens; + if (!subcommand || subcommand === "--help" || subcommand === "-h") { + return { kind: "help", text: EXTENSION_MANAGE_HELP }; + } + + if (subcommand === "install") { + const command = createCliReferenceCommand("extension-install").argument( + "", + "owner/repo[@ref], git:host/path[@ref], a git URL, or a local path", + ); + + let parsedSource = ""; + let parsedOptions: { yes?: boolean } = {}; + command.action((source: string, options: { yes?: boolean }) => { + parsedSource = source; + parsedOptions = options; + }); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + return { + kind: "extension-manage", + action: "install", + source: parsedSource, + yes: parsedOptions.yes ?? false, + }; + } + + if (subcommand === "list") { + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: EXTENSION_MANAGE_HELP }; + } + if (rest.length > 0) { + throw new Error("`hunk extension list` does not accept additional arguments."); + } + return { kind: "extension-manage", action: "list" }; + } + + if (subcommand === "update") { + const command = createCliReferenceCommand("extension-update").argument("[name]"); + + let parsedName: string | undefined; + command.action((name: string | undefined) => { + parsedName = name; + }); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + return { kind: "extension-manage", action: "update", name: parsedName }; + } + + if (subcommand === "remove" || subcommand === "rm" || subcommand === "uninstall") { + const command = createCliReferenceCommand("extension-remove").argument(""); + + let parsedName = ""; + command.action((name: string) => { + parsedName = name; + }); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + return { kind: "extension-manage", action: "remove", name: parsedName }; + } + + throw new Error("Supported extension subcommands are install, list, update, and remove."); +} + /** Parse `hunk daemon serve` as the canonical local daemon entrypoint. */ async function parseDaemonCommand(tokens: string[]): Promise { const [subcommand, ...rest] = tokens; @@ -1509,6 +1644,8 @@ export async function parseCli(argv: string[]): Promise { return parseMarkupCommand(rest); case "skill": return parseSkillCommand(rest); + case "extension": + return parseExtensionCommand(rest); case "daemon": case "mcp": return parseDaemonCommand(rest); diff --git a/src/core/paths.ts b/src/core/paths.ts index ce52be544..b568c5ff4 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -104,6 +104,20 @@ export function resolveGlobalExtensionsDir(env: NodeJS.ProcessEnv = process.env) return configDir ? join(configDir, "hunk", "extensions") : undefined; } +/** + * Directory inside the global extensions dir that `hunk extension install` + * owns. It is not itself a folder extension, so a plain scan of the global + * dir skips it; discovery scans its subdirectories — one per installed + * repository — explicitly. + */ +export const INSTALLED_EXTENSIONS_DIR_NAME = "installed"; + +/** Resolve the managed install root for `hunk extension install`. */ +export function resolveInstalledExtensionsRoot(env: NodeJS.ProcessEnv = process.env) { + const extensionsDir = resolveGlobalExtensionsDir(env); + return extensionsDir ? join(extensionsDir, INSTALLED_EXTENSIONS_DIR_NAME) : undefined; +} + /** Search one path and its parents for one relative child path. */ function findRelativePathFromAncestors(startPath: string, relativePath: string) { let current = resolve(startPath); diff --git a/src/core/types.ts b/src/core/types.ts index a3b941a0e..f19510db4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -352,6 +352,40 @@ export interface MarkupGuideCommandInput { kind: "markup-guide"; } +export interface ExtensionInstallCommandInput { + kind: "extension-manage"; + action: "install"; + /** Install source spec: owner/repo, git:host/path, a git URL, or a local path. */ + source: string; + /** Skip the interactive confirmation (required when stdin is not a TTY). */ + yes: boolean; +} + +export interface ExtensionListCommandInput { + kind: "extension-manage"; + action: "list"; +} + +export interface ExtensionUpdateCommandInput { + kind: "extension-manage"; + action: "update"; + /** One managed install to update; every managed install when omitted. */ + name?: string; +} + +export interface ExtensionRemoveCommandInput { + kind: "extension-manage"; + action: "remove"; + name: string; +} + +/** `hunk extension ...` managed-install commands. */ +export type ExtensionManageCommandInput = + | ExtensionInstallCommandInput + | ExtensionListCommandInput + | ExtensionUpdateCommandInput + | ExtensionRemoveCommandInput; + export type ParsedCliInput = | CliInput | HelpCommandInput @@ -359,7 +393,8 @@ export type ParsedCliInput = | DaemonServeCommandInput | SessionCommandInput | MarkupRenderCommandInput - | MarkupGuideCommandInput; + | MarkupGuideCommandInput + | ExtensionManageCommandInput; export interface ReloadContext { cwd: string; diff --git a/src/extensions/discovery.ts b/src/extensions/discovery.ts index 33bb5861c..2ad10298d 100644 --- a/src/extensions/discovery.ts +++ b/src/extensions/discovery.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import { homedir } from "node:os"; import { basename, isAbsolute, join, resolve } from "node:path"; -import { resolveGlobalExtensionsDir } from "../core/paths"; +import { INSTALLED_EXTENSIONS_DIR_NAME, resolveGlobalExtensionsDir } from "../core/paths"; import { findProjectRootCandidate } from "../core/projectRoot"; import { deriveExtensionId, type ExtensionCandidate, type ExtensionOrigin } from "./types"; @@ -238,6 +238,52 @@ function scanExtensionsDir(dir: string): DiscoveredExtensionEntry[] { return entries; } +/** + * Resolve one directory the way explicit paths and managed installs share. + * + * A directory that is itself a folder extension expands to just its declared + * entries; anything else is a container of extensions and gets scanned. This is + * also the shape `hunk extension install` validates a cloned repository + * against, so "what would load" has exactly one definition. + */ +export function resolveExtensionContainerEntries(dir: string): DiscoveredExtensionEntry[] { + const folderEntries = resolveFolderExtensionEntries(dir); + return folderEntries.length > 0 ? folderEntries : scanExtensionsDir(dir); +} + +/** + * Report whether one directory would load at least one extension entry. + * + * The installer asks this before recording a clone, so a repository that is + * not an extension at all fails the install instead of installing as noise. + */ +export function directoryContainsExtensionEntries(dir: string) { + return resolveExtensionContainerEntries(dir).length > 0; +} + +/** + * Scan the managed install root: one repository clone per subdirectory. + * + * Each clone resolves like an explicit directory path — as a folder extension + * when it declares itself one, otherwise as a container of entry files — so a + * repository shares one layout contract between `--extension ` during + * development and `hunk extension install` after publishing. Non-directories + * (the records file) are skipped. + */ +function scanInstalledExtensionsRoot(root: string): DiscoveredExtensionEntry[] { + const entries: DiscoveredExtensionEntry[] = []; + + for (const entry of readSortedDirEntries(root)) { + if (!entry.isDirectory()) { + continue; + } + + entries.push(...resolveExtensionContainerEntries(join(root, entry.name))); + } + + return entries; +} + /** * Expand a leading `~` to the user's home directory. * @@ -280,8 +326,7 @@ function expandExplicitPath(path: string, cwd: string): DiscoveredExtensionEntry return [toStandaloneEntry(resolvedPath)]; } - const folderEntries = resolveFolderExtensionEntries(resolvedPath); - return folderEntries.length > 0 ? folderEntries : scanExtensionsDir(resolvedPath); + return resolveExtensionContainerEntries(resolvedPath); } /** @@ -309,7 +354,17 @@ export function discoverExtensions(options: DiscoverExtensionsOptions = {}): Ext }, { origin: "global", - entries: globalExtensionsDir ? scanExtensionsDir(globalExtensionsDir) : [], + entries: globalExtensionsDir + ? [ + ...scanExtensionsDir(globalExtensionsDir), + // Managed installs live one level deeper so `hunk extension + // install` owns a directory hand-copied extensions never collide + // with; they load with the same global origin and trust posture. + ...scanInstalledExtensionsRoot( + join(globalExtensionsDir, INSTALLED_EXTENSIONS_DIR_NAME), + ), + ] + : [], }, { origin: "repo", diff --git a/src/extensions/manage/cli.ts b/src/extensions/manage/cli.ts new file mode 100644 index 000000000..42f1af87f --- /dev/null +++ b/src/extensions/manage/cli.ts @@ -0,0 +1,146 @@ +import { HunkUserError } from "../../core/errors"; +import { resolveInstalledExtensionsRoot } from "../../core/paths"; +import type { ExtensionManageCommandInput } from "../../core/types"; +import { + installExtension, + listExtensions, + removeExtension, + updateExtension, + type ExtensionManageContext, +} from "./install"; +import { parseExtensionInstallSource } from "./source"; + +/** + * The I/O one `hunk extension` command runs against. + * + * Everything the runner touches outside the managed install root arrives + * through this seam, so tests can drive install confirmations and read output + * without owning a terminal. + */ +export interface ExtensionManageIo { + stdout: (text: string) => void; + stderr: (text: string) => void; + /** Ask one yes/no question on a real terminal; absent when there is none. */ + confirm?: (question: string) => Promise; + env?: NodeJS.ProcessEnv; +} + +/** Shorten one commit sha for display. */ +function shortCommit(commit: string) { + return commit.slice(0, 7); +} + +/** Phrase one recorded source with its pinned ref, when it has one. */ +function describeSource(source: string, ref: string | undefined) { + return ref !== undefined ? `${source} @ ${ref}` : source; +} + +/** Resolve the managed install root or explain why there is none. */ +function requireInstalledRoot(env: NodeJS.ProcessEnv) { + const installedRoot = resolveInstalledExtensionsRoot(env); + if (!installedRoot) { + throw new HunkUserError( + "Could not resolve the extension install directory because HOME/XDG_CONFIG_HOME is unset.", + ); + } + + return installedRoot; +} + +/** + * Run one `hunk extension` command and return its exit code. + * + * Install is the only interactive step: extensions execute with the user's + * full permissions, so a fresh install requires either a terminal confirmation + * or an explicit `--yes`. Everything else operates on what is already + * recorded and just prints what it did. + */ +export async function runExtensionManageCommand( + input: ExtensionManageCommandInput, + io: ExtensionManageIo, +): Promise { + const env = io.env ?? process.env; + const context: ExtensionManageContext = { + installedRoot: requireInstalledRoot(env), + log: (line) => io.stderr(`${line}\n`), + }; + + if (input.action === "install") { + const source = parseExtensionInstallSource(input.source); + + if (!input.yes) { + if (!io.confirm) { + throw new HunkUserError( + "Installing an extension needs a confirmation, and there is no terminal to ask on.", + [`Re-run with --yes after reviewing ${source.cloneUrl}.`], + ); + } + + io.stdout( + `Install ${describeSource(source.cloneUrl, source.ref)}?\n` + + "Extensions run with your full user permissions. Only install repositories you trust.\n", + ); + if (!(await io.confirm("Proceed? [y/N] "))) { + io.stdout("Install cancelled.\n"); + return 1; + } + } + + const outcome = installExtension(context, source); + io.stdout( + `Installed ${outcome.name}${outcome.version ? ` v${outcome.version}` : ""} at ${shortCommit(outcome.commit)} into ${outcome.directory}.\n`, + ); + if (outcome.dependencyWarning) { + io.stderr(`warning: ${outcome.dependencyWarning}\n`); + } + io.stdout("New Hunk sessions will load it automatically.\n"); + return 0; + } + + if (input.action === "list") { + const entries = listExtensions(context); + if (entries.length === 0) { + io.stdout( + "No managed extension installs.\nInstall one with `hunk extension install /`.\n", + ); + return 0; + } + + for (const entry of entries) { + const version = entry.version ? `v${entry.version}` : shortCommit(entry.record.commit); + const missing = entry.present ? "" : " (missing on disk — reinstall or remove)"; + // The clone URL plus ref, not the raw spec: a spec like `acme/x@v1` + // already embeds the ref, and printing both would repeat it. + io.stdout( + `${entry.name} ${version} ${describeSource(entry.record.cloneUrl, entry.record.ref)}${missing}\n`, + ); + } + return 0; + } + + if (input.action === "update") { + const names = + input.name !== undefined ? [input.name] : listExtensions(context).map((entry) => entry.name); + if (names.length === 0) { + io.stdout("No managed extension installs to update.\n"); + return 0; + } + + for (const name of names) { + const outcome = updateExtension(context, name); + io.stdout( + outcome.changed + ? `Updated ${outcome.name}${outcome.version ? ` to v${outcome.version}` : ""}: ${shortCommit(outcome.previousCommit)} -> ${shortCommit(outcome.commit)}.\n` + : `${outcome.name} is already up to date (${shortCommit(outcome.commit)}).\n`, + ); + if (outcome.dependencyWarning) { + io.stderr(`warning: ${outcome.dependencyWarning}\n`); + } + } + return 0; + } + + removeExtension(context, input.name); + io.stdout(`Removed ${input.name}.\n`); + return 0; +} diff --git a/src/extensions/manage/install.test.ts b/src/extensions/manage/install.test.ts new file mode 100644 index 000000000..6117a81b1 --- /dev/null +++ b/src/extensions/manage/install.test.ts @@ -0,0 +1,272 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverExtensions } from "../discovery"; +import { runExtensionManageCommand } from "./cli"; +import { + installExtension, + listExtensions, + removeExtension, + updateExtension, + type ExtensionManageContext, +} from "./install"; +import { readInstallRecords } from "./records"; +import { parseExtensionInstallSource } from "./source"; + +const tempDirs: string[] = []; + +function createTempDir(prefix: string) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +/** Run one git command in a fixture repo, failing the test on error. */ +function runFixtureGit(cwd: string, args: string[]) { + const proc = Bun.spawnSync(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + if (proc.exitCode !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${proc.stderr?.toString()}`); + } + return proc.stdout?.toString() ?? ""; +} + +/** Create one commit-able extension repository fixture and return its path. */ +function createExtensionRepoFixture(name: string) { + const repo = join(createTempDir("hunk-manage-fixture-"), name); + mkdirSync(repo, { recursive: true }); + runFixtureGit(repo, ["init", "--quiet"]); + runFixtureGit(repo, ["config", "user.email", "test@example.com"]); + runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ name, version: "1.0.0", hunk: { extensions: ["./index.ts"] } }), + ); + writeFileSync(join(repo, "index.ts"), "export default () => {};\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); + return repo; +} + +/** Build one manage context against a fresh managed root. */ +function createTestContext(): ExtensionManageContext & { logs: string[] } { + const logs: string[] = []; + return { + installedRoot: join(createTempDir("hunk-manage-root-"), "installed"), + log: (line) => logs.push(line), + logs, + }; +} + +describe("managed extension installs", () => { + test("installs, records, and discovers a local git repository", () => { + const repo = createExtensionRepoFixture("word-diff"); + const context = createTestContext(); + + const outcome = installExtension(context, parseExtensionInstallSource(repo)); + + expect(outcome.name).toBe("word-diff"); + expect(outcome.version).toBe("1.0.0"); + expect(existsSync(join(outcome.directory, "index.ts"))).toBe(true); + + const records = readInstallRecords(context.installedRoot); + expect(records["word-diff"]?.cloneUrl).toBe(repo); + expect(records["word-diff"]?.commit).toBe(outcome.commit); + + // Discovery picks the install up through the global group, one level below + // the global extensions dir. + const globalDir = join(context.installedRoot, ".."); + const candidates = discoverExtensions({ + cwd: globalDir, + repoRoot: undefined, + globalExtensionsDir: globalDir, + }); + expect(candidates).toEqual([ + { + id: "word-diff", + path: join(context.installedRoot, "word-diff", "index.ts"), + origin: "global", + }, + ]); + }); + + test("installs a pinned tag and stays put until updated", () => { + const repo = createExtensionRepoFixture("pinned-ext"); + runFixtureGit(repo, ["tag", "v1"]); + const context = createTestContext(); + + const outcome = installExtension(context, parseExtensionInstallSource(`${repo}@v1`)); + expect(readInstallRecords(context.installedRoot)["pinned-ext"]?.ref).toBe("v1"); + + // A new commit on the default branch must not move a tag-pinned install. + writeFileSync(join(repo, "extra.ts"), "export const later = true;\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "later"]); + + const update = updateExtension(context, "pinned-ext"); + expect(update.changed).toBe(false); + expect(update.commit).toBe(outcome.commit); + }); + + test("updates a branch-tracking install to the new commit", () => { + const repo = createExtensionRepoFixture("tracking-ext"); + const context = createTestContext(); + const installed = installExtension(context, parseExtensionInstallSource(repo)); + + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ + name: "tracking-ext", + version: "1.1.0", + hunk: { extensions: ["./index.ts"] }, + }), + ); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "bump"]); + + const update = updateExtension(context, "tracking-ext"); + expect(update.changed).toBe(true); + expect(update.previousCommit).toBe(installed.commit); + expect(update.commit).not.toBe(installed.commit); + expect(update.version).toBe("1.1.0"); + expect(readInstallRecords(context.installedRoot)["tracking-ext"]?.commit).toBe(update.commit); + }); + + test("refuses a repository that contains no extension", () => { + const repo = join(createTempDir("hunk-manage-empty-"), "not-an-ext"); + mkdirSync(repo, { recursive: true }); + runFixtureGit(repo, ["init", "--quiet"]); + runFixtureGit(repo, ["config", "user.email", "test@example.com"]); + runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); + writeFileSync(join(repo, "README.md"), "not an extension\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); + const context = createTestContext(); + + expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( + /does not contain a Hunk extension/, + ); + expect(existsSync(join(context.installedRoot, "not-an-ext"))).toBe(false); + expect(readInstallRecords(context.installedRoot)).toEqual({}); + }); + + test("refuses to install over an existing record or an unmanaged directory", () => { + const repo = createExtensionRepoFixture("twice-ext"); + const context = createTestContext(); + installExtension(context, parseExtensionInstallSource(repo)); + + expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( + /already installed/, + ); + + mkdirSync(join(context.installedRoot, "hand-copied"), { recursive: true }); + expect(() => + installExtension(context, { + ...parseExtensionInstallSource(repo), + name: "hand-copied", + }), + ).toThrow(/not a managed install/); + }); + + test("removes a managed install's directory and record", () => { + const repo = createExtensionRepoFixture("removable-ext"); + const context = createTestContext(); + const outcome = installExtension(context, parseExtensionInstallSource(repo)); + + removeExtension(context, "removable-ext"); + + expect(existsSync(outcome.directory)).toBe(false); + expect(readInstallRecords(context.installedRoot)).toEqual({}); + expect(() => removeExtension(context, "removable-ext")).toThrow(/not a managed install/); + }); + + test("lists installs with version, source, and missing-directory state", () => { + const repo = createExtensionRepoFixture("listed-ext"); + const context = createTestContext(); + installExtension(context, parseExtensionInstallSource(repo)); + + const entries = listExtensions(context); + expect(entries).toHaveLength(1); + expect(entries[0]?.name).toBe("listed-ext"); + expect(entries[0]?.version).toBe("1.0.0"); + expect(entries[0]?.present).toBe(true); + + rmSync(join(context.installedRoot, "listed-ext"), { recursive: true, force: true }); + expect(listExtensions(context)[0]?.present).toBe(false); + }); +}); + +describe("hunk extension command runner", () => { + /** Drive the runner against a temp config dir, capturing output. */ + function createRunnerIo(confirmAnswer?: boolean) { + const configDir = createTempDir("hunk-manage-config-"); + const out: string[] = []; + const err: string[] = []; + return { + configDir, + out, + err, + io: { + stdout: (text: string) => out.push(text), + stderr: (text: string) => err.push(text), + ...(confirmAnswer !== undefined ? { confirm: async () => confirmAnswer } : {}), + env: { XDG_CONFIG_HOME: configDir } as NodeJS.ProcessEnv, + }, + }; + } + + test("install --yes runs end to end and list reports it", async () => { + const repo = createExtensionRepoFixture("runner-ext"); + const runner = createRunnerIo(); + + const exitCode = await runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: repo, yes: true }, + runner.io, + ); + + expect(exitCode).toBe(0); + expect(runner.out.join("")).toContain("Installed runner-ext v1.0.0"); + + const listExit = await runExtensionManageCommand( + { kind: "extension-manage", action: "list" }, + runner.io, + ); + expect(listExit).toBe(0); + expect(runner.out.join("")).toContain("runner-ext v1.0.0"); + }); + + test("install without --yes needs a confirmation and honors a refusal", async () => { + const repo = createExtensionRepoFixture("prompted-ext"); + const noTerminal = createRunnerIo(); + + await expect( + runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: repo, yes: false }, + noTerminal.io, + ), + ).rejects.toThrow(/no terminal/); + + const refused = createRunnerIo(false); + const exitCode = await runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: repo, yes: false }, + refused.io, + ); + expect(exitCode).toBe(1); + expect(refused.out.join("")).toContain("full user permissions"); + expect(refused.out.join("")).toContain("Install cancelled."); + }); +}); diff --git a/src/extensions/manage/install.ts b/src/extensions/manage/install.ts new file mode 100644 index 000000000..97f989d22 --- /dev/null +++ b/src/extensions/manage/install.ts @@ -0,0 +1,374 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { HunkUserError } from "../../core/errors"; +import { directoryContainsExtensionEntries } from "../discovery"; +import { + readInstallRecords, + writeInstallRecords, + type ExtensionInstallRecord, + type ExtensionInstallRecordMap, +} from "./records"; +import type { ExtensionInstallSource } from "./source"; + +/** + * Everything the managed-install operations need from their caller. + * + * The operations are pure filesystem-and-git against `installedRoot`, so the + * CLI runner resolves the root once from the environment and tests point it at + * a temp directory; nothing in here reads global state. + */ +export interface ExtensionManageContext { + /** Managed install root; created on demand. */ + installedRoot: string; + /** Progress sink; one short line per step. */ + log: (line: string) => void; + /** Timestamp seam so tests can pin record times. */ + now?: () => Date; +} + +/** Outcome of one install or update, for the runner to phrase. */ +export interface ExtensionInstallOutcome { + name: string; + directory: string; + commit: string; + /** Version from the clone's `package.json`, when it declares one. */ + version?: string; + /** Set when dependencies were declared but could not be installed. */ + dependencyWarning?: string; +} + +/** One row of `hunk extension list`. */ +export interface ExtensionInstallListEntry { + name: string; + record: ExtensionInstallRecord; + directory: string; + /** Version from the installed `package.json`, when present. */ + version?: string; + /** False when the recorded directory is gone from disk. */ + present: boolean; +} + +/** Run one git invocation, returning stdout or throwing a user-facing error. */ +function runGit(args: string[], options: { cwd?: string } = {}) { + let proc: ReturnType; + try { + proc = Bun.spawnSync(["git", ...args], { + cwd: options.cwd, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + } catch (error) { + throw new HunkUserError( + `Could not run git: ${error instanceof Error ? error.message : String(error)}`, + ["Installing extensions requires a git executable on PATH."], + ); + } + + if (proc.exitCode !== 0) { + const stderr = proc.stderr?.toString().trim() ?? ""; + throw new HunkUserError( + `git ${args[0]} failed${stderr.length > 0 ? `: ${stderr.split("\n").at(-1)}` : "."}`, + ); + } + + return proc.stdout?.toString() ?? ""; +} + +/** Read one clone's `package.json` version, tolerating anything malformed. */ +function readInstalledVersion(dir: string) { + try { + const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as unknown; + if (typeof manifest === "object" && manifest !== null) { + const version = (manifest as Record).version; + if (typeof version === "string" && version.length > 0) { + return version; + } + } + } catch { + // No package.json, or not one we can read — the install is still valid. + } + + return undefined; +} + +/** Report whether one clone declares npm dependencies its entries may import. */ +function declaresDependencies(dir: string) { + try { + const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as unknown; + if (typeof manifest !== "object" || manifest === null) { + return false; + } + + const dependencies = (manifest as Record).dependencies; + return ( + typeof dependencies === "object" && + dependencies !== null && + Object.keys(dependencies as Record).length > 0 + ); + } catch { + return false; + } +} + +/** + * Install a clone's npm dependencies into its own `node_modules`. + * + * Hunk itself may be a compiled binary, so this shells out to whatever `bun` + * is on PATH rather than assuming the running executable can act as a package + * manager. A missing or failing `bun` degrades to a warning: the extension is + * installed either way, and the host will report a load issue naming the + * missing module if the user runs before installing dependencies by hand. + */ +function installDependencies(dir: string): string | undefined { + let proc: ReturnType; + try { + proc = Bun.spawnSync(["bun", "install", "--production"], { + cwd: dir, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + } catch { + return `dependencies not installed — no \`bun\` on PATH; run \`bun install\` (or \`npm install\`) in ${dir}`; + } + + if (proc.exitCode !== 0) { + return `dependencies not installed — \`bun install\` failed in ${dir}; run it there to see why`; + } + + return undefined; +} + +/** + * Clone one source at its requested ref and return the checkout's commit. + * + * A ref is usually a branch or tag, which a shallow `--branch` clone fetches + * in one step; when that fails (a bare commit sha, or a host refusing shallow + * fetches) the fallback is a full clone plus checkout, which accepts anything + * `git checkout` does. + */ +function cloneSource(source: ExtensionInstallSource, destination: string) { + if (source.ref === undefined) { + runGit(["clone", "--quiet", "--depth", "1", "--", source.cloneUrl, destination]); + } else { + try { + runGit([ + "clone", + "--quiet", + "--depth", + "1", + "--branch", + source.ref, + "--", + source.cloneUrl, + destination, + ]); + } catch { + rmSync(destination, { recursive: true, force: true }); + runGit(["clone", "--quiet", "--", source.cloneUrl, destination]); + runGit(["checkout", "--quiet", source.ref], { cwd: destination }); + } + } + + return runGit(["rev-parse", "HEAD"], { cwd: destination }).trim(); +} + +/** + * Clone into a staging directory, validate the layout, and prepare dependencies. + * + * Everything that can fail happens against the staging path, so the real + * install directory is only ever swapped in whole; a failed install or update + * leaves whatever was there before untouched. + */ +function stageClone(context: ExtensionManageContext, source: ExtensionInstallSource) { + mkdirSync(context.installedRoot, { recursive: true }); + const stagingDir = join(context.installedRoot, `.staging-${source.name}`); + rmSync(stagingDir, { recursive: true, force: true }); + + let commit: string; + try { + commit = cloneSource(source, stagingDir); + + if (!directoryContainsExtensionEntries(stagingDir)) { + throw new HunkUserError(`${source.spec} does not contain a Hunk extension.`, [ + 'An extension repository needs a package.json with a `hunk` field, an index.* entry, or top-level entry files — see "Publishing an extension" in docs/extensions.md.', + ]); + } + + const dependencyWarning = declaresDependencies(stagingDir) + ? (context.log("installing dependencies…"), installDependencies(stagingDir)) + : undefined; + + return { stagingDir, commit, dependencyWarning }; + } catch (error) { + rmSync(stagingDir, { recursive: true, force: true }); + throw error; + } +} + +/** Swap one staged clone into its final directory. */ +function promoteStagedClone(stagingDir: string, directory: string) { + rmSync(directory, { recursive: true, force: true }); + renameSync(stagingDir, directory); +} + +/** Persist one record, merging over whatever is already stored. */ +function saveRecord( + context: ExtensionManageContext, + records: ExtensionInstallRecordMap, + name: string, + record: ExtensionInstallRecord, +) { + writeInstallRecords(context.installedRoot, { ...records, [name]: record }); +} + +/** + * Install one extension repository into the managed root. + * + * Refuses a name that is already recorded (update is the explicit path for + * that) or whose directory already exists unrecorded, so a hand-placed folder + * is never overwritten by an install that happens to share its name. + */ +export function installExtension( + context: ExtensionManageContext, + source: ExtensionInstallSource, +): ExtensionInstallOutcome { + const records = readInstallRecords(context.installedRoot); + const directory = join(context.installedRoot, source.name); + + if (records[source.name]) { + throw new HunkUserError(`"${source.name}" is already installed.`, [ + `Run \`hunk extension update ${source.name}\` to refresh it, or \`hunk extension remove ${source.name}\` first.`, + ]); + } + + if (existsSync(directory)) { + throw new HunkUserError( + `${directory} already exists but is not a managed install; move it aside before installing "${source.name}".`, + ); + } + + context.log(`cloning ${source.cloneUrl}${source.ref ? ` @ ${source.ref}` : ""}…`); + const { stagingDir, commit, dependencyWarning } = stageClone(context, source); + promoteStagedClone(stagingDir, directory); + + const timestamp = (context.now?.() ?? new Date()).toISOString(); + saveRecord(context, records, source.name, { + source: source.spec, + cloneUrl: source.cloneUrl, + ...(source.ref !== undefined ? { ref: source.ref } : {}), + commit, + installedAt: timestamp, + updatedAt: timestamp, + }); + + return { + name: source.name, + directory, + commit, + version: readInstalledVersion(directory), + ...(dependencyWarning !== undefined ? { dependencyWarning } : {}), + }; +} + +/** Outcome of one update pass over a single managed install. */ +export interface ExtensionUpdateOutcome extends ExtensionInstallOutcome { + previousCommit: string; + changed: boolean; +} + +/** + * Update one managed install by re-cloning its recorded source. + * + * Re-cloning rather than fetching keeps the operation insensitive to how the + * previous clone was made (shallow, force-pushed ref, moved tag) at the cost + * of bandwidth, which is the right trade for something run occasionally. + */ +export function updateExtension( + context: ExtensionManageContext, + name: string, +): ExtensionUpdateOutcome { + const records = readInstallRecords(context.installedRoot); + const record = records[name]; + if (!record) { + throw new HunkUserError(`"${name}" is not a managed install.`, [ + "Run `hunk extension list` to see managed installs.", + ]); + } + + const source: ExtensionInstallSource = { + spec: record.source, + cloneUrl: record.cloneUrl, + ...(record.ref !== undefined ? { ref: record.ref } : {}), + name, + }; + const directory = join(context.installedRoot, name); + + context.log(`checking ${record.cloneUrl}${record.ref ? ` @ ${record.ref}` : ""}…`); + const { stagingDir, commit, dependencyWarning } = stageClone(context, source); + + if (commit === record.commit && existsSync(directory)) { + rmSync(stagingDir, { recursive: true, force: true }); + return { + name, + directory, + commit, + previousCommit: record.commit, + changed: false, + version: readInstalledVersion(directory), + }; + } + + promoteStagedClone(stagingDir, directory); + saveRecord(context, records, name, { + ...record, + commit, + updatedAt: (context.now?.() ?? new Date()).toISOString(), + }); + + return { + name, + directory, + commit, + previousCommit: record.commit, + changed: true, + version: readInstalledVersion(directory), + ...(dependencyWarning !== undefined ? { dependencyWarning } : {}), + }; +} + +/** Remove one managed install's directory and record. */ +export function removeExtension(context: ExtensionManageContext, name: string) { + const records = readInstallRecords(context.installedRoot); + const record = records[name]; + if (!record) { + throw new HunkUserError(`"${name}" is not a managed install.`, [ + "Run `hunk extension list` to see managed installs.", + ]); + } + + rmSync(join(context.installedRoot, name), { recursive: true, force: true }); + const { [name]: _removed, ...remaining } = records; + writeInstallRecords(context.installedRoot, remaining); +} + +/** List every managed install, in name order. */ +export function listExtensions(context: ExtensionManageContext): ExtensionInstallListEntry[] { + const records = readInstallRecords(context.installedRoot); + + return Object.entries(records) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, record]) => { + const directory = join(context.installedRoot, name); + const present = existsSync(directory); + const version = present ? readInstalledVersion(directory) : undefined; + return { + name, + record, + directory, + present, + ...(version !== undefined ? { version } : {}), + }; + }); +} diff --git a/src/extensions/manage/records.ts b/src/extensions/manage/records.ts new file mode 100644 index 000000000..223b075da --- /dev/null +++ b/src/extensions/manage/records.ts @@ -0,0 +1,84 @@ +import { join } from "node:path"; +import { readHunkStateRecord, writeHunkStateRecord } from "../../core/hunkState"; + +/** + * What `hunk extension install` remembers about one managed install. + * + * The record is the source of truth for which directories under the managed + * install root Hunk owns: `list`, `update`, and `remove` operate only on + * recorded names, so a folder the user copied in by hand is never touched. + */ +export interface ExtensionInstallRecord { + /** The install source exactly as the user typed it. */ + source: string; + /** URL (or local path) `git clone` was run with. */ + cloneUrl: string; + /** Branch, tag, or commit pinned with `@ref`, if any. */ + ref?: string; + /** Commit the install currently sits at. */ + commit: string; + /** ISO timestamp of the first install. */ + installedAt: string; + /** ISO timestamp of the last install or update. */ + updatedAt: string; +} + +export type ExtensionInstallRecordMap = Record; + +/** File inside the managed install root that holds the records. */ +const RECORDS_FILE_NAME = "records.json"; + +/** Resolve the records file for one managed install root. */ +export function resolveInstallRecordsPath(installedRoot: string) { + return join(installedRoot, RECORDS_FILE_NAME); +} + +/** Accept one stored record only when every required field survived the disk. */ +function normalizeRecord(value: unknown): ExtensionInstallRecord | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + + const record = value as Record; + if ( + typeof record.source !== "string" || + typeof record.cloneUrl !== "string" || + typeof record.commit !== "string" || + typeof record.installedAt !== "string" || + typeof record.updatedAt !== "string" + ) { + return undefined; + } + + return { + source: record.source, + cloneUrl: record.cloneUrl, + ...(typeof record.ref === "string" ? { ref: record.ref } : {}), + commit: record.commit, + installedAt: record.installedAt, + updatedAt: record.updatedAt, + }; +} + +/** Read every install record, treating a missing or damaged file as empty. */ +export function readInstallRecords(installedRoot: string): ExtensionInstallRecordMap { + const stored = readHunkStateRecord(resolveInstallRecordsPath(installedRoot)).installs; + if (typeof stored !== "object" || stored === null || Array.isArray(stored)) { + return {}; + } + + const records: ExtensionInstallRecordMap = {}; + for (const [name, value] of Object.entries(stored as Record)) { + const normalized = normalizeRecord(value); + if (normalized) { + records[name] = normalized; + } + } + + return records; +} + +/** Persist the full install record map. */ +export function writeInstallRecords(installedRoot: string, records: ExtensionInstallRecordMap) { + writeHunkStateRecord(resolveInstallRecordsPath(installedRoot), { installs: records }); +} diff --git a/src/extensions/manage/source.test.ts b/src/extensions/manage/source.test.ts new file mode 100644 index 000000000..896fe2275 --- /dev/null +++ b/src/extensions/manage/source.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { parseExtensionInstallSource } from "./source"; + +describe("extension install source parsing", () => { + test("expands owner/repo shorthand to a GitHub clone URL", () => { + expect(parseExtensionInstallSource("acme/hunk-word-diff")).toEqual({ + spec: "acme/hunk-word-diff", + cloneUrl: "https://github.com/acme/hunk-word-diff", + name: "hunk-word-diff", + }); + }); + + test("splits an @ref suffix off the shorthand", () => { + expect(parseExtensionInstallSource("acme/hunk-word-diff@v1.2.0")).toEqual({ + spec: "acme/hunk-word-diff@v1.2.0", + cloneUrl: "https://github.com/acme/hunk-word-diff", + ref: "v1.2.0", + name: "hunk-word-diff", + }); + }); + + test("assumes https for a git: prefixed host path", () => { + expect(parseExtensionInstallSource("git:codeberg.org/acme/hunk-ext@main")).toEqual({ + spec: "git:codeberg.org/acme/hunk-ext@main", + cloneUrl: "https://codeberg.org/acme/hunk-ext", + ref: "main", + name: "hunk-ext", + }); + }); + + test("passes explicit transports through verbatim", () => { + expect(parseExtensionInstallSource("https://github.com/acme/hunk-ext.git").cloneUrl).toBe( + "https://github.com/acme/hunk-ext.git", + ); + expect(parseExtensionInstallSource("git@github.com:acme/hunk-ext.git")).toEqual({ + spec: "git@github.com:acme/hunk-ext.git", + cloneUrl: "git@github.com:acme/hunk-ext.git", + name: "hunk-ext", + }); + }); + + test("keeps an scp-like user@host untouched by ref splitting", () => { + const parsed = parseExtensionInstallSource("git@github.com:acme/hunk-ext@v2"); + expect(parsed.cloneUrl).toBe("git@github.com:acme/hunk-ext"); + expect(parsed.ref).toBe("v2"); + }); + + test("accepts a local path source", () => { + const parsed = parseExtensionInstallSource("/tmp/fixtures/hunk-ext"); + expect(parsed.cloneUrl).toBe("/tmp/fixtures/hunk-ext"); + expect(parsed.name).toBe("hunk-ext"); + }); + + test("strips a trailing .git from the derived name", () => { + expect(parseExtensionInstallSource("acme/hunk-ext.git").name).toBe("hunk-ext"); + }); + + test("refuses a repository name that cannot be an extension id", () => { + expect(() => parseExtensionInstallSource("acme/my.weird.repo")).toThrow( + /cannot be an extension id/, + ); + }); + + test("refuses an empty ref and a bare word", () => { + expect(() => parseExtensionInstallSource("acme/hunk-ext@")).toThrow(/empty ref/); + expect(() => parseExtensionInstallSource("not-a-repo")).toThrow(/not a repository/); + }); +}); diff --git a/src/extensions/manage/source.ts b/src/extensions/manage/source.ts new file mode 100644 index 000000000..269017c33 --- /dev/null +++ b/src/extensions/manage/source.ts @@ -0,0 +1,128 @@ +import { isAbsolute } from "node:path"; +import { EXTENSION_ID_RULE, isValidExtensionId } from "../extensionIds"; + +/** + * One parsed `hunk extension install` source. + * + * The spec grammar is deliberately git-shaped rather than registry-shaped: + * extensions are shared as plain git repositories, so every form normalizes to + * a clone URL plus an optional ref, and the repository name doubles as the + * managed install's directory — and therefore extension id — on disk. + */ +export interface ExtensionInstallSource { + /** The spec exactly as the user typed it, kept for records and messages. */ + spec: string; + /** URL (or local path) handed to `git clone`. */ + cloneUrl: string; + /** Branch, tag, or commit requested with an `@ref` suffix, if any. */ + ref?: string; + /** Repository name; the install directory and extension id namespace. */ + name: string; +} + +/** Match `owner/repo` shorthand: exactly two path segments, no scheme or host. */ +const GITHUB_SHORTHAND_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +/** Return whether one location already names a transport git understands. */ +function hasExplicitTransport(location: string) { + return ( + /^(https?|ssh|git|file):\/\//.test(location) || + // scp-like syntax: user@host:path + /^[^/@]+@[^/:]+:/.test(location) + ); +} + +/** Return whether one location is a local filesystem path. */ +function isLocalPath(location: string) { + return ( + isAbsolute(location) || + location.startsWith("./") || + location.startsWith("../") || + location.startsWith("~") + ); +} + +/** + * Split one `@ref` suffix off a location. + * + * Only an `@` after the last `/` is a ref separator, so scp-like + * `git@github.com:owner/repo` and userinfo in URLs stay untouched. + */ +function splitRefSuffix(location: string): { location: string; ref?: string } { + const atIndex = location.lastIndexOf("@"); + if (atIndex <= 0 || atIndex < location.lastIndexOf("/")) { + return { location }; + } + + const ref = location.slice(atIndex + 1); + if (ref.length === 0) { + throw new Error(`Install source "${location}" has an empty ref after "@".`); + } + + return { location: location.slice(0, atIndex), ref }; +} + +/** Derive the repository name from one clone location. */ +function deriveRepositoryName(location: string) { + const trimmed = location.replace(/\/+$/, ""); + // scp-like locations separate the path with ":", URLs and paths with "/". + const lastSegment = trimmed.split(/[/:]/).at(-1) ?? ""; + return lastSegment.endsWith(".git") ? lastSegment.slice(0, -4) : lastSegment; +} + +/** + * Parse one install source spec into a clone URL, optional ref, and name. + * + * Accepted forms, in the spirit of `pi install git:...` and + * `herdr plugin install owner/repo`: + * + * - `owner/repo` — GitHub shorthand + * - `git:github.com/owner/repo` — pi-style, `https://` is assumed + * - `https://...`, `ssh://...`, `git@host:path` — passed to git verbatim + * - a local path — mostly useful for testing an extension before publishing + * + * Every form takes an optional `@ref` suffix naming a branch, tag, or commit. + */ +export function parseExtensionInstallSource(spec: string): ExtensionInstallSource { + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new Error("Install source must not be empty."); + } + + // `git:` marks "the rest is a clone location"; it is a spec prefix, not a + // scheme, so `git://host/path` (with slashes) stays a transport URL. + const explicitGit = trimmed.startsWith("git:") && !trimmed.startsWith("git://"); + const { location, ref } = splitRefSuffix(explicitGit ? trimmed.slice(4) : trimmed); + if (location.length === 0) { + throw new Error(`Install source "${spec}" names no repository.`); + } + + let cloneUrl: string; + if (hasExplicitTransport(location) || isLocalPath(location)) { + cloneUrl = location; + } else if (!explicitGit && GITHUB_SHORTHAND_PATTERN.test(location)) { + cloneUrl = `https://github.com/${location}`; + } else if (location.includes("/")) { + // `git:github.com/owner/repo` and friends: a bare host/path location. + cloneUrl = `https://${location}`; + } else { + throw new Error( + `Install source "${spec}" is not a repository. Use owner/repo, git:host/path, a git URL, or a local path.`, + ); + } + + const name = deriveRepositoryName(location); + if (name.length === 0) { + throw new Error(`Install source "${spec}" names no repository.`); + } + + if (!isValidExtensionId(name)) { + throw new Error( + `Repository name "${name}" cannot be an extension id — ${EXTENSION_ID_RULE}. Rename the repository or install it manually.`, + ); + } + + return ref !== undefined + ? { spec: trimmed, cloneUrl, ref, name } + : { spec: trimmed, cloneUrl, name }; +} diff --git a/src/main.tsx b/src/main.tsx index 28f0d2d7d..fe25d5cf4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -26,6 +26,35 @@ async function main() { process.exit(0); } + if (startupPlan.kind === "extension-manage") { + const [{ runExtensionManageCommand }, readline] = await Promise.all([ + import("./extensions/manage/cli"), + import("node:readline/promises"), + ]); + // A confirmation needs a real terminal on both sides; piped runs use --yes. + const canConfirm = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); + process.exit( + await runExtensionManageCommand(startupPlan.input, { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text), + confirm: canConfirm + ? async (question) => { + const prompt = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = await prompt.question(question); + return ["y", "yes"].includes(answer.trim().toLowerCase()); + } finally { + prompt.close(); + } + } + : undefined, + }), + ); + } + 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/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 28959b1fc..e8314fe86 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -172,6 +172,54 @@ print a bundled Hunk skill path hunk skill path [name] ``` +## `hunk extension install` + +install a shared extension from a git repository + +### Usage + +```bash +hunk extension install /[@ref] +hunk extension install git:/[@ref] +hunk extension install [@ref] +``` + +### Command-specific options + +| Option | Description | +| ------- | ----------------------------------------------------- | +| `--yes` | skip the confirmation prompt (required without a TTY) | + +## `hunk extension list` + +list extensions installed with `hunk extension install` + +### Usage + +```bash +hunk extension list +``` + +## `hunk extension update` + +re-clone managed extension installs from their recorded sources + +### Usage + +```bash +hunk extension update [name] +``` + +## `hunk extension remove` + +remove one managed extension install + +### Usage + +```bash +hunk extension remove +``` + ## `hunk daemon serve` run the local Hunk session daemon and websocket session broker From 02c39ef64c25ed04c1cb0647b3f180bdbb1c0a59 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:43:40 +0000 Subject: [PATCH 3/8] docs(extensions): document publishing and the hunk-extension topic Sharing an extension now has a documented path end to end: the repo guide, website guide, README, and hunk-extensions skill all describe the folder-extension repository layout, manifest metadata, hunk extension install sources, and the hunk-extension GitHub topic that serves as the zero-infrastructure community listing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb --- .changeset/heavy-moons-tell.md | 5 ++ README.md | 17 ++++- docs/extensions.md | 62 +++++++++++++++++++ skills/hunk-extensions/SKILL.md | 17 ++++- .../content/docs/docs/extend/extensions.md | 30 +++++++++ 5 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 .changeset/heavy-moons-tell.md diff --git a/.changeset/heavy-moons-tell.md b/.changeset/heavy-moons-tell.md new file mode 100644 index 000000000..2f2ef28c3 --- /dev/null +++ b/.changeset/heavy-moons-tell.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Install shared extensions straight from git with `hunk extension install /[@ref]` (plus `list`, `update`, and `remove`), let extension manifests declare a minimum API version via `"hunk": {"apiVersion": N}`, and find community extensions under the `hunk-extension` GitHub topic. diff --git a/README.md b/README.md index 644197ea4..a272103c5 100644 --- a/README.md +++ b/README.md @@ -239,9 +239,22 @@ export default function (hunk: HunkExtensionAPI) { } ``` +Extensions shared as git repositories install straight from their host, and a +`hunk-extension` GitHub topic marks community ones: + +```bash +hunk extension install acme/hunk-word-diff@v1.2.0 # or git:host/path, a URL, a local path +hunk extension list # then update [name] / remove +``` + +Browse community extensions at +[github.com/topics/hunk-extension](https://github.com/topics/hunk-extension); +publish yours by pushing the extension to a repository root and adding that +topic. + See [docs/extensions.md](docs/extensions.md) for the full API, the trust model, -and the `[extensions]` / `[extension.]` config reference. Installable examples -include [review triage](examples/extensions/review-triage/), an optional +publishing guidance, and the `[extensions]` / `[extension.]` config reference. +Installable examples include [review triage](examples/extensions/review-triage/), an optional [rendered Markdown file view](examples/extensions/rendered-markdown/), and a [Vim navigation mode](examples/extensions/vim-navigation/) built from public semantic commands. diff --git a/docs/extensions.md b/docs/extensions.md index 54e2548d2..39f017a77 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -125,6 +125,68 @@ trust prompt, even when the path points inside the repository under review. Never pass a path you have not read — including one copy-pasted from a repository's own README. +## Sharing and installing extensions + +Extensions are shared as plain git repositories — there is no registry to +publish to. `hunk extension install` clones one into a managed directory +(`~/.config/hunk/extensions/installed//`), verifies it actually +contains an extension, installs its npm dependencies when it declares any, and +records the source and resolved commit: + +```bash +hunk extension install acme/hunk-word-diff # GitHub shorthand +hunk extension install acme/hunk-word-diff@v1.2.0 # pin a tag, branch, or commit +hunk extension install git:codeberg.org/acme/ext # any host; https:// is assumed +hunk extension install https://github.com/acme/hunk-word-diff.git +hunk extension install ~/dev/hunk-word-diff # a local checkout, for testing +``` + +Managed installs load through the global source group — same origin, same +precedence, no trust prompt — because installing one is the explicit consent: +the install asks for confirmation (or `--yes`) after stating that extensions +run with your full user permissions. Only install repositories you trust. + +`hunk extension list` shows every managed install with its version, commit, and +source. `hunk extension update [name]` re-clones one install (or all of them) +from its recorded source — an install pinned with `@ref` stays at that ref +until you re-install with a different one. `hunk extension remove ` +deletes the install and its record. Managed installs never collide with +extensions you copied into `~/.config/hunk/extensions/` by hand, and the +installer refuses to overwrite an unmanaged directory of the same name. + +### Publishing an extension + +A publishable extension repository is just the folder-extension layout at the +repository root: + +```text +hunk-word-diff/ + package.json # name, version, description, hunk field + index.ts # or entries declared in "hunk": {"extensions": [...]} + README.md +``` + +To publish one: + +1. Give `package.json` a real `name`, `version`, and `description`, declare + entries under the `hunk` field, and state `"hunk": {"apiVersion": N}` if you + rely on recent API surface (see [the manifest](#where-hunk-looks-for-extensions)). +2. Keep it dependency-light. Declared `dependencies` are installed with + `bun install` at install time when the user has `bun` on PATH; without it + they get a warning and instructions. `react`, `@opentui/*`, and + `hunkdiff/extension` come from the host at runtime and belong in + `devDependencies` (types only), never `dependencies`. +3. Tag releases (`v1.2.0`) so users can pin with `@v1.2.0` instead of tracking + your default branch. +4. Push the repository to any git host and add the **`hunk-extension`** GitHub + topic so people can find it: every public repository with that topic shows + up at . + +Before publishing, exercise the exact layout users will install: +`hunk extension install /path/to/your/checkout` installs from a local +repository, and `hunk diff --extension /path/to/your/checkout` loads it for one +run without installing anything. + ## Bundled extensions Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension, diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 901d6b5ae..ee600094c 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -68,9 +68,20 @@ one level of folder extensions. A folder is an extension if it has a `package.json` with `{"hunk": {"extensions": ["./index.ts"]}}`, or an `index.{ts,tsx,js,jsx,mjs}`. Reach for a folder only when you need npm dependencies, helper modules, or a README; a single file keeps the install to one -`cp`. Hunk never installs anything, so a folder extension's `node_modules` has to -exist on every machine that loads it — keep a repo-shared extension -dependency-free. +`cp`. A `.hunk/extensions/` folder extension's `node_modules` has to exist on +every machine that loads it — keep a repo-shared extension dependency-free. + +Shared extensions install from git with `hunk extension install ` +(`owner/repo[@ref]`, `git:host/path[@ref]`, a git URL, or a local path) into +`~/.config/hunk/extensions/installed//`, where they load with global +origin; `list`, `update`, and `remove` manage them. Declared `dependencies` are +`bun install`ed at install time. The manifest may state +`{"hunk": {"apiVersion": N}}` — the minimum extension API version — and an older +Hunk refuses the extension with a startup notice instead of failing mid-factory. +To publish, push the folder-extension layout to a git repository's root with +real `name`/`version`/`description`, tag releases for `@ref` pins, and add the +`hunk-extension` GitHub topic so it appears at +. The **id** is the file stem, or the folder name for a folder extension — unless its manifest declares several entries, in which case each entry is its own diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 6aa66b308..472a1252c 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -66,6 +66,36 @@ The **id** is the file stem, or the folder name for `/index.ts` and single Ids start with a letter or digit, then letters, digits, `-`, or `_`. `hunk`, `git`, `jj`, and `sl` are reserved. An invalid id — or a second source offering an already-loaded id — is skipped with a startup notice. +## Installing shared extensions + +Extensions are shared as plain git repositories. `hunk extension install` clones one into a managed directory under `~/.config/hunk/extensions/installed/`, verifies it contains an extension, installs its npm dependencies when it declares any, and records the source and commit: + +```bash +hunk extension install acme/hunk-word-diff # GitHub shorthand +hunk extension install acme/hunk-word-diff@v1.2.0 # pin a tag, branch, or commit +hunk extension install git:codeberg.org/acme/ext # any host; https:// is assumed +hunk extension install ~/dev/hunk-word-diff # a local checkout, for testing +``` + +- `hunk extension list` shows every managed install with its version, commit, and source. +- `hunk extension update [name]` re-clones one install (or all of them) from its recorded source; an `@ref` pin stays put until you re-install with a different one. +- `hunk extension remove ` deletes the install and its record. Hand-copied extensions in `~/.config/hunk/extensions/` are never touched. + +Installing is the consent step: extensions run with your full user permissions, so a fresh install asks for confirmation (or takes `--yes`) after naming the repository. Only install repositories you trust. Managed installs then load through the global group above — same precedence, no further prompts. + +Find community extensions by browsing the [`hunk-extension` topic on GitHub](https://github.com/topics/hunk-extension). + +## Publishing an extension + +A publishable extension repository is the folder-extension layout at the repository root — `package.json` with a `hunk` field (or an `index.*` entry), code, README. To share one: + +1. Fill in `package.json`'s `name`, `version`, and `description`, and declare `"hunk": {"apiVersion": N}` if you rely on recent API surface — an older Hunk then refuses the install cleanly instead of failing mid-load. +2. Keep `dependencies` real: they are installed into the extension's own `node_modules` at install time. `react`, `@opentui/*`, and `hunkdiff/extension` come from the host at runtime and belong in `devDependencies`. +3. Tag releases so users can pin with `@v1.2.0`. +4. Push to any git host and add the **`hunk-extension`** GitHub topic so your repository appears in the [community listing](https://github.com/topics/hunk-extension). + +Test the exact layout users will get with `hunk extension install /path/to/checkout`, or load it for one run with `hunk diff --extension /path/to/checkout`. + ## Bundled extensions Hunk's Git, Jujutsu, Sapling, and file-navigation pane use the same public extension API. Bundled extensions differ from yours in three ways: From 9c9ac8ea6f95b7c8992ebc611216fdb001b489cd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:47:24 +0000 Subject: [PATCH 4/8] fix(extensions): skip the dependency pass on a no-op extension update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An update stages a fresh clone and compares its commit before swapping, but dependencies were installed during staging — before the comparison — so a pinned or unchanged install paid a full bun install on every update run. Stage now clones and validates only, and dependencies install just before a staged clone is promoted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb --- src/extensions/manage/install.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/extensions/manage/install.ts b/src/extensions/manage/install.ts index 97f989d22..9bf488e0a 100644 --- a/src/extensions/manage/install.ts +++ b/src/extensions/manage/install.ts @@ -175,11 +175,13 @@ function cloneSource(source: ExtensionInstallSource, destination: string) { } /** - * Clone into a staging directory, validate the layout, and prepare dependencies. + * Clone into a staging directory and validate the layout. * * Everything that can fail happens against the staging path, so the real * install directory is only ever swapped in whole; a failed install or update - * leaves whatever was there before untouched. + * leaves whatever was there before untouched. Dependencies are deliberately + * not installed here: an update compares the staged commit first, so an + * unchanged install never pays for a dependency pass. */ function stageClone(context: ExtensionManageContext, source: ExtensionInstallSource) { mkdirSync(context.installedRoot, { recursive: true }); @@ -196,17 +198,23 @@ function stageClone(context: ExtensionManageContext, source: ExtensionInstallSou ]); } - const dependencyWarning = declaresDependencies(stagingDir) - ? (context.log("installing dependencies…"), installDependencies(stagingDir)) - : undefined; - - return { stagingDir, commit, dependencyWarning }; + return { stagingDir, commit }; } catch (error) { rmSync(stagingDir, { recursive: true, force: true }); throw error; } } +/** Install a staged clone's npm dependencies when it declares any. */ +function prepareStagedDependencies(context: ExtensionManageContext, stagingDir: string) { + if (!declaresDependencies(stagingDir)) { + return undefined; + } + + context.log("installing dependencies…"); + return installDependencies(stagingDir); +} + /** Swap one staged clone into its final directory. */ function promoteStagedClone(stagingDir: string, directory: string) { rmSync(directory, { recursive: true, force: true }); @@ -250,7 +258,8 @@ export function installExtension( } context.log(`cloning ${source.cloneUrl}${source.ref ? ` @ ${source.ref}` : ""}…`); - const { stagingDir, commit, dependencyWarning } = stageClone(context, source); + const { stagingDir, commit } = stageClone(context, source); + const dependencyWarning = prepareStagedDependencies(context, stagingDir); promoteStagedClone(stagingDir, directory); const timestamp = (context.now?.() ?? new Date()).toISOString(); @@ -306,7 +315,7 @@ export function updateExtension( const directory = join(context.installedRoot, name); context.log(`checking ${record.cloneUrl}${record.ref ? ` @ ${record.ref}` : ""}…`); - const { stagingDir, commit, dependencyWarning } = stageClone(context, source); + const { stagingDir, commit } = stageClone(context, source); if (commit === record.commit && existsSync(directory)) { rmSync(stagingDir, { recursive: true, force: true }); @@ -320,6 +329,7 @@ export function updateExtension( }; } + const dependencyWarning = prepareStagedDependencies(context, stagingDir); promoteStagedClone(stagingDir, directory); saveRecord(context, records, name, { ...record, From db2a3bba6fe8db715463ab14a477023456bcad55 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:58:12 +0000 Subject: [PATCH 5/8] fix(extensions): require deliberate extension signals at install time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install validation accepted any repository with a bare src/index.ts one level down — the shape of nearly every JavaScript project, including pi extensions whose manifests use a pi field instead of hunk — and such installs could only fail later at load time. The installer now requires a root hunk manifest, a root index entry, top-level entry files, or a subfolder with its own hunk manifest before recording a clone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb --- src/extensions/discovery.ts | 24 ++++++++++++--- src/extensions/manage/install.test.ts | 43 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/extensions/discovery.ts b/src/extensions/discovery.ts index 2ad10298d..d6d0d61b4 100644 --- a/src/extensions/discovery.ts +++ b/src/extensions/discovery.ts @@ -252,13 +252,29 @@ export function resolveExtensionContainerEntries(dir: string): DiscoveredExtensi } /** - * Report whether one directory would load at least one extension entry. + * Report whether one directory deliberately publishes Hunk extension entries. * - * The installer asks this before recording a clone, so a repository that is - * not an extension at all fails the install instead of installing as noise. + * The installer asks this before recording a clone, and it is stricter than + * what discovery would load: only a root `hunk` manifest, a root `index.*` + * entry, top-level entry files, or a subfolder with its own `hunk` manifest + * count. The bare `index.*` fallback for subfolders is deliberately excluded — + * almost every JavaScript repository has a `src/index.ts`, and accepting that + * shape would install arbitrary repositories (a pi extension, a random + * library) as extensions that can only fail at load time. */ export function directoryContainsExtensionEntries(dir: string) { - return resolveExtensionContainerEntries(dir).length > 0; + const manifest = readExtensionManifest(dir); + if ((manifest?.entryPaths?.length ?? 0) > 0 || findFolderExtensionIndex(dir)) { + return true; + } + + return readSortedDirEntries(dir).some((entry) => { + if (entry.isDirectory()) { + return (readExtensionManifest(join(dir, entry.name))?.entryPaths?.length ?? 0) > 0; + } + + return EXTENSION_ENTRY_SUFFIXES.some((suffix) => entry.name.endsWith(suffix)); + }); } /** diff --git a/src/extensions/manage/install.test.ts b/src/extensions/manage/install.test.ts index 6117a81b1..e08ee1cf2 100644 --- a/src/extensions/manage/install.test.ts +++ b/src/extensions/manage/install.test.ts @@ -270,3 +270,46 @@ describe("hunk extension command runner", () => { expect(refused.out.join("")).toContain("Install cancelled."); }); }); + +describe("install validation strictness", () => { + test("refuses a repository whose only entry is an incidental src/index.ts", () => { + // The shape of nearly every JavaScript project — and of pi extensions, + // whose manifests use a `pi` field instead of `hunk`. + const repo = join(createTempDir("hunk-manage-incidental-"), "pi-shaped"); + mkdirSync(join(repo, "src"), { recursive: true }); + runFixtureGit(repo, ["init", "--quiet"]); + runFixtureGit(repo, ["config", "user.email", "test@example.com"]); + runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ name: "pi-shaped", pi: { extensions: ["./src/index.ts"] } }), + ); + writeFileSync(join(repo, "src", "index.ts"), "export default () => {};\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); + const context = createTestContext(); + + expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( + /does not contain a Hunk extension/, + ); + }); + + test("accepts a collection repository of subfolders with hunk manifests", () => { + const repo = join(createTempDir("hunk-manage-collection-"), "ext-pack"); + mkdirSync(join(repo, "one"), { recursive: true }); + runFixtureGit(repo, ["init", "--quiet"]); + runFixtureGit(repo, ["config", "user.email", "test@example.com"]); + runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); + writeFileSync( + join(repo, "one", "package.json"), + JSON.stringify({ name: "one", hunk: { extensions: ["./entry.ts"] } }), + ); + writeFileSync(join(repo, "one", "entry.ts"), "export default () => {};\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); + const context = createTestContext(); + + const outcome = installExtension(context, parseExtensionInstallSource(repo)); + expect(outcome.name).toBe("ext-pack"); + }); +}); From d6540829fb83b0b1aa8ca098374aa44fb2ebe507 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 21:22:26 +0000 Subject: [PATCH 6/8] feat(cli): accept hunk ext as an alias for hunk extension Package-manager muscle memory expects a short spelling, and the daemon command already set the precedent with its mcp alias. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb --- src/core/cli.test.ts | 13 +++++++++++++ src/core/cli.ts | 7 ++++++- website/src/content/docs/docs/reference/cli.md | 8 ++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index e9e553fe5..39a70af36 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -1619,6 +1619,19 @@ describe("parseCli extension management commands", () => { }); }); + test("accepts ext as an alias for extension", async () => { + expect(await parseCli(["bun", "hunk", "ext", "list"])).toEqual({ + kind: "extension-manage", + action: "list", + }); + expect(await parseCli(["bun", "hunk", "ext", "install", "acme/hunk-ext", "--yes"])).toEqual({ + kind: "extension-manage", + action: "install", + source: "acme/hunk-ext", + yes: true, + }); + }); + test("shows extension help for the bare command and rejects unknown subcommands", async () => { const parsed = await parseCli(["bun", "hunk", "extension"]); expect(parsed.kind).toBe("help"); diff --git a/src/core/cli.ts b/src/core/cli.ts index 704f81724..e4ccca782 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -197,6 +197,7 @@ export const CLI_REFERENCE_COMMANDS = { "hunk extension install git:/[@ref]", "hunk extension install [@ref]", ], + aliases: ["hunk ext install"], options: [ { flag: "--yes", @@ -208,16 +209,19 @@ export const CLI_REFERENCE_COMMANDS = { path: "extension list", summary: "list extensions installed with `hunk extension install`", synopsis: ["hunk extension list"], + aliases: ["hunk ext list"], }, "extension-update": { path: "extension update", summary: "re-clone managed extension installs from their recorded sources", synopsis: ["hunk extension update [name]"], + aliases: ["hunk ext update"], }, "extension-remove": { path: "extension remove", summary: "remove one managed extension install", synopsis: ["hunk extension remove "], + aliases: ["hunk ext remove"], }, "daemon-serve": { path: "daemon serve", @@ -1421,7 +1425,7 @@ const EXTENSION_MANAGE_HELP = [ " hunk extension update [name]", " hunk extension remove ", "", - "Install and manage shared extensions.", + "Install and manage shared extensions. `hunk ext` is an alias for `hunk extension`.", "", "install clone an extension repository into Hunk's managed install directory;", " sources are /[@ref], git:/[@ref], a git URL,", @@ -1645,6 +1649,7 @@ export async function parseCli(argv: string[]): Promise { case "skill": return parseSkillCommand(rest); case "extension": + case "ext": return parseExtensionCommand(rest); case "daemon": case "mcp": diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index e8314fe86..2aa4a124c 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -184,6 +184,8 @@ hunk extension install git:/[@ref] hunk extension install [@ref] ``` +**Aliases:** `hunk ext install`. + ### Command-specific options | Option | Description | @@ -200,6 +202,8 @@ list extensions installed with `hunk extension install` hunk extension list ``` +**Aliases:** `hunk ext list`. + ## `hunk extension update` re-clone managed extension installs from their recorded sources @@ -210,6 +214,8 @@ re-clone managed extension installs from their recorded sources hunk extension update [name] ``` +**Aliases:** `hunk ext update`. + ## `hunk extension remove` remove one managed extension install @@ -220,6 +226,8 @@ remove one managed extension install hunk extension remove ``` +**Aliases:** `hunk ext remove`. + ## `hunk daemon serve` run the local Hunk session daemon and websocket session broker From 1b46dc3ea2daf5f1e249a507272e62110e3713e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 21:31:52 +0000 Subject: [PATCH 7/8] fix(extensions): harden install paths, promotion, and record writes Address review findings: local install sources now expand ~ and are recorded absolute so a later update works from any directory; promotion moves the previous install aside and restores it if the swap fails instead of deleting it first; records merge over a fresh read at write time and staging directories are pid-suffixed, so overlapping commands cannot drop each other's records or share a workspace; discovery skips the installer's dot-prefixed workspace directories. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb --- src/extensions/discovery.test.ts | 19 ++++++++ src/extensions/discovery.ts | 7 ++- src/extensions/manage/install.ts | 69 +++++++++++++++++++--------- src/extensions/manage/source.test.ts | 14 ++++++ src/extensions/manage/source.ts | 10 +++- 5 files changed, 94 insertions(+), 25 deletions(-) diff --git a/src/extensions/discovery.test.ts b/src/extensions/discovery.test.ts index 9999ad731..f20bfd943 100644 --- a/src/extensions/discovery.test.ts +++ b/src/extensions/discovery.test.ts @@ -529,3 +529,22 @@ describe("manifest api version requirements", () => { expect(candidates).toEqual([{ id: "bad-api-ext", path: entry, origin: "flag" }]); }); }); + +describe("managed install root scanning", () => { + test("skips the installer's dot-prefixed staging and backup directories", () => { + const globalDir = createTempDir("hunk-ext-installed-dots-"); + const installedRoot = join(globalDir, "installed"); + const real = writeExtensionFile(installedRoot, "real-ext", "index.ts"); + // Installer workspace directories carry full extension layouts but must not load. + writeExtensionFile(installedRoot, ".staging-real-ext-123", "index.ts"); + writeExtensionFile(installedRoot, ".previous-real-ext", "index.ts"); + + const candidates = discoverExtensions({ + cwd: globalDir, + repoRoot: undefined, + globalExtensionsDir: globalDir, + }); + + expect(candidates).toEqual([{ id: "real-ext", path: real, origin: "global" }]); + }); +}); diff --git a/src/extensions/discovery.ts b/src/extensions/discovery.ts index d6d0d61b4..0c58ce53b 100644 --- a/src/extensions/discovery.ts +++ b/src/extensions/discovery.ts @@ -290,7 +290,9 @@ function scanInstalledExtensionsRoot(root: string): DiscoveredExtensionEntry[] { const entries: DiscoveredExtensionEntry[] = []; for (const entry of readSortedDirEntries(root)) { - if (!entry.isDirectory()) { + // Dot-prefixed directories are the installer's own workspace (staging + // clones, promotion backups) and must never load as extensions. + if (!entry.isDirectory() || entry.name.startsWith(".")) { continue; } @@ -308,8 +310,9 @@ function scanInstalledExtensionsRoot(root: string): DiscoveredExtensionEntry[] { * `~/` prefix is expanded — `~user` is deliberately left alone, since resolving * another account's home is a shell feature Hunk has no business guessing at. * Both separators are accepted so a Windows config may write `~\dev\...`. + * Exported so install-source parsing expands `~` the same single way. */ -function expandHomePath(path: string) { +export function expandHomePath(path: string) { if (path === "~") { return homedir(); } diff --git a/src/extensions/manage/install.ts b/src/extensions/manage/install.ts index 9bf488e0a..fdef84731 100644 --- a/src/extensions/manage/install.ts +++ b/src/extensions/manage/install.ts @@ -1,13 +1,8 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { HunkUserError } from "../../core/errors"; import { directoryContainsExtensionEntries } from "../discovery"; -import { - readInstallRecords, - writeInstallRecords, - type ExtensionInstallRecord, - type ExtensionInstallRecordMap, -} from "./records"; +import { readInstallRecords, writeInstallRecords, type ExtensionInstallRecord } from "./records"; import type { ExtensionInstallSource } from "./source"; /** @@ -185,7 +180,9 @@ function cloneSource(source: ExtensionInstallSource, destination: string) { */ function stageClone(context: ExtensionManageContext, source: ExtensionInstallSource) { mkdirSync(context.installedRoot, { recursive: true }); - const stagingDir = join(context.installedRoot, `.staging-${source.name}`); + // Pid-suffixed so two overlapping commands for the same name cannot clone + // into — or clean up — each other's staging directory. + const stagingDir = join(context.installedRoot, `.staging-${source.name}-${process.pid}`); rmSync(stagingDir, { recursive: true, force: true }); let commit: string; @@ -215,20 +212,50 @@ function prepareStagedDependencies(context: ExtensionManageContext, stagingDir: return installDependencies(stagingDir); } -/** Swap one staged clone into its final directory. */ +/** + * Swap one staged clone into its final directory. + * + * The existing install is moved aside rather than deleted first, so a rename + * that fails (a file held open on Windows, say) can restore it: the update + * either lands whole or leaves the previous install exactly where it was. + * The aside directory is dot-prefixed so discovery never scans it. + */ function promoteStagedClone(stagingDir: string, directory: string) { - rmSync(directory, { recursive: true, force: true }); - renameSync(stagingDir, directory); + const previousDir = join(dirname(directory), `.previous-${basename(directory)}`); + rmSync(previousDir, { recursive: true, force: true }); + + const hadPrevious = existsSync(directory); + if (hadPrevious) { + renameSync(directory, previousDir); + } + + try { + renameSync(stagingDir, directory); + } catch (error) { + if (hadPrevious) { + renameSync(previousDir, directory); + } + rmSync(stagingDir, { recursive: true, force: true }); + throw error; + } + + rmSync(previousDir, { recursive: true, force: true }); } -/** Persist one record, merging over whatever is already stored. */ -function saveRecord( - context: ExtensionManageContext, - records: ExtensionInstallRecordMap, - name: string, - record: ExtensionInstallRecord, -) { - writeInstallRecords(context.installedRoot, { ...records, [name]: record }); +/** + * Persist one record, merging over a fresh read of the stored map. + * + * The map is re-read here rather than carried from the operation's start, + * because a clone sits between the two: merging over that stale snapshot + * would silently drop any record another process wrote in the meantime. The + * remaining read-to-write window matches the accepted posture of Hunk's + * state file (see `updateHunkStateRecord`). + */ +function saveRecord(context: ExtensionManageContext, name: string, record: ExtensionInstallRecord) { + writeInstallRecords(context.installedRoot, { + ...readInstallRecords(context.installedRoot), + [name]: record, + }); } /** @@ -263,7 +290,7 @@ export function installExtension( promoteStagedClone(stagingDir, directory); const timestamp = (context.now?.() ?? new Date()).toISOString(); - saveRecord(context, records, source.name, { + saveRecord(context, source.name, { source: source.spec, cloneUrl: source.cloneUrl, ...(source.ref !== undefined ? { ref: source.ref } : {}), @@ -331,7 +358,7 @@ export function updateExtension( const dependencyWarning = prepareStagedDependencies(context, stagingDir); promoteStagedClone(stagingDir, directory); - saveRecord(context, records, name, { + saveRecord(context, name, { ...record, commit, updatedAt: (context.now?.() ?? new Date()).toISOString(), diff --git a/src/extensions/manage/source.test.ts b/src/extensions/manage/source.test.ts index 896fe2275..26c329aba 100644 --- a/src/extensions/manage/source.test.ts +++ b/src/extensions/manage/source.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; import { parseExtensionInstallSource } from "./source"; describe("extension install source parsing", () => { @@ -66,3 +68,15 @@ describe("extension install source parsing", () => { expect(() => parseExtensionInstallSource("not-a-repo")).toThrow(/not a repository/); }); }); + +describe("local path sources", () => { + test("expands ~ and stores local paths absolute so update survives a cwd change", () => { + const tilde = parseExtensionInstallSource("~/dev/hunk-word-diff@v1"); + expect(tilde.cloneUrl).toBe(join(homedir(), "dev", "hunk-word-diff")); + expect(tilde.ref).toBe("v1"); + + const relative = parseExtensionInstallSource("./fixtures/hunk-ext"); + expect(isAbsolute(relative.cloneUrl)).toBe(true); + expect(relative.cloneUrl).toBe(resolve("./fixtures/hunk-ext")); + }); +}); diff --git a/src/extensions/manage/source.ts b/src/extensions/manage/source.ts index 269017c33..017f0ae24 100644 --- a/src/extensions/manage/source.ts +++ b/src/extensions/manage/source.ts @@ -1,4 +1,5 @@ -import { isAbsolute } from "node:path"; +import { isAbsolute, resolve } from "node:path"; +import { expandHomePath } from "../discovery"; import { EXTENSION_ID_RULE, isValidExtensionId } from "../extensionIds"; /** @@ -98,8 +99,13 @@ export function parseExtensionInstallSource(spec: string): ExtensionInstallSourc } let cloneUrl: string; - if (hasExplicitTransport(location) || isLocalPath(location)) { + if (hasExplicitTransport(location)) { cloneUrl = location; + } else if (isLocalPath(location)) { + // Git never expands `~` itself, and the record must survive a later + // `update` run from a different working directory, so local paths are + // stored home-expanded and absolute. + cloneUrl = resolve(expandHomePath(location)); } else if (!explicitGit && GITHUB_SHORTHAND_PATTERN.test(location)) { cloneUrl = `https://github.com/${location}`; } else if (location.includes("/")) { From 8f8375a35777a21016fb3a63ba5f20d395652f93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 01:27:03 +0000 Subject: [PATCH 8/8] fix(extensions): parse Windows paths in extension install sources Repository-name derivation split only on / and :, so a Windows local source like C:\dev\hunk-ext produced a backslash-riddled name that failed id validation and broke every install on Windows. Name derivation and ref splitting now treat both separators, Windows-style relative prefixes count as local paths, and the local-path test builds its fixture with join so CI exercises real separators on every platform. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb --- src/extensions/manage/source.test.ts | 11 +++++++---- src/extensions/manage/source.ts | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/extensions/manage/source.test.ts b/src/extensions/manage/source.test.ts index 26c329aba..1d414f0f6 100644 --- a/src/extensions/manage/source.test.ts +++ b/src/extensions/manage/source.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { homedir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import { parseExtensionInstallSource } from "./source"; @@ -47,9 +47,12 @@ describe("extension install source parsing", () => { expect(parsed.ref).toBe("v2"); }); - test("accepts a local path source", () => { - const parsed = parseExtensionInstallSource("/tmp/fixtures/hunk-ext"); - expect(parsed.cloneUrl).toBe("/tmp/fixtures/hunk-ext"); + test("accepts a platform-native absolute path source", () => { + // Built with join so the case exercises real separators on every platform — + // backslashes on Windows, slashes elsewhere. + const localPath = join(tmpdir(), "fixtures", "hunk-ext"); + const parsed = parseExtensionInstallSource(localPath); + expect(parsed.cloneUrl).toBe(resolve(localPath)); expect(parsed.name).toBe("hunk-ext"); }); diff --git a/src/extensions/manage/source.ts b/src/extensions/manage/source.ts index 017f0ae24..d16714583 100644 --- a/src/extensions/manage/source.ts +++ b/src/extensions/manage/source.ts @@ -35,14 +35,22 @@ function hasExplicitTransport(location: string) { /** Return whether one location is a local filesystem path. */ function isLocalPath(location: string) { + // Both separators for the relative forms, so Windows `.\dev\ext` works too. return ( isAbsolute(location) || location.startsWith("./") || location.startsWith("../") || + location.startsWith(".\\") || + location.startsWith("..\\") || location.startsWith("~") ); } +/** Index of the last path separator, counting both `/` and Windows `\`. */ +function lastSeparatorIndex(location: string) { + return Math.max(location.lastIndexOf("/"), location.lastIndexOf("\\")); +} + /** * Split one `@ref` suffix off a location. * @@ -51,7 +59,7 @@ function isLocalPath(location: string) { */ function splitRefSuffix(location: string): { location: string; ref?: string } { const atIndex = location.lastIndexOf("@"); - if (atIndex <= 0 || atIndex < location.lastIndexOf("/")) { + if (atIndex <= 0 || atIndex < lastSeparatorIndex(location)) { return { location }; } @@ -65,9 +73,11 @@ function splitRefSuffix(location: string): { location: string; ref?: string } { /** Derive the repository name from one clone location. */ function deriveRepositoryName(location: string) { - const trimmed = location.replace(/\/+$/, ""); - // scp-like locations separate the path with ":", URLs and paths with "/". - const lastSegment = trimmed.split(/[/:]/).at(-1) ?? ""; + const trimmed = location.replace(/[/\\]+$/, ""); + // scp-like locations separate the path with ":", URLs and POSIX paths with + // "/", and Windows local paths with "\" (their drive colon splits too, which + // is fine — the repository name is always the last segment). + const lastSegment = trimmed.split(/[/\\:]/).at(-1) ?? ""; return lastSegment.endsWith(".git") ? lastSegment.slice(0, -4) : lastSegment; }