@olivervea/pipelines 2.0.0-beta.2 → 2.0.0-beta.6

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @olivervea/pipelines
2
2
 
3
- The [Olve.Pipelines](https://github.com/OliverVea/Olve.Pipelines) v2 SDK, imported as `@olve/pipelines`. It has four parts:
3
+ The [Olve.Pipelines](https://github.com/OliverVea/Olve.Pipelines) v2 SDK. It has four parts:
4
4
 
5
5
  - the **authoring API**: models, steps, handlers and the typed pipeline builder;
6
6
  - the **step harness**: the runtime compiled into each pipeline's single executable (`describe`, `run`, `map-trigger`);
@@ -12,14 +12,14 @@ The manifest format is in [`manifest.schema.json`](manifest.schema.json), genera
12
12
 
13
13
  ```
14
14
  sdk/
15
- src/index.ts authoring API (`@olve/pipelines`)
16
- src/core/ models, secrets, steps, implement()
15
+ src/index.ts authoring API (`@olivervea/pipelines`)
16
+ src/core/ models, secrets, config, steps, implement()
17
17
  src/builder/ pipeline builder (types + runtime records), Ref<T>
18
- src/library/ library steps (script, gitHubSource, imageBuild, helmDeployOverSsh), their exported helpers (httpHealthCheck) and SDK models
18
+ src/library/ library steps (script, gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy), their exported helpers (httpHealthCheck, registryTag, npmDistTag) and SDK models
19
19
  src/describe/ describe: manifest, checks, mapper recording + source analysis, manifest schema
20
- src/harness/ harness CLI modes, env contract, envelope, result upload (`@olve/pipelines/harness`)
20
+ src/harness/ harness CLI modes, env contract, envelope, result upload (`@olivervea/pipelines/harness`)
21
21
  src/ctx/ the ctx handed to handlers
22
- src/testing/ testHandler (`@olve/pipelines/testing`)
22
+ src/testing/ testHandler (`@olivervea/pipelines/testing`)
23
23
  src/build/ olve-build
24
24
  src/dev/ olve-dev
25
25
  examples/self-deploy/ fixture pipeline (the self-deploy shape) used by the tests
@@ -30,13 +30,12 @@ sdk/
30
30
 
31
31
  ## Install
32
32
 
33
- The package is `@olivervea/pipelines` on npm; pipelines import it as `@olve/pipelines` through an alias
34
- in the pipeline directory's `package.json`:
33
+ The package is `@olivervea/pipelines` on npm; a pipeline directory's `package.json` depends on it by that name:
35
34
 
36
35
  ```json
37
36
  {
38
37
  "type": "module",
39
- "dependencies": { "@olve/pipelines": "npm:@olivervea/pipelines@2.0.0-beta.1" },
38
+ "dependencies": { "@olivervea/pipelines": "2.0.0-beta.3" },
40
39
  "devDependencies": { "@types/bun": "1.4.3", "typescript": "7.0.2" }
41
40
  }
42
41
  ```
@@ -47,11 +46,9 @@ bunx olve-dev describe .
47
46
  bunx olve-build . --out .olve/out
48
47
  ```
49
48
 
50
- - Keep the alias name: `olve-build` generates imports of `@olve/pipelines/harness`, so an unaliased
51
- `@olivervea/pipelines` dependency does not build.
52
49
  - The package ships TypeScript sources (Bun runs them directly; `harness.sdkHash` hashes them), so the
53
50
  same version gives the same `sdkHash` and handler hashes from npm or from `file:../sdk`.
54
- - Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@olve/pipelines": "file:../sdk"`.
51
+ - Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@olivervea/pipelines": "file:../sdk"`.
55
52
 
56
53
  ### Releases
57
54
 
@@ -67,7 +64,7 @@ A pipeline directory (the binding path, `.pipelines/` by default) looks like thi
67
64
  ```
68
65
  models.ts data models (contract file)
69
66
  pipeline.ts steps + `export default pipeline(…)` (contract file)
70
- handlers/<step-name>.ts one per custom step: `export default implement(step, fn)`
67
+ handlers/*.ts one per custom step: `export default implement(step, fn)` (pairs by the step, not the file name)
71
68
  handlers/lib/… shared helpers (not handlers)
72
69
  scripts/*.sh embedded with `import s from "../scripts/x.sh" with { type: "file" }`
73
70
  ```
@@ -77,7 +74,7 @@ Contract files hold contracts only. Describe evaluates them without loading hand
77
74
  ### Models, secrets, steps
78
75
 
79
76
  ```ts
80
- import { model, z, fileRef, optional, secret, step, steps, Source } from "@olve/pipelines";
77
+ import { model, z, fileRef, optional, secret, step, steps, Source } from "@olivervea/pipelines";
81
78
 
82
79
  export const BuildArtifact = model("BuildArtifact", z.object({
83
80
  version: z.string(),
@@ -111,6 +108,40 @@ Rules:
111
108
  - A **plain-schema input** has no model to match on, so the step needs a mapper.
112
109
  - **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
113
110
 
111
+ ### Config
112
+
113
+ Values that differ per deployment (hosts, namespaces, which identity provider) are **config**: typed entries the pipeline declares, whose values live on the instance (one YAML document per pipeline, edited with `pl config` or the dashboard), not in the repo.
114
+
115
+ ```ts
116
+ import { config, secret, secretRef, step, z } from "@olivervea/pipelines";
117
+
118
+ export const DEX_CLIENT_SECRET = secret("DEX_CLIENT_SECRET", "Dex client secret.");
119
+ export const AUTH = config("auth", "How users authenticate.", z.discriminatedUnion("mode", [
120
+ z.object({ mode: z.literal("none") }),
121
+ z.object({ mode: z.literal("dex"), issuer: z.string().url(), clientSecret: secretRef(DEX_CLIENT_SECRET) }),
122
+ ]));
123
+ export const TARGET = config("target", "Where the release is deployed.",
124
+ z.object({ host: z.string(), namespace: z.string().default("olve-pipelines") }));
125
+
126
+ export const deploy = step({ name: "deploy", description: "Deploys the release.", image: "alpine:3.22",
127
+ config: [AUTH, TARGET], // ctx.config is typed to exactly these
128
+ secrets: [DEX_CLIENT_SECRET] }); // a secretRef's secret must be declared too
129
+ ```
130
+
131
+ ```yaml
132
+ # the instance's config document for this pipeline
133
+ auth: { mode: dex, issuer: https://dex.example.com, clientSecret: { secret: DEX_CLIENT_SECRET } }
134
+ target: { host: deploy@box }
135
+ ```
136
+
137
+ - `config(name, description, schema)` mirrors `secret(...)`. Names match `^[a-z][a-zA-Z0-9]*$` (they are YAML keys).
138
+ - An entry whose schema accepts `undefined` (`.optional()`, `.default(…)`) is optional; every other entry is required. Defaults are applied when config is read, never written into the document.
139
+ - Handlers see `ctx.config` with the output view (defaults applied); a discriminated union narrows as usual.
140
+ - `secretRef(S)` is a field whose value is `{ secret: "<S.name>" }`: it names a secret, never holds its value. The handler reads the value from `ctx.secrets`. A step that receives an entry containing a `secretRef` must declare that secret.
141
+ - Config is per **run**: the run pins a config snapshot at start; continues and step reruns reuse it. Secrets are per attempt.
142
+ - Library steps take **config references** in place of values: `host: TARGET.host`, `target: TARGET.beta`. A reference is a typed proxy (`ConfigRef<T>`, `T` = the field's output type; `Configurable<T> = T | ConfigRef<T>`), so a `number` field or an optional field can't go where a `string` is required. Fields named `kind`, `name`, `description` or `schema` (the entry's own properties) are reached through `$` (`TARGET.$.name`); `TARGET.$` and `TARGET` itself reference the whole entry. The step's `config` gets every referenced entry (`configEntries` in the manifest); `manifestConfig` records each reference as `{"$config": "<entry>.<path>"}` (field names must not contain `.`; literal keys starting with `$` are reserved); `run()` resolves them from `ctx.config` (defaults applied) before doing anything. `describe` and reconcile reject a reference to an entry the step doesn't receive or a field the entry doesn't have.
143
+ - `clusterAccess: true` on a step asks for the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`; manifest `clusterAccess: true`, omitted otherwise). Without the instance setting the pod gets no token, like every step.
144
+
114
145
  **`steps.script` contract** (for the script):
115
146
 
116
147
  | Name | What it holds |
@@ -123,28 +154,43 @@ In the output JSON, an object of the form `{"$file": "<path>"}` or `{"$dir": "<p
123
154
 
124
155
  ### Library steps
125
156
 
126
- Library steps carry their own implementation and image; they take typed config and use the SDK models `Source`, `BuiltImage` and `HelmRelease`. The set grows from real use: a step or an option exists because a pipeline uses it.
157
+ Library steps carry their own implementation and image; they take typed config and use the SDK models `Source`, `BuiltImage`, `PushedImage` and `HelmRelease`. The set grows from real use: a step or an option exists because a pipeline uses it.
127
158
 
128
159
  | Step | Image | Input → output | Config |
129
160
  |---|---|---|---|
130
161
  | `steps.script` | yours | yours | `script` (embedded), `env` (see above) |
131
- | `steps.gitHubSource` | `alpine:3.22` (work happens in the harness) | `{ repo: "owner/name", sha }` (plain schemas: needs a mapper) → `Source` | `token?: Secret` (sent as `Authorization: Bearer`; becomes the step's secret) |
132
- | `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `cacheRepo?` (kaniko layer cache), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default: the homelab pull-through caches, `{}` = off), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2) |
133
- | `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, image: BuiltImage }` → `HelmRelease { release, namespace, image }` | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `healthUrl?` |
134
-
135
- - `gitHubSource` downloads `https://api.github.com/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
136
- - `imageBuild` copies the tree to `/kaniko/build-context` (kaniko wipes other directories between multi-stage builds), builds `Dockerfile` at the tree root with `--no-push --tar-path --digest-file`, and outputs the tarball. `repository` in the output is fully qualified the way a runtime names a loaded image (`x` → `docker.io/library/x`). A **stall watchdog** kills kaniko when it prints nothing for `stallTimeoutSeconds` (a base-image pull can stall on an idle connection at "Unpacking rootfs", and kaniko has no read timeout), failing the attempt; `retries` then starts a fresh attempt. The retry is deliberately a new pod, never a second kaniko in the same container: kaniko unpacks the base image over the container root and does not clean up when killed.
162
+ | `steps.gitHubSource` | `alpine:3.22` (work happens in the harness) | `{ repo: "owner/name", sha }` (plain schemas: needs a mapper) → `Source` | `token?: Secret` (sent as `Authorization: Bearer` when set; unset = anonymous; becomes the step's secret), `apiUrl?` (default `https://api.github.com`) |
163
+ | `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `context?` (dir in the source tree; default the root), `dockerfile?` (relative to the context; default `Dockerfile`), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<context hash>`, skip when the registry has it), `cacheRepo?` (kaniko layer cache), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default: the homelab pull-through caches, `{}` = off), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2), `registryAuth?` (`{ username, password: Secret }`: log in to the `repository` registry; or a config reference to `{ username, password: { secret: NAME } }`, with `NAME` in `secrets`), `insecureRegistry?` (that registry is plain HTTP), `secrets?` (declared secrets a config `registryAuth` may name), `enabled?` (`false` skips the step) |
164
+ | `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, <key>: BuiltImage }` per `images` (`PushedImage` with `fromRegistry`) → `HelmRelease { release, namespace, image }` (`image`: the first image) | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `images?` (input key → values prefix; default `{ image: "image" }`), `fromRegistry?`, `imageRefs?` (`PushedImage` input key → values prefix: set as `<prefix>.repository`/`.tag`/`.digest`, not pulled; works next to tarball `images`), `deployments?` (default `[release]`), `rolloutTimeoutSeconds?` (default 120), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
165
+ | `steps.helmDeployInCluster` (and the `target` form of `helmDeployOverSsh`) | `alpine/helm:3.16.2` (ssh: `alpine:3.22`) | `{ source: Source, <key>: PushedImage }` per `images`, `<key>: optional(PushedImage)` per `imageRefs` → `HelmRelease` (`image`: `repository:tag@digest` of the first image) | `target` (a `select` case's reference to a `helmTarget()` variant: `{ kind: "inCluster", namespace, release, values, set, url? }` for helmDeployInCluster, the same plus `host` with `kind: "ssh"` for helmDeployOverSsh; `url` is where step pods reach the release, default its in-cluster Service URL, and is passed on in `HelmRelease.url`), `sshKey: Secret` (ssh only), `chart`, `inlineValues?` (objects written as values files after the target's), `images?`, `imageRefs?`, `deployments?` (ssh), `rolloutTimeoutSeconds?` (default 120), `healthPath?`; helmDeployInCluster declares `clusterAccess` |
166
+ | `steps.composeDeploy` | `alpine:3.22` (work happens in the harness) | `{ image: PushedImage }` → `HelmRelease { release: <service>, namespace: "compose", image: <repository>@<digest> }` | `target` (a `select` case's reference to the compose variant of `deployTarget()`: `{ kind: "compose", deployerUrl, service, token: { secret }, healthUrl? }`), `token: Secret` (must be the secret the target names; the step's only secret), `timeoutSeconds?` |
167
+
168
+ - `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
169
+ - `imageBuild` copies the tree to `/kaniko/build-context` (kaniko wipes other directories between multi-stage builds), builds `--context=/kaniko/build-context[/<context>] --dockerfile=<that>/<dockerfile>` with `--no-push --tar-path --digest-file`, and outputs the tarball. A missing context or Dockerfile fails the step before kaniko starts. `repository` in the output is fully qualified the way a runtime names a loaded image (`x` → `docker.io/library/x`). A **stall watchdog** kills kaniko when it prints nothing for `stallTimeoutSeconds` (a base-image pull can stall on an idle connection at "Unpacking rootfs", and kaniko has no read timeout), failing the attempt; `retries` then starts a fresh attempt. The retry is deliberately a new pod, never a second kaniko in the same container: kaniko unpacks the base image over the container root and does not clean up when killed.
137
170
  - **Caches never block a build.** `registryMap` defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`: `mcr.microsoft.com` → `mcr-cache.ovea.pro`, `index.docker.io` → `hub-cache.ovea.pro`; kaniko under gVisor stalls pulling from mcr.microsoft.com directly). A map you pass replaces the default; `{}` turns mapping off. Before kaniko starts, every mirror host and the `cacheRepo` registry gets a 5 s `GET https://<host>/v2/` (200 or 401 = up); one that doesn't answer is dropped with a log line (`mcr-cache.ovea.pro unreachable; pulling from mcr.microsoft.com directly`, or no `--cache` for the layer cache). After that, kaniko itself copes: it falls back to the original registry when a mirror can't serve an image's manifest (`RetrieveRemoteImage`; `--skip-default-registry-fallback` is never set), a layer-cache lookup error is a cache miss, a cache push error is a warning, and `--skip-push-permission-check` stops its startup check of the cache repo from exiting. A cache that dies mid-download (after the manifest came from it) fails or stalls the attempt; `retries` starts a fresh one, which probes again.
