@runeditrun/rer 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +113 -2
  2. package/cli/README.md +32 -5
  3. package/cli/contract.mjs +5 -5
  4. package/cli/deploy.mjs +17 -3
  5. package/cli/detached-server.mjs +51 -0
  6. package/cli/dev-identity.mjs +2 -1
  7. package/cli/door-mode.mjs +134 -0
  8. package/cli/door.mjs +263 -0
  9. package/cli/hosted-output.mjs +17 -7
  10. package/cli/hydration/browser.mjs +91 -0
  11. package/cli/hydration/cases.mjs +106 -0
  12. package/cli/hydration/check.mjs +105 -0
  13. package/cli/hydration/probe.mjs +164 -0
  14. package/cli/hydration/scripts.mjs +39 -0
  15. package/cli/hydration/verdict.mjs +85 -0
  16. package/cli/inspect.mjs +57 -10
  17. package/cli/lifecycle.mjs +36 -12
  18. package/cli/local.mjs +9 -3
  19. package/cli/main.mjs +3 -0
  20. package/cli/managed-server.mjs +62 -21
  21. package/cli/package-calls.mjs +81 -0
  22. package/cli/package-mode.mjs +46 -0
  23. package/cli/parse.mjs +15 -4
  24. package/cli/pnpm-runtime.mjs +96 -0
  25. package/cli/portability-worker.mjs +2 -2
  26. package/cli/release.mjs +1 -0
  27. package/cli/runtime-front-door.mjs +4 -2
  28. package/cli/server-proxy.mjs +69 -5
  29. package/cli/server-registry.mjs +10 -7
  30. package/cli/settings.mjs +14 -0
  31. package/cli/site-bindings.mjs +291 -0
  32. package/cli/site-browser-objects.mjs +36 -0
  33. package/cli/site-check.mjs +111 -0
  34. package/cli/site-client.mjs +50 -0
  35. package/cli/site-data.mjs +54 -0
  36. package/cli/site-entities.json +1 -0
  37. package/cli/site-input-assets.mjs +58 -0
  38. package/cli/site-inputs.mjs +79 -0
  39. package/cli/site-markup.mjs +102 -0
  40. package/cli/site-preview.mjs +53 -0
  41. package/cli/site-private.mjs +36 -0
  42. package/cli/site-references.mjs +93 -0
  43. package/cli/site-render.mjs +79 -0
  44. package/cli/site-syntax.mjs +21 -0
  45. package/cli/site.mjs +130 -0
  46. package/global/CHANGELOG.md +88 -0
  47. package/global/README.md +113 -2
  48. package/global/checkout.mjs +24 -10
  49. package/global/cli/install.mjs +6 -2
  50. package/global/delegate.mjs +9 -7
  51. package/global/door/command.mjs +254 -0
  52. package/global/door/control.mjs +65 -0
  53. package/global/door/cookies.mjs +41 -0
  54. package/global/door/pages.mjs +49 -0
  55. package/global/door/policy.mjs +49 -0
  56. package/global/door/routes.mjs +45 -0
  57. package/global/door/server.mjs +255 -0
  58. package/global/door/sites.mjs +192 -0
  59. package/global/launch/dev.mjs +21 -7
  60. package/global/launch/door.mjs +36 -0
  61. package/global/launch/install-pinned.mjs +22 -0
  62. package/global/launch/prepare.mjs +38 -5
  63. package/global/launch/render.mjs +6 -1
  64. package/global/launch/steps.mjs +12 -7
  65. package/global/launch/worker.mjs +3 -3
  66. package/global/list.mjs +6 -3
  67. package/global/main.mjs +39 -3
  68. package/global/output.mjs +12 -0
  69. package/global/rer.mjs +3 -0
  70. package/global/version.mjs +8 -8
  71. package/modules/dependencies/protocol.mjs +2 -1
  72. package/modules/deploy-kit/README.md +34 -1
  73. package/modules/deploy-kit/artifact.mjs +10 -0
  74. package/modules/deploy-kit/deploy.mjs +5 -2
  75. package/modules/deploy-kit/diagnostics.mjs +55 -0
  76. package/modules/deploy-kit/owner-cli.mjs +10 -4
  77. package/modules/deploy-kit/owner.mjs +8 -4
  78. package/modules/deploy-kit/prepare.mjs +3 -2
  79. package/modules/deploy-kit/provider.mjs +51 -9
  80. package/modules/deploy-kit/site.mjs +135 -0
  81. package/modules/erasure/CONTRACT.md +6 -3
  82. package/modules/erasure/README.md +8 -1
  83. package/modules/erasure/audit.mjs +2 -2
  84. package/modules/erasure/cascade.mjs +52 -11
  85. package/modules/erasure/order.mjs +29 -22
  86. package/modules/erasure/plan.mjs +4 -2
  87. package/modules/erasure/queue.mjs +41 -0
  88. package/modules/erasure/scope.mjs +3 -3
  89. package/modules/erasure/verification.mjs +4 -2
  90. package/modules/mail/README.md +1 -1
  91. package/modules/mail/index.mjs +1 -1
  92. package/modules/mail/src/core/errors.mjs +9 -0
  93. package/modules/mail/src/development/index.d.mts +6 -0
  94. package/modules/mail/src/development/index.mjs +6 -3
  95. package/modules/mail/src/development/viewer-html.mjs +3 -3
  96. package/modules/mail/src/development/viewer.mjs +8 -1
  97. package/modules/operator-auth/accounts/native.mjs +1 -1
  98. package/modules/operator-auth/dev/badge.mjs +4 -1
  99. package/modules/operator-auth/dev/index.mjs +15 -5
  100. package/modules/operator-auth/http.mjs +2 -3
  101. package/modules/operator-auth/index.d.mts +4 -9
  102. package/modules/operator-auth/loopback.mjs +17 -0
  103. package/modules/operator-auth/page.mjs +1 -1
  104. package/modules/operator-auth/service.mjs +1 -0
  105. package/modules/operator-auth/signin/index.d.mts +11 -0
  106. package/modules/operator-auth/signin/index.mjs +10 -7
  107. package/modules/owner-claim/verify.mjs +2 -1
  108. package/modules/portability/README.md +8 -0
  109. package/modules/portability/hash.mjs +6 -2
  110. package/modules/portability/import.mjs +1 -1
  111. package/modules/portability/legacy-files.mjs +4 -4
  112. package/modules/portability/legacy-safety.mjs +51 -10
  113. package/modules/portability/migration-history.mjs +15 -0
  114. package/modules/release/README.md +68 -3
  115. package/modules/release/keygen.mjs +18 -0
  116. package/modules/release/produce.mjs +14 -2
  117. package/modules/release/sign.mjs +44 -0
  118. package/modules/release/signature.mjs +336 -0
  119. package/modules/release/signing-cli.mjs +56 -0
  120. package/modules/release/signing-io.mjs +61 -0
  121. package/modules/release/signing-key.mjs +22 -0
  122. package/modules/release/source.mjs +15 -4
  123. package/modules/release/statement.mjs +80 -0
  124. package/modules/release/vectors/generate.mjs +381 -0
  125. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  126. package/modules/release/verify.mjs +27 -0
  127. package/onboarding/adapter-auth.mjs +4 -3
  128. package/onboarding/adapter-settings.mjs +21 -5
  129. package/onboarding/adapter.mjs +2 -1
  130. package/onboarding/app-fetch.mjs +79 -0
  131. package/onboarding/archive-output.d.mts +4 -2
  132. package/onboarding/archive-output.mjs +22 -10
  133. package/onboarding/dependency-approval.mjs +4 -3
  134. package/onboarding/local/environment.mjs +7 -2
  135. package/onboarding/operator-install.mjs +8 -2
  136. package/onboarding/operator-prerequisites.mjs +5 -2
  137. package/onboarding/production-assets.mjs +23 -0
  138. package/onboarding/production-runtime.mjs +8 -4
  139. package/onboarding/queued-entry.mjs +9 -5
  140. package/operations/operations.mjs +11 -2
  141. package/package.json +76 -1
  142. package/scripts/publish-check/README.md +27 -17
  143. package/scripts/publish-check/content_identities.py +14 -233
  144. package/scripts/publish-check/git_history.py +2 -2
  145. package/scripts/publish-check/github_refs.py +28 -2
  146. package/scripts/publish-check/gitleaks.toml +1 -1
  147. package/scripts/publish-check/history_scan.py +6 -4
  148. package/scripts/publish-check/hygiene_config.py +3 -1
  149. package/scripts/publish-check/public_hygiene.py +63 -8
  150. package/scripts/publish-check/publication-config.json +16 -1
  151. package/scripts/publish-check/source_metadata.py +62 -0
  152. package/scripts/publish-check/source_shapes.py +91 -0
  153. package/scripts/publish-check/tree_scan.py +2 -2
  154. package/scripts/publish-check/source_hashes.mjs +0 -6
