@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.
Files changed (109) hide show
  1. package/README.md +115 -2
  2. package/cli/README.md +28 -3
  3. package/cli/contract.mjs +4 -4
  4. package/cli/dev-identity.mjs +2 -1
  5. package/cli/door-mode.mjs +134 -0
  6. package/cli/door.mjs +263 -0
  7. package/cli/hosted-output.mjs +15 -5
  8. package/cli/hydration/browser.mjs +91 -0
  9. package/cli/hydration/cases.mjs +106 -0
  10. package/cli/hydration/check.mjs +105 -0
  11. package/cli/hydration/probe.mjs +164 -0
  12. package/cli/hydration/scripts.mjs +39 -0
  13. package/cli/hydration/verdict.mjs +85 -0
  14. package/cli/inspect.mjs +56 -9
  15. package/cli/lifecycle.mjs +36 -12
  16. package/cli/local.mjs +9 -3
  17. package/cli/main.mjs +2 -0
  18. package/cli/managed-server.mjs +52 -18
  19. package/cli/package-calls.mjs +81 -0
  20. package/cli/package-mode.mjs +46 -0
  21. package/cli/parse.mjs +14 -4
  22. package/cli/pnpm-runtime.mjs +96 -0
  23. package/cli/portability-worker.mjs +2 -2
  24. package/cli/runtime-front-door.mjs +4 -2
  25. package/cli/server-proxy.mjs +69 -5
  26. package/cli/server-registry.mjs +19 -11
  27. package/cli/settings.mjs +14 -0
  28. package/cli/site-check.mjs +96 -0
  29. package/cli/site-preview.mjs +53 -0
  30. package/cli/site-render.mjs +77 -0
  31. package/cli/site.mjs +109 -0
  32. package/global/CHANGELOG.md +63 -0
  33. package/global/README.md +115 -2
  34. package/global/checkout.mjs +24 -10
  35. package/global/cli/install.mjs +6 -2
  36. package/global/delegate.mjs +9 -7
  37. package/global/door/command.mjs +254 -0
  38. package/global/door/control.mjs +65 -0
  39. package/global/door/cookies.mjs +41 -0
  40. package/global/door/pages.mjs +49 -0
  41. package/global/door/policy.mjs +49 -0
  42. package/global/door/routes.mjs +45 -0
  43. package/global/door/server.mjs +255 -0
  44. package/global/door/sites.mjs +192 -0
  45. package/global/launch/dev.mjs +25 -23
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/facts.mjs +3 -2
  48. package/global/launch/install-pinned.mjs +22 -0
  49. package/global/launch/prepare.mjs +41 -6
  50. package/global/launch/render.mjs +6 -1
  51. package/global/launch/state.mjs +10 -1
  52. package/global/launch/steps.mjs +24 -10
  53. package/global/launch/stop.mjs +89 -0
  54. package/global/launch/worker.mjs +30 -11
  55. package/global/list.mjs +6 -3
  56. package/global/main.mjs +37 -3
  57. package/global/version.mjs +8 -8
  58. package/modules/dependencies/protocol.mjs +2 -1
  59. package/modules/deploy-kit/README.md +34 -1
  60. package/modules/deploy-kit/artifact.mjs +10 -0
  61. package/modules/deploy-kit/owner.mjs +5 -4
  62. package/modules/deploy-kit/prepare.mjs +3 -2
  63. package/modules/deploy-kit/provider.mjs +6 -3
  64. package/modules/deploy-kit/site.mjs +135 -0
  65. package/modules/erasure/CONTRACT.md +6 -3
  66. package/modules/erasure/README.md +8 -1
  67. package/modules/erasure/audit.mjs +2 -2
  68. package/modules/erasure/cascade.mjs +52 -11
  69. package/modules/erasure/order.mjs +29 -22
  70. package/modules/erasure/plan.mjs +4 -2
  71. package/modules/erasure/queue.mjs +41 -0
  72. package/modules/erasure/scope.mjs +3 -3
  73. package/modules/erasure/verification.mjs +4 -2
  74. package/modules/mail/README.md +1 -1
  75. package/modules/mail/index.mjs +1 -1
  76. package/modules/mail/src/core/errors.mjs +9 -0
  77. package/modules/mail/src/development/index.d.mts +6 -0
  78. package/modules/mail/src/development/index.mjs +6 -3
  79. package/modules/mail/src/development/viewer-html.mjs +3 -3
  80. package/modules/mail/src/development/viewer.mjs +8 -1
  81. package/modules/operator-auth/dev/index.mjs +15 -5
  82. package/modules/operator-auth/http.mjs +2 -3
  83. package/modules/operator-auth/loopback.mjs +17 -0
  84. package/modules/owner-claim/verify.mjs +2 -1
  85. package/modules/portability/README.md +8 -0
  86. package/modules/portability/hash.mjs +6 -2
  87. package/modules/portability/import.mjs +1 -1
  88. package/modules/portability/legacy-files.mjs +4 -4
  89. package/modules/portability/legacy-safety.mjs +51 -10
  90. package/modules/portability/migration-history.mjs +15 -0
  91. package/modules/release/README.md +5 -3
  92. package/modules/release/produce.mjs +5 -2
  93. package/modules/release/source.mjs +15 -4
  94. package/onboarding/adapter-auth.mjs +4 -3
  95. package/onboarding/adapter-settings.mjs +21 -5
  96. package/onboarding/adapter.mjs +2 -1
  97. package/onboarding/app-fetch.mjs +79 -0
  98. package/onboarding/archive-output.d.mts +4 -2
  99. package/onboarding/archive-output.mjs +22 -10
  100. package/onboarding/dependency-approval.mjs +4 -3
  101. package/onboarding/local/environment.mjs +7 -2
  102. package/onboarding/operator-install.mjs +8 -2
  103. package/onboarding/operator-prerequisites.mjs +5 -2
  104. package/onboarding/queued-entry.mjs +9 -5
  105. package/operations/operations.mjs +11 -2
  106. package/package.json +75 -1
  107. package/scripts/publish-check/github_refs.py +28 -2
  108. package/scripts/publish-check/public_hygiene.py +35 -4
  109. package/scripts/publish-check/publication-config.json +14 -1
