@runeditrun/rer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +115 -2
  2. package/cli/README.md +28 -3
  3. package/cli/contract.mjs +4 -4
  4. package/cli/dev-identity.mjs +2 -1
  5. package/cli/door-mode.mjs +134 -0
  6. package/cli/door.mjs +263 -0
  7. package/cli/hosted-output.mjs +15 -5
  8. package/cli/hydration/browser.mjs +91 -0
  9. package/cli/hydration/cases.mjs +106 -0
  10. package/cli/hydration/check.mjs +105 -0
  11. package/cli/hydration/probe.mjs +164 -0
  12. package/cli/hydration/scripts.mjs +39 -0
  13. package/cli/hydration/verdict.mjs +85 -0
  14. package/cli/inspect.mjs +56 -9
  15. package/cli/lifecycle.mjs +36 -12
  16. package/cli/local.mjs +9 -3
  17. package/cli/main.mjs +2 -0
  18. package/cli/managed-server.mjs +52 -18
  19. package/cli/package-calls.mjs +81 -0
  20. package/cli/package-mode.mjs +46 -0
  21. package/cli/parse.mjs +14 -4
  22. package/cli/pnpm-runtime.mjs +96 -0
  23. package/cli/portability-worker.mjs +2 -2
  24. package/cli/runtime-front-door.mjs +4 -2
  25. package/cli/server-proxy.mjs +69 -5
  26. package/cli/server-registry.mjs +19 -11
  27. package/cli/settings.mjs +14 -0
  28. package/cli/site-check.mjs +96 -0
  29. package/cli/site-preview.mjs +53 -0
  30. package/cli/site-render.mjs +77 -0
  31. package/cli/site.mjs +109 -0
  32. package/global/CHANGELOG.md +63 -0
  33. package/global/README.md +115 -2
  34. package/global/checkout.mjs +24 -10
  35. package/global/cli/install.mjs +6 -2
  36. package/global/delegate.mjs +9 -7
  37. package/global/door/command.mjs +254 -0
  38. package/global/door/control.mjs +65 -0
  39. package/global/door/cookies.mjs +41 -0
  40. package/global/door/pages.mjs +49 -0
  41. package/global/door/policy.mjs +49 -0
  42. package/global/door/routes.mjs +45 -0
  43. package/global/door/server.mjs +255 -0
  44. package/global/door/sites.mjs +192 -0
  45. package/global/launch/dev.mjs +25 -23
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/facts.mjs +3 -2
  48. package/global/launch/install-pinned.mjs +22 -0
  49. package/global/launch/prepare.mjs +41 -6
  50. package/global/launch/render.mjs +6 -1
  51. package/global/launch/state.mjs +10 -1
  52. package/global/launch/steps.mjs +24 -10
  53. package/global/launch/stop.mjs +89 -0
  54. package/global/launch/worker.mjs +30 -11
  55. package/global/list.mjs +6 -3
  56. package/global/main.mjs +37 -3
  57. package/global/version.mjs +8 -8
  58. package/modules/dependencies/protocol.mjs +2 -1
  59. package/modules/deploy-kit/README.md +34 -1
  60. package/modules/deploy-kit/artifact.mjs +10 -0
  61. package/modules/deploy-kit/owner.mjs +5 -4
  62. package/modules/deploy-kit/prepare.mjs +3 -2
  63. package/modules/deploy-kit/provider.mjs +6 -3
  64. package/modules/deploy-kit/site.mjs +135 -0
  65. package/modules/erasure/CONTRACT.md +6 -3
  66. package/modules/erasure/README.md +8 -1
  67. package/modules/erasure/audit.mjs +2 -2
  68. package/modules/erasure/cascade.mjs +52 -11
  69. package/modules/erasure/order.mjs +29 -22
  70. package/modules/erasure/plan.mjs +4 -2
  71. package/modules/erasure/queue.mjs +41 -0
  72. package/modules/erasure/scope.mjs +3 -3
  73. package/modules/erasure/verification.mjs +4 -2
  74. package/modules/mail/README.md +1 -1
  75. package/modules/mail/index.mjs +1 -1
  76. package/modules/mail/src/core/errors.mjs +9 -0
  77. package/modules/mail/src/development/index.d.mts +6 -0
  78. package/modules/mail/src/development/index.mjs +6 -3
  79. package/modules/mail/src/development/viewer-html.mjs +3 -3
  80. package/modules/mail/src/development/viewer.mjs +8 -1
  81. package/modules/operator-auth/dev/index.mjs +15 -5
  82. package/modules/operator-auth/http.mjs +2 -3
  83. package/modules/operator-auth/loopback.mjs +17 -0
  84. package/modules/owner-claim/verify.mjs +2 -1
  85. package/modules/portability/README.md +8 -0
  86. package/modules/portability/hash.mjs +6 -2
  87. package/modules/portability/import.mjs +1 -1
  88. package/modules/portability/legacy-files.mjs +4 -4
  89. package/modules/portability/legacy-safety.mjs +51 -10
  90. package/modules/portability/migration-history.mjs +15 -0
  91. package/modules/release/README.md +5 -3
  92. package/modules/release/produce.mjs +5 -2
  93. package/modules/release/source.mjs +15 -4
  94. package/onboarding/adapter-auth.mjs +4 -3
  95. package/onboarding/adapter-settings.mjs +21 -5
  96. package/onboarding/adapter.mjs +2 -1
  97. package/onboarding/app-fetch.mjs +79 -0
  98. package/onboarding/archive-output.d.mts +4 -2
  99. package/onboarding/archive-output.mjs +22 -10
  100. package/onboarding/dependency-approval.mjs +4 -3
  101. package/onboarding/local/environment.mjs +7 -2
  102. package/onboarding/operator-install.mjs +8 -2
  103. package/onboarding/operator-prerequisites.mjs +5 -2
  104. package/onboarding/queued-entry.mjs +9 -5
  105. package/operations/operations.mjs +11 -2
  106. package/package.json +75 -1
  107. package/scripts/publish-check/github_refs.py +28 -2
  108. package/scripts/publish-check/public_hygiene.py +35 -4
  109. package/scripts/publish-check/publication-config.json +14 -1
