@runeditrun/rer 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +113 -2
  2. package/cli/README.md +32 -5
  3. package/cli/contract.mjs +5 -5
  4. package/cli/deploy.mjs +17 -3
  5. package/cli/detached-server.mjs +51 -0
  6. package/cli/dev-identity.mjs +2 -1
  7. package/cli/door-mode.mjs +134 -0
  8. package/cli/door.mjs +263 -0
  9. package/cli/hosted-output.mjs +17 -7
  10. package/cli/hydration/browser.mjs +91 -0
  11. package/cli/hydration/cases.mjs +106 -0
  12. package/cli/hydration/check.mjs +105 -0
  13. package/cli/hydration/probe.mjs +164 -0
  14. package/cli/hydration/scripts.mjs +39 -0
  15. package/cli/hydration/verdict.mjs +85 -0
  16. package/cli/inspect.mjs +57 -10
  17. package/cli/lifecycle.mjs +36 -12
  18. package/cli/local.mjs +9 -3
  19. package/cli/main.mjs +3 -0
  20. package/cli/managed-server.mjs +62 -21
  21. package/cli/package-calls.mjs +81 -0
  22. package/cli/package-mode.mjs +46 -0
  23. package/cli/parse.mjs +15 -4
  24. package/cli/pnpm-runtime.mjs +96 -0
  25. package/cli/portability-worker.mjs +2 -2
  26. package/cli/release.mjs +1 -0
  27. package/cli/runtime-front-door.mjs +4 -2
  28. package/cli/server-proxy.mjs +69 -5
  29. package/cli/server-registry.mjs +10 -7
  30. package/cli/settings.mjs +14 -0
  31. package/cli/site-bindings.mjs +291 -0
  32. package/cli/site-browser-objects.mjs +36 -0
  33. package/cli/site-check.mjs +111 -0
  34. package/cli/site-client.mjs +50 -0
  35. package/cli/site-data.mjs +54 -0
  36. package/cli/site-entities.json +1 -0
  37. package/cli/site-input-assets.mjs +58 -0
  38. package/cli/site-inputs.mjs +79 -0
  39. package/cli/site-markup.mjs +102 -0
  40. package/cli/site-preview.mjs +53 -0
  41. package/cli/site-private.mjs +36 -0
  42. package/cli/site-references.mjs +93 -0
  43. package/cli/site-render.mjs +79 -0
  44. package/cli/site-syntax.mjs +21 -0
  45. package/cli/site.mjs +130 -0
  46. package/global/CHANGELOG.md +88 -0
  47. package/global/README.md +113 -2
  48. package/global/checkout.mjs +24 -10
  49. package/global/cli/install.mjs +6 -2
  50. package/global/delegate.mjs +9 -7
  51. package/global/door/command.mjs +254 -0
  52. package/global/door/control.mjs +65 -0
  53. package/global/door/cookies.mjs +41 -0
  54. package/global/door/pages.mjs +49 -0
  55. package/global/door/policy.mjs +49 -0
  56. package/global/door/routes.mjs +45 -0
  57. package/global/door/server.mjs +255 -0
  58. package/global/door/sites.mjs +192 -0
  59. package/global/launch/dev.mjs +21 -7
  60. package/global/launch/door.mjs +36 -0
  61. package/global/launch/install-pinned.mjs +22 -0
  62. package/global/launch/prepare.mjs +38 -5
  63. package/global/launch/render.mjs +6 -1
  64. package/global/launch/steps.mjs +12 -7
  65. package/global/launch/worker.mjs +3 -3
  66. package/global/list.mjs +6 -3
  67. package/global/main.mjs +39 -3
  68. package/global/output.mjs +12 -0
  69. package/global/rer.mjs +3 -0
  70. package/global/version.mjs +8 -8
  71. package/modules/dependencies/protocol.mjs +2 -1
  72. package/modules/deploy-kit/README.md +34 -1
  73. package/modules/deploy-kit/artifact.mjs +10 -0
  74. package/modules/deploy-kit/deploy.mjs +5 -2
  75. package/modules/deploy-kit/diagnostics.mjs +55 -0
  76. package/modules/deploy-kit/owner-cli.mjs +10 -4
  77. package/modules/deploy-kit/owner.mjs +8 -4
  78. package/modules/deploy-kit/prepare.mjs +3 -2
  79. package/modules/deploy-kit/provider.mjs +51 -9
  80. package/modules/deploy-kit/site.mjs +135 -0
  81. package/modules/erasure/CONTRACT.md +6 -3
  82. package/modules/erasure/README.md +8 -1
  83. package/modules/erasure/audit.mjs +2 -2
  84. package/modules/erasure/cascade.mjs +52 -11
  85. package/modules/erasure/order.mjs +29 -22
  86. package/modules/erasure/plan.mjs +4 -2
  87. package/modules/erasure/queue.mjs +41 -0
  88. package/modules/erasure/scope.mjs +3 -3
  89. package/modules/erasure/verification.mjs +4 -2
  90. package/modules/mail/README.md +1 -1
  91. package/modules/mail/index.mjs +1 -1
  92. package/modules/mail/src/core/errors.mjs +9 -0
  93. package/modules/mail/src/development/index.d.mts +6 -0
  94. package/modules/mail/src/development/index.mjs +6 -3
  95. package/modules/mail/src/development/viewer-html.mjs +3 -3
  96. package/modules/mail/src/development/viewer.mjs +8 -1
  97. package/modules/operator-auth/accounts/native.mjs +1 -1
  98. package/modules/operator-auth/dev/badge.mjs +4 -1
  99. package/modules/operator-auth/dev/index.mjs +15 -5
  100. package/modules/operator-auth/http.mjs +2 -3
  101. package/modules/operator-auth/index.d.mts +4 -9
  102. package/modules/operator-auth/loopback.mjs +17 -0
  103. package/modules/operator-auth/page.mjs +1 -1
  104. package/modules/operator-auth/service.mjs +1 -0
  105. package/modules/operator-auth/signin/index.d.mts +11 -0
  106. package/modules/operator-auth/signin/index.mjs +10 -7
  107. package/modules/owner-claim/verify.mjs +2 -1
  108. package/modules/portability/README.md +8 -0
  109. package/modules/portability/hash.mjs +6 -2
  110. package/modules/portability/import.mjs +1 -1
  111. package/modules/portability/legacy-files.mjs +4 -4
  112. package/modules/portability/legacy-safety.mjs +51 -10
  113. package/modules/portability/migration-history.mjs +15 -0
  114. package/modules/release/README.md +68 -3
  115. package/modules/release/keygen.mjs +18 -0
  116. package/modules/release/produce.mjs +14 -2
  117. package/modules/release/sign.mjs +44 -0
  118. package/modules/release/signature.mjs +336 -0
  119. package/modules/release/signing-cli.mjs +56 -0
  120. package/modules/release/signing-io.mjs +61 -0
  121. package/modules/release/signing-key.mjs +22 -0
  122. package/modules/release/source.mjs +15 -4
  123. package/modules/release/statement.mjs +80 -0
  124. package/modules/release/vectors/generate.mjs +381 -0
  125. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  126. package/modules/release/verify.mjs +27 -0
  127. package/onboarding/adapter-auth.mjs +4 -3
  128. package/onboarding/adapter-settings.mjs +21 -5
  129. package/onboarding/adapter.mjs +2 -1
  130. package/onboarding/app-fetch.mjs +79 -0
  131. package/onboarding/archive-output.d.mts +4 -2
  132. package/onboarding/archive-output.mjs +22 -10
  133. package/onboarding/dependency-approval.mjs +4 -3
  134. package/onboarding/local/environment.mjs +7 -2
  135. package/onboarding/operator-install.mjs +8 -2
  136. package/onboarding/operator-prerequisites.mjs +5 -2
  137. package/onboarding/production-assets.mjs +23 -0
  138. package/onboarding/production-runtime.mjs +8 -4
  139. package/onboarding/queued-entry.mjs +9 -5
  140. package/operations/operations.mjs +11 -2
  141. package/package.json +76 -1
  142. package/scripts/publish-check/README.md +27 -17
  143. package/scripts/publish-check/content_identities.py +14 -233
  144. package/scripts/publish-check/git_history.py +2 -2
  145. package/scripts/publish-check/github_refs.py +28 -2
  146. package/scripts/publish-check/gitleaks.toml +1 -1
  147. package/scripts/publish-check/history_scan.py +6 -4
  148. package/scripts/publish-check/hygiene_config.py +3 -1
  149. package/scripts/publish-check/public_hygiene.py +63 -8
  150. package/scripts/publish-check/publication-config.json +16 -1
  151. package/scripts/publish-check/source_metadata.py +62 -0
  152. package/scripts/publish-check/source_shapes.py +91 -0
  153. package/scripts/publish-check/tree_scan.py +2 -2
  154. package/scripts/publish-check/source_hashes.mjs +0 -6
