fix(coding-agent): made plugin install transaction atomic on validation failure
The #3063 fix introduced a second mutating step — `bun update <name>` — that rewrites bun.lock before extension validation runs. Three failure paths could still leave the rejected commit pinned in the lockfile or active tree: - Extension validation throwing after `bun update` had refreshed bun.lock — rollback restored package.json and node_modules/<name> but never touched bun.lock. - Feature validation (`omp plugin install pkg[ghost]`) throwing outside the rollback block entirely. - Runtime-config save failing after a successful install with no rollback path. Snapshot bun.lock alongside package.json before `bun install` runs and route every post-install step (resolution, update, package.json read, feature validation, extension validation, runtime-config save) through one outer catch that restores all three (package.json + bun.lock + node_modules/<name> from snapshot). `#rollbackFailedInstall` now tolerates an unresolved `actualName` for failures that throw before the dep key is known. Three regression tests in plugin-install-validation.test.ts pin the new contract: bun.lock restoration after a git reinstall fails validation, bun.lock removal when it didn't exist pre-install, and rollback on an unknown feature request. Addresses review feedback on #3069.
This commit is contained in:
@@ -248,11 +248,29 @@ export class PluginManager {
|
||||
}
|
||||
|
||||
async #rollbackFailedInstall(
|
||||
actualName: string,
|
||||
actualName: string | undefined,
|
||||
packageJsonBefore: string,
|
||||
bunLockBefore: string | null,
|
||||
snapshot: PluginPackageSnapshot | null,
|
||||
): Promise<void> {
|
||||
await Bun.write(getPluginsPackageJson(), packageJsonBefore);
|
||||
|
||||
// Restore (or remove) bun's lockfile. Without this, a `bun install` +
|
||||
// `bun update` pair that successfully rewrote `bun.lock` would leave the
|
||||
// rejected commit pinned even when validation rolls everything else back.
|
||||
const bunLockPath = path.join(getPluginsDir(), "bun.lock");
|
||||
if (bunLockBefore === null) {
|
||||
await fs.promises.rm(bunLockPath, { force: true });
|
||||
} else {
|
||||
await Bun.write(bunLockPath, bunLockBefore);
|
||||
}
|
||||
|
||||
// `actualName` may be undefined when the install failed before the dep
|
||||
// key was resolved — package.json + bun.lock restoration above is the
|
||||
// complete rollback in that case.
|
||||
if (!actualName) {
|
||||
return;
|
||||
}
|
||||
const packagePath = path.join(getPluginsNodeModules(), actualName);
|
||||
await fs.promises.rm(packagePath, { recursive: true, force: true });
|
||||
if (!snapshot) {
|
||||
@@ -343,6 +361,19 @@ export class PluginManager {
|
||||
}
|
||||
const pkgJsonPath = getPluginsPackageJson();
|
||||
const packageJsonBefore = await Bun.file(pkgJsonPath).text();
|
||||
// Snapshot bun's lockfile so the rollback path can restore the pin. Every
|
||||
// step below — `bun install`, `bun update`, feature/extension validation,
|
||||
// runtime-config save — must either complete entirely or leave the
|
||||
// lockfile pointing at its pre-install state. Absent before install means
|
||||
// "remove on rollback".
|
||||
const bunLockPath = path.join(getPluginsDir(), "bun.lock");
|
||||
let bunLockBefore: string | null;
|
||||
try {
|
||||
bunLockBefore = await Bun.file(bunLockPath).text();
|
||||
} catch (err) {
|
||||
if (!isEnoent(err)) throw err;
|
||||
bunLockBefore = null;
|
||||
}
|
||||
const depsBefore = await this.#readDeps(pkgJsonPath);
|
||||
const packageInstallSpec = gitSource ? gitInstallSpec(spec.packageName, gitSource) : spec.packageName;
|
||||
const existingActualName = gitSource
|
||||
@@ -350,6 +381,10 @@ export class PluginManager {
|
||||
: extractPackageName(spec.packageName);
|
||||
const packageSnapshot = await this.#snapshotInstalledPackage(existingActualName);
|
||||
|
||||
// `actualName` is hoisted so the rollback handler can clean up the right
|
||||
// node_modules entry even if a step between `bun install` and the final
|
||||
// validation throws.
|
||||
let actualName: string | undefined;
|
||||
try {
|
||||
// Step 1: write the spec into plugins/package.json + node_modules.
|
||||
const installProc = Bun.spawn(["bun", "install", packageInstallSpec], {
|
||||
@@ -366,7 +401,6 @@ export class PluginManager {
|
||||
}
|
||||
// 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;
|
||||
@@ -401,7 +435,7 @@ export class PluginManager {
|
||||
// `bun update <name>` re-resolves the ref against the remote and
|
||||
// rewrites the pin; SHA-pinned refs stay put because the commit can't
|
||||
// move. First-time installs skip this — the initial `bun install` already
|
||||
// fetched HEAD.
|
||||
// fetched HEAD. Rollback is handled by the outer catch.
|
||||
if (gitSource && existingActualName) {
|
||||
const updateProc = Bun.spawn(["bun", "update", actualName], {
|
||||
cwd: getPluginsDir(),
|
||||
@@ -413,12 +447,11 @@ export class PluginManager {
|
||||
const updateExit = await updateProc.exited;
|
||||
if (updateExit !== 0) {
|
||||
const stderr = await new Response(updateProc.stderr).text();
|
||||
await this.#rollbackFailedInstall(actualName, packageJsonBefore, packageSnapshot);
|
||||
throw new Error(`bun update ${actualName} failed: ${stderr}`);
|
||||
}
|
||||
}
|
||||
const pkgPath = path.join(getPluginsNodeModules(), actualName, "package.json");
|
||||
|
||||
const pkgPath = path.join(getPluginsNodeModules(), actualName, "package.json");
|
||||
let pkg: { name: string; version: string; omp?: PluginManifest; pi?: PluginManifest };
|
||||
try {
|
||||
pkg = await Bun.file(pkgPath).json();
|
||||
@@ -465,18 +498,7 @@ export class PluginManager {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.#validateInstalledExtensions(installedPlugin);
|
||||
} catch (err) {
|
||||
try {
|
||||
await this.#rollbackFailedInstall(actualName, packageJsonBefore, packageSnapshot);
|
||||
} catch (rollbackErr) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
||||
throw new Error(`${message}\nRollback failed: ${rollbackMessage}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await this.#validateInstalledExtensions(installedPlugin);
|
||||
|
||||
// Update runtime config
|
||||
const config = await this.#ensureConfigLoaded();
|
||||
@@ -488,6 +510,20 @@ export class PluginManager {
|
||||
await this.#saveRuntimeConfig();
|
||||
|
||||
return installedPlugin;
|
||||
} catch (err) {
|
||||
try {
|
||||
await this.#rollbackFailedInstall(
|
||||
actualName ?? existingActualName,
|
||||
packageJsonBefore,
|
||||
bunLockBefore,
|
||||
packageSnapshot,
|
||||
);
|
||||
} catch (rollbackErr) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
||||
throw new Error(`${message}\nRollback failed: ${rollbackMessage}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await this.#cleanupSnapshot(packageSnapshot);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user