@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.
- package/dist/commands.d.ts +4 -2
- package/dist/gate-options.d.ts +2 -0
- package/dist/gate.d.ts +9 -0
- package/dist/gate.js +103 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +98 -58
- package/dist/test-isolation.d.ts +7 -0
- package/package.json +8 -2
- package/src/commands.ts +4 -6
- package/src/gate-answers.ts +1 -1
- package/src/gate-options.ts +7 -0
- package/src/gate.ts +9 -0
- package/src/index.ts +1 -0
- package/src/test-isolation.ts +36 -0
- package/src/transport.ts +20 -0
package/dist/commands.d.ts
CHANGED
|
@@ -123,8 +123,10 @@ export interface GateQuestion {
|
|
|
123
123
|
multi: boolean;
|
|
124
124
|
options: GateOption[];
|
|
125
125
|
}
|
|
126
|
-
|
|
127
|
-
|
|
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
|
package/dist/gate-options.d.ts
CHANGED
|
@@ -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 ||
|
|
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/
|
|
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
|
|
694
|
-
import { dirname, join as
|
|
732
|
+
import { homedir as homedir3 } from "os";
|
|
733
|
+
import { dirname, join as join3 } from "path";
|
|
695
734
|
function defaultReposJsonPath() {
|
|
696
|
-
return
|
|
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 =
|
|
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
|
|
829
|
-
import { join as
|
|
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
|
|
834
|
-
import { join as
|
|
872
|
+
import { homedir as homedir4, hostname } from "os";
|
|
873
|
+
import { join as join4 } from "path";
|
|
835
874
|
function home() {
|
|
836
|
-
return process.env.HOME ??
|
|
875
|
+
return process.env.HOME ?? homedir4();
|
|
837
876
|
}
|
|
838
877
|
function userSettingsPath() {
|
|
839
|
-
return
|
|
878
|
+
return join4(home(), ".mattstack", "user", "settings.user.jsonc");
|
|
840
879
|
}
|
|
841
880
|
function teamSettingsPath(team) {
|
|
842
|
-
return
|
|
881
|
+
return join4(teamsDir(), team, "mattstack", "settings.team.jsonc");
|
|
843
882
|
}
|
|
844
883
|
function teamLocalPath(team) {
|
|
845
|
-
return
|
|
884
|
+
return join4(home(), ".mattstack", "rt", "teams", `${team}.json`);
|
|
846
885
|
}
|
|
847
886
|
function machineSettingsPath() {
|
|
848
|
-
return
|
|
887
|
+
return join4(home(), ".mattstack", "user", "local", machineKey(), "settings.local.jsonc");
|
|
849
888
|
}
|
|
850
889
|
function teamsDir() {
|
|
851
|
-
return
|
|
890
|
+
return join4(home(), ".mattstack", "teams");
|
|
852
891
|
}
|
|
853
892
|
function machineKey() {
|
|
854
|
-
const override =
|
|
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
|
|
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(
|
|
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 ${
|
|
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
|
|
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 ??
|
|
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.
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
export
|
|
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
|
package/src/gate-answers.ts
CHANGED
package/src/gate-options.ts
CHANGED
|
@@ -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
|
@@ -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,
|