@curia-sh/cli 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # @curia-sh/cli
2
+
3
+ Curia's lifecycle interface. It installs, updates, rolls back, diagnoses, and removes one Curia installation. It contains no Curia service code: the service, the app, and the overseer run in containers that the Compose bundle of an installed version describes.
4
+
5
+ For the operator's view, read the [command reference](https://github.com/alp82/curia/blob/main/docs/operator/command-reference.md). This file is for people who work on the package.
6
+
7
+ ## What this version ships
8
+
9
+ This version ships the stable launcher, the command vocabulary, the installation-root boundary, the operator configuration contract, the supported-host preflight, the Compose bundle contract, the release manifest with its verification, the stable-release index with its selection rule, `curia install` and `curia reinstall`, `curia doctor`, `curia update` end to end (discovery, acquisition, verified staging beside the active version, the target's validation of the current configuration, and the switch of the live installation with session re-adoption), `curia rollback` (the switch back to the one retained release, after that release validates the configuration), `curia uninstall` (the removal of the runnable footprint with the installation preserved), and `curia purge` (the confirmed removal of everything local, with the external cleanup report). Every lifecycle command is implemented. Each one opens its root through the boundary first, so the root refusals are real.
10
+
11
+ ## Layout
12
+
13
+ - `bin/curia.mjs`: the process entry. It hands `argv`, `env`, and the streams to `runCli` and exits with what `runCli` returns.
14
+ - `src/cli.mjs`: `runCli`. It routes one command, prints usage, turns a thrown `Refusal` into exit `3` and any other error into exit `1`. Nothing in the package calls `process.exit`.
15
+ - `src/commands.mjs`: the command table, in lifecycle order. One entry per command with a one-line summary and a `run(context)`.
16
+ - `src/exit.mjs`: the four exit codes and the `Refusal` error.
17
+ - `src/root.mjs`: the installation root. `installationRoot(env)` resolves it, `openRoot(root, { uid })` is the one safe way in and raises every boundary refusal, `ensureLayout` creates the root and its seven boundaries with mode `0700`, and the record functions read and write `state/installation.json`. It also names the two paths an installed version must have.
18
+ - `src/atomic.mjs`: `writeAtomically(path, content, { mode })`, the temporary-file, `fsync`, and rename write that every critical file goes through.
19
+ - `src/config.mjs`: the operator configuration contract, `config/config.yaml`. One reader, validator, renderer, and atomic writer, shared with the Curia service and the Curia app. See [The operator configuration](#the-operator-configuration).
20
+ - `src/lock.mjs`: `withLifecycleLock(root, operation)`, the exclusive lifecycle-operation lock at `run/lifecycle.lock`.
21
+ - `src/layout.mjs`: `serviceLayout(root)`, where the service data lives inside the seven boundaries, and `SERVICE_MOUNTS`, what each container may mount. See [The service layout and the secret files](#the-service-layout-and-the-secret-files).
22
+ - `src/secrets.mjs`: the catalogue of long-lived secret files under `secrets/`, their reader, writer, and status, shared with the Curia service.
23
+ - `src/bundle.mjs`: the Compose bundle contract: the project name, the installation label, the four release images, the run-time variables, and the render, inspect, and env-file functions. See [The Compose bundle](#the-compose-bundle).
24
+ - `src/manifest.mjs`: the release manifest contract and the release verification: what one release is, how the manifest is created, rendered, and parsed, and the two verification doors that `curia install`, `curia update`, and `curia doctor` call. See [The release manifest](#the-release-manifest).
25
+ - `src/archive.mjs`: `readArchive(bytes)` and `extractArchive(bytes, dir, { strip })`, a reader and an extractor for gzipped tar archives, which is how the verification opens the package tarball and the bundle archive and how `curia update` unpacks the package and the runtime without a system `tar`.
26
+ - `src/stage.mjs`: `placeVersion`, the one door from a stage (the seven files `STAGE_FILES` names) to a read-only `versions/<version>/`, shared by `curia install` and `curia update`, and `validateWithRelease`, an installed release's validation of the current operator configuration with its own reader, shared by `curia update` and `curia rollback`. See [Update](#update).
27
+ - `src/steps.mjs`: `namedSteps`, the `[n/N] <step>` printing and the `<step> failed: <cause>` wrapping that `curia update` and `curia rollback` share.
28
+ - `src/rollback.mjs`: `curia rollback`, the four named steps from the root to the switch back to the one rollback release. See [Rollback](#rollback).
29
+ - `src/uninstall.mjs`: `curia uninstall`, the four named steps from the root to the preserved installation. See [Uninstall](#uninstall).
30
+ - `src/purge.mjs`: `curia purge`, the six named steps from the warning and the one confirmation to the removed root and the external cleanup report. See [Purge](#purge).
31
+ - `src/resources.mjs`: the Docker resources of one installation, found and removed by the installation label, and the release images, found by their exact repositories and removed only when Docker proves them unused. `curia uninstall` and `curia purge` share the first; only purge removes images.
32
+ - `src/tailscale.mjs`: `state/tailscale.json` (the record the service writes during integration setup) and the withdrawal of the Serve routes Curia created. The daemon imports the record's reader and writer from here.
33
+ - `src/acquire.mjs`: `acquireRelease`, the download and proof of one version's artifacts into a stage, the bootstrap's own steps in this package's code, through injectable `acquireProbes`. See [Update](#update).
34
+ - `src/update.mjs`: `curia update`, the six named steps from the stable-release index to a staged, verified, validated target and the switch to it. See [Update](#update).
35
+ - `src/switch.mjs`: `switchRelease`, the switch of a live installation from one installed release to another, with the acceptance checks, the atomic activation, the one proven switch back on failure, and the retention of one rollback release. `curia update` and `curia rollback` share it. See [The switch](#the-switch).
36
+ - `src/stable.mjs`: the stable-release index and the selection rule: the signed index's contract, the two transitions (`promote`, `withdraw`), `selectRelease`, and the fetch that verifies the index against the key this package pins at `stable-index.pub`. See [The stable-release index](#the-stable-release-index).
37
+ - `src/preflight.mjs`: the supported-host preflight. `gatherHostFacts` reads the host through injectable probes, `evaluateHostFacts` turns the facts into one report, and `preflight` does both and prints it. See [The host preflight](#the-host-preflight).
38
+ - `src/launcher.mjs`: renders the stable `curia` launcher for one installation root.
39
+ - `src/compose.mjs`: the one seam to Docker Compose. `composeProject` names one version's project files, `writeComposeEnvironment` writes `run/compose.env`, `startProject` pulls and brings the project up, and `waitForHealth` waits for the five services, all through an injectable `dockerRunner`. See [Install and reinstall](#install-and-reinstall).
40
+ - `src/install.mjs`: `curia install` and `curia reinstall`, the six named steps from the host checks to healthy services. See [Install and reinstall](#install-and-reinstall).
41
+ - `src/doctor.mjs`: `curia doctor`, the read-only pass over every direct check, and the redaction every printed line goes through. See [The doctor](#the-doctor).
42
+
43
+ ## The root boundary
44
+
45
+ A lifecycle command's `run` gets `{ env, args, stdout, stderr, uid, root }`. It calls `openRoot(root, { uid })` before it does anything else and reads the status that comes back: `absent`, `empty`, or `installed` with the record. `openRoot` throws a `Refusal` for root execution, a relative root, a symbolic link at the root, a boundary, or the record, foreign ownership, a mode that reaches past the owner, and a nonempty root without a record. A command that changes the installation then calls `ensureLayout` if it may create the root, and wraps its work in `withLifecycleLock`. The order matters: the boundary refuses before the lock exists, and the lock lives under `run/`, which `ensureLayout` creates.
46
+
47
+ A command that writes a record calls `createInstallationRecord(version)` and `writeInstallationRecord(root, record)`. The writer rejects any key beyond `format`, `installationId`, and `activeVersion`. Write the record as soon as the layout exists: `openRoot` recognizes a root by its record, so a root that has a layout but no record reads as unknown on the next run.
48
+
49
+ ## The operator configuration
50
+
51
+ `src/config.mjs` is the one module that reads, validates, renders, and writes `config/config.yaml`. The daemon and the Curia app import it from `daemon/src` by relative path (`../../cli/src/config.mjs`), and the source deployment's compose file mounts `cli/src` into their containers for that reason. One module in three processes is what keeps a file meaning one thing and a refusal reading the same everywhere. The operator's view is [Operator configuration](https://github.com/alp82/curia/blob/main/docs/operator/configuration.md).
52
+
53
+ The interface:
54
+
55
+ - `OPERATOR_CONFIG_KEYS` and `WATCH_MODES` name the contract. `operatorConfigPath(root)` is `<root>/config/config.yaml`.
56
+ - `readOperatorConfig(path)` returns the validated configuration, `null` when there is no file, and throws a `ConfigError` for a symbolic link or an invalid file. The message carries the path, the line, the key, and the rule.
57
+ - `parseOperatorConfig(text, { file })` and `validateOperatorConfig(data)` are the two doors in: text from disk, or an object from the app. Both apply one table of rules.
58
+ - `renderOperatorConfig(data)` validates and prints the file in contract order. `writeOperatorConfig(path, data)` does that and lands it through `writeAtomically` with mode `0600`. An invalid configuration throws before anything touches the disk.
59
+ - `initialOperatorConfig()` is what `curia install` writes: `{ max_concurrent: 4 }` and nothing else.
60
+
61
+ The reader is a strict subset of YAML written by hand, because the package has no dependencies and the file has one small documented shape. It refuses what it does not read, by line, rather than guessing. A `ConfigError` is not a `Refusal`: the command that meets one decides what it means, so `curia doctor` reports it and `curia install` fails on it.
62
+
63
+ ## The service layout and the secret files
64
+
65
+ `src/layout.mjs` is the one place that says where the service's mutable data lives inside the root, and what each container may see of it. The daemon imports it through `daemon/src/paths.mjs`, and the Compose bundle at `deploy/bundle/compose.yaml` is inspected against it by `daemon/test/bundlecompose.test.mjs`. The operator's view is [Secrets, mounts, and what survives](https://github.com/alp82/curia/blob/main/docs/operator/secrets.md).
66
+
67
+ - `serviceLayout(root)` returns the seven boundaries plus the paths inside them that a service names: `overseerConfigDir` under `work/`, `home` and `overseerRepos` under `cache/`, `overseerTokens` under `run/`. Every path sits in the boundary whose lifecycle class it has, so the survival contract of the root applies with no extra rule.
68
+ - `SERVICE_MOUNTS` lists, per service, the layout paths it mounts and the mode. The service gets `config/` and the five mutable boundaries read-write: it reads `config/` at boot and writes `config/config.yaml` when the operator saves from the Curia app, which mounts nothing of the root (#880). The tmux runtime gets `work/` and `home`. The attach surface and the app get nothing. The overseer gets its config directory and its mirrors read-write and its tokens read-only. `DOCKER_SOCKET_SERVICES` names the two containers that reach the Docker socket.
69
+
70
+ `src/secrets.mjs` is the catalogue of long-lived credentials, one owner-only file each under `secrets/`:
71
+
72
+ - `SECRET_FILES` names the four files, what each holds, and who writes it. `SECRET_NAMES` is the list of names.
73
+ - `readSecret(root, name)` returns the text or `null`, and throws a `SecretError` for a symbolic link, a foreign owner, or a mode that reaches past the owner. `writeSecret(root, name, text)` lands the file through `writeAtomically` at mode `0600` and refuses an empty value.
74
+ - `secretsStatus(root)` reports each file as `present`, `absent`, or `refused` with the reason, and never reads a value. `curia doctor` and the service's overview use it.
75
+ - `credentialsInEnvironment(env)` names the environment keys that carry a credential. The service refuses to boot under a root while any of them is set. `redact(text, values)` replaces given values in a text on its way to a log or a response.
76
+
77
+ A `SecretError` is not a `Refusal`, for the same reason a `ConfigError` is not: the command that meets one decides what it means.
78
+
79
+ ## The host preflight
80
+
81
+ `src/preflight.mjs` is the one module that decides whether the host can carry an operation. `curia install` (#873) and `curia update` (#883) call it after `openRoot` and before the lock, and `curia doctor` (#881) calls it for its host section. The operator's view is [Supported hosts and preflight checks](https://github.com/alp82/curia/blob/main/docs/operator/supported-hosts.md).
82
+
83
+ The interface:
84
+
85
+ - `preflight({ uid, root, stdout }, probes)` gathers the facts, evaluates them, prints the report on `stdout`, and returns `{ ok, checks, refusal, facts }`. A command throws `report.refusal`, a `Refusal`, when `ok` is false. Pass `facts` instead of `uid` and `root` to evaluate facts you already have.
86
+ - `gatherHostFacts({ uid, root }, probes)` reads the host into one plain object: `os`, `arch`, `cpus`, `memoryBytes`, `disk`, `ports`, `docker`, `compose`, `tailscale`, and `outbound`. Every read goes through `probes`, whose default is `hostProbes`: `exec`, `readFile`, `arch`, `cpus`, `memoryBytes`, `freeDiskBytes`, `socketAccessible`, `groups`, and `fetchOrigin`. A test hands in fakes, so the suite never depends on the machine it runs on. The test file's `ubuntu()` fixture is the shape of the facts.
87
+ - `evaluateHostFacts(facts)` is pure. It returns one result per entry of `CHECKS`, in order, each `{ name, status, observed, action }` with `status` one of `passed`, `warning`, or `refused`. A refused check carries the one corrective action. `refusal` is one `Refusal` that lists every refused condition, so the operator sees all of them at once.
88
+ - `renderPreflight(report)` prints one line per check and a summary line.
89
+ - The constants are the contract: `SUPPORTED_SYSTEMS`, `MINIMUM_PROFILE`, `RECOMMENDED_PROFILE`, `TESTED_VERSIONS`, `REQUIRED_PORTS`, `SANDBOX_PORTS`, `RELEASE_ORIGINS`, and `CLOCK_SKEW_LIMIT_SECONDS`. `daemon/test/preflightports.test.mjs` keeps the ports in step with `config/curia.yaml`.
90
+
91
+ Three probes create temporary resources, and each removes its own before it returns: the port probe listens on every port it tests and closes the listener; the Docker probe writes one temporary directory, opens one loopback HTTP listener, and runs one `--rm` container named `curia-preflight-<id>` that reads the directory through a bind mount and fetches the listener over the host network, then removes the container by force when the run failed or timed out, closes the listener, and deletes the directory. Nothing in the module installs or reconfigures the host.
92
+
93
+ ## The Compose bundle
94
+
95
+ `src/bundle.mjs` is the one place that says what a release's Compose bundle is, shared by the release workflow that renders it, the tests that inspect it, and the lifecycle commands that start it. The operator's view is [Release images and the Compose bundle](https://github.com/alp82/curia/blob/main/docs/operator/bundle.md).
96
+
97
+ - The constants are the contract: `COMPOSE_PROJECT` (`curia`), `INSTALLATION_LABEL` (`sh.curia.installation`), `IMAGE_REGISTRY` (`ghcr.io/alp82`), `RELEASE_IMAGES` (the four images by service), and `BUNDLE_VARIABLES` (the five run-time variables, in env-file order).
98
+ - `imageReference(service, digest)` is `ghcr.io/alp82/<image>@sha256:<digest>` and refuses anything but a full digest.
99
+ - `renderBundle(template, digests)` replaces each `${CURIA_<SERVICE>_IMAGE...}` in `deploy/bundle/compose.yaml` with the digest reference and leaves every other variable alone. It is deterministic.
100
+ - `inspectBundle(text)` returns the problems a rendered bundle has, one line each: a project name other than `curia`, an image that is not a digest reference under the registry, a variable outside the run-time set, a build stanza, an env file, or an operator path. Empty means fit to publish.
101
+ - `bundleEnvironment({ root, uid, gid, dockerGid, installationId })` renders the env file `curia install` writes under `run/` and passes with `--env-file`. Paths and numbers only.
102
+
103
+ The module reads text by line and never a YAML tree, because the package has no dependencies and every question is answerable that way. The release script that uses it is `deploy/bundle/render.mjs`, which writes the bundle directory, a deterministic `.tar.gz`, its `.sha256`, and the digest set for one version. `deploy/bundle/pins.mjs` reads the Node and Claude Code pins the images build with from `config/curia.yaml`.
104
+
105
+ ## The release manifest
106
+
107
+ `src/manifest.mjs` is the one place that says what a Curia release is and proves that a downloaded or installed one is whole. The release workflow writes the manifest through `deploy/bundle/render.mjs`, the publication step copies it into this package as `manifest.json`, and the lifecycle commands verify against it. The operator's view is [The release manifest and release verification](https://github.com/alp82/curia/blob/main/docs/operator/release-manifest.md).
108
+
109
+ The contract:
110
+
111
+ - `MANIFEST_FORMAT` (`1`), `PACKAGE_NAME` (`@curia-sh/cli`), `RELEASE_REPOSITORY` (`alp82/curia`), `RELEASE_WORKFLOW` (the signer workflow), and `MANIFEST_FILE` (`manifest.json`, so `versions/<version>/cli/manifest.json` once installed). `releaseAssets(version)` names the five files a release publishes: the manifest, the bundle archive, its `.sha256`, the digest set, and the package tarball.
112
+ - `createManifest({ version, commit, bundleSha256, digests })` builds one from the facts the workflow holds. `renderManifest(manifest)` is the one text form, keys in contract order, so two manifests that say the same thing are the same bytes. `parseManifest(text)` validates every field, refuses any key outside the contract, and throws a `ManifestError` that names the field and the rule.
113
+ - `evaluateRelease(facts)` is pure: facts in, `{ ok, checks, refusal, manifest }` out, one `{ name, status, observed, action }` per entry of `RELEASE_CHECKS` (`manifest`, `version`, `package integrity`, `bundle checksum`, `image digests`, `release manifest`) and, when the facts come from an installed version, `PROVENANCE_CHECKS` (`installed files`, `image provenance`, `package provenance`). `status` is `passed` or `failed`. `refusal` is one `Refusal` that lists every failed condition with its action. A null fact is a missing artifact and fails its check.
114
+ - `verifyStagedRelease({ version, tarball, archive, checksum }, { stdout }, probes)` is the door for `curia install` (#873), `curia update` (#883), and the bootstrap (#872): the downloaded bytes in, the report printed and returned. The caller throws `report.refusal` when `ok` is false and unpacks when it is true.
115
+ - `verifyInstalledRelease({ root, version, stdout }, probes)` is the door for `curia doctor` (#881). It reads the retained artifacts and the installed files under `versions/<version>/` through `versionPaths`, verifies them the same way, and adds the provenance checks. Read-only.
116
+ - `releaseProbes` are the three network boundaries, each injectable: `packument(name, version)` asks the npm registry for the integrity value and whether provenance is recorded, `releaseManifest(version)` downloads the manifest asset from the GitHub release, and `attestation({ reference, commit, version })` runs `gh attestation verify` for one image digest. `attestationCommand(reference, { commit })` is the exact command line, which the report prints as the corrective action.
117
+ - `renderVerification(report)` prints one line per check and a summary. No line carries a full digest, a full integrity value, or a manifest body.
118
+
119
+ The tests build a complete release the way the workflow does (an archive, a manifest that binds it, a package tarball that embeds the manifest, and a fake registry) and then change one thing at a time. `daemon/test/bundlerelease.test.mjs` proves that `render.mjs` writes a manifest that binds what it rendered.
120
+
121
+ ## The stable-release index
122
+
123
+ `src/stable.mjs` is the one place that says which published version an installation should run and how that answer is trusted. The index is `release/stable.json` on `main`, one signed file that names the stable release and the withdrawn versions and never describes a release. `deploy/release/index.mjs` writes it, `.github/workflows/stable-index.yml` commits it, and `curia update` (#883), the service's daily check, and the Curia app read it through this module. The operator's view is [Releases, the stable-release index, and version selection](https://github.com/alp82/curia/blob/main/docs/operator/releases.md).
124
+
125
+ The contract:
126
+
127
+ - `STABLE_INDEX_FORMAT` (`1`), `STABLE_INDEX_PATH` (`release/stable.json`), `STABLE_INDEX_URL` (the raw file on `main`), and `STABLE_INDEX_KEY_FILE` (`stable-index.pub`, beside `package.json`, so `versions/<version>/cli/stable-index.pub` once installed). `isPrerelease(version)` is the one definition of a prerelease: a release version with a hyphenated suffix. `releaseNotesUrl(version)` is the GitHub release page.
128
+ - `createStableIndex({ sequence, updated, stable, withdrawn })`, `renderStableIndex` (the one canonical text, which the signature covers), and `parseStableIndex`. Every field is required, nothing beyond the five is allowed, `stable` is never a prerelease and never withdrawn, and a `StableIndexError` names the field and the rule.
129
+ - `signStableIndex(index, privateKeyPem)` writes the envelope `{ index, signature: { algorithm, key, value } }`, deterministic for one index. `verifyStableIndex(text, { publicKey })` returns the index or throws: no pinned key, no signature, another key (the message names both fingerprints), or a changed byte all fail. `pinnedPublicKey()` reads the shipped key, `keyFingerprint` names one, and `generateStableIndexKeys()` makes a pair for `deploy/release/keygen.mjs` and the tests.
130
+ - `promote(index, version, { updated })` and `withdraw(index, version, { updated })` are pure: index in, index out, the sequence one higher when something changed and the same index when nothing did. Promotion refuses a prerelease and a withdrawn version. Withdrawing the stable release clears it.
131
+ - `selectRelease(index, { requested, prerelease })` is the one selection rule and returns `{ version, selection }` with `selection` one of `stable`, `exact`, or `prerelease`, or throws a `Refusal`: no stable release named, a withdrawn version, a prerelease without `--prerelease`, `--prerelease` without a version or with a plain version, or a string that is not a release version. `selectionFromArgs(args)` reads `[<version>] [--prerelease]` for `curia update`, and `renderSelection` prints the one line.
132
+ - `fetchStableIndex({ stdout, publicKey }, probes)` downloads through `stableProbes.stableIndex()`, verifies, prints one line, and returns `{ ok, index, error }`. A failed fetch carries the reason and no index, so a caller cannot select from a file that did not verify.
133
+
134
+ ## The bootstrap
135
+
136
+ `deploy/bootstrap/curia-install.sh` is the script the operator downloads and runs on a host with no Node.js (#872). It is not part of this package: it is what acquires the package. It is one Bash file, the same for every release, published as the release asset `curia-install.sh` with only its own version stamped in by `deploy/bootstrap/render.mjs`, so `releases/latest/download/curia-install.sh` is the current one. The operator's view is [The bootstrap](https://github.com/alp82/curia/blob/main/docs/operator/bootstrap.md).
137
+
138
+ What it needs from this package:
139
+
140
+ - `curia.node` in `package.json`: the exact Node.js version to stage under `versions/<version>/node`, read with `sed`, so it stays one `x.y.z` on its own line. `daemon/test/bootstrap.test.mjs` keeps it equal to `sandbox.node_version` in `config/curia.yaml`, the pin the release images run on.
141
+ - `stable-index.pub`, `src/stable.mjs` (`fetchStableIndex`, `selectRelease`, `renderSelection`), `src/manifest.mjs` (`verifyStagedRelease`), and `src/exit.mjs` (`Refusal`): the script writes a small `verify.mjs` into its stage that imports these from the staged package and runs them on the files it downloaded, with probes that read those files instead of the network.
142
+ - `bin/curia.mjs`: the hand-off. The script runs `curia install` or `curia purge` on the staged runtime with `CURIA_ROOT` set, and for an installation `CURIA_STAGE` set to a directory that holds `node/`, `cli/`, `cli.tgz`, `bundle.tar.gz`, and `bundle.tar.gz.sha256`, the names `versionPaths` uses. The stage is removed when the command returns, so `curia install` copies what it keeps before it returns.
143
+
144
+ `daemon/test/bootstrap.test.mjs` runs the script against a local artifact server built from this package's sources and proves the hand-off, every refusal, and the purge dispatch without a network.
145
+
146
+ ## Install and reinstall
147
+
148
+ `src/install.mjs` is `curia install` and `curia reinstall` (#873): one linear sequence of six named steps, `preflight`, `root`, `stage`, `activate`, `start`, and `health`, from the verified stage to a healthy Compose project and the app address. The operator's view is [Install and reinstall](https://github.com/alp82/curia/blob/main/docs/operator/install.md).
149
+
150
+ The interface:
151
+
152
+ - `runInstall(context, deps)` is the command. `context` is what `runCli` hands a command (`env`, `stdout`, `uid`, `gid`, `root`) plus `mode`, `install` or `reinstall`. `deps` are the boundaries a test replaces: `hostProbes` (the preflight's), `releaseProbes` (the manifest's), `docker` (the runner in `src/compose.mjs`), and `sleep` and `now` for the health wait. `installCommand(mode)` binds the mode for the command table.
153
+ - `INSTALL_STEPS` names the steps in order. Every step is idempotent by inspection: it reads what is there and does only what is missing, so a rerun lands at the step that failed with no persisted operation record. A failure is rethrown as `<step> failed: <cause>` plus the command that reruns it (the bootstrap before the launcher exists, the launcher after). A `Refusal` is rethrown as `<step>: <condition>` and stays a refusal.
154
+ - The version installed is `packageVersion`, this interface's own. With `CURIA_STAGE` set, `stage` verifies the stage through `verifyStagedRelease`, refuses a stage of another version, copies it into a sibling of `versions/<version>/`, and renames it into place, replacing the directory if it was there. Without `CURIA_STAGE`, `stage` verifies the retained artifacts already under `versions/<version>/` and moves on, or refuses when there is no complete version.
155
+ - `root` runs `ensureLayout`, then takes `withLifecycleLock` for the rest of the command, writes the record (a fresh ID for a fresh root, the existing record otherwise), and writes `initialOperatorConfig()` only when `config/config.yaml` is absent. `activate` writes the record with the version active, writes the launcher through `writeAtomically` at mode `0755`, and removes every other directory under `versions/`. `start` writes `run/compose.env`, creates the mount sources from `serviceLayout`, pulls, and brings the project up. `health` is `waitForHealth`.
156
+ - `APP_SERVE_PORT` is the Curia app's Serve port, which the completion line uses with the node's first `CertDomains` entry from the preflight facts. `daemon/test/preflightports.test.mjs` keeps it equal to `dashboard.serve_port` in `config/curia.yaml`.
157
+
158
+ `src/compose.mjs` is the one seam to Docker: `composeProject({ root, version })` names the env file and the bundle file and builds the `docker compose --env-file ... -f ...` argument list; `writeComposeEnvironment` writes the env file with `bundleEnvironment`; `startProject` runs `pull` then `up --detach --remove-orphans`; `serviceStates` reads `ps --all --format json` (one object per line, or one array on older Compose); and `waitForHealth` polls until every service in `SERVICES` is healthy, fails at once on one that exited or is unhealthy, and fails at `HEALTH_TIMEOUT_MS` on one still starting, naming the service and the `logs` command. `dockerRunner` is the real `docker`; every function takes a `docker` to replace it. `compose(project, verb, { docker })` is the one invocation every other verb goes through. `curia update` (#883 and #884) and `curia rollback` (#885) reuse this module for switching. `curia uninstall` (#886) and `curia purge` (#887) tear down by label through `src/resources.mjs` instead, so a teardown never depends on the Compose files being there.
159
+
160
+ The tests are `test/install.test.mjs` and `test/compose.test.mjs`, against `test/fixtures/install.mjs`: one packaged release built the way the workflow builds one, the stage as the bootstrap leaves it, fake host probes, and a fake `docker` that records the Compose verbs and answers `ps`. They cover the clean install, the preserved-root reinstall, a failed activation and its rerun, a failed health wait and its launcher rerun without a stage, a failed pull, and every refusal. `daemon/test/installbundle.test.mjs` installs the real `deploy/bundle/compose.yaml` and has Docker Compose read the env file and the installed bundle; it skips where Docker is absent.
161
+
162
+ ## Update
163
+
164
+ `src/update.mjs` is `curia update` (#883 and #884): one linear sequence of six named steps, `preflight`, `select`, `acquire`, `stage`, `validate`, and `switch`, from the signed stable-release index to a verified target staged beside the active version, the target's own reader validating the current operator configuration, and the switch of the live installation to it. The operator's view is [Update discovery, staging, and the switch](https://github.com/alp82/curia/blob/main/docs/operator/update.md).
165
+
166
+ The interface:
167
+
168
+ - `runUpdate(context, deps)` is the command. `context` is what `runCli` hands a command, with `args` holding `[<version>] [--prerelease]` (`selectionFromArgs` reads them; a `StableIndexError` there becomes a `UsageError`, exit `2`, before anything runs, and `commands.update.options` tells `runCli` to let the option through). `deps` are the boundaries a test replaces: `hostProbes`, `stableProbes` and `publicKey` (the index read and the pinned key), `acquireProbes` (the artifact downloads and the runtime's `--version`), `releaseProbes` (the manifest's), `validateTarget`, `docker`, `fetch` (the loopback reads of the service and the app), and `sleep` and `now` (the clock of the waits).
169
+ - `select` runs before the lock and refuses on any discovery failure with "The running installation is not affected". A selected version equal to the active one ends the command with `EXIT.ok` and nothing downloaded. A withdrawn active version is a printed warning, never a stop.
170
+ - `acquire` takes `withLifecycleLock` for the rest of the command and runs `acquireRelease` into `cache/update/<target>.<pid>/`, unless `versions/<target>/` is already complete, in which case `verifyRetained` proves it and nothing downloads. `stage` is `placeVersion` from `src/stage.mjs`, the same door `curia install` uses, lifted with a `version` parameter: the release verification, the copy into a sibling directory, and the rename. `validate` imports `src/config.mjs` from the staged package and calls its `readOperatorConfig` on `config/config.yaml`; a `ConfigError` fails the step with the contract's sentence. `switch` is `switchRelease` from `src/switch.mjs`, described next.
171
+ - Nothing rewrites the launcher: it reads the record, and the record is what the switch writes after acceptance. A failed switch leaves the record, the launcher, and `run/compose.env`'s content as they were, and keeps the staged target for the rerun.
172
+
173
+ ### The switch
174
+
175
+ `src/switch.mjs` is `switchRelease({ root, from, to, record, environment, stdout }, { docker, fetch, sleep, now })` (#884, implementing #854): the one door from one installed release to another for a live installation, which `curia update` calls with the target and `curia rollback` (#885) calls with the retained previous release. Both releases are complete under `versions/`, the target is validated, and the caller holds the lock. The sequence:
176
+
177
+ 1. `liveSessions` reads `GET /overview` on the service's loopback port and keeps every agent whose `tmux_live` is true.
178
+ 2. `writeComposeEnvironment` rewrites `run/compose.env`, `pull` fetches the target's `CORE_SERVICES` (`daemon`, `dashboard`, `overseer`) by digest, and `up --detach --no-deps daemon dashboard overseer` on `composeProject({ root, version: to })` recreates those three. `tmux`, `ttyd`, and every agent container are never named, `--no-deps` keeps Compose off the runtime, and nothing removes orphans.
179
+ 3. `waitForHealth` on the target project, then `GET /ping` on the service and on the app must both carry `version: to` (the daemon and the sidecar answer that route before any gate), then `GET /overview` is read until every live session is in `agents` with `tmux_live: true` or gone from tmux (ended on its own, reported), for `READOPTION_TIMEOUT_MS`. A session still in `untracked` at the deadline fails.
180
+ 4. `writeInstallationRecord` names `to` active, atomically, and every directory under `versions/` other than `to` and `from` is removed.
181
+
182
+ A failure after the recreate runs the same `up` on `composeProject({ root, version: from })` once, proves that release the way the target was proven (`waitForHealth`, both `/ping` routes carrying `version: from`, and the same live sessions adopted back within `READOPTION_TIMEOUT_MS`), and throws a `SwitchError` that carries the cause, the switch back's outcome (`Switched back to <from>, which is healthy and re-adopted N live sessions.` or `The switch back to <from> failed too: <cause>` with the reinstall named), and the fact that the record still names `from`. Nothing retries the target and nothing runs the switch back twice (#885). The tests are the `a selected update` and `the switch` groups of `test/update.test.mjs`, against `fakeLoopback` in `test/fixtures/install.mjs`, which answers `/ping` and `/overview` for whichever bundle the last `up` recreated the core services from, and whose `readopt(session, bundle)` lets one release adopt what another does not.
183
+
184
+ `src/acquire.mjs` does what `deploy/bootstrap/curia-install.sh` does in the shell, in the same order and with the same proofs: the registry record and the tarball (SHA-512 against `dist.integrity`), the package unpacked and its version and `curia.node` read, `SHASUMS256.txt` and the runtime from nodejs.org (SHA-256, then `bin/node --version` equal to the pin), the manifest, the bundle, and the `.sha256` from the release (the bundle against both). `releaseUrls(version)` and `runtimeUrls(nodeVersion)` name the origins. Every failure is a `Refusal` that names the artifact and one action, and nothing prints an integrity value.
185
+
186
+ The tests are `test/update.test.mjs` (no update, a selected update with live agents and its reruns, an exact version, a withdrawn active version, retention after a second update, the switch with the overseer beside the service, polled re-adoption, an ended session, an unadopted session, failed health, a wrong version, a failed switch back, atomic activation, an exact prerelease with and without `--prerelease`, withdrawal, offline discovery and a foreign key, failed validation, a target without a reader, a refused release door, usage, a root with no installation, a refused host), `test/acquire.test.mjs` (every download and proof boundary), and `test/archive.test.mjs` (extraction with modes and links, and the entries that would escape the destination). The fixtures in `test/fixtures/install.mjs` build a release with its pinned runtime archive and the URL map the acquisition reads, so no test touches the network.
187
+
188
+ ## Rollback
189
+
190
+ `src/rollback.mjs` is `curia rollback` (#885, implementing #854): four named steps, `preflight`, `select`, `validate`, and `switch`, printed as `[n/4] <step>` through `namedSteps` in `src/steps.mjs`. The operator's view is [Rollback](https://github.com/alp82/curia/blob/main/docs/operator/rollback.md).
191
+
192
+ The interface:
193
+
194
+ - `runRollback(context, deps)` is the command. `context` is what `runCli` hands a command; the command declares no options, so `runCli` refuses any as a usage error before it runs. `deps` are the boundaries a test replaces: `hostProbes`, `validateTarget` (defaults to `validateWithRelease`), `docker`, `fetch`, `sleep`, and `now`.
195
+ - `preflight` is `openRoot` (an installation is required) and the host preflight, which is also where the Docker group ID for `run/compose.env` comes from. `select` takes `withLifecycleLock` for the rest of the command and calls `rollbackRelease(root, active)`: the directories under `versions/` that are not a dot directory, not the active version, and complete by `isCompleteStage`. Exactly one is the rollback release; none or two is a `Refusal` that names them and the way out (finish the update, or remove the staged release). `validate` is `validateWithRelease` on the rollback release; an `IncompatibleRelease` becomes a `Refusal` (exit `3`) with the contract's sentence and the action that fits a rollback, and nothing has been touched. `switch` is `switchRelease` with `from: active` and `to: previous`, so the acceptance, the atomic record write, the switch back on failure, and the retention are the update's. After a rollback `versions/` holds the same two releases with the roles swapped.
196
+ - `validateWithRelease({ root, version, dir })` in `src/stage.mjs` imports `cli/src/config.mjs` from the installed version and calls its `readOperatorConfig` on `config/config.yaml`. It throws `IncompatibleRelease` with `reason` `configuration` (the release refuses the file; the message carries the `ConfigError`) or `reader` (the release carries no reader). `curia update` fails its `validate` step on one and `curia rollback` refuses on one; each adds its own action sentence.
197
+
198
+ The tests are `test/rollback.test.mjs`: the steps, `rollbackRelease` over real directories, a rollback with live sessions that proves the preserved directories byte for byte and prints no secret value, retention (an incomplete directory and a staging leftover removed, the next update moving forward, a second rollback going forward again), the refusals (an incompatible configuration through a fixture reader that refuses one key, a release without a reader, no rollback release, two candidates after a failed update, no installation, a refused host, an option), and the failures (failed health with the switch back and its re-adoption, an unadopted session, a switch back that fails too). `test/update.test.mjs` gained the switch back that fails its re-adoption proof. `daemon/test/journalforward.test.mjs` is the guard on the migration rule the rollback release depends on.
199
+
200
+ ## Uninstall
201
+
202
+ `src/uninstall.mjs` is `curia uninstall` (#886, implementing #855): four named steps, `preflight`, `docker`, `routes`, and `files`, printed as `[n/4] <step>` through `namedSteps`. The operator's view is [Uninstall and reinstall from the preserved root](https://github.com/alp82/curia/blob/main/docs/operator/uninstall.md).
203
+
204
+ The interface:
205
+
206
+ - `runUninstall(context, deps)` is the command. `context` is what `runCli` hands a command; the command declares no options. `deps` are the boundaries a test replaces: `docker` (the runner in `src/compose.mjs`) and `tailscale` (`tailscaleRunner` in `src/tailscale.mjs`, the same shape). No host preflight runs: uninstall needs no fact of the host beyond Docker answering.
207
+ - `preflight` is `openRoot`, which must find an installation; the record gives the installation ID and the active version. `docker` takes `withLifecycleLock` for the rest of the command and calls `removeInstallationResources(installationId, { docker, stdout })` from `src/resources.mjs`: `installationResources` lists containers (`docker ps --all --filter label=sh.curia.installation=<id>`), networks, and volumes by the label, and the removal stops the running containers, then removes every container, network, and volume it listed. Nothing goes through the Compose files, which a partial cleanup may already have removed, and nothing is found by name. Images are never listed. `routes` is `withdrawServeRoutes({ stateDir, stdout }, { tailscale })`: the routes `readTailscaleRecord(stateDir).serve` names, each turned off with `tailscale serve --https=<port> off` when `tailscale serve status --json` shows it standing. A missing `tailscale` command withdraws nothing and says so; a node that answers an error fails the step. The record is state and is not rewritten. `files` empties `versions/`, `cache/`, and `run/` (`REMOVED_BOUNDARIES`) except the lock, which the lock releases, and then removes the launcher when its `CURIA_ROOT` line names this root; a launcher of another root is kept.
208
+ - The completion prints the preserved root, `PRESERVED_BOUNDARIES`, the fact that the images stay, `reinstallCommand` and `purgeCommand` (the bootstrap, with `--root <root>` when the root is not the default one), and `externalChecklist(root, { uid })`: the GitHub App ID from `secrets/github-app.json`, the Discord server and channel from `state/discord.json`, and the machine name from `state/tailscale.json`. Identifiers only, never a value from a secret file, and a file that cannot be read contributes nothing.
209
+ - Every step reads before it removes, so a rerun over a partial cleanup does the rest and a rerun over a finished uninstall changes nothing. The rerun line names the launcher while it exists and the bootstrap's reinstall afterwards.
210
+
211
+ The tests are `test/uninstall.test.mjs`, against `fakeDockerHost` and `fakeTailscale` in `test/fixtures/install.mjs`: a fake Docker host holding labelled and unlabelled containers, networks, and volumes beside another installation's, which answers the label-filtered listings and the removals and can refuse a verb a number of times, and a fake `tailscale` holding the node's Serve config. They cover the full uninstall over an installed root (what is stopped, removed, withdrawn, emptied, kept, and printed, with no secret value in the output), the nondefault root in the printed commands, a repeated uninstall, a partial cleanup and its rerun, a host without `tailscale` and a node that does not answer, a launcher of another root, the external checklist, the refusals, and the reinstall from the preserved root through `runInstall` in both modes with the same installation ID, followed by a second uninstall.
212
+
213
+ ## Purge
214
+
215
+ `src/purge.mjs` is `curia purge` (#887, implementing #855): six named steps, `preflight`, `confirm`, `docker`, `routes`, `images`, and `root`, printed as `[n/6] <step>` through `namedSteps`. The operator's view is [Purge and external cleanup](https://github.com/alp82/curia/blob/main/docs/operator/purge.md).
216
+
217
+ The interface:
218
+
219
+ - `runPurge(context, deps)` is the command. `context` is what `runCli` hands a command, with `args` holding `[--confirm <root>]` and nothing else (`confirmationFromArgs` reads it; anything else is a `UsageError`, exit `2`, before anything runs, and `commands.purge.options` tells `runCli` to let the option through). `deps` are the boundaries a test replaces: `docker` (the runner in `src/compose.mjs`), `tailscale` (`tailscaleRunner`), `prompt` (the one question on the terminal, `question => Promise<answer>`, by default `readline` on the process's stdin and stdout), and `isTerminal` (whether stdin is a TTY).
220
+ - `preflight` is `openRoot`, which must find an installation, then the reads that the root's removal would lose: the installation ID and active version from the record, `externalChecklist` (from `src/uninstall.mjs`), and the recorded Serve routes. It prints the warning with the exact root. `confirm` is the confirmation contract: `--confirm <root>` must equal the root exactly; without the flag, a terminal is asked once and the answer must equal the root; without a terminal and without the flag, a `Refusal` names the flag. Every miss is a `Refusal` with `Nothing changed.`
221
+ - `docker` creates `run/` when it is missing (the bootstrap's `--purge` may run over a root an uninstall or a partial purge left without one), takes `withLifecycleLock` for the rest of the command, and is `removeInstallationResources`, uninstall's teardown by label. `routes` is `withdrawServeRoutes`, whose answer now carries `unreachable` so the report can tell a route that was not standing from one the host could not ask about. `images` is `removeReleaseImages({ docker, stdout })` from `src/resources.mjs`: `releaseImages` lists every image under the four exact repositories `RELEASE_IMAGES` names (`docker image ls --filter reference=<registry>/<name>`, never a name prefix), and each is removed only when `docker ps --all --filter ancestor=<id>` lists no container and `docker image rm <id>` (never `--force`) succeeds; a refused or in-use image is kept with the reason and is not a failure. `root` removes the launcher when its `CURIA_ROOT` line names this root, then the root through `removeRoot`: everything beside `state/`, then everything in `state/` beside the record, then the rest, so a failure part way leaves a root `openRoot` still recognizes.
222
+ - The completion prints what was removed and kept, then the external report: `externalChecklist`'s lines (GitHub App ID, Discord server and channel, Tailscale machine name), one line per recorded Serve route (`withdrawn`, `was not standing`, or still recorded with the command to run on the node), and the model-provider line, under one sentence that says deleting the local secret files revoked nothing. No secret value is printed.
223
+
224
+ The tests are `test/purge.test.mjs`, against `fakeDockerHost` (which now holds images, answers `image ls` by repository, `ps --filter ancestor=`, and `image rm` with Docker's in-use refusal) and `fakeTailscale`: the full purge (the warning, the one question, the order docker, routes, images, launcher, root, scope isolation from another installation's resources and images and from an image whose name starts with `curia-`, the in-use daemon image kept, no secret in the output), the noninteractive `--confirm` in both spellings, the root last, every confirmation miss with nothing changed, the partial cleanup and its rerun, the bootstrap's case of a root without `run/` and without a launcher, a root that already lost its resources, a host without `tailscale`, an image Docker refuses, the refusals, and the command table.
225
+
226
+ ## The doctor
227
+
228
+ `src/doctor.mjs` is `curia doctor` (#881): one read-only pass over the direct checks an installed Curia has, printed in nine sections and summarized in one line. The operator's view is [Diagnostics with `curia doctor`](https://github.com/alp82/curia/blob/main/docs/operator/doctor.md).
229
+
230
+ The interface:
231
+
232
+ - `runDoctor(context, deps)` is the command. `context` is what `runCli` hands a command. `deps` are the boundaries a test replaces: `hostProbes` (the preflight's), `releaseProbes` (the manifest's), `docker` (the runner in `src/compose.mjs`), and `fetch` for the two loopback reads of the service and the one of the app. It returns `EXIT.ok` when nothing failed and `EXIT.failed` when a check failed or a host condition is refused. The one refusal it raises is `openRoot`'s.
233
+ - `DOCTOR_SECTIONS` names the sections in order: `host`, `installation`, `configuration`, `release`, `secrets`, `containers`, `service`, `integrations`, `app`. `SERVICE_PORT` and `APP_PORT` are the two loopback ports it reads; `daemon/test/preflightports.test.mjs` keeps the app port equal to `config/curia.yaml`.
234
+ - Every check is the shape the preflight and the release verification already produce, `{ name, status, observed, action }`, with `status` one of `passed`, `warning`, `failed`, or `refused`. The doctor composes the existing modules and adds no framework: `preflight` for the host, `openRoot` and `versionPaths` for the installation, `readOperatorConfig` for the configuration (the `ConfigError` message verbatim), `verifyInstalledRelease` for the release and its provenance, `secretsStatus` and `credentialsInEnvironment` for the secrets, `serviceStates` for the containers, and the service's own `GET /setup` and `GET /identity` for the integrations, so a card reads the same here and on the Setup screen.
235
+ - `redactDiagnostic(text)` is what every printed line passes through, and `scrubFacts(value)` drops string values under credential-named keys from every service answer before the doctor reads it. Neither knows a secret value: they work by shape (a Discord token, a provider key, a GitHub token, a private key block, a JWT, a bearer, a 64-hex agent or conversation token, a `code=` or `token=` value). A `sha256:` digest and the 32-hex installation ID stay.
236
+
237
+ The doctor writes nothing, takes no lock, and asks Docker for `ps` only. The tests in `test/doctor.test.mjs` install a root through the install fixtures, hand in a fake service, and cover the healthy, degraded, invalid-configuration, lost-integration, unhealthy-container, missing-installation, provenance, and secret-bearing cases with no network and no Docker.
238
+
239
+ ## The launcher
240
+
241
+ The bootstrap writes `~/.local/bin/curia` once per installation. The launcher is a POSIX shell script with the installation root written into it. On each run it reads `state/installation.json`, takes `activeVersion`, and runs:
242
+
243
+ ```text
244
+ <root>/versions/<activeVersion>/node/bin/node <root>/versions/<activeVersion>/cli/bin/curia.mjs "$@"
245
+ ```
246
+
247
+ with `CURIA_ROOT` exported. An update changes the active version and never rewrites the launcher. When the record is missing or either file is absent, the launcher exits `3` and names the missing file.
248
+
249
+ `versions/<version>/cli/` is the unpacked package: the `package/` directory of the npm tarball. `versions/<version>/node/` is the pinned Node runtime. Later versions may add sibling files under the version directory; the launcher reads only these two paths.
250
+
251
+ ## Tests
252
+
253
+ ```sh
254
+ npm test
255
+ ```
256
+
257
+ The suite has no dependencies, reads no file outside its own temporary directories, and reaches no network. The stable-index tests sign with key pairs they generate. `test/package.test.mjs` runs `npm pack` and installs the tarball into an empty prefix, so it needs `npm` and `tar` on the path; the manifest and archive tests build their fixtures with the same `tar`. The launcher tests run the rendered script with `/bin/sh` against a fake version directory whose `node` is a shell script that reports how it was called.
258
+
259
+ There is no build step. `npm pack` produces the release artifact.
package/bin/curia.mjs ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+
3
+ // The lifecycle interface's process entry. The installed launcher runs this
4
+ // file on the pinned runtime under versions/<active>/node with CURIA_ROOT set.
5
+ // `npm install -g @curia-sh/cli` links it as `curia` too, which is how the
6
+ // package is invoked before an installation exists.
7
+
8
+ import { runCli } from '../src/cli.mjs'
9
+
10
+ process.exitCode = await runCli({
11
+ argv: process.argv.slice(2),
12
+ env: process.env,
13
+ stdout: process.stdout,
14
+ stderr: process.stderr,
15
+ })
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@curia-sh/cli",
3
+ "version": "0.4.1",
4
+ "description": "Curia's lifecycle interface: install, update, roll back, diagnose, and remove one Curia installation.",
5
+ "type": "module",
6
+ "bin": {
7
+ "curia": "bin/curia.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "manifest.json",
13
+ "stable-index.pub",
14
+ "README.md"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/alp82/curia.git",
19
+ "directory": "cli"
20
+ },
21
+ "homepage": "https://curia.sh",
22
+ "//engines": "The host Node only matters while the package is invoked without an installed version, such as in this test suite. An installed Curia runs the lifecycle interface on its own pinned runtime under versions/<version>/node.",
23
+ "engines": {
24
+ "node": ">=24.0.0"
25
+ },
26
+ "//curia": "The Node.js runtime the bootstrap stages under versions/<version>/node: the exact version config/curia.yaml pins for the release images, read by deploy/bootstrap/curia-install.sh with sed, so it stays one x.y.z on its own line. daemon/test/bootstrap.test.mjs keeps it equal to the pin.",
27
+ "curia": {
28
+ "node": "24.19.0"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test 'test/*.test.mjs'"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }
@@ -0,0 +1,188 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { execFile } from 'node:child_process'
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ import { Refusal } from './exit.mjs'
7
+ import { extractArchive, ArchiveError } from './archive.mjs'
8
+ import { NPM_REGISTRY, PACKAGE_NAME, RELEASE_DOWNLOADS, parseManifest, releaseAssets } from './manifest.mjs'
9
+
10
+ // Acquiring one release into a stage (#883, the bootstrap's download and
11
+ // proof steps in the lifecycle interface's own code).
12
+ //
13
+ // `deploy/bootstrap/curia-install.sh` downloads a release with curl and
14
+ // proves it in the shell before any of the package runs. `curia update`
15
+ // already runs as the installed interface, so it does the same work here,
16
+ // from the same origins, in the same order, with the same proofs:
17
+ //
18
+ // 1. the registry record and the tarball of `@curia-sh/cli@<version>`,
19
+ // the tarball proven by SHA-512 against the record's `dist.integrity`,
20
+ // unpacked as `cli/`, its `package.json` naming the version;
21
+ // 2. the pinned Node.js runtime (`curia.node` in that `package.json`) from
22
+ // nodejs.org with its `SHASUMS256.txt`, proven by SHA-256, unpacked as
23
+ // `node/`, and `node/bin/node --version` reporting the pin;
24
+ // 3. the release manifest, the Compose bundle, and its `.sha256` from the
25
+ // GitHub release, the bundle proven against the `.sha256` file and the
26
+ // manifest, the manifest naming the version.
27
+ //
28
+ // What the stage holds afterwards is what the bootstrap hands `curia
29
+ // install`: `node/`, `cli/`, `cli.tgz`, `bundle.tar.gz`, and
30
+ // `bundle.tar.gz.sha256`, the seven files `STAGE_FILES` names. The release
31
+ // door (`verifyStagedRelease`) is still asked after this, by `placeVersion`;
32
+ // these proofs are what makes it safe to unpack and run the staged code at
33
+ // all. Every download and the runtime's version check go through
34
+ // `acquireProbes`, so a test hands in files instead of a network.
35
+
36
+ export const NODE_DIST = 'https://nodejs.org/dist'
37
+
38
+ // The one command that installs Curia on a host, or reinstalls it over a
39
+ // preserved root: the bootstrap, downloaded to a file and run. `--purge`
40
+ // makes it the purge command; `--root <dir>` names a nondefault root.
41
+ export const BOOTSTRAP_COMMAND = 'curl -fsSLO https://github.com/alp82/curia/releases/latest/download/curia-install.sh && bash curia-install.sh'
42
+
43
+ // One download at a time, with the time a 60 MB runtime may take on a slow
44
+ // link. `null` bytes with a status or an error, never a partial file.
45
+ export const acquireProbes = Object.freeze({
46
+ download: async (url) => {
47
+ try {
48
+ const response = await fetch(url, { signal: AbortSignal.timeout(600_000) })
49
+ if (!response.ok) return { ok: false, status: response.status }
50
+ return { ok: true, bytes: Buffer.from(await response.arrayBuffer()) }
51
+ } catch (e) {
52
+ return { ok: false, error: e.cause?.message ?? e.message }
53
+ }
54
+ },
55
+ nodeVersion: (binary) => new Promise((resolve) => {
56
+ execFile(binary, ['--version'], { timeout: 30_000 }, (error, stdout) => resolve(error ? null : stdout.trim()))
57
+ }),
58
+ })
59
+
60
+ // The URLs one version's artifacts are read from.
61
+ export function releaseUrls(version) {
62
+ const bare = PACKAGE_NAME.split('/')[1]
63
+ const assets = releaseAssets(version)
64
+ return {
65
+ packument: `${NPM_REGISTRY}/${PACKAGE_NAME}/${version}`,
66
+ tarball: `${NPM_REGISTRY}/${PACKAGE_NAME}/-/${bare}-${version}.tgz`,
67
+ manifest: `${RELEASE_DOWNLOADS}/v${version}/${assets.manifest}`,
68
+ bundle: `${RELEASE_DOWNLOADS}/v${version}/${assets.bundle}`,
69
+ checksum: `${RELEASE_DOWNLOADS}/v${version}/${assets.checksum}`,
70
+ }
71
+ }
72
+
73
+ export function runtimeUrls(nodeVersion) {
74
+ const name = `node-v${nodeVersion}-linux-x64`
75
+ return {
76
+ name: `${name}.tar.gz`,
77
+ checksums: `${NODE_DIST}/v${nodeVersion}/SHASUMS256.txt`,
78
+ archive: `${NODE_DIST}/v${nodeVersion}/${name}.tar.gz`,
79
+ }
80
+ }
81
+
82
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
83
+ const sha512 = (bytes) => createHash('sha512').update(bytes).digest('hex')
84
+
85
+ // Downloads and proves one version into `stage`, an existing empty
86
+ // directory. Prints one line per artifact. Returns `{ version, node }`.
87
+ // Throws a `Refusal` that names the artifact and one action when a download
88
+ // or a proof fails; the caller removes the stage.
89
+ export async function acquireRelease({ version, stage, stdout }, probes = acquireProbes) {
90
+ const say = (text) => stdout.write(`${text}\n`)
91
+ const get = async (url, what) => {
92
+ const got = await probes.download(url)
93
+ if (got?.ok) return got.bytes
94
+ if (got?.status === 404) throw new Refusal(`${what} is not at ${url}. Check that the version is published, and run the command again.`)
95
+ throw new Refusal(`could not download ${what} from ${url} (${got?.error ?? `HTTP ${got?.status}`}). Check outbound access and run the command again.`)
96
+ }
97
+ const urls = releaseUrls(version)
98
+
99
+ // 1. The package.
100
+ const packument = json(await get(urls.packument, `the registry record of ${PACKAGE_NAME}@${version}`), 'the registry record')
101
+ const integrity = packument?.dist?.integrity
102
+ if (typeof integrity !== 'string' || !integrity.startsWith('sha512-')) {
103
+ throw new Refusal(`the registry records no sha512 integrity for ${PACKAGE_NAME}@${version}, so the package cannot be proven. Report it at https://github.com/alp82/curia/issues.`)
104
+ }
105
+ const tarball = await get(urls.tarball, `the package ${PACKAGE_NAME}@${version}`)
106
+ if (sha512(tarball) !== Buffer.from(integrity.slice('sha512-'.length), 'base64').toString('hex')) {
107
+ throw new Refusal(`package integrity: the downloaded ${PACKAGE_NAME}@${version} does not have the SHA-512 the registry records. The tarball was substituted or damaged in transit: do not use it, and run the command again.`)
108
+ }
109
+ writeFileSync(join(stage, 'cli.tgz'), tarball)
110
+ mkdirSync(join(stage, 'cli'))
111
+ unpack(tarball, join(stage, 'cli'), `the package ${PACKAGE_NAME}@${version}`)
112
+ const packageFile = join(stage, 'cli', 'package.json')
113
+ if (!existsSync(packageFile)) throw new Refusal(`the package ${PACKAGE_NAME}@${version} carries no package.json.`)
114
+ const packageJson = json(readFileSync(packageFile), 'the package.json of the package')
115
+ if (packageJson.version !== version) {
116
+ throw new Refusal(`version mismatch: the package names version ${packageJson.version}, and ${version} was selected.`)
117
+ }
118
+ const nodeVersion = packageJson.curia?.node
119
+ if (typeof nodeVersion !== 'string' || !/^\d+\.\d+\.\d+$/.test(nodeVersion)) {
120
+ throw new Refusal(`the package ${PACKAGE_NAME}@${version} pins no Node.js runtime (curia.node in its package.json), so its runtime cannot be staged.`)
121
+ }
122
+ say(`${PACKAGE_NAME}@${version} downloaded and proven against the registry's integrity record`)
123
+
124
+ // 2. The runtime.
125
+ const runtime = runtimeUrls(nodeVersion)
126
+ const checksums = (await get(runtime.checksums, `the checksums of Node.js v${nodeVersion}`)).toString('utf8')
127
+ const expected = checksums.split('\n').map((l) => l.trim().split(/\s+/)).find((f) => f[1] === runtime.name)?.[0]
128
+ if (!expected) throw new Refusal(`SHASUMS256.txt does not list ${runtime.name}, so Node.js v${nodeVersion} cannot be proven.`)
129
+ const nodeArchive = await get(runtime.archive, `Node.js v${nodeVersion}`)
130
+ if (sha256(nodeArchive) !== expected) {
131
+ throw new Refusal(`Node.js v${nodeVersion} checksum: the downloaded ${runtime.name} does not have the SHA-256 that SHASUMS256.txt lists. The runtime was substituted or damaged in transit: do not use it, and run the command again.`)
132
+ }
133
+ mkdirSync(join(stage, 'node'))
134
+ unpack(nodeArchive, join(stage, 'node'), `Node.js v${nodeVersion}`)
135
+ const binary = join(stage, 'node', 'bin', 'node')
136
+ if (!existsSync(binary)) throw new Refusal(`Node.js v${nodeVersion} has no bin/node.`)
137
+ const reported = await probes.nodeVersion(binary)
138
+ if (reported === null) {
139
+ throw new Refusal(`the staged Node.js at ${binary} does not run. If the installation root's filesystem is mounted noexec, move the root to one that allows execution and run the command again.`)
140
+ }
141
+ if (reported !== `v${nodeVersion}`) {
142
+ throw new Refusal(`the staged Node.js reports ${reported}, not v${nodeVersion}, which the package pins.`)
143
+ }
144
+ say(`Node.js ${reported} downloaded and proven against SHASUMS256.txt`)
145
+
146
+ // 3. The bundle and the manifest.
147
+ const manifestText = (await get(urls.manifest, releaseAssets(version).manifest)).toString('utf8')
148
+ const bundle = await get(urls.bundle, releaseAssets(version).bundle)
149
+ const checksum = (await get(urls.checksum, releaseAssets(version).checksum)).toString('utf8')
150
+ const actual = sha256(bundle)
151
+ if (checksum.trim().split(/\s+/)[0] !== actual) {
152
+ throw new Refusal(`bundle checksum: ${releaseAssets(version).bundle} does not have the SHA-256 its .sha256 file names. The bundle was substituted or damaged in transit: do not use it, and run the command again.`)
153
+ }
154
+ let manifest
155
+ try {
156
+ manifest = parseManifest(manifestText)
157
+ } catch (e) {
158
+ throw new Refusal(`the release manifest of ${version} cannot be read: ${e.message}`)
159
+ }
160
+ if (manifest.bundle.sha256 !== actual) {
161
+ throw new Refusal(`bundle checksum: ${releaseAssets(version).bundle} does not have the SHA-256 the release manifest binds. The bundle was substituted or damaged in transit: do not use it, and run the command again.`)
162
+ }
163
+ if (manifest.version !== version) {
164
+ throw new Refusal(`version mismatch: the release manifest is for version ${manifest.version}, and ${version} was selected.`)
165
+ }
166
+ writeFileSync(join(stage, 'bundle.tar.gz'), bundle)
167
+ writeFileSync(join(stage, 'bundle.tar.gz.sha256'), checksum)
168
+ say(`the Compose bundle of ${version} downloaded and proven against its checksum and the release manifest`)
169
+
170
+ return { version, node: nodeVersion }
171
+ }
172
+
173
+ function json(bytes, what) {
174
+ try {
175
+ return JSON.parse(bytes.toString('utf8'))
176
+ } catch (e) {
177
+ throw new Refusal(`${what} is not JSON: ${e.message}`)
178
+ }
179
+ }
180
+
181
+ function unpack(bytes, dir, what) {
182
+ try {
183
+ extractArchive(bytes, dir, { strip: 1 })
184
+ } catch (e) {
185
+ if (e instanceof ArchiveError) throw new Refusal(`${what} is not a gzipped tar archive: ${e.message}`)
186
+ throw e
187
+ }
188
+ }