@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,190 @@
1
+ import type { BaseDirs, EnvVars, Home, ResolveOptions } from './base-dirs.ts';
2
+ /**
3
+ * Which method produced a {@link Tier}.
4
+ *
5
+ * Exposed so a caller — or a gate run over a third party's install — can tell
6
+ * a suite address from a standalone one without parsing the segment string.
7
+ *
8
+ * A minor release may add a kind, as the Rust `TierKind` is
9
+ * `#[non_exhaustive]`. A `switch` over it needs a `default` arm.
10
+ *
11
+ * Stable in 0.x.
12
+ */
13
+ export type TierKind = 'vendor' | 'suite' | 'standalone';
14
+ /**
15
+ * Every {@link TierKind}.
16
+ *
17
+ * Stable in 0.x.
18
+ */
19
+ export declare const TIER_KINDS: readonly TierKind[];
20
+ /**
21
+ * Whether a bundle identifier sits under `vendor`: a bare prefix test. The
22
+ * Rust twin's `is_vendor_bundle_id` is the same test. Nothing stricter: a
23
+ * dot-boundary rule would make the twins disagree about which apps conform.
24
+ *
25
+ * For a caller with no {@link TieredDirs}. The Rust twin also has a
26
+ * debug-only assert form; TypeScript has no debug build, so the caller
27
+ * decides what to do with the answer.
28
+ *
29
+ * Stable in 0.x.
30
+ */
31
+ export declare function isVendorBundleId(vendor: string, bundleId: string): boolean;
32
+ /**
33
+ * A directory addressed by tier and home — or the untiered scratch root — plus
34
+ * any scope and app segments below it. Immutable: every builder method returns
35
+ * a new value.
36
+ *
37
+ * Exported from the package as a type only. Get one from `Tier.home`,
38
+ * `TieredDirs.scratch()`, `Dir.scoped` or `Dir.app`, which check their input.
39
+ *
40
+ * Stable in 0.x.
41
+ */
42
+ export declare class Dir {
43
+ #private;
44
+ /** The resolved root this path hangs off: a home's base, or scratch. */
45
+ readonly base: string;
46
+ /** Validated segments below {@link base}, outermost first. */
47
+ get segments(): readonly string[];
48
+ constructor(base: string, segments: readonly string[]);
49
+ /** The absolute directory, joined with the host's separator. */
50
+ get path(): string;
51
+ /**
52
+ * The segments below the base, always `/`-separated.
53
+ *
54
+ * This is the value the parity fixture pins: the Rust twin and this one must
55
+ * produce the same string here for the same input.
56
+ */
57
+ get relative(): string;
58
+ /**
59
+ * Key this location by a content root's id:
60
+ * `.scoped('vaults', vaultId)` appends `vaults/<vaultId>`.
61
+ *
62
+ * Both arguments are validated as single path segments.
63
+ */
64
+ scoped(kind: string, key: string): Dir;
65
+ /** Append one app's own directory. Validated as a single path segment. */
66
+ app(app: string): Dir;
67
+ toString(): string;
68
+ }
69
+ /**
70
+ * One rung of the vertical axis — who shares the state — bound to a set of
71
+ * base directories. Pick a home off it to get a directory.
72
+ *
73
+ * Tiers are distinct addresses. `vendor().durable()`, `suite(k).durable()` and
74
+ * `standalone(t).durable()` are different locations and the SDK never reads two
75
+ * and merges them. A given piece of state lives at exactly one tier and the
76
+ * caller names the tier it means. "Look up both" is something a specific
77
+ * reader does deliberately, per file, at its own call site — never a per-key
78
+ * cascade baked in here, which starts as two lines and ends as deep-merge
79
+ * with array-override rules nobody can predict.
80
+ *
81
+ * Exported from the package as a type only. Get one from `TieredDirs.vendor()`,
82
+ * `suite(key)` or `standalone(tool)`, which check their input.
83
+ *
84
+ * Stable in 0.x.
85
+ */
86
+ export declare class Tier {
87
+ #private;
88
+ /** The directory segment this tier contributes, e.g. `com.example.notes`. */
89
+ readonly segment: string;
90
+ /** Which method produced this tier (`vendor`, `suite` or `standalone`). */
91
+ readonly kind: TierKind;
92
+ constructor(bases: BaseDirs, kind: TierKind, segment: string);
93
+ /** Long-lived state that must survive a cache purge. */
94
+ durable(): Dir;
95
+ /** User-editable configuration. */
96
+ config(): Dir;
97
+ /** Regenerable data. The OS may delete this at any time. */
98
+ cache(): Dir;
99
+ /** State that should outlive a restart but is not user data. */
100
+ state(): Dir;
101
+ /** Log files. Never under `cache` — the OS purges cache. */
102
+ logs(): Dir;
103
+ /** Pidfiles, sockets, locks. Never under `cache`. */
104
+ runtime(): Dir;
105
+ /** The same six homes, named dynamically. Table-driven callers use this. */
106
+ home(home: Home): Dir;
107
+ }
108
+ /**
109
+ * The entry point: a vendor and base directories resolved once, then the tiers
110
+ * off them.
111
+ *
112
+ * ```ts
113
+ * const dirs = TieredDirs.fromEnv('com.example', envVars('EXAMPLE'))
114
+ * dirs.suite('notes').cache().scoped('vaults', vaultId).app('search').path
115
+ * // <cache>/com.example.notes/vaults/<vaultId>/search
116
+ * ```
117
+ *
118
+ * The product tier — one product's own directory, keyed by bundle id — is not
119
+ * here. An app framework usually resolves it, and this package takes no
120
+ * framework dependency.
121
+ *
122
+ * Stable in 0.x.
123
+ */
124
+ export declare class TieredDirs {
125
+ /** The vendor segment: the vendor tier, and the prefix of every suite. */
126
+ readonly vendorName: string;
127
+ /** The roots every path in this instance hangs off. Resolved at construction. */
128
+ readonly bases: Readonly<BaseDirs>;
129
+ /**
130
+ * Build from injected roots — the seam a server that cannot read the
131
+ * environment uses, handing in roots from its own config.
132
+ *
133
+ * Throws {@link TieredDirsError} with code `INVALID_KEY` unless `vendor` is
134
+ * a single path segment.
135
+ */
136
+ constructor(vendor: string, bases: BaseDirs);
137
+ /**
138
+ * Build from the environment, reading it exactly once through
139
+ * {@link systemBaseDirs}. Every input is injectable, so a test asserts exact
140
+ * paths without a real home directory. `opts.convention` picks the per-OS
141
+ * table: `native` (the default) or `xdg`.
142
+ *
143
+ * Throws {@link TieredDirsError} as {@link systemBaseDirs} does, and with
144
+ * code `INVALID_KEY` unless `vendor` is a single path segment.
145
+ */
146
+ static fromEnv(vendor: string, vars: EnvVars, opts?: ResolveOptions): TieredDirs;
147
+ /** Shared by every product line the vendor ships: `<vendor>`. */
148
+ vendor(): Tier;
149
+ /**
150
+ * Shared by the products that share user data: `<vendor>.<suiteKey>`.
151
+ *
152
+ * `suiteKey` is a caller-supplied constant, not something derived from a
153
+ * bundle id.
154
+ */
155
+ suite(suiteKey: string): Tier;
156
+ /**
157
+ * One general tool's own root: the bare `tool` name, with no vendor segment.
158
+ *
159
+ * For a tool the vendor ships into other people's repositories. It is not
160
+ * part of the vendor's suite, so it must not write under the vendor's
161
+ * namespace. Its config directory is `~/.config/<tool>` on Linux and under
162
+ * `xdg` on macOS, and `~/Library/Application Support/<tool>` under `native`
163
+ * on macOS.
164
+ *
165
+ * Use {@link TieredDirs.suite} for anything that ships as part of the suite.
166
+ * The two are different addresses and nothing merges them.
167
+ */
168
+ standalone(tool: string): Tier;
169
+ /**
170
+ * Whether a bundle identifier sits under this vendor.
171
+ *
172
+ * {@link isVendorBundleId} with this vendor. A migration tracker, not a
173
+ * gate: TS has no debug assertion, so the caller decides what to do with
174
+ * the answer.
175
+ */
176
+ isVendorBundleId(bundleId: string): boolean;
177
+ /**
178
+ * The platform temp directory, untiered: state that does not outlive the
179
+ * call that wrote it.
180
+ *
181
+ * A thin wrapper over the platform temp dir. It adds nothing, and that is
182
+ * its whole value: with it, "no direct OS path call outside this package" is
183
+ * a greppable invariant instead of a rule with exceptions.
184
+ *
185
+ * Not {@link Tier.runtime}. Runtime holds state that does outlive the call
186
+ * and describes a live process — a pidfile, a socket, a lock — and the OS
187
+ * sweeps temp while such a process is still running.
188
+ */
189
+ scratch(): Dir;
190
+ }
@@ -0,0 +1,237 @@
1
+ import { join } from 'node:path';
2
+ import { systemBaseDirs } from "./base-dirs.js";
3
+ import { assertSegment } from "./segment.js";
4
+ /**
5
+ * Every {@link TierKind}.
6
+ *
7
+ * Stable in 0.x.
8
+ */
9
+ export const TIER_KINDS = ['vendor', 'suite', 'standalone'];
10
+ /**
11
+ * Whether a bundle identifier sits under `vendor`: a bare prefix test. The
12
+ * Rust twin's `is_vendor_bundle_id` is the same test. Nothing stricter: a
13
+ * dot-boundary rule would make the twins disagree about which apps conform.
14
+ *
15
+ * For a caller with no {@link TieredDirs}. The Rust twin also has a
16
+ * debug-only assert form; TypeScript has no debug build, so the caller
17
+ * decides what to do with the answer.
18
+ *
19
+ * Stable in 0.x.
20
+ */
21
+ export function isVendorBundleId(vendor, bundleId) {
22
+ return bundleId.startsWith(vendor);
23
+ }
24
+ /**
25
+ * A directory addressed by tier and home — or the untiered scratch root — plus
26
+ * any scope and app segments below it. Immutable: every builder method returns
27
+ * a new value.
28
+ *
29
+ * Exported from the package as a type only. Get one from `Tier.home`,
30
+ * `TieredDirs.scratch()`, `Dir.scoped` or `Dir.app`, which check their input.
31
+ *
32
+ * Stable in 0.x.
33
+ */
34
+ export class Dir {
35
+ /** The resolved root this path hangs off: a home's base, or scratch. */
36
+ base;
37
+ /** Validated segments below {@link base}, outermost first. */
38
+ get segments() {
39
+ return this.#segments;
40
+ }
41
+ // A private field makes the type nominal: an object literal is not a `Dir`.
42
+ #segments;
43
+ constructor(base, segments) {
44
+ this.base = base;
45
+ this.#segments = Object.freeze([...segments]);
46
+ }
47
+ /** The absolute directory, joined with the host's separator. */
48
+ get path() {
49
+ return this.segments.length === 0 ? this.base : join(this.base, ...this.segments);
50
+ }
51
+ /**
52
+ * The segments below the base, always `/`-separated.
53
+ *
54
+ * This is the value the parity fixture pins: the Rust twin and this one must
55
+ * produce the same string here for the same input.
56
+ */
57
+ get relative() {
58
+ return this.segments.join('/');
59
+ }
60
+ /**
61
+ * Key this location by a content root's id:
62
+ * `.scoped('vaults', vaultId)` appends `vaults/<vaultId>`.
63
+ *
64
+ * Both arguments are validated as single path segments.
65
+ */
66
+ scoped(kind, key) {
67
+ assertSegment('scope kind', kind);
68
+ assertSegment('scope', key);
69
+ return new Dir(this.base, [...this.segments, kind, key]);
70
+ }
71
+ /** Append one app's own directory. Validated as a single path segment. */
72
+ app(app) {
73
+ assertSegment('app', app);
74
+ return new Dir(this.base, [...this.segments, app]);
75
+ }
76
+ toString() {
77
+ return this.path;
78
+ }
79
+ }
80
+ /**
81
+ * One rung of the vertical axis — who shares the state — bound to a set of
82
+ * base directories. Pick a home off it to get a directory.
83
+ *
84
+ * Tiers are distinct addresses. `vendor().durable()`, `suite(k).durable()` and
85
+ * `standalone(t).durable()` are different locations and the SDK never reads two
86
+ * and merges them. A given piece of state lives at exactly one tier and the
87
+ * caller names the tier it means. "Look up both" is something a specific
88
+ * reader does deliberately, per file, at its own call site — never a per-key
89
+ * cascade baked in here, which starts as two lines and ends as deep-merge
90
+ * with array-override rules nobody can predict.
91
+ *
92
+ * Exported from the package as a type only. Get one from `TieredDirs.vendor()`,
93
+ * `suite(key)` or `standalone(tool)`, which check their input.
94
+ *
95
+ * Stable in 0.x.
96
+ */
97
+ export class Tier {
98
+ /** The directory segment this tier contributes, e.g. `com.example.notes`. */
99
+ segment;
100
+ /** Which method produced this tier (`vendor`, `suite` or `standalone`). */
101
+ kind;
102
+ #bases;
103
+ constructor(bases, kind, segment) {
104
+ this.#bases = bases;
105
+ this.kind = kind;
106
+ this.segment = segment;
107
+ }
108
+ /** Long-lived state that must survive a cache purge. */
109
+ durable() {
110
+ return this.home('durable');
111
+ }
112
+ /** User-editable configuration. */
113
+ config() {
114
+ return this.home('config');
115
+ }
116
+ /** Regenerable data. The OS may delete this at any time. */
117
+ cache() {
118
+ return this.home('cache');
119
+ }
120
+ /** State that should outlive a restart but is not user data. */
121
+ state() {
122
+ return this.home('state');
123
+ }
124
+ /** Log files. Never under `cache` — the OS purges cache. */
125
+ logs() {
126
+ return this.home('logs');
127
+ }
128
+ /** Pidfiles, sockets, locks. Never under `cache`. */
129
+ runtime() {
130
+ return this.home('runtime');
131
+ }
132
+ /** The same six homes, named dynamically. Table-driven callers use this. */
133
+ home(home) {
134
+ return new Dir(this.#bases[home], [this.segment]);
135
+ }
136
+ }
137
+ /**
138
+ * The entry point: a vendor and base directories resolved once, then the tiers
139
+ * off them.
140
+ *
141
+ * ```ts
142
+ * const dirs = TieredDirs.fromEnv('com.example', envVars('EXAMPLE'))
143
+ * dirs.suite('notes').cache().scoped('vaults', vaultId).app('search').path
144
+ * // <cache>/com.example.notes/vaults/<vaultId>/search
145
+ * ```
146
+ *
147
+ * The product tier — one product's own directory, keyed by bundle id — is not
148
+ * here. An app framework usually resolves it, and this package takes no
149
+ * framework dependency.
150
+ *
151
+ * Stable in 0.x.
152
+ */
153
+ export class TieredDirs {
154
+ /** The vendor segment: the vendor tier, and the prefix of every suite. */
155
+ vendorName;
156
+ /** The roots every path in this instance hangs off. Resolved at construction. */
157
+ bases;
158
+ /**
159
+ * Build from injected roots — the seam a server that cannot read the
160
+ * environment uses, handing in roots from its own config.
161
+ *
162
+ * Throws {@link TieredDirsError} with code `INVALID_KEY` unless `vendor` is
163
+ * a single path segment.
164
+ */
165
+ constructor(vendor, bases) {
166
+ assertSegment('vendor', vendor);
167
+ this.vendorName = vendor;
168
+ this.bases = Object.freeze({ ...bases });
169
+ }
170
+ /**
171
+ * Build from the environment, reading it exactly once through
172
+ * {@link systemBaseDirs}. Every input is injectable, so a test asserts exact
173
+ * paths without a real home directory. `opts.convention` picks the per-OS
174
+ * table: `native` (the default) or `xdg`.
175
+ *
176
+ * Throws {@link TieredDirsError} as {@link systemBaseDirs} does, and with
177
+ * code `INVALID_KEY` unless `vendor` is a single path segment.
178
+ */
179
+ static fromEnv(vendor, vars, opts = {}) {
180
+ return new TieredDirs(vendor, systemBaseDirs(vars, opts));
181
+ }
182
+ /** Shared by every product line the vendor ships: `<vendor>`. */
183
+ vendor() {
184
+ return new Tier(this.bases, 'vendor', this.vendorName);
185
+ }
186
+ /**
187
+ * Shared by the products that share user data: `<vendor>.<suiteKey>`.
188
+ *
189
+ * `suiteKey` is a caller-supplied constant, not something derived from a
190
+ * bundle id.
191
+ */
192
+ suite(suiteKey) {
193
+ assertSegment('suite', suiteKey);
194
+ return new Tier(this.bases, 'suite', `${this.vendorName}.${suiteKey}`);
195
+ }
196
+ /**
197
+ * One general tool's own root: the bare `tool` name, with no vendor segment.
198
+ *
199
+ * For a tool the vendor ships into other people's repositories. It is not
200
+ * part of the vendor's suite, so it must not write under the vendor's
201
+ * namespace. Its config directory is `~/.config/<tool>` on Linux and under
202
+ * `xdg` on macOS, and `~/Library/Application Support/<tool>` under `native`
203
+ * on macOS.
204
+ *
205
+ * Use {@link TieredDirs.suite} for anything that ships as part of the suite.
206
+ * The two are different addresses and nothing merges them.
207
+ */
208
+ standalone(tool) {
209
+ assertSegment('standalone', tool);
210
+ return new Tier(this.bases, 'standalone', tool);
211
+ }
212
+ /**
213
+ * Whether a bundle identifier sits under this vendor.
214
+ *
215
+ * {@link isVendorBundleId} with this vendor. A migration tracker, not a
216
+ * gate: TS has no debug assertion, so the caller decides what to do with
217
+ * the answer.
218
+ */
219
+ isVendorBundleId(bundleId) {
220
+ return isVendorBundleId(this.vendorName, bundleId);
221
+ }
222
+ /**
223
+ * The platform temp directory, untiered: state that does not outlive the
224
+ * call that wrote it.
225
+ *
226
+ * A thin wrapper over the platform temp dir. It adds nothing, and that is
227
+ * its whole value: with it, "no direct OS path call outside this package" is
228
+ * a greppable invariant instead of a rule with exceptions.
229
+ *
230
+ * Not {@link Tier.runtime}. Runtime holds state that does outlive the call
231
+ * and describes a live process — a pidfile, a socket, a lock — and the OS
232
+ * sweeps temp while such a process is still running.
233
+ */
234
+ scratch() {
235
+ return new Dir(this.bases.scratch, []);
236
+ }
237
+ }
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@osdlc/tiered-dirs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
4
+ "description": "Machine-local directories for a vendor's products, on two axes: tier (vendor, suite, standalone tool) and home (durable, config, cache, state, logs, runtime), resolved over the per-OS directory table with native or XDG paths, plus an untiered scratch root. Override variables under a caller-chosen prefix, injectable roots, and tests kept off the real home. Twin of the Rust `tiered-dirs` crate.",
5
+ "license": "MIT OR Apache-2.0",
6
+ "author": "sksizer",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/osdlc/osdlc.git",
10
+ "directory": "packages/ts/tiered-dirs"
11
+ },
12
+ "homepage": "https://github.com/osdlc/osdlc/tree/main/packages/ts/tiered-dirs#readme",
13
+ "bugs": "https://github.com/osdlc/osdlc/issues",
14
+ "keywords": [
15
+ "paths",
16
+ "xdg",
17
+ "app-dirs",
18
+ "config",
19
+ "cache",
20
+ "cross-platform",
21
+ "typescript"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist/",
39
+ "README.md",
40
+ "CHANGELOG.md",
41
+ "LICENSE-MIT",
42
+ "LICENSE-APACHE",
43
+ "SECURITY.md",
44
+ "CONTRIBUTING.md"
45
+ ],
46
+ "devDependencies": {
47
+ "@types/node": "^26.1.0",
48
+ "@vitest/coverage-v8": "^5.0.5",
49
+ "oxlint": "^1.65.0",
50
+ "prettier": "3.9.6",
51
+ "typescript": "~6.0.3",
52
+ "vitest": "^5.0.5",
53
+ "@osdlc/prettier-config": "^0.2.3",
54
+ "@osdlc/tsconfig": "^0.3.3"
55
+ },
56
+ "scripts": {
57
+ "build": "tsc -p tsconfig.build.json",
58
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
59
+ "test": "vitest run",
60
+ "coverage": "vitest run --coverage",
61
+ "smoke": "node tests/smoke.mjs",
62
+ "format": "prettier --write .",
63
+ "format:check": "prettier --check .",
64
+ "lint": "oxlint .",
65
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit"
66
+ }
6
67
  }