@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.
Files changed (106) hide show
  1. package/README.md +111 -1
  2. package/cli/README.md +28 -3
  3. package/cli/contract.mjs +4 -4
  4. package/cli/dev-identity.mjs +2 -1
  5. package/cli/door-mode.mjs +134 -0
  6. package/cli/door.mjs +263 -0
  7. package/cli/hosted-output.mjs +15 -5
  8. package/cli/hydration/browser.mjs +91 -0
  9. package/cli/hydration/cases.mjs +106 -0
  10. package/cli/hydration/check.mjs +105 -0
  11. package/cli/hydration/probe.mjs +164 -0
  12. package/cli/hydration/scripts.mjs +39 -0
  13. package/cli/hydration/verdict.mjs +85 -0
  14. package/cli/inspect.mjs +56 -9
  15. package/cli/lifecycle.mjs +36 -12
  16. package/cli/local.mjs +9 -3
  17. package/cli/main.mjs +2 -0
  18. package/cli/managed-server.mjs +52 -18
  19. package/cli/package-calls.mjs +81 -0
  20. package/cli/package-mode.mjs +46 -0
  21. package/cli/parse.mjs +14 -4
  22. package/cli/pnpm-runtime.mjs +96 -0
  23. package/cli/portability-worker.mjs +2 -2
  24. package/cli/runtime-front-door.mjs +4 -2
  25. package/cli/server-proxy.mjs +69 -5
  26. package/cli/server-registry.mjs +10 -7
  27. package/cli/settings.mjs +14 -0
  28. package/cli/site-check.mjs +96 -0
  29. package/cli/site-preview.mjs +53 -0
  30. package/cli/site-render.mjs +77 -0
  31. package/cli/site.mjs +109 -0
  32. package/global/CHANGELOG.md +63 -0
  33. package/global/README.md +111 -1
  34. package/global/checkout.mjs +24 -10
  35. package/global/cli/install.mjs +6 -2
  36. package/global/delegate.mjs +9 -7
  37. package/global/door/command.mjs +254 -0
  38. package/global/door/control.mjs +65 -0
  39. package/global/door/cookies.mjs +41 -0
  40. package/global/door/pages.mjs +49 -0
  41. package/global/door/policy.mjs +49 -0
  42. package/global/door/routes.mjs +45 -0
  43. package/global/door/server.mjs +255 -0
  44. package/global/door/sites.mjs +192 -0
  45. package/global/launch/dev.mjs +21 -7
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/install-pinned.mjs +22 -0
  48. package/global/launch/prepare.mjs +38 -5
  49. package/global/launch/render.mjs +6 -1
  50. package/global/launch/steps.mjs +12 -7
  51. package/global/launch/worker.mjs +3 -3
  52. package/global/list.mjs +6 -3
  53. package/global/main.mjs +37 -3
  54. package/global/version.mjs +8 -8
  55. package/modules/dependencies/protocol.mjs +2 -1
  56. package/modules/deploy-kit/README.md +34 -1
  57. package/modules/deploy-kit/artifact.mjs +10 -0
  58. package/modules/deploy-kit/owner.mjs +5 -4
  59. package/modules/deploy-kit/prepare.mjs +3 -2
  60. package/modules/deploy-kit/provider.mjs +6 -3
  61. package/modules/deploy-kit/site.mjs +135 -0
  62. package/modules/erasure/CONTRACT.md +6 -3
  63. package/modules/erasure/README.md +8 -1
  64. package/modules/erasure/audit.mjs +2 -2
  65. package/modules/erasure/cascade.mjs +52 -11
  66. package/modules/erasure/order.mjs +29 -22
  67. package/modules/erasure/plan.mjs +4 -2
  68. package/modules/erasure/queue.mjs +41 -0
  69. package/modules/erasure/scope.mjs +3 -3
  70. package/modules/erasure/verification.mjs +4 -2
  71. package/modules/mail/README.md +1 -1
  72. package/modules/mail/index.mjs +1 -1
  73. package/modules/mail/src/core/errors.mjs +9 -0
  74. package/modules/mail/src/development/index.d.mts +6 -0
  75. package/modules/mail/src/development/index.mjs +6 -3
  76. package/modules/mail/src/development/viewer-html.mjs +3 -3
  77. package/modules/mail/src/development/viewer.mjs +8 -1
  78. package/modules/operator-auth/dev/index.mjs +15 -5
  79. package/modules/operator-auth/http.mjs +2 -3
  80. package/modules/operator-auth/loopback.mjs +17 -0
  81. package/modules/owner-claim/verify.mjs +2 -1
  82. package/modules/portability/README.md +8 -0
  83. package/modules/portability/hash.mjs +6 -2
  84. package/modules/portability/import.mjs +1 -1
  85. package/modules/portability/legacy-files.mjs +4 -4
  86. package/modules/portability/legacy-safety.mjs +51 -10
  87. package/modules/portability/migration-history.mjs +15 -0
  88. package/modules/release/README.md +5 -3
  89. package/modules/release/produce.mjs +5 -2
  90. package/modules/release/source.mjs +15 -4
  91. package/onboarding/adapter-auth.mjs +4 -3
  92. package/onboarding/adapter-settings.mjs +21 -5
  93. package/onboarding/adapter.mjs +2 -1
  94. package/onboarding/app-fetch.mjs +79 -0
  95. package/onboarding/archive-output.d.mts +4 -2
  96. package/onboarding/archive-output.mjs +22 -10
  97. package/onboarding/dependency-approval.mjs +4 -3
  98. package/onboarding/local/environment.mjs +7 -2
  99. package/onboarding/operator-install.mjs +8 -2
  100. package/onboarding/operator-prerequisites.mjs +5 -2
  101. package/onboarding/queued-entry.mjs +9 -5
  102. package/operations/operations.mjs +11 -2
  103. package/package.json +75 -1
  104. package/scripts/publish-check/github_refs.py +28 -2
  105. package/scripts/publish-check/public_hygiene.py +35 -4
  106. 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] === `node scripts/rer.mjs${verb ? ' ' + verb : ''}`)),
