---
title: CLI
description: Commands, flags, output, and exit codes of the spyglass binary.
---

The same scans and server are available from code: see the
[library](/spyglass/reference/library).

## Usage

```text
Spyglass — workspace analysis and a local viewer

  spyglass [path...]                      Scan, serve, and open the browser
  spyglass scan [path...]                 Scan and save each workspace's dataset
  spyglass serve [path...]                Serve the saved datasets without scanning
  spyglass report <package> [path...]     Print a package report from the saved dataset
  spyglass lens <id> [path...]            Print a lens from the saved dataset
  spyglass check [path...]                Check the workspace's rules against the saved dataset
  spyglass plan <opportunity> [path...]   Plan one opportunity and authorize it; writes nothing in the workspace
  spyglass apply <opportunity> [path...]  Preview a planned change; write and verify it with --write
  spyglass export <dir> [path...]         Write a static viewer of the saved datasets to <dir>

The path defaults to the current directory. spyglass, scan, serve, and export read
several paths as one collection; the other commands read one workspace.
A directory named like a command is written as a path: ./scan, ./serve, ./report, ./lens, ./check, ./plan, ./apply, ./export.

  --state <path>        State root (default: ~/.foundry/spyglass)
  --history             spyglass, scan: analyze Git checkpoints as well
  --port <number>       spyglass, serve: local port (default: 4173; 0 chooses a free port)
  --no-open             spyglass, serve: do not open the browser
  --write               apply: write the change, verify it, then scan again
  --format <format>     report, lens, check, plan, apply: terminal, text, markdown (default: terminal; text when piped)
  --density <density>   report, lens, check, plan, apply: compact, normal, expanded (default: normal)
  --json                Print the result as one line of JSON
  --quiet               Print no progress
  --help, -h            Show this help

Exit codes: 0 done, 1 failed, 2 bad arguments, 3 a rule broken (check).
```

The first argument is a command only when it is exactly `scan`, `serve`,
`report`, `lens`, `check`, `plan`, `apply`, or `export`; anything else is
the workspace path, for the default command. A directory with a command's name is written as a path,
`spyglass ./report`. The path defaults to the current directory and must be
an existing directory.

## Several workspaces

`spyglass`, `scan`, `serve`, and `export` read two or more paths as one
collection: workspaces read together, such as a product split across
repositories that depend on each other. One path runs exactly as it always
has.

