@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.
- package/README.md +5 -2
- package/dist/client.d.ts +13 -1
- package/dist/commands.d.ts +156 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +1132 -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 +39 -1
- package/src/commands.ts +85 -1
- package/src/index.ts +29 -1
- 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,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
|
-
|
|
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 ??
|
|
46
|
+
const sockPath = opts.sockPath ?? defaultSock();
|
|
32
47
|
try {
|
|
33
48
|
const res = await fetch(`http://localhost/${cmd}`, {
|
|
34
49
|
unix: sockPath,
|