@sibylline-labs/eris 0.0.0-stage → 0.3.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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +146 -2
- package/bin/eris.ts +42 -0
- package/docs/ARCHITECTURE.md +188 -0
- package/docs/COMPLETION.md +106 -0
- package/docs/ERRORS.md +632 -0
- package/docs/OUTPUT-CONTRACT.md +219 -0
- package/docs/README.md +30 -0
- package/docs/UPDATE.md +159 -0
- package/docs/YAK.md +395 -0
- package/docs/dev/ADDING-A-COMMAND.md +79 -0
- package/docs/dev/DEVELOPMENT.md +88 -0
- package/docs/dev/LAYOUT.md +74 -0
- package/docs/dev/PLUGINS.md +382 -0
- package/docs/dev/RELEASING.md +157 -0
- package/docs/dev/TESTING.md +84 -0
- package/docs/dev/surfaces/secrets.md +95 -0
- package/package.json +55 -4
- package/packages/yak-contract/package.json +28 -0
- package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
- package/packages/yak-contract/src/index.ts +1 -0
- package/packages/yak-contract/src/v2.ts +375 -0
- package/plugins/index.ts +12 -0
- package/plugins/yak/handlers.ts +128 -0
- package/plugins/yak/index.ts +51 -0
- package/plugins/yak/login.ts +300 -0
- package/plugins/yak/yak-context.ts +280 -0
- package/plugins/yak/yak-identity.ts +399 -0
- package/plugins/yak/yak-redact.ts +686 -0
- package/plugins/yak/yak-setup.ts +133 -0
- package/plugins/yak/yak-submit.ts +1166 -0
- package/plugins/yak/yak.ts +485 -0
- package/src/build.ts +248 -0
- package/src/cli/args.ts +434 -0
- package/src/cli/commands/completion/bash.ts +24 -0
- package/src/cli/commands/completion/domain.ts +203 -0
- package/src/cli/commands/completion/emit.ts +246 -0
- package/src/cli/commands/completion/shell.ts +24 -0
- package/src/cli/commands/completion/zsh.ts +24 -0
- package/src/cli/commands/completion.ts +48 -0
- package/src/cli/commands/update/github.ts +328 -0
- package/src/cli/commands/update/opt-in.ts +32 -0
- package/src/cli/commands/update/refusal.ts +21 -0
- package/src/cli/commands/update/release.ts +351 -0
- package/src/cli/commands/update/replace.ts +143 -0
- package/src/cli/commands/update/semver.ts +48 -0
- package/src/cli/commands/update.ts +47 -0
- package/src/cli/commands/usage.ts +68 -0
- package/src/cli/commands/version.ts +29 -0
- package/src/cli/compose.ts +273 -0
- package/src/cli/output.ts +81 -0
- package/src/cli/plugin.ts +85 -0
- package/src/cli/registry.ts +180 -0
- package/src/cli/router.ts +31 -0
- package/src/cli/ts-verb.ts +126 -0
- package/src/main.ts +223 -0
- package/src/runtime/env.ts +16 -0
- package/src/runtime/eris-assets.d.ts +5 -0
- package/src/runtime/home.ts +306 -0
- package/src/runtime/logging.ts +43 -0
- package/src/runtime/process.ts +388 -0
- package/src/runtime/settings.ts +191 -0
- package/src/sdk.ts +62 -0
- package/testing/build-child.ts +55 -0
- package/testing/fake-runner.ts +23 -0
- package/testing/index.ts +16 -0
- package/ts/lib/eris-fail.ts +180 -0
- 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
|
+
}
|