138
- - `helmDeployOverSsh` streams the image tar into the host's **k3s** containerd (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then `image.repository`/`image.tag`/`image.pullPolicy=Never`, which always win), waits for `kubectl rollout status deploy/<release>`, and with `healthUrl` polls it (5 × 5 s) from the harness.
171
+ - **Registry mode** (`push: true`, for images too big for the step's ~1 GiB scratch space and Garage): kaniko runs without `--no-push`/`--tar-path`, pushes to `repository` (it must start with a registry host; anonymous unless `registryAuth`) and the output is a `PushedImage` with the digest from `--digest-file`. With `contentTag`, the tag is `src-` + the first 12 hex of the sha256 of the context directory's deterministic tar (the bytes `ctx.output.dir` would write), computed from the fetched tree; a `HEAD https://<host>/v2/<path>/manifests/<tag>` (answering a Basic or Bearer challenge: anonymous token, or the `registryAuth` login; an anonymous lookup that is denied counts as "not there") that answers a `Docker-Content-Digest` skips kaniko and outputs that digest. With `registryAuth`, the step declares the password secret and writes `$DOCKER_CONFIG/config.json` (`/kaniko/.docker` in the kaniko image) for that registry before kaniko runs, so pushes, base-image and cache pulls from it are logged in. `insecureRegistry` makes the lookups plain HTTP and adds kaniko `--insecure --insecure-registry=<host>`.
172
+ - `helmDeployOverSsh` gets each image into the host's **k3s** containerd: a `BuiltImage` tar is streamed in (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), a `PushedImage` (`fromRegistry`) is pulled there (`… pull -q`, so the rollout doesn't wait on the pull). It copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then per image `<prefix>.repository`/`.tag`/`.pullPolicy` — `Never` for tarballs, `IfNotPresent` for registry images — which always win), waits for `kubectl rollout status` of each of `deployments` in order (`deploy/<name>` for a bare name), and with `healthUrl` polls it (5 × 5 s unless given) from the harness. Several images (one chart, two Deployments):
173
+
174
+ ```ts
175
+ steps.helmDeployOverSsh({ name: "deploy-beta", description: "", host: "oliver@bulwark-m2", sshKey: SSH_PRIVATE_KEY,
176
+ release: "olve-slimerepublics", namespace: "apps-beta", chart: "helm",
177
+ images: { server: "image", web: "web.image" }, deployments: ["olve-slimerepublics-server", "olve-slimerepublics-web"] });
178
+ ```
179
+
180
+ - **Deploy targets.** `helmTarget(defaults?, extend?)` is the `inCluster | ssh` union. `deployTarget({ namespace?, release?, extend?, composeToken, optional? })` adds `compose` (`{ deployerUrl, service, token: secretRef(composeToken), healthUrl?, …extend }`) and, with `optional: true`, `none` (`{ kind: "none" }`, no other fields; its `select` case usually has no steps). A library step that references a target narrowed to one case needs only that case's secrets: the helm steps don't declare the compose `token`.
181
+ - `composeDeploy` resolves its target, checks `kind` is `compose` and that `token.secret` is the declared secret, then `POST <deployerUrl>/v1/deploy` `{ service, image: "<repository>@<digest>" }` with `Authorization: Bearer <token>`. Each line of the answer's `log` is logged as `deployer: …`; anything but HTTP 2xx with `status: "deployed"` fails the step with the deployer's status and message. With `healthUrl` it then runs `httpHealthCheck` (12 × 5 s). The deployer itself (`compose/deployer`) and its API: [docs/setup/compose-deploy.md](../docs/setup/compose-deploy.md).
139
182
 