@@ -10,13 +10,14 @@ import { open, realpath, stat } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { setTimeout as delay } from 'node:timers/promises';
12
12
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
- import { processInfo } from '../../modules/job-queue/processes.mjs';
14
13
  import { dispatcher } from '../checkout.mjs';
15
14
  import { delegate } from '../delegate.mjs';
16
15
  import { ownRoot } from '../package-info.mjs';
17
16
  import { LaunchFailure } from './failure.mjs';
17
+ import { describeCheckout } from './facts.mjs';
18
18
  import { prepare } from './prepare.mjs';
19
19
  import { printer } from './render.mjs';
20
+ import { stopLaunch } from './stop.mjs';
20
21
  import { launchDirectory, liveWorker } from './state.mjs';
21
22
 
22
23
  const workerPath = fileURLToPath(new URL('./worker.mjs', import.meta.url));
@@ -24,19 +25,30 @@ const workerPath = fileURLToPath(new URL('./worker.mjs', import.meta.url));
24
25
  export async function launchDev(target) {
25
26
  const args = target.args.slice(1);
26
27
  if (args.includes('--detach') && args.includes('--foreground')) { process.stderr.write('Choose one of --detach and --foreground.\n'); return 2; }
27
- const devArgs = args.filter(arg => arg !== '--detach' && arg !== '--foreground');
28
+ // --door-start: the front door is starting this checkout (internal).
29
+ const doorStart = args.includes('--door-start');
30
+ const devArgs = args.filter(arg => !['--detach', '--foreground', '--door-start'].includes(arg));
31
+ // A pin from before the front door cannot move to it: say so in one line.
32
+ if ((devArgs.includes('--door') || devArgs.includes('--direct')) && !await doorCapable(target.root)) {
33
+ process.stderr.write('This checkout\'s pinned Base has no front-door support, so it stays direct. Refresh its Base to serve it through the door; rer door add still lists it and starts it on a visit.\n');
34
+ return 2;
35
+ }
28
36
  // An invocation the pinned dispatcher refuses goes straight to it, so the
29
37
  // refusal is its own, with nothing prepared first.
30
38
  if (!await accepted(target.root, devArgs)) return delegate(target, ['dev', ...devArgs]);
31
39
  const checkout = await realpath(target.root), directory = await launchDirectory(checkout);
32
40
  if (devArgs.includes('--stop')) {
33
- try { await stopWorker(directory); } catch (failure) { process.stderr.write(failure.message + '\n'); return failure.exitCode ?? 1; }
34
- return delegate(target, ['dev', ...devArgs]);
41
+ try { return await stopLaunch(target, devArgs, { checkout, directory }); }
42
+ catch (failure) { if (!(failure instanceof LaunchFailure)) throw failure; process.stderr.write(failure.message + '\n'); return failure.exitCode; }
35
43
  }
36
44
  const here = await realpath(process.cwd()).catch(() => undefined);
37
- const out = printer({ json: devArgs.includes('--json'), login: devArgs.includes('--login'), stopHint: `rer dev --stop${here === checkout ? '' : ` --cwd ${JSON.stringify(checkout)}`}` });
45
+ const out = printer({ json: devArgs.includes('--json'), login: devArgs.includes('--login'), stopHint: `rer dev --stop${here === checkout ? '' : ` --cwd ${JSON.stringify(checkout)}`}`, keyless: doorStart });
38
46
  const detach = args.includes('--detach') || (!args.includes('--foreground') && !process.stdout.isTTY);
39
- return detach ? detached(target, devArgs, directory, out) : foreground(target, devArgs, directory, out);
47
+ return detach ? detached(target, devArgs, directory, out, doorStart) : foreground(target, devArgs, directory, out, doorStart);
48
+ }
49
+
50
+ async function doorCapable(root) {
51
+ try { return Boolean((await describeCheckout(root, dispatcher(root).entry, process.env)).door); } catch { return true; }
40
52
  }
41
53
 
