Skip to content

Commit 6d2acce

Browse files
fix(install): roll back a marketplace this install created when the plugin install fails; exact standalone marketplace plans
- install: when marketplace add succeeded but plugin install / receipt write fails, remove the marketplace this run registered (its claim lived only in memory); a pre-existing marketplace is never touched - install.mjs --uninstall --mode marketplace --plan names the receipt store, marketplaces root, and agent-bundle namespace it would prune, matching the completed run
1 parent 25fd409 commit 6d2acce

4 files changed

Lines changed: 113 additions & 9 deletions

File tree

‎packages/agent-bundle/src/install/install.ts‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -669,14 +669,36 @@ const installPublicCli = async (
669669
'add',
670670
identity.bundleRoot,
671671
]);
672-
await runHostCommand(runner, identity, host, host === 'claude'
673-
? ['plugin', 'install', id, '--scope', scope]
674-
: ['plugin', 'add', id]);
675-
await writeStoredInstallReceipt(receiptPath, createInstallReceipt({
676-
...recorded,
677-
inventory: storeInventory,
678-
updatedAt: new Date().toISOString(),
679-
}));
672+
// Between `marketplace add` and the receipt write, a marketplace this run created is claimed only in memory.
673+
// If the plugin install or the receipt write fails there, roll the registration back rather than leave a
674+
// marketplace nothing records: a retry would then sample it as pre-existing, record only the plugin, and
675+
// `uninstall` would retain it as user-owned forever.
676+
const createdMarketplace = previousReceipt === undefined &&
677+
recorded.registrations.some((registration) => registration.kind === `${host}-marketplace`);
678+
try {
679+
await runHostCommand(runner, identity, host, host === 'claude'
680+
? ['plugin', 'install', id, '--scope', scope]
681+
: ['plugin', 'add', id]);
682+
await writeStoredInstallReceipt(receiptPath, createInstallReceipt({
683+
...recorded,
684+
inventory: storeInventory,
685+
updatedAt: new Date().toISOString(),
686+
}));
687+
} catch (error) {
688+
if (!createdMarketplace) throw error;
689+
try {
690+
await runHostCommand(runner, identity, host, publicHostMarketplaceRemoveArguments(marketplace), 'removal');
691+
} catch (rollbackError) {
692+
throw failure(
693+
'AB7004',
694+
`${errorMessage(error)} Rolling back the marketplace this install registered also failed: ` +
695+
`${errorMessage(rollbackError)}. Marketplace ${marketplace} is registered with ${host} but no receipt records it; ` +
696+
`run \`${host} ${publicHostMarketplaceRemoveArguments(marketplace).join(' ')}\` before retrying.`,
697+
host,
698+
);
699+
}
700+
throw error;
701+
}
680702
// The receipt lands before the load verdict: the host did install the copy, so a refused one stays
681703
// receipt-owned and `uninstall` removes it without --force.
682704
if (host === 'claude') {

‎packages/agent-bundle/src/install/surface.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,19 @@ const cursorUninstallerSource = (): readonly string[] => [
507507
' console.log(` 1. Open Cursor, then Customize -> Plugins, and uninstall "${pluginName}" (marketplace ${pluginName}-marketplace) there.`);',
508508
' }',
509509
' };',
510-
" if (plan) { summary('Would uninstall'); finish('Would uninstall', directories); process.exit(0); }",
510+
' if (plan) {',
511+
' // The plan names exactly what the run would prune: each parent below is removed only once every entry in it is gone.',
512+
' const gone = new Set([...files, ...directories]);',
513+
' const prunable = [];',
514+
" for (const directory of [receiptsRoot, marketplaceRoot, join(cursorRoot, 'agent-bundle')]) {",
515+
' let entries;',
516+
" try { entries = await readdir(directory); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue; throw error; }",
517+
' if (entries.every((entry) => gone.has(join(directory, entry)))) { gone.add(directory); prunable.push(directory); }',
518+
' }',
519+
" summary('Would uninstall');",
520+
" finish('Would uninstall', [...directories, ...prunable]);",
521+
' process.exit(0);',
522+
' }',
511523
' if (repoExists) await rm(marketplaceRepo, { force: true, recursive: true });',
512524
' for (const path of files) await rm(path, { force: true });',
513525
' const pruned = [...directories];',

‎packages/agent-bundle/tests/install-surface.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,25 @@ it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-ow
799799
const marketplaceUninstalled = await run(installer, ['--uninstall', '--mode', 'marketplace'], home);
800800
expect(marketplaceUninstalled).toMatchObject({ code: 0, stderr: '' });
801801
expect(marketplaceUninstalled.stdout).toContain('Registration cursor-marketplace-staging install-fixture-marketplace: removed');
802+
// The plan is exact: this was the last staged marketplace and store receipt, so the run prunes the receipt
803+
// store, the marketplaces root, and the agent-bundle namespace — and the plan already named every one of them.
804+
const pathLines = (stdout: string, label: string): string[] => {
805+
const lines = stdout.split('\n');
806+
const start = lines.findIndex((line) => line.startsWith(label));
807+
const listed: string[] = [];
808+
for (const line of lines.slice(start + 1)) {
809+
if (!line.startsWith(' ')) break;
810+
listed.push(line.trim());
811+
}
812+
return listed.sort();
813+
};
814+
const plannedDirectories = pathLines(marketplacePlan.stdout, 'Would remove directory');
815+
expect(plannedDirectories).toEqual(pathLines(marketplaceUninstalled.stdout, 'Removed directory'));
816+
expect(plannedDirectories).toEqual(expect.arrayContaining([
817+
join(cursorRoot, 'agent-bundle', 'receipts'),
818+
join(cursorRoot, 'agent-bundle', 'marketplaces'),
819+
join(cursorRoot, 'agent-bundle'),
820+
]));
802821
expect(marketplaceUninstalled.stdout).toContain('Data (keep): unavailable');
803822
expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] });
804823
expect((await run(installer, ['--uninstall', '--mode', 'marketplace'], home)).stdout).toContain('Not installed install-fixture@1.2.3 for cursor (marketplace mode)');

