ADR-0005: Universal, XDG-Based Application Storage Port
Status: Accepted
Context
src/storage/registry-storage.ts (RegistryStorage) and several other
extension services (BundleInstaller's bundle download/staging cache,
UserScopeService's bundle-manifest lookup for unsync, PromptLoader,
ApmRuntimeManager) resolve their on-disk root directly from
vscode.ExtensionContext.globalStorageUri.fsPath. RegistryStorage's
constructor takes the full vscode.ExtensionContext and internally uses
two VS Code-specific substrates: context.globalStorageUri.fsPath for the
registry config file (sources, profiles, settings), source/bundle caches, and
installed-bundle records for user/workspace scope; and
context.globalState for small key/value data (bundle update preferences).
RegistryManager (constructs RegistryStorage directly), BundleInstaller
(ditto), and UpdateChecker/AutoUpdateService (take a RegistryStorage
instance via constructor injection) — all still pending strangler-fig
extraction (migration plan §7.5, Phase 4 item 2) — depend on this concrete,
vscode-coupled class.
Verified not affected, despite an initial assumption otherwise:
HubManager depends on HubStorage (src/storage/hub-storage.ts), a
separate class from RegistryStorage. HubStorage's constructor already
takes a plain storagePath: string — it has zero vscode import at all; the
extension's composition root (extension.ts, commands/hub-profile-commands.ts)
is the only place that happens to pass context.globalStorageUri.fsPath in.
This is already exactly the target pattern (plain-string injection, same as
app/install/*'s Target.rootPath) — confirmed by direct inspection while
starting HubManager's own strangler-fig slice, no port work needed there.
packages/infra already independently established the right pattern for
CLI-side state, twice, coincidentally without being unified into one port:
harvest/default-paths.ts's defaultCacheDir() (respects XDG_CACHE_HOME
and an AI_PRIMITIVES_HUB_CACHE override, falls back to
~/.cache/ai-primitives-hub) and stores/layout-config-store.ts's
resolveUserConfigDir() (respects XDG_CONFIG_HOME, falls back to
~/.config/ai-primitives-hub). Both are pure, env-injectable, and have no
vscode dependency — exactly the target shape.
packages/app/src/install/* (InstallPipeline/installBundle) is already
fully agnostic: it takes an explicit Target (with rootPath) and injected
port implementations, never resolving a storage root itself. That module is
proof the pattern already works end-to-end for the install/write path; the
gap is specifically the registry/bookkeeping storage layer used by the
services Phase 4 has not yet migrated.
The packages/cli package has, and must keep, zero dependency on the
vscode module. Without an explicit decision here, the path of least
resistance when extracting RegistryManager/UpdateChecker/AutoUpdateService
into app would be to port their logic but leave them taking a concrete,
vscode-coupled storage object — silently recreating the "two parallel
domains" problem ADR-0001 exists to fix, one layer deeper (storage instead
of business logic).
Decision
- Add a
coreport,AppStorage(packages/core/src/ports/app-storage.ts), modeling the directory/file responsibilitiesRegistryStoragealready defines (config, cache, installed-bundle records per scope, profiles, logs) as an interface — novscode, no directfscalls, matching this repo's existing port style (ports/filesystem.ts,ports/target-writer.ts). infraprovides the default, universal implementation — an XDG Base Directory-compliantAppStorage(XDG_DATA_HOME/XDG_CONFIG_HOME/XDG_CACHE_HOME, each falling back to~/.local/share,~/.config,~/.cache+ai-primitives-hubper POSIX convention). This consolidates the two existing ad hoc XDG resolvers under one coherent, tested module instead of leaving three independent XDG implementations in the codebase long-term. This is what the CLI uses by default, and what any future non-VS-Code client uses too.- The VS Code extension keeps its own adapter backed by
context.globalStorageUri.fsPath(either a thin wrapper orRegistryStorageitself refactored to implement the port). This is a deliberate, permanent choice, not a temporary shim: real users' data already lives underglobalStorageUri, and relocating it would be a silent, undiscoverable migration for existing installs. This mirrors the precedent ADR-0004 already set — don't relocate/rename identifiers real users' data already depends on. - When
UpdateChecker/AutoUpdateServiceandRegistryManagerare extracted (migration plan §7.5, Phase 4 item 2, remaining sub-items), theirapp-layer use-cases depend on theAppStorageport, not onRegistryStorage/vscode.ExtensionContextdirectly — the same patterninstall-bundle.tsalready uses forBundleDownloader/BundleExtractor/TargetWriter. In practice this meansRegistryStorageitself is refactored to depend on the injected port for its own path/state resolution, while remaining the single facadeUpdateChecker/AutoUpdateService/RegistryManagerdepend on — so those three need no constructor/signature changes of their own. LockfileManagerandHubManager/HubStorageare explicitly out of scope for this decision:LockfileManager's storage (<repo-root>/prompt-registry.lock.json) is already workspace-relative and portable (constructor takes a plainrepositoryPath: string), andHubStorage's constructor already takes a plainstoragePath: stringwith zerovscodedependency (see Context above). Likewiseapp/install/*is already agnostic viaTarget.rootPath+ injected ports. All three were verified by direct inspection while investigating this decision and need no change.
Consequences
- Positive: the CLI can implement
hub/profile/source/statuscommands (Phase 5, §7.6 item 3) against the exact sameapp-layer orchestration the extension uses, with zerovscodedependency — extending ADR-0001's "one shared domain" proof from business logic to persisted state. - Positive: consolidates two ad hoc XDG resolvers into one coherent,
fully-tested storage port, and closes a latent inconsistency
(
XDG_DATA_HOMEwas not respected anywhere) before it spreads further. - Negative: the
RegistryManager/UpdateChecker/AutoUpdateServiceextraction grows by one small, dedicated sub-step (defining the port + its two adapters, applied insideRegistryStorage) — accepted, since deferring it would mean redoing the extraction once this gap was noticed anyway. - Explicitly unaffected:
LockfileManager,HubManager/HubStorage, andapp/install/*— already portable/agnostic, confirmed by direct inspection while drafting this ADR.