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