140
183
  **Helpers.** General functions the library steps are built on, exported for custom handlers:
141
184
 
142
185
  | Helper | What it does |
143
186
  |---|---|
144
187
  | `httpHealthCheck(ctx, url, { attempts?, delaySeconds?, headers? })` | GETs `url` until it returns 2xx (redirects followed), retrying network errors and non-2xx statuses; defaults 5 attempts, 5 s apart (v1's deploy-beta loop). Throws the last error when the attempts run out. Runs in the harness, so the image needs no curl. `helmDeployOverSsh`'s `healthUrl` uses it. |
188
+ | `serviceUrl(release)` | `http://<release>.<namespace>.svc.cluster.local` for a `HelmRelease` (charts that name the Service after the release). |
189
+ | `checkSpa(ctx, url)` | `GET <url>/` must reference an `/assets/*.js` bundle (Vite's layout) and the bundle must be served as JavaScript; returns `{ html, bundle }` for app-specific checks. |
190
+ | `sshSession(ctx, { host, key })` | Installs openssh-client (apk), writes the key to `<tmpDir>/ssh/id` (trailing newline, mode 600; host key not verified) and returns `{ run(remote), text(remote), copy(local, remote), shell(remote) }`; `shell` is the quoted ssh command for pipes (`ctx.sh(`printf '%s' "$V" \| ${s.shell("kubectl apply -f -")}`)`). `helmDeployOverSsh` uses it. `shq(s)` is the POSIX quoting it uses. |
145
191
 
146
192
  ```ts
147
- import { httpHealthCheck, implement } from "@olve/pipelines";
193
+ import { httpHealthCheck, implement } from "@olivervea/pipelines";
148
194
  export default implement(smoke, async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
149
195
  ```
150
196
 
@@ -152,7 +198,7 @@ export default implement(smoke, async (_, ctx) => { await httpHealthCheck(ctx, "
152
198
 
153
199
  ```ts
154
200
  // handlers/deploy-beta.ts
155
- import { implement } from "@olve/pipelines";
201
+ import { implement } from "@olivervea/pipelines";
156
202
  import { deployBeta } from "../pipeline";
157
203
  import deployScript from "../scripts/deploy.sh" with { type: "file" };
158
204
 
@@ -165,7 +211,7 @@ export default implement(deployBeta, async ({ artifact }, ctx) => {
165
211
 
166
212
  | `ctx.` | |
167
213
  |---|---|
168
- | `sh(cmd \| script, { env, cwd, args })`, `sh.text(…)` | Runs a command string, or an embedded script, through the image's shell. The script is copied to `$OLVE_DIR/tmp/scripts/` first. The shell is the first of `/bin/sh`, `/busybox/sh`, `/bin/bash` that exists; a shell-less image gets a clear `ShellNotFoundError`. A script's shebang is honoured when its interpreter exists. A string counts as an embedded script when it is a `/$bunfs/…` path, or an absolute path to an existing `*.sh` or `*.bash` file. Pass `{ script }` to force script mode. A non-zero exit throws a `CommandError` that carries `exitCode`. |
214
+ | `sh(cmd \| script, { env, cwd, args })`, `sh.text(…)` | Runs a command string, or an embedded script, through the image's shell. The script is copied to `$OLVE_DIR/tmp/scripts/` first. The shell is the first of `/bin/sh`, `/busybox/sh`, `/bin/bash` that exists; a shell-less image gets a clear `ShellNotFoundError`. A script's shebang is honoured when its interpreter exists. A string counts as an embedded script when it is a `/$bunfs/…` path, or an absolute path to an existing `*.sh` or `*.bash` file. Pass `{ script }` to force script mode. `args` are the script's arguments, or `$1`, `$2`… of a command string. A non-zero exit throws a `CommandError` that carries `exitCode`. |
169
215
  | `exec([argv…], { env, cwd, idleTimeoutSeconds })` | Runs a binary directly, with no shell (works on distroless). `idleTimeoutSeconds` (also on `sh`) is an inactivity watchdog: no stdout/stderr output for that long → SIGTERM, SIGKILL 10 s later, and a `CommandStalledError`. |
170
216
  | `readText(path)`, `readJson(path)` | Paths are relative to the cwd. |
171
217
  | `files.fetch(ref)` | Downloads a file reference and verifies its sha256. Directory refs are extracted. Returns a local path. |
@@ -173,6 +219,7 @@ export default implement(deployBeta, async ({ artifact }, ctx) => {
173
219
  | `http.fetch`, `http.getJson(url, { headers })`, `http.expectOk(url, { method, headers })` | Bound to `ctx.signal`. A non-2xx status throws a readable error, with the URL redacted after `?`. |
174
220
  | `retry({ attempts, delaySeconds, backoff? }, fn)` | Retries `fn`. Can be cancelled. |
175
221
  | `secrets.NAME` | Typed to the step's declared secrets. Read from the env var `NAME`. Throws if the variable is missing. |
222
+ | `config.<entry>` | Typed to the step's declared config entries (output view, defaults applied). From input.json's `$config`. An optional entry that is not set is `undefined`. |
176
223
  | `log(message, fields?)` | Writes a `message k=v …` line to stderr. |
177
224
  | `signal` | Aborted on SIGTERM/SIGINT. Running commands are sent SIGTERM. |
178
225
  | `run` | `{ pipeline, id, number, attempt, step, version }` (from the env, see below). |
@@ -199,15 +246,16 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
199
246
  - **The `from` argument.** It is keyed by step name, plus `input` for the pipeline input. It holds `Ref<T>` values, so only property access and literals compile. Use `from["deploy-beta"].url` for names that contain hyphens.
200
247
  - **Describe checks the mapper twice.** It records the mapper with proxies, and it statically analyses the mapper's source. The source must be an object literal of member chains, literals, constant arrays and nested object literals. Ternaries, `&&`, `||`, `??`, `!`, comparisons, arithmetic, template strings, calls, spread and optional chaining are all rejected. Free identifiers such as module constants are allowed; they are recorded as `{const}`.
201
248
  - **Triggers.** A pipeline may call `onPush` at most once. Each `onPoll` and `onWebhook` needs a distinct `name` (`onPoll` defaults to `"poll"`). `onWebhook({ name, verify }, ({ headers, body }) => input | null)` maps a delivery to `/api/hooks/<pipeline id>/<name>`; `verify` is `hmacSha256({ secret, header, prefix? })`, `githubSignature(secret)` or `bearer(secret)`, and `null` ignores the delivery.
249
+ - **`select` (branch on config).** `.select(TARGET.prod, { ssh: (p, prod) => p.stage(…), inCluster: (p, prod) => p.stage(…) })`: the first argument references a `z.discriminatedUnion` in a config entry (`DiscriminatorOf<U>` is its one literal field; a union with no or several literal fields is a type error). Cases are exhaustive and checked at compile time; each gets the reference narrowed to its variant (`ConfigNode<VariantOf<U, D, V>>`), recorded as `{"$config": "target.prod", "$case": "target.prod.kind=ssh"}` and usable only inside that case. The manifest records `{select, discriminator, cases}`; the service resolves it per run from the pinned config snapshot (`when` is per run input). The same step name may appear in several cases (same output model; not two different custom steps, since handlers pair by name); a step every case has is definitely available afterwards, one only some have is maybe-available. The harness picks the variant of a same-named step from `OLVE_CASES` (a JSON array of the case conditions the step sits in, set by the service).
202
250
  - **Failure handlers.** `.onFailure(handler, { steps? })` takes a step whose inputs are all `FailureContext`. When a run fails, the service runs each handler whose scope (default: any step) contains a failed step, once per failure: `run <handler>` in the handler's image with its declared secrets, from the run's pinned binary. It is untracked and best-effort — not an attempt of the run, never retried, and its own failure triggers nothing; its log is kept at `runs/<p>/<run>/_failure/<handler>/<id>/log`. `FailureContext` carries `pipeline {id, name}`, `run {id, number, address, versionStamp, trigger, url}`, `version {number, commit}`, the run's `input`, a one-line `reason`, and `failedSteps[]` (`step, attempt, address, reason, logUrl`) — only the steps in the handler's scope, never empty.
203
251
 
204
252
  ### Testing handlers
205
253
 
206
254
  ```ts
207
- import { testHandler, testFileRef } from "@olve/pipelines/testing";
255
+ import { testHandler, testFileRef } from "@olivervea/pipelines/testing";
208
256
  import deployBeta from "../handlers/deploy-beta";
209
257
 
210
- const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, ctx: { http: fakeHttp }, run: { number: 412 } });
258
+ const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, config: { target: { host: "h" } }, ctx: { http: fakeHttp }, run: { number: 412 } });
211
259
  expect(r).toMatchObject({ status: "succeeded", output: { release: "olve-pipelines" } }); // r.filesDir, r.logs, r.exitCode
212
260
  ```
213
261
 
@@ -235,7 +283,7 @@ This section is everything the C# side needs in order to run the binary.
235
283
 
236
284
  | Path | Who | What |
237
285
  |---|---|---|
238
- | `input.json` | service (via olve-init) | **run**: a JSON object with exactly the step's input keys, built from the manifest's `input` mappings. Leave out a key whose ref root step was skipped; this is allowed only for `optional` inputs. Unknown keys → exit 65. **map-trigger push**: the GitHub push payload. **map-trigger poll**: `{"value": "<extracted value as string>"}`. |
286
+ | `input.json` | service (via olve-init) | **run**: a JSON object with exactly the step's input keys, built from the manifest's `input` mappings. Leave out a key whose ref root step was skipped; this is allowed only for `optional` inputs. Unknown keys → exit 65. For a step with `configEntries`, the reserved key `"$config"` holds `{ <entry>: <value> }` from the run's config (entries that are not set are left out); each entry is parsed with its schema, and a missing required entry or an invalid value → exit 65 (`config.<entry>…`). Other steps ignore `$config`. **map-trigger push**: `{"ref": "refs/heads/<branch>", "after": "<sha>", "repository": {"full_name": "<owner>/<repo>"}}` (`PushEvent`). **map-trigger poll**: `{"value": "<extracted value as string>"}`. **map-trigger webhook**: `{"headers": {…}, "body": …}` (`WebhookEvent`). |
239
287
  | `work/output.json` | harness | The envelope. Any stale copy is deleted at start, and the new file is written atomically. |
240
288
  | `work/files/<object path>` | harness | Output files and directory tars (see "File references"). |
241
289
  | `tmp/` | harness | Scratch space. Point `TMPDIR` here. |
@@ -252,6 +300,7 @@ This section is everything the C# side needs in order to run the binary.
252
300
  | `OLVE_RUN` | run | Run number (integer ≥ 0). Written into produced file refs. Default 0. |
253
301
  | `OLVE_ATTEMPT` | run | Attempt number (integer ≥ 1). Written into produced file refs. Default 1. |
254
302
  | `OLVE_VERSION_STAMP` | run | Derived version, e.g. `2026.10.21.412` → `ctx.run.version`. |
303
+ | `OLVE_CASES` | run | Only for a step inside a `select`: a JSON array of the case conditions it sits in (`["target.prod.kind=ssh"]`); `run <step>` uses it to pick the variant of a name several cases use. |
255
304
  | `<SECRET_NAME>` | per step | Each declared secret as an env var of the **same name**: `secretKeyRef` key = name. Mount only the step's `secrets` (for poll triggers, the poll's `secrets`). Child processes inherit them. The mount is optional: an unset secret leaves the variable unset and `ctx.secrets.<NAME>` throws on access, so a handler can check `process.env.<NAME>` first and skip. |
256
305
  | `OLVE_RESULT_URL` | optional | Pre-signed **PUT** for `result.tar.gz` (see below). Unset means no upload. |
257
306
  | `OLVE_FILE_URLS` | optional | JSON object mapping file key → pre-signed **GET** URL, one entry per file ref in the input. The key is `<run>/<step>/<attempt>/<path>` (`path` exactly as in the ref, including a trailing `/` for directories). The URL points at the stored object (see the storage rule). |
@@ -286,7 +335,7 @@ For `map-trigger`, `output` is the pipeline input after it has been parsed by th
286
335
  | 0 | ok (succeeded or skipped) | yes |
287
336
  | 1 | the handler or trigger mapper threw | failed / `handler` |
288
337
  | 64 | usage: unknown mode, step or trigger | none |
289
- | 65 | `input.json` missing, not JSON, or invalid for the step (or event) | failed / `input` |
338
+ | 65 | `input.json` missing, not JSON, or invalid for the step (or event), including its `$config` | failed / `input` |
290
339
  | 66 | the return value failed strict validation, or an own file ref is missing or changed | failed / `output` |
291
340
  | 74 | the `result.tar.gz` upload failed after retries. Used only when the step itself succeeded; otherwise the step's own code wins. | yes (printed) |
292
341
  | 78 | the pipeline is invalid (describe problems, or a missing or wrong handler module) | none |
@@ -320,6 +369,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
320
369
  - **`apiVersion`** is `"2.0"`.
321
370
  - **`harness`** is `{ bun, sdk, sdkHash }`.
322
371
  - **`models[name]`** is `{ output, input? }`.
372
+ - **Step `kind`** is `custom` or the library step's name (`imageBuild`, `composeDeploy`, …). To the service it is a label: any non-empty kind is accepted, so a new library step needs no service change.
323
373
  - `output` is the `z.output` view, with `additionalProperties: false` on every object. It validates outputs, the pool and trigger results.
324
374
  - `input` is the `z.input` view (defaults optional). Use it for trigger forms and manual input. It is emitted only when it differs from `output` beyond `additionalProperties`.
325
375
  - **Step `input`** maps each input key to a mapping:
@@ -329,8 +379,8 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
329
379
 
330
380
  An object whose only key is `ref` (with a string value) or `const` is a tag. Describe never emits a nested object with exactly that key set. Model-matched inputs are emitted already resolved (`{"ref": "build"}`).
331
381
  - **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
332
- - **`handlerHash`** is the step's implementation identity. A step version can be computed from image + `inputTypes`/model versions + `output` model version + `secrets` + `input` mappings + `handlerHash`. Stage placement, `when` and the step's `description` are not part of it.
333
- - Custom steps: `sha256(canonical {bundle, harness})`. `bundle` hashes the handler bundled on its own, with `pipeline.ts`, `models.ts` and `@olve/pipelines` external. It is tree-shaken and includes embedded scripts, so it changes only when that handler's code (or code it uses) changes.
382
+ - **`handlerHash`** is the step's implementation identity. A step version can be computed from image + `inputTypes`/model versions + `output` model version + `secrets` + `configEntries` (and those entries' schemas) + `input` mappings + `handlerHash`. Stage placement, `when` and the step's `description` are not part of it.
383
+ - Custom steps: `sha256(canonical {bundle, harness})`. `bundle` hashes the handler bundled on its own, with `pipeline.ts`, `models.ts` and `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts, so it changes only when that handler's code (or code it uses) changes.
334
384
  - Library steps: `sha256(canonical {library: kind, config, harness})`.
335
385
  - Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
336
386
  - **`stages`** is an array whose items are either `{steps: [Step…]}` (parallel, with a barrier) or `{when: {field: [values]}, stages: [...]}`.
@@ -340,6 +390,8 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
340
390
  - `{kind: "webhook", name, verify: {scheme: "hmac-sha256", secret, header, prefix} | {scheme: "bearer", secret}}`. The `map-trigger` event is `{headers, body}`; a `null` mapping yields a `skipped` envelope (the delivery is ignored).
341
391
  - **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
342
392
  - **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
393
+ - **`config`** lists every config entry steps declare, sorted by name, as `{name, description, required, schema}`. `schema` is the `z.input` view (defaults listed); it validates the entry's value in the config document. `required` is false when the schema accepts a missing value. A `secretRef` field is marked `"x-olve-kind": "secretRef"`, with the secret's name as `properties.secret.const`. Omitted when there are no entries.
394
+ - **Step `configEntries`** names the entries the step receives (sorted; omitted when none). It is part of the step's content, like `secrets`. The step key `config` is the library-step configuration.
343
395
 
344
396
  Describe runs these checks; reconcile should re-run the manifest-level ones:
345
397
 
@@ -351,6 +403,7 @@ Describe runs these checks; reconcile should re-run the manifest-level ones:
351
403
  - `when` on an unknown or non-literal field, or with an invalid value;
352
404
  - a poll header referencing an undeclared secret, duplicate triggers;
353
405
  - conflicting secret descriptions;
406
+ - config names, one description and schema per config name, a step receiving a `secretRef` without declaring its secret, and input keys starting with `$` (reserved);
354
407
  - an `onFailure` scope naming an unknown step, and failure-handler inputs.
355
408
 
356
409
  ## Build tool
@@ -360,7 +413,7 @@ olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl]
360
413
  olve-build <pipelineDir> --hash-only # print handler hashes (diagnostics)
