@oren-sh/cli
v0.13.0
Published
Universal pipelines — tasks como contratos, rodando em qualquer lugar
Readme
@oren-sh/cli
Oren's pipeline runner. It reads contracts, resolves implementations and runs each step as an isolated container, orchestrated by Dagger.
It implements the specification in ../spec — this repository does not define the contract format, it only executes it.
0.3.0 breaks everything that came before. The
oren.yamlformat changed completely from 0.2.x:arguments/extra-argsbecameinputs,configbecame typeddependencies, and the protocol between CLI and worker stopped using argv and stdout delimiters. Pipelines and workers from 0.2.x do not run here. While the version is 0.x, the format may still change.
Start with GUIDE.md — a project from scratch, step by step, with every command verified.
npm install -g @oren-sh/cliUsage
oren init # a project that uses tasks
oren init worker <n> # a fresh task, with a worker (--template shell|node)
oren add <source> # brings a task into the project
oren install # resolves images to digests and writes oren.lock
oren validate # checks everything without starting a single container
oren doctor # checks the prerequisites
oren run dev # runs the `dev` pipeline from oren.yaml
oren run dev --yes # authorizes dependencies without confirmation (CI)
oren generate gitlab dev -o .gitlab-ci.yml # translates for the CI
oren generate cloudbuild dev -o cloudbuild.yaml
oren cache # entries, space used and the ceiling
oren cache prune # frees what is unused
oren cache limit 20GB # caps the growthOptions: -f pipeline file, -p properties file, -r local registry, -v execution-engine log.
Example
cd examples/hello
oren validate
oren run devTwo chained steps, one of them writing back to the host, with a secret input and secrecy propagation — it doubles as a smoke test of the whole flow.
Layout
| Directory | Responsibility |
| ------------------- | ----------------------------------------------------------------------- |
| src/spec/ | Document loading and validation, the type registry, semver |
| src/registry/ | Contract and implementation resolution (local files and the remote registry) |
| src/pipeline/ | Expressions with secrecy propagation, step resolution, orchestration |
| src/runtime/ | Protocol v1 and container execution |
| src/consent/ | Dependency authorization and its cache |
| src/state/ | Run state in .oren/runs/ |
Decisions worth knowing
No live state between steps. Outputs are written to .oren/runs/<id>/ and read back from there. It keeps the local mode equivalent to the generated mode, where every step becomes a separate CI job and nothing survives between them.
Secrecy is contagious. An ordinary field that interpolates another step's secret output becomes secret itself — redacted in the log and in the on-disk state. Without that, embedding the credential in any string would be enough for it to resurface.
The CLI grants exactly what was declared. An implementation that uses the Docker socket without declaring engine/docker fails at execution. It is what stops under-declaring from becoming a ranking advantage in the catalogue.
The worker's identity is immutable. oren.lock stores the digest-pinned reference (repo@sha256:...) or the build's content digest. It is what anchors consent: without it the identity would be the tag, and republishing alpine:3.20 with different content would silently transfer the authorization to unreviewed code. Running without a lockfile works, but warns.
Consent is recorded, not asked every time. .oren/consent.json is made to be committed: the team inherits the decision and a privilege escalation becomes a diff in code review. The fingerprint covers image and dependencies, so a fresh image or a fresh dependency invalidates the earlier authorization.
Output outside the contract fails the step, even with exit code zero.
The worker's log is captured and attributed to the step. The engine's LogOutput shows the graph's operations (withExec, withNewFile), not what the worker printed — without capture, its output would never reach the user. Known limitation: it appears at the end of the step, because the SDK exposes no stream during execution.
Input with a secret field travels as a secret, not as a file. The engine prints the content of withNewFile in its progress, so --verbose would leak in plain text everything the CLI carefully redacts. Inputs without secrets stay ordinary files, which are visible and cacheable.
The execution engine is dedicated to Oren. It is not a fork: it is the official registry.dagger.io/engine image, running in its own container (oren-engine-<version>) and volume (oren-cache-<version>), selected by the SDK via _EXPERIMENTAL_DAGGER_RUNNER_HOST.
Without that, cache prune would wipe the cache of every other Dagger use on the machine and cache limit would rewrite the user's global ~/.config/dagger/engine.json. Dagger is an implementation detail of Oren and should have no consequences outside it.
The container and the volume carry the version because BuildKit's cache format has no guaranteed compatibility across versions; when a new one comes up, Oren's older ones are removed so the disk does not accumulate one copy per update. Two known risks: the environment variable carries the _EXPERIMENTAL_ prefix and may change without notice, and the cache is not shared with other Dagger uses — common layers get downloaded twice.
The worker can be built by the execution engine. An implementation declares build: { context } instead of image, and the image is built on the spot, cached by content. Without this, local development would demand docker build followed by docker save, because the engine does not see the machine's Docker daemon. An implementation with build is not publishable: whoever consumes it needs the built image.
A task with an external effect is not cached. The contract declares effects: external for push, remote tags, deploys — things whose value is not in the output. Without that the engine would return the cached result and the effect would never happen again, with the pipeline reporting success.
CI generation
oren generate <target> <environment> translates the pipeline into the platform's native configuration. The same contracts and the same digest-pinned images, executed by native jobs instead of the CLI.
It demands oren.lock: without pinned images, the generated configuration would run whatever the tag points at on any given day — losing the reproducibility that justifies generating. Implementations with build: are refused, because a runner does not have the worker's directory.
| Target | State |
| --- | --- |
| gitlab | output validated against GitLab CI's official schema |
| github | not validated against the real platform — derived from the documentation |
| cloudbuild | not validated against the real platform — derived from the documentation |
Known limitation: the worker's image needs jq when an input comes from another step — it is what assembles input.json inside the job, including interpolation in mixed text (v${outputs['x'].y}-rc), which is supported.
Authentication
oren login # opens the browser (device flow)
oren login --token # paste a token already issued in the web interface
echo $TOKEN | oren login --tokenThe default is the device flow (RFC 8628): the CLI shows a short code, you authenticate in the browser, and the CLI polls until someone approves.
Open in the browser: https://registry.oren.sh/device
and enter the code: WDJB-MJHT
waiting for authorization…
✓ authenticated as techliteThe CLI never sees the password. That is what makes password managers, second factors and federated accounts work — none of the three fits in a terminal prompt.
What stays on disk is always a revocable token, in ~/.oren/credentials.json
with mode 600. The password, when used at all, serves once and is neither stored
nor resent.
--token exists because typing the password into a terminal has costs no
implementation care removes: a password manager cannot reach a terminal prompt,
2FA has nowhere to go, a federated account has no password to type, and the
habit of typing the registry password into a CLI is the very gesture phishing
wants to train.
The token never comes as an argument — whatever is on the command line stays in
the shell history and shows up in ps for any process on the machine. It comes
from an echo-free prompt, or from stdin.
In CI, OREN_TOKEN — no login at all.
E-mail and password existed here and left. With SSO on the registry it went from merely uncomfortable to impossible: a federated account has no password to type.
The flow has one known attack — someone opens the request on their machine and talks another person into approving the code. Nothing in the protocol prevents it; what prevents it is the approval screen saying what is at stake and never approving on its own, even with the code pre-filled in the URL.
Language
The CLI speaks English by default and Portuguese when the environment asks. The
choice comes, in this order, from OREN_LANG, LC_ALL, LC_MESSAGES and
LANG; a language without a catalogue falls back to English.
OREN_LANG=pt oren run devThe catalogues live in src/i18n/. English is canonical: it defines the TYPE
the other languages have to satisfy, so a missing key does not compile — it does
not become a message that silently vanishes at the wrong moment. The tests run
with OREN_LANG=en pinned, and assertions compare against the catalogue instead
of repeating the sentence.
What is NOT translated: command names (oren run is the same word everywhere,
and translating them would invalidate every tutorial) and the help's metavalues
(<source>, [pipeline]).
Registries
Contracts and implementations are read from .oren/registry/ (*.task.yaml,
*.impl.yaml) and from the remote registry — the public one at
https://registry.oren.sh, or whatever OREN_REGISTRY / .oren/config.json points at. The
local files win: they are the development loop.
Development
npm run sync-spec # copies schemas and types from ../spec
npm run build
npm test # unit tests, no Docker (test runs build first)
npm run test:e2e # 17 end-to-end tests against a real container, ~2min
npm run test:allThe end-to-end tests exist because the three most serious bugs of the first version got past typecheck and unit tests: authorization announced but not recorded, an engine-wrapped error surfacing as a stack trace, and a secret value leaking through interpolation in an ordinary field. Each of them has a test today that fails if the fix is undone — verified by reintroducing the bugs.
They skip on their own when Docker is absent, instead of failing.
sync-spec runs automatically on build. Point OREN_SPEC_PATH if the spec
repository is not sitting alongside.
Not implemented
The image source (contract read from OCI labels) and published base images.
