Starting a repository

These commands live in @fairgarden/distribution, which owns repository shape, submodules and versioning. This page covers what they mean for a monolith; the full reference is in that package's own documentation.

Both kinds of repository can be scaffolded without installing anything first.

pnpx @fairgarden/distribution init monolith acme --url https://github.com/acme/acme.git
pnpx @fairgarden/distribution init module acme-widget --name @acme/widget \
  --url https://github.com/acme/widget.git

Neither will write over a directory that already has anything in it. Both run git init, which matters because modules are consumed as submodules; pass --no-git to skip it.

--url is where the repository will live. It becomes the origin remote and the package's repository field. It matters most for a module, since that is the URL a monolith will add it as a submodule by — see Submodule URLs. Without it the repository is scaffolded with no remote, and you are told so.

A monolith repository

acme/
  .npmrc                     link workspace packages by version
  pnpm-workspace.yaml        apps/*, packages/*, packages/*/docs
  turbo.json
  package.json
  apps/monolith/
    next.config.ts           withMonolith, with an empty app map
    src/app/                 the committed route tree
    .gitignore               ignores the derived /app and /pages

src/app/ is where the mount symlinks go, and the derived app/ and pages/ trees are gitignored — see Mounting.

A module repository

acme-widget/
  next.config.ts             withMonolithicPortability
  eslint.config.mjs          the monolith lint rules
  tsconfig.json              paths, so it can import itself by package name
  lib/link.ts                a Link and href bound to this package
  app/                       ordinary App Router routes

It is an ordinary Next app. The only additions are the things that keep it mountable: the portability check, the lint rules, and a portable Link. The generated page imports that Link by package name, which is the convention the lint rules enforce.

Run it on its own with pnpm dev and nothing behaves differently — the mount prefix is empty until a monolith sets one.

Adding a module

fg-dist add-module git@github.com:acme/widget.git
fg-dist add-module ../widget --at packages/widget --name w

This adds the repository as a submodule under apps/<name>, depends on it from the monolith, and mounts it:

Added apps/my-widget
Depending on @acme/widget@0.1.0-alpha.0 in apps/monolith/package.json
Mounted at /my-widget in apps/monolith/next.config.ts

Run pnpm install and `fg-monolith merge-package-json`.

The monolith is found by which app depends on @fairgarden/monolith, not by a fixed path, so it works whatever the app is called.

Editing the config

The mount is written into next.config.ts:

export default withMonolith(
  {
    // the monolith's own Next config
  },
  {
    'my-widget': '@acme/widget',
    // mount name -> module, added by `fg-dist add-module`
  }
)

The file is parsed to find where the mount map is, and the entry is spliced into the original text at that offset. Printing the parsed tree back out would reformat the whole file and move its comments over a one-line change, so it is not done that way — everything except the new line is left exactly as it was.

A mount name that is not a valid identifier is quoted, an existing mount is left alone rather than duplicated, and a missing mount map is added alongside the config already being passed.

When the config cannot be edited — it does not import withMonolith, or the apps are declared somewhere else rather than inline — the command says so and prints the line to add. By then the submodule is already there, so failing the whole command over a line someone can paste would be the worse outcome.

Submodule URLs

.gitmodules is committed, so whatever URL is recorded in it is what every later clone uses — including a build host that has no SSH key. Vercel clones submodules over HTTPS and only public ones, so an scp-style or ssh:// URL checks out fine on a developer's machine and then fails in the build.

Both add-module and init --url rewrite SSH URLs to HTTPS, and say so:

Added apps/widget from https://github.com/acme/widget.git
(rewritten from git@github.com:acme/widget.git; a submodule is cloned without an SSH key)

git@host:owner/repo.git and ssh://git@host:22/owner/repo.git both become https://host/owner/repo.git. URLs that already speak HTTP are left alone, and so are local paths, which have no host to rewrite.

--ssh records the URL as given. Only reach for it when nothing but a developer's machine will ever clone the submodule, and the command will remind you what that costs.

sync reports submodules already recorded with an SSH URL, alongside the HTTPS form to replace them with:

3 module(s) are recorded with an ssh url, which a clone without a key cannot use:
  apps/id  git@github.com:fairgarden/id.git  ->  https://github.com/fairgarden/id.git

fg-dist use-https does the rewrite for you:

fg-dist use-https             # rewrite every ssh url
fg-dist use-https id design   # only these
fg-dist use-https --dry-run   # report without changing .gitmodules

It updates .gitmodules, runs git submodule sync so existing checkouts follow, and leaves the change to commit.

Being cloneable is more than being https

HTTPS is necessary but not sufficient: a build host has no credentials at all, so the repository also has to be readable anonymously — which means public.

use-https checks exactly that afterwards, by asking each remote for a ref with prompting disabled, and exits non-zero when any cannot be read:

Checking whether each can be cloned without credentials:
  apps/id  public
  apps/members  not readable

1 of these cannot be read anonymously, so a build host still cannot clone them.
Make them public, or expect the build to fail.

--no-verify skips it. For a module that has to stay private, see the package fallback.