Skip to content

Commit 9fbcf56

Browse files
committed
fix(cli): preserve global install ownership and recovery
Keep successful native plugin selections recoverable, protect unowned files, and complete global cleanup without removing other marketplace sources. Restore managed OpenCode reload compatibility. Refs: #1142
1 parent 51f046d commit 9fbcf56

16 files changed

Lines changed: 526 additions & 75 deletions

‎cli/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,15 @@ Counts the tokens of Claude Code and Codex sessions, locally. Details: [FAQ](../
126126
| `opencode`, `kilo` | Copies the plugins' skills and agents into `~/.config/opencode/` or `~/.config/kilo/` |
127127
| `cursor` | Copies the plugins into `~/.cursor/plugins/local/`; Cursor must allow local plugin imports |
128128

129+
Files without recorded AIDD ownership are refused even when their content matches. This includes
130+
copies from an older `make reload`, or files left by an interrupted install before ownership was
131+
saved. Move the named files aside, then rerun the original setup or reload command.
132+
129133
Copilot needs a CLI whose `plugin marketplace list` accepts `--json` (verified with 1.0.98; 1.0.83 refuses it).
130134

131135
Native activation needs the tool's executable on `PATH`.
132136
Without it, registration succeeds with a warning and activation stays unrun.
133-
Install it, then run `aidd sync --global`.
137+
Install it, then rerun the original `aidd setup --global` command with its `--ide` and `--plugins` selections.
134138

135139
```sh
136140
aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all --yes

‎cli/src/contexts/framework/application/clean-use-case.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,8 @@ export class CleanUseCase {
272272
this.hostPluginRegistries.get(toolId),
273273
registration.hostName,
274274
new Set([...registrations.pluginRefs, ...machineRefs]),
275-
projectRoot
275+
projectRoot,
276+
toolId
276277
);
277278
}
278279
}
@@ -604,7 +605,8 @@ export class CleanUseCase {
604605
reader,
605606
sourceRegistration.hostName,
606607
new Set([...registrations.pluginRefs, ...machineRefs]),
607-
projectRoot
608+
projectRoot,
609+
toolId
608610
);
609611
} catch (error) {
610612
this.logger.warn(`${binary}: '${ref}' left enabled; ${String(error)}`);

‎cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,8 @@ export class CleanUserScopeUseCase {
327327
this.hostPluginRegistries.get(toolId),
328328
registration.hostName,
329329
owned,
330-
projectRoot
330+
projectRoot,
331+
toolId
331332
);
332333
for (const ref of refsByHost.get(registration.hostName) ?? []) {
333334
if (hostRefs.get(ref)?.enabled !== true)
@@ -419,6 +420,15 @@ export class CleanUserScopeUseCase {
419420
await this.purgeWhitelistedPath("cache/built", "cache/built", (candidate) =>
420421
this.fs.deleteDirectory(candidate)
421422
);
423+
// Other user marketplaces keep their registered sources. Plugin fetches belong to the
424+
// machine manifest being removed, whose active dependents were checked before any purge.
425+
const frameworkSource = `cache/marketplaces/${FRAMEWORK_MARKETPLACE_NAME}`;
426+
for (const path of [frameworkSource, "cache/plugin-cache"]) {
427+
await this.purgeWhitelistedPath(path, path, (candidate) =>
428+
this.fs.deleteDirectory(candidate)
429+
);
430+
}
431+
await this.purgeEmptyCacheShell("cache/marketplaces");
422432
// The self-update check cache, written into the same `cache/` directory by any online
423433
// command: left behind it keeps the shell below non-empty, the one occupant that made
424434
// "leaves nothing of aidd's on the machine" false in the ordinary case.
@@ -444,17 +454,15 @@ export class CleanUserScopeUseCase {
444454
await this.userManifestRepo.delete();
445455
}
446456

447-
/** `cache/built/` and `cache/update-check.json` are this whitelist's only occupants of
448-
* `userConfigDir()/cache/`. The shell around them is removed only once proven empty, under the
449-
* same containment as every other candidate — never assumed from having just purged its only
450-
* children, since a future writer under `cache/` would make that assumption stale silently. */
451-
private async purgeEmptyCacheShell(): Promise<void> {
457+
/** Remove a cache directory only once proven empty, with the same containment as every
458+
* whitelist candidate: another marketplace or a future cache writer may still use it. */
459+
private async purgeEmptyCacheShell(path = "cache"): Promise<void> {
452460
const candidate = await resolveCacheCandidate(
453461
this.fs,
454462
this.logger,
455463
this.userConfigDir(),
456-
"cache",
457-
"user scope: cache"
464+
path,
465+
`user scope: ${path}`
458466
);
459467
if (candidate === null) return;
460468
let entries: string[];
@@ -466,7 +474,7 @@ export class CleanUserScopeUseCase {
466474
}
467475
if (entries.length > 0) return;
468476
await this.fs.deleteDirectory(candidate);
469-
this.logger.info(`user scope: cache purged: ${candidate}`);
477+
this.logger.info(`user scope: ${path} purged: ${candidate}`);
470478
}
471479

472480
/** Never `userConfigDir()` itself, and never on a manifest's word: every fixed whitelist entry

‎cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
630630
toolId,
631631
[
632632
...new Set([
633-
...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias),
633+
...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias, scope),
634634
...requested,
635635
]),
636636
],
@@ -782,7 +782,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
782782
toolId: ToolId,
783783
manifest: Manifest,
784784
marketplaces: readonly Marketplace[],
785-
hostNameByAlias: ReadonlyMap<string, string>
785+
hostNameByAlias: ReadonlyMap<string, string>,
786+
scope: MarketplaceScope
786787
): string[] {
787788
const byName = new Map(marketplaces.map((m) => [m.name, m]));
788789
const refs: string[] = [];
@@ -793,6 +794,18 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
793794
if (hostName === undefined) continue;
794795
refs.push(`${plugin.name}@${hostName}`);
795796
}
797+
// Native-only global installs record successful activations, not copied plugin files.
798+
// Claims also recover a selection an older sync dropped from its projection.
799+
if (scope === "user") {
800+
const native = manifest.getNativeRegistrations(toolId);
801+
const provenHosts = [...hostNameByAlias.values()];
802+
for (const ref of [
803+
...(native?.pluginRefs ?? []),
804+
...(native?.pluginClaims ?? []).map((claim) => claim.ref),
805+
]) {
806+
if (provenHosts.some((hostName) => ref.endsWith(`@${hostName}`))) refs.push(ref);
807+
}
808+
}
796809
return refs;
797810
}
798811

‎cli/src/contexts/framework/application/ownership/global-plugin-files.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ export async function readGlobalPluginFiles(
4545
/**
4646
* Refuses a machine-scope write before any byte lands: every path must stay a normalized relative
4747
* path, its nearest existing parent must resolve strictly inside the tool's user directory, and a
48-
* file already there that this plugin does not own is never overwritten unless it already holds
49-
* the same bytes. Creates the user directory itself when the host has not yet.
48+
* file already there that this plugin does not own is never overwritten. Creates the user
49+
* directory itself when the host has not yet.
5050
*/
5151
export async function assertGlobalFilesWritable(
5252
fs: FileReader & FileWriter,
@@ -80,7 +80,6 @@ export async function assertGlobalFilesWritable(
8080
if (plugin?.files.has(rel) === true) continue;
8181
const target = join(boundary, rel);
8282
if (!(await fs.fileExists(target))) continue;
83-
if ((await fs.readFileHash(target)).value === file.hash.value) continue;
8483
throw new Error(
8584
`${toolId}: '${target}' already exists and was not installed by aidd; move or delete it, then rerun.`
8685
);

‎cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ export class NativeHostRegistrationGate {
9595
reader,
9696
registration.hostName,
9797
owned,
98-
projectRoot
98+
projectRoot,
99+
toolId
99100
);
100101
const refs = (registrations.pluginClaims ?? []).filter((claim) =>
101102
claim.ref.endsWith(`@${registration.hostName}`)

‎cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ToolId } from "../../../../kernel/tool.js";
12
import type {
23
HostPluginRegistryEntry,
34
HostPluginRegistryReader,
@@ -8,6 +9,7 @@ import type {
89
NativeMarketplaceSourceReader,
910
NativeMarketplaceSourceReading,
1011
} from "../../../tools/domain/ports/native-marketplace-source-reader.js";
12+
import { hostRefInUse } from "../../../tools/domain/registry.js";
1113
import type { NativeMarketplaceRegistration } from "../../domain/manifest/native-registrations.js";
1214

1315
export interface NativeMarketplaceSourceInspection {
@@ -80,7 +82,8 @@ export async function assertNoForeignNativeRefs(
8082
reader: HostPluginRegistryReader | undefined,
8183
hostName: string,
8284
ownedRefs: ReadonlySet<string>,
83-
projectRoot: string
85+
projectRoot: string,
86+
toolId: ToolId
8487
): Promise<ReadonlyMap<string, HostPluginRegistryEntry>> {
8588
if (reader === undefined)
8689
throw new Error(
@@ -99,9 +102,10 @@ export async function assertNoForeignNativeRefs(
99102
`Catalogue '${hostName}': host plugin registry unreadable (${reading.unreadable ?? reading.location}); no host mutation made.`
100103
);
101104
const refs = reading.refs ?? new Map<string, HostPluginRegistryEntry>();
102-
const foreign = [...refs.keys()].find(
103-
(ref) => ref.endsWith(`@${hostName}`) && !ownedRefs.has(ref)
104-
);
105+
const foreign = [...refs].find(
106+
([ref, entry]) =>
107+
hostRefInUse(toolId, entry.enabled) && ref.endsWith(`@${hostName}`) && !ownedRefs.has(ref)
108+
)?.[0];
105109
if (foreign !== undefined)
106110
throw new Error(
107111
`Catalogue '${hostName}' includes foreign host ref '${foreign}'; no host mutation made.`

‎cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,9 @@ export class UserPluginFileUpdater {
160160
if (plugin.files.has(file.relativePath)) continue;
161161
const target = join(baseDir, file.relativePath);
162162
if (!(await this.fs.fileExists(target))) continue;
163-
if ((await this.fs.readFileHash(target)).value !== file.hash.value) {
164-
throw new Error(
165-
`${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.`
166-
);
167-
}
163+
throw new Error(
164+
`${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.`
165+
);
168166
}
169167
}
170168

0 commit comments

Comments
 (0)