@@ -5,41 +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
22
  // A worker passes the signal a stop aborts: no step starts after it, and the
20
- // step running then is signalled through `children`.
21
- export async function prepare({ root, args, env, report, directory, log, children, signal }) {
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
+ }
22
42
  const checkout = await realpath(root), { mode, entry } = dispatcher(root);
23
43
  const described = await describeCheckout(root, entry, env, signal);
24
44
  const pin = await pinPolicy(root, mode, described);
25
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 });
26
48
  report({ phase: 'check', message: 'checking the server limit' });
27
49
  const capacity = await checkCapacity(checkout, childEnv, pin);
28
- if (capacity.own) return { entry, added, own: capacity.own };
50
+ if (capacity.own) { await bringBackDoor(plan, env, report); return { entry, added, own: capacity.own }; }
29
51
  const note = message => report({ phase: 'note', message });
30
52
  const step = (record, args, fields, env = childEnv) => recorded(directory, record, () => runVerb(entry, args, { root, env, note, log, children, signal }), fields);
31
53
  // Only this setup fills the seed's declared local defaults.
32
54
  const setupEnv = { ...childEnv, [localDefaults]: '1' };
33
- 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');
34
58
  report({ phase: 'install', message: 'installing dependencies' });
35
59
  await step(installState, ['install']);
36
60
  }
37
61
  const last = await setupState.read(directory), setup = await setupNeeded(root, described, last);
62
+ if (setup && doorStart) throw unprepared('set up');
38
63
  const host = flagValue(args, '--host'), hostArgs = host ? ['--host', host] : [];