@@ -0,0 +1,164 @@
1
+ // The in-page half of the hydration check. `probe` is serialized into an init
2
+ // script, so it must stay self-contained (no imports, no closures over module
3
+ // scope). It runs before the document parses, while the check holds every
4
+ // application script in the network layer:
5
+ // - when parsing ends (readyState "interactive"), it surveys the page sections,
6
+ // makes the case's edit, snapshots every element in <body> and starts sampling;
7
+ // - it releases the held scripts (window.__rerRelease) once every one-shot
8
+ // animation has finished ("after") or at the midpoint of the edited section's
9
+ // intro window ("mid"), recording whether React had already hydrated;
10
+ // - every frame it samples one-shot animations, the opacity of animated elements
11
+ // and which page sections are present;
12
+ // - once React owns the page and the DOM and animations are quiet, it records
13
+ // which snapshot elements were replaced and sets settledAt.
14
+ // Elements are matched across a rebuild by a structural key (tag:index path from
15
+ // <body>) that ignores script, style, link, template and noscript elements and the
16
+ // nodes the probe inserted.
17
+ export function probe(config) {
18
+ const S = window.__rerProbe = { config, survey: null, edit: null, editBeforeHydration: null, sectionKeys: [], addressSections: [], addressLoose: false,
19
+ replaced: [], starts: {}, opacity: {}, missing: [], frames: 0, oneShot: [], releasedAt: null, releaseNote: null, fiberAtRelease: null, hydratedAt: null, settledAt: null, settled: null, rer: null, error: null };
20
+ const SECTION = new Set(['HEADER', 'NAV', 'SECTION', 'ARTICLE', 'ASIDE', 'FOOTER']), SKIP = new Set(['SCRIPT', 'STYLE', 'LINK', 'TEMPLATE', 'NOSCRIPT']);
21
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/, ADDRESS = '.__cf_email__,a[href*="/cdn-cgi/l/email-protection"]';
22
+ const inserted = new WeakSet(), tracked = new WeakMap(), watch = new Set();
23
+ const now = () => Math.round(performance.now() * 10) / 10;
24
+ const own = (el, prefix) => { for (const key of Object.keys(el)) if (key.startsWith(prefix)) return el[key]; return undefined; };
25
+ const fiber = el => own(el, '__reactFiber$') !== undefined;
26
+ const counted = el => !SKIP.has(el.tagName) && !inserted.has(el);
27
+ const kids = el => [...el.children].filter(counted);
28
+ const keyOf = el => {
29
+ const parts = [];
30
+ for (let e = el; e !== document.body; e = e.parentElement) { if (!e || !e.parentElement || !counted(e)) return null; parts.unshift(e.tagName.toLowerCase() + ':' + kids(e.parentElement).indexOf(e)); }
31
+ return parts.join('>');
32
+ };
33
+ const resolveKey = key => {
34
+ let el = document.body;
35
+ for (const part of key.split('>')) { const at = part.lastIndexOf(':'); el = kids(el)[+part.slice(at + 1)]; if (!el || el.tagName.toLowerCase() !== part.slice(0, at)) return null; }
36
+ return el;
37
+ };
38
+ const walk = (el, key, out) => { let i = 0; for (const child of el.children) { if (!counted(child)) continue; const k = (key ? key + '>' : '') + child.tagName.toLowerCase() + ':' + i++; out.push([child, k]); walk(child, k, out); } return out; };
39
+ const outermost = () => { const out = []; const visit = el => { for (const child of el.children) { if (!counted(child)) continue; if (SECTION.has(child.tagName)) out.push(child); else visit(child); } }; visit(document.body); return out; };
40
+ const label = el => el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.classList.length ? '.' + el.classList[0] : '') + (el.getAttribute('aria-label') ? `[aria-label="${el.getAttribute('aria-label')}"]` : '');
41
+ const texts = root => {
42
+ const out = [], walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
43
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) { const p = n.parentElement; if (p && !p.closest('script,style,template,noscript,textarea,svg') && n.nodeValue.trim()) out.push(n); }
44
+ return out;
45
+ };
46
+ // A text node with sibling nodes is a separate text fiber: splitting it always mismatches.
47
+ const mixed = n => n.nodeValue.trim().length >= 4 && n.parentNode.childNodes.length >= 2;
48
+ const lone = n => n.nodeValue.trim().length >= 2 && n.parentNode.childNodes.length === 1;
49
+ const split = n => { const rest = n.splitText(Math.max(1, Math.floor(n.nodeValue.length / 2))), span = document.createElement('span'); inserted.add(span); span.textContent = rest.nodeValue; rest.replaceWith(span); };
50
+ const anyFiber = () => fiber(document.body) || [...document.body.querySelectorAll('*')].some(fiber);
51
+ // An element React has not hydrated yet: no fiber, under a parent React owns that does not set its HTML.
52
+ const pending = () => [...document.body.querySelectorAll('*')].filter(el => counted(el) && !el.closest('svg') && !fiber(el) && el.parentElement && fiber(el.parentElement) && !own(el.parentElement, '__reactProps$')?.dangerouslySetInnerHTML).length;
53
+ const oneShot = () => document.getAnimations().filter(a => {
54
+ if (typeof CSSTransition !== 'undefined' && a instanceof CSSTransition) return false;
55
+ const timing = a.effect && a.effect.getComputedTiming();
56
+ return timing && Number.isFinite(timing.endTime) && timing.activeDuration >= 50;
57
+ });
58
+ const outside = n => !outermost().some(section => section.contains(n));
59
+ let snapshot = [], scope = null, editAt = null, lastMutation = 0;
60
+
61
+ function begin() {
62
+ const sections = outermost();
63
+ S.sectionKeys = sections.map(keyOf);
64
+ S.survey = {
65
+ sections: sections.map(section => { const all = texts(section); return { key: keyOf(section), label: label(section), split: all.some(mixed), lone: all.some(lone) }; }),
66
+ loose: texts(document.body).some(n => outside(n) && mixed(n)),
67
+ advisories: texts(document.body).filter(n => mixed(n) && EMAIL.test(n.nodeValue)).map(n => { const section = sections.find(s => s.contains(n)); return { section: section ? label(section) : 'outside every section', text: n.nodeValue.trim().slice(0, 80) }; }),
68
+ };
69
+ S.addressSections = sections.filter(s => s.querySelector(ADDRESS) || EMAIL.test(s.textContent)).map(keyOf);
70
+ S.addressLoose = texts(document.body).some(n => outside(n) && EMAIL.test(n.nodeValue)) || [...document.querySelectorAll(ADDRESS)].some(outside);
71
+ S.editBeforeHydration = !anyFiber();
72
+ if (config.kind === 'section' || config.kind === 'loose') {
73
+ const root = config.kind === 'section' ? sections[config.section] : document.body;
74
+ if (config.kind === 'section') scope = root;
75
+ const candidates = root ? texts(root).filter(n => config.kind === 'section' || outside(n)) : [];
76
+ const target = candidates.find(mixed) ?? (config.kind === 'section' ? candidates.find(lone) : undefined);
77
+ if (target) {
78
+ const mode = mixed(target) ? 'split' : 'text';
79
+ S.edit = { mode, parent: keyOf(target.parentElement), text: target.nodeValue.trim().slice(0, 60) };
80
+ if (mode === 'split') split(target); else target.nodeValue = 'Übersetzt: ' + target.nodeValue;
81
+ }
82
+ }
83
+ snapshot = walk(document.body, '', []);
84
+ editAt = now();
85
+ new MutationObserver(() => { lastMutation = now(); }).observe(document.body, { childList: true, subtree: true, characterData: true });
86
+ requestAnimationFrame(frame);
87
+ }
88
+
89
+ function release(note) {
90
+ // React owning any element already means a script the check could not hold (an
91
+ // inline module script) hydrated the page, so the release timing was not exercised.
92
+ S.releasedAt = now(); S.releaseNote = note ?? null; S.fiberAtRelease = anyFiber();
93
+ window.__rerRelease();
94
+ }
95
+
96
+ function tryRelease() {
97
+ if (S.releasedAt !== null || S.frames < 3) return;
98
+ const animations = oneShot();
99
+ if (config.release === 'mid') {
100
+ const mine = animations.filter(a => (scope ?? document.body).contains(a.effect.target));
101
+ if (!mine.length) return release('no intro to release into');
102
+ if (mine.some(a => a.startTime === null)) return;
103
+ const start = Math.min(...mine.map(a => a.startTime + a.effect.getTiming().delay)), end = Math.max(...mine.map(a => a.startTime + a.effect.getComputedTiming().endTime));
104
+ const at = document.timeline.currentTime;
105
+ if (at >= (start + end) / 2) release(`${Math.round(100 * (at - start) / (end - start))}% through the intro window (${Math.round(start)}-${Math.round(end)} ms)`);
106
+ return;
107
+ }
108
+ if (animations.some(a => a.playState !== 'finished') && now() - editAt < 15000) return;
109
+ release();
110
+ }
111
+
112
+ function sample() {
113
+ S.frames++;
114
+ for (const a of oneShot()) {
115
+ const el = a.effect.target;
116
+ if (!el || !el.isConnected) continue;
117
+ let record = tracked.get(el);
118
+ // A new element object at a key gets a new id, so a rebuilt element is a second entry.
119
+ if (!record) { const key = keyOf(el); if (key === null) continue; record = { key, id: S.oneShot.length + 1 }; tracked.set(el, record); watch.add(key); }
120
+ const name = a.animationName || a.id || 'animation', list = (S.starts[record.key + ' ' + name] ??= []);
121
+ if (!S.oneShot.includes(record.key + ' ' + name)) S.oneShot.push(record.key + ' ' + name);
122
+ if (a.startTime !== null && !list.some(item => item.id === record.id)) list.push({ id: record.id, start: Math.round((a.startTime + a.effect.getTiming().delay) * 10) / 10, seen: now() });
123
+ }
124
+ for (const key of watch) {
125
+ const el = resolveKey(key);
126
+ if (!el) continue;
127
+ const opacity = +getComputedStyle(el).opacity, record = (S.opacity[key] ??= { first: opacity, max: opacity, dip: null });
128
+ if (opacity > record.max) record.max = opacity;
129
+ else if (record.max > record.first && record.max - opacity > 0.15 && (!record.dip || record.max - opacity > record.dip.from - record.dip.to)) record.dip = { from: record.max, to: opacity, at: now() };
130
+ }
131
+ const present = new Set(outermost().map(keyOf)), lost = S.sectionKeys.filter(key => !present.has(key));
132
+ if (lost.length) S.missing.push({ at: now(), lost });
133
+ }
134
+
135
+ function frame() {
136
+ if (S.settledAt !== null) return;
137
+ try {
138
+ sample();
139
+ tryRelease();
140
+ const t = now();
141
+ if (S.releasedAt !== null) {
142
+ if (S.hydratedAt === null && fiber(document.body) && pending() === 0) S.hydratedAt = t;
143
+ const quiet = t - lastMutation > 300 && !oneShot().some(a => a.playState === 'running');
144
+ if (S.hydratedAt !== null && t - S.hydratedAt > 300 && quiet) return settle('hydrated');
145
+ if (S.hydratedAt === null && t - S.releasedAt > 8000 && !anyFiber()) return settle('not hydrated');
146
+ if (t - S.releasedAt > 20000) return settle('timeout');
147
+ }
148
+ } catch (error) { S.error = String(error && error.stack || error); return settle('error'); }
149
+ requestAnimationFrame(frame);
150
+ }
151
+
152
+ function settle(state) {
153
+ S.settled = state; S.settledAt = now();
154
+ S.replaced = snapshot.filter(([el]) => !el.isConnected).map(([, key]) => key);
155
+ S.rer = window.__rerHydration ? JSON.parse(JSON.stringify(window.__rerHydration)) : null;
156
+ }
157
+
158
+ let begun = false;
159
+ document.addEventListener('readystatechange', () => {
160
+ if (begun || document.readyState === 'loading') return;
161
+ begun = true;
162
+ try { begin(); } catch (error) { S.error = String(error && error.stack || error); settle('error'); }
163
+ });
164
+ }
@@ -0,0 +1,39 @@
1
+ // Which scripts a page loads count as the application's, so a page that loads them
2
+ // and never hydrates fails rather than being skipped as plain HTML. They are:
3
+ // script requests to the page's own origin; module scripts and modulepreload links
4
+ // in the served document, from any origin (a Vite base on a CDN host); and
5
+ // cross-origin script requests sent in CORS mode, as every module fetch is, that
6
+ // the document does not name as a classic <script src>. A third-party classic
7
+ // script on a plain-HTML page is none of these.
8
+
9
+ const attribute = (attrs, name) => { const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, 'i').exec(attrs); return match ? (match[1] ?? match[2] ?? match[3]) : null; };
10
+ const resolveUrl = (href, base) => { try { return new URL(href, base).href; } catch { return null; } };
11
+
12
+ /** The module scripts (inline ones as "inline module script"), modulepreload links
13
+ * and classic script sources of a served document, as absolute URLs. */
14
+ export function documentScripts(html, pageUrl) {
15
+ const tag = /<!--[\s\S]*?-->|<(script|link)\b([^>]*)>/gi, modules = [], classic = [];
16
+ for (let match; (match = tag.exec(html));) {
17
+ const name = match[1]?.toLowerCase(), attrs = match[2] ?? '';
18
+ if (name === 'link') {
19
+ const href = attribute(attrs, 'href');
20
+ if (href && (attribute(attrs, 'rel') ?? '').toLowerCase().split(/\s+/).includes('modulepreload')) modules.push(resolveUrl(href, pageUrl));
21
+ } else if (name === 'script') {
22
+ const src = attribute(attrs, 'src'), module = (attribute(attrs, 'type') ?? '').trim().toLowerCase() === 'module';
23
+ if (module) modules.push(src ? resolveUrl(src, pageUrl) : 'inline module script');
24
+ else if (src) classic.push(resolveUrl(src, pageUrl));
25
+ const close = html.toLowerCase().indexOf('</script', tag.lastIndex);
26
+ tag.lastIndex = close < 0 ? html.length : close;
27
+ }
28
+ }
29
+ return { modules: modules.filter(Boolean), classic: classic.filter(Boolean) };
30
+ }
31
+
32
+ /** The application scripts of one load of `path`, given the document it was served:
33
+ * same-origin paths as paths, other origins as full URLs. */
34
+ export function applicationScripts(load, html, origin, path) {
35
+ const page = new URL(path, origin), show = url => { const parsed = new URL(url, page); return parsed.origin === page.origin ? parsed.pathname + parsed.search : parsed.href; };
36
+ const { modules, classic } = documentScripts(html ?? '', page.href), named = new Set(classic);
37
+ const found = [...load.scripts, ...modules.map(url => url === 'inline module script' ? url : show(url)), ...load.crossScripts.filter(url => !named.has(url)).map(show)];
38
+ return [...new Set(found)];
39
+ }
@@ -0,0 +1,85 @@
1
+ // Turns probe records into failures. Every failure names the page, the case, the
2
+ // section and its evidence.
3
+ import { describeCase } from './cases.mjs';
4
+
5
+ const LATE_MS = 34; // two frames
6
+ const within = (key, scope) => key === scope || key.startsWith(scope + '>');
7
+ const list = (keys, limit = 6) => keys.slice(0, limit).join(', ') + (keys.length > limit ? ` and ${keys.length - limit} more` : '');
8
+
9
+ /** Replays in one load: a one-shot animation on a rebuilt element starting more
10
+ * than two frames after the original did, or an animated element whose opacity
11
+ * dropped by more than 0.15 after rising (unless it did so on the plain load). */
12
+ export function replays(record, baseline) {
13
+ const found = [];
14
+ for (const [id, entries] of Object.entries(record.starts ?? {})) {
15
+ const [key, name] = id.split(' ');
16
+ for (const entry of entries.slice(1)) { const late = entry.start - entries[0].start; if (late > LATE_MS) { found.push(`${key} restarted its ${name} animation ${Math.round(late)} ms late`); break; } }
17
+ }
18
+ for (const [key, opacity] of Object.entries(record.opacity ?? {})) {
19
+ if (opacity.dip && !baseline?.opacity?.[key]?.dip) found.push(`${key} opacity fell from ${opacity.dip.from.toFixed(2)} to ${opacity.dip.to.toFixed(2)}`);
20
+ }
21
+ return found;
22
+ }
23
+
24
+ /** What one edit load did: replaced elements (less those the plain load replaces),
25
+ * those outside the region the case may rebuild, replays, and frames that lacked
26
+ * a page section present at the edit. */
27
+ export function measure(item, record, { survey, baseline }) {
28
+ const base = baseline.record ?? {}, excluded = new Set(base.replaced ?? []);
29
+ const replaced = (record.replaced ?? []).filter(key => !excluded.has(key));
30
+ let allowed = () => true;
31
+ if (item.kind === 'section') { const scope = survey.sections[item.section].key; allowed = key => within(key, scope); }
32
+ else if (item.kind === 'loose') allowed = key => !survey.sections.some(section => within(key, section.key));
33
+ else if (item.kind === 'cloudflare') allowed = key => (record.addressSections ?? []).some(scope => within(key, scope)) || (record.addressLoose && !survey.sections.some(section => within(key, section.key)));
34
+ return { replaced, outside: replaced.filter(key => !allowed(key)), replays: replays(record, base), missing: record.missing ?? [] };
35
+ }
36
+
37
+ /** A page the check may skip: React never hydrated it, it loads no application
38
+ * script (scripts.mjs) and its plain load raised no page or console error. A page
39
+ * whose scripts load but never hydrate (a broken client entry, or client scripts
40
+ * on another origin that the check aborts) is a failure, not a skip. */
41
+ export const plainHtml = (load, scripts) => load.record?.settled === 'not hydrated' && !scripts.length && !load.errors.length;
42
+
43
+ /** The failure for a page whose plain load never hydrated and is not plain HTML. */
44
+ export function judgeUnhydrated({ path, scripts, baseline: { record, errors } }) {
45
+ const state = record?.settled ?? 'no probe record';
46
+ const why = state === 'not hydrated' ? `React never hydrated the page${scripts.length ? ` although it loads application scripts (${list(scripts, 3)})` : ''}` : `the page did not settle (${state}${record?.error ? ': ' + record.error : ''})`;
47
+ return `${path} plain load: ${why}${errors.length ? `; ${list([...new Set(errors)], 3)}` : ''}`;
48
+ }
49
+
50
+ /** Failures for one page: its plain load, its case loads and its canaries. */
51
+ export function judgePage(page) {
52
+ const failures = [], { path, baseline, loads, survey } = page, base = baseline.record;
53
+ const fail = (what, evidence) => failures.push(`${path} ${what}: ${evidence}`);
54
+ const noisy = (what, load) => { if (load.errors.length) fail(what, `recovery was not quiet (${list([...new Set(load.errors)], 3)})`); };
55
+ const unheld = (what, load) => { if (load.unheld?.length) fail(what, `not exercised: the check's script hold let ${list(load.unheld, 3)} reach the page before the probe released the scripts`); return load.unheld?.length > 0; };
56
+ unheld('plain load', baseline);
57
+ noisy('plain load', baseline);
58
+ if (base.rer?.count) fail('plain load', `window.__rerHydration.count is ${base.rer.count} with no edit (${list(base.rer.stacks ?? [], 2)})`);
59
+ let exercised = false;
60
+ for (const { item, load } of loads) {
61
+ const what = describeCase(item, survey), record = load.record;
62
+ if (!record || record.settled !== 'hydrated') { fail(what, record?.settled === 'error' ? `the probe failed: ${record.error}` : `the page did not settle after hydration within the time limit (${record?.settled ?? 'no record'})`); continue; }
63
+ if (unheld(what, load)) continue;
64
+ noisy(what, load);
65
+ if (item.kind === 'plain') {
66
+ if (item.reduced && record.oneShot.length) fail(what, `one-shot animations ran: ${list(record.oneShot)}`);
67
+ if (record.rer?.count) fail(what, `window.__rerHydration.count is ${record.rer.count} with no edit`);
68
+ continue;
69
+ }
70
+ if (!record.editBeforeHydration) { fail(what, 'not exercised: the edit landed after React had started hydrating'); continue; }
71
+ if (record.fiberAtRelease !== false) { fail(what, 'not exercised: React hydrated before the probe released the scripts (a script the check cannot hold, such as the client bundle inlined in an inline module script, hydrated the page)'); continue; }
72
+ if (item.kind !== 'cloudflare' && !record.edit) { fail(what, 'not exercised: the probe found no text to edit'); continue; }
73
+ const { replaced, outside, replays: replayed, missing } = measure(item, record, page);
74
+ if (outside.length) fail(what, `React replaced ${outside.length} element(s) outside ${item.kind === 'section' ? 'the edited section' : item.kind === 'loose' ? 'the loose content (a page section was rebuilt)' : 'the sections holding addresses'}: ${list(outside)}`);
75
+ if (replaced.length) {
76
+ if (item.kind !== 'cloudflare') exercised = true;
77
+ if (!(record.rer?.count >= 1)) fail(what, `React rebuilt ${replaced.length} element(s) but window.__rerHydration.count is ${record.rer?.count ?? 'absent'}; the client entry must pass onRecoverableError: createRecoverableErrorHandler()`);
78
+ }
79
+ for (const replay of replayed) fail(what, `an intro replayed: ${replay}`);
80
+ if (missing.length) fail(what, `${missing.length} frame(s) lacked a page section present at the edit (${list([...new Set(missing.flatMap(frame => frame.lost))])})`);
81
+ }
82
+ if (survey.sections.length && loads.some(({ item }) => item.kind === 'section' || item.kind === 'loose') && !exercised) fail('all edits', 'not exercised: no edit before hydration made React rebuild anything, so the check proved nothing on this page');
83
+ if (survey.sections.length && !loads.some(({ item }) => item.kind === 'section')) fail('all edits', 'not exercised: no page section has text to edit');
84
+ return failures;
85
+ }
package/cli/inspect.mjs CHANGED
@@ -1,19 +1,23 @@
1
1
  import { dependencyDoctor } from './dependency-doctor.mjs';
