@mattstack/rt-client 0.23.0 → 0.25.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.
@@ -123,8 +123,10 @@ export interface GateQuestion {
123
123
  multi: boolean;
124
124
  options: GateOption[];
125
125
  }
126
- export declare function gateOptionValue(o: GateOption): string;
127
- export declare function gateOptionLabel(o: GateOption): string;
126
+ /** Implementations live in gate-options.ts (the browser-safe ./gate
127
+ subpath); re-exported here so existing commands.ts/index.ts consumers
128
+ are unaffected. */
129
+ export { gateOptionValue, gateOptionLabel } from "./gate-options.ts";
128
130
  /** `session` is the answering surface's own session id, recorded so the
129
131
  push facility can tell a self-answer from a remote one and skip the
130
132
  doorbell it would otherwise send back to the writer. Optional: a caller
@@ -6,6 +6,8 @@ export interface GateOptionObject {
6
6
  value: string;
7
7
  label: string;
8
8
  }
9
+ export declare function gateOptionValue(o: GateOption): string;
10
+ export declare function gateOptionLabel(o: GateOption): string;
9
11
  /** Bare string s becomes {value: s, label: s}; a well-formed {value,label}
10
12
  object passes through untouched. Total over whatever actually arrives
11
13
  on the wire, not just the declared GateOption union: a partial object
package/dist/gate.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Browser-safe entry point (the "./gate" subpath export): the pure gate
3
+ * helpers only, so a browser bundle (mattstack-apps gate-kit) never drags in
4
+ * commands.ts's Node-only neighbors the way importing from "." would.
5
+ */
6
+ export * from "./gate-answers.ts";
7
+ export * from "./gate-options.ts";
8
+ export * from "./gate-presentation.ts";
9
+ export type { GateOption, GateQuestion, GateAnswer } from "./commands.ts";
package/dist/gate.js ADDED
@@ -0,0 +1,103 @@
1
+ // src/gate-options.ts
2
+ function gateOptionValue(o) {
3
+ return typeof o === "string" ? o : o.value;
4
+ }
5
+ function gateOptionLabel(o) {
6
+ return typeof o === "string" ? o : o.label || o.value;
7
+ }
8
+ var RECOMMENDED_SUFFIX = " (Recommended)";
9
+ var HAS_RECOMMENDED_SUFFIX = /\(\s*recommended\s*\)\s*$/i;
10
+ function isWordLikeLabel(label) {
11
+ return /^[a-z][^A-Z0-9/:\\@]*$/.test(label);
12
+ }
13
+ function capitalize(label) {
14
+ return isWordLikeLabel(label) ? label[0].toUpperCase() + label.slice(1) : label;
15
+ }
16
+ function normalizeGateOptions(options) {
17
+ return options.map((o) => {
18
+ const recommended = o !== null && typeof o === "object" && o.recommended === true;
19
+ let value;
20
+ let label;
21
+ if (typeof o === "string") {
22
+ value = o;
23
+ label = o;
24
+ } else if (o !== null && typeof o === "object") {
25
+ const v = typeof o.value === "string" ? o.value : undefined;
26
+ const l = typeof o.label === "string" ? o.label : undefined;
27
+ value = v ?? l ?? String(o);
28
+ label = l ?? v ?? String(o);
29
+ } else {
30
+ value = String(o);
31
+ label = String(o);
32
+ }
33
+ label = capitalize(label);
34
+ if (recommended && label && !HAS_RECOMMENDED_SUFFIX.test(label))
35
+ label += RECOMMENDED_SUFFIX;
36
+ return { value, label };
37
+ });
38
+ }
39
+ function normalizeGateQuestions(questions) {
40
+ return questions.map((q) => ({ ...q, options: normalizeGateOptions(q.options) }));
41
+ }
42
+
43
+ // src/gate-answers.ts
44
+ function unwrapGateAnswerValue(raw) {
45
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) {
46
+ return raw.value;
47
+ }
48
+ return raw;
49
+ }
50
+ function wrapperNoteIsValid(raw) {
51
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("value" in raw)) {
52
+ return true;
53
+ }
54
+ const note = raw.note;
55
+ return note === undefined || typeof note === "string";
56
+ }
57
+ function validateGateAnswers(questions, answers) {
58
+ const byId = new Map(questions.map((q) => [q.id, q]));
59
+ for (const [qid, raw] of Object.entries(answers)) {
60
+ const question = byId.get(qid);
61
+ if (!question)
62
+ return `unknown question id: ${qid}`;
63
+ if (!wrapperNoteIsValid(raw))
64
+ return `question ${qid} note must be a string`;
65
+ const value = unwrapGateAnswerValue(raw);
66
+ const isArray = Array.isArray(value);
67
+ if (question.multi && !isArray)
68
+ return `question ${qid} expects an array (multi)`;
69
+ if (!question.multi && isArray)
70
+ return `question ${qid} expects a single value`;
71
+ const values = isArray ? value : [value];
72
+ if (!values.every((v) => typeof v === "string"))
73
+ return `question ${qid} value must be a string`;
74
+ if (question.options.length > 0) {
75
+ const members = question.options.map(gateOptionValue);
76
+ for (const v of values) {
77
+ if (!members.includes(v))
78
+ return `answer for "${qid}" is not one of its options: "${v}"`;
79
+ }
80
+ }
81
+ }
82
+ const missing = questions.map((q) => q.id).filter((id) => !Object.prototype.hasOwnProperty.call(answers, id));
83
+ if (missing.length > 0)
84
+ return `missing answer(s) for: ${missing.join(", ")}`;
85
+ return null;
86
+ }
87
+ // src/gate-presentation.ts
88
+ var GATE_FORM_OPTION_CAP = 4;
89
+ function gatePresentation(args) {
90
+ if (!args.paneId || !args.sessionId)
91
+ return "wait";
92
+ return args.questions.every((q) => q.options.length <= GATE_FORM_OPTION_CAP) ? "form" : "wait";
93
+ }
94
+ export {
95
+ validateGateAnswers,
96
+ unwrapGateAnswerValue,
97
+ normalizeGateQuestions,
98
+ normalizeGateOptions,
99
+ gatePresentation,
100
+ gateOptionValue,
101
+ gateOptionLabel,
102
+ GATE_FORM_OPTION_CAP
103
+ };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
2
2
  export type { RtResponse, RtClientOptions } from "./transport.ts";
