@mattstack/rt-client 0.4.0 → 0.4.1
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/index.d.ts +1 -1
- package/dist/index.js +60 -0
- package/dist/settings/write.d.ts +12 -0
- package/package.json +1 -1
- package/src/index.ts +1 -1
- 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/index.d.ts
CHANGED
|
@@ -8,7 +8,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
|
@@ -1202,6 +1202,30 @@ function setSetting(key, value, scope, opts = {}) {
|
|
|
1202
1202
|
writeIntoStore(storePath, jsonPath, value, scope !== "team");
|
|
1203
1203
|
console.error(`rt: wrote "${key}" to the local ${scope} store (${storePath}) — this is local only until you commit and push it.`);
|
|
1204
1204
|
}
|
|
1205
|
+
function unsetSetting(key, scope, opts = {}) {
|
|
1206
|
+
const def = getDef(key);
|
|
1207
|
+
if (!def) {
|
|
1208
|
+
refuse(`unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
|
|
1209
|
+
}
|
|
1210
|
+
if (!isMigrated(def)) {
|
|
1211
|
+
refuse(migratedFalseMessage(key, def));
|
|
1212
|
+
}
|
|
1213
|
+
if (!def.scopes.includes(scope)) {
|
|
1214
|
+
refuse(`"${key}" cannot be unset in the ${scope} store (allowed: ${def.scopes.join(", ")})`);
|
|
1215
|
+
}
|
|
1216
|
+
if (opts.repoIdentity !== undefined && def.repoScoped !== true) {
|
|
1217
|
+
refuse(`"${key}" is not repo-scoped — omit the repo identity`);
|
|
1218
|
+
}
|
|
1219
|
+
const storePath = resolveStorePathForUnset(scope, opts);
|
|
1220
|
+
if (storePath === null || !existsSync3(storePath))
|
|
1221
|
+
return false;
|
|
1222
|
+
const jsonPath = opts.repoIdentity !== undefined ? ["repos", opts.repoIdentity, key] : [key];
|
|
1223
|
+
const removed = removeFromStore(storePath, jsonPath);
|
|
1224
|
+
if (removed) {
|
|
1225
|
+
console.error(`rt: removed "${key}" from the local ${scope} store (${storePath}) — this is local only until you commit and push it.`);
|
|
1226
|
+
}
|
|
1227
|
+
return removed;
|
|
1228
|
+
}
|
|
1205
1229
|
function migratedFalseMessage(key, def) {
|
|
1206
1230
|
const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : "";
|
|
1207
1231
|
return `"${key}" is not writable through the settings resolver yet${legacyPart}`;
|
|
@@ -1227,6 +1251,23 @@ function resolveStorePath(scope, opts) {
|
|
|
1227
1251
|
}
|
|
1228
1252
|
return teamSettingsPath(teams[0]);
|
|
1229
1253
|
}
|
|
1254
|
+
function resolveStorePathForUnset(scope, opts) {
|
|
1255
|
+
if (scope === "user")
|
|
1256
|
+
return userSettingsPath();
|
|
1257
|
+
if (scope === "machine")
|
|
1258
|
+
return machineSettingsPath();
|
|
1259
|
+
if (opts.team !== undefined) {
|
|
1260
|
+
const path = teamSettingsPath(opts.team);
|
|
1261
|
+
return existsSync3(path) ? path : null;
|
|
1262
|
+
}
|
|
1263
|
+
const teams = listTeams();
|
|
1264
|
+
if (teams.length === 0)
|
|
1265
|
+
return null;
|
|
1266
|
+
if (teams.length > 1) {
|
|
1267
|
+
refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
|
|
1268
|
+
}
|
|
1269
|
+
return teamSettingsPath(teams[0]);
|
|
1270
|
+
}
|
|
1230
1271
|
function seedHeader() {
|
|
1231
1272
|
return `// rt settings — created by \`rt settings set\`. JSONC: comments and trailing commas are fine.
|
|
1232
1273
|
{}
|
|
@@ -1289,6 +1330,24 @@ function writeIntoStore(storePath, jsonPath, value, createIfMissing) {
|
|
|
1289
1330
|
const finalText = next.endsWith(`
|
|
1290
1331
|
`) ? next : `${next}
|
|
1291
1332
|
`;
|
|
1333
|
+
writeTempThenRename(storePath, finalText);
|
|
1334
|
+
}
|
|
1335
|
+
function removeFromStore(storePath, jsonPath) {
|
|
1336
|
+
const content = readFileSync4(storePath, "utf8");
|
|
1337
|
+
if (content.trim() === "")
|
|
1338
|
+
return false;
|
|
1339
|
+
assertEditableJsonc(storePath, content);
|
|
1340
|
+
const edits = modify(content, jsonPath, undefined, { formattingOptions: FORMAT });
|
|
1341
|
+
if (edits.length === 0)
|
|
1342
|
+
return false;
|
|
1343
|
+
const next = applyEdits(content, edits);
|
|
1344
|
+
const finalText = next.endsWith(`
|
|
1345
|
+
`) ? next : `${next}
|
|
1346
|
+
`;
|
|
1347
|
+
writeTempThenRename(storePath, finalText);
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
function writeTempThenRename(storePath, finalText) {
|
|
1292
1351
|
const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1293
1352
|
try {
|
|
1294
1353
|
writeFileSync(tmp, finalText);
|
|
@@ -1451,6 +1510,7 @@ async function resolveNameToIdentity(name, reposJsonPath) {
|
|
|
1451
1510
|
}
|
|
1452
1511
|
export {
|
|
1453
1512
|
validateValue,
|
|
1513
|
+
unsetSetting,
|
|
1454
1514
|
subscribe,
|
|
1455
1515
|
setSetting,
|
|
1456
1516
|
serializeIdentity,
|
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
package/src/index.ts
CHANGED
|
@@ -68,7 +68,7 @@ export type {
|
|
|
68
68
|
ExpandCtx,
|
|
69
69
|
} from "./settings/resolve.ts";
|
|
70
70
|
|
|
71
|
-
export { setSetting } from "./settings/write.ts";
|
|
71
|
+
export { setSetting, unsetSetting } from "./settings/write.ts";
|
|
72
72
|
export type { SetSettingOpts } from "./settings/write.ts";
|
|
73
73
|
|
|
74
74
|
export { getDef, allDefs, validateValue, isMigrated } from "./settings/registry-machinery.ts";
|
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);
|