@mattstack/rt-client 0.2.0 → 0.4.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 +93 -2
- package/dist/commands.d.ts +318 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.js +1385 -9
- package/dist/repos.d.ts +9 -3
- package/dist/settings/exec.d.ts +26 -0
- package/dist/settings/identity.d.ts +80 -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 +161 -2
- package/src/commands.ts +155 -1
- package/src/index.ts +62 -1
- package/src/repos.ts +89 -14
- package/src/settings/exec.ts +67 -0
- package/src/settings/identity.ts +218 -0
- package/src/settings/paths.ts +80 -0
- package/src/settings/registry-defs.ts +476 -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
package/dist/index.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
1
4
|
// src/transport.ts
|
|
2
5
|
import { homedir } from "os";
|
|
3
6
|
import { join } from "path";
|
|
4
|
-
|
|
7
|
+
function defaultSock() {
|
|
8
|
+
return join(process.env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock");
|
|
9
|
+
}
|
|
10
|
+
var DEFAULT_SOCK = defaultSock();
|
|
5
11
|
async function rtCommand(cmd, payload, opts = {}) {
|
|
6
|
-
const sockPath = opts.sockPath ??
|
|
12
|
+
const sockPath = opts.sockPath ?? defaultSock();
|
|
7
13
|
try {
|
|
8
14
|
const res = await fetch(`http://localhost/${cmd}`, {
|
|
9
15
|
unix: sockPath,
|
|
@@ -36,12 +42,119 @@ function readMrsByBranch(repoName, branches, opts = {}) {
|
|
|
36
42
|
function resolveForgeToken(repoName, forge, opts = {}) {
|
|
37
43
|
return rtCommand("secrets:forge-token", { repoName, forge }, { sockPath: opts.sockPath, timeoutMs: 1e4 });
|
|
38
44
|
}
|
|
45
|
+
function listRuns(repo, opts = {}) {
|
|
46
|
+
const payload = {};
|
|
47
|
+
if (repo !== undefined)
|
|
48
|
+
payload.repo = repo;
|
|
49
|
+
return rtCommand("runs:list", payload, { sockPath: opts.sockPath, timeoutMs: 1e4 });
|
|
50
|
+
}
|
|
51
|
+
function getRun(runId, repo, opts = {}) {
|
|
52
|
+
const payload = { runId };
|
|
53
|
+
if (repo !== undefined)
|
|
54
|
+
payload.repo = repo;
|
|
55
|
+
return rtCommand("runs:get", payload, { sockPath: opts.sockPath, timeoutMs: 1e4 });
|
|
56
|
+
}
|
|
57
|
+
function abandonRun(runId, repo, reason, opts = {}) {
|
|
58
|
+
const payload = { runId };
|
|
59
|
+
if (repo !== undefined)
|
|
60
|
+
payload.repo = repo;
|
|
61
|
+
if (reason !== undefined)
|
|
62
|
+
payload.reason = reason;
|
|
63
|
+
return rtCommand("runs:abandon", payload, { sockPath: opts.sockPath, timeoutMs: 1e4 });
|
|
64
|
+
}
|
|
65
|
+
function chatJoin(a, o = {}) {
|
|
66
|
+
const payload = { room: a.room, handle: a.handle };
|
|
67
|
+
if (a.wakeOn !== undefined)
|
|
68
|
+
payload.wakeOn = a.wakeOn;
|
|
69
|
+
if (a.cwd !== undefined)
|
|
70
|
+
payload.cwd = a.cwd;
|
|
71
|
+
if (a.pane !== undefined)
|
|
72
|
+
payload.pane = a.pane;
|
|
73
|
+
return rtCommand("chat:join", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
74
|
+
}
|
|
75
|
+
function chatLeave(a, o = {}) {
|
|
76
|
+
return rtCommand("chat:leave", { room: a.room, handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
77
|
+
}
|
|
78
|
+
function chatPost(a, o = {}) {
|
|
79
|
+
return rtCommand("chat:post", { room: a.room, handle: a.handle, body: a.body }, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
80
|
+
}
|
|
81
|
+
function chatRead(a, o = {}) {
|
|
82
|
+
const payload = { handle: a.handle };
|
|
83
|
+
if (a.room !== undefined)
|
|
84
|
+
payload.room = a.room;
|
|
85
|
+
if (a.limit !== undefined)
|
|
86
|
+
payload.limit = a.limit;
|
|
87
|
+
if (a.sinceMs !== undefined)
|
|
88
|
+
payload.sinceMs = a.sinceMs;
|
|
89
|
+
return rtCommand("chat:read", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
90
|
+
}
|
|
91
|
+
function chatRooms(a, o = {}) {
|
|
92
|
+
return rtCommand("chat:rooms", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
93
|
+
}
|
|
94
|
+
function chatWho(a, o = {}) {
|
|
95
|
+
return rtCommand("chat:who", { room: a.room }, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
96
|
+
}
|
|
97
|
+
function chatMark(a, o = {}) {
|
|
98
|
+
const payload = { handle: a.handle };
|
|
99
|
+
if (a.room !== undefined)
|
|
100
|
+
payload.room = a.room;
|
|
101
|
+
return rtCommand("chat:mark", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
102
|
+
}
|
|
103
|
+
function chatMessages(a, o = {}) {
|
|
104
|
+
const payload = { room: a.room };
|
|
105
|
+
if (a.before !== undefined)
|
|
106
|
+
payload.before = a.before;
|
|
107
|
+
if (a.limit !== undefined)
|
|
108
|
+
payload.limit = a.limit;
|
|
109
|
+
return rtCommand("chat:messages", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
110
|
+
}
|
|
111
|
+
function chatArm(a, o = {}) {
|
|
112
|
+
const payload = { handle: a.handle };
|
|
113
|
+
if (a.room !== undefined)
|
|
114
|
+
payload.room = a.room;
|
|
115
|
+
return rtCommand("chat:arm", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
116
|
+
}
|
|
117
|
+
function chatTouch(a, o = {}) {
|
|
118
|
+
return rtCommand("chat:touch", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
119
|
+
}
|
|
120
|
+
function chatDisarm(a, o = {}) {
|
|
121
|
+
return rtCommand("chat:disarm", { handle: a.handle }, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
122
|
+
}
|
|
123
|
+
function chatUnreadWaking(a, o = {}) {
|
|
124
|
+
const payload = { handle: a.handle };
|
|
125
|
+
if (a.room !== undefined)
|
|
126
|
+
payload.room = a.room;
|
|
127
|
+
return rtCommand("chat:unread-waking", payload, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
128
|
+
}
|
|
129
|
+
function eventsHead(o = {}) {
|
|
130
|
+
return rtCommand("events:head", {}, { sockPath: o.sockPath, timeoutMs: 1e4 });
|
|
131
|
+
}
|
|
39
132
|
// src/commands.ts
|
|
40
133
|
var COMMAND_NAMES = [
|
|
41
134
|
"project-mrs:read",
|
|
42
135
|
"discussions:read",
|
|
43
136
|
"mr:by-branch",
|
|
44
|
-
"secrets:forge-token"
|
|
137
|
+
"secrets:forge-token",
|
|
138
|
+
"secrets:read",
|
|
139
|
+
"events:emit",
|
|
140
|
+
"events:wait",
|
|
141
|
+
"events:list",
|
|
142
|
+
"events:head",
|
|
143
|
+
"runs:list",
|
|
144
|
+
"runs:get",
|
|
145
|
+
"runs:abandon",
|
|
146
|
+
"chat:join",
|
|
147
|
+
"chat:leave",
|
|
148
|
+
"chat:post",
|
|
149
|
+
"chat:read",
|
|
150
|
+
"chat:rooms",
|
|
151
|
+
"chat:who",
|
|
152
|
+
"chat:mark",
|
|
153
|
+
"chat:messages",
|
|
154
|
+
"chat:arm",
|
|
155
|
+
"chat:touch",
|
|
156
|
+
"chat:disarm",
|
|
157
|
+
"chat:unread-waking"
|
|
45
158
|
];
|
|
46
159
|
// src/relay.ts
|
|
47
160
|
var DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
|
|
@@ -89,16 +202,48 @@ function subscribe(onEvent, opts = {}) {
|
|
|
89
202
|
// src/repos.ts
|
|
90
203
|
import { existsSync, readFileSync } from "fs";
|
|
91
204
|
import { homedir as homedir2 } from "os";
|
|
92
|
-
import { join as join2 } from "path";
|
|
205
|
+
import { dirname, join as join2 } from "path";
|
|
93
206
|
function defaultReposJsonPath() {
|
|
94
|
-
return join2(homedir2(), ".rt", "repos.json");
|
|
207
|
+
return join2(homedir2(), ".mattstack", "rt", "repos.json");
|
|
95
208
|
}
|
|
96
|
-
function
|
|
97
|
-
|
|
209
|
+
function loadBunSqliteDatabase() {
|
|
210
|
+
try {
|
|
211
|
+
return __require("bun:sqlite").Database;
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function repoNameFromStateDb(repoPath, dbPath) {
|
|
217
|
+
if (!existsSync(dbPath))
|
|
218
|
+
return null;
|
|
219
|
+
const DatabaseCtor = loadBunSqliteDatabase();
|
|
220
|
+
if (!DatabaseCtor)
|
|
221
|
+
return null;
|
|
98
222
|
try {
|
|
99
|
-
|
|
223
|
+
const db = new DatabaseCtor(dbPath, { readonly: true });
|
|
224
|
+
try {
|
|
225
|
+
const rows = db.query("SELECT k, v FROM kv WHERE ns = 'repo-index';").all();
|
|
226
|
+
for (const row of rows) {
|
|
227
|
+
try {
|
|
228
|
+
if (JSON.parse(row.v) === repoPath)
|
|
229
|
+
return row.k;
|
|
230
|
+
} catch {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
100
234
|
return null;
|
|
101
|
-
|
|
235
|
+
} finally {
|
|
236
|
+
db.close();
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function repoNameFromJson(repoPath, reposJsonPath) {
|
|
243
|
+
try {
|
|
244
|
+
if (!existsSync(reposJsonPath))
|
|
245
|
+
return null;
|
|
246
|
+
const raw = readFileSync(reposJsonPath, "utf8");
|
|
102
247
|
const index = JSON.parse(raw);
|
|
103
248
|
for (const [repoName, value] of Object.entries(index)) {
|
|
104
249
|
if (value === repoPath)
|
|
@@ -109,14 +254,1245 @@ function repoNameForPath(repoPath, reposJsonPath) {
|
|
|
109
254
|
return null;
|
|
110
255
|
}
|
|
111
256
|
}
|
|
257
|
+
function repoNameForPath(repoPath, reposJsonPath) {
|
|
258
|
+
const jsonPath = reposJsonPath ?? defaultReposJsonPath();
|
|
259
|
+
const dbPath = join2(dirname(jsonPath), "state.db");
|
|
260
|
+
const fromDb = repoNameFromStateDb(repoPath, dbPath);
|
|
261
|
+
if (fromDb !== null)
|
|
262
|
+
return fromDb;
|
|
263
|
+
return repoNameFromJson(repoPath, jsonPath);
|
|
264
|
+
}
|
|
265
|
+
// src/settings/resolve.ts
|
|
266
|
+
import { homedir as homedir4 } from "os";
|
|
267
|
+
import { join as join5 } from "path";
|
|
268
|
+
|
|
269
|
+
// src/settings/paths.ts
|
|
270
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
271
|
+
import { homedir as homedir3, hostname } from "os";
|
|
272
|
+
import { join as join3 } from "path";
|
|
273
|
+
function home() {
|
|
274
|
+
return process.env.HOME ?? homedir3();
|
|
275
|
+
}
|
|
276
|
+
function userSettingsPath() {
|
|
277
|
+
return join3(home(), ".mattstack", "user", "settings.user.jsonc");
|
|
278
|
+
}
|
|
279
|
+
function teamSettingsPath(team) {
|
|
280
|
+
return join3(teamsDir(), team, "mattstack", "settings.team.jsonc");
|
|
281
|
+
}
|
|
282
|
+
function machineSettingsPath() {
|
|
283
|
+
return join3(home(), ".mattstack", "user", "local", machineKey(), "settings.local.jsonc");
|
|
284
|
+
}
|
|
285
|
+
function teamsDir() {
|
|
286
|
+
return join3(home(), ".mattstack", "teams");
|
|
287
|
+
}
|
|
288
|
+
function machineKey() {
|
|
289
|
+
const override = join3(home(), ".mattstack", "machine-key");
|
|
290
|
+
try {
|
|
291
|
+
const v = readFileSync2(override, "utf8").trim();
|
|
292
|
+
if (isSafeMachineKeySegment(v))
|
|
293
|
+
return v;
|
|
294
|
+
} catch {}
|
|
295
|
+
const slug = hostname().toLowerCase().replace(/\.local$/, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
296
|
+
return slug || "default";
|
|
297
|
+
}
|
|
298
|
+
function isSafeMachineKeySegment(v) {
|
|
299
|
+
return v.length > 0 && v !== "." && v !== ".." && !v.includes("/") && !v.includes("\\");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/settings/registry-defs.ts
|
|
303
|
+
var ALL_SCOPES = ["user", "team", "machine"];
|
|
304
|
+
var REGISTRY = [
|
|
305
|
+
{
|
|
306
|
+
key: "rt.roles",
|
|
307
|
+
type: "object",
|
|
308
|
+
scopes: ALL_SCOPES,
|
|
309
|
+
merge: "deep",
|
|
310
|
+
repoScoped: true,
|
|
311
|
+
migrated: true,
|
|
312
|
+
pathGuardFields: ["hook"],
|
|
313
|
+
description: "Per-repo dev-role definitions: port pools, env passthrough, and the dev-server hook command."
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
key: "rt.intercepts",
|
|
317
|
+
type: "array",
|
|
318
|
+
scopes: ALL_SCOPES,
|
|
319
|
+
merge: "replace",
|
|
320
|
+
repoScoped: true,
|
|
321
|
+
migrated: true,
|
|
322
|
+
description: "Per-repo endpoint intercept rules consumed by rt intercept install."
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
key: "rt.worktrees",
|
|
326
|
+
type: "object",
|
|
327
|
+
scopes: ALL_SCOPES,
|
|
328
|
+
default: { onDeck: 0 },
|
|
329
|
+
merge: "deep",
|
|
330
|
+
repoScoped: true,
|
|
331
|
+
migrated: true,
|
|
332
|
+
description: "Per-repo worktree pool config (onDeck size, ready steps, name pool); root/branchFormat/ready computed-or-empty in the reader."
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
key: "rt.repoIdentityOverrides",
|
|
336
|
+
type: "object",
|
|
337
|
+
scopes: ["machine"],
|
|
338
|
+
merge: "replace",
|
|
339
|
+
migrated: true,
|
|
340
|
+
description: "Map of observed remote URL to pinned repo identity, for forks/multi-remote repos on this machine."
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
key: "rt.repoRoots",
|
|
344
|
+
type: "array",
|
|
345
|
+
scopes: ["machine"],
|
|
346
|
+
default: [],
|
|
347
|
+
merge: "replace",
|
|
348
|
+
migrated: true,
|
|
349
|
+
description: 'Directories rt scans for git repos (rt cd, run-outside-a-repo pickers). Entries may start with "~/" or use "${home}". One level deep, plus worktree-pool parent folders one level deeper.'
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
key: "rt.notifications",
|
|
353
|
+
type: "object",
|
|
354
|
+
scopes: ["user"],
|
|
355
|
+
merge: "deep",
|
|
356
|
+
migrated: true,
|
|
357
|
+
description: "Desktop notification preferences (which events notify, sound on/off)."
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
key: "rt.cron",
|
|
361
|
+
type: "object",
|
|
362
|
+
scopes: ["machine"],
|
|
363
|
+
merge: "deep",
|
|
364
|
+
migrated: true,
|
|
365
|
+
description: "Scheduled rt job definitions and their cron expressions. Restart the daemon to apply changes."
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
key: "rt.repoTracking",
|
|
369
|
+
type: "object",
|
|
370
|
+
scopes: ["machine"],
|
|
371
|
+
merge: "deep",
|
|
372
|
+
migrated: true,
|
|
373
|
+
description: "Which repos rt tracks for background sync and status polling."
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
key: "rt.runsPruneDays",
|
|
377
|
+
type: "number",
|
|
378
|
+
scopes: ["machine"],
|
|
379
|
+
default: 30,
|
|
380
|
+
merge: "replace",
|
|
381
|
+
migrated: true,
|
|
382
|
+
description: "Age floor in days for pruning finished pipeline run directories under ~/.mattstack/runs (default 30)."
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
key: "rt.runaway",
|
|
386
|
+
type: "object",
|
|
387
|
+
scopes: ["machine"],
|
|
388
|
+
merge: "deep",
|
|
389
|
+
migrated: true,
|
|
390
|
+
description: "Thresholds for the runaway-process guard that kills stuck dev servers. Restart the daemon to apply changes."
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
key: "rt.workspacePrefs",
|
|
394
|
+
type: "object",
|
|
395
|
+
scopes: ["machine"],
|
|
396
|
+
merge: "deep",
|
|
397
|
+
migrated: true,
|
|
398
|
+
description: "Per-machine editor/terminal preferences applied when opening a worktree."
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
key: "rt.homeSnapshot",
|
|
402
|
+
type: "object",
|
|
403
|
+
scopes: ["machine"],
|
|
404
|
+
default: { enabled: true, debounceSec: 20, pushDelaySec: 60, janitorThresholdHours: 6, janitorIntervalMin: 30 },
|
|
405
|
+
merge: "deep",
|
|
406
|
+
migrated: true,
|
|
407
|
+
description: "Home-repo snapshot daemon config: enabled, debounce/push delays, and the janitor threshold/interval for zones left dirty too long."
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
key: "rt.sync",
|
|
411
|
+
type: "object",
|
|
412
|
+
scopes: ALL_SCOPES,
|
|
413
|
+
merge: "deep",
|
|
414
|
+
repoScoped: true,
|
|
415
|
+
migrated: true,
|
|
416
|
+
description: "Branch sync behavior: fast-forward rules and stale-branch handling."
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
key: "rt.branchNaming",
|
|
420
|
+
type: "object",
|
|
421
|
+
scopes: ALL_SCOPES,
|
|
422
|
+
merge: "deep",
|
|
423
|
+
repoScoped: true,
|
|
424
|
+
migrated: true,
|
|
425
|
+
description: "Branch-naming templates, repoScoped. Read by the VS Code extension (extensions/vscode/rt-context), which lazily imports the legacy repos/<repo>/branch-naming.json into this key on first read; rt itself has no CLI-side reader."
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
key: "rt.variations",
|
|
429
|
+
type: "object",
|
|
430
|
+
scopes: ALL_SCOPES,
|
|
431
|
+
merge: "deep",
|
|
432
|
+
repoScoped: true,
|
|
433
|
+
migrated: true,
|
|
434
|
+
description: "Named parameter sets rt run can pick between for a command."
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
key: "rt.presets",
|
|
438
|
+
type: "object",
|
|
439
|
+
scopes: ALL_SCOPES,
|
|
440
|
+
merge: "deep",
|
|
441
|
+
repoScoped: true,
|
|
442
|
+
migrated: true,
|
|
443
|
+
description: "Saved argument presets for frequently repeated rt commands, keyed by name."
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
key: "rt.dopplerTemplate",
|
|
447
|
+
type: "array",
|
|
448
|
+
scopes: ALL_SCOPES,
|
|
449
|
+
merge: "replace",
|
|
450
|
+
repoScoped: true,
|
|
451
|
+
migrated: true,
|
|
452
|
+
description: "Template used to generate a repo's Doppler secrets config."
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
key: "rt.worktreeApp",
|
|
456
|
+
type: "object",
|
|
457
|
+
scopes: ["machine"],
|
|
458
|
+
merge: "deep",
|
|
459
|
+
migrated: true,
|
|
460
|
+
description: "Machine-local worktree feature toggle (enabled, killProcesses); ownership-latch port of ~/.mattstack/rt/worktrees.json, store wins per field. A distinct key from rt.worktrees (the per-repo pool config above) on purpose — same file family, unrelated shape and scope."
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
key: "rt.sdmEnrichment",
|
|
464
|
+
type: "object",
|
|
465
|
+
scopes: ["team"],
|
|
466
|
+
merge: "replace",
|
|
467
|
+
migrated: true,
|
|
468
|
+
description: "Team-declared StrongDM resource enrichment (resource name -> label/tier/db/reasonSuggestion); team-ONLY by design, enrichment names employer resources and must never be settable in a user or machine store. Ownership-latch port of ~/.mattstack/rt/sdm/enrichment.jsonc, store wins wholesale (a name-keyed map, not a field-bag)."
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
key: "rt.logRetentionDays",
|
|
472
|
+
type: "number",
|
|
473
|
+
scopes: ["machine", "user"],
|
|
474
|
+
default: 14,
|
|
475
|
+
merge: "replace",
|
|
476
|
+
migrated: true,
|
|
477
|
+
description: "Age floor in days for the log janitor pruning every surface's rotated log files under ~/.mattstack/rt/logs (default 14). A fresh key, not an ownership-latch port, so a default is fine here."
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
key: "rt.hooks",
|
|
481
|
+
type: "object",
|
|
482
|
+
scopes: ALL_SCOPES,
|
|
483
|
+
merge: "deep",
|
|
484
|
+
repoScoped: true,
|
|
485
|
+
migrated: true,
|
|
486
|
+
description: "Per-repo git hook enable/disable state ({enabled, hooks: {<hookName>: boolean}}); ownership-latch port of repos/<repo>/hooks.json, store wins per field once it owns the key — including per-hook-name entries inside the nested hooks map, each defaulting to enabled when absent. The installed git-hook shim still greps repos/<repo>/hooks.json with zero process spawns (a hook fires on every git operation); that file is now a DERIVED CACHE this key writes through, kept current by commands/hooks.ts's regenerateHooksCache at every write seam."
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
key: "mattstack.integrations",
|
|
490
|
+
type: "object",
|
|
491
|
+
scopes: ["team"],
|
|
492
|
+
merge: "deep",
|
|
493
|
+
description: "Team-wide external integration config (forge/slack/linear/switchboard) the installer provisions; client secrets never live here."
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
key: "mattstack.tracking",
|
|
497
|
+
type: "object",
|
|
498
|
+
scopes: ["team"],
|
|
499
|
+
merge: "deep",
|
|
500
|
+
description: 'Team-declared repo tracking intent, identity-keyed; the daemon layers it under machine-scoped rt.repoTracking, which wins per repo whenever it names that repo at all — including an explicit local {mode:"off"} entry, the way to opt a repo out of team-declared tracking.'
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
key: "mattstack.appPath",
|
|
504
|
+
type: "string",
|
|
505
|
+
scopes: ["machine"],
|
|
506
|
+
merge: "replace",
|
|
507
|
+
description: "Absolute path to the installed mattstack.app bundle, written by the app at launch so rt stops hardcoding ~/Applications."
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
key: "rt.integrations",
|
|
511
|
+
type: "object",
|
|
512
|
+
scopes: ["user"],
|
|
513
|
+
merge: "deep",
|
|
514
|
+
migrated: true,
|
|
515
|
+
description: "User-confirmed integration hosts (forgeHost, switchboardUrl), written only by an explicit `rt setup <id> connect --host` after that host validates a real credential. The one trusted source a credential is ever sent to — mattstack.integrations' team-declared host is shown to the user but never auto-used for a fetch."
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
key: "claude.marketplaces",
|
|
519
|
+
type: "array",
|
|
520
|
+
scopes: ["user", "team"],
|
|
521
|
+
merge: "replace",
|
|
522
|
+
description: "Claude Code plugin marketplaces to replay on restore, in add order."
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
key: "claude.plugins",
|
|
526
|
+
type: "array",
|
|
527
|
+
scopes: ["user", "team"],
|
|
528
|
+
merge: "replace",
|
|
529
|
+
description: "Claude Code plugins to replay on restore, in install order."
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
key: "deck.apps",
|
|
533
|
+
type: "object",
|
|
534
|
+
scopes: ["user"],
|
|
535
|
+
merge: "deep",
|
|
536
|
+
description: "Per-app deck publish state (published flag, publicFollowsOverride); password hashes and session secrets stay out of this store."
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
key: "deck.access",
|
|
540
|
+
type: "object",
|
|
541
|
+
scopes: ["user"],
|
|
542
|
+
merge: "deep",
|
|
543
|
+
description: "deck's access-control roster, migrated from access.json."
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
key: "deck.platform",
|
|
547
|
+
type: "object",
|
|
548
|
+
scopes: ["machine"],
|
|
549
|
+
merge: "deep",
|
|
550
|
+
description: "deck's platform-level machine config: public domain and legacy URL prefixes; Cloudflare secrets stay out of this store."
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
key: "board.gitlabHost",
|
|
554
|
+
type: "string",
|
|
555
|
+
scopes: ["team"],
|
|
556
|
+
merge: "replace",
|
|
557
|
+
description: "GitLab host the board polls for MRs, shared by the whole team."
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
key: "board.projects",
|
|
561
|
+
type: "array",
|
|
562
|
+
scopes: ["team"],
|
|
563
|
+
merge: "replace",
|
|
564
|
+
description: "GitLab projects the board tracks, shared by the whole team."
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
key: "board.members",
|
|
568
|
+
type: "array",
|
|
569
|
+
scopes: ["team"],
|
|
570
|
+
merge: "replace",
|
|
571
|
+
description: "The board's full member roster, including hidden-by-default entries."
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
key: "board.title",
|
|
575
|
+
type: "string",
|
|
576
|
+
scopes: ["team"],
|
|
577
|
+
merge: "replace",
|
|
578
|
+
description: "Display title shown in the board's UI."
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
key: "board.botUsernames",
|
|
582
|
+
type: "array",
|
|
583
|
+
scopes: ["team"],
|
|
584
|
+
merge: "replace",
|
|
585
|
+
description: "GitLab usernames the board treats as bots, excluded from human MR attribution."
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
key: "board.ticketPrefixes",
|
|
589
|
+
type: "array",
|
|
590
|
+
scopes: ["team"],
|
|
591
|
+
merge: "replace",
|
|
592
|
+
description: "Ticket key prefixes (e.g. RT, MAT) the board links out to Linear from an MR title."
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
key: "board.slack",
|
|
596
|
+
type: "object",
|
|
597
|
+
scopes: ["team"],
|
|
598
|
+
merge: "deep",
|
|
599
|
+
description: "The board's Slack posting config (app id, client id, channel, callback port); client secrets stay out of this store."
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
key: "board.doctorSkill",
|
|
603
|
+
type: "string",
|
|
604
|
+
scopes: ["team"],
|
|
605
|
+
merge: "replace",
|
|
606
|
+
description: "Default doctor skill for repairing a stuck MR; a repo's skills.jsonc doctor slot overrides it when present."
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
key: "board.triage.doctorSkill",
|
|
610
|
+
type: "string",
|
|
611
|
+
scopes: ["team"],
|
|
612
|
+
merge: "replace",
|
|
613
|
+
description: "Doctor skill the board's own API-tier triage sweep runs on your MRs; deliberately never resolved through a repo's skills.jsonc manifest. A sibling flat key of board.triage, not a field inside it — the board reader assembles the two independently."
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
key: "board.staleAfterDays",
|
|
617
|
+
type: "number",
|
|
618
|
+
scopes: ["user"],
|
|
619
|
+
merge: "replace",
|
|
620
|
+
description: "Days of MR inactivity before the board flags it stale, for this developer."
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
key: "board.workspaces",
|
|
624
|
+
type: "object",
|
|
625
|
+
scopes: ["user"],
|
|
626
|
+
merge: "deep",
|
|
627
|
+
description: "Herdr workspace names the board's review/respond/doctor panes launch into, per developer."
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
key: "board.defaultMember",
|
|
631
|
+
type: "string",
|
|
632
|
+
scopes: ["user"],
|
|
633
|
+
merge: "replace",
|
|
634
|
+
description: "Which board member identity this developer's local board runs as by default."
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
key: "board.hiddenMembers",
|
|
638
|
+
type: "array",
|
|
639
|
+
scopes: ["user"],
|
|
640
|
+
merge: "replace",
|
|
641
|
+
description: "Usernames this developer hides from the team roster's board.members list; overlays the team truth without editing it."
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
key: "board.triage",
|
|
645
|
+
type: "object",
|
|
646
|
+
scopes: ["user"],
|
|
647
|
+
merge: "deep",
|
|
648
|
+
description: "This developer's triage user-intent flags (which triage sweeps run automatically); a sibling flat key of board.triage.doctorSkill, not its container — the board reader assembles the two independently."
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
key: "board.claudeCommand",
|
|
652
|
+
type: "string",
|
|
653
|
+
scopes: ["machine"],
|
|
654
|
+
merge: "replace",
|
|
655
|
+
description: "Local command used to launch Claude Code for the board's review/respond/doctor panes."
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
key: "board.cwds",
|
|
659
|
+
type: "object",
|
|
660
|
+
scopes: ["machine"],
|
|
661
|
+
merge: "deep",
|
|
662
|
+
description: "Local working directories the board's review/respond/doctor panes launch from."
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
key: "board.rtRepos",
|
|
666
|
+
type: "array",
|
|
667
|
+
scopes: ["machine"],
|
|
668
|
+
merge: "replace",
|
|
669
|
+
description: "rt-registered repo names the board resolves MRs against on this machine."
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
key: "board.triageMaxConcurrent",
|
|
673
|
+
type: "number",
|
|
674
|
+
scopes: ["machine"],
|
|
675
|
+
merge: "replace",
|
|
676
|
+
description: "Max concurrent triage panes the board launches on this machine."
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
key: "board.switchboardUrl",
|
|
680
|
+
type: "string",
|
|
681
|
+
scopes: ["machine"],
|
|
682
|
+
merge: "replace",
|
|
683
|
+
description: "Local switchboard URL the board's POST /peer/join writer targets."
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
key: "gitq.workSlots",
|
|
687
|
+
type: "object",
|
|
688
|
+
scopes: ["machine"],
|
|
689
|
+
merge: "deep",
|
|
690
|
+
description: "gitq's local work-slot config: on-disk location and the max slot count."
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
key: "gitq.forges",
|
|
694
|
+
type: "object",
|
|
695
|
+
scopes: ["user"],
|
|
696
|
+
merge: "deep",
|
|
697
|
+
description: "gitq's host-keyed forge config, tokenEnv names only — never a live token."
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
key: "gitq.board",
|
|
701
|
+
type: "object",
|
|
702
|
+
scopes: ["machine"],
|
|
703
|
+
merge: "deep",
|
|
704
|
+
description: "gitq checkout-board config: tracked repos, local port, and the herdr workspace it launches into."
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
key: "chat.handle",
|
|
708
|
+
type: "string",
|
|
709
|
+
scopes: ["user"],
|
|
710
|
+
merge: "replace",
|
|
711
|
+
description: "Explicit rt chat handle for this developer on this machine; overrides the derived <repo>-<dir> handle when set."
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
key: "chat.humanHandle",
|
|
715
|
+
type: "string",
|
|
716
|
+
scopes: ["user"],
|
|
717
|
+
default: "matt",
|
|
718
|
+
merge: "replace",
|
|
719
|
+
description: "The human's own chat handle, so agents can @-mention them by name."
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
key: "chat.push.provider",
|
|
723
|
+
type: "string",
|
|
724
|
+
scopes: ["user"],
|
|
725
|
+
merge: "replace",
|
|
726
|
+
description: "Push notification provider used to alert the human of chat mentions when away from a terminal."
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
key: "chat.push.target",
|
|
730
|
+
type: "string",
|
|
731
|
+
scopes: ["user"],
|
|
732
|
+
merge: "replace",
|
|
733
|
+
description: "Destination (topic/URL/token) the configured chat.push.provider sends to."
|
|
734
|
+
}
|
|
735
|
+
];
|
|
736
|
+
|
|
737
|
+
// src/settings/registry-machinery.ts
|
|
738
|
+
var BY_KEY = new Map(REGISTRY.map((def) => [def.key, def]));
|
|
739
|
+
function getDef(key) {
|
|
740
|
+
return BY_KEY.get(key);
|
|
741
|
+
}
|
|
742
|
+
function allDefs() {
|
|
743
|
+
return [...REGISTRY];
|
|
744
|
+
}
|
|
745
|
+
function isMigrated(def) {
|
|
746
|
+
return def.migrated !== false;
|
|
747
|
+
}
|
|
748
|
+
var PATH_LIKE = /^[/~]/;
|
|
749
|
+
function typeOf(value) {
|
|
750
|
+
if (value === null)
|
|
751
|
+
return "null";
|
|
752
|
+
if (Array.isArray(value))
|
|
753
|
+
return "array";
|
|
754
|
+
return typeof value;
|
|
755
|
+
}
|
|
756
|
+
function validateValue(def, value) {
|
|
757
|
+
const typeCheck = checkType(def.type, value);
|
|
758
|
+
if (!typeCheck.ok)
|
|
759
|
+
return typeCheck;
|
|
760
|
+
if (def.pathGuardFields && def.pathGuardFields.length > 0) {
|
|
761
|
+
const violation = findPathGuardViolation(value, def.pathGuardFields);
|
|
762
|
+
if (violation) {
|
|
763
|
+
return {
|
|
764
|
+
ok: false,
|
|
765
|
+
reason: `field "${violation.field}" looks like a path literal ("${violation.value}"); path literals are only legal in the machine store`
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return { ok: true };
|
|
770
|
+
}
|
|
771
|
+
function checkType(type, value) {
|
|
772
|
+
switch (type) {
|
|
773
|
+
case "string":
|
|
774
|
+
return typeof value === "string" ? { ok: true } : { ok: false, reason: `expected string, got ${typeOf(value)}` };
|
|
775
|
+
case "number":
|
|
776
|
+
return typeof value === "number" ? { ok: true } : { ok: false, reason: `expected number, got ${typeOf(value)}` };
|
|
777
|
+
case "boolean":
|
|
778
|
+
return typeof value === "boolean" ? { ok: true } : { ok: false, reason: `expected boolean, got ${typeOf(value)}` };
|
|
779
|
+
case "array":
|
|
780
|
+
return Array.isArray(value) ? { ok: true } : { ok: false, reason: `expected array, got ${typeOf(value)}` };
|
|
781
|
+
case "object":
|
|
782
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? { ok: true } : { ok: false, reason: `expected object, got ${typeOf(value)}` };
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function findPathGuardViolation(value, guardFields) {
|
|
786
|
+
if (Array.isArray(value)) {
|
|
787
|
+
for (const item of value) {
|
|
788
|
+
const hit = findPathGuardViolation(item, guardFields);
|
|
789
|
+
if (hit)
|
|
790
|
+
return hit;
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
if (value !== null && typeof value === "object") {
|
|
795
|
+
for (const [field, fieldValue] of Object.entries(value)) {
|
|
796
|
+
if (guardFields.includes(field) && typeof fieldValue === "string" && PATH_LIKE.test(fieldValue)) {
|
|
797
|
+
return { field, value: fieldValue };
|
|
798
|
+
}
|
|
799
|
+
const hit = findPathGuardViolation(fieldValue, guardFields);
|
|
800
|
+
if (hit)
|
|
801
|
+
return hit;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/settings/stores.ts
|
|
808
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
|
|
809
|
+
import { parse } from "jsonc-parser";
|
|
810
|
+
import { join as join4 } from "path";
|
|
811
|
+
var EMPTY_STORE = (file, exists) => ({
|
|
812
|
+
global: {},
|
|
813
|
+
repos: {},
|
|
814
|
+
file,
|
|
815
|
+
exists
|
|
816
|
+
});
|
|
817
|
+
function readStore(file) {
|
|
818
|
+
if (!existsSync2(file))
|
|
819
|
+
return EMPTY_STORE(file, false);
|
|
820
|
+
let raw;
|
|
821
|
+
try {
|
|
822
|
+
raw = readFileSync3(file, "utf8");
|
|
823
|
+
} catch (err) {
|
|
824
|
+
console.warn(`rt: failed to read settings store ${file}, ignoring: ${err.message}`);
|
|
825
|
+
return EMPTY_STORE(file, true);
|
|
826
|
+
}
|
|
827
|
+
if (raw.trim() === "")
|
|
828
|
+
return EMPTY_STORE(file, true);
|
|
829
|
+
const errors = [];
|
|
830
|
+
const root = parse(raw, errors, { allowTrailingComma: true });
|
|
831
|
+
if (errors.length > 0 || root === undefined || typeof root !== "object" || Array.isArray(root)) {
|
|
832
|
+
console.warn(`rt: malformed settings store ${file}, ignoring (treating as empty)`);
|
|
833
|
+
return EMPTY_STORE(file, true);
|
|
834
|
+
}
|
|
835
|
+
const { repos, ...global } = root;
|
|
836
|
+
const reposIsValid = repos !== undefined && typeof repos === "object" && repos !== null && !Array.isArray(repos);
|
|
837
|
+
if (repos !== undefined && !reposIsValid) {
|
|
838
|
+
console.warn(`rt: malformed "repos" section in settings store ${file}, ignoring repo sections (global keys still apply)`);
|
|
839
|
+
}
|
|
840
|
+
const reposValid = reposIsValid ? repos : {};
|
|
841
|
+
return { global, repos: reposValid, file, exists: true };
|
|
842
|
+
}
|
|
843
|
+
function listTeams() {
|
|
844
|
+
const dir = teamsDir();
|
|
845
|
+
if (!existsSync2(dir))
|
|
846
|
+
return [];
|
|
847
|
+
let entries;
|
|
848
|
+
try {
|
|
849
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
850
|
+
} catch (err) {
|
|
851
|
+
console.warn(`rt: failed to list teams in ${dir}, treating as no teams: ${err.message}`);
|
|
852
|
+
return [];
|
|
853
|
+
}
|
|
854
|
+
const teams = [];
|
|
855
|
+
for (const entry of entries) {
|
|
856
|
+
try {
|
|
857
|
+
const isDir = entry.isDirectory() || entry.isSymbolicLink() && statSync(join4(dir, entry.name)).isDirectory();
|
|
858
|
+
if (!isDir)
|
|
859
|
+
continue;
|
|
860
|
+
if (existsSync2(teamSettingsPath(entry.name)))
|
|
861
|
+
teams.push(entry.name);
|
|
862
|
+
} catch (err) {
|
|
863
|
+
console.warn(`rt: skipping unreadable teams entry ${join4(dir, entry.name)}: ${err.message}`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return teams;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/settings/resolve.ts
|
|
870
|
+
var SCOPE_ORDER = [
|
|
871
|
+
"default",
|
|
872
|
+
"team",
|
|
873
|
+
"user",
|
|
874
|
+
"team.repo",
|
|
875
|
+
"user.repo",
|
|
876
|
+
"machine",
|
|
877
|
+
"machine.repo"
|
|
878
|
+
];
|
|
879
|
+
var VAR_RE = /\$\{([^}]*)\}/g;
|
|
880
|
+
var TEAM_VAR_RE = /^team:(.+)$/;
|
|
881
|
+
function expandVariables(value, ctx) {
|
|
882
|
+
if (typeof value === "string")
|
|
883
|
+
return expandString(value, ctx);
|
|
884
|
+
if (Array.isArray(value))
|
|
885
|
+
return value.map((item) => expandVariables(item, ctx));
|
|
886
|
+
if (isPlainObject(value)) {
|
|
887
|
+
const out = {};
|
|
888
|
+
for (const [k, v] of Object.entries(value))
|
|
889
|
+
out[k] = expandVariables(v, ctx);
|
|
890
|
+
return out;
|
|
891
|
+
}
|
|
892
|
+
return value;
|
|
893
|
+
}
|
|
894
|
+
function expandString(input, ctx) {
|
|
895
|
+
return input.replace(VAR_RE, (match, name) => {
|
|
896
|
+
if (name === "home")
|
|
897
|
+
return ctx.home;
|
|
898
|
+
if (name === "repoRoot")
|
|
899
|
+
return required(ctx.repoRoot, "repoRoot", "a repo path");
|
|
900
|
+
if (name === "worktree")
|
|
901
|
+
return required(ctx.worktree, "worktree", "a worktree path");
|
|
902
|
+
const team = TEAM_VAR_RE.exec(name);
|
|
903
|
+
if (team)
|
|
904
|
+
return teamPath(ctx.teamsDir, team[1]);
|
|
905
|
+
return match;
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
function teamPath(teamsDir2, name) {
|
|
909
|
+
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
|
|
910
|
+
throw new Error(`rt: cannot expand \${team:${name}} — a team name must be a single directory segment (no "/", "\\" or "..")`);
|
|
911
|
+
}
|
|
912
|
+
return join5(teamsDir2, name);
|
|
913
|
+
}
|
|
914
|
+
function required(value, name, needs) {
|
|
915
|
+
if (value === undefined || value === "") {
|
|
916
|
+
throw new Error(`rt: cannot expand \${${name}} — this setting was resolved without ${needs}`);
|
|
917
|
+
}
|
|
918
|
+
return value;
|
|
919
|
+
}
|
|
920
|
+
function readStores() {
|
|
921
|
+
return {
|
|
922
|
+
user: readStore(userSettingsPath()),
|
|
923
|
+
machine: readStore(machineSettingsPath()),
|
|
924
|
+
teams: [...listTeams()].sort().map((team) => readStore(teamSettingsPath(team)))
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
function collectSlots(def, stores, opts) {
|
|
928
|
+
const slots = [];
|
|
929
|
+
const identity = opts.repoIdentity ?? null;
|
|
930
|
+
const useRepo = def.repoScoped === true && typeof identity === "string" && identity !== "";
|
|
931
|
+
const repoSection = (store) => useRepo ? store.repos[identity] : undefined;
|
|
932
|
+
const push = (scope, file, section) => {
|
|
933
|
+
const value = section?.[def.key];
|
|
934
|
+
if (value === undefined)
|
|
935
|
+
slots.push({ scope, file, present: false });
|
|
936
|
+
else
|
|
937
|
+
slots.push({ scope, file, present: true, value });
|
|
938
|
+
};
|
|
939
|
+
const pushTeams = (scope, section) => {
|
|
940
|
+
if (stores.teams.length === 0) {
|
|
941
|
+
slots.push({ scope, file: null, present: false });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
for (const store of stores.teams)
|
|
945
|
+
push(scope, store.file, section(store));
|
|
946
|
+
};
|
|
947
|
+
slots.push(def.default === undefined ? { scope: "default", file: null, present: false } : { scope: "default", file: null, present: true, value: structuredClone(def.default) });
|
|
948
|
+
pushTeams("team", (store) => store.global);
|
|
949
|
+
push("user", stores.user.file, stores.user.global);
|
|
950
|
+
if (useRepo)
|
|
951
|
+
pushTeams("team.repo", repoSection);
|
|
952
|
+
if (useRepo)
|
|
953
|
+
push("user.repo", stores.user.file, repoSection(stores.user));
|
|
954
|
+
push("machine", stores.machine.file, stores.machine.global);
|
|
955
|
+
if (useRepo)
|
|
956
|
+
push("machine.repo", stores.machine.file, repoSection(stores.machine));
|
|
957
|
+
return slots;
|
|
958
|
+
}
|
|
959
|
+
var TEAM_LOCKED_SCOPES = ["default", "team", "team.repo"];
|
|
960
|
+
function baseScope(scope) {
|
|
961
|
+
if (scope === "team" || scope === "team.repo")
|
|
962
|
+
return "team";
|
|
963
|
+
if (scope === "user" || scope === "user.repo")
|
|
964
|
+
return "user";
|
|
965
|
+
if (scope === "machine" || scope === "machine.repo")
|
|
966
|
+
return "machine";
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
function validateForScope(def, scope, value) {
|
|
970
|
+
const shared = scope === "team" || scope === "user" || scope === "team.repo" || scope === "user.repo";
|
|
971
|
+
return validateValue(shared ? def : { ...def, pathGuardFields: undefined }, value);
|
|
972
|
+
}
|
|
973
|
+
function resolveDef(def, stores, opts) {
|
|
974
|
+
const slots = collectSlots(def, stores, opts);
|
|
975
|
+
const rows = [];
|
|
976
|
+
const invalid = [];
|
|
977
|
+
const applied = [];
|
|
978
|
+
for (const slot of slots) {
|
|
979
|
+
const row = { scope: slot.scope, file: slot.file, present: slot.present };
|
|
980
|
+
if (!slot.present) {
|
|
981
|
+
rows.push(row);
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
row.value = slot.value;
|
|
985
|
+
if (def.teamLocked && !TEAM_LOCKED_SCOPES.includes(slot.scope)) {
|
|
986
|
+
row.shadowed = "teamLocked";
|
|
987
|
+
rows.push(row);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
const base = baseScope(slot.scope);
|
|
991
|
+
if (base !== null && !def.scopes.includes(base)) {
|
|
992
|
+
const reason = `not settable in the ${base} store (allowed: ${def.scopes.join(", ")})`;
|
|
993
|
+
row.invalid = reason;
|
|
994
|
+
invalid.push({ scope: slot.scope, file: slot.file, reason });
|
|
995
|
+
rows.push(row);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if (slot.scope !== "default") {
|
|
999
|
+
const check = validateForScope(def, slot.scope, slot.value);
|
|
1000
|
+
if (!check.ok) {
|
|
1001
|
+
row.invalid = check.reason;
|
|
1002
|
+
invalid.push({ scope: slot.scope, file: slot.file, reason: check.reason });
|
|
1003
|
+
rows.push(row);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
rows.push(row);
|
|
1008
|
+
applied.push({ scope: slot.scope, file: slot.file, value: slot.value });
|
|
1009
|
+
}
|
|
1010
|
+
const merged = mergeApplied(def, applied);
|
|
1011
|
+
return { value: merged.value, provenance: merged.provenance, invalid, rows };
|
|
1012
|
+
}
|
|
1013
|
+
function mergeApplied(def, applied) {
|
|
1014
|
+
if (applied.length === 0)
|
|
1015
|
+
return { value: undefined, provenance: [] };
|
|
1016
|
+
if (def.merge === "deep" && def.type === "object") {
|
|
1017
|
+
const objectLayers = applied.filter((layer) => isPlainObject(layer.value));
|
|
1018
|
+
if (objectLayers.length > 0) {
|
|
1019
|
+
const { value, contributors } = deepMerge(objectLayers.map((layer) => layer.value));
|
|
1020
|
+
return {
|
|
1021
|
+
value,
|
|
1022
|
+
provenance: contributors.map((i) => {
|
|
1023
|
+
const layer = objectLayers[i];
|
|
1024
|
+
return { scope: layer.scope, file: layer.file };
|
|
1025
|
+
})
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const winner = applied[applied.length - 1];
|
|
1030
|
+
return { value: winner.value, provenance: [{ scope: winner.scope, file: winner.file }] };
|
|
1031
|
+
}
|
|
1032
|
+
var PATH_SEP = "\x00";
|
|
1033
|
+
function deepMerge(layers) {
|
|
1034
|
+
const owner = new Map;
|
|
1035
|
+
let acc = {};
|
|
1036
|
+
layers.forEach((layer, index) => {
|
|
1037
|
+
acc = overlay(acc, layer, owner, index, "");
|
|
1038
|
+
});
|
|
1039
|
+
const contributors = [...new Set(owner.values())].sort((a, b) => a - b);
|
|
1040
|
+
return { value: acc, contributors };
|
|
1041
|
+
}
|
|
1042
|
+
function overlay(base, over, owner, index, prefix) {
|
|
1043
|
+
const out = { ...base };
|
|
1044
|
+
for (const [key, value] of Object.entries(over)) {
|
|
1045
|
+
const path = prefix === "" ? key : `${prefix}${PATH_SEP}${key}`;
|
|
1046
|
+
const current = out[key];
|
|
1047
|
+
if (isPlainObject(value) && isPlainObject(current)) {
|
|
1048
|
+
out[key] = overlay(current, value, owner, index, path);
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
out[key] = value;
|
|
1052
|
+
clearOwners(owner, path);
|
|
1053
|
+
registerLeaves(value, path, owner, index);
|
|
1054
|
+
}
|
|
1055
|
+
return out;
|
|
1056
|
+
}
|
|
1057
|
+
function clearOwners(owner, path) {
|
|
1058
|
+
owner.delete(path);
|
|
1059
|
+
const under = `${path}${PATH_SEP}`;
|
|
1060
|
+
for (const existing of [...owner.keys()]) {
|
|
1061
|
+
if (existing.startsWith(under))
|
|
1062
|
+
owner.delete(existing);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
function registerLeaves(value, path, owner, index) {
|
|
1066
|
+
if (isPlainObject(value)) {
|
|
1067
|
+
const entries = Object.entries(value);
|
|
1068
|
+
if (entries.length > 0) {
|
|
1069
|
+
for (const [key, child] of entries) {
|
|
1070
|
+
registerLeaves(child, `${path}${PATH_SEP}${key}`, owner, index);
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
owner.set(path, index);
|
|
1076
|
+
}
|
|
1077
|
+
function isPlainObject(value) {
|
|
1078
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1079
|
+
}
|
|
1080
|
+
function unknownKey(key) {
|
|
1081
|
+
return new Error(`rt: unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
|
|
1082
|
+
}
|
|
1083
|
+
function expandCtxFrom(opts) {
|
|
1084
|
+
return {
|
|
1085
|
+
repoRoot: opts.expandCtx?.repoRoot,
|
|
1086
|
+
worktree: opts.expandCtx?.worktree,
|
|
1087
|
+
home: process.env.HOME ?? homedir4(),
|
|
1088
|
+
teamsDir: teamsDir()
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function warnInvalid(key, entry) {
|
|
1092
|
+
console.warn(`rt: ignoring "${key}" from the ${entry.scope} scope (${entry.file ?? "no file"}): ${entry.reason}`);
|
|
1093
|
+
}
|
|
1094
|
+
function getSetting(key, opts = {}) {
|
|
1095
|
+
const def = getDef(key);
|
|
1096
|
+
if (!def)
|
|
1097
|
+
throw unknownKey(key);
|
|
1098
|
+
const resolution = resolveDef(def, readStores(), opts);
|
|
1099
|
+
for (const entry of resolution.invalid)
|
|
1100
|
+
warnInvalid(key, entry);
|
|
1101
|
+
const shouldExpand = opts.expand ?? true;
|
|
1102
|
+
const value = shouldExpand && resolution.value !== undefined ? expandVariables(resolution.value, expandCtxFrom(opts)) : resolution.value;
|
|
1103
|
+
return { value, provenance: resolution.provenance };
|
|
1104
|
+
}
|
|
1105
|
+
function listSettings(opts = {}) {
|
|
1106
|
+
const stores = readStores();
|
|
1107
|
+
const ctx = expandCtxFrom(opts);
|
|
1108
|
+
const shouldExpand = opts.expand ?? true;
|
|
1109
|
+
const out = [];
|
|
1110
|
+
for (const def of allDefs()) {
|
|
1111
|
+
const resolution = resolveDef(def, stores, opts);
|
|
1112
|
+
for (const entry of resolution.invalid)
|
|
1113
|
+
warnInvalid(def.key, entry);
|
|
1114
|
+
const listed = {
|
|
1115
|
+
key: def.key,
|
|
1116
|
+
value: resolution.value,
|
|
1117
|
+
provenance: resolution.provenance,
|
|
1118
|
+
migrated: isMigrated(def)
|
|
1119
|
+
};
|
|
1120
|
+
if (resolution.invalid.length > 0)
|
|
1121
|
+
listed.invalid = resolution.invalid;
|
|
1122
|
+
if (shouldExpand && resolution.value !== undefined) {
|
|
1123
|
+
try {
|
|
1124
|
+
listed.value = expandVariables(resolution.value, ctx);
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
listed.expandError = err.message;
|
|
1127
|
+
console.warn(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
out.push(listed);
|
|
1131
|
+
}
|
|
1132
|
+
out.push(...listUnregistered(stores, opts));
|
|
1133
|
+
return out;
|
|
1134
|
+
}
|
|
1135
|
+
function listUnregistered(stores, opts) {
|
|
1136
|
+
const identity = opts.repoIdentity ?? null;
|
|
1137
|
+
const found = new Map;
|
|
1138
|
+
const scan = (scope, file, section) => {
|
|
1139
|
+
for (const [key, value] of Object.entries(section ?? {})) {
|
|
1140
|
+
if (getDef(key))
|
|
1141
|
+
continue;
|
|
1142
|
+
found.set(key, { scope, file, value });
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
const repoSection = (store) => typeof identity === "string" && identity !== "" ? store.repos[identity] : undefined;
|
|
1146
|
+
for (const store of stores.teams)
|
|
1147
|
+
scan("team", store.file, store.global);
|
|
1148
|
+
scan("user", stores.user.file, stores.user.global);
|
|
1149
|
+
for (const store of stores.teams)
|
|
1150
|
+
scan("team.repo", store.file, repoSection(store));
|
|
1151
|
+
scan("user.repo", stores.user.file, repoSection(stores.user));
|
|
1152
|
+
scan("machine", stores.machine.file, stores.machine.global);
|
|
1153
|
+
scan("machine.repo", stores.machine.file, repoSection(stores.machine));
|
|
1154
|
+
return [...found.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, hit]) => {
|
|
1155
|
+
console.warn(`rt: unregistered setting "${key}" in ${hit.file} — ignoring it (this rt may be older than the store)`);
|
|
1156
|
+
return {
|
|
1157
|
+
key,
|
|
1158
|
+
value: hit.value,
|
|
1159
|
+
provenance: [{ scope: hit.scope, file: hit.file }],
|
|
1160
|
+
migrated: false,
|
|
1161
|
+
unregistered: true
|
|
1162
|
+
};
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
function explainSetting(key, opts = {}) {
|
|
1166
|
+
const def = getDef(key);
|
|
1167
|
+
if (!def)
|
|
1168
|
+
throw unknownKey(key);
|
|
1169
|
+
return resolveDef(def, readStores(), opts).rows;
|
|
1170
|
+
}
|
|
1171
|
+
// src/settings/write.ts
|
|
1172
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync4, renameSync, unlinkSync, writeFileSync } from "fs";
|
|
1173
|
+
import { applyEdits, modify, parseTree } from "jsonc-parser";
|
|
1174
|
+
import { randomBytes } from "crypto";
|
|
1175
|
+
import { dirname as dirname2 } from "path";
|
|
1176
|
+
var FORMAT = { tabSize: 2, insertSpaces: true, eol: `
|
|
1177
|
+
` };
|
|
1178
|
+
function refuse(message) {
|
|
1179
|
+
throw new Error(`rt: ${message}`);
|
|
1180
|
+
}
|
|
1181
|
+
function setSetting(key, value, scope, opts = {}) {
|
|
1182
|
+
const def = getDef(key);
|
|
1183
|
+
if (!def) {
|
|
1184
|
+
refuse(`unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
|
|
1185
|
+
}
|
|
1186
|
+
if (!isMigrated(def)) {
|
|
1187
|
+
refuse(migratedFalseMessage(key, def));
|
|
1188
|
+
}
|
|
1189
|
+
if (!def.scopes.includes(scope)) {
|
|
1190
|
+
refuse(`"${key}" cannot be set in the ${scope} store (allowed: ${def.scopes.join(", ")})`);
|
|
1191
|
+
}
|
|
1192
|
+
if (opts.repoIdentity !== undefined && def.repoScoped !== true) {
|
|
1193
|
+
refuse(`"${key}" is not repo-scoped — omit the repo identity`);
|
|
1194
|
+
}
|
|
1195
|
+
const guardedDef = scope === "machine" ? { ...def, pathGuardFields: undefined } : def;
|
|
1196
|
+
const check = validateValue(guardedDef, value);
|
|
1197
|
+
if (!check.ok) {
|
|
1198
|
+
refuse(`refusing to set "${key}": ${check.reason} — use \${team:<name>} or \${repoRoot} instead`);
|
|
1199
|
+
}
|
|
1200
|
+
const storePath = resolveStorePath(scope, opts);
|
|
1201
|
+
const jsonPath = opts.repoIdentity !== undefined ? ["repos", opts.repoIdentity, key] : [key];
|
|
1202
|
+
writeIntoStore(storePath, jsonPath, value, scope !== "team");
|
|
1203
|
+
console.error(`rt: wrote "${key}" to the local ${scope} store (${storePath}) — this is local only until you commit and push it.`);
|
|
1204
|
+
}
|
|
1205
|
+
function migratedFalseMessage(key, def) {
|
|
1206
|
+
const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : "";
|
|
1207
|
+
return `"${key}" is not writable through the settings resolver yet${legacyPart}`;
|
|
1208
|
+
}
|
|
1209
|
+
function resolveStorePath(scope, opts) {
|
|
1210
|
+
if (scope === "user")
|
|
1211
|
+
return userSettingsPath();
|
|
1212
|
+
if (scope === "machine")
|
|
1213
|
+
return machineSettingsPath();
|
|
1214
|
+
if (opts.team !== undefined) {
|
|
1215
|
+
const path = teamSettingsPath(opts.team);
|
|
1216
|
+
if (!existsSync3(path)) {
|
|
1217
|
+
refuse(`team store for "${opts.team}" does not exist (${path}) — clone/seed it before writing to it`);
|
|
1218
|
+
}
|
|
1219
|
+
return path;
|
|
1220
|
+
}
|
|
1221
|
+
const teams = listTeams();
|
|
1222
|
+
if (teams.length === 0) {
|
|
1223
|
+
refuse(`no local team store found — clone a team under ~/.mattstack/teams/<name> or pass opts.team`);
|
|
1224
|
+
}
|
|
1225
|
+
if (teams.length > 1) {
|
|
1226
|
+
refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
|
|
1227
|
+
}
|
|
1228
|
+
return teamSettingsPath(teams[0]);
|
|
1229
|
+
}
|
|
1230
|
+
function seedHeader() {
|
|
1231
|
+
return `// rt settings — created by \`rt settings set\`. JSONC: comments and trailing commas are fine.
|
|
1232
|
+
{}
|
|
1233
|
+
`;
|
|
1234
|
+
}
|
|
1235
|
+
function assertEditableJsonc(file, content) {
|
|
1236
|
+
const errors = [];
|
|
1237
|
+
const tree = parseTree(content, errors, { allowTrailingComma: true });
|
|
1238
|
+
const malformed = errors.length > 0 || tree === undefined || tree.type !== "object" || findDuplicateKey(tree) !== undefined;
|
|
1239
|
+
if (malformed) {
|
|
1240
|
+
refuse(`fix the JSONC syntax error in ${file} first — refusing to edit a malformed store`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
function findDuplicateKey(node) {
|
|
1244
|
+
if (node.type === "object" && node.children) {
|
|
1245
|
+
const seen = new Set;
|
|
1246
|
+
for (const property of node.children) {
|
|
1247
|
+
const keyNode = property.children?.[0];
|
|
1248
|
+
if (keyNode !== undefined && typeof keyNode.value === "string") {
|
|
1249
|
+
if (seen.has(keyNode.value))
|
|
1250
|
+
return keyNode.value;
|
|
1251
|
+
seen.add(keyNode.value);
|
|
1252
|
+
}
|
|
1253
|
+
const valueNode = property.children?.[1];
|
|
1254
|
+
if (valueNode !== undefined) {
|
|
1255
|
+
const nested = findDuplicateKey(valueNode);
|
|
1256
|
+
if (nested !== undefined)
|
|
1257
|
+
return nested;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (node.type === "array" && node.children) {
|
|
1263
|
+
for (const child of node.children) {
|
|
1264
|
+
const nested = findDuplicateKey(child);
|
|
1265
|
+
if (nested !== undefined)
|
|
1266
|
+
return nested;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
function writeIntoStore(storePath, jsonPath, value, createIfMissing) {
|
|
1272
|
+
let content;
|
|
1273
|
+
if (existsSync3(storePath)) {
|
|
1274
|
+
content = readFileSync4(storePath, "utf8");
|
|
1275
|
+
if (content.trim() === "") {
|
|
1276
|
+
content = seedHeader();
|
|
1277
|
+
} else {
|
|
1278
|
+
assertEditableJsonc(storePath, content);
|
|
1279
|
+
}
|
|
1280
|
+
} else {
|
|
1281
|
+
if (!createIfMissing) {
|
|
1282
|
+
refuse(`store file ${storePath} does not exist`);
|
|
1283
|
+
}
|
|
1284
|
+
mkdirSync(dirname2(storePath), { recursive: true });
|
|
1285
|
+
content = seedHeader();
|
|
1286
|
+
}
|
|
1287
|
+
const edits = modify(content, jsonPath, value, { formattingOptions: FORMAT });
|
|
1288
|
+
const next = applyEdits(content, edits);
|
|
1289
|
+
const finalText = next.endsWith(`
|
|
1290
|
+
`) ? next : `${next}
|
|
1291
|
+
`;
|
|
1292
|
+
const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1293
|
+
try {
|
|
1294
|
+
writeFileSync(tmp, finalText);
|
|
1295
|
+
renameSync(tmp, storePath);
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
try {
|
|
1298
|
+
unlinkSync(tmp);
|
|
1299
|
+
} catch {}
|
|
1300
|
+
throw err;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
// src/settings/identity.ts
|
|
1304
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, realpathSync } from "fs";
|
|
1305
|
+
|
|
1306
|
+
// src/settings/exec.ts
|
|
1307
|
+
async function runCapture(argv, opts = {}) {
|
|
1308
|
+
const captureStderr = opts.stderr === "pipe";
|
|
1309
|
+
let proc;
|
|
1310
|
+
try {
|
|
1311
|
+
proc = Bun.spawn(argv, {
|
|
1312
|
+
cwd: opts.cwd,
|
|
1313
|
+
env: opts.env ?? { ...process.env },
|
|
1314
|
+
stdin: "ignore",
|
|
1315
|
+
stdout: "pipe",
|
|
1316
|
+
stderr: captureStderr ? "pipe" : "ignore"
|
|
1317
|
+
});
|
|
1318
|
+
} catch {
|
|
1319
|
+
return { stdout: "", stderr: "", exitCode: -1 };
|
|
1320
|
+
}
|
|
1321
|
+
const timer = setTimeout(() => {
|
|
1322
|
+
try {
|
|
1323
|
+
proc.kill();
|
|
1324
|
+
} catch {}
|
|
1325
|
+
}, opts.timeoutMs ?? 1e4);
|
|
1326
|
+
try {
|
|
1327
|
+
const stdoutPromise = new Response(proc.stdout).text();
|
|
1328
|
+
const stderrPromise = captureStderr ? new Response(proc.stderr).text() : Promise.resolve("");
|
|
1329
|
+
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
1330
|
+
const exitCode = await proc.exited;
|
|
1331
|
+
return { stdout, stderr, exitCode };
|
|
1332
|
+
} catch {
|
|
1333
|
+
return { stdout: "", stderr: "", exitCode: -1 };
|
|
1334
|
+
} finally {
|
|
1335
|
+
clearTimeout(timer);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// src/settings/identity.ts
|
|
1340
|
+
var URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
|
|
1341
|
+
var SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
|
|
1342
|
+
function serializeIdentity(id) {
|
|
1343
|
+
return `${id.kind}:${encodeURIComponent(id.id)}`;
|
|
1344
|
+
}
|
|
1345
|
+
function parseIdentity(wire) {
|
|
1346
|
+
const colon = wire.indexOf(":");
|
|
1347
|
+
if (colon === -1)
|
|
1348
|
+
return null;
|
|
1349
|
+
const kind = wire.slice(0, colon);
|
|
1350
|
+
if (kind !== "remote" && kind !== "path")
|
|
1351
|
+
return null;
|
|
1352
|
+
const encoded = wire.slice(colon + 1);
|
|
1353
|
+
let id;
|
|
1354
|
+
try {
|
|
1355
|
+
id = decodeURIComponent(encoded);
|
|
1356
|
+
} catch {
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
if (encodeURIComponent(id) !== encoded)
|
|
1360
|
+
return null;
|
|
1361
|
+
return { kind, id };
|
|
1362
|
+
}
|
|
1363
|
+
function normalizeRemote(remote) {
|
|
1364
|
+
const trimmed = remote.trim();
|
|
1365
|
+
if (!trimmed)
|
|
1366
|
+
return null;
|
|
1367
|
+
let host;
|
|
1368
|
+
let path;
|
|
1369
|
+
const urlMatch = URL_RE.exec(trimmed);
|
|
1370
|
+
if (urlMatch) {
|
|
1371
|
+
host = urlMatch[1];
|
|
1372
|
+
path = urlMatch[2];
|
|
1373
|
+
} else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
|
|
1374
|
+
const scpMatch = SCP_RE.exec(trimmed);
|
|
1375
|
+
if (scpMatch) {
|
|
1376
|
+
host = scpMatch[1];
|
|
1377
|
+
path = scpMatch[2];
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (!host || !path)
|
|
1381
|
+
return null;
|
|
1382
|
+
const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
1383
|
+
if (!normalizedPath)
|
|
1384
|
+
return null;
|
|
1385
|
+
return `${host.toLowerCase()}/${normalizedPath}`;
|
|
1386
|
+
}
|
|
1387
|
+
function identityFromRemote(remote) {
|
|
1388
|
+
const store = readStore(machineSettingsPath());
|
|
1389
|
+
const overrides = store.global["rt.repoIdentityOverrides"];
|
|
1390
|
+
if (overrides !== null && typeof overrides === "object" && !Array.isArray(overrides)) {
|
|
1391
|
+
const hit = overrides[remote];
|
|
1392
|
+
if (typeof hit === "string")
|
|
1393
|
+
return { kind: "remote", id: hit };
|
|
1394
|
+
}
|
|
1395
|
+
const normalized = normalizeRemote(remote);
|
|
1396
|
+
return normalized === null ? null : { kind: "remote", id: normalized };
|
|
1397
|
+
}
|
|
1398
|
+
var memo = new Map;
|
|
1399
|
+
async function deriveRepoIdentity(repoPath) {
|
|
1400
|
+
const cached = memo.get(repoPath);
|
|
1401
|
+
if (cached)
|
|
1402
|
+
return cached;
|
|
1403
|
+
const result = await (async () => {
|
|
1404
|
+
const spawned = await runCapture(["git", "-C", repoPath, "config", "--get", "remote.origin.url"]);
|
|
1405
|
+
if (spawned.exitCode === 0) {
|
|
1406
|
+
const remote = spawned.stdout.trim();
|
|
1407
|
+
const fromRemote = remote ? identityFromRemote(remote) : null;
|
|
1408
|
+
if (fromRemote)
|
|
1409
|
+
return fromRemote;
|
|
1410
|
+
}
|
|
1411
|
+
const listed = await runCapture(["git", "-C", repoPath, "worktree", "list", "--porcelain"]);
|
|
1412
|
+
const first = listed.exitCode === 0 ? /^worktree (.+)$/m.exec(listed.stdout)?.[1]?.trim() : undefined;
|
|
1413
|
+
let base;
|
|
1414
|
+
if (first) {
|
|
1415
|
+
const top = await runCapture(["git", "-C", first, "rev-parse", "--show-toplevel"]);
|
|
1416
|
+
if (top.exitCode === 0 && top.stdout.trim())
|
|
1417
|
+
base = top.stdout.trim();
|
|
1418
|
+
}
|
|
1419
|
+
if (!base) {
|
|
1420
|
+
const own = await runCapture(["git", "-C", repoPath, "rev-parse", "--show-toplevel"]);
|
|
1421
|
+
base = own.exitCode === 0 && own.stdout.trim() ? own.stdout.trim() : repoPath;
|
|
1422
|
+
}
|
|
1423
|
+
return { kind: "path", id: safeRealpath(base) };
|
|
1424
|
+
})();
|
|
1425
|
+
if (result.kind === "remote")
|
|
1426
|
+
memo.set(repoPath, Promise.resolve(result));
|
|
1427
|
+
return result;
|
|
1428
|
+
}
|
|
1429
|
+
function clearIdentityMemo() {
|
|
1430
|
+
memo.clear();
|
|
1431
|
+
}
|
|
1432
|
+
function safeRealpath(p) {
|
|
1433
|
+
try {
|
|
1434
|
+
return realpathSync(p);
|
|
1435
|
+
} catch {
|
|
1436
|
+
return p;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
async function resolveNameToIdentity(name, reposJsonPath) {
|
|
1440
|
+
if (!existsSync4(reposJsonPath))
|
|
1441
|
+
return null;
|
|
1442
|
+
try {
|
|
1443
|
+
const index = JSON.parse(readFileSync5(reposJsonPath, "utf8"));
|
|
1444
|
+
const path = index[name];
|
|
1445
|
+
if (typeof path !== "string")
|
|
1446
|
+
return null;
|
|
1447
|
+
return await deriveRepoIdentity(path);
|
|
1448
|
+
} catch {
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
112
1452
|
export {
|
|
1453
|
+
validateValue,
|
|
113
1454
|
subscribe,
|
|
1455
|
+
setSetting,
|
|
1456
|
+
serializeIdentity,
|
|
114
1457
|
rtCommand,
|
|
1458
|
+
resolveNameToIdentity,
|
|
115
1459
|
resolveForgeToken,
|
|
116
1460
|
repoNameForPath,
|
|
1461
|
+
readStore,
|
|
117
1462
|
readProjectMRs,
|
|
118
1463
|
readMrsByBranch,
|
|
119
1464
|
readDiscussions,
|
|
1465
|
+
parseIdentity,
|
|
1466
|
+
normalizeRemote,
|
|
1467
|
+
listTeams,
|
|
1468
|
+
listSettings,
|
|
1469
|
+
listRuns,
|
|
1470
|
+
isMigrated,
|
|
1471
|
+
identityFromRemote,
|
|
1472
|
+
getSetting,
|
|
1473
|
+
getRun,
|
|
1474
|
+
getDef,
|
|
1475
|
+
explainSetting,
|
|
1476
|
+
expandVariables,
|
|
1477
|
+
eventsHead,
|
|
1478
|
+
deriveRepoIdentity,
|
|
1479
|
+
clearIdentityMemo,
|
|
1480
|
+
chatWho,
|
|
1481
|
+
chatUnreadWaking,
|
|
1482
|
+
chatTouch,
|
|
1483
|
+
chatRooms,
|
|
1484
|
+
chatRead,
|
|
1485
|
+
chatPost,
|
|
1486
|
+
chatMessages,
|
|
1487
|
+
chatMark,
|
|
1488
|
+
chatLeave,
|
|
1489
|
+
chatJoin,
|
|
1490
|
+
chatDisarm,
|
|
1491
|
+
chatArm,
|
|
1492
|
+
allDefs,
|
|
1493
|
+
abandonRun,
|
|
1494
|
+
SCOPE_ORDER,
|
|
1495
|
+
REGISTRY,
|
|
120
1496
|
DEFAULT_WS_URL,
|
|
121
1497
|
DEFAULT_SOCK,
|
|
122
1498
|
COMMAND_NAMES
|