@runeditrun/rer 0.1.1 → 0.2.0
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 +111 -1
- package/cli/README.md +28 -3
- package/cli/contract.mjs +4 -4
- package/cli/dev-identity.mjs +2 -1
- package/cli/door-mode.mjs +134 -0
- package/cli/door.mjs +263 -0
- package/cli/hosted-output.mjs +15 -5
- package/cli/hydration/browser.mjs +91 -0
- package/cli/hydration/cases.mjs +106 -0
- package/cli/hydration/check.mjs +105 -0
- package/cli/hydration/probe.mjs +164 -0
- package/cli/hydration/scripts.mjs +39 -0
- package/cli/hydration/verdict.mjs +85 -0
- package/cli/inspect.mjs +56 -9
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +2 -0
- package/cli/managed-server.mjs +52 -18
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +14 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +10 -7
- package/cli/settings.mjs +14 -0
- package/cli/site-check.mjs +96 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-render.mjs +77 -0
- package/cli/site.mjs +109 -0
- package/global/CHANGELOG.md +63 -0
- package/global/README.md +111 -1
- package/global/checkout.mjs +24 -10
- package/global/cli/install.mjs +6 -2
- package/global/delegate.mjs +9 -7
- package/global/door/command.mjs +254 -0
- package/global/door/control.mjs +65 -0
- package/global/door/cookies.mjs +41 -0
- package/global/door/pages.mjs +49 -0
- package/global/door/policy.mjs +49 -0
- package/global/door/routes.mjs +45 -0
- package/global/door/server.mjs +255 -0
- package/global/door/sites.mjs +192 -0
- package/global/launch/dev.mjs +21 -7
- package/global/launch/door.mjs +36 -0
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +38 -5
- package/global/launch/render.mjs +6 -1
- package/global/launch/steps.mjs +12 -7
- package/global/launch/worker.mjs +3 -3
- package/global/list.mjs +6 -3
- package/global/main.mjs +37 -3
- package/global/version.mjs +8 -8
- package/modules/dependencies/protocol.mjs +2 -1
- package/modules/deploy-kit/README.md +34 -1
- package/modules/deploy-kit/artifact.mjs +10 -0
- package/modules/deploy-kit/owner.mjs +5 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +6 -3
- package/modules/deploy-kit/site.mjs +135 -0
- package/modules/erasure/CONTRACT.md +6 -3
- package/modules/erasure/README.md +8 -1
- package/modules/erasure/audit.mjs +2 -2
- package/modules/erasure/cascade.mjs +52 -11
- package/modules/erasure/order.mjs +29 -22
- package/modules/erasure/plan.mjs +4 -2
- package/modules/erasure/queue.mjs +41 -0
- package/modules/erasure/scope.mjs +3 -3
- package/modules/erasure/verification.mjs +4 -2
- package/modules/mail/README.md +1 -1
- package/modules/mail/index.mjs +1 -1
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/development/index.d.mts +6 -0
- package/modules/mail/src/development/index.mjs +6 -3
- package/modules/mail/src/development/viewer-html.mjs +3 -3
- package/modules/mail/src/development/viewer.mjs +8 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/owner-claim/verify.mjs +2 -1
- package/modules/portability/README.md +8 -0
- package/modules/portability/hash.mjs +6 -2
- package/modules/portability/import.mjs +1 -1
- package/modules/portability/legacy-files.mjs +4 -4
- package/modules/portability/legacy-safety.mjs +51 -10
- package/modules/portability/migration-history.mjs +15 -0
- package/modules/release/README.md +5 -3
- package/modules/release/produce.mjs +5 -2
- package/modules/release/source.mjs +15 -4
- package/onboarding/adapter-auth.mjs +4 -3
- package/onboarding/adapter-settings.mjs +21 -5
- package/onboarding/adapter.mjs +2 -1
- package/onboarding/app-fetch.mjs +79 -0
- package/onboarding/archive-output.d.mts +4 -2
- package/onboarding/archive-output.mjs +22 -10
- package/onboarding/dependency-approval.mjs +4 -3
- package/onboarding/local/environment.mjs +7 -2
- package/onboarding/operator-install.mjs +8 -2
- package/onboarding/operator-prerequisites.mjs +5 -2
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +75 -1
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/public_hygiene.py +35 -4
- package/scripts/publish-check/publication-config.json +14 -1
package/README.md
CHANGED
|
@@ -97,6 +97,10 @@ RER_OPEN http://127.0.0.1:8787/__dev/key#...
|
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
or `RER_SIGN_IN <outbox>` for a seed that signs in through its development outbox.
|
|
100
|
+
A seed that declares a run site prints one more line after those,
|
|
101
|
+
`RER_SITE http://127.0.0.1:8788`: a local preview of its static run site (its
|
|
102
|
+
marketing homepage, `/run` and docs) on the first free port above the app's. It
|
|
103
|
+
stops with the app.
|
|
100
104
|
It never prompts and never re-runs setup on a checkout that is already set up. A
|
|
101
105
|
setup a launch started that was stopped or failed is run again by the next
|
|
102
106
|
`rer dev`, keeping the owner and origin it saved, unless a setup has finished
|
|
@@ -130,6 +134,27 @@ checkout never records a limit you did not set.
|
|
|
130
134
|
A start is never refused for machine load alone unless `RER_SERVERS_LOAD_THRESHOLD`
|
|
131
135
|
or `RER_JOBS_LOAD_THRESHOLD` is set; memory and disk guards stay.
|
|
132
136
|
|
|
137
|
+
## The front door
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
rer door enable # opt this computer in: port 8700, 10 servers, 12 hours idle
|
|
141
|
+
rer door add ~/review/* # register checkouts; prints each http://NAME.localhost:8700
|
|
142
|
+
rer door status --links # every checkout, its state, and sign-in links
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
One process on one port serves every registered checkout at
|
|
146
|
+
`http://NAME.localhost:PORT` (NAME is its folder name, unique and stable);
|
|
147
|
+
`http://localhost:PORT` lists them. A stopped checkout starts when you open it
|
|
148
|
+
in a browser and stops when idle; subresources, API calls and WebSockets never
|
|
149
|
+
start one. `rer door enable` stores the policy (`--port`, `--server-limit`,
|
|
150
|
+
`--idle-timeout`) and the `RER_*` variables in effect, so whoever later starts
|
|
151
|
+
the door cannot change what its checkouts run with. While it is enabled, `rer
|
|
152
|
+
dev` sets fresh checkouts up behind it; `rer dev --door` moves an existing one
|
|
153
|
+
(keeping its data), `rer dev --direct` moves it back, and `--port N` always
|
|
154
|
+
serves directly. `rer door start`, `stop`, `restart`, `disable` and `remove
|
|
155
|
+
NAME` do what they say; after a reboot or logout the door needs `rer door
|
|
156
|
+
start`. `rer door --help` has the details.
|
|
157
|
+
|
|
133
158
|
## Find what to launch
|
|
134
159
|
|
|
135
160
|
```sh
|
|
@@ -144,6 +169,91 @@ Independent projects (an `OPERATIONS.json` version 2 without a seed contract) ru
|
|
|
144
169
|
their declared commands with `rer <verb> [-- args]`; zero-setup launching does not
|
|
145
170
|
apply to them. `OPERATIONS.json` version 1 is not supported.
|
|
146
171
|
|
|
172
|
+
## Package mode: a checkout that pins `@runeditrun/rer`
|
|
173
|
+
|
|
174
|
+
A checkout runs one of two copies of rer. In **vendored mode** it carries
|
|
175
|
+
`scripts/rer.mjs`, `scripts/base-cli` and `scripts/base-onboarding`, and `rer`
|
|
176
|
+
runs that copy. In **package mode** it carries none of them and pins this package
|
|
177
|
+
as an exact devDependency instead (`"@runeditrun/rer": "0.2.0"`, locked in
|
|
178
|
+
`pnpm-lock.yaml`): its `OPERATIONS.json` argv and package scripts are `rer VERB`,
|
|
179
|
+
which inside `pnpm run` is the pinned copy in `node_modules/.bin`, so
|
|
180
|
+
`pnpm install --frozen-lockfile && pnpm run build` needs no global `rer`. A global
|
|
181
|
+
`rer` hands every verb to the pinned package, launching included. Until
|
|
182
|
+
`node_modules` holds the pinned version, `rer install` and `rer dev` install it
|
|
183
|
+
first (the frozen `pnpm install`) and every other verb says to run `rer install`.
|
|
184
|
+
|
|
185
|
+
Heavy package scripts wait for this computer's job queue with
|
|
186
|
+
`rer queue NAME -- COMMAND [ARGS]` (in vendored mode,
|
|
187
|
+
`node scripts/base-onboarding/queued-entry.mjs NAME -- COMMAND`).
|
|
188
|
+
|
|
189
|
+
`rer check --profile first-party` accepts either mode. In package mode it asks for
|
|
190
|
+
the exact devDependency (`SC-13.package.pin`), its lockfile entry
|
|
191
|
+
(`SC-13.package.lockfile`), no vendored copy (`SC-13.package.vendored.*`) and `rer
|
|
192
|
+
VERB` targets and aliases (`SC-02.target.*`, `SC-02.alias.*`); in vendored mode for
|
|
193
|
+
the copy and its recorded inventory, as before.
|
|
194
|
+
|
|
195
|
+
Convert a vendored seed from a Base checkout with
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
node scripts/export-cli.mjs --to-package SEED_ROOT [--version X]
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
(the default version is the latest published one, read with the seed's own npm
|
|
202
|
+
configuration). It refuses, listing them, when the vendored copy differs from its
|
|
203
|
+
recorded inventory: carry those edits with `pnpm patch @runeditrun/rer` once
|
|
204
|
+
converted, or upstream them to Base. Otherwise it pins and locks the package
|
|
205
|
+
(`pnpm install --lockfile-only`), rewrites imports into the copy to the exports
|
|
206
|
+
below (anything else to `@runeditrun/rer/internal/PATH`, which promises nothing
|
|
207
|
+
beyond the pinned version), rewrites the package scripts and `OPERATIONS.json`
|
|
208
|
+
argv to `rer VERB` and the delivery operations (`rer-project.json` and the
|
|
209
|
+
`scripts/delivery/base/packaging.json` it is generated from), the shell scripts
|
|
210
|
+
they and CI workflows run, and workflow lines to `pnpm exec rer VERB` (a frozen
|
|
211
|
+
`pnpm install` first where nothing has installed yet), drops the copy's own records from `base-copies.json` (the
|
|
212
|
+
app-runtime copies it records stay as they are), removes the copy and lists every
|
|
213
|
+
line that still names it. A later refresh is a version bump:
|
|
214
|
+
`pnpm add -D -E @runeditrun/rer@X`.
|
|
215
|
+
|
|
216
|
+
### Exports
|
|
217
|
+
|
|
218
|
+
| Import | What it gives | Vendored path it replaces |
|
|
219
|
+
| --- | --- | --- |
|
|
220
|
+
| `@runeditrun/rer/adapter` | `commandAdapter` | `scripts/base-cli/cli/adapter.mjs` |
|
|
221
|
+
| `@runeditrun/rer/deploy` | `ownerDeploy` | `scripts/base-cli/cli/deploy.mjs` |
|
|
222
|
+
| `@runeditrun/rer/portability` | `localPortability` | `scripts/base-cli/cli/portability.mjs` |
|
|
223
|
+
| `@runeditrun/rer/errors` | the CLI's error helpers | `scripts/base-cli/cli/errors.mjs` |
|
|
224
|
+
| `@runeditrun/rer/jsonc` | JSONC reading | `scripts/base-cli/cli/jsonc.mjs` |
|
|
225
|
+
| `@runeditrun/rer/parse`, `/scheduled` | CLI argument parsing, scheduled-job helpers | `scripts/base-cli/cli/parse.mjs`, `scheduled.mjs` |
|
|
226
|
+
| `@runeditrun/rer/dev-identity` | dev identity helpers | `scripts/base-cli/cli/dev-identity.mjs` |
|
|
227
|
+
| `@runeditrun/rer/operator-installer` | `operatorInstaller`, `developmentTarget`, `previewTarget` | `scripts/base-onboarding/operator-installer.mjs` |
|
|
228
|
+
| `@runeditrun/rer/local-installer` | `localInstaller` | `scripts/base-onboarding/adapter.mjs` |
|
|
229
|
+
| `@runeditrun/rer/operator-install` | `installOperator` | `scripts/base-onboarding/operator-install.mjs` |
|
|
230
|
+
| `@runeditrun/rer/operator-prerequisites` | `operatorPrerequisites` | `scripts/base-onboarding/operator-prerequisites.mjs` |
|
|
231
|
+
| `@runeditrun/rer/local/state` | `selectLocalState` | `scripts/base-onboarding/local/state.mjs` |
|
|
232
|
+
| `@runeditrun/rer/local/build` | local build step | `scripts/base-onboarding/local/build.mjs` |
|
|
233
|
+
| `@runeditrun/rer/state`, `/commands` | onboarding state and command runners | `scripts/base-onboarding/state.mjs`, `commands.mjs` |
|
|
234
|
+
| `@runeditrun/rer/local/environment`, `/local/files`, `/local/operation`, `/local/recovery` | local setup helpers | `scripts/base-onboarding/local/*.mjs` |
|
|
235
|
+
| `@runeditrun/rer/private-files` | private file helpers | `scripts/base-onboarding/private-files.mjs` |
|
|
236
|
+
| `@runeditrun/rer/adapter-settings` | adapter settings helpers | `scripts/base-onboarding/adapter-settings.mjs` |
|
|
237
|
+
| `@runeditrun/rer/adapter-auth`, `/adapter-hosting` | adapter auth and hosting helpers | `scripts/base-onboarding/adapter-auth.mjs`, `adapter-hosting.mjs` |
|
|
238
|
+
| `@runeditrun/rer/process-diagnostics` | process diagnostics | `scripts/base-onboarding/process-diagnostics.mjs` |
|
|
239
|
+
| `@runeditrun/rer/process-cleanup`, `/process-scope` | process cleanup and scope | `scripts/base-onboarding/process-cleanup.mjs`, `process-scope.mjs` |
|
|
240
|
+
| `@runeditrun/rer/failures`, `/redaction` | failure and redaction helpers | `scripts/base-onboarding/failures.mjs`, `redaction.mjs` |
|
|
241
|
+
| `@runeditrun/rer/setup-manifest`, `/setup-schema` | setup manifest loading and its JSON schema | `scripts/base-onboarding/setup-manifest.mjs`, `schema/SETUP.schema.json` |
|
|
242
|
+
| `@runeditrun/rer/production-runtime`, `/production-modules` | production runtime helpers | `scripts/base-onboarding/production-runtime.mjs`, `production-modules.mjs` |
|
|
243
|
+
| `@runeditrun/rer/queued-entry` | the job-queue entry | `scripts/base-onboarding/queued-entry.mjs` |
|
|
244
|
+
| `@runeditrun/rer/archive-output` | `archiveOutputGuard` (with types) | `scripts/base-onboarding/archive-output.mjs` |
|
|
245
|
+
| `@runeditrun/rer/launcher` | the browser installer launcher | `scripts/base-onboarding/launcher.mjs` |
|
|
246
|
+
| `@runeditrun/rer/installer-server` | the browser installer's server (installer journeys) | `scripts/base-onboarding/server.mjs` |
|
|
247
|
+
| `@runeditrun/rer/server-resources` | managed server resource readings (runtime journeys) | `scripts/base-cli/cli/server-resources.mjs` |
|
|
248
|
+
| `@runeditrun/rer/operations` | OPERATIONS describe and check | `scripts/base-cli/operations/operations.mjs` |
|
|
249
|
+
| `@runeditrun/rer/modules/portability`, `/catalogue`, `/cli`, `/cli-options`, `/files`, `/format`, `/http`, `/import`, `/journal`, `/legacy-safety`, `/migration-history`, `/storage` | portability module | `scripts/base-cli/modules/portability/*.mjs` |
|
|
250
|
+
| `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source` | release export and publication scan | `scripts/base-cli/modules/release/*.mjs` |
|
|
251
|
+
| `@runeditrun/rer/modules/deploy-kit/owner`, `/owner-cli`, `/prepare`, `/provider` | deploy kit | `scripts/base-cli/modules/deploy-kit/*.mjs` |
|
|
252
|
+
| `@runeditrun/rer/modules/operator-auth`, `/dev`, `/dev/badge`, `/dev-admission`, `/accounts/native`, `/identity/email-link` | operator auth | `scripts/base-cli/modules/operator-auth/index.mjs`, `dev/index.mjs`, `dev/badge.mjs`, `dev-admission.mjs`, `accounts/native.mjs`, `identity/email-link/index.mjs` |
|
|
253
|
+
| `@runeditrun/rer/modules/erasure/fence` | erasure fence statements | `scripts/base-cli/modules/erasure/fence.mjs` |
|
|
254
|
+
| `@runeditrun/rer/modules/owner-claim`, `/modules/dependencies` | owner claim, dependency links | `scripts/base-cli/modules/owner-claim/index.mjs`, `dependencies/index.mjs` |
|
|
255
|
+
| `@runeditrun/rer/internal/PATH` | any other package file, for tests | `scripts/base-cli/PATH`, and `scripts/base-onboarding/PATH` with PATH under `onboarding/` |
|
|
256
|
+
|
|
147
257
|
## Verbs
|
|
148
258
|
|
|
149
259
|
In a checkout, every verb runs that checkout's own pinned `rer`; `rer VERB --help`
|
|
@@ -153,6 +263,6 @@ there lists its flags:
|
|
|
153
263
|
`export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release`.
|
|
154
264
|
|
|
155
265
|
Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
|
|
156
|
-
`rer --version` and `rer --help`.
|
|
266
|
+
`rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
|
|
157
267
|
|
|
158
268
|
Licence: MIT.
|
package/cli/README.md
CHANGED
|
@@ -20,6 +20,18 @@ boundary and imports the existing `scripts/base-onboarding/`; no second onboardi
|
|
|
20
20
|
tree or neighboring checkout is used. The foundation composition maps copy these
|
|
21
21
|
same sources from an explicit committed Base revision and record their provenance.
|
|
22
22
|
|
|
23
|
+
`node scripts/export-cli.mjs --to-package SEED_ROOT [--version X]` instead moves a
|
|
24
|
+
vendored seed to package mode: it pins `@runeditrun/rer` exactly (default: the
|
|
25
|
+
latest published version), locks it, rewrites imports into the copy to the
|
|
26
|
+
package's exports and the command targets to `rer VERB`, rewrites delivery
|
|
27
|
+
operations, the shell scripts they and CI workflows run, and workflow lines to
|
|
28
|
+
`pnpm exec rer VERB` (`scripts/export-cli/calls.mjs`), and removes the copy; it
|
|
29
|
+
refuses, listing them, when the copy holds local edits. `cli/package-mode.mjs`
|
|
30
|
+
names what each mode's command targets are and reads the lockfile entry that
|
|
31
|
+
`check` requires (SC-13); `cli/package-calls.mjs` finds any of those commands
|
|
32
|
+
that still calls the removed copy, which `check` refuses (SC-13.package.calls). `global/README.md` lists the exports and the vendored
|
|
33
|
+
path each replaces.
|
|
34
|
+
|
|
23
35
|
`node scripts/export-reference.mjs NEW_PATH` creates a standalone copy of the
|
|
24
36
|
reference with runtime imports relocated into its own source tree and an explicit
|
|
25
37
|
transformation inventory. It preserves source/tool pins and the real app; it does
|
|
@@ -168,7 +180,7 @@ expiry clean up owned descendants before releasing state and capacity.
|
|
|
168
180
|
|
|
169
181
|
The private registry defaults to the OS user's `~/.rer/servers` independently
|
|
170
182
|
of checkout and Codex home; `RER_SERVERS_DIR` selects an isolated directory.
|
|
171
|
-
The combined dev/preview cap is 10 (`RER_SERVER_LIMIT`, positive integer; a limit recorded by a server that did not set it never lowers the cap
|
|
183
|
+
The combined dev/preview cap is 10 (`RER_SERVER_LIMIT`, positive integer). A start that sets `RER_SERVER_LIMIT` is capped by that value, higher or lower, whatever running servers recorded; a start that sets nothing is capped by the lowest limit a running server's person set explicitly, and a limit recorded by a server that did not set it never lowers the cap.
|
|
172
184
|
Starting reservations count. At the cap a start is refused with a plain message
|
|
173
185
|
naming each running server, its checkout and its stop command; rer never stops
|
|
174
186
|
another checkout's server to make room, in any mode. A run that declares itself
|
|
@@ -207,6 +219,15 @@ Heavy verbs alone accept `--no-queue`, only with `CI=true`. Synthesized install
|
|
|
207
219
|
child CI does not change the original decision. Inspection/help/outbox URL,
|
|
208
220
|
stop and reset inventory/removal do not start a heavy job or application.
|
|
209
221
|
|
|
222
|
+
After the seed's verification target, `verify` runs two checks on the production
|
|
223
|
+
build served as `rer deploy` hosts it: `hosted-output.mjs` (built files and the deploy
|
|
224
|
+
identity) and `hydration/` (page sections hydrate as separate units; split into
|
|
225
|
+
`check.mjs` for the entry and standalone command, `browser.mjs` for Chromium, serving
|
|
226
|
+
and one load, `probe.mjs` for the in-page probe, `cases.mjs` for pages, cases and the
|
|
227
|
+
Cloudflare rewrite, `scripts.mjs` for which scripts are the application's, and
|
|
228
|
+
`verdict.mjs` for failures). SEED-COMMAND-CONTRACT.md,
|
|
229
|
+
"Hydration units", describes the check.
|
|
230
|
+
|
|
210
231
|
Run the CLI contract and current first-party seed checks as part of Base's
|
|
211
232
|
documented verification workflow. Structural `check` results and local runtime
|
|
212
233
|
checks do not establish hosted acceptance or provider delivery.
|
|
@@ -233,11 +254,15 @@ is the tarball's path. `global/package.json` is the one file that names the next
|
|
|
233
254
|
release version; a build from a commit not tagged `rer-v<version>` is stamped
|
|
234
255
|
`<version>-dev.g<sha8>`. The installed `rer` finds the nearest `OPERATIONS.json`
|
|
235
256
|
(or exactly `--cwd`, with no upward search) and runs the checkout's pinned `node_modules/@runeditrun/rer`, else
|
|
236
|
-
its vendored `scripts/rer.mjs` in process, else its own bundled `bin/rer.mjs`.
|
|
257
|
+
its vendored `scripts/rer.mjs` in process, else its own bundled `bin/rer.mjs`. A
|
|
258
|
+
pinned package that `node_modules` does not hold yet is installed by `rer install`
|
|
259
|
+
and `rer dev` first (the frozen `pnpm install`).
|
|
237
260
|
Global verbs: `rer cli status|install|uninstall`, `rer list [DIR]`, `rer update`,
|
|
238
261
|
and the zero-setup `rer dev`, which composes the pinned dispatcher's public verbs so it
|
|
239
262
|
works on every pin. `global/README.md` is the package's own README; the package
|
|
240
263
|
root also carries the script installer, `install.sh`, kept in `global/`. The build is
|
|
241
264
|
reproducible and `.github/workflows/rer-release.yml` publishes it; see
|
|
242
265
|
`scripts/rer-package/README.md`. `verify:rer` packs, installs and launches it end
|
|
243
|
-
to end
|
|
266
|
+
to end on the package-mode reference (served from a local registry, see
|
|
267
|
+
`scripts/rer-package/registry.mjs`), and drives `install.sh` and `rer update`
|
|
268
|
+
against that registry.
|
package/cli/contract.mjs
CHANGED
|
@@ -5,13 +5,13 @@ export const serverDefaults = Object.freeze({ serverLimit: 10, idleSeconds: 4320
|
|
|
5
5
|
export const commonFlags = ['--help', '--cwd', '--non-interactive', '--json'];
|
|
6
6
|
const definitions = {
|
|
7
7
|
install: [[], 'Frozen dependency installation; no setup or global tool changes.'],
|
|
8
|
-
setup: [['--owner-email', '--owner-name', '--port', '--host', '--answers'], 'Build, migrate, prepare private settings and first owner; stop the temporary app.'],
|
|
9
|
-
dev: [['--port', '--host', '--idle-timeout', '--stop', '--as', '--login', '--models', '--models-service'], 'Foreground development with assets, HMR and the declared scheduled triggers run locally on their schedule. With devIdentity it opens signed in as the owner (--as PERSONA picks another start, --login uses the outbox sign-in instead); otherwise ordinary owner sign-in through the outbox.'],
|
|
8
|
+
setup: [['--owner-email', '--owner-name', '--port', '--host', '--answers', '--door'], 'Build, migrate, prepare private settings and first owner; stop the temporary app. --door sets a fresh checkout up at its front-door origin http://NAME.localhost:PORT (after rer door enable).'],
|
|
9
|
+
dev: [['--port', '--host', '--idle-timeout', '--stop', '--as', '--login', '--models', '--models-service', '--door', '--direct'], 'Foreground development with assets, HMR and the declared scheduled triggers run locally on their schedule. With devIdentity it opens signed in as the owner (--as PERSONA picks another start, --login uses the outbox sign-in instead); otherwise ordinary owner sign-in through the outbox. --door moves this checkout to its front-door origin http://NAME.localhost:PORT, keeping its data; --direct moves it back; --port and --host serve directly.'],
|
|
10
10
|
preview: [['--port', '--host', '--idle-timeout', '--stop'], 'Foreground fixed production output; reuse intact build, preserve owner/state, idle timeout and explicit stop.'],
|
|
11
|
-
build: [[], 'Production build with private local settings excluded and restored.'],
|
|
11
|
+
build: [['--site'], 'Production build with private local settings excluded and restored. --site instead writes the static run site (homepage, /run, docs) to its declared folder and prints that folder.'],
|
|
12
12
|
verify: [[], 'Run the complete internal verification target.'],
|
|
13
13
|
reset: [['--yes'], 'Confirmed removal of the declared local inventory only.'],
|
|
14
|
-
deploy: [['--environment', '--dry-run', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port'], 'Owner-account deployment and health; dry run performs local validation/build only.'],
|
|
14
|
+
deploy: [['--environment', '--dry-run', '--site', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port'], 'Owner-account deployment and health; dry run performs local validation/build only. --site deploys the run site instead: an assets-only Worker on its custom domain with www redirected; with --dry-run it builds the site and prints the plan with no provider request.'],
|
|
15
15
|
export: [['--target', '--output', '--origin', '--session-file'], 'Complete supported installation archive with integrity and exclusions.'],
|
|
16
16
|
import: [['--target', '--input', '--journal', '--origin', '--session-file', '--yes', '--legacy-v1', '--acknowledge-unverified-metadata', '--output'], 'Confirmed atomic restore into an empty destination; --journal LATER_ARCHIVE also fences rows that installation erased since. Backup-only local v1 recovery requires --legacy-v1 --acknowledge-unverified-metadata and a new --output v2 archive; --journal requires normal v2 import.'],
|
|
17
17
|
outbox: [['--port', '--host', '--owner-email', '--open'], 'Credential-free local outbox URL; optional ordinary sign-in request and explicit browser opening.'],
|
package/cli/dev-identity.mjs
CHANGED
|
@@ -58,7 +58,8 @@ export function refuseManagedInstallation(...sources) {
|
|
|
58
58
|
|
|
59
59
|
// The persona list the running app declares, read with the key header.
|
|
60
60
|
export async function resolvePersona(url, key, as, signal) {
|
|
61
|
-
const
|
|
61
|
+
const { appFetch } = await shared('app-fetch.mjs');
|
|
62
|
+
const response = await appFetch(url + '/__dev/personas', { headers: { [DEV_KEY_HEADER]: key }, signal: AbortSignal.any([signal, AbortSignal.timeout(10000)].filter(Boolean)) });
|
|
62
63
|
if (!response.ok) { await response.body?.cancel(); throw refuse(`The application did not serve dev identity (HTTP ${response.status}). Check its local-only entry.`); }
|
|
63
64
|
const personas = await response.json(), persona = personas.find(entry => entry.as === as);
|
|
64
65
|
if (!persona) throw invalid(`Unknown dev persona ${as}. This app declares: ${personas.map(entry => entry.as).join(', ')}.`);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Door mode for one checkout: its saved origin is http://NAME.localhost:PORT,
|
|
2
|
+
// it serves on 127.0.0.1:<OS-assigned port> behind the machine's front door,
|
|
3
|
+
// and its probes go straight to that port (never through the door). A saved
|
|
4
|
+
// 127.0.0.1 origin keeps direct mode; --port and --host always serve directly.
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { claimSiteName, doorHost, doorOrigin, doorProtocol, doorSecretHeader, doorSite, readDoorState, runningDoor, verifyDoor } from './door.mjs';
|
|
9
|
+
import { refuse } from './errors.mjs';
|
|
10
|
+
import { firstFreePort } from './port.mjs';
|
|
11
|
+
import { localSettingKeys } from './settings.mjs';
|
|
12
|
+
import { shared } from './shared.mjs';
|
|
13
|
+
|
|
14
|
+
const moveRecord = '.rer/local/door-move.json';
|
|
15
|
+
|
|
16
|
+
// The enabled door this checkout can use, or a refusal saying why not.
|
|
17
|
+
export async function enabledDoor(env = process.env) {
|
|
18
|
+
const state = await readDoorState(env);
|
|
19
|
+
if (!state?.door?.enabled) throw refuse(`The front door is not enabled on this computer (${state?.directory ?? 'RER_DOOR_DIR'}). Enable it with rer door enable, or serve directly with rer dev --direct.`);
|
|
20
|
+
const running = await runningDoor(env);
|
|
21
|
+
const protocol = running?.protocol ?? state.door.protocol;
|
|
22
|
+
if (protocol !== doorProtocol) throw refuse(`The front door speaks protocol ${protocol}, which this checkout's rer (protocol ${doorProtocol}) does not know. Run rer door restart with the installed rer.`);
|
|
23
|
+
return state.door;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The listening side of a door origin: 127.0.0.1 on a port the OS assigns,
|
|
27
|
+
// guarded by a secret chosen for this start.
|
|
28
|
+
export function doorSelection(name, port, fields) {
|
|
29
|
+
return { ...fields, url: doorOrigin(name, port), host: '127.0.0.1', port: 0, explicitPort: false, door: { name, port, host: doorHost(name, port), header: doorSecretHeader, secret: randomBytes(32).toString('base64url') } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Selection for a checkout whose saved origin is a door origin, or that asks
|
|
33
|
+
// for the door (--door on a fresh setup). Returns undefined for direct mode.
|
|
34
|
+
export async function selectDoor(project, options, previous, fields) {
|
|
35
|
+
if (options.port !== undefined || options.host !== undefined || options.direct) return undefined;
|
|
36
|
+
const saved = previous && doorSite(previous.origin);
|
|
37
|
+
if (saved) {
|
|
38
|
+
// A server or setup on a saved origin re-claims the name it names
|
|
39
|
+
// (registering a checkout that rer door remove unregistered) and never
|
|
40
|
+
// serves under a name another checkout holds.
|
|
41
|
+
await enabledDoor();
|
|
42
|
+
if (['dev', 'preview', 'setup'].includes(options.verb)) await claimSavedName(project, previous.origin);
|
|
43
|
+
return doorSelection(saved.name, saved.port, fields);
|
|
44
|
+
}
|
|
45
|
+
if (!options.door) return undefined;
|
|
46
|
+
if (previous) throw refuse('This checkout is already set up at ' + previous.origin + '. Move it to the front door with rer dev --door, which keeps its data.');
|
|
47
|
+
const door = await enabledDoor(), name = await claimSiteName(process.env, project.root, { mode: 'door' });
|
|
48
|
+
return doorSelection(name, door.port, fields);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A server on a door origin holds the name that origin names: it registers it
|
|
52
|
+
// again after rer door remove, and refuses when another checkout holds it.
|
|
53
|
+
export async function claimSavedName(project, origin) {
|
|
54
|
+
const site = doorSite(origin);
|
|
55
|
+
if (site && (await readDoorState())?.door) await claimSiteName(process.env, project.root, { preferred: site.name, mode: 'door', exact: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Record the server's door route on its registry entry and send rer's own
|
|
59
|
+
// requests for the origin straight to its proxy.
|
|
60
|
+
export async function announceDoorRoute(selected, proxy, registry) {
|
|
61
|
+
const { routeOrigin } = await shared('app-fetch.mjs');
|
|
62
|
+
routeOrigin(selected.url, { port: proxy.port, headers: { [doorSecretHeader]: selected.door.secret } });
|
|
63
|
+
await registry?.update({ port: proxy.port, door: { v: doorProtocol, name: selected.door.name, port: selected.door.port, secret: selected.door.secret } });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A running server's route, from its registry entry: only while its server
|
|
67
|
+
// process is alive and itself listens on the recorded proxy port, so nothing
|
|
68
|
+
// rer sends (a dev key among it) reaches a process that took that port later.
|
|
69
|
+
export async function routeRunning(record) {
|
|
70
|
+
if (!record?.door) return;
|
|
71
|
+
const { alive } = await import('../modules/job-queue/identity.mjs');
|
|
72
|
+
const { listeners } = await shared('process-diagnostics.mjs');
|
|
73
|
+
const live = await alive(record.owner).catch(() => false);
|
|
74
|
+
const holds = live && (await listeners([record.owner.pid])).some(row => row.pid === record.owner.pid && row.names.some(name => /^(?:127\.0\.0\.1|\[?::ffff:127\.0\.0\.1\]?):(\d+)$/.exec(name)?.[1] === String(record.port)));
|
|
75
|
+
if (!holds) throw refuse(`This checkout's server (PID ${record.owner?.pid}) is not serving on its recorded port 127.0.0.1:${record.port}, so nothing was sent to it. Stop it with rer dev --stop, then start it again with rer dev.`);
|
|
76
|
+
const { routeOrigin } = await shared('app-fetch.mjs');
|
|
77
|
+
routeOrigin(record.url, { port: record.port, headers: { [doorSecretHeader]: record.door.secret } });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Before a dev key is printed for a door origin.
|
|
81
|
+
export async function verifyDoorFor(url) {
|
|
82
|
+
const site = doorSite(url);
|
|
83
|
+
if (site) await verifyDoor(process.env, site.port);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The saved origin, read without changing anything (rer describe).
|
|
87
|
+
export async function savedOrigin(project) {
|
|
88
|
+
const secrets = project.adapter?.hooks?.secrets;
|
|
89
|
+
if (!secrets) return null;
|
|
90
|
+
let text;
|
|
91
|
+
try { text = await readFile(join(project.root, secrets.file ?? '.dev.vars'), 'utf8'); } catch { return null; }
|
|
92
|
+
try {
|
|
93
|
+
const values = (secrets.format ?? 'dotenv') === 'json' ? JSON.parse(text) : (await shared('local/environment.mjs')).localVariables(text);
|
|
94
|
+
return typeof values?.[localSettingKeys(project).origin] === 'string' ? values[localSettingKeys(project).origin] : null;
|
|
95
|
+
} catch { return null; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// rer dev --door / --direct: rewrite only the saved origin, keeping the local
|
|
99
|
+
// database and state. --door records the direct origin it replaces; --direct
|
|
100
|
+
// restores it (or the first free port from 8787, or --port).
|
|
101
|
+
export async function moveCheckout(project, options, out, registry) {
|
|
102
|
+
const secrets = project.adapter?.hooks?.secrets;
|
|
103
|
+
if (!secrets) throw refuse('This checkout declares no saved origin, so it cannot move to or from the front door.');
|
|
104
|
+
const { readSettings, replaceSavedOrigin } = await shared('adapter-settings.mjs');
|
|
105
|
+
const key = localSettingKeys(project).origin, current = (await readSettings(project.root, secrets)).values[key];
|
|
106
|
+
if (!current) throw refuse('This checkout is not set up yet. Run rer dev without --door or --direct; with the door enabled it is set up there.');
|
|
107
|
+
const atDoor = Boolean(doorSite(current));
|
|
108
|
+
if (options.door === atDoor) return;
|
|
109
|
+
const running = await registry.existing(options.verb, { ...options, supplied: new Set() });
|
|
110
|
+
if (running) throw refuse(`Stop this checkout's running server first (rer dev --stop), then move it.`);
|
|
111
|
+
let target, record;
|
|
112
|
+
const { privateDirectory, readPrivateFile, writePrivateFile } = await shared('private-files.mjs');
|
|
113
|
+
if (options.door) {
|
|
114
|
+
const door = await enabledDoor(), name = await claimSiteName(process.env, project.root, { mode: 'door' });
|
|
115
|
+
target = doorOrigin(name, door.port);
|
|
116
|
+
record = { previousOrigin: current, movedAt: new Date().toISOString() };
|
|
117
|
+
} else {
|
|
118
|
+
let previous;
|
|
119
|
+
try { previous = JSON.parse(await readPrivateFile(join(project.root, moveRecord))).previousOrigin; } catch {}
|
|
120
|
+
target = options.port !== undefined ? `http://127.0.0.1:${options.port}` : previous && !doorSite(previous) ? previous : `http://127.0.0.1:${await firstFreePort('127.0.0.1')}`;
|
|
121
|
+
const site = doorSite(current);
|
|
122
|
+
if (site && await readDoorState()) await claimSiteName(process.env, project.root, { preferred: site.name, mode: 'direct', origin: target });
|
|
123
|
+
}
|
|
124
|
+
const { localOperation } = await shared('local/operation.mjs');
|
|
125
|
+
let operation;
|
|
126
|
+
try { operation = await localOperation(project.root); } catch (error) { throw refuse(error.message); }
|
|
127
|
+
try {
|
|
128
|
+
await replaceSavedOrigin(project.root, secrets, target);
|
|
129
|
+
if (record) { await privateDirectory(project.root, ['.rer', 'local']); await writePrivateFile(join(project.root, moveRecord), JSON.stringify(record) + '\n'); }
|
|
130
|
+
else await rm(join(project.root, moveRecord), { force: true });
|
|
131
|
+
} finally { await operation.release(); }
|
|
132
|
+
const line = options.door ? `Moved this checkout from ${current} to ${target}, through the front door. Its data is kept; rer dev --direct moves it back.` : `Moved this checkout from ${current} back to ${target}. Its data is kept.`;
|
|
133
|
+
if (options.json) out.event('moved', { from: current, to: target }); else out.human(line);
|
|
134
|
+
}
|
package/cli/door.mjs
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// The front door's protocol (version 1). The door itself lives in the global
|
|
2
|
+
// `rer` package, which bundles this file; each checkout's pinned copy reads the
|
|
3
|
+
// same state, so the two meet only here.
|
|
4
|
+
//
|
|
5
|
+
// RER_DOOR_DIR (default ~/.rer/door): a 0700 directory owned by this account,
|
|
6
|
+
// never a link, holding 0600 files:
|
|
7
|
+
// state.json door policy, recorded environment and the registered sites
|
|
8
|
+
// (and the names removed ones keep reserved)
|
|
9
|
+
// (written under the job queue's bakery lock)
|
|
10
|
+
// token the door's secret; it proves it holds it before rer prints a key
|
|
11
|
+
// process.json the running door: protocol, PID and start, port, addresses,
|
|
12
|
+
// version and version directory
|
|
13
|
+
// A checkout keeps its door identity in its own .rer/local/door-site.json
|
|
14
|
+
// ({id}, 0600); a registered site records the same id, so a moved or renamed
|
|
15
|
+
// checkout keeps its name (and with it its origin and sign-in).
|
|
16
|
+
// A checkout in door mode serves on 127.0.0.1:<proxyPort> and records
|
|
17
|
+
// `door: {v, name, port, secret}` on its server-registry entry (also 0600). Its
|
|
18
|
+
// proxy answers only its own Host carrying the per-start secret header, which
|
|
19
|
+
// only the door (and rer's own probes) add.
|
|
20
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { chmod, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
23
|
+
import { userInfo } from 'node:os';
|
|
24
|
+
import { basename, join, resolve } from 'node:path';
|
|
25
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
26
|
+
import { identity } from '../modules/job-queue/identity.mjs';
|
|
27
|
+
import { processInfo } from '../modules/job-queue/processes.mjs';
|
|
28
|
+
import { transaction } from '../modules/job-queue/storage.mjs';
|
|
29
|
+
import { refuse } from './errors.mjs';
|
|
30
|
+
import { shared } from './shared.mjs';
|
|
31
|
+
|
|
32
|
+
export const doorProtocol = 1;
|
|
33
|
+
export const defaultDoorPort = 8700;
|
|
34
|
+
export const doorSecretHeader = 'x-rer-door-secret';
|
|
35
|
+
export const doorChallengeHeader = 'x-rer-door-challenge';
|
|
36
|
+
export const doorIdentityPath = '/__rer/door/identity';
|
|
37
|
+
export const doorDefaults = Object.freeze({ serverLimit: 10, idleSeconds: 43200 });
|
|
38
|
+
|
|
39
|
+
export const doorDirectory = (env = process.env) => resolve(env.RER_DOOR_DIR ?? join(userInfo().homedir, '.rer/door'));
|
|
40
|
+
export const doorOrigin = (name, port) => `http://${name}.localhost:${port}`;
|
|
41
|
+
export const doorHost = (name, port) => `${name}.localhost:${port}`;
|
|
42
|
+
|
|
43
|
+
// A checkout's name: the DNS-safe slug of its folder name.
|
|
44
|
+
export function siteSlug(path) {
|
|
45
|
+
return basename(path).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '').slice(0, 50).replace(/-+$/, '') || 'site';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// { name, port } when `origin` is exactly a saved front-door origin.
|
|
49
|
+
export function doorSite(origin) {
|
|
50
|
+
let url; try { url = new URL(origin); } catch { return undefined; }
|
|
51
|
+
const match = /^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.localhost$/.exec(url.hostname);
|
|
52
|
+
if (!match || url.protocol !== 'http:' || !url.port || url.origin !== origin) return undefined;
|
|
53
|
+
return { name: match[1], port: Number(url.port) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ownedDirectory(directory, create) {
|
|
57
|
+
if (create) await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
58
|
+
let info;
|
|
59
|
+
try { info = await lstat(directory); } catch (error) { if (error.code === 'ENOENT' && !create) return false; throw error; }
|
|
60
|
+
if (info.isSymbolicLink() || !info.isDirectory() || info.uid !== process.getuid()) throw refuse(`The front door directory ${directory} is foreign or linked. Preserve it for inspection and choose an owned RER_DOOR_DIR.`);
|
|
61
|
+
if (create) await chmod(directory, 0o700);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A 0600 file in the door directory, or undefined when it does not exist.
|
|
66
|
+
export async function readPrivate(path) {
|
|
67
|
+
let info;
|
|
68
|
+
try { info = await lstat(path); } catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
69
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.uid !== process.getuid()) throw refuse(`${path} is foreign or linked. Preserve it for inspection.`);
|
|
70
|
+
if ((info.mode & 0o077) !== 0) await chmod(path, 0o600);
|
|
71
|
+
return readFile(path, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function writePrivate(path, text) {
|
|
75
|
+
const existing = await readPrivate(path);
|
|
76
|
+
if (existing !== undefined) await chmod(path, 0o600);
|
|
77
|
+
await writeFile(path, text, { mode: 0o600 });
|
|
78
|
+
await chmod(path, 0o600);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function prepareDoorDirectory(env = process.env) {
|
|
82
|
+
const directory = doorDirectory(env);
|
|
83
|
+
await ownedDirectory(directory, true);
|
|
84
|
+
for (const name of ['locks', 'logs']) await ownedDirectory(join(directory, name), true);
|
|
85
|
+
return directory;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The recorded door, read-only: { directory, door, sites } or undefined.
|
|
89
|
+
// `sites` lists registered checkouts only, never a removed one's reservation.
|
|
90
|
+
export async function readDoorState(env = process.env) {
|
|
91
|
+
const directory = doorDirectory(env);
|
|
92
|
+
if (!await ownedDirectory(directory, false)) return undefined;
|
|
93
|
+
const text = await readPrivate(join(directory, 'state.json'));
|
|
94
|
+
if (text === undefined) return undefined;
|
|
95
|
+
const state = JSON.parse(text);
|
|
96
|
+
return state.door ? { directory, door: state.door, sites: (state.sites ?? []).filter(site => !site.removed) } : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Change the door state under the lock. `action(state)` edits state.door and
|
|
100
|
+
// state.sites in place. (The lock's own file also keeps empty queue fields.)
|
|
101
|
+
export async function changeDoorState(env, action) {
|
|
102
|
+
const directory = await prepareDoorDirectory(env);
|
|
103
|
+
await readPrivate(join(directory, 'state.json'));
|
|
104
|
+
const lifetime = await identity();
|
|
105
|
+
try {
|
|
106
|
+
return await transaction(directory, lifetime.info, async state => { state.sites ??= []; return action(state); });
|
|
107
|
+
} finally { await lifetime.close(); }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function ensureToken(directory) {
|
|
111
|
+
const path = join(directory, 'token');
|
|
112
|
+
if (await readPrivate(path) === undefined) await writeFile(path, randomBytes(32).toString('hex') + '\n', { flag: 'wx', mode: 0o600 }).catch(error => { if (error.code !== 'EEXIST') throw error; });
|
|
113
|
+
return (await readPrivate(path)).trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The proof names the loopback address it was asked on, so a process holding
|
|
117
|
+
// one address cannot relay a challenge to the door on the other.
|
|
118
|
+
export const doorProof = (token, challenge, address) => createHmac('sha256', token).update(`rer-door:${address}:${challenge}`).digest('hex');
|
|
119
|
+
|
|
120
|
+
// The running door's record, when its process is alive: { pid, start, port, ... }.
|
|
121
|
+
export async function runningDoor(env = process.env) {
|
|
122
|
+
const directory = doorDirectory(env);
|
|
123
|
+
if (!await ownedDirectory(directory, false)) return undefined;
|
|
124
|
+
const text = await readPrivate(join(directory, 'process.json'));
|
|
125
|
+
if (!text) return undefined;
|
|
126
|
+
const record = JSON.parse(text), current = await processInfo(record.pid);
|
|
127
|
+
return current && !current.zombie && current.start === record.start ? record : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Names are unique and stable per checkout. `rer door remove` keeps a removed
|
|
131
|
+
// checkout's name reserved for it (`removed: true`), because its saved origin
|
|
132
|
+
// still names it: the door stops routing it, no other checkout can claim it,
|
|
133
|
+
// and that checkout's next rer dev or rer door add takes it back. A
|
|
134
|
+
// reservation ends only when its checkout folder no longer exists.
|
|
135
|
+
function releaseVanished(state) {
|
|
136
|
+
state.sites = state.sites.filter(site => !site.removed || existsSync(site.checkout));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const identityFile = '.rer/local/door-site.json';
|
|
140
|
+
const newIdentity = () => randomBytes(16).toString('hex');
|
|
141
|
+
|
|
142
|
+
// The door identity a checkout folder holds, or undefined.
|
|
143
|
+
export function checkoutIdentity(root) {
|
|
144
|
+
try { const id = JSON.parse(readFileSync(join(root, identityFile), 'utf8')).id; return /^[0-9a-f]{32}$/.test(id) ? id : undefined; } catch { return undefined; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function saveCheckoutIdentity(root, id) {
|
|
148
|
+
if (checkoutIdentity(root) === id) return;
|
|
149
|
+
const { privateDirectory, writePrivateFile } = await shared('private-files.mjs');
|
|
150
|
+
await privateDirectory(root, ['.rer', 'local']);
|
|
151
|
+
await writePrivateFile(join(root, identityFile), JSON.stringify({ id }) + '\n');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// In a door state being changed: the checkout's entry, claiming a name when it
|
|
155
|
+
// has none (its slug, or `preferred`, with a numeric suffix when another
|
|
156
|
+
// checkout holds that name). A removed entry is registered again.
|
|
157
|
+
// `id` is the identity the checkout folder holds (undefined when it holds
|
|
158
|
+
// none). A checkout is found by its folder, or else by its identity: a
|
|
159
|
+
// registered checkout whose folder no longer holds that identity has moved,
|
|
160
|
+
// and its entry follows it to the new folder, name and all. A copy (whose
|
|
161
|
+
// identity the original folder still holds) is a checkout of its own and gets
|
|
162
|
+
// a new identity. An entry registered before checkouts kept an identity
|
|
163
|
+
// follows a checkout whose saved origin names it (`preferred`) once its own
|
|
164
|
+
// folder is gone. The caller saves `entry.id` in the checkout folder.
|
|
165
|
+
// `holder` names the checkout that holds `preferred` when it is not this one.
|
|
166
|
+
export function claimEntry(state, checkout, preferred, id) {
|
|
167
|
+
releaseVanished(state);
|
|
168
|
+
// By folder, unless that folder now holds another checkout's identity.
|
|
169
|
+
let entry = state.sites.find(site => site.checkout === checkout && !(site.id && id && site.id !== id)), copy = false;
|
|
170
|
+
if (!entry && id) {
|
|
171
|
+
const prior = state.sites.find(site => site.id === id);
|
|
172
|
+
if (prior && checkoutIdentity(prior.checkout) !== id) entry = Object.assign(prior, { checkout, movedAt: Date.now() });
|
|
173
|
+
else copy = Boolean(prior);
|
|
174
|
+
}
|
|
175
|
+
if (!entry && preferred) {
|
|
176
|
+
const named = state.sites.find(site => site.name === preferred && !site.id && !existsSync(site.checkout));
|
|
177
|
+
if (named) entry = Object.assign(named, { checkout, movedAt: Date.now() });
|
|
178
|
+
}
|
|
179
|
+
if (entry) delete entry.removed;
|
|
180
|
+
else {
|
|
181
|
+
const taken = new Set(state.sites.map(site => site.name)), base = preferred ?? siteSlug(checkout);
|
|
182
|
+
let name = base;
|
|
183
|
+
for (let suffix = 2; taken.has(name); suffix++) name = `${base}-${suffix}`;
|
|
184
|
+
entry = { name, checkout, addedAt: Date.now() };
|
|
185
|
+
state.sites.push(entry);
|
|
186
|
+
}
|
|
187
|
+
entry.id ??= copy || !id ? newIdentity() : id;
|
|
188
|
+
const holder = preferred && entry.name !== preferred ? state.sites.find(site => site.name === preferred) : undefined;
|
|
189
|
+
return { entry, holder };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A checkout's registered name, claiming one when it has none. `mode` (door or
|
|
193
|
+
// direct) and a direct `origin` are kept on the entry for listings while the
|
|
194
|
+
// checkout is stopped. With `exact` (a saved door origin, which names its
|
|
195
|
+
// host), the claim must give `preferred` itself: when another checkout holds
|
|
196
|
+
// it, or this checkout is registered under another name, nothing changes and
|
|
197
|
+
// rer refuses, so a name and its cookies never reach a second checkout.
|
|
198
|
+
export async function claimSiteName(env, checkout, { preferred, mode, origin, exact = false } = {}) {
|
|
199
|
+
const describe = entry => { if (mode) entry.mode = mode; if (origin) entry.origin = origin; else if (mode === 'door') delete entry.origin; };
|
|
200
|
+
const held = checkoutIdentity(checkout);
|
|
201
|
+
const claimed = await changeDoorState(env, state => {
|
|
202
|
+
const { entry, holder } = claimEntry(state, checkout, preferred, held);
|
|
203
|
+
if (exact && entry.name !== preferred) throw refuse(holder
|
|
204
|
+
? `This checkout's saved origin names ${preferred}.localhost, which the front door has registered for another checkout (${holder.checkout}). Nothing was started and no sign-in link was printed. Give this checkout its own name: rer dev --direct, then rer dev --door.`
|
|
205
|
+
: `This checkout's saved origin names ${preferred}.localhost, but the front door has it registered as ${entry.name}. Nothing was started and no sign-in link was printed. Move it again: rer dev --direct, then rer dev --door.`);
|
|
206
|
+
describe(entry);
|
|
207
|
+
return { name: entry.name, id: entry.id };
|
|
208
|
+
});
|
|
209
|
+
await saveCheckoutIdentity(checkout, claimed.id);
|
|
210
|
+
return claimed.name;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const portOf = name => Number(/:(\d+)$/.exec(name)?.[1]);
|
|
214
|
+
|
|
215
|
+
async function challenge(host, port, token) {
|
|
216
|
+
const { loopbackRequest } = await shared('app-fetch.mjs');
|
|
217
|
+
const nonce = randomBytes(24).toString('hex');
|
|
218
|
+
const reply = await loopbackRequest({ host, port, path: doorIdentityPath, headers: { host: `localhost:${port}`, [doorChallengeHeader]: nonce }, timeoutMs: 5000 });
|
|
219
|
+
let answer; try { answer = JSON.parse(reply.body.toString('utf8')); } catch {}
|
|
220
|
+
const expected = Buffer.from(doorProof(token, nonce, host)), given = Buffer.from(String(answer?.proof ?? ''));
|
|
221
|
+
return reply.status === 200 && given.length === expected.length && timingSafeEqual(given, expected) ? answer : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Before rer prints a dev key for a door origin on port `port`: every loopback
|
|
225
|
+
// listener on it must belong to the door PID recorded in its state, and the
|
|
226
|
+
// door must prove it holds the 0600 token on each address it serves. While
|
|
227
|
+
// `rer door restart` (or enable) replaces the door (`restarting` in its state,
|
|
228
|
+
// for at most restartWindowMs), a failed check is repeated until the new door
|
|
229
|
+
// passes it, so a start under way finishes against the new door.
|
|
230
|
+
export const restartWindowMs = 90_000;
|
|
231
|
+
export async function verifyDoor(env, port) {
|
|
232
|
+
for (;;) {
|
|
233
|
+
try { return await verifyOnce(env, port); }
|
|
234
|
+
catch (error) {
|
|
235
|
+
const since = (await readDoorState(env).catch(() => undefined))?.door?.restarting;
|
|
236
|
+
if (!(Date.now() - since < restartWindowMs)) throw error;
|
|
237
|
+
await delay(250);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function verifyOnce(env, port) {
|
|
243
|
+
const directory = doorDirectory(env), record = await runningDoor(env);
|
|
244
|
+
const { listeners, processName } = await shared('process-diagnostics.mjs');
|
|
245
|
+
const holders = (await listeners()).filter(row => row.names.some(name => portOf(name) === port));
|
|
246
|
+
const foreign = holders.find(row => row.pid !== record?.pid);
|
|
247
|
+
const withheld = 'No sign-in link was printed.';
|
|
248
|
+
if (foreign) throw refuse(`Port ${port} is held by PID ${foreign.pid} (${await processName(foreign.pid)}), which is not this account's front door${record ? ` (PID ${record.pid})` : ''}. ${withheld} Stop PID ${foreign.pid}, then run rer door restart.`);
|
|
249
|
+
if (!record || record.port !== port) throw refuse(`The front door is not running on port ${port}. ${withheld} Start it with rer door start, then run this again.`);
|
|
250
|
+
const token = (await readPrivate(join(directory, 'token')))?.trim();
|
|
251
|
+
if (!token) throw refuse(`The front door's token is missing from ${directory}. ${withheld} Run rer door restart.`);
|
|
252
|
+
for (const host of ['127.0.0.1', '::1']) {
|
|
253
|
+
let answer;
|
|
254
|
+
try { answer = await challenge(host, port, token); }
|
|
255
|
+
catch (error) {
|
|
256
|
+
// No IPv6 loopback, or nothing on [::1]:P while the door serves only 127.0.0.1.
|
|
257
|
+
if (host === '::1' && ['ECONNREFUSED', 'EADDRNOTAVAIL', 'EAFNOSUPPORT', 'ENETUNREACH'].includes(error.code) && !record.addresses?.includes('::1')) continue;
|
|
258
|
+
throw refuse(`The front door (PID ${record.pid}) did not answer on ${host === '::1' ? '[::1]' : host}:${port}: ${error.code ?? error.message}. ${withheld} Run rer door restart.`);
|
|
259
|
+
}
|
|
260
|
+
if (!answer || answer.pid !== record.pid) throw refuse(`Something other than this account's front door (PID ${record.pid}) answers on ${host === '::1' ? '[::1]' : host}:${port}. ${withheld} Find and stop it, then run rer door restart.`);
|
|
261
|
+
}
|
|
262
|
+
return record;
|
|
263
|
+
}
|