diff --git a/src/setup/hermes-plugin.mjs b/src/setup/hermes-plugin.mjs index 353fb5b..986d36e 100644 --- a/src/setup/hermes-plugin.mjs +++ b/src/setup/hermes-plugin.mjs @@ -9,6 +9,8 @@ import { findHermesExecutable } from "../hermes/navigator.mjs"; const execFileAsync = promisify(execFile); const PLUGIN_NAME = "louder-bridge"; +const PLUGIN_OVERRIDE_KEY = + "plugins.entries.louder-bridge.allow_tool_override"; const OWNERSHIP_MARKER = ".louder-bridge-owned"; function defaultPluginSource() { @@ -177,12 +179,7 @@ async function pluginConfigSnapshot(hermes, run, hermesHome) { const [enabled, disabled, override] = await Promise.all([ readConfigValue(hermes, "plugins.enabled", run, hermesHome), readConfigValue(hermes, "plugins.disabled", run, hermesHome), - readConfigValue( - hermes, - "plugins.entries.louder-bridge.allow_tool_override", - run, - hermesHome, - ), + readConfigValue(hermes, PLUGIN_OVERRIDE_KEY, run, hermesHome), ]); return { enabled, disabled, override }; } @@ -197,7 +194,7 @@ async function unsetConfigValue(hermes, key, run, hermesHome) { } } -async function removePluginConfigEntries( +async function deactivatePluginConfig( hermes, config, run, @@ -208,27 +205,37 @@ async function removePluginConfigEntries( if (configFile && expectedFile) { requireConfigSnapshot(configFile, expectedFile); } - for (const key of ["plugins.enabled", "plugins.disabled"]) { - while (true) { - const currentFile = configFile - ? configFileSnapshot(configFile) - : null; - const state = await readConfigValue(hermes, key, run, hermesHome); - if (configFile) requireConfigSnapshot(configFile, currentFile); - const index = Array.isArray(state.value) - ? state.value.lastIndexOf(PLUGIN_NAME) - : -1; - if (index < 0) break; - await unsetConfigValue(hermes, `${key}.${index}`, run, hermesHome); - } + // Hermes has no atomic remove-by-value config command. Its plugin command + // removes enabled entries by value, leaving a disabled tombstone that a + // later enable clears without risking an unrelated list item. + if ( + Array.isArray(config.enabled.value) && + config.enabled.value.includes(PLUGIN_NAME) + ) { + await runHermes( + hermes, + ["plugins", "disable", PLUGIN_NAME], + run, + hermesHome, + ); } if (config.override.exists) { - await unsetConfigValue( + const overrideFile = configFile ? configFileSnapshot(configFile) : null; + const override = await readConfigValue( hermes, - "plugins.entries.louder-bridge.allow_tool_override", + PLUGIN_OVERRIDE_KEY, run, hermesHome, ); + if (configFile) requireConfigSnapshot(configFile, overrideFile); + if (override.exists) { + await unsetConfigValue( + hermes, + PLUGIN_OVERRIDE_KEY, + run, + hermesHome, + ); + } } } @@ -246,6 +253,17 @@ function managedPluginConfigState(state) { }; } +function pluginConfigStatesEquivalent(left, right) { + const leftManaged = managedPluginConfigState(left); + const rightManaged = managedPluginConfigState(right); + if (leftManaged.enabled !== rightManaged.enabled) return false; + if ( + JSON.stringify(leftManaged.override) !== + JSON.stringify(rightManaged.override) + ) return false; + return !leftManaged.enabled || leftManaged.disabled === rightManaged.disabled; +} + function pluginConfigStatesMatch(left, right) { return ( JSON.stringify(managedPluginConfigState(left)) === @@ -253,9 +271,13 @@ function pluginConfigStatesMatch(left, right) { ); } +function pluginConfigSnapshotsMatch(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + function requirePluginConfigRemoved(state) { const managed = managedPluginConfigState(state); - if (managed.enabled || managed.disabled || managed.override.exists) { + if (managed.enabled || managed.override.exists) { throw new Error( "Hermes plugin settings changed during removal, so Louder Bridge restored the previous installation.", ); @@ -294,7 +316,7 @@ async function applyPluginConfigState( configFile, expectedFile, ) { - await removePluginConfigEntries( + await deactivatePluginConfig( hermes, current, run, @@ -331,7 +353,7 @@ async function applyPluginConfigState( if (desired.override.exists) { await setBooleanConfigValue( hermes, - "plugins.entries.louder-bridge.allow_tool_override", + PLUGIN_OVERRIDE_KEY, desired.override.value, run, hermesHome, @@ -339,7 +361,7 @@ async function applyPluginConfigState( } else { await unsetConfigValue( hermes, - "plugins.entries.louder-bridge.allow_tool_override", + PLUGIN_OVERRIDE_KEY, run, hermesHome, ); @@ -355,6 +377,7 @@ async function applyAndVerifyPluginConfigState( configFile, expectedFile, ) { + if (pluginConfigSnapshotsMatch(current, desired)) return current; await applyPluginConfigState( hermes, current, @@ -367,11 +390,12 @@ async function applyAndVerifyPluginConfigState( const appliedFile = configFileSnapshot(configFile); const appliedState = await pluginConfigSnapshot(hermes, run, hermesHome); requireConfigSnapshot(configFile, appliedFile); - if (!pluginConfigStatesMatch(appliedState, desired)) { + if (!pluginConfigStatesEquivalent(appliedState, desired)) { throw new Error( "Hermes plugin settings changed during rollback, so Louder Bridge left the plugin in place.", ); } + return appliedState; } async function rollbackPluginConfig({ @@ -392,7 +416,7 @@ async function rollbackPluginConfig({ ); } requireExistingOwnedPlugin(target); - await applyAndVerifyPluginConfigState( + return applyAndVerifyPluginConfigState( hermes, currentState, stateBefore, @@ -433,11 +457,61 @@ function removeDirectory(directory) { if (entry(directory)) fs.rmSync(directory, { recursive: true }); } +async function rollbackInstalledPluginFiles({ + target, + backup, + rename = fs.renameSync, + restoreConfig, +}) { + requireExistingOwnedPlugin(target); + const displaced = path.join( + path.dirname(target), + `.${PLUGIN_NAME}.${randomUUID()}.rolling-back`, + ); + let targetDisplaced = false; + let backupRestored = false; + try { + rename(target, displaced); + targetDisplaced = true; + if (backup) { + rename(backup, target); + backupRestored = true; + } + removeDirectory(displaced); + } catch (error) { + const rollbackErrors = [error]; + if ( + targetDisplaced && + !backupRestored && + !entry(target) && + entry(displaced) + ) { + try { + rename(displaced, target); + } catch (caught) { + rollbackErrors.push(caught); + } + } + if (!backupRestored && isOwnedPlugin(target)) { + try { + await restoreConfig(); + } catch (caught) { + rollbackErrors.push(caught); + } + } + throw new AggregateError( + rollbackErrors, + "Hermes plugin installation could not be fully rolled back.", + ); + } +} + export async function installHermesPlugin({ homeDirectory = os.homedir(), source = defaultPluginSource(), hermes = findHermesExecutable(), run = execFileAsync, + rename = fs.renameSync, } = {}) { if (!hermes) return { installed: false, reason: "not-installed" }; const { configFile, hermesHome, target } = await activeHermesPluginLocation( @@ -465,11 +539,11 @@ export async function installHermesPlugin({ ); if (previous) { requireExistingOwnedPlugin(target); - fs.renameSync(target, backup); + rename(target, backup); } else if (entry(target)) { throw new Error("A Hermes louder-bridge plugin appeared during setup."); } - fs.renameSync(staging, target); + rename(staging, target); targetInstalled = true; enableStarted = true; await runHermes( @@ -493,9 +567,13 @@ export async function installHermesPlugin({ stateAfter, hermesHome, run, + rename, }; } catch (error) { const rollbackErrors = []; + let configRollbackVerified = !enableStarted; + let failedConfigState = null; + let restoredConfigState = null; if (enableStarted) { try { const currentFile = configFileSnapshot(configFile); @@ -506,7 +584,8 @@ export async function installHermesPlugin({ ); requireConfigSnapshot(configFile, currentFile); if (entry(target)) requireExistingOwnedPlugin(target); - await applyAndVerifyPluginConfigState( + failedConfigState = currentState; + restoredConfigState = await applyAndVerifyPluginConfigState( hermes, currentState, stateBefore, @@ -515,29 +594,49 @@ export async function installHermesPlugin({ configFile, currentFile, ); + configRollbackVerified = true; } catch (caught) { rollbackErrors.push(caught); } } removeDirectory(staging); - let canRestoreBackup = true; - if (targetInstalled) { - try { - requireOwnedPlugin(target); - removeDirectory(target); - } catch (caught) { - rollbackErrors.push(caught); - canRestoreBackup = false; + if (targetInstalled && configRollbackVerified) { + if (!entry(target)) { + if (previous) { + try { + rename(backup, target); + } catch (caught) { + rollbackErrors.push(caught); + } + } + } else { + try { + await rollbackInstalledPluginFiles({ + target, + backup: previous ? backup : null, + rename, + restoreConfig: () => rollbackPluginConfig({ + hermes, + run, + hermesHome, + configFile, + target, + stateBefore: failedConfigState, + stateAfter: restoredConfigState, + }), + }); + } catch (caught) { + rollbackErrors.push(caught); + } } - } - if (entry(backup) && canRestoreBackup) { + } else if (!targetInstalled && entry(backup)) { if (entry(target)) { rollbackErrors.push( new Error("A Hermes louder-bridge plugin appeared during setup rollback."), ); } else { try { - fs.renameSync(backup, target); + rename(backup, target); } catch (caught) { rollbackErrors.push(caught); } @@ -555,11 +654,19 @@ export async function installHermesPlugin({ export async function rollbackHermesPluginInstallation(transaction) { if (!transaction?.installed) return; + const rename = transaction.rename ?? fs.renameSync; requireExistingOwnedPlugin(transaction.target); - await rollbackPluginConfig(transaction); - requireExistingOwnedPlugin(transaction.target); - removeDirectory(transaction.target); - if (transaction.backup) fs.renameSync(transaction.backup, transaction.target); + const restoredConfigState = await rollbackPluginConfig(transaction); + await rollbackInstalledPluginFiles({ + target: transaction.target, + backup: transaction.backup, + rename, + restoreConfig: () => rollbackPluginConfig({ + ...transaction, + stateBefore: transaction.stateAfter, + stateAfter: restoredConfigState, + }), + }); } export function commitHermesPluginInstallation(transaction) { @@ -623,7 +730,7 @@ async function removeHermesPluginLocation(location, hermes, run) { ); fs.renameSync(target, backup); try { - await removePluginConfigEntries( + await deactivatePluginConfig( hermes, stateBefore, run, @@ -691,7 +798,33 @@ async function rollbackHermesPluginRemovalEntry(removal) { ); } fs.renameSync(removal.backup, removal.target); - await rollbackPluginConfig(removal); + try { + await rollbackPluginConfig(removal); + } catch (error) { + const rollbackErrors = [error]; + try { + const currentFile = configFileSnapshot(removal.configFile); + const currentState = await pluginConfigSnapshot( + removal.hermes, + removal.run, + removal.hermesHome, + ); + requireConfigSnapshot(removal.configFile, currentFile); + if ( + pluginConfigStatesEquivalent(currentState, removal.stateAfter) && + !entry(removal.backup) + ) { + requireExistingOwnedPlugin(removal.target); + fs.renameSync(removal.target, removal.backup); + } + } catch (caught) { + rollbackErrors.push(caught); + } + throw new AggregateError( + rollbackErrors, + "Hermes plugin removal entry could not be fully rolled back.", + ); + } } export async function rollbackHermesPluginRemoval(transaction) { @@ -701,7 +834,11 @@ export async function rollbackHermesPluginRemoval(transaction) { try { await rollbackHermesPluginRemovalEntry(removal); } catch (error) { - errors.push(error); + if (error instanceof AggregateError) { + errors.push(...error.errors); + } else { + errors.push(error); + } } } if (errors.length > 0) { diff --git a/test/hermes-plugin.test.mjs b/test/hermes-plugin.test.mjs index 9cd54c2..e1708cd 100644 --- a/test/hermes-plugin.test.mjs +++ b/test/hermes-plugin.test.mjs @@ -62,7 +62,12 @@ function fakeHermes(initial = {}, configPath = "/tmp/hermes/config.yaml") { } if (args[0] === "plugins" && args[1] === "enable") { const enabled = config.get("plugins.enabled") ?? []; + const disabled = config.get("plugins.disabled") ?? []; config.set("plugins.enabled", [...new Set([...enabled, "louder-bridge"])]); + config.set( + "plugins.disabled", + disabled.filter((name) => name !== "louder-bridge"), + ); config.set( "plugins.entries.louder-bridge.allow_tool_override", false, @@ -501,6 +506,99 @@ test("revalidates the installed plugin before reporting success", async (context ); }); +test("keeps the installed plugin when config rollback cannot be verified", async (context) => { + const files = fixture(); + context.after(() => fs.rmSync(files.root, { recursive: true })); + const hermes = fakeHermes({}, files.config); + let enableFinished = false; + const run = async (...args) => { + if ( + enableFinished && + args[1][0] === "config" && + args[1][1] === "get" + ) { + throw new Error("Hermes config is unavailable"); + } + const result = await hermes.run(...args); + if (args[1][0] === "plugins" && args[1][1] === "enable") { + enableFinished = true; + } + return result; + }; + + await assert.rejects( + installHermesPlugin({ + homeDirectory: files.root, + source: files.source, + hermes: "/hermes", + run, + }), + /could not be fully rolled back/, + ); + + assert.equal(fs.existsSync(files.target), true); + assert.deepEqual(readFakeConfig(files.config).get("plugins.enabled"), [ + "louder-bridge", + ]); +}); + +test("restores the new install when failed setup cannot restore its backup", async (context) => { + const files = fixture(); + context.after(() => fs.rmSync(files.root, { recursive: true })); + fs.mkdirSync(files.target, { recursive: true }); + fs.writeFileSync(path.join(files.target, ".louder-bridge-owned"), "owned\n"); + fs.writeFileSync(path.join(files.target, "previous.txt"), "previous\n"); + const hermes = fakeHermes( + { "plugins.enabled": ["existing-plugin"] }, + files.config, + ); + let enabled = false; + let verificationFailed = false; + const run = async (...args) => { + if ( + enabled && + !verificationFailed && + args[1][0] === "config" && + args[1][1] === "get" + ) { + verificationFailed = true; + throw new Error("simulated verification failure"); + } + const result = await hermes.run(...args); + if (args[1][0] === "plugins" && args[1][1] === "enable") { + enabled = true; + } + return result; + }; + const rename = (source, destination) => { + if (verificationFailed && source.endsWith(".previous")) { + throw new Error("simulated backup restore failure"); + } + fs.renameSync(source, destination); + }; + + await assert.rejects( + installHermesPlugin({ + homeDirectory: files.root, + source: files.source, + hermes: "/hermes", + run, + rename, + }), + /could not be fully rolled back/, + ); + + assert.equal(fs.readFileSync(path.join(files.target, "__init__.py"), "utf8"), "VERSION = 2\n"); + const backups = fs + .readdirSync(path.dirname(files.target)) + .filter((name) => name.endsWith(".previous")); + assert.equal(backups.length, 1); + assert.deepEqual(readFakeConfig(files.config).get("plugins.enabled"), [ + "existing-plugin", + "louder-bridge", + ]); +}); + test("verifies restored settings before removing an installed plugin", async (context) => { const files = fixture(); context.after(() => fs.rmSync(files.root, { recursive: true })); @@ -617,7 +715,10 @@ test("removes only the managed plugin and can roll the removal back", async (con }); assert.equal(fs.existsSync(files.target), false); assert.deepEqual(hermes.config.get("plugins.enabled"), ["existing-plugin"]); - assert.deepEqual(hermes.config.get("plugins.disabled"), ["disabled-plugin"]); + assert.deepEqual(hermes.config.get("plugins.disabled"), [ + "disabled-plugin", + "louder-bridge", + ]); await rollbackHermesPluginRemoval(transaction); assert.equal(fs.existsSync(files.target), true); @@ -633,6 +734,17 @@ test("removes only the managed plugin and can roll the removal back", async (con }); commitHermesPluginRemoval(second); assert.equal(fs.existsSync(files.target), false); + + const reinstallation = await installHermesPlugin({ + homeDirectory: files.root, + source: files.source, + hermes: "/hermes", + run: hermes.run, + }); + assert.deepEqual(readFakeConfig(files.config).get("plugins.disabled"), [ + "disabled-plugin", + ]); + commitHermesPluginInstallation(reinstallation); }); test("installs and removes the plugin for the active Hermes profile", async (context) => { @@ -797,10 +909,88 @@ test("preserves unrelated plugin edits during install rollback", async (context) assert.deepEqual(restored.get("plugins.disabled"), [ "disabled-plugin", "concurrent-disabled-plugin", + "louder-bridge", ]); assert.equal(fs.existsSync(files.target), false); }); +test("restores installed settings when filesystem rollback fails", async (context) => { + const files = fixture(); + context.after(() => fs.rmSync(files.root, { recursive: true })); + fs.mkdirSync(files.target, { recursive: true }); + fs.writeFileSync(path.join(files.target, ".louder-bridge-owned"), "owned\n"); + fs.writeFileSync(path.join(files.target, "previous.txt"), "previous\n"); + const hermes = fakeHermes( + { "plugins.enabled": ["existing-plugin"] }, + files.config, + ); + let failRollbackMove = false; + const rename = (source, destination) => { + if (failRollbackMove && source === files.target) { + throw new Error("simulated filesystem failure"); + } + fs.renameSync(source, destination); + }; + const installation = await installHermesPlugin({ + homeDirectory: files.root, + source: files.source, + hermes: "/hermes", + run: hermes.run, + rename, + }); + failRollbackMove = true; + + await assert.rejects( + rollbackHermesPluginInstallation(installation), + /could not be fully rolled back/, + ); + + assert.equal(fs.existsSync(files.target), true); + assert.equal(fs.existsSync(installation.backup), true); + assert.deepEqual(readFakeConfig(files.config).get("plugins.enabled"), [ + "existing-plugin", + "louder-bridge", + ]); +}); + +test("restores the new plugin when installation backup restore fails", async (context) => { + const files = fixture(); + context.after(() => fs.rmSync(files.root, { recursive: true })); + fs.mkdirSync(files.target, { recursive: true }); + fs.writeFileSync(path.join(files.target, ".louder-bridge-owned"), "owned\n"); + fs.writeFileSync(path.join(files.target, "previous.txt"), "previous\n"); + const hermes = fakeHermes( + { "plugins.enabled": ["existing-plugin"] }, + files.config, + ); + let installation; + const rename = (source, destination) => { + if (installation && source === installation.backup) { + throw new Error("simulated backup restore failure"); + } + fs.renameSync(source, destination); + }; + installation = await installHermesPlugin({ + homeDirectory: files.root, + source: files.source, + hermes: "/hermes", + run: hermes.run, + rename, + }); + + await assert.rejects( + rollbackHermesPluginInstallation(installation), + /could not be fully rolled back/, + ); + + assert.equal(fs.readFileSync(path.join(files.target, "__init__.py"), "utf8"), "VERSION = 2\n"); + assert.equal(fs.existsSync(installation.backup), true); + assert.deepEqual(readFakeConfig(files.config).get("plugins.enabled"), [ + "existing-plugin", + "louder-bridge", + ]); +}); + test("revalidates the installed plugin before rollback deletes it", async (context) => { const files = fixture(); context.after(() => fs.rmSync(files.root, { recursive: true })); @@ -818,8 +1008,8 @@ test("revalidates the installed plugin before rollback deletes it", async (conte if ( replaceDuringRollback && !replaced && - args[1][0] === "config" && - args[1][1] === "unset" + args[1][0] === "plugins" && + args[1][1] === "disable" ) { replaced = true; fs.rmSync(files.target, { recursive: true }); @@ -951,6 +1141,74 @@ test("rechecks a restored plugin before replaying removal state", async (context assert.deepEqual(readFakeConfig(files.config).get("plugins.enabled"), []); }); +test("reapplies removal when config rollback fails before mutation", async (context) => { + const files = fixture(); + context.after(() => fs.rmSync(files.root, { recursive: true })); + fs.mkdirSync(files.target, { recursive: true }); + fs.writeFileSync(path.join(files.target, ".louder-bridge-owned"), "owned\n"); + const hermes = fakeHermes( + { "plugins.enabled": ["louder-bridge"] }, + files.config, + ); + let rollbackStarted = false; + let failed = false; + const run = async (...args) => { + if ( + rollbackStarted && + !failed && + args[1][0] === "config" && + args[1][1] === "get" + ) { + failed = true; + throw new Error("simulated config failure"); + } + return hermes.run(...args); + }; + const transaction = await removeHermesPlugin({ + homeDirectory: files.root, + hermes: "/hermes", + run, + }); + rollbackStarted = true; + + await assert.rejects( + rollbackHermesPluginRemoval(transaction), + /could not be fully rolled back/, + ); + + assert.equal(fs.existsSync(files.target), false); + assert.equal(fs.existsSync(transaction.removals[0].backup), true); + assert.deepEqual(readFakeConfig(files.config).get("plugins.enabled"), []); +}); + +test("stops rollback after a concurrent disabled-state edit", async (context) => { + const files = fixture(); + context.after(() => fs.rmSync(files.root, { recursive: true })); + fs.mkdirSync(files.target, { recursive: true }); + fs.writeFileSync(path.join(files.target, ".louder-bridge-owned"), "owned\n"); + const hermes = fakeHermes( + { "plugins.enabled": ["louder-bridge"] }, + files.config, + ); + const transaction = await removeHermesPlugin({ + homeDirectory: files.root, + hermes: "/hermes", + run: hermes.run, + }); + const edited = Object.fromEntries(readFakeConfig(files.config)); + edited["plugins.disabled"] = []; + writeFakeConfig(files.config, edited); + + await assert.rejects( + rollbackHermesPluginRemoval(transaction), + /could not be fully rolled back/, + ); + + assert.equal(fs.existsSync(files.target), false); + assert.equal(fs.existsSync(transaction.removals[0].backup), true); + assert.deepEqual(readFakeConfig(files.config).get("plugins.disabled"), []); +}); + test("leaves an unowned same-name plugin in another profile untouched", async (context) => { const files = fixture(); context.after(() => fs.rmSync(files.root, { recursive: true })); @@ -1068,8 +1326,8 @@ test("revalidates each profile target immediately before removal", async (contex if ( !replaced && hermesHome === path.dirname(files.config) && - args[1][0] === "config" && - args[1][1] === "unset" + args[1][0] === "plugins" && + args[1][1] === "disable" ) { replaced = true; fs.rmSync(writerTarget, { recursive: true }); @@ -1098,7 +1356,7 @@ test("revalidates each profile target immediately before removal", async (contex ); }); -test("stops uninstall when a plugin list changes before indexed removal", async (context) => { +test("stops uninstall when a plugin list changes before deactivation", async (context) => { const files = fixture(); context.after(() => fs.rmSync(files.root, { recursive: true })); fs.mkdirSync(files.target, { recursive: true }); @@ -1139,7 +1397,7 @@ test("stops uninstall when a plugin list changes before indexed removal", async ]); }); -test("recomputes plugin indexes after each Hermes config edit", async (context) => { +test("uses Hermes value-based disable when plugin lists change", async (context) => { const files = fixture(); context.after(() => fs.rmSync(files.root, { recursive: true })); fs.mkdirSync(files.target, { recursive: true }); @@ -1154,23 +1412,22 @@ test("recomputes plugin indexes after each Hermes config edit", async (context) }, files.config); let reordered = false; const run = async (...args) => { - const result = await hermes.run(...args); if ( !reordered && - args[1][0] === "config" && - args[1][1] === "unset" && - args[1][2].startsWith("plugins.enabled.") + args[1][0] === "plugins" && + args[1][1] === "disable" ) { reordered = true; const changed = Object.fromEntries(readFakeConfig(files.config)); changed["plugins.enabled"] = [ + "concurrent-plugin", "existing-b", "existing-a", "louder-bridge", ]; writeFakeConfig(files.config, changed); } - return result; + return hermes.run(...args); }; const transaction = await removeHermesPlugin({ @@ -1181,9 +1438,19 @@ test("recomputes plugin indexes after each Hermes config edit", async (context) assert.equal(reordered, true); assert.deepEqual(readFakeConfig(files.config).get("plugins.enabled"), [ + "concurrent-plugin", "existing-b", "existing-a", ]); + assert.equal( + hermes.calls.some( + ([, command, action, key]) => + command === "config" && + action === "unset" && + /plugins\.(?:enabled|disabled)\.\d+/.test(key), + ), + false, + ); commitHermesPluginRemoval(transaction); }); @@ -1244,8 +1511,8 @@ test("preserves a replacement during failed-removal rollback", async (context) = const result = await hermes.run(...args); if ( !failed && - args[1][0] === "config" && - args[1][1] === "unset" + args[1][0] === "plugins" && + args[1][1] === "disable" ) { failed = true; fs.mkdirSync(files.target);