@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,126 @@
1
+ /**
2
+ * ts-verb.ts — the steps between argv and a verb's dispatcher, for a verb the
3
+ * router runs in TypeScript:
4
+ *
5
+ * 1. the global `--output` parse
6
+ * 2. the machine-surface allow-list
7
+ * 3. the entitlement lock, for a plugin group whose product is not bought
8
+ * 4. the verb, with the flag stripped
9
+ *
10
+ * A verb stops early by throwing `CliExit` after writing its diagnostic, the
11
+ * TS counterpart of `eris::die` / `eris::fail` exiting the shell.
12
+ *
13
+ * A `session` (src/cli/compose.ts) supplies the composed allow-list, its fix
14
+ * text and the locks, and is handed on to the verb; without one, steps 2 and 3
15
+ * read core's registry alone.
16
+ *
17
+ * Everything past step 3 is imported lazily, so a package the checkout's
18
+ * node_modules cannot resolve surfaces from inside step 4. It ends the command
19
+ * as the `eris-deps-not-installed` block, never a bare resolution
20
+ * error. Nothing on this path or above it may import a third-party package, or
21
+ * the block could not be reached.
22
+ */
23
+
24
+ import { accessSync, constants } from "node:fs";
25
+ import { delimiter, join, relative, sep } from "node:path";
26
+ import { renderFailureBlock } from "../../ts/lib/eris-fail";
27
+ import type { CliContext, VerbHandler } from "../main";
28
+ import { asksForHelp, CliExit, GlobalFlagError, parseGlobal } from "./args";
29
+ import { lockedOutcome, lockedProduct, type Session } from "./compose";
30
+ import { sanitizeDisplay, unsupportedJsonBlock } from "./output";
31
+
32
+ export { CliExit };
33
+
34
+ /** `command -v <name>` over PATH, for an executable file. */
35
+ export function onPath(name: string, env: Record<string, string | undefined>): string | undefined {
36
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
37
+ if (dir === "") continue;
38
+ const p = join(dir, name);
39
+ try {
40
+ accessSync(p, constants.X_OK);
41
+ return p;
42
+ } catch {
43
+ // not here
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /** What bun sets on the error for a module it could not find: `import` and
50
+ * `require` use different codes, and neither error is an `Error` subclass. */
51
+ const NOT_FOUND_CODES = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]);
52
+
53
+ /** A bare package name, as opposed to a path (`./x`, `/x`) or a scheme (`node:x`). */
54
+ const NOT_A_PACKAGE = /^(\.|\/|[a-z][a-z0-9+.-]*:)/i;
55
+
56
+ /**
57
+ * The failure block for a thrown value that is bun failing to resolve a
58
+ * PACKAGE, else null. A missing relative module is a defect in the checkout's
59
+ * own source and stays a crash: telling an operator to `bun install` for it
60
+ * would send them away from the bug. It reads the error's structured fields,
61
+ * never its message text.
62
+ *
63
+ * It names the fix and does not run it: an inline `bun install` is unbounded
64
+ * and reaches the network mid-command, the same reason `eris yak` refuses to
65
+ * run one.
66
+ */
67
+ export function missingDependencyBlock(e: unknown, erisDir: string): string | null {
68
+ if (typeof e !== "object" || e === null) return null;
69
+ const { code, specifier, referrer } = e as Record<string, unknown>;
70
+ if (typeof code !== "string" || !NOT_FOUND_CODES.has(code)) return null;
71
+ if (typeof specifier !== "string" || NOT_A_PACKAGE.test(specifier)) return null;
72
+ const rel = typeof referrer === "string" && referrer.startsWith(`${erisDir}${sep}`) ? relative(erisDir, referrer) : referrer;
73
+ const by = typeof rel === "string" && rel !== "" ? ` (needed by ${sanitizeDisplay(rel)})` : "";
74
+ return renderFailureBlock(
75
+ {
76
+ slug: "eris-deps-not-installed",
77
+ what: `eris's node_modules is missing or out of date — cannot resolve '${sanitizeDisplay(specifier)}'${by}`,
78
+ fix: `from ${erisDir}, run: bun install --frozen-lockfile`,
79
+ },
80
+ erisDir,
81
+ );
82
+ }
83
+
84
+ export async function runTsVerb(
85
+ verb: string,
86
+ argv: readonly string[],
87
+ ctx: CliContext,
88
+ handlers: Readonly<Record<string, () => Promise<VerbHandler>>>,
89
+ session?: Session,
90
+ ): Promise<number> {
91
+ try {
92
+ let parsed: ReturnType<typeof parseGlobal>;
93
+ try {
94
+ parsed = parseGlobal(argv, ctx.erisDir, { keepTerminator: true });
95
+ } catch (e) {
96
+ if (e instanceof GlobalFlagError) {
97
+ ctx.stderr.write(e.block);
98
+ return 1;
99
+ }
100
+ throw e;
101
+ }
102
+ const refusal = unsupportedJsonBlock(parsed.argv[0], parsed.argv[1], parsed.format, ctx.erisDir, session?.cli.jsonSurfaces, session?.cli.jsonFix);
103
+ if (refusal !== null) {
104
+ ctx.stderr.write(refusal);
105
+ return 1;
106
+ }
107
+ if (session !== undefined) {
108
+ const product = lockedProduct(session, verb);
109
+ if (product !== undefined) {
110
+ const out = lockedOutcome(session, product, parsed.argv, asksForHelp(parsed.argv), ctx.erisDir);
111
+ ctx[out.stream].write(out.text);
112
+ return out.rc;
113
+ }
114
+ }
115
+ const load = handlers[verb];
116
+ if (!load) throw new Error(`no TypeScript handler for '${verb}'`);
117
+ const handler = await load();
118
+ return await handler(parsed.argv.slice(1), { ...ctx, format: parsed.format, ...(session !== undefined ? { session } : {}) });
119
+ } catch (e) {
120
+ if (e instanceof CliExit) return e.code;
121
+ const block = missingDependencyBlock(e, ctx.erisDir);
122
+ if (block === null) throw e;
123
+ ctx.stderr.write(block);
124
+ return 1;
125
+ }
126
+ }
package/src/main.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * main.ts — the eris program, reached through the `eris` shim.
3
+ *
4
+ * One decision per command line (src/cli/router.ts): a TS-routed command runs
5
+ * in this process behind the global `--output` pre-pass; anything else is
6
+ * refused as an unknown command, behind the same pre-pass.
7
+ *
8
+ * The shim hands over its logical directory through a private variable,
9
+ * removed before anything is spawned. The shim has already removed
10
+ * BUN_CONFIG_VERBOSE_FETCH (see its header): it cannot be removed from here,
11
+ * because bun reads it from its own environment at fetch time and
12
+ * `delete process.env.X` does not reach that. Before any of that,
13
+ * `~/.eris/settings.json` fills unset variables in this process's environment
14
+ * (src/runtime/settings.ts). Everything past the router is imported lazily.
15
+ *
16
+ * The commands are core's registry composed with the build's plugin list,
17
+ * plugins/index.ts (src/cli/compose.ts). Another distribution composes its own
18
+ * list and calls `runProgram({ cli })` from its entry; a test hands `main` a
19
+ * composition directly.
20
+ *
21
+ * `erisDir` is the shim's directory when the shim ran, else the install root
22
+ * (src/runtime/home.ts): the checkout, a distribution's own root when its entry
23
+ * named one (`runProgram({ installRoot })`), or a compiled binary's asset home. A
24
+ * binary never reads `_ERIS_SHIM_DIR`: only the shim sets it, and a stray value
25
+ * in a caller's environment must not move the files the binary reads.
26
+ */
27
+
28
+ import { PLUGINS } from "../plugins";
29
+ import { asksForHelp, locateVerb } from "./cli/args";
30
+ import { type Cli, composeCli, NO_ENTITLEMENTS, type Session } from "./cli/compose";
31
+ import { sanitizeDisplay } from "./cli/output";
32
+ import { ARGV_SHAPES_NO_ASSETS, REGISTRY } from "./cli/registry";
33
+ import { route, type Impl } from "./cli/router";
34
+ import { adoptSourceIdentity, adoptSourceRoot, adoptUnwrittenHome, type DistributionIdentity, HomeUnwritableError, isCompiled, resolveInstallRoot } from "./runtime/home";
35
+ import { warn } from "./runtime/logging";
36
+ import { applySettings } from "./runtime/settings";
37
+
38
+ export const SHIM_ENV = ["_ERIS_SHIM_DIR"] as const;
39
+
40
+ export interface Sink {
41
+ write(chunk: string): unknown;
42
+ }
43
+
44
+ export interface CliContext {
45
+ /** The logical install root: the shim's directory, a distribution's declared root, or a binary's asset home. */
46
+ erisDir: string;
47
+ /** The caller's environment, shim variables removed. */
48
+ env: Record<string, string | undefined>;
49
+ stdout: Sink;
50
+ stderr: Sink;
51
+ }
52
+
53
+ /** What a handler is called with: the command line's context, its output
54
+ * format, and the session it runs in (absent when a test calls a handler directly). */
55
+ export type VerbContext = CliContext & { format: "text" | "json"; session?: Session };
56
+
57
+ export type VerbHandler = (args: string[], ctx: VerbContext) => Promise<number>;
58
+
59
+ /** The TS implementation of each `ts` row in src/cli/registry.ts, plus the
60
+ * argv shapes: the bare program and `-h`/`--help` print the top-level usage. */
61
+ export const HANDLERS: Readonly<Record<string, () => Promise<VerbHandler>>> = {
62
+ "": async () => (await import("./cli/commands/usage")).runUsage,
63
+ "-h": async () => (await import("./cli/commands/usage")).runUsage,
64
+ "--help": async () => (await import("./cli/commands/usage")).runUsage,
65
+ "--version": async () => (await import("./cli/commands/version")).runVersion,
66
+ completion: async () => (await import("./cli/commands/completion")).runCompletion,
67
+ update: async () => (await import("./cli/commands/update")).runUpdate,
68
+ };
69
+
70
+ /** Core composed with the build's plugin list. */
71
+ export const CLI: Cli = composeCli(REGISTRY, HANDLERS, PLUGINS);
72
+
73
+ /** The handler an unknown verb reaches, after the global pre-pass. */
74
+ export const UNKNOWN_VERB = "\0unknown";
75
+
76
+ const unknownCommand =
77
+ (verb: string): VerbHandler =>
78
+ async (_args, ctx) => {
79
+ ctx.stderr.write(`ERROR Unknown command: ${sanitizeDisplay(verb)}. Run 'eris --help'\n`);
80
+ return 1;
81
+ };
82
+
83
+ export interface MainOptions {
84
+ /** The composed commands; `CLI` when absent. */
85
+ cli?: Cli;
86
+ /** The products this workspace has bought; `NO_ENTITLEMENTS` when absent. */
87
+ entitlements?: ReadonlySet<string>;
88
+ /** Overrides the composition's router table. */
89
+ table?: Readonly<Record<string, Impl>>;
90
+ stdout?: Sink;
91
+ stderr?: Sink;
92
+ }
93
+
94
+ /**
95
+ * A command line that reads nothing from the install root: usage, a `--help`
96
+ * ahead of any `--`, `--version`, and `completion`. A binary whose asset home cannot be
97
+ * written still runs these, because they are what an operator reaches for to
98
+ * find out how to fix it (and `completion` runs on every shell start). Every
99
+ * other command line is refused with `eris-cache-unwritable`. Only consulted
100
+ * after the write failed, so a mistake here never reaches a writable cache: a
101
+ * command wrongly left out gets the block, and one wrongly put in fails on its
102
+ * own missing file instead of the block.
103
+ */
104
+ export function readsNoAssets(argv: readonly string[]): boolean {
105
+ const verb = locateVerb(argv)[0];
106
+ return verb === "completion" || (verb !== undefined && ARGV_SHAPES_NO_ASSETS.includes(verb)) || asksForHelp(argv);
107
+ }
108
+
109
+ /**
110
+ * The command's `erisDir`, or null once `eris-cache-unwritable` has been
111
+ * written to `stderr` for a command line that needs the asset home.
112
+ */
113
+ async function resolveErisDir(argv: readonly string[], rawEnv: Record<string, string | undefined>, stderr: Sink): Promise<string | null> {
114
+ if (!isCompiled && rawEnv._ERIS_SHIM_DIR) return rawEnv._ERIS_SHIM_DIR;
115
+ try {
116
+ return await resolveInstallRoot(rawEnv);
117
+ } catch (e) {
118
+ if (!(e instanceof HomeUnwritableError)) throw e;
119
+ if (readsNoAssets(argv)) {
120
+ adoptUnwrittenHome(e.home);
121
+ return e.home;
122
+ }
123
+ const { renderFailureBlock } = await import("../ts/lib/eris-fail");
124
+ stderr.write(
125
+ renderFailureBlock(
126
+ {
127
+ slug: "eris-cache-unwritable",
128
+ what: `eris cannot write its asset cache under ${sanitizeDisplay(e.base)}: ${sanitizeDisplay(e.reason)}`,
129
+ fix:
130
+ "set ERIS_CACHE_DIR to a directory eris can write (it keeps one copy of the files compiled into the binary there, per build)\n" +
131
+ "or set XDG_CACHE_HOME: eris uses $XDG_CACHE_HOME/eris, default ~/.cache/eris",
132
+ },
133
+ null,
134
+ ),
135
+ );
136
+ return null;
137
+ }
138
+ }
139
+
140
+ export async function main(
141
+ argv: readonly string[],
142
+ rawEnv: Record<string, string | undefined> = process.env,
143
+ opts: MainOptions = {},
144
+ ): Promise<number> {
145
+ const env: Record<string, string | undefined> = { ...rawEnv };
146
+ const stdout = opts.stdout ?? process.stdout;
147
+ const stderr = opts.stderr ?? process.stderr;
148
+ const erisDir = await resolveErisDir(argv, rawEnv, stderr);
149
+ if (erisDir === null) return 1;
150
+ for (const k of SHIM_ENV) delete env[k];
151
+
152
+ const cli = opts.cli ?? CLI;
153
+ const session: Session = { cli, entitled: opts.entitlements ?? NO_ENTITLEMENTS };
154
+ const r = route(argv, opts.table ?? cli.table);
155
+
156
+ const { runTsVerb } = await import("./cli/ts-verb");
157
+ if (r.kind === "unknown") {
158
+ return runTsVerb(UNKNOWN_VERB, argv, { erisDir, env, stdout, stderr }, { [UNKNOWN_VERB]: async () => unknownCommand(r.verb) }, session);
159
+ }
160
+ return runTsVerb(r.verb, argv, { erisDir, env, stdout, stderr }, cli.handlers, session);
161
+ }
162
+
163
+ /**
164
+ * End with `code` once both streams have drained, so a pipe reader gets every
165
+ * byte. The process is left to exit on its own: bun writes to a pipe
166
+ * asynchronously, and an explicit `process.exit` — even from an empty write's
167
+ * callback — dropped the tail of a large stdout under load (measured: 12
168
+ * concurrent `migrate pragma` dry runs, 38 KB each, cut at 21–37 KB).
169
+ * The unref'd timer only fires if something else is keeping the loop alive.
170
+ */
171
+ function exitAfterFlush(code: number): void {
172
+ process.exitCode = code;
173
+ setTimeout(() => process.exit(code), 10_000).unref();
174
+ }
175
+
176
+ export interface ProgramOptions extends Pick<MainOptions, "cli" | "entitlements"> {
177
+ /**
178
+ * The distribution's root, an absolute path: what a run from source reads its
179
+ * files from and names its docs under (`ctx.erisDir`, a failure block's local
180
+ * `docs:` line), in place of this package's directory. Pass the directory
181
+ * `build()` takes as `root`, so a run from source and the binary read the
182
+ * same relative paths. A compiled binary ignores it (its asset home is its
183
+ * root), and the shim's `_ERIS_SHIM_DIR` wins over it.
184
+ */
185
+ installRoot?: string;
186
+ /**
187
+ * Who the distribution is (`releaseRepo`, `versionExtra`, `docsBase`), for a
188
+ * run from source: what `eris --version` prints, the repository `eris update`
189
+ * and `releaseRepo()` name, and the base of a failure block's second `docs:`
190
+ * line, in place of this repository's. Pass the values `build()` is given, so a
191
+ * run from source and the binary say the same. Checked as `build()` checks
192
+ * them. A compiled binary keeps the identity its build baked, whatever is
193
+ * passed here; no environment variable moves either.
194
+ */
195
+ identity?: DistributionIdentity;
196
+ }
197
+
198
+ /** The process entry: this file's own when run, and a distribution's. */
199
+ export function runProgram(opts: ProgramOptions = {}): void {
200
+ const { installRoot: root, identity, ...mainOpts } = opts;
201
+ if (root !== undefined) adoptSourceRoot(root);
202
+ if (identity !== undefined) adoptSourceIdentity(identity);
203
+ // A compiled binary has no shim to unset BUN_CONFIG_VERBOSE_FETCH before bun
204
+ // starts, and `delete` does not reach bun's reader — but an assignment does:
205
+ // "0" turns verbose fetch off for this process (measured, bun 1.3.13).
206
+ // Pinned against the binary by tests/compiled-binary.test.ts under ERIS_BIN.
207
+ if (process.env.BUN_CONFIG_VERBOSE_FETCH !== undefined) process.env.BUN_CONFIG_VERBOSE_FETCH = "0";
208
+ // Into process.env itself, before routing: TS verbs and their spawns read it
209
+ // directly.
210
+ applySettings(process.env, (msg) => warn(msg));
211
+ // Held until `main` settles. Bun does not count every pending native
212
+ // operation as keeping the loop alive — an awaited `crypto.subtle.digest`
213
+ // is one (observed: `eris yak` under load ended at exit 0 with nothing
214
+ // written, mid idempotency-key digest) — and a loop that drains
215
+ // with `main` still pending ends the process as a silent success.
216
+ const hold = setInterval(() => {}, 1 << 30);
217
+ main(process.argv.slice(2), process.env, mainOpts).finally(() => clearInterval(hold)).then(exitAfterFlush, (e) => {
218
+ process.stderr.write(`ERROR ${e instanceof Error ? e.message : String(e)}\n`);
219
+ exitAfterFlush(1);
220
+ });
221
+ }
222
+
223
+ if (import.meta.main) runProgram();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * env.ts — the ONE way to read an environment variable by a name you were
3
+ * handed (a `--token-env NAME`, a `${REF}` in a settings file).
4
+ *
5
+ * An env object is a plain object, so indexing it with `__proto__` or
6
+ * `constructor` returns an inherited value and a variable that is not set reads
7
+ * as set. Only an own property counts. The `env-read-own-property` ast-grep rule
8
+ * (rules/) keeps every other site off the raw index.
9
+ */
10
+
11
+ export type EnvLike = Readonly<Record<string, string | undefined>>;
12
+
13
+ /** `name`'s value in `env`, or `undefined` when it is not an own property. */
14
+ export function readEnv(env: EnvLike, name: string): string | undefined {
15
+ return Object.hasOwn(env, name) ? env[name] : undefined;
16
+ }
@@ -0,0 +1,5 @@
1
+ /** The repo files a compiled binary carries (relative path → text), generated by
2
+ * `build()` (src/build.ts) and read only by `src/runtime/home.ts` inside a binary. */
3
+ declare module "eris:assets" {
4
+ export const ASSETS: Readonly<Record<string, string>>;
5
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * home.ts — the install root: the directory eris reads its own repo files from
3
+ * (the docs a failure block's `docs:` line names). `main.ts` makes it the
4
+ * command's `erisDir`; `erisHome()` (ts/lib/eris-fail.ts) falls back to it.
5
+ *
6
+ * Two ways to run, one answer each:
7
+ *
8
+ * - From a checkout (the `eris` shim → `bun src/main.ts`): the checkout, two
9
+ * directories above this file. A distribution run from source is a checkout
10
+ * of its own: its entry names its root (`runProgram({ installRoot })`,
11
+ * `adoptSourceRoot`), so the files it reads are its own, not those of the
12
+ * eris package in its node_modules. A failure block's `docs:` line names a doc
13
+ * under that root when it holds the doc, else under the eris package's (the
14
+ * order `build()` takes docs in, `docsRoot`).
15
+ * - As a compiled binary (`build()`, src/build.ts): the files ride inside the binary
16
+ * as the build-generated `eris:assets` module, and are written once per build
17
+ * to `${ERIS_CACHE_DIR:-${XDG_CACHE_HOME:-~/.cache}/eris}/<build id>/`, which is
18
+ * the root. The build id is a content hash of the assets and the source, so a
19
+ * build never reuses another build's files.
20
+ *
21
+ * The build's identity (release repository, `--version` line, docs base) follows
22
+ * the same split: a binary's is baked by `build()` and always wins; a run from
23
+ * source reports the one its entry adopted (`runProgram({ identity })`,
24
+ * `adoptSourceIdentity`), else this repository's.
25
+ *
26
+ * Only the build defines `__ERIS_BUILD_ID__`, so `typeof` tells the two apart; a
27
+ * plain `bun` run never reaches the `eris:assets` import, which only the build's
28
+ * plugin can resolve.
29
+ *
30
+ * The write is race-safe, because one binary can start many processes at once:
31
+ * a marker file names a finished home; otherwise the files go to a private
32
+ * `<home>.tmp-<pid>-<rand>` beside it, which is renamed into place. A rename
33
+ * that loses to a peer (EEXIST / ENOTEMPTY) removes its own temp dir and uses
34
+ * the winner's. Nothing is ever executed from the home.
35
+ *
36
+ * A cache base eris cannot write is `HomeUnwritableError`, which `main` turns
37
+ * into the `eris-cache-unwritable` block. It does not stop a command line that
38
+ * reads no file from the home (see `readsNoAssets` in main.ts): that one carries
39
+ * on with the home's path, which then names a directory that does not exist.
40
+ */
41
+
42
+ /// <reference path="./eris-assets.d.ts" />
43
+ // The `eris:assets` module `build()` supplies at bundle time: referenced here so a
44
+ // program that imports this file (a distribution through eris/sdk) typechecks with
45
+ // no `files` entry of its own for the declaration.
46
+
47
+ import { randomBytes } from "node:crypto";
48
+ import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
49
+ import { homedir } from "node:os";
50
+ import { dirname, isAbsolute, join, resolve } from "node:path";
51
+ import pkg from "../../package.json";
52
+
53
+ declare const __ERIS_BUILD_ID__: string | undefined;
54
+ declare const __ERIS_RELEASE_REPO__: string | undefined;
55
+ declare const __ERIS_VERSION__: string | undefined;
56
+ declare const __ERIS_VERSION_EXTRA__: string | null | undefined;
57
+ declare const __ERIS_DOCS_BASE__: string | undefined;
58
+
59
+ type Env = Record<string, string | undefined>;
60
+
61
+ /** The compiled binary's build id; null when running from a checkout. */
62
+ export const BUILD_ID: string | null = typeof __ERIS_BUILD_ID__ === "undefined" ? null : __ERIS_BUILD_ID__;
63
+
64
+ /** The GitHub repository (`owner/name`) this repository's releases are published to: a build's or a run's, unless its distribution names its own. */
65
+ export const DEFAULT_RELEASE_REPO = "sibylline-labs/eris";
66
+
67
+ /**
68
+ * This build's version, from the first source that has one: the tag a build was
69
+ * given (`build({ tag })`); else the package's own version, which the release
70
+ * stamps into package.json before it publishes to npm, as `v<version>`; else
71
+ * `dev` (an untagged build, a checkout, and a git dependency, all `0.0.0`).
72
+ */
73
+ export function versionOf(bakedTag: string | undefined, packageVersion: string | undefined): string {
74
+ if (bakedTag !== undefined) return bakedTag;
75
+ if (packageVersion !== undefined && packageVersion !== "0.0.0" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(packageVersion)) return `v${packageVersion}`;
76
+ return "dev";
77
+ }
78
+
79
+ /** This build's version (`versionOf`): `eris --version` prints it and `eris update` compares releases with it. */
80
+ export const VERSION: string = versionOf(typeof __ERIS_VERSION__ === "string" ? __ERIS_VERSION__ : undefined, BUILD_ID !== null ? undefined : pkg.version);
81
+
82
+ /** Where a failure block's second `docs:` line points: this repository's public docs. */
83
+ export const DEFAULT_DOCS_BASE = "https://github.com/sibylline-labs/eris/blob/main";
84
+
85
+ /** True inside a binary built by `build()` (src/build.ts). */
86
+ export const isCompiled = BUILD_ID !== null;
87
+
88
+ /**
89
+ * Who a distribution is: what `build()` bakes into its binary (src/build.ts) and
90
+ * what its entry hands `runProgram({ identity })` for a run from source. Each
91
+ * field left out is this repository's.
92
+ */
93
+ export interface DistributionIdentity {
94
+ /** `owner/name` the distribution's own releases are published to: `eris --version` names it and `eris update` reads it. */
95
+ readonly releaseRepo?: string;
96
+ /** One printable line `eris --version` prints after the version line: the core a distribution was built on, say. */
97
+ readonly versionExtra?: string;
98
+ /** An https URL (no query, fragment or space) a failure block's second `docs:` line appends the doc path to. */
99
+ readonly docsBase?: string;
100
+ }
101
+
102
+ /** A `DistributionIdentity` checked and filled in. */
103
+ export interface Identity {
104
+ readonly releaseRepo: string;
105
+ readonly versionExtra: string | null;
106
+ readonly docsBase: string;
107
+ }
108
+
109
+ const RELEASE_REPO_SHAPE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
110
+ /** One line of printable ASCII, visible at both ends. */
111
+ const VERSION_LINE = /^[\x21-\x7e](?:[\x20-\x7e]{0,118}[\x21-\x7e])?$/;
112
+ /** An https URL with no query, fragment or space, whose trailing slashes are dropped. */
113
+ const DOCS_BASE_URL = /^https:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[A-Za-z0-9._~%/-]*)?$/;
114
+
115
+ /** `id` checked and filled in with this repository's defaults; the one rule `build()` and `runProgram` both apply. */
116
+ export function resolveIdentity(id: DistributionIdentity = {}): Identity {
117
+ const releaseRepo = id.releaseRepo ?? DEFAULT_RELEASE_REPO;
118
+ if (!RELEASE_REPO_SHAPE.test(releaseRepo)) throw new Error(`releaseRepo must be owner/name: ${JSON.stringify(releaseRepo)}`);
119
+ const versionExtra = id.versionExtra ?? null;
120
+ if (versionExtra !== null && !VERSION_LINE.test(versionExtra)) {
121
+ throw new Error(`versionExtra must be one printable line of at most 120 characters: ${JSON.stringify(versionExtra)}`);
122
+ }
123
+ const docsBase = (id.docsBase ?? DEFAULT_DOCS_BASE).replace(/\/+$/, "");
124
+ if (!DOCS_BASE_URL.test(docsBase)) throw new Error(`docsBase must be an https URL with no query, fragment or space: ${JSON.stringify(id.docsBase)}`);
125
+ return { releaseRepo, versionExtra, docsBase };
126
+ }
127
+
128
+ /**
129
+ * The binary's identity, as its build baked it; null outside a binary. Baked
130
+ * values always win: a binary reports what it was built and released as, and
131
+ * nothing its entry, its caller or the environment says at run time moves it.
132
+ */
133
+ const BAKED: Identity | null = isCompiled
134
+ ? {
135
+ releaseRepo: typeof __ERIS_RELEASE_REPO__ === "string" ? __ERIS_RELEASE_REPO__ : DEFAULT_RELEASE_REPO,
136
+ versionExtra: typeof __ERIS_VERSION_EXTRA__ === "string" ? __ERIS_VERSION_EXTRA__ : null,
137
+ docsBase: typeof __ERIS_DOCS_BASE__ === "string" ? __ERIS_DOCS_BASE__ : DEFAULT_DOCS_BASE,
138
+ }
139
+ : null;
140
+
141
+ let sourceIdentity: Identity = resolveIdentity();
142
+
143
+ /**
144
+ * A distribution's identity, for a run from source (`runProgram({ identity })`,
145
+ * src/main.ts): what `identity()` answers with in place of this repository's.
146
+ * Checked as `build()` checks it, so a source run never reports what its binary
147
+ * could not; a compiled binary checks it too, then keeps its baked identity.
148
+ */
149
+ export function adoptSourceIdentity(id: DistributionIdentity): void {
150
+ sourceIdentity = resolveIdentity(id);
151
+ }
152
+
153
+ /** This process's identity: a binary's baked one, else the one its entry adopted, else this repository's. */
154
+ export function identity(): Identity {
155
+ return BAKED ?? sourceIdentity;
156
+ }
157
+
158
+ /** Where this build's own releases are published (`identity().releaseRepo`): what `eris update` reads. */
159
+ export function releaseRepo(): string {
160
+ return identity().releaseRepo;
161
+ }
162
+
163
+ /** The file whose presence marks a home as completely written. */
164
+ export const HOME_MARKER = ".eris-home-complete";
165
+
166
+ /** The checkout this source file sits in. Meaningless inside a binary. */
167
+ export function checkoutRoot(): string {
168
+ return resolve(import.meta.dir, "..", "..");
169
+ }
170
+
171
+ /** `${ERIS_CACHE_DIR:-${XDG_CACHE_HOME:-~/.cache}/eris}` — the parent of every build's home. */
172
+ export function cacheBase(env: Env): string {
173
+ if (env.ERIS_CACHE_DIR) return env.ERIS_CACHE_DIR;
174
+ const xdg = env.XDG_CACHE_HOME || join(env.HOME || homedir(), ".cache");
175
+ return join(xdg, "eris");
176
+ }
177
+
178
+ /** A relative path with no `..`, no empty and no absolute segment. */
179
+ function safeRel(rel: string): boolean {
180
+ return rel !== "" && !rel.startsWith("/") && rel.split("/").every((s) => s !== "" && s !== "." && s !== "..");
181
+ }
182
+
183
+ const errCode = (e: unknown) => (typeof e === "object" && e !== null ? (e as { code?: unknown }).code : undefined);
184
+
185
+ /**
186
+ * `<base>/<buildId>/`, holding `assets` (relative path → text) and the marker.
187
+ * Returns the home. Writes nothing when the marker is already there.
188
+ */
189
+ export function materializeHome(base: string, buildId: string, assets: Readonly<Record<string, string>>): string {
190
+ const home = join(base, buildId);
191
+ const marker = join(home, HOME_MARKER);
192
+ if (existsSync(marker)) return home;
193
+ mkdirSync(base, { recursive: true });
194
+ const tmp = `${home}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
195
+ try {
196
+ for (const [rel, text] of Object.entries(assets)) {
197
+ if (!safeRel(rel)) throw new Error(`eris asset path is not a plain relative path: ${JSON.stringify(rel)}`);
198
+ const file = join(tmp, rel);
199
+ mkdirSync(dirname(file), { recursive: true });
200
+ writeFileSync(file, text);
201
+ }
202
+ writeFileSync(join(tmp, HOME_MARKER), `${buildId}\n`);
203
+ renameSync(tmp, home);
204
+ return home;
205
+ } catch (e) {
206
+ rmSync(tmp, { recursive: true, force: true });
207
+ const code = errCode(e);
208
+ if (code !== "EEXIST" && code !== "ENOTEMPTY") throw e;
209
+ }
210
+ // A peer renamed its copy into place first.
211
+ if (existsSync(marker)) return home;
212
+ throw new Error(`${home} exists but is not a complete eris asset home (no ${HOME_MARKER}) — remove it and run eris again`);
213
+ }
214
+
215
+ let compiledRoot: string | undefined;
216
+ let sourceRoot: string | undefined;
217
+
218
+ /**
219
+ * A distribution's own root, for a run from source (`runProgram({ installRoot })`,
220
+ * src/main.ts): `installRoot()` answers with it in place of `checkoutRoot()`.
221
+ * A compiled binary ignores it, because its asset home is its root.
222
+ */
223
+ export function adoptSourceRoot(dir: string): void {
224
+ if (!isAbsolute(dir)) throw new Error(`installRoot must be an absolute path: ${JSON.stringify(dir)}`);
225
+ sourceRoot = dir;
226
+ }
227
+
228
+ /** The binary's asset home could not be written: the cache base is read-only, full, or not a directory. */
229
+ export class HomeUnwritableError extends Error {
230
+ constructor(
231
+ /** The cache base, `${ERIS_CACHE_DIR:-${XDG_CACHE_HOME:-~/.cache}/eris}`. */
232
+ readonly base: string,
233
+ /** Where the home would have been: `<base>/<build id>`. */
234
+ readonly home: string,
235
+ readonly reason: string,
236
+ ) {
237
+ super(`cannot write eris's asset home ${home}: ${reason}`);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * The install root for this process, materializing a binary's home on first
243
+ * call. Throws `HomeUnwritableError` when the home cannot be written.
244
+ */
245
+ export async function resolveInstallRoot(env: Env): Promise<string> {
246
+ if (!isCompiled) return sourceRoot ?? checkoutRoot();
247
+ if (compiledRoot === undefined) {
248
+ const { ASSETS } = await import("eris:assets");
249
+ const base = cacheBase(env);
250
+ try {
251
+ compiledRoot = materializeHome(base, BUILD_ID as string, ASSETS);
252
+ } catch (e) {
253
+ throw new HomeUnwritableError(base, join(base, BUILD_ID as string), e instanceof Error ? e.message : String(e));
254
+ }
255
+ }
256
+ return compiledRoot;
257
+ }
258
+
259
+ /**
260
+ * Carry on without a written home: `installRoot()` answers with the path the
261
+ * home would have had, so a block rendered afterwards still names one. For a
262
+ * command line that reads nothing from it, after `resolveInstallRoot` threw.
263
+ */
264
+ export function adoptUnwrittenHome(home: string): void {
265
+ compiledRoot = home;
266
+ homeUnwritten = true;
267
+ }
268
+
269
+ let homeUnwritten = false;
270
+
271
+ /** Whether the install root was adopted without its files being written (`eris-cache-unwritable`). */
272
+ export function installRootIsUnwritten(): boolean {
273
+ return homeUnwritten;
274
+ }
275
+
276
+ /** The install root without materializing: a binary's must already be resolved by `main`. */
277
+ export function installRoot(): string {
278
+ if (!isCompiled) return sourceRoot ?? checkoutRoot();
279
+ if (compiledRoot === undefined) throw new Error("eris install root read before main() resolved it");
280
+ return compiledRoot;
281
+ }
282
+
283
+ /**
284
+ * The first of `roots` that holds the repo-relative doc `rel` (an `#anchor` is
285
+ * ignored) as a file. `build()` takes each doc the source names from its doc
286
+ * roots by this rule, and `docsRoot` names a run from source's by it, so a
287
+ * binary and the source run point at the same copy.
288
+ */
289
+ export function firstDocRoot(roots: readonly string[], rel: string): string | undefined {
290
+ const file = rel.split("#")[0]!;
291
+ return roots.find((r) => existsSync(join(r, file)) && statSync(join(r, file)).isFile());
292
+ }
293
+
294
+ /**
295
+ * The root a failure block's local `docs:` line names `rel` under, given the
296
+ * `home` it was handed. When `home` is the install root of a run from source, that
297
+ * is the install root, then this package's root, as `build()` falls back for the
298
+ * binary: a distribution carries only the docs it owns, and a line naming a core
299
+ * doc under its root would name a file that is not there. A compiled binary's home
300
+ * already holds every doc its source names; an `ERIS_DIR` or any other `home` is
301
+ * the caller's say, and a doc that neither root holds keeps `home`.
302
+ */
303
+ export function docsRoot(home: string, rel: string): string {
304
+ if (isCompiled || home !== installRoot()) return home;
305
+ return firstDocRoot([home, checkoutRoot()], rel) ?? home;
306
+ }