@mattstack/rt-client 0.1.1 → 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,129 @@
1
+ /**
2
+ * Reading the four settings store files (RT-47).
3
+ *
4
+ * `readStore` is the shared "raw JSONC → {global, repos}" step every store
5
+ * (user/team/machine) goes through before the resolver layers them by scope.
6
+ * It uses jsonc-parser (`parse`) rather than lib/jsonc.ts's stripJsonc: this
7
+ * is the one place in rt that also needs to WRITE these files back with
8
+ * comments/formatting intact (via jsonc-parser's `modify`/`applyEdits`, added
9
+ * alongside `setSetting` in resolve.ts), and both directions should go
10
+ * through the same library. stripJsonc keeps its existing callers.
11
+ *
12
+ * A store file is honest-degrade, not throw-on-read: absent, empty, or
13
+ * malformed all resolve to an empty store rather than crashing a caller that
14
+ * just wants "whatever settings exist" (teammates run version-skewed
15
+ * binaries; a store file with content this rt can't parse must not brick
16
+ * every settings read).
17
+ */
18
+
19
+ import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "fs";
20
+ import { parse, type ParseError } from "jsonc-parser";
21
+ import { join } from "path";
22
+ import { teamsDir, teamSettingsPath } from "./paths.ts";
23
+
24
+ export interface StoreFile {
25
+ /** Top-level keys other than "repos" — the global scope for this store. */
26
+ global: Record<string, unknown>;
27
+ /** The "repos" object, keyed by repo identity. Empty if absent. */
28
+ repos: Record<string, Record<string, unknown>>;
29
+ /** The path this store was read from (echoed back for provenance). */
30
+ file: string;
31
+ /** False only when the file does not exist at all. */
32
+ exists: boolean;
33
+ }
34
+
35
+ const EMPTY_STORE = (file: string, exists: boolean): StoreFile => ({
36
+ global: {},
37
+ repos: {},
38
+ file,
39
+ exists,
40
+ });
41
+
42
+ /**
43
+ * Reads and parses one settings store file. Never throws:
44
+ * - missing file → `{ exists: false }`, empty maps.
45
+ * - present but malformed (parse errors, or a root that isn't a JSON
46
+ * object) → `{ exists: true }`, empty maps, one console.warn.
47
+ * - present and well-formed → `{ exists: true }`, split into
48
+ * `global`/`repos`.
49
+ */
50
+ export function readStore(file: string): StoreFile {
51
+ if (!existsSync(file)) return EMPTY_STORE(file, false);
52
+
53
+ let raw: string;
54
+ try {
55
+ raw = readFileSync(file, "utf8");
56
+ } catch (err) {
57
+ console.warn(`rt: failed to read settings store ${file}, ignoring: ${(err as Error).message}`);
58
+ return EMPTY_STORE(file, true);
59
+ }
60
+
61
+ if (raw.trim() === "") return EMPTY_STORE(file, true);
62
+
63
+ const errors: ParseError[] = [];
64
+ const root = parse(raw, errors, { allowTrailingComma: true });
65
+
66
+ if (errors.length > 0 || root === undefined || typeof root !== "object" || Array.isArray(root)) {
67
+ console.warn(`rt: malformed settings store ${file}, ignoring (treating as empty)`);
68
+ return EMPTY_STORE(file, true);
69
+ }
70
+
71
+ const { repos, ...global } = root as Record<string, unknown>;
72
+ const reposIsValid = repos !== undefined && typeof repos === "object" && repos !== null && !Array.isArray(repos);
73
+
74
+ if (repos !== undefined && !reposIsValid) {
75
+ console.warn(`rt: malformed "repos" section in settings store ${file}, ignoring repo sections (global keys still apply)`);
76
+ }
77
+
78
+ const reposValid = reposIsValid ? (repos as Record<string, Record<string, unknown>>) : {};
79
+
80
+ return { global, repos: reposValid, file, exists: true };
81
+ }
82
+
83
+ /**
84
+ * Names of every team that has a local settings store — i.e. subdirectories
85
+ * of teamsDir() that contain mattstack/settings.team.jsonc. A team dir without a
86
+ * settings file (a clone mid-setup, or an unrelated directory) is not yet a
87
+ * team as far as the resolver is concerned.
88
+ *
89
+ * Honest-degrade like readStore, and for a sharper reason: this scan is on the
90
+ * path of EVERY settings resolution, so one bad directory entry must never
91
+ * brick `rt settings` or any reader behind it. A team clone that was symlinked
92
+ * in and later moved leaves a dangling symlink here, and the follow-the-link
93
+ * stat that keeps symlinked clones working throws ENOENT on exactly that — so
94
+ * the scan is guarded twice: around the readdir (an unreadable teams dir means
95
+ * no teams), and around EACH entry (a dangling link, an EACCES, or a stat that
96
+ * loses a race with a concurrent move skips that entry and leaves the healthy
97
+ * teams intact).
98
+ */
99
+ export function listTeams(): string[] {
100
+ const dir = teamsDir();
101
+ if (!existsSync(dir)) return [];
102
+
103
+ let entries: Dirent[];
104
+ try {
105
+ entries = readdirSync(dir, { withFileTypes: true });
106
+ } catch (err) {
107
+ console.warn(`rt: failed to list teams in ${dir}, treating as no teams: ${(err as Error).message}`);
108
+ return [];
109
+ }
110
+
111
+ const teams: string[] = [];
112
+ for (const entry of entries) {
113
+ try {
114
+ // isDirectory() is false for a symlink, but a symlinked team clone is a
115
+ // real team — those resolve through stat, which is also what throws on a
116
+ // dangling link, hence the per-entry try.
117
+ const isDir =
118
+ entry.isDirectory() ||
119
+ (entry.isSymbolicLink() && statSync(join(dir, entry.name)).isDirectory());
120
+ if (!isDir) continue;
121
+ if (existsSync(teamSettingsPath(entry.name))) teams.push(entry.name);
122
+ } catch (err) {
123
+ console.warn(
124
+ `rt: skipping unreadable teams entry ${join(dir, entry.name)}: ${(err as Error).message}`,
125
+ );
126
+ }
127
+ }
128
+ return teams;
129
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The settings write path (RT-47): `setSetting` — a single, comment-preserving
3
+ * write into one of the three AUTHORED stores (user/team/machine; `default`
4
+ * is a read-only rung and never appears here).
5
+ *
6
+ * Writes go through jsonc-parser's `modify`/`applyEdits` rather than
7
+ * parse-mutate-stringify, so existing comments and formatting in the store
8
+ * file survive a write to an unrelated key (verified: `modify` only rewrites
9
+ * the minimal edit range needed for the touched path; everything else in the
10
+ * document — including comments — is untouched text).
11
+ *
12
+ * JSONPath segments are literal object keys, not `.`-namespaced walks: a
13
+ * global write targets `[key]` (e.g. `["rt.hooks"]`) and a repoScoped write
14
+ * targets `["repos", identity, key]`. A dotted key like `"rt.roles"` is one
15
+ * path segment, not two — jsonc-parser never splits on `.` (verified with a
16
+ * throwaway script against the installed 3.3.1). Missing parents (`"repos"`,
17
+ * or the identity's section under it) are created by `modify` itself, and
18
+ * that creation is comment-safe — no special-casing needed here.
19
+ *
20
+ * Creating an absent store file (user/machine only — see "Team selection"
21
+ * below for why team stores are never auto-created): the file is seeded
22
+ * in-memory as `// header comment\n{}\n` BEFORE the first `modify` call.
23
+ * This is required, not cosmetic — a verified footgun: running `modify` on a
24
+ * comment-only document with NO braces at all (e.g. just `// header\n`)
25
+ * places the new object first and re-emits the header AFTER the closing
26
+ * brace, which is backwards. Seeding an empty object ahead of time gives
27
+ * `modify` real JSON structure to edit into, and the header comment stays
28
+ * exactly where it was written, above the object.
29
+ *
30
+ * ── Refusals ────────────────────────────────────────────────────────────
31
+ * In order: unregistered key, migrated:false (naming `def.legacyFile`), a
32
+ * scope the def does not list, a
33
+ * repoIdentity supplied for a key that is not `repoScoped`, a value that
34
+ * fails `registry.validateValue` (type check + the path-literal guard), and
35
+ * finally — only for `scope: "team"` — a team store that cannot be resolved
36
+ * (see below). Filesystem-touching checks (team resolution) run last, after
37
+ * every pure/in-memory refusal, so a bad call never creates or touches a
38
+ * file it was going to refuse anyway.
39
+ *
40
+ * ── The path-literal guard is scope-aware ──────────────────────────────
41
+ * Mirrors `resolve.ts`'s `validateForScope`: `def.pathGuardFields` (wave 1:
42
+ * `rt.roles.hook`) is enforced at `user` and `team` scope, where a path
43
+ * literal would silently stop applying the moment a teammate's checkout (or
44
+ * this developer's own machine) sits at a different path. `machine` scope is
45
+ * exempt — it is the one store where a path literal is the CORRECT way to
46
+ * express something local-only, so writes there skip the guard entirely
47
+ * (implemented by stripping `pathGuardFields` before calling
48
+ * `validateValue`, same trick `resolve.ts` uses on the read side).
49
+ *
50
+ * ── Team selection (a design decision this task made, per the brief) ──
51
+ * The base signature (`setSetting(key, value, scope, opts?)`) is extended
52
+ * here with `opts.team`, an explicit team NAME to target. Selection rule for
53
+ * `scope: "team"`:
54
+ * - `opts.team` given → that team's store; refuse if it has no local
55
+ * settings file (a team dir can exist mid-clone without one — see
56
+ * `stores.ts#listTeams`).
57
+ * - `opts.team` omitted, exactly one team has a local store → use it.
58
+ * - `opts.team` omitted, zero or multiple teams have a local store →
59
+ * refuse with a clear error (asking for `opts.team` in the multiple
60
+ * case). Wave 1 ships exactly one team, so this is the common path; the
61
+ * alternative of silently picking "the first team alphabetically" was
62
+ * considered and rejected — guessing which team's shared file to mutate
63
+ * is exactly the silent-oracle behavior this design bans elsewhere.
64
+ * A team store is NEVER auto-created by `setSetting` — team stores are
65
+ * seeded by the migration/orchestrator step and live in a repo that needs a
66
+ * commit+push to reach teammates; conjuring one here would produce an
67
+ * uncommitted, unshared file masquerading as team state.
68
+ *
69
+ * Every successful `scope: "team"` write prints one reminder line to
70
+ * stderr: the edit only exists in this local clone until it is committed
71
+ * and pushed. No such reminder for `user`/`machine` (nothing to push there
72
+ * in wave 1).
73
+ *
74
+ * ── Malformed stores refuse rather than edit around the damage ─────────
75
+ * An existing store's on-disk text is parsed and checked (`assertEditableJsonc`)
76
+ * before `modify` ever runs: real parse errors, a non-object root, or a
77
+ * duplicate key anywhere in the tree all refuse with one message naming the
78
+ * file. The duplicate-key case is the sharp one — it is not a parse error at
79
+ * all (JSON's grammar permits it), but `modify` edits the FIRST occurrence by
80
+ * offset while every reader takes the LAST, so a naive edit-in-place would
81
+ * report success while the effective value never changes, and the file would
82
+ * still degrade to empty on the next `readStore`. Refusing is the only
83
+ * option that doesn't either lie about success or write a still-broken file.
84
+ *
85
+ * ── Writes are write-temp-then-rename ───────────────────────────────────
86
+ * Mirrors `lib/json-store.ts`'s `writeJson`: the edited text is written to a
87
+ * `<path>.<pid>.<random>.tmp` file in the SAME directory, then renamed onto
88
+ * the real path — stores never tear, matching the rest of rt's persistence.
89
+ * All three stores are tracked repos now, but nothing auto-commits a write
90
+ * (H2, the snapshot daemon, is unbuilt) — a torn write would sit as a
91
+ * corrupt uncommitted file until a human noticed. The tmp file carries the
92
+ * edited TEXT exactly as `applyEdits` produced it, never round-tripped
93
+ * through `JSON.stringify` — that's what keeps comments alive.
94
+ */
95
+
96
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs";
97
+ import { applyEdits, modify, parseTree, type JSONPath, type Node, type ParseError } from "jsonc-parser";
98
+ import { randomBytes } from "crypto";
99
+ import { dirname } from "path";
100
+ import { machineSettingsPath, teamSettingsPath, userSettingsPath } from "./paths.ts";
101
+ import { getDef, isMigrated, validateValue, type SettingDef, type SettingScope } from "./registry-machinery.ts";
102
+ import { listTeams } from "./stores.ts";
103
+
104
+ export interface SetSettingOpts {
105
+ /** Normalized repo identity — required to target a repoScoped key's `repos.<identity>` section. */
106
+ repoIdentity?: string;
107
+ /**
108
+ * Which team's local store to write into, for `scope: "team"`. See the
109
+ * module doc's "Team selection" section. Ignored for `user`/`machine`.
110
+ */
111
+ team?: string;
112
+ }
113
+
114
+ const FORMAT = { tabSize: 2, insertSpaces: true, eol: "\n" };
115
+
116
+ function refuse(message: string): never {
117
+ throw new Error(`rt: ${message}`);
118
+ }
119
+
120
+ /**
121
+ * Writes `value` for `key` into the given scope's store, preserving every
122
+ * existing comment and creating the file/section it needs. See the module
123
+ * doc for the full refusal list and the team-selection rule.
124
+ */
125
+ export function setSetting(key: string, value: unknown, scope: SettingScope, opts: SetSettingOpts = {}): void {
126
+ const def = getDef(key);
127
+ if (!def) {
128
+ refuse(`unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
129
+ }
130
+
131
+ if (!isMigrated(def)) {
132
+ refuse(migratedFalseMessage(key, def));
133
+ }
134
+
135
+ if (!def.scopes.includes(scope)) {
136
+ refuse(`"${key}" cannot be set in the ${scope} store (allowed: ${def.scopes.join(", ")})`);
137
+ }
138
+
139
+ if (opts.repoIdentity !== undefined && def.repoScoped !== true) {
140
+ refuse(`"${key}" is not repo-scoped — omit the repo identity`);
141
+ }
142
+
143
+ // machine scope is exempt from the path-literal guard (see module doc).
144
+ const guardedDef: SettingDef = scope === "machine" ? { ...def, pathGuardFields: undefined } : def;
145
+ const check = validateValue(guardedDef, value);
146
+ if (!check.ok) {
147
+ refuse(`refusing to set "${key}": ${check.reason} — use \${team:<name>} or \${repoRoot} instead`);
148
+ }
149
+
150
+ const storePath = resolveStorePath(scope, opts);
151
+ const jsonPath: JSONPath = opts.repoIdentity !== undefined ? ["repos", opts.repoIdentity, key] : [key];
152
+
153
+ writeIntoStore(storePath, jsonPath, value, /* createIfMissing */ scope !== "team");
154
+
155
+ // All three stores are tracked repos with nothing auto-committing a write
156
+ // (H2, the snapshot daemon, is unbuilt) — every scope gets the reminder,
157
+ // not just team.
158
+ console.error(
159
+ `rt: wrote "${key}" to the local ${scope} store (${storePath}) — this is local only until you commit and push it.`,
160
+ );
161
+ }
162
+
163
+ function migratedFalseMessage(key: string, def: SettingDef): string {
164
+ const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : "";
165
+ return `"${key}" is not writable through the settings resolver yet${legacyPart}`;
166
+ }
167
+
168
+ /** Resolves which store file a write targets, applying the team-selection rule for `scope: "team"`. */
169
+ function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string {
170
+ if (scope === "user") return userSettingsPath();
171
+ if (scope === "machine") return machineSettingsPath();
172
+
173
+ if (opts.team !== undefined) {
174
+ const path = teamSettingsPath(opts.team);
175
+ if (!existsSync(path)) {
176
+ refuse(`team store for "${opts.team}" does not exist (${path}) — clone/seed it before writing to it`);
177
+ }
178
+ return path;
179
+ }
180
+
181
+ const teams = listTeams();
182
+ if (teams.length === 0) {
183
+ refuse(`no local team store found — clone a team under ~/.mattstack/teams/<name> or pass opts.team`);
184
+ }
185
+ if (teams.length > 1) {
186
+ refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
187
+ }
188
+ return teamSettingsPath(teams[0] as string);
189
+ }
190
+
191
+ /** `// header comment\n{}\n` — see module doc for why the object must be seeded before the first `modify`. */
192
+ function seedHeader(): string {
193
+ return `// rt settings — created by \`rt settings set\`. JSONC: comments and trailing commas are fine.\n{}\n`;
194
+ }
195
+
196
+ /**
197
+ * Refuses to edit a store whose on-disk text is not a single well-formed
198
+ * JSONC object. Two failure classes:
199
+ * - genuine parse errors (unbalanced braces, trailing garbage, etc.) —
200
+ * caught by jsonc-parser's own error collection, the same check
201
+ * `stores.ts#readStore` runs on the read side;
202
+ * - a document that PARSES but is unsafe to `modify`: a non-object root, or
203
+ * a duplicate key anywhere in the tree. `modify` edits the FIRST
204
+ * occurrence of a duplicate key by offset, while every reader (`parse`,
205
+ * `JSON.parse`) takes the LAST — so a naive edit-in-place would report
206
+ * success while the effective value never changes (verified with a
207
+ * throwaway script: `modify` touched offset 14 in `{"rt.hooks":1,"rt.hooks":2}`,
208
+ * but re-parsing the "fixed" text still returned `2`). Both classes refuse
209
+ * rather than silently editing around the damage — the alternative is a
210
+ * write that reports success but does nothing, or one that writes a still-
211
+ * broken file that the NEXT read honest-degrades to an empty store.
212
+ */
213
+ function assertEditableJsonc(file: string, content: string): void {
214
+ const errors: ParseError[] = [];
215
+ const tree = parseTree(content, errors, { allowTrailingComma: true });
216
+
217
+ const malformed =
218
+ errors.length > 0 || tree === undefined || tree.type !== "object" || findDuplicateKey(tree) !== undefined;
219
+
220
+ if (malformed) {
221
+ refuse(`fix the JSONC syntax error in ${file} first — refusing to edit a malformed store`);
222
+ }
223
+ }
224
+
225
+ /** Depth-first search for the first duplicate property name in any object in the tree. */
226
+ function findDuplicateKey(node: Node): string | undefined {
227
+ if (node.type === "object" && node.children) {
228
+ const seen = new Set<string>();
229
+ for (const property of node.children) {
230
+ const keyNode = property.children?.[0];
231
+ if (keyNode !== undefined && typeof keyNode.value === "string") {
232
+ if (seen.has(keyNode.value)) return keyNode.value;
233
+ seen.add(keyNode.value);
234
+ }
235
+ const valueNode = property.children?.[1];
236
+ if (valueNode !== undefined) {
237
+ const nested = findDuplicateKey(valueNode);
238
+ if (nested !== undefined) return nested;
239
+ }
240
+ }
241
+ return undefined;
242
+ }
243
+ if (node.type === "array" && node.children) {
244
+ for (const child of node.children) {
245
+ const nested = findDuplicateKey(child);
246
+ if (nested !== undefined) return nested;
247
+ }
248
+ }
249
+ return undefined;
250
+ }
251
+
252
+ function writeIntoStore(storePath: string, jsonPath: JSONPath, value: unknown, createIfMissing: boolean): void {
253
+ let content: string;
254
+ if (existsSync(storePath)) {
255
+ content = readFileSync(storePath, "utf8");
256
+ if (content.trim() === "") {
257
+ content = seedHeader();
258
+ } else {
259
+ assertEditableJsonc(storePath, content);
260
+ }
261
+ } else {
262
+ if (!createIfMissing) {
263
+ // Unreachable via setSetting today: resolveStorePath already refuses
264
+ // every "team" path that lacks a file before we get here. Kept as a
265
+ // defensive guard against a future caller of writeIntoStore directly.
266
+ refuse(`store file ${storePath} does not exist`);
267
+ }
268
+ mkdirSync(dirname(storePath), { recursive: true });
269
+ content = seedHeader();
270
+ }
271
+
272
+ const edits = modify(content, jsonPath, value, { formattingOptions: FORMAT });
273
+ const next = applyEdits(content, edits);
274
+ const finalText = next.endsWith("\n") ? next : `${next}\n`;
275
+
276
+ // Write-temp-then-rename in the same directory, mirroring
277
+ // lib/json-store.ts's writeJson — stores never tear. All three stores are
278
+ // tracked repos now, but nothing auto-commits a write (H2 is unbuilt), so
279
+ // a torn write would sit as a corrupt uncommitted file until a human
280
+ // noticed. The edited TEXT is written as-is, never round-tripped through
281
+ // JSON.stringify, so comments and formatting survive.
282
+ const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
283
+ try {
284
+ writeFileSync(tmp, finalText);
285
+ renameSync(tmp, storePath);
286
+ } catch (err) {
287
+ try {
288
+ unlinkSync(tmp);
289
+ } catch {
290
+ // tmp file never got created, or was already cleaned up — nothing to do
291
+ }
292
+ throw err;
293
+ }
294
+ }
package/src/transport.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * rt daemon transport: HTTP over a unix socket (`~/.rt/rt.sock`).
2
+ * rt daemon transport: HTTP over a unix socket (`~/.mattstack/rt/rt.sock`).
3
3
  *
4
4
  * POST http://localhost/<cmd> with a JSON payload, response envelope
5
5
  * `{ ok, data?, error? }`. Every call degrades to `{ ok: false, error }`
@@ -21,14 +21,29 @@ export interface RtClientOptions {
21
21
  wsUrl?: string;
22
22
  }
23
23
 
24
- export const DEFAULT_SOCK = join(homedir(), ".rt", "rt.sock");
24
+ // Duplicates the ~/.mattstack/rt layout: rt-client has no dependency on rt's
25
+ // lib/, so this literal cannot import rtDir(). repo-tools/lib/rt-paths.ts is
26
+ // the authority — change there first, mirror here (same convention as
27
+ // settings/paths.ts's call-time `home()`).
28
+ function defaultSock(): string {
29
+ return join(process.env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock");
30
+ }
31
+
32
+ /**
33
+ * Display-only: a module-load snapshot for callers that just want to show
34
+ * the default path (no consumer imports it today — checked). `rtCommand`
35
+ * itself never reads this constant; it calls `defaultSock()` fresh on every
36
+ * invocation so a test can repoint `process.env.HOME` at any time before
37
+ * calling, not only before this module first loads.
38
+ */
39
+ export const DEFAULT_SOCK = defaultSock();
25
40
 
26
41
  export async function rtCommand<T = unknown>(
27
42
  cmd: string,
28
43
  payload: Record<string, unknown>,
29
44
  opts: { sockPath?: string; timeoutMs?: number } = {},
30
45
  ): Promise<RtResponse<T>> {
31
- const sockPath = opts.sockPath ?? DEFAULT_SOCK;
46
+ const sockPath = opts.sockPath ?? defaultSock();
32
47
  try {
33
48
  const res = await fetch(`http://localhost/${cmd}`, {
34
49
  unix: sockPath,