361
414
  ```
362
415
 
363
- 1. Hash every `handlers/*.ts` (see `handlerHash`).
416
+ 1. Pair each `handlers/*.ts` with the step its `implement(step, …)` names, and hash it (see `handlerHash`).
364
417
  2. Generate `<pipelineDir>/.olve/gen/handlers.ts` (a registry of literal `import()` paths, plus hashes and harness info) and `entry.ts`. Add `.olve/` to `.gitignore`.
365
418
  3. Run `bun build --compile --target=bun-linux-x64 --no-compile-autoload-dotenv --no-compile-autoload-bunfig --bytecode --format=esm` → `<out>/step`. The output is never minified, because mapper source analysis needs real source.
366
419
  4. Check that `PT_INTERP` is `/lib64/ld-linux-x86-64.so.2`. On alpine, Bun silently produces a musl binary; the check fails it.
@@ -378,9 +431,9 @@ bun install --frozen-lockfile
378
431
  bunx olve-build .pipelines --out /work/out --upload-url "$STEP_PUT_URL" --sentinels
379
432
  ```
380
433
 
381
- From npm (the alias), nothing else is needed. A local `file:`/`link:` dependency (`"@olve/pipelines": "file:../sdk"`, as Olve.Pipelines' own `.pipelines/` does) resolves to its **source directory**, not the copy in `node_modules`, so its own dependencies must be installed there too: the build Job runs `bun install --frozen-lockfile --production` in every local dependency of the binding's `package.json` that is inside the repo, before building.
434
+ From npm, nothing else is needed. A local `file:`/`link:` dependency (`"@olivervea/pipelines": "file:../sdk"`, as Olve.Pipelines' own `.pipelines/` does) resolves to its **source directory**, not the copy in `node_modules`, so its own dependencies must be installed there too: the build Job runs `bun install --frozen-lockfile --production` in every local dependency of the binding's `package.json` that is inside the repo, before building.
382
435
 
383
- The repo depends on the SDK as a normal package. It resolves through `exports` (`@olve/pipelines`, `@olve/pipelines/harness`, `@olve/pipelines/testing`), and the tests cover this with a `file:` install. `bun` must be on `PATH`, because the bins use `#!/usr/bin/env bun`. A consumer `tsconfig.json` has to include the dot-directory explicitly (`"include": [".pipelines/**/*.ts"]`), and should exclude `.pipelines/.olve`.
436
+ The repo depends on the SDK as a normal package. It resolves through `exports` (`@olivervea/pipelines`, `@olivervea/pipelines/harness`, `@olivervea/pipelines/testing`), and the tests cover this with a `file:` install. `bun` must be on `PATH`, because the bins use `#!/usr/bin/env bun`. A consumer `tsconfig.json` has to include the dot-directory explicitly (`"include": [".pipelines/**/*.ts"]`), and should exclude `.pipelines/.olve`.
384
437
 
