@mattstack/rt-client 0.2.0 → 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 +6 -2
- package/dist/commands.d.ts +138 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +1123 -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 +21 -2
- package/src/commands.ts +65 -1
- package/src/index.ts +26 -0
- 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
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
export interface RunResult {
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
exitCode: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Run argv and capture stdout. Never throws: spawn failures and timeouts
|
|
17
|
+
* surface as a non-zero exitCode with whatever stdout was collected.
|
|
18
|
+
*
|
|
19
|
+
* Children inherit the caller's live `process.env` unless `opts.env` overrides it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runCapture(argv: [string, ...string[]], opts?: {
|
|
22
|
+
cwd?: string;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
stderr?: "ignore" | "pipe";
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
26
|
+
}): Promise<RunResult>;
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
* Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
|
|
29
|
+
* embedded credentials stripped) or null when the remote doesn't match a
|
|
30
|
+
* recognized host form (local paths, garbage input).
|
|
31
|
+
*/
|
|
32
|
+
export declare function normalizeRemote(remote: string): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* The sync helper every non-derivation call site uses: machine-store
|
|
35
|
+
* fork/multi-remote overrides (exact remote-URL match) then normalizeRemote.
|
|
36
|
+
* Reads the machine store fresh each call (files are small; store reads are
|
|
37
|
+
* not memoized anywhere in the resolver design).
|
|
38
|
+
*/
|
|
39
|
+
export declare function identityFromRemote(remote: string): string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Async derivation from a repo path: `git -C <repoPath> config --get
|
|
42
|
+
* remote.origin.url`, then identityFromRemote (so overrides apply to
|
|
43
|
+
* derivation too). Never a sync spawn — safe to call from daemon contexts.
|
|
44
|
+
*
|
|
45
|
+
* Only a SUCCESSFUL derivation (non-null identity) is memoized, for the life
|
|
46
|
+
* of the process; a remote change after that first success is NOT picked up
|
|
47
|
+
* until clearIdentityMemo() — documented behavior, not a bug (see spec:
|
|
48
|
+
* derivation is a one-time capture per process, not a live poll). A FAILED
|
|
49
|
+
* derivation (no remote yet, git not initialized yet, etc.) is never cached
|
|
50
|
+
* and is retried on every subsequent call — a caller racing repo
|
|
51
|
+
* provisioning (mid-clone, daemon-startup) must not permanently lose
|
|
52
|
+
* identity for a path just because it asked too early.
|
|
53
|
+
*/
|
|
54
|
+
export declare function deriveRepoIdentity(repoPath: string): Promise<string | null>;
|
|
55
|
+
/** Test-only: clear the derivation memo so a test can force re-derivation. */
|
|
56
|
+
export declare function clearIdentityMemo(): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
/** ~/.mattstack/user/settings.user.jsonc — the user store. */
|
|
12
|
+
export declare function userSettingsPath(): string;
|
|
13
|
+
/** ~/.mattstack/teams/<team>/mattstack/settings.team.jsonc — the team store. */
|
|
14
|
+
export declare function teamSettingsPath(team: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* ~/.mattstack/user/local/<machineKey()>/settings.local.jsonc — the machine
|
|
17
|
+
* store, TRACKED and keyed per machine (path literals legal here only).
|
|
18
|
+
*/
|
|
19
|
+
export declare function machineSettingsPath(): string;
|
|
20
|
+
/** ~/.mattstack/teams — the container every team's local clone lives under. */
|
|
21
|
+
export declare function teamsDir(): string;
|
|
22
|
+
/**
|
|
23
|
+
* The stable per-machine key that scopes the machine settings store — so
|
|
24
|
+
* `user/local/<key>/` never collides across machines sharing one synced
|
|
25
|
+
* `user/` tree.
|
|
26
|
+
*
|
|
27
|
+
* 1. `~/.mattstack/machine-key`, trimmed, if present, non-empty, and a SAFE
|
|
28
|
+
* PATH SEGMENT (no `/` or `\`, not `.` or `..`) — an explicit override
|
|
29
|
+
* for machines whose hostname isn't stable or unique (fresh installs,
|
|
30
|
+
* cloned VMs). The value becomes a directory name directly under
|
|
31
|
+
* `user/local/`, so anything else (a separator, or a segment that would
|
|
32
|
+
* walk up/stay put) is treated exactly as if the file were absent,
|
|
33
|
+
* rather than let the override escape that directory.
|
|
34
|
+
* 2. Otherwise the hostname, slugified: lowercased, a trailing `.local`
|
|
35
|
+
* dropped (mDNS suffix, not part of the identity), every run of
|
|
36
|
+
* characters outside `[a-z0-9-]` collapsed to one `-`, leading/trailing
|
|
37
|
+
* `-` trimmed. An all-illegal hostname slugs to `""`, which falls back
|
|
38
|
+
* to `"default"` rather than producing an empty path segment.
|
|
39
|
+
*/
|
|
40
|
+
export declare function machineKey(): string;
|
|
41
|
+
/** 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. */
|
|
42
|
+
export declare function isSafeMachineKeySegment(v: string): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings key TABLE (RT-47/RT-50): rt's rows, plus the suite rows
|
|
3
|
+
* (deck/board/gitq/mattstack/claude) — the machinery in registry-machinery.ts
|
|
4
|
+
* that reads and validates against this table does not change when rows are
|
|
5
|
+
* added.
|
|
6
|
+
*
|
|
7
|
+
* Suite rows omit `migrated` (see registry-machinery.ts's docblock): they
|
|
8
|
+
* carry no rt-legacy file to migrate from, so `isMigrated()` treats the
|
|
9
|
+
* absent flag as resolver-backed from day one.
|
|
10
|
+
*/
|
|
11
|
+
import type { SettingDef } from "./registry-machinery.ts";
|
|
12
|
+
export declare const REGISTRY: readonly SettingDef[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings schema registry machinery (RT-47/RT-50): lookup and
|
|
3
|
+
* validation over a static table describing every known settings key.
|
|
4
|
+
*
|
|
5
|
+
* This module is pure data plumbing — no file IO, no daemon dependency, safe
|
|
6
|
+
* to import anywhere (including the daemon thread). The def TABLE itself
|
|
7
|
+
* lives in registry-defs.ts (rt's rows today; a later task adds the rest of
|
|
8
|
+
* the suite); this file only knows how to look a def up and check a value
|
|
9
|
+
* against it.
|
|
10
|
+
*
|
|
11
|
+
* `migrated: true` means the reader for this key goes through the resolver.
|
|
12
|
+
* `migrated: false` keys still appear in `rt settings list` (so the full
|
|
13
|
+
* settings map is visible even before a key's reader has been ported), but
|
|
14
|
+
* `set` on them refuses — see the spec's "Schema registry" section for why
|
|
15
|
+
* writing a value nothing reads is the dishonesty class this design bans.
|
|
16
|
+
*
|
|
17
|
+
* `migrated` is omitted entirely (not `undefined` written out) for suite keys
|
|
18
|
+
* outside rt's wave-1 legacy-file migration — deck/board/gitq/mattstack/claude
|
|
19
|
+
* defs have no legacy file to migrate FROM, so the flag is meaningless for
|
|
20
|
+
* them. `isMigrated()` is the one place that turns absence into "yes,
|
|
21
|
+
* resolver-backed" — every other module must call it rather than testing
|
|
22
|
+
* `def.migrated` directly, or a suite key's absent flag reads as `false` and
|
|
23
|
+
* `set` refuses it.
|
|
24
|
+
*/
|
|
25
|
+
export type SettingScope = "user" | "team" | "machine";
|
|
26
|
+
export interface SettingDef {
|
|
27
|
+
key: string;
|
|
28
|
+
type: "string" | "number" | "boolean" | "object" | "array";
|
|
29
|
+
scopes: SettingScope[];
|
|
30
|
+
default?: unknown;
|
|
31
|
+
merge: "replace" | "deep";
|
|
32
|
+
teamLocked?: boolean;
|
|
33
|
+
secret?: boolean;
|
|
34
|
+
repoScoped?: boolean;
|
|
35
|
+
migrated?: boolean;
|
|
36
|
+
legacyFile?: string;
|
|
37
|
+
pathGuardFields?: string[];
|
|
38
|
+
description: string;
|
|
39
|
+
}
|
|
40
|
+
/** Looks up a def by its flat namespaced key (e.g. "rt.roles"). */
|
|
41
|
+
export declare function getDef(key: string): SettingDef | undefined;
|
|
42
|
+
/** Every registered def, in registry declaration order. */
|
|
43
|
+
export declare function allDefs(): SettingDef[];
|
|
44
|
+
/** True unless `def.migrated` is explicitly `false` — see the module doc. */
|
|
45
|
+
export declare function isMigrated(def: SettingDef): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Checks whether `value` is a legal value for `def`: the JSON-ish type
|
|
48
|
+
* matches def.type, and (when def.pathGuardFields is set) no guarded field
|
|
49
|
+
* anywhere in the value looks like an absolute path or home-relative path
|
|
50
|
+
* literal — those are only legal in the machine store's own file contents,
|
|
51
|
+
* never as a shared-scope value (spec: "No path type exists ... enforced for
|
|
52
|
+
* wave-1 keys on the hook field specifically").
|
|
53
|
+
*/
|
|
54
|
+
export declare function validateValue(def: SettingDef, value: unknown): {
|
|
55
|
+
ok: true;
|
|
56
|
+
} | {
|
|
57
|
+
ok: false;
|
|
58
|
+
reason: string;
|
|
59
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings resolver (RT-47): one read path that layers the four stores
|
|
3
|
+
* and the registry default into a single answer plus the provenance that
|
|
4
|
+
* explains it.
|
|
5
|
+
*
|
|
6
|
+
* Scope ladder, weakest → strongest:
|
|
7
|
+
*
|
|
8
|
+
* default < team < user < team.repo < user.repo < machine < machine.repo
|
|
9
|
+
*
|
|
10
|
+
* Merge is per-key schema, never global (`SettingDef.merge`):
|
|
11
|
+
* - `replace` — the strongest valid scope wins atomically; provenance has
|
|
12
|
+
* exactly one entry.
|
|
13
|
+
* - `deep` — object values overlay field-by-field walking weakest → strongest;
|
|
14
|
+
* arrays and scalars inside a deep key still replace atomically. Provenance
|
|
15
|
+
* lists every scope that still owns at least one leaf of the resolved value,
|
|
16
|
+
* weakest-first — a scope whose every field was overridden is NOT listed
|
|
17
|
+
* (same honesty rule that makes `replace` provenance length 1).
|
|
18
|
+
*
|
|
19
|
+
* Degrade rules (teammates run version-skewed binaries; one unknown key in the
|
|
20
|
+
* team store must never brick resolution):
|
|
21
|
+
* - explicit `get`/`explain` of an unregistered key → throw.
|
|
22
|
+
* - unregistered keys FOUND in files → warn + skip, surfaced by `listSettings`
|
|
23
|
+
* with `unregistered: true`.
|
|
24
|
+
* - a registered key whose found value fails validation → warn + skip THAT
|
|
25
|
+
* scope only, labeled `invalid` in list/explain; weaker and stronger scopes
|
|
26
|
+
* still apply.
|
|
27
|
+
*
|
|
28
|
+
* Three deliberate decisions this file makes that the spec left to the
|
|
29
|
+
* implementation:
|
|
30
|
+
* 1. **The path-literal guard is scope-aware.** `validateValue`'s guarded
|
|
31
|
+
* fields (`rt.roles.hook`) are only illegal in SHARED scopes. The machine
|
|
32
|
+
* store is explicitly allowed path literals. So team/user rungs get the
|
|
33
|
+
* full check, the machine rung gets the type check alone.
|
|
34
|
+
* 2. **A value found in a store the def does not allow is skipped**, labeled
|
|
35
|
+
* like any other invalid value (`rt.repoIdentityOverrides` is machine-only;
|
|
36
|
+
* honouring a team-store copy of it would defeat the schema).
|
|
37
|
+
* 3. **`explain` shows values AS AUTHORED** (never expanded) because its job
|
|
38
|
+
* is to say what is in which file, and **`list` degrades** an unexpandable
|
|
39
|
+
* value to its raw form with an `expandError` label rather than throwing —
|
|
40
|
+
* one bad value must not brick a survey of every key. `get` is the loud
|
|
41
|
+
* one: an unsatisfiable closed-set variable throws.
|
|
42
|
+
*
|
|
43
|
+
* The resolver is daemon-FREE and sync: no spawns anywhere, repo identity is a
|
|
44
|
+
* pre-derived input (see identity.ts for the async derivation). Store files are
|
|
45
|
+
* parsed fresh per call — they are small, and memoization is a later
|
|
46
|
+
* optimization that would need invalidation this wave does not have.
|
|
47
|
+
*
|
|
48
|
+
* Writes (`setSetting`) land in a later task; this module is read-side only.
|
|
49
|
+
*/
|
|
50
|
+
export type Scope = "machine.repo" | "machine" | "user.repo" | "team.repo" | "user" | "team" | "default";
|
|
51
|
+
/** The scope ladder, weakest first. Also the order every result is built in. */
|
|
52
|
+
export declare const SCOPE_ORDER: Scope[];
|
|
53
|
+
export interface Provenance {
|
|
54
|
+
scope: Scope;
|
|
55
|
+
/** The file the value came from; null for the registry default. */
|
|
56
|
+
file: string | null;
|
|
57
|
+
}
|
|
58
|
+
export interface ResolveOpts {
|
|
59
|
+
/** Normalized repo identity (identity.ts). Null/absent = repo rungs are unreachable. */
|
|
60
|
+
repoIdentity?: string | null;
|
|
61
|
+
/** Expand closed-set variables in the resolved value. Default true. */
|
|
62
|
+
expand?: boolean;
|
|
63
|
+
expandCtx?: {
|
|
64
|
+
repoRoot?: string;
|
|
65
|
+
worktree?: string;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export interface Resolved<T> {
|
|
69
|
+
value: T;
|
|
70
|
+
/** ALWAYS an array, weakest-first. Length 1 for replace keys. */
|
|
71
|
+
provenance: Provenance[];
|
|
72
|
+
}
|
|
73
|
+
/** A scope whose authored value was found but refused (type, path guard, or store). */
|
|
74
|
+
export interface InvalidScope {
|
|
75
|
+
scope: Scope;
|
|
76
|
+
file: string | null;
|
|
77
|
+
reason: string;
|
|
78
|
+
}
|
|
79
|
+
export interface ListedSetting {
|
|
80
|
+
key: string;
|
|
81
|
+
value: unknown;
|
|
82
|
+
provenance: Provenance[];
|
|
83
|
+
migrated: boolean;
|
|
84
|
+
/** Present only for keys found in files but absent from the registry. */
|
|
85
|
+
unregistered?: true;
|
|
86
|
+
/** Scopes skipped during resolution, with the reason each was refused. */
|
|
87
|
+
invalid?: InvalidScope[];
|
|
88
|
+
/** Set when the value could not be expanded here; `value` is then raw. */
|
|
89
|
+
expandError?: string;
|
|
90
|
+
}
|
|
91
|
+
export interface ExplainRow {
|
|
92
|
+
scope: Scope;
|
|
93
|
+
file: string | null;
|
|
94
|
+
present: boolean;
|
|
95
|
+
/** The value AS AUTHORED — never variable-expanded. */
|
|
96
|
+
value?: unknown;
|
|
97
|
+
/** Set when the value was ignored because the key is teamLocked. */
|
|
98
|
+
shadowed?: "teamLocked";
|
|
99
|
+
/** Set when the value was refused; the reason it was refused. */
|
|
100
|
+
invalid?: string;
|
|
101
|
+
}
|
|
102
|
+
export interface ExpandCtx {
|
|
103
|
+
repoRoot?: string;
|
|
104
|
+
worktree?: string;
|
|
105
|
+
home: string;
|
|
106
|
+
teamsDir: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Replaces ONLY `${repoRoot}`, `${worktree}`, `${home}` and `${team:<name>}`.
|
|
110
|
+
* Every other `${...}` passes through verbatim — domain templates like the
|
|
111
|
+
* interceptor's `${port}` are not ours to expand, and the same string may hold
|
|
112
|
+
* both kinds, so substitution is per-occurrence. `${team:<name>}` is lexical:
|
|
113
|
+
* `<teamsDir>/<name>` with no existence check (a missing team surfaces at use
|
|
114
|
+
* time through the consumer's own fail-open path), but the name must be a
|
|
115
|
+
* single directory segment — see `teamPath`. A closed-set variable with no
|
|
116
|
+
* context in `ctx` throws — silently emitting a half-expanded path is the
|
|
117
|
+
* dishonesty this design bans.
|
|
118
|
+
*
|
|
119
|
+
* Recurses through arrays and plain objects; non-strings pass through. Never
|
|
120
|
+
* mutates its input.
|
|
121
|
+
*/
|
|
122
|
+
export declare function expandVariables(value: unknown, ctx: ExpandCtx): unknown;
|
|
123
|
+
/**
|
|
124
|
+
* Resolves one key across the whole ladder. Throws for an unregistered key —
|
|
125
|
+
* an explicit get of something rt has never heard of is a caller bug, not a
|
|
126
|
+
* degrade (contrast: unknown keys FOUND in files, which only warn).
|
|
127
|
+
*/
|
|
128
|
+
export declare function getSetting<T>(key: string, opts?: ResolveOpts): Resolved<T>;
|
|
129
|
+
/**
|
|
130
|
+
* Every registered key resolved (registry order), then every unregistered key
|
|
131
|
+
* found in the stores (alphabetical). Nothing here throws: a survey of the
|
|
132
|
+
* whole settings map must survive one bad value, so an unexpandable value
|
|
133
|
+
* degrades to its raw form plus an `expandError` label.
|
|
134
|
+
*/
|
|
135
|
+
export declare function listSettings(opts?: ResolveOpts): ListedSetting[];
|
|
136
|
+
/**
|
|
137
|
+
* One row per reachable rung, weakest-first, with values AS AUTHORED. Repo
|
|
138
|
+
* rungs are omitted entirely when the key is not repoScoped or no identity was
|
|
139
|
+
* supplied — showing rungs that could never apply would be noise, not honesty.
|
|
140
|
+
*/
|
|
141
|
+
export declare function explainSetting(key: string, opts?: ResolveOpts): ExplainRow[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the four settings store files (RT-47).
|
|
3
|
+
*
|
|
4
|
+
* `readStore` is the shared "raw JSONC → {global, repos}" step every store
|
|
5
|
+
* (user/team/machine) goes through before the resolver layers them by scope.
|
|
6
|
+
* It uses jsonc-parser (`parse`) rather than lib/jsonc.ts's stripJsonc: this
|
|
7
|
+
* is the one place in rt that also needs to WRITE these files back with
|
|
8
|
+
* comments/formatting intact (via jsonc-parser's `modify`/`applyEdits`, added
|
|
9
|
+
* alongside `setSetting` in resolve.ts), and both directions should go
|
|
10
|
+
* through the same library. stripJsonc keeps its existing callers.
|
|
11
|
+
*
|
|
12
|
+
* A store file is honest-degrade, not throw-on-read: absent, empty, or
|
|
13
|
+
* malformed all resolve to an empty store rather than crashing a caller that
|
|
14
|
+
* just wants "whatever settings exist" (teammates run version-skewed
|
|
15
|
+
* binaries; a store file with content this rt can't parse must not brick
|
|
16
|
+
* every settings read).
|
|
17
|
+
*/
|
|
18
|
+
export interface StoreFile {
|
|
19
|
+
/** Top-level keys other than "repos" — the global scope for this store. */
|
|
20
|
+
global: Record<string, unknown>;
|
|
21
|
+
/** The "repos" object, keyed by repo identity. Empty if absent. */
|
|
22
|
+
repos: Record<string, Record<string, unknown>>;
|
|
23
|
+
/** The path this store was read from (echoed back for provenance). */
|
|
24
|
+
file: string;
|
|
25
|
+
/** False only when the file does not exist at all. */
|
|
26
|
+
exists: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Reads and parses one settings store file. Never throws:
|
|
30
|
+
* - missing file → `{ exists: false }`, empty maps.
|
|
31
|
+
* - present but malformed (parse errors, or a root that isn't a JSON
|
|
32
|
+
* object) → `{ exists: true }`, empty maps, one console.warn.
|
|
33
|
+
* - present and well-formed → `{ exists: true }`, split into
|
|
34
|
+
* `global`/`repos`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function readStore(file: string): StoreFile;
|
|
37
|
+
/**
|
|
38
|
+
* Names of every team that has a local settings store — i.e. subdirectories
|
|
39
|
+
* of teamsDir() that contain mattstack/settings.team.jsonc. A team dir without a
|
|
40
|
+
* settings file (a clone mid-setup, or an unrelated directory) is not yet a
|
|
41
|
+
* team as far as the resolver is concerned.
|
|
42
|
+
*
|
|
43
|
+
* Honest-degrade like readStore, and for a sharper reason: this scan is on the
|
|
44
|
+
* path of EVERY settings resolution, so one bad directory entry must never
|
|
45
|
+
* brick `rt settings` or any reader behind it. A team clone that was symlinked
|
|
46
|
+
* in and later moved leaves a dangling symlink here, and the follow-the-link
|
|
47
|
+
* stat that keeps symlinked clones working throws ENOENT on exactly that — so
|
|
48
|
+
* the scan is guarded twice: around the readdir (an unreadable teams dir means
|
|
49
|
+
* no teams), and around EACH entry (a dangling link, an EACCES, or a stat that
|
|
50
|
+
* loses a race with a concurrent move skips that entry and leaves the healthy
|
|
51
|
+
* teams intact).
|
|
52
|
+
*/
|
|
53
|
+
export declare function listTeams(): string[];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings write path (RT-47): `setSetting` — a single, comment-preserving
|
|
3
|
+
* write into one of the three AUTHORED stores (user/team/machine; `default`
|
|
4
|
+
* is a read-only rung and never appears here).
|
|
5
|
+
*
|
|
6
|
+
* Writes go through jsonc-parser's `modify`/`applyEdits` rather than
|
|
7
|
+
* parse-mutate-stringify, so existing comments and formatting in the store
|
|
8
|
+
* file survive a write to an unrelated key (verified: `modify` only rewrites
|
|
9
|
+
* the minimal edit range needed for the touched path; everything else in the
|
|
10
|
+
* document — including comments — is untouched text).
|
|
11
|
+
*
|
|
12
|
+
* JSONPath segments are literal object keys, not `.`-namespaced walks: a
|
|
13
|
+
* global write targets `[key]` (e.g. `["rt.hooks"]`) and a repoScoped write
|
|
14
|
+
* targets `["repos", identity, key]`. A dotted key like `"rt.roles"` is one
|
|
15
|
+
* path segment, not two — jsonc-parser never splits on `.` (verified with a
|
|
16
|
+
* throwaway script against the installed 3.3.1). Missing parents (`"repos"`,
|
|
17
|
+
* or the identity's section under it) are created by `modify` itself, and
|
|
18
|
+
* that creation is comment-safe — no special-casing needed here.
|
|
19
|
+
*
|
|
20
|
+
* Creating an absent store file (user/machine only — see "Team selection"
|
|
21
|
+
* below for why team stores are never auto-created): the file is seeded
|
|
22
|
+
* in-memory as `// header comment\n{}\n` BEFORE the first `modify` call.
|
|
23
|
+
* This is required, not cosmetic — a verified footgun: running `modify` on a
|
|
24
|
+
* comment-only document with NO braces at all (e.g. just `// header\n`)
|
|
25
|
+
* places the new object first and re-emits the header AFTER the closing
|
|
26
|
+
* brace, which is backwards. Seeding an empty object ahead of time gives
|
|
27
|
+
* `modify` real JSON structure to edit into, and the header comment stays
|
|
28
|
+
* exactly where it was written, above the object.
|
|
29
|
+
*
|
|
30
|
+
* ── Refusals ────────────────────────────────────────────────────────────
|
|
31
|
+
* In order: unregistered key, migrated:false (naming `def.legacyFile`), a
|
|
32
|
+
* scope the def does not list, a
|
|
33
|
+
* repoIdentity supplied for a key that is not `repoScoped`, a value that
|
|
34
|
+
* fails `registry.validateValue` (type check + the path-literal guard), and
|
|
35
|
+
* finally — only for `scope: "team"` — a team store that cannot be resolved
|
|
36
|
+
* (see below). Filesystem-touching checks (team resolution) run last, after
|
|
37
|
+
* every pure/in-memory refusal, so a bad call never creates or touches a
|
|
38
|
+
* file it was going to refuse anyway.
|
|
39
|
+
*
|
|
40
|
+
* ── The path-literal guard is scope-aware ──────────────────────────────
|
|
41
|
+
* Mirrors `resolve.ts`'s `validateForScope`: `def.pathGuardFields` (wave 1:
|
|
42
|
+
* `rt.roles.hook`) is enforced at `user` and `team` scope, where a path
|
|
43
|
+
* literal would silently stop applying the moment a teammate's checkout (or
|
|
44
|
+
* this developer's own machine) sits at a different path. `machine` scope is
|
|
45
|
+
* exempt — it is the one store where a path literal is the CORRECT way to
|
|
46
|
+
* express something local-only, so writes there skip the guard entirely
|
|
47
|
+
* (implemented by stripping `pathGuardFields` before calling
|
|
48
|
+
* `validateValue`, same trick `resolve.ts` uses on the read side).
|
|
49
|
+
*
|
|
50
|
+
* ── Team selection (a design decision this task made, per the brief) ──
|
|
51
|
+
* The base signature (`setSetting(key, value, scope, opts?)`) is extended
|
|
52
|
+
* here with `opts.team`, an explicit team NAME to target. Selection rule for
|
|
53
|
+
* `scope: "team"`:
|
|
54
|
+
* - `opts.team` given → that team's store; refuse if it has no local
|
|
55
|
+
* settings file (a team dir can exist mid-clone without one — see
|
|
56
|
+
* `stores.ts#listTeams`).
|
|
57
|
+
* - `opts.team` omitted, exactly one team has a local store → use it.
|
|
58
|
+
* - `opts.team` omitted, zero or multiple teams have a local store →
|
|
59
|
+
* refuse with a clear error (asking for `opts.team` in the multiple
|
|
60
|
+
* case). Wave 1 ships exactly one team, so this is the common path; the
|
|
61
|
+
* alternative of silently picking "the first team alphabetically" was
|
|
62
|
+
* considered and rejected — guessing which team's shared file to mutate
|
|
63
|
+
* is exactly the silent-oracle behavior this design bans elsewhere.
|
|
64
|
+
* A team store is NEVER auto-created by `setSetting` — team stores are
|
|
65
|
+
* seeded by the migration/orchestrator step and live in a repo that needs a
|
|
66
|
+
* commit+push to reach teammates; conjuring one here would produce an
|
|
67
|
+
* uncommitted, unshared file masquerading as team state.
|
|
68
|
+
*
|
|
69
|
+
* Every successful `scope: "team"` write prints one reminder line to
|
|
70
|
+
* stderr: the edit only exists in this local clone until it is committed
|
|
71
|
+
* and pushed. No such reminder for `user`/`machine` (nothing to push there
|
|
72
|
+
* in wave 1).
|
|
73
|
+
*
|
|
74
|
+
* ── Malformed stores refuse rather than edit around the damage ─────────
|
|
75
|
+
* An existing store's on-disk text is parsed and checked (`assertEditableJsonc`)
|
|
76
|
+
* before `modify` ever runs: real parse errors, a non-object root, or a
|
|
77
|
+
* duplicate key anywhere in the tree all refuse with one message naming the
|
|
78
|
+
* file. The duplicate-key case is the sharp one — it is not a parse error at
|
|
79
|
+
* all (JSON's grammar permits it), but `modify` edits the FIRST occurrence by
|
|
80
|
+
* offset while every reader takes the LAST, so a naive edit-in-place would
|
|
81
|
+
* report success while the effective value never changes, and the file would
|
|
82
|
+
* still degrade to empty on the next `readStore`. Refusing is the only
|
|
83
|
+
* option that doesn't either lie about success or write a still-broken file.
|
|
84
|
+
*
|
|
85
|
+
* ── Writes are write-temp-then-rename ───────────────────────────────────
|
|
86
|
+
* Mirrors `lib/json-store.ts`'s `writeJson`: the edited text is written to a
|
|
87
|
+
* `<path>.<pid>.<random>.tmp` file in the SAME directory, then renamed onto
|
|
88
|
+
* the real path — stores never tear, matching the rest of rt's persistence.
|
|
89
|
+
* All three stores are tracked repos now, but nothing auto-commits a write
|
|
90
|
+
* (H2, the snapshot daemon, is unbuilt) — a torn write would sit as a
|
|
91
|
+
* corrupt uncommitted file until a human noticed. The tmp file carries the
|
|
92
|
+
* edited TEXT exactly as `applyEdits` produced it, never round-tripped
|
|
93
|
+
* through `JSON.stringify` — that's what keeps comments alive.
|
|
94
|
+
*/
|
|
95
|
+
import { type SettingScope } from "./registry-machinery.ts";
|
|
96
|
+
export interface SetSettingOpts {
|
|
97
|
+
/** Normalized repo identity — required to target a repoScoped key's `repos.<identity>` section. */
|
|
98
|
+
repoIdentity?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Which team's local store to write into, for `scope: "team"`. See the
|
|
101
|
+
* module doc's "Team selection" section. Ignored for `user`/`machine`.
|
|
102
|
+
*/
|
|
103
|
+
team?: string;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Writes `value` for `key` into the given scope's store, preserving every
|
|
107
|
+
* existing comment and creating the file/section it needs. See the module
|
|
108
|
+
* doc for the full refusal list and the team-selection rule.
|
|
109
|
+
*/
|
|
110
|
+
export declare function setSetting(key: string, value: unknown, scope: SettingScope, opts?: SetSettingOpts): void;
|
package/dist/transport.d.ts
CHANGED
|
@@ -7,6 +7,13 @@ export interface RtClientOptions {
|
|
|
7
7
|
sockPath?: string;
|
|
8
8
|
wsUrl?: string;
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Display-only: a module-load snapshot for callers that just want to show
|
|
12
|
+
* the default path (no consumer imports it today — checked). `rtCommand`
|
|
13
|
+
* itself never reads this constant; it calls `defaultSock()` fresh on every
|
|
14
|
+
* invocation so a test can repoint `process.env.HOME` at any time before
|
|
15
|
+
* calling, not only before this module first loads.
|
|
16
|
+
*/
|
|
10
17
|
export declare const DEFAULT_SOCK: string;
|
|
11
18
|
export declare function rtCommand<T = unknown>(cmd: string, payload: Record<string, unknown>, opts?: {
|
|
12
19
|
sockPath?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mattstack/rt-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"peerDependencies": {
|
|
15
15
|
"@mattstack/glance": ">=0.13.0"
|
|
16
16
|
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"jsonc-parser": "^3.3.1"
|
|
19
|
+
},
|
|
17
20
|
"description": "Typed client for the rt daemon: repos, worktrees, ports, tokens, and the event relay",
|
|
18
21
|
"license": "MIT",
|
|
19
22
|
"repository": {
|
|
@@ -28,12 +31,17 @@
|
|
|
28
31
|
"files": [
|
|
29
32
|
"dist",
|
|
30
33
|
"src",
|
|
34
|
+
"!src/**/__tests__",
|
|
31
35
|
"LICENSE",
|
|
32
36
|
"README.md"
|
|
33
37
|
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"bun": ">=1.0.0"
|
|
40
|
+
},
|
|
34
41
|
"scripts": {
|
|
35
42
|
"build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.json",
|
|
36
|
-
"check-types": "tsc --noEmit -p tsconfig.json"
|
|
43
|
+
"check-types": "tsc --noEmit -p tsconfig.json",
|
|
44
|
+
"prepack": "bun run build"
|
|
37
45
|
},
|
|
38
46
|
"publishConfig": {
|
|
39
47
|
"access": "public"
|
package/src/client.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { rtCommand } from "./transport.ts";
|
|
7
7
|
import type { RtResponse, RtClientOptions } from "./transport.ts";
|
|
8
|
-
import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, ForgeSlug, ForgeTokenData } from "./commands.ts";
|
|
8
|
+
import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, ForgeSlug, ForgeTokenData, RunSummary, RunDetail } from "./commands.ts";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* One repo's project open-MR store. A cold repo forces a full paginated sync
|
|
@@ -58,7 +58,7 @@ export function readMrsByBranch(
|
|
|
58
58
|
* side: an untracked repo comes back `ok: false` with the `rt daemon track`
|
|
59
59
|
* command to run, which is the fail-closed shape callers should surface
|
|
60
60
|
* verbatim. Callers keep env-var precedence on their own side; this is the
|
|
61
|
-
* fallback that replaces reading ~/.rt/secrets.json directly.
|
|
61
|
+
* fallback that replaces reading ~/.mattstack/rt/secrets.json directly.
|
|
62
62
|
*/
|
|
63
63
|
export function resolveForgeToken(
|
|
64
64
|
repoName: string,
|
|
@@ -71,3 +71,22 @@ export function resolveForgeToken(
|
|
|
71
71
|
{ sockPath: opts.sockPath, timeoutMs: 10_000 },
|
|
72
72
|
);
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
export function listRuns(
|
|
76
|
+
repo?: string,
|
|
77
|
+
opts: RtClientOptions = {},
|
|
78
|
+
): Promise<RtResponse<{ runs: RunSummary[] }>> {
|
|
79
|
+
const payload: Record<string, unknown> = {};
|
|
80
|
+
if (repo !== undefined) payload.repo = repo;
|
|
81
|
+
return rtCommand<{ runs: RunSummary[] }>("runs:list", payload, { sockPath: opts.sockPath, timeoutMs: 10_000 });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getRun(
|
|
85
|
+
runId: string,
|
|
86
|
+
repo?: string,
|
|
87
|
+
opts: RtClientOptions = {},
|
|
88
|
+
): Promise<RtResponse<RunDetail>> {
|
|
89
|
+
const payload: Record<string, unknown> = { runId };
|
|
90
|
+
if (repo !== undefined) payload.repo = repo;
|
|
91
|
+
return rtCommand<RunDetail>("runs:get", payload, { sockPath: opts.sockPath, timeoutMs: 10_000 });
|
|
92
|
+
}
|