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 e28f8bf2e..39f017a77 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 @@ -103,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/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..39a70af36 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -1564,3 +1564,91 @@ 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("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"); + 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..e4ccca782 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,40 @@ 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]", + ], + aliases: ["hunk ext install"], + 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"], + 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", summary: "run the local Hunk session daemon and websocket session broker", @@ -386,6 +421,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 +843,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 +1418,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. `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,", + " 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 +1648,9 @@ export async function parseCli(argv: string[]): Promise { return parseMarkupCommand(rest); case "skill": return parseSkillCommand(rest); + case "extension": + case "ext": + 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.test.ts b/src/extensions/discovery.test.ts index fb36d077c..f20bfd943 100644 --- a/src/extensions/discovery.test.ts +++ b/src/extensions/discovery.test.ts @@ -469,3 +469,82 @@ 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" }]); + }); +}); + +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 7c8987742..0c58ce53b 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"; @@ -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))] : []; } /** @@ -205,6 +238,70 @@ 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 deliberately publishes Hunk extension entries. + * + * 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) { + 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)); + }); +} + +/** + * 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)) { + // 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; + } + + entries.push(...resolveExtensionContainerEntries(join(root, entry.name))); + } + + return entries; +} + /** * Expand a leading `~` to the user's home directory. * @@ -213,8 +310,9 @@ function scanExtensionsDir(dir: 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(); } @@ -247,8 +345,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); } /** @@ -276,7 +373,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", @@ -306,7 +413,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/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..e08ee1cf2 --- /dev/null +++ b/src/extensions/manage/install.test.ts @@ -0,0 +1,315 @@ +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."); + }); +}); + +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"); + }); +}); diff --git a/src/extensions/manage/install.ts b/src/extensions/manage/install.ts new file mode 100644 index 000000000..fdef84731 --- /dev/null +++ b/src/extensions/manage/install.ts @@ -0,0 +1,411 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { HunkUserError } from "../../core/errors"; +import { directoryContainsExtensionEntries } from "../discovery"; +import { readInstallRecords, writeInstallRecords, type ExtensionInstallRecord } 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 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. 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 }); + // 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; + 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.', + ]); + } + + 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. + * + * 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) { + 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 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, + }); +} + +/** + * 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 } = stageClone(context, source); + const dependencyWarning = prepareStagedDependencies(context, stagingDir); + promoteStagedClone(stagingDir, directory); + + const timestamp = (context.now?.() ?? new Date()).toISOString(); + saveRecord(context, 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 } = 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), + }; + } + + const dependencyWarning = prepareStagedDependencies(context, stagingDir); + promoteStagedClone(stagingDir, directory); + saveRecord(context, 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..1d414f0f6 --- /dev/null +++ b/src/extensions/manage/source.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { homedir, tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +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 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"); + }); + + 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/); + }); +}); + +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 new file mode 100644 index 000000000..d16714583 --- /dev/null +++ b/src/extensions/manage/source.ts @@ -0,0 +1,144 @@ +import { isAbsolute, resolve } from "node:path"; +import { expandHomePath } from "../discovery"; +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) { + // 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. + * + * 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 < lastSeparatorIndex(location)) { + 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 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; +} + +/** + * 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)) { + 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("/")) { + // `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/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 { 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/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: diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 28959b1fc..2aa4a124c 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -172,6 +172,62 @@ 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] +``` + +**Aliases:** `hunk ext install`. + +### 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 +``` + +**Aliases:** `hunk ext list`. + +## `hunk extension update` + +re-clone managed extension installs from their recorded sources + +### Usage + +```bash +hunk extension update [name] +``` + +**Aliases:** `hunk ext update`. + +## `hunk extension remove` + +remove one managed extension install + +### Usage + +```bash +hunk extension remove +``` + +**Aliases:** `hunk ext remove`. + ## `hunk daemon serve` run the local Hunk session daemon and websocket session broker