@sibylline-labs/eris 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +146 -2
  4. package/bin/eris.ts +42 -0
  5. package/docs/ARCHITECTURE.md +188 -0
  6. package/docs/COMPLETION.md +106 -0
  7. package/docs/ERRORS.md +632 -0
  8. package/docs/OUTPUT-CONTRACT.md +219 -0
  9. package/docs/README.md +30 -0
  10. package/docs/UPDATE.md +159 -0
  11. package/docs/YAK.md +395 -0
  12. package/docs/dev/ADDING-A-COMMAND.md +79 -0
  13. package/docs/dev/DEVELOPMENT.md +88 -0
  14. package/docs/dev/LAYOUT.md +74 -0
  15. package/docs/dev/PLUGINS.md +382 -0
  16. package/docs/dev/RELEASING.md +157 -0
  17. package/docs/dev/TESTING.md +84 -0
  18. package/docs/dev/surfaces/secrets.md +95 -0
  19. package/package.json +55 -4
  20. package/packages/yak-contract/package.json +28 -0
  21. package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
  22. package/packages/yak-contract/src/index.ts +1 -0
  23. package/packages/yak-contract/src/v2.ts +375 -0
  24. package/plugins/index.ts +12 -0
  25. package/plugins/yak/handlers.ts +128 -0
  26. package/plugins/yak/index.ts +51 -0
  27. package/plugins/yak/login.ts +300 -0
  28. package/plugins/yak/yak-context.ts +280 -0
  29. package/plugins/yak/yak-identity.ts +399 -0
  30. package/plugins/yak/yak-redact.ts +686 -0
  31. package/plugins/yak/yak-setup.ts +133 -0
  32. package/plugins/yak/yak-submit.ts +1166 -0
  33. package/plugins/yak/yak.ts +485 -0
  34. package/src/build.ts +248 -0
  35. package/src/cli/args.ts +434 -0
  36. package/src/cli/commands/completion/bash.ts +24 -0
  37. package/src/cli/commands/completion/domain.ts +203 -0
  38. package/src/cli/commands/completion/emit.ts +246 -0
  39. package/src/cli/commands/completion/shell.ts +24 -0
  40. package/src/cli/commands/completion/zsh.ts +24 -0
  41. package/src/cli/commands/completion.ts +48 -0
  42. package/src/cli/commands/update/github.ts +328 -0
  43. package/src/cli/commands/update/opt-in.ts +32 -0
  44. package/src/cli/commands/update/refusal.ts +21 -0
  45. package/src/cli/commands/update/release.ts +351 -0
  46. package/src/cli/commands/update/replace.ts +143 -0
  47. package/src/cli/commands/update/semver.ts +48 -0
  48. package/src/cli/commands/update.ts +47 -0
  49. package/src/cli/commands/usage.ts +68 -0
  50. package/src/cli/commands/version.ts +29 -0
  51. package/src/cli/compose.ts +273 -0
  52. package/src/cli/output.ts +81 -0
  53. package/src/cli/plugin.ts +85 -0
  54. package/src/cli/registry.ts +180 -0
  55. package/src/cli/router.ts +31 -0
  56. package/src/cli/ts-verb.ts +126 -0
  57. package/src/main.ts +223 -0
  58. package/src/runtime/env.ts +16 -0
  59. package/src/runtime/eris-assets.d.ts +5 -0
  60. package/src/runtime/home.ts +306 -0
  61. package/src/runtime/logging.ts +43 -0
  62. package/src/runtime/process.ts +388 -0
  63. package/src/runtime/settings.ts +191 -0
  64. package/src/sdk.ts +62 -0
  65. package/testing/build-child.ts +55 -0
  66. package/testing/fake-runner.ts +23 -0
  67. package/testing/index.ts +16 -0
  68. package/ts/lib/eris-fail.ts +180 -0
  69. package/ts/lib/pool.ts +63 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * logging.ts — the four diagnostics, as the earlier Bash implementation