2
2
  import { inventory } from './export.mjs';
3
- import { readFile } from 'node:fs/promises';
3
+ import { lstat, readFile } from 'node:fs/promises';
4
4
  import { createHash } from 'node:crypto';
5
- import { resolve } from 'node:path';
5
+ import { delimiter, dirname, resolve } from 'node:path';
6
6
  import { aliases, pins, requiredHooks, serverDefaults, verbs, releaseFlags } from './contract.mjs';
7
7
  import { runningVersion } from './version.mjs';
8
8
  import { contained, readPackage } from './project.mjs';
9
9
  import { shared } from './shared.mjs';
10
- import { missing } from './errors.mjs';
10
+ import { missing, RerError } from './errors.mjs';
11
11
  import { projectNodePin } from './node-runtime-discovery.mjs';
12
12
  import { localSettingKeys, requiredLocalSettings } from './settings.mjs';
13
13
  import { localAiDiagnostics } from './ai-local.mjs';
14
14
  import { authCopyClauses } from './auth-copy.mjs';
15
15
  import { cacheWarnings } from './cache-doctor.mjs';
16
16
  import { deployedMain } from './deploy-config.mjs';
17
+ import { commandArgv, commandLine, declaredPackage, exactVersion, lockedPackage, packageName, vendoredPaths } from './package-mode.mjs';
18
+ import { vendoredCalls } from './package-calls.mjs';
19
+ import { doorProtocol } from './door.mjs';
20
+ import { savedOrigin } from './door-mode.mjs';
17
21
 
