@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.
- package/CHANGELOG.md +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- 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
|
+
}
|