@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,328 @@
1
+ /**
2
+ * github.ts — `eris update`'s only network code: the releases API of the build's
3
+ * own repository, and the download of a release's assets.
4
+ *
5
+ * What it guarantees, and why each line is here:
6
+ *
7
+ * - An asset is fetched through its API url (`assets[].url`) with
8
+ * `Accept: application/octet-stream`, never through `browser_download_url`:
9
+ * for a private repository the API url with a token answers (a redirect to
10
+ * storage) and the browser url answers 404.
11
+ * - The token (`GH_TOKEN`, else `GITHUB_TOKEN`) is read only when the API base
12
+ * is `https://api.github.com` or a loopback host (a test's server), so an
13
+ * `ERIS_UPDATE_API` naming any other host never receives it (`sendsToken`).
14
+ * It is sent only to the API's own origin. Redirects are followed by hand,
15
+ * so each hop is judged on its own:
16
+ * the redirect to storage carries no `Authorization` (storage rejects a
17
+ * request that has both a signature and a token), and an asset url on
18
+ * another host never receives it.
19
+ * - The API base is `https://api.github.com` unless `ERIS_UPDATE_API` names
20
+ * another (a GitHub Enterprise host, or a test's loopback server). It must be
21
+ * https, or http for a loopback host only, and carry no credentials: the
22
+ * bearer never travels in the clear to a host nobody chose.
23
+ * - Every request has an idle deadline, a redirect chain has a limit, and a body
24
+ * has a size limit, so a stalled or hostile server ends the command.
25
+ *
26
+ * It decides nothing about versions or files; that is src/cli/commands/update.ts.
27
+ */
28
+
29
+ import { Refusal, reasonOf } from "./refusal";
30
+
31
+ export const DEFAULT_API = "https://api.github.com/";
32
+
33
+ /** The loopback hosts an `http:` API base may name (`URL.hostname` brackets IPv6). */
34
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
35
+
36
+ const MAX_REDIRECTS = 5;
37
+ const MAX_JSON_BYTES = 4 << 20;
38
+ export const MAX_TEXT_BYTES = 1 << 20;
39
+ export const MAX_BINARY_BYTES = 1 << 30;
40
+
41
+ /** A release tag as `build()` accepts one (src/build.ts). */
42
+ export const TAG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
43
+
44
+ /** The API base from `ERIS_UPDATE_API`, or the default; null when it is not usable. */
45
+ export function parseApiBase(raw: string | undefined): URL | null {
46
+ let url: URL;
47
+ try {
48
+ url = new URL(raw === undefined || raw === "" ? DEFAULT_API : raw);
49
+ } catch {
50
+ return null;
51
+ }
52
+ if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return null;
53
+ if (url.protocol === "http:" ? !LOOPBACK_HOSTS.has(url.hostname) : url.protocol !== "https:") return null;
54
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/";
55
+ return url;
56
+ }
57
+
58
+ /** Whether the token may go to `api`: GitHub's own API, or a loopback host on any scheme. */
59
+ export function sendsToken(api: URL): boolean {
60
+ return api.origin === new URL(DEFAULT_API).origin || LOOPBACK_HOSTS.has(api.hostname);
61
+ }
62
+
63
+ /** Whether a download may follow a redirect (or list an asset) at `to`: https always, http only under a loopback `http:` API. */
64
+ export function allowedRedirect(api: URL, to: URL): boolean {
65
+ return to.protocol === "https:" || (to.protocol === "http:" && api.protocol === "http:");
66
+ }
67
+
68
+ export interface Asset {
69
+ readonly name: string;
70
+ readonly url: URL;
71
+ }
72
+
73
+ export interface Release {
74
+ readonly tag: string;
75
+ readonly prerelease: boolean;
76
+ readonly draft: boolean;
77
+ readonly assets: readonly Asset[];
78
+ }
79
+
80
+ export interface GitHubOptions {
81
+ readonly base: URL;
82
+ /** Sent to `base`'s origin and nowhere else. */
83
+ readonly token: string | undefined;
84
+ /** `owner/name`. */
85
+ readonly repo: string;
86
+ readonly userAgent: string;
87
+ /** How long the API may stay silent, in ms. */
88
+ readonly apiTimeoutMs: number;
89
+ /** How long a download may stay silent between two chunks, in ms. */
90
+ readonly downloadTimeoutMs: number;
91
+ }
92
+
93
+ /** An idle deadline: `touch` restarts it, and when it runs out `signal` aborts. */
94
+ class Deadline {
95
+ private readonly ctl = new AbortController();
96
+ private timer: ReturnType<typeof setTimeout> | undefined;
97
+ fired = false;
98
+ constructor(readonly ms: number) {
99
+ this.touch();
100
+ }
101
+ get signal(): AbortSignal {
102
+ return this.ctl.signal;
103
+ }
104
+ touch(): void {
105
+ clearTimeout(this.timer);
106
+ this.timer = setTimeout(() => {
107
+ this.fired = true;
108
+ this.ctl.abort();
109
+ }, this.ms);
110
+ }
111
+ done(): void {
112
+ clearTimeout(this.timer);
113
+ }
114
+ }
115
+
116
+ const seconds = (ms: number): string => String(+(ms / 1000).toFixed(2));
117
+ const isoSeconds = (epochSeconds: number): string => new Date(epochSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
118
+
119
+ const TOKEN_NAMES = "GH_TOKEN (or GITHUB_TOKEN)";
120
+
121
+ export class GitHub {
122
+ constructor(private readonly o: GitHubOptions) {}
123
+
124
+ private get host(): string {
125
+ return this.o.base.host;
126
+ }
127
+
128
+ private get api(): string {
129
+ return this.o.base.href.replace(/\/$/, "");
130
+ }
131
+
132
+ private get releasesUrl(): string {
133
+ return `https://github.com/${this.o.repo}/releases`;
134
+ }
135
+
136
+ private unreachable(why: string): Refusal {
137
+ return new Refusal({
138
+ slug: "update-unreachable",
139
+ what: why,
140
+ fix: `check the network connection (eris update needs https access to ${this.host}), then run eris update again`,
141
+ });
142
+ }
143
+
144
+ private apiError(what: string, fix: string): Refusal {
145
+ return new Refusal({ slug: "update-api-error", what, fix });
146
+ }
147
+
148
+ /** The refusal for a non-success answer. `notFound` says what a 404 means for the request. */
149
+ private refusal(res: Response, notFound: () => Refusal): Refusal {
150
+ const status = res.status;
151
+ const limited = status === 429 || (status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || res.headers.has("retry-after")));
152
+ if (limited) {
153
+ const reset = Number(res.headers.get("x-ratelimit-reset"));
154
+ const until = Number.isFinite(reset) && reset > 0 ? isoSeconds(reset) : undefined;
155
+ const retry = res.headers.get("retry-after");
156
+ const when = until !== undefined ? ` until ${until}` : retry !== null && /^\d{1,6}$/.test(retry) ? ` for ${retry}s` : "";
157
+ return new Refusal({
158
+ slug: "update-rate-limited",
159
+ what: `${this.host} refused the request: the rate limit is spent${when}`,
160
+ fix:
161
+ this.o.token === undefined
162
+ ? `set ${TOKEN_NAMES} to a GitHub token: an authenticated request has a far higher limit than an anonymous one\nthen run eris update again`
163
+ : `the limit for ${TOKEN_NAMES} is spent: wait${when === "" ? "" : ` (it resets${when})`}, then run eris update again`,
164
+ });
165
+ }
166
+ if (status === 404) return notFound();
167
+ const what = `${this.host} answered ${status}${res.statusText !== "" ? ` ${res.statusText.replace(/[^\x20-\x7e]/g, "")}` : ""}`;
168
+ if (status === 401) {
169
+ return this.apiError(
170
+ what,
171
+ this.o.token === undefined
172
+ ? `the API asked for credentials: set ${TOKEN_NAMES} to a GitHub token that can read ${this.o.repo}`
173
+ : `${TOKEN_NAMES} was rejected: unset it, or replace it with a valid token`,
174
+ );
175
+ }
176
+ if (status === 403) return this.apiError(what, `if ${this.o.repo} is private, ${TOKEN_NAMES} must be a token that can read it`);
177
+ return this.apiError(what, `retry in a few minutes; if it persists, check ${this.releasesUrl}`);
178
+ }
179
+
180
+ private releaseNotFound(tag: string | undefined): Refusal {
181
+ return this.o.token === undefined
182
+ ? new Refusal({
183
+ slug: "update-needs-token",
184
+ what: `${this.host} answered 404 for ${this.o.repo}'s ${tag === undefined ? "latest release" : `release ${tag}`}, and no token is set`,
185
+ fix: `if ${this.o.repo} is private, set ${TOKEN_NAMES} to a token that can read it, then run eris update again\notherwise the release does not exist: see ${this.releasesUrl}`,
186
+ })
187
+ : notFound(this.o.repo, tag);
188
+ }
189
+
190
+ /**
191
+ * One GET with redirects followed by hand. The `Authorization` header is set per
192
+ * hop, for the API's origin only. The deadline restarts on every hop, and the
193
+ * caller restarts it per chunk while it reads the body.
194
+ */
195
+ private async get(first: URL, accept: string, idleMs: number): Promise<{ res: Response; deadline: Deadline }> {
196
+ const deadline = new Deadline(idleMs);
197
+ let at = first;
198
+ try {
199
+ for (let hops = 0; ; hops++) {
200
+ const headers = new Headers({ accept, "user-agent": this.o.userAgent });
201
+ if (at.origin === this.o.base.origin) {
202
+ headers.set("x-github-api-version", "2022-11-28");
203
+ if (this.o.token !== undefined) headers.set("authorization", `Bearer ${this.o.token}`);
204
+ }
205
+ deadline.touch();
206
+ const res = await fetch(at, { headers, redirect: "manual", signal: deadline.signal });
207
+ if (![301, 302, 303, 307, 308].includes(res.status)) return { res, deadline };
208
+ await res.body?.cancel();
209
+ const location = res.headers.get("location");
210
+ if (hops >= MAX_REDIRECTS) throw this.apiError(`${at.host} redirected ${MAX_REDIRECTS + 1} times in a row`, `retry in a few minutes; if it persists, check ${this.releasesUrl}`);
211
+ let next: URL | undefined;
212
+ try {
213
+ next = location === null ? undefined : new URL(location, at);
214
+ } catch {
215
+ next = undefined;
216
+ }
217
+ if (next === undefined || !allowedRedirect(this.o.base, next)) {
218
+ throw this.apiError(`${at.host} redirected to a location eris will not follow`, `retry in a few minutes; if it persists, check ${this.releasesUrl}`);
219
+ }
220
+ at = next;
221
+ }
222
+ } catch (e) {
223
+ deadline.done();
224
+ if (e instanceof Refusal) throw e;
225
+ throw this.unreachable(deadline.fired ? `${at.host} did not respond within ${seconds(idleMs)}s` : `could not reach ${at.host}: ${reasonOf(e)}`);
226
+ }
227
+ }
228
+
229
+ /** Every chunk of the body to `onChunk`, refusing past `limit` bytes. */
230
+ private async read(res: Response, deadline: Deadline, limit: number, onChunk: (chunk: Uint8Array) => void): Promise<void> {
231
+ const reader = res.body?.getReader();
232
+ if (reader === undefined) return;
233
+ let total = 0;
234
+ try {
235
+ for (;;) {
236
+ deadline.touch();
237
+ const { done, value } = await reader.read();
238
+ if (done) return;
239
+ total += value.length;
240
+ if (total > limit) {
241
+ await reader.cancel();
242
+ throw this.apiError(`${res.url === "" ? this.host : new URL(res.url).host} sent more than ${limit} bytes`, "this is not a release eris recognises; check the release page");
243
+ }
244
+ onChunk(value);
245
+ }
246
+ } catch (e) {
247
+ if (e instanceof Refusal) throw e;
248
+ throw this.unreachable(deadline.fired ? `${this.host} stopped sending data for ${seconds(deadline.ms)}s` : `the download from ${this.host} failed: ${reasonOf(e)}`);
249
+ } finally {
250
+ deadline.done();
251
+ }
252
+ }
253
+
254
+ private async text(res: Response, deadline: Deadline, limit: number): Promise<string> {
255
+ const chunks: Uint8Array[] = [];
256
+ await this.read(res, deadline, limit, (c) => chunks.push(c));
257
+ return Buffer.concat(chunks).toString("utf8");
258
+ }
259
+
260
+ /** The latest release, or the one tagged `tag`. */
261
+ async release(tag: string | undefined): Promise<Release> {
262
+ const path = tag === undefined ? "latest" : `tags/${encodeURIComponent(tag)}`;
263
+ const { res, deadline } = await this.get(new URL(`${this.api}/repos/${this.o.repo}/releases/${path}`), "application/vnd.github+json", this.o.apiTimeoutMs);
264
+ if (!res.ok) {
265
+ deadline.done();
266
+ await res.body?.cancel();
267
+ throw this.refusal(res, () => this.releaseNotFound(tag));
268
+ }
269
+ const malformed = () => this.apiError(`${this.host} answered with something that is not a release record`, `retry in a few minutes; if it persists, check ${this.releasesUrl}`);
270
+ let body: unknown;
271
+ try {
272
+ body = JSON.parse(await this.text(res, deadline, MAX_JSON_BYTES));
273
+ } catch (e) {
274
+ if (e instanceof Refusal) throw e;
275
+ throw malformed();
276
+ }
277
+ const r = (typeof body === "object" && body !== null ? body : {}) as Record<string, unknown>;
278
+ if (typeof r.tag_name !== "string" || !TAG.test(r.tag_name) || !Array.isArray(r.assets)) throw malformed();
279
+ const assets: Asset[] = [];
280
+ for (const a of r.assets as unknown[]) {
281
+ const rec = (typeof a === "object" && a !== null ? a : {}) as Record<string, unknown>;
282
+ let url: URL | undefined;
283
+ try {
284
+ url = typeof rec.url === "string" ? new URL(rec.url) : undefined;
285
+ } catch {
286
+ url = undefined;
287
+ }
288
+ if (typeof rec.name !== "string" || url === undefined || !allowedRedirect(this.o.base, url)) throw malformed();
289
+ assets.push({ name: rec.name, url });
290
+ }
291
+ return { tag: r.tag_name, prerelease: r.prerelease === true, draft: r.draft === true, assets };
292
+ }
293
+
294
+ /** An asset's bytes, to `onChunk`, in order. */
295
+ async download(asset: Asset, limit: number, onChunk: (chunk: Uint8Array) => void): Promise<void> {
296
+ const { res, deadline } = await this.get(asset.url, "application/octet-stream", this.o.downloadTimeoutMs);
297
+ if (!res.ok) {
298
+ deadline.done();
299
+ await res.body?.cancel();
300
+ throw this.refusal(res, () => this.assetNotFound(asset));
301
+ }
302
+ await this.read(res, deadline, limit, onChunk);
303
+ }
304
+
305
+ /** An asset's text. */
306
+ async downloadText(asset: Asset): Promise<string> {
307
+ const chunks: Uint8Array[] = [];
308
+ await this.download(asset, MAX_TEXT_BYTES, (c) => chunks.push(c));
309
+ return Buffer.concat(chunks).toString("utf8");
310
+ }
311
+
312
+ private assetNotFound(asset: Asset): Refusal {
313
+ return new Refusal({
314
+ slug: "update-asset-missing",
315
+ what: `${this.host} listed ${asset.name} but answered 404 for it`,
316
+ fix: `retry in a few minutes; if it persists, the release is incomplete: check ${this.releasesUrl}`,
317
+ });
318
+ }
319
+ }
320
+
321
+ /** A release the repository does not have (or has only as a draft or prerelease). */
322
+ export function notFound(repo: string, tag: string | undefined, why = ""): Refusal {
323
+ return new Refusal({
324
+ slug: "update-release-not-found",
325
+ what: `${repo} has no ${tag === undefined ? "published release" : `release ${tag}`} that eris can install${why}`,
326
+ fix: `check the tag against https://github.com/${repo}/releases\nif ${repo} is private, ${TOKEN_NAMES} must be a token that can read it`,
327
+ });
328
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * opt-in.ts — `RELEASE_UPDATE`, the in-place `eris update` (./release.ts) as a
3
+ * row and a lazy handler, for a distribution that publishes compiled binaries
4
+ * to GitHub releases. Core's own `update` row prints the npm commands; a
5
+ * distribution swaps both halves in when it composes (docs/dev/PLUGINS.md#distributions):
6
+ *
7
+ * composeCli({ ...REGISTRY, update: RELEASE_UPDATE.row }, { ...HANDLERS, update: RELEASE_UPDATE.handler }, plugins)
8
+ *
9
+ * This module is light, because `eris/sdk` exports it and a plugin manifest
10
+ * loads that on every command line: the handler is imported on first use.
11
+ */
12
+
13
+ import type { VerbHandler } from "../../../main";
14
+ import type { Row } from "../../registry";
15
+
16
+ /** The release verb's row: the flags ./release.ts reads with `readRowArgs`. */
17
+ export const RELEASE_UPDATE_ROW = {
18
+ impl: "ts",
19
+ summary: "Update this eris binary to the latest release (--check, --to)",
20
+ json: true,
21
+ usage: "[--check] [--to <tag>]",
22
+ flags: [
23
+ { name: "--check", takesValue: false },
24
+ { name: "--to", takesValue: true },
25
+ ],
26
+ } as const satisfies Row;
27
+
28
+ /** The row and the handler thunk a distribution composes in place of core's `update`. */
29
+ export const RELEASE_UPDATE: { readonly row: Row; readonly handler: () => Promise<VerbHandler> } = {
30
+ row: RELEASE_UPDATE_ROW,
31
+ handler: async () => (await import("./release")).runReleaseUpdate,
32
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * refusal.ts — how `eris update` stops: a `Refusal` carries the failure block's
3
+ * input, thrown from wherever the cause is found and rendered once by the verb
4
+ * (src/cli/commands/update.ts), so a failure is one block and nothing cascades.
5
+ */
6
+
7
+ import type { ErisFailureInput } from "../../../../ts/lib/eris-fail";
8
+ import { sanitizeDisplay } from "../../output";
9
+
10
+ export class Refusal extends Error {
11
+ constructor(readonly input: ErisFailureInput) {
12
+ super(input.what);
13
+ }
14
+ }
15
+
16
+ /** An error's own words on one line, fit to print in a failure block. */
17
+ export function reasonOf(e: unknown): string {
18
+ const code = typeof e === "object" && e !== null ? (e as { code?: unknown }).code : undefined;
19
+ const message = e instanceof Error ? e.message : String(e);
20
+ return sanitizeDisplay(`${typeof code === "string" ? `${code}: ` : ""}${message}`).replace(/\s+/g, " ").slice(0, 200);
21
+ }