42
54
  async function accepted(root, devArgs) {
@@ -45,7 +57,7 @@ async function accepted(root, devArgs) {
45
57
  try { (await import(pathToFileURL(parser).href)).parse(['dev', ...devArgs], process.cwd()); return true; } catch { return false; }
46
58
  }
47
59
 
48
- async function foreground(target, devArgs, directory, out) {
60
+ async function foreground(target, devArgs, directory, out, doorStart) {
49
61
  const holder = await liveWorker(directory);
50
62
  if (holder) {
51
63
  out.attaching(holder.startedAt);
@@ -56,20 +68,22 @@ async function foreground(target, devArgs, directory, out) {
56
68
  let prepared;
57
69
  // The same progress lines as a detached launch while install or setup runs.
58
70
  const heartbeat = setInterval(() => out.heartbeat(), 1000);
59
- try { prepared = await prepare({ root: target.root, args: devArgs, env: process.env, report: fields => out.phase(fields), directory, log: text => log.write(text + '\n'), children: new Set() }); }
71
+ try { prepared = await prepare({ root: target.root, args: devArgs, env: process.env, report: fields => out.phase(fields), directory, log: text => log.write(text + '\n'), children: new Set(), doorStart }); }
60
72
  catch (failure) { if (!(failure instanceof LaunchFailure)) throw failure; out.failure(failure); return failure.exitCode; }
61
73
  finally { clearInterval(heartbeat); log.end(); }
62
74
  Object.assign(process.env, prepared.added);
63
75
  return delegate(target, ['dev', ...devArgs]);
64
76
  }
65
77
 
66
- async function detached(target, devArgs, directory, out) {
78
+ async function detached(target, devArgs, directory, out, doorStart) {
67
79
  let token, worker;
68
80
  const holder = await liveWorker(directory);
69
81
  if (holder) { token = holder.token; out.attaching(holder.startedAt); }
70
82
  else {
71
83
  token = randomUUID();
72
- worker = spawn(process.execPath, [workerPath, target.root, token, JSON.stringify(devArgs.filter(arg => arg !== '--json'))], { cwd: target.root, env: process.env, detached: true, stdio: 'ignore' });
84
+ // The worker ends itself for a stop that ran after this command started.
85
+ worker = spawn(process.execPath, [workerPath, target.root, token, JSON.stringify(devArgs.filter(arg => arg !== '--json')), String(performance.timeOrigin), ...(doorStart ? ['door-start'] : [])], { cwd: target.root, env: process.env, detached: true, stdio: 'ignore' });
86
+ worker.once('error', () => {});
73
87
  worker.unref();
74
88
  }
75
89
  return follow(directory, token, out, { worker });
@@ -81,6 +95,7 @@ async function follow(directory, token, out, { worker, quiet = false }) {
81
95
  const path = join(directory, `events-${token}.jsonl`);
82
96
  let offset = 0, rest = '', exited = false, missingSince, polls = 0;
83
97
  worker?.once('exit', () => { exited = true; });
98
+ worker?.once('error', () => { exited = true; });
84
99
  for (;;) {
85
100
  let size = 0;
86
101
  try { size = (await stat(path)).size; } catch (error) { if (error.code !== 'ENOENT') throw error; }
@@ -114,16 +129,3 @@ function missing(directory) {
114
129
  process.stderr.write(`rer dev: the launch stopped before it finished. Run rer dev again; its log is ${join(directory, 'launch.log')}.\n`);
115
130
  return 1;
116
131
  }
117
-
118
- // rer dev --stop also ends a preparation still running for this checkout.
119
- async function stopWorker(directory) {
120
- const holder = await liveWorker(directory);
121
- if (!holder) return;
122
- try { process.kill(holder.pid, 'SIGTERM'); } catch (error) { if (error.code === 'ESRCH') return; throw error; }
123
- for (let waited = 0; waited < 60000; waited += 250) {
124
- const current = await processInfo(holder.pid);
125
- if (!current || current.zombie || current.start !== holder.start) return;
126
- await delay(250);
127
- }
128
- throw new LaunchFailure(1, `The launch worker (PID ${holder.pid}) did not stop within a minute.`);
129
- }
@@ -0,0 +1,36 @@
1
+ // What the front door means for one `rer dev`. With the door enabled, a fresh
2
+ // checkout whose pin supports it is set up at its door origin; a checkout
3
+ // already there needs the door running, which `rer dev` brings back only for
4
+ // the registry the door recorded (a fleet agent or a test with its own
5
+ // RER_DOOR_DIR or RER_SERVERS_DIR never starts the owner's door). --port,
6
+ // --host and --direct always mean direct mode.
7
+ import { doorProtocol, doorSite, readDoorState, runningDoor } from '../../cli/door.mjs';
8
+ import { startDoor } from '../door/control.mjs';
9
+ import { ownsRegistry } from '../door/policy.mjs';
10
+ import { LaunchFailure } from './failure.mjs';
11
+
12
+ const has = (args, flag) => args.includes(flag);
13
+
14
+ export async function doorPlan(described, args, env) {
15
+ const capable = described?.door?.protocol === doorProtocol;
16
+ const direct = has(args, '--direct') || has(args, '--port') || has(args, '--host');
17
+ const state = await readDoorState(env).catch(() => undefined), door = state?.door;
18
+ const running = door?.enabled ? await runningDoor(env).catch(() => undefined) : undefined;
19
+ const protocol = running?.protocol ?? door?.protocol;
20
+ const known = Boolean(door?.enabled) && protocol === doorProtocol;
21
+ const note = door?.enabled && !known ? `The front door speaks protocol ${protocol}, which this rer does not know, so this checkout stays direct. Run rer door restart.` : undefined;
22
+ const saved = Boolean(described?.local?.origin && doorSite(described.local.origin));
23
+ return { door, known, note, freshAtDoor: known && capable && !direct, atDoor: !direct && (saved || has(args, '--door')) };
24
+ }
25
+
26
+ // Bring an enabled door back before a door-mode checkout starts.
27
+ export async function bringBackDoor(plan, env, report) {
28
+ if (!plan.atDoor || !plan.door?.enabled || !plan.known) return;
29
+ if (await runningDoor(env)) return;
30
+ if (!ownsRegistry(plan.door, env)) {
31
+ report({ phase: 'note', message: 'the front door is not running and this shell uses another RER_DOOR_DIR or RER_SERVERS_DIR than the one it recorded, so it was not started' });
32
+ return;
33
+ }
34
+ report({ phase: 'door', message: 'starting the front door (it was not running)' });
35
+ try { await startDoor(env); } catch (error) { throw new LaunchFailure(error.exitCode ?? 1, error.message); }
36
+ }
@@ -11,11 +11,12 @@ const run = promisify(execFile);
11
11
  async function mtime(path) { try { return (await stat(path)).mtimeMs; } catch (error) { if (error.code === 'ENOENT') return undefined; throw error; } }
12
12
 
13
13
  // The pinned dispatcher's own read-only description.
14
- export async function describeCheckout(root, entry, env) {
14
+ export async function describeCheckout(root, entry, env, signal) {
15
15
  try {
16
- const { stdout } = await run(process.execPath, [entry, 'describe', '--json'], { cwd: root, env, maxBuffer: 16 * 1024 * 1024 });
16
+ const { stdout } = await run(process.execPath, [entry, 'describe', '--json'], { cwd: root, env, maxBuffer: 16 * 1024 * 1024, signal });
17
17
  return JSON.parse(stdout);
18
18
  } catch (error) {
19
+ if (signal?.aborted) throw signal.reason;
19
20
  throw new LaunchFailure(error.code ?? 1, (error.stderr?.trim() || `rer describe failed in ${root}.`));
20
21
  }
21
22
  }
@@ -0,0 +1,22 @@
1
+ // A checkout that pins the @runeditrun/rer package runs its own rer once
2
+ // node_modules holds the pinned version; until then none of it can run (its
3
+ // adapter imports the package). So its first dependency install is this
4
+ // launcher's: the frozen pnpm install that `rer install` runs.
5
+ import { spawn } from 'node:child_process';
6
+ import { pinnedPackage } from '../checkout.mjs';
7
+ import { packageName } from '../package-info.mjs';
8
+ import { LaunchFailure } from './failure.mjs';
9
+
10
+ export async function installPinned(root, { env = process.env, output = text => process.stderr.write(text), children, signal } = {}) {
11
+ signal?.throwIfAborted();
12
+ const child = spawn('pnpm', ['install', '--frozen-lockfile'], { cwd: root, env: { ...env, CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
13
+ children?.add(child);
14
+ if (signal?.aborted) child.kill('SIGTERM');
15
+ let tail = '';
16
+ for (const stream of [child.stdout, child.stderr]) stream.on('data', data => { tail = (tail + data).slice(-4096); output(String(data)); });
17
+ const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', (exit, name) => resolve(exit ?? (name ? 128 : 1))); });
18
+ children?.delete(child);
19
+ if (code !== 0) throw new LaunchFailure(code, `pnpm install --frozen-lockfile failed in ${root} (exit ${code}):\n${tail.trim().split('\n').slice(-8).join('\n')}`);
20
+ const pinned = pinnedPackage(root);
21
+ if (!pinned?.ready) throw new LaunchFailure(3, `${root} pins ${packageName} ${pinned?.declared}, but after pnpm install node_modules holds ${pinned?.installed ?? 'no copy of it'}. Correct the pin or the lockfile (pnpm install), then run rer again.`);
22
+ }
@@ -5,39 +5,73 @@
5
5
  // settings are never changed. An install or setup this launcher started and
6
6
  // that did not finish runs again on the next launch.
7
7
  import { realpath } from 'node:fs/promises';
8
- import { dispatcher } from '../checkout.mjs';
8
+ import { dispatcher, pinnedPackage } from '../checkout.mjs';
9
9
  import { liveServers } from '../registry.mjs';
10
10
  import { checkCapacity, personEnvironment, pinPolicy } from './capacity.mjs';
11
+ import { bringBackDoor, doorPlan } from './door.mjs';
12
+ import { LaunchFailure } from './failure.mjs';
11
13
  import { describeCheckout, freePort, localOwner, needsInstall, setupNeeded } from './facts.mjs';
12
14
  import { claimPort, installState, setupState } from './state.mjs';
13
15
  import { runVerb } from './steps.mjs';
14
16
  import { localDefaults } from './local-defaults.mjs';
17
+ import { installPinned } from './install-pinned.mjs';
15
18
 
16
19
  // The value given for a flag in the user's dev arguments.
17
20
  export const flagValue = (args, flag) => { const index = args.indexOf(flag); return index < 0 ? undefined : args[index + 1]; };
18
21
 
19
- export async function prepare({ root, args, env, report, directory, log, children }) {
22
+ // A worker passes the signal a stop aborts: no step starts after it, and the
23
+ // step running then is signalled through `children`. `doorStart`: the front
24
+ // door is starting this checkout on a visit, which never installs or sets up;
25
+ // an unprepared checkout is refused with the reason.
26
+ export async function prepare({ root, args, env, report, directory, log, children, signal, doorStart = false }) {
27
+ // A checkout pinning the rer package whose node_modules lacks that version
28
+ // installs it first (after the same quick server-limit check: a pin with
29
+ // package mode has the person defaults); every step after this runs the
30
+ // pinned package's verbs.
31
+ let installed = false;
32
+ if (pinnedPackage(root)?.ready === false) {
33
+ if (doorStart) throw new LaunchFailure(3, `${root} is not installed, and the front door starts only prepared checkouts. Run rer dev in it once.`);
34
+ const current = await pinPolicy(root, 'package');
35
+ report({ phase: 'check', message: 'checking the server limit' });
36
+ const capacity = await checkCapacity(await realpath(root), { ...env, ...personEnvironment(env, args, current) }, current);
37
+ if (capacity.own) throw new LaunchFailure(3, `A server of ${root} is running, but its node_modules lacks the rer version it pins. Stop it with rer dev --stop, then run rer dev again.`);
38
+ report({ phase: 'install', message: 'installing dependencies' });
39
+ await recorded(directory, installState, () => installPinned(root, { env, output: text => log(text.trimEnd()), children, signal }));
40
+ installed = true;
41
+ }
20
42
  const checkout = await realpath(root), { mode, entry } = dispatcher(root);
21
- const described = await describeCheckout(root, entry, env);
43
+ const described = await describeCheckout(root, entry, env, signal);
22
44
  const pin = await pinPolicy(root, mode, described);
23
45
  const added = personEnvironment(env, args, pin), childEnv = { ...env, ...added };
46
+ const plan = await doorPlan(described, args, env);
47
+ if (plan.note) report({ phase: 'note', message: plan.note });
24
48
  report({ phase: 'check', message: 'checking the server limit' });
25
49
  const capacity = await checkCapacity(checkout, childEnv, pin);
26
- if (capacity.own) return { entry, added, own: capacity.own };
50
+ if (capacity.own) { await bringBackDoor(plan, env, report); return { entry, added, own: capacity.own }; }
27
51
  const note = message => report({ phase: 'note', message });
28
- const step = (record, args, fields, env = childEnv) => recorded(directory, record, () => runVerb(entry, args, { root, env, note, log, children }), fields);
52
+ const step = (record, args, fields, env = childEnv) => recorded(directory, record, () => runVerb(entry, args, { root, env, note, log, children, signal }), fields);
29
53
  // Only this setup fills the seed's declared local defaults.
30
54
  const setupEnv = { ...childEnv, [localDefaults]: '1' };
31
- if (await needsInstall(root, await installState.read(directory))) {
55
+ const unprepared = what => new LaunchFailure(3, `${checkout} is not ${what}, and the front door starts only prepared checkouts. Run rer dev in it once.`);
56
+ if (!installed && await needsInstall(root, await installState.read(directory))) {
57
+ if (doorStart) throw unprepared('installed');
32
58
  report({ phase: 'install', message: 'installing dependencies' });
33
59
  await step(installState, ['install']);
34
60
  }
35
61
  const last = await setupState.read(directory), setup = await setupNeeded(root, described, last);
62
+ if (setup && doorStart) throw unprepared('set up');
36
63
  const host = flagValue(args, '--host'), hostArgs = host ? ['--host', host] : [];
37
64
  if (setup === 'resume') {
38
65
  // Saved settings hold an owner and origin; setup keeps them.
39
66
  report({ phase: 'setup', message: 'finishing this checkout\'s setup, keeping its saved owner and port' });
40
67
  await step(setupState, ['setup', ...hostArgs], {}, setupEnv);
68
+ } else if (setup === 'fresh' && plan.freshAtDoor) {
69
+ // With the door enabled, a fresh checkout is set up at its door origin.
70
+ const owner = localOwner(checkout);
71
+ report({ phase: 'setup', message: `setting up a local owner (${owner}) at its front-door address` });
72
+ await step(setupState, ['setup', '--owner-email', owner, '--door'], { owner, door: true }, setupEnv);
73
+ await bringBackDoor({ ...plan, atDoor: true }, env, report);
74
+ return { entry, added };
41
75
  } else if (setup === 'fresh') {
42
76
  // An interrupted setup resumes on the port it chose while that port is free.
43
77
  const owner = localOwner(checkout), explicit = flagValue(args, '--port');
@@ -48,6 +82,7 @@ export async function prepare({ root, args, env, report, directory, log, childre
48
82
  // The claim lasts until the server registers the port: the caller releases it.
49
83
  return { entry, added, release };
50
84
  }
85
+ await bringBackDoor(plan, env, report);
51
86
  return { entry, added };
52
87
  }
53
88
 
@@ -3,7 +3,9 @@
3
3
  const elapsed = ms => { const s = Math.round(ms / 1000); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; };
4
4
  const quiet = 20000;
5
5
 
6
- export function printer({ json, login, stopHint }) {
6
+ // `keyless` (a start by the front door, whose log this is): the ready lines
7
+ // leave out the dev-key link, so the door's files never hold a key.
8
+ export function printer({ json, login, stopHint, keyless = false }) {
7
9
  const started = Date.now();
8
10
  let current = 'preparing', last = Date.now();
9
11
  const event = (name, fields) => process.stdout.write(JSON.stringify({ version: 1, verb: 'dev', event: name, ...fields }) + '\n');
@@ -19,6 +21,7 @@ export function printer({ json, login, stopHint }) {
19
21
  heartbeat() { if (Date.now() - last >= quiet) progress('waiting', `still ${current} (${elapsed(Date.now() - started)}). Allow up to 10 minutes; if this command is stopped, run rer dev again to reattach.`); },
20
22
  // The dispatcher's ready event, as its own human ready lines.
21
23
  ready(ready, { log, pid }) {
24
+ if (keyless) { const { openUrl, ...rest } = ready; ready = rest; }
22
25
  if (json) { event('server', { pid, log }); process.stdout.write(JSON.stringify(ready) + '\n'); return; }
23
26
  const out = [`RER_READY ${ready.url}`];
24
27
  if (ready.openUrl && !login) {
@@ -29,6 +32,8 @@ export function printer({ json, login, stopHint }) {
29
32
  if (ready.openUrl) out.push(`RER_OPEN ${ready.openUrl}`);
30
33
  out.push(`Open ${ready.openUrl ? '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.`);
31
34
  }
35
+ // A seed with a run site: its static homepage, /run and docs, served locally.
36
+ if (ready.siteUrl) out.push(`RER_SITE ${ready.siteUrl}`);
32
37
  out.push(`The server keeps running in the background${pid ? ` (PID ${pid})` : ''}. Stop it with: ${stopHint}`);
33
38
  if (log) out.push(`Server log (private): ${log}`);
34
39
  process.stdout.write(out.join('\n') + '\n');
@@ -6,7 +6,8 @@
6
6
  // setup.json whether a setup this launcher started has finished
7
7
  // launch.log the worker's private log (child output)
8
8
  // server.log the private log of the server rer started
9
- // server.json that server's PID
9
+ // server.json that server's PID and start time, and the process rer spawned
10
+ // stop.json when rer dev --stop last ran: a launch requested before it stops
10
11
  import { createHash } from 'node:crypto';
11
12
  import { appendFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
12
13
  import { basename, join } from 'node:path';
@@ -91,6 +92,14 @@ const stepRecord = name => ({
91
92
  });
92
93
  export const installState = stepRecord('install'), setupState = stepRecord('setup');
93
94
 
95
+ // A stop asks every launch started before it to end. A worker reads this right
96
+ // after it claims the launch; a stop that ran before the claim found no worker
97
+ // to signal, so the worker ends itself here instead of starting anything.
98
+ export const stopRequest = {
99
+ write: directory => writeFile(join(directory, 'stop.json'), JSON.stringify({ at: Date.now(), pid: process.pid }), { mode: 0o600 }),
100
+ covers: async (directory, requestedAt) => ((await readJson(join(directory, 'stop.json')))?.at ?? -Infinity) >= requestedAt,
101
+ };
102
+
94
103
  export const serverRecord = {
95
104
  read: directory => readJson(join(directory, 'server.json')),
96
105
  write: (directory, record) => writeFile(join(directory, 'server.json'), JSON.stringify(record), { mode: 0o600 }),
@@ -21,10 +21,18 @@ export function failureFrom(code, error, output) {
21
21
  return new LaunchFailure(code || 1, error?.message ?? (output.trim().split('\n').slice(-5).join('\n') || `The command stopped with exit ${code}.`), error);
22
22
  }
23
23
 
24
+ // A child joins the set a stop signals; one spawned as the stop arrives is
25
+ // signalled at once rather than left to finish.
26
+ function own(children, child, signal) {
27
+ children.add(child);
28
+ if (signal?.aborted) child.kill('SIGTERM');
29
+ }
30
+
24
31
  // One bounded verb, never prompting: no stdin, --non-interactive, --json.
25
- export async function runVerb(entry, args, { root, env, note, log, children }) {
32
+ export async function runVerb(entry, args, { root, env, note, log, children, signal }) {
33
+ signal?.throwIfAborted();
26
34
  const child = spawn(process.execPath, [entry, ...args, '--json', '--non-interactive'], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
27
- children.add(child);
35
+ own(children, child, signal);
28
36
  let error, output = '';
29
37
  const lines = createInterface({ input: child.stdout });
30
38
  lines.on('line', line => { const event = events(line); if (event?.event === 'error') error = event; const text = event && waitingNote(event); if (text) note(text); log(line); });
@@ -55,18 +63,24 @@ async function follow(path, from, onLine, stopped) {
55
63
  // Start `dev` detached into its own process group with its output in a private
56
64
  // log, and return its ready event once it prints one. The server outlives this
57
65
  // process and stops on rer dev --stop or its idle timeout.
58
- export async function startServer(entry, args, { root, env, logPath, children }) {
66
+ export async function startServer(entry, args, { root, env, logPath, children, signal, note = () => {} }) {
67
+ signal?.throwIfAborted();
59
68
  const handle = await open(logPath, 'a', 0o600), from = (await handle.stat()).size;
60
- let child;
61
- try { child = spawn(process.execPath, [entry, 'dev', ...args, '--json'], { cwd: root, env, detached: true, stdio: ['ignore', handle.fd, handle.fd] }); }
62
- finally { await handle.close(); }
63
- children.add(child);
64
- let exit, ready, error, output = '';
65
- child.once('exit', (code, signal) => { exit = code ?? (signal ? 128 : 1); });
69
+ let child, exit, spawnError;
70
+ // Listening before anything is awaited: a spawn that fails reports it on the next tick.
71
+ try {
72
+ child = spawn(process.execPath, [entry, 'dev', ...args, '--json'], { cwd: root, env, detached: true, stdio: ['ignore', handle.fd, handle.fd] });
73
+ child.once('error', failure => { spawnError = failure; exit ??= 1; });
74
+ child.once('exit', (code, name) => { exit = code ?? (name ? 128 : 1); });
75
+ } finally { await handle.close(); }
76
+ own(children, child, signal);
77
+ let ready, error, output = '';
66
78
  await follow(logPath, from, line => {
67
79
  const event = events(line);
68
80
  if (event?.event === 'ready') { ready = event; return true; }
69
81
  if (event?.event === 'error') error = event;
82
+ // rer dev --door / --direct: where the checkout moved.
83
+ if (event?.event === 'moved') note(`moved this checkout from ${event.from} to ${event.to}; its data is kept`);
70
84
  if (!event) output = (output + line + '\n').slice(-8192);
71
85
  return false;
72
86
  }, () => exit !== undefined);
@@ -75,7 +89,7 @@ export async function startServer(entry, args, { root, env, logPath, children })
75
89
  for await (const line of createInterface({ input: createReadStream(logPath, { start: from }) })) { const event = events(line); if (event?.event === 'ready') ready = event; if (event?.event === 'error') error = event; }
76
90
  }
77
91
  children.delete(child);
78
- if (!ready) throw failureFrom(exit, error, output);
92
+ if (!ready) throw failureFrom(exit, error, spawnError ? `rer could not run in ${root}: ${spawnError.code ?? spawnError.message}.` : output);
79
93
  child.unref();
80
94
  return { ready, pid: child.pid, reused: exit === 0 };
81
95
  }
@@ -0,0 +1,89 @@
1
+ // rer dev --stop for a checkout the launcher may be preparing or serving.
2
+ // It ends a launch still preparing (or one that has not yet claimed the
3
+ // checkout), has the pinned dispatcher stop the registered server, and then
4
+ // checks that every process it found -- the worker, the server rer started,
5
+ // the registered server and everything they spawned -- has exited. It says
6
+ // what it stopped, or exits non-zero naming what is still running.
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { processInfo } from '../../modules/job-queue/processes.mjs';
9
+ import { readProcessTable, sameBirth } from '../../onboarding/process-table.mjs';
10
+ import { delegate } from '../delegate.mjs';
11
+ import { liveServers } from '../registry.mjs';
12
+ import { LaunchFailure } from './failure.mjs';
13
+ import { liveWorker, serverRecord, stopRequest } from './state.mjs';
14
+
15
+ export async function stopLaunch(target, devArgs, { checkout, directory }) {
16
+ // Written before looking for a worker: one that claims the launch after this
17
+ // look reads it and ends itself (worker.mjs).
18
+ await stopRequest.write(directory);
19
+ const holder = await liveWorker(directory);
20
+ const launch = holder ? await tree([holder]) : [];
21
+ if (holder) await endWorker(holder);
22
+ // Only now: a worker stopped while its server came up has registered it.
23
+ const record = (await liveServers()).find(item => item.checkout === checkout);
24
+ const started = await serverRecord.read(directory);
25
+ const server = await tree([record?.owner, record?.runtimeProcess, started, started?.spawned]);
26
+ await delegate(target, ['dev', ...devArgs]);
27
+ if (process.exitCode) return process.exitCode;
28
+ const left = await exited([...launch, ...server], 15000);
29
+ if (left.length) {
30
+ const pids = left.map(item => item.pid).join(' ');
31
+ process.stderr.write(`rer dev --stop: ${left.length === 1 ? 'a process' : `${left.length} processes`} of this checkout's dev server ${left.length === 1 ? 'is' : 'are'} still running 15 seconds after the stop (PID ${pids}). Stop ${left.length === 1 ? 'it' : 'them'} with kill -TERM ${pids}, then run rer dev --stop again.\n`);
32
+ return 1;
33
+ }
34
+ if (!devArgs.includes('--json')) process.stdout.write(said({ checkout, record, started, server, holder, launch }) + '\n');
35
+ return 0;
36
+ }
37
+
38
+ // SIGTERM the preparing worker and wait for it to exit.
39
+ async function endWorker(holder) {
40
+ try { process.kill(holder.pid, 'SIGTERM'); } catch (error) { if (error.code === 'ESRCH') return; throw error; }
41
+ for (let waited = 0; waited < 60000; waited += 250) {
42
+ const current = await processInfo(holder.pid);
43
+ if (!current || current.zombie || current.start !== holder.start) return;
44
+ await delay(250);
45
+ }
46
+ throw new LaunchFailure(1, `The launch worker (PID ${holder.pid}) did not stop within a minute.`);
47
+ }
48
+
49
+ // The live processes among these recorded identities ({pid, start}), with
50
+ // every process descended from them, as process-table entries.
51
+ async function tree(identities) {
52
+ const roots = new Set();
53
+ for (const identity of identities) {
54
+ if (!identity?.pid || !identity.start) continue;
55
+ const current = await processInfo(identity.pid);
56
+ if (current && !current.zombie && current.start === identity.start) roots.add(identity.pid);
57
+ }
58
+ if (!roots.size) return [];
59
+ const table = await readProcessTable(), found = table.filter(entry => roots.has(entry.pid));
60
+ for (let index = 0; index < found.length; index++) {
61
+ for (const entry of table) if (entry.ppid === found[index].pid && !found.includes(entry)) found.push(entry);
62
+ }
63
+ return found;
64
+ }
65
+
66
+ // Wait until none of these processes is running; return those still running.
67
+ async function exited(processes, within) {
68
+ const deadline = Date.now() + within;
69
+ for (;;) {
70
+ const table = processes.length ? await readProcessTable() : [];
71
+ const left = processes.filter(item => table.some(entry => sameBirth(item, entry) && !entry.state.startsWith('Z')));
72
+ if (!left.length || Date.now() >= deadline) return left;
73
+ await delay(250);
74
+ }
75
+ }
76
+
77
+ function said({ checkout, record, started, server, holder, launch }) {
78
+ const lines = [], more = n => n > 0 ? ` with ${n} more ${n === 1 ? 'process' : 'processes'}` : '';
79
+ if (server.length) {
80
+ const pid = record?.owner?.pid ?? started?.pid ?? server[0].pid;
81
+ lines.push(`Stopped the dev server${record?.url ? ` at ${record.url}` : ''} for ${checkout} (PID ${pid})${more(server.length - 1)}.`);
82
+ }
83
+ if (holder) {
84
+ const own = launch.filter(item => !server.some(entry => sameBirth(item, entry)));
85
+ lines.push(`Stopped the launch still running for ${checkout} (PID ${holder.pid})${more(own.length - 1)}.`);
86
+ }
87
+ if (!lines.length) lines.push(`No dev server was running for ${checkout}.`);
88
+ return lines.join('\n');
89
+ }
@@ -1,41 +1,60 @@
1
- // The detached launch worker: node worker.mjs ROOT TOKEN ARGS_JSON
1
+ // The detached launch worker: node worker.mjs ROOT TOKEN ARGS_JSON REQUESTED_AT
2
+ // (REQUESTED_AT: when the `rer dev` that spawned it started, epoch ms).
2
3
  // It runs in its own session, so killing the `rer dev` that started it (or that
3
4
  // command's whole process group) leaves it running; a rerun attaches to it.
5
+ // rer dev --stop ends it: SIGTERM, or, for a stop that ran before this worker
6
+ // claimed the launch, the stop request it reads right after claiming. Either
7
+ // way no further step starts, the running one is signalled, and the launch
8
+ // reports that it was stopped instead of ready.
4
9
  import { createWriteStream } from 'node:fs';
5
10
  import { realpath } from 'node:fs/promises';
6
11
  import { join } from 'node:path';
12
+ import { processInfo } from '../../modules/job-queue/processes.mjs';
7
13
  import { liveServers } from '../registry.mjs';
14
+ import { LaunchFailure } from './failure.mjs';
8
15
  import { prepare } from './prepare.mjs';
9
- import { claimLaunch, eventLog, launchDirectory, liveWorker, releaseLaunch, serverRecord } from './state.mjs';
16
+ import { claimLaunch, eventLog, launchDirectory, liveWorker, releaseLaunch, serverRecord, stopRequest } from './state.mjs';
10
17
  import { startServer } from './steps.mjs';
11
18
 
12
- const [root, token, argsJSON] = process.argv.slice(2), args = JSON.parse(argsJSON);
19
+ const [root, token, argsJSON, requestedAtText, mode] = process.argv.slice(2), args = JSON.parse(argsJSON), requestedAt = Number(requestedAtText), doorStart = mode === 'door-start';
13
20
  const checkout = await realpath(root), directory = await launchDirectory(checkout), events = eventLog(directory, token);
21
+ // Listening before the claim: a stop may signal this worker as soon as it is the holder.
22
+ const children = new Set(), stopping = new AbortController();
23
+ const stop = () => { stopping.abort(); for (const child of children) child.kill('SIGTERM'); };
24
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, stop);
25
+ process.on('SIGHUP', () => {});
14
26
  if (!await claimLaunch(directory, token)) {
15
27
  const holder = await liveWorker(directory);
16
28
  await events.write({ event: 'redirect', token: holder?.token });
17
29
  } else {
18
30
  const logFile = createWriteStream(join(directory, 'launch.log'), { flags: 'a', mode: 0o600 });
19
31
  const log = text => logFile.write(text + '\n');
20
- const children = new Set();
21
- for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { for (const child of children) child.kill('SIGTERM'); });
22
- process.on('SIGHUP', () => {});
23
32
  try {
24
- const prepared = await prepare({ root, args, env: process.env, report: fields => events.write({ event: 'phase', ...fields }), directory, log, children });
33
+ if (await stopRequest.covers(directory, requestedAt)) stop();
34
+ stopping.signal.throwIfAborted();
35
+ const prepared = await prepare({ root, args, env: process.env, report: fields => events.write({ event: 'phase', ...fields }), directory, log, children, signal: stopping.signal, doorStart });
25
36
  await events.write({ event: 'phase', phase: 'start', message: prepared.own ? 'the app is already running; checking it' : 'starting the app' });
26
37
  // A server rer started logs to server.log; asking a running one for its
27
38
  // ready lines goes to reuse.log, and names server.log only when rer started it.
28
39
  const serverLog = join(directory, 'server.log');
29
- const started = await startServer(prepared.entry, args, { root, env: { ...process.env, ...prepared.added }, logPath: prepared.own ? join(directory, 'reuse.log') : serverLog, children });
40
+ let started;
41
+ try { started = await startServer(prepared.entry, args, { root, env: { ...process.env, ...prepared.added }, logPath: prepared.own ? join(directory, 'reuse.log') : serverLog, children, signal: stopping.signal, note: message => events.write({ event: 'phase', phase: 'note', message }) }); }
42
+ finally { await prepared.release?.(); }
30
43
  const record = (await liveServers(process.env)).find(item => item.checkout === checkout);
31
- await prepared.release?.();
32
44
  const pid = record?.owner?.pid ?? started.pid;
33
- if (!prepared.own) await serverRecord.write(directory, { pid });
45
+ // The server's PID, and the process rer spawned for it (the same one unless
46
+ // the pin re-runs itself under its own Node), each with its start time.
47
+ const identity = async id => ({ pid: id, start: (await processInfo(id))?.start });
48
+ if (!prepared.own) await serverRecord.write(directory, { ...await identity(pid), spawned: await identity(started.pid) });
34
49
  const ours = !prepared.own || (await serverRecord.read(directory))?.pid === pid;
50
+ // A stop that arrived while the server came up finds it registered and
51
+ // stops it; this launch reports the stop rather than a server about to end.
52
+ stopping.signal.throwIfAborted();
35
53
  await events.write({ event: 'ready', ready: started.ready, pid, ...(ours ? { log: serverLog } : {}) });
36
54
  } catch (error) {
37
55
  log(String(error?.stack ?? error));
38
- await events.write({ event: 'failed', exitCode: error.exitCode ?? 1, message: error.exitCode ? error.message : `The launch failed unexpectedly; see ${join(directory, 'launch.log')}.`, ...(error.event ? { error: error.event } : {}) });
56
+ const failure = stopping.signal.aborted ? new LaunchFailure(1, 'rer dev --stop stopped this launch before the app was ready.') : error;
57
+ await events.write({ event: 'failed', exitCode: failure.exitCode ?? 1, message: failure.exitCode ? failure.message : `The launch failed unexpectedly; see ${join(directory, 'launch.log')}.`, ...(failure.event ? { error: failure.event } : {}) });
39
58
  } finally {
40
59
  await releaseLaunch(directory, token);
41
60
  logFile.end();
package/global/list.mjs CHANGED
@@ -4,7 +4,7 @@ import { execFile } from 'node:child_process';
4
4
  import { readdir, realpath, stat } from 'node:fs/promises';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
- import { descriptorProfile, dispatcher } from './checkout.mjs';
7
+ import { descriptorProfile, dispatcher, pinnedPackage } from './checkout.mjs';
8
8
  import { liveServers } from './registry.mjs';
9
9
  import { describeCheckout, setupNeeded } from './launch/facts.mjs';
10
10
  import { launchPath, setupState } from './launch/state.mjs';
@@ -27,8 +27,11 @@ async function entry(path, running) {
27
27
  if (!result.launchable) result.reason = reasons[facts.profile] ?? `OPERATIONS.json is version ${facts.version ?? '(none)'}, which rer does not run`;
28
28
  result.prepared = null;
29
29
  if (profile === 'first-party') {
30
- // Set up: saved settings, and no setup a launch started left unfinished.
31
- try { result.prepared = !await setupNeeded(path, await describeCheckout(path, dispatcher(path).entry, process.env), await setupState.read(launchPath(path))); }
30
+ // Set up: saved settings, and no setup a launch started left unfinished. A
31
+ // checkout whose pinned rer package is not installed yet has never been
32
+ // launched: rer dev installs it first.
33
+ if (pinnedPackage(path)?.ready === false) result.prepared = false;
34
+ else try { result.prepared = !await setupNeeded(path, await describeCheckout(path, dispatcher(path).entry, process.env), await setupState.read(launchPath(path))); }
32
35
  catch (failure) { result.launchable = false; result.reason = `rer describe fails here: ${failure.message.split('\n')[0]}`; }
33
36
  }
34
37
  result.url = running.get(path) ?? null;