@mattstack/rt-client 0.1.1 → 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/src/commands.ts CHANGED
@@ -45,12 +45,96 @@ export interface MrByBranchData {
45
45
  syncedAt: number;
46
46
  }
47
47
 
48
+ /** Forges the daemon can hold a token for. */
49
+ export type ForgeSlug = "gitlab" | "github";
50
+
51
+ export interface ForgeTokenData {
52
+ token: string;
53
+ }
54
+
55
+ /**
56
+ * Duplicated shape on purpose (RT-44): rt-client cannot import daemon
57
+ * internals, so this mirrors lib/daemon/events-bus.ts's BusEvent.
58
+ */
59
+ export interface EventsBusEvent { id: number; topic: string; payload: unknown; emittedAt: number }
60
+
61
+ export interface RunSummary {
62
+ id: string; repo: string; work_type: string; pipeline: string;
63
+ status: string; current_stage: string | null; spawned_by: string | null;
64
+ started_at: number; ended_at: number | null;
65
+ }
66
+ export interface RunStageRow { name: string; status: string; attempt: number; started_at: number | null; ended_at: number | null; }
67
+ export interface RunFieldRow { key: string; value: string; produced_by: string; at: number; }
68
+ export interface RunDecisionRow { contract: string; scope: string; selection: string; decided_by: string; decided_at: number; }
69
+ export interface RunDetail { run: RunSummary; stages: RunStageRow[]; fields: RunFieldRow[]; decisions: RunDecisionRow[]; schemaAhead: boolean; }
70
+
48
71
  export interface Commands {
49
72
  "project-mrs:read": { payload: { repoName: string; maxAgeMs?: number; demand?: DemandDecl }; data: ProjectMRsData };
50
73
  "discussions:read": { payload: { repoName: string; iid: number }; data: DiscussionsData };
51
74
  "mr:by-branch": { payload: { repoName: string; branches: string[] }; data: MrByBranchData };
75
+ /**
76
+ * The forge token for one tracked repo (MAT-33). Repo-scoped on purpose:
77
+ * rt gates access per repo through repo-tracking.json, and this verb is
78
+ * what lets consumers stop reading ~/.mattstack/rt/secrets.json directly, which
79
+ * walked around that grant model entirely. An untracked repo is refused;
80
+ * the caller's env vars keep precedence on the caller's side.
81
+ */
82
+ "secrets:forge-token": { payload: { repoName: string; forge: ForgeSlug }; data: ForgeTokenData };
83
+ /**
84
+ * A per-`scope` whitelisted subset of secrets, each scope reading its own
85
+ * encrypted domain(s): "extension" (default, so the VS Code extension
86
+ * needs no change) is linearApiKey/gitlabToken from the `rt` domain;
87
+ * "deck" is cfApiToken/cfZoneId from the `deck` domain; "board" is
88
+ * cross-domain — slackToken/slackClientSecret/slackSigningSecret from the
89
+ * `board` domain plus gitlabToken/switchboardToken/switchboardAdminToken
90
+ * from the `rt` domain. `data` is a union of the per-scope shapes, not a
91
+ * merged bag of every key — that makes a caller narrowing on the wrong
92
+ * scope's fields a compile error instead of a silent `undefined`. Every
93
+ * key optional (present only when set). Not a general secrets export —
94
+ * extend a whitelist here, in lockstep with
95
+ * lib/daemon/handlers/secrets.ts and (for "extension")
96
+ * extensions/vscode/rt-context/src/secrets.ts, if a consumer needs another
97
+ * key.
98
+ *
99
+ * `token` is required and checked in the HANDLER (not a transport-layer
100
+ * gate alone), since this verb is reachable over the unauthenticated unix
101
+ * socket too — see lib/daemon/handlers/secrets.ts's doc comment. HTTP
102
+ * callers get it forwarded automatically from their X-RT-Token header;
103
+ * socket callers must read ~/.mattstack/rt/api-token themselves. The gate
104
+ * applies identically to every scope.
105
+ */
106
+ "secrets:read": {
107
+ payload: { token?: string; scope?: "extension" | "deck" | "board" };
108
+ data:
109
+ | { linearApiKey?: string; gitlabToken?: string }
110
+ | { cfApiToken?: string; cfZoneId?: string }
111
+ | {
112
+ slackToken?: string;
113
+ slackClientSecret?: string;
114
+ slackSigningSecret?: string;
115
+ gitlabToken?: string;
116
+ switchboardToken?: string;
117
+ switchboardAdminToken?: string;
118
+ };
119
+ };
120
+ "events:emit": { payload: { topic: string; payload?: unknown }; data: { id: number } };
121
+ "events:wait": { payload: { pattern: string; after?: number; waitMs?: number }; data: { events: EventsBusEvent[]; cursor: number } };
122
+ "events:list": { payload: { pattern: string; after?: number; limit?: number }; data: { events: EventsBusEvent[]; cursor: number } };
123
+ "runs:list": { payload: { repo?: string }; data: { runs: RunSummary[] } };
124
+ "runs:get": { payload: { runId: string; repo?: string }; data: RunDetail };
52
125
  }
