Aster is a build orchestrator for polyglot monorepos. It discovers projects across languages, connects their dependencies into one graph, and runs work in the correct order while independent targets execute in parallel.
aster test --all
aster build //services/api
aster affected test --base=mainNew to Aster? Follow the end-to-end getting started tutorial to create a workspace, connect project dependencies, build, test, cache, watch, and run only affected projects.
Install · Quick start · Configuration · Contributing · Support · Security
- One graph across languages. Rust, Node.js, Go, Python, Elixir, Java, Kotlin, and Ruby projects can depend on one another while each ecosystem keeps using its native tools.
- Correct work, maximum concurrency. Prerequisites run first; unrelated targets run together; failures stop the work that depends on them.
- Fast local feedback. Content-aware caching, affected-project selection, and watch mode avoid repeating work that cannot change the result.
- A single development cockpit.
aster services upsupervises long-lived services with focused logs, restarts, search, and collision-free ports. - A graph you can inspect.
aster list,aster graph, andaster whyexplain what Aster found and why a target will run.
Aster comes from Ancient Greek astḗr (ἀστήρ), meaning “star.” The flower took the same name from its radiating, star-shaped head. The metaphor fits a monorepo: each project is a point in a larger constellation, dependencies draw the lines between them, and Aster turns that graph into one coordinated build. The word history is documented by the Online Etymology Dictionary and the flower form by the Chicago Botanic Garden.
A single command builds FirstLanding's Elixir, TypeScript, and Go projects in dependency order while independent work runs in parallel:
For local development, aster services up keeps the platform and web services
in one dashboard with focused logs, service switching, search, restart, and
mouse controls:
The screenshot fixtures use the real Aster binary against a synthetic, FirstLanding-shaped workspace, so these images can be regenerated without private source code or credentials.
brew install ArchAstro/tools/asterAster requires Rust 1.88 or newer.
cargo install --git https://github.com/ArchAstro/aster.git --lockedPrebuilt archives are available from GitHub Releases for Linux x86-64, macOS x86-64, and macOS Apple Silicon. Windows is not currently built or tested by the project.
Tagged releases also include native x86-64 packages for Debian/Ubuntu, RPM-based distributions, and Arch Linux.
For Debian and Ubuntu, add the signed Aster repository and install by package name:
sudo install -d -m 0755 /usr/share/keyrings
sudo curl -fsSL https://archastro.github.io/aster/aster-archive-keyring.gpg -o /usr/share/keyrings/aster-archive-keyring.gpg
sudo curl -fsSL https://archastro.github.io/aster/apt/aster.sources -o /etc/apt/sources.list.d/aster.sources
sudo apt update
sudo apt install aster-archive-keyring asterFor Fedora and other RPM-based distributions:
sudo curl -fsSL https://archastro.github.io/aster/rpm/aster.repo -o /etc/yum.repos.d/aster.repo
sudo dnf install asterYou can also download a native package directly from the GitHub release:
sudo apt install ./aster_VERSION_amd64.deb
sudo dnf install ./aster-VERSION-1.x86_64.rpm
sudo pacman -U ./aster-VERSION-1-x86_64.pkg.tar.zstThese packages currently target x86-64 Linux systems with glibc 2.35 or newer.
Run the same target across a workspace:
aster test --all
aster build --all
aster lint --allSelect projects by address, directory, or the current directory:
aster test //services/api
aster build //libs/core //libs/utils
aster test .
aster test 'services/*'Dependencies run by default. Use --no-deps to omit them or --dependents to
include reverse dependencies.
Run different targets in one dependency-aware invocation:
aster run //services/api:test //libs/core:build //tools/cli:lintExplore what Aster discovered:
aster list
aster graph
aster graph //services/api:build
aster why //services/api:test //libs/core:buildUse aster --help and aster <command> --help for the complete CLI reference.
aster --skills prints a workspace-independent Markdown usage guide covering
project selection, common targets, affected runs, watching, services, logs,
caching, and configuration. It is suitable for supplying directly to an LLM.
| Language | Marker | Common detected targets |
|---|---|---|
| Rust | Cargo.toml |
deps, build, test, lint, format, clean |
| Node.js | package.json |
deps, build, test, lint, format, clean |
| Go | go.mod |
deps, build, test, lint, clean |
| Python | pyproject.toml |
deps, build, test, lint, format, clean |
| Elixir | mix.exs |
deps, build, test, lint, format, clean |
| Java | Gradle/Maven plus .java |
deps, build, test, lint, format, clean |
| Kotlin | Gradle/Maven plus .kt |
deps, build, test, lint, format, clean |
| Ruby | Gemfile, *.gemspec |
deps, build, test, lint, format, dev, clean |
Detected targets depend on the tools and scripts present in each project.
Node.js projects use the packageManager field or lockfile to choose npm or
pnpm. Workspace members inherit their workspace root's package manager.
Java and Kotlin are detected independently from their build system, so one
Gradle or Maven project may report either or both languages. Kotlin DSL files
such as build.gradle.kts configure the build and do not by themselves make a
project Kotlin. Use --lang java or --lang kotlin across either build system.
Gradle and Maven projects prefer their checked-in wrappers and scope module
targets to the native multi-project build or reactor, which remains responsible
for ordering dependencies within that build. Pure aggregator roots and embedded
Maven integration-test fixtures are not exposed as standalone projects. In a
directory containing both Gradle and Maven configuration, Maven takes precedence
so the project has one unambiguous Aster address. A colocated package.json
similarly keeps the existing Node.js project rather than creating a conflicting
Gradle address.
Ruby projects use Bundler when a Gemfile is present and detect conventional gem
builds, Rake/Minitest, RSpec, Rails tests and servers, and RuboCop. A colocated
gemspec is the canonical marker for a packaged gem, while Rails and gem projects
take precedence over colocated JavaScript asset packages at the same
directory-based Aster address. Ruby config is parsed statically and is never
evaluated during discovery.
Place aster.toml beside a project's language marker:
name = "api"
depends_on = ["//libs/core:build"]
[targets]
lint = "npm run lint"
check = { alias = "test" }
[targets.test]
command = "npm test -- {files}"
depends_on = ["//self:build"]
capabilities = ["files_list"]
files_glob = "**/*.test.ts"
exclusive_resources = ["database"]
[targets.test.cache]
enabled = true
include = ["config/**/*.json"]
exclude = ["**/*.generated.ts"]
env = ["CI"]
outputs = ["coverage/summary.json"]Run aster project init to generate a starter file.
Target commands are parsed like a shell command line for quoting, escaping, and
leading NAME=value environment assignments, but they are executed directly.
Shell operators such as pipes, redirects, &&, substitutions, and glob
expansion are not interpreted.
Because a && b would run a with &&, b and b's arguments as extra
arguments, Aster refuses to load a target command that contains an unquoted
|, &, ;, < or > (for example &&, ||, |, ;, &, >, >>,
<, 2>, 2>&1, 2>/dev/null), an unquoted $(…) or backtick
substitution, or an unquoted line break between two words in a multi-line
command (a shell would start a second command there; Aster would pass the next
line as arguments). Leading and trailing line breaks and \ line
continuations are fine. The error names the target, its aster.toml, and the word.
Every command that reads configuration fails, including aster list and
aster graph. Operators inside quotes or escaped with a backslash are literal
arguments and are accepted, so grep '|' notes.txt is fine.
To run several steps, either split them into targets joined with depends_on,
which gives each step its own log and cache entry, or invoke a shell
explicitly:
[targets.generate]
command = "sh -c 'generator | formatter > src/generated.rs'"
cache = { enabled = false }
[targets.e2e]
command = "mix test test/e2e_test.exs"
depends_on = ["//self:format-check"]
[targets.format-check]
command = "mix format --check-formatted test/e2e_test.exs"Captured targets receive a closed stdin, so tools that prompt on a TTY fail
instead of hanging behind Aster's progress UI. Streaming targets
(stream = true, or aster <target> --stream) inherit the caller's terminal.
Only the files_list capability is supported. When present, {files} is
required to be a standalone command argument and is safely expanded into
individual path arguments. It cannot be embedded in a quoted shell script or
combined argument, used as the executable, or passed directly through a command
interpreter. Use a fixed wrapper executable for more complex handling.
Affected paths are filtered by files_glob.
Unknown fields, capabilities, dependencies, and invalid globs are configuration
errors.
Aster's local cache memoizes successful target executions; it does not restore
artifacts. The default cacheable target names are deps, build, test,
lint, format, typecheck, and check. Other targets must opt in with
cache.enabled = true. Set enabled = false to opt out.
GitHub Actions can still warm native tool caches (Cargo registry, npm, and
the rest) with aster ci cache-plan. See
GitHub Actions.
Every cache key includes platform:<os>-<arch> (for example platform:linux-x86_64), so an artifact built on one OS and CPU architecture is never restored on another.
include, exclude, and env extend a target's detected cache inputs.
An env name ending in * matches every variable that starts with the name
without that *, sorted by name. FOO_* hashes FOO_A and FOO_B and
ignores BAR. A name without a trailing * is exact. Plugins use the same
syntax; Gradle tracks ORG_GRADLE_PROJECT_*.
workspace_include extends the inputs with files outside the project, relative to
the workspace root, so a member can name a root Cargo.lock or
tsconfig.base.json that include cannot see. Those paths are hashed with a
ws: prefix and do not collide with a file of the same name in the project.
A Cargo workspace member also hashes the workspace-root Cargo.toml and
Cargo.lock, plus rust-toolchain.toml and .cargo/config.toml for targets
other than deps. A nested Node project hashes a root package-lock.json,
pnpm-lock.yaml, or yarn.lock when that file exists, and tsconfig.base.json
for targets other than deps.
Configured outputs must still exist for a cached success to be reused. Clean
targets invalidate the project's cache after succeeding.
A target that passed only on a retry (see below) is not cached, so the next run sees the flake again.
aster --retry-failed test //api # one retry
aster --retry-failed=2 --test-results=results.ndjson test //api
ASTER_RETRY_FAILED=1 aster test //apiWhen a test target fails and its captured results name the failing tests,
--retry-failed[=N] (default 1; ASTER_RETRY_FAILED=N) reruns only those
tests, up to N times, with the runner's own selection flags, and records every
attempt (attempt 1 is the original run, 2 the first retry). The target passes
when every failing test passes on a retry, nothing else fails and the retry
commands exit 0; it then reports PASS //api:test (12.3s) — flaky: 2 tests passed on retry, --json lists them under tests.flaky, and GitHub Actions
gets a ::warning annotation per flaky test. Otherwise the target fails with
the last attempt's failures.
Retries capture results even without --test-results; that flag alone decides
whether a results file is written.
retry_setup is a shell command run in the target's working directory before
each retry, such as a database reset. If it fails the retry is skipped and the
original failure stands.
[targets.test]
command = "mix test"
retry_setup = "mix ecto.reset"| Runner | Retry selects |
|---|---|
mix test |
the failing test files (the report has no line), without --partitions or MIX_TEST_PARTITION |
go test |
-run per failing package, subtests anchored level by level (^TestA$/^sub$) |
Vitest, Jest, bun test |
the failing files and an escaped -t pattern |
pytest |
node ids, file::Class::test |
cargo test |
-- --exact name; needs --no-fail-fast in the command |
cargo nextest run |
-E 'test(=name)'; needs --no-fail-fast, plain Rust names |
node --test |
--test-name-pattern per test and the failing files, for a direct node --test or tsx --test command only |
| Gradle, Maven | not retried |
Nothing is retried when the report has no failing test (a crash, a compile
error), when the command was killed by a signal, when the command stops at its
first failure (pytest -x, --bail, mix test --max-failures, go test -failfast), when a target runs several commands, or when the runner cannot
select single tests.
aster affected test --base=main
aster affected test --base=main --dependents
aster affected test --base=main --related
aster affected test --dry-runAster compares HEAD with the merge base of HEAD and --base, then includes
uncommitted changes. CI must fetch enough history for the merge base to exist.
aster affected test --base=main --only-affected-files
aster affected test-ci --base=main --dependents --only-affected-files --dry-run--only-affected-files narrows targets that declare the files_list
capability to each project's changed files. The requested target is narrowed,
and so is every target of the same project that it depends on, so a wrapper
such as test-ci = { command = "true", depends_on = ["//self:test"] } narrows
its test prerequisite. A {files} placeholder expands to the changed files
(after files_glob); otherwise the language plugin rewrites the command.
- A requested target with no relevant changed files is skipped; a narrowed prerequisite with none is reported as skipped while its wrapper still runs. A prerequisite whose plugin declines to narrow it runs in full.
- A project with no changed files of its own, selected only through
--dependents, runs its targets in full ({files}expands to nothing). So does a project with changed files whose dependency also changed under--dependents: its own file list does not describe that change. Without--dependentsit runs on its own changed files, like every other project in the run;--relatedruns the tests that reach the dependency's change. --dry-runprints the chosen commands and the reasoning under each target;--jsonadds them ascommandsandselection.
Rust test targets (the detected test target, or any target whose command is
cargo test … and declares files_list) run only the tests related to the
change, the way vitest related follows importers:
- Aster walks each crate target from its root through
moddeclarations and builds a module graph fromcrate::,super::,self::,usealiases, glob imports andmacro_rules!invocations. The related modules are the changed modules plus every module that transitively references one. A changed module that implements another module's type also marks that module changed. - Library unit tests run filtered to the related module paths
(
cargo test -p <crate> --lib -- a:: b::). Tests in the crate root file run by exact name whenever any module is related. Integration tests, benches and examples run when they changed or reference a related module through the crate name; tests that run a binary (CARGO_BIN_EXE_*, assert_cmd'scargo_bin, escargot) run when any binary is related. Doctests all run when any module is related, unless the command uses--all-targets, which excludes them. - A non-Rust file maps to the sources whose string literals name it as a
path (
include_str!("../fixtures/x.json"),"port/index.toml",dir.join("fixtures")). A file no source names runs the original command. - Other workspace members that changed, or that depend on a changed member
through a path dependency, run in full with
-p. Cargo.toml,Cargo.lock, toolchain files,.cargo/config*, the build script, the library root and.rsfiles outside every target run the original command. So do commands that already pick test targets or filters, changes that relate most of the library, and any change that maps to no test. With files changed, the target is never skipped.
The analysis is textual. It over-approximates: libtest filters match substrings, module granularity selects every test in a related module, and any string that names a file by path counts as a use. It can miss dependencies that never appear as a path:
- Trait use through generics only. A module that calls
T::method()on a genericT: Traitis related to the trait's module but not to a module that only implements the trait for a concrete type the caller never names. Blanket impls (impl<T: Bound> Trait for T) and impls for imported types do mark the trait or type module changed. - Files reached at run time without naming them: directory walks from a
parent directory, or names built with
format!. - Code generated by procedural macros or
include!d from files outside the module tree.
aster affected test --base=main --related
aster affected test-ci --base=main --related --dry-run
aster affected build --base=main --related--related selects at the level of functions, types and modules instead of
projects, and needs no files_list capability, {files} placeholder or
wrapper script. Aster parses every Elixir, TypeScript/JavaScript, Go, Python
and Rust file in the workspace with tree-sitter, maps the diff's hunks onto
the definitions they touch, and follows references from those definitions to
the tests that reach them, across project boundaries.
-
Test commands run narrowed.
mix test, Vitest, Jest, Mocha,bun test,node --test,go testandpytestare recognised in the requested target and in the same-project targets it depends on, whether run directly (pnpm exec vitest run) or through a package script that is only the runner. Elixir, JavaScript and Python run the selected test files; Go runs the selected packages with-run '^(TestA|TestB)$'. A command that already lists test files keeps the ones selected. A{files}placeholder receives the selected test files. -
Shard and wrapper projects follow the project whose tests they run. A shell one-liner that ends in a test command is narrowed in place, in the directory it changes into:
# core-test-shard-1/aster.toml depends_on = ["//core"] [targets.test-ci] command = "bash -c 'cd ../core && MIX_TEST_PARTITION=1 exec mix test --partitions 2'"
runs
… exec mix test --partitions 2 test/a_test.exs test/b_test.exswith the tests selected in//core, so each shard takes its slice of the selection;vitest --shard=1/2works the same way. Amix testshard whose partition gets none of the selected files is skipped: partition 3 of two files is empty, and Mix fails on paths that match nothing. Everything before the final command is kept, which must be a plain command after an optionalcd <dir> &&. Test targets the requested target depends on in other projects are narrowed too, each to its own project's selection, so acommand = "true"wrapper over//core:smokeruns only the smoke tests that reach the change. A shard is skipped when none do, runs as written when the sharded project runs in full, and runs in full when its own files change. -
Projects the change does not reach are skipped.
--relatedreplaces--dependents: a dependent runs only when one of its definitions refers to something that changed, and then only the tests that do. For targets that are not test commands (build,lint), the project runs as written when the change reaches it and is skipped otherwise. -
Comments, documentation and blank lines select nothing.
-
--dry-runprints, under each target, the narrowed command and the reference chain from each selected test file back to the changed line.
How a change is followed:
- A changed line inside a function, method, type, constant or test marks
that definition. A changed import or
aliasline marks the definitions that use what it brings in. Any other line outside a definition (module attributes,use, top-level statements, side-effect imports) marks the whole file. Removed lines are read against the old file, so deleting a function selects the tests that still call it. - A definition is affected when it mentions an affected name and uses the
file that defines it: an import binding (re-exports and
tsconfigpaths followed), an Elixir module name afteralias, a Go package qualifier, a Python import.conftest.pyapplies to the tests beneath it. - In Elixir a call names its module, so
Orders.changeset/2andUsers.changeset/2are told apart: a definition refers to a changed function when it calls it through that module, calls it unqualified from the same file or a file that imports it, or holds its name as an atom (apply/3,{Module, :function, args}). A module that only appears in a function head's parameter patterns is matched against, not used. - A route declaration (a module-level call given a path and a module, such
as
get "/orders/:id", OrderController, :show, nested inscope "/api" do … end) is reached by request, not by name. When its controller is affected, the definitions that hold a matching path literal are affected:"/api/orders/#{id}"and~p"/api/orders/#{id}"match, and so does a shorter base such as"/api/orders"that code may extend. If no code names a route's path, everything that goes through the router is affected instead. - Methods, and functions of an Elixir
defimpl, are reached through a value, so their callers need not import their file. They are matched by name, against member accesses (value.name) in the changed project and the projects downstream of it, with these exceptions:- A member of a literal, of a built-in global (
Promise.resolve), or of something imported from outside the workspace (path.join) is not a method of anything the workspace declares. - In Go,
pkg.Nameis that package'sNameand nothing else, and a variable the function declares with a struct type of its own package (w *Worker,w := Worker{}) calls that type's methods or those of the types it embeds. this.nameandself.namecall the enclosing class's method, or one from a class it extends or that extends it. A base class that cannot be named (a mixin call) leaves the call open.
- A member of a literal, of a built-in global (
- In Go, TypeScript, JavaScript, Python and Rust, code runs only once
something imports its file (in Rust: once a module refers to another). A test is therefore selected only when every definition
on some chain from the change to the test is in a file the test's own
imports lead to. A test whose imports cannot all be followed (a computed
import(name), an import of workspace code that resolves to no file) is taken to load anything. This does not apply to Elixir, where the whole application is loaded. - Rust is read with its types:
- Tests written beside the code they test (
#[cfg(test)] mod tests) are test code though their file is not a test file. A selected test runs by its full path with--exact; a file selected as a whole, and tests a macro defines, run by their module's path. Doctests are not analysed and run whenever the original command would run them. value.name(…)is a method call;value.namereads a field and calls nothing.Type::nameandSelf::nameare that type's.- A value's type is taken from where it is written: a parameter, a
letwith a type, a struct field, a struct literal, or the declared return type of the call that made it (let s = Arc::new(Store::new())is aStore). A method call on such a value reaches only that type's methods and those of the traits it implements; a type from outside the workspace has none. - Where the type is not written, the call is matched by name, but only from definitions that could hold such a value: the method's type, or the trait it is called through, must be reachable from the types the caller mentions and the signatures of what it calls.
- A method of a trait the workspace does not declare (
Display::fmt,Drop::drop,From::from) is called by formatting, operators and conversions that name nothing. A change to one reaches every definition that uses the type's file. - A path is followed through re-exports: with
pub use parts::*in the crate root,crate::ports::Runneris theRunnerofparts::ports. A trait reached that way is one the workspace declares. - A
macro_rules!macro is found by its name wherever it is used. - A test that runs the package's binary (
CARGO_BIN_EXE_*,assert_cmd) runs for any change to the package's sources. Cargo.toml,Cargo.lock,build.rs, the toolchain file and.cargo/configuration run the project in full.
- Tests written beside the code they test (
- A file the test runner loads before every test (one a
vitest.*,jest.config.*orplaywright.config.*file names, such assetupFiles) is no test's import. A change that reaches one runs the project in full. - A definition nothing names is assumed to be called another way: a
callback (
handle_call,mount, anything marked@impl), a macro, a route. It affects every definition that uses its file. - Elixir code that lists modules at run time
(
:application.get_key(app, :modules),Application.spec(app, :modules),:code.all_loaded()) can call into any of them, so it is affected by every source change in its own project and in the projects that one depends on. - A file that is not source maps to the definitions whose string literals
name it (by path from anywhere, by bare file name within its own
project), to the Go declaration that embeds it (
//go:embed), to the Go tests beside itstestdata/, or to the test that owns its__snapshots__file. Prose (.md,.txt, …) that nothing names selects nothing. A source file read as data by code in another language is found the same way. - A source file is also data to a test in its own language that names its
path without loading it (
readFileSync("../money/src/format.ts")): any change to the file, a comment included, affects the test. The path is read from the workspace root or the test's project root, or from the test's directory when it is written relative. Whatimport,requireand a module mock name is loaded, and followed by reference as before. - A directory a test names by path the same way maps every file beneath it
to the test, documents (
.md,.rst, …) excepted, unless the directory holds a whole project. The project that owns such a file still runs in full when none of its own code names the file. - Both rules read paths in tests only: a test runs in the workspace, and other code names paths where it is deployed.
- Tests in projects that never declared a dependency on the changed project are found too, when they refer to what changed. Such a project runs only those tests.
Where the source graph cannot see, Aster runs more:
- Manifests, lockfiles and tool configuration (
package.json,tsconfig*.json,vitest.config.*,mix.exs,config/*.exs,test/test_helper.exs,go.mod,pyproject.toml,aster.toml, …) run their project in full, and every project downstream of it. A workspace-level lockfile does the same for every project beneath it. - A project runs in full when a non-source file nothing names changed, when a changed source file reaches no test at all, or when the project imports Python modules by computed name.
- Inside the set
--dependentswould select, a project runs in full when it depends on an affected project in another language, on one Aster does not analyse (Ruby, JVM), or on one it never imports from: such a dependency is on a built artifact or a binary, which the source graph cannot follow. A project that says which of its sources use such a dependency runs only those and the tests that reach them (see below). - A dependency in another language is followed by source after all when
every test of the dependent is in that language and builds on the
dependency's code: an Elixir test kept in a project marked by a
package.json, say, and run from the dependency's directory. The project then runs only when the change reaches one of its tests. A test in a second language, a[consumes]entry, or a path into the dependency means it is also used some other way, and the rule above applies. - A project's runner cannot name a test in another language. When one is selected in a project the change's dependents include, the project's targets run as written.
- A command Aster cannot read as a test runner runs as written: a script file, a one-liner whose last command is piped, uses a variable or sits in a block, a command that lists test files outside its project, or a selection too long for one command line.
aster graph --source prints what --related works from: each definition a
change touches and, beneath it, the definitions that use it, down to the
tests.
aster graph --source # uncommitted changes
aster graph --source --commit origin/main...HEAD # a branch
aster graph --source --commit HEAD~3..HEAD # a range
aster graph --source --commit HEAD~1 # working tree against a ref
aster graph --source --dir services/api --ext ts,tsx
aster --json graph --source --commit main..HEAD # nodes and edges--commit reads its value as git diff does. --dir and --ext limit which
changed files are considered. Each definition appears once, under the one it
was first reached through, so the output is a tree over the graph rather
than every edge. Projects that run in full for a reason the graph cannot show
are listed after it. Files the change does not touch are read from the
working tree, so check out the head of a range for an exact answer.
A project can depend on another one it never imports: its tests launch the
other project's binary, read its configs, or run its built bundle. Aster
cannot see which tests do, so it runs the dependent in full. [consumes] in
the dependent's aster.toml says which of its sources use the dependency,
and only those run:
# platform/aster.toml
depends_on = ["//services/gateway:build"]
[consumes."//services/gateway"]
infer = true # definitions that name a path in the gateway
files = ["test/support/gateway_harness.ex"] # sources that use it without naming a path- The key is a project the file lists in
depends_on. At least one ofinferandfilesis required. infer = truecounts every definition whose string literals name a path inside the dependency's directory:Path.expand("../../gateway", __DIR__),"services/gateway/configs/a.yaml","//services/gateway". A relative path is resolved against the file and against the project directory. Paths inside nested projects belong to those projects.fileslists sources as globs relative to the project (//starts at the workspace root). A pattern that matches no source file voids the entry. An empty list states that no source of the project uses the dependency, so a change to it never selects the project.- Each counted definition selects the tests that reach it through the
project's own source graph: a helper that names the gateway selects the
tests that use the helper. The tests that run, and the project's shards,
are narrowed as for any other selection, and what the counted definitions
affect reaches the project's own dependents the same way. Code under
test/,tests/,spec/and__tests__/supports tests and does not change what the project provides to its dependents. --dry-runsays why. Each selected test names the path or declaration that counted and the dependency that changed. A project that still runs in full says whether the entry was missing, matched nothing, or reached no test; without an entry it counts how many definitions name the dependency.
infer is opt-in because a path in a string is evidence of use, not proof
of all use. It cannot see paths assembled from pieces
(Path.join(root, "services", "gateway")), taken from environment
variables or configuration, written in files that are not source (shell
scripts, JSON), or kept in a helper of a project the dependent does not
contain. List such sources in files. A dependency with no entry, or with
no definition that names it, runs the dependent in full.
The analysis is syntactic and resolves names, not types, so it mostly errs
towards running too much: any definition that uses a file and mentions a
changed name counts as a caller. It can miss a dependency that leaves no
name in the source: a function invoked through a name built at run time
(apply(mod, String.to_atom(…)), getattr, obj[name]()) when the
function has other, named callers; a file read from a path assembled at run
time; code produced by a generator that is not checked in. A test that reaches a route only through a path assembled
at run time is not selected for that route when other code does name the
path.
Workspace-relative files can be excluded from affected analysis in the root
aster.toml:
[affected]
ignore = [".agents/**", "docs/generated/**"]The root-level ignore list separately controls project discovery.
A lane is a named subset of projects in the root aster.toml, for CI that
gives each group of projects its own job:
[affected.lanes.backend]
include = ["//services/..."]
exclude = ["//services/web/..."]
[affected.lanes.web]
include = ["//services/web/..."]aster affected test --base=main --dependents --lane backend
aster --json affected test --base=main --related --dry-run --lane backend --lane web--lane keeps the affected projects the lane matches, after dependents are
added; the targets they depend on still run. Repeat it with --dry-run to
plan several lanes from one analysis of the change: each lane gets the plan it
would get alone, and --json prints them under lanes, keyed by lane name.
Running takes one lane per invocation.
aster watch //services/api:build
aster watch //services/api:dev --debounce 500msWatch mode observes the requested targets and their transitive dependencies.
Non-stream prerequisites run before a stream = true target starts. Relevant
source changes received during a build are preserved for the next cycle.
Configure filesystem behavior in the root aster.toml:
[watch]
ignore = ["coverage/**"]
suppress_paths = ["services/web/priv/static/assets/**"]
debounce_ms = 300Built-in ignores cover common VCS, dependency, and build-output directories.
During the cooldown window, only configured suppress_paths are dropped; use
them for generated paths that would otherwise create feedback loops.
aster services up runs a configured set of long-lived targets in one supervised
dashboard:
aster services up
aster services up intern
aster services up --no-ui
aster services up --dry-run
aster services up --daemon
aster services up main --proxySet daemon = true under [dev] to keep service ownership in Aster's per-user
daemon by default. Ordinary aster services up still opens the dashboard, but
q only detaches it; the bundle continues running. Re-running the command
reattaches, and r restarts the focused service through the supervisor. Use
--no-ui for a detached launch, services list to inspect worktree bundles,
services down [group] to stop them, or services daemon stop for all bundles.
The explicit --daemon flag remains a headless one-shot launch.
Service stdout, stderr, and Aster lifecycle messages are also persisted to
.aster/logs/<worktree>/<service>/logs.txt in the workspace. Each service log
is capped at 10 MiB; Aster truncates it and continues writing when it reaches
the limit. Read one through the system pager from a terminal, or emit raw text
when piping or redirecting:
aster services logs platform-backend
aster services logs platform-backend | grep ERROR
aster services logs platform-backend > platform-backend.logInteractive output honors $PAGER, then falls back to less or more.
List every current allocation for this worktree, including separate supervisor instances and crash-left listeners:
aster services ports
aster --json services portsHuman output maps primary ports to service names and includes all dependency
ports. JSON returns a stable workspace/instances object; each instance has
its supervisor PID, active or orphaned status, service mappings, and the
complete named-port map.
Clear stale or orphaned processes from development ports before starting the stack again:
aster services kill-ports --dry-run # inspect every known worktree port
aster services kill-ports # clean every known worktree port
aster services kill-ports api web 4011 # clean named and explicit portsThe command sends a graceful termination request to each listener, waits
briefly, then force-kills listeners that still hold a selected port. It targets
only processes that own the selected listening ports. An explicit numeric port
need not appear in aster.toml and can be cleaned from outside an Aster
workspace.
Run scripts/test-dynamic-service-ports for a standalone lifecycle smoke test.
It builds Aster, creates a temporary Git workspace with three dynamic services,
and reports PASS/FAIL for graceful shutdown and crash-plus-kill-ports
recovery. Set ASTER_BIN to test another binary or ASTER_KEEP_TEMP=1 to keep
the generated workspace and logs.
Targets named dev remain ordinary targets (aster dev <project selectors>).
If a project has a target named services, run it explicitly with
aster target services <project selectors>. The same escape hatch works for
any target name that conflicts with a built-in Aster command.
Services are mappings to ordinary stream = true targets. Their non-stream
target dependencies are pre-start steps: Aster runs them before the service
starts and again before a dependency-triggered or manual restart. The same
transitive target graph determines which project directories are watched.
Services can be collected into named groups in the root aster.toml:
[dev.ports.intern-control]
env = "INTERN_CONTROL_PORT"
default = 5001
[dev.service_groups]
main = ["platform", "developer-portal", "user-portal", "agent-network"]
intern = { services = ["intern-postgres", "intern-data", "intern-ctl", "intern-gateway", "intern-fe"], control_port = "intern-control" }aster services up intern runs that group. With no group argument, Aster runs
the main group plus services that do not appear in any group. When no main
group exists, the default remains all ungrouped services. A service may belong
to more than one group. The array form uses [dev].control_port. The detailed
form can set a named control_port, allowing multiple groups to run concurrently
without their control sockets conflicting. A detailed main group also supplies
the control port for aster services up with no group argument.
The dashboard uses scalable colored service tabs beside one focused log
stream. Use h/l or click a tab to switch services, drag the divider or use
[/] to resize, and scroll the service list independently when it exceeds
the terminal height. It also supports fullscreen logs (f), wrapping (w),
search (/), mouse line selection and clipboard copy (y), browser opening
(o or [open]), manual restart (r), and the ? controls overlay. Press
m to disable dashboard mouse capture when native terminal selection is
preferred.
Configure the harness in the workspace-root aster.toml:
[dev]
daemon = true
port_env_files = [".env", ".env.local"]
control_port = "control"
[dev.ports.api]
allocation = "dynamic"
range = [4000, 4099]
preferred = 4000
[dev.ports.web]
env = "WEB_PORT"
default = 3000
offset_from = "api"
offset_base = 4000
saturating_offset = true
[dev.ports.control]
env = "CONTROL_PORT"
default = 5000
[dev.services.api]
target = "//services/api:dev"
port = "api"
open_path = "/health"
env_files = ["services/api/.env"]
port_env = { PORT = "api", WEB_PORT = "web" }
inherit_env = ["GOOGLE_CLOUD_MODE"]
order = 10
[dev.services.web]
target = "//services/web:dev"
port = "web"
port_env = { PORT = "web" }
env = { API_URL = "http://localhost:{ports.api}" }
order = 20A TLS edge is supervised like any other service, but terminates HTTPS and
routes browser traffic to other services by their named ports. Certificate
trust is an explicit setup step; services up never installs software or
changes the host trust store.
[dev.ports.https]
default = 8443
[dev.services.local-edge]
port = "https"
open_path = "/"
tls_proxy = { certificate_hosts = ["app.example.test", "*.local.example.test"], open_host = "app.example.test", dns_domain = "example.test", routes = [{ host = "app.example.test", upstream_port = "web" }, { host_suffix = ".local.example.test", open_host = "demo.local.example.test", upstream_port = "api" }] }On macOS with fish:
brew install mkcert dnsmasq
aster services tls setup local-edge
aster services upsetup runs mkcert -install, writes a mode-0600 key under
.aster/tls/local-edge/, and verifies that dns_domain resolves to loopback.
Configure wildcard DNS separately (for example, dnsmasq
address=/.example.test/127.0.0.1). If Chrome cached an earlier DNS failure,
fully quit it and reopen it; also disable Chrome Secure DNS if it bypasses the
macOS resolver. Aster prints the same checks when DNS validation fails.
The proxy binds only to 127.0.0.1, routes only to configured named ports,
and supports HTTP upgrades for development WebSockets/HMR. A suffix route may
accept names deeper than a static wildcard certificate covers. For those SNI
names, Aster asks mkcert for an exact certificate on first use and caches it
under .aster/tls/<edge>/hosts/; issuance is restricted to configured routes
and capped at 64 exact certificates per edge to bound local resource use.
Use port 8443 when the operating system restricts unprivileged processes from
binding port 443. Do not run the complete development stack as root.
When a TLS route points to another service's named port, that service's
dashboard [open] action uses the route's HTTPS hostname. Exact routes infer
it from host. To publish an open URL for a suffix route, configure a concrete
open_host; a suffix alone does not identify which tenant hostname to open.
Named ports have two allocation modes. Existing integer and detailed definitions
are static; a detailed static definition may say allocation = "static"
explicitly. A dynamic root uses allocation = "dynamic", an inclusive range,
and an optional preferred candidate. Aster atomically claims the root and its
selected derived ports, skips ports leased by another Aster supervisor or bound
by another process, and holds the leases until the supervisor exits.
Each supervisor also writes a worktree-scoped allocation manifest. Normal
shutdown removes it after child teardown. If the supervisor crashes and leaves
an orphan listener, services kill-ports uses the retained manifest to resolve
dynamic names and removes it after the complete recorded bundle is free.
Explicit numeric cleanup remains available for non-Aster listeners.
port_env_files participate only in static named-port resolution. A service receives
only a small process baseline (PATH, home/user, temporary-directory, locale,
shell, and terminal variables), its own env_files, explicit env,
ASTER_SERVICE_NAME, and (when it has a port) ASTER_SERVICE_PORT; leading
target-command environment assignments take final precedence. Other ambient
variables are intentionally not inherited unless their names appear in that
service's inherit_env allowlist. Process environment values named by a port's
env field take precedence over file_env values from those files. When
file_env is omitted, the env names are also checked in the files. An
offset_from port adds the positive delta from offset_base, which is useful
for collision-free worktree stacks. By default, a source below the baseline is
an error; saturating_offset = true clamps that delta to zero.
port_env = { PORT = "api" } injects a resolved named port after service env
files are loaded, so stale checked-in values cannot override an allocation. A
key cannot appear in both port_env and env. {port} and {ports.<name>}
remain available in service target commands and env for composite values such
as URLs. Port references do not implicitly start services; groups remain the
process-selection contract. These templates are separate from the {files}
target capability. open_path controls the dashboard's browser URL.
A service can declare a proxy target without changing its normal launch. Pass
--proxy to insert every proxy configured by the selected service group:
[dev.ports.platform]
default = 4000
[dev.ports.platform-proxy-upstream]
allocation = "dynamic"
range = [14000, 14999]
[dev.services.platform]
target = "//services/platform:dev"
port = "platform"
port_env = { PHX_PORT = "platform" }
[dev.services.platform.proxy]
target = "//services/go/platform-proxy-logger:dev"
upstream_port = "platform-proxy-upstream"
env = { PLATFORM_PROXY_LISTEN_ADDR = "127.0.0.1:{proxy.listen_port}", PLATFORM_PROXY_UPSTREAM_URL = "http://127.0.0.1:{proxy.upstream_port}" }Without the flag, platform binds its advertised platform port directly.
With aster services up main --proxy, the generated platform-proxy sidecar
binds that same advertised port and Platform binds platform-proxy-upstream.
services ports, {ports.platform}, and other service discovery continue to
report the original platform port. For the underlying service, {port},
ASTER_SERVICE_PORT, and port_env entries that reference its own port switch
to the upstream port.
Proxy commands and proxy env values support {port} for the advertised port,
all normal {ports.<name>} templates, plus {proxy.listen_port} and
{proxy.upstream_port}. Aster also supplies ASTER_PROXY_SERVICE_NAME,
ASTER_PROXY_LISTEN_PORT, and ASTER_PROXY_UPSTREAM_PORT. The upstream port is
leased only when proxy mode is enabled. If a daemon-managed group is already
running in the other mode, stop it with aster services down <group> before
relaunching. Proxy output is available through aster services logs <service>-proxy.
When control_port is configured, Aster accepts the platform launcher's
line-delimited JSON commands on localhost: status, list_services,
restart (with a service field), restart_all, and shutdown. Aster prints
the path to a per-run token file; state-changing requests must include its
contents as the JSON token field. Read-only requests do not require it.
See CONTRIBUTING.md for development setup and pull-request checks. Use GitHub Discussions for usage questions and design conversations, and the issue forms for reproducible bugs and feature requests; the full support policy is in SUPPORT.md.
Please report vulnerabilities privately as described in SECURITY.md. Community expectations and project decision-making are documented in CODE_OF_CONDUCT.md and GOVERNANCE.md.


