src/main/core/paths/README.md
Single source of truth for every filesystem path used by the main process.
All paths are registered in pathRegistry.ts and accessed exclusively via application.getPath().
import { application } from '@application'
const dir = application.getPath('feature.files.data')
//=> '/Users/alice/Library/Application Support/CherryStudio/Data/Files'
const file = application.getPath('feature.files.data', 'avatar.png')
//=> '.../Data/Files/avatar.png'
application.getPath('invalid.key')
// TS2345: '"invalid.key"' is not assignable to type 'PathKey'
| File | Role |
|---|---|
constants.ts | Earliest path constants (CHERRY_HOME, BOOT_CONFIG_PATH, LOGS_DIR) — used before the registry exists; imported directly by the pre-registry bootstrappers (LoggerService, BootConfigService) |
pathRegistry.ts | buildPathRegistry() + shouldAutoEnsure + PathKey / PathMap types. Imported directly by Application.ts. ESLint-enforced key format |
No barrel. The module's public access point is application.getPath(), not an index.ts — its two files are independent building blocks imported directly by their specific consumers (per Naming §6.4: a directory that merely aggregates independent sub-modules gets no barrel).
| Namespace | Ownership | Examples |
|---|---|---|
cherry.* | Generic infra under ~/.cherrystudio | cherry.home, cherry.bin |
sys.* | OS-managed directories | sys.home, sys.temp, sys.downloads |
app.* | Electron app: install dir, userData, database, logs, temp root | app.userdata, app.database.file |
feature.* | Cherry-owned feature data (grouped by feature) | feature.files.data, feature.mcp.oauth |
external.* | Third-party paths (Cherry reads/writes, does NOT own) | external.openclaw.config |
Default to feature.* for new keys. The other four scopes are effectively closed.
feature.* → Cherry creates/manages/may delete. external.* → Cherry MUST NOT delete.
Format: /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ (enforced by ESLint data-schema-key/valid-key)
., each starts with a lettersnake_case (e.g. crash_dumps, lan_transfer)| Style | When | Example |
|---|---|---|
_file suffix | Standalone file | app.exe_file |
.file last segment | File with sibling keys | app.database.file (sibling: app.database.migrations) |
| No suffix | Directory (default) | feature.files.data |
Critical: Directory keys MUST NOT end with file — auto-ensure uses this to distinguish files from directories.
Application.getPath() auto-creates directories on first access (cached, at most once per key):
mkdirSync(base, { recursive: true })file) → mkdirSync(dirname(base)) (file itself is NOT created)Keys in the NO_ENSURE array (in pathRegistry.ts) skip auto-ensure. This is
required not only for read-only/external paths, but also when the owning
business workflow must control when materialization happens so a path lookup or
database-only operation cannot create files. Two entry forms:
'sys.', 'external.') — matches all keys under itAdd a key to NO_ENSURE only if the target is read-only in production,
owned by a third party, or has an explicit owner that performs validated
materialization separately from path resolution.
Type-checked via satisfies — typos and stale references fail at compile time.
. Separator Is Semantic, Not Physicala.b.c does NOT imply a.b.c is a sub-path of a.b on disk. Examples:
| Key | Physical location | Note |
|---|---|---|
feature.mcp.oauth | ~/.cherrystudio/config/mcp/oauth | Under config/, not mcp/ |
feature.agents.skills.install.temp | {app.temp}/skill-install | Sibling feature.agents.skills lives at {userData}/Data/Skills |
Never assume filesystem nesting from key nesting. Consult pathRegistry.ts directly.
// ✅ pathRegistry.ts
'feature.knowledgebase.data': path.join(appUserDataData, 'KnowledgeBase'),
// ❌ ad-hoc join bypasses the registry
path.join(application.getPath('app.userdata.data'), 'KnowledgeBase')
getPath's second argumentapplication.getPath('feature.files.data', 'avatar.png') // ✅
application.getPath('feature.files.data', '../escape') // ⚠️ warns
The filename is validated — absolute paths, .., and separators trigger a warning.
path.join over a registered keyconst workspace = path.join(
application.getPath('feature.agents.data'),
agentId
)
Reserved for features that genuinely need per-instance subdirectories.
feature.*)pathRegistry.ts under the appropriate sectionappUserDataData, appTemp, etc.)_file), sibling file (.file)NO_ENSUREpnpm lintpathRegistry.tsNo object literals besides the registry itself — the ESLint rule validates every string-keyed property in the file. Helper constants must be primitives; put helper objects in a separate file.
buildPathRegistry() runs once during preboot (after app.setPath('userData', ...), before app.whenReady()). Key implications:
process.resourcesPath, or Node built-insdownloads, documents, desktop) are best-effort: if Electron cannot resolve a
redirected known folder, the registry logs a warning and falls back to the conventional directory under the
user's home instead of aborting startupapplication.getPath() before initPathRegistry() throwsLoggerService and BootConfigService bypass the registry — they read from paths/constants.ts directly (they run before the registry exists)Mock @main/core/paths/pathRegistry (the deep path, not the public re-export) and inject via __setPathMapForTesting:
vi.mock('@main/core/paths/pathRegistry', () => ({
buildPathRegistry: () =>
Object.freeze({ 'feature.files.data': '/mock/Data/Files' })
}))
import { buildPathRegistry } from '@main/core/paths/pathRegistry'
import { Application } from '@main/core/application/Application'
const app = Application.getInstance()
app.__setPathMapForTesting(buildPathRegistry())
Import Application from the file path (not the directory) to bypass the global test mock.
Use pnpm typecheck for type-level assertions — vitest's esbuild path doesn't enforce them.