53
126
 
54
127
  export type CommandName = keyof Commands;
55
128
 
56
- export const COMMAND_NAMES: readonly CommandName[] = ["project-mrs:read", "discussions:read", "mr:by-branch"];
129
+ export const COMMAND_NAMES: readonly CommandName[] = [
130
+ "project-mrs:read",
131
+ "discussions:read",
132
+ "mr:by-branch",
133
+ "secrets:forge-token",
134
+ "secrets:read",
135
+ "events:emit",
136
+ "events:wait",
137
+ "events:list",
138
+ "runs:list",
139
+ "runs:get",
140
+ ];
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
2
2
  export type { RtResponse, RtClientOptions } from "./transport.ts";
3
3
 
4
- export { readProjectMRs, readDiscussions, readMrsByBranch } from "./client.ts";
4
+ export { readProjectMRs, readDiscussions, readMrsByBranch, resolveForgeToken } from "./client.ts";
5
5
 
6
6
  export { COMMAND_NAMES } from "./commands.ts";
7
7
  export type {
@@ -14,9 +14,37 @@ export type {
14
14
  MrByBranchData,
15
15
  Commands,
16
16
  CommandName,
17
+ ForgeSlug,
18
+ ForgeTokenData,
17
19
  } from "./commands.ts";
18
20
 
19
21
  export { subscribe, DEFAULT_WS_URL } from "./relay.ts";
20
22
  export type { RelayEventType } from "./relay.ts";
21
23
 
22
24
  export { repoNameForPath } from "./repos.ts";
25
+
26
+ // ─── Settings (RT-50) ────────────────────────────────────────────────────────
27
+
28
+ export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER } from "./settings/resolve.ts";
29
+ export type {
30
+ Scope,
31
+ Provenance,
32
+ ResolveOpts,
33
+ Resolved,
34
+ InvalidScope,
35
+ ListedSetting,
36
+ ExplainRow,
37
+ ExpandCtx,
38
+ } from "./settings/resolve.ts";
39
+
40
+ export { setSetting } from "./settings/write.ts";
41
+ export type { SetSettingOpts } from "./settings/write.ts";
42
+
43
+ export { getDef, allDefs, validateValue, isMigrated } from "./settings/registry-machinery.ts";
44
+ export type { SettingDef, SettingScope } from "./settings/registry-machinery.ts";
45
+ export { REGISTRY } from "./settings/registry-defs.ts";
46
+
47
+ export { readStore, listTeams } from "./settings/stores.ts";
48
+ export type { StoreFile } from "./settings/stores.ts";
49
+
50
+ export { normalizeRemote, identityFromRemote, deriveRepoIdentity, clearIdentityMemo } from "./settings/identity.ts";
package/src/repos.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Repo-name resolution against rt's global index (~/.rt/repos.json), a flat
2
+ * Repo-name resolution against rt's global index (~/.mattstack/rt/repos.json),
3
+ * a flat
3
4
  * `{ "<repoName>": "<absolute path>" }` map. Lets a client resolve "what
4
5
  * directory am I in" to "what does the daemon call this repo" without
5
6
  * maintaining its own copy of the mapping.
@@ -9,7 +10,10 @@ import { homedir } from "os";
9
10
  import { join } from "path";
10
11
 
11
12
  function defaultReposJsonPath(): string {
12
- return join(homedir(), ".rt", "repos.json");
13
+ // Duplicates the ~/.mattstack/rt layout: rt-client has no dependency on rt's
14
+ // lib/, so this literal cannot import rtDir(). repo-tools/lib/rt-paths.ts is
15
+ // the authority — change there first, mirror here.
16
+ return join(homedir(), ".mattstack", "rt", "repos.json");
13
17
  }
