@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.
@@ -0,0 +1,477 @@
1
+ import { homedir, tmpdir, userInfo } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { TieredDirsError } from "./errors.js";
4
+ /**
5
+ * Every {@link Home}, in table order.
6
+ *
7
+ * Stable in 0.x.
8
+ */
9
+ export const HOMES = ['durable', 'config', 'cache', 'state', 'logs', 'runtime'];
10
+ /**
11
+ * Every {@link Convention}. The first is the default.
12
+ *
13
+ * Stable in 0.x.
14
+ */
15
+ export const CONVENTIONS = ['native', 'xdg'];
16
+ /** The {@link Convention} named exactly `value`, if any. */
17
+ function findConvention(value) {
18
+ return CONVENTIONS.find((convention) => convention === value);
19
+ }
20
+ /**
21
+ * Parse a {@link Convention}: exactly `native` or `xdg`, in lower case,
22
+ * untrimmed. The Rust twin's `Convention::from_str` is the same rule.
23
+ *
24
+ * Throws {@link TieredDirsError} with code `INVALID_CONVENTION` for any other
25
+ * value. {@link EnvVars.convention} is read differently: it is trimmed first.
26
+ *
27
+ * Stable in 0.x.
28
+ */
29
+ export function parseConvention(value) {
30
+ const convention = findConvention(value);
31
+ if (convention !== undefined)
32
+ return convention;
33
+ throw new TieredDirsError('INVALID_CONVENTION', `convention must be "native" or "xdg", got ${JSON.stringify(value)}`);
34
+ }
35
+ /** Every key of {@link BaseDirs}: the six homes plus `scratch`. */
36
+ const BASE_DIR_KEYS = [...HOMES, 'scratch'];
37
+ /** Directory name for runtime state where the platform offers none (see {@link systemBaseDirs}). */
38
+ const RUNTIME_FALLBACK_SEGMENT = 'run';
39
+ /**
40
+ * {@link EnvVars} named from one prefix: `<P>_ROOT`, `<P>_DURABLE_DIR`,
41
+ * `<P>_CONFIG_DIR`, `<P>_CACHE_DIR`, `<P>_STATE_DIR`, `<P>_LOGS_DIR`,
42
+ * `<P>_RUNTIME_DIR`, `<P>_SCRATCH_DIR`, `<P>_REQUIRE_OVERRIDE` and
43
+ * `<P>_CONVENTION`. The Rust twin's `env_vars!` derives the same names.
44
+ *
45
+ * Stable in 0.x.
46
+ */
47
+ export function envVars(prefix) {
48
+ return Object.freeze({
49
+ root: `${prefix}_ROOT`,
50
+ dirs: Object.freeze({
51
+ durable: `${prefix}_DURABLE_DIR`,
52
+ config: `${prefix}_CONFIG_DIR`,
53
+ cache: `${prefix}_CACHE_DIR`,
54
+ state: `${prefix}_STATE_DIR`,
55
+ logs: `${prefix}_LOGS_DIR`,
56
+ runtime: `${prefix}_RUNTIME_DIR`,
57
+ scratch: `${prefix}_SCRATCH_DIR`,
58
+ }),
59
+ requireOverride: `${prefix}_REQUIRE_OVERRIDE`,
60
+ convention: `${prefix}_CONVENTION`,
61
+ });
62
+ }
63
+ /**
64
+ * Every variable that moves a base directory: the root and the seven
65
+ * per-directory names. Excludes {@link EnvVars.requireOverride} and
66
+ * {@link EnvVars.convention}, which move nothing.
67
+ *
68
+ * Stable in 0.x.
69
+ */
70
+ export function overrideVars(vars) {
71
+ return [vars.root, ...BASE_DIR_KEYS.map((key) => vars.dirs[key])];
72
+ }
73
+ /**
74
+ * Whether `env` sets any of {@link overrideVars} to a non-empty value. The
75
+ * Rust twin's `EnvVars::is_overridden` is the same test.
76
+ *
77
+ * Stable in 0.x.
78
+ */
79
+ export function isOverridden(vars, env = process.env) {
80
+ return overrideVars(vars).some((name) => Boolean(env[name]));
81
+ }
82
+ /**
83
+ * Every base directory under one stub root: `<root>/durable`, `<root>/config`,
84
+ * `<root>/cache`, `<root>/state`, `<root>/logs`, `<root>/scratch`, and runtime
85
+ * at `<root>/durable/run`.
86
+ *
87
+ * The test and parity-fixture seam, and the layout {@link EnvVars.root} selects. The
88
+ * Rust twin's `BaseDirs::rooted` has the same layout, so a fixture row asserts
89
+ * identical absolute paths on both arms.
90
+ *
91
+ * Stable in 0.x.
92
+ */
93
+ export function rootedBaseDirs(root) {
94
+ const durable = join(root, 'durable');
95
+ return {
96
+ durable,
97
+ config: join(root, 'config'),
98
+ cache: join(root, 'cache'),
99
+ state: join(root, 'state'),
100
+ logs: join(root, 'logs'),
101
+ runtime: join(durable, RUNTIME_FALLBACK_SEGMENT),
102
+ scratch: join(root, 'scratch'),
103
+ };
104
+ }
105
+ /**
106
+ * Resolve the base directories. Each takes the first answer in this order:
107
+ *
108
+ * 1. `opts.overrides`.
109
+ * 2. The root's own variable, `vars.dirs[key]` ({@link EnvVars.dirs}).
110
+ * 3. `vars.root` ({@link EnvVars.root}) — the {@link rootedBaseDirs} layout.
111
+ * 4. The per-OS table below, for the convention: {@link EnvVars.convention}
112
+ * when set, else `opts.convention`, else `native`. The variable is
113
+ * trimmed, then must be exactly `native` or `xdg`; anything else throws.
114
+ *
115
+ * Runtime follows durable: when the runtime variable is unset but durable is
116
+ * moved by step 2 or 3, runtime is `<durable>/run`, never the OS runtime
117
+ * directory. An empty variable counts as unset; a relative one throws. The
118
+ * Rust twin's `BaseDirs::from_env` resolves steps 2–4 the same way, held to it
119
+ * by the parity fixture's `env_cases`.
120
+ *
121
+ * The per-OS table this package owns so no caller has to. Linux, and macOS
122
+ * under `xdg`, use the XDG column; Windows uses its column under both.
123
+ *
124
+ * | home | macOS, `native` | XDG (Linux/other; macOS `xdg`) | Windows |
125
+ * |---------|--------------------------------|---------------------------------|----------------------|
126
+ * | durable | `~/Library/Application Support`| `$XDG_DATA_HOME`, `~/.local/share` | `%APPDATA%`, `<home>/AppData/Roaming` |
127
+ * | config | `~/Library/Application Support`| `$XDG_CONFIG_HOME`, `~/.config` | `%APPDATA%`, `<home>/AppData/Roaming` |
128
+ * | cache | `~/Library/Caches` | `$XDG_CACHE_HOME`, `~/.cache` | `%LOCALAPPDATA%`, `<home>/AppData/Local` |
129
+ * | state | `~/Library/Application Support`| `$XDG_STATE_HOME`, `~/.local/state` | `<local>/state`, where `<local>` is the cache cell |
130
+ * | logs | `~/Library/Logs` | `$XDG_STATE_HOME`, `~/.local/state` | `<durable>/logs` |
131
+ * | runtime | `<durable>/run` | `$XDG_RUNTIME_DIR`, `<durable>/run` | `<durable>/run` |
132
+ * | scratch | `$TMPDIR`, `/tmp` | `$TMPDIR`, `/tmp` | `%TEMP%`, `%TMP%`, `<home>/AppData/Local/Temp` |
133
+ *
134
+ * A relative `XDG_*` value is ignored, as the XDG spec says, and `~` is not
135
+ * expanded. Under XDG, logs and state share one directory.
136
+ *
137
+ * Two of those cells need care:
138
+ *
139
+ * - `logs` has no cross-platform library equivalent. macOS wants
140
+ * `~/Library/Logs`; where the OS offers nothing, logs land at
141
+ * `<durable>/logs`. They are never folded into `cache`.
142
+ * - `runtime` is real only under the XDG table (Linux, and macOS under
143
+ * `xdg`), and only when `XDG_RUNTIME_DIR` is set. Everywhere else it
144
+ * resolves under `<durable>/run` — deliberately not under cache, because the
145
+ * OS may purge cache while the process a pidfile describes is still alive.
146
+ * State is never cache either: on Windows it is `<local>/state`, beside
147
+ * each tier's cache folder. The reserved segment names keep them apart.
148
+ *
149
+ * `scratch` is resolved from the injected environment rather than by calling
150
+ * `os.tmpdir()`, which reads the real `process.env` and would make an injected
151
+ * environment stop being the whole story. The Rust twin asks its own platform
152
+ * the same question; the two roots need not match, because parity pins only
153
+ * what sits below a root.
154
+ *
155
+ * Throws {@link TieredDirsError}. Its `code` is `MISSING_BASE_DIR` when a
156
+ * root falls back to the home directory and none can be found,
157
+ * `RELATIVE_OVERRIDE` when an override variable is not absolute,
158
+ * `INVALID_CONVENTION` when {@link EnvVars.convention} names no convention,
159
+ * and `OVERRIDE_REQUIRED` as the second test rule below says. Headless CI and
160
+ * sandboxes do not always have a home directory.
161
+ *
162
+ * Two test rules run first, and only when neither `env` nor `home` is
163
+ * injected (an injected environment is the whole story):
164
+ *
165
+ * - In a test runner process with the root variable unset and `HOME` not
166
+ * moved, the root reads as {@link testRoot}:
167
+ * `<tmp>/tiered-dirs-test-<pid>-<start ms>`, unique per process and
168
+ * cached per module instance (per test file under Vitest's default isolation).
169
+ * `opts.testRoot: false` turns this rule off; the next one still applies.
170
+ * A test runner process has a test file as its entry point and runs
171
+ * under `NODE_ENV=test`, `NODE_TEST_CONTEXT` (set by `node --test`), Bun, or
172
+ * a Vitest worker. Bun counts on its own because `bun test` keeps a
173
+ * `NODE_ENV` that is already set. A Vitest worker has `VITEST_WORKER_ID` or
174
+ * `VITEST_POOL_ID` set, and its entry point is a file in the `dist/workers`
175
+ * folder of the `vitest` package (Vitest 4 and later) or tinypool's
176
+ * `dist/entry/process.js` or `dist/entry/worker.js` (Vitest 3 and earlier),
177
+ * not the test file, so a worker counts by that entry point instead of the
178
+ * test-file check. A child a test spawns
179
+ * inherits the Vitest variables but has a different entry point, so it gets
180
+ * no default. A test that points `HOME` at a fixture, in-process or for a child,
181
+ * keeps it.
182
+ * - With {@link EnvVars.requireOverride} set, a root that would fall back to
183
+ * the per-OS table throws a {@link TieredDirsError} naming the fix.
184
+ *
185
+ * The Rust twin's `BaseDirs::from_env` applies the same two rules, with two
186
+ * differences. It detects a test by its cargo test binary rather than by the
187
+ * environment. And the "`HOME` not moved" condition exists only here: a
188
+ * TypeScript test can point `HOME` at a fixture to redirect the home, which a
189
+ * Rust test cannot do through `dirs`, so the TypeScript rule yields to it.
190
+ * The cost is that a CI job whose `HOME` differs from the passwd home, such as
191
+ * a GitHub Actions container job (`HOME=/github/home`, passwd home `/root`),
192
+ * gets no test-root default. Set `<P>_ROOT` there.
193
+ *
194
+ * Stable in 0.x.
195
+ */
196
+ export function systemBaseDirs(vars, opts = {}) {
197
+ const platform = opts.platform ?? process.platform;
198
+ const over = stripUndefined(opts.overrides ?? {});
199
+ const env = opts.env === undefined && opts.home === undefined
200
+ ? guardedEnv(vars, process.env, over, opts.testRoot ?? true)
201
+ : (opts.env ?? process.env);
202
+ let cachedHome;
203
+ const home = () => (cachedHome ??= resolveHome(opts, platform, env));
204
+ const convention = envConvention(vars, env) ?? opts.convention ?? 'native';
205
+ const fromEnv = envOverrides(vars, env, platform);
206
+ const table = platformTable(platform, convention, env, home);
207
+ const roots = {
208
+ durable: '',
209
+ config: '',
210
+ cache: '',
211
+ state: '',
212
+ logs: '',
213
+ runtime: '',
214
+ scratch: '',
215
+ };
216
+ for (const key of BASE_DIR_KEYS) {
217
+ roots[key] = over[key] ?? fromEnv[key] ?? table[key]();
218
+ }
219
+ return roots;
220
+ }
221
+ /**
222
+ * The process environment with the test rules of {@link systemBaseDirs}
223
+ * applied: the root variable filled in for a test, or a throw when
224
+ * {@link EnvVars.requireOverride} forbids an OS fallback.
225
+ */
226
+ function guardedEnv(vars, env, overrides, useTestRoot) {
227
+ if (env[vars.root])
228
+ return env;
229
+ const root = useTestRoot ? testRoot() : undefined;
230
+ if (root !== undefined && !homeMoved(env)) {
231
+ return { ...env, [vars.root]: root };
232
+ }
233
+ if (env[vars.requireOverride]) {
234
+ // Runtime follows a moved durable, so it needs no variable of its own.
235
+ const required = BASE_DIR_KEYS.filter((key) => key !== 'runtime' && !overrides[key]);
236
+ const missing = required.find((key) => !env[vars.dirs[key]]);
237
+ if (missing !== undefined) {
238
+ throw new TieredDirsError('OVERRIDE_REQUIRED', `${vars.requireOverride} is set but neither ${vars.root} nor ${vars.dirs[missing]} is: ` +
239
+ `set ${vars.root} to a scratch directory, or build from rootedBaseDirs`);
240
+ }
241
+ }
242
+ return env;
243
+ }
244
+ /** {@link testRoot}'s answer, once it has looked. */
245
+ let processTestRoot;
246
+ /**
247
+ * `<tmp>/tiered-dirs-test-<pid>-<start ms>` when this process is a test runner
248
+ * process, else `undefined`. Resolved once per module instance: once per test
249
+ * file under Vitest's default isolation, and once per installed copy of the
250
+ * package.
251
+ *
252
+ * A test runner process is the one {@link systemBaseDirs} describes: a test
253
+ * file as its entry point under `NODE_ENV=test`, `NODE_TEST_CONTEXT` or Bun,
254
+ * or a Vitest worker. A child a test spawns is not one.
255
+ *
256
+ * The start time keeps a later process that reuses the pid off this one's
257
+ * leftover state. Callers that read the environment themselves, such as a
258
+ * product-tier layer, use this to apply the same default. The Rust twin's
259
+ * `test_root()` is the same per-process root, detected by its cargo test
260
+ * binary.
261
+ *
262
+ * Stable in 0.x.
263
+ */
264
+ export function testRoot() {
265
+ if (!processTestRoot) {
266
+ processTestRoot = {
267
+ root: isTestRunnerProcess(process.env)
268
+ ? join(tmpdir(), `tiered-dirs-test-${process.pid}-${Date.now()}`)
269
+ : undefined,
270
+ };
271
+ }
272
+ return processTestRoot.root;
273
+ }
274
+ /** A test file name: `*.test.ts`, `*_spec.mjs`, and the like. */
275
+ const TEST_FILE = /[._](test|spec)\.[cm]?[jt]sx?$/;
276
+ /**
277
+ * A Vitest worker's entry point: a file in the `vitest` package's
278
+ * `dist/workers` folder (Vitest 4+), or tinypool's `dist/entry/process.js` or
279
+ * `dist/entry/worker.js` (Vitest 3 and earlier).
280
+ */
281
+ const VITEST_WORKER_ENTRY = /[\\/]vitest[\\/]dist[\\/]workers[\\/]|[\\/]tinypool[\\/]dist[\\/]entry[\\/](process|worker)\.[cm]?js$/;
282
+ /** Whether this process is a test runner running a test file (see {@link systemBaseDirs}). */
283
+ export function isTestRunnerProcess(env, entry = process.argv[1] ?? '') {
284
+ // Vitest sets its variables in the worker's environment, so a child a test
285
+ // spawns inherits them; its entry point is what tells it apart.
286
+ if (Boolean(env['VITEST_WORKER_ID'] || env['VITEST_POOL_ID']) &&
287
+ VITEST_WORKER_ENTRY.test(entry)) {
288
+ return true;
289
+ }
290
+ const runner = process.versions.bun !== undefined ||
291
+ env['NODE_ENV'] === 'test' ||
292
+ Boolean(env['NODE_TEST_CONTEXT']);
293
+ return runner && TEST_FILE.test(entry);
294
+ }
295
+ /**
296
+ * Whether `HOME` differs from the user's home as the runtime reports it: the
297
+ * passwd entry under node, the `HOME` the process started with under bun.
298
+ */
299
+ function homeMoved(env) {
300
+ const home = env['HOME'];
301
+ if (!home)
302
+ return false;
303
+ try {
304
+ return home !== userInfo().homedir;
305
+ }
306
+ catch {
307
+ return false;
308
+ }
309
+ }
310
+ /** {@link EnvVars.convention}, parsed: trimmed, then exactly `native` or `xdg`. */
311
+ function envConvention(vars, env) {
312
+ const value = env[vars.convention];
313
+ if (!value)
314
+ return undefined;
315
+ const trimmed = value.trim();
316
+ const convention = findConvention(trimmed);
317
+ if (convention !== undefined)
318
+ return convention;
319
+ throw new TieredDirsError('INVALID_CONVENTION', `${vars.convention} must be "native" or "xdg", got ${JSON.stringify(value)}`);
320
+ }
321
+ /** Steps 2 and 3 of {@link systemBaseDirs}: the roots the environment moves. */
322
+ function envOverrides(vars, env, platform) {
323
+ const read = (name) => {
324
+ const value = env[name];
325
+ if (!value)
326
+ return undefined;
327
+ if (!isAbsolutePath(value, platform)) {
328
+ throw new TieredDirsError('RELATIVE_OVERRIDE', `${name} must be an absolute path`);
329
+ }
330
+ return value;
331
+ };
332
+ const rootValue = read(vars.root);
333
+ const rooted = rootValue === undefined ? undefined : rootedBaseDirs(rootValue);
334
+ const out = {};
335
+ for (const key of BASE_DIR_KEYS) {
336
+ if (key === 'runtime')
337
+ continue;
338
+ const value = read(vars.dirs[key]) ?? rooted?.[key];
339
+ if (value !== undefined)
340
+ out[key] = value;
341
+ }
342
+ const runtime = read(vars.dirs.runtime) ??
343
+ (out.durable === undefined ? undefined : join(out.durable, RUNTIME_FALLBACK_SEGMENT));
344
+ if (runtime !== undefined)
345
+ out.runtime = runtime;
346
+ return out;
347
+ }
348
+ /**
349
+ * The XDG layout under `home`, with the `XDG_*` variables read from `env`: the
350
+ * `xdg` table, on any host. Each home is its `XDG_*` variable when that holds
351
+ * an absolute path, else its default under `home`. Logs share the state home.
352
+ * Scratch is `$TMPDIR`, else `/tmp`.
353
+ *
354
+ * The Rust twin's `BaseDirs::xdg` builds the same layout, held to it by the
355
+ * parity fixture's `convention_cases`.
356
+ *
357
+ * Stable in 0.x.
358
+ */
359
+ export function xdgBaseDirs(home, env) {
360
+ const table = xdgTable(env, () => home, 'linux');
361
+ return {
362
+ durable: table.durable(),
363
+ config: table.config(),
364
+ cache: table.cache(),
365
+ state: table.state(),
366
+ logs: table.logs(),
367
+ runtime: table.runtime(),
368
+ scratch: table.scratch(),
369
+ };
370
+ }
371
+ /**
372
+ * The per-OS table, one thunk per root. Deferred so that an overridden root
373
+ * skips its cell, and with it the home lookup that cell would have made.
374
+ */
375
+ function platformTable(platform, convention, env, home) {
376
+ if (platform === 'darwin' && convention === 'native') {
377
+ const support = () => join(home(), 'Library', 'Application Support');
378
+ return {
379
+ durable: support,
380
+ config: support,
381
+ cache: () => join(home(), 'Library', 'Caches'),
382
+ state: support,
383
+ logs: () => join(home(), 'Library', 'Logs'),
384
+ runtime: () => join(support(), RUNTIME_FALLBACK_SEGMENT),
385
+ scratch: () => posixScratch(env, platform),
386
+ };
387
+ }
388
+ // Windows uses its own places under both conventions: `xdg` means `native`.
389
+ if (platform === 'win32') {
390
+ const roaming = () => absoluteEnv(env, 'APPDATA', platform) ?? join(home(), 'AppData', 'Roaming');
391
+ const local = () => absoluteEnv(env, 'LOCALAPPDATA', platform) ?? join(home(), 'AppData', 'Local');
392
+ return {
393
+ durable: roaming,
394
+ config: roaming,
395
+ cache: local,
396
+ state: () => join(local(), 'state'),
397
+ logs: () => join(roaming(), 'logs'),
398
+ runtime: () => join(roaming(), RUNTIME_FALLBACK_SEGMENT),
399
+ scratch: () => absoluteEnv(env, 'TEMP', platform) ??
400
+ absoluteEnv(env, 'TMP', platform) ??
401
+ join(home(), 'AppData', 'Local', 'Temp'),
402
+ };
403
+ }
404
+ return xdgTable(env, home, platform);
405
+ }
406
+ /** The XDG table: Linux and other Unix systems, and macOS under `xdg`. */
407
+ function xdgTable(env, home, platform) {
408
+ const durable = () => absoluteEnv(env, 'XDG_DATA_HOME', platform) ?? join(home(), '.local', 'share');
409
+ const state = () => absoluteEnv(env, 'XDG_STATE_HOME', platform) ?? join(home(), '.local', 'state');
410
+ return {
411
+ durable,
412
+ config: () => absoluteEnv(env, 'XDG_CONFIG_HOME', platform) ?? join(home(), '.config'),
413
+ cache: () => absoluteEnv(env, 'XDG_CACHE_HOME', platform) ?? join(home(), '.cache'),
414
+ state,
415
+ logs: state,
416
+ runtime: () => absoluteEnv(env, 'XDG_RUNTIME_DIR', platform) ?? join(durable(), RUNTIME_FALLBACK_SEGMENT),
417
+ scratch: () => posixScratch(env, platform),
418
+ };
419
+ }
420
+ function posixScratch(env, platform) {
421
+ return absoluteEnv(env, 'TMPDIR', platform) ?? '/tmp';
422
+ }
423
+ function resolveHome(opts, platform, env) {
424
+ if (opts.home !== undefined) {
425
+ if (opts.home === '') {
426
+ throw new TieredDirsError('MISSING_BASE_DIR', 'home must be a non-empty path');
427
+ }
428
+ return opts.home;
429
+ }
430
+ // Rust's `dirs` ignores HOME on Windows, and under Cygwin or MSYS it can be
431
+ // a POSIX-style path, so Windows reads USERPROFILE only.
432
+ const fromEnv = platform === 'win32' ? env['USERPROFILE'] : env['HOME'];
433
+ if (fromEnv)
434
+ return fromEnv;
435
+ // An injected environment is the whole story — falling back to the host's
436
+ // real home here would make a test's paths depend on the machine running it.
437
+ if (opts.env !== undefined) {
438
+ throw new TieredDirsError('MISSING_BASE_DIR', 'no home directory: the supplied environment sets neither HOME nor USERPROFILE');
439
+ }
440
+ let host = '';
441
+ try {
442
+ host = homedir();
443
+ }
444
+ catch {
445
+ host = '';
446
+ }
447
+ if (host)
448
+ return host;
449
+ throw new TieredDirsError('MISSING_BASE_DIR', 'no home directory: HOME is unset and the OS reports none — pass `home` or `overrides`');
450
+ }
451
+ /**
452
+ * An environment variable, honoured only when it names an absolute path.
453
+ * The XDG spec says a relative value is invalid and must be ignored.
454
+ */
455
+ function absoluteEnv(env, key, platform) {
456
+ const value = env[key];
457
+ if (!value)
458
+ return undefined;
459
+ return isAbsolutePath(value, platform) ? value : undefined;
460
+ }
461
+ /** `path.isAbsolute` for a named platform rather than the host's. */
462
+ function isAbsolutePath(value, platform) {
463
+ if (platform === 'win32') {
464
+ return /^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\') || value.startsWith('//');
465
+ }
466
+ return value.startsWith('/');
467
+ }
468
+ /** Drop explicit `undefined` entries so an override cannot blank out a root. */
469
+ function stripUndefined(overrides) {
470
+ const out = {};
471
+ for (const key of BASE_DIR_KEYS) {
472
+ const value = overrides[key];
473
+ if (value !== undefined)
474
+ out[key] = value;
475
+ }
476
+ return out;
477
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What went wrong, one value per variant of the Rust twin's `Error`:
3
+ *
4
+ * - `MISSING_BASE_DIR`: a base directory needs a home directory and none can
5
+ * be found.
6
+ * - `RELATIVE_OVERRIDE`: an override variable holds a relative path.
7
+ * - `OVERRIDE_REQUIRED`: `EnvVars.requireOverride` is set and a base
8
+ * directory would fall back to the per-OS table.
9
+ * - `INVALID_CONVENTION`: `EnvVars.convention`, or the value given to
10
+ * `parseConvention`, is not `native` or `xdg`.
11
+ * - `INVALID_KEY`: a caller-supplied segment is not one safe path segment, or
12
+ * is `state`, `logs` or `run`.
13
+ *
14
+ * A minor release may add a code, as the Rust `Error` is `#[non_exhaustive]`.
15
+ * A `switch` over it needs a `default` arm.
16
+ *
17
+ * Stable in 0.x.
18
+ */
19
+ export type TieredDirsErrorCode = 'MISSING_BASE_DIR' | 'RELATIVE_OVERRIDE' | 'OVERRIDE_REQUIRED' | 'INVALID_CONVENTION' | 'INVALID_KEY';
20
+ /**
21
+ * Every failure this package raises. One class, so a consumer can tell a
22
+ * directory-resolution problem from an unrelated `Error`. {@link code} says
23
+ * which failure it is, so a caller need not match on the message.
24
+ *
25
+ * Nothing here ever aborts the process. Base directories can fail to resolve —
26
+ * headless CI and sandboxes do not always have a home — so resolution throws a
27
+ * catchable error and never asserts.
28
+ *
29
+ * Stable in 0.x.
30
+ */
31
+ export declare class TieredDirsError extends Error {
32
+ readonly name = "TieredDirsError";
33
+ /** Which failure this is. */
34
+ readonly code: TieredDirsErrorCode;
35
+ constructor(code: TieredDirsErrorCode, message: string);
36
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Every failure this package raises. One class, so a consumer can tell a
3
+ * directory-resolution problem from an unrelated `Error`. {@link code} says
4
+ * which failure it is, so a caller need not match on the message.
5
+ *
6
+ * Nothing here ever aborts the process. Base directories can fail to resolve —
7
+ * headless CI and sandboxes do not always have a home — so resolution throws a
8
+ * catchable error and never asserts.
9
+ *
10
+ * Stable in 0.x.
11
+ */
12
+ export class TieredDirsError extends Error {
13
+ name = 'TieredDirsError';
14
+ /** Which failure this is. */
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.code = code;
19
+ }
20
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `@osdlc/tiered-dirs` — one answer to "where does this state live?" for a
3
+ * vendor's products, in Rust and TypeScript alike.
4
+ *
5
+ * Two axes. The tier says who shares the state (the vendor, a suite of
6
+ * products that share user data, or one general tool shipped outside the
7
+ * suite). The home says how durable it is (durable, config, cache, state,
8
+ * logs, runtime). A caller names both:
9
+ *
10
+ * ```ts
11
+ * TieredDirs.fromEnv('com.example', envVars('EXAMPLE')).suite('notes').cache().app('search')
12
+ * ```
13
+ *
14
+ * `TieredDirs.scratch()` sits outside both axes: the platform temp directory,
15
+ * for state that does not outlive a single call.
16
+ *
17
+ * This module is the twin of the Rust `tiered-dirs` crate. Identical input
18
+ * must yield an identical relative path in both, and `tests/parity.test.ts`
19
+ * pins that against a fixture both twins read.
20
+ */
21
+ export { TieredDirsError } from './errors.ts';
22
+ export type { TieredDirsErrorCode } from './errors.ts';
23
+ export { isValidSegment } from './segment.ts';
24
+ export { CONVENTIONS, HOMES, envVars, isOverridden, overrideVars, parseConvention, rootedBaseDirs, systemBaseDirs, testRoot, xdgBaseDirs, } from './base-dirs.ts';
25
+ export type { BaseDirs, Convention, EnvVars, Home, ResolveOptions } from './base-dirs.ts';
26
+ export { TIER_KINDS, TieredDirs, isVendorBundleId } from './tiered-dirs.ts';
27
+ export type { Dir, Tier, TierKind } from './tiered-dirs.ts';
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@osdlc/tiered-dirs` — one answer to "where does this state live?" for a
3
+ * vendor's products, in Rust and TypeScript alike.
4
+ *
5
+ * Two axes. The tier says who shares the state (the vendor, a suite of
6
+ * products that share user data, or one general tool shipped outside the
7
+ * suite). The home says how durable it is (durable, config, cache, state,
8
+ * logs, runtime). A caller names both:
9
+ *
10
+ * ```ts
11
+ * TieredDirs.fromEnv('com.example', envVars('EXAMPLE')).suite('notes').cache().app('search')
12
+ * ```
13
+ *
14
+ * `TieredDirs.scratch()` sits outside both axes: the platform temp directory,
15
+ * for state that does not outlive a single call.
16
+ *
17
+ * This module is the twin of the Rust `tiered-dirs` crate. Identical input
18
+ * must yield an identical relative path in both, and `tests/parity.test.ts`
19
+ * pins that against a fixture both twins read.
20
+ */
21
+ export { TieredDirsError } from "./errors.js";
22
+ export { isValidSegment } from "./segment.js";
23
+ export { CONVENTIONS, HOMES, envVars, isOverridden, overrideVars, parseConvention, rootedBaseDirs, systemBaseDirs, testRoot, xdgBaseDirs, } from "./base-dirs.js";
24
+ export { TIER_KINDS, TieredDirs, isVendorBundleId } from "./tiered-dirs.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Whether `key` is a safe single path segment.
3
+ *
4
+ * The regex alone still admits `a..b`, so the separator and `..` rejections
5
+ * stay explicit. `state`, `logs` and `run` are refused in any case: Windows
6
+ * and macOS paths ignore case. The Rust twin's `is_valid_segment` is the same
7
+ * predicate.
8
+ *
9
+ * Stable in 0.x.
10
+ */
11
+ export declare function isValidSegment(key: string): boolean;
12
+ /**
13
+ * Throw unless `key` is a safe single path segment. `kind` names the argument
14
+ * in the message (`vendor`, `suite`, `standalone`, `scope kind`, `scope`,
15
+ * `app`), matching the Rust twin's `check_key`.
16
+ */
17
+ export declare function assertSegment(kind: string, key: string): void;
@@ -0,0 +1,40 @@
1
+ import { TieredDirsError } from "./errors.js";
2
+ /** A path segment: a letter/digit, then letters/digits and `._-`. */
3
+ const SEGMENT = /^[a-z0-9][a-z0-9._-]*$/i;
4
+ /**
5
+ * Folders this package puts directly under another home's base on some OS:
6
+ * state under cache and logs under durable on Windows, and runtime (`run`)
7
+ * under durable wherever the platform has none. A tier named after one would
8
+ * land on that home's root, so they are refused, ignoring case.
9
+ */
10
+ const RESERVED = new Set(['state', 'logs', 'run']);
11
+ /**
12
+ * Whether `key` is a safe single path segment.
13
+ *
14
+ * The regex alone still admits `a..b`, so the separator and `..` rejections
15
+ * stay explicit. `state`, `logs` and `run` are refused in any case: Windows
16
+ * and macOS paths ignore case. The Rust twin's `is_valid_segment` is the same
17
+ * predicate.
18
+ *
19
+ * Stable in 0.x.
20
+ */
21
+ export function isValidSegment(key) {
22
+ if (key === '')
23
+ return false;
24
+ if (key.includes('/') || key.includes('\\') || key.includes('..'))
25
+ return false;
26
+ if (RESERVED.has(key.toLowerCase()))
27
+ return false;
28
+ return SEGMENT.test(key);
29
+ }
30
+ /**
31
+ * Throw unless `key` is a safe single path segment. `kind` names the argument
32
+ * in the message (`vendor`, `suite`, `standalone`, `scope kind`, `scope`,
33
+ * `app`), matching the Rust twin's `check_key`.
34
+ */
35
+ export function assertSegment(kind, key) {
36
+ if (!isValidSegment(key)) {
37
+ throw new TieredDirsError('INVALID_KEY', `invalid ${kind} key ${JSON.stringify(key)}: want a leading letter/digit ` +
38
+ 'then ._-, no path separator or "..", and not state, logs or run');
39
+ }
40
+ }