385
438
  How the service runs it (reconcile, `BuildJobBuilder`): one Job per (pipeline, config commit), named `olve-build-<pipeline12>-<sha12>`. The repo tarball comes from `codeload.github.com/<repo>/tar.gz/<sha>` anonymously (public repos), or from the GitHub API tarball endpoint with the binding's credentials key from the pipeline Secret. The binary is PUT to `versions/<pipeline>/builds/<sha>/step.tar.gz`, and a version added from that build keeps this key as its `BinaryKey`. The in-repo fixture `.pipelines-test/` (bound on v2 beta) uses exactly this layout.
386
439
 
@@ -390,11 +443,11 @@ The manifest is the text between the sentinels in the pod log. The manifest text
390
443
 
391
444
  ```
392
445
  olve-dev describe <dir> [--sentinels] # same bytes as the compiled binary's describe
393
- olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
446
+ olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
394
447
  olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
395
448
  ```
396
449
 
397
- `olve-dev` runs handlers from source, through the same validation path as the binary. `$OLVE_DIR` defaults to `<dir>/.olve`, and outputs land in `<dir>/.olve/work/`. Set `OLVE_FILES_DIR` to a local pool to fetch file refs.
450
+ `olve-dev` runs handlers from source, through the same validation path as the binary. `$OLVE_DIR` defaults to `<dir>/.olve`, and outputs land in `<dir>/.olve/work/`. Set `OLVE_FILES_DIR` to a local pool to fetch file refs. `--config-file` (a JSON object of config entries) becomes the input's `$config`.
398
451
 