18
22
  // cliVersion: the rer version of the code answering (absent for a vendored copy
19
23
  // too old to record one). defaults: what a server start applies when the person
@@ -28,8 +32,10 @@ export async function describe(project) {
28
32
  const capabilities = project.adapter?.verbs ?? {};
29
33
  return { contractVersion: 1, ...versioned, profile: 'first-party', defaults, pins: { node: pkg.engines?.node, pnpm: pkg.packageManager?.replace(/^pnpm@/, '') },
30
34
  verbs: Object.fromEntries(Object.entries(capabilities).map(([verb, capability]) => [verb, { flags: (verbs[verb]?.flags ?? releaseFlags).filter(flag => capability.flags?.includes(flag)), effects: verbs[verb]?.effects ?? 'Existing Base release producer.' }])),
31
- aliases: Object.fromEntries(Object.entries(aliases).filter(([alias, verb]) => pkg.scripts?.[alias] === `node scripts/rer.mjs${verb ? ' ' + verb : ''}`)),
32
- local: { state: project.adapter?.state, settings: project.adapter?.settings ?? [], installerState: '.seed/installer', outbox: '/__dev/outbox', ...await localState(project), ...(project.adapter?.devIdentity ? { devIdentity: true, devKey: '.rer/local/dev-key.env' } : {}) },
35
+ aliases: Object.fromEntries(Object.entries(aliases).filter(([alias, verb]) => pkg.scripts?.[alias] === commandLine(verb, declaredPackage(pkg) !== undefined))),
36
+ local: { state: project.adapter?.state, settings: project.adapter?.settings ?? [], installerState: '.seed/installer', outbox: '/__dev/outbox', ...await localState(project), origin: await savedOrigin(project), ...(project.adapter?.devIdentity ? { devIdentity: true, devKey: '.rer/local/dev-key.env' } : {}) },
37
+ // This pin serves a checkout through the machine's front door (rer door).
38
+ door: { protocol: doorProtocol },
33
39
  };
34
40
  }