14
18
 
15
19
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Async subprocess capture, duplicated from repo-tools/lib/subprocess.ts:
3
+ * rt-client has no dependency on rt's lib/, so this can't import runCapture
4
+ * from there. lib/subprocess.ts is the authority — change there first,
5
+ * mirror here.
6
+ *
7
+ * execSync blocks the event loop for the entire child lifetime; identity
8
+ * derivation must stay safe to call from daemon contexts, hence this instead.
9
+ */
10
+
11
+ export interface RunResult {
12
+ stdout: string;
13
+ stderr: string;
14
+ exitCode: number;
15
+ }
16
+
17
+ /**
18
+ * Run argv and capture stdout. Never throws: spawn failures and timeouts
19
+ * surface as a non-zero exitCode with whatever stdout was collected.
20
+ *
21
+ * Children inherit the caller's live `process.env` unless `opts.env` overrides it.
22
+ */
23
+ export async function runCapture(
24
+ argv: [string, ...string[]],
25
+ opts: {
26
+ cwd?: string;
27
+ timeoutMs?: number;
28
+ stderr?: "ignore" | "pipe";
29
+ env?: Record<string, string | undefined>;
30
+ } = {},
31
+ ): Promise<RunResult> {
32
+ const captureStderr = opts.stderr === "pipe";
33
+ let proc: ReturnType<typeof Bun.spawn>;
34
+ try {
35
+ proc = Bun.spawn(argv, {
36
+ cwd: opts.cwd,
37
+ // Bun.spawn ignores assignments made to process.env after startup, so an
38
+ // inherited env strands a PATH resolved at boot and leaves
39
+ // `#!/usr/bin/env node` shebangs unresolvable under launchd. execSync,
40
+ // which this replaces, reads process.env per call.
41
+ env: opts.env ?? { ...process.env },
42
+ stdin: "ignore",
43
+ stdout: "pipe",
44
+ stderr: captureStderr ? "pipe" : "ignore",
45
+ });
46
+ } catch {
47
+ return { stdout: "", stderr: "", exitCode: -1 };
48
+ }
49
+
50
+ const timer = setTimeout(() => {
51
+ try { proc.kill(); } catch { /* already exited */ }
52
+ }, opts.timeoutMs ?? 10_000);
53
+
54
+ try {
55
+ const stdoutPromise = new Response(proc.stdout as ReadableStream).text();
56
+ const stderrPromise = captureStderr
57
+ ? new Response(proc.stderr as ReadableStream).text()
58
+ : Promise.resolve("");
59
+ const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
60
+ const exitCode = await proc.exited;
61
+ return { stdout, stderr, exitCode };
62
+ } catch {
63
+ return { stdout: "", stderr: "", exitCode: -1 };
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Repo identity: the normalized-remote string that keys `repos.<identity>`
3
+ * sections in every settings store (RT-47 spec, "Repo identity").
4
+ *
5
+ * Identity is `host/path` (lowercase host, path case preserved), derived from
6
+ * `remote.origin.url` — never a filesystem path, so it is checkout-location
7
+ * independent: every worktree of a repo shares the same remote and therefore
8
+ * the same identity. A remote that doesn't match a recognized host form
9
+ * (bare local paths are the main case — repos.json has two) normalizes to
10
+ * null, meaning repo-scoped sections are unreachable for it and only global
11
+ * scopes apply. That's an honest degrade, not a crash.
12
+ *
13
+ * Three entry points:
14
+ * - `normalizeRemote` is the pure string transform, no I/O.
15
+ * - `identityFromRemote` layers the machine store's fork/multi-remote
16
+ * overrides (`rt.repoIdentityOverrides`, keyed by observed remote URL) on
17
+ * top of `normalizeRemote`. It's synchronous — the one helper every
18
+ * non-derivation site uses (run.ts, buildInterceptRules, tests) — so
19
+ * fork-pinning works everywhere identity is computed from a remote in
20
+ * hand, not just at derivation time.
21
+ * - `deriveRepoIdentity` is the async entry point for when only a repo path
22
+ * is in hand: it shells out to git for the remote (never a sync spawn —
23
+ * this must stay safe to call from daemon contexts) and then routes
24
+ * through `identityFromRemote`, memoized per path so repeated callers in
25
+ * one process don't re-spawn git.
26
+ */
27
+
28
+ import { runCapture } from "./exec.ts";
29
+ import { machineSettingsPath } from "./paths.ts";
30
+ import { readStore } from "./stores.ts";
31
+
32
+ // Full-URL forms: scheme://[user[:pass]@]host/path — https, ssh, git, http, ...
33
+ const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
34
+
35
+ // scp-like scp syntax: [user@]host:path (git@gitlab.com:group/repo.git).
36
+ // Deliberately excludes anything starting with "/" (absolute local paths)
37
+ // so a Windows-drive-letter-free local remote never falsely matches.
38
+ const SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
39
+
40
+ /**
41
+ * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
42
+ * embedded credentials stripped) or null when the remote doesn't match a
43
+ * recognized host form (local paths, garbage input).
44
+ */
45
+ export function normalizeRemote(remote: string): string | null {
46
+ const trimmed = remote.trim();
47
+ if (!trimmed) return null;
48
+
49
+ let host: string | undefined;
50
+ let path: string | undefined;
51
+
52
+ const urlMatch = URL_RE.exec(trimmed);
53
+ if (urlMatch) {
54
+ host = urlMatch[1];
55
+ path = urlMatch[2];
56
+ } else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
57
+ const scpMatch = SCP_RE.exec(trimmed);
58
+ if (scpMatch) {
59
+ host = scpMatch[1];
60
+ path = scpMatch[2];
61
+ }
62
+ }
63
+
64
+ if (!host || !path) return null;
65
+
66
+ const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
67
+ if (!normalizedPath) return null;
68
+
69
+ return `${host.toLowerCase()}/${normalizedPath}`;
70
+ }
71
+
72
+ /**
73
+ * The sync helper every non-derivation call site uses: machine-store
74
+ * fork/multi-remote overrides (exact remote-URL match) then normalizeRemote.
75
+ * Reads the machine store fresh each call (files are small; store reads are
76
+ * not memoized anywhere in the resolver design).
77
+ */
78
+ export function identityFromRemote(remote: string): string | null {
79
+ const store = readStore(machineSettingsPath());
80
+ const overrides = store.global["rt.repoIdentityOverrides"];
81
+ if (overrides !== null && typeof overrides === "object" && !Array.isArray(overrides)) {
82
+ const hit = (overrides as Record<string, unknown>)[remote];
83
+ if (typeof hit === "string") return hit;
84
+ }
85
+ return normalizeRemote(remote);
86
+ }
87
+
88
+ // Per-process, per-repo-path memoization. Promise-valued so concurrent
89
+ // callers for the same path share one spawn rather than racing.
90
+ const memo = new Map<string, Promise<string | null>>();
91
+
92
+ /**
93
+ * Async derivation from a repo path: `git -C <repoPath> config --get
94
+ * remote.origin.url`, then identityFromRemote (so overrides apply to
95
+ * derivation too). Never a sync spawn — safe to call from daemon contexts.
96
+ *
97
+ * Only a SUCCESSFUL derivation (non-null identity) is memoized, for the life
98
+ * of the process; a remote change after that first success is NOT picked up
99
+ * until clearIdentityMemo() — documented behavior, not a bug (see spec:
100
+ * derivation is a one-time capture per process, not a live poll). A FAILED
101
+ * derivation (no remote yet, git not initialized yet, etc.) is never cached
102
+ * and is retried on every subsequent call — a caller racing repo
103
+ * provisioning (mid-clone, daemon-startup) must not permanently lose
104
+ * identity for a path just because it asked too early.
105
+ */
106
+ export async function deriveRepoIdentity(repoPath: string): Promise<string | null> {
107
+ const cached = memo.get(repoPath);
108
+ if (cached) return cached;
109
+
110
+ const result = await (async (): Promise<string | null> => {
111
+ const spawned = await runCapture(["git", "-C", repoPath, "config", "--get", "remote.origin.url"]);
112
+ if (spawned.exitCode !== 0) return null;
113
+ const remote = spawned.stdout.trim();
114
+ if (!remote) return null;
115
+ return identityFromRemote(remote);
116
+ })();
117
+
118
+ if (result !== null) memo.set(repoPath, Promise.resolve(result));
119
+ return result;
120
+ }
121
+
122
+ /** Test-only: clear the derivation memo so a test can force re-derivation. */
123
+ export function clearIdentityMemo(): void {
124
+ memo.clear();
125
+ }
@@ -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
+ }