Elide embeds a compiler, a Maven resolver, a container assembler and a test runner. It does not embed protoc, shellcheck, or whatever your project needs next. A build extension is how you teach your build about one — as a JavaScript or TypeScript module Elide evaluates while it configures the build.
An extension declares tasks. Each one becomes a real task in the graph: you can name it as a build target, depend on it from another task, put it in a group, and see it in elide build --inspect.
Declaring an extension
List it in your manifest, relative to the project root:
amends "elide:project.pkl"
name = "demo"
extensions {
"tools/protoc.ts"
}Extensions are declared, never discovered, so running one is always something your project asked for. A path that does not exist fails the build rather than quietly registering nothing.
A path is resolved against your project root and has to stay inside your source tree — your project root, or the workspace root when your project is a member of one, so members can share a file above their own roots (../tools/protoc.ts). An absolute path outside the tree, a path that climbs out of it, and a symlink that points out of it are all refused, each reported like any other bad declaration.
A declaration names one of two things, and Elide decides which from the file extension alone: an ES module — .ts, .mts, .js, .mjs, .jsx, .tsx — or a .elx package archive (see Packaging an extension). .cjs is not accepted: the contract is a default export, which a CommonJS script cannot declare, and a .js extension is always read as a module whatever the nearest package.json says. Anything else fails the build rather than being guessed at.
An entry does not have to be a path at all: it may name a package to download from a URL, npm or Maven. See Fetching an extension.
Writing one
export default ({ task }: ElideBuild) => {
task(
"protoc",
{
description: "Generate protobuf sources",
group: "compile",
inputs: ["proto/**/*.proto"],
outputs: ["src/main/generated"],
},
(ctx) => {
const result = ctx.exec("protoc", [
`--kotlin_out=${ctx.layout.projectDir}/src/main/generated`,
"proto/api.proto",
]);
if (!result.ok) ctx.fail(result.stderr);
},
);
};Then:
elide build --inspect # protoc Generate protobuf sources
elide build protoc # run just that task
elide build compile # run it with everything else in its groupTypes ship in @elide-dev/types as ElideBuild, ElideBuildTaskContext and ElideBuildTaskOptions.
Your module’s default export runs while the build is being configured, and may be async. Each task’s action runs when the task does, and may also be async; the build waits for either.
Packaging an extension
An extension that outgrows a single file ships as an archive: a zip with a manifest named extension.json at its root, named .elx. The name is not decoration — the declaration’s file extension is what tells Elide it has a package, and .elx is the only one that does, so tools/protoc.elx loads and the same bytes named tools/protoc.zip do not.
protoc-tasks/
├── extension.json
├── tasks.ts
└── lib/
└── flags.ts{
"name": "protoc-tasks",
"version": "1.0.0",
"description": "Generates sources with protoc",
"license": "Apache-2.0",
"repository": "https://github.com/example/protoc-tasks",
"homepage": "https://example.dev/protoc-tasks",
"authors": ["Example Inc."],
"type": "source",
"entrypoint": "tasks.ts"
}The archive’s manifest requires name and type. type is source — the one kind there is — and a source package must also name an entrypoint, a path relative to the archive root; one that points outside the archive is rejected. Everything else is metadata Elide reports back to you, and keys it does not know are ignored, so a package may carry keys of its own.
Building the package
Declare the package as an artifact and elide build produces it:
amends "elide:project.pkl"
import "elide:Extensions.pkl" as Extensions
name = "protoc-tasks"
version = "1.0.0"
description = "Generates sources with protoc"
artifacts {
["tasks"] = new Extensions.ExtensionArchive {
entrypoint = "tasks.ts"
srcs { "lib/**/*.ts" }
license = "Apache-2.0"
repository = "https://github.com/example/protoc-tasks"
authors { "Example Inc." }
}
}That registers a task named package-extension-tasks, run by a plain elide build, which writes .dev/artifacts/tasks.elx. The extension.json above is written for you: name, version and description fall back to the project’s own, and type is always source.
entrypoint is the module whose default export declares your tasks. srcs are the modules it imports, as paths or glob patterns relative to the project root; the entrypoint itself is always packaged. Files keep the paths they have in your project, so relative imports resolve inside the archive exactly as they do in your checkout. Every packaged file must be inside the project once its links are resolved: a symlink pointing out of the project fails the build rather than shipping what it points at.
You can of course still build the zip yourself, from the directory holding the manifest, so that extension.json lands at the root:
cd protoc-tasks && zip -r ../tools/protoc.elx .A source package
"type": "source" means the archive carries your source, and the entrypoint’s file extension decides how it is loaded — exactly as it does for a file you declare directly. A .ts, .tsx, .mts, .js, .jsx or .mjs entrypoint is evaluated as a guest module, and because Elide unpacks the archive before evaluating it, relative imports inside the package (import { flags } from "./lib/flags.ts") resolve.
Nothing is bundled or compiled on the way in: a package ships the sources you wrote, and you write the same extension whether it travels as one file or as an archive — the same default export, the same task(...), the same ctx.
Declaring a package
Like a local extension — a path, with settings if it takes them — or as something to fetch:
extensions {
"tools/shellcheck.elx"
new {
from = "tools/protoc.elx"
config { greeting = "hei" }
}
}Elide unpacks the archive under your build directory, in .dev/extensions/packages/<archive>-<hash>/contents/, and unpacks it again whenever the archive changes. An entry that would be written outside that directory is rejected rather than unpacked.
What --inspect reports
elide build --inspect prints an Extensions: section covering everything your manifest declared. For each one it shows the declaration you wrote, the package’s name and version, its description, what kind of extension it is — JavaScript/TypeScript module or packaged extension — its license, its repository, and the names of the tasks it declared.
Extensions:
tools/protoc.elx protoc-tasks 1.0.0
Generates sources with protoc
packaged extension · Apache-2.0 · https://github.com/example/protoc-tasks · Example Inc.
tasks: protoc-generate, protoc-cleanDistributing the archive is up to you: attach it to a release, serve it from a URL, publish it as an npm package with whatever you already use. However it travels, a project consumes it the same way — see Fetching an extension.
Fetching an extension
An extension does not have to live in your project. A declaration that opens with one of three reserved schemes names something to download:
extensions {
"url:https://downloads.example.com/protoc-tasks-1.4.0.elx"
"npm:@acme/protoc-tasks@1.4.0"
"maven:com.acme:protoc-tasks:elx:1.4.0"
new {
from = "npm:@acme/codegen@2.0.1"
config { proto = "src/proto" }
}
}Only url:, npm: and maven: are reserved. Everything else is a path, so C:\tools\ext.ts still means what it says, and a scheme Elide adds later cannot change how a manifest that already builds is read.
Past the download nothing is different. A fetched .elx loads as a package and a fetched .ts as a guest module — what it resolved to decides, not how it arrived. A url: must be an absolute https URL; cleartext http is refused except to a loopback host, which is there so you can test against something local. Redirects are followed — up to five hops — and every hop is held to that same rule, so a redirect cannot carry a loopback fetch out to another machine in cleartext.
Versions are pinned
A remote version names exactly one release. npm:tasks and maven:com.acme:tasks are rejected by the manifest schema for naming none; npm:tasks@^1.2, npm:tasks@latest and maven:com.acme:tasks:1.2.3-SNAPSHOT are rejected for naming more than one.
That is the cache’s doing. A fetched extension is stored under what you declared, so a moving version would either cost a registry round trip on every build — making --offline impossible — or silently freeze at whatever your first build happened to see.
Where it is cached
Fetched artifacts land in .dev/dependencies/extensions/remote/, beside your resolved Maven repository and shared by a whole workspace the same way. Because the declaration is pinned, what is cached is never stale: only the first build reaches the network, and elide build --offline works from then on. A declaration that has not been fetched and cannot be fetched says so:
Build extension 'npm:@acme/protoc-tasks@1.4.0' has not been downloaded, and --offline forbids fetching it
Run the build once without --offline to populate the cache.Whatever digest the source publishes is checked: npm’s dist.integrity or dist.shasum, and Maven’s .sha1 sidecar. A repository that serves an artifact but no digest is fine; one that serves both and disagrees with itself fails the build.
Pinning the bytes
A version pin says which release runs; an integrity pin says which bytes. Spell it as Subresource Integrity — sha256-<base64> or sha512-<base64> — beside the source it belongs to:
extensions {
new {
from = "url:https://downloads.example.com/protoc-tasks-1.4.0.elx"
integrity = "sha512-Ln7jRoUMYMK7TBZ0bNyPmrHDCgAsQ3bIt5nZeUuzpuGqBbw1zYWTPdA2tRbClC2CoRP3UExpHnCw+5kZLgP0lQ=="
}
}A url: is trusted on TLS and its host alone, so a pin is what makes it reproducible. It is checked before the download is cached and again on every cache hit, and an npm package runs from a tree extracted out of those same verified bytes, so neither the server nor the workspace’s shared cache can change what runs behind your back. A cached file that no longer matches the pin is downloaded again and replaced; under --offline the build fails instead, naming the cached file. A pin stacks with whatever the registry publishes rather than replacing it: an npm or Maven digest is still checked.
Only a fetched source takes one. Pinning a path under your project is rejected — its bytes are already whatever your tree holds.
A download is bounded in size and in time: a response over 64 MiB fails, and so does a host that answers and then stalls, rather than hanging your configuration.
npm packages
The tarball is cached once for the workspace and extracted into your project’s build directory, under .dev/extensions/npm/<name>/<version>/contents/, and loaded from its entrypoint: the one extension.json names if the package ships one, otherwise package.json’s main, otherwise index.js. A package carrying an extension.json is a package in the full sense, read exactly as a .elx is — one that is invalid, or names a type this Elide does not know, fails the build rather than falling back to main.
Resolution walks three steps, in order: dependencies.npm.registry when your manifest sets one, then $ELIDE_PKGST_NPM_ENDPOINT — the same variable Elide’s own npm resolver reads — and failing both, Elide’s npm.pkg.st proxy, or the public registry at registry.npmjs.org when you pass --use-pkgst=false.
dependencies {
npm {
registry = "https://npm.internal.example.com"
}
}Maven artifacts
The coordinate is fetched from the repositories you already declare — dependencies.maven.repositories, plus Elide’s proxy and Maven Central unless enableDefaultRepositories is off — using the credentials those repositories carry. Credentials go only to the repository’s own scheme, host and port; a redirect anywhere else is followed without them. The usual [:packaging[:classifier]] sits between artifact and version, and a coordinate that names no packaging takes elx: maven:com.acme:tasks:1.4.0 fetches tasks-1.4.0.elx, the same as maven:com.acme:tasks:elx:1.4.0. A packaging Elide cannot load (maven:com.acme:tasks:zip:1.4.0) is rejected as any unloadable declaration is.
Repositories are tried in order. One that answers with an error, a response that is too large or stalls, or a redirect that is refused is passed over for the next; a .sha1 it publishes that disagrees with the artifact fails the build.
One artifact is fetched, and no dependency graph is resolved, exactly as for an archive you declare by path. An extension that needs libraries ships them inside its package.
Configuring an extension
An extension that takes settings is declared as a block rather than a bare path, and carries them with it. Elide does not interpret them:
extensions {
"tools/shellcheck.ts"
new {
from = "tools/protoc.ts"
config {
version = "25.1"
plugins = new Listing { "grpc" }
}
}
}That block reaches that extension — and only that extension — as config, while it declares tasks and while they run:
export default ({ task, config }: ElideBuild) => {
task("protoc", { description: `protoc ${config.version}` }, (ctx) => {
const { version, plugins } = ctx.config;
});
};An extension you declared as a bare path reads an empty config, so checking a setting is undefined is all a default takes.
An extension that wants its settings typed and documented ships a Pkl module of its own, which your project copies in, imports and fills in. A manifest imports local files, the Pkl standard library and Elide’s own schema — never a package:// or other network URI — so the module lives in your project:
import "tools/Protoc.pkl"
extensions {
new {
from = "tools/protoc.ts"
config = new Protoc.Settings { version = "25.1" }
}
}Entries are distinct by path however you write them, so the same extension cannot be declared — or configured — twice.
Write a list inside a block as a Listing (or List("grpc")). Inline elements — plugins { "grpc" } — would reach your extension keyed by position rather than as a list, so the manifest rejects them and tells you the spelling that works.
Settings are data. A value may be a string, a boolean, a number, a Duration, a DataSize (which reaches your extension as a byte count) or a Regex, or a block, Listing, List, Set, Map or Pair of those. A Bytes, a class, a type alias or a function is a manifest error naming where it sits, such as config.flags.blob.
Task options
| Option | Meaning |
|---|---|
description | Shown by elide build --inspect. |
dependsOn | Task names that must complete first; a single name may be given without an array. |
before | Tasks this one must precede: a task name, or deps/compile/test for every task in that group. |
group | deps, compile or test to join a built-in group; any other name mints one of your own. |
default | Whether a bare elide build runs this task. Defaults to true. |
inputs | Project-relative paths or globs whose contents decide whether the task re-runs. |
outputs | Project-relative paths or globs the task writes. |
cache | Whether the build cache may archive this task’s outputs. Defaults to true. |
options | Command-line flags this task accepts (see below). |
inputs and outputs are one contract: declare both and Elide skips the task when nothing changed (see Build Tasks); declare one or neither and it runs every build. Your extension counts as an input too — the file itself and every module it imports by relative path — so editing the entrypoint or one of its helpers re-runs the tasks it declares.
A task that declared both is also cached like Elide’s own: its outputs are archived when it succeeds and restored on any later build whose inputs fingerprint the same, including in a fresh checkout. Set cache: false to keep the up-to-date check and skip the archive — the right call when your outputs depend on something your inputs do not name.
What a declaration stamps as is hashed from the mode and contents of every file it names, and your action reads the same files and the same stamps through ctx.inputs and ctx.outputs — see The files a declaration names.
Flags your task accepts
A task that reads a flag should say so. Declared flags are printed by elide build --inspect and by elide build <task> --help, and checked before anything runs — so a misspelling is reported rather than silently dropped:
task(
"publish",
{
options: [
{ name: "channel", description: "Channel to publish to", arity: "required" },
{ name: "tag", description: "Extra tag to push", arity: "required", repeatable: true },
{ name: "sign", description: "Sign the release", negatable: true, default: "true" },
],
},
(ctx) => {
ctx.option("channel"); // first value, or the declared default
ctx.values("tag"); // every value, in command-line order
ctx.flag("sign"); // `--no-sign` reads false; absent reads the default
},
);| Field | Meaning |
|---|---|
name | The flag, without dashes and in kebab-case: "dry-run" is --dry-run. |
description | Shown by --inspect and --help. |
arity | "none" is a bare flag, "required" needs --flag=value, "optional" takes either. Defaults to "none". |
repeatable | Whether the flag may be passed more than once. Defaults to false. |
negatable | Whether --no-<name> is accepted, reading as "false". Defaults to false. |
default | What a read falls back to when the flag was not passed. |
keyed | Whether what the flag was passed decides what the task produces. Defaults to true. |
--inspect and --help print the declaration as it has to be typed, so the three above read --channel=<value>, --tag=<value> (repeatable) and --sign (or --no-sign) [default: true].
A flag nothing declares fails the build, with the closest declared name when there is one; so does a value given to a bare flag, a required flag left bare, and a repeat of a flag that takes one value. Elide’s own flags — --verbose, --project, --offline and the rest — are always accepted.
ctx.option, ctx.flag and ctx.values read through the declaration, which is what makes --no-sign and a default readable at all: neither is anything a command line says on its own. A name your task did not declare still reads from the command line as parsed.
A declared flag is part of what decides whether your task re-runs: the values it resolved to are recorded alongside your inputs, so a build under --channel=stable is never reported up to date — nor handed what --channel=canary cached — for one that ran under the other. Set keyed: false on a flag that changes how loudly the task talks rather than what it writes, or every build that spells it differently misses the cache for nothing. A flag your action reads without declaring decides nothing at all.
Running before another task
dependsOn names what has to run first. before names what has to wait — including tasks you did not declare, which is what a codegen task needs:
task(
"generate-sources",
{ before: "compile", default: false, outputs: ["src/main/generated"] },
(ctx) => { ... },
);compile, deps and test stand for every task in that group, which is what “before compilation” has to mean: the real keys are per-language and per-source-set (compile-kotlin-main, compile-java-test). Any other name is a single task’s, and a name nothing registered fails the build instead of quietly running nothing. In a workspace, an extension a member declares selects only that member’s tasks, while one the root manifest declares selects the group across every member.
An edge like this also pulls your task into any build that runs the task it precedes — elide build compile above runs generate-sources first, even though it opted out of default.
Incremental builds
A running task is told what the build found stale, and can keep values for its next run:
task("codegen", { inputs: ["proto", "schema"], outputs: ["gen"] }, (ctx) => {
for (const input of ctx.dirty.inputs) ctx.log(`changed: ${input}`);
if (ctx.dirty.outputs.length) ctx.log("an output is missing or was edited");
if (ctx.dirty.extension) ctx.log("this extension or one of its imports changed");
ctx.log(`last built version ${ctx.state.get("version") ?? "(never)"}`);
ctx.state.set("version", ctx.manifest.version);
});ctx.dirty names only the declarations that changed; on a first run, everything you declared is listed. ctx.state holds strings, numbers, booleans, null, arrays and plain objects, keyed by name, and get answers null for a key nothing stored. A function, a symbol and undefined have no stored form, and a value that contains itself is refused rather than followed. A run that fails discards the task’s state entirely, including what earlier runs recorded.
What a task remembers is a memo, not an input: it never decides whether the task re-runs, and it never moves the task’s cache key.
The files a declaration names
ctx.inputs and ctx.outputs are the declarations themselves, resolved and stamped — the same walk and the same digest the build ran to decide whether your task was up to date:
task("codegen", { inputs: ["proto"], outputs: ["gen"] }, (ctx) => {
for (const file of ctx.inputs.files()) {
ctx.log(`${file.path} ${file.size}b ${file.stamp}`);
}
const before = ctx.state.get("proto") ?? {};
const after = Object.fromEntries(ctx.inputs.files().map((f) => [f.path, f.stamp]));
for (const path of Object.keys(after)) {
if (after[path] !== before[path]) ctx.exec("protoc", ["--kotlin_out=gen", path]);
}
ctx.state.set("proto", after);
ctx.log(`produced ${ctx.outputs.stamp()}`);
});files() lists every regular file the declarations name, project-relative and /-separated, sorted and without duplicates. Each entry carries path, absolute, size, modified and stamp — a SHA-256 over that file’s mode and contents. stamp() is the digest over the whole set.
Both take patterns of their own, which need not be ones you declared: ctx.inputs.files("proto/api/**/*.proto") narrows a listing, and ctx.inputs.stamp("proto") is exactly the fingerprint the build recorded for that declaration. Both resolve when you call them, so reading ctx.outputs after writing your outputs describes what you just produced.
Stamps are content-addressed: an edit that leaves a file’s size and modification time alone still changes them, and still re-runs your task. ctx.dirty names a declaration as a whole; a per-file stamp kept in ctx.state, as above, is how a task tells which of its files moved.
Declarations the build has to compute
Some of what decides whether a task is current cannot be written in the declaration: a generated tree, a classpath another task resolved, the version of the tool you are about to run. Give the task a prepare hook and declare it there:
task(
"package",
{
outputs: ["dist"],
prepare: (ctx) => {
ctx.inputs.add("gen/**/*.kt");
ctx.inputs.value("classpath", ctx.state.of("resolve")?.get("classpath"));
ctx.outputs.add(`dist/${ctx.manifest.version}.tgz`);
},
},
(ctx) => {
for (const changed of ctx.dirty.values) ctx.log(`${changed} changed`);
},
);What a hook adds is a declaration like any other: a pattern is stamped, named by ctx.dirty.inputs and hashed into the task’s cache key exactly as a declared one is. ctx.inputs.value(name, value) declares an input that is not a file — anything the build can compute but not stat — and a change to it re-runs the task and keys its archive differently; ctx.dirty.values names which one changed. A task whose declaration is completed this way is tracked, cached and skipped as up to date like any other.
The hook runs on every build your task takes part in, including the ones where it turns out to have nothing to do, so keep it to deriving declarations: it has no exec, no tools and no dirty — that is the answer it is helping to compute — and ctx.state is read-only there. Returning false fails the task before it runs.
Your action can add outputs it only learns by writing them, with the same ctx.outputs.add(...); they are stamped and archived with the rest when it returns. It cannot add inputs — by then the build has already decided whether to run it, so a late ctx.inputs.add is an error rather than a declaration that quietly does nothing.
Passing a value to another task
Your extension’s module is evaluated once per build and shared by every task it declares, so a module-level variable one task assigns is often still there for the next — but nothing guarantees it, and a fresh evaluation starts from nothing. Treat module scope as a cache, never as a channel. What a task holds in ctx.state is the supported way to pass a value on:
task("stage", (ctx) => {
ctx.state.set("staged", `${ctx.layout.buildDir}/staged`);
});
task("publish", { dependsOn: "stage" }, (ctx) => {
const stage = ctx.state.of("stage");
if (stage === null) return ctx.skip("nothing has been staged");
ctx.exec("rsync", [String(stage.get("staged")), "example:/srv"]);
});ctx.state.of(name) answers a view of what that task is up to — get, has and keys — and null when it published nothing at all. A task the build skipped as up to date, or restored from the cache, answers with what it kept the last time it ran: being up to date is a statement about what it produced, and what it published is part of that. null means there was nothing to read — the name is not a task an extension declared, it was not in this build, it failed, or it has not run yet — while a view with no keys is a task that kept nothing. Depend on the task you read; without an edge, “has not run yet” is a race.
A value crosses as the structure it was, not as text, and the same rules as ctx.state apply to what can cross. Reading your own name answers what you hold right now.
What your task is given
The object passed to your action carries the same things the build itself reads:
project— name, version, description, root directory, and the workspace it belongs to.manifest— your resolvedelide.pkl, as ordinary JavaScript data. This is exactly whatelide project export --format=jsonwrites, somanifest.dependencies.maven.packages.map(...)works as you would expect.config— the settings your manifest declared for this extension (see above), empty when it declared none.layout—projectDir,buildDir,artifacts,stagingand friends, as absolute paths.option(name)/flag(name)/values(name)— the flags this invocation was run with, read through your declaration (see above);optionsis the raw parse, for anything you did not declare.task— your own declaration, as the build recorded it, including the options it declares.dirty/state— what changed since your task last ran, what it kept, and what other tasks in this build recorded (see above).
Reporting
| In your action | What the build records |
|---|---|
return, or return true | success |
ctx.fail(reason) | failure, with that reason |
return false | failure, with no reason |
ctx.skip(reason) | skipped, with that reason |
| throw, or reject | failure, with its message |
ctx.exit(code, reason) | failure exiting with code |
ctx.log, ctx.warn and ctx.error write lines attributed to your task. They are build messages, so they go to standard error along with every other build message; standard output carries only what a program you ran writes.
Exiting with a specific status
ctx.exit(code, …message) fails the task and asks the build to exit with code. 0 succeeds instead, which is what makes this the whole pass/fail decision for a task that wraps a program:
ctx.exit(ctx.exec("shellcheck", ["scripts/build.sh"]).exitCode);Reach for it when a status carries meaning a boolean would destroy. A correctness ratchet that exits 2 because the analyzer crashed and 1 because it found a new violation is only useful if the caller can still tell those apart; so is a diagnostic that exits 2 for “not supported on this host” and 1 for “found something”.
The code reaches the process only when your task is the one that stopped the build — another task failing first wins. A code outside 0-255 wraps the way a shell reports one, and never wraps to zero.
Running external tools
const result = ctx.exec("shellcheck", ["scripts/build.sh"], {
cwd: "tools", // relative paths resolve against the project root
env: { CI: "1" }, // added on top of the inherited environment
quiet: false, // set true to keep the program's output out of the build log
timeout: 30_000, // milliseconds; the child is killed and the task fails
});
result.ok; // exited zero
result.exitCode; // 0, 3, …
result.stdout; // captured up to 1 MiB, even when not quiet; the rest is streamed only
result.stderr; // under the same capexec blocks until the program exits and streams its output to the build display as it arrives, attributed to your task. No shell is involved: arguments reach the program exactly as you wrote them, so nothing in a filename or a flag can be reinterpreted as shell syntax. A bare command name is looked up on the PATH the child will run with — the one your env sets, if it sets one — and a name containing a separator resolves against cwd. Cancelling the build kills the program and anything it started. timeout bounds the program you named; once it exits, output still arriving is read for one more second and then dropped, so a background process it left holding its output does not hold up your task — that process is neither waited for nor killed.
Everything guest JavaScript already has is available too — node:fs, node:child_process, fetch — so reach for exec when you want the output attributed to your task, and for node:child_process when you need something it does not cover. One rule comes with that: a child you give the build’s own terminal (stdio: "inherit") writes where the progress display is drawing, so hand it the terminal with ctx.foreground. A child whose output you pipe and print yourself needs nothing — your console output is routed through the display like any other.
Giving a program the terminal
const code = await ctx.foreground(
() =>
new Promise((resolve) => {
const child = spawn(bin, args, { stdio: "inherit" });
child.on("close", resolve);
}),
);The progress display stops drawing for exactly as long as the function runs, and comes back however it ends — so the program’s output and the display’s rows never erase each other. An async function keeps the terminal until its promise settles, and await ctx.foreground(...) answers what the function answered and rethrows what it threw.
Wrap the hand-off, not the whole task: everything your task does before it spawns — resolving paths, reading files, calling ctx.tools — should still be showing progress. Reach for it only when something writes to the build’s own descriptors, which is a child with inherited stdio or anything interactive like a REPL or a debugger. Output written with ctx.log, ctx.exec, ctx.tools or your task’s own console is already linearized against the display.
Using the built-in toolchain
Elide already carries a Kotlin compiler, javac, jar, Native Image, ktfmt and google-java-format. A task that needs one does not have to start another Elide to reach it:
// Before: a new process per compile, diagnostics as text on a stream.
const compiled = ctx.exec("elide", ["kotlinc", "-d", ".dev/classes", "-cp", cp, "src/main/kotlin"]);
if (!compiled.ok) return ctx.fail(compiled.stderr);
ctx.exec("elide", ["jar", "--create", "--file", ".dev/lib/app.jar", "-C", ".dev/classes", "."]);
// After: the same tools, in-process, with structured results.
const result = ctx.tools.kotlinc({
sources: ["src/main/kotlin"], // files, directories or globs, project-relative
out: ".dev/classes", // a directory, or a path ending in `.jar`
classpath: ctx.tools.classpath(), // the resolved compile classpath, in order
friendPaths: [".dev/classes-core"], // whose `internal` declarations this compile may see
args: ["-Xjvm-default=all"], // any further kotlinc flags, verbatim
});
if (!result.ok) {
const first = result.diagnostics.find((d) => d.severity === "error");
return ctx.fail(`${first?.file}:${first?.line}: ${first?.message}`);
}
ctx.tools.jar({
out: ".dev/lib/app.jar",
entries: [{ from: ".dev/classes" }, { from: "LICENSE", into: "META-INF" }],
mainClass: "com.acme.MainKt",
manifest: { "Implementation-Version": ctx.project.version ?? "dev" },
});Every call blocks until the tool returns, and answers { ok, exitCode, tool, diagnostics }. Compiler and formatter diagnostics are shown in the build output exactly as a built-in compile shows them, and come back in diagnostics as { severity, message, file, line, column }; jar and nativeImage produce text, which streams to the build log attributed to your task (pass quiet: true to keep it out) and leave diagnostics empty. Paths are project-relative unless absolute. You may write outputs anywhere, but only outputs under the project directory can be cached and restored, so keep them there when the task declares them.
kotlinc({ sources, out, classpath?, friendPaths?, args?, incremental?, plugins? })— the embedded Kotlin compiler. Compiler plugins your manifest declares or enables (kotlin.features,kotlin.plugins,mavenDependencies().kotlinPlugins) are applied as they are for the built-in compile, and the runtimes those features provide (Metro’s, for one) join the classpath with them — a plugin without its runtime fails the compiler rather than doing nothing.plugins: falseruns the bare compiler, which is what annotation-only sources want. No-jvm-targetis added unless you pass one inargs.javac({ sources, out, classpath?, processorPath?, args? })— the embedded Java compiler; aprocessorPathturns annotation processing on.jar({ out, entries, exclude?, date?, manifest?, mainClass?, quiet? })— each entry is a directory (its contents), a file, or another archive whose entries are taken (archive: true), landing at the archive root or underinto.Manifest-VersionandCreated-Byare always written;mainClassbecomesMain-Class.nativeImage({ args, cwd?, quiet? })— the embedded Native Image driver; relative paths inargsanchor oncwd, which defaults to the project root. The builder runs as a child JVM pinned to Elide’s own JDK.format({ tool, files, check?, args? })— the embedded formatters:toolis"ktfmt"for Kotlin or"javaformat"for google-java-format.filesare files, directories or globs, narrowed to what the formatter accepts (.kt/.ktsor.java); matching nothing succeeds and does nothing. Files are rewritten in place, or withcheck: trueonly reported, which fails the call when any file would change. Either waychangedlists the absolute paths of the files that were not formatted, and each arrives as a diagnostic: a warning undercheck, an info for a rewrite, an error for a file that could not be read or parsed.argspasses further flags to the formatter, such as["--google-style"].classpath({ sourceSet?, usage? })— the resolved dependency classpath as absolute paths in order.usageis"compile"(default; what the compile tasks see),"runtime","test"(the test set’s runtime classpath),"processor"or"modules". It answers what this build has resolved, so a task reading it shoulddependsOn: "maven-dependencies".
ctx.tools.version is the version of these option and result shapes — 1 today. It moves only on an incompatible change; a new optional option or result member does not move it. An extension that must run against more than one Elide branches on it.
The compilers run in-process only through the embedded toolchain. A manifest that selects a custom or managed kotlin.toolchain, or the external jvm.java.compiler.mode, gets an error from ctx.tools.kotlinc / ctx.tools.javac naming the task and the tool; drop the selection, or run that toolchain as a program with ctx.exec. The same is true of a distribution built without the JVM toolchain.
Repacking an archive
jar reads an existing archive as a source, so stripping a dependency down and stamping it reproducibly is one in-process call rather than an unzip / rm -rf / jar pipeline:
ctx.tools.jar({
out: ".dev/lib/compiler-slim.jar",
entries: [{ from: "libs/compiler.jar", archive: true }, { from: ".dev/patched" }],
exclude: ["com/intellij/**", "META-INF/*.SF"],
date: "2024-01-01T00:00:00Z",
});archive: true is never inferred from the file extension: without it, a .jar named by from is added as a single file. The source archive’s own META-INF/MANIFEST.MF is dropped — the result carries the manifest manifest and mainClass describe, never the source’s — and into re-roots everything else. A member whose path, after into, would land outside the archive (../x, or an absolute /x) fails the task rather than being written, and so does an into that climbs out.
exclude globs match the path an entry would take inside the archive, after into has re-rooted it. They are paths, not package names, so a package tree is "kotlin/**" and a file kind is "META-INF/*.SF". A glob on the options applies to every entry whatever its source; a glob on an entry applies to that entry alone, on top of them.
date stamps every entry, the manifest included, at that instant, and the tool writes a root’s files in sorted order — so a repack of unchanged input is byte-identical, which is what an AOT or distribution stamp needs. Without it entry times are not reproducible: an entry passed through untouched keeps its files’ modification times, while an archive member, an entry with into, and an entry an exclude or a collision touched carry the time of the call.
Entries are packaged in the order you list them, and two of them may claim the same archive path: the last one wins, and the build warns naming the path and the source it was taken from. Nothing is merged — a Jar artifact declared in the manifest unions the META-INF/services files of its roots, ctx.tools.jar does not, so a task that needs that union writes the merged file itself and lists it last.