‎packages/agent-bundle/tests/install.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,57 @@ it.each([
256256
await rm(receiptPath);
257257
await installBundle({ ...isolated(fixture), commandRunner: unreadable.runner, from: fixture.from, host, scope });
258258
expect((await readInstallReceiptFile(receiptPath))?.registrations.map((registration) => registration.kind)).toEqual([`${host}-plugin`]);
259+
260+
// `marketplace add` succeeded but the plugin install failed: the marketplace this run created is claimed only
261+
// in memory, so it is rolled back rather than left registered with no receipt (a retry would otherwise sample it
262+
// as pre-existing, record only the plugin, and `uninstall` would retain it as user-owned forever).
263+
await rm(receiptPath);
264+
const installVerb = host === 'claude' ? 'plugin install' : 'plugin add';
265+
const failing: CommandCall[] = [];
266+
const rolledBack = await installBundle({
267+
...isolated(fixture),
268+
commandRunner: { run: async (command, args, runOptions) => {
269+
const call = { args: [...args], command, cwd: runOptions.cwd };
270+
failing.push(call);
271+
if (isMarketplaceListCall(call)) return { code: 0, stderr: '', stdout: noMarketplaces(call) };
272+
return args.join(' ').startsWith(installVerb)
273+
? { code: 1, stderr: 'install exploded', stdout: '' }
274+
: { code: 0, stderr: '', stdout: '' };
275+
} },
276+
from: fixture.from,
277+
host,
278+
scope,
279+
}).catch((failure: unknown) => failure);
280+
expect(rolledBack).toBeInstanceOf(DiagnosticError);
281+
expect((rolledBack as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: host });
282+
expect((rolledBack as DiagnosticError).diagnostics[0]?.message).toContain('install exploded');
283+
expect(failing.map((call) => call.args.join(' ')).slice(-2)).toEqual([
284+
`${installVerb} install-fixture@install-fixture-marketplace${host === 'claude' ? ` --scope ${scope}` : ''}`,
285+
'plugin marketplace remove install-fixture-marketplace',
286+
]);
287+
expect(await readInstallReceiptFile(receiptPath)).toBeUndefined();
288+
289+
// A marketplace that pre-existed the install is not this run's to roll back.
290+
const preExisting: CommandCall[] = [];
291+
await installBundle({
292+
...isolated(fixture),
293+
commandRunner: { run: async (command, args, runOptions) => {
294+
const call = { args: [...args], command, cwd: runOptions.cwd };
295+
preExisting.push(call);
296+
if (isMarketplaceListCall(call)) {
297+
return { code: 0, stderr: '', stdout: host === 'claude'
298+
? JSON.stringify([{ name: 'install-fixture-marketplace' }])
299+
: JSON.stringify({ marketplaces: [{ name: 'install-fixture-marketplace', root: '/elsewhere' }] }) };
300+
}
301+
return args.join(' ').startsWith(installVerb)
302+
? { code: 1, stderr: 'install exploded', stdout: '' }
303+
: { code: 0, stderr: '', stdout: '' };
304+
} },
305+
from: fixture.from,
306+
host,
307+
scope,
308+
}).catch(() => undefined);
309+
expect(preExisting.map((call) => call.args.join(' '))).not.toContain('plugin marketplace remove install-fixture-marketplace');
259310
} finally {
260311
await rm(fixture.cleanupRoot, { force: true, recursive: true });
261312
}

0 commit comments

Comments
 (0)