@mattstack/rt-client 0.2.0 → 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 +5 -2
- package/dist/client.d.ts +6 -2
- package/dist/commands.d.ts +138 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +1123 -4
- package/dist/settings/exec.d.ts +26 -0
- package/dist/settings/identity.d.ts +56 -0
- package/dist/settings/paths.d.ts +42 -0
- package/dist/settings/registry-defs.d.ts +12 -0
- package/dist/settings/registry-machinery.d.ts +59 -0
- package/dist/settings/resolve.d.ts +141 -0
- package/dist/settings/stores.d.ts +53 -0
- package/dist/settings/write.d.ts +110 -0
- package/dist/transport.d.ts +7 -0
- package/package.json +10 -2
- package/src/client.ts +21 -2
- package/src/commands.ts +65 -1
- package/src/index.ts +26 -0
- package/src/repos.ts +6 -2
- package/src/settings/exec.ts +67 -0
- package/src/settings/identity.ts +125 -0
- package/src/settings/paths.ts +80 -0
- package/src/settings/registry-defs.ts +439 -0
- package/src/settings/registry-machinery.ts +141 -0
- package/src/settings/resolve.ts +608 -0
- package/src/settings/stores.ts +129 -0
- package/src/settings/write.ts +294 -0
- package/src/transport.ts +18 -3
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings resolver (RT-47): one read path that layers the four stores
|
|
3
|
+
* and the registry default into a single answer plus the provenance that
|
|
4
|
+
* explains it.
|
|
5
|
+
*
|
|
6
|
+
* Scope ladder, weakest → strongest:
|
|
7
|
+
*
|
|
8
|
+
* default < team < user < team.repo < user.repo < machine < machine.repo
|
|
9
|
+
*
|
|
10
|
+
* Merge is per-key schema, never global (`SettingDef.merge`):
|
|
11
|
+
* - `replace` — the strongest valid scope wins atomically; provenance has
|
|
12
|
+
* exactly one entry.
|
|
13
|
+
* - `deep` — object values overlay field-by-field walking weakest → strongest;
|
|
14
|
+
* arrays and scalars inside a deep key still replace atomically. Provenance
|
|
15
|
+
* lists every scope that still owns at least one leaf of the resolved value,
|
|
16
|
+
* weakest-first — a scope whose every field was overridden is NOT listed
|
|
17
|
+
* (same honesty rule that makes `replace` provenance length 1).
|
|
18
|
+
*
|
|
19
|
+
* Degrade rules (teammates run version-skewed binaries; one unknown key in the
|
|
20
|
+
* team store must never brick resolution):
|
|
21
|
+
* - explicit `get`/`explain` of an unregistered key → throw.
|
|
22
|
+
* - unregistered keys FOUND in files → warn + skip, surfaced by `listSettings`
|
|
23
|
+
* with `unregistered: true`.
|
|
24
|
+
* - a registered key whose found value fails validation → warn + skip THAT
|
|
25
|
+
* scope only, labeled `invalid` in list/explain; weaker and stronger scopes
|
|
26
|
+
* still apply.
|
|
27
|
+
*
|
|
28
|
+
* Three deliberate decisions this file makes that the spec left to the
|
|
29
|
+
* implementation:
|
|
30
|
+
* 1. **The path-literal guard is scope-aware.** `validateValue`'s guarded
|
|
31
|
+
* fields (`rt.roles.hook`) are only illegal in SHARED scopes. The machine
|
|
32
|
+
* store is explicitly allowed path literals. So team/user rungs get the
|
|
33
|
+
* full check, the machine rung gets the type check alone.
|
|
34
|
+
* 2. **A value found in a store the def does not allow is skipped**, labeled
|
|
35
|
+
* like any other invalid value (`rt.repoIdentityOverrides` is machine-only;
|
|
36
|
+
* honouring a team-store copy of it would defeat the schema).
|
|
37
|
+
* 3. **`explain` shows values AS AUTHORED** (never expanded) because its job
|
|
38
|
+
* is to say what is in which file, and **`list` degrades** an unexpandable
|
|
39
|
+
* value to its raw form with an `expandError` label rather than throwing —
|
|
40
|
+
* one bad value must not brick a survey of every key. `get` is the loud
|
|
41
|
+
* one: an unsatisfiable closed-set variable throws.
|
|
42
|
+
*
|
|
43
|
+
* The resolver is daemon-FREE and sync: no spawns anywhere, repo identity is a
|
|
44
|
+
* pre-derived input (see identity.ts for the async derivation). Store files are
|
|
45
|
+
* parsed fresh per call — they are small, and memoization is a later
|
|
46
|
+
* optimization that would need invalidation this wave does not have.
|
|
47
|
+
*
|
|
48
|
+
* Writes (`setSetting`) land in a later task; this module is read-side only.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { homedir } from "os";
|
|
52
|
+
import { join } from "path";
|
|
53
|
+
import {
|
|
54
|
+
machineSettingsPath,
|
|
55
|
+
teamSettingsPath,
|
|
56
|
+
teamsDir,
|
|
57
|
+
userSettingsPath,
|
|
58
|
+
} from "./paths.ts";
|
|
59
|
+
import { allDefs, getDef, isMigrated, validateValue, type SettingDef, type SettingScope } from "./registry-machinery.ts";
|
|
60
|
+
import { listTeams, readStore, type StoreFile } from "./stores.ts";
|
|
61
|
+
|
|
62
|
+
// ─── Public types ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export type Scope =
|
|
65
|
+
| "machine.repo"
|
|
66
|
+
| "machine"
|
|
67
|
+
| "user.repo"
|
|
68
|
+
| "team.repo"
|
|
69
|
+
| "user"
|
|
70
|
+
| "team"
|
|
71
|
+
| "default";
|
|
72
|
+
|
|
73
|
+
/** The scope ladder, weakest first. Also the order every result is built in. */
|
|
74
|
+
export const SCOPE_ORDER: Scope[] = [
|
|
75
|
+
"default",
|
|
76
|
+
"team",
|
|
77
|
+
"user",
|
|
78
|
+
"team.repo",
|
|
79
|
+
"user.repo",
|
|
80
|
+
"machine",
|
|
81
|
+
"machine.repo",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export interface Provenance {
|
|
85
|
+
scope: Scope;
|
|
86
|
+
/** The file the value came from; null for the registry default. */
|
|
87
|
+
file: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ResolveOpts {
|
|
91
|
+
/** Normalized repo identity (identity.ts). Null/absent = repo rungs are unreachable. */
|
|
92
|
+
repoIdentity?: string | null;
|
|
93
|
+
/** Expand closed-set variables in the resolved value. Default true. */
|
|
94
|
+
expand?: boolean;
|
|
95
|
+
expandCtx?: { repoRoot?: string; worktree?: string };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface Resolved<T> {
|
|
99
|
+
value: T;
|
|
100
|
+
/** ALWAYS an array, weakest-first. Length 1 for replace keys. */
|
|
101
|
+
provenance: Provenance[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A scope whose authored value was found but refused (type, path guard, or store). */
|
|
105
|
+
export interface InvalidScope {
|
|
106
|
+
scope: Scope;
|
|
107
|
+
file: string | null;
|
|
108
|
+
reason: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ListedSetting {
|
|
112
|
+
key: string;
|
|
113
|
+
value: unknown;
|
|
114
|
+
provenance: Provenance[];
|
|
115
|
+
migrated: boolean;
|
|
116
|
+
/** Present only for keys found in files but absent from the registry. */
|
|
117
|
+
unregistered?: true;
|
|
118
|
+
/** Scopes skipped during resolution, with the reason each was refused. */
|
|
119
|
+
invalid?: InvalidScope[];
|
|
120
|
+
/** Set when the value could not be expanded here; `value` is then raw. */
|
|
121
|
+
expandError?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ExplainRow {
|
|
125
|
+
scope: Scope;
|
|
126
|
+
file: string | null;
|
|
127
|
+
present: boolean;
|
|
128
|
+
/** The value AS AUTHORED — never variable-expanded. */
|
|
129
|
+
value?: unknown;
|
|
130
|
+
/** Set when the value was ignored because the key is teamLocked. */
|
|
131
|
+
shadowed?: "teamLocked";
|
|
132
|
+
/** Set when the value was refused; the reason it was refused. */
|
|
133
|
+
invalid?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ExpandCtx {
|
|
137
|
+
repoRoot?: string;
|
|
138
|
+
worktree?: string;
|
|
139
|
+
home: string;
|
|
140
|
+
teamsDir: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Variables ───────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const VAR_RE = /\$\{([^}]*)\}/g;
|
|
146
|
+
const TEAM_VAR_RE = /^team:(.+)$/;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Replaces ONLY `${repoRoot}`, `${worktree}`, `${home}` and `${team:<name>}`.
|
|
150
|
+
* Every other `${...}` passes through verbatim — domain templates like the
|
|
151
|
+
* interceptor's `${port}` are not ours to expand, and the same string may hold
|
|
152
|
+
* both kinds, so substitution is per-occurrence. `${team:<name>}` is lexical:
|
|
153
|
+
* `<teamsDir>/<name>` with no existence check (a missing team surfaces at use
|
|
154
|
+
* time through the consumer's own fail-open path), but the name must be a
|
|
155
|
+
* single directory segment — see `teamPath`. A closed-set variable with no
|
|
156
|
+
* context in `ctx` throws — silently emitting a half-expanded path is the
|
|
157
|
+
* dishonesty this design bans.
|
|
158
|
+
*
|
|
159
|
+
* Recurses through arrays and plain objects; non-strings pass through. Never
|
|
160
|
+
* mutates its input.
|
|
161
|
+
*/
|
|
162
|
+
export function expandVariables(value: unknown, ctx: ExpandCtx): unknown {
|
|
163
|
+
if (typeof value === "string") return expandString(value, ctx);
|
|
164
|
+
if (Array.isArray(value)) return value.map((item) => expandVariables(item, ctx));
|
|
165
|
+
if (isPlainObject(value)) {
|
|
166
|
+
const out: Record<string, unknown> = {};
|
|
167
|
+
for (const [k, v] of Object.entries(value)) out[k] = expandVariables(v, ctx);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function expandString(input: string, ctx: ExpandCtx): string {
|
|
174
|
+
return input.replace(VAR_RE, (match, name: string) => {
|
|
175
|
+
if (name === "home") return ctx.home;
|
|
176
|
+
if (name === "repoRoot") return required(ctx.repoRoot, "repoRoot", "a repo path");
|
|
177
|
+
if (name === "worktree") return required(ctx.worktree, "worktree", "a worktree path");
|
|
178
|
+
const team = TEAM_VAR_RE.exec(name);
|
|
179
|
+
if (team) return teamPath(ctx.teamsDir, team[1] as string);
|
|
180
|
+
return match; // not ours — pass through verbatim
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `${team:<name>}` → `<teamsDir>/<name>`, but only for a name that is a single
|
|
186
|
+
* directory segment. `<name>` is a team NAME, and `join()` normalizes away
|
|
187
|
+
* `..`, so `${team:../../.ssh}` would quietly resolve to a path OUTSIDE the
|
|
188
|
+
* teams dir — a store value (a team store's own, even) that reads or executes
|
|
189
|
+
* from anywhere on disk while still looking like a team-relative reference.
|
|
190
|
+
* Any `/`, `\` or `..` therefore throws, on the same closed-set footing as an
|
|
191
|
+
* unsatisfiable `${repoRoot}`: `get` surfaces it, `list` degrades that one
|
|
192
|
+
* value to an `expandError`, and no half-expanded path is ever emitted.
|
|
193
|
+
*/
|
|
194
|
+
function teamPath(teamsDir: string, name: string): string {
|
|
195
|
+
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`rt: cannot expand \${team:${name}} — a team name must be a single directory segment (no "/", "\\" or "..")`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return join(teamsDir, name);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function required(value: string | undefined, name: string, needs: string): string {
|
|
204
|
+
if (value === undefined || value === "") {
|
|
205
|
+
throw new Error(`rt: cannot expand \${${name}} — this setting was resolved without ${needs}`);
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Store reading ───────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
interface StoreBundle {
|
|
213
|
+
user: StoreFile;
|
|
214
|
+
machine: StoreFile;
|
|
215
|
+
/** One per team that has a local settings file, alphabetical (wave 1: overlay all). */
|
|
216
|
+
teams: StoreFile[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readStores(): StoreBundle {
|
|
220
|
+
return {
|
|
221
|
+
user: readStore(userSettingsPath()),
|
|
222
|
+
machine: readStore(machineSettingsPath()),
|
|
223
|
+
teams: [...listTeams()].sort().map((team) => readStore(teamSettingsPath(team))),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Slots: every rung a key could come from, weakest-first ──────────────────
|
|
228
|
+
|
|
229
|
+
interface Slot {
|
|
230
|
+
scope: Scope;
|
|
231
|
+
file: string | null;
|
|
232
|
+
present: boolean;
|
|
233
|
+
value?: unknown;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function collectSlots(def: SettingDef, stores: StoreBundle, opts: ResolveOpts): Slot[] {
|
|
237
|
+
const slots: Slot[] = [];
|
|
238
|
+
const identity = opts.repoIdentity ?? null;
|
|
239
|
+
const useRepo = def.repoScoped === true && typeof identity === "string" && identity !== "";
|
|
240
|
+
const repoSection = (store: StoreFile): Record<string, unknown> | undefined =>
|
|
241
|
+
useRepo ? store.repos[identity as string] : undefined;
|
|
242
|
+
|
|
243
|
+
const push = (scope: Scope, file: string | null, section: Record<string, unknown> | undefined) => {
|
|
244
|
+
const value = section?.[def.key];
|
|
245
|
+
if (value === undefined) slots.push({ scope, file, present: false });
|
|
246
|
+
else slots.push({ scope, file, present: true, value });
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Wave 1 overlays EVERY cloned team, alphabetically, so the result is
|
|
251
|
+
* deterministic; multi-team precedence is explicitly deferred (spec: out of
|
|
252
|
+
* scope — one team exists today). With no team cloned at all we still emit
|
|
253
|
+
* one absent rung so `explain` shows the ladder in full.
|
|
254
|
+
*/
|
|
255
|
+
const pushTeams = (scope: Scope, section: (store: StoreFile) => Record<string, unknown> | undefined) => {
|
|
256
|
+
if (stores.teams.length === 0) {
|
|
257
|
+
slots.push({ scope, file: null, present: false });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
for (const store of stores.teams) push(scope, store.file, section(store));
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// default — cloned so a caller mutating the resolved value cannot corrupt
|
|
264
|
+
// the registry's shared def object.
|
|
265
|
+
slots.push(
|
|
266
|
+
def.default === undefined
|
|
267
|
+
? { scope: "default", file: null, present: false }
|
|
268
|
+
: { scope: "default", file: null, present: true, value: structuredClone(def.default) },
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// The ladder itself, weakest → strongest. Repo rungs are omitted entirely
|
|
272
|
+
// when they are unreachable (key not repoScoped, or no identity in hand) —
|
|
273
|
+
// an unreachable rung in `explain` would be noise, not honesty.
|
|
274
|
+
pushTeams("team", (store) => store.global);
|
|
275
|
+
push("user", stores.user.file, stores.user.global);
|
|
276
|
+
if (useRepo) pushTeams("team.repo", repoSection);
|
|
277
|
+
if (useRepo) push("user.repo", stores.user.file, repoSection(stores.user));
|
|
278
|
+
push("machine", stores.machine.file, stores.machine.global);
|
|
279
|
+
if (useRepo) push("machine.repo", stores.machine.file, repoSection(stores.machine));
|
|
280
|
+
|
|
281
|
+
return slots;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── Resolution ──────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
interface Resolution {
|
|
287
|
+
value: unknown;
|
|
288
|
+
provenance: Provenance[];
|
|
289
|
+
invalid: InvalidScope[];
|
|
290
|
+
rows: ExplainRow[];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const TEAM_LOCKED_SCOPES: Scope[] = ["default", "team", "team.repo"];
|
|
294
|
+
|
|
295
|
+
/** The store a scope's value is authored in — the rung's write-side scope. */
|
|
296
|
+
function baseScope(scope: Scope): SettingScope | null {
|
|
297
|
+
if (scope === "team" || scope === "team.repo") return "team";
|
|
298
|
+
if (scope === "user" || scope === "user.repo") return "user";
|
|
299
|
+
if (scope === "machine" || scope === "machine.repo") return "machine";
|
|
300
|
+
return null; // default is not authored in a store
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The path-literal guard applies to SHARED scopes only — the machine store is
|
|
305
|
+
* the one place path literals are legal.
|
|
306
|
+
*/
|
|
307
|
+
function validateForScope(
|
|
308
|
+
def: SettingDef,
|
|
309
|
+
scope: Scope,
|
|
310
|
+
value: unknown,
|
|
311
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
312
|
+
const shared = scope === "team" || scope === "user" || scope === "team.repo" || scope === "user.repo";
|
|
313
|
+
return validateValue(shared ? def : { ...def, pathGuardFields: undefined }, value);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function resolveDef(def: SettingDef, stores: StoreBundle, opts: ResolveOpts): Resolution {
|
|
317
|
+
const slots = collectSlots(def, stores, opts);
|
|
318
|
+
const rows: ExplainRow[] = [];
|
|
319
|
+
const invalid: InvalidScope[] = [];
|
|
320
|
+
const applied: Array<{ scope: Scope; file: string | null; value: unknown }> = [];
|
|
321
|
+
|
|
322
|
+
for (const slot of slots) {
|
|
323
|
+
const row: ExplainRow = { scope: slot.scope, file: slot.file, present: slot.present };
|
|
324
|
+
if (!slot.present) {
|
|
325
|
+
rows.push(row);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
row.value = slot.value;
|
|
329
|
+
|
|
330
|
+
// teamLocked: team.repo > team > default and nothing else. Other scopes'
|
|
331
|
+
// values are reported, never applied.
|
|
332
|
+
if (def.teamLocked && !TEAM_LOCKED_SCOPES.includes(slot.scope)) {
|
|
333
|
+
row.shadowed = "teamLocked";
|
|
334
|
+
rows.push(row);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// A key authored in a store its def does not list is not this key.
|
|
339
|
+
const base = baseScope(slot.scope);
|
|
340
|
+
if (base !== null && !def.scopes.includes(base)) {
|
|
341
|
+
const reason = `not settable in the ${base} store (allowed: ${def.scopes.join(", ")})`;
|
|
342
|
+
row.invalid = reason;
|
|
343
|
+
invalid.push({ scope: slot.scope, file: slot.file, reason });
|
|
344
|
+
rows.push(row);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The registry default is trusted; everything read off disk is checked.
|
|
349
|
+
if (slot.scope !== "default") {
|
|
350
|
+
const check = validateForScope(def, slot.scope, slot.value);
|
|
351
|
+
if (!check.ok) {
|
|
352
|
+
row.invalid = check.reason;
|
|
353
|
+
invalid.push({ scope: slot.scope, file: slot.file, reason: check.reason });
|
|
354
|
+
rows.push(row);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
rows.push(row);
|
|
360
|
+
applied.push({ scope: slot.scope, file: slot.file, value: slot.value });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const merged = mergeApplied(def, applied);
|
|
364
|
+
return { value: merged.value, provenance: merged.provenance, invalid, rows };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function mergeApplied(
|
|
368
|
+
def: SettingDef,
|
|
369
|
+
applied: Array<{ scope: Scope; file: string | null; value: unknown }>,
|
|
370
|
+
): { value: unknown; provenance: Provenance[] } {
|
|
371
|
+
if (applied.length === 0) return { value: undefined, provenance: [] };
|
|
372
|
+
|
|
373
|
+
// Deep merge is only meaningful for objects; a `deep` def with any other
|
|
374
|
+
// type — or a non-object layer, only reachable through a malformed registry
|
|
375
|
+
// default since every value read off disk is type-checked — falls back to
|
|
376
|
+
// replace rather than inventing semantics for it.
|
|
377
|
+
if (def.merge === "deep" && def.type === "object") {
|
|
378
|
+
const objectLayers = applied.filter((layer) => isPlainObject(layer.value));
|
|
379
|
+
if (objectLayers.length > 0) {
|
|
380
|
+
const { value, contributors } = deepMerge(objectLayers.map((layer) => layer.value));
|
|
381
|
+
return {
|
|
382
|
+
value,
|
|
383
|
+
provenance: contributors.map((i) => {
|
|
384
|
+
const layer = objectLayers[i] as (typeof applied)[number];
|
|
385
|
+
return { scope: layer.scope, file: layer.file };
|
|
386
|
+
}),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const winner = applied[applied.length - 1] as (typeof applied)[number];
|
|
392
|
+
return { value: winner.value, provenance: [{ scope: winner.scope, file: winner.file }] };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── Deep merge with per-leaf attribution ────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
// Leaf paths are joined with NUL so a field name containing a dot cannot
|
|
398
|
+
// collide with a nested path of the same spelling.
|
|
399
|
+
const PATH_SEP = "\u0000";
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Overlays object layers weakest → strongest, tracking which layer owns each
|
|
403
|
+
* surviving leaf. Arrays and scalars replace atomically (an array IS a leaf);
|
|
404
|
+
* objects recurse. `contributors` is the ascending list of layer indexes that
|
|
405
|
+
* still own at least one leaf of the result.
|
|
406
|
+
*/
|
|
407
|
+
function deepMerge(layers: unknown[]): { value: Record<string, unknown>; contributors: number[] } {
|
|
408
|
+
const owner = new Map<string, number>();
|
|
409
|
+
let acc: Record<string, unknown> = {};
|
|
410
|
+
|
|
411
|
+
layers.forEach((layer, index) => {
|
|
412
|
+
acc = overlay(acc, layer as Record<string, unknown>, owner, index, "");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const contributors = [...new Set(owner.values())].sort((a, b) => a - b);
|
|
416
|
+
return { value: acc, contributors };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function overlay(
|
|
420
|
+
base: Record<string, unknown>,
|
|
421
|
+
over: Record<string, unknown>,
|
|
422
|
+
owner: Map<string, number>,
|
|
423
|
+
index: number,
|
|
424
|
+
prefix: string,
|
|
425
|
+
): Record<string, unknown> {
|
|
426
|
+
const out: Record<string, unknown> = { ...base };
|
|
427
|
+
|
|
428
|
+
for (const [key, value] of Object.entries(over)) {
|
|
429
|
+
const path = prefix === "" ? key : `${prefix}${PATH_SEP}${key}`;
|
|
430
|
+
const current = out[key];
|
|
431
|
+
|
|
432
|
+
if (isPlainObject(value) && isPlainObject(current)) {
|
|
433
|
+
out[key] = overlay(current, value, owner, index, path);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
out[key] = value;
|
|
438
|
+
clearOwners(owner, path);
|
|
439
|
+
registerLeaves(value, path, owner, index);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function clearOwners(owner: Map<string, number>, path: string): void {
|
|
446
|
+
owner.delete(path);
|
|
447
|
+
const under = `${path}${PATH_SEP}`;
|
|
448
|
+
for (const existing of [...owner.keys()]) {
|
|
449
|
+
if (existing.startsWith(under)) owner.delete(existing);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Records ownership at LEAF granularity: an object is walked into so that a
|
|
455
|
+
* stronger layer overriding every one of its fields takes the whole thing over
|
|
456
|
+
* (and the weaker layer correctly drops out of provenance).
|
|
457
|
+
*/
|
|
458
|
+
function registerLeaves(value: unknown, path: string, owner: Map<string, number>, index: number): void {
|
|
459
|
+
if (isPlainObject(value)) {
|
|
460
|
+
const entries = Object.entries(value);
|
|
461
|
+
if (entries.length > 0) {
|
|
462
|
+
for (const [key, child] of entries) {
|
|
463
|
+
registerLeaves(child, `${path}${PATH_SEP}${key}`, owner, index);
|
|
464
|
+
}
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
owner.set(path, index);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
472
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
function unknownKey(key: string): Error {
|
|
478
|
+
return new Error(`rt: unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function expandCtxFrom(opts: ResolveOpts): ExpandCtx {
|
|
482
|
+
return {
|
|
483
|
+
repoRoot: opts.expandCtx?.repoRoot,
|
|
484
|
+
worktree: opts.expandCtx?.worktree,
|
|
485
|
+
home: process.env.HOME ?? homedir(),
|
|
486
|
+
teamsDir: teamsDir(),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function warnInvalid(key: string, entry: InvalidScope): void {
|
|
491
|
+
console.warn(
|
|
492
|
+
`rt: ignoring "${key}" from the ${entry.scope} scope (${entry.file ?? "no file"}): ${entry.reason}`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Resolves one key across the whole ladder. Throws for an unregistered key —
|
|
498
|
+
* an explicit get of something rt has never heard of is a caller bug, not a
|
|
499
|
+
* degrade (contrast: unknown keys FOUND in files, which only warn).
|
|
500
|
+
*/
|
|
501
|
+
export function getSetting<T>(key: string, opts: ResolveOpts = {}): Resolved<T> {
|
|
502
|
+
const def = getDef(key);
|
|
503
|
+
if (!def) throw unknownKey(key);
|
|
504
|
+
|
|
505
|
+
const resolution = resolveDef(def, readStores(), opts);
|
|
506
|
+
for (const entry of resolution.invalid) warnInvalid(key, entry);
|
|
507
|
+
|
|
508
|
+
const shouldExpand = opts.expand ?? true;
|
|
509
|
+
const value =
|
|
510
|
+
shouldExpand && resolution.value !== undefined
|
|
511
|
+
? expandVariables(resolution.value, expandCtxFrom(opts))
|
|
512
|
+
: resolution.value;
|
|
513
|
+
|
|
514
|
+
return { value: value as T, provenance: resolution.provenance };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Every registered key resolved (registry order), then every unregistered key
|
|
519
|
+
* found in the stores (alphabetical). Nothing here throws: a survey of the
|
|
520
|
+
* whole settings map must survive one bad value, so an unexpandable value
|
|
521
|
+
* degrades to its raw form plus an `expandError` label.
|
|
522
|
+
*/
|
|
523
|
+
export function listSettings(opts: ResolveOpts = {}): ListedSetting[] {
|
|
524
|
+
const stores = readStores();
|
|
525
|
+
const ctx = expandCtxFrom(opts);
|
|
526
|
+
const shouldExpand = opts.expand ?? true;
|
|
527
|
+
const out: ListedSetting[] = [];
|
|
528
|
+
|
|
529
|
+
for (const def of allDefs()) {
|
|
530
|
+
const resolution = resolveDef(def, stores, opts);
|
|
531
|
+
for (const entry of resolution.invalid) warnInvalid(def.key, entry);
|
|
532
|
+
|
|
533
|
+
const listed: ListedSetting = {
|
|
534
|
+
key: def.key,
|
|
535
|
+
value: resolution.value,
|
|
536
|
+
provenance: resolution.provenance,
|
|
537
|
+
migrated: isMigrated(def),
|
|
538
|
+
};
|
|
539
|
+
if (resolution.invalid.length > 0) listed.invalid = resolution.invalid;
|
|
540
|
+
|
|
541
|
+
if (shouldExpand && resolution.value !== undefined) {
|
|
542
|
+
try {
|
|
543
|
+
listed.value = expandVariables(resolution.value, ctx);
|
|
544
|
+
} catch (err) {
|
|
545
|
+
listed.expandError = (err as Error).message;
|
|
546
|
+
console.warn(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
out.push(listed);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
out.push(...listUnregistered(stores, opts));
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Keys present in a store file that the registry has never heard of. They are
|
|
559
|
+
* never merged (there is no def to say how) — the strongest scope holding one
|
|
560
|
+
* is reported as-is, so a teammate's newer key is visible rather than silently
|
|
561
|
+
* dropped.
|
|
562
|
+
*/
|
|
563
|
+
function listUnregistered(stores: StoreBundle, opts: ResolveOpts): ListedSetting[] {
|
|
564
|
+
const identity = opts.repoIdentity ?? null;
|
|
565
|
+
const found = new Map<string, Provenance & { value: unknown }>();
|
|
566
|
+
|
|
567
|
+
const scan = (scope: Scope, file: string, section: Record<string, unknown> | undefined) => {
|
|
568
|
+
for (const [key, value] of Object.entries(section ?? {})) {
|
|
569
|
+
if (getDef(key)) continue;
|
|
570
|
+
found.set(key, { scope, file, value }); // later (stronger) scans win
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const repoSection = (store: StoreFile) =>
|
|
574
|
+
typeof identity === "string" && identity !== "" ? store.repos[identity] : undefined;
|
|
575
|
+
|
|
576
|
+
for (const store of stores.teams) scan("team", store.file, store.global);
|
|
577
|
+
scan("user", stores.user.file, stores.user.global);
|
|
578
|
+
for (const store of stores.teams) scan("team.repo", store.file, repoSection(store));
|
|
579
|
+
scan("user.repo", stores.user.file, repoSection(stores.user));
|
|
580
|
+
scan("machine", stores.machine.file, stores.machine.global);
|
|
581
|
+
scan("machine.repo", stores.machine.file, repoSection(stores.machine));
|
|
582
|
+
|
|
583
|
+
return [...found.entries()]
|
|
584
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
585
|
+
.map(([key, hit]) => {
|
|
586
|
+
console.warn(
|
|
587
|
+
`rt: unregistered setting "${key}" in ${hit.file} — ignoring it (this rt may be older than the store)`,
|
|
588
|
+
);
|
|
589
|
+
return {
|
|
590
|
+
key,
|
|
591
|
+
value: hit.value,
|
|
592
|
+
provenance: [{ scope: hit.scope, file: hit.file }],
|
|
593
|
+
migrated: false,
|
|
594
|
+
unregistered: true as const,
|
|
595
|
+
};
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* One row per reachable rung, weakest-first, with values AS AUTHORED. Repo
|
|
601
|
+
* rungs are omitted entirely when the key is not repoScoped or no identity was
|
|
602
|
+
* supplied — showing rungs that could never apply would be noise, not honesty.
|
|
603
|
+
*/
|
|
604
|
+
export function explainSetting(key: string, opts: ResolveOpts = {}): ExplainRow[] {
|
|
605
|
+
const def = getDef(key);
|
|
606
|
+
if (!def) throw unknownKey(key);
|
|
607
|
+
return resolveDef(def, readStores(), opts).rows;
|
|
608
|
+
}
|