@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/README.md +5 -2
- package/dist/client.d.ts +13 -1
- package/dist/commands.d.ts +156 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +1132 -4
- package/dist/settings/exec.d.ts +26 -0
- package/dist/settings/identity.d.ts +56 -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 +39 -1
- package/src/commands.ts +85 -1
- package/src/index.ts +29 -1
- package/src/repos.ts +6 -2
- package/src/settings/exec.ts +67 -0
- package/src/settings/identity.ts +125 -0
- package/src/settings/paths.ts +80 -0
- package/src/settings/registry-defs.ts +439 -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
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[] = [
|
|
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),
|
|
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
|
-
|
|
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
|
+
}
|