@sigrun/sigrun 0.0.0-stage → 0.1.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +89 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The ambient seam (spec: docs/approval-ux-seams.md, "Ambient surfaces"):
3
+ * core-owned, channel-neutral records for persistent read-mostly state —
4
+ * statusline slots and panel widgets — written by extensions through the
5
+ * host context and rendered by channels generically.
6
+ *
7
+ * SPLIT (build-and-webui Task 1): the canonical TYPE declarations live in
8
+ * the `@sigrun/shared` package (`shared/src/ambient.ts` — the records ride
9
+ * the §5 wire as-is, so the shapes ARE the channel contract); this module
10
+ * owns the docs, validation, and registry RUNTIME, importing the types
11
+ * (type-only — no runtime dependency on the built package).
12
+ *
13
+ * Lifecycle difference from interactions: an ambient surface is replaced
14
+ * repeatedly (write = FULL state replacement, never a delta) and never
15
+ * blocks. One slot key is owned by exactly one extension (allocation,
16
+ * not silent composition — the pi-zentui chrome-skin lesson).
17
+ *
18
+ * Layering mirrors the interaction seam: strict envelope (key, place,
19
+ * budget, refresh, expires) around a body composed only from the closed
20
+ * ambient primitives (line, rows, gauge, ledger), with opaque `details`.
21
+ */
22
+ import { defineDoc, defineDocFamily } from "@earendil-works/pi-durable";
23
+ /**
24
+ * The ambient record family: one record per surface key, per conversation.
25
+ * (Session-scoped statuslines that outlive one conversation are a v2
26
+ * concern; channels today render conversation-scoped state.)
27
+ */
28
+ export const AmbientDoc = defineDocFamily({
29
+ kind: "sigrun.ambient",
30
+ version: 1,
31
+ scope: "conversation",
32
+ history: "latest",
33
+ fork: "current",
34
+ family: true,
35
+ initial: (key) => ({
36
+ key,
37
+ owner: "",
38
+ place: "status",
39
+ expires: "never",
40
+ updatedAt: 0,
41
+ }),
42
+ });
43
+ export const AmbientOwnersDoc = defineDoc({
44
+ kind: "sigrun.ambient.owners",
45
+ version: 1,
46
+ scope: "session",
47
+ initial: () => ({ owners: {} }),
48
+ });
49
+ const PLACES = new Set(["status", "footer", "panel"]);
50
+ const OVERFLOWS = new Set(["drop", "wrap", "summarize"]);
51
+ const FORMATS = new Set(["compact", "usd", "percent"]);
52
+ const ZONES = new Set(["left", "middle", "right"]);
53
+ /** Structural validation of one ambient record. Returns the error text or null. */
54
+ export function validateAmbient(value) {
55
+ if (typeof value !== "object" || value === null) {
56
+ return "ambient record must be an object";
57
+ }
58
+ const r = value;
59
+ if (typeof r.key !== "string" || r.key.length === 0) {
60
+ return "key must be a non-empty string";
61
+ }
62
+ if (typeof r.place !== "string" || !PLACES.has(r.place)) {
63
+ return `place must be one of status|footer|panel (got ${String(r.place)})`;
64
+ }
65
+ if (r.maxRows !== undefined && typeof r.maxRows !== "number") {
66
+ return "maxRows must be a number";
67
+ }
68
+ if (r.overflow !== undefined && !OVERFLOWS.has(String(r.overflow))) {
69
+ return `overflow must be one of drop|wrap|summarize (got ${String(r.overflow)})`;
70
+ }
71
+ if (r.refreshMs !== undefined && typeof r.refreshMs !== "number") {
72
+ return "refreshMs must be a number";
73
+ }
74
+ if (r.expires !== "never" && r.expires !== "idle") {
75
+ return `expires must be "never" or "idle" (got ${String(r.expires)})`;
76
+ }
77
+ if (typeof r.updatedAt !== "number") {
78
+ return "updatedAt must be a number";
79
+ }
80
+ const body = r.body;
81
+ if (body === undefined)
82
+ return null; // an empty write clears the surface
83
+ if (typeof body !== "object" || body === null) {
84
+ return "body must be an object";
85
+ }
86
+ const b = body;
87
+ const known = new Set(["line", "rows", "gauge", "ledger"]);
88
+ const unknownKey = Object.keys(b).find((k) => !known.has(k));
89
+ if (unknownKey !== undefined) {
90
+ return `body.${unknownKey} is outside the closed primitives (move it to details)`;
91
+ }
92
+ if (b.line !== undefined) {
93
+ const problem = validateLine(b.line);
94
+ if (problem !== null)
95
+ return problem;
96
+ }
97
+ if (b.rows !== undefined) {
98
+ if (!Array.isArray(b.rows))
99
+ return "body.rows must be an array";
100
+ for (const row of b.rows) {
101
+ const problem = validateRow(row);
102
+ if (problem !== null)
103
+ return problem;
104
+ }
105
+ }
106
+ if (b.gauge !== undefined) {
107
+ const problem = validateGauge(b.gauge);
108
+ if (problem !== null)
109
+ return problem;
110
+ }
111
+ if (b.ledger !== undefined) {
112
+ if (!Array.isArray(b.ledger))
113
+ return "body.ledger must be an array";
114
+ for (const e of b.ledger) {
115
+ const problem = validateLedgerEntry(e);
116
+ if (problem !== null)
117
+ return problem;
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ function validateLine(value) {
123
+ if (typeof value !== "object" || value === null)
124
+ return "line must be an object";
125
+ const l = value;
126
+ if (l.text === undefined && l.segments === undefined) {
127
+ return "line needs text or segments";
128
+ }
129
+ if (l.text !== undefined && typeof l.text !== "string") {
130
+ return "line.text must be a string";
131
+ }
132
+ if (l.segments !== undefined) {
133
+ if (!Array.isArray(l.segments))
134
+ return "line.segments must be an array";
135
+ for (const s of l.segments) {
136
+ if (typeof s !== "object" || s === null)
137
+ return "segment must be an object";
138
+ const seg = s;
139
+ if (typeof seg.id !== "string" || seg.id.length === 0) {
140
+ return "segment.id must be a non-empty string";
141
+ }
142
+ if (typeof seg.text !== "string")
143
+ return "segment.text must be a string";
144
+ if (seg.place !== undefined && !ZONES.has(String(seg.place))) {
145
+ return `segment.place must be one of left|middle|right (got ${String(seg.place)})`;
146
+ }
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ function validateRow(value) {
152
+ if (typeof value !== "object" || value === null)
153
+ return "row must be an object";
154
+ const r = value;
155
+ if (typeof r.id !== "string" || r.id.length === 0) {
156
+ return "row.id must be a non-empty string";
157
+ }
158
+ if (typeof r.label !== "string")
159
+ return "row.label must be a string";
160
+ if (r.status !== undefined && typeof r.status !== "string") {
161
+ return "row.status must be a string";
162
+ }
163
+ if (r.severity !== undefined &&
164
+ r.severity !== 0 &&
165
+ r.severity !== 1 &&
166
+ r.severity !== 2) {
167
+ return "row.severity must be 0, 1, or 2";
168
+ }
169
+ if (r.quiet !== undefined && typeof r.quiet !== "boolean") {
170
+ return "row.quiet must be a boolean";
171
+ }
172
+ if (r.detail !== undefined && typeof r.detail !== "string") {
173
+ return "row.detail must be a string";
174
+ }
175
+ if (r.depth !== undefined && typeof r.depth !== "number") {
176
+ return "row.depth must be a number";
177
+ }
178
+ if (r.deps !== undefined && !Array.isArray(r.deps)) {
179
+ return "row.deps must be an array of ids";
180
+ }
181
+ if (Array.isArray(r.deps) && r.deps.some((d) => typeof d !== "string")) {
182
+ return "row.deps must be an array of ids";
183
+ }
184
+ if (r.spinnerLabel !== undefined && typeof r.spinnerLabel !== "string") {
185
+ return "row.spinnerLabel must be a string";
186
+ }
187
+ if (r.metrics !== undefined) {
188
+ const problem = validateMetrics(r.metrics);
189
+ if (problem !== null)
190
+ return problem;
191
+ }
192
+ return null;
193
+ }
194
+ function validateMetrics(value) {
195
+ if (typeof value !== "object" || value === null)
196
+ return "metrics must be an object";
197
+ const m = value;
198
+ for (const k of ["tokens", "cost", "current", "total", "completed"]) {
199
+ if (m[k] !== undefined && typeof m[k] !== "number") {
200
+ return `metrics.${k} must be a number`;
201
+ }
202
+ }
203
+ if (m.format !== undefined && !FORMATS.has(String(m.format))) {
204
+ return `metrics.format must be one of compact|usd|percent (got ${String(m.format)})`;
205
+ }
206
+ return null;
207
+ }
208
+ function validateGauge(value) {
209
+ if (typeof value !== "object" || value === null)
210
+ return "gauge must be an object";
211
+ const g = value;
212
+ if (typeof g.label !== "string")
213
+ return "gauge.label must be a string";
214
+ if (g.percent !== undefined && typeof g.percent !== "number") {
215
+ return "gauge.percent must be a number";
216
+ }
217
+ if (g.phase !== undefined && typeof g.phase !== "string") {
218
+ return "gauge.phase must be a string";
219
+ }
220
+ if (g.elapsedMs !== undefined && typeof g.elapsedMs !== "number") {
221
+ return "gauge.elapsedMs must be a number";
222
+ }
223
+ if (g.recentOutput !== undefined && !Array.isArray(g.recentOutput)) {
224
+ return "gauge.recentOutput must be an array of strings";
225
+ }
226
+ if (Array.isArray(g.recentOutput) && g.recentOutput.some((l) => typeof l !== "string")) {
227
+ return "gauge.recentOutput must be an array of strings";
228
+ }
229
+ if (g.checks !== undefined && !Array.isArray(g.checks)) {
230
+ return "gauge.checks must be row-shaped";
231
+ }
232
+ if (Array.isArray(g.checks)) {
233
+ for (const row of g.checks) {
234
+ const problem = validateRow(row);
235
+ if (problem !== null)
236
+ return `gauge.checks: ${problem}`;
237
+ }
238
+ }
239
+ return null;
240
+ }
241
+ function validateLedgerEntry(value) {
242
+ if (typeof value !== "object" || value === null)
243
+ return "ledger entry must be an object";
244
+ const e = value;
245
+ if (typeof e.id !== "string" || e.id.length === 0) {
246
+ return "ledger.id must be a non-empty string";
247
+ }
248
+ if (typeof e.at !== "number")
249
+ return "ledger.at must be a number";
250
+ if (typeof e.kind !== "string")
251
+ return "ledger.kind must be a string";
252
+ if (typeof e.summary !== "string")
253
+ return "ledger.summary must be a string";
254
+ if (e.detail !== undefined && !Array.isArray(e.detail)) {
255
+ return "ledger.detail must be an array of strings";
256
+ }
257
+ if (Array.isArray(e.detail) && e.detail.some((d) => typeof d !== "string")) {
258
+ return "ledger.detail must be an array of strings";
259
+ }
260
+ return null;
261
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The auth store (the 2026-10-13 config-surface ruling): a pi-style
3
+ * credentials file at `<config-dir>/auth.json` — the same directory the
4
+ * settings store lives in (`SIGRUN_CONFIG_DIR` / `~/.config/sigrun`).
5
+ *
6
+ * Shape: `{ "<provider>": { "apiKey": "..." } }`, one entry per provider
7
+ * id, plus the magic key `"default"` naming the DEFAULT provider's key —
8
+ * so a deployment that does not know the resolved provider id up front
9
+ * can still hand the env-driven OpenAI-compatible provider its key.
10
+ *
11
+ * The file is created with mode 0o600 (owner-only) whenever a write
12
+ * happens. Key material is NEVER logged or echoed: validation errors
13
+ * name the key path, never the value; callers must not interpolate
14
+ * resolved keys into messages.
15
+ *
16
+ * Absent file = no stored keys (the `OPENAI_API_KEY` env fallback still
17
+ * applies). A malformed file (bad JSON, non-object, a key present but not
18
+ * a non-empty string) fails loud on read, like a bad `SIGRUN_*` value.
19
+ */
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
21
+ import { mkdir } from "node:fs/promises";
22
+ import { dirname, join } from "node:path";
23
+ import { defaultSettingsDir } from "./settings.js";
24
+ import { writeFileAtomic } from "./stores.js";
25
+ /** The magic provider key meaning "the resolved default provider's key". */
26
+ export const DEFAULT_AUTH_KEY = "default";
27
+ /** Default file path: `<dir>/auth.json`. */
28
+ export function authPath(dir) {
29
+ return join(dir, "auth.json");
30
+ }
31
+ /** A field-level validation error; names the key path, never the value. */
32
+ export class AuthValidationError extends Error {
33
+ field;
34
+ constructor(field, reason) {
35
+ super(`auth: ${field} ${reason}`);
36
+ this.field = field;
37
+ }
38
+ }
39
+ /**
40
+ * A file-backed auth store. Reads are fresh per call (same discipline as
41
+ * the settings store). The store is permissive about unknown fields inside
42
+ * an entry (future auth kinds — token refresh, base-url pins — may add
43
+ * some) but strict about `apiKey`: when present it must be a non-empty
44
+ * string.
45
+ */
46
+ export class AuthStore {
47
+ path;
48
+ constructor(options = {}) {
49
+ this.path = authPath(options.dir ?? defaultSettingsDir());
50
+ }
51
+ /** The current auth file; absent file = `{}` (no stored keys). */
52
+ read() {
53
+ if (!existsSync(this.path))
54
+ return {};
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(readFileSync(this.path, "utf8"));
58
+ }
59
+ catch (cause) {
60
+ throw new Error(`auth: ${this.path} is not valid JSON (${cause instanceof Error ? cause.message : String(cause)})`);
61
+ }
62
+ if (typeof parsed !== "object" ||
63
+ parsed === null ||
64
+ Array.isArray(parsed)) {
65
+ throw new Error(`auth: ${this.path} must contain a JSON object`);
66
+ }
67
+ const file = parsed;
68
+ for (const [provider, entry] of Object.entries(file)) {
69
+ if (typeof entry !== "object" ||
70
+ entry === null ||
71
+ Array.isArray(entry)) {
72
+ throw new AuthValidationError(provider, "must be an object");
73
+ }
74
+ const apiKey = entry.apiKey;
75
+ if (apiKey !== undefined &&
76
+ (typeof apiKey !== "string" || apiKey === "")) {
77
+ throw new AuthValidationError(`${provider}.apiKey`, "must be a non-empty string");
78
+ }
79
+ }
80
+ return file;
81
+ }
82
+ /**
83
+ * Persist `file` wholesale, creating the file mode 0o600 (owner-only —
84
+ * the file carries key material). An existing file is chmod'd to
85
+ * 0o600 as well: a hand-created world-readable auth file is tightened
86
+ * on the first write.
87
+ */
88
+ write(file) {
89
+ mkdirSync(dirname(this.path), { recursive: true });
90
+ writeFileSync(this.path, `${JSON.stringify(file, null, "\t")}\n`, {
91
+ mode: 0o600,
92
+ });
93
+ }
94
+ /**
95
+ * Set one key's api key (core hardening W3 Task 6, spec §7.6): the
96
+ * server's credential ROUTE writes through this — read-modify-write
97
+ * over the current file (other entries untouched), staged through
98
+ * {@link writeFileAtomic} at 0600 so a crash or concurrent reader only
99
+ * ever sees the old or new complete file. `key` is the auth.json
100
+ * provider key (`<id>`, or the magic `"default"` for the default
101
+ * model's provider); `apiKey` must be a non-empty string — anything
102
+ * else is refused HERE rather than written as a file the store's own
103
+ * `read()` would reject. The pi-ai bridge stays read-only; this method
104
+ * is the server's single write seam.
105
+ */
106
+ async setApiKey(key, apiKey) {
107
+ if (typeof apiKey !== "string" || apiKey === "") {
108
+ throw new AuthValidationError(`${key}.apiKey`, "must be a non-empty string");
109
+ }
110
+ const file = { ...this.read() };
111
+ file[key] = { ...file[key], apiKey };
112
+ await mkdir(dirname(this.path), { recursive: true });
113
+ await writeFileAtomic(this.path, `${JSON.stringify(file, null, "\t")}\n`, 0o600);
114
+ }
115
+ /**
116
+ * Remove one key's entry entirely (spec §7.6: "DELETE removes the
117
+ * entry") — atomic and 0600 like every write. Idempotent: an absent
118
+ * key writes nothing (DELETE of a never-set credential is a no-op,
119
+ * not an error).
120
+ */
121
+ async deleteApiKey(key) {
122
+ const file = this.read();
123
+ if (file[key] === undefined)
124
+ return;
125
+ const next = { ...file };
126
+ delete next[key];
127
+ await mkdir(dirname(this.path), { recursive: true });
128
+ await writeFileAtomic(this.path, `${JSON.stringify(next, null, "\t")}\n`, 0o600);
129
+ }
130
+ }