35
41
 
@@ -57,6 +63,9 @@ export async function check(project, explicitProfile) {
57
63
  for (const loop of project.descriptor.loops ?? []) add('SC-10.loop.' + loop.id, !loop.required || loop.enabled, `Enable the required ${loop.id} loop or correct its declaration.`);
58
64
  if (project.profile === 'first-party' || explicitProfile === 'first-party') {
59
65
  const adapter = project.adapter, pkg = await readPackage(project.root), marker = project.descriptor.seedContract;
66
+ // Package mode (an @runeditrun/rer pin) and vendored mode differ only in
67
+ // where rer comes from: the command targets and the source clauses below.
68
+ const packaged = declaredPackage(pkg) !== undefined;
60
69
  add('SC-09.profile', marker?.version === 1 && marker?.profile === 'first-party', 'Restore OPERATIONS v2 seedContract version 1, profile first-party and the adapter pointer.');
61
70
  add('SC-01.adapter', adapter?.version === 1, 'Export commandAdapter version 1 from scripts/installer/local.mjs.');
62
71
  for (const hook of requiredHooks) add('SC-01.hook.' + hook, adapter?.[hook] ?? adapter?.hooks?.[hook], `Missing ${hook} hook/inventory; adopt the shared command adapter with a real ${hook} target.`);
@@ -64,14 +73,18 @@ export async function check(project, explicitProfile) {
64
73
  const declaration = project.descriptor.commands[verb];
65
74
  add('SC-01.verb.' + verb, declaration && adapter?.verbs?.[verb], `Missing ${verb}; declare it in OPERATIONS and expose its shared implementation.`);
66
75
  for (const flag of definition.flags) add('SC-01.flag.' + verb + '.' + flag.slice(2), adapter?.verbs?.[verb]?.flags?.includes(flag), `Missing ${verb} ${flag}; restore the shared implementation supporting this flag.`);
67
- add('SC-02.target.' + verb, declaration?.argv?.join('\0') === ['node', 'scripts/rer.mjs', verb].join('\0'), `Declare ${verb} argv as node scripts/rer.mjs ${verb}.`);
76
+ const argv = commandArgv(verb, packaged);
77
+ add('SC-02.target.' + verb, declaration?.argv?.join('\0') === argv.join('\0'), `Declare ${verb} argv as ${argv.join(' ')}.`);
68
78
  }
69
- for (const [alias, verb] of Object.entries(aliases)) add('SC-02.alias.' + alias, pkg.scripts?.[alias] === `node scripts/rer.mjs${verb ? ' ' + verb : ''}`, `Set package script ${alias} to node scripts/rer.mjs${verb ? ' ' + verb : ''}.`);
79
+ for (const [alias, verb] of Object.entries(aliases)) add('SC-02.alias.' + alias, pkg.scripts?.[alias] === commandLine(verb, packaged), `Set package script ${alias} to ${commandLine(verb, packaged)}.`);
70
80
  add('SC-02.install-lifecycle', !pkg.scripts?.install, 'Remove the install lifecycle alias; use deps or frozen pnpm install.');
71
81
  let nodeFile; try { nodeFile = (await readFile(resolve(project.root, '.node-version'), 'utf8')).trim(); } catch {}
72
82
  add('SC-02.node-file', nodeFile === pins.node, `Set .node-version to ${pins.node}.`);
73
83
  add('SC-02.node-engine', pkg.engines?.node === pins.node, `Pin package.json engines.node to ${pins.node}.`);
74
84
  add('SC-02.pnpm', pkg.packageManager === `pnpm@${pins.pnpm}`, `Pin packageManager to pnpm@${pins.pnpm}.`);
85
+ if (packaged) for (const [id, ok, message] of await packageClauses(project.root, pkg)) add(id, ok, message);
86
+ // Vendored mode: the copy and its recorded inventory.
87
+ else {
75
88
  for (const path of ['scripts/rer.mjs', 'scripts/base-cli/inventory.json', 'scripts/base-onboarding/adapter.mjs']) {
76
89
  let exists = false; try { await contained(project.root, path); exists = true; } catch {}
77
90
  add('SC-01.portable.' + path, exists, `Restore portable source ${path} through the documented export tool.`);
@@ -94,6 +107,7 @@ export async function check(project, explicitProfile) {
94
107
  add('SC-01.inventory.' + path, exact, `Restore stale or missing portable CLI source ${path} with the source export tool.`);
95
108
  }
96
109
  } catch { add('SC-01.inventory', false, 'Restore the deterministic portable CLI source inventory.'); }
110
+ }
97
111
  add('SC-08.portability', typeof adapter?.portability?.export === 'function' && typeof adapter?.portability?.import === 'function', 'Adopt the reviewed mainline complete local portability API; archive support remains unavailable until then.');
98
112
  if (adapter?.devIdentity) for (const [id, ok, message] of await devIdentityClauses(project.root, adapter)) add(id, ok, message);
99
113
  for (const [id, ok, message] of await authCopyClauses(project.root)) add(id, ok, message);
@@ -102,12 +116,40 @@ export async function check(project, explicitProfile) {
102
116
  return { clauses, ok: clauses.every(clause => clause.ok) };
103
117
  }
104
118
 
119
+ // Package mode: the exact pin, its lockfile entry, no vendored copy and no
120
+ // command still calling one.
121
+ async function packageClauses(root, pkg) {
122
+ const declared = declaredPackage(pkg), locked = await lockedPackage(root), clauses = [];
123
+ clauses.push(['SC-13.package.pin', pkg.devDependencies?.[packageName] === declared && exactVersion.test(declared), `Pin ${packageName} in devDependencies to an exact version (for example "0.2.0"), not ${JSON.stringify(declared)} or a runtime dependency.`]);
124
+ clauses.push(['SC-13.package.lockfile', locked?.locked && locked.specifier === declared && locked.version === declared, `Run pnpm install so pnpm-lock.yaml locks ${packageName} ${declared}${locked?.version ? ` (it locks ${locked.version})` : ''}.`]);
125
+ for (const path of vendoredPaths) {
126
+ let present = true; try { await lstat(resolve(root, path)); } catch { present = false; }
127
+ clauses.push(['SC-13.package.vendored.' + path, !present, `Remove ${path}: a checkout that pins ${packageName} runs the package, not a vendored copy (node scripts/export-cli.mjs --to-package converts one).`]);
128
+ }
129
+ // Delivery operations, the scripts they run and CI workflows call the package too.
130
+ const calls = await vendoredCalls(root);
131
+ clauses.push(['SC-13.package.calls', !calls.length, `Run pnpm exec rer VERB instead of the removed vendored copy (pnpm install --frozen-lockfile first where nothing has installed yet) in ${calls.length} place${calls.length === 1 ? '' : 's'}:\n${calls.slice(0, 20).join('\n')}${calls.length > 20 ? `\n...and ${calls.length - 20} more` : ''}`]);
132
+ return clauses;
133
+ }
134
+
135
+ // Without the pinned pnpm on PATH, rer provides it for this command only
136
+ // (cli/pnpm-runtime.mjs); it never changes global tools.
105
137
  export async function tools(root, store, signal) {
106
138
  const node = await projectNodePin(root);
107
139
  if (node && process.versions.node !== node) throw missing(`Node ${node} is required. Run it with rer, with automatic project runtime selection enabled.`);
108
- const version = await probePnpm(root, store, signal);
109
- if (version !== pins.pnpm) throw missing(`pnpm ${pins.pnpm} is required. Install/select the documented pin; rer never changes global tools.`);
140
+ const found = await probePnpm(root, store, signal).catch(error => { if (signal?.aborted) throw error; return null; });
141
+ if (found === pins.pnpm) return;
142
+ const { cachedPnpm, downloadPnpm } = await import('./pnpm-runtime.mjs');
143
+ let directory = await cachedPnpm(pins.pnpm);
144
+ if (!directory) {
145
+ store.report?.(`Fetching pnpm ${pins.pnpm} for this project (PATH has ${found ? `pnpm ${found}` : 'no pnpm'}); rer keeps it in its own cache.`);
146
+ try { directory = await downloadPnpm(pins.pnpm, signal); }
147
+ catch (error) { if (signal?.aborted || error instanceof RerError) throw error; throw missing(`pnpm ${pins.pnpm} is required and could not be fetched (${error.message}). Check the connection, or install pnpm ${pins.pnpm} yourself.`); }
148
+ }
149
+ usePnpm(directory);
150
+ if (await probePnpm(root, store, signal) !== pins.pnpm) throw missing(`pnpm ${pins.pnpm} is required. The pnpm rer provides did not report it; install pnpm ${pins.pnpm} yourself.`);
110
151
  }
152
+ const usePnpm = directory => { process.env.PATH = [dirname(process.execPath), directory, process.env.PATH].filter(Boolean).join(delimiter); };
111
153
 
112
154
  export async function doctor(project, store, signal) {
113
155
  const { inspectOperations } = await import('../operations/operations.mjs');
@@ -127,6 +169,11 @@ export async function doctor(project, store, signal) {
127
169
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) values = parsed;
128
170
  } catch {}
129
171
  try { pnpm = await probePnpm(project.root, store, signal); } catch (error) { if (signal?.aborted) throw error; }
172
+ // A pnpm rer already provides counts; doctor never fetches one.
173
+ if (pnpm !== pins.pnpm) {
174
+ const directory = await (await import('./pnpm-runtime.mjs')).cachedPnpm(pins.pnpm).catch(() => undefined);
175
+ if (directory) { usePnpm(directory); try { pnpm = await probePnpm(project.root, store, signal); } catch (error) { if (signal?.aborted) throw error; } }
176
+ }
130
177
  }
131
178
  const aiIssues = await localAiDiagnostics(project,process.env);
132
179
  const dependencies=project.profile==='first-party'?await dependencyDoctor(project.root):[];
package/cli/lifecycle.mjs CHANGED
@@ -1,5 +1,3 @@
1
- import { createHmac } from 'node:crypto';
2
- import { readFile } from 'node:fs/promises';
3
1
  import { dependencyInputs } from './dependency-input.mjs';
4
2
  import { linkDependencies } from './dependencies.mjs';
5
3
  import { savedSetupAnswers } from './setup-answers.mjs';
@@ -13,9 +11,10 @@ import { missing, refuse, RerError, lifecycleError } from './errors.mjs';
13
11
  import { executeDeclared } from './independent.mjs';
14
12
  import { transfer } from './transfers.mjs';
15
13
  import { runtimeFrontDoor } from './runtime-front-door.mjs';
16
- import { localSettingKeys, requiredLocalSettings } from './settings.mjs';
14
+ import { localCliSessionProof, localSettingKeys, requiredLocalSettings } from './settings.mjs';
17
15
  import { rememberSetupAnswers } from './setup-answers.mjs';
18
16
  import { ensureDevKey, readDevKey, refuseDevLayerOutput, openUrl, DEV_KEY_HEADER } from './dev-identity.mjs';
17
+ import { announceDoorRoute, routeRunning, verifyDoorFor } from './door-mode.mjs';
19
18
 
20
19
  export async function runCommand(project, options, out) {
21
20
  if(project.profile==='first-party'&&['dev','preview'].includes(options.verb))return (await import('./managed-server.mjs')).managedServer(project,options,out);
@@ -42,11 +41,12 @@ export async function runCommand(project, options, out) {
42
41
  selected = await selectedOrigin(project, options, { setup: options.verb === 'setup' });
43
42
  if (options.verb === 'setup') {input = await setupInput(project, options, selected.vars, signal, { origin: selected.url });await dependencyInputs({project,input,origin:selected.url});}
44
43
  else if (requiredLocalSettings(project, selected.vars).length) throw missing('Private owner settings are missing. Run rer setup --owner-email EMAIL first.');
45
- if (options.verb !== 'outbox') await availablePort(selected.host, selected.port, { root: project.root, saved: !selected.explicitPort && selected.savedOrigin !== undefined });
44
+ if (options.verb !== 'outbox' && !selected.door) await availablePort(selected.host, selected.port, { root: project.root, saved: !selected.explicitPort && selected.savedOrigin !== undefined });
46
45
  if (options.verb === 'setup') announcePort(out, options, selected);
47
46
  }
48
47
  const { localOperation } = await shared('local/operation.mjs');
49
- if (options.verb !== 'outbox') try { operation = await localOperation(project.root, process.env.RER_LOCAL_OPERATION); } catch (error) { throw refuse(error.message); }
48
+ // The run site reads no local state: it builds beside a running rer dev.
49
+ if (options.verb !== 'outbox' && !options.site) try { operation = await localOperation(project.root, process.env.RER_LOCAL_OPERATION); } catch (error) { throw refuse(error.message); }
50
50
  const { recoverInterrupted } = await shared('local/recovery.mjs');
51
51
  if (operation) await recoverInterrupted(project.root, operation);
52
52
  }
@@ -67,6 +67,8 @@ export async function runCommand(project, options, out) {
67
67
  if (project.profile === 'independent') return await executeDeclared(project, options, store, signal);
68
68
  await tools(project.root, store, signal);
69
69
  const adapter = project.adapter;
70
+ // The run site: static pages built from edition identity, never the app build.
71
+ if (options.site) return await (await import('./site.mjs')).siteCommand(project, options, store, signal, out);
70
72
  if (options.verb === 'install') {
71
73
  await store.run('Installing frozen dependencies', 'pnpm', ['install', '--frozen-lockfile'], { cwd: project.root, signal, env: { ...process.env, CI: 'true' } }); return {};
72
74
  }
@@ -75,7 +77,10 @@ export async function runCommand(project, options, out) {
75
77
  if (!Array.isArray(target)) throw refuse('A complete internal verification target is missing.');
76
78
  await store.run('Verifying application', target[0], target[1], { cwd: project.root, signal, env: { ...process.env, RER_LOCAL_OPERATION: operation.token } });
77
79
  await store.step('Serving the production build as rer deploy hosts it');
78
- return { hosted: await (await import('./hosted-output.mjs')).checkHostedOutput(project.root, { declaration: adapter.deployDeclaration ?? adapter.deploy?.declaration }) };
80
+ const declaration = adapter.deployDeclaration ?? adapter.deploy?.declaration;
81
+ const hosted = await (await import('./hosted-output.mjs')).checkHostedOutput(project.root, { declaration });
82
+ await store.step('Checking that page sections hydrate as separate units');
83
+ return { hosted, hydration: await (await import('./hydration/check.mjs')).checkHydration(project.root, { pages: adapter.publicPages, declaration, report: lines => lines.forEach(line => store.report(line)) }) };
79
84
  }
80
85
  if (['export', 'import'].includes(options.verb)) return await transfer(project, options, signal, { store, operationToken: operation.token });
81
86
  if (options.verb === 'deploy') {
@@ -89,7 +94,17 @@ export async function runCommand(project, options, out) {
89
94
  const devKey = adapter.devIdentity ? options.verb === 'setup' ? await ensureDevKey(project.root) : await readDevKey(project.root) : null;
90
95
  if (devKey) store.addSecrets?.([devKey]);
91
96
  let app;
92
- if (options.verb === 'setup') proxy = await runtimeFrontDoor(selected);
97
+ if (options.verb === 'setup') { proxy = await runtimeFrontDoor({ ...selected, devKey: devKey ?? undefined, origin: selected.url }); if (selected.door) await announceDoorRoute(selected, proxy); }
98
+ // The outbox of a door-mode checkout is read from its running server's own
99
+ // proxy, never through the door: with no such server nothing is sent. A key
100
+ // is sent only once the door has proved it is this account's.
101
+ if (options.verb === 'outbox' && selected?.door) {
102
+ const running = await runningRecord(project.root);
103
+ if (!running?.door) throw new RerError('outbox_unavailable', 'The local application outbox is unavailable. Start rer dev first.', 1);
104
+ if (devKey) await verifyDoorFor(selected.url);
105
+ await routeRunning(running);
106
+ }
107
+ const { appFetch } = await shared('app-fetch.mjs');
93
108
  const emailKey = localSettingKeys(project).email;
94
109
  await adapter.run(project.root, input ?? { [emailKey]: options.ownerEmail ?? selected?.vars[emailKey] }, store,
95
110
  child => { app = child; }, signal,
@@ -99,11 +114,9 @@ export async function runCommand(project, options, out) {
99
114
  if (options.verb === 'setup') {
100
115
  await linkDependencies({project,input,origin:url,store,signal});
101
116
  if(project.setupManifest?.dependencies?.length) {
102
- const { localVariables }=await shared('local/environment.mjs');
103
- const values=localVariables(await readFile(new URL(project.adapter.hooks?.secrets?.file??'.dev.vars','file://'+project.root+'/'),'utf8'));
104
- const proof=createHmac('sha256',values.SESSION_SECRET).update('local-cli\0sign-in').digest('hex');
117
+ const proof=await localCliSessionProof(project);
105
118
  store.addSecrets?.([proof]);
106
- const completed=await fetch(url+'/auth/setup-complete',{method:'POST',headers:{Origin:url,'Content-Type':'application/json','x-rer-local-cli':proof},body:'{}',signal});
119
+ const completed=await appFetch(url+'/auth/setup-complete',{method:'POST',headers:{Origin:url,'Content-Type':'application/json','x-rer-local-cli':proof},body:'{}',signal});
107
120
  if(!completed.ok)throw missing('The dependency setup has not completed an owner claim.');
108
121
  await completed.body?.cancel();
109
122
  }
@@ -113,7 +126,7 @@ export async function runCommand(project, options, out) {
113
126
  else if (devKey) { out.human(`RER_SETUP_READY ${url}`); out.human(`Next: rer dev opens ${url} signed in as the owner; first-run setup finishes at ${url}/setup.`); }
114
127
  else { out.human(`RER_SETUP_READY ${url}`); signInInstructions(out, signInUrl); out.human(`Finish first-run setup at ${url}/setup after sign-in.`); out.human('Next: rer dev'); }
115
128
  } else if (options.verb === 'outbox') {
116
- const response = await fetch(signInUrl, { headers: devKey ? { [DEV_KEY_HEADER]: devKey } : {}, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) });
129
+ const response = await appFetch(signInUrl, { headers: devKey ? { [DEV_KEY_HEADER]: devKey } : {}, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }).catch(() => ({ ok: false }));
117
130
  if (!response.ok) throw new RerError('outbox_unavailable', 'The local application outbox is unavailable. Start rer dev first.', 1);
118
131
  await response.body?.cancel();
119
132
  const open = devKey ? openUrl(url, devKey, { path: '/__dev/key', next: '/__dev/outbox' }) : signInUrl;
@@ -132,8 +145,19 @@ export async function runCommand(project, options, out) {
132
145
  }
133
146
  }
134
147
 
148
+ // This checkout's running server, from the shared registry (routeRunning
149
+ // checks that it is alive and holds its proxy port).
150
+ async function runningRecord(root) {
151
+ const { readJson } = await import('../modules/job-queue/storage.mjs');
152
+ const { serversDirectory } = await import('./server-registry.mjs');
153
+ const { realpath } = await import('node:fs/promises');
154
+ const checkout = await realpath(root), state = await readJson(join(serversDirectory(), 'state.json'), { running: [] });
155
+ return (state.running ?? []).find(record => record.checkout === checkout);
156
+ }
157
+
135
158
  // One plain line saying which port this checkout uses, and what moved.
136
159
  function announcePort(out, options, selected) {
160
+ if (selected.door) { if (options.json) out.event('origin', { url: selected.url, door: true }); else out.human(`Using ${selected.url} for this checkout, served through the front door.`); return; }
137
161
  const { moved, skipped } = selected;
138
162
  const line = moved ? `Moving this checkout from port ${moved.from} to port ${moved.to}. Its data is kept.`
139
163
  : !selected.savedOrigin ? `Using port ${selected.port} for this checkout${skipped ? ` (${skipped} is in use)` : ''}.` : undefined;
package/cli/local.mjs CHANGED
@@ -8,6 +8,8 @@ import { origin, loopback } from './parse.mjs';
8
8
  import { refuse } from './errors.mjs';
9
9
  import { defaultPort, firstFreePort, busyPortMessage } from './port.mjs';
10
10
  import { localSettingKeys } from './settings.mjs';
11
+ import { doorSite } from './door.mjs';
12
+ import { selectDoor } from './door-mode.mjs';
11
13
 
12
14
  export async function selectedOrigin(project, options, { setup = false } = {}) {
13
15
  const { readSettings } = await shared('adapter-settings.mjs');
@@ -18,8 +20,11 @@ export async function selectedOrigin(project, options, { setup = false } = {}) {
18
20
  try { previous = new URL(vars[originKey]); } catch { throw refuse('Saved origin is malformed. Restore private settings or use confirmed reset.'); }
19
21
  if (previous.origin !== vars[originKey] || previous.protocol !== 'http:' || !loopback(previous.hostname.replace(/^\[|\]$/g, ''))) throw refuse('Saved origin is unsafe. Restore private settings or use confirmed reset.');
20
22
  }
21
- const host = options.host ?? previous?.hostname.replace(/^\[|\]$/g, '') ?? '127.0.0.1';
22
- const savedPort = previous ? Number(previous.port || 80) : undefined;
23
+ // A front-door origin (saved, or --door on a fresh setup) serves through the door.
24
+ const door = await selectDoor(project, options, previous, { vars, savedOrigin: previous?.origin });
25
+ if (door) return door;
26
+ const host = options.host ?? (previous && !doorSite(previous.origin) ? previous.hostname.replace(/^\[|\]$/g, '') : '127.0.0.1');
27
+ const savedPort = previous && !doorSite(previous.origin) ? Number(previous.port || 80) : undefined;
23
28
  // A first setup with no port asked for takes the first free one, never a busy one.
24
29
  let port = options.port ?? savedPort, taken;
25
30
  if (port === undefined && setup) { port = await firstFreePort(host); taken = port !== defaultPort; }
@@ -95,6 +100,7 @@ export async function refuseLegacyProcess(project,{reset=false}={}) {
95
100
  // Confirmed reset can recover malformed settings; checkout/state custody is
96
101
  // still checked even when there is no usable saved origin.
97
102
  const {localProcessConflict}=await shared('process-diagnostics.mjs');
98
- const conflict=await localProcessConflict(project.root,selected?.savedOrigin);
103
+ // The front door holds a door origin's port, not this checkout.
104
+ const conflict=await localProcessConflict(project.root,selected?.door?undefined:selected?.savedOrigin);
99
105
  if(conflict)throw refuse(`A local process holds ${conflict.reason}: PID ${conflict.pid} (${conflict.command}). Stop it with kill -TERM ${conflict.pid}, then retry. Use rer dev --stop or rer preview --stop for a registered server.`);
100
106
  }
package/cli/main.mjs CHANGED
@@ -38,6 +38,8 @@ export async function main(argv = process.argv.slice(2)) {
38
38
  if (!options.json) out.human(JSON.stringify(result, null, 2));
39
39
  out.result(result.ok, result.ok ? 0 : 3, { diagnostics: result }); return result.ok ? 0 : 3;
40
40
  }
41
+ // A checkout without a declared run site refuses --site before any effect.
42
+ if (options.site) (await import('./site.mjs')).declaredSite(project);
41
43
  const bounded=await (await import('./bounded.mjs')).boundedCommand(project,options,argv,out);
42
44
  if(bounded)return bounded.exitCode;
43
45
  if(options.platform==='container' && !['export','import'].includes(options.verb)) { const result = await (await import('./container.mjs')).containerCommand(project,options,out); out.result(true,0,result); return 0; }