39
64
  if (setup === 'resume') {
40
65
  // Saved settings hold an owner and origin; setup keeps them.
41
66
  report({ phase: 'setup', message: 'finishing this checkout\'s setup, keeping its saved owner and port' });
42
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 };
43
75
  } else if (setup === 'fresh') {
44
76
  // An interrupted setup resumes on the port it chose while that port is free.
45
77
  const owner = localOwner(checkout), explicit = flagValue(args, '--port');
@@ -50,6 +82,7 @@ export async function prepare({ root, args, env, report, directory, log, childre
50
82
  // The claim lasts until the server registers the port: the caller releases it.
51
83
  return { entry, added, release };
52
84
  }
85
+ await bringBackDoor(plan, env, report);
53
86
  return { entry, added };
54
87
  }
55
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');
@@ -63,19 +63,24 @@ async function follow(path, from, onLine, stopped) {
63
63
  // Start `dev` detached into its own process group with its output in a private
64
64
  // log, and return its ready event once it prints one. The server outlives this
65
65
  // process and stops on rer dev --stop or its idle timeout.
66
- export async function startServer(entry, args, { root, env, logPath, children, signal }) {
66
+ export async function startServer(entry, args, { root, env, logPath, children, signal, note = () => {} }) {
67
67
  signal?.throwIfAborted();
68
68
  const handle = await open(logPath, 'a', 0o600), from = (await handle.stat()).size;
69
- let child;
70
- try { child = spawn(process.execPath, [entry, 'dev', ...args, '--json'], { cwd: root, env, detached: true, stdio: ['ignore', handle.fd, handle.fd] }); }
71
- finally { await handle.close(); }
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(); }
72
76
  own(children, child, signal);
73
- let exit, ready, error, output = '';
74
- child.once('exit', (code, name) => { exit = code ?? (name ? 128 : 1); });
77
+ let ready, error, output = '';
75
78
  await follow(logPath, from, line => {
76
79
  const event = events(line);
77
80
  if (event?.event === 'ready') { ready = event; return true; }
78
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`);
79
84
  if (!event) output = (output + line + '\n').slice(-8192);
80
85
  return false;
81
86
  }, () => exit !== undefined);
@@ -84,7 +89,7 @@ export async function startServer(entry, args, { root, env, logPath, children, s
84
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; }
85
90
  }
86
91
  children.delete(child);
87
- 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);
88
93
  child.unref();
89
94
  return { ready, pid: child.pid, reused: exit === 0 };
90
95
  }
@@ -16,7 +16,7 @@ import { prepare } from './prepare.mjs';
16
16
  import { claimLaunch, eventLog, launchDirectory, liveWorker, releaseLaunch, serverRecord, stopRequest } from './state.mjs';
17
17
  import { startServer } from './steps.mjs';
18
18
 
19
- const [root, token, argsJSON, requestedAtText] = process.argv.slice(2), args = JSON.parse(argsJSON), requestedAt = Number(requestedAtText);
19
+ const [root, token, argsJSON, requestedAtText, mode] = process.argv.slice(2), args = JSON.parse(argsJSON), requestedAt = Number(requestedAtText), doorStart = mode === 'door-start';
20
20
  const checkout = await realpath(root), directory = await launchDirectory(checkout), events = eventLog(directory, token);
21
21
  // Listening before the claim: a stop may signal this worker as soon as it is the holder.
22
22
  const children = new Set(), stopping = new AbortController();
@@ -32,13 +32,13 @@ if (!await claimLaunch(directory, token)) {
32
32
  try {
33
33
  if (await stopRequest.covers(directory, requestedAt)) stop();
34
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 });
35
+ const prepared = await prepare({ root, args, env: process.env, report: fields => events.write({ event: 'phase', ...fields }), directory, log, children, signal: stopping.signal, doorStart });
36
36
  await events.write({ event: 'phase', phase: 'start', message: prepared.own ? 'the app is already running; checking it' : 'starting the app' });
37
37
  // A server rer started logs to server.log; asking a running one for its
38
38
  // ready lines goes to reuse.log, and names server.log only when rer started it.
39
39
  const serverLog = join(directory, 'server.log');
40
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 }); }
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
42
  finally { await prepared.release?.(); }
43
43
  const record = (await liveServers(process.env)).find(item => item.checkout === checkout);
44
44
  const pid = record?.owner?.pid ?? started.pid;
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;
package/global/main.mjs CHANGED
@@ -1,11 +1,14 @@
1
- import { dispatcher, locate, unsupportedMessage } from './checkout.mjs';
1
+ import { dispatcher, locate, pinnedPackage, unsupportedMessage } from './checkout.mjs';
2
2
  import { delegate } from './delegate.mjs';
3
3
  import { localDefaults } from './launch/local-defaults.mjs';
4
+ import { handleBrokenPipes } from './output.mjs';
5
+ import { packageName as pinnedName } from './package-info.mjs';
4
6
 
5
7
  const help = `Usage: rer VERB [FLAGS]
6
8
 
7
9
  Inside a project (the nearest OPERATIONS.json at or above this folder, or --cwd DIR),
8
- every verb runs that checkout's own pinned rer, exactly as node scripts/rer.mjs VERB would.
10
+ every verb runs that checkout's own pinned rer: the @runeditrun/rer version its package.json
11
+ pins (installed first by rer install or rer dev), else its vendored scripts/rer.mjs.
9
12
 
10
13
  rer dev Launch a first-party checkout with no setup steps: installs dependencies,
11
14
  sets up a local owner and a free port when needed, starts the app and prints
@@ -14,6 +17,8 @@ every verb runs that checkout's own pinned rer, exactly as node scripts/rer.mjs
14
17
  to 10 minutes on a first launch, or rerun it to reattach. rer dev --stop stops it.
15
18
  rer list [DIR] The checkouts in DIR (default: here), newest first, with whether rer dev can
16
19
  launch them, whether they are set up, and their running URL. --json for data.
20
+ rer door ... The opt-in front door: every checkout at http://NAME.localhost:PORT, started
21
+ on its first visit and stopped when idle. rer door --help for its verbs.
17
22
  rer cli status This rer: version, contents digest, install channel, launcher and other rer
18
23
  commands on PATH. --json for data.
19
24
  rer cli install Install this package for the current user (~/.local/bin/rer).
@@ -21,12 +26,19 @@ every verb runs that checkout's own pinned rer, exactly as node scripts/rer.mjs
21
26
  rer update Move to the latest published rer (--version X for an exact one), the way it
22
27
  was installed: npx and install.sh installs switch atomically, npm installs run npm.
23
28
  rer --version Print this rer's version; inside a checkout, also the version the checkout pins.
29
+ rer queue NAME -- COMMAND [ARGS]
30
+ Run COMMAND once this computer's job queue admits it (heavy package scripts
31
+ of a checkout that pins the rer package use it).
24
32
 
25
33
  Checkout verbs: install, setup, dev, preview, build, verify, reset, deploy, export, import,
26
34
  outbox, doctor, describe, check, release. Run rer VERB --help in a checkout for its flags.
27
35
  `;
28
36
 
37
+ // The verbs that install an uninstalled pinned package before they run.
38
+ const installsFirst = new Set(['install', 'check', 'describe']);
39
+
29
40
  export async function main(argv) {
41
+ handleBrokenPipes();
30
42
  const [verb] = argv;
31
43
  if (verb === '--version' || verb === '-v') return (await import('./version.mjs')).versionCommand(argv);
32
44
  // Only the zero-setup launch sets this, for its own setup child; an explicit
@@ -38,10 +50,34 @@ export async function main(argv) {
38
50
  if (!verb || verb === '--help' || verb === '-h' || verb === 'help') { process.stdout.write(help); return 0; }
39
51
  if (verb === 'cli') return (await import('./cli/command.mjs')).cliCommand(argv.slice(1));
40
52
  if (verb === 'list') return (await import('./list.mjs')).listCommand(argv.slice(1));
53
+ if (verb === 'door') return (await import('./door/command.mjs')).doorCommand(argv.slice(1));
41
54
  const target = locate(argv);
42
55
  if (target.profile === 'unsupported') { process.stderr.write(unsupportedMessage(target.root, target.version) + '\n'); return 2; }
56
+ const first = target.profile === 'first-party', mode = first ? dispatcher(target.root).mode : undefined;
57
+ if (verb === 'queue') {
58
+ const [, name, separator, command] = argv;
59
+ if (!name || separator !== '--' || !command) { process.stderr.write('Usage: rer queue NAME -- COMMAND [ARGS]\n'); return 2; }
60
+ if (mode === 'package') return delegate(target, target.args);
61
+ return (await import('../onboarding/queued-entry.mjs')).queuedCommandLine(argv.slice(1));
62
+ }
43
63
  // A checkout that pins its own rer package runs all of it, launching included.
44
- const mode = target.profile === 'first-party' ? dispatcher(target.root).mode : undefined;
64
+ // Until node_modules holds the pinned version, install, check and describe
65
+ // install it with the frozen lockfile (then run the pinned verb, so a fresh
66
+ // clone checks with the pinned bytes), the zero-setup launch installs it
67
+ // first, and every other verb says to install.
68
+ const pinned = first && mode !== 'package' ? pinnedPackage(target.root) : undefined;
69
+ if (pinned && !pinned.ready && !argv.includes('--help')) {
70
+ if (installsFirst.has(verb)) {
71
+ const { installPinned } = await import('./launch/install-pinned.mjs'), { LaunchFailure } = await import('./launch/failure.mjs');
72
+ try { await installPinned(target.root); }
73
+ catch (failure) { if (!(failure instanceof LaunchFailure)) throw failure; process.stderr.write(failure.message + '\n'); return failure.exitCode; }
74
+ return delegate(target, target.args);
75
+ }
76
+ if (verb !== 'dev') {
77
+ process.stderr.write(`${target.root} pins ${pinnedName} ${pinned.declared}, ${pinned.installed ? `but node_modules holds ${pinned.installed}` : 'which is not installed yet'}. Run rer install, then rer ${verb} again.\n`);
78
+ return 3;
79
+ }
80
+ }
45
81
  if (verb === 'dev' && mode && mode !== 'package' && !argv.includes('--help')) return (await import('./launch/dev.mjs')).launchDev(target);
46
82
  // Every other server start or queued job on a vendored pin gets the person
47
83
  // defaults too (a pinned package applies its own).
@@ -0,0 +1,12 @@
1
+ let installed = false;
2
+
3
+ export function handleBrokenPipes() {
4
+ if (installed) return;
5
+ installed = true;
6
+ for (const stream of [process.stdout, process.stderr]) {
7
+ stream.on('error', error => {
8
+ if (error.code === 'EPIPE') return;
9
+ throw error;
10
+ });
11
+ }
12
+ }
package/global/rer.mjs CHANGED
@@ -2,6 +2,9 @@
2
2
  // The installed `rer` command. This file avoids newer syntax so that an old
3
3
  // Node still reaches the one-line minimum-version message.
4
4
  import { readFileSync } from 'node:fs';
5
+ import { handleBrokenPipes } from './output.mjs';
6
+
7
+ handleBrokenPipes();
5
8
 
6
9
  function manifest() {
7
10
  // Packed: the package's own package.json. From a Base checkout: the template.
@@ -2,9 +2,7 @@
2
2
  // checkout (or with --cwd DIR) a second line names the version that checkout
3
3
  // pins: its package's, or the one its vendored copy recorded, which
4
4
  // `rer describe` reports as cliVersion.
5
- import { readFileSync } from 'node:fs';
6
- import { dirname, join } from 'node:path';
7
- import { dispatcher, locate } from './checkout.mjs';
5
+ import { dispatcher, locate, pinnedPackage } from './checkout.mjs';
8
6
  import { describeCheckout } from './launch/facts.mjs';
9
7
  import { ownManifest } from './package-info.mjs';
10
8
 
@@ -12,13 +10,15 @@ export async function versionCommand(argv) {
12
10
  process.stdout.write(ownManifest().version + '\n');
13
11
  const target = locate(argv);
14
12
  if (target.profile !== 'first-party') return 0;
15
- const { mode, entry } = dispatcher(target.root);
16
- if (mode === 'bundled') return 0;
17
- if (mode === 'package') {
18
- const pinned = JSON.parse(readFileSync(join(dirname(dirname(entry)), 'package.json'), 'utf8')).version;
19
- process.stdout.write(`This checkout (${target.root}) pins rer ${pinned} (package).\n`);
13
+ const pinned = pinnedPackage(target.root);
14
+ if (pinned) {
15
+ process.stdout.write(pinned.ready
16
+ ? `This checkout (${target.root}) pins rer ${pinned.installed} (package).\n`
17
+ : `This checkout (${target.root}) pins rer ${pinned.declared} (package), ${pinned.installed ? `but node_modules holds ${pinned.installed}` : 'not installed yet'}: run rer install.\n`);
20
18
  return 0;
21
19
  }
20
+ const { mode, entry } = dispatcher(target.root);
21
+ if (mode === 'bundled') return 0;
22
22
  let described;
23
23
  try { described = await describeCheckout(target.root, entry, process.env); }
24
24
  catch (failure) { process.stdout.write(`This checkout (${target.root}): its pinned rer version could not be read: ${failure.message.split('\n')[0]}\n`); return 0; }
@@ -1,5 +1,6 @@
1
1
  import { readJson, discover } from '../operator-auth/identity/oidc/network.mjs';
2
2
  import { PRESETS, MINIMUM_PROVIDER_VERSION } from '../operator-auth/identity/oidc/presets.mjs';
3
+ import { isLoopbackHost } from '../operator-auth/loopback.mjs';
3
4
  export const INTERFACE = 'oidc-provider@1';
4
5
  export const CALLBACK = '/.well-known/rer-link/callback';
5
6
  export const PATHS = ['/auth/oidc/auth/callback','/auth/oidc/auth/logout'];
@@ -7,7 +8,7 @@ export const NETWORK = { timeoutMs:5000 };
7
8
  export function insist(value, code = 'invalid_link', status = 400) { if (!value) throw Object.assign(new Error(code),{code,status}); }
8
9
  export function originOf(value, local = false) {
9
10
  let u;try { u=new URL(value); } catch { insist(false); }
10
- insist(u.origin===value && !u.username && !u.password && (u.protocol==='https:' || local && u.protocol==='http:' && ['localhost','127.0.0.1','[::1]'].includes(u.hostname)));
11
+ insist(u.origin===value && !u.username && !u.password && (u.protocol==='https:' || local && u.protocol==='http:' && isLoopbackHost(u.hostname)));
11
12
  return u.origin;
12
13
  }
13
14
  export async function providerMetadata(issuer, fetcher, contract = INTERFACE) {
@@ -140,12 +140,45 @@ When a token lacks a permission, the CLI prints which one instead of only a stat
140
140
  A seed carries a copy of `modules/deploy-kit/` from a Base pin. Refresh the copy, write a `deploy.json` from the plan the seed already builds, and point `deploy`, and the hosting step of `setup`, at `owner-cli.mjs`. Keep any throwaway controller only for proofs. In `deploy.json` declare:
141
141
 
142
142
  - the bindings the app uses (`DB`, `FILES`, `JOBS` and so on), with each queue's consumer and dead-letter queue and each SQLite Durable Object class;
143
- - the secrets the app reads, such as its session secret and the mail provider's key;
143
+ - the secrets the app reads, such as its session secret and the mail provider's key. A secret item may add `verifyStandIn`, the hosted-valid value `rer verify`'s hosted check binds in its place (`{"binding": "MAIL_PROVIDER", "environment": "MAIL_PROVIDER", "verifyStandIn": "resend"}`); the kit validates it (a non-empty string), leaves it out of the plan and its fingerprint, and never sends it or makes it a secret value;
144
144
  - any Cron schedule and the public setup fields the app takes in `vars`;
145
145
  - `originVar`, the variable that holds the app's public origin (for example `APP_ORIGIN`).
146
146
 
147
147
  A seed whose setup provisions nothing declares the same bindings and deploys through its own `deploy` verb. Resource names change from `rer-seedproof-*` or a chosen prefix to `<project>-<environment>-*`. The kit refuses rather than adopts an earlier installation's resources.
148
148
 
149
+ ### Run site (`rer deploy --site`)
150
+
151
+ A seed's run site (`run-<seed>.com`: its marketing homepage, `/run` and docs, written by `rer build --site`) deploys to the owner's account as a **Base-generated site Worker** (`site.mjs`). The seed declares only the apex domain, beside `deploy.json`, in the file its `commandAdapter` `site.declaration` names (Base's seeds: `deploy-site.json`):
152
+
153
+ ```json
154
+ { "version": 1, "domain": "run-cradle.com" }
155
+ ```
156
+
157
+ The domain is an apex hostname: no scheme, path, port, upper case or `www.`. The plan (`sitePlan`) names:
158
+
159
+ - the Worker `<project>-<environment>--site`. Owner Worker names never contain `--`, so it can never be the installed app's Worker (`<project>-<environment>`), and a site deploy never reads or writes the app Worker or any of its resources;
160
+ - its one module, `site-worker.mjs` (`SITE_WORKER`): fixed content Base generates, with no seed code. Its only binding is the assets (`ASSETS`), it runs first on every request (`run_worker_first`), and it has no secrets, no schedules and workers.dev off. A request to `www.<domain>` gets a 301 to `https://<domain>` with path and query kept; every other request is answered from the assets exactly as an assets-only Worker would answer it: the `rer build --site` folder with `html_handling: auto-trailing-slash` (`/run` serves `run.html`) and `not_found_handling: 404-page`;
161
+ - the apex as a Workers Custom Domain, and `www.<domain>` as a second Custom Domain on the same Worker, so the www host has DNS and a certificate and its requests reach the Worker's redirect.
162
+
163
+ No zone ruleset is read or written. Every request now invokes the Worker: one page view costs one Worker request for the page and one per same-origin file it loads (the default seed's run site: at most 7 per uncached page view).
164
+
165
+ `rer deploy --site --dry-run` builds the site and prints that plan (`--json`: a `site` event and a result with `dryRun: true`, `deployed: false` and the same `site` object) with no provider request and nothing written, even with `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` set. Without `--dry-run` it needs both inputs. Ownership is read from the provider alone, so a deploy from a fresh checkout (a CI runner with no `.rer/`) recognises the site it deployed before:
166
+
167
+ - the Worker is the kit's when its latest deployment serves, at 100%, the Worker's newest version, and that version's message annotation is the kit's `rer-site:<fingerprint>` (every site upload sets it);
168
+ - a Custom Domain is the kit's when its service is the kit's Worker.
169
+
170
+ A read-only preflight reads the zone (it must be in this account, else `site-zone-missing`), the Worker and the zone's Custom Domains, and refuses before any write: `resource-name-collision` for a Worker of the name whose live version the kit did not upload, or for a hostname attached to another service (or to a Worker that is not the kit's); `owned-resource-replaced` for a kit Worker whose latest deployment is not the kit's upload (a dashboard rollback or a split deployment). It then uploads the assets and the site Worker, checks that the new version is live, turns workers.dev off, attaches each missing hostname, and reads the Worker and both domains back. A repeat attaches nothing new. `.rer/deploy/site-<environment>.json` receives a receipt for the person; it is never read. The real path is tested only against the fake Cloudflare API in `tests/site-provider.mjs`, where every zone ruleset path answers 403; it has not run against a live account.
171
+
172
+ The token needs, beyond the owner-account permissions above:
173
+
174
+ | Permission | Scope | Used for |
175
+ | --- | --- | --- |
176
+ | Workers Scripts: Edit | the account | the site Worker upload, its assets, versions and deployments, workers.dev, and Workers Custom Domains (`/workers/domains`) |
177
+ | Zone: Read | the run site zones | finding the zone by name |
178
+ | Workers Routes: Edit | the run site zones | Workers Custom Domains, which also create their DNS records and certificates |
179
+
180
+ No Single Redirect (dynamic redirect ruleset) permission is needed. Base's references declare reserved `.example` domains (`run-default-seed.example`, `run-<edition>.example` for a new foundation), so a real deploy of either stops at `site-zone-missing`.
181
+
149
182
  ## Migration safety
150
183
 
151
184
  SQL files have ordered unique names and SHA-256 identities. `_rer_deploy_migrations` is permanent controller-owned history. Applied entries must be an unchanged prefix of the desired set. A new migration gets an `applying` claim before its SQL and completion marker are submitted together to the D1 batch query API. Any interrupted, failed or uncertain outcome remains blocked and must be inspected explicitly; the kit never automatically retries, rolls back user data, or marks an unknown outcome successful. Readback checks every final identity.
@@ -40,6 +40,16 @@ export async function collectFiles(root, directory = '', budget = {bytes:0,count
40
40
  }
41
41
  return files;
42
42
  }
43
+ // Wrangler's .assetsignore in an assets folder: gitignore-style lines naming
44
+ // files that are never assets. The file itself is never one either.
45
+ export function assetsIgnore(text = '') {
46
+ const rules = String(text).split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#')).map(line => {
47
+ const negate = line.startsWith('!'), body = negate ? line.slice(1) : line, directory = body.endsWith('/'), pattern = body.replace(/\/+$/, '');
48
+ const glob = pattern.replace(/^\//, '').split('/').map(part => part === '**' ? '.*' : part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]*').replaceAll('?', '[^/]')).join('/');
49
+ return { negate, test: new RegExp(`^${pattern.includes('/') ? '' : '(?:.*/)?'}${glob}${directory ? '/.*' : '(?:/.*)?'}$`) };
50
+ });
51
+ return path => path === '.assetsignore' || rules.reduce((ignored, rule) => rule.test.test(path) ? !rule.negate : ignored, false);
52
+ }
43
53
  /** Built files a deployed Worker must serve: everything for the browser except documents
44
54
  * (the app answers those; asset html_handling redirects them) and dot paths (never public). */
45
55
  export const publicAssetPaths = paths => paths.filter(path => !path.split('/').some(part => part.startsWith('.')) && extname(path) !== '.html');
@@ -1,3 +1,4 @@
1
+ import { recordDeployFailure } from './diagnostics.mjs';
1
2
  import { verifyServiceGrants } from './service-grants.mjs';
2
3
  import { assert, hash, validatePlan } from './validation.mjs';
3
4
  import { validateArtifact, publicAssetPaths } from './artifact.mjs';
@@ -55,8 +56,10 @@ export async function deploy({api,plan,artifact,artifactSha256,previousReceipt,r
55
56
  if (onReceipt) await onReceipt(receipt);
56
57
  // One built file proves the Worker hands static assets to the browser (assets run behind it).
57
58
  const asset = publicAssetPaths(artifact.assets.map(x => x.path))[0];
58
- if (unchanged) await verifyHealth({...healthOptions,api,plan,receipt,fetch,asset});
59
- else await firstHealth({api,plan,receipt,fetch,healthOptions,readiness,onHealth,asset});
59
+ try {
60
+ if (unchanged) await verifyHealth({...healthOptions,api,plan,receipt,fetch,asset});
61
+ else await firstHealth({api,plan,receipt,fetch,healthOptions,readiness,onHealth,asset});
62
+ } catch (error) { throw recordDeployFailure(error,{step:'health',...(error.code ? {code:error.code,output:error.message} : {})}); }
60
63
  return receipt;
61
64
  }
62
65
 
@@ -0,0 +1,55 @@
1
+ // Provider text is private until the CLI's existing credential redactor approves
2
+ // it. Identity, rather than mutable Error properties, carries this diagnostic.
3
+ const failures = new WeakMap();
4
+ export function recordDeployFailure(error, details) {
5
+ failures.set(error,{...failures.get(error),...details});
6
+ return error;
7
+ }
8
+ export function deployDiagnostic(error, redact) {
9
+ const detail = failures.get(error);
10
+ if (!detail) return undefined;
11
+ const clean = value => (redact ?? String)(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g,'').replace(/\b[a-f0-9]{32}\b/gi,'[REDACTED]');
12
+ const failureCode = detail.code ?? error.message;
13
+ const code = /^[a-zA-Z0-9_-]{1,120}$/.test(failureCode) ? clean(failureCode) : 'deploy-failed';
14
+ return {code,step:detail.step ?? 'deploy',created:(detail.created ?? []).map(clean),
15
+ ...(typeof error.resource==='string' && /^(?:d1|r2|kv|queues|worker|service):[a-z0-9-]+$/.test(error.resource) ? {resource:clean(error.resource)} : {}),
16
+ ...(detail.providerCodes?.length ? {providerCodes:detail.providerCodes} : {}),
17
+ output:(detail.output ? (redact ? clean(detail.output) : '(provider output withheld; rer deploy provides redacted details)') : '(no provider output)').split(/\r?\n/).slice(0,8).map(line=>line.slice(0,500)).join('\n')};
18
+ }
19
+ function operation(path, options = {}) {
20
+ const write = options.method && options.method !== 'GET';
21
+ if (/\/d1\/database\/[^/]+\/query/.test(path)) return 'migrate D1';
22
+ for (const [prefix,kind] of [['/d1/database','D1'],['/r2/buckets','R2'],['/queues','queue'],['/storage/kv/namespaces','KV']]) {
23
+ if (path === prefix && options.method === 'POST') return `create ${kind}`;
24
+ if (path.startsWith(prefix)) return `check ${kind}`;
25
+ }
26
+ if (path.includes('/secrets')) return 'secrets';
27
+ if (path.includes('/subdomain') || path.includes('/routes') || path.includes('/domains')) return 'route';
28
+ if (path.includes('/schedules')) return 'scheduled triggers';
29
+ if (write && path.startsWith('/workers/')) return 'upload Worker';
30
+ return 'check Worker';
31
+ }
32
+ export function deploymentDiagnostics(api, {onCreated} = {}) {
33
+ const created = [];
34
+ const recordCreated = resource => { created.push(resource); onCreated?.(resource); };
35
+ let currentStep = 'prepare deploy';
36
+ const guard = method => async (path, options) => {
37
+ const step = operation(path,options);
38
+ currentStep = step;
39
+ try {
40
+ const result = await api[method](path,options);
41
+ if (step.startsWith('create ')) recordCreated(`${step.slice(7)}:${options.body.name ?? options.body.queue_name ?? options.body.title}`);
42
+ return result;
43
+ } catch (error) { throw recordDeployFailure(error,{step,created:[...created]}); }
44
+ };
45
+ return {...api,request:guard('request'),list:guard('list'),envelope:guard('envelope'),recordCreated,
46
+ annotateFailure(error, step = currentStep) {
47
+ const prior = failures.get(error);
48
+ // Node filesystem/CLI codes are structural; their messages can hold paths
49
+ // and inputs, so retain them privately for the caller's redaction boundary.
50
+ const cliCode = typeof error.code === 'string' && /^(?:E[A-Z0-9_]{1,63}|health-[a-z-]+)$/.test(error.code) ? error.code : undefined;
51
+ recordDeployFailure(error,{step:prior?.step ?? step,created:prior?.created ?? [...created],
52
+ ...(!prior?.code && cliCode ? {code:cliCode,...(!prior?.output ? {output:error.message} : {})} : {})});
53
+ return error;
54
+ }};
55
+ }
@@ -1,3 +1,4 @@
1
+ import { deploymentDiagnostics, deployDiagnostic } from './diagnostics.mjs';
1
2
  import { execFileSync } from 'node:child_process';
2
3
  import { resolve, join } from 'node:path';
3
4
  import { pathToFileURL } from 'node:url';
@@ -21,7 +22,7 @@ async function ownerArtifact(root, product) {
21
22
  }
22
23
 
23
24
  /** Inputs: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, plus the secrets the declaration names on first deploy. */
24
- export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, localOnlyKeys = [], notice = line => console.error(line)} = {}) {
25
+ export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, localOnlyKeys = [], notice = line => console.error(line), onCreated} = {}) {
25
26
  let parsed;
26
27
  try { parsed = parseArgs({args:argv,allowPositionals:true,strict:true,options:{declaration:{type:'string'},environment:{type:'string'},product:{type:'string',default:''},state:{type:'string'}}}); }
27
28
  catch { throw new Error('usage'); }
@@ -36,23 +37,28 @@ export async function main(argv = process.argv.slice(2), env = process.env, {fet
36
37
  try { declaration = JSON.parse(await regularBytes(resolve(root,values.declaration),256 * 1024)); }
37
38
  catch (error) { throw new Error(error.code === 'ENOENT' ? 'missing-owner-declaration' : 'invalid-owner-declaration'); }
38
39
  assert(typeof declaration?.project === 'string','missing-owner-project');
39
- const api = cloudflare({accountId:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_API_TOKEN,fetch});
40
+ const api = deploymentDiagnostics(cloudflare({accountId:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_API_TOKEN,fetch}),{onCreated});
40
41
  const target = await ownerTarget(api,{project:declaration.project,environment:values.environment});
41
42
  const statePath = values.state ? resolve(root,values.state) : join(root,'.rer','deploy',`${target.environment}.json`);
42
43
  const artifact = await ownerArtifact(root,values.product);
43
44
  const plan = ownerPlan(target,declaration,artifact,{localOnlyKeys});
44
45
  if (command === 'health') {
45
- await ownerHealth({api,target,plan,statePath,fetch,healthOptions:{...healthOptions,readiness:{single:true,totalMs:30000}}});
46
+ await ownerHealth({api,target,plan,statePath,fetch,healthOptions:{...healthOptions,readiness:{single:true,totalMs:30000}}}).catch(error=>{throw api.annotateFailure(error,'health');});
46
47
  return {ok:true,worker:target.worker,origin:target.origin};
47
48
  }
48
49
  // Dispatch is the provider's: on some accounts a new Cron starts only at the account scheduler's next refresh.
49
50
  if (plan.crons.length) notice(cronNotice);
50
- const {receipt,created} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions});
51
+ const {receipt,created} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions}).catch(error => {
52
+ api.annotateFailure(error,error.message.startsWith('missing-secret-') ? 'secrets' : undefined);
53
+ throw error;
54
+ });
51
55
  return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,state:statePath};
