@osdlc/tiered-dirs 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,423 @@
1
- # Temporary Holding Version
1
+ # @osdlc/tiered-dirs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Machine-local directories for every product a vendor ships, on Linux, macOS
4
+ and Windows.
5
+
6
+ ## The problem
7
+
8
+ A program that keeps state on disk needs a directory for it, and the right one
9
+ differs by operating system and by kind of state. A vendor that ships several
10
+ programs also has to decide which state they share and which they keep apart.
11
+ And a test suite must never write into the developer's real home directory.
12
+
13
+ `@osdlc/tiered-dirs` answers all three. A caller names a directory on two axes:
14
+
15
+ - **tier**: who shares the state. `vendor()` is `<vendor>`, shared by
16
+ everything the vendor ships. `suite(key)` is `<vendor>.<key>`, shared by the
17
+ programs of one suite. `standalone(tool)` is the bare `<tool>`, for a general
18
+ tool that is not part of the vendor's suite.
19
+ - **home**: how long the state must last. `durable`, `config`, `cache`,
20
+ `state`, `logs` or `runtime`.
21
+
22
+ `scratch()` sits outside both axes: the platform temp directory.
23
+
24
+ A **convention** picks the per-OS table the homes come from: `native` (the
25
+ default) or `xdg`. They differ on macOS only.
26
+
27
+ It is for a vendor with more than one program, or one program with tests that
28
+ must not touch the real home. It computes paths only: it creates, reads and
29
+ deletes nothing on disk. It has no dependencies. The
30
+ [Rust crate `tiered-dirs`](https://crates.io/crates/tiered-dirs) is its twin
31
+ and gives the same relative paths.
32
+
33
+ ## When not to use it
34
+
35
+ - One program with one config file. `env-paths` or `xdg-app-paths` is enough.
36
+ - You want the app framework's own directories, for example Tauri's. Use the
37
+ framework's.
38
+ - You need the directories created. This package does not create them.
39
+
40
+ ## How it compares
41
+
42
+ | Package | What it gives |
43
+ |---|---|
44
+ | [env-paths](https://github.com/sindresorhus/env-paths) | One tool's data, config, cache, log and temp directories per OS. |
45
+ | [xdg-basedir](https://github.com/sindresorhus/xdg-basedir) | The XDG variables and their defaults, with no app name. |
46
+ | [xdg-app-paths](https://github.com/rivy/js.xdg-app-paths) | One tool's XDG directories, with a legacy fallback. |
47
+ | [os-paths](https://github.com/rivy/js.os-paths) | The OS home, temp and XDG directories. |
48
+ | [appdirsjs](https://github.com/codingjerk/appdirs-js) | One tool's cache, config and data directories. |
49
+
50
+ They give per-app or XDG directories for one tool. None of them has shared
51
+ vendor and suite tiers, separate state and runtime homes that are never the
52
+ cache, one override variable per root, a scratch root for tests, or a Rust
53
+ twin held to the same paths.
54
+
55
+ ## Install
56
+
57
+ ```sh
58
+ npm install @osdlc/tiered-dirs
59
+ ```
60
+
61
+ ## Quick start
62
+
63
+ ```ts
64
+ import { TieredDirs, envVars } from '@osdlc/tiered-dirs'
65
+
66
+ const dirs = TieredDirs.fromEnv('com.example', envVars('EXAMPLE'), {
67
+ platform: 'linux',
68
+ env: { HOME: '/home/ada' },
69
+ })
70
+
71
+ dirs.vendor().config().path
72
+ // /home/ada/.config/com.example
73
+
74
+ dirs.suite('notes').cache().scoped('vaults', 'v7').app('search').path
75
+ // /home/ada/.cache/com.example.notes/vaults/v7/search
76
+ ```
77
+
78
+ A real program leaves out `platform` and `env`; they are there so the paths
79
+ above do not depend on the machine. `examples/usage.test.ts` runs every export
80
+ of this package.
81
+
82
+ ## Tiers
83
+
84
+ | tier | segment | shared by |
85
+ |---|---|---|
86
+ | vendor | `<vendor>` | every product line the vendor ships |
87
+ | suite | `<vendor>.<suiteKey>` | the products that share user data |
88
+ | standalone | `<tool>` | one general tool, shipped outside the suite |
89
+
90
+ The vendor is the one segment the caller fixes up front. The suite segment is
91
+ composed from it. `suiteKey` is a caller-supplied constant, not derived from a
92
+ bundle identifier. `Tier.kind` reports which method produced a tier (`vendor`,
93
+ `suite` or `standalone`).
94
+
95
+ `Tier` and `Dir` are exported as types only. Their constructors are not part
96
+ of the package, so a value always comes from a checked method:
97
+ `TieredDirs.vendor()`, `suite()`, `standalone()` and `scratch()`, `Tier.home()`
98
+ and the home methods, and `Dir.scoped()` and `Dir.app()`.
99
+
100
+ The product tier, one product's own directory keyed by bundle identifier, is
101
+ not here. An app framework usually resolves it, and this package takes no
102
+ framework dependency. `TieredDirs.isVendorBundleId(bundleId)` lets a layer
103
+ above check that a product's bundle id sits under the vendor.
104
+ `isVendorBundleId(vendor, bundleId)` is the same check for a caller with no
105
+ `TieredDirs`. Both are a bare prefix test, as in the Rust twin.
106
+
107
+ Tiers are distinct addresses and there is no merge. `vendor().durable()`,
108
+ `suite(k).durable()` and `standalone(t).durable()` are different locations.
109
+ This package never reads two and merges them. A given piece of state lives at
110
+ exactly one tier and the caller names the tier it means.
111
+
112
+ ### A general tool supplies its own root
113
+
114
+ The vendor and suite tiers govern a suite: products that share one owner. A
115
+ general tool the vendor ships into other people's repositories is not in that
116
+ suite, and `suite('tool')` would write the vendor's namespace onto a stranger's
117
+ machine.
118
+
119
+ `standalone(tool)` is the tier for that tool. Its segment is the bare tool
120
+ name, with no vendor composition. Its config directory is `~/.config/<tool>` on
121
+ Linux, and on macOS under `xdg`. Under `native` on macOS it is
122
+ `~/Library/Application Support/<tool>`.
123
+
124
+ ```ts
125
+ import { TieredDirs, envVars } from '@osdlc/tiered-dirs'
126
+
127
+ const cli = TieredDirs.fromEnv('com.example', envVars('TOOL'), {
128
+ convention: 'xdg',
129
+ platform: 'darwin',
130
+ env: { HOME: '/Users/ada' },
131
+ })
132
+ cli.standalone('tool').config().path
133
+ // /Users/ada/.config/tool
134
+ ```
135
+
136
+ ## Homes
137
+
138
+ | home | Linux (both) | macOS, `native` | macOS, `xdg` | Windows (both) |
139
+ |---|---|---|---|---|
140
+ | durable | `$XDG_DATA_HOME`, `~/.local/share` | `~/Library/Application Support` | `$XDG_DATA_HOME`, `~/.local/share` | `%APPDATA%` |
141
+ | config | `$XDG_CONFIG_HOME`, `~/.config` | `~/Library/Application Support` | `$XDG_CONFIG_HOME`, `~/.config` | `%APPDATA%` |
142
+ | cache | `$XDG_CACHE_HOME`, `~/.cache` | `~/Library/Caches` | `$XDG_CACHE_HOME`, `~/.cache` | `%LOCALAPPDATA%` |
143
+ | state | `$XDG_STATE_HOME`, `~/.local/state` | `~/Library/Application Support` | `$XDG_STATE_HOME`, `~/.local/state` | `%LOCALAPPDATA%\state` |
144
+ | logs | `$XDG_STATE_HOME`, `~/.local/state` | `~/Library/Logs` | `$XDG_STATE_HOME`, `~/.local/state` | `<durable>/logs` |
145
+ | runtime | `$XDG_RUNTIME_DIR`, `<durable>/run` | `<durable>/run` | `$XDG_RUNTIME_DIR`, `<durable>/run` | `<durable>/run` |
146
+
147
+ `logs` and `state` never fall back to `cache`, because the OS purges cache. On
148
+ Windows `state` is `%LOCALAPPDATA%\state`: under the same root as each tier's
149
+ cache folder and beside it, never the same folder. `state`, `logs` and `run`
150
+ are reserved segment names, in any case, so no tier can land on another home's
151
+ root: `standalone('state')` throws `TieredDirsError`. `runtime` sits under
152
+ `durable` for the same reason: the OS may purge cache while the process a
153
+ pidfile describes is still alive. Under XDG, logs and state share one
154
+ directory, so keep their file names apart. A relative `XDG_*` value is
155
+ ignored, as the XDG spec says, and `~` is not expanded. On Windows, `xdg`
156
+ means `native`.
157
+
158
+ ## Choosing a convention
159
+
160
+ - A CLI or developer tool uses `xdg`. Its users look for `~/.config/<tool>` and
161
+ keep it in their dotfiles.
162
+ - A GUI app uses `native`. macOS users and tools expect
163
+ `~/Library/Application Support`.
164
+ - A file that two programs read is resolved in one place, by the program that
165
+ owns it, under that program's convention.
166
+
167
+ Pass `{ convention: 'xdg' }` to `systemBaseDirs` or `TieredDirs.fromEnv`.
168
+ `<P>_CONVENTION=native` or `xdg` overrides the caller's choice; any other value
169
+ throws `TieredDirsError`. The value is trimmed, then must match exactly, in
170
+ lower case. `parseConvention(value)` parses a convention from anywhere else,
171
+ such as a flag: exactly `native` or `xdg`, untrimmed, like the Rust twin's
172
+ `FromStr`. `xdgBaseDirs(home, env)` builds the XDG table under any home, on
173
+ any host.
174
+
175
+ ## Overrides and scratch roots
176
+
177
+ Every base directory can be moved by environment variable, so a test or harness
178
+ can run a whole app inside one scratch root. The caller picks the names:
179
+ `envVars('EXAMPLE')` reads `EXAMPLE_ROOT`, `EXAMPLE_<HOME>_DIR`,
180
+ `EXAMPLE_SCRATCH_DIR`, `EXAMPLE_REQUIRE_OVERRIDE` and `EXAMPLE_CONVENTION`.
181
+ `overrideVars(vars)` lists the ones that move a directory, and
182
+ `isOverridden(vars, env)` says whether any of them is set to a non-empty value
183
+ (`env` defaults to `process.env`). Per base directory,
184
+ the first answer wins:
185
+
186
+ | order | source | effect |
187
+ |---|---|---|
188
+ | 1 | `<P>_<HOME>_DIR` | moves one: `<P>_DURABLE_DIR`, `<P>_CONFIG_DIR`, `<P>_CACHE_DIR`, `<P>_STATE_DIR`, `<P>_LOGS_DIR`, `<P>_RUNTIME_DIR`, `<P>_SCRATCH_DIR` |
189
+ | 2 | `<P>_ROOT` | the rooted layout: `<root>/durable`, `<root>/config`, `<root>/cache`, `<root>/state`, `<root>/logs`, `<root>/scratch`, runtime at `<root>/durable/run` |
190
+ | 3 | the OS default | the table above, for `<P>_CONVENTION` when set, else `opts.convention`, else `native` |
191
+
192
+ Runtime follows durable: when `<P>_RUNTIME_DIR` is unset but durable is moved by
193
+ step 1 or 2, runtime is `<durable>/run`. An empty variable counts as unset; a
194
+ relative one is an error. `opts.overrides` on `systemBaseDirs` sits ahead of all
195
+ three. `rootedBaseDirs(root)` builds the layout of step 2 directly.
196
+
197
+ ### Tests never resolve a real home
198
+
199
+ When neither `env` nor `home` is injected, `systemBaseDirs` applies two rules
200
+ first:
201
+
202
+ - In a test runner process where `<P>_ROOT` is unset and `HOME` is not moved,
203
+ `<P>_ROOT` reads as `<tmp>/tiered-dirs-test-<pid>-<start ms>`. "HOME moved"
204
+ means `HOME` differs from the passwd home. The Rust twin has no such
205
+ condition. It is kept so a test that points `HOME` at a fixture keeps its
206
+ paths. `{ testRoot: false }` turns the test-root default off explicitly. A CI
207
+ job that sets `HOME` itself, such as a GitHub Actions container job
208
+ (`HOME=/github/home`, passwd home `/root`), therefore gets no test-root
209
+ default: set `<P>_ROOT` there. A test runner
210
+ process is one with a test file as its entry point that runs with
211
+ `NODE_ENV=test`, `NODE_TEST_CONTEXT` (set by `node --test`) or under Bun.
212
+ A Vitest worker is detected too. A test that points `HOME` at a fixture
213
+ keeps it. A child process a test spawns gets no default, because its entry
214
+ point is neither a test file nor a Vitest worker.
215
+ - With `<P>_REQUIRE_OVERRIDE` set, a root that would fall back to the per-OS
216
+ table throws a `TieredDirsError` naming the fix.
217
+
218
+ `testRoot()` returns that root, cached per module instance (per test file under
219
+ Vitest's default isolation), in a test runner process, and
220
+ `undefined` elsewhere. A layer that reads its own variables, such as a
221
+ product tier, uses it to apply the same default. Pass `{ testRoot: false }`
222
+ to `systemBaseDirs` or `TieredDirs.fromEnv` to skip the first rule only.
223
+ `<P>_ROOT` and `<P>_REQUIRE_OVERRIDE` still apply.
224
+
225
+ ## Scratch
226
+
227
+ `scratch()` returns the platform temp directory: `$TMPDIR` or `/tmp`, and
228
+ `%TEMP%` / `%TMP%` on Windows. It adds nothing to the platform answer, so "no
229
+ direct OS path call outside this package" can be checked with a search.
230
+
231
+ Scratch is not a `Home` and not a tier. It holds state that does not outlive a
232
+ single call. The `runtime` home holds state that does and describes a live
233
+ process: a pidfile, a socket, a lock.
234
+
235
+ ## Injectable roots
236
+
237
+ Nothing below `systemBaseDirs` reads the environment. A `TieredDirs` resolves
238
+ its roots once, at construction, and every path after that is pure string work.
239
+ A server that cannot read the environment hands in roots from its own config:
240
+
241
+ ```ts
242
+ import { TieredDirs, type BaseDirs } from '@osdlc/tiered-dirs'
243
+
244
+ const bases: BaseDirs = {
245
+ durable: '/srv/data',
246
+ config: '/srv/config',
247
+ cache: '/srv/cache',
248
+ state: '/srv/state',
249
+ logs: '/srv/logs',
250
+ runtime: '/srv/run',
251
+ scratch: '/srv/tmp',
252
+ }
253
+ const dirs = new TieredDirs('com.example', bases)
254
+ dirs.vendor().cache().path
255
+ // /srv/cache/com.example
256
+ ```
257
+
258
+ Every `BaseDirs` field is `readonly`. To change a root, build a new object, for
259
+ example `{ ...bases, cache: '/srv/other' }`.
260
+
261
+ Tests use the same seam to assert exact paths without touching a real home
262
+ directory.
263
+
264
+ ## Failure
265
+
266
+ Every base directory can fail to resolve; headless CI and sandboxes do not
267
+ always have a home. `systemBaseDirs` and `TieredDirs.fromEnv` throw
268
+ `TieredDirsError` rather than assert, and `overrides` rescues an environment
269
+ with no home at all. `vendor`, `suiteKey`, `tool`, `kind`, `key` and `app` are
270
+ each checked as a single path segment: non-empty, no separator, no `..`,
271
+ matching `/^[a-z0-9][a-z0-9._-]*$/i`, and not `state`, `logs` or `run`.
272
+ `isValidSegment(key)` is that rule. An invalid one throws `TieredDirsError`.
273
+
274
+ `TieredDirsError.code` says which failure it is, one code per variant of the
275
+ Rust twin's `Error`. Its type is `TieredDirsErrorCode`:
276
+
277
+ | code | when |
278
+ |---|---|
279
+ | `MISSING_BASE_DIR` | a root needs a home directory and none can be found |
280
+ | `RELATIVE_OVERRIDE` | an override variable holds a relative path |
281
+ | `OVERRIDE_REQUIRED` | `<P>_REQUIRE_OVERRIDE` is set and a root would fall back to the per-OS table |
282
+ | `INVALID_CONVENTION` | `<P>_CONVENTION` or `parseConvention` got a value other than `native` or `xdg` |
283
+ | `INVALID_KEY` | a segment fails `isValidSegment` |
284
+
285
+ The message gives the details, such as the variable or the key.
286
+
287
+ ## API
288
+
289
+ ```typescript
290
+ function envVars(prefix: string): EnvVars
291
+ function overrideVars(vars: EnvVars): readonly string[]
292
+ function isOverridden(vars: EnvVars, env?: Record<string, string | undefined>): boolean
293
+ interface EnvVars {
294
+ root: string
295
+ dirs: Record<keyof BaseDirs, string>
296
+ requireOverride: string
297
+ convention: string
298
+ }
299
+
300
+ function isValidSegment(key: string): boolean
301
+
302
+ type Home = 'durable' | 'config' | 'cache' | 'state' | 'logs' | 'runtime'
303
+ const HOMES: readonly Home[]
304
+ type Convention = 'native' | 'xdg'
305
+ const CONVENTIONS: readonly Convention[]
306
+ function parseConvention(value: string): Convention
307
+ type TierKind = 'vendor' | 'suite' | 'standalone'
308
+ const TIER_KINDS: readonly TierKind[]
309
+ interface BaseDirs {
310
+ readonly durable: string
311
+ readonly config: string
312
+ readonly cache: string
313
+ readonly state: string
314
+ readonly logs: string
315
+ readonly runtime: string
316
+ readonly scratch: string
317
+ }
318
+ interface ResolveOptions {
319
+ platform?: string
320
+ convention?: Convention
321
+ env?: Record<string, string | undefined>
322
+ home?: string
323
+ overrides?: Partial<BaseDirs>
324
+ testRoot?: boolean // default true
325
+ }
326
+ function systemBaseDirs(vars: EnvVars, opts?: ResolveOptions): BaseDirs
327
+ function testRoot(): string | undefined
328
+ function rootedBaseDirs(root: string): BaseDirs
329
+ function xdgBaseDirs(home: string, env: Record<string, string | undefined>): BaseDirs
330
+
331
+ class TieredDirs {
332
+ constructor(vendor: string, bases: BaseDirs)
333
+ static fromEnv(vendor: string, vars: EnvVars, opts?: ResolveOptions): TieredDirs
334
+ readonly vendorName: string
335
+ readonly bases: Readonly<BaseDirs>
336
+ vendor(): Tier
337
+ suite(suiteKey: string): Tier
338
+ standalone(tool: string): Tier
339
+ scratch(): Dir
340
+ isVendorBundleId(bundleId: string): boolean
341
+ }
342
+ function isVendorBundleId(vendor: string, bundleId: string): boolean
343
+
344
+ // Tier and Dir are exported as types only. Get one from the methods above.
345
+ class Tier {
346
+ readonly segment: string
347
+ readonly kind: TierKind
348
+ durable(): Dir
349
+ config(): Dir
350
+ cache(): Dir
351
+ state(): Dir
352
+ logs(): Dir
353
+ runtime(): Dir
354
+ home(home: Home): Dir
355
+ }
356
+
357
+ class Dir {
358
+ readonly base: string
359
+ readonly segments: readonly string[]
360
+ get path(): string // absolute, host separator
361
+ get relative(): string // below the base, always "/"-separated
362
+ scoped(kind: string, key: string): Dir
363
+ app(app: string): Dir
364
+ }
365
+
366
+ type TieredDirsErrorCode =
367
+ | 'MISSING_BASE_DIR'
368
+ | 'RELATIVE_OVERRIDE'
369
+ | 'OVERRIDE_REQUIRED'
370
+ | 'INVALID_CONVENTION'
371
+ | 'INVALID_KEY'
372
+ class TieredDirsError extends Error {
373
+ readonly code: TieredDirsErrorCode
374
+ }
375
+ ```
376
+
377
+ ## Parity
378
+
379
+ This package is the twin of the Rust `tiered-dirs` crate.
380
+ `packages/rust/tiered-dirs/tests/fixtures/parity.json` in the repository is
381
+ the contract between them: a table of (tier, home, scope, app) to expected
382
+ relative path, a `scratch_cases` list for the scratch root, `env_cases` for the
383
+ override order, `convention_cases` for the XDG layout under a stub home and
384
+ `rejected_conventions`. Both suites read that one file with base directories
385
+ stubbed: `rootedBaseDirs('/stub')` here, `BaseDirs::rooted("/stub")` there.
386
+ Neither twin may add a path shape the other lacks.
387
+
388
+ ## Stability
389
+
390
+ Every export is stable in 0.x, and its TSDoc says so: `TieredDirs`,
391
+ `TieredDirsError`, `envVars`, `overrideVars`, `isOverridden`,
392
+ `systemBaseDirs`, `testRoot`, `rootedBaseDirs`, `xdgBaseDirs`,
393
+ `parseConvention`, `isValidSegment`, `isVendorBundleId`, `HOMES`,
394
+ `CONVENTIONS`, `TIER_KINDS` and the types `Tier`, `Dir`, `BaseDirs`, `EnvVars`, `Home`,
395
+ `Convention`, `TierKind`, `ResolveOptions` and `TieredDirsErrorCode`. A
396
+ breaking change to one of them ships only in a new 0.x minor release, with a
397
+ CHANGELOG entry that starts with `Breaking:`.
398
+
399
+ Some of them may grow in a minor release, as their Rust twins are
400
+ `#[non_exhaustive]`: `TierKind` and `Convention` may gain a member,
401
+ `TieredDirsErrorCode` a code, and `EnvVars` a name. A `switch` over one of the
402
+ unions needs a `default` arm. Build `EnvVars` with `envVars()`, not by hand,
403
+ so a new name is filled in. `Home` is exhaustive on purpose: a new home
404
+ changes every caller's layout, so adding one is a breaking release.
405
+
406
+ ## Runtimes and platforms
407
+
408
+ Node.js 22 or later, ESM only. The built package is tested on Node.js 22 and
409
+ 24, on Linux and macOS. The code uses `node:os` and `node:path` only. The path
410
+ tables above cover Windows, and the tests check all three tables by injecting
411
+ the platform. The tests compare paths without depending on the separator, but
412
+ they do not run on a Windows host in CI yet. Bun is not tested, though the
413
+ test-root rule recognises it.
414
+
415
+ ## Security
416
+
417
+ The package builds path strings. It runs no commands, writes no files and
418
+ creates no temporary files. Every caller-supplied segment is checked, and an
419
+ override variable must be an absolute path. See [SECURITY.md](./SECURITY.md).
420
+
421
+ ## License
422
+
423
+ `MIT OR Apache-2.0`, at your option.
package/SECURITY.md ADDED
@@ -0,0 +1,23 @@
1
+ # Security policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please report vulnerabilities privately. Do not open a public issue.
6
+
7
+ Use GitHub private vulnerability reporting on the repository: open the
8
+ [Security tab](https://github.com/osdlc/osdlc/security) and choose
9
+ "Report a vulnerability".
10
+
11
+ Include the version, a short description, and steps to reproduce. A minimal
12
+ code sample helps.
13
+
14
+ ## Supported versions
15
+
16
+ Only the latest release gets security fixes. While the version is 0.x, that
17
+ is the latest minor release. Upgrade to it.
18
+
19
+ ## What to expect
20
+
21
+ This is a small project. We aim to reply within a week. We will tell you
22
+ whether we accept the report and keep you updated until a fix is released.
23
+ We will credit you in the release notes if you want that.