@runeditrun/rer 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -1
- package/cli/README.md +28 -3
- package/cli/contract.mjs +4 -4
- package/cli/dev-identity.mjs +2 -1
- package/cli/door-mode.mjs +134 -0
- package/cli/door.mjs +263 -0
- package/cli/hosted-output.mjs +15 -5
- package/cli/hydration/browser.mjs +91 -0
- package/cli/hydration/cases.mjs +106 -0
- package/cli/hydration/check.mjs +105 -0
- package/cli/hydration/probe.mjs +164 -0
- package/cli/hydration/scripts.mjs +39 -0
- package/cli/hydration/verdict.mjs +85 -0
- package/cli/inspect.mjs +56 -9
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +2 -0
- package/cli/managed-server.mjs +52 -18
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +14 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +10 -7
- package/cli/settings.mjs +14 -0
- package/cli/site-check.mjs +96 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-render.mjs +77 -0
- package/cli/site.mjs +109 -0
- package/global/CHANGELOG.md +63 -0
- package/global/README.md +111 -1
- package/global/checkout.mjs +24 -10
- package/global/cli/install.mjs +6 -2
- package/global/delegate.mjs +9 -7
- package/global/door/command.mjs +254 -0
- package/global/door/control.mjs +65 -0
- package/global/door/cookies.mjs +41 -0
- package/global/door/pages.mjs +49 -0
- package/global/door/policy.mjs +49 -0
- package/global/door/routes.mjs +45 -0
- package/global/door/server.mjs +255 -0
- package/global/door/sites.mjs +192 -0
- package/global/launch/dev.mjs +21 -7
- package/global/launch/door.mjs +36 -0
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +38 -5
- package/global/launch/render.mjs +6 -1
- package/global/launch/steps.mjs +12 -7
- package/global/launch/worker.mjs +3 -3
- package/global/list.mjs +6 -3
- package/global/main.mjs +37 -3
- package/global/version.mjs +8 -8
- package/modules/dependencies/protocol.mjs +2 -1
- package/modules/deploy-kit/README.md +34 -1
- package/modules/deploy-kit/artifact.mjs +10 -0
- package/modules/deploy-kit/owner.mjs +5 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +6 -3
- package/modules/deploy-kit/site.mjs +135 -0
- package/modules/erasure/CONTRACT.md +6 -3
- package/modules/erasure/README.md +8 -1
- package/modules/erasure/audit.mjs +2 -2
- package/modules/erasure/cascade.mjs +52 -11
- package/modules/erasure/order.mjs +29 -22
- package/modules/erasure/plan.mjs +4 -2
- package/modules/erasure/queue.mjs +41 -0
- package/modules/erasure/scope.mjs +3 -3
- package/modules/erasure/verification.mjs +4 -2
- package/modules/mail/README.md +1 -1
- package/modules/mail/index.mjs +1 -1
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/development/index.d.mts +6 -0
- package/modules/mail/src/development/index.mjs +6 -3
- package/modules/mail/src/development/viewer-html.mjs +3 -3
- package/modules/mail/src/development/viewer.mjs +8 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/owner-claim/verify.mjs +2 -1
- package/modules/portability/README.md +8 -0
- package/modules/portability/hash.mjs +6 -2
- package/modules/portability/import.mjs +1 -1
- package/modules/portability/legacy-files.mjs +4 -4
- package/modules/portability/legacy-safety.mjs +51 -10
- package/modules/portability/migration-history.mjs +15 -0
- package/modules/release/README.md +5 -3
- package/modules/release/produce.mjs +5 -2
- package/modules/release/source.mjs +15 -4
- package/onboarding/adapter-auth.mjs +4 -3
- package/onboarding/adapter-settings.mjs +21 -5
- package/onboarding/adapter.mjs +2 -1
- package/onboarding/app-fetch.mjs +79 -0
- package/onboarding/archive-output.d.mts +4 -2
- package/onboarding/archive-output.mjs +22 -10
- package/onboarding/dependency-approval.mjs +4 -3
- package/onboarding/local/environment.mjs +7 -2
- package/onboarding/operator-install.mjs +8 -2
- package/onboarding/operator-prerequisites.mjs +5 -2
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +75 -1
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/public_hygiene.py +35 -4
- package/scripts/publish-check/publication-config.json +14 -1
|
@@ -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] ===
|
|
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
|
-
|
|
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
|
@@ -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; }
|
package/cli/managed-server.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { prepareLiveModels } from './ai-local.mjs';
|
|
2
2
|
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import {join} from 'node:path';
|
|
3
4
|
import { serverRegistry } from './server-registry.mjs';
|
|
4
5
|
import { activityClock } from './server-activity.mjs';
|
|
5
6
|
import { runtimeFrontDoor } from './runtime-front-door.mjs';
|
|
@@ -13,24 +14,29 @@ import { invalid,missing,refuse,RerError,lifecycleError } from './errors.mjs';
|
|
|
13
14
|
import { processInfo } from '../modules/job-queue/processes.mjs';
|
|
14
15
|
import { declaredCrons,runScheduled } from './scheduled.mjs';
|
|
15
16
|
import { readDevKey,requireDevKey,refuseManagedInstallation,resolvePersona,openUrl,DEV_KEY_HEADER } from './dev-identity.mjs';
|
|
17
|
+
import { announceDoorRoute,claimSavedName,moveCheckout,routeRunning,verifyDoorFor } from './door-mode.mjs';
|
|
16
18
|
|
|
17
19
|
// Seeds without devIdentity keep the outbox sign-in unchanged. With it, dev
|
|
18
20
|
// opens signed in (RER_SIGNED_IN, RER_OPEN); --login and preview keep the
|
|
19
21
|
// outbox, reached through RER_OPEN's one-time key exchange. A runtime that
|
|
20
22
|
// serves no outbox (a seed's preview without one) is not pointed at it.
|
|
21
|
-
|
|
23
|
+
// A seed that declares a run site also gets RER_SITE: its static run site
|
|
24
|
+
// (homepage, /run, docs) served locally on its own port, after the ready lines.
|
|
25
|
+
export function ready(out,url,dev,json=false,outbox=true,siteUrl){
|
|
22
26
|
const signInUrl=url+'/__dev/outbox',open=dev&&(dev.identity||outbox);
|
|
23
|
-
if(json){out.event('ready',{url,...(outbox?{signInUrl}:{}),...(dev?{signedInAs:dev.signedInAs??null,...(open?{openUrl:dev.openUrl}:{})}:{})});return;}
|
|
27
|
+
if(json){out.event('ready',{url,...(outbox?{signInUrl}:{}),...(dev?{signedInAs:dev.signedInAs??null,...(open?{openUrl:dev.openUrl}:{})}:{}),...(siteUrl?{siteUrl}:{})});return;}
|
|
24
28
|
out.human(`RER_READY ${url}`);
|
|
25
|
-
if(dev?.identity){if(dev.signedInAs)out.human(`RER_SIGNED_IN ${dev.signedInAs}`);out.human(`RER_OPEN ${dev.openUrl}`);out.human('Open that link on this computer. Its fragment holds this checkout\'s dev key, which the page keeps only as a cookie; never share the link.');
|
|
26
|
-
if(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
if(dev?.identity){if(dev.signedInAs)out.human(`RER_SIGNED_IN ${dev.signedInAs}`);else if(dev.ownerLeft)out.human('No active owner remains in this app, so rer dev cannot sign in as one. Use rer dev --login with another operator, or rer reset --yes and rer setup for a new owner.');out.human(`RER_OPEN ${dev.openUrl}`);out.human('Open that link on this computer. Its fragment holds this checkout\'s dev key, which the page keeps only as a cookie; never share the link.');}
|
|
30
|
+
else if(outbox){
|
|
31
|
+
out.human(`RER_SIGN_IN ${signInUrl}`);
|
|
32
|
+
if(dev)out.human(`RER_OPEN ${dev.openUrl}`);
|
|
33
|
+
out.human(`Open ${dev?'the last link on this computer (it carries the dev key; never share it)':'the outbox'}, choose the newest sign-in message and follow its link. No email was sent.`);
|
|
34
|
+
}
|
|
35
|
+
if(siteUrl)out.human(`RER_SITE ${siteUrl}`);
|
|
30
36
|
}
|
|
31
37
|
export async function managedServer(project,options,out,{clock={}}={}){
|
|
32
38
|
const registry=await serverRegistry(project.root),controller=new AbortController();
|
|
33
|
-
let store,operation,proxy,activity,app,termination,reason,cleanupPromise,finished,selected,cleaned;
|
|
39
|
+
let store,operation,proxy,activity,app,termination,reason,cleanupPromise,finished,selected,cleaned,site;
|
|
34
40
|
const scheduling=new AbortController();
|
|
35
41
|
let activityWrites=Promise.resolve(),pendingActivity,activityWriting=false,infrastructureFailure;
|
|
36
42
|
function recordActivity(fields){
|
|
@@ -39,13 +45,14 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
39
45
|
}
|
|
40
46
|
const stopped=new Promise(resolve=>{finished=resolve;});
|
|
41
47
|
const cleanupDone=new Promise(resolve=>{cleaned=resolve;});
|
|
42
|
-
const cleanup=()=>cleanupPromise??=(async()=>{scheduling.abort();activity?.close();await store?.closeCommands();await proxy?.close();await operation?.release();})();
|
|
48
|
+
const cleanup=()=>cleanupPromise??=(async()=>{scheduling.abort();activity?.close();await site?.close();await store?.closeCommands();await proxy?.close();await operation?.release();})();
|
|
43
49
|
const stop=async why=>{reason??=why;controller.abort();finished();await cleanupDone;return {};};
|
|
44
50
|
const handlers=new Map(['SIGINT','SIGTERM'].map(name=>[name,()=>{termination=name;void stop(name);} ]));
|
|
45
51
|
for(const [name,handler]of handlers)process.on(name,handler);
|
|
46
52
|
try{
|
|
47
53
|
if(options.stop){await registry.stop(options.verb,options);return {};}
|
|
48
54
|
controller.signal.throwIfAborted();
|
|
55
|
+
if(options.door||options.direct)await moveCheckout(project,options,out,registry);
|
|
49
56
|
const devIdentity=project.adapter.devIdentity;
|
|
50
57
|
if(options.as!==undefined&&!(devIdentity&&options.verb==='dev'))throw invalid('--as needs a seed that declares devIdentity.');
|
|
51
58
|
let devKey=devIdentity?await readDevKey(project.root):null;
|
|
@@ -62,13 +69,20 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
62
69
|
if(options.verb==='dev' && (running.models??'fake')!==(options.models??'fake'))throw refuse('Stop the running dev server before changing --models mode.');
|
|
63
70
|
if(options.modelsService)throw refuse('Stop the running dev server before saving live-dev credentials.');
|
|
64
71
|
if(options.verb==='dev'&&Boolean(running.login)!==Boolean(options.login))throw refuse(`The running dev server ${running.login?'uses':'does not use'} --login. Stop it with rer dev --stop to change sign-in mode.`);
|
|
65
|
-
|
|
72
|
+
// A door-mode server is probed on its own proxy, never through the door.
|
|
73
|
+
await claimSavedName(project,running.url);
|
|
74
|
+
await routeRunning(running);
|
|
75
|
+
const {appFetch}=await shared('app-fetch.mjs');
|
|
76
|
+
const response=await appFetch(running.url+'/health',{headers:{'x-rer-probe':running.probe},signal:AbortSignal.timeout(5000)});
|
|
66
77
|
if(!response.ok || (await response.json()).status!=='ok')throw refuse('Registered application failed health verification. Stop it before retrying.');
|
|
67
78
|
if(devIdentity)devKey=await requireDevKey(project.root);
|
|
68
|
-
|
|
79
|
+
// A key is sent (or printed) for a door origin only once the door proves it is ours.
|
|
80
|
+
if(devIdentity)await verifyDoorFor(running.url);
|
|
81
|
+
const dev=devIdentity&&await devReady(running.url,devKey,options,controller.signal);
|
|
82
|
+
await emitReady(out,options,project.root,running.url,dev,true,running.siteUrl);return {reused:true};
|
|
69
83
|
}
|
|
70
84
|
await registry.serve(async action=>{
|
|
71
|
-
if(action==='status')return {...activity?.snapshot(),url:selected?.url,probe:proxy?.probe};
|
|
85
|
+
if(action==='status')return {...activity?.snapshot(),url:selected?.url,probe:proxy?.probe,siteUrl:site?.url};
|
|
72
86
|
if(action==='stop')return stop('explicit stop');
|
|
73
87
|
throw Error('Unknown management operation');
|
|
74
88
|
});
|
|
@@ -81,7 +95,7 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
81
95
|
const seed=project.adapter.setup?await loadInstallerSeed(project.root,project.adapter):undefined;project.setupManifest=seed?.manifest;
|
|
82
96
|
const keys=localSettingKeys(project);
|
|
83
97
|
selected=await selectedOrigin(project,options);
|
|
84
|
-
await availablePort(selected.host,selected.port,{root:project.root,saved:!selected.explicitPort&&selected.savedOrigin!==undefined});
|
|
98
|
+
if(!selected.door)await availablePort(selected.host,selected.port,{root:project.root,saved:!selected.explicitPort&&selected.savedOrigin!==undefined});
|
|
85
99
|
if(!selected.vars[keys.email])throw missing('Private owner settings are missing. Run rer setup first.');
|
|
86
100
|
if(devIdentity&&!devKey)devKey=await requireDevKey(project.root);
|
|
87
101
|
if(devIdentity&&options.verb==='dev'&&!options.login)refuseManagedInstallation(selected.vars,process.env);
|
|
@@ -97,7 +111,8 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
97
111
|
await tools(project.root,store,controller.signal);
|
|
98
112
|
activity=activityClock({...clock,seconds:options.idleTimeout,onIdle:()=>void stop('idle timeout'),onChange:recordActivity});
|
|
99
113
|
const crons=project.adapter.scheduled!==false?await declaredCrons(project.root,project.adapter).catch(error=>{throw refuse(`The declared scheduled triggers cannot run locally: ${error.message}`);}):[];
|
|
100
|
-
proxy=await runtimeFrontDoor({host:selected.host,port:selected.port,activity});const internal=proxy.runtimePort;
|
|
114
|
+
proxy=await runtimeFrontDoor({host:selected.host,port:selected.port,activity,door:selected.door,devKey:devKey??undefined,origin:selected.url});const internal=proxy.runtimePort;
|
|
115
|
+
if(selected.door)await announceDoorRoute(selected,proxy,registry);
|
|
101
116
|
const modelsLive = options.verb==='dev' ? await prepareLiveModels(project.root,options,store,controller.signal) : undefined;
|
|
102
117
|
const adapterOptions={modelsLive,selectedOrigin:selected.url,operationToken:operation.token,privateSettings:project.adapter.settings,runtimePort:internal,devKey:devKey??undefined,devLogin:Boolean(options.login),scheduled:crons.length>0};
|
|
103
118
|
const input={[keys.email]:selected.vars[keys.email]};
|
|
@@ -110,9 +125,16 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
110
125
|
if(!identity)throw Error('Runtime exited before custody registration.');
|
|
111
126
|
await registry.update({runtimeProcess:{pid:identity.pid,start:identity.start}});
|
|
112
127
|
},controller.signal,{...adapterOptions,verb:options.verb,dev:project.adapter.dev,preview:project.adapter.preview});
|
|
128
|
+
// A key is sent (or printed) for a door origin only once the door proves it is ours.
|
|
129
|
+
if(devKey)await verifyDoorFor(selected.url);
|
|
113
130
|
const dev=devIdentity&&await devReady(selected.url,devKey,options,controller.signal);
|
|
114
131
|
const outbox=await outboxServed(selected.url,devKey,controller.signal);
|
|
115
|
-
|
|
132
|
+
if(options.verb==='dev'&&project.adapter.site){
|
|
133
|
+
const {devSite}=await import('./site.mjs');
|
|
134
|
+
site=await devSite(project,store,controller.signal,{host:selected.host,port:selected.port});
|
|
135
|
+
await registry.update({siteUrl:site.url});
|
|
136
|
+
}
|
|
137
|
+
activity.ready();await emitReady(out,options,project.root,selected.url,dev,outbox,site?.url);
|
|
116
138
|
if(crons.length){
|
|
117
139
|
out.progress(`Running ${crons.length===1?'the scheduled trigger':'the scheduled triggers'} locally: ${crons.join(' | ')}\n`);
|
|
118
140
|
void runScheduled({crons,url:`http://127.0.0.1:${internal}`,path:options.verb==='preview'?'/cdn-cgi/local/scheduled':undefined,signal:scheduling.signal,report:run=>reportScheduled(out,options,run)});
|
|
@@ -130,14 +152,26 @@ function reportScheduled(out,options,run){
|
|
|
130
152
|
}
|
|
131
153
|
// GET, not HEAD: a seed's outbox route need not answer HEAD. The body is never read.
|
|
132
154
|
async function outboxServed(url,devKey,signal){
|
|
133
|
-
const
|
|
155
|
+
const {appFetch}=await shared('app-fetch.mjs');
|
|
156
|
+
const response=await appFetch(url+'/__dev/outbox',{headers:devKey?{[DEV_KEY_HEADER]:devKey}:{},signal});
|
|
134
157
|
await response.body?.cancel();
|
|
135
158
|
return response.ok;
|
|
136
159
|
}
|
|
137
|
-
|
|
160
|
+
// dev also keeps its RER_OPEN link in owner-only .rer/local/open-url (0600),
|
|
161
|
+
// which reset removes with the rest of .rer/local, for a person or agent that
|
|
162
|
+
// did not keep the terminal output.
|
|
163
|
+
async function emitReady(out,options,root,url,dev,outbox,siteUrl){
|
|
164
|
+
if(options.verb==='dev'&&dev?.openUrl){
|
|
165
|
+
const {privateDirectory,writePrivateFile}=await shared('private-files.mjs');
|
|
166
|
+
await writePrivateFile(join(await privateDirectory(root,['.rer','local']),'open-url'),dev.openUrl+'\n');
|
|
167
|
+
}
|
|
168
|
+
ready(out,url,dev||undefined,options.json,outbox,siteUrl);
|
|
169
|
+
}
|
|
138
170
|
async function devReady(url,key,options,signal){
|
|
139
171
|
if(options.verb==='preview')return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
|
|
140
172
|
if(options.login)return {openUrl:openUrl(url,key,{next:'/__dev/outbox'})};
|
|
141
173
|
const as=options.as??'owner',persona=await resolvePersona(url,key,as,signal);
|
|
142
|
-
|
|
174
|
+
// The owner persona names the owner the session will actually have (the
|
|
175
|
+
// remaining owner once the setup owner has left), or none when none is left.
|
|
176
|
+
return {identity:true,signedInAs:persona.email??null,ownerLeft:as==='owner'&&!persona.email,openUrl:openUrl(url,key,{path:'/__dev/key',as})};
|
|
143
177
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A package-mode checkout's commands outside package.json and OPERATIONS.json:
|
|
2
|
+
// its delivery operations (rer-project.json, and the delivery producer's
|
|
3
|
+
// packaging.json that rer-project.json is generated from), the scripts those
|
|
4
|
+
// operations and its CI workflows run, and the workflows themselves. Converting
|
|
5
|
+
// to package mode rewrites their calls into the removed vendored copy
|
|
6
|
+
// (scripts/rer.mjs, scripts/base-cli, scripts/base-onboarding); check fails
|
|
7
|
+
// while any remains.
|
|
8
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// A command line that runs a file of the vendored copy, and (in a JavaScript
|
|
12
|
+
// file) a string literal naming one, as a spawn argument does.
|
|
13
|
+
const vendoredCall = /\b(?:node|bash|sh)\s+(?:\.\/)?scripts\/(?:rer\.mjs|base-cli\/|base-onboarding\/)/;
|
|
14
|
+
const vendoredLiteral = /(['"`])(?:\.\/)?scripts\/(?:rer\.mjs|base-cli\/|base-onboarding\/)[^'"`\n]*\1/;
|
|
15
|
+
const commentLine = /^\s*(?:#|\/\/|\/?\*)/;
|
|
16
|
+
const jsFile = /\.(?:mjs|cjs|js)$/;
|
|
17
|
+
// A seed-relative script path in a command.
|
|
18
|
+
const scriptToken = /(?<![\w./$-])(?:\.\/)?((?:[\w.-]+\/)*[\w.-]+\.(?:sh|bash|mjs|cjs|js))(?![\w./-])/g;
|
|
19
|
+
|
|
20
|
+
// The files holding delivery operations, and where in each they are.
|
|
21
|
+
export const operationFiles = [
|
|
22
|
+
{ file: 'rer-project.json', operations: value => value?.operations },
|
|
23
|
+
{ file: 'scripts/delivery/base/packaging.json', operations: value => value?.project?.operations },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
async function text(root, file) {
|
|
27
|
+
try { return await readFile(resolve(root, file), 'utf8'); } catch (error) { if (error.code === 'ENOENT' || error.code === 'EISDIR') return undefined; throw error; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Each operations file present: { file, text, value, operations }.
|
|
31
|
+
export async function operationSources(root) {
|
|
32
|
+
const found = [];
|
|
33
|
+
for (const { file, operations } of operationFiles) {
|
|
34
|
+
const source = await text(root, file);
|
|
35
|
+
if (source === undefined) continue;
|
|
36
|
+
let value; try { value = JSON.parse(source); } catch { continue; }
|
|
37
|
+
const commands = operations(value);
|
|
38
|
+
if (commands && typeof commands === 'object') found.push({ file, text: source, value, operations: commands });
|
|
39
|
+
}
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The checkout's CI workflow files: { file, text }.
|
|
44
|
+
export async function workflowSources(root) {
|
|
45
|
+
let names;
|
|
46
|
+
try { names = await readdir(resolve(root, '.github/workflows')); } catch { return []; }
|
|
47
|
+
const found = [];
|
|
48
|
+
for (const name of names.filter(name => /\.ya?ml$/.test(name)).sort()) found.push({ file: `.github/workflows/${name}`, text: await text(root, `.github/workflows/${name}`) });
|
|
49
|
+
return found.filter(source => source.text !== undefined);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The seed's own script files a command runs (that exist, outside node_modules
|
|
53
|
+
// and the vendored copy).
|
|
54
|
+
export async function calledScripts(root, command) {
|
|
55
|
+
const found = [];
|
|
56
|
+
for (const [, path] of command.matchAll(scriptToken)) {
|
|
57
|
+
if (/^(?:node_modules\/|scripts\/(?:rer\.mjs$|base-cli\/|base-onboarding\/))/.test(path) || found.includes(path)) continue;
|
|
58
|
+
if (await text(root, path) !== undefined) found.push(path);
|
|
59
|
+
}
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Every place these commands still call the vendored copy, as `file:line: text`
|
|
64
|
+
// (or `file operations.NAME: command`).
|
|
65
|
+
export async function vendoredCalls(root) {
|
|
66
|
+
const calls = [], scripts = new Set();
|
|
67
|
+
for (const { file, operations } of await operationSources(root)) for (const [name, command] of Object.entries(operations)) {
|
|
68
|
+
if (typeof command !== 'string') continue;
|
|
69
|
+
if (vendoredCall.test(command)) calls.push(`${file} operations.${name}: ${command}`);
|
|
70
|
+
for (const path of await calledScripts(root, command)) scripts.add(path);
|
|
71
|
+
}
|
|
72
|
+
const lines = (file, source, literals) => source.split('\n').forEach((line, index) => {
|
|
73
|
+
if (!commentLine.test(line) && (vendoredCall.test(line) || (literals && vendoredLiteral.test(line)))) calls.push(`${file}:${index + 1}: ${line.trim().slice(0, 160)}`);
|
|
74
|
+
});
|
|
75
|
+
for (const { file, text: source } of await workflowSources(root)) {
|
|
76
|
+
lines(file, source, false);
|
|
77
|
+
for (const path of await calledScripts(root, source)) scripts.add(path);
|
|
78
|
+
}
|
|
79
|
+
for (const path of [...scripts].sort()) lines(path, await text(root, path), jsFile.test(path));
|
|
80
|
+
return calls;
|
|
81
|
+
}
|