@telorun/cli
v0.96.0
Published
Telo CLI - Command-line interface for the Telo runtime.
Readme
Telo CLI
The Telo CLI is the command-line interface for the Telo kernel. It loads and runs YAML manifests on your local machine, watches them for changes during development, statically validates them with telo check, pre-installs controllers with telo install, refreshes imports: pins with telo upgrade, rewrites legacy spellings with telo migrate, ships an application as a single executable with telo package, and publishes module artifacts to an OCI registry with telo publish.
Installation
npm install -g @telorun/cli
# or
pnpm add -g @telorun/cliQuick Start
# Run a local manifest
telo ./examples/hello-api
# Run from a remote URL
telo https://raw.githubusercontent.com/telorun/telo/main/examples/hello-api/telo.yaml
# Watch mode - auto-restart on file changes
telo --watch ./manifest.yamlGlobal options
These apply to every command.
-o, --output text|json— Encoding of the CLI's own output. Defaulttext. Underjson, stdout carries one machine-readable document and nothing else, while prose, progress and warnings keep going to stderr in both formats — so a script parses stdout and a human still sees why something failed.telo runis exempt: the application writes to the same two descriptors and neither is the CLI's to claim, so its machine surface is--debuginstead. Underjson, stdout is never coloured; stderr keeps its colour. An unrecognised format is an error, not a fallback to text.--verbose— Verbose CLI logging on stderr.--help, -h/--version— The usual.
Machine output shapes. Commands that report a result (check, install, upgrade, migrate, publish, release) emit an envelope { ok, … } under -o json and nothing on stdout under text. Commands that produce a document (cel, search, module versions|manifest|digest|resources|kinds) write that document under either format — their older per-command --json flags predate -o and keep working — and never mix an error envelope into it: a failure there is prose on stderr plus a non-zero exit.
The cache root. Every command that reads or writes .telo/ resolves its location the same way, in this order:
TELO_CACHE_DIR, if set..telo/beside the nearesttelo-workspace.yamlabove the manifest — so one repository has one cache, whatever the manifest's depth..telo/beside the manifest, when no marker exists above it.
A monorepo therefore keeps its cache at the workspace root, which matters when you COPY a single app into an image — see Docker image.
Environment variables the CLI reads are listed in Running in production.
Commands
telo publish <paths..>
Publish one or more module artifacts to an OCI registry. For each manifest, the command:
- Finds all
controllersentries with alocal_pathqualifier (i.e. locally-developed packages). - Optionally bumps each controller package version with
--bump. - Builds each controller package.
- Publishes each controller package to its registry (currently npm). If the version already exists, the publish step is skipped — the command is idempotent.
- Rewrites the PURL version specs in the manifest to exact static versions.
- Bumps
metadata.versionin the manifest when--bumpis given. - Pushes the artifact to an OCI registry —
telo.yamlin its own layer, plus one layer per group of files the manifest declares (see below).
telo publish ./modules/my-module/telo.yaml
telo publish ./modules/my-module/telo.yaml --bump=patch
telo publish ./modules/a/telo.yaml ./modules/b/telo.yaml --bump=minor
telo publish ./modules/my-module/telo.yaml --dry-run
telo publish ./modules/my-module/telo.yaml --skip-controllersShipping files with files:
A Telo.Application or Telo.Library may declare a files: list to ship files
alongside telo.yaml — bundled controllers, a built SPA served by Http.Static,
templates, seed data. Without it, only the manifest reaches the registry and a
relative Http.Static root: resolves to an empty directory on the consumer.
kind: Telo.Application
metadata: { name: todo-app, version: 1.0.0 }
files:
- public/** # ship the built frontend
- "!**/*.map" # but not source mapsfiles: entries are ordered, .gitignore-style patterns (the same ignore
engine git uses): positive patterns opt files in, ! patterns carve them out,
last match wins. They are resolved against the manifest directory. A small
always-on set is never shipped regardless of patterns: node_modules/,
.git/, .telo/, .telobundle.*.
When files: selects anything, telo publish partitions the selection into
layers and pushes each as its own blob: one layer per bundled-controller platform,
one for whatever the optional assets: list claims, and one for everything else.
It prints the partition so you can see where each file landed. The consumer then
fetches only what it needs — a Node host skips a Rust controller's binary, a
linux/amd64 host skips the darwin/arm64 one, and the asset layer is fetched
only if something actually reads a file from it.
Declaring assets: (a subset of files:) is what makes those files lazy:
files:
- nodejs/*.mjs
- public/**
assets:
- public/**Omitting it is safe — the files still ship and still resolve, they are just fetched alongside the module's controllers instead of on demand.
telo run materializes layers on demand, so nothing has to be pre-fetched.
telo install pre-fetches everything for one platform so a later run needs no
network; pass --platform os/arch[/libc] (e.g. linux/arm64/musl) when baking an
image for an architecture other than the build machine's.
Options:
--bump patch|minor|major— Bump all controller package versions before publishing. Also bumpsmetadata.versionin the manifest.--dry-run— Show what would happen without writing files or publishing anything.--skip-controllers— Skip the controller build/publish/PURL-rewrite loop and only run static analysis and push the artifact to the OCI registry. Use this when controller packages have already been published by another tool (e.g. Changesets in CI). Mutually exclusive with--bump.
telo check <paths..>
Statically validates one or more manifests without running them. Uses the Telo analyzer to check schema correctness, x-telo-ref references, CEL expression types, and resource scope visibility. Exits with code 1 if any errors are found.
telo check ./manifest.yaml
telo check ./modules/my-module/telo.yaml
telo check https://example.com/manifest.yamlAccepts local paths, directories containing a telo.yaml, or HTTP(S) URLs.
Imports resolve through the same .telo/manifests/ cache telo run reads, and check writes through to it after a successful load (disable with --no-cache-write). Freshness depends on how the import addresses its target:
- Pinned (
…@1.2.3#sha256-…, or an@sha256:reference) — verified against the hash on read, so a repeat check of a fully pinned manifest needs no network at all. - Mutable OCI tag — revalidated with one
HEADper reference against the digest recorded in.telo/manifests/.origins.json. If the tag has moved, that entry is dropped and refetched. - HTTP(S) URL — never read from the cache by
check, since its key carries no version segment and a hit would be served indefinitely. Always re-fetched (one request, the same cost revalidating would have).
One loader serves every path in a single invocation, so telo check a b c resolves a module shared between them once.
Example output:
manifest.yaml:14:5 error No Telo.Definition found for kind "Http.Srver". UNDEFINED_KIND
manifest.yaml:22:7 warning variables.port is declared but never referenced in any CEL expression. UNUSED_DECLARATION
1 error, 1 warningEvery code is listed in the diagnostics reference, along with what triggers it and how to fix it.
On success:
✓ No issues foundtelo install <paths..>
Pre-downloads every controller declared by a manifest and its transitive imports into the on-disk cache, and persists every imported manifest's YAML alongside it. At runtime the kernel finds each controller already installed AND resolves every import from disk — boot does zero network I/O.
Installs run in parallel; failures are reported per controller and the command exits non-zero if any failed. For a platform you NAME, a module layer that did not materialize is also a failure — a fetch that could not complete, or a layer constraining an axis the target leaves undetermined (pass --abi <family>-<version> beside --platform os/arch[/libc]). The tree such an install produces is the only cache its consumer has — a baked image, a packaged application — so a skipped layer would otherwise surface as a boot failure on another machine. Without --platform the target is this machine, whose ABI is used automatically, and a gap is a warning: telo run here still fetches what it needs. Subsequent runs are idempotent — already-cached packages are skipped, and manifest cache files are overwritten with freshly fetched bytes.
telo install ./apps/my-app/telo.yaml
telo install ./apps/a/telo.yaml ./apps/b/telo.yamlEnvironment:
TELO_PKG_MANAGER— Override the package manager invoked for controller installs. Defaults tonpm. Set topnpm(or any compatible CLI) when the runtime environment ships a different manager.
The cache lives under the cache root — .telo/ beside the manifest, or beside telo-workspace.yaml when one sits above it (see Global options):
.telo/npm/— controller node_modules tree (one realm per manifest), for the modules that still deliver their controller from npm..telo/controller-src/— bundles built from a local module's controller sources. Only ever written for a module that is a working copy on disk: a published module ships a prebuilt bundle in its artifact and never reaches this path. Each entry is named by a digest of every input the build read, so an edit anywhere in the source graph yields a new entry rather than invalidating one..telo/manifests/oci/<host>/<repo…>/<tag>/telo.yaml— manifests imported from an OCI registry..telo/manifests/url/<host>/<pathname>— manifests imported via raw HTTP URLs..telo/manifests/.origins.json— for each cached import named by a mutable tag, the OCI manifest digest that produced the copy.telo checkrevalidates against it with oneHEADper reference; a pinned import needs no entry, since its bytes are verified against the ref's own hash.
Every manifest entry is keyed <transport>/<host>/<path…>/<version>/<file>, the same grammar the
discovery hub uses for its cached manifests.
The whole .telo/ tree is portable: COPY the directory that holds it into your image and both caches travel with it. Without a workspace marker that is the manifest's own directory; with one it is the marker's — copy that, or pin the location with TELO_CACHE_DIR in both build and production stages.
Example output:
Installing 20 controllers for apps/my-app/telo.yaml
✓ pkg:telo/local/js?path=./nodejs/server.mjs
✓ pkg:telo/local/js?path=./nodejs/request.mjs
...
✓ 20 installed in 3.2sA bundled controller (pkg:telo/local/js) ships inside its module's own artifact, so
"installing" it is materializing that artifact's controller layer — nothing is fetched
from npm. A pkg:npm candidate still resolves through the npm cache above.
Typical Dockerfile usage:
FROM telorun/node:latest-slim as build
WORKDIR /srv
COPY apps/my-app/ apps/my-app/
COPY modules/ modules/
RUN telo install apps/my-app/telo.yaml
FROM telorun/node:latest-slim as production
WORKDIR /srv
COPY --from=build /srv /srv
CMD ["telo", "apps/my-app/telo.yaml"]Available image variants:
telorun/node:<version>— debian base, no rust toolchain.telorun/node:<version>-slim— debian-slim base, no rust toolchain (smallest footprint; recommended for production).telorun/node:<version>-rust-<rust-version>— slim + rust toolchain (controllers that compile native deps at install time).telorun/node:<version>-rust-<rust-version>-slim— the same image, under its historical name.
Pin to an exact CLI version for reproducible builds; latest, <major>, and <major>.<minor> are rolling tags.
The build stage materializes .telo/npm/ and .telo/manifests/ under the cache root; the production stage is a single COPY and does no network I/O at boot.
telo upgrade <paths..>
Scans one or more manifests for remote imports: entries — an OCI ref (oci://host/repo@tag) — asks each ref's transport for the latest published version, and rewrites the source in place when a newer version is available. Both the scalar shorthand (Alias: <src>) and the object form (Alias: { source: <src>, … }) are handled. Version enumeration, ref reconstruction, and integrity hashing are all delegated to the transport that owns the ref's scheme, so every backend Telo can resolve is also upgradeable — the command never special-cases a scheme. The rewrite operates at the byte level: only the version characters of changed source values are spliced into the original file. Comments, indentation, folded block scalars (>- / |), quote style on the source value, and every other byte outside the rewritten ranges are preserved exactly. The on-disk YAML is mutated only when at least one import in the file changes.
Accepts the same path shapes as check / install: a manifest file, a directory containing a telo.yaml, or several of those mixed. By default only the imports declared in the files you pass are inspected; pass --recursive / -r to also follow relative (local) imports into their sibling manifests and upgrade those too.
telo upgrade ./apps/my-app/telo.yaml
telo upgrade ./apps/my-app # directory → ./apps/my-app/telo.yaml
telo upgrade ./apps/a ./apps/b --dry-run
telo upgrade ./apps/my-app --recursive # follow ./relative imports too
telo upgrade ./manifest.yaml --include-prereleaseOptions:
--include-prerelease— Consider versions with a SemVer prerelease segment (e.g.1.0.0-beta.1) when picking the latest. Off by default — prereleases are ignored unless the flag is set.--dry-run— Show the proposed rewrites without touching any files.--recursive,-r— Follow relative (local) imports and upgrade their manifests too. Cycle-safe, and each file is upgraded at most once even when reached from several manifests. Remote refs (OCI / HTTP) are always upgraded in place; recursion only descends into on-disk siblings.
Behavior per import:
| Pinned version state | Action | Log marker |
| --- | --- | --- |
| Equal to the latest published | leave unchanged | = already at <ver> |
| Lower than the latest, and itself published | rewrite to latest | ↑ <old> → <new> |
| Not present in the origin's version list | rewrite to latest (repair) — flagged with (pinned version not published). Direction can be downward if the broken pin is higher than anything published. | ↑ or ↓ |
| Module not found (404) / no eligible versions after filtering | leave unchanged, report | ! no published versions |
| Remote ref with no comparable version — a bare https:// URL, an OCI digest pin (@sha256:…), or a moving tag like latest | leave unchanged | · skipped (not version-pinned) / ! unparseable current version |
| source is a relative / absolute local path | leave unchanged (or follow under --recursive) | · skipped (local import — use --recursive to follow) |
| source is not a remote ref at all | leave unchanged | · skipped (not a remote ref) |
A non-existent pin is always treated as broken and repaired against the origin — leaving an unbootable pin in place would defeat the point of the command — but the rewrite is annotated so the action is visible. Network or non-404 errors are surfaced per import and produce a non-zero exit code; other imports in the same file still get processed.
Example output:
Upgrading apps/my-app/telo.yaml
↑ oci://ghcr.io/telorun/run 0.2.4 → 0.2.7
↑ oci://ghcr.io/telorun/http-server 0.19.1 → 0.20.0
= acme/lib already at 2.0.0
↓ oci://ghcr.io/telorun/foo 9.9.9 → 0.4.1 (pinned version not published)
! oci://ghcr.io/telorun/does-not-exist no published versions
· ../sibling skipped (local import — use --recursive to follow)
3 upgraded, 1 already current, 2 skippedtelo migrate <paths..>
Rewrites legacy spellings in a manifest to their current form.
Telo occasionally renames something a manifest can say — a schema annotation, a keyword, a value grammar. Because published artifacts carry the old spelling and cannot be edited, the loader rewrites every legacy spelling in memory on the way in: the old form and the new one behave identically, on every kernel. telo migrate applies the same rewrites to your file, so the source says what the runtime already reads.
Running it is never a prerequisite for your manifest to work. It is a repair of the text, offered because telo check reports each legacy spelling as a deprecation warning and a warning you cannot act on is not actionable.
telo migrate ./manifest.yaml
telo migrate ./apps/my-app # directory → ./apps/my-app/telo.yaml
telo migrate ./apps/a ./apps/bThe rewrite operates at the byte level, exactly as telo upgrade's does: comments, indentation, folded block scalars (>- / |), quote style and every byte outside the rewritten ranges are preserved. A file with nothing to migrate is not touched.
Scope. Only the manifest you name and its include: partials are rewritten. Imported modules are left alone — a published dependency is not yours to fix, and its author is the only person who can. That is the same rule that decides which deprecation warnings telo check shows you.
Example output:
ref-slot-scalar-type modules/mine/telo.yaml 3 rewrites
3 rewrites in 1 file. Imported modules were not touched.A rewrite the loader applies in memory can still be unwritable in place — a flow-style sequence has no item line to extend, a block scalar's span covers the newline that ended its mapping entry, and two rewrites whose byte spans overlap cannot both be spliced. Those locations are reported on stderr rather than skipped silently, since the diagnostic that sent you here says to run this command; each needs a hand edit:
! ref-slot-scalar-type modules/mine/telo.yaml targets could not be rewritten in place (the YAML there cannot carry the edit) — fix it by handUnder -o json the payload is { ok, rewrites, unwritable, files: [{ file, rewrites: [{ migration, count }], unwritable: [{ migration, path }] }] }.
telo package <manifest>
Build a single executable that runs one application on a machine with no Node.js, no telo and no network. The file is the released telo binary for the chosen platform with the application's payload inside it: the manifest, every local file its graph reaches, and the whole resolved import closure, already warmed.
telo package ./telo.yaml --out dist/orders
telo package ./telo.yaml --out dist/orders --platform linux/arm64
telo package inspect dist/ordersOptions:
--out <file>- Where to write the executable. Required;.exeis added for a windows platform.--platform <os/arch[/libc]>- The platform to package for (linux/amd64,linux/arm64/musl,darwin/arm64, …), in the same vocabularytelo install --platformtakes. Defaults to the host; on linux an omittedlibcmeansgnu. A darwin platform can only be packaged on a macOS host, because the payload rides in a Mach-O segment and the binary is re-signed afterwards.
The binary carries the telo that packaged it, enforced rather than assumed: there is no flag to choose another version, and only a released telo may download a carrier. A working copy and the release of the same version number are different code, so from a source checkout you build that checkout's own binary (pnpm --filter @telorun/cli build:standalone) and package with it; that binary is the carrier for its own platform, and packaging for another platform needs an installed release. A payload carries only controllers that are files on the target machine: a closure whose kind is delivered from npm (pkg:npm) or built from a crate (pkg:cargo) is refused, naming the kind and the candidate.
Running the result: every argument belongs to the application, .env files are read from the working directory, and signals and exit codes behave exactly as under telo run. TELO_APP_INFO=1 ./orders prints what the binary carries; TELO_APP_DIR moves the directory it unpacks into (~/.cache/telo/apps/<name>-<digest> by default). TELO_CACHE_DIR is ignored inside a packaged app — the payload is its cache.
Full guide: Packaging an application.
telo run <path> (default)
Load and run a Telo manifest. run is the default command, so telo ./manifest.yaml and telo run ./manifest.yaml are the same thing.
Arguments:
path- Path to a YAML manifest file, a directory containingtelo.yaml, or an HTTP(S) URL.
Options:
--watch, -w- Watch manifest file(s) for changes and restart automatically--debug- Write every kernel event, in order, to.telo.debug.jsonlnext to the manifest. No network, no UI — the file is the machine-readable record of a run.telo runis exempt from-o json(the app owns stdout/stderr), so this is the machine surface for a run.--inspect[=[host:]port]- Start the live inspection endpoint (default127.0.0.1:9230), serve the inspection UI and open it in a browser. Holds the app open even when it would otherwise exit. The endpoint streams event payloads — inputs and outputs of every call — so binding a non-loopback host is warned about; mark credentials withx-telo-sensitiveso they ride the wire as[redacted].--no-open- With--inspect, do not open the browser.--no-cache-write- Validate in-memory and read the existing.telocache, but never persist new derived entries (compiled validators, analysis stamp). For ephemeral, read-only runs (e.g. a prebuilt container whose deps are baked atTELO_CACHE_DIR); the cache is still used, only writes are suppressed.--- Everything after it is handed to the kernel as the application's own arguments, for controllers that read them.
See Global options for --verbose, -o and the cache root.
telo search <query>
Search resource kinds across every module the hub tracks, matched on name and description. This is how you find a kind before you know which module owns it.
Arguments:
query- What the resource should do, in plain words.
Options:
--kinds- Flat kind hits, one line per resource kind, instead of grouping by module--json- Emit the hub response as JSON--hub-url <url>- Base URL of the hub. OverridesTELO_HUB_URL.
telo search "key value store"oci://ghcr.io/telorun/kv-store@<version> — Durable key/value storage with atomic conditional writes…
Store (Provider) A configured durable key/value store: read a value with its version token…
oci://ghcr.io/telorun/cache@<version> — Caching of computed or fetched values under a key…
Store (Provider) A configured cache backend that holds values under a key…
Lookup (Invocable) Reads a cached key and reports the outcome as a miss, a fresh hit or a stale hit…Each hit prints the exact ref to paste into your imports: map, and the kinds that ref makes available.
telo module <subcommand> <ref>
Inspect a module without importing it. <ref> is any form the runtime resolves: a local path, an oci:// ref, or a direct URL.
| Subcommand | What it prints |
| --- | --- |
| versions | The module's published versions, newest first (one entry for a local path or direct URL) |
| manifest | The module's telo.yaml, verified against the inline hash when the ref is pinned |
| digest | The version's content-identity digest — a cheap read that downloads no payload |
| resources | The resource instances the module declares |
| kinds | The resource kinds the module defines, with capability and whether each is exported |
telo module versions oci://ghcr.io/telorun/console
telo module kinds oci://ghcr.io/telorun/console@<version>telo cel <subcommand>
Inspect and evaluate Telo's CEL environment — useful for checking an expression's syntax and result without wiring it into a manifest first.
| Subcommand | What it does |
| --- | --- |
| functions | Lists the CEL standard-library functions available in manifests |
| eval <expression> | Evaluates an expression — the body of a !cel scalar |
telo cel eval "'a-' + string(1+2)"
# a-3eval runs with no manifest loaded, so variables, resources, steps and the rest of the manifest scope are not available; it is for the pure-expression half of CEL.
Examples
Simple HTTP Server
Create a file server.yaml:
kind: Telo.Application
metadata:
name: Example
version: 1.0.0
imports:
Http: oci://ghcr.io/telorun/http-server@<version>
Run: oci://ghcr.io/telorun/run@<version>
targets:
- !ref Server
ports:
http:
env: PORT
default: 8080
variables:
audience:
env: AUDIENCE
type: string
default: World
---
kind: Http.Server
metadata:
name: Server
port: !cel "ports.http"
mounts:
- path: /api
mount: !ref HelloApi
---
kind: Http.Api
metadata:
name: HelloApi
routes:
- request:
path: /hello
method: GET
handler:
kind: Run.Value
# `bindings:` names a temporary value: computed once, read by bare name
# wherever this resource's expressions need it.
bindings:
who: !cel "variables.audience"
value:
message: !cel "'Hello, ' + who + '!'"
greeted: !cel "who"
returns:
- status: 200
content:
application/json:
body:
message: !cel "result.message"Run it:
telo server.yamlAccess it at http://localhost:8080/api/hello
Watch Mode for Development
telo --watch ./manifest.yamlIn watch mode, the manifest is reloaded and the kernel restarted whenever any manifest files change. This is useful while developing.
Remote Manifests
You can run manifests directly from URLs without downloading them:
telo https://example.com/my-manifest.yaml