Documentation menu

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 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):

  1. ember.toml [target].main
  2. pesde.toml [target].lib
  3. forest.json root
  4. Rojo default.project.json tree $path
  5. 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.