Guides
Dependencies & installing
Last updated October 23, 2026
Resolution#
Resolution is a breadth-first walk over the dependency graph. Everything — including cross-ecosystem transitive dependencies (a pesde package pulling a wally package) — flattens into an install set per environment root, deduped by package name: the first requirement per name wins, and later requirements that reject the chosen version are a hard error naming the tree they came from:
Dependency conflict in the server tree: acme/bar is required as both '^1' and '^2'
Direct dependencies are seeded before transitive ones, so when your manifest and a dependency both want the same package, your requirement is the one that wins.
Because each root resolves on its own, the same package can land in two
roots at once, even at two different versions — see
environment roots below. An
[overrides] entry redirects one dependency edge rather
than the whole install set.
The install layout#
Ember uses a wally-style linker, and only the dependencies you declare get a
top-level link file. Installing chief/traits, which itself depends on
chief/bin and chief/core:
packages/shared/ <- per-environment output ([config] overrides)
traits.luau <- the one you declared
.ember/
.state <- fast-path stamp (see below)
chief_traits/ <- extracted package contents
packages/shared/bin.luau <- how traits reaches bin
packages/shared/core.luau <- ...and core
chief_bin/ <- transitive: storage only
chief_core/
The top-level links are your require surface — exactly what your manifest
declares, nothing more. Transitive packages are installed and fully
usable, but only by the packages that asked for them, through nested link
files inside each package's own storage folder. To require one from your own
code, add it to [dependencies].
The .state stamp records what the last install produced, and backs the
fast path. Deleting it (or the whole folder) just
forces a full install.
Environment roots are self-contained#
A dependency tree installs into its root's environment, not into each
package's own. Installing brinkokevin/lapis (server realm), which depends
on shared-realm evaera/promise:
packages/server/
Lapis.luau
.ember/
brinkokevin_lapis/
evaera_promise/ <- shared realm, server root
packages/shared is not created at all. This is wally's model, where
ServerPackages is self-contained too, and it is what keeps Roblox projects
working: the two roots mount under different DataModel subtrees, so a
shared-realm copy of a server dependency would break its links under Rojo,
and server-only code would replicate to clients through ReplicatedStorage.
What follows from it:
- A package needed by both a shared and a server dependency installs twice, once per root. That is intentional, and the archive cache makes the second copy a disk write rather than a download.
- The two copies can be different versions, since conflicts are judged per root.
- Workspace members are the exception to the duplication: a member owns its name in every root and always links in place, so a server-tree dependency on a member binds to your local copy, never the published one. A version requirement the member can't satisfy is a conflict, not a silent fallback.
Link files#
Link files re-export the package's entry point and its exported types —
Luau type exports don't flow through a plain return require(...), so Ember
parses the entry point and restates every export type (pesde's scheme:
generic defaults kept on the declaration side, dropped on the use side;
variadic generic packs supported; exported type functions re-exported via
their parameters):
local module = require("./.ember/chief_traits/src/init")
export type Source<T> = module.Source<T>
return module
A package exporting no types gets a compact one-liner. An entry point that can't be parsed (or is absurdly deeply nested) still installs and links, minus the type re-exports, with a warning.
Entry point detection#
Tried in order (extensions are stripped — Luau string requires reject them):
- ember.toml
[target].main - pesde.toml
[target].lib - forest.json
root - Rojo
default.project.jsontree$path - Conventional locations:
init.luau,init.lua,src/init.*,lib/init.*
No entry point → warning, no link file.
Environment detection#
When the index doesn't know a package's environment: the extracted package's ember.toml → pesde.toml → wally.toml (realm). When none of them answer, install stops and asks the package to say so itself:
Could not determine the environment of <name>; set `environment` under [target] in ember.toml
The lockfile#
ember.lock (TOML, version = 2) has one [[package]] entry per installed
package with name, version, environment, index, and a fully-baked download
URL — --locked installs re-download from those URLs without consulting any
index. Entries also carry context, the root they install under, omitted
when it matches environment; link appears only on the dependencies you
declared. Workspace dependencies are recorded as type = "workspace" with a
relative path and are re-linked, not downloaded. Commit the lockfile.
Lockfile version 2 is a breaking change. Older embr cannot read a v2 lockfile, so once yours regenerates, everyone on the team needs the new release.
Migration is otherwise automatic: the new embr reads v1 locks fine for
ordinary installs, and the first one rebuilds into the new layout and
rewrites the file (stale transitive links disappear on their own). The
exception is embr install --locked, which refuses a v1 lock, because
replaying it would scatter trees across roots:
ember.lock is version 1, from before self-contained environment roots; run `embr install` once without --locked to regenerate it
CI that runs --locked therefore needs one plain install committed first.
Caches and speed#
Package downloads run concurrently and land in ~/.ember/archive-cache, so
reinstalls skip the network; index checks stay fresh for 5 minutes. The
no-change case takes a fast path measured in milliseconds — the details
live on embr install, and
embr cache clean empties both caches.
Indices#
Indices are git repositories, shallow-cloned and pulled into
~/.ember/index-cache/; a successful pull marks an index fresh for 5
minutes, so back-to-back installs skip it, and network failures fall back
to the stale cache with a warning. Three formats are auto-detected: wally
(root config.json), pesde (root config.toml), and Ember's own index
(pesde format whose entries carry a baked download URL — which is why
installs never need the registry API to be up).
A git host that is down cannot hang an install. A transfer that stalls for 30 seconds fails. A server error or a stall gets three tries, half a second and then one second apart, and then the error names the host and says that it may be down. A missing index or a bad URL fails at once.
Forest is the exception: it has no git index, so
Ember asks its API (https://api.forest.dev) on every resolve and keeps no
index cache for it. A package published to Forest requires its dependencies
from a folder beside its root module, Packages unless its forest.json
names another, so Ember writes each nested link there as well.
The local index is a folder, ~/.ember/local/index, so it needs no clone
and no refresh. See local packages.