@mattstack/rt-client 0.4.0 → 0.5.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 +48 -0
- package/dist/client.d.ts +7 -1
- package/dist/commands.d.ts +47 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +74 -1
- package/dist/settings/identity-codec.d.ts +28 -0
- package/dist/settings/identity-codec.js +53 -0
- package/dist/settings/identity.d.ts +3 -20
- package/dist/settings/write.d.ts +12 -0
- package/package.json +8 -2
- package/src/client.ts +17 -0
- package/src/commands.ts +34 -2
- package/src/index.ts +3 -1
- package/src/settings/identity-codec.ts +82 -0
- package/src/settings/identity.ts +3 -72
- package/src/settings/registry-defs.ts +7 -0
- package/src/settings/write.ts +91 -0
package/README.md
CHANGED
|
@@ -26,6 +26,54 @@ Bun-only: the settings exec path (`src/settings/exec.ts`) shells out via
|
|
|
26
26
|
`@mattstack/glance` is a peer dependency: rt-client returns glance's forge types
|
|
27
27
|
so merge request shapes stay identical across rt, gitq, and mr-board.
|
|
28
28
|
|
|
29
|
+
## Repo identity
|
|
30
|
+
|
|
31
|
+
Every per-repo key in the rt estate is a stable serialized identity, not a
|
|
32
|
+
repo name. Whatever you store per-repo, send to the daemon, or put in a REST
|
|
33
|
+
path is keyed by the wire form this package emits. The one exception:
|
|
34
|
+
settings-store sections (`repos.<identity>`) key on the RAW `host/path` form
|
|
35
|
+
(`RepoIdentity.id` for a remote-kind identity) — the settings resolver never
|
|
36
|
+
sees the wire form, and a serialized key there misses silently.
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
remote:gitlab.com%2Facme%2Facme-dev path:%2FUsers%2Fdev%2Fscratch
|
|
40
|
+
└─┬──┘ └──────────┬─────────────┘
|
|
41
|
+
kind the id, encodeURIComponent'd — slash-free, fits one URL segment
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`kind` is `remote` (the repo has an origin: id is normalized `host/path`) or
|
|
45
|
+
`path` (no usable remote: id is the main worktree's realpath). The `:` is a
|
|
46
|
+
literal delimiter.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import {
|
|
50
|
+
deriveRepoIdentity, // (repoPath: string) => Promise<RepoIdentity> — never null
|
|
51
|
+
serializeIdentity, // (id: RepoIdentity) => string — the wire form above
|
|
52
|
+
parseIdentity, // (wire: string) => RepoIdentity | null — THE validity check
|
|
53
|
+
identityFromRemote, // (remoteUrl: string) => RepoIdentity | null — sync
|
|
54
|
+
type RepoIdentity, // { kind: "remote" | "path"; id: string }
|
|
55
|
+
} from '@mattstack/rt-client';
|
|
56
|
+
|
|
57
|
+
const identity = serializeIdentity(await deriveRepoIdentity(repoPath));
|
|
58
|
+
|
|
59
|
+
await rtCommand(['worktree', 'list', '--repo', identity]); // daemon key
|
|
60
|
+
const url = `/api/runs/${encodeURIComponent(identity)}/${runId}`; // URL segment
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Do | Don't |
|
|
64
|
+
|---|---|
|
|
65
|
+
| Get identities from these functions, once, at the boundary | Re-derive with your own git calls (`git remote get-url` diverges under `insteadOf`) |
|
|
66
|
+
| Key stores and daemon payloads on the serialized form | Key anything on a folder basename or a remote's last segment |
|
|
67
|
+
| Key settings sections (`repos.<identity>`) on the raw `host/path` id | Put the serialized form in a settings lookup, or the raw form in a daemon payload |
|
|
68
|
+
| `encodeURIComponent(identity)` in URL path segments | Ship the wire form raw in a URL — its `%` signs decode into slashes |
|
|
69
|
+
| Decode for display: `parseIdentity(wire)`, then the id's last path segment (remote) or basename (path) — the returned `id` is already decoded | `decodeURIComponent` the id again, show the wire form to a human, or build a chat handle from it |
|
|
70
|
+
| Treat the `repo` field from `runs:list` as an opaque key, passed back verbatim | Validate or re-derive `runs:*` repo keys — pre-cutover runs keep their original keys |
|
|
71
|
+
|
|
72
|
+
Repo-keyed daemon verbs accept serialized identities only; a bare repo name
|
|
73
|
+
doesn't error, it resolves empty. `parseIdentity` is strict — only strings
|
|
74
|
+
`serializeIdentity` emitted parse — so validate payloads with it, and never
|
|
75
|
+
hand-assemble or string-split a wire.
|
|
76
|
+
|
|
29
77
|
## License
|
|
30
78
|
|
|
31
79
|
MIT
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RtResponse, RtClientOptions } from "./transport.ts";
|
|
2
|
-
import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, ForgeSlug, ForgeTokenData, RunSummary, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary } from "./commands.ts";
|
|
2
|
+
import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, BranchEnrichment, ForgeSlug, ForgeTokenData, RunSummary, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary } from "./commands.ts";
|
|
3
3
|
/**
|
|
4
4
|
* One repo's project open-MR store. A cold repo forces a full paginated sync
|
|
5
5
|
* on the daemon side when maxAgeMs demands it, which can run tens of seconds
|
|
@@ -13,6 +13,12 @@ import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, Forge
|
|
|
13
13
|
export declare function readProjectMRs(repoName: string, maxAgeMs?: number, opts?: RtClientOptions, demand?: DemandDecl): Promise<RtResponse<ProjectMRsData>>;
|
|
14
14
|
export declare function readDiscussions(repoName: string, iid: number, opts?: RtClientOptions): Promise<RtResponse<DiscussionsData>>;
|
|
15
15
|
export declare function readMrsByBranch(repoName: string, branches: string[], opts?: RtClientOptions): Promise<RtResponse<MrByBranchData>>;
|
|
16
|
+
/**
|
|
17
|
+
* Cached ticket/MR enrichment for a set of branches, keyed by branch name
|
|
18
|
+
* (the cache's own primary key -- see lib/state/branch-cache.ts). Serves
|
|
19
|
+
* whatever the daemon already has; it does not trigger a fetch.
|
|
20
|
+
*/
|
|
21
|
+
export declare function readBranchCache(branches: string[], opts?: RtClientOptions): Promise<RtResponse<Record<string, BranchEnrichment>>>;
|
|
16
22
|
/**
|
|
17
23
|
* The forge token for one tracked repo (MAT-33). Grant-gated on the daemon
|
|
18
24
|
* side: an untracked repo comes back `ok: false` with the `rt daemon track`
|
package/dist/commands.d.ts
CHANGED
|
@@ -10,17 +10,24 @@ export type Discussion = MRDetail["discussions"][number];
|
|
|
10
10
|
export interface DemandDecl {
|
|
11
11
|
client: string;
|
|
12
12
|
authors: string[];
|
|
13
|
+
/** Codeowner sections this client needs covered (spec: second demand axis). */
|
|
14
|
+
codeownerSections?: string[];
|
|
13
15
|
declaredAt: number;
|
|
14
16
|
}
|
|
15
17
|
export interface ProjectMRsScope {
|
|
16
18
|
authors: string[];
|
|
17
19
|
windowDays: number;
|
|
18
20
|
uncovered: string[];
|
|
21
|
+
/** Effective synced section union; absent from a pre-sections daemon. */
|
|
22
|
+
sections?: string[];
|
|
23
|
+
/** Demanded sections not yet swept for this client. */
|
|
24
|
+
uncoveredSections?: string[];
|
|
19
25
|
}
|
|
20
26
|
export interface ProjectMRsData {
|
|
21
27
|
mrs: Record<string, {
|
|
22
28
|
pr: PullRequest;
|
|
23
29
|
fetchedAt: number;
|
|
30
|
+
codeownerSections?: string[];
|
|
24
31
|
}>;
|
|
25
32
|
listSyncedAt: number;
|
|
26
33
|
source: "poll" | "events" | "mutation";
|
|
@@ -40,6 +47,31 @@ export interface MrByBranchData {
|
|
|
40
47
|
byBranch: Record<string, MrByBranchEntry | null>;
|
|
41
48
|
syncedAt: number;
|
|
42
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Trimmed, structural view of the daemon's `CacheEntry` (lib/state/branch-cache.ts) --
|
|
52
|
+
* rt-client cannot import daemon/lib internals, so this names only the fields
|
|
53
|
+
* console's run-view rows read, spelled exactly as they land on the wire
|
|
54
|
+
* (`mr` is `toMRInfo(pr)`, i.e. `getMRDashboardProps` -- camelCase `webUrl`,
|
|
55
|
+
* nested `pipeline.status`, no `ciStatus`). Extra wire fields (including the
|
|
56
|
+
* rest of `pipeline`) are fine; anything this shape doesn't name is simply
|
|
57
|
+
* not surfaced.
|
|
58
|
+
*/
|
|
59
|
+
export interface BranchEnrichment {
|
|
60
|
+
ticket: {
|
|
61
|
+
identifier: string;
|
|
62
|
+
title: string;
|
|
63
|
+
url: string;
|
|
64
|
+
} | null;
|
|
65
|
+
mr: {
|
|
66
|
+
iid: number;
|
|
67
|
+
webUrl: string | null;
|
|
68
|
+
state: string;
|
|
69
|
+
pipeline: {
|
|
70
|
+
status: string;
|
|
71
|
+
} | null;
|
|
72
|
+
} | null;
|
|
73
|
+
fetchedAt: number;
|
|
74
|
+
}
|
|
43
75
|
/** Forges the daemon can hold a token for. */
|
|
44
76
|
export type ForgeSlug = "gitlab" | "github";
|
|
45
77
|
export interface ForgeTokenData {
|
|
@@ -90,7 +122,7 @@ export interface RoomSummary {
|
|
|
90
122
|
}
|
|
91
123
|
export type Attention = {
|
|
92
124
|
needs: boolean;
|
|
93
|
-
reason: "failed" | "stale" | "stranded" | null;
|
|
125
|
+
reason: "failed" | "stale" | "stranded" | "blocked" | null;
|
|
94
126
|
evidence: string;
|
|
95
127
|
};
|
|
96
128
|
export interface RunSummary {
|
|
@@ -115,6 +147,20 @@ export interface RunSummary {
|
|
|
115
147
|
the run has not produced that field yet. */
|
|
116
148
|
ticket: string | null;
|
|
117
149
|
branch: string | null;
|
|
150
|
+
/** The herdr agent attributed to this run (matched by recorded claude
|
|
151
|
+
session, else by worktree), mirrored live from `herdr agent list`.
|
|
152
|
+
Null when no agent matches or herdr is unavailable; absent on
|
|
153
|
+
pre-mirror daemons. */
|
|
154
|
+
agent?: RunAgent | null;
|
|
155
|
+
/** Executed stages only, in run order — the pipeline may define more that have not started. */
|
|
156
|
+
stages?: {
|
|
157
|
+
name: string;
|
|
158
|
+
status: string;
|
|
159
|
+
}[];
|
|
160
|
+
}
|
|
161
|
+
export interface RunAgent {
|
|
162
|
+
status: "working" | "idle" | "blocked" | "done" | "unknown";
|
|
163
|
+
pane: string;
|
|
118
164
|
}
|
|
119
165
|
export interface RunStageRow {
|
|
120
166
|
name: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
|
|
2
2
|
export type { RtResponse, RtClientOptions } from "./transport.ts";
|
|
3
|
-
export { readProjectMRs, readDiscussions, readMrsByBranch, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatArm, chatTouch, chatDisarm, chatUnreadWaking, eventsHead, } from "./client.ts";
|
|
3
|
+
export { readProjectMRs, readDiscussions, readMrsByBranch, readBranchCache, resolveForgeToken, listRuns, getRun, abandonRun, chatJoin, chatLeave, chatPost, chatRead, chatRooms, chatWho, chatMark, chatMessages, chatArm, chatTouch, chatDisarm, chatUnreadWaking, eventsHead, } from "./client.ts";
|
|
4
4
|
export { COMMAND_NAMES } from "./commands.ts";
|
|
5
|
-
export type { Discussion, DemandDecl, ProjectMRsScope, ProjectMRsData, DiscussionsData, MrByBranchEntry, MrByBranchData, Commands, CommandName, ForgeSlug, ForgeTokenData, Attention, RunSummary, RunStageRow, RunFieldRow, RunDecisionRow, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary, } from "./commands.ts";
|
|
5
|
+
export type { Discussion, DemandDecl, ProjectMRsScope, ProjectMRsData, DiscussionsData, MrByBranchEntry, MrByBranchData, BranchEnrichment, Commands, CommandName, ForgeSlug, ForgeTokenData, Attention, RunSummary, RunStageRow, RunFieldRow, RunDecisionRow, RunDetail, WakeMode, ChatMember, ChatMessage, RoomSummary, } from "./commands.ts";
|
|
6
6
|
export { subscribe, DEFAULT_WS_URL } from "./relay.ts";
|
|
7
7
|
export type { RelayEventType } from "./relay.ts";
|
|
8
8
|
export { repoNameForPath } from "./repos.ts";
|
|
9
9
|
export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER } from "./settings/resolve.ts";
|
|
10
10
|
export type { Scope, Provenance, ResolveOpts, Resolved, InvalidScope, ListedSetting, ExplainRow, ExpandCtx, } from "./settings/resolve.ts";
|
|
11
|
-
export { setSetting } from "./settings/write.ts";
|
|
11
|
+
export { setSetting, unsetSetting } from "./settings/write.ts";
|
|
12
12
|
export type { SetSettingOpts } from "./settings/write.ts";
|
|
13
13
|
export { getDef, allDefs, validateValue, isMigrated } from "./settings/registry-machinery.ts";
|
|
14
14
|
export type { SettingDef, SettingScope } from "./settings/registry-machinery.ts";
|
package/dist/index.js
CHANGED
|
@@ -39,6 +39,9 @@ function readDiscussions(repoName, iid, opts = {}) {
|
|
|
39
39
|
function readMrsByBranch(repoName, branches, opts = {}) {
|
|
40
40
|
return rtCommand("mr:by-branch", { repoName, branches }, { sockPath: opts.sockPath, timeoutMs: 60000 });
|
|
41
41
|
}
|
|
42
|
+
function readBranchCache(branches, opts = {}) {
|
|
43
|
+
return rtCommand("cache:read", { branches }, { sockPath: opts.sockPath, timeoutMs: 1e4 });
|
|
44
|
+
}
|
|
42
45
|
function resolveForgeToken(repoName, forge, opts = {}) {
|
|
43
46
|
return rtCommand("secrets:forge-token", { repoName, forge }, { sockPath: opts.sockPath, timeoutMs: 1e4 });
|
|
44
47
|
}
|
|
@@ -612,6 +615,13 @@ var REGISTRY = [
|
|
|
612
615
|
merge: "replace",
|
|
613
616
|
description: "Doctor skill the board's own API-tier triage sweep runs on your MRs; deliberately never resolved through a repo's skills.jsonc manifest. A sibling flat key of board.triage, not a field inside it — the board reader assembles the two independently."
|
|
614
617
|
},
|
|
618
|
+
{
|
|
619
|
+
key: "board.tabs",
|
|
620
|
+
type: "array",
|
|
621
|
+
scopes: ["team"],
|
|
622
|
+
merge: "replace",
|
|
623
|
+
description: "Board tab definitions ({id, label, source, slackChannel?, reviewSkill?}); source.kind 'authors' is the classic roster board, 'codeowners' lists MRs blocked on an unapproved CODEOWNERS section. Absent = one implicit authors tab (fallback lives in the board reader, never here)."
|
|
624
|
+
},
|
|
615
625
|
{
|
|
616
626
|
key: "board.staleAfterDays",
|
|
617
627
|
type: "number",
|
|
@@ -1202,6 +1212,30 @@ function setSetting(key, value, scope, opts = {}) {
|
|
|
1202
1212
|
writeIntoStore(storePath, jsonPath, value, scope !== "team");
|
|
1203
1213
|
console.error(`rt: wrote "${key}" to the local ${scope} store (${storePath}) — this is local only until you commit and push it.`);
|
|
1204
1214
|
}
|
|
1215
|
+
function unsetSetting(key, scope, opts = {}) {
|
|
1216
|
+
const def = getDef(key);
|
|
1217
|
+
if (!def) {
|
|
1218
|
+
refuse(`unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
|
|
1219
|
+
}
|
|
1220
|
+
if (!isMigrated(def)) {
|
|
1221
|
+
refuse(migratedFalseMessage(key, def));
|
|
1222
|
+
}
|
|
1223
|
+
if (!def.scopes.includes(scope)) {
|
|
1224
|
+
refuse(`"${key}" cannot be unset in the ${scope} store (allowed: ${def.scopes.join(", ")})`);
|
|
1225
|
+
}
|
|
1226
|
+
if (opts.repoIdentity !== undefined && def.repoScoped !== true) {
|
|
1227
|
+
refuse(`"${key}" is not repo-scoped — omit the repo identity`);
|
|
1228
|
+
}
|
|
1229
|
+
const storePath = resolveStorePathForUnset(scope, opts);
|
|
1230
|
+
if (storePath === null || !existsSync3(storePath))
|
|
1231
|
+
return false;
|
|
1232
|
+
const jsonPath = opts.repoIdentity !== undefined ? ["repos", opts.repoIdentity, key] : [key];
|
|
1233
|
+
const removed = removeFromStore(storePath, jsonPath);
|
|
1234
|
+
if (removed) {
|
|
1235
|
+
console.error(`rt: removed "${key}" from the local ${scope} store (${storePath}) — this is local only until you commit and push it.`);
|
|
1236
|
+
}
|
|
1237
|
+
return removed;
|
|
1238
|
+
}
|
|
1205
1239
|
function migratedFalseMessage(key, def) {
|
|
1206
1240
|
const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : "";
|
|
1207
1241
|
return `"${key}" is not writable through the settings resolver yet${legacyPart}`;
|
|
@@ -1227,6 +1261,23 @@ function resolveStorePath(scope, opts) {
|
|
|
1227
1261
|
}
|
|
1228
1262
|
return teamSettingsPath(teams[0]);
|
|
1229
1263
|
}
|
|
1264
|
+
function resolveStorePathForUnset(scope, opts) {
|
|
1265
|
+
if (scope === "user")
|
|
1266
|
+
return userSettingsPath();
|
|
1267
|
+
if (scope === "machine")
|
|
1268
|
+
return machineSettingsPath();
|
|
1269
|
+
if (opts.team !== undefined) {
|
|
1270
|
+
const path = teamSettingsPath(opts.team);
|
|
1271
|
+
return existsSync3(path) ? path : null;
|
|
1272
|
+
}
|
|
1273
|
+
const teams = listTeams();
|
|
1274
|
+
if (teams.length === 0)
|
|
1275
|
+
return null;
|
|
1276
|
+
if (teams.length > 1) {
|
|
1277
|
+
refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
|
|
1278
|
+
}
|
|
1279
|
+
return teamSettingsPath(teams[0]);
|
|
1280
|
+
}
|
|
1230
1281
|
function seedHeader() {
|
|
1231
1282
|
return `// rt settings — created by \`rt settings set\`. JSONC: comments and trailing commas are fine.
|
|
1232
1283
|
{}
|
|
@@ -1289,6 +1340,24 @@ function writeIntoStore(storePath, jsonPath, value, createIfMissing) {
|
|
|
1289
1340
|
const finalText = next.endsWith(`
|
|
1290
1341
|
`) ? next : `${next}
|
|
1291
1342
|
`;
|
|
1343
|
+
writeTempThenRename(storePath, finalText);
|
|
1344
|
+
}
|
|
1345
|
+
function removeFromStore(storePath, jsonPath) {
|
|
1346
|
+
const content = readFileSync4(storePath, "utf8");
|
|
1347
|
+
if (content.trim() === "")
|
|
1348
|
+
return false;
|
|
1349
|
+
assertEditableJsonc(storePath, content);
|
|
1350
|
+
const edits = modify(content, jsonPath, undefined, { formattingOptions: FORMAT });
|
|
1351
|
+
if (edits.length === 0)
|
|
1352
|
+
return false;
|
|
1353
|
+
const next = applyEdits(content, edits);
|
|
1354
|
+
const finalText = next.endsWith(`
|
|
1355
|
+
`) ? next : `${next}
|
|
1356
|
+
`;
|
|
1357
|
+
writeTempThenRename(storePath, finalText);
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
function writeTempThenRename(storePath, finalText) {
|
|
1292
1361
|
const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1293
1362
|
try {
|
|
1294
1363
|
writeFileSync(tmp, finalText);
|
|
@@ -1336,7 +1405,7 @@ async function runCapture(argv, opts = {}) {
|
|
|
1336
1405
|
}
|
|
1337
1406
|
}
|
|
1338
1407
|
|
|
1339
|
-
// src/settings/identity.ts
|
|
1408
|
+
// src/settings/identity-codec.ts
|
|
1340
1409
|
var URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
|
|
1341
1410
|
var SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
|
|
1342
1411
|
function serializeIdentity(id) {
|
|
@@ -1384,6 +1453,8 @@ function normalizeRemote(remote) {
|
|
|
1384
1453
|
return null;
|
|
1385
1454
|
return `${host.toLowerCase()}/${normalizedPath}`;
|
|
1386
1455
|
}
|
|
1456
|
+
|
|
1457
|
+
// src/settings/identity.ts
|
|
1387
1458
|
function identityFromRemote(remote) {
|
|
1388
1459
|
const store = readStore(machineSettingsPath());
|
|
1389
1460
|
const overrides = store.global["rt.repoIdentityOverrides"];
|
|
@@ -1451,6 +1522,7 @@ async function resolveNameToIdentity(name, reposJsonPath) {
|
|
|
1451
1522
|
}
|
|
1452
1523
|
export {
|
|
1453
1524
|
validateValue,
|
|
1525
|
+
unsetSetting,
|
|
1454
1526
|
subscribe,
|
|
1455
1527
|
setSetting,
|
|
1456
1528
|
serializeIdentity,
|
|
@@ -1462,6 +1534,7 @@ export {
|
|
|
1462
1534
|
readProjectMRs,
|
|
1463
1535
|
readMrsByBranch,
|
|
1464
1536
|
readDiscussions,
|
|
1537
|
+
readBranchCache,
|
|
1465
1538
|
parseIdentity,
|
|
1466
1539
|
normalizeRemote,
|
|
1467
1540
|
listTeams,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of the repo-identity contract: the wire codec and the
|
|
3
|
+
* remote-URL normalizer. Split from identity.ts so browser bundles can key
|
|
4
|
+
* and label repos without dragging in fs/child_process — this module must
|
|
5
|
+
* never import node builtins or anything that does (the `./identity`
|
|
6
|
+
* subpath export points here, and a browser consumer evaluates it at module
|
|
7
|
+
* scope). Override-aware and derivation entry points stay in identity.ts.
|
|
8
|
+
*/
|
|
9
|
+
export type RepoIdentity = {
|
|
10
|
+
kind: "remote";
|
|
11
|
+
id: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "path";
|
|
14
|
+
id: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The wire form crosses the daemon socket, sits in board config, and lands in
|
|
18
|
+
* console's `/runs/:repo/...` URL — all of which need one slash-free segment.
|
|
19
|
+
* `encodeURIComponent` guarantees that and is exactly reversible.
|
|
20
|
+
*/
|
|
21
|
+
export declare function serializeIdentity(id: RepoIdentity): string;
|
|
22
|
+
export declare function parseIdentity(wire: string): RepoIdentity | null;
|
|
23
|
+
/**
|
|
24
|
+
* Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
|
|
25
|
+
* embedded credentials stripped) or null when the remote doesn't match a
|
|
26
|
+
* recognized host form (local paths, garbage input).
|
|
27
|
+
*/
|
|
28
|
+
export declare function normalizeRemote(remote: string): string | null;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/settings/identity-codec.ts
|
|
2
|
+
var URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
|
|
3
|
+
var SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
|
|
4
|
+
function serializeIdentity(id) {
|
|
5
|
+
return `${id.kind}:${encodeURIComponent(id.id)}`;
|
|
6
|
+
}
|
|
7
|
+
function parseIdentity(wire) {
|
|
8
|
+
const colon = wire.indexOf(":");
|
|
9
|
+
if (colon === -1)
|
|
10
|
+
return null;
|
|
11
|
+
const kind = wire.slice(0, colon);
|
|
12
|
+
if (kind !== "remote" && kind !== "path")
|
|
13
|
+
return null;
|
|
14
|
+
const encoded = wire.slice(colon + 1);
|
|
15
|
+
let id;
|
|
16
|
+
try {
|
|
17
|
+
id = decodeURIComponent(encoded);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (encodeURIComponent(id) !== encoded)
|
|
22
|
+
return null;
|
|
23
|
+
return { kind, id };
|
|
24
|
+
}
|
|
25
|
+
function normalizeRemote(remote) {
|
|
26
|
+
const trimmed = remote.trim();
|
|
27
|
+
if (!trimmed)
|
|
28
|
+
return null;
|
|
29
|
+
let host;
|
|
30
|
+
let path;
|
|
31
|
+
const urlMatch = URL_RE.exec(trimmed);
|
|
32
|
+
if (urlMatch) {
|
|
33
|
+
host = urlMatch[1];
|
|
34
|
+
path = urlMatch[2];
|
|
35
|
+
} else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
|
|
36
|
+
const scpMatch = SCP_RE.exec(trimmed);
|
|
37
|
+
if (scpMatch) {
|
|
38
|
+
host = scpMatch[1];
|
|
39
|
+
path = scpMatch[2];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!host || !path)
|
|
43
|
+
return null;
|
|
44
|
+
const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
45
|
+
if (!normalizedPath)
|
|
46
|
+
return null;
|
|
47
|
+
return `${host.toLowerCase()}/${normalizedPath}`;
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
serializeIdentity,
|
|
51
|
+
parseIdentity,
|
|
52
|
+
normalizeRemote
|
|
53
|
+
};
|
|
@@ -25,26 +25,9 @@
|
|
|
25
25
|
* through `identityFromRemote`, memoized per path so repeated callers in
|
|
26
26
|
* one process don't re-spawn git.
|
|
27
27
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} | {
|
|
32
|
-
kind: "path";
|
|
33
|
-
id: string;
|
|
34
|
-
};
|
|
35
|
-
/**
|
|
36
|
-
* The wire form crosses the daemon socket, sits in board config, and lands in
|
|
37
|
-
* console's `/runs/:repo/...` URL — all of which need one slash-free segment.
|
|
38
|
-
* `encodeURIComponent` guarantees that and is exactly reversible.
|
|
39
|
-
*/
|
|
40
|
-
export declare function serializeIdentity(id: RepoIdentity): string;
|
|
41
|
-
export declare function parseIdentity(wire: string): RepoIdentity | null;
|
|
42
|
-
/**
|
|
43
|
-
* Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
|
|
44
|
-
* embedded credentials stripped) or null when the remote doesn't match a
|
|
45
|
-
* recognized host form (local paths, garbage input).
|
|
46
|
-
*/
|
|
47
|
-
export declare function normalizeRemote(remote: string): string | null;
|
|
28
|
+
import { type RepoIdentity } from "./identity-codec.ts";
|
|
29
|
+
export { serializeIdentity, parseIdentity, normalizeRemote } from "./identity-codec.ts";
|
|
30
|
+
export type { RepoIdentity } from "./identity-codec.ts";
|
|
48
31
|
/**
|
|
49
32
|
* The sync helper every non-derivation call site uses: machine-store
|
|
50
33
|
* fork/multi-remote overrides (exact remote-URL match) then normalizeRemote.
|
package/dist/settings/write.d.ts
CHANGED
|
@@ -108,3 +108,15 @@ export interface SetSettingOpts {
|
|
|
108
108
|
* doc for the full refusal list and the team-selection rule.
|
|
109
109
|
*/
|
|
110
110
|
export declare function setSetting(key: string, value: unknown, scope: SettingScope, opts?: SetSettingOpts): void;
|
|
111
|
+
/**
|
|
112
|
+
* Removes `key` from the given scope's store, comment-preserving. The refusal
|
|
113
|
+
* ladder is `setSetting`'s minus the value check (there is no value): unknown
|
|
114
|
+
* key, unmigrated, scope not in `def.scopes`, repoIdentity on a non-repoScoped
|
|
115
|
+
* key, and the team-selection rule when ambiguous. Divergences from set, both
|
|
116
|
+
* because removal has nothing to act on: a store FILE that does not exist is a
|
|
117
|
+
* clean no-op rather than a refusal (an explicit `opts.team` naming a team
|
|
118
|
+
* with no local store included — nothing to remove is success, not an error),
|
|
119
|
+
* and a key not present in the store is a no-op. Returns whether anything was
|
|
120
|
+
* actually removed; the local-only reminder prints only on a real removal.
|
|
121
|
+
*/
|
|
122
|
+
export declare function unsetSetting(key: string, scope: SettingScope, opts?: SetSettingOpts): boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mattstack/rt-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
"import": "./dist/index.js",
|
|
10
10
|
"default": "./dist/index.js"
|
|
11
11
|
},
|
|
12
|
+
"./identity": {
|
|
13
|
+
"types": "./dist/settings/identity-codec.d.ts",
|
|
14
|
+
"bun": "./src/settings/identity-codec.ts",
|
|
15
|
+
"import": "./dist/settings/identity-codec.js",
|
|
16
|
+
"default": "./dist/settings/identity-codec.js"
|
|
17
|
+
},
|
|
12
18
|
"./test/fake-daemon.ts": "./test/fake-daemon.ts"
|
|
13
19
|
},
|
|
14
20
|
"peerDependencies": {
|
|
@@ -39,7 +45,7 @@
|
|
|
39
45
|
"bun": ">=1.0.0"
|
|
40
46
|
},
|
|
41
47
|
"scripts": {
|
|
42
|
-
"build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.json",
|
|
48
|
+
"build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && bun build src/settings/identity-codec.ts --outfile dist/settings/identity-codec.js --target browser --format esm && tsc -p tsconfig.json",
|
|
43
49
|
"check-types": "tsc --noEmit -p tsconfig.json",
|
|
44
50
|
"prepack": "bun run build"
|
|
45
51
|
},
|
package/src/client.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ProjectMRsData,
|
|
11
11
|
DiscussionsData,
|
|
12
12
|
MrByBranchData,
|
|
13
|
+
BranchEnrichment,
|
|
13
14
|
ForgeSlug,
|
|
14
15
|
ForgeTokenData,
|
|
15
16
|
RunSummary,
|
|
@@ -66,6 +67,22 @@ export function readMrsByBranch(
|
|
|
66
67
|
);
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Cached ticket/MR enrichment for a set of branches, keyed by branch name
|
|
72
|
+
* (the cache's own primary key -- see lib/state/branch-cache.ts). Serves
|
|
73
|
+
* whatever the daemon already has; it does not trigger a fetch.
|
|
74
|
+
*/
|
|
75
|
+
export function readBranchCache(
|
|
76
|
+
branches: string[],
|
|
77
|
+
opts: RtClientOptions = {},
|
|
78
|
+
): Promise<RtResponse<Record<string, BranchEnrichment>>> {
|
|
79
|
+
return rtCommand<Record<string, BranchEnrichment>>(
|
|
80
|
+
"cache:read",
|
|
81
|
+
{ branches },
|
|
82
|
+
{ sockPath: opts.sockPath, timeoutMs: 10_000 },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
/**
|
|
70
87
|
* The forge token for one tracked repo (MAT-33). Grant-gated on the daemon
|
|
71
88
|
* side: an untracked repo comes back `ok: false` with the `rt daemon track`
|
package/src/commands.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type Discussion = MRDetail["discussions"][number];
|
|
|
12
12
|
export interface DemandDecl {
|
|
13
13
|
client: string;
|
|
14
14
|
authors: string[];
|
|
15
|
+
/** Codeowner sections this client needs covered (spec: second demand axis). */
|
|
16
|
+
codeownerSections?: string[];
|
|
15
17
|
declaredAt: number;
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -19,10 +21,14 @@ export interface ProjectMRsScope {
|
|
|
19
21
|
authors: string[];
|
|
20
22
|
windowDays: number;
|
|
21
23
|
uncovered: string[];
|
|
24
|
+
/** Effective synced section union; absent from a pre-sections daemon. */
|
|
25
|
+
sections?: string[];
|
|
26
|
+
/** Demanded sections not yet swept for this client. */
|
|
27
|
+
uncoveredSections?: string[];
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export interface ProjectMRsData {
|
|
25
|
-
mrs: Record<string, { pr: PullRequest; fetchedAt: number }>;
|
|
31
|
+
mrs: Record<string, { pr: PullRequest; fetchedAt: number; codeownerSections?: string[] }>;
|
|
26
32
|
listSyncedAt: number;
|
|
27
33
|
source: "poll" | "events" | "mutation";
|
|
28
34
|
syncedAt: number;
|
|
@@ -45,6 +51,21 @@ export interface MrByBranchData {
|
|
|
45
51
|
syncedAt: number;
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Trimmed, structural view of the daemon's `CacheEntry` (lib/state/branch-cache.ts) --
|
|
56
|
+
* rt-client cannot import daemon/lib internals, so this names only the fields
|
|
57
|
+
* console's run-view rows read, spelled exactly as they land on the wire
|
|
58
|
+
* (`mr` is `toMRInfo(pr)`, i.e. `getMRDashboardProps` -- camelCase `webUrl`,
|
|
59
|
+
* nested `pipeline.status`, no `ciStatus`). Extra wire fields (including the
|
|
60
|
+
* rest of `pipeline`) are fine; anything this shape doesn't name is simply
|
|
61
|
+
* not surfaced.
|
|
62
|
+
*/
|
|
63
|
+
export interface BranchEnrichment {
|
|
64
|
+
ticket: { identifier: string; title: string; url: string } | null;
|
|
65
|
+
mr: { iid: number; webUrl: string | null; state: string; pipeline: { status: string } | null } | null;
|
|
66
|
+
fetchedAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
/** Forges the daemon can hold a token for. */
|
|
49
70
|
export type ForgeSlug = "gitlab" | "github";
|
|
50
71
|
|
|
@@ -99,7 +120,7 @@ export interface RoomSummary {
|
|
|
99
120
|
// never derive two verdicts that can disagree.
|
|
100
121
|
export type Attention = {
|
|
101
122
|
needs: boolean;
|
|
102
|
-
reason: "failed" | "stale" | "stranded" | null;
|
|
123
|
+
reason: "failed" | "stale" | "stranded" | "blocked" | null;
|
|
103
124
|
evidence: string;
|
|
104
125
|
};
|
|
105
126
|
|
|
@@ -120,6 +141,17 @@ export interface RunSummary {
|
|
|
120
141
|
the run has not produced that field yet. */
|
|
121
142
|
ticket: string | null;
|
|
122
143
|
branch: string | null;
|
|
144
|
+
/** The herdr agent attributed to this run (matched by recorded claude
|
|
145
|
+
session, else by worktree), mirrored live from `herdr agent list`.
|
|
146
|
+
Null when no agent matches or herdr is unavailable; absent on
|
|
147
|
+
pre-mirror daemons. */
|
|
148
|
+
agent?: RunAgent | null;
|
|
149
|
+
/** Executed stages only, in run order — the pipeline may define more that have not started. */
|
|
150
|
+
stages?: { name: string; status: string }[];
|
|
151
|
+
}
|
|
152
|
+
export interface RunAgent {
|
|
153
|
+
status: "working" | "idle" | "blocked" | "done" | "unknown";
|
|
154
|
+
pane: string;
|
|
123
155
|
}
|
|
124
156
|
export interface RunStageRow {
|
|
125
157
|
name: string; status: string; attempt: number;
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export {
|
|
|
5
5
|
readProjectMRs,
|
|
6
6
|
readDiscussions,
|
|
7
7
|
readMrsByBranch,
|
|
8
|
+
readBranchCache,
|
|
8
9
|
resolveForgeToken,
|
|
9
10
|
listRuns,
|
|
10
11
|
getRun,
|
|
@@ -33,6 +34,7 @@ export type {
|
|
|
33
34
|
DiscussionsData,
|
|
34
35
|
MrByBranchEntry,
|
|
35
36
|
MrByBranchData,
|
|
37
|
+
BranchEnrichment,
|
|
36
38
|
Commands,
|
|
37
39
|
CommandName,
|
|
38
40
|
ForgeSlug,
|
|
@@ -68,7 +70,7 @@ export type {
|
|
|
68
70
|
ExpandCtx,
|
|
69
71
|
} from "./settings/resolve.ts";
|
|
70
72
|
|
|
71
|
-
export { setSetting } from "./settings/write.ts";
|
|
73
|
+
export { setSetting, unsetSetting } from "./settings/write.ts";
|
|
72
74
|
export type { SetSettingOpts } from "./settings/write.ts";
|
|
73
75
|
|
|
74
76
|
export { getDef, allDefs, validateValue, isMigrated } from "./settings/registry-machinery.ts";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of the repo-identity contract: the wire codec and the
|
|
3
|
+
* remote-URL normalizer. Split from identity.ts so browser bundles can key
|
|
4
|
+
* and label repos without dragging in fs/child_process — this module must
|
|
5
|
+
* never import node builtins or anything that does (the `./identity`
|
|
6
|
+
* subpath export points here, and a browser consumer evaluates it at module
|
|
7
|
+
* scope). Override-aware and derivation entry points stay in identity.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Full-URL forms: scheme://[user[:pass]@]host/path — https, ssh, git, http, ...
|
|
11
|
+
const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
|
|
12
|
+
|
|
13
|
+
// scp-like scp syntax: [user@]host:path (git@gitlab.com:group/repo.git).
|
|
14
|
+
// Deliberately excludes anything starting with "/" (absolute local paths)
|
|
15
|
+
// so a Windows-drive-letter-free local remote never falsely matches.
|
|
16
|
+
const SCP_RE = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/;
|
|
17
|
+
|
|
18
|
+
export type RepoIdentity =
|
|
19
|
+
| { kind: "remote"; id: string }
|
|
20
|
+
| { kind: "path"; id: string };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The wire form crosses the daemon socket, sits in board config, and lands in
|
|
24
|
+
* console's `/runs/:repo/...` URL — all of which need one slash-free segment.
|
|
25
|
+
* `encodeURIComponent` guarantees that and is exactly reversible.
|
|
26
|
+
*/
|
|
27
|
+
export function serializeIdentity(id: RepoIdentity): string {
|
|
28
|
+
return `${id.kind}:${encodeURIComponent(id.id)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseIdentity(wire: string): RepoIdentity | null {
|
|
32
|
+
const colon = wire.indexOf(":");
|
|
33
|
+
if (colon === -1) return null;
|
|
34
|
+
const kind = wire.slice(0, colon);
|
|
35
|
+
if (kind !== "remote" && kind !== "path") return null;
|
|
36
|
+
const encoded = wire.slice(colon + 1);
|
|
37
|
+
let id: string;
|
|
38
|
+
try {
|
|
39
|
+
id = decodeURIComponent(encoded);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// Canonical wires only: the id segment must be byte-for-byte what
|
|
44
|
+
// serializeIdentity emits. Guard sites validate with parseIdentity and then
|
|
45
|
+
// use the WIRE as a single path component (repoDataDir et al.) — a
|
|
46
|
+
// hand-built wire with a literal "/" ("path:../..") would otherwise parse
|
|
47
|
+
// and escape the state directory.
|
|
48
|
+
if (encodeURIComponent(id) !== encoded) return null;
|
|
49
|
+
return { kind, id };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
|
|
54
|
+
* embedded credentials stripped) or null when the remote doesn't match a
|
|
55
|
+
* recognized host form (local paths, garbage input).
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeRemote(remote: string): string | null {
|
|
58
|
+
const trimmed = remote.trim();
|
|
59
|
+
if (!trimmed) return null;
|
|
60
|
+
|
|
61
|
+
let host: string | undefined;
|
|
62
|
+
let path: string | undefined;
|
|
63
|
+
|
|
64
|
+
const urlMatch = URL_RE.exec(trimmed);
|
|
65
|
+
if (urlMatch) {
|
|
66
|
+
host = urlMatch[1];
|
|
67
|
+
path = urlMatch[2];
|
|
68
|
+
} else if (!trimmed.startsWith("/") && !trimmed.startsWith("~")) {
|
|
69
|
+
const scpMatch = SCP_RE.exec(trimmed);
|
|
70
|
+
if (scpMatch) {
|
|
71
|
+
host = scpMatch[1];
|
|
72
|
+
path = scpMatch[2];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!host || !path) return null;
|
|
77
|
+
|
|
78
|
+
const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
79
|
+
if (!normalizedPath) return null;
|
|
80
|
+
|
|
81
|
+
return `${host.toLowerCase()}/${normalizedPath}`;
|
|
82
|
+
}
|
package/src/settings/identity.ts
CHANGED
|
@@ -32,79 +32,10 @@ import { runCapture } from "./exec.ts";
|
|
|
32
32
|
import { machineSettingsPath } from "./paths.ts";
|
|
33
33
|
import { readStore } from "./stores.ts";
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
const URL_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/]+)\/(.+)$/;
|
|
35
|
+
import { normalizeRemote, type RepoIdentity } from "./identity-codec.ts";
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
}
|
|
37
|
+
export { serializeIdentity, parseIdentity, normalizeRemote } from "./identity-codec.ts";
|
|
38
|
+
export type { RepoIdentity } from "./identity-codec.ts";
|
|
108
39
|
|
|
109
40
|
/**
|
|
110
41
|
* The sync helper every non-derivation call site uses: machine-store
|
|
@@ -345,6 +345,13 @@ export const REGISTRY: readonly SettingDef[] = [
|
|
|
345
345
|
merge: "replace",
|
|
346
346
|
description: "Doctor skill the board's own API-tier triage sweep runs on your MRs; deliberately never resolved through a repo's skills.jsonc manifest. A sibling flat key of board.triage, not a field inside it — the board reader assembles the two independently.",
|
|
347
347
|
},
|
|
348
|
+
{
|
|
349
|
+
key: "board.tabs",
|
|
350
|
+
type: "array",
|
|
351
|
+
scopes: ["team"],
|
|
352
|
+
merge: "replace",
|
|
353
|
+
description: "Board tab definitions ({id, label, source, slackChannel?, reviewSkill?}); source.kind 'authors' is the classic roster board, 'codeowners' lists MRs blocked on an unapproved CODEOWNERS section. Absent = one implicit authors tab (fallback lives in the board reader, never here).",
|
|
354
|
+
},
|
|
348
355
|
|
|
349
356
|
// --- board (user) ----------------------------------------------------------
|
|
350
357
|
{
|
package/src/settings/write.ts
CHANGED
|
@@ -160,6 +160,49 @@ export function setSetting(key: string, value: unknown, scope: SettingScope, opt
|
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Removes `key` from the given scope's store, comment-preserving. The refusal
|
|
165
|
+
* ladder is `setSetting`'s minus the value check (there is no value): unknown
|
|
166
|
+
* key, unmigrated, scope not in `def.scopes`, repoIdentity on a non-repoScoped
|
|
167
|
+
* key, and the team-selection rule when ambiguous. Divergences from set, both
|
|
168
|
+
* because removal has nothing to act on: a store FILE that does not exist is a
|
|
169
|
+
* clean no-op rather than a refusal (an explicit `opts.team` naming a team
|
|
170
|
+
* with no local store included — nothing to remove is success, not an error),
|
|
171
|
+
* and a key not present in the store is a no-op. Returns whether anything was
|
|
172
|
+
* actually removed; the local-only reminder prints only on a real removal.
|
|
173
|
+
*/
|
|
174
|
+
export function unsetSetting(key: string, scope: SettingScope, opts: SetSettingOpts = {}): boolean {
|
|
175
|
+
const def = getDef(key);
|
|
176
|
+
if (!def) {
|
|
177
|
+
refuse(`unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!isMigrated(def)) {
|
|
181
|
+
refuse(migratedFalseMessage(key, def));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!def.scopes.includes(scope)) {
|
|
185
|
+
refuse(`"${key}" cannot be unset in the ${scope} store (allowed: ${def.scopes.join(", ")})`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (opts.repoIdentity !== undefined && def.repoScoped !== true) {
|
|
189
|
+
refuse(`"${key}" is not repo-scoped — omit the repo identity`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const storePath = resolveStorePathForUnset(scope, opts);
|
|
193
|
+
if (storePath === null || !existsSync(storePath)) return false;
|
|
194
|
+
|
|
195
|
+
const jsonPath: JSONPath = opts.repoIdentity !== undefined ? ["repos", opts.repoIdentity, key] : [key];
|
|
196
|
+
const removed = removeFromStore(storePath, jsonPath);
|
|
197
|
+
|
|
198
|
+
if (removed) {
|
|
199
|
+
console.error(
|
|
200
|
+
`rt: removed "${key}" from the local ${scope} store (${storePath}) — this is local only until you commit and push it.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return removed;
|
|
204
|
+
}
|
|
205
|
+
|
|
163
206
|
function migratedFalseMessage(key: string, def: SettingDef): string {
|
|
164
207
|
const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : "";
|
|
165
208
|
return `"${key}" is not writable through the settings resolver yet${legacyPart}`;
|
|
@@ -188,6 +231,29 @@ function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string {
|
|
|
188
231
|
return teamSettingsPath(teams[0] as string);
|
|
189
232
|
}
|
|
190
233
|
|
|
234
|
+
/**
|
|
235
|
+
* `resolveStorePath` for removal: same selection rule, but "no store to
|
|
236
|
+
* target" answers null (nothing to remove) instead of refusing — EXCEPT the
|
|
237
|
+
* multiple-teams case, which still refuses: guessing which team's store to
|
|
238
|
+
* edit is banned on the unset side for the same reason as the set side.
|
|
239
|
+
*/
|
|
240
|
+
function resolveStorePathForUnset(scope: SettingScope, opts: SetSettingOpts): string | null {
|
|
241
|
+
if (scope === "user") return userSettingsPath();
|
|
242
|
+
if (scope === "machine") return machineSettingsPath();
|
|
243
|
+
|
|
244
|
+
if (opts.team !== undefined) {
|
|
245
|
+
const path = teamSettingsPath(opts.team);
|
|
246
|
+
return existsSync(path) ? path : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const teams = listTeams();
|
|
250
|
+
if (teams.length === 0) return null;
|
|
251
|
+
if (teams.length > 1) {
|
|
252
|
+
refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
|
|
253
|
+
}
|
|
254
|
+
return teamSettingsPath(teams[0] as string);
|
|
255
|
+
}
|
|
256
|
+
|
|
191
257
|
/** `// header comment\n{}\n` — see module doc for why the object must be seeded before the first `modify`. */
|
|
192
258
|
function seedHeader(): string {
|
|
193
259
|
return `// rt settings — created by \`rt settings set\`. JSONC: comments and trailing commas are fine.\n{}\n`;
|
|
@@ -279,6 +345,31 @@ function writeIntoStore(storePath: string, jsonPath: JSONPath, value: unknown, c
|
|
|
279
345
|
// a torn write would sit as a corrupt uncommitted file until a human
|
|
280
346
|
// noticed. The edited TEXT is written as-is, never round-tripped through
|
|
281
347
|
// JSON.stringify, so comments and formatting survive.
|
|
348
|
+
writeTempThenRename(storePath, finalText);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Removes `jsonPath` from an existing store file. A key that isn't present
|
|
353
|
+
* yields zero edits from `modify` and the file is left untouched (no write,
|
|
354
|
+
* no mtime churn). Malformed stores refuse exactly as on the set side —
|
|
355
|
+
* `modify`-by-offset against a duplicate-key document is as wrong for
|
|
356
|
+
* removal as it is for writes.
|
|
357
|
+
*/
|
|
358
|
+
function removeFromStore(storePath: string, jsonPath: JSONPath): boolean {
|
|
359
|
+
const content = readFileSync(storePath, "utf8");
|
|
360
|
+
if (content.trim() === "") return false;
|
|
361
|
+
assertEditableJsonc(storePath, content);
|
|
362
|
+
|
|
363
|
+
const edits = modify(content, jsonPath, undefined, { formattingOptions: FORMAT });
|
|
364
|
+
if (edits.length === 0) return false;
|
|
365
|
+
|
|
366
|
+
const next = applyEdits(content, edits);
|
|
367
|
+
const finalText = next.endsWith("\n") ? next : `${next}\n`;
|
|
368
|
+
writeTempThenRename(storePath, finalText);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function writeTempThenRename(storePath: string, finalText: string): void {
|
|
282
373
|
const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
283
374
|
try {
|
|
284
375
|
writeFileSync(tmp, finalText);
|