@@ -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
- const keep = new Set([name, previousName].filter(Boolean));
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);
@@ -1,13 +1,15 @@
1
- // Run an invocation with the checkout's own dispatcher, in this process, exactly
2
- // as `node scripts/rer.mjs ...` would from the project root: process.argv[1] is
3
- // that file, so its Node-pin re-exec and queued child spawns behave as they do
4
- // when it is run directly. The dispatcher sets process.exitCode itself.
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
+ }
@@ -0,0 +1,49 @@
1
+ // The door's own small pages: the index, starting, start button, failure and
2
+ // unknown-name pages. Plain HTML; the starting page polls the door's state
3
+ // path (a fetch, which never starts a site) and reloads, keeping the fragment.
4
+ const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
5
+
6
+ const style = `:root{color-scheme:light dark;--bg:#f7f7f5;--fg:#1d1d1b;--muted:#666;--line:#ddd;--accent:#2457c5}
7
+ @media (prefers-color-scheme:dark){:root{--bg:#161615;--fg:#ecece8;--muted:#a3a39d;--line:#333;--accent:#8fb0ff}}
8
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,sans-serif}
9
+ main{max-width:760px;margin:0 auto;padding:40px 16px}h1{font-size:20px;margin:0 0 4px}p{margin:8px 0}.muted{color:var(--muted)}
10
+ table{width:100%;border-collapse:collapse;margin:20px 0}th,td{text-align:left;padding:8px 6px;border-bottom:1px solid var(--line);vertical-align:top}
11
+ a{color:var(--accent)}code{font:13px ui-monospace,monospace}button{font:inherit;padding:6px 14px;border:1px solid var(--line);border-radius:6px;background:transparent;color:var(--fg);cursor:pointer}
12
+ pre{white-space:pre-wrap;background:rgba(127,127,127,.12);padding:12px;border-radius:6px;font:13px ui-monospace,monospace}`;
13
+
14
+ function page(title, body, { script = '' } = {}) {
15
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="origin"><title>${escape(title)}</title><style>${style}</style></head><body><main>${body}</main>${script ? `<script>${script}</script>` : ''}</body></html>`;
16
+ }
17
+
18
+ export const rebootNote = 'After a reboot or logout the front door needs <code>rer door start</code> (or any <code>rer dev</code> in a door-mode checkout).';
19
+
20
+ export function indexPage({ port, version, sites }) {
21
+ const rows = sites.map(site => {
22
+ const link = site.mode === 'door' || site.state !== 'running' ? site.doorUrl : site.url;
23
+ const direct = site.mode === 'direct' ? ` <span class="muted">direct${site.url && site.url !== site.doorUrl ? `: <a href="${escape(site.url)}">${escape(site.url)}</a>` : ''}</span>` : '';
24
+ return `<tr><td><a href="${escape(link)}/">${escape(site.name)}</a>${direct}</td><td>${escape(site.state)}</td><td class="muted"><code>${escape(site.path)}</code></td></tr>`;
25
+ }).join('');
26
+ return page('rer front door', `<h1>rer front door</h1><p class="muted">Port ${port} · rer ${escape(version)}. A site starts when you open it and stops when idle.</p>
27
+ ${sites.length ? `<table><thead><tr><th>Site</th><th>State</th><th>Checkout</th></tr></thead><tbody>${rows}</tbody></table>` : '<p>No checkouts are registered. Add one with <code>rer door add PATH</code>.</p>'}
28
+ <p class="muted">Add a checkout with <code>rer door add PATH</code>; see sign-in links with <code>rer door status --links</code>. ${rebootNote}</p>`);
29
+ }
30
+
31
+ const poll = `const fragment=location.hash;async function check(){try{const r=await fetch('/__rer/door/state',{cache:'no-store'});const s=await r.json();
32
+ if(s.state==='running'){if(s.redirect){location.replace(s.redirect+location.pathname+location.search+fragment);}else{location.reload();}return;}
33
+ if(s.state==='failed'){document.getElementById('status').textContent='The site could not start:';const p=document.getElementById('reason');p.textContent=s.reason;p.hidden=false;return;}}catch{}setTimeout(check,1000);}check();`;
34
+
35
+ export function startingPage(name) {
36
+ return page(`Starting ${name}`, `<h1>Starting ${escape(name)}…</h1><p id="status" class="muted">This page reloads when the site is ready. A first start can take a minute.</p><pre id="reason" hidden></pre>`, { script: poll });
37
+ }
38
+
39
+ export function confirmPage(name, to) {
40
+ return page(`Start ${name}?`, `<h1>${escape(name)} is not running</h1><p>Another site sent you here. Start it?</p><form method="post" action="/__rer/door/start?to=${encodeURIComponent(to)}"><button type="submit">Start ${escape(name)}</button></form>`);
41
+ }
42
+
43
+ export function failurePage(name, reason, to) {
44
+ return page(`${name} could not start`, `<h1>${escape(name)} could not start</h1><pre>${escape(reason)}</pre><form method="post" action="/__rer/door/start?to=${encodeURIComponent(to)}"><button type="submit">Try again</button></form>`);
45
+ }
46
+
47
+ export function unknownPage(name, port) {
48
+ return page('No such site', `<h1>No checkout is registered as ${escape(name)}</h1><p>Register a checkout with <code>rer door add PATH</code>, then open <code>http://NAME.localhost:${port}</code>. <a href="http://localhost:${port}/">All sites</a>.</p>`);
49
+ }
@@ -0,0 +1,49 @@
1
+ // The door's policy is stored, never inherited: `rer door enable` records the
2
+ // limit, the idle time and the RER_* variables in effect at that moment, and
3
+ // every site the door starts gets exactly those on top of an allowlisted base
4
+ // environment. Whoever later starts the door (a fleet agent, a test) cannot
5
+ // change them by running it with another environment.
6
+ import { resolve } from 'node:path';
7
+ import { doorDefaults, doorDirectory } from '../../cli/door.mjs';
8
+ import { serversDirectory } from '../registry.mjs';
9
+
10
+ const base = new Set(['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'TMPDIR', 'TERM']);
11
+ // Set by the policy itself, so never recorded from a shell.
12
+ const policyVariables = new Set(['RER_SERVER_LIMIT', 'RER_SERVER_IDLE_SECONDS']);
13
+
14
+ export function baseEnvironment(env = process.env) {
15
+ return Object.fromEntries(Object.entries(env).filter(([name, value]) => value !== undefined && (base.has(name) || name.startsWith('LC_'))));
16
+ }
17
+
18
+ export function recordedEnvironment(env = process.env) {
19
+ return Object.fromEntries(Object.entries(env).filter(([name, value]) => value !== undefined && name.startsWith('RER_') && !policyVariables.has(name)).sort(([a], [b]) => a.localeCompare(b)));
20
+ }
21
+
22
+ function count(value, name, minimum = 1) {
23
+ if (!/^[0-9]+$/.test(value ?? '') || !Number.isSafeInteger(Number(value)) || Number(value) < minimum) throw Object.assign(Error(`${name} takes a whole number of at least ${minimum}.`), { exitCode: 2 });
24
+ return Number(value);
25
+ }
26
+
27
+ // What `rer door enable` records: flags, else the person defaults.
28
+ export function enabledPolicy({ port, serverLimit, idleTimeout }, env = process.env) {
29
+ const chosen = port === undefined ? undefined : count(port, '--port');
30
+ if (chosen !== undefined && chosen > 65535) throw Object.assign(Error('--port takes a port from 1 to 65535.'), { exitCode: 2 });
31
+ return {
32
+ port: chosen,
33
+ policy: { serverLimit: serverLimit === undefined ? doorDefaults.serverLimit : count(serverLimit, '--server-limit'), idleSeconds: idleTimeout === undefined ? doorDefaults.idleSeconds : count(idleTimeout, '--idle-timeout') },
34
+ directory: doorDirectory(env),
35
+ serversDir: resolve(serversDirectory(env)),
36
+ environment: recordedEnvironment(env),
37
+ };
38
+ }
39
+
40
+ // The environment of a site the door starts.
41
+ export function siteEnvironment(door, env = process.env) {
42
+ return { ...baseEnvironment(env), ...door.environment, RER_DOOR_DIR: door.directory, RER_SERVERS_DIR: door.serversDir, RER_SERVER_LIMIT: String(door.policy.serverLimit), RER_SERVER_IDLE_SECONDS: String(door.policy.idleSeconds) };
43
+ }
44
+
45
+ // The environment of the door process itself.
46
+ export const doorProcessEnvironment = (door, env = process.env) => ({ ...baseEnvironment(env), RER_DOOR_DIR: door.directory });
47
+
48
+ // `rer dev` may bring an enabled door back only for the registry it records.
49
+ export const ownsRegistry = (door, env = process.env) => doorDirectory(env) === door.directory && resolve(serversDirectory(env)) === door.serversDir;
@@ -0,0 +1,45 @@
1
+ // How the door reads a request: which Host it names, and whether it may start
2
+ // a stopped site. Measured facts behind the start rule (Chromium 140-154,
3
+ // WebKit 26, Safari 27): only a top-level document navigation carries
4
+ // Sec-Fetch-Mode navigate with Sec-Fetch-Dest document; iframes say iframe,
5
+ // subresources and fetch say no-cors/cors, Chromium sends no Sec-Fetch-* on a
6
+ // WebSocket upgrade, and an open tab's Vite ping is a fetch.
7
+ import { isLocalhostName } from '../../modules/operator-auth/loopback.mjs';
8
+
9
+ export const indexHostnames = new Set(['localhost', '127.0.0.1', '[::1]']);
10
+
11
+ // { kind: 'index' | 'site' | 'redirect' | 'foreign', name?, host?, canonical? }
12
+ // Matching is case-insensitive: any letter case of a door Host is answered as
13
+ // the lowercase Host is, and `host` is that canonical lowercase spelling, which
14
+ // the door sends upstream (a site's dev server allows only lowercase
15
+ // *.localhost). A trailing-dot Host redirects to the canonical Host.
16
+ export function readHost(header, port) {
17
+ const match = /^(\[[0-9a-f:.]+\]|[^:[\]]+)(?::(\d{1,5}))?$/.exec(String(header ?? '').toLowerCase());
18
+ if (!match || Number(match[2] ?? 80) !== port) return { kind: 'foreign' };
19
+ const hostname = match[1].replace(/\.+$/, ''), host = `${hostname}:${port}`;
20
+ const index = indexHostnames.has(hostname);
21
+ if (!index && !isLocalhostName(hostname)) return { kind: 'foreign' };
22
+ if (hostname !== match[1]) return { kind: 'redirect', canonical: host };
23
+ if (index) return { kind: 'index', host };
24
+ // Subdomains route to their site: x.cradle.localhost is cradle.
25
+ return { kind: 'site', name: hostname.slice(0, -'.localhost'.length).split('.').pop(), host };
26
+ }
27
+
28
+ const refererOrigin = value => { try { return new URL(value).origin; } catch { return undefined; } };
29
+
30
+ // 'start': a top-level navigation the person made (typed, bookmarked, same
31
+ // site, from the door's own index, or a client without fetch metadata asking
32
+ // for HTML). 'confirm': another site's navigation, which gets a Start button.
33
+ // 'never': everything else (subresources, fetch/XHR, prefetch, OPTIONS, other
34
+ // methods and every WebSocket upgrade).
35
+ export function startKind(request, indexOrigins) {
36
+ const headers = request.headers;
37
+ if (headers.upgrade || !['GET', 'HEAD'].includes(request.method)) return 'never';
38
+ if (headers['sec-purpose'] || headers.purpose || headers['x-purpose'] || headers['x-moz'] === 'prefetch') return 'never';
39
+ const mode = headers['sec-fetch-mode'], dest = headers['sec-fetch-dest'], site = headers['sec-fetch-site'];
40
+ if (mode === undefined && dest === undefined && site === undefined) return /\btext\/html\b/i.test(headers.accept ?? '') ? 'start' : 'never';
41
+ if (mode !== 'navigate' || dest !== 'document') return 'never';
42
+ if (['none', 'same-origin', 'same-site'].includes(site)) return 'start';
43
+ if (indexOrigins.has(refererOrigin(headers.referer))) return 'start';
44
+ return 'confirm';
45
+ }