@mattstack/rt-client 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Repo identity: the tagged value that keys `repos.<identity>` sections in
3
+ * every settings store.
4
+ *
5
+ * A `remote`-kind identity is `host/path` (lowercase host, path case
6
+ * preserved), derived from `remote.origin.url` — checkout-location
7
+ * independent, since every worktree of a repo shares the same remote. When
8
+ * no usable remote exists, identity falls back to `path`-kind: the realpath
9
+ * of the *main* worktree, which is still shared across that repo's linked
10
+ * worktrees (see `deriveRepoIdentity`) even though it is filesystem-bound.
11
+ * `deriveRepoIdentity` therefore never returns null — every repo has at
12
+ * least a path-kind identity.
13
+ *
14
+ * Three entry points:
15
+ * - `normalizeRemote` is the pure string transform, no I/O.
16
+ * - `identityFromRemote` layers the machine store's fork/multi-remote
17
+ * overrides (`rt.repoIdentityOverrides`, keyed by observed remote URL) on
18
+ * top of `normalizeRemote`. It's synchronous — the one helper every
19
+ * non-derivation site uses (run.ts, buildInterceptRules, tests) — so
20
+ * fork-pinning works everywhere identity is computed from a remote in
21
+ * hand, not just at derivation time.
22
+ * - `deriveRepoIdentity` is the async entry point for when only a repo path
23
+ * is in hand: it shells out to git for the remote (never a sync spawn —
24
+ * this must stay safe to call from daemon contexts) and then routes
25
+ * through `identityFromRemote`, memoized per path so repeated callers in
26
+ * one process don't re-spawn git.
27
+ */
28
+
29
+ import { existsSync, readFileSync, realpathSync } from "fs";
30
+ import { join } from "path";
31
+ import { runCapture } from "./exec.ts";
32
+ import { machineSettingsPath } from "./paths.ts";
33
+ import { readStore } from "./stores.ts";
34
+
35
+ // Full-URL forms: scheme://[user[:pass]@]host/path — https, ssh, git, http, ...
36
+ const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
37
+
38
+ // scp-like scp syntax: [user@]host:path (git@gitlab.com:group/repo.git).
39
+ // Deliberately excludes anything starting with "/" (absolute local paths)
40
+ // so a Windows-drive-letter-free local remote never falsely matches.
41
+ const SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
42
+
43
+ export type RepoIdentity =
44
+ | { kind: "remote"; id: string }
45
+ | { kind: "path"; id: string };
46
+
47
+ /**
48
+ * The wire form crosses the daemon socket, sits in board config, and lands in
49
+ * console's `/runs/:repo/...` URL — all of which need one slash-free segment.
50
+ * `encodeURIComponent` guarantees that and is exactly reversible.
51
+ */
52
+ export function serializeIdentity(id: RepoIdentity): string {
53
+ return `${id.kind}:${encodeURIComponent(id.id)}`;
54
+ }
55
+
56
+ export function parseIdentity(wire: string): RepoIdentity | null {
57
+ const colon = wire.indexOf(":");
58
+ if (colon === -1) return null;
59
+ const kind = wire.slice(0, colon);
60
+ if (kind !== "remote" && kind !== "path") return null;
61
+ const encoded = wire.slice(colon + 1);
62
+ let id: string;
63
+ try {
64
+ id = decodeURIComponent(encoded);
65
+ } catch {
66
+ return null;
67
+ }
68
+ // Canonical wires only: the id segment must be byte-for-byte what
69
+ // serializeIdentity emits. Guard sites validate with parseIdentity and then
70
+ // use the WIRE as a single path component (repoDataDir et al.) — a
71
+ // hand-built wire with a literal "/" ("path:../..") would otherwise parse
72
+ // and escape the state directory.
73
+ if (encodeURIComponent(id) !== encoded) return null;
74
+ return { kind, id };
75
+ }
76
+
77
+ /**
78
+ * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
79
+ * embedded credentials stripped) or null when the remote doesn't match a
80
+ * recognized host form (local paths, garbage input).
81
+ */
82
+ export function normalizeRemote(remote: string): string | null {
83
+ const trimmed = remote.trim();
84
+ if (!trimmed) return null;
85
+
86
+ let host: string | undefined;
87
+ let path: string | undefined;
88
+
89
+ const urlMatch = URL_RE.exec(trimmed);
90
+ if (urlMatch) {
91
+ host = urlMatch[1];
92
+ path = urlMatch[2];
93
+ } else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
94
+ const scpMatch = SCP_RE.exec(trimmed);
95
+ if (scpMatch) {
96
+ host = scpMatch[1];
97
+ path = scpMatch[2];
98
+ }
99
+ }
100
+
101
+ if (!host || !path) return null;
102
+
103
+ const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
104
+ if (!normalizedPath) return null;
105
+
106
+ return `${host.toLowerCase()}/${normalizedPath}`;
107
+ }
108
+
109
+ /**
110
+ * The sync helper every non-derivation call site uses: machine-store
111
+ * fork/multi-remote overrides (exact remote-URL match) then normalizeRemote.
112
+ * Reads the machine store fresh each call (files are small; store reads are
113
+ * not memoized anywhere in the resolver design).
114
+ */
115
+ export function identityFromRemote(remote: string): RepoIdentity | null {
116
+ const store = readStore(machineSettingsPath());
117
+ const overrides = store.global["rt.repoIdentityOverrides"];
118
+ if (overrides !== null && typeof overrides === "object" && !Array.isArray(overrides)) {
119
+ const hit = (overrides as Record<string, unknown>)[remote];
120
+ if (typeof hit === "string") return { kind: "remote", id: hit };
121
+ }
122
+ const normalized = normalizeRemote(remote);
123
+ return normalized === null ? null : { kind: "remote", id: normalized };
124
+ }
125
+
126
+ // Per-process, per-repo-path memoization. Promise-valued so concurrent
127
+ // callers for the same path share one spawn rather than racing.
128
+ const memo = new Map<string, Promise<RepoIdentity>>();
129
+
130
+ /**
131
+ * Async derivation from a repo path: `git -C <repoPath> config --get
132
+ * remote.origin.url`, then identityFromRemote (so overrides apply to
133
+ * derivation too). Never a sync spawn — safe to call from daemon contexts.
134
+ * Never returns null: no usable remote falls back to a path-kind identity
135
+ * (the main worktree's realpath, via `git worktree list`, so every linked
136
+ * worktree of one repo still shares the same identity).
137
+ *
138
+ * Only a `remote`-kind result is memoized, for the life of the process; a
139
+ * remote change after that first success is NOT picked up until
140
+ * clearIdentityMemo() — documented behavior, not a bug (see spec: derivation
141
+ * is a one-time capture per process, not a live poll). A `path`-kind result
142
+ * is never cached and is retried on every subsequent call — cheap to
143
+ * recompute, and a caller racing repo provisioning (mid-clone,
144
+ * daemon-startup, a remote added after the fact) must not permanently lose
145
+ * the chance to pick up a real remote just because it asked too early.
146
+ */
147
+ export async function deriveRepoIdentity(repoPath: string): Promise<RepoIdentity> {
148
+ const cached = memo.get(repoPath);
149
+ if (cached) return cached;
150
+
151
+ const result = await (async (): Promise<RepoIdentity> => {
152
+ const spawned = await runCapture(["git", "-C", repoPath, "config", "--get", "remote.origin.url"]);
153
+ if (spawned.exitCode === 0) {
154
+ const remote = spawned.stdout.trim();
155
+ const fromRemote = remote ? identityFromRemote(remote) : null;
156
+ if (fromRemote) return fromRemote;
157
+ }
158
+ // Main worktree via `git worktree list` (main is always listed first) —
159
+ // NOT `--git-common-dir/..`, which points outside the tree under
160
+ // `--separate-git-dir` and would derive one shared identity for every
161
+ // repo whose metadata lives in the same parent directory. In that layout
162
+ // git lists the git DIR as the main entry, so the listed path is resolved
163
+ // through its own `--show-toplevel`, degrading to this worktree's
164
+ // toplevel when the entry isn't a work tree at all.
165
+ const listed = await runCapture(["git", "-C", repoPath, "worktree", "list", "--porcelain"]);
166
+ const first = listed.exitCode === 0 ? /^worktree (.+)$/m.exec(listed.stdout)?.[1]?.trim() : undefined;
167
+ let base: string | undefined;
168
+ if (first) {
169
+ const top = await runCapture(["git", "-C", first, "rev-parse", "--show-toplevel"]);
170
+ if (top.exitCode === 0 && top.stdout.trim()) base = top.stdout.trim();
171
+ }
172
+ if (!base) {
173
+ const own = await runCapture(["git", "-C", repoPath, "rev-parse", "--show-toplevel"]);
174
+ base = own.exitCode === 0 && own.stdout.trim() ? own.stdout.trim() : repoPath;
175
+ }
176
+ return { kind: "path", id: safeRealpath(base) };
177
+ })();
178
+
179
+ if (result.kind === "remote") memo.set(repoPath, Promise.resolve(result));
180
+ return result;
181
+ }
182
+
183
+ /** Test-only: clear the derivation memo so a test can force re-derivation. */
184
+ export function clearIdentityMemo(): void {
185
+ memo.clear();
186
+ }
187
+
188
+ // realpath of a path that no longer exists throws ENOENT. A path-kind identity
189
+ // may be derived for a worktree whose directory is already gone (dispose flows
190
+ // call this on the tree being removed), and derivation must degrade to the
191
+ // literal path there, never throw past its callers.
192
+ function safeRealpath(p: string): string {
193
+ try {
194
+ return realpathSync(p);
195
+ } catch {
196
+ return p;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * One-shot helper for rewriting board's existing name-valued config to
202
+ * host/path identities. NOT a runtime path — the daemon never calls it.
203
+ * Resolves a repo name to the identity of the path it points at in repos.json.
204
+ */
205
+ export async function resolveNameToIdentity(
206
+ name: string,
207
+ reposJsonPath: string,
208
+ ): Promise<RepoIdentity | null> {
209
+ if (!existsSync(reposJsonPath)) return null;
210
+ try {
211
+ const index = JSON.parse(readFileSync(reposJsonPath, "utf8")) as Record<string, unknown>;
212
+ const path = index[name];
213
+ if (typeof path !== "string") return null;
214
+ return await deriveRepoIdentity(path);
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Settings-store path layout, duplicated from repo-tools/lib/rt-paths.ts:
3
+ * rt-client has no dependency on rt's lib/, so these literals cannot import
4
+ * rtDir()/userSettingsPath()/etc. lib/rt-paths.ts is the authority — change
5
+ * there first, mirror here (same convention as transport.ts's DEFAULT_SOCK
6
+ * and repos.ts's defaultReposJsonPath).
7
+ *
8
+ * HOME is resolved at CALL time via `process.env.HOME ?? homedir()`, matching
9
+ * the original, so tests can repoint the whole tree at a temp dir.
10
+ */
11
+
12
+ import { readFileSync } from "fs";
13
+ import { homedir, hostname } from "os";
14
+ import { join } from "path";
15
+
16
+ function home(): string {
17
+ return process.env.HOME ?? homedir();
18
+ }
19
+
20
+ /** ~/.mattstack/user/settings.user.jsonc — the user store. */
21
+ export function userSettingsPath(): string {
22
+ return join(home(), ".mattstack", "user", "settings.user.jsonc");
23
+ }
24
+
25
+ /** ~/.mattstack/teams/<team>/mattstack/settings.team.jsonc — the team store. */
26
+ export function teamSettingsPath(team: string): string {
27
+ return join(teamsDir(), team, "mattstack", "settings.team.jsonc");
28
+ }
29
+
30
+ /**
31
+ * ~/.mattstack/user/local/<machineKey()>/settings.local.jsonc — the machine
32
+ * store, TRACKED and keyed per machine (path literals legal here only).
33
+ */
34
+ export function machineSettingsPath(): string {
35
+ return join(home(), ".mattstack", "user", "local", machineKey(), "settings.local.jsonc");
36
+ }
37
+
38
+ /** ~/.mattstack/teams — the container every team's local clone lives under. */
39
+ export function teamsDir(): string {
40
+ return join(home(), ".mattstack", "teams");
41
+ }
42
+
43
+ /**
44
+ * The stable per-machine key that scopes the machine settings store — so
45
+ * `user/local/<key>/` never collides across machines sharing one synced
46
+ * `user/` tree.
47
+ *
48
+ * 1. `~/.mattstack/machine-key`, trimmed, if present, non-empty, and a SAFE
49
+ * PATH SEGMENT (no `/` or `\`, not `.` or `..`) — an explicit override
50
+ * for machines whose hostname isn't stable or unique (fresh installs,
51
+ * cloned VMs). The value becomes a directory name directly under
52
+ * `user/local/`, so anything else (a separator, or a segment that would
53
+ * walk up/stay put) is treated exactly as if the file were absent,
54
+ * rather than let the override escape that directory.
55
+ * 2. Otherwise the hostname, slugified: lowercased, a trailing `.local`
56
+ * dropped (mDNS suffix, not part of the identity), every run of
57
+ * characters outside `[a-z0-9-]` collapsed to one `-`, leading/trailing
58
+ * `-` trimmed. An all-illegal hostname slugs to `""`, which falls back
59
+ * to `"default"` rather than producing an empty path segment.
60
+ */
61
+ export function machineKey(): string {
62
+ const override = join(home(), ".mattstack", "machine-key");
63
+ try {
64
+ const v = readFileSync(override, "utf8").trim();
65
+ if (isSafeMachineKeySegment(v)) return v;
66
+ } catch {
67
+ // no override file — fall through to the hostname slug
68
+ }
69
+ const slug = hostname()
70
+ .toLowerCase()
71
+ .replace(/\.local$/, "")
72
+ .replace(/[^a-z0-9-]+/g, "-")
73
+ .replace(/^-+|-+$/g, "");
74
+ return slug || "default";
75
+ }
76
+
77
+ /** Mirrored verbatim from lib/rt-paths.ts's isSafeMachineKeySegment — the two must agree or a machine-key value could pass one side's check and fail the other's. */
78
+ export function isSafeMachineKeySegment(v: string): boolean {
79
+ return v.length > 0 && v !== "." && v !== ".." && !v.includes("/") && !v.includes("\\");
80
+ }