3
+ export { guardTestDaemonEnv } from "./test-isolation.ts";
3
4
  export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatAck, chatClaim, chatRelease, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatSignIn, chatSignOut, chatAway, chatBack, chatBuddies, chatDm, chatArchive, chatDmOpen, eventsHead, eventsEmit, eventsWait, eventsList, agentStart, agentResume, agentGet, agentList, paneList, panePeek, paneSpawn, paneAccounts, paneDirectories, chatInvite, paneSend, paneFocus, reconcilerStatus, reconcilerClear, gateOpen, gateAsk, gateAnswer, gateWait, gateList, gatePark, gateClose, gateSubscribe, gateUnsubscribe, gateSubscriptions, herdStart, herdSpawn, herdAsk, herdMilestone, herdAnswer, herdReport, herdGates, herdStatus, herdList, herdResume, herdClose, herdAttend, herdWrapUp, herdStopHidden, bgEnsure, bgStatus, bgStop, bgRelease, } from "./client.ts";
4
5
  export { COMMAND_NAMES, GATE_BY_PANE, gateOptionValue, gateOptionLabel } from "./commands.ts";
5
6
  export { GATE_FORM_OPTION_CAP, gatePresentation } from "./gate-presentation.ts";
package/dist/index.js CHANGED
@@ -2,14 +2,52 @@ import { createRequire } from "node:module";
2
2
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
3
 
4
4
  // src/transport.ts
5
+ import { homedir as homedir2 } from "os";
6
+ import { join as join2 } from "path";
7
+
8
+ // src/test-isolation.ts
5
9
  import { homedir } from "os";
6
10
  import { join } from "path";