52
56
  }
53
57
 
54
58
  /** Prints refusal codes and resource names only; never inputs, tokens or provider bodies. */
55
59
  export function report(error) {
60
+ const diagnostic = deployDiagnostic(error);
61
+ if (diagnostic) return {ok:false,...diagnostic};
56
62
  if (error.message === 'missing-input') return {ok:false,code:'missing-input',inputs:error.inputs};
57
63
  if (error.message === 'usage') return {ok:false,code:'usage',usage};
58
64
  return {ok:false,code:/^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? error.message : 'owner-deploy-failed',...(error.resource ? {resource:error.resource} : {})};
@@ -18,7 +18,7 @@ const segment = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
18
18
  const STATE_KIND = 'rer-deploy-owner-state';
19
19
  const KINDS = ['d1','r2','kv','queues'];
20
20
  const DECLARATION_KEYS = ['version','project','compatibilityDate','compatibilityFlags','d1','r2','kv','queues','durableObjects','secrets','crons','vars','originVar','services','workersDev','platforms','container'];
21
- const ENTRY_KEYS = {d1:['binding'],r2:['binding','locationHint'],kv:['binding'],queues:['binding','consumer','deadLetterQueue'],durableObjects:['binding','className','storage'],secrets:['binding','environment'],services:['binding','project','entrypoint','allowedHosts','grant']};
21
+ const ENTRY_KEYS = {d1:['binding'],r2:['binding','locationHint'],kv:['binding'],queues:['binding','consumer','deadLetterQueue'],durableObjects:['binding','className','storage'],secrets:['binding','environment','verifyStandIn'],services:['binding','project','entrypoint','allowedHosts','grant']};
22
22
 
23
23
  function refuse(code, kind, name) { const error = new Error(code); error.resource = `${kind}:${name}`; return error; }
24
24
 
@@ -110,7 +110,8 @@ export function ownerPlan(target, declaration, artifact, { localOnlyKeys = [] }
110
110
  ...(kv.length ? {kv} : {}),
111
111
  queues:queues.map(item => ({...item,name:name(item.binding),...(item.deadLetterQueue === undefined ? {} : {deadLetterQueue:name(item.deadLetterQueue)})})),
112
112
  durableObjects:entries(declaration,'durableObjects').map(item => ({...item})),
113
- secrets:entries(declaration,'secrets').map(item => ({...item})),
113
+ // verifyStandIn is only `rer verify`'s hosted-check value: never planned, sent or stored.
114
+ secrets:entries(declaration,'secrets').map(({verifyStandIn,...item}) => { assert(verifyStandIn === undefined || typeof verifyStandIn === 'string' && verifyStandIn.length > 0 && verifyStandIn.length <= 1024, 'invalid-verify-stand-in'); return item; }),
114
115
  crons:[...(declaration.crons ?? [])],vars,
115
116
  // Only declarations using these add the keys, so earlier plans and fingerprints are unchanged.
116
117
  ...(services.length ? {services} : {}),...(declaration.workersDev === false ? {workersDev:false} : {})});
