diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml new file mode 100644 index 0000000..bedc26e --- /dev/null +++ b/.github/workflows/cli.yml @@ -0,0 +1,21 @@ +name: CLI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: rustup toolchain install 1.93.0 --profile minimal --component clippy --component rustfmt + - run: cargo +1.93.0 fmt --all --check + - run: cargo +1.93.0 clippy --all-targets --locked -- -D warnings + - run: cargo +1.93.0 test --locked + - run: rustup toolchain install 1.85.0 --profile minimal + - run: cargo +1.85.0 check --all-targets --locked diff --git a/Cargo.toml b/Cargo.toml index 5ecf1e2..e390621 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ [package] name = "lyracore-cli" version = "0.1.0" +default-run = "lyracore" edition = "2021" # 1.85 is a floor the LOCKED dependency graph sets, not this crate's own code: several # transitive crates declare `edition = "2024"`, which Cargo needs 1.85 just to parse. (The @@ -19,6 +20,10 @@ publish = false name = "lyracore" path = "src/bin/main.rs" +[[bin]] +name = "lyracore-ops" +path = "src/bin/ops.rs" + [dependencies] thiserror = "1" serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index e603724..f193aed 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,12 @@ The CLI for [LyraCore](https://github.com/LyraCoreProject/LyraCore). Start a local realm, import world data, manage Packages, prepare client content, and provision Accounts. -It also provides read-only production status and an explicit command to reconcile the standalone -SpacetimeDB system service. It does not install Rust or SpacetimeDB or manage backups. The system -service changes only when you run -[`service reconcile`](#service-reconcile--make-the-host-match-the-tracked-unit). - -## Use from a LyraCore checkout +## Run from a Core checkout Follow the [LyraCore quickstart](https://github.com/LyraCoreProject/LyraCore/blob/main/docs/quickstart.md) -to install the prerequisites and clone Core. Its `./lyracore` launcher installs and runs the CLI +to install the prerequisites and clone Core. Its `./lyracore` launcher builds and runs the CLI commit pinned in `.lyracore-cli-rev`. You do not need to clone this CLI repository to use it. -From the Core checkout root: - ```bash ./lyracore doctor ./lyracore dev up @@ -25,640 +18,59 @@ From the Core checkout root: ./lyracore import ./lyracore dev check ./lyracore dev status -lyracore dev check -``` - -The global `lyracore` launcher supplied by Core's installer also works from a checkout subdirectory. -The commands below use that spelling. Use `./lyracore` from the checkout root if you skipped the -installer. - -## Commands - -[`docs/commands.md`](docs/commands.md) is the full reference for every command below, the -`import`, `config`, `client` and `packages` families included. - -`lyracore help` shows the short getting-started list. `lyracore help --all` shows the full help. -In the reference below, square brackets mark optional arguments and `...` means an argument can -repeat. Commands run against the selected Core checkout. - -### Local realm and publishing - -```text -lyracore help [--all] -lyracore doctor -lyracore bindings [--check] -lyracore preflight -lyracore publish [DATABASE ...] [--skip-preflight] -lyracore dev up [--single] [--lan ] -lyracore dev status -lyracore dev check -lyracore dev logs [spacetime|gateway] -lyracore dev smoke ACCOUNT CHARACTER [--password-stdin] -lyracore dev down [--forget] -lyracore account create USER [--password-stdin] -lyracore character gm NAME true|false -lyracore update -``` - -### World data and client content - -```text -lyracore config -lyracore config set client-data PATH -lyracore import [--client-data PATH] [--accept] [--profile-shard PROFILE=SHARD ...] -lyracore import world [--client-data PATH] [--accept] [--profile-shard PROFILE=SHARD ...] -lyracore import check [--profile-shard PROFILE=SHARD ...] -lyracore import vmaps [--client-data PATH] [--profile-shard PROFILE=SHARD ...] -lyracore client sync -lyracore client pack --out DIR [--zip] -``` - -Repeat `--profile-shard` for each assignment. `import` and `import world` are the same command. - -### Packages - -```text -lyracore packages list -lyracore packages new NAME -lyracore packages add FOLDER|GIT-URL|NAME [--yes] -lyracore packages update [NAME] [--yes] -lyracore packages enable NAME -lyracore packages disable NAME [--yes] -lyracore packages remove NAME [--yes] -lyracore packages build -lyracore packages check -lyracore packages apply [DATABASE ...] [--check] [--yes] [--force-all] [--client-data PATH] -lyracore packages config NAME [KEY [VALUE]] [--new] -``` - -### Production inspection and host service - -```text -lyracore production status --server URI --gateway-log PATH --realm-core DB DATABASE ... -lyracore service reconcile -``` - -`production status` requires the complete Shard list, including the Shard named by `--realm-core`. -`service reconcile` requires root and restarts SpacetimeDB. Read its section before running it. - -## Import world data - -```bash -lyracore config set client-data /path/to/WoW-1.12.1/Data -lyracore config -lyracore import -``` - -`config set client-data` checks and saves the path in `.lyracore/config.json`. `import` uses an -explicit `--client-data PATH` first, then the saved path, then asks for one. - -With the realm running, `import` fetches the pinned cMaNGOS `classic-db` world data and combines -it with your own 1.12.1 client's archives. It imports the supported Alliance early-game areas and -Deadmines, including world content, DBCs, terrain, and navigation. Playable worlds require -imported content. A fresh publish contains configuration and runtime schedules only. It creates -no Accounts, Characters, or game catalogue. You do not need a running MaNGOS installation. - -Server-issued Operator credentials reach importer subprocesses through a temporary SpacetimeDB -CLI config with owner-only permissions. The CLI removes that config when the import ends. The -global SpacetimeDB login stays intact. - -The command asks for consent on every run. For a scripted run: - -```bash -lyracore import world --client-data /path/to/WoW-1.12.1/Data --accept -``` - -The default plan follows the recorded fixture topology. A sharded realm uses `alliance-eastern`, -`alliance-kalimdor`, and `instances`; a single-Shard realm uses `alliance-single`. To name the -destinations explicitly, supply all three sharded World Import Profile assignments: - -```bash -lyracore import world \ - --profile-shard alliance-eastern=lyracore \ - --profile-shard alliance-kalimdor=lyracore-kalimdor \ - --profile-shard instances=lyracore-instances -``` - -Each assignment must name a distinct Shard. `starting-eastern` and `starting-kalimdor` can replace -their `alliance-*` counterparts to include the Horde starting areas. These assignments select -Shard names on the local SpacetimeDB node; they do not select a remote host. - -The import checks each destination against its World Import Profile. World profiles require -skinning content. The `instances` profile allows no skinnable creatures, as in Deadmines, but -requires loot for every referenced skinning table. - -`lyracore import world` automatically imports required WMO geometry for Deeprun Tram and Deadmines -before completing content. `lyracore import vmaps` refreshes model and WMO collision geometry in -populated World Shards. -It accepts the same client path and profile assignments, skips the Instance Pool, and does not -enable exact collision checks. It reads your own archives without fetching game data, so it has -no `--accept` option. - -## Manage Packages - -| Command | What it does | -| --- | --- | -| `packages list` | Show enabled and disabled Packages, their Package Sources, Content Identities, local changes, and registrations. | -| `packages new NAME` | Create a Package from Core's `packages/example/` Reference Package and run preflight. | -| `packages add FOLDER\|GIT-URL\|NAME` | Print a Trust Review, ask for confirmation, copy the Package into `packages/`, record its Package Source, and run preflight. | -| `packages update [NAME]` | Update one Git-backed Package or all of them, including Official Package Sources. Refuse local changes, review the candidate, and restore the previous copy if preflight fails. | -| `packages enable NAME` | Move a disabled Package back into `packages/`. | -| `packages disable NAME` | Run Package Teardown when a dev node is recorded, then move the folder into `.lyracore/packages-disabled/`. | -| `packages remove NAME` | Delete a disabled Package after confirmation. Refuse changes to its recorded Content Identity. | -| `packages build` | Refresh typings, install pinned Bun dependencies, typecheck and run Datascripts, compile Runtime Scripts, and check the generated artifacts together. | -| `packages check` | Check Package Deltas and Script Artifacts against their inputs, refreshing typings when needed. Preflight also runs this check. | -| `packages apply` | Prepare installed sources, publish Rust Packages, and apply artifacts on each target Shard. | -| `packages config NAME [KEY [VALUE]]` | List Package Config, read one key, or write one key on every recorded fixture Shard. | - -A bare name such as `playerbots` selects a Package from the -[Official Package Collection](https://github.com/LyraCoreProject/packages). A Git Package Source -must contain one Package at its root. Use `./my-package` to select a local folder explicitly. - -```bash -lyracore packages add playerbots -lyracore packages list -lyracore packages apply --check -``` - -Adding, updating, enabling, or disabling a Package does not publish the Module or sync a client. -Follow the next steps the command prints. Package Teardown deletes the Package's durable rows -before disabling it on a recorded dev node, so the command asks first. Without a recorded node it -moves the folder and reports any tables that still need teardown before publishing. - -`packages apply` uses the recorded fixture Shards unless you name destinations. `--check` prints -the plan without changing Shards. It can build local artifacts. `apply` prepares missing or stale -artifacts and publishes the Module when the Package Inventory contains Rust or a target records -pending Package Teardown. Artifact application skips Import Families whose recorded inputs already -match, so a retry resumes unfinished work; `--force-all` reapplies matching families too. -`--client-data PATH` supplies the client archives needed for the spell family. An empty enabled -Package set also reconciles state, removing Runtime Scripts and spell changes from disabled -Packages. - -`packages config NAME KEY VALUE` changes a key the Package already seeded. Add `--new` to create -a key. Reads report disagreements between Shards. Use `lyracore config` for the client path; -Package Config holds the values a Package reads at runtime. - -Use `--yes` to answer the confirmation for `add`, `update`, `disable`, `remove`, or `apply` in -advance. It does not bypass their checks. Bun is needed when `build` or `apply` must compile sources; -applying a prebuilt Package does not require it. - -`packages build` records a Build Identity for each artifact it generates. Datascripts need a Base -Snapshot from your client data; if it is missing, the command prints how to create it. -`packages check` reports a missing Base Snapshot as unverifiable. Source-free prebuilt Script -Artifacts still pass the Rust artifact checks without local authoring inputs. - -## Prepare client content - -```bash -lyracore client sync -lyracore client pack --out ./client-artifact --zip -``` - -`client sync` requires the saved client-data path. It builds `Data/patch-3.MPQ`, installs enabled -Packages' addons into `Interface/AddOns/`, and clears the client's `WDB/` cache. Restart the -client for MPQ changes or use `/reload` for addons. Addons left by a disabled or removed Package -are reported when detected and remain on disk. - -`client pack` builds a separate Client Artifact from authored content in `client-patch/` and -enabled Packages. It leaves the configured client alone and includes no base game assets. -`--out` is relative to the Core checkout when it is not absolute. `--zip` also creates `DIR.zip` -and requires the `zip` command. A nonempty output directory must be an earlier Client Artifact -created by this command before it can be replaced. - -## Grant Character GM access - -```bash -lyracore character gm Ginger true -lyracore character gm Ginger false ``` -The command searches the fixture's World Shards for the named Character and uses the coordinator -credential to set GM level 3 for `true` or level 0 for `false`. +Core's installer also supplies a global `lyracore` launcher that works from checkout subdirectories. +The local realm uses four databases by default; `dev up --single` selects one. Import your world +content before play. Stop the processes owned by the CLI with `lyracore dev down`. -## Update Core +## Find a command ```bash -lyracore update +lyracore help # initial setup +lyracore help --all # complete command surface +lyracore packages --help # one command family +lyracore help packages apply # one operation ``` -This fetches `origin`, refuses tracked local changes, and resets the Core checkout to `origin/main`. -It does not restart the realm. Review the reported change, then restart a local fixture with -`lyracore dev down` followed by `lyracore dev up` to rebuild and republish. If the CLI pin changed, -the Core launcher installs it on the next invocation. +The [command reference](docs/commands.md) holds the detailed behavior and safety contracts. +Full and focused help share one catalogue; a test keeps its documented command list synchronized. -Runtime state lives in the target checkout's git-ignored `.lyracore/`: `state.json` records the -processes the CLI started, `logs/` holds their output, and `config.json` holds the client path. -When the CLI mints a local coordinator identity, `coordinator-token` stores its credential with -mode `0600`. +Before a direct Gateway Cargo build or editor check, run `lyracore bindings` to prepare generated +bindings. `dev up` prepares them automatically. See the [bindings contract](docs/commands.md#bindings). -## `bindings` - -Run `./lyracore bindings` before a Gateway Cargo build or editor check on a fresh Core checkout. -It generates private Rust bindings from the local Module with `debug_reducers`, using the pinned -Rust and SpacetimeDB tools. Schema generation and CLI publishing take Rust flags from the -checkout's Cargo configuration and ignore ambient Rust-flag overrides. It does not contact a Realm. `dev up` prepares them before its Gateway -build, and the publish upgrade check reads the prepared schema. - -Files live under `.lyracore/generated/gateway/`, outside the source tree. Repeated preparation -reuses output while source and generated-file hashes match. Module and workspace sources, installed -Package sources, the Runtime Script event catalogue, Cargo configuration and lockfile, and tool -pins determine freshness. A failed generation leaves the previous complete output in place. - -`./lyracore bindings --check` checks existing output without running Cargo or SpacetimeDB and -prints JSON with `directory` and `watches`. Core's Gateway build script uses that contract through -the installed pinned CLI. Missing or stale bindings stop the build with preparation instructions. -Deleting `.lyracore/generated/gateway/` clears the generated cache, including old directories kept -for compilers that were already running. - -## `preflight` — the offline deploy gate - -```bash -lyracore preflight -``` - -Preflight checks the build and schema without contacting a SpacetimeDB node. It runs six checks: - -| # | Check | The break it catches | -| --- | --- | --- | -| 0 | `rustc` and the `spacetime` CLI **exactly** match the versions the checkout pins (`rust-toolchain.toml`, `module/Cargo.toml`) | tools drifting out from under the pin — a CLI ahead of it publishes a schema the repo never tested against | -| 1 | the module compiles with `--features=debug_reducers` | code that only a publish compiles, so the default test config never sees it | -| 2 | real, offline wasm schema extraction (`spacetime generate` into a scratch directory) | a `#[default(0)]` on a `u64`, which SpacetimeDB rejects at migration time and nothing in-tree validates | -| 3 | every `#[client_visibility_filter]` names real tables and columns | a filter stored as raw text at publish, rejecting a gateway **subscription** at login time | -| 4 | a script with a configurable `DB` target threads it into every tool it drives | an ETL writing to one database and asserting against another | -| 5 | Package Delta and Script Artifact inputs match their Build Identities | stale Package content reaching a Shard | - -Every check runs even after one fails, so a run hands back every problem rather than one per -attempt. Check 0 is an EXACT match, unlike `doctor`'s minimum-version floor: newer is not fine when -a publish is the thing being gated. Where `spacetimedb-standalone` is unavailable, -`PREFLIGHT_SKIP_SCHEMA=1` skips check 2, records a blocking failure, and continues the independent -checks. The bypass can collect the remaining evidence but cannot approve a publish because nothing -validated your `#[default]` encodings. -The SpacetimeDB CLI itself is mandatory: if it is absent, preflight still runs the independent -checks but exits nonzero and refuses to call the gate complete. - -## `production status` — explicit, read-only topology verification - -```bash -lyracore production status --server http://127.0.0.1:3000 --gateway-log /tmp/gw.log \ - --realm-core lyracore-realm \ - lyracore lyracore-world-1 lyracore-instances lyracore-realm -``` - -Production status requires the SpacetimeDB server nickname, host, or URL and never substitutes -`local` or the contributor fixture's database names. It distinguishes databases missing from -`spacetime list -s ` from published schemas that cannot be reached, -then reads only the latest gateway-start segment and reports the configured set, one coordinator -connection per database, coordinator credential warnings, realm-core activation, listeners, -startup errors, realm-address mismatch, and missing writer-occupancy metrics. Warnings leave the -exit code at zero but the summary says `WARNINGS`, never `HEALTHY`. It is strictly observational: -no publish, reducer, SQL write, signal, or restart is available through it. - -## `publish` — the one correct deploy - -```bash -lyracore publish # every database in the active fixture topology -lyracore publish lyracore lyracore-world-1 realm-core -``` +`packages apply` prepares and activates installed source. Authors can separately use +`packages build` to produce artifacts and `packages check` to verify existing ones. +`client sync` installs client content; `client pack --out DIR` builds a distributable artifact. -Runs `preflight`, then `spacetime publish -s local -p /module ---build-options=--features=debug_reducers --yes ` for each database in turn, stopping at -the first failure. With no names, it publishes the active fixture's recorded topology; with no -active fixture, it uses the default sharded topology that the next `dev up` would provision. -Explicit database **NAMES** are published exactly as given: +`lyracore update` updates Core only when it can preserve local commits. Replacing rewritten mirror +history requires `--reset-mirror`, which saves a recovery branch first. Neither form restarts +anything. See the [update contract](docs/commands.md#update--update-core-and-print-restart-instructions). -* `--features=debug_reducers` is baked in — a plain build omits the debug module, so publish reports - a FALSE "Removed table" breaking change and aborts; -* `--yes` is baked in — SpacetimeDB prompts for ANY schema change, even an additive END-appended - `#[default]` column, and a non-interactive stdin turns that prompt into an EOF abort; -* `-c` / `--delete-data` — the destructive wipe — is **refused**, as is any other flag-shaped - argument, with exit 2 and before a single `spacetime` process starts. Nothing is forwarded. +## Host administration -`--skip-preflight` skips the preflight checks. Binding preparation and Loot Roll upgrade checks -still run. Publishing several databases in one command is what makes "every shard" checkable instead of -remembered: a schema change needs all of them, and the gateway reports a shard left behind only as -"realm-core unreachable — LOGONS WILL BE REFUSED". +`lyracore production status` inspects an explicitly named production topology without changing it. +Root-only systemd reconciliation lives in the separate `lyracore-ops` binary and uses the selected +checkout without updating source. See [installation and host operations](docs/operations.md). +The developer CLI does not install Rust or SpacetimeDB or manage backups. -## What `dev up` does - -`dev up` brings up a **sharded** realm by default — four databases, one per production tier, so the -topology a contributor develops against is the one production has rather than a single-database -simplification: - -| database | role | wired by | -| --- | --- | --- | -| `lyracore` | default world shard: map 0, Eastern Kingdoms | `LYRACORE_DATABASE` | -| `lyracore-kalimdor` | map 1 | `LYRACORE_SHARD_MAP="1:*=lyracore-kalimdor"` | -| `lyracore-instances` | instance pool: every dungeon run (map 36, Deadmines) | `LYRACORE_SHARD_MAP="36:*=lyracore-instances"` | -| `lyracore-realm` | realm-core: accounts, sessions, the character→shard index | `LYRACORE_REALM_CORE` | - -Realm-core is **mandatory**, not optional: a gateway serving more than one world shard with no -realm-core refuses to serve them. And `lyracore` is first in every list this CLI builds, because it -is what every lookup no shard-map rule answers collapses to, and because a publish walks the list in -order — so the database whose failure matters most is the one that fails first. - -The instance pool joined in #108, for the reason the region shard left: it is a production tier a -fresh clone could not exercise at all, so instance routing was the one split nobody developed -against. Both shard-map rules are the same shape — an instance map routes exactly like a continent -one, and the bucket half of a rule exists to spread ONE map's instances over a pool of several -databases, which a one-database pool does not need. - -Three names here — `lyracore`, `lyracore-instances`, `lyracore-realm` — are also production's. What -keeps a fixture off a production node is the **node**, not the name: every publish this CLI renders -is `-s local`, against the SpacetimeDB on loopback:3000 that `dev up` starts. - -There is no map-0 region shard here any more. LyraCore's alpha topology reversal (2026-08-08) -retired location/region sharding, and the gateway stopped reading `LYRACORE_REGION_SHARDS` and -`game_map_region` with it — so a database for it was one `dev up` published, claimed, and then -reported as a collapsed realm on every single run, because the gateway never connected it. -`LYRACORE_REGION_SHARDS` is still *actively unset* for the child in both modes, so an old recipe -exported in your shell cannot put it back. - -**Dungeon populations spawn on the world shard until you say otherwise.** Routing map 36 off -`lyracore` does not by itself move the spawning: `game_config.hosts_instances` is a per-database -flag that defaults to on everywhere, so the gateway logs one WARNING per start saying every -Deadmines entry spawns its ~207 creatures on the world writer and evicts them again after the -transfer. The run works. To model the production Phase A split, turn the flag off on the world -shard once — it survives a republish: - -``` -spacetime sql -s local lyracore "UPDATE game_config SET hosts_instances = false WHERE id = 0" -``` - -Leave it alone under `dev up --single`, where the one database has to host its own dungeons. - -The steps: - -1. Starts SpacetimeDB on `127.0.0.1:3000`, **or reuses one already listening there.** A node the CLI - did not start is never recorded and never stopped by `dev down`. -2. Builds the gateway. -3. Runs `preflight`, then publishes each database in turn, through the same internal command - `lyracore publish` uses — which is what guarantees `--features=debug_reducers`, `--yes`, - `-s local`, and the unreachability of a `-c` wipe. No path here renders a `spacetime publish` - any other way, clears a database, or re-selects the SpacetimeDB server. A failure part way - through names the databases that *did* land, because a half-published realm presents as an - unrelated mid-session hang rather than a loud "no such table". -4. Resolves the coordinator credential (see below), minting one from the local node if this host has - no SpacetimeDB login. -5. Calls `claim_operator` **as that identity**, on every database (idempotent for the same identity, - so repeating `dev up` is not an error). A shard claimed by nobody, or by a different identity, - refuses the gateway's own writes — and nothing fails until the first write that shard has to - serve. -6. Starts the gateway with the same credential, bound to loopback. -7. **Reads the realised topology back out of the gateway's own log** and fails if it came up short. - See below. - -### The silent collapse, and what is done about it - -A gateway's response to bad topology configuration is not an error. It is **collapse to one -database**: a malformed shard-map rule is logged and dropped, an absent — or default-equal — -`LYRACORE_REALM_CORE` reads as "unconfigured", an empty `LYRACORE_SHARD_MAP` still counts as set, and -a database that never published is "unreachable, falling back to the default". The result starts, -binds, answers its health probe, passes every PID-and-port check, and serves one database while the -others sit published, claimed and unused. - -So `dev up` does not stop at exporting the right strings: - -* it reads `coordinator connected to shard ` back out of `.lyracore/logs/gateway.log` — the - gateway awaits every one of those connections *before* it binds its listeners, so a gateway - answering its port has already written all of them — and **fails, naming the missing databases**, - if the realm came up short. The gateway is left running and recorded, so `dev down` still stops - it; -* a gateway build that does not log that line at all is reported as *unverified* rather than as - collapsed; -* `dev status` reports each database separately: published or unreachable, connected or never - reached. - -### `--single` — one database, on purpose - -`dev up --single` is the pre-sharding fixture, unchanged: one database, no realm-core, and -`LYRACORE_SHARD_MAP`, `LYRACORE_SHARD_MAP_FILE`, `LYRACORE_REALM_CORE` and `LYRACORE_REGION_SHARDS` -all *actively unset* for the child gateway — so a contributor who has the production recipe exported -in their shell still gets the fixture, not a multi-database gateway pointed at databases this CLI -never published. An unconfigured shard map collapses every lookup to `LYRACORE_DATABASE`, making the -result equivalent to a single-database build. - -The two options compose: `dev up --single --lan 192.168.1.50` is a one-database realm on the LAN. - -A running gateway cannot be re-sharded any more than it can be rebound — its shard set is read from -the environment once, at startup. Switching modes is refused with the `dev down` to run first, rather -than reporting "already up" for a realm with the wrong number of databases in it. - -### The coordinator credential - -Steps 4–6 are not optional plumbing. `game_account` and `game_session` are **private** module tables -and `provision_account` is operator-gated, so the gateway's coordinator connection has to -authenticate as the identity that claimed the operator. Without it the gateway starts, warns, and -dies ~15 seconds later on `coordinator subscriptions not applied within 15s`, which reads like a -broken node rather than a missing credential; `account create` would fail as "operator only" for the -same reason. - -Where the credential comes from, in order: - -1. **`.lyracore/coordinator-token`** — one this CLI minted on an earlier run. It wins, because it is - the identity that already claimed the operator: `claim_operator` is idempotent for the same - identity and refuses a different one, so preferring anything else once this file exists would - lock the checkout out of its own database. -2. **`spacetime login show --token`** — if you already use SpacetimeDB, your identity is reused and - nothing is minted or stored. -3. **`POST /v1/identity` on the local node** — a **server-issued** identity, minted from the node - `dev up` just started and persisted at mode `0600`. - -Step 3 is what keeps the quickstart anonymous. `spacetime login` offers only the spacetimedb.com -browser flow, so requiring it would put a third-party account signup in front of `git clone && -./lyracore dev up`; a server-issued token is exactly as privileged here, because the module trusts -whoever claimed the operator and this CLI claims it with the token it just minted. That claim is -made over the node's HTTP API rather than by shelling out to `spacetime call`, which would run as -the CLI's identity instead — a claim by one identity and a gateway running as another is precisely -the lock-out this avoids. - -`account create` uses the same ladder **without** step 3: a freshly minted identity has claimed -nothing, so it would be refused after the password had already been read. It says to run `dev up`. - -The credential reaches a child as an **environment variable, never an argument** (`ps` shows -nothing), and as an `Authorization` header, never a URL. `CommandSpec` renders program and arguments -only — so it cannot reach a log line, an error message, or `state.json`. The one place it touches -disk is `.lyracore/coordinator-token`, created `0600`, inside a git-ignored directory. - -Re-running `dev up` on a healthy stack does nothing; on a partially-up stack it starts only the -missing part. - -### `--lan ` — let another machine on your network connect - -`dev up --lan 192.168.1.50` binds the two CLIENT-FACING listeners (logon 3724, world 8085) to that -address and advertises it in the realm list, so a 1.12.1 client elsewhere on the LAN can set its -realmlist to it and play. - -**SpacetimeDB is not part of that.** It stays on `127.0.0.1:3000` in every mode: the database's -admin surface is not something a `dev` command should put on a network. - -The address must be a private one — `10.0.0.0/8`, `172.16.0.0/12`, or `192.168.0.0/16`. A public -address, or `0.0.0.0`, is a usage error rather than a wildcard bind, because "expose an alpha game -server to the internet" should not be one mistyped character away from "let my flatmate log in". - -A running gateway cannot be rebound: switching modes is refused with the `dev down` to run first, -rather than reporting "already up" for a realm that is not listening where you asked. - -### `dev check` and `dev smoke` - -`lyracore dev check` asks the Module to check imported content on each World Shard and the -Instance Pool. It checks completed imports and actual content dependencies. It reports all -missing destinations and changes no content. `dev status` reports processes and published -Shards; `preflight` checks the build offline. - -```bash -lyracore account create ALICE -lyracore import -lyracore dev check -lyracore dev smoke ALICE Ginger -``` - -Smoke reads your Account password from a hidden terminal prompt, or from stdin with -`--password-stdin`. It checks content before requesting the password. It builds the Headless -Client from the release pinned in Core's `.wire-harness-rev`, verifies its commit, and runs -login, Character enumeration and world entry. The client creates the named Human Warrior if -it does not exist. That Character uses imported starting content. - -The release cache is `.lyracore/wire-harness//`. Set `LYRACORE_WIRE_HARNESS_DIR` to use a -local checkout; the CLI reports that override on every run. Smoke uses the local Realm's -recorded client address, including LAN mode. - -### Not the production topology - -The sharded fixture is not the production recipe in the server repo's `docs/danger-zones.md` §3 — -different databases, loopback only, and every topology variable decided by this CLI rather than -inherited from your shell. Nothing here reads one out of the environment in either mode. - -A gateway already serving the world port is **refused, never adopted** — its build and topology are -unknown, and the health probe would otherwise pass against someone else's listener. - -## Stopping things safely - -A bare PID is not an identity: PIDs get reused, and signalling a recycled one kills a stranger's -process. Each recorded PID is stored with its process start time and command name, read via POSIX -`ps` — no `/proc`, no `grep -P`, no GNU-only flags, so Linux and macOS behave the same. - -`dev status` checks the things that can be wrong independently: the recorded PID is still the -process the CLI started, its endpoint answers (on the LAN address, in LAN mode), and — **per -database** — that it is published on the node and that the gateway actually connected to it. A stack -whose PIDs and ports are both perfect is still broken if a database was never published, and still -half-broken if one was published and never reached; in a sharded realm the database that *is* fine -is invariably the default one, which is why the report is per-database rather than one line. - -Which databases it reports is read from `state.json`, so it describes the realm that is actually -running rather than the one today's default would build. - -`dev down` compares that identity before signalling anything. If the PID now belongs to something -else it **refuses and kills nothing**, directing you to `dev down --forget`, which drops the record -without signalling. - -## `service reconcile` — make the host match the tracked unit - -```bash -sudo ./lyracore service reconcile -``` - -For a **production host** only. It makes the host's `spacetimedb-standalone` supervisor match the -unit tracked in the checkout at `deploy/systemd/spacetimedb-standalone.service`. Deployment -reconciliation is one job, so the verb owns the git steps too. In order: - -1. **Root, up front.** `id -u` runs before the fetch. The plan resets the checkout and then writes - to `/etc/systemd/system`, and a plan that stops halfway for a password is worse than one that - never started. -2. **The same checkout update `update` does.** Fetch `origin`, refuse over tracked local edits, and - move to `origin/main` with `git reset --hard`. Tracked local edits refuse *everything*, the - service change included. -3. **Host prerequisites.** `busctl` with JSON output, the unit's `User=` account, its `ExecStart` - binary, its `--data-dir`, and the directory holding its `StandardError=append:` log. Each missing - one is a refusal before the host changes. None of them is created for you. -4. **A conflicting service.** Every active unit whose `ExecStart` or `WorkingDirectory` claims the - same data directory or listen address. A hand-rolled legacy `spacetimedb.service` is named and - refused, never migrated and never stopped on your behalf, so two nodes cannot race for one port - and one data directory. -5. **The install.** `install -o root -g root -m 0644` into `/etc/systemd/system/`, then - `systemctl daemon-reload`, `enable`, `restart`. -6. **The verification.** `systemctl show` must report `ActiveState=active`, the tracked `LimitNOFILE`, - and stderr mode `append`. The CLI compares the running `MainPID` file descriptor 2 with the tracked - log by device and inode. A different destination or unreadable file fails reconciliation. The - typed `ExecStart` D-Bus property must match the tracked command, including each argument boundary. - If the command differs, the failure also names the applicable systemd drop-ins. A node with the - inherited 1024-descriptor ceiling fails reconciliation. - -Every expected value is read out of the tracked unit rather than duplicated in this CLI, so it -cannot certify a host against a contract the checkout no longer ships. The node's persistent data -directory is only ever checked for existence: never created, moved or deleted. Two runs converge on -the same end state, and it runs even when the checkout is already at `origin/main`, because -deployment drift is independent of git drift. It restarts the node every time, so every run costs a -short outage. - -Steps 3 and 4 check the host before changing it, so a failure there leaves the checkout on -`origin/main` and the host as it was. The reset in step 2 comes first on purpose: the unit to -install, and the contract to check the host against, are read out of the updated checkout. - -No gateway, module publish or schema migration is touched. Those stay operator decisions. Plain -`update` is the contributor command and reconciles nothing. - -## Passwords - -`account create` reads the password from a hidden terminal prompt, or from one bounded stdin line -with `--password-stdin`. It is handed to `lyracore-gateway provision USER --password-stdin` over the child's -stdin and never becomes a command-line argument, so `ps` shows only the username. It is held in a -zeroized buffer and is absent from rendered commands, logs, error messages, and `state.json`. - -```bash -printf 'hunter2' | lyracore account create ALICE --password-stdin -``` - -On a sharded realm the account is written **twice** — once on the world shard, whose account id owns -the characters, and once on realm-core, which is where the logon server answers the SRP6 challenge -from. `account create` reads the running realm's topology out of `state.json` and hands the -provisioning child the same `LYRACORE_REALM_CORE` the gateway is using. Without it the account -exists, the command reports success, and the login is refused forever. - -## Project layout coupling - -`src/project.rs` is the single adapter holding the target project's internal database, package, -path, and bind names. Renaming those internals is a one-file change here, and no public command -surface moves with it. - -Publishing and preflight live in this CLI's `cmd/publish.rs` and `cmd/preflight.rs`. World import -drives Core's importer binary and its `importer/scripts/` tools for the cMaNGOS fetch, class spells, -and profile checks. `dev smoke` fetches the test code selected by Core's `.wire-harness-rev`. - -## Host operations scripts - -`deploy/` holds the production host scripts that are not CLI commands: the SpacetimeDB history -prune, the disk guard that renews the Bot Capacity Lease, the diagnostic capture wrapper, and their -systemd units. They moved here from the LyraCore repository. The server's `docs/danger-zones.md` -and `docs/operations/disk-policy.md` give the install steps; run them from a checkout of this -repository. The Standalone Supervisor unit stays in the LyraCore checkout, because -`service reconcile` reads it from there. +## Development ```bash +cargo test --locked +cargo clippy --all-targets --locked -- -D warnings +cargo +1.85 check --locked # supported minimum toolchain bash deploy/spacetimedb-prune-test.sh python3 -m unittest discover -s deploy -p 'test_*.py' ``` -## Exit codes - -| Code | Meaning | -| ---: | --- | -| `0` | Success — including "already up", "already down", and a `doctor` with only warnings | -| `1` | Operational failure: missing prerequisite, failed subprocess, or a refused foreign PID | -| `2` | Invalid invocation, or not inside a checkout | - -`doctor` exits nonzero only for launch-blocking failures. A busy port is a warning, not a failure — -it is usually your own running stack. - -## Development - -```bash -cargo test -cargo +1.85 check # the supported minimum toolchain -``` +Unit tests use fake process adapters and temporary directories. Checkout-update integration tests +also invoke the built CLI and Git against disposable local repositories, without network access. +No test starts a real realm or administers a host service. -Tests run entirely against fake command and process adapters plus temporary directories; nothing in -the suite starts a real server or touches a real process. +[Core contract ownership](docs/contract-ownership.md) records the interfaces needed to remove the +remaining artifact-format and import-verification duplication without losing checks. ## License -MIT OR Apache-2.0 +Licensed under either [Apache License 2.0](LICENSE-APACHE) or [MIT License](LICENSE-MIT), at your option. diff --git a/docs/commands.md b/docs/commands.md index 6c3e873..7e9fd91 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -7,16 +7,9 @@ This is the command reference for `lyracore`. It moved here from the LyraCore re > is the clone → running realm → connected client path, with prerequisites and troubleshooting. > This page is the CLI's command and safety contract. -`lyracore` runs the local developer fixture — since #327 a **sharded** one, four databases split -along the continental divide and the open-world/instance one -(§"Sharded out of the box, on purpose"; `--single` collapses it back to one). It -deliberately does not manage production realms, backups, or the installation of Rust and -SpacetimeDB. The one system service it can touch is the Standalone Supervisor tracked in this -checkout, and only when [`service reconcile`](#service-reconcile--make-a-host-match-the-tracked-unit) -asks it to. - -**The CLI lives in its own repository**, [`LyraCoreProject/lyracore-cli`](https://github.com/LyraCoreProject/lyracore-cli). -This repo does not vendor it. +`lyracore` runs a local developer realm, imports content, manages Packages and inspects an +explicit production topology. It does not install Rust or SpacetimeDB or manage backups. +Host service administration lives in the separate [`lyracore-ops` tool](operations.md). ## Running it @@ -45,75 +38,48 @@ was private, which meant a fresh clone's very first command failed for anyone bu ## Commands + ```text +lyracore help [--all | COMMAND ...] lyracore doctor lyracore bindings [--check] lyracore preflight lyracore publish [DATABASE ...] [--skip-preflight] -lyracore dev up [--lan ] +lyracore dev up [--single] [--lan IP] lyracore dev status +lyracore dev logs [spacetime|gateway] [--all] lyracore dev check -lyracore dev logs [spacetime|gateway] lyracore dev smoke ACCOUNT CHARACTER [--password-stdin] lyracore dev down [--forget] lyracore account create USER [--password-stdin] -lyracore account alpha-test-tools enrollment REALM_CORE [true|false] -lyracore account alpha-test-tools grant|revoke REALM_CORE ACCOUNT -lyracore import [--accept] [--client-data PATH] +lyracore character gm NAME true|false +lyracore import [--accept] [--client-data PATH] [--profile-shard PROFILE=SHARD ...] +lyracore import world [--accept] [--client-data PATH] [--profile-shard PROFILE=SHARD ...] +lyracore import check [--profile-shard PROFILE=SHARD ...] +lyracore import vmaps [--client-data PATH] [--profile-shard PROFILE=SHARD ...] lyracore config lyracore config set client-data PATH lyracore client sync lyracore client pack --out DIR [--zip] lyracore packages add FOLDER|GIT-URL|NAME [--yes] +lyracore packages update [NAME] [--yes] +lyracore packages enable NAME +lyracore packages disable NAME [--yes] +lyracore packages remove NAME [--yes] +lyracore packages apply [DATABASE ...] [--check] [--yes] [--force-all] [--client-data PATH] +lyracore packages config NAME [KEY [VALUE]] [--new] [--database NAME ...] lyracore packages build lyracore packages check -lyracore packages config NAME [KEY [VALUE]] [--new] -lyracore packages disable NAME [--yes] -lyracore packages enable NAME lyracore packages list lyracore packages new NAME [--from RUNG] -lyracore packages remove NAME [--yes] -lyracore packages apply [DATABASE ...] [--check] [--yes] [--force-all] [--client-data PATH] -lyracore packages update [NAME] [--yes] -lyracore character gm NAME true|false -lyracore production status --server SERVER --gateway-log PATH --realm-core DB DATABASE ... -lyracore service reconcile -lyracore update +lyracore production status --server URI --gateway-log PATH --realm-core DB DATABASE ... +lyracore update [--reset-mirror] ``` + -| Command | What it is | -| --- | --- | -| `doctor` | are the prerequisites for `dev up` present? | -| `preflight` | the OFFLINE deploy gate — the same five checks the core repo's own pre-publish gate runs, natively | -| `publish` | the ONE correct `spacetime publish`, with the two mandatory flags and no path to a wipe | -| `dev up` | start (or reuse) the loopback fixture — four databases: two world shards, the instance pool, realm-core (`--single` for one) | -| `dev status` | process identity, endpoint, and whether the database is actually published | -| `dev logs` | tail the components this CLI started | -| `dev smoke` | check content, then log in with your chosen Account and Character through the pinned Headless Client | -| `dev check` | check imported content on each local World Shard and Instance Pool | -| `import check` | check imported content using explicit World Import Profile assignments | -| `dev down` | stop only the processes this CLI started, and only if the PID is still ours | -| `account create` | provision an account's SRP6 credentials without a password in `argv` | -| `account alpha-test-tools` | read or set automatic enrollment, or grant or revoke one Account | -| `import` | import playable content into each selected World Shard and Instance Pool after consent | -| `config` | show, or set, the client-data path `import` and `doctor` remember | -| `client sync` | pack `patch-3.MPQ` and every enabled Package's addons, then install them into the configured client | -| `client pack` | build the Client Artifact a player installs: package-authored content only, into a directory of your choosing | -| `packages add` | install a Package from a folder on this machine, from a Git URL, or by bare name from the Official Package Collection, after a trust review and a confirmation | -| `packages build` | regenerate the Module schema typings, typecheck every Datascript against them, then emit and validate each enabled Package's Package Delta and Script Artifact | -| `packages check` | verify every enabled Package's generated artifact against its recorded Build Identity, regenerating the Module typings fresh | -| `packages config` | read a Package's key-values, or write one to every Shard of the fixture topology | -| `packages disable` | run Package Teardown on every Shard, then move the Package out of the build's sight, keeping it on disk | -| `packages enable` | move a disabled Package back into the build | -| `packages list` | every installed Package: enabled or disabled, where it came from, and whether it has drifted | -| `packages new` | copy and rename a Reference Package from the collection tag matching this checkout's Package API | -| `packages remove` | delete a disabled Package, after a confirmation and a check for local changes | -| `packages apply` | prepare installed sources, publish Rust Packages, and apply artifacts to the named Shards or recorded development topology | -| `packages update` | update Git Package Sources from their repository and Official Package Sources from the compatible collection tag | -| `character gm` | flip GM commands on or off for a character, on whichever world shard has it | -| `production status` | read-only checks for an explicitly named production topology and the latest gateway start | -| `service reconcile` | make a production host's Standalone Supervisor match the unit tracked in this checkout. Root only | -| `update` | pull the latest LyraCore into this checkout and tell you how to restart it | +Use `lyracore help --all` for the complete descriptions, `lyracore packages --help` for a +command family, or `lyracore help packages apply` for one operation. Full and focused help +come from the same catalogue; tests keep the command list above in sync with it. **The CLI shells out to nothing in `scripts/`.** It drives this checkout through its *files* — `Cargo.toml`, `rust-toolchain.toml`, `module/`, `scripts/*.sh` (read, not run), `.wire-harness-rev`. @@ -269,8 +235,9 @@ that folder said at the time. A symlink anywhere inside the candidate is refused reason. A `.git` directory is skipped. The install then writes a **Provenance Stamp** — `packages//.lyracore-package.toml`, holding -the Package Source, the Content Identity of what was copied, and the install time — and runs -`preflight`. A Git Package Source records one key more: the exact commit that was installed. +the Package Source, the Content Identity of what was copied, and the install time. +Server content runs `preflight`; a client-only Package skips the module build and is validated +when `client sync` or `client pack` packs its content. A Git Package Source records one key more: the exact commit that was installed. **An argument that looks like a URL is a Git Package Source.** `https://`, `http://`, `ssh://`, `git://` and the scp-style `git@host:path` are cloned. Everything else is a path on this machine, as @@ -315,7 +282,8 @@ The checkout's Package API version selects its tag, as with `packages add`. The `example-rust` and `example-all`. A missing tag or rung leaves the Package Inventory unchanged. The command copies the source into `packages/NAME/`, renames Package names, Rust identifiers and -client filenames, prints its Trust Review and runs `preflight`. The Provenance Stamp keeps the +client filenames and prints its Trust Review. Server-content scaffolds run `preflight`; +client-only scaffolds are validated when packed. The Provenance Stamp keeps the `scaffold` kind and records the chosen rung and exact collection revision. `packages update` does not replace scaffolded code. @@ -417,8 +385,10 @@ somewhere else. That is `packages remove`'s rule, for the same reason: local edi copy are recorded nowhere, and neither command can get them back. **The previous revision is kept until the new one is proven.** The old folder moves out of the -inventory, the new revision installs in its place, and `preflight` runs. A disabled Package stays -disabled and is excluded from compilation. The old folder is deleted only after `preflight` passes. If anything fails, the previous revision goes back byte for byte, +inventory and the new revision installs in its place. Enabled server-content changes run +`preflight`, including changes that remove server content. Disabled Packages remain disabled and +skip module validation, as do client-only changes. All candidates still undergo shape, source-drift +and Trust Review checks. The old folder is deleted only after the applicable checks pass. If anything fails, the previous revision goes back byte for byte, the candidate is discarded, and the error names both commits. `update` publishes nothing and synchronizes no client; it prints the steps it did not run. @@ -464,8 +434,11 @@ cause is fixed rewrites the Shards that already took the value, which changes no A **Datascript** is author-time TypeScript that describes game data. It is written against the Module's own schema, so the names and types in it cannot drift from the Module. `packages build` enforces that and, once a Package has a Datascript, turns it into a validated Package Delta. The -same command compiles a Package's **Runtime Scripts** into its Script Artifact. A version gate, -then up to eight steps, in this order: +same command compiles a Package's **Runtime Scripts** into its Script Artifact. +The enabled source inventory determines the work: no sources means no Bun or schema generation; +existing artifacts are still validated and artifacts whose last Runtime Script source was removed +are retired. Runtime-script-only builds need Bun and the locked dependencies, but no schema +typings or Datascript typecheck. With Datascripts, the steps are: 0. `bun --version` must match the checkout's pin. A hard failure, not a warning: the next two steps run `bun install` and the locked `tsc` for real, against whatever Bun is on `PATH`. @@ -477,8 +450,8 @@ then up to eight steps, in this order: 3. `tsc --noEmit` typechecks Core's Datascript project and each enabled Package's local Datascripts. The CLI extends Core's compiler configuration in a temporary file. No JavaScript is emitted. -Steps 4 to 8 run only when an enabled Package carries a Datascript or a Runtime Script. A checkout -with neither builds exactly as it did before those steps existed: +Steps 1 and 3 are Datascript-only. Step 7 also validates source-free artifacts without a Bun +installation. Artifact production and identity recording continue as follows: 4. The Base Snapshot must already exist at `datascripts/generated/base-snapshot.json`, or the build fails fast with the exact `lyracore-importer --spell-snapshot` command to build one, once, rather @@ -548,7 +521,7 @@ The Build Identity covers both source directories, so editing either requires a ### Bun is author-side only -`packages build` needs Bun. `packages apply` runs that build when installed sources need new +`packages build` needs Bun only when enabled Package sources need compilation. `packages apply` runs that build when installed sources need new artifacts. **An Operator applying a prebuilt Package needs no Bun and no Node.** Nothing in `dev up`, `preflight`, `publish` or `client sync` invokes a JavaScript toolchain. That is why `doctor` reports a missing or different Bun version as a warning and never as a launch blocker. @@ -672,7 +645,8 @@ Local generated files can change during a check. If the Package Inventory contains Rust, or a target records pending Package Teardown, `apply` publishes this checkout's Module. It keeps the normal preflight and Loot Roll upgrade checks and repairs schedules on each published Shard before continuing. Rust Packages cause a publish on -every run; artifact provenance does not track compiled Rust. Disable a Rust Package through +every run, including disabled Packages whose code may still be deployed after an offline disable; +artifact provenance does not track compiled Rust. Disable a Rust Package through `packages disable` before removing it so its tables and Characters receive Package Teardown. Each Shard completes its publish and schedule repair, when needed, before applying artifacts. @@ -1134,27 +1108,6 @@ unset — per `gateway/src/config.rs`, an unconfigured shard map collapses every you are debugging something that is not about sharding, when RAM is tight (four databases cost more than one), or to establish whether a bug is a sharding bug at all. -## `account alpha-test-tools` controls - -```bash -./lyracore account alpha-test-tools enrollment lyracore-realm -./lyracore account alpha-test-tools enrollment lyracore-realm true -./lyracore account alpha-test-tools enrollment lyracore-realm false -./lyracore account alpha-test-tools grant lyracore-realm ACCOUNT -./lyracore account alpha-test-tools revoke lyracore-realm ACCOUNT -``` - -The first command reads whether genuinely new Accounts receive Alpha Test Tools. Adding `true` or -`false` enables or disables that automatic enrollment. It does not change existing Accounts. -`grant` and `revoke` change one existing Account, with the name normalized to uppercase as it is -during provisioning. - -Every form requires the Realm-core database name. Use `lyracore-realm` for the default sharded -fixture and `lyracore` under `dev up --single`. The CLI refuses a missing target, invalid boolean or -action, and a missing Account name before it sends a request. It authenticates the read and every -Operator-only change with the coordinator credential that `dev up` resolved. The credential stays -out of arguments, rendered commands, and output. - ## `character gm` — grant or revoke GM commands ```bash @@ -1219,65 +1172,46 @@ zeroized buffer, and is absent from rendered commands, logs, error messages, and printf 'hunter2' | ./lyracore account create ALICE --password-stdin ``` -## `service reconcile` — make a host match the tracked unit +## Host administration + +`lyracore service reconcile` has moved to `lyracore-ops service reconcile`. +See [Host operations](operations.md) for installation and the reconciliation contract. +It does not fetch or reset the Core checkout. + +## `update` — update Core and print restart instructions ```bash -sudo ./lyracore service reconcile +./lyracore update +./lyracore update --reset-mirror # only when replacing rewritten mirror history ``` -For a **production host** only, and the encoded form of the manual install in -[`docs/danger-zones.md`](https://github.com/LyraCoreProject/LyraCore/blob/main/docs/danger-zones.md) §3. It makes the host's Standalone Supervisor match -`deploy/systemd/spacetimedb-standalone.service` in this checkout. Service Reconciliation is one -job, so the verb owns the git steps too: - -1. `id -u`, before the fetch. The plan resets the checkout and then writes to - `/etc/systemd/system`, so it asks for the privilege once rather than stopping halfway for a - password. -2. The same checkout update `update` does. A tracked local edit still refuses everything, the - service change included. -3. The host prerequisites the unit names: its `User=` account, its `ExecStart` binary, its - `--data-dir`, and the directory holding its `StandardError=append:` log. Each missing one is a - refusal naming the command that fixes it. None is created for you. -4. Conflicting-service detection. Every active unit whose `ExecStart` or `WorkingDirectory` claims - the same data directory or listen address. A hand-rolled legacy `spacetimedb.service` is named - and refused, never migrated and never stopped on your behalf, so two nodes cannot race for one - port and one data directory. -5. `install -o root -g root -m 0644` into `/etc/systemd/system/`, then `systemctl daemon-reload`, - `enable`, `restart`. -6. Verification. `systemctl show` must report `ActiveState=active` plus the `LimitNOFILE` and - `StandardError` the tracked unit declares. A node that came back with the inherited - 1024-descriptor ceiling is reported as NOT reconciled instead of passing. - -Every expected value is read out of the tracked unit rather than duplicated in the CLI, so it -cannot certify a host against a contract this checkout no longer ships. The node's persistent -database directory is only ever checked for existence: never created, moved or deleted. Two runs -converge on the same end state, and it runs even when the checkout already sits on `origin/main`, -because deployment drift is independent of git drift. It restarts the node every time, so every run -costs a short outage. - -Steps 3 to 6 read the host before they change it, so a refusal there leaves the checkout on -`origin/main` and the host as it was. The reset in step 2 comes first on purpose: the unit to -install, and the contract to check the host against, are read out of the updated checkout. - -No gateway rebuild, module publish or schema migration is implied. Those stay operator decisions. - -## `update` — pull and restart +An ordinary update refuses tracked edits, fetches `origin`, and checks for commits in `HEAD` +that are absent from `origin/main`. Any such commits stop the update, including a clean local +branch ahead of the remote. Reconcile development branches with Git yourself. Otherwise the +checkout moves to the fetched revision using `git reset --keep`. + +Some mirror releases replace history rather than advancing it. `--reset-mirror` explicitly +allows that replacement and first retains the old revision on a local +`lyracore-backup/` branch. The printed branch can be inspected or switched to +for recovery. This option still refuses tracked edits and incoming paths that overlap untracked +or ignored files/directories. Unrelated untracked files are preserved. A failed update leaves +any recovery branch in place. If the recovery branch already exists, the update refuses to +overwrite it; rename that branch with Git before retrying. + +A checkout already at `origin/main` reports that it is current. When the CLI pin changes, the +new revision installs on the next `lyracore` invocation. + +Nothing restarts automatically. To rebuild and republish after an update, run: ```bash -./lyracore update +./lyracore dev down && ./lyracore dev up ``` -Fetches `origin`, then refuses — listing the files — if the checkout has any **tracked** edit; -untracked files are fine. The next step is a `git reset --hard origin/main`, and the refusal exists -so that step never has anything of yours to discard. A clean tree at the same commit as `origin/main` -prints "already up to date" and does nothing else. Otherwise it reads `.lyracore-cli-rev` before -resetting, resets, and prints the old and new commit — plus a note if the CLI pin itself moved, -since that revision installs on the *next* `lyracore` invocation, not this one. - -It does not restart anything for you: it prints `./lyracore dev down && ./lyracore dev up` as the -following step, the same rebuild-and-republish restart §"Everyday commands" in the quickstart -documents, because a schema change coming in on the pull needs every fixture database republished, -not just a process restart. +## `dev logs` — bounded output + +`lyracore dev logs [spacetime|gateway]` shows the last 100 lines of each selected log, +capped at 64 KiB per file. `--all` streams the complete files without loading them into memory. +Neither form follows future output. ## Exit codes diff --git a/docs/contract-ownership.md b/docs/contract-ownership.md new file mode 100644 index 0000000..95170dc --- /dev/null +++ b/docs/contract-ownership.md @@ -0,0 +1,30 @@ +# Core contract ownership + +The CLI should own checkout selection, local processes, command sequencing, confirmation, +and actionable failure reports. Core should own game-content and artifact format rules. + +Two contracts still need a Core interface before their CLI copies can be removed: + +- `src/cmd/packages/artifact.rs` and `script.rs` reproduce canonical artifact bytes and digests. + Core's `lyracore-delta-check` validates and traces artifacts and exposes `--print-events`, + but its current output is a human report, not a versioned metadata protocol. A replacement + needs machine-readable canonical bytes, digests, package identities, counts and conflicts + from the selected checkout. Only then can the CLI remove its parser/canonicalizer without + parsing presentation text or compiling against an unrelated Core revision. +- `src/cmd/import.rs` owns detailed post-import SQL assertions for profiles, trainers, spells, + quests and geometry. Core needs a profile-aware verification command that accepts an explicit + server, database and credential source and returns a structured report with a failing exit + status. `check_world_content` checks readiness; it does not replace those detailed import + assertions. A full import script also cannot be substituted for a read-only verification step. + +Keep these checks until replacements exist in the supported Core checkout. Do not maintain +both paths indefinitely: migrate their fixtures with the Core interface, switch the CLI to it, +and delete the duplicated rules. Keep credentials out of arguments and reports throughout. + +The Loot Roll upgrade guard in `publish` also remains necessary while supported destinations +can carry the old schema. Remove it only when the supported upgrade floor excludes that state. + +Skipping publication solely because all Rust Packages are disabled is also unsafe: an offline +disable creates no remote teardown record, so a target may still run that Package's code. +Keep the inventory-wide publication trigger until per-target compiled-module identity can prove +that disabled Rust has already been removed. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..5549778 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,41 @@ +# Host operations + +Host administration uses `lyracore-ops`, separate from the developer CLI. The previous +`lyracore service reconcile` command now prints the replacement command and exits without changes. + +From a checkout of this CLI at the revision you want to install: + +```bash +cargo install --locked --path . --bin lyracore-ops +``` + +Then, from the selected **Core** checkout, run the installed binary as root. Use its absolute +installed path if root's PATH does not contain your Cargo bin directory: + +```bash +sudo "$HOME/.cargo/bin/lyracore-ops" service reconcile +``` + +The tool reads `deploy/systemd/spacetimedb-standalone.service` from that Core checkout. +Update and review the checkout separately, as its owner, before reconciliation. This command +does not fetch, reset, build the gateway, publish a module, or perform a schema migration. + +Reconciliation performs these steps: + +1. Verify root privileges with `id -u` and read the selected unit. +2. Check the unit's account, executable, data directory, log directory and required host tools. + Missing prerequisites are named and refused; the tool does not create them. +3. Refuse another active service claiming the same data directory or listen address. +4. Install the unit into `/etc/systemd/system`, reload systemd, enable it and restart it. +5. Verify the effective start command, active state, descriptor limit and stderr destination + against the tracked unit, including systemd overrides. + +The database directory is checked for existence, never created, moved or deleted. +Every reconciliation restarts the node and causes a short outage. Failures identify which +step stopped; reconciliation is not a transaction that rolls back a failed restart. + +`lyracore production status` remains a read-only developer command for an explicit topology. + +The `deploy/` directory also contains the history-prune, disk-guard and diagnostic-capture +scripts and their systemd units. Their installation remains described by Core's +`docs/danger-zones.md` and `docs/operations/disk-policy.md`. diff --git a/src/bin/main.rs b/src/bin/main.rs index e50d0f8..77ac858 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -11,8 +11,9 @@ fn main() { // bug — so on failure only (never on success, and never for any other command) it gets one // extra line pointing at `doctor`. Parsed here, ahead of `run`, so a bad-flags usage error // (which never reaches `dev up`'s own logic) doesn't earn the hint. - let is_dev_up = matches!(Command::parse(&args), Ok(Command::DevUp { .. })); - std::process::exit(match run(&args) { + let command = Command::parse(&args); + let is_dev_up = matches!(&command, Ok(Command::DevUp { .. })); + std::process::exit(match command.and_then(run) { Ok(code) => code, Err(e) => { eprintln!("lyracore: {e}"); @@ -26,8 +27,7 @@ fn main() { }); } -fn run(args: &[String]) -> Result { - let command = Command::parse(args)?; +fn run(command: Command) -> Result { let runner = RealProcessRunner; let inspector = RealProcessInspector; // Only ever pointed at the loopback node: minting an identity and claiming the operator as it. @@ -38,6 +38,10 @@ fn run(args: &[String]) -> Result { println!("{}", cmd::USAGE); Ok(EXIT_OK) } + Command::HelpTopic(topic) => { + println!("{}", cmd::help::topic(&topic)?); + Ok(EXIT_OK) + } Command::HelpAll => { println!("{}", cmd::USAGE_ALL); Ok(EXIT_OK) @@ -101,9 +105,9 @@ fn run(args: &[String]) -> Result { }) .map(|_| EXIT_OK) } - Command::DevLogs(component) => { + Command::DevLogs { component, all } => { let dev = cmd::dev::DevManager::new(ProjectLayout::discover()?)?; - dev.logs(component).map(|_| EXIT_OK) + dev.logs(component, all).map(|_| EXIT_OK) } Command::DevDown { forget } => { let mut dev = cmd::dev::DevManager::new(ProjectLayout::discover()?)?; @@ -215,9 +219,8 @@ fn run(args: &[String]) -> Result { EXIT_OK }) } - Command::ServiceReconcile => { - cmd::service::reconcile(&ProjectLayout::discover()?, &runner).map(|_| EXIT_OK) + Command::Update { reset_mirror } => { + cmd::update::run(&ProjectLayout::discover()?, &runner, reset_mirror).map(|_| EXIT_OK) } - Command::Update => cmd::update::run(&ProjectLayout::discover()?, &runner).map(|_| EXIT_OK), } } diff --git a/src/bin/ops.rs b/src/bin/ops.rs new file mode 100644 index 0000000..bc1b25c --- /dev/null +++ b/src/bin/ops.rs @@ -0,0 +1,36 @@ +use lyracore_cli::{ + error::EXIT_OK, ops, proc::RealProcessRunner, project::ProjectLayout, Error, Result, +}; + +const USAGE: &str = "lyracore-ops: host administration\n\nUSAGE:\n lyracore-ops service reconcile\n\nInstalls the Standalone Supervisor unit from the current Core checkout and restarts\nSpacetimeDB. Requires root. Does not fetch, reset or publish source.\nUpdate and review the checkout separately before running this command."; + +fn run(args: &[String]) -> Result<()> { + let args: Vec<_> = args.iter().map(String::as_str).collect(); + match args.as_slice() { + [] + | ["help"] + | ["--help"] + | ["-h"] + | ["service", "--help"] + | ["service", "reconcile", "--help"] => { + println!("{USAGE}"); + Ok(()) + } + ["service", "reconcile"] => { + ops::service::reconcile(&ProjectLayout::discover()?, &RealProcessRunner) + } + _ => Err(Error::Usage(USAGE.into())), + } +} + +fn main() { + let args: Vec<_> = std::env::args().skip(1).collect(); + let code = match run(&args) { + Ok(()) => EXIT_OK, + Err(error) => { + eprintln!("lyracore-ops: {error}"); + error.exit_code() + } + }; + std::process::exit(code); +} diff --git a/src/cmd/config.rs b/src/cmd/config.rs index 795ccd2..cee9c50 100644 --- a/src/cmd/config.rs +++ b/src/cmd/config.rs @@ -61,7 +61,10 @@ mod tests { // error instead, and cover the value it would have printed via `Config::load` directly. let project = checkout(&tmp); assert!(show(&project).is_ok()); - assert_eq!(Config::load(&project.config_file()).unwrap().client_data, None); + assert_eq!( + Config::load(&project.config_file()).unwrap().client_data, + None + ); } #[test] @@ -91,7 +94,10 @@ mod tests { .to_string(); assert!(error.contains("no such directory"), "{error}"); assert!( - Config::load(&project.config_file()).unwrap().client_data.is_none(), + Config::load(&project.config_file()) + .unwrap() + .client_data + .is_none(), "a refused path must not be persisted" ); } diff --git a/src/cmd/dev.rs b/src/cmd/dev.rs index a504f39..6b43a16 100644 --- a/src/cmd/dev.rs +++ b/src/cmd/dev.rs @@ -838,14 +838,13 @@ impl DevManager { // ---- logs ---- - pub fn logs(&self, component: Option) -> Result<()> { + pub fn logs(&self, component: Option, all: bool) -> Result<()> { let components = component.map_or_else(|| Component::ALL.to_vec(), |c| vec![c]); for component in components { let path = self.project.log_file(component); println!("=== {} — {} ===", component.as_str(), path.display()); - match std::fs::read_to_string(&path) { - Ok(content) if content.is_empty() => println!("(empty)"), - Ok(content) => print!("{content}"), + match super::logs::show(&path, all, &mut std::io::stdout().lock()) { + Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => { println!("(no log yet — `lyracore dev up` has not started this component)"); } @@ -3122,7 +3121,7 @@ mod tests { let tmp = TempDir::new().unwrap(); let dev = DevManager::new(project(&tmp)).unwrap(); // No log files exist yet — this must not error. - assert!(dev.logs(None).is_ok()); - assert!(dev.logs(Some(Component::Gateway)).is_ok()); + assert!(dev.logs(None, false).is_ok()); + assert!(dev.logs(Some(Component::Gateway), false).is_ok()); } } diff --git a/src/cmd/help.rs b/src/cmd/help.rs new file mode 100644 index 0000000..e6bee15 --- /dev/null +++ b/src/cmd/help.rs @@ -0,0 +1,130 @@ +//! A single command catalogue for full and focused help. +use crate::{Error, Result}; + +pub const USAGE: &str = "\ +lyracore: run a local LyraCore vanilla WoW Realm + +USAGE: + lyracore doctor check the prerequisites for `dev up` + lyracore dev up publish and start the local Realm for setup + lyracore account create USER + provision an Account with your chosen password + lyracore import import world content from public game data and + your own 1.12.1 client before play + lyracore dev check check imported content on every playable Shard + lyracore dev status report the running processes and published Shards + lyracore dev down stop the local Realm + +Run `lyracore help --all` to see every command."; + +struct Entry { + path: &'static str, + usage: &'static str, + summary: &'static str, +} +macro_rules! commands { + ($( $path:literal, $usage:literal, $summary:literal; )*) => { + const ENTRIES: &[Entry] = &[$(Entry { path: $path, usage: $usage, summary: $summary },)*]; + pub const COMMAND_REFERENCE: &str = concat!("lyracore help [--all | COMMAND ...]\n", $("lyracore ", $usage, "\n",)*); + pub const USAGE_ALL: &str = concat!( + "lyracore: local Realm development\n\nUSAGE:\n", + $(" lyracore ", $usage, "\n ", $summary, "\n\n",)* + "Use `lyracore COMMAND --help` for focused help.\nHost administration: lyracore-ops service reconcile.\nFull reference: docs/commands.md" + ); + } +} +commands! { + "doctor", "doctor", "Check prerequisites for dev up."; + "bindings", "bindings [--check]", "Prepare Gateway Rust bindings outside the source tree. --check reports freshness as JSON without generating files."; + "preflight", "preflight", "Validate the deploy build, schema, filters and artifact identities offline."; + "publish", "publish [DATABASE ...] [--skip-preflight]", "Publish the module to named databases or the development topology. No data-wiping flags. --skip-preflight skips preflight; binding preparation and upgrade checks still run."; + "dev up", "dev up [--single] [--lan IP]", "Start or reuse four local databases. --single uses one; --lan serves game clients on a private address while SpacetimeDB stays on loopback."; + "dev status", "dev status", "Report processes, published databases and gateway connections."; + "dev logs", "dev logs [spacetime|gateway] [--all]", "Show the last 100 lines (up to 64 KiB) per log. --all streams the complete files."; + "dev check", "dev check", "Alias for import check using the recorded development topology."; + "dev smoke", "dev smoke ACCOUNT CHARACTER [--password-stdin]", "Check content, then exercise login/world entry through the pinned Headless Client. Passwords use a hidden prompt or one stdin line."; + "dev down", "dev down [--forget]", "Stop only processes this CLI owns. --forget drops their records without signalling them."; + "account create", "account create USER [--password-stdin]", "Provision an Account using a hidden password prompt or one stdin line; never a password argument."; + "character gm", "character gm NAME true|false", "Grant or revoke GM commands on the world shard holding the Character."; + "import", "import [--accept] [--client-data PATH] [--profile-shard PROFILE=SHARD ...]", "Alias for import world. Import content before play from public game data and your own client archives."; + "import world", "import world [--accept] [--client-data PATH] [--profile-shard PROFILE=SHARD ...]", "Import Eastern Kingdoms and Kalimdor content plus instances. --accept answers consent. Explicit profiles require a complete assignment; otherwise use the development topology."; + "import check", "import check [--profile-shard PROFILE=SHARD ...]", "Check content readiness at the import destinations without changing their content."; + "import vmaps", "import vmaps [--client-data PATH] [--profile-shard PROFILE=SHARD ...]", "Refresh collision geometry on each populated World Shard. Reads your client archives; clears content readiness until a full world import completes."; + "config", "config", "Show the persisted client-data path."; + "config set client-data", "config set client-data PATH", "Validate and remember your client Data directory for import and client sync."; + "client sync", "client sync", "Pack and install patch-3.MPQ and enabled addons into the configured client. Reports leftover addons; does not delete them."; + "client pack", "client pack --out DIR [--zip]", "Build a distributable artifact of package-authored content. --zip also writes DIR.zip; never installs into the configured client."; + "packages add", "packages add FOLDER|GIT-URL|NAME [--yes]", "Review and install source; --yes confirms installation. Server content runs preflight; client-only content is validated when packed. Does not activate it."; + "packages update", "packages update [NAME] [--yes]", "Update Git/official sources with drift protection and rollback. Disabled and client-only changes skip module preflight. Does not activate them."; + "packages enable", "packages enable NAME", "Return disabled source to the build. Run packages apply to activate server content, client sync for client content."; + "packages disable", "packages disable NAME [--yes]", "Tear down a recorded realm’s Package data and config, then exclude its source from the build. Re-enabling does not restore deleted data. --yes confirms teardown."; + "packages remove", "packages remove NAME [--yes]", "Delete a disabled Package after drift checks and confirmation. Disable it first; --yes confirms deletion."; + "packages apply", "packages apply [DATABASE ...] [--check] [--yes] [--force-all] [--client-data PATH]", "Prepare sources, publish Rust Packages and apply artifacts. --check may build local files but performs no Realm writes. --force-all reapplies matching artifacts; --yes confirms Realm writes."; + "packages config", "packages config NAME [KEY [VALUE]] [--new] [--database NAME ...]", "Read or write Package settings across named Shards or the development topology. --new permits a key the Package did not seed."; + "packages build", "packages build", "Build enabled Package sources. Datascripts require schema typings and typechecking; runtime scripts need only their builder. Empty/source-free inventories need no Bun."; + "packages check", "packages check", "Check existing artifacts against their Build Identity without rebuilding artifacts. Distinct from packages apply --check, which prepares an activation plan."; + "packages list", "packages list", "List enabled and disabled Packages, origins and source drift."; + "packages new", "packages new NAME [--from RUNG]", "Scaffold a compatible Reference Package. Defaults to example-script; --from selects a different reference."; + "production status", "production status --server URI --gateway-log PATH --realm-core DB DATABASE ...", "Read-only verification of the complete explicit topology and latest gateway startup. Include realm-core in DATABASE ...; gateway-log is a local file."; + "update", "update [--reset-mirror]", "Update Core without dropping local commits. --reset-mirror explicitly replaces mirror history and saves a recovery branch. This does not restart the realm."; +} + +pub fn topic(topic: &str) -> Result { + let selected: Vec<_> = ENTRIES + .iter() + .filter(|entry| entry.path == topic || entry.path.starts_with(&format!("{topic} "))) + .collect(); + if selected.is_empty() { + return Err(Error::Usage(format!( + "unknown help topic '{topic}'; use `lyracore help --all`" + ))); + } + let mut text = String::from("USAGE:\n"); + for entry in selected { + text.push_str(&format!( + " lyracore {}\n {}\n\n", + entry.usage, entry.summary + )); + } + Ok(text) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::Command; + #[test] + fn every_registered_command_has_focused_help_without_execution() { + for entry in ENTRIES { + let mut args: Vec<_> = entry.path.split_whitespace().map(str::to_string).collect(); + args.push("--help".into()); + assert_eq!( + Command::parse(&args).unwrap(), + Command::HelpTopic(entry.path.into()) + ); + assert!(topic(entry.path).unwrap().contains(entry.usage)); + } + } + #[test] + fn command_reference_matches_the_help_catalogue() { + let reference = include_str!("../../docs/commands.md"); + let block = reference + .split("\n```text\n") + .nth(1) + .unwrap() + .split("```\n") + .next() + .unwrap(); + assert_eq!( + block, COMMAND_REFERENCE, + "update the command list from the help catalogue" + ); + } + #[test] + fn group_help_does_not_include_other_groups() { + let help = topic("packages").unwrap(); + assert!(help.contains("packages apply")); + assert!(!help.contains("lyracore dev up")); + assert!(topic("packages invented").is_err()); + } +} diff --git a/src/cmd/import.rs b/src/cmd/import.rs index a398891..aa6529c 100644 --- a/src/cmd/import.rs +++ b/src/cmd/import.rs @@ -3238,14 +3238,16 @@ pub(crate) mod tests { other => panic!("unexpected call kind: {other:?}"), }) .collect(); - for (needle, shipped) in [("pull-classic-db.sh", ProjectLayout::PULL_CLASSIC_DB_SCRIPT)] { - let spec = specs - .iter() - .find(|s| s.render().contains(needle)) - .unwrap_or_else(|| panic!("{needle} never ran")); - assert_eq!(spec.program(), "bash"); - assert!(spec.args()[0].ends_with(shipped), "{}", spec.render()); - } + let spec = specs + .iter() + .find(|s| s.render().contains("pull-classic-db.sh")) + .expect("pull-classic-db.sh never ran"); + assert_eq!(spec.program(), "bash"); + assert!( + spec.args()[0].ends_with(ProjectLayout::PULL_CLASSIC_DB_SCRIPT), + "{}", + spec.render() + ); // The manifest is SOURCED (bash -c), never executed as a script of its own. let floors = specs .iter() diff --git a/src/cmd/logs.rs b/src/cmd/logs.rs new file mode 100644 index 0000000..1a48213 --- /dev/null +++ b/src/cmd/logs.rs @@ -0,0 +1,85 @@ +//! Bounded log display; complete output is streamed only on explicit request. +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +const MAX_BYTES: u64 = 64 * 1024; +const MAX_LINES: usize = 100; + +pub(super) fn show(path: &Path, all: bool, out: &mut impl Write) -> std::io::Result<()> { + let mut file = std::fs::File::open(path)?; + let len = file.metadata()?.len(); + if len == 0 { + return writeln!(out, "(empty)"); + } + if all { + std::io::copy(&mut file, out)?; + return Ok(()); + } + let offset = len.saturating_sub(MAX_BYTES); + file.seek(SeekFrom::Start(offset))?; + let mut bytes = Vec::new(); + file.take(MAX_BYTES).read_to_end(&mut bytes)?; + // Do not count the terminal newline as an extra empty line. + let end = bytes.len() - usize::from(bytes.last() == Some(&b'\n')); + let mut start = bytes[..end] + .iter() + .enumerate() + .rev() + .filter(|(_, byte)| **byte == b'\n') + .nth(MAX_LINES - 1) + .map_or(0, |(index, _)| index + 1); + if offset > 0 && start == 0 { + // A byte cap may split a line. Prefer complete lines; a single huge line stays bounded. + start = bytes[..end] + .iter() + .position(|b| *b == b'\n') + .map_or(0, |i| i + 1); + } + if offset > 0 || start > 0 { + writeln!( + out, + "(last {MAX_LINES} lines, at most 64 KiB; use --all for the full log)" + )?; + } + out.write_all(&bytes[start..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_bounds_bytes_and_lines_while_all_preserves_the_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("gateway.log"); + let content: String = (0..2000) + .map(|i| format!("{i}: {}\n", "x".repeat(100))) + .collect(); + std::fs::write(&path, &content).unwrap(); + let mut out = Vec::new(); + show(&path, false, &mut out).unwrap(); + let tail = String::from_utf8(out).unwrap(); + assert!(tail.contains("1900: ")); + assert!(!tail.contains("1899: ")); + assert!(tail.ends_with(&format!("1999: {}\n", "x".repeat(100)))); + assert_eq!(tail.lines().count(), MAX_LINES + 1); + let mut all = Vec::new(); + show(&path, true, &mut all).unwrap(); + assert_eq!(all, content.as_bytes()); + } + + #[test] + fn huge_lines_are_bounded_and_unterminated_short_logs_are_preserved() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("gateway.log"); + for content in ["x".repeat(2_000_000), "first\nlast".into(), "".into()] { + std::fs::write(&path, &content).unwrap(); + let mut out = Vec::new(); + show(&path, false, &mut out).unwrap(); + assert!(out.len() <= MAX_BYTES as usize + 100); + if content == "first\nlast" { + assert_eq!(out, content.as_bytes()); + } + } + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 9824098..c07e127 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -7,11 +7,11 @@ pub mod dev; pub mod doctor; mod gateway_log; pub mod import; +mod logs; pub mod packages; pub mod preflight; pub mod production; pub mod publish; -pub mod service; pub mod update; use crate::project::{ClientBind, Component, Topology}; @@ -19,189 +19,8 @@ use crate::{Error, Result}; use account::PasswordSource; use import::{ImportOptions, VmapOptions}; -/// The default help: just enough to get a newcomer from a fresh checkout to a running server. -/// Everything else — the commands you only need once you're developing LyraCore itself — lives in -/// [`USAGE_ALL`], reached with `lyracore help --all`. -pub const USAGE: &str = "\ -lyracore: run a local LyraCore vanilla WoW Realm - -USAGE: - lyracore doctor check the prerequisites for `dev up` - lyracore dev up publish and start the local Realm for setup - lyracore account create USER - provision an Account with your chosen password - lyracore import import world content from public game data and - your own 1.12.1 client before play - lyracore dev check check imported content on every playable Shard - lyracore dev status report the running processes and published Shards - lyracore dev down stop the local Realm - -Run `lyracore help --all` to see every command."; - -/// The full command surface, including the contributor-facing corners `USAGE` leaves out. -pub const USAGE_ALL: &str = "\ -lyracore — local LyraCore development - -USAGE: - lyracore doctor check whether your machine is ready for `dev up` - lyracore bindings [--check] prepare Gateway Rust bindings, or check freshness as JSON - lyracore preflight the offline deploy gate: build, schema, filters - lyracore publish [DATABASE ...] preflight, then publish the module. With no - names, publishes every database of the active - fixture topology (the default sharded topology - if none is recorded); named databases are - published exactly as given. Takes database - NAMES only — SpacetimeDB's data-wiping -c flag - is deliberately not exposed here - lyracore publish --skip-preflight [DB ...] publish without running the offline checks - (preflight) first - lyracore dev up start (or reuse) the local realm: four databases - — Eastern Kingdoms, Kalimdor, the instance pool, - and realm-core - lyracore dev up --single a one-database setup instead, for a quicker - local test — skips the multi-database sharded - configuration - lyracore dev up --lan also serve clients on this machine's private-LAN - address (SpacetimeDB stays on loopback) - lyracore dev status report each component's state, and each - database's own published/connected verdict - lyracore dev logs [spacetime|gateway] show a component's log file - lyracore dev check check imported content on World Shards and the - Instance Pool, without changing their content - lyracore dev smoke ACCOUNT CHARACTER [--password-stdin] - check content, then run the pinned Headless Client - with your Account password - lyracore dev down [--forget] stop the processes this CLI started - lyracore account create USER [--password-stdin] - provision an account's SRP6 credentials - lyracore import [--client-data PATH] import the playable world: Alliance early-game corridors - in Eastern Kingdoms and Kalimdor, plus the - instance maps. Pulls cmangos' classic-db, - reads your own 1.12.1 client's Data/ archives, - drives the importer and asserts the FLOOR_* - import floors on every destination. Asks for - consent first, every time - lyracore import world --profile-shard PROFILE=SHARD - explicitly assign all three sharded World Import - Profiles on an external realm. For each continent, - choose either alliance-* or starting-*; the latter - also includes Horde starting areas. Assign instances - as the third profile, using a distinct Shard for each - lyracore import world --accept the same command by its full name (`import` - is its alias), with the consent answered in - advance (scripted runs) - lyracore import check [--profile-shard PROFILE=SHARD ...] - check imported content at the same destinations - selected by `import world` - lyracore import vmaps [--client-data PATH] exact model/WMO collision triangles for each - populated World Shard, including Kalimdor, - read from your own client's archives — nothing - is fetched, so there is no consent gate - lyracore config show the persisted client-data path (or \"(unset)\") - lyracore config set client-data PATH validate and remember your 1.12.1 client's Data/ - directory, so `import` and `doctor` stop asking - lyracore client sync pack patch-3.MPQ and every enabled Package's - addons, then install them into the configured - client-data path. Warns (best-effort) about an - addon a disabled or removed Package left behind - lyracore client pack --out DIR [--zip] build the Client Artifact: package-authored - content only, as DIR/Data/patch-3.MPQ and - DIR/Interface/AddOns/. Never touches the configured - client. --zip also writes DIR.zip - lyracore character gm NAME true|false grant (true) or revoke (false) GM level for a - character — tries every world shard in turn - lyracore packages add FOLDER|GIT-URL|NAME [--yes] - install a Package from a folder on this machine, - from a repository whose root is one Package, or by - bare NAME from the Official Package Collection: - copies it into packages/, records where it came from - (and the exact commit, for a Git Package Source or - the collection), prints a deterministic review of - the tables, reducers, hooks, addons and client - overrides it registers, asks before copying, then - runs preflight. It never publishes — it prints the - steps left - lyracore packages update [NAME] [--yes] advance a Git-backed Package to the repository's - current commit, or every Git-backed Package when no - name is given. Refuses a folder that has drifted - from its recorded content identity, reviews and asks - before replacing it, and keeps the previous revision - until the new one preflights - lyracore packages enable NAME move a disabled Package back into packages/, where - the build compiles it again. Its provenance stamp - travels with the folder - lyracore packages disable NAME [--yes] move an enabled Package into .lyracore/packages- - disabled/, out of the build's sight but still on - disk. With the dev stack up, asks, then runs - Package Teardown on every Shard first, so the next - publish can remove the Package's tables - lyracore packages remove NAME [--yes] delete a DISABLED Package from this checkout. Asks - first, and refuses a folder that no longer matches - its recorded content identity, because those local - changes exist nowhere else - lyracore packages apply [DATABASE ...] [--check] [--yes] [--force-all] - prepare installed sources, publish the Module for - Rust Packages, then apply artifacts to each Shard. - Defaults to the recorded development topology. - --check prepares and validates without Realm writes; - --force-all also applies matching artifacts. - --client-data PATH supplies the client Data directory - lyracore packages config NAME [KEY [VALUE]] [--new] - read and write an installed Package's Package Config - — the durable key-values it reads at runtime. With no - KEY it lists every key; with a KEY it prints that - value; with a VALUE it writes the key to EVERY Shard - of the fixture topology, because the rows are - per-Shard state. A read reports a key the Shards - disagree about rather than picking one answer. --new - creates a key the Package never seeded; without it - the Module refuses an unknown key and names the ones - it does have - lyracore packages build regenerate the Module schema typings into - datascripts/generated/, install the pinned Bun - dependencies from datascripts/bun.lock, then - typecheck every Datascript against them. Author-side - only: applying a prebuilt Package needs no Bun - lyracore packages check verify every enabled Package's generated artifact - against its recorded Build Identity: the Datascript - source, generated typings, Base Snapshot, authoring - library and toolchain pins it was built from. - Regenerates the typings fresh, so a Module schema - change is caught even on a clean checkout. Refuses - naming the specific input that changed and the - rebuild command; a missing Base Snapshot is reported - unverifiable rather than failing. `preflight` (and so - `publish`) runs the same check - lyracore packages list every installed Package: enabled or disabled, its - Package Source, its recorded content identity, - whether the installed copy has drifted from it, - and what it registers - lyracore packages new NAME [--from RUNG] copy a Reference Package from the collection tag for - this checkout's Package API. Defaults to example-script. - Other rungs: example-client, example-data, example-rust, - example-all. Records the rung and revision, renames - source and runs preflight. Build new artifacts with - `packages build` after choosing distinct IDs - lyracore production status --server URI --gateway-log PATH --realm-core DB DATABASE ... - read-only production topology, schema, connection, - realm-core and listener verdicts - lyracore service reconcile make a PRODUCTION host's spacetimedb-standalone - service match the unit tracked in the checkout. - Root only: it updates the checkout to origin/main - (refusing over a dirty working tree, as `update` - does), checks the host prerequisites the unit - names, refuses when another active service already - owns the same data directory or listen address, - installs the unit into /etc/systemd/system, reloads - systemd, enables and restarts the node, then - verifies its ActiveState, LimitNOFILE and stderr - destination. It never creates, moves or deletes the - node's data directory - lyracore update pull the latest checkout in place and restart the - local dev stack (refuses over a dirty working tree) - -The password is read from stdin with --password-stdin, otherwise from a hidden terminal -prompt. It is never passed as a command-line argument."; +pub mod help; +pub use help::{USAGE, USAGE_ALL}; #[derive(Debug, PartialEq, Eq)] pub enum Command { @@ -221,7 +40,10 @@ pub enum Command { topology: Topology, }, DevStatus, - DevLogs(Option), + DevLogs { + component: Option, + all: bool, + }, DevCheck, DevSmoke { account: String, @@ -293,24 +115,31 @@ pub enum Command { PackagesApply(packages::apply::ApplyOptions), PackagesConfig(packages::config::ConfigOptions), ProductionStatus(production::StatusOptions), - /// The one root-only, system-state verb on this surface, deliberately its own: `update` stays - /// a contributor's git-pull replacement and owns no service. - ServiceReconcile, - Update, + Update { + reset_mirror: bool, + }, Help, HelpAll, + HelpTopic(String), } impl Command { pub fn parse(args: &[String]) -> Result { let args: Vec<&str> = args.iter().map(String::as_str).collect(); + if args.len() > 1 && matches!(args.last(), Some(&"--help" | &"-h")) { + let topic = args[..args.len() - 1].join(" "); + help::topic(&topic)?; + return Ok(Command::HelpTopic(topic)); + } match args.as_slice() { [] => Ok(Command::Help), ["-h"] | ["--help"] | ["help"] => Ok(Command::Help), ["help", "--all"] => Ok(Command::HelpAll), - ["help", other, ..] => Err(Error::Usage(format!( - "`lyracore help` takes no arguments other than --all (got '{other}')" - ))), + ["help", topic @ ..] => { + let topic = topic.join(" "); + help::topic(&topic)?; + Ok(Command::HelpTopic(topic)) + }, ["doctor"] => Ok(Command::Doctor), ["bindings"] => Ok(Command::Bindings { check: false }), @@ -350,14 +179,20 @@ impl Command { ["dev", "smoke", ..] => Err(Error::Usage("`dev smoke` needs ACCOUNT CHARACTER [--password-stdin]".to_string())), ["dev", "down"] => Ok(Command::DevDown { forget: false }), ["dev", "down", "--forget"] => Ok(Command::DevDown { forget: true }), - ["dev", "logs"] => Ok(Command::DevLogs(None)), - ["dev", "logs", name] => Component::parse(name) - .map(|c| Command::DevLogs(Some(c))) - .ok_or_else(|| { - Error::Usage(format!( - "unknown component '{name}' — expected one of: spacetime, gateway" - )) - }), + ["dev", "logs", rest @ ..] => { + let mut component = None; + let mut all = false; + for arg in rest { + match *arg { + "--all" if !all => all = true, + name if component.is_none() => { + component = Some(Component::parse(name).ok_or_else(|| Error::Usage(format!("unknown log component or option '{name}'; use spacetime, gateway, or --all")))?); + } + other => return Err(Error::Usage(format!("unexpected dev logs argument '{other}'"))), + } + } + Ok(Command::DevLogs { component, all }) + }, ["dev"] => Err(Error::Usage( "`dev` needs a subcommand: up, status, check, logs, smoke, down".to_string(), )), @@ -467,11 +302,7 @@ impl Command { Ok(Command::PackagesRemove { name, yes }) } ["packages", ..] => Err(Error::Usage( - "`packages` supports: add FOLDER|GIT-URL [--yes], build, check, config NAME [KEY \ - [VALUE]] [--new], disable NAME [--yes], enable NAME, list, new NAME, remove NAME \ - [--yes], apply [DATABASE ...] [--check] [--yes] [--force-all], update [NAME] \ - [--yes]" - .to_string(), + "unknown or incomplete packages command; run `lyracore packages --help`".into(), )), ["production", "status", rest @ ..] => parse_production_status(rest), @@ -481,22 +312,14 @@ impl Command { .to_string(), )), - // One subverb today. The catch-alls below name it, the way `client` and `character` - // do, rather than reporting "unknown command" for a group that exists. - ["service", "reconcile"] => Ok(Command::ServiceReconcile), - ["service", "reconcile", other, ..] => Err(Error::Usage(format!( - "`service reconcile` takes no arguments (got '{other}')" - ))), - ["service"] => Err(Error::Usage( - "`service` needs a subcommand: reconcile".to_string(), + ["service", ..] => Err(Error::Usage( + "host service administration moved to `lyracore-ops service reconcile`; it uses the checked-out unit without updating source".into(), )), - ["service", other, ..] => Err(Error::Usage(format!( - "unknown `service` subcommand '{other}' — expected reconcile" - ))), - ["update"] => Ok(Command::Update), + ["update"] => Ok(Command::Update { reset_mirror: false }), + ["update", "--reset-mirror"] => Ok(Command::Update { reset_mirror: true }), ["update", other, ..] => Err(Error::Usage(format!( - "`update` takes no arguments (got '{other}')" + "`update` accepts only --reset-mirror (got '{other}')" ))), [other, ..] => Err(Error::Usage(format!("unknown command '{other}'"))), @@ -1125,7 +948,13 @@ mod tests { source: PasswordSource::Tty, } ); - assert_eq!(parse("dev logs").unwrap(), Command::DevLogs(None)); + assert_eq!( + parse("dev logs").unwrap(), + Command::DevLogs { + component: None, + all: false + } + ); assert_eq!( parse("dev down").unwrap(), Command::DevDown { forget: false } @@ -1154,7 +983,10 @@ mod tests { fn logs_can_name_a_component() { assert_eq!( parse("dev logs gateway").unwrap(), - Command::DevLogs(Some(Component::Gateway)) + Command::DevLogs { + component: Some(Component::Gateway), + all: false + } ); assert!(parse("dev logs realm-core").is_err()); } @@ -2092,22 +1924,23 @@ mod tests { } #[test] - fn packages_without_a_recognised_verb_names_the_nine_that_exist() { - for line in ["packages", "packages install greeter", "packages upgrade"] { - let error = parse(line).unwrap_err().to_string(); - for verb in PACKAGES_VERBS { - assert!(error.contains(verb), "{line}: {error}"); - } + fn unknown_packages_commands_point_to_the_catalogue_help() { + for line in ["packages", "packages invented", "packages --bogus"] { + let error = parse(line).unwrap_err(); + assert_eq!(error.exit_code(), crate::error::EXIT_USAGE); + assert!( + error.to_string().contains("lyracore packages --help"), + "{error}" + ); } } - const PACKAGES_VERBS: [&str; 9] = [ - "add", "build", "check", "disable", "enable", "list", "new", "remove", "update", - ]; - #[test] fn the_help_text_documents_packages() { - for verb in PACKAGES_VERBS { + for verb in [ + "add", "build", "check", "disable", "enable", "list", "new", "remove", "update", + "apply", "config", + ] { assert!( USAGE_ALL.contains(&format!("lyracore packages {verb}")), "{USAGE_ALL}" @@ -2119,10 +1952,10 @@ mod tests { #[test] fn service_reconcile_takes_no_arguments() { - assert_eq!( - parse("service reconcile").unwrap(), - Command::ServiceReconcile - ); + assert!(parse("service reconcile") + .unwrap_err() + .to_string() + .contains("lyracore-ops service reconcile")); for line in [ "service reconcile --force", "service reconcile spacetimedb-standalone.service", @@ -2146,26 +1979,22 @@ mod tests { } } - /// The verb mutates a production host, so the help has to say what it does to one: which - /// service, that systemd is reloaded and restarted, what is verified, and that it needs root. #[test] - fn the_help_text_documents_what_service_reconcile_changes() { - for phrase in [ - "lyracore service reconcile", - "spacetimedb-standalone", - "/etc/systemd/system", - "LimitNOFILE", - "Root only", - ] { - assert!(USAGE_ALL.contains(phrase), "{phrase}: {USAGE_ALL}"); - } + fn developer_help_points_to_the_separate_host_tool() { + assert!(USAGE_ALL.contains("lyracore-ops")); + assert!(!USAGE_ALL.contains("lyracore service reconcile")); } // ---- update ---- #[test] fn update_takes_no_arguments() { - assert_eq!(parse("update").unwrap(), Command::Update); + assert_eq!( + parse("update").unwrap(), + Command::Update { + reset_mirror: false + } + ); assert!(parse("update origin").is_err()); } @@ -2174,26 +2003,12 @@ mod tests { assert!(USAGE_ALL.contains("lyracore update"), "{USAGE_ALL}"); } - /// `update` is a contributor's git-pull replacement. Reconciling a host is `service - /// reconcile`'s job, so `update`'s whole help entry must not grow a word about services, and - /// must not grow at all: it is the two lines it has always been. - #[test] - fn the_update_help_entry_says_nothing_about_services_and_has_not_grown() { - let lines: Vec<&str> = USAGE_ALL.lines().collect(); - let start = lines - .iter() - .position(|line| line.contains("lyracore update")) - .expect("update is in the full help"); - let entry: Vec<&str> = lines[start..] - .iter() - .take_while(|line| !line.trim().is_empty()) - .copied() - .collect(); - assert_eq!(entry.len(), 2, "{entry:?}"); - let entry = entry.join(" "); - for absent in ["service", "systemd", "reconcile", "install", "root", "sudo"] { - assert!(!entry.contains(absent), "{absent}: {entry}"); - } + #[test] + fn update_help_explains_mirror_reset_and_restart() { + let help = help::topic("update").unwrap(); + assert!(help.contains("--reset-mirror")); + assert!(help.contains("recovery branch")); + assert!(help.contains("does not restart")); } // ---- help ---- @@ -2272,7 +2087,6 @@ mod tests { "lyracore packages new", "lyracore packages remove", "lyracore character gm", - "lyracore service reconcile", "lyracore update", "--password-stdin", ] { diff --git a/src/cmd/packages/apply.rs b/src/cmd/packages/apply.rs index 54b04f8..17fe6a4 100644 --- a/src/cmd/packages/apply.rs +++ b/src/cmd/packages/apply.rs @@ -331,6 +331,8 @@ pub fn run( } databases(&shards)?; check::prepare(project, runner, prompt, options.client_data.as_deref())?; + // Disabled Rust may still be deployed if it was disabled without a running local stack. + // Its removal needs a publish even when the target has no pending teardown record. let has_rust = inventory(project)? .iter() .any(|package| package.dir.join("src/mod.rs").is_file()); @@ -2171,6 +2173,35 @@ mod tests { assert!(writes[2].ends_with("two") && writes[3].contains("two debug_repair_after_publish")); } + #[test] + fn disabled_rust_without_pending_teardown_publishes_its_removal() { + let checkout = Checkout::new(); + checkout.with_rust(); + super::super::lifecycle::tests::disable_without_stack(&checkout.project, "greeter") + .unwrap(); + let stack = FakeStack::new(); + run( + &checkout.project, + &stack.runner(), + &ScriptedPrompt::new(&[]), + &checkout.options_without_client_data(&["one"]), + ) + .unwrap(); + let calls = stack.rendered(); + assert!( + calls + .iter() + .any(|call| call.starts_with("spacetime publish")), + "{calls:?}" + ); + assert!( + calls + .iter() + .any(|call| call.contains("debug_repair_after_publish")), + "{calls:?}" + ); + } + #[test] fn pending_teardown_publishes_after_the_last_rust_folder_was_removed() { let checkout = Checkout::new(); diff --git a/src/cmd/packages/build.rs b/src/cmd/packages/build.rs index 7245fc4..532e5e3 100644 --- a/src/cmd/packages/build.rs +++ b/src/cmd/packages/build.rs @@ -1,7 +1,9 @@ //! `lyracore packages build` — regenerate the Datascript typings, typecheck against them, then run //! every enabled Package's Datascripts, compile its Runtime Scripts, and validate what came out. //! -//! A version gate, then up to eight steps, in this order, and the order is the contract: +//! Inventory comes first. Empty/source-free builds skip Bun and schema generation. Runtime-script- +//! only builds skip schema generation and Datascript typechecking. Existing artifacts still validate. +//! With Datascripts, the version gate and subsequent steps run in this order: //! //! 0. `bun --version` must match the checkout's exact pin. A hard failure, unlike `doctor`'s Bun //! check: `doctor` only reports drift on a machine that may never run Bun at all, but this @@ -358,30 +360,40 @@ pub(crate) fn validate_script_artifacts( }) } -pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { +fn prepare_toolchain( + project: &ProjectLayout, + runner: &dyn ProcessRunner, + datascript_packages: &[String], +) -> Result<()> { let datascripts = project.datascripts_dir(); - let missing = ["package.json", "bun.lock", "tsconfig.json"] - .into_iter() + let required = if datascript_packages.is_empty() { + &["package.json", "bun.lock"][..] + } else { + &["package.json", "bun.lock", "tsconfig.json"][..] + }; + let missing = required + .iter() + .copied() .filter(|name| !datascripts.join(name).is_file()) .collect::>(); if !missing.is_empty() { return Err(Error::PrerequisiteMissing(format!( "the Datascript project at {} is incomplete (missing {}). `packages build` needs the \ - checked-in Bun project before it can regenerate datascripts/generated/ and \ - typecheck the Datascripts. Restore the missing files from this checkout's branch.", + checked-in Bun project to compile enabled Package sources. Restore the missing \ + files from this checkout's branch.", datascripts.display(), missing.join(", ") ))); } - script::refuse_stale_transition(project)?; verify_bun(runner)?; - println!( - "regenerating Module schema typings -> {}", - project.datascript_types_dir().display() - ); - runner + if !datascript_packages.is_empty() { + println!( + "regenerating Module schema typings -> {}", + project.datascript_types_dir().display() + ); + runner .run_streaming(&typegen_command(project)) .map_err(|e| { Error::Process(format!( @@ -390,6 +402,7 @@ pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { compile fails here first. Nothing was typechecked.\n ({e})" )) })?; + } println!("installing the pinned Datascript dependencies (bun.lock)"); runner .run_streaming(&install_command(project)) @@ -402,27 +415,36 @@ pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { )) })?; - let datascript_packages = packages_with_datascripts(project)?; - let config = typecheck_config(project, &datascript_packages)?; - let mut typecheck = typecheck_command(project); - if let Some(config) = &config { - typecheck = typecheck - .arg("--project") - .arg(config.path().to_string_lossy().to_string()); - } - println!("typechecking the Datascripts against the regenerated typings"); - runner.run_streaming(&typecheck).map_err(|e| { - Error::Process(format!( - "the Datascripts do not typecheck against the current Module schema. This is the \ + if !datascript_packages.is_empty() { + let config = typecheck_config(project, datascript_packages)?; + let mut typecheck = typecheck_command(project); + if let Some(config) = &config { + typecheck = typecheck + .arg("--project") + .arg(config.path().to_string_lossy().to_string()); + } + println!("typechecking the Datascripts against the regenerated typings"); + runner.run_streaming(&typecheck).map_err(|e| { + Error::Process(format!( + "the Datascripts do not typecheck against the current Module schema. This is the \ gate doing its job — the errors above name the file, line and column to fix. A \ column the Module renamed, retyped or removed shows up there.\n ({e})" - )) - })?; + )) + })?; - println!(); - println!("Datascripts typecheck against the current Module schema."); + println!(); + println!("Datascripts typecheck against the current Module schema."); + } + Ok(()) +} +pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { + script::refuse_stale_transition(project)?; + let datascript_packages = packages_with_datascripts(project)?; let script_packages = script::packages_with_scripts(project)?; + if !datascript_packages.is_empty() || !script_packages.is_empty() { + prepare_toolchain(project, runner, &datascript_packages)?; + } for path in script::remove_artifacts_without_sources(project)? { println!( "removed source-built Script Artifact with no Runtime Script sources: {}", @@ -433,6 +455,7 @@ pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { if project.packages_dir().is_dir() { validate_generated_artifacts(project, runner, None)?; } + println!("no enabled Package sources to build."); return Ok(()); } @@ -543,17 +566,32 @@ mod tests { .unwrap(); } + #[test] + fn an_empty_inventory_needs_no_toolchain_or_datascript_project() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + let project = ProjectLayout::from_root(tmp.path()).unwrap(); + let stack = FakeStack::new() + .fail_on("bun", "unavailable") + .fail_on("spacetime", "unavailable"); + run(&project, &stack.runner()).unwrap(); + assert!(stack.rendered().is_empty()); + } + #[test] fn a_build_verifies_bun_generates_typings_installs_the_lockfile_then_typechecks_in_that_order() { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); let stack = FakeStack::new(); run(&project, &stack.runner()).unwrap(); let calls = stack.rendered(); - assert_eq!(calls.len(), 4, "{calls:?}"); + assert_eq!(calls.len(), 6, "{calls:?}"); assert!(calls[0].starts_with("bun --version"), "{calls:?}"); assert!(calls[1].contains("spacetime generate"), "{calls:?}"); assert!(calls[1].contains("--lang typescript"), "{calls:?}"); @@ -604,6 +642,9 @@ mod tests { fn a_failed_typegen_stops_before_anything_is_typechecked() { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); let stack = FakeStack::new().fail_on("spacetime generate", "the module does not compile"); let error = run(&project, &stack.runner()).unwrap_err(); @@ -618,6 +659,9 @@ mod tests { fn a_lockfile_that_no_longer_matches_package_json_fails_with_the_fix() { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); let stack = FakeStack::new().fail_on("bun install", "lockfile had changes"); let error = run(&project, &stack.runner()).unwrap_err(); @@ -638,6 +682,9 @@ mod tests { // step streams — so the error here supplies the reading, not a copy of the transcript. let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); let stack = FakeStack::new().fail_on("tsc", "exit status 1"); let error = run(&project, &stack.runner()).unwrap_err(); @@ -653,6 +700,7 @@ mod tests { std::fs::create_dir_all(&root).unwrap(); std::fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap(); let project = ProjectLayout::from_root(&root).unwrap(); + with_datascript(&project, "example", "main"); let stack = FakeStack::new(); let error = run(&project, &stack.runner()).unwrap_err(); @@ -668,6 +716,9 @@ mod tests { fn every_required_datascript_project_file_is_checked_before_typegen() { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); std::fs::remove_file(project.datascripts_dir().join("tsconfig.json")).unwrap(); let stack = FakeStack::new(); @@ -683,6 +734,9 @@ mod tests { fn a_missing_bun_fails_before_anything_is_generated_or_installed() { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); let stack = FakeStack::new().fail_on("bun --version", "command not found"); let error = run(&project, &stack.runner()).unwrap_err(); @@ -696,6 +750,9 @@ mod tests { fn a_bun_older_than_the_pin_fails_the_build() { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); let stack = FakeStack::new().with_stdout("bun --version", "1.3.6"); let error = run(&project, &stack.runner()).unwrap_err(); @@ -711,6 +768,9 @@ mod tests { // The pin is exact, not a floor: a Datascript's typecheck is only verified against 1.3.7. let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); let stack = FakeStack::new().with_stdout("bun --version", "1.4.0"); let error = run(&project, &stack.runner()).unwrap_err(); @@ -722,6 +782,9 @@ mod tests { fn a_malformed_bun_banner_fails_the_build_rather_than_guessing() { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); let stack = FakeStack::new().with_stdout("bun --version", "bun 1.3.7"); let error = run(&project, &stack.runner()).unwrap_err(); @@ -737,6 +800,9 @@ mod tests { fn the_pinned_bun_lets_the_build_proceed() { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); + with_datascript(&project, "example", "main"); + with_base_snapshot(&project); + with_generated_artifact(&project, "example"); let stack = FakeStack::new().with_stdout("bun --version", "1.3.7"); run(&project, &stack.runner()).unwrap(); @@ -1015,13 +1081,16 @@ mod tests { run(&project, &stack.runner()).unwrap(); let calls = stack.rendered(); - assert_eq!(calls.len(), 7, "{calls:?}"); - assert!(calls[4].contains("--print-events"), "{calls:?}"); - assert!(calls[5].contains("build-scripts.ts"), "{calls:?}"); - assert!(calls[5].ends_with("fire_nova"), "{calls:?}"); - assert!(calls[6].contains("lyracore-delta-check"), "{calls:?}"); + assert!(!calls + .iter() + .any(|call| call.contains("spacetime generate") || call.contains("tsc"))); + assert_eq!(calls.len(), 5, "{calls:?}"); + assert!(calls[2].contains("--print-events"), "{calls:?}"); + assert!(calls[3].contains("build-scripts.ts"), "{calls:?}"); + assert!(calls[3].ends_with("fire_nova"), "{calls:?}"); + assert!(calls[4].contains("lyracore-delta-check"), "{calls:?}"); assert!( - calls[6].contains("fire_nova/data/.generated/fire_nova.script.json"), + calls[4].contains("fire_nova/data/.generated/fire_nova.script.json"), "{calls:?}" ); } @@ -1084,7 +1153,9 @@ mod tests { ), ) .unwrap(); - let stack = FakeStack::new(); + let stack = FakeStack::new() + .fail_on("bun", "unavailable") + .fail_on("spacetime", "unavailable"); run(&project, &stack.runner()).unwrap(); super::super::check::run(&project, &stack.runner()).unwrap(); @@ -1127,11 +1198,7 @@ mod tests { run(&project, &stack.runner()).unwrap(); let calls = stack.rendered(); - assert_eq!(calls.len(), 4, "{calls:?}"); - for call in &calls { - assert!(!call.contains("bun run"), "{calls:?}"); - assert!(!call.contains("lyracore-delta-check"), "{calls:?}"); - } + assert!(calls.is_empty(), "{calls:?}"); } #[test] diff --git a/src/cmd/packages/config.rs b/src/cmd/packages/config.rs index 6ead157..aee2b16 100644 --- a/src/cmd/packages/config.rs +++ b/src/cmd/packages/config.rs @@ -648,7 +648,13 @@ mod tests { let mut options = list_options("greeter"); options.databases = named.iter().map(|s| s.to_string()).collect(); - run(&checkout.project, &stack.runner(), &FakeHttp::new(), &options).unwrap(); + run( + &checkout.project, + &stack.runner(), + &FakeHttp::new(), + &options, + ) + .unwrap(); let asked = queries(&stack); assert_eq!(asked.len(), named.len()); diff --git a/src/cmd/packages/git.rs b/src/cmd/packages/git.rs index 2e76df0..e670440 100644 --- a/src/cmd/packages/git.rs +++ b/src/cmd/packages/git.rs @@ -15,7 +15,7 @@ //! `update` therefore advances a RECORDED commit rather than a working copy: the stamp says which //! repository and which commit the installed tree came from, and the update replaces the whole //! folder with the repository's current one. The old folder is kept until the new revision is -//! installed and preflight is green, so every failure is recoverable. +//! installed and the applicable checks pass, so every failure is recoverable. //! //! Neither verb publishes anything or synchronizes a client. @@ -27,7 +27,6 @@ use super::{ Origin, PackageName, PackageState, }; use crate::cmd::import::Prompt; -use crate::cmd::preflight; use crate::proc::{CommandSpec, ProcessRunner}; use crate::project::ProjectLayout; use crate::{Error, Result}; @@ -293,7 +292,7 @@ pub fn update( println!(); println!( "stopping at '{}'. The {advanced} Package(s) updated before it are \ - installed and preflighted; they are not rolled back.", + installed; they are not rolled back.", package.name.as_str() ); } @@ -387,6 +386,8 @@ fn update_one( let origin = source.origin(clone.revision()); let compiled_in = package.state == PackageState::Enabled; + let server_changed = super::affects_server(project, &package.dir) + || super::affects_server(project, &candidate_path); let preserved = PreservedPackage::move_aside(project, package)?; let installed = install_tree( project, @@ -406,13 +407,11 @@ fn update_one( println!(" identity {identity}"); println!(); if compiled_in { - println!("running preflight with the new revision compiled in"); + super::validate_install(project, runner, server_changed) } else { - println!( - "running preflight ('{name}' is disabled, so the module does not compile it in)" - ); + println!("'{name}' is disabled; validation for activation runs after enabling it."); + Ok(()) } - preflight::run(project, runner) }); if let Err(cause) = outcome { return Err(preserved.restore( @@ -426,10 +425,7 @@ fn update_one( preserved.discard(); println!(); - println!( - "'{name}' is at {} and preflight is green.", - short(clone.revision()) - ); + println!("'{name}' is at {}.", short(clone.revision())); if !compiled_in { println!(" lyracore packages enable {name}"); } @@ -588,7 +584,7 @@ fn refuse_dirty(package: &InstalledPackage, recorded: &ProvenanceStamp) -> Resul } /// The Package as it was before an update, moved out of its inventory but kept on disk until the -/// new revision is installed AND preflight is green. +/// new revision is installed AND the applicable checks pass. /// /// The failure that matters during an update is the one that happens with the old folder already /// gone. Keeping it makes every one of them recoverable: the old revision goes back where it was, @@ -1101,6 +1097,58 @@ mod tests { ); } + #[test] + fn changing_a_rust_package_to_client_only_still_validates_and_rolls_back() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let tree = candidate(&tmp, "anything"); + install(&project, &repository(&tree, FIRST)); + std::fs::remove_dir_all(tree.join("src")).unwrap(); + std::fs::create_dir_all(tree.join("client")).unwrap(); + let stack = repository(&tree, SECOND) + .fail_on("cargo check", "module still references removed Rust"); + assert!(update( + &project, + &stack.runner(), + &Answer("yes"), + Some("greeter"), + true + ) + .is_err()); + let installed = project.packages_dir().join("greeter"); + assert!(installed.join("src/mod.rs").is_file()); + assert_eq!(ProvenanceStamp::read(&installed).unwrap().revision, FIRST); + } + + #[test] + fn an_enabled_client_only_update_needs_no_module_toolchain() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let tree = candidate(&tmp, "anything"); + std::fs::remove_dir_all(tree.join("src")).unwrap(); + std::fs::create_dir_all(tree.join("client")).unwrap(); + std::fs::write(tree.join("client/README"), "v1").unwrap(); + install(&project, &repository(&tree, FIRST)); + std::fs::write(tree.join("client/README"), "v2").unwrap(); + let stack = repository(&tree, SECOND) + .fail_on("cargo", "unavailable") + .fail_on("spacetime", "unavailable"); + update( + &project, + &stack.runner(), + &Answer("yes"), + Some("greeter"), + true, + ) + .unwrap(); + let installed = project.packages_dir().join("greeter"); + assert_eq!( + std::fs::read_to_string(installed.join("client/README")).unwrap(), + "v2" + ); + assert_eq!(ProvenanceStamp::read(&installed).unwrap().revision, SECOND); + } + // ---- which Packages `update` will act on ---- #[test] @@ -1214,7 +1262,9 @@ mod tests { super::super::lifecycle::tests::disable_without_stack(&project, "greeter").unwrap(); std::fs::write(tree.join("src/mod.rs"), "pub fn greet_v2() {}\n").unwrap(); - let stack = repository(&tree, SECOND); + let stack = repository(&tree, SECOND) + .fail_on("cargo", "compiler unavailable") + .fail_on("spacetime", "unavailable"); update(&project, &stack.runner(), &Answer("yes"), None, true).unwrap(); let disabled = project.packages_disabled_dir().join("greeter"); diff --git a/src/cmd/packages/lifecycle.rs b/src/cmd/packages/lifecycle.rs index fb5928a..551f7c9 100644 --- a/src/cmd/packages/lifecycle.rs +++ b/src/cmd/packages/lifecycle.rs @@ -126,7 +126,7 @@ pub fn disable(project: &ProjectLayout, realm: &Realm, name: &str, yes: bool) -> print!("{}", super::activation_steps(&destination)); println!(); println!( - "undo this move with `lyracore packages enable {}`.", + "re-enable the source with `lyracore packages enable {}`. This does not restore data deleted by Package Teardown.", name.as_str() ); Ok(()) diff --git a/src/cmd/packages/mod.rs b/src/cmd/packages/mod.rs index ddfeebf..eb3f496 100644 --- a/src/cmd/packages/mod.rs +++ b/src/cmd/packages/mod.rs @@ -251,8 +251,7 @@ pub fn check_collision(project: &ProjectLayout, name: &PackageName) -> Result<() /// Refuse a destination `packages/` that Git already tracks in the core checkout: a /// first-party Package like the Reference Package (`example`) or an in-tree instance Package /// (`fire_nova`). Installing over one would overwrite tracked content outside any Package -/// Inventory move, dirtying the checkout. `lyracore update` and `service reconcile` then refuse to -/// run against a dirty tree. +/// Inventory move, dirtying the checkout and blocking `lyracore update`. fn check_not_tracked( project: &ProjectLayout, runner: &dyn ProcessRunner, @@ -272,8 +271,7 @@ fn check_not_tracked( } Err(Error::Usage(format!( "cannot install '{}' from {}: {} is already tracked by Git in this checkout. Installing \ - over a tracked Package would dirty the checkout, and `lyracore update` and `service \ - reconcile` then refuse to run. Nothing was copied.", + over a tracked Package would dirty the checkout, and `lyracore update` then refuses to run. Nothing was copied.", name.as_str(), source, project.root.join(&relative).display() @@ -464,6 +462,30 @@ impl Origin<'_> { } } +/// Whether this tree contributes server content or enables a legacy Datascript. +pub(crate) fn affects_server(project: &ProjectLayout, dir: &Path) -> bool { + ["src", "data", "scripts", "datascripts"] + .iter() + .any(|name| dir.join(name).is_dir()) + || dir + .file_name() + .is_some_and(|name| project.datascripts_src_dir().join(name).is_dir()) +} + +/// Only server-content changes need the deploy gate. Client packing owns client validation. +pub(crate) fn validate_install( + project: &ProjectLayout, + runner: &dyn ProcessRunner, + server_changed: bool, +) -> Result<()> { + if server_changed { + preflight::run(project, runner) + } else { + println!("client-only content: module preflight is not needed; client sync/pack validates the client artifact."); + Ok(()) + } +} + /// The installation contract, from the first refusal to the printed next steps. /// /// Order matters and is the whole design: everything that can refuse the install does so BEFORE @@ -525,8 +547,7 @@ pub(crate) fn install( println!(" identity {identity}"); println!(); - println!("running preflight with the Package compiled in"); - preflight::run(project, runner).map_err(|e| { + validate_install(project, runner, affects_server(project, &destination)).map_err(|e| { Error::Process(format!( "preflight failed with '{}' installed, so it has NOT been published and the module on \ the node is unchanged.\n The Package is on disk at {}. Fix it there and re-run \ @@ -538,7 +559,7 @@ pub(crate) fn install( })?; println!(); - println!("'{}' is installed and preflight is green.", name.as_str()); + println!("'{}' is installed.", name.as_str()); print!("{}", activation_steps(&destination)); Ok(()) } @@ -841,7 +862,7 @@ pub fn new( println!(" revision {}", collection.revision()); println!(" identity {identity}"); - preflight::run(project, runner).map_err(|e| { + validate_install(project, runner, affects_server(project, &destination)).map_err(|e| { Error::Process(format!( "preflight failed after '{}' was scaffolded. Nothing was published.\n The Package \ remains at {}. Fix the reported cause and re-run `lyracore preflight`, or remove \ @@ -852,7 +873,7 @@ pub fn new( )) })?; - println!("'{}' is scaffolded and preflight is green.", name.as_str()); + println!("'{}' is scaffolded.", name.as_str()); print!("{next_steps}"); Ok(()) } @@ -1252,6 +1273,30 @@ pub(super) mod tests { // ---- the shape contract ---- + #[test] + fn installing_client_only_content_does_not_compile_the_module() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let source = tmp.path().join("ui"); + std::fs::create_dir_all(source.join("client/addons/UI")).unwrap(); + let stack = FakeStack::new() + .fail_on("cargo", "compiler unavailable") + .fail_on("spacetime", "unavailable"); + add( + &project, + &stack.runner(), + &Answer("yes"), + source.to_str().unwrap(), + true, + ) + .unwrap(); + assert!(project.packages_dir().join("ui/client/addons/UI").is_dir()); + assert!(!stack + .rendered() + .iter() + .any(|call| call.starts_with("cargo ") || call.starts_with("spacetime "))); + } + #[test] fn a_client_only_package_is_valid_and_a_src_without_mod_rs_is_not() { let tmp = TempDir::new().unwrap(); diff --git a/src/cmd/update.rs b/src/cmd/update.rs index 97c1ec6..7e108bf 100644 --- a/src/cmd/update.rs +++ b/src/cmd/update.rs @@ -1,98 +1,107 @@ -//! `lyracore update` — pull the latest checkout in place, and tell you how to restart the stack. -//! -//! A user clone's plain `git pull` is broken by design today: `scripts/mirror-publish.sh` squashes -//! every publish into a new single-root commit and force-pushes, so the local and remote histories -//! are unrelated. `git reset --hard origin/main` is the operation that works across BOTH that -//! shape and the fast-forward one a future append-forward mirror will have, which is why this -//! reaches for it rather than a merge — and why it refuses outright over anything a hard reset -//! would discard. -//! -//! This does NOT restart the dev stack itself — it only prints the command to. Running `dev -//! down`/`dev up` unasked would spin up the whole fixture on a checkout that has no stack running -//! at all, and shelling out to a `lyracore` on PATH assumes the launcher shim exists, which a -//! contributor driving this binary directly may not have. +//! Update Core without losing local commits or untracked files. +//! Ordinary updates must be fast-forwards. Rewritten mirror history requires an explicit reset, +//! which first retains HEAD on a recovery branch. Restarting the realm remains a separate action. use crate::proc::{CommandSpec, ProcessRunner}; use crate::project::ProjectLayout; use crate::{Error, Result}; -/// The lyracore-cli release pin, at the checkout root — the same file `Phase B` of the sharded -/// fixture's mirror work bumps on every landed release. Read before and after the reset purely to -/// report whether it moved; installing a new pin is the `lyracore` shim's job, not this command's. const LYRACORE_CLI_PIN: &str = ".lyracore-cli-rev"; -pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { - if pull(project, runner)? == Checkout::AlreadyCurrent { - return Ok(()); - } - - println!(); - println!("this does not restart anything for you. To rebuild the gateway and republish"); - println!("every fixture database, run:"); - println!(" ./lyracore dev down && ./lyracore dev up"); - Ok(()) -} - -/// Whether the checkout moved. `update` prints its restart advice only when it did; `service -/// reconcile` ignores it, because deployment drift is independent of git drift. -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum Checkout { - AlreadyCurrent, - Updated, -} - -/// Move the checkout to `origin/main`, or report that it is already there. -/// -/// Shared with `service reconcile`, which does exactly this git work before it touches the host: -/// one reconciliation, so the two cannot drift into two different ideas of what "updated" means. -/// `refuse_if_dirty` below blocks BOTH callers. -pub(crate) fn pull(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result { +pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner, reset_mirror: bool) -> Result<()> { + let git = || CommandSpec::new("git").cwd(project.root.clone()); + // Refuse local edits before even fetching. --keep below also protects edits made during fetch. + refuse_if_dirty(&runner.run_and_wait(&git().arg("status").arg("--porcelain"))?)?; println!("· fetching origin..."); - runner.run_and_wait(&git(project).arg("fetch").arg("origin"))?; - - let porcelain = runner.run_and_wait(&git(project).arg("status").arg("--porcelain"))?; - refuse_if_dirty(&porcelain)?; - - let head = runner.run_and_wait(&git(project).arg("rev-parse").arg("HEAD"))?; - let upstream = runner.run_and_wait(&git(project).arg("rev-parse").arg("origin/main"))?; - let head = head.trim().to_string(); - let upstream = upstream.trim().to_string(); + runner.run_and_wait(&git().arg("fetch").arg("origin"))?; + let head = runner.run_and_wait(&git().arg("rev-parse").arg("HEAD"))?; + let upstream = runner.run_and_wait(&git().arg("rev-parse").arg("origin/main"))?; + let head = head.trim(); + let upstream = upstream.trim(); if head == upstream { println!("already up to date."); - return Ok(Checkout::AlreadyCurrent); + return Ok(()); + } + if !reset_mirror { + let local = runner.run_and_wait( + &git() + .arg("rev-list") + .arg("--count") + .arg(format!("{upstream}..{head}")), + )?; + let count = local.trim().parse::().map_err(|_| { + Error::Process("could not count local commits; nothing was updated".into()) + })?; + if count != 0 { + return Err(Error::Process(format!( + "HEAD has {count} commit(s) absent from origin/main. Nothing was updated. \ + Reconcile local development with Git. To replace rewritten mirror history, use \ + `lyracore update --reset-mirror`; it saves the current revision on a recovery branch first." + ))); + } + } + // Git reset protects ordinary untracked files, but can overwrite ignored ones. Check both, + // including untracked directories that an incoming tracked file would replace. Reset affects + // the whole repository, even when the selected workspace is nested below its root. + let untracked = runner.run_and_wait( + &git() + .arg("ls-files") + .arg("--others") + .arg("--directory") + .arg("--full-name") + .arg("-z") + .arg("--") + .arg(":/"), + )?; + let incoming = runner.run_and_wait( + &git() + .arg("ls-tree") + .arg("-r") + .arg("--full-tree") + .arg("--name-only") + .arg("-z") + .arg(upstream), + )?; + for path in untracked.split('\0').filter(|p| !p.is_empty()) { + let path = path.trim_end_matches('/'); + if incoming + .split('\0') + .filter(|p| !p.is_empty()) + .any(|tracked| { + tracked == path + || tracked.starts_with(&format!("{path}/")) + || path.starts_with(&format!("{tracked}/")) + }) + { + return Err(Error::Process(format!("untracked or ignored path {path:?} overlaps origin/main. Move it aside before updating; nothing was updated."))); + } + } + if reset_mirror { + let backup = format!("lyracore-backup/{head}"); + // Never overwrite a recovery branch: users may have advanced it or checked it out. + // Refusing an existing name also prevents reset from moving the backup itself. + runner.run_and_wait(&git().arg("branch").arg("--").arg(&backup).arg(head))?; + println!("saved the previous revision on branch {backup}"); } - let old_pin = read_pin(project); - - println!("· updating to origin/main..."); - runner.run_and_wait(&git(project).arg("reset").arg("--hard").arg("origin/main"))?; - - let new_pin = read_pin(project); - println!("updated {} → {}", short(&head), short(&upstream)); - if old_pin != new_pin { + runner.run_and_wait(&git().arg("reset").arg("--keep").arg(upstream))?; + println!("updated {} → {}", short(head), short(upstream)); + if old_pin != read_pin(project) { println!( - " the lyracore-cli pin changed — the next `lyracore` invocation installs the new \ - release automatically." + "the CLI pin changed; the next `lyracore` invocation installs the pinned revision." ); } - Ok(Checkout::Updated) + println!("this does not restart anything. To rebuild and republish the local realm, run:"); + println!(" ./lyracore dev down && ./lyracore dev up"); + Ok(()) } -/// A commit sha, shortened for the "updated X → Y" line — the conventional 7 characters, or -/// whatever `git rev-parse` gave us if it was somehow shorter than that. fn short(sha: &str) -> &str { &sha[..sha.len().min(7)] } -fn git(project: &ProjectLayout) -> CommandSpec { - CommandSpec::new("git").cwd(project.root.clone()) -} - -/// Any `git status --porcelain` line that does not start with `??` describes something a `reset -/// --hard` would discard: a modification, a staged change, a rename, a deletion. Untracked files -/// are `??` and survive a reset untouched, so they alone must never block an update. fn refuse_if_dirty(porcelain: &str) -> Result<()> { - let dirty: Vec<&str> = porcelain + let dirty: Vec<_> = porcelain .lines() .filter(|line| !line.trim().is_empty() && !line.starts_with("??")) .collect(); @@ -100,12 +109,8 @@ fn refuse_if_dirty(porcelain: &str) -> Result<()> { return Ok(()); } Err(Error::Process(format!( - "commit or stash first — update uses `git reset --hard`, which would discard:\n{}", - dirty - .iter() - .map(|l| format!(" {l}")) - .collect::>() - .join("\n") + "commit or stash tracked edits before updating:\n{}", + dirty.join("\n") ))) } @@ -116,87 +121,14 @@ fn read_pin(project: &ProjectLayout) -> Option { .filter(|s| !s.is_empty()) } -/// Fake stacks for the two checkout states `pull` distinguishes, shared with `service reconcile`'s -/// tests: a checkout already on `origin/main`, and one behind it. -#[cfg(test)] -pub(crate) mod testing { - use crate::proc::fake::FakeStack; - - pub fn same_sha_stack() -> FakeStack { - FakeStack::new() - .with_stdout( - "rev-parse HEAD", - "aaaa1111111111111111111111111111111111111\n", - ) - .with_stdout( - "rev-parse origin/main", - "aaaa1111111111111111111111111111111111111\n", - ) - } - - pub fn ahead_stack() -> FakeStack { - FakeStack::new() - .with_stdout( - "rev-parse HEAD", - "aaaa1111111111111111111111111111111111111\n", - ) - .with_stdout( - "rev-parse origin/main", - "bbbb2222222222222222222222222222222222222\n", - ) - } -} - #[cfg(test)] mod tests { - use super::testing::{ahead_stack, same_sha_stack}; use super::*; - use crate::proc::fake::{Call, FakeStack}; use tempfile::TempDir; - fn project(tmp: &TempDir) -> ProjectLayout { std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); ProjectLayout::from_root(tmp.path()).unwrap() } - - // ---- the dirty-tree refusal ---- - - #[test] - fn a_dirty_tracked_file_is_refused_before_any_reset() { - let tmp = TempDir::new().unwrap(); - let project = project(&tmp); - let stack = FakeStack::new().with_stdout("status --porcelain", " M module/src/foo.rs\n"); - - let error = run(&project, &stack.runner()).unwrap_err().to_string(); - assert!(error.contains("module/src/foo.rs"), "{error}"); - assert!(error.contains("commit or stash"), "{error}"); - assert_eq!( - stack.rendered(), - vec![ - "git fetch origin".to_string(), - "git status --porcelain".to_string() - ], - "no reset may run against a dirty tree" - ); - } - - #[test] - fn untracked_files_alone_do_not_block_an_update() { - let tmp = TempDir::new().unwrap(); - let project = project(&tmp); - let stack = ahead_stack().with_stdout("status --porcelain", "?? scratch/notes.txt\n"); - - run(&project, &stack.runner()).unwrap(); - assert!( - stack - .rendered() - .iter() - .any(|r| r == "git reset --hard origin/main"), - "{:?}", - stack.rendered() - ); - } - #[test] fn refuse_if_dirty_treats_only_non_double_question_mark_lines_as_dirty() { assert!(refuse_if_dirty("?? new.txt\n?? another.txt\n").is_ok()); @@ -207,81 +139,6 @@ mod tests { assert!(refuse_if_dirty("?? untracked.rs\n M changed.rs\n").is_err()); } - // ---- the already-current short-circuit ---- - - #[test] - fn head_equal_to_upstream_short_circuits_before_any_reset() { - let tmp = TempDir::new().unwrap(); - let project = project(&tmp); - let stack = same_sha_stack(); - - run(&project, &stack.runner()).unwrap(); - - assert_eq!( - stack.rendered(), - vec![ - "git fetch origin".to_string(), - "git status --porcelain".to_string(), - "git rev-parse HEAD".to_string(), - "git rev-parse origin/main".to_string(), - ] - ); - } - - // ---- the happy path ---- - - #[test] - fn the_happy_path_runs_the_five_git_steps_and_never_executes_the_restart() { - let tmp = TempDir::new().unwrap(); - let project = project(&tmp); - let stack = ahead_stack(); - - run(&project, &stack.runner()).unwrap(); - - // `update` only PRINTS the follow-up (`./lyracore dev down && ./lyracore dev up`) — it - // must never run it. Auto-running would spin up the whole fixture on a checkout with no - // stack at all, and depends on a `lyracore` launcher being on PATH that a contributor - // driving this binary directly may not have. - assert_eq!( - stack.rendered(), - vec![ - "git fetch origin".to_string(), - "git status --porcelain".to_string(), - "git rev-parse HEAD".to_string(), - "git rev-parse origin/main".to_string(), - "git reset --hard origin/main".to_string(), - ] - ); - assert!( - stack.calls().iter().all(|c| matches!(c, Call::Wait(_))), - "no streamed or spawned call may appear: {:?}", - stack.calls() - ); - } - - #[test] - fn every_git_call_runs_from_the_checkout_root() { - let tmp = TempDir::new().unwrap(); - let project = project(&tmp); - let stack = ahead_stack(); - - run(&project, &stack.runner()).unwrap(); - - for call in stack.calls() { - let Call::Wait(spec) = call else { continue }; - if spec.program() == "git" { - assert_eq!( - spec.cwd_value(), - Some(project.root.as_path()), - "{}", - spec.render() - ); - } - } - } - - // ---- the pin readout ---- - #[test] fn the_pin_is_read_trimmed_or_reads_as_absent() { let tmp = TempDir::new().unwrap(); @@ -291,8 +148,6 @@ mod tests { assert_eq!(read_pin(&project).as_deref(), Some("abc123")); } - // ---- the "updated X → Y" line's shas ---- - #[test] fn short_truncates_a_full_sha_to_seven_characters() { assert_eq!( diff --git a/src/lib.rs b/src/lib.rs index dc9823b..708c6d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod config; pub mod error; pub mod harness; pub mod http; +pub mod ops; pub mod proc; pub mod project; pub mod rls; diff --git a/src/ops/mod.rs b/src/ops/mod.rs new file mode 100644 index 0000000..c746b5b --- /dev/null +++ b/src/ops/mod.rs @@ -0,0 +1,2 @@ +//! Host administration, kept separate from the developer command surface. +pub mod service; diff --git a/src/cmd/service.rs b/src/ops/service.rs similarity index 86% rename from src/cmd/service.rs rename to src/ops/service.rs index 4f91e0b..a381359 100644 --- a/src/cmd/service.rs +++ b/src/ops/service.rs @@ -1,26 +1,6 @@ -//! `lyracore service reconcile` — make this host's standalone supervisor match the tracked unit. -//! -//! The server repository's `docs/danger-zones.md` §3 installs -//! `deploy/systemd/spacetimedb-standalone.service` by hand: `install`, `systemctl daemon-reload`, -//! `systemctl enable`, restart, then read the effective properties back. A host reconciled by hand -//! matches whatever its last operator typed; this encodes the same ordered steps so it matches the -//! artifact in the checkout instead. -//! -//! Deployment reconciliation is ONE job, so this verb owns the git steps too: it moves the -//! checkout to `origin/main` (the same work `update` does, shared with it) and then reconciles the -//! host against what that checkout now ships. It is a separate verb from `update` because it is a -//! root-only host mutator, and `update` is a contributor's git-pull replacement. -//! -//! Two rules shape everything below. Every step goes through [`ProcessRunner`], so the whole plan -//! is an ordered, assertable list of commands rather than side effects on a machine. And the -//! service contract it verifies — start command, descriptor limit, stderr destination, data -//! directory, listen address — is READ OUT of the tracked unit, never duplicated here, so this CLI -//! cannot claim a host is reconciled against a contract the checkout no longer ships. -//! -//! It manages the supervisor only. The persistent database directory is checked for existence and -//! otherwise never touched: no create, no move, no delete. - -use crate::cmd::update; +//! Host-only reconciliation of the Standalone Supervisor from the selected Core checkout. +//! Invoked by `lyracore-ops service reconcile`. It neither updates source nor publishes modules. + use crate::proc::{CommandSpec, ProcessRunner}; use crate::project::ProjectLayout; use crate::{Error, Result}; @@ -30,40 +10,20 @@ use crate::{Error, Result}; /// the same name. const SYSTEMD_UNIT_DIR: &str = "/etc/systemd/system"; -/// Update the checkout, install the tracked unit, reload systemd, enable it, restart it, and -/// verify the result. -/// -/// Refuses — before the HOST is touched — when the invocation is not root, when the checkout has -/// local work a reset would discard, when a host prerequisite is missing, or when another active -/// service already owns the node's data directory or listen address. -/// -/// "Before the host is touched" and not "before anything": the checkout reset below runs first, on -/// purpose. The unit and the contract to check the host against are read out of the UPDATED -/// checkout, so checking the host before the reset would check it against a contract this command -/// is not about to install. A refusal after that point therefore leaves the checkout on -/// `origin/main` with the host untouched, which is the state an operator can simply re-run from. +/// Install the unit from the selected checkout and verify the restarted supervisor. +/// Updating source is a separate, unprivileged operation. pub fn reconcile(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { - // Fail fast, before the network and before anything on disk moves: this plan resets the - // checkout and then writes to /etc, and a half-done privileged plan is worse than one that - // never started. require_root(runner)?; - // Deployment drift is independent of git drift: a host can sit exactly on origin/main with the - // wrong unit installed, or none at all. So the reconciliation below runs either way. - update::pull(project, runner)?; reconcile_unit(project, runner) } -/// `service reconcile` writes to `/etc/systemd/system` and drives `systemctl`. Asking for the -/// privilege up front, rather than per command, keeps the plan one atomic decision: no mid-run -/// password prompt, and no half-installed unit because the fifth command was the first to be -/// denied. fn require_root(runner: &dyn ProcessRunner) -> Result<()> { let euid = runner.run_and_wait(&CommandSpec::new("id").arg("-u"))?; match euid.trim().parse::() { Ok(0) => Ok(()), Ok(other) => Err(Error::Process(format!( "`service reconcile` installs a systemd unit and restarts the standalone node, which \ - needs root — this process runs as uid {other}. Re-run it as `sudo ./lyracore service \ + needs root — this process runs as uid {other}. Re-run it as `sudo lyracore-ops service \ reconcile`." ))), Err(_) => Err(Error::Process(format!( @@ -701,7 +661,6 @@ fn effective_exec_start(property: &str) -> std::result::Result Vec { - vec![ - "git fetch origin".to_string(), - "git status --porcelain".to_string(), - "git rev-parse HEAD".to_string(), - "git rev-parse origin/main".to_string(), - ] - } - // ---- what the tracked unit says ---- #[test] @@ -907,36 +855,14 @@ ExecStart=/usr/bin/standalone start let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); let project = ProjectLayout::from_root(tmp.path()).unwrap(); - let stack = reconcilable_host(same_sha_stack()); + let stack = reconcilable_host(FakeStack::new()); let error = reconcile(&project, &stack.runner()) .unwrap_err() .to_string(); assert!(error.contains(ProjectLayout::STANDALONE_UNIT), "{error}"); - let mut expected = vec!["id -u".to_string()]; - expected.extend(git_steps()); - assert_eq!(stack.rendered(), expected); - } - - /// The same refusal from a checkout that was BEHIND `origin/main`, which is the case that - /// shows the real ordering: the reset lands first, deliberately, because the unit to install - /// is read out of the UPDATED checkout. The host is still untouched afterwards. - #[test] - fn the_reset_lands_before_the_unit_is_read_and_leaves_the_host_untouched() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); - let project = ProjectLayout::from_root(tmp.path()).unwrap(); - let stack = reconcilable_host(ahead_stack()); - - let error = reconcile(&project, &stack.runner()) - .unwrap_err() - .to_string(); - - assert!(error.contains(ProjectLayout::STANDALONE_UNIT), "{error}"); - let mut expected = vec!["id -u".to_string()]; - expected.extend(git_steps()); - expected.push("git reset --hard origin/main".to_string()); + let expected = vec!["id -u".to_string()]; assert_eq!(stack.rendered(), expected); } @@ -946,20 +872,20 @@ ExecStart=/usr/bin/standalone start fn a_non_root_invocation_is_refused_before_anything_else_runs() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()).with_stdout("id -u", "1000\n"); + let stack = reconcilable_host(FakeStack::new()).with_stdout("id -u", "1000\n"); let error = reconcile(&project, &stack.runner()) .unwrap_err() .to_string(); assert!( - error.contains("sudo ./lyracore service reconcile"), + error.contains("sudo lyracore-ops service reconcile"), "{error}" ); assert_eq!( stack.rendered(), vec!["id -u".to_string()], - "not even the fetch may run without the privilege the plan needs" + "no host operation may run without the privilege the plan needs" ); } @@ -967,7 +893,7 @@ ExecStart=/usr/bin/standalone start fn an_unreadable_user_id_is_refused_rather_than_assumed_to_be_root() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()).with_stdout("id -u", "nobody\n"); + let stack = reconcilable_host(FakeStack::new()).with_stdout("id -u", "nobody\n"); let error = reconcile(&project, &stack.runner()) .unwrap_err() @@ -977,50 +903,13 @@ ExecStart=/usr/bin/standalone start assert_eq!(stack.rendered(), vec!["id -u".to_string()]); } - // ---- the git half ---- - - #[test] - fn a_dirty_tree_blocks_the_service_change_too() { - let tmp = TempDir::new().unwrap(); - let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) - .with_stdout("status --porcelain", " M module/src/foo.rs\n"); - - let error = reconcile(&project, &stack.runner()) - .unwrap_err() - .to_string(); - - assert!(error.contains("commit or stash"), "{error}"); - assert_eq!( - stack.rendered(), - vec![ - "id -u".to_string(), - "git fetch origin".to_string(), - "git status --porcelain".to_string(), - ], - "a dirty tree stops the reset AND the systemd mutation" - ); - } - #[test] - fn an_already_current_checkout_still_reconciles_the_supervisor() { + fn reconciliation_never_reads_or_mutates_git() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(same_sha_stack()); - + let stack = reconcilable_host(FakeStack::new()).fail_on("git", "git is unavailable"); reconcile(&project, &stack.runner()).unwrap(); - - let rendered = stack.rendered(); - assert!( - !rendered.iter().any(|r| r.contains("reset --hard")), - "{rendered:?}" - ); - assert!( - rendered - .iter() - .any(|r| r == "systemctl --no-pager restart spacetimedb-standalone.service"), - "deployment drift is repaired without a new commit: {rendered:?}" - ); + assert!(!stack.rendered().iter().any(|call| call.starts_with("git "))); } // ---- the happy path ---- @@ -1029,14 +918,12 @@ ExecStart=/usr/bin/standalone start fn reconciliation_runs_the_runbook_steps_in_order() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()); + let stack = reconcilable_host(FakeStack::new()); reconcile(&project, &stack.runner()).unwrap(); let mut expected = vec!["id -u".to_string()]; - expected.extend(git_steps()); expected.extend([ - "git reset --hard origin/main".to_string(), "busctl --json=short --version".to_string(), "id lyracore".to_string(), "test -x /opt/lyracore/spacetimedb/spacetimedb-standalone".to_string(), @@ -1073,7 +960,7 @@ ExecStart=/usr/bin/standalone start fn the_persistent_data_directory_is_only_ever_read() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()); + let stack = reconcilable_host(FakeStack::new()); reconcile(&project, &stack.runner()).unwrap(); @@ -1110,7 +997,7 @@ ExecStart=/usr/bin/standalone start fn missing_busctl_json_support_is_refused_before_mutation() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .fail_on("busctl --json=short --version", "unknown option --json"); let error = reconcile(&project, &stack.runner()) @@ -1124,7 +1011,7 @@ ExecStart=/usr/bin/standalone start fn a_missing_service_account_is_refused() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()).fail_on("id lyracore", "no such user"); + let stack = reconcilable_host(FakeStack::new()).fail_on("id lyracore", "no such user"); let error = reconcile(&project, &stack.runner()) .unwrap_err() @@ -1137,7 +1024,7 @@ ExecStart=/usr/bin/standalone start fn a_missing_standalone_binary_is_refused() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()).fail_on("test -x", "not executable"); + let stack = reconcilable_host(FakeStack::new()).fail_on("test -x", "not executable"); let error = reconcile(&project, &stack.runner()) .unwrap_err() @@ -1154,7 +1041,7 @@ ExecStart=/usr/bin/standalone start fn a_missing_data_directory_is_refused_and_never_created() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .fail_on("test -d /var/lib/lyracore/spacetimedb", "no such directory"); let error = reconcile(&project, &stack.runner()) @@ -1169,7 +1056,7 @@ ExecStart=/usr/bin/standalone start let tmp = TempDir::new().unwrap(); let project = project(&tmp); let stack = - reconcilable_host(ahead_stack()).fail_on("test -d /var/log/lyracore", "no such dir"); + reconcilable_host(FakeStack::new()).fail_on("test -d /var/log/lyracore", "no such dir"); let error = reconcile(&project, &stack.runner()) .unwrap_err() @@ -1184,7 +1071,7 @@ ExecStart=/usr/bin/standalone start fn an_active_service_owning_the_data_directory_is_named_and_refused() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .with_stdout( "list-units", "spacetimedb.service loaded active running node\n", @@ -1213,7 +1100,7 @@ ExecStart=/usr/bin/standalone start fn an_active_service_owning_the_listen_address_is_named_and_refused() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .with_stdout( "list-units", "stdb-old.service loaded active running node\n", @@ -1243,7 +1130,7 @@ ExecStart=/usr/bin/standalone start &continued_exec, ); std::fs::write(project.standalone_unit(), continued).unwrap(); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .with_stdout( "list-units", "stdb-old.service loaded active running node\n", @@ -1267,7 +1154,7 @@ ExecStart=/usr/bin/standalone start fn an_unrelated_active_service_does_not_block_reconciliation() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()); + let stack = reconcilable_host(FakeStack::new()); reconcile(&project, &stack.runner()).unwrap(); @@ -1287,7 +1174,7 @@ ExecStart=/usr/bin/standalone start fn a_failed_restart_stops_with_the_log_pointers_and_never_verifies() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .fail_on("restart spacetimedb-standalone", "Job failed"); let error = reconcile(&project, &stack.runner()) @@ -1316,7 +1203,7 @@ ExecStart=/usr/bin/standalone start fn an_inactive_unit_after_restart_is_reported_as_unreconciled() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()).with_stdout( + let stack = reconcilable_host(FakeStack::new()).with_stdout( "--property=ActiveState", &effective_properties("failed", "", "524288", TRACKED_STDERR), ); @@ -1333,7 +1220,7 @@ ExecStart=/usr/bin/standalone start fn an_unreadable_typed_command_is_reported_as_unreconciled() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()).fail_on( + let stack = reconcilable_host(FakeStack::new()).fail_on( "busctl --json=short get-property", "D-Bus property read failed", ); @@ -1351,7 +1238,7 @@ ExecStart=/usr/bin/standalone start fn an_effective_command_changed_by_a_drop_in_is_reported_as_unreconciled() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .with_stdout( "--property=ActiveState", &effective_properties( @@ -1393,7 +1280,7 @@ ExecStart=/usr/bin/standalone start fn an_effective_executable_path_drift_is_reported_even_when_argv_matches() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .with_stdout( "--property=ActiveState", &effective_properties( @@ -1423,7 +1310,7 @@ ExecStart=/usr/bin/standalone start fn effective_argument_boundaries_are_compared_without_flattening() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .with_stdout( "--property=ActiveState", &effective_properties( @@ -1462,7 +1349,7 @@ ExecStart=/usr/bin/standalone start fn multiple_effective_start_commands_are_reported_as_unreconciled() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .with_stdout( "--property=ActiveState", &effective_properties( @@ -1500,7 +1387,7 @@ ExecStart=/usr/bin/standalone start fn the_inherited_1024_descriptor_ceiling_is_reported_as_unreconciled() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()).with_stdout( + let stack = reconcilable_host(FakeStack::new()).with_stdout( "--property=ActiveState", &effective_properties("active", "", "1024", TRACKED_STDERR), ); @@ -1519,7 +1406,7 @@ ExecStart=/usr/bin/standalone start fn systemd_append_mode_with_the_tracked_open_file_is_reconciled() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()) + let stack = reconcilable_host(FakeStack::new()) .with_stdout( "--property=ActiveState", "ActiveState=active\nDropInPaths=\nLimitNOFILE=524288\nStandardError=append\nMainPID=1234\n", @@ -1542,7 +1429,7 @@ ExecStart=/usr/bin/standalone start let tmp = TempDir::new().unwrap(); let project = project(&tmp); let stack = - reconcilable_host(ahead_stack()).with_stdout("stat --dereference", identities); + reconcilable_host(FakeStack::new()).with_stdout("stat --dereference", identities); let error = reconcile(&project, &stack.runner()) .unwrap_err() @@ -1561,7 +1448,7 @@ ExecStart=/usr/bin/standalone start let tmp = TempDir::new().unwrap(); let project = project(&tmp); let stack = - reconcilable_host(ahead_stack()).fail_on("stat --dereference", "Permission denied"); + reconcilable_host(FakeStack::new()).fail_on("stat --dereference", "Permission denied"); let error = reconcile(&project, &stack.runner()) .unwrap_err() @@ -1585,7 +1472,7 @@ ExecStart=/usr/bin/standalone start ] { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()).with_stdout( + let stack = reconcilable_host(FakeStack::new()).with_stdout( "--property=ActiveState", &format!( "ActiveState=active\nLimitNOFILE=524288\nStandardError=append\n{pid_property}" @@ -1612,7 +1499,7 @@ ExecStart=/usr/bin/standalone start fn a_stderr_destination_that_keeps_no_evidence_is_reported_as_unreconciled() { let tmp = TempDir::new().unwrap(); let project = project(&tmp); - let stack = reconcilable_host(ahead_stack()).with_stdout( + let stack = reconcilable_host(FakeStack::new()).with_stdout( "--property=ActiveState", &effective_properties("active", "", "524288", "inherit"), ); diff --git a/src/project.rs b/src/project.rs index a2c508b..b52e80f 100644 --- a/src/project.rs +++ b/src/project.rs @@ -395,7 +395,6 @@ impl ProjectLayout { /// harness's own adapter seam; a release that also carries a suite entrypoint is preferred. pub const HARNESS_SMOKE_SEAM: &'static str = "adapters/lyracore/wire.sh"; pub const HARNESS_CLIENT_BIN: &'static str = "vanilla-wire"; - /// The fixture account and character the login smoke signs in as. // The database is loopback in every mode. Only the two client-facing ports follow the // `ClientBind` chosen by `dev up [--lan IP]`. diff --git a/tests/command_help.rs b/tests/command_help.rs new file mode 100644 index 0000000..4e966d7 --- /dev/null +++ b/tests/command_help.rs @@ -0,0 +1,40 @@ +use std::process::Command; + +#[test] +fn focused_help_needs_no_checkout_and_performs_no_work() { + let dir = tempfile::tempdir().unwrap(); + for args in [ + vec!["packages", "--help"], + vec!["import", "--help"], + vec!["dev", "up", "--help"], + vec!["help", "packages", "apply"], + vec!["update", "--help"], + vec!["bindings", "--help"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_lyracore")) + .args(&args) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("USAGE:")); + } + let output = Command::new(env!("CARGO_BIN_EXE_lyracore-ops")) + .args(["service", "reconcile", "--help"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let output = Command::new(env!("CARGO_BIN_EXE_lyracore")) + .args(["service", "reconcile"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("lyracore-ops service reconcile")); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); +} diff --git a/tests/update_checkout.rs b/tests/update_checkout.rs new file mode 100644 index 0000000..8ecc918 --- /dev/null +++ b/tests/update_checkout.rs @@ -0,0 +1,258 @@ +//! Exercise update against real, local-only repositories, including work a reset could lose. +use std::path::Path; +use std::process::{Command, Output}; +use tempfile::TempDir; + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(["-c", "core.hooksPath=/dev/null"]) + .args(args) + .current_dir(root) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().into() +} + +struct Checkout { + tmp: TempDir, +} + +impl Checkout { + fn new() -> Self { + let this = Self { + tmp: TempDir::new().unwrap(), + }; + let root = this.tmp.path(); + git(root, &["init", "-q", "-b", "main", "origin"]); + let origin = root.join("origin"); + git(&origin, &["config", "user.name", "CLI test"]); + git(&origin, &["config", "user.email", "cli@example.invalid"]); + std::fs::write(origin.join("Cargo.toml"), "[workspace]\n").unwrap(); + std::fs::write(origin.join(".gitignore"), "ignored.txt\n").unwrap(); + git(&origin, &["add", "."]); + git( + &origin, + &["-c", "commit.gpgsign=false", "commit", "-qm", "initial"], + ); + git(root, &["clone", "-q", "origin", "work"]); + git(&root.join("work"), &["config", "user.name", "CLI test"]); + git( + &root.join("work"), + &["config", "user.email", "cli@example.invalid"], + ); + this + } + + fn path(&self, name: &str) -> std::path::PathBuf { + self.tmp.path().join(name) + } + + fn commit(&self, repo: &str, file: &str, content: &str) -> String { + let root = self.path(repo); + std::fs::write(root.join(file), content).unwrap(); + git(&root, &["add", "-f", file]); + git( + &root, + &["-c", "commit.gpgsign=false", "commit", "-qm", file], + ); + git(&root, &["rev-parse", "HEAD"]) + } + + fn update(&self, flags: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_lyracore")) + .arg("update") + .args(flags) + .current_dir(self.path("work")) + .output() + .unwrap() + } +} + +#[test] +fn dirty_tracked_work_blocks_both_update_modes() { + for flags in [vec![], vec!["--reset-mirror"]] { + let checkout = Checkout::new(); + let head = git(&checkout.path("work"), &["rev-parse", "HEAD"]); + checkout.commit("origin", "remote.txt", "remote"); + std::fs::write( + checkout.path("work/Cargo.toml"), + "[workspace]\n# local edit\n", + ) + .unwrap(); + assert!(!checkout.update(&flags).status.success()); + assert_eq!(git(&checkout.path("work"), &["rev-parse", "HEAD"]), head); + assert!(std::fs::read_to_string(checkout.path("work/Cargo.toml")) + .unwrap() + .contains("local edit")); + } +} + +#[test] +fn a_failed_recovery_ref_prevents_the_mirror_reset() { + let checkout = Checkout::new(); + let head = checkout.commit("work", "local.txt", "keep me"); + // A branch at this prefix prevents creating a recovery branch beneath it. + git(&checkout.path("work"), &["branch", "lyracore-backup"]); + assert!(!checkout.update(&["--reset-mirror"]).status.success()); + assert_eq!(git(&checkout.path("work"), &["rev-parse", "HEAD"]), head); + assert_eq!( + std::fs::read_to_string(checkout.path("work/local.txt")).unwrap(), + "keep me" + ); +} + +#[test] +fn mirror_reset_never_moves_an_existing_recovery_branch() { + for active_backup in [false, true] { + let checkout = Checkout::new(); + let work = checkout.path("work"); + let head = checkout.commit("work", "local.txt", "recover me"); + let backup = format!("lyracore-backup/{head}"); + git(&work, &["checkout", "-b", &backup]); + let saved = if active_backup { + head.clone() + } else { + let advanced = checkout.commit("work", "recovered.txt", "more work"); + git(&work, &["checkout", "main"]); + advanced + }; + let output = checkout.update(&["--reset-mirror"]); + assert!(!output.status.success(), "must refuse an existing backup"); + assert_eq!(git(&work, &["rev-parse", "HEAD"]), head); + assert_eq!(git(&work, &["rev-parse", &backup]), saved); + } +} + +#[test] +fn update_keeps_clean_local_commits() { + let checkout = Checkout::new(); + let head = checkout.commit("work", "local.txt", "keep me"); + let output = checkout.update(&[]); + assert!(!output.status.success(), "update must refuse local commits"); + assert_eq!(git(&checkout.path("work"), &["rev-parse", "HEAD"]), head); + assert_eq!( + std::fs::read_to_string(checkout.path("work/local.txt")).unwrap(), + "keep me" + ); +} + +#[test] +fn update_fast_forwards_without_touching_untracked_work() { + let checkout = Checkout::new(); + let upstream = checkout.commit("origin", "remote.txt", "upstream"); + std::fs::write(checkout.path("work/notes.txt"), "notes").unwrap(); + let output = checkout.update(&[]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + git(&checkout.path("work"), &["rev-parse", "HEAD"]), + upstream + ); + assert_eq!( + std::fs::read_to_string(checkout.path("work/notes.txt")).unwrap(), + "notes" + ); +} + +#[test] +fn explicit_mirror_reset_keeps_a_recovery_branch() { + let checkout = Checkout::new(); + let old = checkout.commit("work", "local.txt", "recover me"); + let origin = checkout.path("origin"); + git(&origin, &["checkout", "--orphan", "replacement"]); + git( + &origin, + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "new mirror root", + ], + ); + git(&origin, &["branch", "-M", "main"]); + let upstream = git(&origin, &["rev-parse", "HEAD"]); + assert!(!checkout.update(&[]).status.success()); + let output = checkout.update(&["--reset-mirror"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let work = checkout.path("work"); + assert_eq!(git(&work, &["rev-parse", "HEAD"]), upstream); + assert_eq!( + git( + &work, + &["rev-parse", &format!("refs/heads/lyracore-backup/{old}")] + ), + old + ); + assert_eq!( + git( + &work, + &["show", &format!("lyracore-backup/{old}:local.txt")] + ), + "recover me" + ); +} + +#[test] +fn update_refuses_untracked_and_ignored_files_in_the_way() { + for file in ["notes.txt", "ignored.txt"] { + for flags in [vec![], vec!["--reset-mirror"]] { + let checkout = Checkout::new(); + let work = checkout.path("work"); + let head = git(&work, &["rev-parse", "HEAD"]); + checkout.commit("origin", file, "remote version"); + std::fs::write(work.join(file), "local version").unwrap(); + let output = checkout.update(&flags); + assert!( + !output.status.success(), + "must preserve {file} with {flags:?}" + ); + assert_eq!(git(&work, &["rev-parse", "HEAD"]), head); + assert_eq!( + std::fs::read_to_string(work.join(file)).unwrap(), + "local version" + ); + } + } +} + +#[test] +fn nested_workspace_updates_protect_files_outside_the_workspace() { + for flags in [vec![], vec!["--reset-mirror"]] { + let checkout = Checkout::new(); + let work = checkout.path("work"); + std::fs::create_dir(checkout.path("origin/core")).unwrap(); + let head = checkout.commit("origin", "core/Cargo.toml", "[workspace]\n"); + git(&work, &["fetch", "origin"]); + git(&work, &["reset", "--hard", &head]); + std::fs::write(work.join("ignored.txt"), "local work").unwrap(); + checkout.commit("origin", "ignored.txt", "incoming file"); + let output = Command::new(env!("CARGO_BIN_EXE_lyracore")) + .arg("update") + .args(flags) + .current_dir(work.join("core")) + .output() + .unwrap(); + assert!( + !output.status.success(), + "must protect the entire repository" + ); + assert_eq!(git(&work, &["rev-parse", "HEAD"]), head); + assert_eq!( + std::fs::read_to_string(work.join("ignored.txt")).unwrap(), + "local work" + ); + } +}