399
452
  ## Development
400
453
 
@@ -47,6 +47,42 @@
47
47
  "const": "2.0",
48
48
  "type": "string"
49
49
  },
50
+ "config": {
51
+ "description": "Config entries used by steps, sorted by name (omitted when none). Fields of kind secretRef ({secret: NAME}) name a secret.",
52
+ "items": {
53
+ "additionalProperties": false,
54
+ "properties": {
55
+ "description": {
56
+ "type": "string"
57
+ },
58
+ "name": {
59
+ "description": "Config entry name; a key of the pipeline's config document.",
60
+ "pattern": "^[a-z][a-zA-Z0-9]*$",
61
+ "type": "string"
62
+ },
63
+ "required": {
64
+ "description": "False when the schema accepts a missing value (optional, or a default).",
65
+ "type": "boolean"
66
+ },
67
+ "schema": {
68
+ "additionalProperties": {},
69
+ "description": "z.input view: validates the config document's value for this entry (defaults listed).",
70
+ "propertyNames": {
71
+ "type": "string"
72
+ },
73
+ "type": "object"
74
+ }
75
+ },
76
+ "required": [
77
+ "name",
78
+ "description",
79
+ "required",
80
+ "schema"
81
+ ],
82
+ "type": "object"
83
+ },
84
+ "type": "array"
85
+ },
50
86
  "description": {
51
87
  "type": "string"
52
88
  },