@@ -182,7 +183,7 @@ async function checkSavedResourceIdentities(api, state) {
182
183
  async function checkServiceWorkers(api, plan) {
183
184
  for (const {service} of plan.services ?? []) if (await workerSettings(api, service) === null) throw refuse('service-worker-missing','service',service);
184
185
  }
185
- async function workerSettings(api, worker) {
186
+ export async function workerSettings(api, worker) {
186
187
  try { return await api.request(`/workers/scripts/${worker}/settings`); }
187
188
  catch (error) { if (error.status === 404) return null; throw error; }
188
189
  }
@@ -220,7 +221,7 @@ async function checkOwnedWorker(api, plan, artifact, state) {
220
221
  }
221
222
 
222
223
  /** One deploy at a time per state file: an exclusive lock file for the whole run. */
223
- async function withStateLock(path, run) {
224
+ export async function withStateLock(path, run) {
224
225
  await mkdir(dirname(path),{recursive:true,mode:0o700});
225
226
  const lock = `${path}.lock`;
226
227
  let handle;
@@ -272,6 +273,9 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
272
273
  }
273
274
  const result = await api.request(path, options);
274
275
  if (sending) {
276
+ // A successful upload creates a Worker only when ownership preflight found
277
+ // it absent. Record it before readback, which can fail after creation.
278
+ if (!exists) api.recordCreated?.(`Worker:${plan.worker}`);
275
279
  const active = await activeWorker(api, plan.worker);
276
280
  assert(active && deployedFingerprint(active.settings) === state.pendingUpload.fingerprint, 'uploaded-worker-drift');
277
281
  state.pendingUpload.provider = {versionId:active.versionId,deploymentId:active.deploymentId,etag:active.etag};