feat(coding-agent): accept GitHub/git URLs in plugin install
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -348,10 +348,12 @@ async function handleInstall(
|
||||
flags: { json?: boolean; force?: boolean; dryRun?: boolean; scope?: "user" | "project" },
|
||||
): Promise<void> {
|
||||
if (packages.length === 0) {
|
||||
console.error(chalk.red(`Usage: ${APP_NAME} plugin install <package[@version]>[features] ...`));
|
||||
console.error(chalk.red(`Usage: ${APP_NAME} plugin install <source>[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 <pkg[@ver]>[features] Install plugins from npm
|
||||
install <source>[features] Install plugins from npm, GitHub, or git URL
|
||||
uninstall <pkg> Remove plugins
|
||||
list Show installed plugins
|
||||
link <path> 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 <pkg> List all settings
|
||||
config get <pkg> <key> 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
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,29 @@ const KNOWN_HOSTS: Record<string, (pathname: string, hash: string) => { user: st
|
||||
"codeberg.org": extractStandard,
|
||||
};
|
||||
|
||||
/**
|
||||
* Namespaced shorthand prefixes recognised by bun's `bun install <spec>`, 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<string, string> = {
|
||||
github: "github.com",
|
||||
gitlab: "gitlab.com",
|
||||
bitbucket: "bitbucket.org",
|
||||
codeberg: "codeberg.org",
|
||||
sourcehut: "git.sr.ht",
|
||||
srht: "git.sr.ht",
|
||||
};
|
||||
|
||||
/**
|
||||
* `<prefix>:<user>/<repo>[.git][#<ref>]` shape. `<repo>` is non-greedy so the
|
||||
* optional `.git` suffix and `#ref` tail bind tightly; `<repo>` may itself
|
||||
* contain `/` to support nested GitLab groups (`gitlab:group/sub/project`).
|
||||
* `<user>` 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 `<scheme>://`.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string>> {
|
||||
try {
|
||||
const json = await Bun.file(pkgJsonPath).json();
|
||||
return (json.dependencies as Record<string, string>) ?? {};
|
||||
} 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<InstalledPlugin> {
|
||||
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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <git-spec>`'s side effects (writing the dep into
|
||||
* `plugins/package.json` under its real name, and dropping a matching
|
||||
* `node_modules/<name>/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<Uint8Array> {
|
||||
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)/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user