11
+ function parseForbidSocks(raw) {
12
+ const parsed = JSON.parse(raw);
13
+ if (!Array.isArray(parsed) || parsed.some((path) => typeof path !== "string")) {
14
+ throw new Error("rt-client: RT_TEST_FORBID_SOCKS must be a JSON string array of socket paths");
15
+ }
16
+ return parsed;
17
+ }
18
+ function guardTestDaemonEnv(env = process.env) {
19
+ const forbidden = new Set;
20
+ if (env.RT_TEST_FORBID_SOCKS) {
21
+ for (const path of parseForbidSocks(env.RT_TEST_FORBID_SOCKS))
22
+ forbidden.add(path);
23
+ }
24
+ if (env.RT_DAEMON_SOCK)
25
+ forbidden.add(env.RT_DAEMON_SOCK);
26
+ if (env.RT_APP_SOCKET)
27
+ forbidden.add(env.RT_APP_SOCKET);
28
+ forbidden.add(join(env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock"));
29
+ delete env.RT_DAEMON_SOCK;
30
+ delete env.RT_APP_SOCKET;
31
+ env.RT_TEST_FORBID_SOCKS = JSON.stringify([...forbidden]);
32
+ }
33
+
34
+ // src/transport.ts
7
35
  function defaultSock() {
8
- return process.env.RT_DAEMON_SOCK || join(process.env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock");
36
+ return process.env.RT_DAEMON_SOCK || join2(process.env.HOME ?? homedir2(), ".mattstack", "rt", "rt.sock");
9
37
  }
10
38
  var DEFAULT_SOCK = defaultSock();
39
+ function assertSockNotForbidden(cmd, sockPath) {
40
+ const raw = process.env.RT_TEST_FORBID_SOCKS;
41
+ if (!raw)
42
+ return;
43
+ const forbidden = parseForbidSocks(raw);
44
+ if (forbidden.includes(sockPath)) {
45
+ throw new Error(`rt-client: refusing "${cmd}" at forbidden socket ${sockPath}: RT_TEST_FORBID_SOCKS marks it as a live daemon socket, so this dispatch would have escaped test isolation`);
46
+ }
47
+ }
11
48
  async function rtCommand(cmd, payload, opts = {}) {
12
49
  const sockPath = opts.sockPath ?? defaultSock();
50
+ assertSockNotForbidden(cmd, sockPath);
13
51
  try {
14
52
  const res = await fetch(`http://localhost/${cmd}`, {
15
53
  unix: sockPath,
@@ -418,14 +456,50 @@ function bgStop(o = {}) {
418
456
  function bgRelease(a, o = {}) {
419
457
  return rtCommand("bg:release", { claim: a.claim }, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 1e4 });
420
458
  }
421
- // src/commands.ts
422
- var GATE_BY_PANE = "pane";
459
+ // src/gate-options.ts
423
460
  function gateOptionValue(o) {
424
461
  return typeof o === "string" ? o : o.value;
425
462
  }
426
463
  function gateOptionLabel(o) {
427
464
  return typeof o === "string" ? o : o.label || o.value;
428
465
  }
466
+ var RECOMMENDED_SUFFIX = " (Recommended)";
467
+ var HAS_RECOMMENDED_SUFFIX = /\(\s*recommended\s*\)\s*$/i;
468
+ function isWordLikeLabel(label) {
469
+ return /^[a-z][^A-Z0-9/:\\@]*$/.test(label);
470
+ }
471
+ function capitalize(label) {
472
+ return isWordLikeLabel(label) ? label[0].toUpperCase() + label.slice(1) : label;
473
+ }
474
+ function normalizeGateOptions(options) {
475
+ return options.map((o) => {
476
+ const recommended = o !== null && typeof o === "object" && o.recommended === true;
477
+ let value;
478
+ let label;
479
+ if (typeof o === "string") {
480
+ value = o;
481
+ label = o;
482
+ } else if (o !== null && typeof o === "object") {
483
+ const v = typeof o.value === "string" ? o.value : undefined;
484
+ const l = typeof o.label === "string" ? o.label : undefined;
485
+ value = v ?? l ?? String(o);
486
+ label = l ?? v ?? String(o);
487
+ } else {
488
+ value = String(o);
489
+ label = String(o);
490
+ }
491
+ label = capitalize(label);
492
+ if (recommended && label && !HAS_RECOMMENDED_SUFFIX.test(label))
493
+ label += RECOMMENDED_SUFFIX;
494
+ return { value, label };
495
+ });
496
+ }
497
+ function normalizeGateQuestions(questions) {
498
+ return questions.map((q) => ({ ...q, options: normalizeGateOptions(q.options) }));
499
+ }
500
+
501
+ // src/commands.ts
502
+ var GATE_BY_PANE = "pane";
429
503
  var COMMAND_NAMES = [
430
504
  "project-mrs:read",
431
505
  "discussions:read",
@@ -547,41 +621,6 @@ function gatePresentation(args) {
547
621
  return "wait";
548
622
  return args.questions.every((q) => q.options.length <= GATE_FORM_OPTION_CAP) ? "form" : "wait";
549
623
  }
550
- // src/gate-options.ts
551
- var RECOMMENDED_SUFFIX = " (Recommended)";
552
- var HAS_RECOMMENDED_SUFFIX = /\(\s*recommended\s*\)\s*$/i;
553
- function isWordLikeLabel(label) {
554
- return /^[a-z][^A-Z0-9/:\\@]*$/.test(label);
555
- }
556
- function capitalize(label) {
557
- return isWordLikeLabel(label) ? label[0].toUpperCase() + label.slice(1) : label;
558
- }
559
- function normalizeGateOptions(options) {
560
- return options.map((o) => {
561
- const recommended = o !== null && typeof o === "object" && o.recommended === true;
562
- let value;
563
- let label;
564
- if (typeof o === "string") {
565
- value = o;
566
- label = o;
567
- } else if (o !== null && typeof o === "object") {
568
- const v = typeof o.value === "string" ? o.value : undefined;
569
- const l = typeof o.label === "string" ? o.label : undefined;
570
- value = v ?? l ?? String(o);
571
- label = l ?? v ?? String(o);
572
- } else {
573
- value = String(o);
574
- label = String(o);
575
- }
576
- label = capitalize(label);
577
- if (recommended && label && !HAS_RECOMMENDED_SUFFIX.test(label))
578
- label += RECOMMENDED_SUFFIX;
579
- return { value, label };
580
- });
581
- }
582
- function normalizeGateQuestions(questions) {
583
- return questions.map((q) => ({ ...q, options: normalizeGateOptions(q.options) }));
584
- }
585
624
  // src/gate-answers.ts
586
625
  function unwrapGateAnswerValue(raw) {
587
626
  if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) {
@@ -690,10 +729,10 @@ async function daemonHealth(opts = {}) {
690
729
  }
691
730
  // src/repos.ts
692
731
  import { existsSync, readFileSync } from "fs";
693
- import { homedir as homedir2 } from "os";
694
- import { dirname, join as join2 } from "path";
732
+ import { homedir as homedir3 } from "os";
733
+ import { dirname, join as join3 } from "path";
695
734
  function defaultReposJsonPath() {
696
- return join2(homedir2(), ".mattstack", "rt", "repos.json");
735
+ return join3(homedir3(), ".mattstack", "rt", "repos.json");
697
736
  }
698
737
  function loadBunSqliteDatabase() {
699
738
  try {
@@ -745,7 +784,7 @@ function repoNameFromJson(repoPath, reposJsonPath) {
745
784
  }
746
785
  function repoNameForPath(repoPath, reposJsonPath) {
747
786
  const jsonPath = reposJsonPath ?? defaultReposJsonPath();
748
- const dbPath = join2(dirname(jsonPath), "state.db");
787
+ const dbPath = join3(dirname(jsonPath), "state.db");
749
788
  const fromDb = repoNameFromStateDb(repoPath, dbPath);
750
789
  if (fromDb !== null)
751
790
  return fromDb;
@@ -825,33 +864,33 @@ function formatPaneRef(paneId, server) {
825
864
  return BG_PREFIX + paneId;
826
865
  }
827
866
  // src/settings/resolve.ts
828
- import { homedir as homedir4 } from "os";
829
- import { join as join5 } from "path";
867
+ import { homedir as homedir5 } from "os";
868
+ import { join as join6 } from "path";
830
869
 
831
870
  // src/settings/paths.ts
832
871
  import { readFileSync as readFileSync2 } from "fs";
833
- import { homedir as homedir3, hostname } from "os";
834
- import { join as join3 } from "path";
872
+ import { homedir as homedir4, hostname } from "os";
873
+ import { join as join4 } from "path";
835
874
  function home() {
836
- return process.env.HOME ?? homedir3();
875
+ return process.env.HOME ?? homedir4();
837
876
  }
838
877
  function userSettingsPath() {
839
- return join3(home(), ".mattstack", "user", "settings.user.jsonc");
878
+ return join4(home(), ".mattstack", "user", "settings.user.jsonc");
840
879
  }
841
880
  function teamSettingsPath(team) {
842
- return join3(teamsDir(), team, "mattstack", "settings.team.jsonc");
881
+ return join4(teamsDir(), team, "mattstack", "settings.team.jsonc");
843
882
  }
844
883
  function teamLocalPath(team) {
845
- return join3(home(), ".mattstack", "rt", "teams", `${team}.json`);
884
+ return join4(home(), ".mattstack", "rt", "teams", `${team}.json`);
846
885
  }
847
886
  function machineSettingsPath() {
848
- return join3(home(), ".mattstack", "user", "local", machineKey(), "settings.local.jsonc");
887
+ return join4(home(), ".mattstack", "user", "local", machineKey(), "settings.local.jsonc");
849
888
  }
850
889
  function teamsDir() {
851
- return join3(home(), ".mattstack", "teams");
890
+ return join4(home(), ".mattstack", "teams");
852
891
  }
853
892
  function machineKey() {
854
- const override = join3(home(), ".mattstack", "machine-key");
893
+ const override = join4(home(), ".mattstack", "machine-key");
855
894
  try {
856
895
  const v = readFileSync2(override, "utf8").trim();
857
896
  if (isSafeMachineKeySegment(v))
@@ -1590,7 +1629,7 @@ function findPathGuardViolation(value, guardFields) {
1590
1629
  // src/settings/stores.ts
1591
1630
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
1592
1631
  import { parse } from "jsonc-parser";
1593
- import { join as join4 } from "path";
1632
+ import { join as join5 } from "path";
1594
1633
  var EMPTY_STORE = (file, exists) => ({
1595
1634
  global: {},
1596
1635
  repos: {},
@@ -1637,13 +1676,13 @@ function listTeams() {
1637
1676
  const teams = [];
1638
1677
  for (const entry of entries) {
1639
1678
  try {
1640
- const isDir = entry.isDirectory() || entry.isSymbolicLink() && statSync(join4(dir, entry.name)).isDirectory();
1679
+ const isDir = entry.isDirectory() || entry.isSymbolicLink() && statSync(join5(dir, entry.name)).isDirectory();
1641
1680
  if (!isDir)
1642
1681
  continue;
1643
1682
  if (existsSync2(teamSettingsPath(entry.name)))
1644
1683
  teams.push(entry.name);
1645
1684
  } catch (err) {
1646
- console.warn(`rt: skipping unreadable teams entry ${join4(dir, entry.name)}: ${err.message}`);
1685
+ console.warn(`rt: skipping unreadable teams entry ${join5(dir, entry.name)}: ${err.message}`);
1647
1686
  }
1648
1687
  }
1649
1688
  return teams;
@@ -1692,7 +1731,7 @@ function teamPath(teamsDir2, name) {
1692
1731
  if (name.includes("/") || name.includes("\\") || name.includes("..")) {
1693
1732
  throw new Error(`rt: cannot expand \${team:${name}} — a team name must be a single directory segment (no "/", "\\" or "..")`);
1694
1733
  }
1695
- return join5(teamsDir2, name);
1734
+ return join6(teamsDir2, name);
1696
1735
  }
1697
1736
  function required(value, name, needs) {
1698
1737
  if (value === undefined || value === "") {
@@ -1867,7 +1906,7 @@ function expandCtxFrom(opts) {
1867
1906
  return {
1868
1907
  repoRoot: opts.expandCtx?.repoRoot,
1869
1908
  worktree: opts.expandCtx?.worktree,
1870
- home: process.env.HOME ?? homedir4(),
1909
+ home: process.env.HOME ?? homedir5(),
1871
1910
  teamsDir: teamsDir()
1872
1911
  };
1873
1912
  }
@@ -2408,6 +2447,7 @@ export {
2408
2447
  herdAttend,
2409
2448
  herdAsk,
2410
2449
  herdAnswer,
2450
+ guardTestDaemonEnv,
2411
2451
  getSetting,
2412
2452
  getRun,
2413
2453
  getDef,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Strict by design: a JSON string is iterable and has .includes(), so an
3
+ * unvalidated parse would let a malformed value corrupt the merge here or
4
+ * silently disarm the transport guard. Anything but a string array throws.
5
+ */
6
+ export declare function parseForbidSocks(raw: string): string[];
7
+ export declare function guardTestDaemonEnv(env?: NodeJS.ProcessEnv): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mattstack/rt-client",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -15,6 +15,12 @@
15
15
  "import": "./dist/settings/identity-codec.js",
16
16
  "default": "./dist/settings/identity-codec.js"
17
17
  },
18
+ "./gate": {
19
+ "types": "./dist/gate.d.ts",
20
+ "bun": "./src/gate.ts",
21
+ "import": "./dist/gate.js",
22
+ "default": "./dist/gate.js"
23
+ },
18
24
  "./test/fake-daemon.ts": "./test/fake-daemon.ts"
19
25
  },
20
26
  "peerDependencies": {
@@ -45,7 +51,7 @@
45
51
  "bun": ">=1.0.0"
46
52
  },
47
53
  "scripts": {
48
- "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && bun build src/settings/identity-codec.ts --outfile dist/settings/identity-codec.js --target browser --format esm && tsc -p tsconfig.json",
54
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && bun build src/settings/identity-codec.ts --outfile dist/settings/identity-codec.js --target browser --format esm && bun build src/gate.ts --outfile dist/gate.js --target browser --format esm && tsc -p tsconfig.json",
49
55
  "check-types": "tsc --noEmit -p tsconfig.json",
50
56
  "prepack": "bun run build"
51
57
  },
package/src/commands.ts CHANGED
@@ -107,12 +107,10 @@ export interface GateOrigin {
107
107
  presentation?: "form" | "wait";
108
108
  }
109
109
  export interface GateQuestion { id: string; label: string; multi: boolean; options: GateOption[] }
110
- export function gateOptionValue(o: GateOption): string {
111
- return typeof o === "string" ? o : o.value;
112
- }
113
- export function gateOptionLabel(o: GateOption): string {
114
- return typeof o === "string" ? o : (o.label || o.value);
115
- }
110
+ /** Implementations live in gate-options.ts (the browser-safe ./gate
111
+ subpath); re-exported here so existing commands.ts/index.ts consumers
112
+ are unaffected. */
113
+ export { gateOptionValue, gateOptionLabel } from "./gate-options.ts";
116
114
  /** `session` is the answering surface's own session id, recorded so the
117
115
  push facility can tell a self-answer from a remote one and skip the
118
116
  doorbell it would otherwise send back to the writer. Optional: a caller
@@ -1,5 +1,5 @@
1
1
  import type { GateQuestion, GateAnswer } from "./commands.ts";
2
- import { gateOptionValue } from "./commands.ts";
2
+ import { gateOptionValue } from "./gate-options.ts";
3
3
 
4
4
  export type GateAnswerWire = GateAnswer["answers"][string];
5
5
 
@@ -8,6 +8,13 @@ export interface GateOptionObject {
8
8
  label: string;
9
9
  }
10
10
 
11
+ export function gateOptionValue(o: GateOption): string {
12
+ return typeof o === "string" ? o : o.value;
13
+ }
14
+ export function gateOptionLabel(o: GateOption): string {
15
+ return typeof o === "string" ? o : (o.label || o.value);
16
+ }
17
+
11
18
  /** Suffix gate-kit's stripRecommended (mattstack-apps repo,
12
19
  packages/gate-kit/src/options.ts) parses off a label to render its own
13
20
  "recommended" badge. This is the
package/src/gate.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Browser-safe entry point (the "./gate" subpath export): the pure gate
3
+ * helpers only, so a browser bundle (mattstack-apps gate-kit) never drags in
4
+ * commands.ts's Node-only neighbors the way importing from "." would.
5
+ */
6
+ export * from "./gate-answers.ts";
7
+ export * from "./gate-options.ts";
8
+ export * from "./gate-presentation.ts";
9
+ export type { GateOption, GateQuestion, GateAnswer } from "./commands.ts";
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
2
2
  export type { RtResponse, RtClientOptions } from "./transport.ts";
3
+ export { guardTestDaemonEnv } from "./test-isolation.ts";
3
4
 
4
5
  export {
5
6
  readProjectMRs,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Test-run isolation for suites that repoint HOME at a throwaway directory.
3
+ * An ambient RT_DAEMON_SOCK (herdr panes, agent shells) routes rtCommand at
4
+ * a live daemon over the top of the fake HOME, so a repointed HOME alone is
5
+ * not isolation. A test preload calls this BEFORE repointing HOME: it strips
6
+ * the live pointers from the env and arms RT_TEST_FORBID_SOCKS, the list of
7
+ * sockets rtCommand refuses to dispatch at (transport.ts).
8
+ */
9
+ import { homedir } from "os";
10
+ import { join } from "path";
11
+
12
+ /**
13
+ * Strict by design: a JSON string is iterable and has .includes(), so an
14
+ * unvalidated parse would let a malformed value corrupt the merge here or
15
+ * silently disarm the transport guard. Anything but a string array throws.
16
+ */
17
+ export function parseForbidSocks(raw: string): string[] {
18
+ const parsed: unknown = JSON.parse(raw);
19
+ if (!Array.isArray(parsed) || parsed.some((path) => typeof path !== "string")) {
20
+ throw new Error("rt-client: RT_TEST_FORBID_SOCKS must be a JSON string array of socket paths");
21
+ }
22
+ return parsed;
23
+ }
24
+
25
+ export function guardTestDaemonEnv(env: NodeJS.ProcessEnv = process.env): void {
26
+ const forbidden = new Set<string>();
27
+ if (env.RT_TEST_FORBID_SOCKS) {
28
+ for (const path of parseForbidSocks(env.RT_TEST_FORBID_SOCKS)) forbidden.add(path);
29
+ }
30
+ if (env.RT_DAEMON_SOCK) forbidden.add(env.RT_DAEMON_SOCK);
31
+ if (env.RT_APP_SOCKET) forbidden.add(env.RT_APP_SOCKET);
32
+ forbidden.add(join(env.HOME ?? homedir(), ".mattstack", "rt", "rt.sock"));
33
+ delete env.RT_DAEMON_SOCK;
34
+ delete env.RT_APP_SOCKET;
35
+ env.RT_TEST_FORBID_SOCKS = JSON.stringify([...forbidden]);
36
+ }
package/src/transport.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { homedir } from "os";
11
11
  import { join } from "path";
12
+ import { parseForbidSocks } from "./test-isolation.ts";
12
13
 
13
14
  export interface RtResponse<T = unknown> {
14
15
  ok: boolean;
@@ -56,12 +57,31 @@ function defaultSock(): string {
56
57
  */
57
58
  export const DEFAULT_SOCK = defaultSock();
58
59
 
60
+ /**
61
+ * The one exception to the never-throws contract below: when a test preload
62
+ * has armed RT_TEST_FORBID_SOCKS (see test-isolation.ts), dispatching at a
63
+ * listed socket throws instead of degrading. A degrade envelope could be
64
+ * tolerated by the calling test; escaping isolation must fail the run loudly.
65
+ * A malformed list throws too rather than silently disarming the guard.
66
+ */
67
+ function assertSockNotForbidden(cmd: string, sockPath: string): void {
68
+ const raw = process.env.RT_TEST_FORBID_SOCKS;
69
+ if (!raw) return;
70
+ const forbidden = parseForbidSocks(raw);
71
+ if (forbidden.includes(sockPath)) {
72
+ throw new Error(
73
+ `rt-client: refusing "${cmd}" at forbidden socket ${sockPath}: RT_TEST_FORBID_SOCKS marks it as a live daemon socket, so this dispatch would have escaped test isolation`,
74
+ );
75
+ }
76
+ }
77
+
59
78
  export async function rtCommand<T = unknown>(
60
79
  cmd: string,
61
80
  payload: Record<string, unknown>,
62
81
  opts: { sockPath?: string; timeoutMs?: number } = {},
63
82
  ): Promise<RtResponse<T>> {
64
83
  const sockPath = opts.sockPath ?? defaultSock();
84
+ assertSockNotForbidden(cmd, sockPath);
65
85
  try {
66
86
  const res = await fetch(`http://localhost/${cmd}`, {
67
87
  unix: sockPath,