@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.
- package/README.md +113 -2
- package/cli/README.md +32 -5
- package/cli/contract.mjs +5 -5
- package/cli/deploy.mjs +17 -3
- package/cli/detached-server.mjs +51 -0
- package/cli/dev-identity.mjs +2 -1
- package/cli/door-mode.mjs +134 -0
- package/cli/door.mjs +263 -0
- package/cli/hosted-output.mjs +17 -7
- package/cli/hydration/browser.mjs +91 -0
- package/cli/hydration/cases.mjs +106 -0
- package/cli/hydration/check.mjs +105 -0
- package/cli/hydration/probe.mjs +164 -0
- package/cli/hydration/scripts.mjs +39 -0
- package/cli/hydration/verdict.mjs +85 -0
- package/cli/inspect.mjs +57 -10
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +3 -0
- package/cli/managed-server.mjs +62 -21
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +15 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/release.mjs +1 -0
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +10 -7
- package/cli/settings.mjs +14 -0
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +111 -0
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +54 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +93 -0
- package/cli/site-render.mjs +79 -0
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +130 -0
- package/global/CHANGELOG.md +88 -0
- package/global/README.md +113 -2
- package/global/checkout.mjs +24 -10
- package/global/cli/install.mjs +6 -2
- package/global/delegate.mjs +9 -7
- package/global/door/command.mjs +254 -0
- package/global/door/control.mjs +65 -0
- package/global/door/cookies.mjs +41 -0
- package/global/door/pages.mjs +49 -0
- package/global/door/policy.mjs +49 -0
- package/global/door/routes.mjs +45 -0
- package/global/door/server.mjs +255 -0
- package/global/door/sites.mjs +192 -0
- package/global/launch/dev.mjs +21 -7
- package/global/launch/door.mjs +36 -0
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +38 -5
- package/global/launch/render.mjs +6 -1
- package/global/launch/steps.mjs +12 -7
- package/global/launch/worker.mjs +3 -3
- package/global/list.mjs +6 -3
- package/global/main.mjs +39 -3
- package/global/output.mjs +12 -0
- package/global/rer.mjs +3 -0
- package/global/version.mjs +8 -8
- package/modules/dependencies/protocol.mjs +2 -1
- package/modules/deploy-kit/README.md +34 -1
- package/modules/deploy-kit/artifact.mjs +10 -0
- package/modules/deploy-kit/deploy.mjs +5 -2
- package/modules/deploy-kit/diagnostics.mjs +55 -0
- package/modules/deploy-kit/owner-cli.mjs +10 -4
- package/modules/deploy-kit/owner.mjs +8 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +51 -9
- package/modules/deploy-kit/site.mjs +135 -0
- package/modules/erasure/CONTRACT.md +6 -3
- package/modules/erasure/README.md +8 -1
- package/modules/erasure/audit.mjs +2 -2
- package/modules/erasure/cascade.mjs +52 -11
- package/modules/erasure/order.mjs +29 -22
- package/modules/erasure/plan.mjs +4 -2
- package/modules/erasure/queue.mjs +41 -0
- package/modules/erasure/scope.mjs +3 -3
- package/modules/erasure/verification.mjs +4 -2
- package/modules/mail/README.md +1 -1
- package/modules/mail/index.mjs +1 -1
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/development/index.d.mts +6 -0
- package/modules/mail/src/development/index.mjs +6 -3
- package/modules/mail/src/development/viewer-html.mjs +3 -3
- package/modules/mail/src/development/viewer.mjs +8 -1
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/index.d.mts +4 -9
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- package/modules/owner-claim/verify.mjs +2 -1
- package/modules/portability/README.md +8 -0
- package/modules/portability/hash.mjs +6 -2
- package/modules/portability/import.mjs +1 -1
- package/modules/portability/legacy-files.mjs +4 -4
- package/modules/portability/legacy-safety.mjs +51 -10
- package/modules/portability/migration-history.mjs +15 -0
- package/modules/release/README.md +68 -3
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +14 -2
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/source.mjs +15 -4
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/adapter-auth.mjs +4 -3
- package/onboarding/adapter-settings.mjs +21 -5
- package/onboarding/adapter.mjs +2 -1
- package/onboarding/app-fetch.mjs +79 -0
- package/onboarding/archive-output.d.mts +4 -2
- package/onboarding/archive-output.mjs +22 -10
- package/onboarding/dependency-approval.mjs +4 -3
- package/onboarding/local/environment.mjs +7 -2
- package/onboarding/operator-install.mjs +8 -2
- package/onboarding/operator-prerequisites.mjs +5 -2
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +76 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +63 -8
- package/scripts/publish-check/publication-config.json +16 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
|
@@ -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
|
|
@@ -27,9 +31,11 @@ export async function describe(project) {
|
|
|
27
31
|
};
|
|
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
|
-
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 ?? '
|
|
31
|
-
aliases: Object.fromEntries(Object.entries(aliases).filter(([alias, verb]) => pkg.scripts?.[alias] ===
|
|
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' } : {}) },
|
|
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 ?? 'Produce releases; keygen writes a public anchor and, on success, its private key to non-terminal stdout; sign writes a detached signature and prints (but does not execute) the GitHub upload command; receipt verifies against local anchors offline.' }])),
|
|
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
|
-
|
|
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] ===
|
|
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
|
|
109
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
22
|
-
const
|
|
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
|
-
|
|
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
|
@@ -27,6 +27,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
27
27
|
if (project.profile === 'independent' && !['describe', 'check', 'doctor'].includes(options.verb)) options = parse(argv, process.cwd(), { independent: true });
|
|
28
28
|
else if (options.forwarded.length) throw invalid('First-party commands accept only the documented flags, without forwarded arguments.');
|
|
29
29
|
out = output(options);
|
|
30
|
+
if (project.profile === 'first-party' && options.detach && ['dev','preview'].includes(options.verb)) return (await import('./detached-server.mjs')).detachedServer(options, argv, out);
|
|
30
31
|
if (options.verb === 'describe') { const result = await describe(project); out.human(options.json ? JSON.stringify(result) : JSON.stringify(result, null, 2)); return 0; }
|
|
31
32
|
if (options.verb === 'check') {
|
|
32
33
|
const result = await check(project, options.profile);
|
|
@@ -38,6 +39,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
38
39
|
if (!options.json) out.human(JSON.stringify(result, null, 2));
|
|
39
40
|
out.result(result.ok, result.ok ? 0 : 3, { diagnostics: result }); return result.ok ? 0 : 3;
|
|
40
41
|
}
|
|
42
|
+
// A checkout without a declared run site refuses --site before any effect.
|
|
43
|
+
if (options.site) (await import('./site.mjs')).declaredSite(project);
|
|
41
44
|
const bounded=await (await import('./bounded.mjs')).boundedCommand(project,options,argv,out);
|
|
42
45
|
if(bounded)return bounded.exitCode;
|
|
43
46
|
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; }
|