@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/CHANGELOG.md +99 -0
- package/CONTRIBUTING.md +66 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +422 -2
- package/SECURITY.md +23 -0
- package/dist/base-dirs.d.ts +311 -0
- package/dist/base-dirs.js +477 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +20 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +24 -0
- package/dist/segment.d.ts +17 -0
- package/dist/segment.js +40 -0
- package/dist/tiered-dirs.d.ts +190 -0
- package/dist/tiered-dirs.js +237 -0
- package/package.json +64 -3
|
@@ -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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/segment.js
ADDED
|
@@ -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
|
+
}
|