3
+ * printed them:
4
+ *
5
+ * eris::die() { printf 'ERROR %s\n' "$1" >&2; exit 1; }
6
+ * eris::warn() { printf 'WARN %s\n' "$1" >&2; }
7
+ * eris::info() { printf 'INFO %s\n' "$1" >&2; }
8
+ * eris::ok() { printf 'INFO ✓ %s\n' "$1" >&2; }
9
+ *
10
+ * Byte-identical, stderr only, unconditional (no TTY or colour branch): this
11
+ * is the `LEVEL<space>message` shape docs/OUTPUT-CONTRACT.md §1/§4 pins and
12
+ * consumers line-prefix-parse. `tests/runtime-logging.test.ts` holds every
13
+ * function to those bytes. The sink is injectable for tests; it defaults to
14
+ * process.stderr and is NEVER stdout.
15
+ */
16
+
17
+ export interface LogSink {
18
+ write(chunk: string): unknown;
19
+ }
20
+
21
+ export type ExitFn = (code: number) => never;
22
+
23
+ const defaultExit: ExitFn = (code) => process.exit(code);
24
+
25
+ export function warn(msg: string, sink: LogSink = process.stderr): void {
26
+ sink.write(`WARN ${msg}\n`);
27
+ }
28
+
29
+ export function info(msg: string, sink: LogSink = process.stderr): void {
30
+ sink.write(`INFO ${msg}\n`);
31
+ }
32
+
33
+ export function ok(msg: string, sink: LogSink = process.stderr): void {
34
+ sink.write(`INFO ✓ ${msg}\n`);
35
+ }
36
+
37
+ /** `eris::die` — the ERROR line, then exit 1. Prefer `emitFailureAndExit`
38
+ * (ts/lib/eris-fail.ts) for anything an operator must act on: an unslugged
39
+ * die carries no `slug:`/`docs:` pointer. */
40
+ export function die(msg: string, sink: LogSink = process.stderr, exit: ExitFn = defaultExit): never {
41
+ sink.write(`ERROR ${msg}\n`);
42
+ return exit(1);
43
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * process.ts — the subprocess adapter.
3
+ *
4
+ * Every external binary the TS side runs goes through a `Runner`, so tests
5
+ * inject a fake (`createFakeRunner`, testing/fake-runner.ts) instead of stubbing
6
+ * PATH, and the safety rules live in ONE place:
7
+ *
8
+ * - argv arrays only, never a shell string (`shell` is never set).
9
+ * - stdout/stderr captured as Buffers. The ONLY stderr a result exposes is
10
+ * the one passed through `redact`, so a caller echoing it into a failure
11
+ * block cannot leak what the hook removes (a tool's `--log-http` mode
12
+ * streams request bodies to stderr).
13
+ * - `BUN_CONFIG_VERBOSE_FETCH` is stripped from every child env: under it
14
+ * bun prints request bodies and `Authorization` headers to stderr.
15
+ * - an explicit timeout on every call; on expiry the WHOLE tree dies.
16
+ * - the result distinguishes ok / exit / signal / timeout / spawn-failed,
17
+ * so "could not tell" never reads as a clean non-zero.
18
+ *
19
+ * WHY THE TREE IS ENUMERATED:
20
+ * a child spawned `detached` leads its own process group, but that is not
21
+ * sufficient — any wrapped tool may `setpgid` into a group of its own (GNU
22
+ * `timeout` does), and a group kill cannot reach it. It then reparents to
23
+ * PID 1 holding the capture pipe, and the reader never sees EOF. So the tree
24
+ * is read out of ONE `ps` snapshot taken BEFORE the first signal (a killed
25
+ * parent's children reparent within milliseconds and become unreachable by
26
+ * ppid), and every group found is signalled except our own, then every pid.
27
+ * TERM first; KILL after `killGraceMs`; and if the pipes still have not closed
28
+ * after that, they are destroyed and the result says so (`forcedClose`), so
29
+ * the call always settles and a leaked kill is never reported as clean.
30
+ *
31
+ * WHY A SIGNAL REGISTRY: a `detached` child is outside the terminal's
32
+ * foreground group, so a Ctrl-C no longer reaches it — bun would die and the
33
+ * child would run on under PID 1 holding the pipe (an orphan seen in practice). While
34
+ * any child is live, SIGINT/SIGTERM/SIGHUP are forwarded to every tracked
35
+ * tree, and when ours is the only listener the signal is re-raised so the
36
+ * default 128+n death is preserved. `detached: false` opts out, for a child
37
+ * that must share the caller's group and owns its own signal handling.
38
+ */
39
+
40
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
41
+ import { resolveConcurrency, runPool } from "../../ts/lib/pool";
42
+
43
+ export interface RunOptions {
44
+ /** Hard deadline in ms. Required: an unbounded wait is how CLIs hang. */
45
+ timeoutMs: number;
46
+ /** TERM → KILL escalation delay once the deadline fires. Default 2000. */
47
+ killGraceMs?: number;
48
+ cwd?: string;
49
+ /** Child env. Defaults to process.env. BUN_CONFIG_VERBOSE_FETCH is always removed. */
50
+ env?: Record<string, string | undefined>;
51
+ /** Written to the child's stdin, which is then closed. Absent → stdin is /dev/null. */
52
+ input?: string | Buffer;
53
+ /** Applied to captured stderr before it is exposed on the result. */
54
+ redact?: (stderr: string) => string;
55
+ /** Own process group (default true). See the file header before turning it off. */
56
+ detached?: boolean;
57
+ }
58
+
59
+ interface Captured {
60
+ stdout: Buffer;
61
+ /** Already redacted. There is no raw copy. */
62
+ stderr: Buffer;
63
+ }
64
+
65
+ export type RunResult =
66
+ | (Captured & { kind: "ok"; code: 0 })
67
+ | (Captured & { kind: "exit"; code: number })
68
+ | (Captured & { kind: "signal"; signal: string })
69
+ | (Captured & {
70
+ kind: "timeout";
71
+ timeoutMs: number;
72
+ /** TERM was not enough; the tree was sent KILL. */
73
+ escalated: boolean;
74
+ /** The pipes were still open after KILL and were destroyed. Something
75
+ * outside the enumerated tree may still hold them. */
76
+ forcedClose: boolean;
77
+ })
78
+ | (Captured & { kind: "spawn-failed"; errno: string; message: string });
79
+
80
+ export interface Runner {
81
+ run(argv: readonly string[], opts: RunOptions): Promise<RunResult>;
82
+ }
83
+
84
+ const STRIPPED_ENV = ["BUN_CONFIG_VERBOSE_FETCH"];
85
+ const FORCE_CLOSE_MS = 1000;
86
+
87
+ export function childEnv(env: Record<string, string | undefined> = process.env): Record<string, string> {
88
+ const out: Record<string, string> = {};
89
+ for (const [k, v] of Object.entries(env)) {
90
+ if (v !== undefined && !STRIPPED_ENV.includes(k)) out[k] = v;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ // --- tree enumeration -------------------------------------------------------------
96
+
97
+ interface PsRow {
98
+ pid: number;
99
+ ppid: number;
100
+ pgid: number;
101
+ }
102
+
103
+ /** (pid, ppid, pgid) for every process — POSIX `ps`, same columns on procps
104
+ * and BSD. Empty on failure (the caller falls back to the roots alone). */
105
+ function psSnapshot(): PsRow[] {
106
+ const r = spawnSync("ps", ["-A", "-o", "pid=,ppid=,pgid="], { encoding: "utf8" });
107
+ if (r.status !== 0 || typeof r.stdout !== "string") return [];
108
+ const rows: PsRow[] = [];
109
+ for (const line of r.stdout.split("\n")) {
110
+ const [pid, ppid, pgid] = line.trim().split(/\s+/).map(Number);
111
+ if (pid && ppid !== undefined && pgid !== undefined && !Number.isNaN(ppid) && !Number.isNaN(pgid)) {
112
+ rows.push({ pid, ppid, pgid });
113
+ }
114
+ }
115
+ return rows;
116
+ }
117
+
118
+ /** Every pid in the subtrees rooted at `roots` (roots included). */
119
+ function subtree(rows: PsRow[], roots: Iterable<number>): Set<number> {
120
+ const all = new Set(roots);
121
+ let frontier = new Set(all);
122
+ while (frontier.size > 0) {
123
+ const next = new Set<number>();
124
+ for (const row of rows) {
125
+ if (frontier.has(row.ppid) && !all.has(row.pid)) {
126
+ all.add(row.pid);
127
+ next.add(row.pid);
128
+ }
129
+ }
130
+ frontier = next;
131
+ }
132
+ return all;
133
+ }
134
+
135
+ function trySignal(target: number, sig: NodeJS.Signals): void {
136
+ try {
137
+ process.kill(target, sig);
138
+ } catch {
139
+ // Already gone (ESRCH) or not ours (EPERM): nothing to reap.
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Signal every process in the trees rooted at `roots`, plus `known` pids
145
+ * enumerated earlier (they may have reparented to PID 1 since). Groups first
146
+ * (reaching anything spawned into a target's group after the snapshot), never
147
+ * our own group or 0/1; then every pid. Returns the pids it found, for a later
148
+ * escalation to include.
149
+ */
150
+ export function signalTree(
151
+ roots: Iterable<number>,
152
+ sig: NodeJS.Signals | readonly NodeJS.Signals[],
153
+ known: Iterable<number> = [],
154
+ ): Set<number> {
155
+ const sigs = typeof sig === "string" ? [sig] : sig;
156
+ const rows = psSnapshot();
157
+ const live = new Set(rows.map((r) => r.pid));
158
+ const targets = subtree(rows, roots);
159
+ for (const pid of known) if (live.has(pid)) targets.add(pid);
160
+ if (rows.length === 0) {
161
+ for (const pid of roots) targets.add(pid);
162
+ }
163
+ const selfPgid = rows.find((r) => r.pid === process.pid)?.pgid;
164
+ const groups = new Set<number>();
165
+ for (const row of rows) if (targets.has(row.pid)) groups.add(row.pgid);
166
+ for (const s of sigs) {
167
+ for (const g of groups) {
168
+ if (g > 1 && g !== selfPgid) trySignal(-g, s);
169
+ }
170
+ for (const pid of targets) if (pid > 1 && pid !== process.pid) trySignal(pid, s);
171
+ }
172
+ return targets;
173
+ }
174
+
175
+ // --- live-child registry (signal forwarding) --------------------------------------
176
+
177
+ const FORWARDED: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
178
+ const liveRoots = new Set<number>();
179
+ const handlers = new Map<NodeJS.Signals, () => void>();
180
+ let exitHook: (() => void) | undefined;
181
+
182
+ const SIGNAL_GRACE_MS = 1000;
183
+ let pending: { sig: NodeJS.Signals; known: Set<number>; timer: ReturnType<typeof setTimeout> } | undefined;
184
+
185
+ /**
186
+ * Forward the signal AND a TERM to every tracked tree: a background job in a
187
+ * non-interactive bash runs with SIGINT ignored, so the signal alone leaves
188
+ * exactly the orphan this exists to prevent (observed: a `perl … &` grandchild
189
+ * survived a forwarded SIGINT). Then give the trees `SIGNAL_GRACE_MS` to close
190
+ * — their `run` calls settle normally — KILL whatever is left, and, when ours
191
+ * is the only listener, re-raise so the caller dies by the default 128+n. A
192
+ * second signal during the grace escalates immediately.
193
+ */
194
+ function onSignal(sig: NodeJS.Signals): void {
195
+ if (pending) {
196
+ finishSignal();
197
+ return;
198
+ }
199
+ const known = signalTree(liveRoots, sig === "SIGTERM" ? ["SIGTERM"] : [sig, "SIGTERM"]);
200
+ pending = { sig, known, timer: setTimeout(finishSignal, SIGNAL_GRACE_MS) };
201
+ if (liveRoots.size === 0) finishSignal();
202
+ }
203
+
204
+ function finishSignal(): void {
205
+ if (!pending) return;
206
+ const { sig, known, timer } = pending;
207
+ pending = undefined;
208
+ clearTimeout(timer);
209
+ signalTree(liveRoots, "SIGKILL", known);
210
+ if (process.listenerCount(sig) === 1 && handlers.get(sig)) {
211
+ // Ours is the only listener: restore the default disposition and die by
212
+ // the same signal, so the caller sees 128+n exactly as without us.
213
+ uninstall();
214
+ process.kill(process.pid, sig);
215
+ return;
216
+ }
217
+ if (liveRoots.size === 0) uninstall();
218
+ }
219
+
220
+ function install(): void {
221
+ if (handlers.size > 0) return;
222
+ for (const sig of FORWARDED) {
223
+ const h = () => onSignal(sig);
224
+ handlers.set(sig, h);
225
+ process.on(sig, h);
226
+ }
227
+ exitHook = () => {
228
+ if (liveRoots.size > 0) signalTree(liveRoots, "SIGTERM");
229
+ };
230
+ process.on("exit", exitHook);
231
+ }
232
+
233
+ function uninstall(): void {
234
+ for (const [sig, h] of handlers) process.removeListener(sig, h);
235
+ handlers.clear();
236
+ if (exitHook) process.removeListener("exit", exitHook);
237
+ exitHook = undefined;
238
+ }
239
+
240
+ function track(pid: number): void {
241
+ liveRoots.add(pid);
242
+ install();
243
+ }
244
+
245
+ /** `pid` is undefined for a run that never produced a child (spawn failed): it only disarms. */
246
+ function untrack(pid: number | undefined): void {
247
+ if (pid !== undefined) liveRoots.delete(pid);
248
+ if (liveRoots.size > 0) return;
249
+ if (pending) finishSignal();
250
+ else uninstall();
251
+ }
252
+
253
+ // --- the real runner ---------------------------------------------------------------
254
+
255
+ export const nodeRunner: Runner = {
256
+ run(argv, opts) {
257
+ return new Promise<RunResult>((resolveResult) => {
258
+ const [cmd, ...args] = argv;
259
+ const detached = opts.detached ?? true;
260
+ const out: Buffer[] = [];
261
+ const err: Buffer[] = [];
262
+ const captured = (): Captured => ({
263
+ stdout: Buffer.concat(out),
264
+ // Decode only when there is a hook: an identity round-trip through
265
+ // UTF-8 would rewrite non-UTF-8 bytes.
266
+ stderr: opts.redact
267
+ ? Buffer.from(opts.redact(Buffer.concat(err).toString("utf8")), "utf8")
268
+ : Buffer.concat(err),
269
+ });
270
+
271
+ let settled = false;
272
+ let child: ChildProcess;
273
+ const timers: ReturnType<typeof setTimeout>[] = [];
274
+ let timedOut = false;
275
+ let escalated = false;
276
+ let forcedClose = false;
277
+ let enumerated = new Set<number>();
278
+
279
+ const settle = (r: RunResult) => {
280
+ if (settled) return;
281
+ settled = true;
282
+ for (const t of timers) clearTimeout(t);
283
+ untrack(child?.pid);
284
+ resolveResult(r);
285
+ };
286
+
287
+ if (!cmd) {
288
+ settle({ kind: "spawn-failed", errno: "EINVAL", message: "empty argv", ...captured() });
289
+ return;
290
+ }
291
+
292
+ // Armed BEFORE the spawn: the child is running from inside spawn(), which
293
+ // can take milliseconds to return on a cold process, and a signal in that
294
+ // window would find no handler and kill us by the default disposition,
295
+ // orphaning the tree. A JS handler only runs once the synchronous
296
+ // spawn + track below has finished, so a signal never sees a live child
297
+ // that is not yet tracked. A spawn that yields no pid (ENOENT) stays armed
298
+ // with nothing tracked until its `error` settles; a signal in that gap
299
+ // takes finishSignal's no-live-roots path, which re-raises it.
300
+ install();
301
+ try {
302
+ child = spawn(cmd, args, {
303
+ cwd: opts.cwd,
304
+ env: childEnv(opts.env ?? process.env),
305
+ stdio: [opts.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
306
+ detached,
307
+ });
308
+ } catch (e) {
309
+ const ex = e as NodeJS.ErrnoException;
310
+ settle({ kind: "spawn-failed", errno: ex.code ?? "UNKNOWN", message: ex.message, ...captured() });
311
+ return;
312
+ }
313
+
314
+ child.stdout?.on("data", (d: Buffer) => out.push(d));
315
+ child.stderr?.on("data", (d: Buffer) => err.push(d));
316
+ if (child.pid) track(child.pid);
317
+
318
+ child.on("error", (e: NodeJS.ErrnoException) => {
319
+ // A spawn failure fires `error` (then `close`); an error after a
320
+ // successful spawn is left to `close`, which still reports the exit.
321
+ if (!child.pid) settle({ kind: "spawn-failed", errno: e.code ?? "UNKNOWN", message: e.message, ...captured() });
322
+ });
323
+
324
+ child.on("close", (code, signal) => {
325
+ if (timedOut) {
326
+ settle({ kind: "timeout", timeoutMs: opts.timeoutMs, escalated, forcedClose, ...captured() });
327
+ } else if (signal) {
328
+ settle({ kind: "signal", signal, ...captured() });
329
+ } else if (code === 0) {
330
+ settle({ kind: "ok", code: 0, ...captured() });
331
+ } else if (code !== null) {
332
+ settle({ kind: "exit", code, ...captured() });
333
+ }
334
+ });
335
+
336
+ if (opts.input !== undefined && child.stdin) {
337
+ child.stdin.on("error", () => {}); // EPIPE when the child ignores stdin
338
+ child.stdin.end(opts.input);
339
+ }
340
+
341
+ const pid = child.pid;
342
+ timers.push(
343
+ setTimeout(() => {
344
+ if (settled || !pid) return;
345
+ timedOut = true;
346
+ enumerated = signalTree([pid], "SIGTERM");
347
+ timers.push(
348
+ setTimeout(() => {
349
+ if (settled) return;
350
+ escalated = true;
351
+ enumerated = signalTree([pid], "SIGKILL", enumerated);
352
+ timers.push(
353
+ setTimeout(() => {
354
+ if (settled) return;
355
+ forcedClose = true;
356
+ child.stdout?.destroy();
357
+ child.stderr?.destroy();
358
+ settle({ kind: "timeout", timeoutMs: opts.timeoutMs, escalated, forcedClose, ...captured() });
359
+ }, FORCE_CLOSE_MS),
360
+ );
361
+ }, opts.killGraceMs ?? 2000),
362
+ );
363
+ }, opts.timeoutMs),
364
+ );
365
+ });
366
+ },
367
+ };
368
+
369
+ // --- fan-out -------------------------------------------------------------
370
+
371
+ export interface RunJob {
372
+ argv: readonly string[];
373
+ opts: RunOptions;
374
+ }
375
+
376
+ /**
377
+ * Run `jobs` at most N in flight, results in input order. N is
378
+ * `ERIS_BATCH_CONCURRENCY`, CLAMPED to 8 when unset/invalid (ts/lib/pool.ts)
379
+ * — a typo must never turn a batch into an unbounded fan-out.
380
+ */
381
+ export function runMany(
382
+ jobs: readonly RunJob[],
383
+ opts: { runner?: Runner; env?: Record<string, string | undefined> } = {},
384
+ ): Promise<RunResult[]> {
385
+ const runner = opts.runner ?? nodeRunner;
386
+ const env = opts.env ?? process.env;
387
+ return runPool(jobs, resolveConcurrency(env.ERIS_BATCH_CONCURRENCY), (job) => runner.run(job.argv, job.opts));
388
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * settings.ts — `~/.eris/settings.json`, the operator's standing environment.
3
+ *
4
+ * {
5
+ * "source": ["~/creds/.dev-credentials"],
6
+ * "env": { "API_TOKEN": "${API_EDITOR_TOKEN}" },
7
+ * "unset": ["READ_ONLY_TOKEN"]
8
+ * }
9
+ *
10
+ * The three keys are the file form of
11
+ * `set -a; source <file>; set +a; env -u <NAME> NAME=value … eris`:
12
+ *
13
+ * - `source` — files of `KEY=value` lines (`export` prefix, `#` comments, single
14
+ * or double quotes, `$VAR` / `${VAR}` outside single quotes), read in order,
15
+ * a later file winning. They are PARSED, never executed: a command
16
+ * substitution stays literal text.
17
+ * - `env` — entries over the sourced values, each value `${VAR}`-expanded
18
+ * against the environment as it will be (caller first, then the settings
19
+ * resolved so far). An unset reference expands to "", as in a shell.
20
+ * - `unset` — names removed from the result, the caller's own value INCLUDED:
21
+ * it exists to drop a variable a shell profile exports.
22
+ *
23
+ * `source` and `env` fill only variables the caller left UNSET; a variable in the
24
+ * environment wins, an empty one included. The router applies the result to its
25
+ * own process before routing, so a TS verb and every child it spawns see the
26
+ * same values.
27
+ *
28
+ * Fail-open: a missing settings file is silence; an unreadable or malformed one,
29
+ * a missing source file, an unparseable line, a non-string value and a DENIED name
30
+ * are each one stderr warning and skipped — never a refusal. Warnings name files,
31
+ * line numbers and keys, never a value. Denied: `BUN_CONFIG_VERBOSE_FETCH` (bun
32
+ * would print bearers and secret bodies, see the `eris` shim) and the router's
33
+ * own `_ERIS_*` handover.
34
+ */
35
+
36
+ import { readFileSync } from "node:fs";
37
+ import { homedir } from "node:os";
38
+ import { join } from "node:path";
39
+ import { readEnv } from "./env";
40
+
41
+ type Env = Record<string, string | undefined>;
42
+ type Warn = (msg: string) => void;
43
+
44
+ export interface Settings {
45
+ source: string[];
46
+ env: Record<string, string>;
47
+ unset: string[];
48
+ }
49
+
50
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
51
+
52
+ function home(env: Env): string {
53
+ return env.HOME || homedir();
54
+ }
55
+
56
+ export function settingsPath(env: Env): string {
57
+ return join(home(env), ".eris", "settings.json");
58
+ }
59
+
60
+ function denied(name: string): boolean {
61
+ return name === "BUN_CONFIG_VERBOSE_FETCH" || name.startsWith("_ERIS_");
62
+ }
63
+
64
+ /** `$VAR` and `${VAR}` against `lookup`; an unset name expands to "". */
65
+ export function expand(value: string, lookup: (name: string) => string | undefined): string {
66
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, a, b) => lookup(a ?? b) ?? "");
67
+ }
68
+
69
+ function stringList(doc: Record<string, unknown>, key: string, path: string, warn: Warn): string[] {
70
+ const raw = doc[key];
71
+ if (raw === undefined) return [];
72
+ if (!Array.isArray(raw)) {
73
+ warn(`${path}: "${key}" must be a list of strings — ignored`);
74
+ return [];
75
+ }
76
+ return raw.filter((v, i) => {
77
+ if (typeof v === "string") return true;
78
+ warn(`${path}: ${key}[${i}] is not a string — skipped`);
79
+ return false;
80
+ });
81
+ }
82
+
83
+ /** The validated settings document, or an empty one when there is none to use. */
84
+ export function readSettings(path: string, warn: Warn): Settings {
85
+ const none: Settings = { source: [], env: {}, unset: [] };
86
+ let text: string;
87
+ try {
88
+ text = readFileSync(path, "utf8");
89
+ } catch (e) {
90
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") warn(`ignoring ${path}: ${(e as Error).message}`);
91
+ return none;
92
+ }
93
+ let doc: unknown;
94
+ try {
95
+ doc = JSON.parse(text);
96
+ } catch (e) {
97
+ warn(`ignoring ${path}: not valid JSON (${(e as Error).message})`);
98
+ return none;
99
+ }
100
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
101
+ warn(`ignoring ${path}: expected a JSON object`);
102
+ return none;
103
+ }
104
+ const d = doc as Record<string, unknown>;
105
+ const env: Record<string, string> = {};
106
+ if (d.env !== undefined) {
107
+ if (d.env === null || typeof d.env !== "object" || Array.isArray(d.env)) {
108
+ warn(`${path}: "env" must be an object of strings — ignored`);
109
+ } else {
110
+ for (const [k, v] of Object.entries(d.env)) {
111
+ if (denied(k)) warn(`${path}: env.${k} is not settable here — skipped`);
112
+ else if (typeof v !== "string") warn(`${path}: env.${k} is not a string — skipped`);
113
+ else env[k] = v;
114
+ }
115
+ }
116
+ }
117
+ const unset = stringList(d, "unset", path, warn).filter((k) => {
118
+ if (!denied(k)) return true;
119
+ warn(`${path}: unset ${k} is not allowed here — skipped`);
120
+ return false;
121
+ });
122
+ return { source: stringList(d, "source", path, warn), env, unset };
123
+ }
124
+
125
+ /** One sourced file's assignments, in order. Values are expanded by the caller's
126
+ * `lookup` as each line is read, so a line can use one assigned above it. */
127
+ export function parseEnvFile(
128
+ text: string,
129
+ path: string,
130
+ lookup: (name: string) => string | undefined,
131
+ warn: Warn,
132
+ ): [string, string][] {
133
+ const out: [string, string][] = [];
134
+ const local = new Map<string, string>();
135
+ const see = (n: string) => (local.has(n) ? local.get(n) : lookup(n));
136
+ text.split(/\r?\n/).forEach((raw, i) => {
137
+ const line = raw.trim().replace(/^export\s+/, "");
138
+ if (line === "" || line.startsWith("#")) return;
139
+ const eq = line.indexOf("=");
140
+ const key = eq > 0 ? line.slice(0, eq) : "";
141
+ if (!NAME.test(key)) {
142
+ warn(`${path}:${i + 1}: not a KEY=value line — skipped`);
143
+ return;
144
+ }
145
+ let rest = line.slice(eq + 1);
146
+ let value: string;
147
+ const q = rest[0];
148
+ if (q === "'" || q === '"') {
149
+ const end = rest.indexOf(q, 1);
150
+ if (end < 0) {
151
+ warn(`${path}:${i + 1}: unterminated quote — skipped`);
152
+ return;
153
+ }
154
+ value = rest.slice(1, end);
155
+ if (q === '"') value = expand(value.replace(/\\(["\\$`])/g, "$1"), see);
156
+ } else {
157
+ rest = rest.replace(/\s+#.*$/, "").trim();
158
+ value = expand(rest, see);
159
+ }
160
+ local.set(key, value);
161
+ out.push([key, value]);
162
+ });
163
+ return out;
164
+ }
165
+
166
+ /** Apply the settings file to `env`, in place. */
167
+ export function applySettings(env: Env, warn: Warn): void {
168
+ const path = settingsPath(env);
169
+ const s = readSettings(path, warn);
170
+ const layer: Record<string, string> = {};
171
+ const lookup = (n: string) => readEnv(env, n) ?? readEnv(layer, n);
172
+ for (const src of s.source) {
173
+ const file = src.startsWith("~/") ? join(home(env), src.slice(2)) : src;
174
+ let text: string;
175
+ try {
176
+ text = readFileSync(file, "utf8");
177
+ } catch (e) {
178
+ warn(`${path}: source ${file}: ${(e as NodeJS.ErrnoException).code ?? (e as Error).message} — skipped`);
179
+ continue;
180
+ }
181
+ for (const [k, v] of parseEnvFile(text, file, lookup, warn)) {
182
+ if (denied(k)) warn(`${file}: ${k} is not settable here — skipped`);
183
+ else layer[k] = v;
184
+ }
185
+ }
186
+ for (const [k, v] of Object.entries(s.env)) layer[k] = expand(v, lookup);
187
+ for (const [k, v] of Object.entries(layer)) {
188
+ if (readEnv(env, k) === undefined) env[k] = v;
189
+ }
190
+ for (const k of s.unset) delete env[k];
191
+ }
package/src/sdk.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * sdk.ts — `eris/sdk`, the one module through which another repository reaches
3
+ * core: a distribution's entry, its plugins and the libraries they share
4
+ * (docs/dev/PLUGINS.md#distributions). The package's `exports` map names it,
5
+ * `eris/sdk/testing` (the test helpers, testing/, which this module does not
6
+ * export, so no binary carries them), `eris/yak-contract` (the Yaks wire
7
+ * contract, a workspace package of its own rather than core), `eris/yak-redact`
8
+ * (the client's redaction pass, plugins/yak/yak-redact.ts, which imports
9
+ * nothing) and nothing deeper, so a core file can move without breaking a
10
+ * distribution.
11
+ *
12
+ * What it carries:
13
+ *
14
+ * - the plugin SDK (src/cli/plugin.ts): `definePlugin`, `readArgs`,
15
+ * `groupArgs` (over a plugin's own rows), `readEnv`, the failure block;
16
+ * - composition: core's rows (`REGISTRY`) and handlers (`HANDLERS`), this
17
+ * build's plugin list (`PLUGINS`), `composeCli` and its `menu` option, and
18
+ * `RELEASE_UPDATE`, the in-place `eris update` a distribution that
19
+ * publishes binaries composes in place of core's;
20
+ * - the program: `runProgram({ cli })`, a distribution's process entry, and
21
+ * `main`, the seam a test drives;
22
+ * - argv and output helpers a handler shares with core's verbs, the global
23
+ * `--output` pre-pass (`parseGlobal`, `GlobalFlagError`) included;
24
+ * - the runtime: subprocesses (the only way eris spawns), the bounded pool
25
+ * (`runPool`, and the `ERIS_BATCH_*` readers), the diagnostics, the
26
+ * install root, and the build's identity (`BUILD_ID`, `releaseRepo()`, the
27
+ * `DistributionIdentity` that `build()` and `runProgram` take);
28
+ * - `build()`, which compiles a distribution (src/build.ts), loaded on its
29
+ * first call so a plugin manifest importing this module stays light on the
30
+ * completion path.
31
+ *
32
+ * This package's own plugins import src/cli/plugin.ts, not this module.
33
+ */
34
+
35
+ import type { BuildOptions, BuildResult } from "./build";
36
+
37
+ export * from "./cli/plugin";
38
+ export type { VerbPath } from "./cli/registry";
39
+ export { flagsOf, REGISTRY, rowsOfGroup } from "./cli/registry";
40
+ export type { ArgError, SubOf } from "./cli/args";
41
+ export { argErrorWhat, GlobalFlagError, parseGlobal, readVerbArgs, refuseArg, usageLine } from "./cli/args";
42
+ export { sanitizeDisplay } from "./cli/output";
43
+ export { onPath } from "./cli/ts-verb";
44
+ export { RELEASE_UPDATE } from "./cli/commands/update/opt-in";
45
+ export type { Cli, ComposeOptions, Session } from "./cli/compose";
46
+ export { composeCli, NO_ENTITLEMENTS, PluginCompositionError } from "./cli/compose";
47
+ export type { MainOptions, ProgramOptions } from "./main";
48
+ export { HANDLERS, main, runProgram } from "./main";
49
+ export { PLUGINS } from "../plugins";
50
+ export type { RunJob, RunOptions, RunResult, Runner } from "./runtime/process";
51
+ export { childEnv, nodeRunner, runMany, signalTree } from "./runtime/process";
52
+ export { resolveConcurrency, resolveDeadlineMs, runPool } from "../ts/lib/pool";
53
+ export { info, ok, warn } from "./runtime/logging";
54
+ export type { DistributionIdentity } from "./runtime/home";
55
+ export { BUILD_ID, installRoot, installRootIsUnwritten, isCompiled, releaseRepo } from "./runtime/home";
56
+ export type { ErisFailureInput } from "../ts/lib/eris-fail";
57
+ export type { BuildOptions, BuildResult, BuildStub } from "./build";
58
+
59
+ /** Compile a distribution into one executable (src/build.ts). */
60
+ export async function build(opts: BuildOptions): Promise<BuildResult> {
61
+ return (await import("./build")).build(opts);
62
+ }