32
- local: { state: project.adapter?.state, settings: project.adapter?.settings ?? [], installerState: '.seed/installer', outbox: '/__dev/outbox', ...await localState(project), ...(project.adapter?.devIdentity ? { devIdentity: true, devKey: '.rer/local/dev-key.env' } : {}) },
35
+ aliases: Object.fromEntries(Object.entries(aliases).filter(([alias, verb]) => pkg.scripts?.[alias] === commandLine(verb, declaredPackage(pkg) !== undefined))),
36
+ local: { state: project.adapter?.state, settings: project.adapter?.settings ?? [], installerState: '.seed/installer', outbox: '/__dev/outbox', ...await localState(project), origin: await savedOrigin(project), ...(project.adapter?.devIdentity ? { devIdentity: true, devKey: '.rer/local/dev-key.env' } : {}) },
37
+ // This pin serves a checkout through the machine's front door (rer door).
38
+ door: { protocol: doorProtocol },
33
39
  };
34
40
  }
35
41
 
@@ -57,6 +63,9 @@ export async function check(project, explicitProfile) {
57
63
  for (const loop of project.descriptor.loops ?? []) add('SC-10.loop.' + loop.id, !loop.required || loop.enabled, `Enable the required ${loop.id} loop or correct its declaration.`);
58
64
  if (project.profile === 'first-party' || explicitProfile === 'first-party') {
59
65
  const adapter = project.adapter, pkg = await readPackage(project.root), marker = project.descriptor.seedContract;
66
+ // Package mode (an @runeditrun/rer pin) and vendored mode differ only in
67
+ // where rer comes from: the command targets and the source clauses below.
68
+ const packaged = declaredPackage(pkg) !== undefined;
60
69
  add('SC-09.profile', marker?.version === 1 && marker?.profile === 'first-party', 'Restore OPERATIONS v2 seedContract version 1, profile first-party and the adapter pointer.');
61
70
  add('SC-01.adapter', adapter?.version === 1, 'Export commandAdapter version 1 from scripts/installer/local.mjs.');
62
71
  for (const hook of requiredHooks) add('SC-01.hook.' + hook, adapter?.[hook] ?? adapter?.hooks?.[hook], `Missing ${hook} hook/inventory; adopt the shared command adapter with a real ${hook} target.`);
@@ -64,14 +73,18 @@ export async function check(project, explicitProfile) {
64
73
  const declaration = project.descriptor.commands[verb];
65
74
  add('SC-01.verb.' + verb, declaration && adapter?.verbs?.[verb], `Missing ${verb}; declare it in OPERATIONS and expose its shared implementation.`);
66
75
  for (const flag of definition.flags) add('SC-01.flag.' + verb + '.' + flag.slice(2), adapter?.verbs?.[verb]?.flags?.includes(flag), `Missing ${verb} ${flag}; restore the shared implementation supporting this flag.`);
67
- add('SC-02.target.' + verb, declaration?.argv?.join('\0') === ['node', 'scripts/rer.mjs', verb].join('\0'), `Declare ${verb} argv as node scripts/rer.mjs ${verb}.`);
76
+ const argv = commandArgv(verb, packaged);
77
+ add('SC-02.target.' + verb, declaration?.argv?.join('\0') === argv.join('\0'), `Declare ${verb} argv as ${argv.join(' ')}.`);
68
78
  }
69
- for (const [alias, verb] of Object.entries(aliases)) add('SC-02.alias.' + alias, pkg.scripts?.[alias] === `node scripts/rer.mjs${verb ? ' ' + verb : ''}`, `Set package script ${alias} to node scripts/rer.mjs${verb ? ' ' + verb : ''}.`);
79
+ for (const [alias, verb] of Object.entries(aliases)) add('SC-02.alias.' + alias, pkg.scripts?.[alias] === commandLine(verb, packaged), `Set package script ${alias} to ${commandLine(verb, packaged)}.`);
70
80
  add('SC-02.install-lifecycle', !pkg.scripts?.install, 'Remove the install lifecycle alias; use deps or frozen pnpm install.');
71
81
  let nodeFile; try { nodeFile = (await readFile(resolve(project.root, '.node-version'), 'utf8')).trim(); } catch {}
72
82
  add('SC-02.node-file', nodeFile === pins.node, `Set .node-version to ${pins.node}.`);
73
83
  add('SC-02.node-engine', pkg.engines?.node === pins.node, `Pin package.json engines.node to ${pins.node}.`);
74
84
  add('SC-02.pnpm', pkg.packageManager === `pnpm@${pins.pnpm}`, `Pin packageManager to pnpm@${pins.pnpm}.`);
85
+ if (packaged) for (const [id, ok, message] of await packageClauses(project.root, pkg)) add(id, ok, message);
86
+ // Vendored mode: the copy and its recorded inventory.
87
+ else {
75
88
  for (const path of ['scripts/rer.mjs', 'scripts/base-cli/inventory.json', 'scripts/base-onboarding/adapter.mjs']) {
76
89
  let exists = false; try { await contained(project.root, path); exists = true; } catch {}
77
90
  add('SC-01.portable.' + path, exists, `Restore portable source ${path} through the documented export tool.`);
@@ -94,6 +107,7 @@ export async function check(project, explicitProfile) {
94
107
  add('SC-01.inventory.' + path, exact, `Restore stale or missing portable CLI source ${path} with the source export tool.`);
95
108
  }
96
109
  } catch { add('SC-01.inventory', false, 'Restore the deterministic portable CLI source inventory.'); }
110
+ }
97
111
  add('SC-08.portability', typeof adapter?.portability?.export === 'function' && typeof adapter?.portability?.import === 'function', 'Adopt the reviewed mainline complete local portability API; archive support remains unavailable until then.');
98
112
  if (adapter?.devIdentity) for (const [id, ok, message] of await devIdentityClauses(project.root, adapter)) add(id, ok, message);
99
113
  for (const [id, ok, message] of await authCopyClauses(project.root)) add(id, ok, message);
@@ -102,12 +116,40 @@ export async function check(project, explicitProfile) {
102
116
  return { clauses, ok: clauses.every(clause => clause.ok) };
103
117
  }
104
118
 
119
+ // Package mode: the exact pin, its lockfile entry, no vendored copy and no
120
+ // command still calling one.
121
+ async function packageClauses(root, pkg) {
122
+ const declared = declaredPackage(pkg), locked = await lockedPackage(root), clauses = [];
123
+ clauses.push(['SC-13.package.pin', pkg.devDependencies?.[packageName] === declared && exactVersion.test(declared), `Pin ${packageName} in devDependencies to an exact version (for example "0.2.0"), not ${JSON.stringify(declared)} or a runtime dependency.`]);
124
+ clauses.push(['SC-13.package.lockfile', locked?.locked && locked.specifier === declared && locked.version === declared, `Run pnpm install so pnpm-lock.yaml locks ${packageName} ${declared}${locked?.version ? ` (it locks ${locked.version})` : ''}.`]);
125
+ for (const path of vendoredPaths) {
126
+ let present = true; try { await lstat(resolve(root, path)); } catch { present = false; }
127
+ clauses.push(['SC-13.package.vendored.' + path, !present, `Remove ${path}: a checkout that pins ${packageName} runs the package, not a vendored copy (node scripts/export-cli.mjs --to-package converts one).`]);
128
+ }
129
+ // Delivery operations, the scripts they run and CI workflows call the package too.
130
+ const calls = await vendoredCalls(root);
131
+ clauses.push(['SC-13.package.calls', !calls.length, `Run pnpm exec rer VERB instead of the removed vendored copy (pnpm install --frozen-lockfile first where nothing has installed yet) in ${calls.length} place${calls.length === 1 ? '' : 's'}:\n${calls.slice(0, 20).join('\n')}${calls.length > 20 ? `\n...and ${calls.length - 20} more` : ''}`]);
132
+ return clauses;
133
+ }
134
+
135
+ // Without the pinned pnpm on PATH, rer provides it for this command only
136
+ // (cli/pnpm-runtime.mjs); it never changes global tools.
105
137
  export async function tools(root, store, signal) {
106
138
  const node = await projectNodePin(root);
107
139
  if (node && process.versions.node !== node) throw missing(`Node ${node} is required. Run it with rer, with automatic project runtime selection enabled.`);
108
- const version = await probePnpm(root, store, signal);
109
- if (version !== pins.pnpm) throw missing(`pnpm ${pins.pnpm} is required. Install/select the documented pin; rer never changes global tools.`);
140
+ const found = await probePnpm(root, store, signal).catch(error => { if (signal?.aborted) throw error; return null; });
141
+ if (found === pins.pnpm) return;
142
+ const { cachedPnpm, downloadPnpm } = await import('./pnpm-runtime.mjs');
143
+ let directory = await cachedPnpm(pins.pnpm);
144
+ if (!directory) {
145
+ store.report?.(`Fetching pnpm ${pins.pnpm} for this project (PATH has ${found ? `pnpm ${found}` : 'no pnpm'}); rer keeps it in its own cache.`);
146
+ try { directory = await downloadPnpm(pins.pnpm, signal); }
147
+ catch (error) { if (signal?.aborted || error instanceof RerError) throw error; throw missing(`pnpm ${pins.pnpm} is required and could not be fetched (${error.message}). Check the connection, or install pnpm ${pins.pnpm} yourself.`); }
148
+ }
149
+ usePnpm(directory);
150
+ if (await probePnpm(root, store, signal) !== pins.pnpm) throw missing(`pnpm ${pins.pnpm} is required. The pnpm rer provides did not report it; install pnpm ${pins.pnpm} yourself.`);
110
151
  }
152
+ const usePnpm = directory => { process.env.PATH = [dirname(process.execPath), directory, process.env.PATH].filter(Boolean).join(delimiter); };
111
153
 
112
154
  export async function doctor(project, store, signal) {
113
155
  const { inspectOperations } = await import('../operations/operations.mjs');
@@ -127,6 +169,11 @@ export async function doctor(project, store, signal) {
127
169
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) values = parsed;
128
170
  } catch {}
129
171
  try { pnpm = await probePnpm(project.root, store, signal); } catch (error) { if (signal?.aborted) throw error; }
172
+ // A pnpm rer already provides counts; doctor never fetches one.
173
+ if (pnpm !== pins.pnpm) {
174
+ const directory = await (await import('./pnpm-runtime.mjs')).cachedPnpm(pins.pnpm).catch(() => undefined);
175
+ if (directory) { usePnpm(directory); try { pnpm = await probePnpm(project.root, store, signal); } catch (error) { if (signal?.aborted) throw error; } }
176
+ }
130
177
  }
131
178
  const aiIssues = await localAiDiagnostics(project,process.env);
132
179
  const dependencies=project.profile==='first-party'?await dependencyDoctor(project.root):[];
package/cli/lifecycle.mjs CHANGED
@@ -1,5 +1,3 @@
1
- import { createHmac } from 'node:crypto';
2
- import { readFile } from 'node:fs/promises';
3
1
  import { dependencyInputs } from './dependency-input.mjs';
4
2
  import { linkDependencies } from './dependencies.mjs';
5
3
  import { savedSetupAnswers } from './setup-answers.mjs';
@@ -13,9 +11,10 @@ import { missing, refuse, RerError, lifecycleError } from './errors.mjs';
13
11
  import { executeDeclared } from './independent.mjs';
14
12
  import { transfer } from './transfers.mjs';
15
13
  import { runtimeFrontDoor } from './runtime-front-door.mjs';
16
- import { localSettingKeys, requiredLocalSettings } from './settings.mjs';
14
+ import { localCliSessionProof, localSettingKeys, requiredLocalSettings } from './settings.mjs';
17
15
  import { rememberSetupAnswers } from './setup-answers.mjs';
18
16
  import { ensureDevKey, readDevKey, refuseDevLayerOutput, openUrl, DEV_KEY_HEADER } from './dev-identity.mjs';
17
+ import { announceDoorRoute, routeRunning, verifyDoorFor } from './door-mode.mjs';
19
18
 
20
19
  export async function runCommand(project, options, out) {
21
20
  if(project.profile==='first-party'&&['dev','preview'].includes(options.verb))return (await import('./managed-server.mjs')).managedServer(project,options,out);
@@ -42,11 +41,12 @@ export async function runCommand(project, options, out) {
42
41
  selected = await selectedOrigin(project, options, { setup: options.verb === 'setup' });
43
42
  if (options.verb === 'setup') {input = await setupInput(project, options, selected.vars, signal, { origin: selected.url });await dependencyInputs({project,input,origin:selected.url});}
44
43
  else if (requiredLocalSettings(project, selected.vars).length) throw missing('Private owner settings are missing. Run rer setup --owner-email EMAIL first.');
45
- if (options.verb !== 'outbox') await availablePort(selected.host, selected.port, { root: project.root, saved: !selected.explicitPort && selected.savedOrigin !== undefined });
44
+ if (options.verb !== 'outbox' && !selected.door) await availablePort(selected.host, selected.port, { root: project.root, saved: !selected.explicitPort && selected.savedOrigin !== undefined });
46
45
  if (options.verb === 'setup') announcePort(out, options, selected);
47
46
  }
48
47
  const { localOperation } = await shared('local/operation.mjs');
49
- if (options.verb !== 'outbox') try { operation = await localOperation(project.root, process.env.RER_LOCAL_OPERATION); } catch (error) { throw refuse(error.message); }
48
+ // The run site reads no local state: it builds beside a running rer dev.
49
+ if (options.verb !== 'outbox' && !options.site) try { operation = await localOperation(project.root, process.env.RER_LOCAL_OPERATION); } catch (error) { throw refuse(error.message); }
50
50
  const { recoverInterrupted } = await shared('local/recovery.mjs');
51
51
  if (operation) await recoverInterrupted(project.root, operation);
52
52
  }
@@ -67,6 +67,8 @@ export async function runCommand(project, options, out) {
67
67
  if (project.profile === 'independent') return await executeDeclared(project, options, store, signal);
68
68
  await tools(project.root, store, signal);
69
69
  const adapter = project.adapter;
70
+ // The run site: static pages built from edition identity, never the app build.
71
+ if (options.site) return await (await import('./site.mjs')).siteCommand(project, options, store, signal, out);
70
72
  if (options.verb === 'install') {
71
73
  await store.run('Installing frozen dependencies', 'pnpm', ['install', '--frozen-lockfile'], { cwd: project.root, signal, env: { ...process.env, CI: 'true' } }); return {};
72
74
  }
@@ -75,7 +77,10 @@ export async function runCommand(project, options, out) {
75
77
  if (!Array.isArray(target)) throw refuse('A complete internal verification target is missing.');
76
78
  await store.run('Verifying application', target[0], target[1], { cwd: project.root, signal, env: { ...process.env, RER_LOCAL_OPERATION: operation.token } });
77
79
  await store.step('Serving the production build as rer deploy hosts it');
78
- return { hosted: await (await import('./hosted-output.mjs')).checkHostedOutput(project.root, { declaration: adapter.deployDeclaration ?? adapter.deploy?.declaration }) };
80
+ const declaration = adapter.deployDeclaration ?? adapter.deploy?.declaration;
81
+ const hosted = await (await import('./hosted-output.mjs')).checkHostedOutput(project.root, { declaration });
82
+ await store.step('Checking that page sections hydrate as separate units');
83
+ return { hosted, hydration: await (await import('./hydration/check.mjs')).checkHydration(project.root, { pages: adapter.publicPages, declaration, report: lines => lines.forEach(line => store.report(line)) }) };
79
84
  }
80
85
  if (['export', 'import'].includes(options.verb)) return await transfer(project, options, signal, { store, operationToken: operation.token });
81
86
  if (options.verb === 'deploy') {
@@ -89,7 +94,17 @@ export async function runCommand(project, options, out) {
89
94
  const devKey = adapter.devIdentity ? options.verb === 'setup' ? await ensureDevKey(project.root) : await readDevKey(project.root) : null;
90
95
  if (devKey) store.addSecrets?.([devKey]);
91
96
  let app;
92
- if (options.verb === 'setup') proxy = await runtimeFrontDoor(selected);
97
+ if (options.verb === 'setup') { proxy = await runtimeFrontDoor({ ...selected, devKey: devKey ?? undefined, origin: selected.url }); if (selected.door) await announceDoorRoute(selected, proxy); }
98
+ // The outbox of a door-mode checkout is read from its running server's own
99
+ // proxy, never through the door: with no such server nothing is sent. A key
100
+ // is sent only once the door has proved it is this account's.
101
+ if (options.verb === 'outbox' && selected?.door) {
102
+ const running = await runningRecord(project.root);
103
+ if (!running?.door) throw new RerError('outbox_unavailable', 'The local application outbox is unavailable. Start rer dev first.', 1);
104
+ if (devKey) await verifyDoorFor(selected.url);
105
+ await routeRunning(running);
106
+ }
107
+ const { appFetch } = await shared('app-fetch.mjs');
93
108
  const emailKey = localSettingKeys(project).email;
94
109
  await adapter.run(project.root, input ?? { [emailKey]: options.ownerEmail ?? selected?.vars[emailKey] }, store,
95
110
  child => { app = child; }, signal,
@@ -99,11 +114,9 @@ export async function runCommand(project, options, out) {
99
114
  if (options.verb === 'setup') {
100
115
  await linkDependencies({project,input,origin:url,store,signal});
101
116
  if(project.setupManifest?.dependencies?.length) {
102
- const { localVariables }=await shared('local/environment.mjs');
103
- const values=localVariables(await readFile(new URL(project.adapter.hooks?.secrets?.file??'.dev.vars','file://'+project.root+'/'),'utf8'));
104
- const proof=createHmac('sha256',values.SESSION_SECRET).update('local-cli\0sign-in').digest('hex');
117
+ const proof=await localCliSessionProof(project);
105
118
  store.addSecrets?.([proof]);
106
- const completed=await fetch(url+'/auth/setup-complete',{method:'POST',headers:{Origin:url,'Content-Type':'application/json','x-rer-local-cli':proof},body:'{}',signal});
119
+ const completed=await appFetch(url+'/auth/setup-complete',{method:'POST',headers:{Origin:url,'Content-Type':'application/json','x-rer-local-cli':proof},body:'{}',signal});
107
120
  if(!completed.ok)throw missing('The dependency setup has not completed an owner claim.');
108
121
  await completed.body?.cancel();
109
122
  }
@@ -113,7 +126,7 @@ export async function runCommand(project, options, out) {
113
126
  else if (devKey) { out.human(`RER_SETUP_READY ${url}`); out.human(`Next: rer dev opens ${url} signed in as the owner; first-run setup finishes at ${url}/setup.`); }
114
127
  else { out.human(`RER_SETUP_READY ${url}`); signInInstructions(out, signInUrl); out.human(`Finish first-run setup at ${url}/setup after sign-in.`); out.human('Next: rer dev'); }
115
128
  } else if (options.verb === 'outbox') {
116
- const response = await fetch(signInUrl, { headers: devKey ? { [DEV_KEY_HEADER]: devKey } : {}, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) });
129
+ const response = await appFetch(signInUrl, { headers: devKey ? { [DEV_KEY_HEADER]: devKey } : {}, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }).catch(() => ({ ok: false }));
117
130
  if (!response.ok) throw new RerError('outbox_unavailable', 'The local application outbox is unavailable. Start rer dev first.', 1);
118
131
  await response.body?.cancel();
119
132
  const open = devKey ? openUrl(url, devKey, { path: '/__dev/key', next: '/__dev/outbox' }) : signInUrl;
@@ -132,8 +145,19 @@ export async function runCommand(project, options, out) {
132
145
  }
133
146
  }
134
147
 
148
+ // This checkout's running server, from the shared registry (routeRunning
149
+ // checks that it is alive and holds its proxy port).
150
+ async function runningRecord(root) {
151
+ const { readJson } = await import('../modules/job-queue/storage.mjs');
152
+ const { serversDirectory } = await import('./server-registry.mjs');
153
+ const { realpath } = await import('node:fs/promises');
154
+ const checkout = await realpath(root), state = await readJson(join(serversDirectory(), 'state.json'), { running: [] });
155
+ return (state.running ?? []).find(record => record.checkout === checkout);
156
+ }
157
+
135
158
  // One plain line saying which port this checkout uses, and what moved.
136
159
  function announcePort(out, options, selected) {
160
+ if (selected.door) { if (options.json) out.event('origin', { url: selected.url, door: true }); else out.human(`Using ${selected.url} for this checkout, served through the front door.`); return; }
137
161
  const { moved, skipped } = selected;
138
162
  const line = moved ? `Moving this checkout from port ${moved.from} to port ${moved.to}. Its data is kept.`
139
163
  : !selected.savedOrigin ? `Using port ${selected.port} for this checkout${skipped ? ` (${skipped} is in use)` : ''}.` : undefined;
package/cli/local.mjs CHANGED
@@ -8,6 +8,8 @@ import { origin, loopback } from './parse.mjs';
8
8
  import { refuse } from './errors.mjs';
9
9
  import { defaultPort, firstFreePort, busyPortMessage } from './port.mjs';
10
10
  import { localSettingKeys } from './settings.mjs';
11
+ import { doorSite } from './door.mjs';
12
+ import { selectDoor } from './door-mode.mjs';
11
13
 
12
14
  export async function selectedOrigin(project, options, { setup = false } = {}) {
13
15
  const { readSettings } = await shared('adapter-settings.mjs');
@@ -18,8 +20,11 @@ export async function selectedOrigin(project, options, { setup = false } = {}) {
18
20
  try { previous = new URL(vars[originKey]); } catch { throw refuse('Saved origin is malformed. Restore private settings or use confirmed reset.'); }
19
21
  if (previous.origin !== vars[originKey] || previous.protocol !== 'http:' || !loopback(previous.hostname.replace(/^\[|\]$/g, ''))) throw refuse('Saved origin is unsafe. Restore private settings or use confirmed reset.');
20
22
  }
21
- const host = options.host ?? previous?.hostname.replace(/^\[|\]$/g, '') ?? '127.0.0.1';
22
- const savedPort = previous ? Number(previous.port || 80) : undefined;
23
+ // A front-door origin (saved, or --door on a fresh setup) serves through the door.
24
+ const door = await selectDoor(project, options, previous, { vars, savedOrigin: previous?.origin });
25
+ if (door) return door;
26
+ const host = options.host ?? (previous && !doorSite(previous.origin) ? previous.hostname.replace(/^\[|\]$/g, '') : '127.0.0.1');
27
+ const savedPort = previous && !doorSite(previous.origin) ? Number(previous.port || 80) : undefined;
23
28
  // A first setup with no port asked for takes the first free one, never a busy one.
24
29
  let port = options.port ?? savedPort, taken;
25
30
  if (port === undefined && setup) { port = await firstFreePort(host); taken = port !== defaultPort; }
@@ -95,6 +100,7 @@ export async function refuseLegacyProcess(project,{reset=false}={}) {
95
100
  // Confirmed reset can recover malformed settings; checkout/state custody is
96
101
  // still checked even when there is no usable saved origin.
97
102
  const {localProcessConflict}=await shared('process-diagnostics.mjs');
98
- const conflict=await localProcessConflict(project.root,selected?.savedOrigin);
103
+ // The front door holds a door origin's port, not this checkout.
104
+ const conflict=await localProcessConflict(project.root,selected?.door?undefined:selected?.savedOrigin);
99
105
  if(conflict)throw refuse(`A local process holds ${conflict.reason}: PID ${conflict.pid} (${conflict.command}). Stop it with kill -TERM ${conflict.pid}, then retry. Use rer dev --stop or rer preview --stop for a registered server.`);
100
106
  }
package/cli/main.mjs CHANGED
@@ -38,6 +38,8 @@ export async function main(argv = process.argv.slice(2)) {
38
38
  if (!options.json) out.human(JSON.stringify(result, null, 2));
39
39
  out.result(result.ok, result.ok ? 0 : 3, { diagnostics: result }); return result.ok ? 0 : 3;
40
40
  }
41
+ // A checkout without a declared run site refuses --site before any effect.
42
+ if (options.site) (await import('./site.mjs')).declaredSite(project);
41
43
  const bounded=await (await import('./bounded.mjs')).boundedCommand(project,options,argv,out);
42
44
  if(bounded)return bounded.exitCode;
43
45
  if(options.platform==='container' && !['export','import'].includes(options.verb)) { const result = await (await import('./container.mjs')).containerCommand(project,options,out); out.result(true,0,result); return 0; }
@@ -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
- export function ready(out,url,dev,json=false,outbox=true){
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.');return;}
26
- if(!outbox)return;
27
- out.human(`RER_SIGN_IN ${signInUrl}`);
28
- if(dev)out.human(`RER_OPEN ${dev.openUrl}`);
29
- 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.`);
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
- const response=await fetch(running.url+'/health',{headers:{'x-rer-probe':running.probe},signal:AbortSignal.timeout(5000)});
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
- emitReady(out,options,running.url,devIdentity&&await devReady(running.url,devKey,options,controller.signal));return {reused:true};
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
- activity.ready();emitReady(out,options,selected.url,dev,outbox);
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 response=await fetch(url+'/__dev/outbox',{headers:devKey?{[DEV_KEY_HEADER]:devKey}:{},signal});
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
- function emitReady(out,options,url,dev,outbox){ready(out,url,dev||undefined,options.json,outbox);}
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
- return {identity:true,signedInAs:persona.email??null,openUrl:openUrl(url,key,{path:'/__dev/key',as})};
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
+ }