@mattstack/rt-client 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/client.d.ts +93 -2
- package/dist/commands.d.ts +318 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.js +1385 -9
- package/dist/repos.d.ts +9 -3
- package/dist/settings/exec.d.ts +26 -0
- package/dist/settings/identity.d.ts +80 -0
- package/dist/settings/paths.d.ts +42 -0
- package/dist/settings/registry-defs.d.ts +12 -0
- package/dist/settings/registry-machinery.d.ts +59 -0
- package/dist/settings/resolve.d.ts +141 -0
- package/dist/settings/stores.d.ts +53 -0
- package/dist/settings/write.d.ts +110 -0
- package/dist/transport.d.ts +7 -0
- package/package.json +10 -2
- package/src/client.ts +161 -2
- package/src/commands.ts +155 -1
- package/src/index.ts +62 -1
- package/src/repos.ts +89 -14
- package/src/settings/exec.ts +67 -0
- package/src/settings/identity.ts +218 -0
- package/src/settings/paths.ts +80 -0
- package/src/settings/registry-defs.ts +476 -0
- package/src/settings/registry-machinery.ts +141 -0
- package/src/settings/resolve.ts +608 -0
- package/src/settings/stores.ts +129 -0
- package/src/settings/write.ts +294 -0
- package/src/transport.ts +18 -3
|
@@ -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
|
+
}
|