@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,382 @@
1
+ # Writing a plugin
2
+
3
+ A plugin adds a verb group to eris without touching core. It is a package that
4
+ exports **registry rows plus handlers**. The rows are pure
5
+ data and the handlers are loaded lazily. A plugin talks to core through one
6
+ module, the SDK at `src/cli/plugin.ts`.
7
+
8
+ Plugins compose at **build time**. A build lists its plugins, core merges their
9
+ rows into the registry, and everything that derives from the registry then sees
10
+ them: the router table, the argv parser, the `--output json` allow-list, shell
11
+ completion and `eris --help`. There is no runtime plugin loader.
12
+
13
+ The worked example is `examples/plugins/widgets/`, which `tests/plugins.test.ts`
14
+ composes and drives. Copy it. The shipped plugin `plugins/yak/` (`eris yak`) is
15
+ the full-size one: a dispatcher, a core, and the core's own modules.
16
+
17
+ ## The shape
18
+
19
+ ```
20
+ plugins/<name>/
21
+ index.ts # the manifest: rows + handler thunks (loaded on every command line)
22
+ handlers.ts # the code (loaded only when the group's command runs)
23
+ ```
24
+
25
+ ## The manifest
26
+
27
+ `index.ts` default-exports `definePlugin({ name, rows, handlers, product? })`:
28
+
29
+ ```ts
30
+ import { definePlugin } from "../../src/cli/plugin";
31
+
32
+ export default definePlugin({
33
+ name: "widgets",
34
+ rows: {
35
+ widgets: { impl: "ts", summary: "Ping and count widgets (ping, count)" },
36
+ "widgets ping": { summary: "Reply with a pong", json: true, flags: [{ name: "--name", takesValue: true }] },
37
+ "widgets count": { summary: "Print N widgets", pos: ["n?"], usage: "<n>" },
38
+ },
39
+ handlers: {
40
+ widgets: async () => (await import("./handlers")).runWidgets,
41
+ },
42
+ });
43
+ ```
44
+
45
+ - **`rows`** use the same `Row` shape as core's `src/cli/registry.ts`. Read its
46
+ header for the fields (`flags`, `pos`, `usage`, `json`, `extraHint`). The
47
+ group row (one word) says `impl: "ts"`. Each sub row hangs off a row the
48
+ plugin declares. A value flag whose value is a file name says `file: true`,
49
+ and tab completion offers file names after it. A row's positional slots
50
+ complete what its `complete` lists ([Completion domains](#completion-domains)).
51
+ - **`handlers`** has one thunk per group. The type checker requires a thunk for
52
+ every group the rows declare. A thunk must `await import()` its module.
53
+ - **The manifest makes no other runtime import.** It loads on every command
54
+ line, including the `eris completion` run at shell start. A static import of
55
+ handler code would put a syntax error or a missing package in your plugin on
56
+ every shell start. The `plugin-manifest-lazy-handlers` ast-grep rule enforces
57
+ this. Type-only imports are fine.
58
+
59
+ ## The handler
60
+
61
+ A handler is a `VerbHandler`, `(args, ctx) => Promise<number>`, the same
62
+ signature core's verbs use. `args` is everything after the group word, with the
63
+ global `--output` flag already removed. `ctx.format` is `"text"` or `"json"`.
64
+ Write payload to `ctx.stdout`, write diagnostics to `ctx.stderr`, and return the
65
+ exit code.
66
+
67
+ ```ts
68
+ import { groupArgs, readArgs, type VerbHandler } from "../../src/cli/plugin";
69
+ import plugin from "./index";
70
+
71
+ export const runWidgets: VerbHandler = async (args, ctx) => {
72
+ const g = groupArgs(plugin.rows, "widgets", args);
73
+ if (g.kind === "bare" || g.kind === "help") return (ctx.stdout.write(USAGE), 0);
74
+ if (g.kind === "unknown") return (ctx.stderr.write(`ERROR Unknown widgets command: ${g.sub}\n`), 1);
75
+ const a = readArgs(plugin.rows, "widgets ping", g.rest, ctx);
76
+ if (a === null) return 0; // --help was printed
77
+ ctx.stdout.write(`pong ${a.flags["--name"] ?? "world"}\n`);
78
+ return 0;
79
+ };
80
+ ```
81
+
82
+ - **`readArgs(rows, path, argv, ctx)`** parses against the row. It accepts the
83
+ row's flags and operands and nothing else. Its result is typed from the row,
84
+ so `a.flags["--nmae"]` does not compile. It prints `--help` and returns
85
+ `null`. A refusal is written to stderr and the command stops with exit 1. Never
86
+ parse argv by hand (core's `adhoc-argv-parse` rule explains why).
87
+ - **`groupArgs(rows, group, argv)`** reads a group handler's first word and
88
+ returns one of four kinds: `bare`, `help`, `sub`, or `unknown`.
89
+ - Follow the output contract ([OUTPUT-CONTRACT.md](../OUTPUT-CONTRACT.md)):
90
+ stdout carries the payload only, a failure is a slugged block from
91
+ `renderFailureBlock`, and a warning is `renderWarnLines` (both re-exported by
92
+ the SDK, with `erisHome`, the install root a block's `docs:` path resolves under).
93
+ - Read an environment variable whose name you were handed with the SDK's
94
+ **`readEnv(env, name)`**, never `env[name]`: an inherited name such as
95
+ `__proto__` would read as set (`env-read-own-property-plugin`).
96
+
97
+ ## What a plugin may import
98
+
99
+ The SDK, `src/cli/plugin.ts`, plus the plugin's own files (reached with `./`),
100
+ packages, and `node:` builtins. Nothing else is allowed: not `src/` or `ts/`,
101
+ not a root-level core file, and not another plugin or
102
+ `plugins/index.ts`. Those are core internals or neighbours that can change
103
+ under you, and the `plugin-imports-sdk-only` rule enforces the boundary for
104
+ static imports, re-exports, `import x = require()`, `require()` and dynamic
105
+ `import()` (quoted or template literal). The rule reads only the specifier, so
106
+ any `..` climb that does not land on the SDK is an error, as is a specifier
107
+ starting at `src/`, `ts/` or `/`. Keep a plugin's files importing each other
108
+ with `./`; subfolders of any name (`./src/helper`) are the plugin's own. If a plugin needs something from
109
+ core, export it from the SDK on purpose.
110
+
111
+ ## `--output json`
112
+
113
+ The allow-list fails closed and is built from your rows. A sub accepts
114
+ `--output json` only if its own row says `json: true`. Every other command is
115
+ refused with `output-json-unsupported`, and the refusal's fix text gains a line
116
+ for each of your json rows, as does the `--output` paragraph of `eris --help`.
117
+ Those fix lines are generated from the row summaries. To describe the JSON shape
118
+ instead, as core's lines do, set `jsonFix` on the plugin: your own lines, each
119
+ ` eris <verb>` padded to column 43 with the shape after it, and each ending in a
120
+ newline (plugins/yak/index.ts sets one).
121
+ `json: true` on a group that has subs is refused at
122
+ composition: it would allow subs that have no machine rendering.
123
+
124
+ ## Entitlements
125
+
126
+ A plugin with no `product` is available to every workspace. Set
127
+ `product: "<name>"` and its groups are **locked** for any workspace whose
128
+ entitlements do not include that product:
129
+
130
+ - `eris --help` marks the group `[locked: <product>]`;
131
+ - `eris <group> ... --help` prints the row's help plus a `Locked:` line, exit 0;
132
+ - anything else prints the `command-locked` block ([ERRORS.md](../ERRORS.md#command-locked)), exit 1.
133
+
134
+ The handler never loads while the group is locked. Entitlements reach `main`
135
+ as `MainOptions.entitlements`. When none are given, `NO_ENTITLEMENTS` applies
136
+ and no product is entitled.
137
+
138
+ ## Completion domains
139
+
140
+ A row's `complete` lists what tab completion offers for each positional slot,
141
+ in order. A slot's words are literals, `@file` (file names, and nothing else),
142
+ or a **completion domain**: words only a shell function can list at tab-press
143
+ time, such as names the plugin keeps in a local file.
144
+
145
+ ```ts
146
+ import { type CompletionDomain, definePlugin } from "../../src/cli/plugin";
147
+
148
+ /** The names to greet; the tone typed in the slot before picks their case. */
149
+ const NAME: CompletionDomain = {
150
+ domain: "@demo_name",
151
+ helper: `__eris_dom_demo_name() {
152
+ case "\${1:-}" in
153
+ loud) echo "ADA GRACE" ;;
154
+ *) echo "ada grace" ;;
155
+ esac
156
+ }
157
+ `,
158
+ };
159
+
160
+ // rows:
161
+ "demo hello": { summary: "Greet a name", pos: ["tone", "name?"], complete: [["loud", "quiet"], [NAME]] },
162
+ ```
163
+
164
+ The helper is shell text that `eris completion` writes into the script every
165
+ shell sources at start, once per distinct text however many rows use it:
166
+
167
+ - It is exactly one function definition, `__eris_dom_<name>() {` for
168
+ `@<name>`, starting at the text's first character and closing at its final
169
+ `}`, then a newline. Nothing sits before or after it, so sourcing the script
170
+ defines it and runs nothing. Functions it calls go in `library`, a list of
171
+ texts held to the same rule, each defining one `__eris_dom_…` function.
172
+ Domains that share a library hand over the same texts, emitted once.
173
+ - The function is called with the words typed in the earlier positional slots
174
+ (`$1` is slot 0's) and prints its words. `compgen -W` expands what it prints,
175
+ so print shell-safe words only. Like the rest of the script it never runs
176
+ eris and never reaches the network, and it stays silent when it has nothing.
177
+ - It is bash-3.2 clean (macOS `/bin/bash`); zsh loads the same body through
178
+ `bashcompinit`. The check does not model shell quoting: it accepts a subset
179
+ whose framing both shells agree on and refuses the rest, even when it is
180
+ valid shell. Printable ASCII only; no comment (keep it in the TypeScript
181
+ beside the text), backtick, heredoc or here-string, `$'…'`, `$"…"`, `$[…]`
182
+ or nested function; a backslash only inside quotes and before a letter or
183
+ digit, so no line continuation; `${ }` holds a name and its operators only;
184
+ `{` as a word only in command position and `}` only after `;` or a newline,
185
+ closing a group; no `case` and no unbalanced parenthesis in a quote inside
186
+ `$( )`.
187
+ - One domain has one helper and library across a build. Two plugins may hand
188
+ over the same domain with the same texts, which are emitted once. A function
189
+ defined by two different texts is refused.
190
+
191
+ Composition checks all of this (`src/cli/commands/completion/domain.ts`) and
192
+ refuses with the line at fault. The helper is trusted like a plugin's handler
193
+ code, and the check is not a sandbox: it stops a mistake from breaking, or
194
+ running code in, every shell that loads completion. Never build helper text
195
+ from anything read at runtime.
196
+
197
+ ## Composition and what it refuses
198
+
199
+ `composeCli(coreRows, coreHandlers, plugins, { menu?, completionNote? })`
200
+ (`src/cli/compose.ts`) merges the rows and throws `PluginCompositionError` on:
201
+
202
+ - a group that collides with a built-in, an argv shape (`-h`), or another plugin's group;
203
+ - a group that is not `impl: "ts"`, or a sub row with another impl (a JavaScript plugin can name one the type does not admit);
204
+ - a sub row whose parent the plugin does not declare;
205
+ - a group with no handler, or a handler with no group;
206
+ - `json: true` on a group with subs;
207
+ - a completion slot word that is not shell-safe, or a completion domain whose
208
+ name, helper or library fails the rules above;
209
+ - the same plugin name composed twice;
210
+ - a `completionNote` that is not one or more non-empty lines of printable ASCII.
211
+
212
+ `menu`, when given, is the handler for the bare program (`eris` with no
213
+ arguments) in place of core's usage; `-h` and `--help` stay the usage, and end
214
+ with `Run 'eris' without arguments for interactive mode.` A build without a
215
+ `menu` prints neither the line nor a menu.
216
+
217
+ `completionNote`, when given, is a paragraph `eris completion --help` prints
218
+ after core's description, before the pointer to the docs: say what the
219
+ distribution's completion domains offer (core's text only knows its own flags
220
+ and file names). Without it the help is core's text, byte for byte.
221
+
222
+ Composition runs in `build()` before bundling, so a refused plugin fails the
223
+ build. It also runs when the program starts, and in every test that imports
224
+ `src/main.ts`.
225
+
226
+ ## Adding a plugin to a build
227
+
228
+ This build's list is `plugins/index.ts`:
229
+
230
+ ```ts
231
+ import widgets from "./widgets";
232
+ export const PLUGINS: readonly ErisPlugin[] = [widgets];
233
+ ```
234
+
235
+ That is the only edit, and nothing under `src/` changes. A different plugin set
236
+ is a distribution.
237
+
238
+ ## Distributions
239
+
240
+ A distribution is another repository that ships its own eris binary: core, this
241
+ build's plugins, and plugins of its own. The worked example is
242
+ `examples/distribution/`, which `tests/distribution.test.ts` composes, builds
243
+ and runs. Copy its shape.
244
+
245
+ 1. **Depend on this package** as a git dependency pinned to a merge commit on
246
+ `main`, never a branch:
247
+
248
+ ```json
249
+ "dependencies": { "eris": "github:sibylline-labs/eris#<merge commit sha>" }
250
+ ```
251
+
252
+ 2. **Import core only as `eris/sdk`** (`src/sdk.ts`): the plugin SDK, core's
253
+ rows and handlers, this build's plugin list, `composeCli`, `runProgram`,
254
+ `main`, the argv and output helpers (`parseGlobal` and `GlobalFlagError`
255
+ included, for an entry that runs outside the router), the runtime
256
+ (subprocesses, the bounded pool `runPool`, logging, the install root,
257
+ `BUILD_ID`, `releaseRepo()`, the `DistributionIdentity` type) and `build`. The package's
258
+ `exports` map refuses any deeper path (`eris/src/...`), so a core refactor
259
+ never breaks a distribution that compiles today. A distribution's plugin
260
+ manifests import `eris/sdk` too, and load handler code lazily as above.
261
+ The `distribution-imports-sdk-only` rule holds this for the example; a
262
+ distribution keeps its own copy for its tree. The `exports` map points at
263
+ TypeScript, so the distribution's own `tsc` checks core's source too; it needs
264
+ `"moduleResolution": "Bundler"` and `bun-types`, and no `files` entry for core's
265
+ declarations (`src/runtime/home.ts` references the `eris:assets` one, the module
266
+ `build()` supplies at bundle time). `tests/distribution.test.ts` typechecks the
267
+ example under a tsconfig that lists only its sources.
268
+
269
+ 3. **Compose and run** from its entry:
270
+
271
+ ```ts
272
+ import { join } from "node:path";
273
+ import { composeCli, type DistributionIdentity, HANDLERS, PLUGINS, REGISTRY, runProgram } from "eris/sdk";
274
+ import mine from "./plugins/mine";
275
+
276
+ export const IDENTITY: DistributionIdentity = {
277
+ releaseRepo: "my-org/my-eris",
278
+ versionExtra: "core <pinned sha>",
279
+ docsBase: "https://github.com/my-org/my-eris/blob/main",
280
+ };
281
+
282
+ export const CLI = composeCli(REGISTRY, HANDLERS, [...PLUGINS, mine], {
283
+ menu: async () => (await import("./menu")).runMenu,
284
+ completionNote: "Also completes mine's names.",
285
+ });
286
+
287
+ if (import.meta.main) runProgram({ cli: CLI, installRoot: join(import.meta.dir, ".."), identity: IDENTITY });
288
+ ```
289
+
290
+ `installRoot` is the distribution's root, an absolute path: the directory
291
+ `build()` takes as `root`, and (for an entry in `src/`) the one above it. Run
292
+ from source, a command's `ctx.erisDir` is that directory, and a file the binary
293
+ carries as an asset is read from the same relative path. The local `docs:` line
294
+ of a failure block names its doc under that directory when it holds the doc,
295
+ else under this package's (`docRoots`' order), so the line names a file that
296
+ exists, as it does in the binary. Left out, `ctx.erisDir` is this package's
297
+ directory, which in a dependent repository is `node_modules/eris`, not the
298
+ distribution. A compiled binary ignores it (its root is its per-build asset
299
+ home), and the `_ERIS_SHIM_DIR` a distribution's own shim sets wins over it.
300
+
301
+ `identity` is who the distribution is: `releaseRepo`, `versionExtra` and
302
+ `docsBase`, the same three fields `build()` takes (step 4). Run from source,
303
+ `eris --version`, `eris update`, `releaseRepo()` and a failure block's second
304
+ `docs:` line report them in place of this repository's; left out, a run from
305
+ source says it is public eris and links public docs, which hold none of the
306
+ distribution's slugs. Keep the values in one module that both the entry and
307
+ the build import (the example's `IDENTITY`), so the two cannot drift.
308
+ `runProgram` checks them as `build()` does. **Precedence:** a compiled binary
309
+ reports the identity its build baked and ignores `identity`, so what a binary
310
+ says it is, and where it updates from, is fixed when it is built; no
311
+ environment variable moves either, in a binary or from source.
312
+
313
+ 4. **Build** with `build()` from `eris/sdk` (`src/build.ts` documents what the
314
+ binary carries):
315
+
316
+ | Option | Meaning |
317
+ |---|---|
318
+ | `entry` | The entry above. Required. |
319
+ | `root` | The distribution's root, which the paths below are relative to, and the `installRoot` its entry hands `runProgram`. Default: the nearest directory with a `package.json` at or above the entry's. |
320
+ | `plugins` | The plugin list the entry composes, composed again before bundling so a refused plugin fails the build. |
321
+ | `assetRoots` | Files, and directories of files, the binary carries and writes to its asset home, at the same relative paths. |
322
+ | `docRoots` | Where a `docs/*.md` the source names is looked for, in order. Default: the root, then this package. |
323
+ | `sourceRoots` | What is hashed into the build id and scanned for docs. Default: `src`, `ts` and `plugins` where present, the entry, `package.json` and the lockfile. This package's source is always added. |
324
+ | `stubs` | `{ match, reason }`: specifiers bundled as a module that throws `<specifier> is not bundled into the compiled eris binary (<reason>)` if evaluated, for a module the binary must not carry. |
325
+ | `releaseRepo` | `owner/name` the binary's own releases are published to, reported by `releaseRepo()` and `eris --version` and read by the release `eris update`. Default: `sibylline-labs/eris`. |
326
+ | `versionExtra` | One printable line `eris --version` prints after the version line, and `extra` in its JSON record: the core a distribution was built on. Default: none. |
327
+ | `docsBase` | An https URL (no query, fragment or space) the doc paths in a failure block's second `docs:` line are appended to, so a distribution links its own docs. Default: this repository's public docs. |
328
+ | `outfile`, `target`, `tag` | The output path (default `dist/eris-<os>-<arch>`), a bun compile target (default: the host), and a release tag: prefixed to the build id, printed by `eris --version` and compared by the release `eris update` (an untagged build reports `dev`). |
329
+
330
+ `eris --version` and `eris update` are core's: a distribution gets both. Core's `eris update`
331
+ only prints the npm commands for this package. A distribution that publishes compiled
332
+ binaries composes the in-place verb, `RELEASE_UPDATE` from `eris/sdk`, in its place:
333
+
334
+ ```ts
335
+ composeCli({ ...REGISTRY, update: RELEASE_UPDATE.row }, { ...HANDLERS, update: RELEASE_UPDATE.handler }, plugins)
336
+ ```
337
+
338
+ Its binary then updates from its own `releaseRepo`, never from this repository's
339
+ releases ([UPDATE.md](../UPDATE.md#the-release-eris-update-distributions)). To be updatable
340
+ it publishes each release in the shape [RELEASING.md](RELEASING.md#a-distributions-releases)
341
+ describes. The example distribution does this.
342
+
343
+ 5. **Test** by driving `main(argv, env, { cli })` with the distribution's
344
+ composition, and by running the binary `build()` writes
345
+ ([below](#testing-a-distribution)).
346
+
347
+ ### Starting without node_modules
348
+
349
+ When a package a verb needs is not installed, core ends the command with the
350
+ `eris-deps-not-installed` block, naming the distribution's `installRoot` in its
351
+ `fix:` line. The SDK does not export that renderer (`missingDependencyBlock`): an
352
+ export would sit in `eris/sdk`, which cannot load when `eris` itself is the missing
353
+ package, the one case a distribution's entry must handle before it has loaded
354
+ anything. Such an entry loads its composition with a dynamic `import()`, and writes
355
+ the block by hand for a package that import cannot resolve (the grammar is
356
+ [OUTPUT-CONTRACT.md](../OUTPUT-CONTRACT.md) §4, the slug's text
357
+ [ERRORS.md](../ERRORS.md#eris-deps-not-installed)), leaving every other failure a crash.
358
+
359
+ ### Testing a distribution
360
+
361
+ Two seams: `main(argv, env, { cli })` with the distribution's composition, which
362
+ every command line crosses, and the binary `build()` writes, driven as a
363
+ subprocess. A distribution's suites import their helpers from **`eris/sdk/testing`**
364
+ rather than copying them:
365
+
366
+ | Export | Use |
367
+ |---|---|
368
+ | `createFakeRunner(respond)`, `FakeRunner` | A `Runner` (`eris/sdk`) that records every call in `calls` and answers from `respond`, to inject in place of `nodeRunner`. |
369
+ | `buildInChild({ module, outfile, tag?, releaseRepo?, versionExtra?, docsBase? })` | `build()` in a child bun, returning its `BuildResult`. `module` is the absolute path of a file that exports `BUILD`, the distribution's `BuildOptions` (`examples/distribution/build.ts` is one); the rest overrides it. Use it for every test that compiles a binary: a compile in the test process fails on Linux in a later test file ([TESTING.md](TESTING.md#the-binary-oracle)). |
370
+
371
+ The subpath is for tests. It is a separate module and `eris/sdk` does not export
372
+ it, so a binary does not carry it: keep it out of the distribution's own source
373
+ (`distribution-imports-sdk-only` refuses it in the example, and this repository's
374
+ `runtime-imports-no-testing` rule keeps core and its plugins from importing it).
375
+
376
+ ## Testing a plugin
377
+
378
+ Compose it in the test and drive `main` with `{ cli }`. This is the seam every
379
+ command line crosses. `tests/plugins.test.ts` is the reference: routing, argv
380
+ refusal, json, completion, help and locks, all through `main`. Add a
381
+ `docs/ERRORS.md` heading for each slug a shipped plugin raises
382
+ (`tests/errors-doc.test.ts` harvests `plugins/`).
@@ -0,0 +1,157 @@
1
+ # Releasing eris
2
+
3
+ A release is a tag. Pushing `vMAJOR.MINOR.PATCH` (or `vMAJOR.MINOR.PATCH-prerelease`)
4
+ runs `.github/workflows/release.yml`, which builds a binary for each platform,
5
+ tests it against the whole suite, publishes it to a GitHub release, and then
6
+ publishes the npm package, `@sibylline-labs/eris` ([below](#the-npm-package)).
7
+ npm is how eris is installed ([UPDATE.md](../UPDATE.md)); the GitHub release is the
8
+ record the npm job waits on, in the shape a distribution's release `eris update`
9
+ reads.
10
+
11
+ ## The release's shape
12
+
13
+ A release carries exactly these assets, and a release `eris update` depends on the names:
14
+
15
+ | Asset | What it is |
16
+ |---|---|
17
+ | `eris-linux-x64` | The compiled binary for Linux on x86-64, built on a Linux runner |
18
+ | `eris-darwin-arm64` | The compiled binary for macOS on Apple Silicon, built on a macOS runner |
19
+ | `SHA256SUMS` | One `<sha256> <asset name>` line per binary, as `sha256sum` writes it |
20
+
21
+ The tag is the binary's version: the workflow builds with
22
+ `bun scripts/build.ts --tag <tag>`, so `eris --version` prints it. The workflow
23
+ refuses a binary that does not report its tag, and a release `eris update` refuses
24
+ to keep one that does not.
25
+
26
+ ## Cutting a release
27
+
28
+ ```bash
29
+ git tag v0.2.0 && git push origin v0.2.0
30
+ ```
31
+
32
+ A tag is `vMAJOR.MINOR.PATCH[-prerelease]`: numbers of at most nine digits, and
33
+ dot-separated prerelease identifiers that are not empty (`RELEASE_TAG` in
34
+ `src/cli/commands/update/semver.ts`, the grammar a release `eris update` orders releases by).
35
+ The release job refuses any other tag before it builds.
36
+
37
+ A tag with a hyphen (`v0.3.0-rc.1`) is a pre-release. GitHub's "latest release"
38
+ skips pre-releases, and npm publishes them under the `next` dist-tag, so
39
+ `@sibylline-labs/eris@latest` never resolves to one; name it to run it
40
+ (`npx @sibylline-labs/eris@0.3.0-rc.1`).
41
+
42
+ To release a ref that is not tagged yet, or to rebuild, dispatch the workflow:
43
+
44
+ ```bash
45
+ gh workflow run release.yml --ref <branch> -f tag=v0.3.0-rc.1 -f prerelease=true
46
+ ```
47
+
48
+ Dispatching over a tag that already exists is refused unless the run builds that
49
+ tag's commit (`--ref <tag>`), so binaries from one commit are never published
50
+ under a tag that names another.
51
+
52
+ ## What the workflow does
53
+
54
+ 1. **plan** validates the tag against `^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$`
55
+ and the existing-tag rule above, and decides pre-release or not.
56
+ 2. **build**, once per platform on a native runner (a bun compile targets its host):
57
+ `bun scripts/build.ts --tag <tag>`, then the whole suite against the binary
58
+ (`ERIS_BIN=<binary> bun test ./tests`), then a check that `eris --version` starts
59
+ with `eris <tag> (`, then the upload. A red leg publishes nothing.
60
+ 3. **publish**, the only job with `contents: write`, writes `SHA256SUMS` over both
61
+ binaries and creates the release with all three assets in one step (so "latest"
62
+ never names a release without them). Re-running updates the assets in place.
63
+ 4. **npm** waits for **build** and **publish** (so a red oracle leg or a failed GitHub
64
+ release publishes no package), stamps the
65
+ tag's version into `package.json`, checks the tarball with `npm publish --dry-run`,
66
+ and publishes it through npm trusted publishing (below).
67
+
68
+ No job reads a secret, no job but **npm** holds `id-token: write`, and every action is
69
+ pinned to a commit SHA; `tests/workflows.test.ts` holds all three, and the permissions.
70
+
71
+ ## The npm package
72
+
73
+ `@sibylline-labs/eris` is this repository's source, run by bun, not a build: its `bin` is
74
+ `bin/eris.ts` and its `exports` name `.ts` files ([ARCHITECTURE.md](../ARCHITECTURE.md#the-packages-bin)).
75
+ `files` in `package.json` is the allow-list of what the tarball holds; `tests/package.test.ts`
76
+ reads `npm pack --dry-run --json` and fails when an `exports` or `bin` target is missing, when the
77
+ suite, CI workflows or scratch data are in, or when `docs/` or the license files are out (a failure
78
+ block's `docs:` line names a file under the package root). The version in `package.json` is
79
+ `0.0.0` in the repository; the release job stamps the tag's (`v0.3.0` is `0.3.0`) into the
80
+ checkout it publishes from, and commits nothing. A pre-release publishes under the `next`
81
+ dist-tag, so `npm install @sibylline-labs/eris` never picks it up.
82
+
83
+ Every pull request runs the `npm-pack` job of `ci.yml`: `npm pack`, `npm publish --dry-run`, then
84
+ the tarball installed with `npm install -g` and with `bun add -g` and run from a directory that
85
+ holds no checkout (`eris --help`; a cwd `bunfig.toml` preload must not run), and a consumer that
86
+ declares the dependency under the key `eris` and imports `eris/sdk`, `eris/yak-contract` and
87
+ `eris/yak-redact`. It
88
+ reads no secret and publishes nothing.
89
+
90
+ The **npm** job holds no npm token. It publishes through
91
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers): with `id-token: write`, npm
92
+ exchanges the job's GitHub OIDC token for a short-lived publish token, and the registry accepts it
93
+ only from the repository and workflow file the package names as its trusted publisher. A trusted
94
+ publish also attaches a provenance attestation, with no `--provenance` flag. What it needs, each
95
+ held by `tests/workflows.test.ts` or the job itself:
96
+
97
+ - a GitHub-hosted runner (`ubuntu-latest`): npm refuses the exchange from a self-hosted one;
98
+ - Node 24 from `actions/setup-node` (every Node 22 bundles npm 10) and npm 11.5.1 or later, which
99
+ a step checks before publishing, since an older npm fails the exchange as `ENEEDAUTH`;
100
+ - no `registry-url` on setup-node and no `.npmrc` token: the publish is `npm publish --access
101
+ public --ignore-scripts` and nothing else;
102
+ - `repository.url` in `package.json` naming this repository. npm normalizes
103
+ `https://github.com/sibylline-labs/eris` to `git+https://github.com/sibylline-labs/eris.git`, so
104
+ the manifest holds that form (`tests/package.test.ts`); a fork must change it to publish.
105
+
106
+ The OIDC token can be minted by any step of the job, so no step runs repository code: every npm
107
+ command carries `--ignore-scripts`, and no step runs bun or node.
108
+
109
+ Setup, once:
110
+
111
+ 1. **Bootstrap publish.** A trusted publisher is configured on a package that exists, so the
112
+ first version goes up by hand, from a clean checkout at a release tag, by a maintainer of the
113
+ `@sibylline-labs` org with 2FA:
114
+
115
+ ```bash
116
+ git checkout v0.3.0 # the tag the release job published to GitHub
117
+ npm version 0.3.0 --no-git-tag-version --ignore-scripts
118
+ npm publish --access public --ignore-scripts
119
+ git checkout package.json
120
+ ```
121
+
122
+ That publish is token-authenticated and carries no provenance; every later one comes from the
123
+ release job.
124
+ 2. **Configure the trusted publisher** at npmjs.com → `@sibylline-labs/eris` → Settings → Trusted
125
+ publishing → GitHub Actions: organization `sibylline-labs`, repository `eris`, workflow
126
+ filename `release.yml`, no environment. Under allowed actions, allow `npm publish`: a
127
+ configuration made after Sep-03-2026 allows only `npm stage publish` by default, and the job
128
+ runs `npm publish`. Every field is case-sensitive and npm checks none of them when you save.
129
+ 3. **Publish within two days.** A new configuration expires unless a publish through it succeeds
130
+ within two days of its creation. Cut the next tag, or re-run the **npm** job of a release whose
131
+ version is not yet on the registry.
132
+ 4. **Lock it down.** Once a trusted publish has succeeded, set Settings → Publishing access to
133
+ "Require two-factor authentication and disallow tokens", and revoke any automation token.
134
+
135
+ A re-run of the job for a version already on the registry says so and passes.
136
+
137
+ What an npm install does not have: the `eris-bun-missing` block (bun not on `PATH` is `env`'s
138
+ own message, since the shim is not what starts it). Its `eris --version` reports the stamped
139
+ version as `v<version>` (`src/runtime/home.ts`, `versionOf`), and its `eris update` prints the
140
+ npx / bunx / `bun add -g` commands ([UPDATE.md](../UPDATE.md)).
141
+
142
+ ## A distribution's releases
143
+
144
+ A distribution compiled with `build({ releaseRepo: "<owner>/<name>" })` that composes
145
+ `RELEASE_UPDATE` ([PLUGINS.md](PLUGINS.md#distributions)) updates itself from that repository ([UPDATE.md](../UPDATE.md#which-repository)). To be updatable it
146
+ publishes, for each release, the same assets under the same names (`eris-<os>-<arch>`
147
+ for the platforms it ships, and `SHA256SUMS`), built with `build({ tag })` so each
148
+ binary reports its tag. A private repository works with `GH_TOKEN` set, which also
149
+ raises the anonymous rate limit.
150
+
151
+ ## Checking a release by hand
152
+
153
+ ```bash
154
+ gh release download v0.2.0 --repo <owner>/eris --pattern 'SHA256SUMS' --pattern 'eris-linux-x64'
155
+ sha256sum -c --ignore-missing SHA256SUMS # shasum -a 256 -c on macOS
156
+ chmod +x eris-linux-x64 && ./eris-linux-x64 --version
157
+ ```
@@ -0,0 +1,84 @@
1
+ # Testing Eris
2
+
3
+ One suite: `bun test` over `tests/*.test.ts`, run beside `bunx tsc --noEmit`,
4
+ `ast-grep scan` and shellcheck ([DEVELOPMENT.md](DEVELOPMENT.md#setup)).
5
+
6
+ ## Running
7
+
8
+ ```bash
9
+ bun test ./tests # the whole suite
10
+ bun test tests/completion.test.ts # one file
11
+ ```
12
+
13
+ - **Do not run as root.** Several tests assert a permission-based refusal.
14
+ - **`tests/required-tools.txt` is the tool surface** (`bun`, `npm`, `zsh`, `git`,
15
+ `script`). `describe.skipIf(!HAVE_X)` reports green when X is absent, so
16
+ `tests/repo-hygiene.test.ts` fails a run missing a listed tool and fails a skip
17
+ guard on an unlisted one. Add a tool there in the commit that adds its guard.
18
+
19
+ ## The binary oracle
20
+
21
+ ```bash
22
+ bun scripts/build.ts # dist/eris-<os>-<arch>, host target only
23
+ ERIS_BIN=$PWD/dist/eris-darwin-arm64 bun test ./tests # every suite; the black-box ones drive the binary
24
+ ```
25
+
26
+ `ERIS_BIN` swaps `ERIS` in `tests/helpers/blackbox.ts` for the compiled binary, and
27
+ every binary process in the run shares one asset cache (`.scratch/oracle-cache`), so the
28
+ race-safe first-run write is exercised by the suites' own parallelism. A test about the
29
+ checkout itself (the shim, a copy of the tree, the shell surface) is guarded by
30
+ `checkoutOnly("<reason>")` and skips; a test of the binary's own behaviour by
31
+ `binaryOnly("<reason>")` (`tests/compiled-binary.test.ts`). `rg 'checkoutOnly\(' tests` is the
32
+ skip list. A black-box test that names the install root (a `docs:` line) asks
33
+ `installRoot()`, never `REPO`.
34
+
35
+ A suite that needs a binary of its own compiles it with `buildInChild` from
36
+ `eris/sdk/testing` (`testing/build-child.ts`, which a distribution's suites use too),
37
+ one child bun per build, never `build()` in the test process: on Linux a compile in a
38
+ later test file fails reading a module an earlier file's compile read. It takes the
39
+ file of a build, a module exporting `BUILD` (`scripts/build.ts` for this repository's
40
+ binary, `examples/distribution/build.ts` for the fixture). A `build()` refusal that
41
+ throws before bundling stays in-process.
42
+
43
+ ## Kinds of test
44
+
45
+ | Kind | How | Example |
46
+ |---|---|---|
47
+ | Black-box CLI | the real `eris` entrypoint as a subprocess, through `tests/helpers/blackbox.ts`: stdout and stderr captured separately, files and modes left on disk, PATH-fake argv | `tests/output-contract.test.ts`, `tests/exit-codes-cli.test.ts` |
48
+ | In-process | `main` driven with a composed CLI, the seam every command line crosses | `tests/plugins.test.ts` |
49
+ | Handler / engine | a handler or a pure module called with injected deps | `tests/yak.test.ts`, `tests/cli-args.test.ts` |
50
+ | Golden | bytes compared to a committed capture, often of the deleted Bash | `tests/eris-fail.test.ts` against `tests/helpers/frozen-oracle.ts` |
51
+ | Policy | a scan over the tree | `tests/source-is-text.test.ts` (FREEZE, the banned quadratic idiom), `tests/compile-safe.test.ts`, `tests/errors-doc.test.ts` |
52
+
53
+ `tests/helpers/frozen-oracle.ts` answers for the earlier Bash implementation from
54
+ `tests/fixtures/yaml-parity/oracle-goldens.json`. A call with no recorded answer
55
+ throws; it never falls back to a live tool.
56
+
57
+ ## Fakes
58
+
59
+ - **Network is a loopback fixture**, never a PATH `curl` fake: the TS verbs use
60
+ `fetch()`, which a PATH shim cannot intercept. The sandbox strips the caller's
61
+ Yaks credentials, so a sandboxed `eris yak` never files a real report.
62
+ `tests/helpers/release-fixture.ts` is GitHub's releases API on two real listeners
63
+ (the API, and a second origin standing in for asset storage that the API redirects
64
+ to), recording every request's `Authorization`; `eris update`'s tests drive it, with
65
+ `ERIS_UPDATE_API` pointing at it.
66
+ - **External binaries are PATH executables**, never in-process fakes: the system under test
67
+ is a separate process. To test "not installed", hide the real binary
68
+ (`hideReal`, `tests/helpers/blackbox.ts`) and assert it no longer resolves.
69
+ - **Real git repos**: `git init -q` in the sandbox, and resolve paths physically
70
+ (macOS `$TMPDIR` is under `/var` → `/private/var`).
71
+
72
+ ## Writing a test
73
+
74
+ - **Red first.** A regression test fails on the broken code, not merely passes on
75
+ the fixed code.
76
+ - **Name the failure mode.** The leading comment says what production failure
77
+ turns it red.
78
+ - **Pair every absence assertion with a positive one** about the same output;
79
+ "the secret does not appear" passes when the path never ran.
80
+ - **Pin a PATH-dependent branch** instead of inheriting whichever one the machine
81
+ selects.
82
+ - **Never hit the network.**
83
+
84
+ New command? [ADDING-A-COMMAND.md](ADDING-A-COMMAND.md) says where its tests go.