@@ -92,7 +128,7 @@
92
128
  "type": "string"
93
129
  },
94
130
  "sdk": {
95
- "description": "@olve/pipelines package version.",
131
+ "description": "@olivervea/pipelines package version.",
96
132
  "type": "string"
97
133
  },
98
134
  "sdkHash": {
@@ -297,22 +333,70 @@
297
333
  "stages"
298
334
  ],
299
335
  "type": "object"
336
+ },
337
+ {
338
+ "additionalProperties": false,
339
+ "properties": {
340
+ "cases": {
341
+ "additionalProperties": {
342
+ "items": {
343
+ "$ref": "#/$defs/Stage"
344
+ },
345
+ "type": "array"
346
+ },
347
+ "description": "Discriminator value → stages. Exactly one case runs; a step name may repeat across cases, nowhere else.",
348
+ "propertyNames": {
349
+ "type": "string"
350
+ },
351
+ "type": "object"
352
+ },
353
+ "discriminator": {
354
+ "description": "The union's literal field whose value names the case.",
355
+ "minLength": 1,
356
+ "type": "string"
357
+ },
358
+ "select": {
359
+ "description": "Config reference (`<entry>.<field>…`) to a discriminated union; the run's config snapshot picks the case.",
360
+ "minLength": 1,
361
+ "type": "string"
362
+ }
363
+ },
364
+ "required": [
365
+ "select",
366
+ "discriminator",
367
+ "cases"
368
+ ],
369
+ "type": "object"
300
370
  }
301
371
  ]
302
372
  },
