From 22e564a85d4f00813cccd5260aad1cd068e6227d Mon Sep 17 00:00:00 2001 From: oldschoola Date: Fri, 29 May 2026 21:57:00 -0700 Subject: [PATCH] feat(coding-agent): accept GitHub/git URLs in `plugin install` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `omp plugin install` to accept git sources alongside npm specs and marketplace refs. Bun's installer already understands git URLs; the blocker was `PluginManager.install`'s strict npm-name validator and the assumption that the actual package name could be derived from the spec. - `git-url.ts`: `parseGitUrl` now recognizes npm-style namespaced shorthand (`github:user/repo`, `gitlab:`, `bitbucket:`, `codeberg:`, `sourcehut:` / `srht:`), with optional `#ref` and `.git` suffix. Exposes `isGitSpec` as `parseGitUrl(s) !== null`. Existing protocol-URL and `git:` shorthand paths are untouched. - `manager.ts`: `install()` branches on `isGitSpec`. Git specs go through a separate `validateGitSpec` (shell-metachar rejection only — `/`, `:`, `@`, `#`, `+` are legal) and the real package name is discovered by snapshotting `plugins/package.json` deps before `bun install` and diffing afterwards. Falls back to value-match on force-reinstall where the key already exists. - Help text in `plugin-cli` documents the new sources and adds a github: example. Smoke tested end-to-end on Windows with both forms against the test repo: PluginManager.install('github:oldschoola/omp-insights') PluginManager.install('https://github.com/oldschoola/omp-insights') both resolve `@oldschoola/omp-insights@1.2.3` and write a correct lock entry. Shell-injection probe (`github:foo/bar; rm -rf /`) is rejected. --- packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/src/cli/plugin-cli.ts | 13 +- .../src/extensibility/plugins/git-url.ts | 79 ++++++++++- .../src/extensibility/plugins/manager.ts | 96 ++++++++++++- packages/coding-agent/test/git-url.test.ts | 127 +++++++++++++++++- .../test/plugin-install-git.test.ts | 123 +++++++++++++++++ 6 files changed, 431 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/test/plugin-install-git.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4b19a5d0e..f70742ba5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -123,6 +123,9 @@ - Fixed the bash (and `recipe`) tool result footer not rendering for failed commands. A non-zero exit threw a `ToolError`, which dropped the result details, so the styled `⟨Wall … | Timeout …⟩` footer was replaced by the raw `Wall time: … seconds` / `Command exited with code N` lines. Non-zero exits now resolve as a non-throwing error result that keeps `wallTimeMs`/`timeoutSeconds`/`exitCode`, and the footer shows `⟨Wall … | Timeout … | Exit: N⟩` with the textual notices folded out of the output pane. Aborts, timeouts, and missing-exit-status still throw as before. - Fixed selector-style UI components to honor `tui.select.up` and `tui.select.down` keybindings instead of hard-coding raw Up/Down arrow bytes ([#1535](https://github.com/can1357/oh-my-pi/issues/1535)). +### Added + +- `omp plugin install` now accepts GitHub/GitLab/Bitbucket shorthand (`github:user/repo`, `gitlab:user/repo`, …) and full git URLs (`https://github.com/user/repo`, `git@github.com:user/repo`, …) in addition to npm specs and marketplace refs. ## [15.5.15] - 2026-05-30 ### Changed diff --git a/packages/coding-agent/src/cli/plugin-cli.ts b/packages/coding-agent/src/cli/plugin-cli.ts index 8ffea2845..ff39db874 100644 --- a/packages/coding-agent/src/cli/plugin-cli.ts +++ b/packages/coding-agent/src/cli/plugin-cli.ts @@ -348,10 +348,12 @@ async function handleInstall( flags: { json?: boolean; force?: boolean; dryRun?: boolean; scope?: "user" | "project" }, ): Promise { if (packages.length === 0) { - console.error(chalk.red(`Usage: ${APP_NAME} plugin install [features] ...`)); + console.error(chalk.red(`Usage: ${APP_NAME} plugin install [features] ...`)); console.error(chalk.dim("Examples:")); console.error(chalk.dim(` ${APP_NAME} plugin install @oh-my-pi/exa`)); console.error(chalk.dim(` ${APP_NAME} plugin install name@marketplace`)); + console.error(chalk.dim(` ${APP_NAME} plugin install github:user/repo`)); + console.error(chalk.dim(` ${APP_NAME} plugin install https://github.com/user/repo#v1.0`)); process.exit(1); } @@ -898,7 +900,7 @@ export function printPluginHelp(): void { console.log(`${chalk.bold(`${APP_NAME} plugin`)} - Plugin lifecycle management ${chalk.bold("Commands:")} - install [features] Install plugins from npm + install [features] Install plugins from npm, GitHub, or git URL uninstall Remove plugins list Show installed plugins link Link local plugin for development @@ -916,6 +918,12 @@ ${chalk.bold("Feature Syntax:")} pkg[*] Install with all features pkg[] Install with no optional features +${chalk.bold("Sources:")} + pkg, pkg@1.2.3 npm package (optionally pinned) + github:user/repo[#ref] GitHub shorthand (also gitlab:, bitbucket:, codeberg:, sourcehut:) + https://github.com/user/repo Full git URL (https, ssh, or git protocol) + name@marketplace Marketplace plugin (see marketplace command) + ${chalk.bold("Config Subcommands:")} config list List all settings config get Get a setting value @@ -938,5 +946,6 @@ ${chalk.bold("Examples:")} ${APP_NAME} plugin config set my-plugin apiKey sk-xxx ${APP_NAME} plugin doctor --fix ${APP_NAME} plugin install --scope project name@marketplace + ${APP_NAME} plugin install github:oldschoola/omp-insights `); } diff --git a/packages/coding-agent/src/extensibility/plugins/git-url.ts b/packages/coding-agent/src/extensibility/plugins/git-url.ts index 3a1df9f85..c77ea1376 100644 --- a/packages/coding-agent/src/extensibility/plugins/git-url.ts +++ b/packages/coding-agent/src/extensibility/plugins/git-url.ts @@ -25,6 +25,29 @@ const KNOWN_HOSTS: Record { user: st "codeberg.org": extractStandard, }; +/** + * Namespaced shorthand prefixes recognised by bun's `bun install `, mapped + * to their canonical host. Mirrors npm's `github:`, `gitlab:`, `bitbucket:` etc. + * shorthand so callers can pass them through to bun verbatim. + */ +const SHORTHAND_PREFIXES: Record = { + github: "github.com", + gitlab: "gitlab.com", + bitbucket: "bitbucket.org", + codeberg: "codeberg.org", + sourcehut: "git.sr.ht", + srht: "git.sr.ht", +}; + +/** + * `:/[.git][#]` shape. `` is non-greedy so the + * optional `.git` suffix and `#ref` tail bind tightly; `` may itself + * contain `/` to support nested GitLab groups (`gitlab:group/sub/project`). + * `` rejects `/`, `:`, `#` to keep protocol URLs (`https://…`) and + * scp-like SSH (`git@github.com:user/repo`) out of this path. + */ +const SHORTHAND_RE = /^([a-z]+):([^/:#]+)\/([^#]+?)(?:\.git)?(?:#(.+))?$/i; + function stripUrlCredentials(url: string): string { if (!url.includes("://")) return url; try { @@ -209,14 +232,55 @@ function parseGenericGitUrl(url: string): GitSource | null { return { type: "git", repo, host, path: normalizedPath, ref, pinned: Boolean(ref) }; } +/** + * Match an npm/bun-style namespaced shorthand (`github:user/repo`, optionally + * `…#ref` or `….git`). Returns null for protocol URLs and any prefix not in + * `SHORTHAND_PREFIXES` so the caller can fall through to the generic paths. + */ +function tryNamespacedShorthand(trimmed: string): GitSource | null { + // Cheap gate: bail out before touching protocol URLs (`https://`, `ssh://`, + // `git://`) where the char after the colon is always `/`. The shorthand we + // care about never starts with `://`. + if (!/^[a-z]+:[^/]/i.test(trimmed)) return null; + const match = trimmed.match(SHORTHAND_RE); + if (!match) return null; + const prefix = (match[1] ?? "").toLowerCase(); + const host = SHORTHAND_PREFIXES[prefix]; + if (!host) return null; + const user = match[2] ?? ""; + const repoPath = match[3] ?? ""; + if (!user || !repoPath) return null; + const ref = match[4]; + if (ref) { + try { + decodeURIComponent(ref); + } catch { + return null; + } + } + const fullPath = `${user}/${repoPath}`; + return { + type: "git", + repo: `https://${host}/${fullPath}`, + host, + path: fullPath, + ref: ref || undefined, + pinned: Boolean(ref), + }; +} + /** * Parse git source into a GitSource. * * Rules: - * - With `git:` prefix, accept shorthand forms. + * - Namespaced shorthand (`github:user/repo`, `gitlab:`, `bitbucket:`, + * `codeberg:`, `sourcehut:`/`srht:`) is accepted directly — these mirror + * bun/npm's built-in install shorthands and skip the rest of the pipeline. + * - With `git:` prefix, accept generic shorthand forms. * - Without `git:` prefix, only accept explicit protocol URLs. * * Handles: + * - `github:user/repo[#ref]`-style namespaced shorthand * - `git:` prefixed URLs (`git:github.com/user/repo`) * - SSH SCP-like URLs (`git:git@github.com:user/repo`) * - HTTPS/HTTP/SSH/git protocol URLs @@ -227,6 +291,10 @@ function parseGenericGitUrl(url: string): GitSource | null { */ export function parseGitUrl(source: string): GitSource | null { const trimmed = source.trim(); + + const shorthand = tryNamespacedShorthand(trimmed); + if (shorthand) return shorthand; + const hasGitPrefix = /^git:(?!\/\/)/i.test(trimmed); const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed; @@ -279,3 +347,12 @@ export function parseGitUrl(source: string): GitSource | null { return parseGenericGitUrl(url); } + +/** + * Returns true if the spec is parseable as a git source (protocol URL, + * scp-like SSH wrapped in `git:`, plain `git:` shorthand, or namespaced + * shorthand like `github:user/repo`). The inverse of "this is an npm spec". + */ +export function isGitSpec(spec: string): boolean { + return parseGitUrl(spec) !== null; +} diff --git a/packages/coding-agent/src/extensibility/plugins/manager.ts b/packages/coding-agent/src/extensibility/plugins/manager.ts index 24e64e1c9..2271ca048 100644 --- a/packages/coding-agent/src/extensibility/plugins/manager.ts +++ b/packages/coding-agent/src/extensibility/plugins/manager.ts @@ -10,6 +10,7 @@ import { isEnoent, logger, } from "@oh-my-pi/pi-utils"; +import { isGitSpec } from "./git-url"; import { extractPackageName, parsePluginSpec } from "./parser"; import type { DoctorCheck, @@ -29,8 +30,14 @@ import type { /** Valid npm package name pattern (scoped and unscoped, with optional version) */ const VALID_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9-._^~>=<]+)?$/i; +/** Characters that are never valid in any plugin install spec — git or npm. */ +const SHELL_METACHARS = /[;&|`$(){}<>\\\n\r\t]/; + /** - * Validate package name to prevent command injection. + * Validate package name to prevent command injection. npm specs only — git + * specs (`github:user/repo`, `https://github.com/...`, ...) MUST go through + * {@link validateGitSpec} instead because they contain characters npm rejects + * (`:`, `/`, `#`, `+`, `@` in non-version positions). */ function validatePackageName(name: string): void { // Remove version specifier for validation @@ -44,6 +51,19 @@ function validatePackageName(name: string): void { } } +/** + * Validate a git install spec — accepts `:`, `/`, `#`, `+`, `.`, `-`, `_`, + * `~`, `@` (which would all fail {@link validatePackageName}) but rejects + * shell metacharacters so the spec stays safe when forwarded to bun install. + * `Bun.spawn` does not invoke a shell, but defense-in-depth keeps things + * obvious for future readers. + */ +function validateGitSpec(spec: string): void { + if (SHELL_METACHARS.test(spec)) { + throw new Error(`Invalid characters in plugin source: ${spec}`); + } +} + // ============================================================================= // Plugin Manager // ============================================================================= @@ -127,12 +147,39 @@ export class PluginManager { } } + /** + * Read the `dependencies` map from `plugins/package.json`. Returns an empty + * object when the file does not exist yet so callers can diff `before` + * against `after` to discover the package bun just installed under its + * real name (git specs do not encode the package name in the spec itself). + */ + async #readDeps(pkgJsonPath: string): Promise> { + try { + const json = await Bun.file(pkgJsonPath).json(); + return (json.dependencies as Record) ?? {}; + } catch (err) { + if (isEnoent(err)) return {}; + throw err; + } + } + // ========================================================================== // Install / Uninstall // ========================================================================== /** - * Install a plugin from npm with optional feature selection. + * Install a plugin with optional feature selection. + * + * Accepts: + * - npm specs: `pkg`, `pkg@1.2.3`, `@scope/pkg`, `pkg[features]` + * - namespaced git shorthand: `github:user/repo[#ref]`, `gitlab:`, `bitbucket:`, + * `codeberg:`, `sourcehut:`/`srht:` + * - full git URLs: `https://github.com/user/repo`, `git@github.com:user/repo`, + * `ssh://…`, `git+https://…` + * + * For git specs the package name is not knowable from the spec, so the + * installer diffs `plugins/package.json` `dependencies` before and after + * to find the newly added key. * * @param specString - Package specifier with optional features: "pkg", "pkg[feat]", "pkg[*]", "pkg[]" * @param options - Install options @@ -140,7 +187,12 @@ export class PluginManager { */ async install(specString: string, options: InstallOptions = {}): Promise { const spec = parsePluginSpec(specString); - validatePackageName(spec.packageName); + const gitSource = isGitSpec(spec.packageName); + if (gitSource) { + validateGitSpec(spec.packageName); + } else { + validatePackageName(spec.packageName); + } await this.#ensurePackageJson(); @@ -154,6 +206,8 @@ export class PluginManager { enabled: true, }; } + const pkgJsonPath = getPluginsPackageJson(); + const depsBefore = gitSource ? await this.#readDeps(pkgJsonPath) : {}; // Run npm install const proc = Bun.spawn(["bun", "install", spec.packageName], { @@ -169,9 +223,39 @@ export class PluginManager { const stderr = await new Response(proc.stderr).text(); throw new Error(`npm install failed: ${stderr}`); } - - // Resolve actual package name (strip version specifier) - const actualName = extractPackageName(spec.packageName); + // Resolve actual package name. npm specs encode the name (strip version); + // git specs do not, so diff plugins/package.json deps to find the new entry. + let actualName: string; + if (gitSource) { + const depsAfter = await this.#readDeps(pkgJsonPath); + let resolved: string | undefined; + for (const key of Object.keys(depsAfter)) { + if (!(key in depsBefore)) { + resolved = key; + break; + } + } + // Fallback: a force-reinstall of an already-present git plugin will not + // add a new key, just rewrite the existing one to the new spec value. + // Match by the value containing the original spec instead. + if (!resolved) { + const needle = spec.packageName.replace(/^git\+/, ""); + for (const [key, value] of Object.entries(depsAfter)) { + if (typeof value === "string" && value.includes(needle)) { + resolved = key; + break; + } + } + } + if (!resolved) { + throw new Error( + `Installed ${spec.packageName} but could not determine package name from plugins/package.json`, + ); + } + actualName = resolved; + } else { + actualName = extractPackageName(spec.packageName); + } const pkgPath = path.join(getPluginsNodeModules(), actualName, "package.json"); let pkg: { name: string; version: string; omp?: PluginManifest; pi?: PluginManifest }; diff --git a/packages/coding-agent/test/git-url.test.ts b/packages/coding-agent/test/git-url.test.ts index 8b589dd40..3b4f8e5ce 100644 --- a/packages/coding-agent/test/git-url.test.ts +++ b/packages/coding-agent/test/git-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseGitUrl } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/git-url"; +import { isGitSpec, parseGitUrl } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/git-url"; describe("parseGitUrl", () => { describe("protocol URLs (accepted without git: prefix)", () => { @@ -76,4 +76,129 @@ describe("parseGitUrl", () => { expect(parseGitUrl("vendor/github.enterprise/tools")).toBeNull(); }); }); + + describe("namespaced shorthand (github:user/repo, gitlab:, …)", () => { + test("parses github: shorthand", () => { + expect(parseGitUrl("github:user/repo")).toEqual({ + type: "git", + host: "github.com", + path: "user/repo", + repo: "https://github.com/user/repo", + ref: undefined, + pinned: false, + }); + }); + + test("captures #ref and marks pinned", () => { + const result = parseGitUrl("github:user/repo#v1.0"); + expect(result).toMatchObject({ + type: "git", + host: "github.com", + path: "user/repo", + repo: "https://github.com/user/repo", + ref: "v1.0", + pinned: true, + }); + }); + + test("strips trailing .git from the path", () => { + const result = parseGitUrl("github:user/repo.git"); + expect(result).toMatchObject({ + type: "git", + host: "github.com", + path: "user/repo", + repo: "https://github.com/user/repo", + pinned: false, + }); + }); + + test("maps gitlab: to gitlab.com", () => { + expect(parseGitUrl("gitlab:user/repo")).toMatchObject({ + type: "git", + host: "gitlab.com", + path: "user/repo", + repo: "https://gitlab.com/user/repo", + }); + }); + + test("maps bitbucket: to bitbucket.org", () => { + expect(parseGitUrl("bitbucket:user/repo")).toMatchObject({ + type: "git", + host: "bitbucket.org", + path: "user/repo", + repo: "https://bitbucket.org/user/repo", + }); + }); + + test("maps codeberg: to codeberg.org", () => { + expect(parseGitUrl("codeberg:user/repo")).toMatchObject({ + type: "git", + host: "codeberg.org", + path: "user/repo", + repo: "https://codeberg.org/user/repo", + }); + }); + + test("maps sourcehut: and srht: to git.sr.ht", () => { + expect(parseGitUrl("sourcehut:user/repo")).toMatchObject({ + type: "git", + host: "git.sr.ht", + path: "user/repo", + repo: "https://git.sr.ht/user/repo", + }); + expect(parseGitUrl("srht:user/repo")).toMatchObject({ + type: "git", + host: "git.sr.ht", + path: "user/repo", + repo: "https://git.sr.ht/user/repo", + }); + }); + + test("rejects missing repo segment", () => { + expect(parseGitUrl("github:user")).toBeNull(); + }); + + test("rejects empty body", () => { + expect(parseGitUrl("github:")).toBeNull(); + }); + + test("rejects unknown shorthand prefix", () => { + expect(parseGitUrl("notahost:user/repo")).toBeNull(); + }); + + test("does not swallow protocol URLs (regression)", () => { + expect(parseGitUrl("https://github.com/user/repo")).toMatchObject({ + type: "git", + host: "github.com", + path: "user/repo", + repo: "https://github.com/user/repo", + }); + }); + }); +}); + +describe("isGitSpec", () => { + test("returns true for namespaced shorthand", () => { + expect(isGitSpec("github:user/repo")).toBe(true); + }); + + test("returns true for https git URLs", () => { + expect(isGitSpec("https://github.com/user/repo")).toBe(true); + }); + + test("returns false for unprefixed scp-like ssh (parseGitUrl rejects it)", () => { + expect(isGitSpec("git@github.com:user/repo")).toBe(false); + }); + + test("returns false for bare npm name", () => { + expect(isGitSpec("my-plugin")).toBe(false); + }); + + test("returns false for scoped npm name", () => { + expect(isGitSpec("@scope/pkg")).toBe(false); + }); + + test("returns false for scoped npm name with version", () => { + expect(isGitSpec("@scope/pkg@1.2.3")).toBe(false); + }); }); diff --git a/packages/coding-agent/test/plugin-install-git.test.ts b/packages/coding-agent/test/plugin-install-git.test.ts new file mode 100644 index 000000000..7b78fe58e --- /dev/null +++ b/packages/coding-agent/test/plugin-install-git.test.ts @@ -0,0 +1,123 @@ +/** + * Install-from-git tests for `PluginManager.install`. + * + * Strategy: spy on the six `@oh-my-pi/pi-utils` plugin-path getters so the + * manager points at a temp directory tree, then spy on `Bun.spawn` so we can + * simulate `bun install `'s side effects (writing the dep into + * `plugins/package.json` under its real name, and dropping a matching + * `node_modules//package.json`). This exercises the real + * `PluginManager.install` end-to-end without hitting the network. + * + * `vi.spyOn` + `vi.restoreAllMocks()` is the same pattern used by + * `test/tools/report-tool-issue.test.ts` (which spies on + * `piUtils.getInstallId`), so we know namespace spying on `pi-utils` exports + * propagates through to consumers of the barrel re-exports. The + * `vi.spyOn(Bun, "spawn")` mock follows `test/git-process-config.test.ts`. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { PluginManager } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/manager"; +import * as piUtils from "@oh-my-pi/pi-utils"; +import type { Subprocess } from "bun"; + +function emptyStream(): ReadableStream { + const body = new Response("").body; + if (!body) { + throw new Error("Failed to create empty response stream"); + } + return body; +} + +describe("PluginManager.install with git sources", () => { + let tmpRoot: string; + let pluginsDir: string; + let pluginsNodeModules: string; + let pluginsPkgJson: string; + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-plugin-git-")); + pluginsDir = path.join(tmpRoot, "plugins"); + pluginsNodeModules = path.join(pluginsDir, "node_modules"); + pluginsPkgJson = path.join(pluginsDir, "package.json"); + await fs.mkdir(pluginsNodeModules, { recursive: true }); + + vi.spyOn(piUtils, "getPluginsDir").mockReturnValue(pluginsDir); + vi.spyOn(piUtils, "getPluginsNodeModules").mockReturnValue(pluginsNodeModules); + vi.spyOn(piUtils, "getPluginsPackageJson").mockReturnValue(pluginsPkgJson); + vi.spyOn(piUtils, "getPluginsLockfile").mockReturnValue(path.join(tmpRoot, "omp-plugins.lock.json")); + vi.spyOn(piUtils, "getProjectDir").mockReturnValue(tmpRoot); + vi.spyOn(piUtils, "getProjectPluginOverridesPath").mockReturnValue(path.join(tmpRoot, "plugin-overrides.json")); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + + test("installs from github: shorthand and resolves real package name from deps diff", async () => { + // Seed the plugins manifest so install()'s `depsBefore` snapshot is empty + // rather than triggering #ensurePackageJson's bootstrap path. + await Bun.write( + pluginsPkgJson, + JSON.stringify({ name: "omp-plugins", private: true, dependencies: {} }, null, 2), + ); + + vi.spyOn(Bun, "spawn").mockImplementation(((cmd: string[]) => { + // Verify the manager forwards the spec verbatim to bun install. + expect(cmd[0]).toBe("bun"); + expect(cmd[1]).toBe("install"); + expect(cmd[2]).toBe("github:foo/bar"); + + // Simulate the on-disk side effects bun install produces for a git + // source: a new dep keyed by the package's own `name` field, plus + // the corresponding entry under node_modules. + const prepare = (async () => { + await Bun.write( + pluginsPkgJson, + JSON.stringify( + { + name: "omp-plugins", + private: true, + dependencies: { "real-name": "github:foo/bar" }, + }, + null, + 2, + ), + ); + const installedDir = path.join(pluginsNodeModules, "real-name"); + await fs.mkdir(installedDir, { recursive: true }); + await Bun.write( + path.join(installedDir, "package.json"), + JSON.stringify({ name: "real-name", version: "0.1.0" }, null, 2), + ); + })(); + + return { + pid: 1, + stdout: emptyStream(), + stderr: emptyStream(), + exited: prepare.then(() => 0), + } as Subprocess; + }) as typeof Bun.spawn); + + const mgr = new PluginManager(tmpRoot); + const result = await mgr.install("github:foo/bar"); + + expect(result.name).toBe("real-name"); + expect(result.version).toBe("0.1.0"); + expect(result.enabled).toBe(true); + expect(result.path).toBe(path.join(pluginsNodeModules, "real-name")); + }); + + test("rejects git specs containing shell metacharacters", async () => { + const mgr = new PluginManager(tmpRoot); + await expect(mgr.install("github:foo/bar; rm -rf /")).rejects.toThrow(/Invalid characters in plugin source/); + }); + + test("still rejects invalid npm names with the original error", async () => { + const mgr = new PluginManager(tmpRoot); + await expect(mgr.install("Invalid Name With Spaces")).rejects.toThrow(/Invalid (package name|characters)/); + }); +});