Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions packages/coding-agent/src/cli/plugin-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isGjcPluginSourceShape,
listGjcBundles,
previewGjcBundleUpdate,
uninstallGjcBundle,
} from "../extensibility/gjc-plugins";
import { PluginManager, parseSettingValue, validateSetting } from "../extensibility/plugins";
import {
Expand Down Expand Up @@ -480,6 +481,31 @@ function describeInstallFailure(error: unknown): string {
return error instanceof GjcPluginLoadError ? error.code : "install_failed";
}

function isGjcRegistryShapeFailure(error: unknown): boolean {
return (
(error instanceof GjcPluginLoadError && error.code === "invalid_manifest") ||
(error instanceof TypeError &&
/(?:not iterable|localeCompare|reading ['"](?:scope|name|pluginRoot|plugins|map))/.test(error.message))
);
}

async function findGjcBundlesForUninstall(
cwd: string,
name: string,
scope: "user" | "project" | undefined,
): Promise<GjcBundleSummary[]> {
const scopes = scope ? [scope] : (["user", "project"] as const);
const matches: GjcBundleSummary[] = [];
for (const candidateScope of scopes) {
try {
const result = await getGjcBundle({ cwd }, bundleIdentity(candidateScope, name));
if (result.ok) matches.push(result.value);
} catch (error) {
if (!isGjcRegistryShapeFailure(error)) throw error;
}
}
return matches;
}
async function handleInstall(
manager: PluginManager,
packages: string[],
Expand Down Expand Up @@ -627,21 +653,41 @@ async function handleInstall(
async function handleUninstall(
manager: PluginManager,
packages: string[],
flags: { json?: boolean; scope?: "user" | "project" },
flags: { json?: boolean; scope?: "user" | "project"; user?: boolean; project?: boolean },
): Promise<void> {
if (packages.length === 0) {
console.error(chalk.red(`Usage: ${APP_NAME} plugin uninstall <package> ...`));
process.exit(1);
}

// For uninstall, check the installed plugins registry directly.
// This works even if the marketplace entry was later removed from marketplaces.json.
const scope = flags.scope ?? (flags.user ? "user" : flags.project ? "project" : undefined);
const cwd = getProjectDir();
const mktMgr = await makeMarketplaceManager();
const installedPlugins = new Set((await mktMgr.listInstalledPlugins()).map(p => p.id));

for (const name of packages) {
const matches = await findGjcBundlesForUninstall(cwd, name, scope);
if (matches.length > 0) {
if (matches.length > 1) {
console.error(chalk.red(`GJC bundle "${name}" is installed in both scopes; specify --user or --project.`));
process.exit(1);
}
const identity = matches[0].identity;
const result = await uninstallGjcBundle({ cwd }, identity);
if (!result.ok) {
console.error(chalk.red(`${theme.status.error} ${result.error.message}`));
if (result.error.recovery) console.error(chalk.dim(` Try: ${result.error.recovery}`));
process.exit(3);
}
if (flags.json) {
console.log(JSON.stringify({ uninstalled: identity }));
} else {
console.log(chalk.green(`${theme.status.success} Uninstalled ${identity.name} (${identity.scope})`));
}
continue;
}

if (installedPlugins.has(name)) {
// Exact match against installed marketplace plugin IDs (name@marketplace)
try {
await mktMgr.uninstallPlugin(name, flags.scope);
console.log(chalk.green(`${theme.status.success} Uninstalled ${name}`));
Expand All @@ -652,7 +698,6 @@ async function handleUninstall(
continue;
}

// npm path
try {
await manager.uninstall(name);
if (flags.json) {
Expand Down
176 changes: 175 additions & 1 deletion packages/coding-agent/src/extensibility/gjc-plugins/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import {
surfaceIdsOf,
targetFingerprint,
} from "./lifecycle-reconciliation";
import { readRegistry, sortRegistryEntries, withRegistryLock, writeRegistryUnlocked } from "./registry";
import {
readRegistry,
registryRootForScope,
sortRegistryEntries,
withRegistryLock,
writeRegistryUnlocked,
} from "./registry";
import type {
GjcBundleIdentity,
GjcBundleSafeSource,
Expand Down Expand Up @@ -49,6 +55,9 @@ export interface GjcLifecycleContext {
function fail(code: GjcLifecycleError["code"], message: string, recovery?: string): GjcLifecycleError {
return recovery ? { code, message, recovery } : { code, message };
}
function isEnoent(error: unknown): boolean {
return (error as NodeJS.ErrnoException)?.code === "ENOENT";
}

const UNSUPPORTED_UPDATE_REASON: Partial<Record<GjcPluginRegistrySource["kind"], string>> = {};

Expand Down Expand Up @@ -224,6 +233,171 @@ export async function getGjcBundle(
return { ok: true, value: toBundleSummary(entry) };
}

function safeInstalledRoot(scope: GjcPluginScope, cwd: string, pluginRoot: string): string | null {
const root = path.resolve(pluginRoot);
const scopeRoot = path.resolve(registryRootForScope(scope, cwd));
const relative = path.relative(scopeRoot, root);
if (!relative || relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) return null;
return root;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === "string");
}

function isUninstallableEntry(value: unknown, identity: GjcBundleIdentity): value is GjcPluginRegistryEntry {
if (!isRecord(value)) return false;
if (
value.name !== identity.name ||
value.scope !== identity.scope ||
typeof value.version !== "string" ||
typeof value.enabled !== "boolean" ||
typeof value.pluginRoot !== "string" ||
typeof value.manifestPath !== "string" ||
typeof value.manifestHash !== "string" ||
typeof value.installedAt !== "string" ||
typeof value.updatedAt !== "string" ||
!isStringArray(value.disabledSurfaceIds) ||
!Array.isArray(value.copiedFiles)
) {
return false;
}
const source = value.source;
if (
!isRecord(source) ||
typeof source.kind !== "string" ||
typeof source.uri !== "string" ||
typeof source.resolvedAt !== "string"
) {
return false;
}
const surfaces = value.surfaces;
if (!isRecord(surfaces)) return false;
for (const key of ["subskills", "tools", "hooks", "mcps", "systemAppendices", "agentAppendices"]) {
const list = surfaces[key];
if (
!Array.isArray(list) ||
!list.every(item => isRecord(item) && typeof item.extensionId === "string" && typeof item.name === "string")
) {
return false;
}
}
if (
!value.copiedFiles.every(
file =>
isRecord(file) &&
typeof file.relativePath === "string" &&
typeof file.sha256 === "string" &&
typeof file.bytes === "number",
)
) {
return false;
}
if (value.quarantine !== undefined) {
if (
!Array.isArray(value.quarantine) ||
!value.quarantine.every(
entry => isRecord(entry) && typeof entry.surfaceId === "string" && typeof entry.code === "string",
)
) {
return false;
}
}
return true;
}

function isMalformedRegistryError(error: unknown): boolean {
return (
(error instanceof GjcPluginLoadError && error.code === "invalid_manifest") ||
(error instanceof TypeError &&
/(?:not iterable|localeCompare|reading ['"](?:scope|name|pluginRoot|plugins|map))/.test(error.message))
);
}

function uninstallFailure(
identity: GjcBundleIdentity,
kind: "metadata" | "remove" | "write" | "restore",
): GjcLifecycleError {
const detail =
kind === "metadata"
? "its installed metadata is invalid"
: kind === "remove"
? "the installed files could not be moved safely"
: kind === "write"
? "its registry could not be updated"
: "the previous state could not be restored";
const recovery =
kind === "metadata"
? `Repair the GJC ${identity.scope} registry, then retry gjc plugin uninstall ${identity.name} --${identity.scope}`
: `Check GJC plugin directory permissions, then retry gjc plugin uninstall ${identity.name} --${identity.scope}`;
return fail("invalid_target", `Could not uninstall GJC bundle "${identity.name}" because ${detail}`, recovery);
}

export async function uninstallGjcBundle(
ctx: GjcLifecycleContext,
identity: GjcBundleIdentity,
): Promise<GjcLifecycleResult<{ identity: GjcBundleIdentity; summary: GjcBundleSummary }>> {
return withRegistryLock(identity.scope, ctx.cwd, async () => {
let registry: Awaited<ReturnType<typeof readRegistry>>;
try {
registry = await readRegistry(identity.scope, ctx.cwd);
} catch (error) {
if (isMalformedRegistryError(error)) return { ok: false, error: uninstallFailure(identity, "metadata") };
throw error;
}

const entry = registry.plugins.find(plugin => plugin && plugin.name === identity.name);
if (!entry) return { ok: false, error: notInstalled(identity) };
if (!isUninstallableEntry(entry, identity)) return { ok: false, error: uninstallFailure(identity, "metadata") };

const root = safeInstalledRoot(identity.scope, ctx.cwd, entry.pluginRoot);
if (!root) return { ok: false, error: uninstallFailure(identity, "metadata") };

const summary = toBundleSummary(entry);
const nextRegistry = { ...registry, plugins: registry.plugins.filter(plugin => plugin !== entry) };
const backupRoot = `${root}.uninstalling-${process.pid}-${Date.now()}`;
let moved = false;

try {
await fs.rename(root, backupRoot);
moved = true;
} catch (error) {
if (!isEnoent(error)) return { ok: false, error: uninstallFailure(identity, "remove") };
}

try {
await writeRegistryUnlocked(nextRegistry, ctx.cwd);
} catch {
if (moved) {
try {
await fs.rename(backupRoot, root);
} catch {
return { ok: false, error: uninstallFailure(identity, "restore") };
}
}
return { ok: false, error: uninstallFailure(identity, "write") };
}

if (moved) {
try {
await fs.rm(backupRoot, { recursive: true, force: true });
} catch {
try {
await writeRegistryUnlocked(registry, ctx.cwd);
await fs.rename(backupRoot, root);
} catch {
return { ok: false, error: uninstallFailure(identity, "restore") };
}
return { ok: false, error: uninstallFailure(identity, "remove") };
}
}
return { ok: true, value: { identity, summary } };
});
}

function notInstalled(identity: GjcBundleIdentity): GjcLifecycleError {
return fail(
"not_installed",
Expand Down
Loading
Loading