303
373
  "Step": {
304
374
  "additionalProperties": false,
305
375
  "properties": {
376
+ "clusterAccess": {
377
+ "const": true,
378
+ "description": "The step pod gets the instance's step ServiceAccount token (Kubernetes:StepServiceAccount), if the instance grants one. Omitted = no token.",
379
+ "type": "boolean"
380
+ },
306
381
  "config": {
307
382
  "additionalProperties": {
308
383
  "$ref": "#/$defs/Json"
309
384
  },
310
- "description": "Library-step configuration (display only; already covered by handlerHash).",
385
+ "description": "Library-step configuration (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries.",
311
386
  "propertyNames": {
312
387
  "type": "string"
313
388
  },
314
389
  "type": "object"
315
390
  },
391
+ "configEntries": {
392
+ "description": "Config entries the step receives (sorted; omitted when none). The service delivers them in input.json under '$config'.",
393
+ "items": {
394
+ "description": "Config entry name; a key of the pipeline's config document.",
395
+ "pattern": "^[a-z][a-zA-Z0-9]*$",
396
+ "type": "string"
397
+ },
398
+ "type": "array"
399
+ },
316
400
  "description": {
317
401
  "description": "What the step does, shown in the UI (may be empty).",
318
402
  "type": "string"
@@ -348,14 +432,8 @@
348
432
  "type": "object"
349
433
  },
350
434
  "kind": {
351
- "description": "'custom' = handlers/<name>.ts; others are SDK library steps.",
352
- "enum": [
353
- "custom",
354
- "script",
355
- "gitHubSource",
356
- "imageBuild",
357
- "helmDeployOverSsh"
358
- ],
435
+ "description": "'custom' = handlers/<name>.ts; others are SDK library steps. A label: the service accepts any kind.",
436
+ "minLength": 1,
359
437
  "type": "string"
360
438
  },
361
439
  "name": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.6",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,7 +48,7 @@ export function generateEntry(project: Project, hashes: Record<string, string>,
48
48
  writeFileSync(
49
49
  join(gen, "handlers.ts"),
50
50
  `// GENERATED by olve-build — do not edit.\n` +
51
- `import type { HandlerRegistry, HarnessInfo } from "@olve/pipelines/harness";\n\n` +
51
+ `import type { HandlerRegistry, HarnessInfo } from "@olivervea/pipelines/harness";\n\n` +
52
52
  `export const handlers: HandlerRegistry = {\n${registry}\n};\n\n` +
53
53
  `export const handlerHashes: Record<string, string> = ${JSON.stringify(hashes, null, 2)};\n\n` +
54
54
  `export const harness: HarnessInfo = ${JSON.stringify(harness, null, 2)};\n`,
@@ -58,7 +58,7 @@ export function generateEntry(project: Project, hashes: Record<string, string>,
58
58
  entry,
59
59
  `// GENERATED by olve-build — do not edit. Entry point of the step binary.\n` +
60
60
  `import pipeline from ${JSON.stringify(relPosix(gen, project.pipelineFile))};\n` +
61
- `import { runHarness } from "@olve/pipelines/harness";\n` +
61
+ `import { runHarness } from "@olivervea/pipelines/harness";\n` +
62
62
  `import { handlers, handlerHashes, harness } from "./handlers.ts";\n\n` +
63
63
  `process.exit(await runHarness({ pipeline, handlers, handlerHashes, harness }, process.argv.slice(2)));\n`,
64
64
  );
@@ -69,7 +69,7 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
69
69
  const log = opts.log ?? ((l: string) => process.stderr.write(l + "\n"));
70
70
  const stdout = opts.stdout ?? (async (t: string) => void (await Bun.write(Bun.stdout, t)));
71
71
  const target = opts.target ?? "bun-linux-x64";
72
- const project = loadProject(dir);
72
+ const project = await loadProject(dir);
73
73
  const out = resolve(opts.out);
74
74
  mkdirSync(out, { recursive: true });
75
75
  const harness = harnessInfo();
package/src/build/cli.ts CHANGED
@@ -32,7 +32,7 @@ async function main(): Promise<number> {
32
32
  const dir = positionals[0]!;
33
33
  try {
34
34
  if (values["hash-only"]) {
35
- const h = await handlerHashes(loadProject(dir), harnessInfo());
35
+ const h = await handlerHashes(await loadProject(dir), harnessInfo());
36
36
  await Bun.write(Bun.stdout, canonicalJson({ harness: harnessInfo(), handlers: h }));
37
37
  return 0;
38
38
  }
@@ -22,7 +22,7 @@ function externals(project: Project): BunPlugin {
22
22
  return {
23
23
  name: "olve-handler-hash-externals",
24
24
  setup(b) {
25
- b.onResolve({ filter: /^@olve\/pipelines(\/.*)?$/ }, (args) => ({ path: args.path, external: true }));
25
+ b.onResolve({ filter: /^@olivervea\/pipelines(\/.*)?$/ }, (args) => ({ path: args.path, external: true }));
26
26
  b.onResolve({ filter: /^\.{1,2}\// }, (args) => {
27
27
  const base = resolve(dirname(args.importer), args.path);
28
28
  for (const cand of [base, `${base}.ts`, join(base, "index.ts")]) if (contracts.has(cand)) return { path: args.path, external: true };
@@ -1,6 +1,7 @@
1
1
  // Layout of a pipeline directory and the identity of the SDK building it.
2
2
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { dirname, join, relative, resolve } from "node:path";
4
+ import { isImplementation } from "../core/handlers.ts";
4
5
  import { sha256Hex } from "../util/hash.ts";
5
6
  import type { HarnessInfo } from "../util/handler-hash.ts";
6
7
 
@@ -11,7 +12,7 @@ export interface Project {
11
12
  pipelineFile: string;
12
13
  /** Contract files (excluded from handler bundles): pipeline.ts, models.ts. */
13
14
  contractFiles: string[];
14
- /** step name → absolute handler file, sorted by name. */
15
+ /** step name → absolute handler file, sorted by step name. The step is the one the file's `implement(step, …)` names. */
15
16
  handlers: [string, string][];
16
17
  }
17
18
 
@@ -19,20 +20,40 @@ export class ProjectError extends Error {
19
20
  override name = "ProjectError";
20
21
  }
21
22
 
22
- export function loadProject(dirArg: string): Project {
23
+ export async function loadProject(dirArg: string): Promise<Project> {
23
24
  const dir = resolve(dirArg);
24
25
  if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new ProjectError(`${dirArg} is not a directory`);
25
26
  const pipelineFile = join(dir, "pipeline.ts");
26
27
  if (!existsSync(pipelineFile)) throw new ProjectError(`${join(dirArg, "pipeline.ts")} not found (it must default-export pipeline({...}))`);
27
28
  const contractFiles = ["pipeline.ts", "models.ts"].map((f) => join(dir, f)).filter((f) => existsSync(f));
28
29
  const hdir = join(dir, "handlers");
29
- const handlers: [string, string][] = existsSync(hdir)
30
+ const files = existsSync(hdir)
30
31
  ? readdirSync(hdir)
31
32
  .filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.endsWith(".d.ts") && statSync(join(hdir, f)).isFile())
32
33
  .sort()
33
- .map((f) => [f.slice(0, -3), join(hdir, f)])
34
34
  : [];
35
- return { dir, pipelineFile, contractFiles, handlers };
35
+ return { dir, pipelineFile, contractFiles, handlers: await resolveHandlers(hdir, files) };
36
+ }
37
+
38
+ /** Pairs each handler file with the step its default export implements (file names don't matter). */
39
+ async function resolveHandlers(hdir: string, files: string[]): Promise<[string, string][]> {
40
+ const byStep = new Map<string, string>();
41
+ for (const f of files) {
42
+ const file = join(hdir, f);
43
+ let impl: unknown;
44
+ try {
45
+ impl = ((await import(file)) as { default?: unknown }).default;
46
+ } catch (e) {
47
+ throw new ProjectError(`handlers/${f} failed to load: ${(e as Error)?.message ?? String(e)}`);
48
+ }
49
+ if (!isImplementation(impl))
50
+ throw new ProjectError(`handlers/${f} must default-export implement(<step>, …); put shared code in a subfolder such as handlers/lib/`);
51
+ const step = (impl.step as { name: string }).name;
52
+ const other = byStep.get(step);
53
+ if (other) throw new ProjectError(`handlers/${other.slice(hdir.length + 1)} and handlers/${f} both implement step '${step}'`);
54
+ byStep.set(step, file);
55
+ }
56
+ return [...byStep].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
36
57
  }
37
58
 
38
59
  function listFiles(dir: string): string[] {