@mattstack/rt-client 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,12 @@
1
1
  // src/transport.ts
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
- var DEFAULT_SOCK = join(homedir(), ".rt", "rt.sock");
4
+ function defaultSock() {
5
+ return join(process.env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock");
6
+ }
7
+ var DEFAULT_SOCK = defaultSock();
5
8
  async function rtCommand(cmd, payload, opts = {}) {
6
- const sockPath = opts.sockPath ?? DEFAULT_SOCK;
9
+ const sockPath = opts.sockPath ?? defaultSock();
7
10
  try {
8
11
  const res = await fetch(`http://localhost/${cmd}`, {
9
12
  unix: sockPath,
@@ -41,7 +44,13 @@ var COMMAND_NAMES = [
41
44
  "project-mrs:read",
42
45
  "discussions:read",
43
46
  "mr:by-branch",
44
- "secrets:forge-token"
47
+ "secrets:forge-token",
48
+ "secrets:read",
49
+ "events:emit",
50
+ "events:wait",
51
+ "events:list",
52
+ "runs:list",
53
+ "runs:get"
45
54
  ];
46
55
  // src/relay.ts
47
56
  var DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
@@ -91,7 +100,7 @@ import { existsSync, readFileSync } from "fs";
91
100
  import { homedir as homedir2 } from "os";
92
101
  import { join as join2 } from "path";
93
102
  function defaultReposJsonPath() {
94
- return join2(homedir2(), ".rt", "repos.json");
103
+ return join2(homedir2(), ".mattstack", "rt", "repos.json");
95
104
  }
96
105
  function repoNameForPath(repoPath, reposJsonPath) {
97
106
  const path = reposJsonPath ?? defaultReposJsonPath();
@@ -109,14 +118,1124 @@ function repoNameForPath(repoPath, reposJsonPath) {
109
118
  return null;
110
119
  }
111
120
  }
121
+ // src/settings/resolve.ts
122
+ import { homedir as homedir4 } from "os";
123
+ import { join as join5 } from "path";
124
+
125
+ // src/settings/paths.ts
126
+ import { readFileSync as readFileSync2 } from "fs";
127
+ import { homedir as homedir3, hostname } from "os";
128
+ import { join as join3 } from "path";
129
+ function home() {
130
+ return process.env.HOME ?? homedir3();
131
+ }
132
+ function userSettingsPath() {
133
+ return join3(home(), ".mattstack", "user", "settings.user.jsonc");
134
+ }
135
+ function teamSettingsPath(team) {
136
+ return join3(teamsDir(), team, "mattstack", "settings.team.jsonc");
137
+ }
138
+ function machineSettingsPath() {
139
+ return join3(home(), ".mattstack", "user", "local", machineKey(), "settings.local.jsonc");
140
+ }
141
+ function teamsDir() {
142
+ return join3(home(), ".mattstack", "teams");
143
+ }
144
+ function machineKey() {
145
+ const override = join3(home(), ".mattstack", "machine-key");
146
+ try {
147
+ const v = readFileSync2(override, "utf8").trim();
148
+ if (isSafeMachineKeySegment(v))
149
+ return v;
150
+ } catch {}
151
+ const slug = hostname().toLowerCase().replace(/\.local$/, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
152
+ return slug || "default";
153
+ }
154
+ function isSafeMachineKeySegment(v) {
155
+ return v.length > 0 && v !== "." && v !== ".." && !v.includes("/") && !v.includes("\\");
156
+ }
157
+
158
+ // src/settings/registry-defs.ts
159
+ var ALL_SCOPES = ["user", "team", "machine"];
160
+ var REGISTRY = [
161
+ {
162
+ key: "rt.roles",
163
+ type: "object",
164
+ scopes: ALL_SCOPES,
165
+ merge: "deep",
166
+ repoScoped: true,
167
+ migrated: true,
168
+ pathGuardFields: ["hook"],
169
+ description: "Per-repo dev-role definitions: port pools, env passthrough, and the dev-server hook command."
170
+ },
171
+ {
172
+ key: "rt.intercepts",
173
+ type: "array",
174
+ scopes: ALL_SCOPES,
175
+ merge: "replace",
176
+ repoScoped: true,
177
+ migrated: true,
178
+ description: "Per-repo endpoint intercept rules consumed by rt intercept install."
179
+ },
180
+ {
181
+ key: "rt.worktrees",
182
+ type: "object",
183
+ scopes: ALL_SCOPES,
184
+ default: { onDeck: 0 },
185
+ merge: "deep",
186
+ repoScoped: true,
187
+ migrated: true,
188
+ description: "Per-repo worktree pool config (onDeck size, ready steps, name pool); root/branchFormat/ready computed-or-empty in the reader."
189
+ },
190
+ {
191
+ key: "rt.repoIdentityOverrides",
192
+ type: "object",
193
+ scopes: ["machine"],
194
+ merge: "replace",
195
+ migrated: true,
196
+ description: "Map of observed remote URL to pinned repo identity, for forks/multi-remote repos on this machine."
197
+ },
198
+ {
199
+ key: "rt.repoRoots",
200
+ type: "array",
201
+ scopes: ["machine"],
202
+ default: [],
203
+ merge: "replace",
204
+ migrated: true,
205
+ 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.'
206
+ },
207
+ {
208
+ key: "rt.notifications",
209
+ type: "object",
210
+ scopes: ["user"],
211
+ merge: "deep",
212
+ migrated: true,
213
+ description: "Desktop notification preferences (which events notify, sound on/off)."
214
+ },
215
+ {
216
+ key: "rt.cron",
217
+ type: "object",
218
+ scopes: ["machine"],
219
+ merge: "deep",
220
+ migrated: true,
221
+ description: "Scheduled rt job definitions and their cron expressions. Restart the daemon to apply changes."
222
+ },
223
+ {
224
+ key: "rt.repoTracking",
225
+ type: "object",
226
+ scopes: ["machine"],
227
+ merge: "deep",
228
+ migrated: true,
229
+ description: "Which repos rt tracks for background sync and status polling."
230
+ },
231
+ {
232
+ key: "rt.runsPruneDays",
233
+ type: "number",
234
+ scopes: ["machine"],
235
+ default: 30,
236
+ merge: "replace",
237
+ migrated: true,
238
+ description: "Age floor in days for pruning finished pipeline run directories under ~/.mattstack/runs (default 30)."
239
+ },
240
+ {
241
+ key: "rt.runaway",
242
+ type: "object",
243
+ scopes: ["machine"],
244
+ merge: "deep",
245
+ migrated: true,
246
+ description: "Thresholds for the runaway-process guard that kills stuck dev servers. Restart the daemon to apply changes."
247
+ },
248
+ {
249
+ key: "rt.workspacePrefs",
250
+ type: "object",
251
+ scopes: ["machine"],
252
+ merge: "deep",
253
+ migrated: true,
254
+ description: "Per-machine editor/terminal preferences applied when opening a worktree."
255
+ },
256
+ {
257
+ key: "rt.homeSnapshot",
258
+ type: "object",
259
+ scopes: ["machine"],
260
+ default: { enabled: true, debounceSec: 20, pushDelaySec: 60, janitorThresholdHours: 6, janitorIntervalMin: 30 },
261
+ merge: "deep",
262
+ migrated: true,
263
+ description: "Home-repo snapshot daemon config: enabled, debounce/push delays, and the janitor threshold/interval for zones left dirty too long."
264
+ },
265
+ {
266
+ key: "rt.sync",
267
+ type: "object",
268
+ scopes: ALL_SCOPES,
269
+ merge: "deep",
270
+ repoScoped: true,
271
+ migrated: true,
272
+ description: "Branch sync behavior: fast-forward rules and stale-branch handling."
273
+ },
274
+ {
275
+ key: "rt.branchNaming",
276
+ type: "object",
277
+ scopes: ALL_SCOPES,
278
+ merge: "deep",
279
+ repoScoped: true,
280
+ migrated: true,
281
+ description: "Branch-naming templates. rt itself has no readers of this key yet — the VS Code extension still reads repos/<repo>/branch-naming.json by repo name, which stays authoritative until the extension ports over. Setting this key stores a value nothing consumes."
282
+ },
283
+ {
284
+ key: "rt.variations",
285
+ type: "object",
286
+ scopes: ALL_SCOPES,
287
+ merge: "deep",
288
+ repoScoped: true,
289
+ migrated: true,
290
+ description: "Named parameter sets rt run can pick between for a command."
291
+ },
292
+ {
293
+ key: "rt.presets",
294
+ type: "object",
295
+ scopes: ALL_SCOPES,
296
+ merge: "deep",
297
+ repoScoped: true,
298
+ migrated: true,
299
+ description: "Saved argument presets for frequently repeated rt commands, keyed by name."
300
+ },
301
+ {
302
+ key: "rt.dopplerTemplate",
303
+ type: "array",
304
+ scopes: ALL_SCOPES,
305
+ merge: "replace",
306
+ repoScoped: true,
307
+ migrated: true,
308
+ description: "Template used to generate a repo's Doppler secrets config."
309
+ },
310
+ {
311
+ key: "rt.worktreeApp",
312
+ type: "object",
313
+ scopes: ["machine"],
314
+ merge: "deep",
315
+ migrated: true,
316
+ 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."
317
+ },
318
+ {
319
+ key: "rt.sdmEnrichment",
320
+ type: "object",
321
+ scopes: ["team"],
322
+ merge: "replace",
323
+ migrated: true,
324
+ 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)."
325
+ },
326
+ {
327
+ key: "rt.logRetentionDays",
328
+ type: "number",
329
+ scopes: ["machine", "user"],
330
+ default: 14,
331
+ merge: "replace",
332
+ migrated: true,
333
+ 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."
334
+ },
335
+ {
336
+ key: "rt.hooks",
337
+ type: "object",
338
+ scopes: ALL_SCOPES,
339
+ merge: "deep",
340
+ repoScoped: true,
341
+ migrated: false,
342
+ legacyFile: "repos/<repo>/hooks.json",
343
+ description: "User-defined lifecycle hooks rt runs around commands (pre/post command scripts)."
344
+ },
345
+ {
346
+ key: "mattstack.integrations",
347
+ type: "object",
348
+ scopes: ["team"],
349
+ merge: "deep",
350
+ description: "Team-wide external integration config (forge/slack/linear/switchboard) the installer provisions; client secrets never live here."
351
+ },
352
+ {
353
+ key: "mattstack.tracking",
354
+ type: "object",
355
+ scopes: ["team"],
356
+ merge: "deep",
357
+ 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.'
358
+ },
359
+ {
360
+ key: "mattstack.appPath",
361
+ type: "string",
362
+ scopes: ["machine"],
363
+ merge: "replace",
364
+ description: "Absolute path to the installed mattstack.app bundle, written by the app at launch so rt stops hardcoding ~/Applications."
365
+ },
366
+ {
367
+ key: "claude.marketplaces",
368
+ type: "array",
369
+ scopes: ["user", "team"],
370
+ merge: "replace",
371
+ description: "Claude Code plugin marketplaces to replay on restore, in add order."
372
+ },
373
+ {
374
+ key: "claude.plugins",
375
+ type: "array",
376
+ scopes: ["user", "team"],
377
+ merge: "replace",
378
+ description: "Claude Code plugins to replay on restore, in install order."
379
+ },
380
+ {
381
+ key: "deck.apps",
382
+ type: "object",
383
+ scopes: ["user"],
384
+ merge: "deep",
385
+ description: "Per-app deck publish state (published flag, publicFollowsOverride); password hashes and session secrets stay out of this store."
386
+ },
387
+ {
388
+ key: "deck.access",
389
+ type: "object",
390
+ scopes: ["user"],
391
+ merge: "deep",
392
+ description: "deck's access-control roster, migrated from access.json."
393
+ },
394
+ {
395
+ key: "deck.platform",
396
+ type: "object",
397
+ scopes: ["machine"],
398
+ merge: "deep",
399
+ description: "deck's platform-level machine config: public domain and legacy URL prefixes; Cloudflare secrets stay out of this store."
400
+ },
401
+ {
402
+ key: "board.gitlabHost",
403
+ type: "string",
404
+ scopes: ["team"],
405
+ merge: "replace",
406
+ description: "GitLab host the board polls for MRs, shared by the whole team."
407
+ },
408
+ {
409
+ key: "board.projects",
410
+ type: "array",
411
+ scopes: ["team"],
412
+ merge: "replace",
413
+ description: "GitLab projects the board tracks, shared by the whole team."
414
+ },
415
+ {
416
+ key: "board.members",
417
+ type: "array",
418
+ scopes: ["team"],
419
+ merge: "replace",
420
+ description: "The board's full member roster, including hidden-by-default entries."
421
+ },
422
+ {
423
+ key: "board.title",
424
+ type: "string",
425
+ scopes: ["team"],
426
+ merge: "replace",
427
+ description: "Display title shown in the board's UI."
428
+ },
429
+ {
430
+ key: "board.botUsernames",
431
+ type: "array",
432
+ scopes: ["team"],
433
+ merge: "replace",
434
+ description: "GitLab usernames the board treats as bots, excluded from human MR attribution."
435
+ },
436
+ {
437
+ key: "board.ticketPrefixes",
438
+ type: "array",
439
+ scopes: ["team"],
440
+ merge: "replace",
441
+ description: "Ticket key prefixes (e.g. RT, MAT) the board links out to Linear from an MR title."
442
+ },
443
+ {
444
+ key: "board.slack",
445
+ type: "object",
446
+ scopes: ["team"],
447
+ merge: "deep",
448
+ description: "The board's Slack posting config (app id, client id, channel, callback port); client secrets stay out of this store."
449
+ },
450
+ {
451
+ key: "board.doctorSkill",
452
+ type: "string",
453
+ scopes: ["team"],
454
+ merge: "replace",
455
+ description: "Default doctor skill for repairing a stuck MR; a repo's skills.jsonc doctor slot overrides it when present."
456
+ },
457
+ {
458
+ key: "board.triage.doctorSkill",
459
+ type: "string",
460
+ scopes: ["team"],
461
+ merge: "replace",
462
+ 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."
463
+ },
464
+ {
465
+ key: "board.staleAfterDays",
466
+ type: "number",
467
+ scopes: ["user"],
468
+ merge: "replace",
469
+ description: "Days of MR inactivity before the board flags it stale, for this developer."
470
+ },
471
+ {
472
+ key: "board.workspaces",
473
+ type: "object",
474
+ scopes: ["user"],
475
+ merge: "deep",
476
+ description: "Herdr workspace names the board's review/respond/doctor panes launch into, per developer."
477
+ },
478
+ {
479
+ key: "board.defaultMember",
480
+ type: "string",
481
+ scopes: ["user"],
482
+ merge: "replace",
483
+ description: "Which board member identity this developer's local board runs as by default."
484
+ },
485
+ {
486
+ key: "board.hiddenMembers",
487
+ type: "array",
488
+ scopes: ["user"],
489
+ merge: "replace",
490
+ description: "Usernames this developer hides from the team roster's board.members list; overlays the team truth without editing it."
491
+ },
492
+ {
493
+ key: "board.triage",
494
+ type: "object",
495
+ scopes: ["user"],
496
+ merge: "deep",
497
+ 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."
498
+ },
499
+ {
500
+ key: "board.claudeCommand",
501
+ type: "string",
502
+ scopes: ["machine"],
503
+ merge: "replace",
504
+ description: "Local command used to launch Claude Code for the board's review/respond/doctor panes."
505
+ },
506
+ {
507
+ key: "board.cwds",
508
+ type: "object",
509
+ scopes: ["machine"],
510
+ merge: "deep",
511
+ description: "Local working directories the board's review/respond/doctor panes launch from."
512
+ },
513
+ {
514
+ key: "board.rtRepos",
515
+ type: "array",
516
+ scopes: ["machine"],
517
+ merge: "replace",
518
+ description: "rt-registered repo names the board resolves MRs against on this machine."
519
+ },
520
+ {
521
+ key: "board.triageMaxConcurrent",
522
+ type: "number",
523
+ scopes: ["machine"],
524
+ merge: "replace",
525
+ description: "Max concurrent triage panes the board launches on this machine."
526
+ },
527
+ {
528
+ key: "board.switchboardUrl",
529
+ type: "string",
530
+ scopes: ["machine"],
531
+ merge: "replace",
532
+ description: "Local switchboard URL the board's POST /peer/join writer targets."
533
+ },
534
+ {
535
+ key: "gitq.workSlots",
536
+ type: "object",
537
+ scopes: ["machine"],
538
+ merge: "deep",
539
+ description: "gitq's local work-slot config: on-disk location and the max slot count."
540
+ },
541
+ {
542
+ key: "gitq.forges",
543
+ type: "object",
544
+ scopes: ["user"],
545
+ merge: "deep",
546
+ description: "gitq's host-keyed forge config, tokenEnv names only — never a live token."
547
+ },
548
+ {
549
+ key: "gitq.board",
550
+ type: "object",
551
+ scopes: ["machine"],
552
+ merge: "deep",
553
+ description: "gitq checkout-board config: tracked repos, local port, and the herdr workspace it launches into."
554
+ }
555
+ ];
556
+
557
+ // src/settings/registry-machinery.ts
558
+ var BY_KEY = new Map(REGISTRY.map((def) => [def.key, def]));
559
+ function getDef(key) {
560
+ return BY_KEY.get(key);
561
+ }
562
+ function allDefs() {
563
+ return [...REGISTRY];
564
+ }
565
+ function isMigrated(def) {
566
+ return def.migrated !== false;
567
+ }
568
+ var PATH_LIKE = /^[/~]/;
569
+ function typeOf(value) {
570
+ if (value === null)
571
+ return "null";
572
+ if (Array.isArray(value))
573
+ return "array";
574
+ return typeof value;
575
+ }
576
+ function validateValue(def, value) {
577
+ const typeCheck = checkType(def.type, value);
578
+ if (!typeCheck.ok)
579
+ return typeCheck;
580
+ if (def.pathGuardFields && def.pathGuardFields.length > 0) {
581
+ const violation = findPathGuardViolation(value, def.pathGuardFields);
582
+ if (violation) {
583
+ return {
584
+ ok: false,
585
+ reason: `field "${violation.field}" looks like a path literal ("${violation.value}"); path literals are only legal in the machine store`
586
+ };
587
+ }
588
+ }
589
+ return { ok: true };
590
+ }
591
+ function checkType(type, value) {
592
+ switch (type) {
593
+ case "string":
594
+ return typeof value === "string" ? { ok: true } : { ok: false, reason: `expected string, got ${typeOf(value)}` };
595
+ case "number":
596
+ return typeof value === "number" ? { ok: true } : { ok: false, reason: `expected number, got ${typeOf(value)}` };
597
+ case "boolean":
598
+ return typeof value === "boolean" ? { ok: true } : { ok: false, reason: `expected boolean, got ${typeOf(value)}` };
599
+ case "array":
600
+ return Array.isArray(value) ? { ok: true } : { ok: false, reason: `expected array, got ${typeOf(value)}` };
601
+ case "object":
602
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? { ok: true } : { ok: false, reason: `expected object, got ${typeOf(value)}` };
603
+ }
604
+ }
605
+ function findPathGuardViolation(value, guardFields) {
606
+ if (Array.isArray(value)) {
607
+ for (const item of value) {
608
+ const hit = findPathGuardViolation(item, guardFields);
609
+ if (hit)
610
+ return hit;
611
+ }
612
+ return null;
613
+ }
614
+ if (value !== null && typeof value === "object") {
615
+ for (const [field, fieldValue] of Object.entries(value)) {
616
+ if (guardFields.includes(field) && typeof fieldValue === "string" && PATH_LIKE.test(fieldValue)) {
617
+ return { field, value: fieldValue };
618
+ }
619
+ const hit = findPathGuardViolation(fieldValue, guardFields);
620
+ if (hit)
621
+ return hit;
622
+ }
623
+ }
624
+ return null;
625
+ }
626
+
627
+ // src/settings/stores.ts
628
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
629
+ import { parse } from "jsonc-parser";
630
+ import { join as join4 } from "path";
631
+ var EMPTY_STORE = (file, exists) => ({
632
+ global: {},
633
+ repos: {},
634
+ file,
635
+ exists
636
+ });
637
+ function readStore(file) {
638
+ if (!existsSync2(file))
639
+ return EMPTY_STORE(file, false);
640
+ let raw;
641
+ try {
642
+ raw = readFileSync3(file, "utf8");
643
+ } catch (err) {
644
+ console.warn(`rt: failed to read settings store ${file}, ignoring: ${err.message}`);
645
+ return EMPTY_STORE(file, true);
646
+ }
647
+ if (raw.trim() === "")
648
+ return EMPTY_STORE(file, true);
649
+ const errors = [];
650
+ const root = parse(raw, errors, { allowTrailingComma: true });
651
+ if (errors.length > 0 || root === undefined || typeof root !== "object" || Array.isArray(root)) {
652
+ console.warn(`rt: malformed settings store ${file}, ignoring (treating as empty)`);
653
+ return EMPTY_STORE(file, true);
654
+ }
655
+ const { repos, ...global } = root;
656
+ const reposIsValid = repos !== undefined && typeof repos === "object" && repos !== null && !Array.isArray(repos);
657
+ if (repos !== undefined && !reposIsValid) {
658
+ console.warn(`rt: malformed "repos" section in settings store ${file}, ignoring repo sections (global keys still apply)`);
659
+ }
660
+ const reposValid = reposIsValid ? repos : {};
661
+ return { global, repos: reposValid, file, exists: true };
662
+ }
663
+ function listTeams() {
664
+ const dir = teamsDir();
665
+ if (!existsSync2(dir))
666
+ return [];
667
+ let entries;
668
+ try {
669
+ entries = readdirSync(dir, { withFileTypes: true });
670
+ } catch (err) {
671
+ console.warn(`rt: failed to list teams in ${dir}, treating as no teams: ${err.message}`);
672
+ return [];
673
+ }
674
+ const teams = [];
675
+ for (const entry of entries) {
676
+ try {
677
+ const isDir = entry.isDirectory() || entry.isSymbolicLink() && statSync(join4(dir, entry.name)).isDirectory();
678
+ if (!isDir)
679
+ continue;
680
+ if (existsSync2(teamSettingsPath(entry.name)))
681
+ teams.push(entry.name);
682
+ } catch (err) {
683
+ console.warn(`rt: skipping unreadable teams entry ${join4(dir, entry.name)}: ${err.message}`);
684
+ }
685
+ }
686
+ return teams;
687
+ }
688
+
689
+ // src/settings/resolve.ts
690
+ var SCOPE_ORDER = [
691
+ "default",
692
+ "team",
693
+ "user",
694
+ "team.repo",
695
+ "user.repo",
696
+ "machine",
697
+ "machine.repo"
698
+ ];
699
+ var VAR_RE = /\$\{([^}]*)\}/g;
700
+ var TEAM_VAR_RE = /^team:(.+)$/;
701
+ function expandVariables(value, ctx) {
702
+ if (typeof value === "string")
703
+ return expandString(value, ctx);
704
+ if (Array.isArray(value))
705
+ return value.map((item) => expandVariables(item, ctx));
706
+ if (isPlainObject(value)) {
707
+ const out = {};
708
+ for (const [k, v] of Object.entries(value))
709
+ out[k] = expandVariables(v, ctx);
710
+ return out;
711
+ }
712
+ return value;
713
+ }
714
+ function expandString(input, ctx) {
715
+ return input.replace(VAR_RE, (match, name) => {
716
+ if (name === "home")
717
+ return ctx.home;
718
+ if (name === "repoRoot")
719
+ return required(ctx.repoRoot, "repoRoot", "a repo path");
720
+ if (name === "worktree")
721
+ return required(ctx.worktree, "worktree", "a worktree path");
722
+ const team = TEAM_VAR_RE.exec(name);
723
+ if (team)
724
+ return teamPath(ctx.teamsDir, team[1]);
725
+ return match;
726
+ });
727
+ }
728
+ function teamPath(teamsDir2, name) {
729
+ if (name.includes("/") || name.includes("\\") || name.includes("..")) {
730
+ throw new Error(`rt: cannot expand \${team:${name}} — a team name must be a single directory segment (no "/", "\\" or "..")`);
731
+ }
732
+ return join5(teamsDir2, name);
733
+ }
734
+ function required(value, name, needs) {
735
+ if (value === undefined || value === "") {
736
+ throw new Error(`rt: cannot expand \${${name}} — this setting was resolved without ${needs}`);
737
+ }
738
+ return value;
739
+ }
740
+ function readStores() {
741
+ return {
742
+ user: readStore(userSettingsPath()),
743
+ machine: readStore(machineSettingsPath()),
744
+ teams: [...listTeams()].sort().map((team) => readStore(teamSettingsPath(team)))
745
+ };
746
+ }
747
+ function collectSlots(def, stores, opts) {
748
+ const slots = [];
749
+ const identity = opts.repoIdentity ?? null;
750
+ const useRepo = def.repoScoped === true && typeof identity === "string" && identity !== "";
751
+ const repoSection = (store) => useRepo ? store.repos[identity] : undefined;
752
+ const push = (scope, file, section) => {
753
+ const value = section?.[def.key];
754
+ if (value === undefined)
755
+ slots.push({ scope, file, present: false });
756
+ else
757
+ slots.push({ scope, file, present: true, value });
758
+ };
759
+ const pushTeams = (scope, section) => {
760
+ if (stores.teams.length === 0) {
761
+ slots.push({ scope, file: null, present: false });
762
+ return;
763
+ }
764
+ for (const store of stores.teams)
765
+ push(scope, store.file, section(store));
766
+ };
767
+ slots.push(def.default === undefined ? { scope: "default", file: null, present: false } : { scope: "default", file: null, present: true, value: structuredClone(def.default) });
768
+ pushTeams("team", (store) => store.global);
769
+ push("user", stores.user.file, stores.user.global);
770
+ if (useRepo)
771
+ pushTeams("team.repo", repoSection);
772
+ if (useRepo)
773
+ push("user.repo", stores.user.file, repoSection(stores.user));
774
+ push("machine", stores.machine.file, stores.machine.global);
775
+ if (useRepo)
776
+ push("machine.repo", stores.machine.file, repoSection(stores.machine));
777
+ return slots;
778
+ }
779
+ var TEAM_LOCKED_SCOPES = ["default", "team", "team.repo"];
780
+ function baseScope(scope) {
781
+ if (scope === "team" || scope === "team.repo")
782
+ return "team";
783
+ if (scope === "user" || scope === "user.repo")
784
+ return "user";
785
+ if (scope === "machine" || scope === "machine.repo")
786
+ return "machine";
787
+ return null;
788
+ }
789
+ function validateForScope(def, scope, value) {
790
+ const shared = scope === "team" || scope === "user" || scope === "team.repo" || scope === "user.repo";
791
+ return validateValue(shared ? def : { ...def, pathGuardFields: undefined }, value);
792
+ }
793
+ function resolveDef(def, stores, opts) {
794
+ const slots = collectSlots(def, stores, opts);
795
+ const rows = [];
796
+ const invalid = [];
797
+ const applied = [];
798
+ for (const slot of slots) {
799
+ const row = { scope: slot.scope, file: slot.file, present: slot.present };
800
+ if (!slot.present) {
801
+ rows.push(row);
802
+ continue;
803
+ }
804
+ row.value = slot.value;
805
+ if (def.teamLocked && !TEAM_LOCKED_SCOPES.includes(slot.scope)) {
806
+ row.shadowed = "teamLocked";
807
+ rows.push(row);
808
+ continue;
809
+ }
810
+ const base = baseScope(slot.scope);
811
+ if (base !== null && !def.scopes.includes(base)) {
812
+ const reason = `not settable in the ${base} store (allowed: ${def.scopes.join(", ")})`;
813
+ row.invalid = reason;
814
+ invalid.push({ scope: slot.scope, file: slot.file, reason });
815
+ rows.push(row);
816
+ continue;
817
+ }
818
+ if (slot.scope !== "default") {
819
+ const check = validateForScope(def, slot.scope, slot.value);
820
+ if (!check.ok) {
821
+ row.invalid = check.reason;
822
+ invalid.push({ scope: slot.scope, file: slot.file, reason: check.reason });
823
+ rows.push(row);
824
+ continue;
825
+ }
826
+ }
827
+ rows.push(row);
828
+ applied.push({ scope: slot.scope, file: slot.file, value: slot.value });
829
+ }
830
+ const merged = mergeApplied(def, applied);
831
+ return { value: merged.value, provenance: merged.provenance, invalid, rows };
832
+ }
833
+ function mergeApplied(def, applied) {
834
+ if (applied.length === 0)
835
+ return { value: undefined, provenance: [] };
836
+ if (def.merge === "deep" && def.type === "object") {
837
+ const objectLayers = applied.filter((layer) => isPlainObject(layer.value));
838
+ if (objectLayers.length > 0) {
839
+ const { value, contributors } = deepMerge(objectLayers.map((layer) => layer.value));
840
+ return {
841
+ value,
842
+ provenance: contributors.map((i) => {
843
+ const layer = objectLayers[i];
844
+ return { scope: layer.scope, file: layer.file };
845
+ })
846
+ };
847
+ }
848
+ }
849
+ const winner = applied[applied.length - 1];
850
+ return { value: winner.value, provenance: [{ scope: winner.scope, file: winner.file }] };
851
+ }
852
+ var PATH_SEP = "\x00";
853
+ function deepMerge(layers) {
854
+ const owner = new Map;
855
+ let acc = {};
856
+ layers.forEach((layer, index) => {
857
+ acc = overlay(acc, layer, owner, index, "");
858
+ });
859
+ const contributors = [...new Set(owner.values())].sort((a, b) => a - b);
860
+ return { value: acc, contributors };
861
+ }
862
+ function overlay(base, over, owner, index, prefix) {
863
+ const out = { ...base };
864
+ for (const [key, value] of Object.entries(over)) {
865
+ const path = prefix === "" ? key : `${prefix}${PATH_SEP}${key}`;
866
+ const current = out[key];
867
+ if (isPlainObject(value) && isPlainObject(current)) {
868
+ out[key] = overlay(current, value, owner, index, path);
869
+ continue;
870
+ }
871
+ out[key] = value;
872
+ clearOwners(owner, path);
873
+ registerLeaves(value, path, owner, index);
874
+ }
875
+ return out;
876
+ }
877
+ function clearOwners(owner, path) {
878
+ owner.delete(path);
879
+ const under = `${path}${PATH_SEP}`;
880
+ for (const existing of [...owner.keys()]) {
881
+ if (existing.startsWith(under))
882
+ owner.delete(existing);
883
+ }
884
+ }
885
+ function registerLeaves(value, path, owner, index) {
886
+ if (isPlainObject(value)) {
887
+ const entries = Object.entries(value);
888
+ if (entries.length > 0) {
889
+ for (const [key, child] of entries) {
890
+ registerLeaves(child, `${path}${PATH_SEP}${key}`, owner, index);
891
+ }
892
+ return;
893
+ }
894
+ }
895
+ owner.set(path, index);
896
+ }
897
+ function isPlainObject(value) {
898
+ return value !== null && typeof value === "object" && !Array.isArray(value);
899
+ }
900
+ function unknownKey(key) {
901
+ return new Error(`rt: unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
902
+ }
903
+ function expandCtxFrom(opts) {
904
+ return {
905
+ repoRoot: opts.expandCtx?.repoRoot,
906
+ worktree: opts.expandCtx?.worktree,
907
+ home: process.env.HOME ?? homedir4(),
908
+ teamsDir: teamsDir()
909
+ };
910
+ }
911
+ function warnInvalid(key, entry) {
912
+ console.warn(`rt: ignoring "${key}" from the ${entry.scope} scope (${entry.file ?? "no file"}): ${entry.reason}`);
913
+ }
914
+ function getSetting(key, opts = {}) {
915
+ const def = getDef(key);
916
+ if (!def)
917
+ throw unknownKey(key);
918
+ const resolution = resolveDef(def, readStores(), opts);
919
+ for (const entry of resolution.invalid)
920
+ warnInvalid(key, entry);
921
+ const shouldExpand = opts.expand ?? true;
922
+ const value = shouldExpand && resolution.value !== undefined ? expandVariables(resolution.value, expandCtxFrom(opts)) : resolution.value;
923
+ return { value, provenance: resolution.provenance };
924
+ }
925
+ function listSettings(opts = {}) {
926
+ const stores = readStores();
927
+ const ctx = expandCtxFrom(opts);
928
+ const shouldExpand = opts.expand ?? true;
929
+ const out = [];
930
+ for (const def of allDefs()) {
931
+ const resolution = resolveDef(def, stores, opts);
932
+ for (const entry of resolution.invalid)
933
+ warnInvalid(def.key, entry);
934
+ const listed = {
935
+ key: def.key,
936
+ value: resolution.value,
937
+ provenance: resolution.provenance,
938
+ migrated: isMigrated(def)
939
+ };
940
+ if (resolution.invalid.length > 0)
941
+ listed.invalid = resolution.invalid;
942
+ if (shouldExpand && resolution.value !== undefined) {
943
+ try {
944
+ listed.value = expandVariables(resolution.value, ctx);
945
+ } catch (err) {
946
+ listed.expandError = err.message;
947
+ console.warn(`rt: showing "${def.key}" unexpanded — ${listed.expandError}`);
948
+ }
949
+ }
950
+ out.push(listed);
951
+ }
952
+ out.push(...listUnregistered(stores, opts));
953
+ return out;
954
+ }
955
+ function listUnregistered(stores, opts) {
956
+ const identity = opts.repoIdentity ?? null;
957
+ const found = new Map;
958
+ const scan = (scope, file, section) => {
959
+ for (const [key, value] of Object.entries(section ?? {})) {
960
+ if (getDef(key))
961
+ continue;
962
+ found.set(key, { scope, file, value });
963
+ }
964
+ };
965
+ const repoSection = (store) => typeof identity === "string" && identity !== "" ? store.repos[identity] : undefined;
966
+ for (const store of stores.teams)
967
+ scan("team", store.file, store.global);
968
+ scan("user", stores.user.file, stores.user.global);
969
+ for (const store of stores.teams)
970
+ scan("team.repo", store.file, repoSection(store));
971
+ scan("user.repo", stores.user.file, repoSection(stores.user));
972
+ scan("machine", stores.machine.file, stores.machine.global);
973
+ scan("machine.repo", stores.machine.file, repoSection(stores.machine));
974
+ return [...found.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, hit]) => {
975
+ console.warn(`rt: unregistered setting "${key}" in ${hit.file} — ignoring it (this rt may be older than the store)`);
976
+ return {
977
+ key,
978
+ value: hit.value,
979
+ provenance: [{ scope: hit.scope, file: hit.file }],
980
+ migrated: false,
981
+ unregistered: true
982
+ };
983
+ });
984
+ }
985
+ function explainSetting(key, opts = {}) {
986
+ const def = getDef(key);
987
+ if (!def)
988
+ throw unknownKey(key);
989
+ return resolveDef(def, readStores(), opts).rows;
990
+ }
991
+ // src/settings/write.ts
992
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync4, renameSync, unlinkSync, writeFileSync } from "fs";
993
+ import { applyEdits, modify, parseTree } from "jsonc-parser";
994
+ import { randomBytes } from "crypto";
995
+ import { dirname } from "path";
996
+ var FORMAT = { tabSize: 2, insertSpaces: true, eol: `
997
+ ` };
998
+ function refuse(message) {
999
+ throw new Error(`rt: ${message}`);
1000
+ }
1001
+ function setSetting(key, value, scope, opts = {}) {
1002
+ const def = getDef(key);
1003
+ if (!def) {
1004
+ refuse(`unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
1005
+ }
1006
+ if (!isMigrated(def)) {
1007
+ refuse(migratedFalseMessage(key, def));
1008
+ }
1009
+ if (!def.scopes.includes(scope)) {
1010
+ refuse(`"${key}" cannot be set in the ${scope} store (allowed: ${def.scopes.join(", ")})`);
1011
+ }
1012
+ if (opts.repoIdentity !== undefined && def.repoScoped !== true) {
1013
+ refuse(`"${key}" is not repo-scoped — omit the repo identity`);
1014
+ }
1015
+ const guardedDef = scope === "machine" ? { ...def, pathGuardFields: undefined } : def;
1016
+ const check = validateValue(guardedDef, value);
1017
+ if (!check.ok) {
1018
+ refuse(`refusing to set "${key}": ${check.reason} — use \${team:<name>} or \${repoRoot} instead`);
1019
+ }
1020
+ const storePath = resolveStorePath(scope, opts);
1021
+ const jsonPath = opts.repoIdentity !== undefined ? ["repos", opts.repoIdentity, key] : [key];
1022
+ writeIntoStore(storePath, jsonPath, value, scope !== "team");
1023
+ console.error(`rt: wrote "${key}" to the local ${scope} store (${storePath}) — this is local only until you commit and push it.`);
1024
+ }
1025
+ function migratedFalseMessage(key, def) {
1026
+ const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : "";
1027
+ return `"${key}" is not writable through the settings resolver yet${legacyPart}`;
1028
+ }
1029
+ function resolveStorePath(scope, opts) {
1030
+ if (scope === "user")
1031
+ return userSettingsPath();
1032
+ if (scope === "machine")
1033
+ return machineSettingsPath();
1034
+ if (opts.team !== undefined) {
1035
+ const path = teamSettingsPath(opts.team);
1036
+ if (!existsSync3(path)) {
1037
+ refuse(`team store for "${opts.team}" does not exist (${path}) — clone/seed it before writing to it`);
1038
+ }
1039
+ return path;
1040
+ }
1041
+ const teams = listTeams();
1042
+ if (teams.length === 0) {
1043
+ refuse(`no local team store found — clone a team under ~/.mattstack/teams/<name> or pass opts.team`);
1044
+ }
1045
+ if (teams.length > 1) {
1046
+ refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
1047
+ }
1048
+ return teamSettingsPath(teams[0]);
1049
+ }
1050
+ function seedHeader() {
1051
+ return `// rt settings — created by \`rt settings set\`. JSONC: comments and trailing commas are fine.
1052
+ {}
1053
+ `;
1054
+ }
1055
+ function assertEditableJsonc(file, content) {
1056
+ const errors = [];
1057
+ const tree = parseTree(content, errors, { allowTrailingComma: true });
1058
+ const malformed = errors.length > 0 || tree === undefined || tree.type !== "object" || findDuplicateKey(tree) !== undefined;
1059
+ if (malformed) {
1060
+ refuse(`fix the JSONC syntax error in ${file} first — refusing to edit a malformed store`);
1061
+ }
1062
+ }
1063
+ function findDuplicateKey(node) {
1064
+ if (node.type === "object" && node.children) {
1065
+ const seen = new Set;
1066
+ for (const property of node.children) {
1067
+ const keyNode = property.children?.[0];
1068
+ if (keyNode !== undefined && typeof keyNode.value === "string") {
1069
+ if (seen.has(keyNode.value))
1070
+ return keyNode.value;
1071
+ seen.add(keyNode.value);
1072
+ }
1073
+ const valueNode = property.children?.[1];
1074
+ if (valueNode !== undefined) {
1075
+ const nested = findDuplicateKey(valueNode);
1076
+ if (nested !== undefined)
1077
+ return nested;
1078
+ }
1079
+ }
1080
+ return;
1081
+ }
1082
+ if (node.type === "array" && node.children) {
1083
+ for (const child of node.children) {
1084
+ const nested = findDuplicateKey(child);
1085
+ if (nested !== undefined)
1086
+ return nested;
1087
+ }
1088
+ }
1089
+ return;
1090
+ }
1091
+ function writeIntoStore(storePath, jsonPath, value, createIfMissing) {
1092
+ let content;
1093
+ if (existsSync3(storePath)) {
1094
+ content = readFileSync4(storePath, "utf8");
1095
+ if (content.trim() === "") {
1096
+ content = seedHeader();
1097
+ } else {
1098
+ assertEditableJsonc(storePath, content);
1099
+ }
1100
+ } else {
1101
+ if (!createIfMissing) {
1102
+ refuse(`store file ${storePath} does not exist`);
1103
+ }
1104
+ mkdirSync(dirname(storePath), { recursive: true });
1105
+ content = seedHeader();
1106
+ }
1107
+ const edits = modify(content, jsonPath, value, { formattingOptions: FORMAT });
1108
+ const next = applyEdits(content, edits);
1109
+ const finalText = next.endsWith(`
1110
+ `) ? next : `${next}
1111
+ `;
1112
+ const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
1113
+ try {
1114
+ writeFileSync(tmp, finalText);
1115
+ renameSync(tmp, storePath);
1116
+ } catch (err) {
1117
+ try {
1118
+ unlinkSync(tmp);
1119
+ } catch {}
1120
+ throw err;
1121
+ }
1122
+ }
1123
+ // src/settings/exec.ts
1124
+ async function runCapture(argv, opts = {}) {
1125
+ const captureStderr = opts.stderr === "pipe";
1126
+ let proc;
1127
+ try {
1128
+ proc = Bun.spawn(argv, {
1129
+ cwd: opts.cwd,
1130
+ env: opts.env ?? { ...process.env },
1131
+ stdin: "ignore",
1132
+ stdout: "pipe",
1133
+ stderr: captureStderr ? "pipe" : "ignore"
1134
+ });
1135
+ } catch {
1136
+ return { stdout: "", stderr: "", exitCode: -1 };
1137
+ }
1138
+ const timer = setTimeout(() => {
1139
+ try {
1140
+ proc.kill();
1141
+ } catch {}
1142
+ }, opts.timeoutMs ?? 1e4);
1143
+ try {
1144
+ const stdoutPromise = new Response(proc.stdout).text();
1145
+ const stderrPromise = captureStderr ? new Response(proc.stderr).text() : Promise.resolve("");
1146
+ const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
1147
+ const exitCode = await proc.exited;
1148
+ return { stdout, stderr, exitCode };
1149
+ } catch {
1150
+ return { stdout: "", stderr: "", exitCode: -1 };
1151
+ } finally {
1152
+ clearTimeout(timer);
1153
+ }
1154
+ }
1155
+
1156
+ // src/settings/identity.ts
1157
+ var URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
1158
+ var SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
1159
+ function normalizeRemote(remote) {
1160
+ const trimmed = remote.trim();
1161
+ if (!trimmed)
1162
+ return null;
1163
+ let host;
1164
+ let path;
1165
+ const urlMatch = URL_RE.exec(trimmed);
1166
+ if (urlMatch) {
1167
+ host = urlMatch[1];
1168
+ path = urlMatch[2];
1169
+ } else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
1170
+ const scpMatch = SCP_RE.exec(trimmed);
1171
+ if (scpMatch) {
1172
+ host = scpMatch[1];
1173
+ path = scpMatch[2];
1174
+ }
1175
+ }
1176
+ if (!host || !path)
1177
+ return null;
1178
+ const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
1179
+ if (!normalizedPath)
1180
+ return null;
1181
+ return `${host.toLowerCase()}/${normalizedPath}`;
1182
+ }
1183
+ function identityFromRemote(remote) {
1184
+ const store = readStore(machineSettingsPath());
1185
+ const overrides = store.global["rt.repoIdentityOverrides"];
1186
+ if (overrides !== null && typeof overrides === "object" && !Array.isArray(overrides)) {
1187
+ const hit = overrides[remote];
1188
+ if (typeof hit === "string")
1189
+ return hit;
1190
+ }
1191
+ return normalizeRemote(remote);
1192
+ }
1193
+ var memo = new Map;
1194
+ async function deriveRepoIdentity(repoPath) {
1195
+ const cached = memo.get(repoPath);
1196
+ if (cached)
1197
+ return cached;
1198
+ const result = await (async () => {
1199
+ const spawned = await runCapture(["git", "-C", repoPath, "config", "--get", "remote.origin.url"]);
1200
+ if (spawned.exitCode !== 0)
1201
+ return null;
1202
+ const remote = spawned.stdout.trim();
1203
+ if (!remote)
1204
+ return null;
1205
+ return identityFromRemote(remote);
1206
+ })();
1207
+ if (result !== null)
1208
+ memo.set(repoPath, Promise.resolve(result));
1209
+ return result;
1210
+ }
1211
+ function clearIdentityMemo() {
1212
+ memo.clear();
1213
+ }
112
1214
  export {
1215
+ validateValue,
113
1216
  subscribe,
1217
+ setSetting,
114
1218
  rtCommand,
115
1219
  resolveForgeToken,
116
1220
  repoNameForPath,
1221
+ readStore,
117
1222
  readProjectMRs,
118
1223
  readMrsByBranch,
119
1224
  readDiscussions,
1225
+ normalizeRemote,
1226
+ listTeams,
1227
+ listSettings,
1228
+ isMigrated,
1229
+ identityFromRemote,
1230
+ getSetting,
1231
+ getDef,
1232
+ explainSetting,
1233
+ expandVariables,
1234
+ deriveRepoIdentity,
1235
+ clearIdentityMemo,
1236
+ allDefs,
1237
+ SCOPE_ORDER,
1238
+ REGISTRY,
120
1239
  DEFAULT_WS_URL,
121
1240
  DEFAULT_SOCK,
122
1241
  COMMAND_NAMES