- Each workspace is scanned on its own, one after the other, with its own
  state, dataset, cache, and lock; nothing is saved for the collection.
  What ties them is the collection manifest, derived from the saved
  datasets: each workspace, its packages qualified by its workspace id, and
  the crossings, a package's declared dependency on a package of another
  workspace. See [output](/spyglass/reference/output#a-collection).
- The workspaces are ordered by resolved root, whatever the order given. A
  workspace inside another exits `2`: `Workspace <outer> holds workspace
  <inner>; the workspaces of a collection cannot overlap.`
- Two paths that name one workspace, the same path twice or a path and a
  link to it, exit `2`, naming the path given twice: `<path> names the same
  workspace as <earlier path>, <root>; give each workspace once.`
- `report`, `lens`, `check`, `plan`, and `apply` read one workspace's
  dataset and refuse a second path with exit `2`: `spyglass report reads one
  workspace's dataset; give one path.`
- When a workspace's scan fails, the workspaces scanned before it keep
  their saved datasets, and the command fails with `Spyglass could not scan
  <root>: <message>`.

## Flags

| Flag | Commands | Default | Effect |
| --- | --- | --- | --- |
| `--state <path>` | all | `~/.foundry/spyglass` | The state root. Each workspace's state directory, `<state>/<id>/`, holds its workspace record, output, and cache; see [output](/spyglass/reference/output). |
| `--history` | `spyglass`, `scan` | off | Analyze Git checkpoints as well; see [history](/spyglass/guides/history). |
| `--port <number>` | `spyglass`, `serve` | `4173` | The port to serve on, `0` to `65535`. `0` picks a free port. |
| `--no-open` | `spyglass`, `serve` | open | Do not open the browser after serving. |
| `--write` | `apply` | off | Write the change, verify it, then scan again; see [apply](#apply). |
| `--format <format>` | `report`, `lens`, `check`, `plan`, `apply` | `terminal` on a terminal, `text` when piped | `terminal`, `text`, or `markdown`. |
| `--density <density>` | `report`, `lens`, `check`, `plan`, `apply` | `normal` | `compact`, `normal`, or `expanded`. |
| `--json` | all | off | Print the result as one line of JSON instead of text. |
| `--quiet` | all | off | Print no progress. |
| `--help`, `-h` | | | Print the help and exit. |

An unknown flag is an error, even next to `--help`. Otherwise `--help`
wins over the rest of the line. A flag the command does not take is an
error too: `spyglass serve does not take --history.`

## What it prints

- stdout carries the command's result and nothing else. stderr carries
  progress, warnings, and errors.
- Progress goes to stderr. In a terminal (stderr and stdin both TTYs), a
  live display takes the bottom rows: the phase and its elapsed time, each
  phase done with its duration, a bar of packages analyzed, the packages
  being analyzed and the latest finished, as analyzed, reused, or failed.
  `scan` and `apply` draw the full panel; `open` draws two rows, since
  the browser is where it is heading. When the scan ends the display
  clears and one summary line stays: `Scanned 16 packages in 41s: 3
  analyzed, 13 reused.` Ctrl-C ends the scan as it always did, exit 130.
- Piped, progress is one line per phase, then `internals: <package>` per
  package; for a collection, `workspace: <root>` before each workspace's
  lines. `--quiet` silences progress in both forms, and nothing else.
  `NO_COLOR` keeps the live display but draws it without colour.
- `scan` ends with `Dataset saved to <dir>` on stdout; for a collection,
  one such line per workspace, then `Collection: <n> workspaces, <n>
  crossings`.
- `report` prints the package report, rendered for the format.
- `lens` prints the lens, rendered for the format.
- `check` prints the `rules` lens, rendered for the format, and exits `3`
  when a rule is broken.
- `apply` prints the outcome, each statement it edits or would edit, before
  and after, and after a write every check it ran; scan progress goes to
  stderr.
- `plan` prints the verdict for one opportunity, its planned changes, what
  stops it, and the verification an apply would run, rendered for the
  format.
- `export` prints `Static viewer saved to <dir>`.
- A server prints `Spyglass: <url>` and `Workspace: <root>` on stdout, one
  `Workspace:` line per workspace of a collection, then
  runs until `SIGINT` or `SIGTERM`, such as Ctrl-C, which stop it and exit
  `0`. The default command prints the scan's line, then the server's.
- A failure prints `Spyglass: <message>` on stderr and nothing more on
  stdout; the default command may already have printed `Dataset saved to
  <dir>` when serving then fails. A usage error adds `Run spyglass --help for
  usage.`

### `--json`

With `--json`, stdout is one line: the [library](/spyglass/reference/library)'s result as JSON.

| Command | The line |
| --- | --- |
| `scan` | `{ "manifest", "output", "history"? }`: the dataset's manifest, the directory it is saved in, and history's manifest when the scan includes it |
| `report` | The package report exactly as the dataset holds it, `output/packages/<name>.json` |
| `lens` | The lens's output exactly as the dataset holds it, `output/lenses/<id>.json` |
| `check` | `{ "generatedAt", "outcome", "lens" }`: the checked dataset's `generatedAt`, `clean`, `violations`, or `incomplete`, and the `rules` lens as saved |
| `plan` | `{ "site", "opportunity", "plan", "verdict", "record"? }`: the symbol as `path#name`, the opportunity's id, the saved plan as its package report holds it, the verdict (its `kind`, with readiness's assessment once readiness judged the plan, and the record when authorized), and the path the record was saved to, only when authorized |
| `apply` | `{ "site", "planId", "mode", "status", "refusal"?, "edits", "changedFiles", "verification"?, "scan"?, "timings"? }`: `mode` is `preview` or `write`; `status` is `preview`, `refused`, `applied`, or `rolled-back`; `refusal` says why, by `kind`; `edits` are `{ file, before, after }`, `after` `null` for a removed statement; `verification` holds every check after a write; `scan` is the scan after it, `{ status, output, durationMs, error? }`; `timings` how long the workspace's scripts ran before and after the write |
| `export` | `{ "directory", "manifest" }`: where the static viewer is, and the manifest of the dataset it holds |
| `serve` | `{ "root", "url" }`, printed once the server listens |
| `spyglass` | One line once the server listens: the scan's fields and the server's, `{ "manifest", "output", "history"?, "root", "url" }` |
| `scan` of a collection | `{ "manifest", "workspaces" }`: the collection manifest, and each workspace's `{ "manifest", "output", "history"? }` in the collection's order |
| `export` of a collection | `{ "directory", "manifest", "workspaces" }`: where the static viewer is, the collection manifest it holds, and each workspace's `{ "id", "root", "dataset" }`, `dataset` being the directory its dataset was copied to |
| `serve` of a collection | `{ "roots", "url" }`: each workspace root in the collection's order |
| `spyglass` of a collection | `{ "manifest", "workspaces", "roots", "url" }`: the collection scan's fields and the server's |

`--json` does not change stderr: progress still goes there, live or as
lines, unless `--quiet` is given. `report --json`, `lens --json`, `check --json`, and
`plan --json`, and `apply --json` take no `--format` or `--density`.
`check --json` and `apply --json` exit as the command does.

## Exit codes

| Code | When |
| --- | --- |
| `0` | The command finished, or `--help`; for `check`, no rule is broken, or none is declared; for `plan`, any verdict, refused and blocked included; for `apply`, a preview, or a write that verified and whose scan published |
| `1` | The command ran and failed: a path that does not exist or is not a directory, a package that failed analysis, a workspace being scanned, exported, or applied, `--history` without Git, the viewer's files missing, a port in use, no saved dataset or one with another schema version, an unknown package, a dataset saved without the lens asked for or with another version of it, a directory export refuses, `check` could not check a `require` rule in a package that failed analysis, which outranks any violation, `plan` or `apply` was given an opportunity the dataset does not hold, found the dataset stale, or found an unfinished apply, `apply` found no record or refused, rolled back, or wrote a verified change whose final swap then failed, or `check` found an unfinished apply |
| `2` | Bad arguments: an unknown flag, a flag the command does not take, a bad value, an unknown lens, a missing package, lens, opportunity, or directory, an opportunity of another shape, two paths that name one workspace, two paths of which one holds the other, or a second path for `report`, `lens`, `check`, `plan`, or `apply` |
| `130`, `143` | `apply --write` was stopped by Ctrl-C (`130`) or `SIGTERM` (`143`), and restored every file it wrote |
| `3` | `check` only: a rule is broken |

Codes above `2` are for commands' own results: `spyglass check` exits `3` on
a violation, and `spyglass apply` exits `130` or `143` once a write Ctrl-C or
`SIGTERM` interrupted is restored, so that a CI job can tell a broken rule from a failed run.
This differs from Marbles, whose usage errors exit `1`; from ESLint, where
`1` means lint errors and `2` a failed run; and from `tsc`, where `1` and
`2` both mean type errors.

A browser that fails to open is not a failure: the URL is printed on stderr
instead, with or without `--quiet`.

## Report

`spyglass report <package> [path...]` prints one package's report from the
saved dataset; it scans nothing. The package is its `package.json` name.
An unknown name fails and lists the dataset's packages. `terminal` uses
colour unless `NO_COLOR` is set, and wraps to the terminal's width; piped
output is 80 columns wide. Every format draws with Unicode glyphs.
`markdown` suits documentation and pull requests.

## Lens

`spyglass lens <id> [path...]` prints one lens from the saved dataset; it scans
nothing, and renders as `report` does. Every scan saves every lens; see
[output](/spyglass/reference/output) for each one's fields.

| Lens | What it shows |
| --- | --- |
| `domains` | The declared domains: the places each one's facets name, the facets that name none, and a domain label for each placed place in a domain |
| `file-roles` | Each package's files by role, and its high fan-in files |
| `frequent-change` | The files at or above the hotspots' commit gate |
| `import-traffic` | Each package pair the imports cross, with the files at each end |
| `nesting` | The folder tree, path regions, and the seams between them |
| `package-sizes` | Each package's dependency layer and files by kind |
| `placement` | Where each place is used against where it lives: fits, spread, or departs |
| `proximity` | What sits close without being related, and what is related without sitting close |
| `responsibility-groups` | Each package's responsibilities joined into groups |
| `rules` | The declared rules against the dataset: each place that breaks a `require` rule, with its saved plan's status and blocker reasons, the rules whose subject names no place, and the packages a `require` could not be checked in |

An unknown id is a usage error that lists the ids. A dataset saved before a
lens existed, or with another version of it, fails and asks for a new scan.

## Check

`spyglass check [path...]` checks the workspace's [rules](/spyglass/reference/configuration#rules)
against the saved dataset; it scans nothing and runs no configuration. It
prints the `rules` lens that the last scan saved, rendered as `lens`
renders, so a rule edited since then takes a scan to show: a warm scan
takes a few seconds, since rules invalidate no package. In CI, run
`spyglass scan --quiet` and then `spyglass check`.

A `require` of `internalize` means that, under its subject, no
package-public symbol is unused outside its package. Each such symbol is a
violation, listed with its saved plan's status and blocker reasons: a
`publishable` package's exports are blocked, since consumers outside the
workspace may import them and Spyglass cannot see them. A `permit` breaks
nothing.

| Exit | When |
| --- | --- |
| `0` | No rule is broken, or no rule is declared (the output says so) |
| `3` | At least one rule is broken |
| `1` | The check could not run, or a `require` rule meets a package that failed analysis, which outranks any violation |

A rule whose subject names no place in the dataset, matched as a domain's
facet is, is listed as unmatched and breaks nothing; check its spelling.

When a `require` meets a package that failed analysis, `check` exits `1`
and names the rule and the package on stderr. A package that keeps failing
under a wide `require`, such as one on `packages`, makes every `check` exit
`1`; narrowing the subject to the packages that analyze fixes it.

## Plan

`spyglass plan <opportunity> [path...]` plans one internalize opportunity from
the saved dataset and asks whether Spyglass may apply it. The opportunity is
written as `check` lists it, `internalize-symbol:<path#name>`, as its saved
plan's id, `plan:internalize-symbol:<path#name>`, or as the bare
`<path#name>`. Any other shape is a usage error; an opportunity the dataset
does not hold fails.

It takes the rules from the `rules` lens the last scan saved, as `check`
does, so it runs no configuration, and a rule edited since then takes a
scan to count. It answers with one verdict, checked cheapest first:

| Verdict | When |
| --- | --- |
| `refused-by-rule` | No `permit` or `require` rule of `internalize` covers the symbol. The output names a rule that would. Nothing runs. |
| `not-executable` | No mutator executes the plan: the saved plan is not `ready`, or it needs an edit `internalize-export` does not make. A `publishable` package's plan is `blocked`, and the output says why. Nothing runs. |
| `readiness` | Readiness judged the plan and did not authorize it: `stale`, `unsupported`, `blocked`, such as a test that fails before any change, or `not-authorized`, such as a package with no `test` script. |
| `authorized` | Every check passed. |

Before it authorizes, readiness runs the baseline of each verification
step: the package's `test` script, and its `typecheck` script where it has
one; without one, Spyglass checks the types itself. So `plan` runs the
workspace's own scripts, with `CI=1`, which can take minutes. Spyglass writes
nothing in the workspace, though the scripts may write what they always
write, such as coverage or build info.

An authorized plan's record, ids and fingerprints only, is saved to
`plans/<digest>.json` in the workspace's state directory; see
[output](/spyglass/reference/output). Any other verdict deletes the
opportunity's earlier record, so a record exists only while the last plan
authorized it. A later apply authorizes again before it writes, so a record
is never a promise that the sources still match.
`plan` takes no lock: it can run while a scan does, and its scripts can run
beside another command's. It refuses while an apply's journal,
`apply/journal.json` in the state directory, exists.

The saved plan is the last scan's, so `plan` first checks that the saved
dataset is current, and fails at once when it is not; see
[current](#a-current-dataset).

## A current dataset

A scan records one hash of everything its analyses read: every source
file, Git's commits and tracked files, the UTC day it ran on, the
configuration's exclusions and profile, and the analysis version. `plan`
and `apply` recompute that hash, in about two seconds on a workspace of
1,500 files, and go on only when it is the recorded one. So a dataset is
current on the UTC day it was scanned, with no commit and no source edit
since. Otherwise they fail with "The saved dataset is stale", and
`spyglass scan` makes it current again; after a commit or on a new UTC day
that scan derives the workspace again, which is most of a cold scan's time. A dataset saved before Spyglass recorded the hash cannot be shown current
either.

`plan` runs no configuration, so it takes the exclusions-and-profile part
of the hash from the dataset rather than from the configuration: an edited
exclusion is caught by `apply`, which loads the configuration, and not by
`plan`. Rules are not part of the hash; `apply` compares them with the
record.

## Apply

`spyglass apply <opportunity> [path...]` applies the plan `spyglass plan`
authorized for an opportunity, written as `plan` takes it. Without
`--write` it is a preview: it prints each statement it would edit, before
and after, and Spyglass writes no source file. With `--write` it writes the
change, verifies it, and scans again.

Apply never trusts the record on its own. Holding the state directory's
lock, in both modes, it:

1. finishes an earlier apply that did not: one interrupted while it wrote
   is restored from its journal, which it reports and exits `1`; one that
   wrote and verified its change waits for `spyglass scan`;
2. loads the configuration as it is now, and refuses a dataset that is not
   [current](#a-current-dataset), before any script runs: the saved
   package report is what the write is checked against;
3. authorizes the plan again, which runs the workspace's test and
   typecheck scripts again, and refuses unless the result is the record's.
   A refusal names what moved, in this order: the sources, with the files,
   the rules, the mutator's capabilities, the verification, the plan, and
   the rollback; "the Spyglass that made the record" when only the record's
   hash differs, as after a Spyglass upgrade;
4. refuses a plan whose rollback cannot restore every file it writes, or
   that creates or deletes a file;
5. refuses when a file it writes has uncommitted changes; an unrelated
   dirty file, or one the plan only reads, does not stop it. `--write`
   needs Git: outside a repository, without Git, or with a repository Git
   cannot read, it refuses and says which;
6. checks the saved plan against the saved package report, which a current
   dataset holds as a fresh analysis would. "Before" is fixed there, when
   currency is checked: a file edited elsewhere while the scripts run is
   not caught by these checks, but by the verification after the write,
   which reads the staged scan of the sources as they then are, and rolls
   back.

Then, with `--write`:

1. it checks each file it writes against the hash the rollback recorded,
   just before writing;
2. it saves the journal, `apply/journal.json` in the state directory, with
   each file's original bytes, and flushes it to disk before the first
   write;
3. it writes each file atomically, keeping its mode and any byte order
   mark;
4. it checks that no new TypeScript error appeared in the package, that
   the symbol still exists and no public route exposes it, that its
   external use is still none, and that the package's surface changed as
   the plan predicted; then it runs the test and typecheck scripts again,
   each of which must pass, or show no failure it did not show before the
   write;
5. on any failed check or error it restores every file it wrote, byte for
   byte, with its mode, and exits `1`; a script killed on its own, as by its
   ten-minute timeout or too much output, is a failed check. On Ctrl-C or
   `SIGTERM`, during the write or any check, the scripts included, it
   restores them too, says it was interrupted, and exits `130` for Ctrl-C,
   `143` for `SIGTERM`; a second signal does not stop the restore. A script
   that traps the signal and exits `0` does not hide it.
   A file that changed after Spyglass wrote it is not overwritten: the journal
   stays, and the error names the file and the journal, which keeps every
   original. Restore that file from Git (its `HEAD` content) and the next
   `spyglass apply` finishes the restore; delete the journal to keep the files
   as they are;
6. the structural checks read the scan after the write: it scans again,
   internals included, staged beside the dataset, keeping the dataset's
   history, since history covers past commits and apply commits nothing.
   A scan that fails leaves nothing to check against, so it fails the
   write, which is restored. On any failure the staged dataset is
   discarded and the published one is untouched;
7. once every check passes, the staged dataset is swapped in and recorded,
   and the record, if it is still the one it loaded, and the journal are
   deleted.

If only that last swap, or recording it, fails, the change stays written
and verified, the journal stays, and `apply` exits `1`: run `spyglass scan`,
which publishes the dataset and then deletes the journal. Until then
`plan`, `apply`, and `check` refuse, and a scan refuses while an apply's
write was interrupted, until `spyglass apply` restores it.

A write that rolls back leaves the scan cache holding the written sources'
analysis, so the next scan analyzes the package again and derives the
workspace again, as after any one-file edit.

Each write and restore replaces the file by a rename, so it keeps the
bytes and the mode, but not another user's ownership, extended attributes,
ACLs, or other hard links to the file. A crash can leave a
`<file>.<uuid>.tmp` beside a file; it is safe to delete. The scripts run
again after the write may write what they always write, such as coverage.

| Exit | When |
| --- | --- |
| `0` | A preview printed, or a change written, verified, and scanned |
| `1` | No record, a refusal (a stale dataset included), a rollback (a scan after the write that failed included), an earlier interrupted write restored, or a verified write whose final swap failed |
| `2` | Bad arguments, such as `--write` on another command |
| `130`, `143` | Ctrl-C (`130`) or `SIGTERM` (`143`) stopped the write, and every file it wrote is restored |

A refusal's `kind` says why: `pending-write` (an earlier apply did not
finish), `refused-by-rule`, `not-executable`, or `readiness` (authorizing
again did not authorize it), `authorization-changed` (with the `parts` that
moved), `rollback` (the plan creates or deletes a file, or a file it writes
cannot be restored), `dirty` (with the `files`), `no-git` (with the
`reason`), or `revalidation` (the mutator's fresh analysis found the saved
plan `stale`, `blocked`, or `unsupported`, with its `blockers`). `stale-dataset` (the dataset is not [current](#a-current-dataset), with the
`reason`) refuses before anything runs. `timings`
is `{ baselineMs, afterMs }`: how long the workspace's scripts ran under the
lock before the write, and again after it.

## Export

`spyglass export <dir> [path...]` writes a static viewer: the viewer's files at
the root of `<dir>`, the workspace's saved dataset, whole, under
`<dir>/data/`, and `.spyglass-static-viewer.json`, which lists the top-level
entries Spyglass wrote. Any static file host serves it, under any path; the
viewer reads `./data/` relative to its page. It scans nothing.

`<dir>` must be absent, an empty directory (a `.DS_Store` is ignored), or a
previous static viewer that holds nothing but what its
`.spyglass-static-viewer.json` lists. A previous static viewer is replaced only
once the new one is complete; a failed export leaves it as it was, though a
crash can leave `<dir>.export-<id>` or `<dir>.previous-<id>` beside it.
Anything else is refused. The check covers the top level only: a file you
add inside one of Spyglass's own entries, such as `data/`, is replaced with it. The refusals:

- `<dir> holds files Spyglass did not write, such as <entry>. Choose another
  directory.` for anything else in it, such as a `.git` directory or a
  `CNAME` file.
- `<dir> is not a directory. Choose another directory.` and `<dir> is a
  symbolic link; Spyglass does not write through links. Choose another
  directory.`
- `<dir>` must not hold the workspace, and must not overlap the state
  directory or the viewer's files.

With several paths, `spyglass export <dir> <path> <path>...` writes the
collection as the server serves it: each workspace's saved dataset, whole,
under `<dir>/data/<workspace id>/`, and `<dir>/data/collection-manifest.json`,
derived from the copied datasets; there is no `<dir>/data/manifest.json`.
`<dir>` must not hold any of the workspaces or overlap any of their state
directories. It holds every workspace's lock while it copies, and refuses
while any of them has an unfinished apply. The viewer cannot show a
collection yet.

To publish from a Git checkout, such as a `gh-pages` branch, export into a
subdirectory of it, or export elsewhere and copy the files in. A static
viewer inside the workspace is allowed; a later scan analyzes its files only
if a package pattern of the workspace covers them.

An export holds the state directory's lock while it copies, as a scan
does, so it fails at once while a scan of the workspace runs, and a scan
fails while it copies. It refuses while an apply is unfinished, since the
dataset may then not describe the sources. An apply holds the lock too, for as long as it
authorizes the plan again, its test and typecheck scripts included, even
for a preview: a scan or export started meanwhile fails at once.

## The server

`serve` binds to `127.0.0.1` on the chosen port and answers `GET` and
`HEAD` only. `/data/<path>` serves `output/<path>` byte for byte with
`Cache-Control: no-store`. Every other path serves the bundled viewer, with
`/` as `index.html`. A path that resolves outside those two directories,
including through a symlink, is `404`. The cache is never served. There is
no API and no directory listing.

For a collection, `/data/collection-manifest.json` is the collection
manifest, derived from the saved datasets at each request, and
`/data/<workspace id>/<path>` serves that workspace's `output/<path>`. Any
other path under `/data/`, an unknown workspace id included, is `404`.
While a workspace's dataset cannot be read, such as while a scan swaps it,
the collection manifest is `503` with `Retry-After: 1` and a message
naming the workspace. The viewer draws a collection as one archipelago
per workspace, side by side, with the crossings between them drawn on the
map and listed in Place.

The default command checks for the viewer's files before it scans, so a
checkout without a build reports `The viewer's files are missing` at once.
A port in use is only found once the scan has saved the dataset; serve it
on another port with `spyglass serve --port <number>`.

`serve` and `export` first check for the viewer's files. Then `serve`,
`report`, `lens`, and `export` read the saved dataset's manifest, and refuse a
workspace with no saved dataset or one saved with another schema version. A workspace whose directory has been removed
fails at once, since its path no longer resolves, even though its dataset is
still in the state root. `serve` takes no lock: a scan that runs while it
serves replaces the dataset atomically, and later requests see the new one.
