@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,450 @@
1
+ /**
2
+ * The sigrun store roots (extension config surfaces, spec §3/§4): the
3
+ * four XDG-respected roots every on-disk surface resolves under —
4
+ * config, data, cache, state — plus the extension slug function that
5
+ * names every per-extension surface under them. ONE resolution chain
6
+ * per root:
7
+ *
8
+ * 1. explicit env — `SIGRUN_CONFIG_DIR` / `SIGRUN_DATA_DIR` /
9
+ * `SIGRUN_CACHE_DIR` / `SIGRUN_STATE_DIR`, used VERBATIM (never
10
+ * suffixed; the deploy sets these to PVC/emptyDir mounts);
11
+ * 2. `$XDG_CONFIG_HOME` / `$XDG_DATA_HOME` / `$XDG_CACHE_HOME` /
12
+ * `$XDG_STATE_HOME` + `/sigrun`, when set and non-empty;
13
+ * 3. the spec defaults of {@link DEFAULT_STORE_ROOTS} (`~` expanded via
14
+ * `homedir()`, the join style of `defaultSettingsDir`).
15
+ *
16
+ * The empty-string-falls-through semantics (`undefined` OR `""` counts
17
+ * as unset) predate this module and hold across the whole chain. The
18
+ * core config-dir helpers (`defaultConfigDir` in src/cli.ts,
19
+ * `defaultSettingsDir` in src/harness/settings.ts) delegate to
20
+ * `resolveStoreRoots` for their configDir — ONE chain everywhere, so
21
+ * settings/auth/packages/git can never split from the store roots
22
+ * (spec Decision 3).
23
+ *
24
+ * Resolution is CALL-TIME on the `env` PARAMETER (never `process.env`
25
+ * directly, never cached): tests vary env per call, and boot reads the
26
+ * live process env once at the boundary. `homedir()` is the one
27
+ * process-global consulted, exactly as the precedents do.
28
+ *
29
+ * The lifecycle half sits below the resolver: `ensureStoreRoots` (the
30
+ * boot-time mkdir of every root but cache) and `ensureCacheDir` (the
31
+ * per-call repair of `<cacheDir>/<slug>`). The split is the plan's
32
+ * Review Focus 5: a wiped cache root (the container `emptyDir` case)
33
+ * must never break boot — so boot never ensures cache, and every
34
+ * cache-path consumer recreates it at call time instead.
35
+ */
36
+ import { randomUUID } from "node:crypto";
37
+ import { homedir } from "node:os";
38
+ import { mkdirSync } from "node:fs";
39
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
40
+ import { join } from "node:path";
41
+ /**
42
+ * The tier-3 defaults (spec §4): `~/.config/sigrun`,
43
+ * `~/.local/share/sigrun`, `~/.cache/sigrun`, `~/.local/state/sigrun`.
44
+ */
45
+ export const DEFAULT_STORE_ROOTS = {
46
+ config: ["~/.config", "sigrun"],
47
+ data: ["~/.local/share", "sigrun"],
48
+ cache: ["~/.cache", "sigrun"],
49
+ state: ["~/.local/state", "sigrun"],
50
+ };
51
+ /** The env var names each root's tiers read. */
52
+ const ENV_VARS = {
53
+ config: { explicit: "SIGRUN_CONFIG_DIR", xdg: "XDG_CONFIG_HOME" },
54
+ data: { explicit: "SIGRUN_DATA_DIR", xdg: "XDG_DATA_HOME" },
55
+ cache: { explicit: "SIGRUN_CACHE_DIR", xdg: "XDG_CACHE_HOME" },
56
+ state: { explicit: "SIGRUN_STATE_DIR", xdg: "XDG_STATE_HOME" },
57
+ };
58
+ /**
59
+ * Tier 1 — the explicit `SIGRUN_*_DIR` override, VERBATIM (no suffix,
60
+ * no `~` expansion, no absolutizing: the precedents use it as given).
61
+ * `undefined` when unset or empty (the unset-adjacent `""` falls
62
+ * through, exactly like `defaultConfigDir`/`defaultSettingsDir`).
63
+ */
64
+ function explicitRoot(env, name) {
65
+ const value = env[name];
66
+ return value !== undefined && value !== "" ? value : undefined;
67
+ }
68
+ /**
69
+ * Tier 2 — `$XDG_*_HOME` with the sigrun dir name appended. `undefined`
70
+ * when unset or empty (the XDG spec's own ""-means-unset reading).
71
+ */
72
+ function xdgRoot(env, name, dirName) {
73
+ const value = env[name];
74
+ return value !== undefined && value !== ""
75
+ ? join(value, dirName)
76
+ : undefined;
77
+ }
78
+ /**
79
+ * Expand a leading `~` against `home`: `~` alone → `home`, `~/rest` →
80
+ * `home/rest`, anything else unchanged. (Only the fixed
81
+ * {@link DEFAULT_STORE_ROOTS} constants flow through here — a future
82
+ * default without the `~` prefix must pass through untouched, not
83
+ * have its first segment silently swallowed.)
84
+ */
85
+ function expandHome(path, home) {
86
+ if (path === "~")
87
+ return home;
88
+ if (path.startsWith("~/"))
89
+ return join(home, path.slice(2));
90
+ return path;
91
+ }
92
+ /** Tier 3 — the spec default: `~`-expanded base + dir name. */
93
+ function defaultRoot(spec, home) {
94
+ return join(expandHome(spec[0], home), spec[1]);
95
+ }
96
+ /** The config root's per-extension settings subdirectory (spec §3 map). */
97
+ const SETTINGS_DIR_NAME = "settings";
98
+ /**
99
+ * `mkdir -p` one path, failing loud with the path and the operation
100
+ * named when it cannot be created (EACCES/EROFS and friends — the
101
+ * message carries the root so a mispermissioned deploy points at the
102
+ * exact directory, not a generic sqlite/npm error from further in).
103
+ */
104
+ async function ensureDir(path) {
105
+ try {
106
+ await mkdir(path, { recursive: true });
107
+ }
108
+ catch (error) {
109
+ throw mkdirFailure(path, error);
110
+ }
111
+ }
112
+ /** The loud `cannot mkdir` error both ensure paths share (async + sync). */
113
+ function mkdirFailure(path, error) {
114
+ const { code, message } = error;
115
+ const detail = code === undefined
116
+ ? String(error)
117
+ : `${code}: ${message ?? String(error)}`;
118
+ return new Error(`sigrun: cannot mkdir ${path} (${detail})`, {
119
+ cause: error,
120
+ });
121
+ }
122
+ /**
123
+ * Ensure the durable store roots exist (spec §6 boot): mkdir recursive
124
+ * on the config root AND its `settings/` subdirectory (the
125
+ * per-extension settings files' home), plus the data and state roots.
126
+ * The CACHE root is deliberately absent from this list — it is
127
+ * regenerable and wipe-prone (`emptyDir` mounts), so ensuring it at
128
+ * boot would make a wipe break the NEXT boot's repair story instead;
129
+ * `ensureCacheDir` owns it, per call.
130
+ */
131
+ export async function ensureStoreRoots(roots) {
132
+ await ensureDir(roots.configDir);
133
+ await ensureDir(join(roots.configDir, SETTINGS_DIR_NAME));
134
+ await ensureDir(roots.dataDir);
135
+ await ensureDir(roots.stateDir);
136
+ }
137
+ /**
138
+ * Ensure and return `<cacheDir>/<slug>` — mkdir -p AT CALL TIME (never
139
+ * at boot), recreating the cache root itself when it has vanished
140
+ * since the last call (the wiped-`emptyDir` repair, Review Focus 5).
141
+ * Idempotent: an existing directory is a no-op. Never touches any
142
+ * other root — repair is confined to the cache path, and the resolved
143
+ * path always sits under `roots.cacheDir` (there is no fallback chain;
144
+ * a missing cache root is recreated, never rerouted under data).
145
+ *
146
+ * The mkdir core is synchronous (`ensureCacheDirAt`): the async wrapper
147
+ * is the call-time contract callers hold, while the sync core serves
148
+ * the extension-stores handle's `paths.cache` getter — a getter cannot
149
+ * await, and a fire-and-forget ensure would let a caller stat a path
150
+ * whose mkdir is still in flight. Same semantics, one implementation.
151
+ */
152
+ export async function ensureCacheDir(roots, slug) {
153
+ return ensureCacheDirAt(roots, slug);
154
+ }
155
+ /** The synchronous mkdir core behind {@link ensureCacheDir}. */
156
+ function ensureCacheDirAt(roots, slug) {
157
+ const dir = join(roots.cacheDir, slug);
158
+ try {
159
+ mkdirSync(dir, { recursive: true });
160
+ }
161
+ catch (error) {
162
+ throw mkdirFailure(dir, error);
163
+ }
164
+ return dir;
165
+ }
166
+ /**
167
+ * The extension slug (spec §6 rename rule): the sanitized identity every
168
+ * sigrun surface keys on — registry name, store sections, seam records,
169
+ * worker names. The chain, verbatim: take everything after the LAST `/`
170
+ * (npm scopes `@org/name` and URL-ish git names both collapse to their
171
+ * last segment — `@a/tool` and `@b/tool` share a slug by design), strip a
172
+ * trailing `@version` suffix (defensive; manifest names cannot carry
173
+ * one), lowercase, fold `[^a-z0-9_-]+` runs to a single `-`, trim edge
174
+ * `-`/`_`. An empty result is the CALLER's rejection — the loader fails
175
+ * the load naming the declared name (Review Focus 2); this function
176
+ * itself just returns "" for names nothing survives.
177
+ */
178
+ export function slugifyName(declared) {
179
+ const segment = declared.slice(declared.lastIndexOf("/") + 1);
180
+ const versionAt = segment.lastIndexOf("@");
181
+ const unversioned = versionAt === -1 ? segment : segment.slice(0, versionAt);
182
+ return unversioned
183
+ .toLowerCase()
184
+ .replace(/[^a-z0-9_-]+/g, "-")
185
+ .replace(/^[-_]+|[-_]+$/g, "");
186
+ }
187
+ /**
188
+ * Resolve all four store roots from `env` (spec §4's one chain, applied
189
+ * per root). Pure over the argument; call it wherever the roots are
190
+ * needed — nothing is cached, so tests and boot share the same path.
191
+ */
192
+ export function resolveStoreRoots(env) {
193
+ const home = homedir();
194
+ const one = (kind) => {
195
+ const { explicit, xdg } = ENV_VARS[kind];
196
+ const spec = DEFAULT_STORE_ROOTS[kind];
197
+ return (explicitRoot(env, explicit) ??
198
+ xdgRoot(env, xdg, spec[1]) ??
199
+ defaultRoot(spec, home));
200
+ };
201
+ return {
202
+ configDir: one("config"),
203
+ dataDir: one("data"),
204
+ cacheDir: one("cache"),
205
+ stateDir: one("state"),
206
+ };
207
+ }
208
+ /**
209
+ * The extension store classes + the secret precedence resolver (spec
210
+ * §3's server-mediated surfaces): extension code NEVER touches these
211
+ * files directly — it asks the server, which is the single writer.
212
+ * Every write goes through {@link writeFileAtomic}, so a crash or a
213
+ * concurrent reader can only ever see the old complete file or the new
214
+ * complete file, never a half-written one.
215
+ */
216
+ /**
217
+ * Atomic file replace: stage `data` to a fresh temp file in the SAME
218
+ * directory as `path` (rename is same-filesystem atomic), stamp the
219
+ * final `mode` on the temp BEFORE the rename (a chmod after the rename
220
+ * would leave a window at the wrong permissions on secrets; `chmod`
221
+ * beats `writeFile`'s `mode` option here because the latter is masked
222
+ * by umask), then `rename` over the target. On failure the staged temp
223
+ * is best-effort removed and the error rethrown — the target keeps its
224
+ * old bytes.
225
+ */
226
+ export async function writeFileAtomic(path, data, mode) {
227
+ const temp = `${path}.${randomUUID()}.tmp`;
228
+ try {
229
+ await writeFile(temp, data, { mode });
230
+ await chmod(temp, mode);
231
+ await rename(temp, path);
232
+ }
233
+ catch (error) {
234
+ await rm(temp, { force: true }).catch(() => { });
235
+ throw error;
236
+ }
237
+ }
238
+ /** Serialize a JSON value the house way (tabs, trailing newline). */
239
+ function serializeJson(value) {
240
+ return `${JSON.stringify(value, null, "\t")}\n`;
241
+ }
242
+ /**
243
+ * Read one JSON-object file. Absent (`ENOENT`) → `{}` — first use is
244
+ * not an error for either store. Every OTHER failure mode is loud:
245
+ * unparseable JSON, or JSON that is not an object (an array/scalar
246
+ * file is malformed for a map-shaped surface). The error names the
247
+ * path — a corrupt `secrets.json` must stop the show pointing at the
248
+ * file, never be silently reset to `{}` by the next write.
249
+ */
250
+ async function readJsonObject(path) {
251
+ let text;
252
+ try {
253
+ text = await readFile(path, "utf8");
254
+ }
255
+ catch (error) {
256
+ if (error.code === "ENOENT")
257
+ return {};
258
+ throw error;
259
+ }
260
+ let parsed;
261
+ try {
262
+ parsed = JSON.parse(text);
263
+ }
264
+ catch (error) {
265
+ throw new Error(`sigrun: malformed JSON in ${path}: ${String(error)}`, { cause: error });
266
+ }
267
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
268
+ throw new Error(`sigrun: malformed JSON in ${path}: expected an object, got ${Array.isArray(parsed) ? "an array" : typeof parsed}`);
269
+ }
270
+ return parsed;
271
+ }
272
+ /** An object-shaped value check (sections are always written objects). */
273
+ function isPlainObject(value) {
274
+ return (typeof value === "object" && value !== null && !Array.isArray(value));
275
+ }
276
+ /**
277
+ * The secrets store (spec §3): ONE `secrets.json` at the config root,
278
+ * keyed by extension slug — whole-file read-modify-write, atomic,
279
+ * `0600`. The server (the API-mediated secret endpoints) is the only
280
+ * writer; extension code reads through `resolveSecretFor`.
281
+ */
282
+ export class SecretsStore {
283
+ secretsPath;
284
+ constructor(secretsPath) {
285
+ this.secretsPath = secretsPath;
286
+ }
287
+ /** One extension's secret section; absent section → `{}`. */
288
+ async readSection(slug) {
289
+ const section = (await readJsonObject(this.secretsPath))[slug];
290
+ return isPlainObject(section) ? section : {};
291
+ }
292
+ /**
293
+ * Set one extension's section, preserving every sibling section
294
+ * (read-modify-write of the whole map). Loud on a malformed file —
295
+ * never a silent reset.
296
+ */
297
+ async writeSection(slug, section) {
298
+ const all = await readJsonObject(this.secretsPath);
299
+ all[slug] = section;
300
+ await writeFileAtomic(this.secretsPath, serializeJson(all), 0o600);
301
+ }
302
+ }
303
+ /**
304
+ * The per-extension settings store (spec §3): one JSON file per slug
305
+ * under `<configDir>/settings/`, contents entirely the extension's
306
+ * shape. Absent → `{}`; atomic `0644` writes (settings are not
307
+ * secrets — world-unreadable is not required, group/other-readable
308
+ * is acceptable).
309
+ */
310
+ export class ExtensionSettingsStore {
311
+ settingsDir;
312
+ constructor(settingsDir) {
313
+ this.settingsDir = settingsDir;
314
+ }
315
+ pathFor(slug) {
316
+ return join(this.settingsDir, `${slug}.json`);
317
+ }
318
+ /** One extension's settings object; absent file → `{}`. */
319
+ async read(slug) {
320
+ return readJsonObject(this.pathFor(slug));
321
+ }
322
+ /** Replace one extension's settings file atomically. */
323
+ async write(slug, value) {
324
+ await writeFileAtomic(this.pathFor(slug), serializeJson(value), 0o644);
325
+ }
326
+ }
327
+ /** The env prefix every extension secret's fallback var carries. */
328
+ const SECRET_ENV_PREFIX = "SIGRUN_EXT_";
329
+ /** slug/key → `SIGRUN_EXT_<SLUG-SNAKE>_<KEY-SNAKE>` (upper, `-`→`_`). */
330
+ function secretEnvName(slug, key) {
331
+ const snake = (part) => part.toUpperCase().replace(/-/g, "_");
332
+ return `${SECRET_ENV_PREFIX}${snake(slug)}_${snake(key)}`;
333
+ }
334
+ /**
335
+ * Resolve one secret for one extension (spec §4 precedence): the
336
+ * STORED section wins when it has the key; else the env var
337
+ * `SIGRUN_EXT_<SLUG>_<KEY>`; else undefined. Pure over its arguments —
338
+ * it NEVER writes env values down (Review Focus 4: env-only
339
+ * resolution must leave the file byte-identical; the write path is
340
+ * the API alone). Takes the SECTION object (the caller just did
341
+ * `readSection`), not a store instance. An empty-string env var
342
+ * counts as unset — the same ""-means-unset reading this module's dir
343
+ * tiers use.
344
+ */
345
+ export function resolveSecretFor(slug, section, key, env) {
346
+ const stored = section[key];
347
+ if (stored !== undefined && stored !== null) {
348
+ return { value: String(stored), source: "stored" };
349
+ }
350
+ const fromEnv = env[secretEnvName(slug, key)];
351
+ return fromEnv !== undefined && fromEnv !== ""
352
+ ? { value: fromEnv, source: "env" }
353
+ : undefined;
354
+ }
355
+ /** The `secrets.json` file name at the config root (spec §3 map). */
356
+ const SECRETS_FILE_NAME = "secrets.json";
357
+ /** Build the shared trio from resolved roots (cheap, no I/O). */
358
+ export function sharedExtensionStores(roots, env = {}) {
359
+ const settings = new ExtensionSettingsStore(join(roots.configDir, SETTINGS_DIR_NAME));
360
+ const secrets = new SecretsStore(join(roots.configDir, SECRETS_FILE_NAME));
361
+ return {
362
+ secrets,
363
+ settings,
364
+ roots,
365
+ settingsFor: (slug) => ({
366
+ get: () => settings.read(slug),
367
+ set: (value) => settings.write(slug, value),
368
+ }),
369
+ secretKeys: async (slug) => {
370
+ // Stored keys first (the resolver's winning source), then the
371
+ // `SIGRUN_EXT_<SLUG>_`-prefixed env fallbacks that are not stored.
372
+ // A stored key with an env twin lists ONCE as stored — exactly
373
+ // `resolveSecretFor`'s precedence, never two entries for one key.
374
+ const section = await secrets.readSection(slug);
375
+ const keys = Object.keys(section).map((key) => ({
376
+ key,
377
+ source: "stored",
378
+ }));
379
+ const prefix = `${SECRET_ENV_PREFIX}${slug
380
+ .toUpperCase()
381
+ .replace(/-/g, "_")}_`;
382
+ for (const name of Object.keys(env)) {
383
+ if (!name.startsWith(prefix))
384
+ continue;
385
+ // The resolver's ""-means-unset reading applies to the listing
386
+ // too: an empty env var is no secret at all.
387
+ if (env[name] === undefined || env[name] === "")
388
+ continue;
389
+ // The inverse of `secretEnvName`'s key map (upper, `-`→`_`):
390
+ // lower, `_`→`-` — kebab keys round-trip exactly; a snake key
391
+ // still forward-resolves (both map to the same env name).
392
+ const key = name.slice(prefix.length).toLowerCase().replace(/_/g, "-");
393
+ if (key === "" || section[key] !== undefined)
394
+ continue;
395
+ keys.push({ key, source: "env" });
396
+ }
397
+ return keys;
398
+ },
399
+ setSecret: async (slug, key, value) => {
400
+ const section = await secrets.readSection(slug);
401
+ section[key] = value;
402
+ await secrets.writeSection(slug, section);
403
+ },
404
+ deleteSecret: async (slug, key) => {
405
+ const section = await secrets.readSection(slug);
406
+ if (section[key] === undefined)
407
+ return;
408
+ delete section[key];
409
+ await secrets.writeSection(slug, section);
410
+ },
411
+ };
412
+ }
413
+ /**
414
+ * Build ONE extension's scoped view over the shared trio — the object
415
+ * the factory host exposes as `stores`. All cheap closure wiring (no
416
+ * I/O at construction): reads/writes ride the shared classes keyed by
417
+ * `slug`, paths are joins, and the cache dir is created only when
418
+ * `paths.cache` is actually read. The seam host REBUILDS this view on
419
+ * `rekey` (the two-phase identity pattern) so the view always keys on
420
+ * the host's CURRENT slug.
421
+ */
422
+ export function scopedExtensionStores(shared, slug) {
423
+ return {
424
+ secrets: {
425
+ async get(key) {
426
+ const section = await shared.secrets.readSection(slug);
427
+ const value = section[key];
428
+ return value === undefined || value === null
429
+ ? undefined
430
+ : String(value);
431
+ },
432
+ async set(key, value) {
433
+ const section = await shared.secrets.readSection(slug);
434
+ section[key] = value;
435
+ await shared.secrets.writeSection(slug, section);
436
+ },
437
+ },
438
+ settings: {
439
+ get: () => shared.settings.read(slug),
440
+ set: (value) => shared.settings.write(slug, value),
441
+ },
442
+ paths: {
443
+ data: join(shared.roots.dataDir, slug),
444
+ state: join(shared.roots.stateDir, slug),
445
+ get cache() {
446
+ return ensureCacheDirAt(shared.roots, slug);
447
+ },
448
+ },
449
+ };
450
+ }