@runeditrun/rer 0.1.0 → 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 +115 -2
- 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 +19 -11
- 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 +115 -2
- 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 +25 -23
- package/global/launch/door.mjs +36 -0
- package/global/launch/facts.mjs +3 -2
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +41 -6
- package/global/launch/render.mjs +6 -1
- package/global/launch/state.mjs +10 -1
- package/global/launch/steps.mjs +24 -10
- package/global/launch/stop.mjs +89 -0
- package/global/launch/worker.mjs +30 -11
- 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/global/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
|
|
@@ -117,7 +121,10 @@ override the choice, and `--json` prints `progress` events and the ready event.
|
|
|
117
121
|
first launch can take minutes: it prints a progress line at least every 30
|
|
118
122
|
seconds, and if the calling command is killed the launch carries on; run
|
|
119
123
|
`rer dev` again to reattach. A second `rer dev` reprints the ready lines.
|
|
120
|
-
`rer dev --stop` stops the server
|
|
124
|
+
`rer dev --stop` stops the server, or a launch still preparing (which then
|
|
125
|
+
reports that it was stopped), waits until every process they started has
|
|
126
|
+
exited and says what it stopped; if any is still running it exits non-zero
|
|
127
|
+
naming their PIDs.
|
|
121
128
|
|
|
122
129
|
Servers started through `rer` (`rer dev`, `rer preview`) get a 10-server limit and a 12-hour idle timeout
|
|
123
130
|
unless you set `RER_SERVER_LIMIT`, `RER_SERVER_IDLE_SECONDS` or `--idle-timeout`.
|
|
@@ -127,6 +134,27 @@ checkout never records a limit you did not set.
|
|
|
127
134
|
A start is never refused for machine load alone unless `RER_SERVERS_LOAD_THRESHOLD`
|
|
128
135
|
or `RER_JOBS_LOAD_THRESHOLD` is set; memory and disk guards stay.
|
|
129
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
|
+
|
|
130
158
|
## Find what to launch
|
|
131
159
|
|
|
132
160
|
```sh
|
|
@@ -141,6 +169,91 @@ Independent projects (an `OPERATIONS.json` version 2 without a seed contract) ru
|
|
|
141
169
|
their declared commands with `rer <verb> [-- args]`; zero-setup launching does not
|
|
142
170
|
apply to them. `OPERATIONS.json` version 1 is not supported.
|
|
143
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
|
+
|
|
144
257
|
## Verbs
|
|
145
258
|
|
|
146
259
|
In a checkout, every verb runs that checkout's own pinned `rer`; `rer VERB --help`
|
|
@@ -150,6 +263,6 @@ there lists its flags:
|
|
|
150
263
|
`export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release`.
|
|
151
264
|
|
|
152
265
|
Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
|
|
153
|
-
`rer --version` and `rer --help`.
|
|
266
|
+
`rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
|
|
154
267
|
|
|
155
268
|
Licence: MIT.
|
package/global/checkout.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// above the working directory, or exactly --cwd DIR. Then pick whose code runs it.
|
|
3
3
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { exactVersion } from '../cli/package-mode.mjs';
|
|
5
6
|
import { ownRoot, packageName } from './package-info.mjs';
|
|
6
7
|
|
|
7
8
|
// The value of --cwd among the wrapper flags (never after a -- separator).
|
|
@@ -53,19 +54,32 @@ export function unsupportedMessage(root, version) {
|
|
|
53
54
|
return `${join(root, 'OPERATIONS.json')} is an OPERATIONS version ${version ?? '(none)'} descriptor, which rer does not run. Use the commands that project documents, or move it to OPERATIONS version 2.`;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// The rer package a checkout pins in package.json (package mode): the declared
|
|
58
|
+
// specifier, the version node_modules holds, and whether that installed copy is
|
|
59
|
+
// the one to run (`ready`: present, and equal to an exact pin). undefined for a
|
|
60
|
+
// checkout that does not declare it.
|
|
61
|
+
export function pinnedPackage(root) {
|
|
62
|
+
let manifest;
|
|
63
|
+
try { manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); } catch { return undefined; }
|
|
64
|
+
const declared = manifest.devDependencies?.[packageName] ?? manifest.dependencies?.[packageName];
|
|
65
|
+
if (!declared) return undefined;
|
|
66
|
+
let installed;
|
|
67
|
+
try { installed = JSON.parse(readFileSync(join(root, 'node_modules', packageName, 'package.json'), 'utf8')).version; } catch {}
|
|
68
|
+
return { declared, installed, ready: installed !== undefined && (installed === declared || !exactVersion.test(declared)) };
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
71
|
+
// What runs a checkout's verbs: `entry` is the dispatcher (the verbs
|
|
72
|
+
// themselves) and, for a pinned package, `launcher` is its own rer command.
|
|
73
|
+
// 1. the checkout's own pinned package, once node_modules holds the version
|
|
74
|
+
// package.json pins (see pinnedPackage), 2. its vendored scripts/rer.mjs, 3. this package's
|
|
75
|
+
// bundled dispatcher.
|
|
65
76
|
export function dispatcher(root) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
const pinned = root ? pinnedPackage(root) : undefined;
|
|
78
|
+
if (pinned?.ready) {
|
|
79
|
+
const location = realpathSync(join(root, 'node_modules', packageName));
|
|
80
|
+
// This package is that pin: it runs the checkout itself, whatever else the checkout holds.
|
|
81
|
+
if (location === ownRoot) return { mode: 'bundled', entry: join(ownRoot, 'bin/rer.mjs') };
|
|
82
|
+
if (existsSync(join(location, 'global/rer.mjs'))) return { mode: 'package', entry: join(location, 'bin/rer.mjs'), launcher: join(location, 'global/rer.mjs') };
|
|
69
83
|
}
|
|
70
84
|
if (root && existsSync(join(root, 'scripts/rer.mjs'))) return { mode: 'vendored', entry: join(root, 'scripts/rer.mjs') };
|
|
71
85
|
return { mode: 'bundled', entry: join(ownRoot, 'bin/rer.mjs') };
|
package/global/cli/install.mjs
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// version directory, switch `current` atomically, keep the previous version,
|
|
3
3
|
// prune older ones and write the launcher. Never edits shell profiles.
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { chmod, cp, mkdir, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { chmod, cp, mkdir, readdir, readlink, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { basename, join, resolve } from 'node:path';
|
|
7
7
|
import { ownManifest, ownRoot, packageDigest, packageFileList } from '../package-info.mjs';
|
|
8
8
|
import { installRecord, launcherOwner, launcherScript, layout } from './layout.mjs';
|
|
9
|
+
import { runningDoor } from '../../cli/door.mjs';
|
|
9
10
|
|
|
10
11
|
async function readLink(path) { try { return await readlink(path); } catch (error) { if (['ENOENT', 'EINVAL'].includes(error.code)) return undefined; throw error; } }
|
|
11
12
|
|
|
@@ -66,7 +67,10 @@ export async function install() {
|
|
|
66
67
|
if (!present) { await rm(join(paths.versions, name), { recursive: true, force: true }); await copyVersion(paths, name); }
|
|
67
68
|
const previousName = current?.startsWith('versions/') && current !== join('versions', name) ? current.slice('versions/'.length) : record?.previous;
|
|
68
69
|
if (current !== join('versions', name)) await switchCurrent(paths, name);
|
|
69
|
-
|
|
70
|
+
// A running front door keeps running from its own version: never prune it.
|
|
71
|
+
const door = await runningDoor().catch(() => undefined);
|
|
72
|
+
const doorVersion = door?.versionDir && resolve(door.versionDir, '..') === await realpath(paths.versions).catch(() => resolve(paths.versions)) ? basename(door.versionDir) : undefined;
|
|
73
|
+
const keep = new Set([name, previousName, doorVersion].filter(Boolean));
|
|
70
74
|
for (const entry of await readdir(paths.versions)) if (!keep.has(entry) && !entry.startsWith('.staging-')) await rm(join(paths.versions, entry), { recursive: true, force: true });
|
|
71
75
|
await writeFile(paths.record, JSON.stringify({ channel, previous: previousName && keep.has(previousName) ? previousName : null }, null, 2) + '\n', { mode: 0o644 });
|
|
72
76
|
if (launcher.text !== script) await writeLauncher(paths);
|
package/global/delegate.mjs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// Run an invocation with the checkout's own
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Run an invocation with the checkout's own rer, in this process. A pinned
|
|
2
|
+
// package runs it with its own rer command, so launching is its own too. A
|
|
3
|
+
// vendored or bundled dispatcher runs it exactly as `node scripts/rer.mjs ...`
|
|
4
|
+
// would from the project root: process.argv[1] is that file, so its Node-pin
|
|
5
|
+
// re-exec and queued child spawns behave as they do when it is run directly.
|
|
6
|
+
// The dispatcher sets process.exitCode itself.
|
|
5
7
|
import { pathToFileURL } from 'node:url';
|
|
6
8
|
import { dispatcher } from './checkout.mjs';
|
|
7
9
|
|
|
8
10
|
export async function delegate(target, args) {
|
|
9
|
-
const { entry } = dispatcher(target.root);
|
|
11
|
+
const { entry, launcher } = dispatcher(target.root);
|
|
10
12
|
if (target.root) process.chdir(target.root);
|
|
11
|
-
process.argv = [process.argv[0], entry, ...args];
|
|
12
|
-
await import(pathToFileURL(entry).href);
|
|
13
|
+
process.argv = [process.argv[0], launcher ?? entry, ...args];
|
|
14
|
+
await import(pathToFileURL(launcher ?? entry).href);
|
|
13
15
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// rer door: the opt-in front door. One process on one port serves every
|
|
2
|
+
// registered checkout at http://NAME.localhost:PORT, starting it on its first
|
|
3
|
+
// visit and stopping it when idle.
|
|
4
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { changeDoorState, checkoutIdentity, claimEntry, defaultDoorPort, doorOrigin, doorProtocol, doorSite, ensureToken, prepareDoorDirectory, readDoorState, runningDoor, saveCheckoutIdentity, verifyDoor } from '../../cli/door.mjs';
|
|
7
|
+
import { openUrl, readDevKey } from '../../cli/dev-identity.mjs';
|
|
8
|
+
import { descriptorProfile, dispatcher, unsupportedMessage } from '../checkout.mjs';
|
|
9
|
+
import { describeCheckout } from '../launch/facts.mjs';
|
|
10
|
+
import { ownManifest } from '../package-info.mjs';
|
|
11
|
+
import { DoorFailure, startDoor, stopDoor } from './control.mjs';
|
|
12
|
+
import { rebootNote } from './pages.mjs';
|
|
13
|
+
import { enabledPolicy } from './policy.mjs';
|
|
14
|
+
import { doorSites } from './sites.mjs';
|
|
15
|
+
|
|
16
|
+
export const doorHelp = `Usage: rer door VERB
|
|
17
|
+
|
|
18
|
+
The front door serves every registered checkout at http://NAME.localhost:PORT
|
|
19
|
+
from one process on one port. A checkout starts on its first visit in a browser
|
|
20
|
+
and stops when idle. It is opt-in for this computer.
|
|
21
|
+
|
|
22
|
+
rer door enable [--port N] [--server-limit N] [--idle-timeout S]
|
|
23
|
+
Opt this computer in, record the policy and start the door.
|
|
24
|
+
Defaults: port 8700, 10 servers, 43200 s (12 hours) idle.
|
|
25
|
+
The policy and the RER_* variables in effect now are stored;
|
|
26
|
+
sites the door starts get those, never another shell's.
|
|
27
|
+
rer door disable Stop the door and opt this computer out.
|
|
28
|
+
rer door start Start the enabled door (needed after a reboot or logout).
|
|
29
|
+
rer door stop Stop the door. Running sites keep running until idle.
|
|
30
|
+
rer door restart Drain and restart the door; open tabs reconnect.
|
|
31
|
+
rer door add [PATH ...]
|
|
32
|
+
Register checkouts (default: here) for lazy start, printing
|
|
33
|
+
each URL and, for a set-up door-mode checkout with dev
|
|
34
|
+
identity, a sign-in link that starts it and signs in.
|
|
35
|
+
rer door remove NAME Unregister one checkout. Its name stays reserved for it;
|
|
36
|
+
rer dev there, or rer door add, registers it again.
|
|
37
|
+
rer door status [--json] [--links]
|
|
38
|
+
The door, its policy and every site with its state and URL;
|
|
39
|
+
--links adds sign-in links.
|
|
40
|
+
|
|
41
|
+
Fresh checkouts launched with rer dev while the door is enabled are set up at
|
|
42
|
+
their door URL. Move an existing one with rer dev --door (rer dev --direct moves
|
|
43
|
+
it back); rer dev --port N always serves directly.
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const out = text => process.stdout.write(text + '\n');
|
|
47
|
+
const err = text => process.stderr.write(text + '\n');
|
|
48
|
+
|
|
49
|
+
function flags(args, valued, booleans = []) {
|
|
50
|
+
const result = { positional: [] };
|
|
51
|
+
for (let i = 0; i < args.length; i++) {
|
|
52
|
+
const arg = args[i];
|
|
53
|
+
if (valued.includes(arg)) { const value = args[++i]; if (value === undefined || value.startsWith('--')) throw new DoorFailure(2, `${arg} needs a value.`); result[arg] = value; }
|
|
54
|
+
else if (booleans.includes(arg)) result[arg] = true;
|
|
55
|
+
else if (arg.startsWith('-')) throw new DoorFailure(2, `Unsupported flag ${arg}. Run rer door --help.`);
|
|
56
|
+
else result.positional.push(arg);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function enabledState(env) {
|
|
62
|
+
const state = await readDoorState(env);
|
|
63
|
+
if (!state?.door?.enabled) throw new DoorFailure(3, 'The front door is not enabled on this computer. Enable it with: rer door enable');
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function runningLine(door, record) {
|
|
68
|
+
return `Front door running at http://localhost:${record.port} (PID ${record.pid}, rer ${record.version}); limit ${door.policy.serverLimit} servers, idle stop after ${door.policy.idleSeconds} s.`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Replace a running door with a new one: the old one stops accepting, hands
|
|
72
|
+
// the starts under way to the new one and gives up its port at once, then
|
|
73
|
+
// finishes its requests; the new one carries those starts on (each one's own
|
|
74
|
+
// rer dev waits for it before its key is printed) and open tabs reconnect.
|
|
75
|
+
// `change` edits the state between the two (rer door enable's new policy).
|
|
76
|
+
async function replaceDoor(env, change) {
|
|
77
|
+
const running = await runningDoor(env);
|
|
78
|
+
if (running) await changeDoorState(env, state => { if (state.door) state.door.restarting = Date.now(); });
|
|
79
|
+
try {
|
|
80
|
+
const stopped = running ? await stopDoor(env, { released: true }) : undefined;
|
|
81
|
+
if (change) await changeDoorState(env, change);
|
|
82
|
+
const { door } = await startDoor(env);
|
|
83
|
+
return { stopped, door };
|
|
84
|
+
} finally { if (running) await changeDoorState(env, state => { if (state.door) delete state.door.restarting; }); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function enable(args, env) {
|
|
88
|
+
const given = flags(args, ['--port', '--server-limit', '--idle-timeout']);
|
|
89
|
+
if (given.positional.length) throw new DoorFailure(2, 'rer door enable takes only flags. Run rer door --help.');
|
|
90
|
+
const chosen = enabledPolicy({ port: given['--port'], serverLimit: given['--server-limit'], idleTimeout: given['--idle-timeout'] }, env);
|
|
91
|
+
const previous = (await readDoorState(env))?.door;
|
|
92
|
+
const port = chosen.port ?? previous?.port ?? defaultDoorPort;
|
|
93
|
+
const directory = await prepareDoorDirectory(env);
|
|
94
|
+
await ensureToken(directory);
|
|
95
|
+
const { door: record } = await replaceDoor(env, state => {
|
|
96
|
+
state.door = { protocol: doorProtocol, enabled: true, port, policy: chosen.policy, directory: chosen.directory, serversDir: chosen.serversDir, environment: chosen.environment, started: state.door?.started ?? [], ...(state.door?.handoff ? { handoff: state.door.handoff } : {}), ...(state.door?.restarting ? { restarting: state.door.restarting } : {}), enabledAt: new Date().toISOString() };
|
|
97
|
+
});
|
|
98
|
+
out(runningLine({ policy: chosen.policy }, record));
|
|
99
|
+
if (previous?.port && previous.port !== port) out(`Checkouts already moved to the door keep their saved port ${previous.port}; move each again with rer dev --direct, then rer dev --door.`);
|
|
100
|
+
out(`Register checkouts with rer door add PATH. ${rebootNote.replace(/<\/?code>/g, '')}`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function disable(args, env) {
|
|
105
|
+
if (args.length) throw new DoorFailure(2, 'rer door disable takes no arguments.');
|
|
106
|
+
const stopped = await stopDoor(env);
|
|
107
|
+
if (await readDoorState(env)) await changeDoorState(env, state => { if (state.door) state.door.enabled = false; });
|
|
108
|
+
out(`${stopped ? `Stopped the front door (PID ${stopped.pid}). ` : ''}The front door is disabled on this computer. Registered checkouts stay registered; rer door enable turns it back on.`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function start(env) {
|
|
113
|
+
const state = await enabledState(env);
|
|
114
|
+
const { door: record, already } = await startDoor(env);
|
|
115
|
+
out(already ? `The front door is already running (PID ${record.pid}).` : runningLine(state.door, record));
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function stop(env) {
|
|
120
|
+
const stopped = await stopDoor(env);
|
|
121
|
+
out(stopped ? `Stopped the front door (PID ${stopped.pid}). Sites it started keep running until idle; rer door start brings it back.` : 'The front door is not running.');
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function restart(env) {
|
|
126
|
+
const state = await enabledState(env);
|
|
127
|
+
const { stopped, door: record } = await replaceDoor(env);
|
|
128
|
+
out(`${stopped ? `Drained the front door (PID ${stopped.pid}); open tabs reconnect. ` : ''}${runningLine(state.door, record)}`);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The sign-in link for a set-up door-mode checkout with dev identity, after the
|
|
133
|
+
// door proves it is this account's. It carries the checkout's dev key in its
|
|
134
|
+
// fragment: printed for the person, never given to the door.
|
|
135
|
+
async function signInLink(site, port) {
|
|
136
|
+
if (site.mode !== 'door') return undefined;
|
|
137
|
+
let key; try { key = await readDevKey(site.path); } catch { return undefined; }
|
|
138
|
+
return key ? openUrl(doorOrigin(site.name, port), key, { path: '/__dev/key', as: 'owner' }) : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A pin from before the door does not describe its saved origin; its direct
|
|
142
|
+
// link is the AUTH_ORIGIN its settings file holds (read only for the link).
|
|
143
|
+
async function oldPinOrigin(root, described) {
|
|
144
|
+
try {
|
|
145
|
+
const text = await readFile(join(root, described.local?.settings?.[0] ?? '.dev.vars'), 'utf8');
|
|
146
|
+
const origin = /^AUTH_ORIGIN=(http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+)\s*$/m.exec(text)?.[1];
|
|
147
|
+
return origin ?? null;
|
|
148
|
+
} catch { return null; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class NameHeld extends Error {}
|
|
152
|
+
|
|
153
|
+
async function add(args, env) {
|
|
154
|
+
const given = flags(args, []);
|
|
155
|
+
const state = await enabledState(env), port = state.door.port;
|
|
156
|
+
let verified, verifyError;
|
|
157
|
+
try { verified = await verifyDoor(env, port); } catch (error) { verifyError = error; }
|
|
158
|
+
let skipped = 0;
|
|
159
|
+
for (const path of given.positional.length ? given.positional : ['.']) {
|
|
160
|
+
let root;
|
|
161
|
+
try { root = await realpath(resolve(path)); } catch { out(`skipped ${path}: no such folder`); skipped++; continue; }
|
|
162
|
+
const facts = descriptorProfile(join(root, 'OPERATIONS.json'));
|
|
163
|
+
if (facts.profile !== 'first-party') {
|
|
164
|
+
out(`skipped ${path}: ${facts.profile === 'independent' ? 'an independent project; the front door serves first-party checkouts' : facts.profile === 'unreadable' ? 'no readable OPERATIONS.json, so not a first-party checkout' : unsupportedMessage(root, facts.version)}`);
|
|
165
|
+
skipped++; continue;
|
|
166
|
+
}
|
|
167
|
+
let described;
|
|
168
|
+
try { described = await describeCheckout(root, dispatcher(root).entry, env); } catch (failure) { out(`skipped ${path}: rer describe fails here: ${failure.message.split('\n')[0]}`); skipped++; continue; }
|
|
169
|
+
const saved = described.local && 'origin' in described.local ? described.local.origin : await oldPinOrigin(root, described), site = saved && doorSite(saved), mode = site && site.port === port ? 'door' : 'direct';
|
|
170
|
+
// A door-mode checkout's saved origin names its site, so it registers under
|
|
171
|
+
// that name or not at all; another checkout keeps a name it holds.
|
|
172
|
+
// A moved or renamed checkout is found by the identity its folder holds.
|
|
173
|
+
let name, id;
|
|
174
|
+
try {
|
|
175
|
+
({ name, id } = await changeDoorState(env, state => {
|
|
176
|
+
const { entry, holder } = claimEntry(state, root, site?.name, checkoutIdentity(root));
|
|
177
|
+
// Thrown, so the state is left as it was.
|
|
178
|
+
if (mode === 'door' && entry.name !== site.name) throw new NameHeld(holder?.checkout ?? `this checkout under ${entry.name}`);
|
|
179
|
+
Object.assign(entry, { mode, door: Boolean(described.door) });
|
|
180
|
+
if (saved && mode === 'direct') entry.origin = saved; else delete entry.origin;
|
|
181
|
+
return { name: entry.name, id: entry.id };
|
|
182
|
+
}));
|
|
183
|
+
await saveCheckoutIdentity(root, id);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!(error instanceof NameHeld)) throw error;
|
|
186
|
+
out(`skipped ${path}: its saved origin names ${site.name}.localhost, which the front door has registered for ${error.message}. Give it its own name with rer dev --direct, then rer dev --door, in ${root}.`);
|
|
187
|
+
skipped++; continue;
|
|
188
|
+
}
|
|
189
|
+
const url = doorOrigin(name, port);
|
|
190
|
+
out(`${name} ${url} (${mode === 'door' ? 'door mode' : `direct${saved ? ` at ${saved}` : ''}; opening the door URL starts it and redirects there`}) ${root}`);
|
|
191
|
+
if (mode === 'door' && described.local?.devIdentity) {
|
|
192
|
+
const link = verified && await signInLink({ name, path: root, mode }, port);
|
|
193
|
+
if (link) out(` sign in: ${link}`);
|
|
194
|
+
else if (verifyError) out(` no sign-in link: ${verifyError.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return skipped && skipped === (given.positional.length || 1) ? 3 : 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The checkout keeps its name reserved: its saved origin still names it, so no
|
|
201
|
+
// other checkout may take it, and its own next rer dev (or rer door add)
|
|
202
|
+
// registers it again.
|
|
203
|
+
async function remove(args, env) {
|
|
204
|
+
const given = flags(args, []);
|
|
205
|
+
if (given.positional.length !== 1) throw new DoorFailure(2, 'Usage: rer door remove NAME');
|
|
206
|
+
const [name] = given.positional;
|
|
207
|
+
const removed = await changeDoorState(env, state => { const site = state.sites.find(item => item.name === name && !item.removed); if (site) site.removed = true; return site; });
|
|
208
|
+
if (!removed) { err(`No checkout is registered as ${name}. rer door status lists them.`); return 3; }
|
|
209
|
+
out(`Unregistered ${name} (${removed.checkout}). Its checkout and any running server are untouched, and the name stays reserved for it: rer dev there, or rer door add, registers it again.`);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function status(args, env) {
|
|
214
|
+
const given = flags(args, [], ['--json', '--links']);
|
|
215
|
+
if (given.positional.length) throw new DoorFailure(2, 'Usage: rer door status [--json] [--links]');
|
|
216
|
+
const state = await readDoorState(env), record = await runningDoor(env), version = record?.version ?? ownManifest().version;
|
|
217
|
+
const door = state?.door;
|
|
218
|
+
const views = door ? await doorSites({ env, door }).views() : [];
|
|
219
|
+
let linkNote;
|
|
220
|
+
if (given['--links'] && door) {
|
|
221
|
+
try {
|
|
222
|
+
await verifyDoor(env, door.port);
|
|
223
|
+
for (const view of views) { const link = await signInLink(view, door.port); if (link) view.signIn = link; }
|
|
224
|
+
} catch (error) { linkNote = error.message; }
|
|
225
|
+
}
|
|
226
|
+
const sites = views.map(view => ({ name: view.name, path: view.path, mode: view.mode, state: view.state === 'failed' ? 'stopped' : view.state, url: view.url, ...(view.proxyPort && view.state === 'running' ? { proxyPort: view.proxyPort } : {}), ...(view.signIn ? { signIn: view.signIn } : {}) }));
|
|
227
|
+
const result = { running: Boolean(record), enabled: Boolean(door?.enabled), port: door?.port ?? record?.port ?? null, pid: record?.pid ?? null, version, policy: door ? { serverLimit: door.policy.serverLimit, idleSeconds: door.policy.idleSeconds } : null, sites };
|
|
228
|
+
if (given['--json']) { out(JSON.stringify({ ...result, ...(linkNote ? { linksUnavailable: linkNote } : {}), ...(!record && door?.enabled ? { note: 'After a reboot or logout the front door needs rer door start.' } : {}) })); return 0; }
|
|
229
|
+
if (!door) { out('The front door is not enabled on this computer. Enable it with: rer door enable'); return 0; }
|
|
230
|
+
out(record ? runningLine(door, record) : door.enabled ? `The front door is enabled on port ${door.port} but not running. Start it with: rer door start` : 'The front door is disabled on this computer. Enable it with: rer door enable');
|
|
231
|
+
if (sites.length) {
|
|
232
|
+
const width = Math.max(4, ...sites.map(site => site.name.length));
|
|
233
|
+
out(`${'NAME'.padEnd(width)} STATE MODE URL`);
|
|
234
|
+
for (const site of sites) {
|
|
235
|
+
out(`${site.name.padEnd(width)} ${site.state.padEnd(8)} ${site.mode.padEnd(6)} ${site.url}${site.proxyPort ? ` (proxy 127.0.0.1:${site.proxyPort})` : ''}`);
|
|
236
|
+
if (site.signIn) out(`${' '.repeat(width)} sign in: ${site.signIn}`);
|
|
237
|
+
}
|
|
238
|
+
} else out('No checkouts are registered. Add one with: rer door add PATH');
|
|
239
|
+
if (linkNote) out(`No sign-in links: ${linkNote}`);
|
|
240
|
+
if (!record && door.enabled) out('After a reboot or logout the front door needs rer door start (or any rer dev in a door-mode checkout).');
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function doorCommand(args, env = process.env) {
|
|
245
|
+
// A reader that stops early (rer door status | grep -q) is not a failure.
|
|
246
|
+
process.stdout.on('error', error => { if (error.code === 'EPIPE') process.exit(0); throw error; });
|
|
247
|
+
const [verb, ...rest] = args;
|
|
248
|
+
if (!verb || ['--help', '-h', 'help'].includes(verb) || rest.includes('--help')) { process.stdout.write(doorHelp); return 0; }
|
|
249
|
+
const verbs = { enable, disable, add, remove, status, start: (_, e) => start(e), stop: (_, e) => stop(e), restart: (_, e) => restart(e) };
|
|
250
|
+
if (!verbs[verb]) { err(`Unknown rer door command: ${verb}. Run rer door --help.`); return 2; }
|
|
251
|
+
if (['start', 'stop', 'restart'].includes(verb) && rest.length) { err(`rer door ${verb} takes no arguments.`); return 2; }
|
|
252
|
+
try { return await verbs[verb](rest, env); }
|
|
253
|
+
catch (error) { if (error instanceof DoorFailure || error.exitCode) { err(error.message); return error.exitCode ?? 1; } throw error; }
|
|
254
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Start and stop the door process. It runs detached from the installed
|
|
2
|
+
// version's own files, with the allowlisted base environment and RER_DOOR_DIR
|
|
3
|
+
// only; its policy comes from the state `rer door enable` recorded.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { open, readFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { processInfo } from '../../modules/job-queue/processes.mjs';
|
|
10
|
+
import { doorProtocol, prepareDoorDirectory, readDoorState, runningDoor, verifyDoor } from '../../cli/door.mjs';
|
|
11
|
+
import { doorProcessEnvironment } from './policy.mjs';
|
|
12
|
+
|
|
13
|
+
const serverPath = fileURLToPath(new URL('./server.mjs', import.meta.url));
|
|
14
|
+
|
|
15
|
+
export class DoorFailure extends Error { constructor(exitCode, message) { super(message); this.exitCode = exitCode; } }
|
|
16
|
+
|
|
17
|
+
export async function startDoor(env = process.env) {
|
|
18
|
+
const state = await readDoorState(env);
|
|
19
|
+
if (!state?.door?.enabled) throw new DoorFailure(3, 'The front door is not enabled on this computer. Enable it with: rer door enable');
|
|
20
|
+
const running = await runningDoor(env);
|
|
21
|
+
if (running) {
|
|
22
|
+
if (running.protocol !== doorProtocol) throw new DoorFailure(4, `A front door speaking protocol ${running.protocol} is running (PID ${running.pid}); this rer speaks protocol ${doorProtocol}. Run rer door restart.`);
|
|
23
|
+
return { door: running, already: true };
|
|
24
|
+
}
|
|
25
|
+
const door = state.door, directory = await prepareDoorDirectory(env), logPath = join(directory, 'logs', 'door.log');
|
|
26
|
+
const log = await open(logPath, 'a', 0o600), from = (await log.stat()).size;
|
|
27
|
+
let child, exited, failed;
|
|
28
|
+
// Listening before anything is awaited: a spawn that fails reports it on the next tick.
|
|
29
|
+
try {
|
|
30
|
+
child = spawn(process.execPath, [serverPath], { cwd: directory, env: doorProcessEnvironment(door, env), detached: true, stdio: ['ignore', log.fd, log.fd] });
|
|
31
|
+
child.once('error', error => { failed = error; exited ??= 1; });
|
|
32
|
+
child.once('exit', code => { exited = code ?? 1; });
|
|
33
|
+
} finally { await log.close(); }
|
|
34
|
+
for (let waited = 0; waited < 20000; waited += 100) {
|
|
35
|
+
const record = await runningDoor(env);
|
|
36
|
+
if (record && record.pid === child.pid) { child.unref(); await verifyDoor(env, record.port); return { door: record }; }
|
|
37
|
+
if (exited !== undefined) {
|
|
38
|
+
if (failed) throw new DoorFailure(1, `The front door could not be started: ${failed.code ?? failed.message}.`);
|
|
39
|
+
// Another start (a rer dev bringing the door back) won the port: use that door.
|
|
40
|
+
if (record?.protocol === doorProtocol) { await verifyDoor(env, record.port); return { door: record, already: true }; }
|
|
41
|
+
const text = (await readFile(logPath, 'utf8')).slice(from).split('\n').filter(line => line.startsWith('rer door: ')).map(line => line.slice('rer door: '.length));
|
|
42
|
+
throw new DoorFailure(exited === 4 ? 4 : 1, text.join('\n') || `The front door stopped while starting (exit ${exited}); see ${logPath}.`);
|
|
43
|
+
}
|
|
44
|
+
await delay(100);
|
|
45
|
+
}
|
|
46
|
+
child.kill('SIGTERM');
|
|
47
|
+
throw new DoorFailure(1, `The front door did not start within 20 seconds; see ${logPath}.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Stop the recorded door by its PID (after checking its start time) and wait
|
|
51
|
+
// until it has drained and exited, or with `released` (a restart) only until
|
|
52
|
+
// it has given up its port and process record, which it does before draining.
|
|
53
|
+
export async function stopDoor(env = process.env, { timeoutMs = 30000, released = false } = {}) {
|
|
54
|
+
const running = await runningDoor(env);
|
|
55
|
+
if (!running) return undefined;
|
|
56
|
+
try { process.kill(running.pid, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
57
|
+
for (let waited = 0; waited < timeoutMs; waited += 100) {
|
|
58
|
+
const current = await processInfo(running.pid);
|
|
59
|
+
// The door removes its own process record as it drains; a stale one names a dead PID.
|
|
60
|
+
if (!current || current.zombie || current.start !== running.start) return running;
|
|
61
|
+
if (released && (await runningDoor(env))?.pid !== running.pid) return running;
|
|
62
|
+
await delay(100);
|
|
63
|
+
}
|
|
64
|
+
throw new DoorFailure(1, `The front door (PID ${running.pid}) did not stop within ${timeoutMs / 1000} seconds.`);
|
|
65
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The door makes every cookie a site sets host-only when its Domain names
|
|
2
|
+
// localhost itself: WebKit stores Domain=localhost (and its dotted spellings,
|
|
3
|
+
// such as localhost. and .localhost.) for every *.localhost site and plain
|
|
4
|
+
// localhost, so one site's cookie would reach every other. It applies to every
|
|
5
|
+
// reply that can carry Set-Cookie: HTTP replies and WebSocket handshake replies.
|
|
6
|
+
|
|
7
|
+
// A Domain value as a browser reads it: whitespace and quotes trimmed, leading
|
|
8
|
+
// and trailing dots ignored, any letter case.
|
|
9
|
+
const namesLocalhost = value => value.trim().replace(/^"(.*)"$/, '$1').trim().replace(/^\.+|\.+$/g, '').toLowerCase() === 'localhost';
|
|
10
|
+
|
|
11
|
+
// One Set-Cookie value with each Domain attribute that names localhost removed.
|
|
12
|
+
export function hostOnlyCookie(value) {
|
|
13
|
+
const [pair, ...attributes] = String(value).split(';');
|
|
14
|
+
const kept = attributes.filter(attribute => {
|
|
15
|
+
const at = attribute.indexOf('=');
|
|
16
|
+
return at < 0 || attribute.slice(0, at).trim().toLowerCase() !== 'domain' || !namesLocalhost(attribute.slice(at + 1));
|
|
17
|
+
});
|
|
18
|
+
return [pair, ...kept].join(';');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// An HTTP response head (status line and headers, without the blank line) as
|
|
22
|
+
// bytes, read the way browsers read it (CRLF or bare LF line ends, folded
|
|
23
|
+
// continuation lines joined), with every Set-Cookie made host-only.
|
|
24
|
+
export function hostOnlyHead(head) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
for (const line of head.toString('latin1').split(/\r?\n/)) {
|
|
27
|
+
if (/^[ \t]/.test(line) && lines.length > 1) lines[lines.length - 1] += ' ' + line.trim();
|
|
28
|
+
else lines.push(line);
|
|
29
|
+
}
|
|
30
|
+
return Buffer.from(lines.map((line, index) => {
|
|
31
|
+
const at = line.indexOf(':');
|
|
32
|
+
return index > 0 && at > 0 && line.slice(0, at).trim().toLowerCase() === 'set-cookie' ? `${line.slice(0, at)}: ${hostOnlyCookie(line.slice(at + 1).trim())}` : line;
|
|
33
|
+
}).join('\r\n') + '\r\n\r\n', 'latin1');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The end of a response head in `buffer`: { head, rest }, or undefined while
|
|
37
|
+
// the blank line has not arrived.
|
|
38
|
+
export function splitHead(buffer) {
|
|
39
|
+
const match = /\r?\n\r?\n/.exec(buffer.toString('latin1'));
|
|
40
|
+
return match && { head: buffer.subarray(0, match.index), rest: buffer.subarray(match.index + match[0].length) };
|
|
41
|
+
}
|