@runeditrun/rer 0.2.0 → 0.2.2

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 (112) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +4 -2
  3. package/cli/auth-copy.mjs +25 -18
  4. package/cli/bounded.mjs +13 -4
  5. package/cli/contract.mjs +3 -3
  6. package/cli/deploy-inputs.mjs +33 -0
  7. package/cli/deploy-owner.mjs +61 -0
  8. package/cli/deploy.mjs +95 -9
  9. package/cli/detached-server.mjs +51 -0
  10. package/cli/door.mjs +1 -1
  11. package/cli/hosted-output.mjs +2 -2
  12. package/cli/hydration/check.mjs +2 -2
  13. package/cli/inspect.mjs +24 -32
  14. package/cli/lifecycle.mjs +14 -4
  15. package/cli/main.mjs +1 -0
  16. package/cli/managed-server.mjs +12 -5
  17. package/cli/parse.mjs +5 -2
  18. package/cli/pnpm-probe.mjs +14 -0
  19. package/cli/pnpm-runtime.mjs +22 -22
  20. package/cli/portability-policy.mjs +55 -0
  21. package/cli/portability-worker.mjs +8 -4
  22. package/cli/portability.mjs +20 -4
  23. package/cli/project-pnpm.mjs +67 -0
  24. package/cli/release.mjs +36 -0
  25. package/cli/server-registry.mjs +2 -2
  26. package/cli/site-bindings.mjs +291 -0
  27. package/cli/site-browser-objects.mjs +36 -0
  28. package/cli/site-check.mjs +55 -31
  29. package/cli/site-client.mjs +50 -0
  30. package/cli/site-data.mjs +55 -0
  31. package/cli/site-entities.json +1 -0
  32. package/cli/site-input-assets.mjs +58 -0
  33. package/cli/site-inputs.mjs +79 -0
  34. package/cli/site-markup.mjs +102 -0
  35. package/cli/site-private.mjs +36 -0
  36. package/cli/site-references.mjs +243 -0
  37. package/cli/site-render.mjs +7 -5
  38. package/cli/site-syntax.mjs +21 -0
  39. package/cli/site.mjs +32 -11
  40. package/global/CHANGELOG.md +39 -0
  41. package/global/README.md +15 -4
  42. package/global/cli/install.mjs +26 -9
  43. package/global/cli/path-link.mjs +30 -0
  44. package/global/cli/status.mjs +1 -1
  45. package/global/cli/uninstall.mjs +5 -1
  46. package/global/launch/install-pinned.mjs +4 -0
  47. package/global/main.mjs +30 -3
  48. package/global/output.mjs +12 -0
  49. package/global/registry.mjs +2 -2
  50. package/global/rer.mjs +3 -0
  51. package/modules/container/deploy-input.mjs +5 -9
  52. package/modules/dependencies/auth.mjs +2 -1
  53. package/modules/deploy-kit/README.md +25 -7
  54. package/modules/deploy-kit/d1-import.mjs +35 -0
  55. package/modules/deploy-kit/deploy.mjs +9 -5
  56. package/modules/deploy-kit/diagnostics.mjs +65 -0
  57. package/modules/deploy-kit/env-file.mjs +26 -0
  58. package/modules/deploy-kit/health-request.mjs +6 -1
  59. package/modules/deploy-kit/health.mjs +35 -15
  60. package/modules/deploy-kit/migration-ledger.mjs +48 -0
  61. package/modules/deploy-kit/migrations.mjs +11 -15
  62. package/modules/deploy-kit/owner-cli.mjs +29 -10
  63. package/modules/deploy-kit/owner-remove.mjs +105 -0
  64. package/modules/deploy-kit/owner-secret.mjs +49 -0
  65. package/modules/deploy-kit/owner.mjs +71 -10
  66. package/modules/deploy-kit/provider.mjs +59 -7
  67. package/modules/job-queue/config.mjs +4 -2
  68. package/modules/operator-auth/accounts/native.mjs +1 -1
  69. package/modules/operator-auth/dev/badge.mjs +4 -1
  70. package/modules/operator-auth/identity/oidc/README.md +18 -0
  71. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  72. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  73. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  74. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  75. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  76. package/modules/operator-auth/index.d.mts +6 -9
  77. package/modules/operator-auth/page.mjs +1 -1
  78. package/modules/operator-auth/routes.mjs +1 -1
  79. package/modules/operator-auth/service.mjs +1 -0
  80. package/modules/operator-auth/signin/index.d.mts +11 -0
  81. package/modules/operator-auth/signin/index.mjs +10 -7
  82. package/modules/portability/README.md +28 -0
  83. package/modules/portability/bridge.mjs +10 -3
  84. package/modules/portability/import.mjs +4 -2
  85. package/modules/release/README.md +63 -0
  86. package/modules/release/keygen.mjs +18 -0
  87. package/modules/release/produce.mjs +9 -0
  88. package/modules/release/sign.mjs +44 -0
  89. package/modules/release/signature.mjs +336 -0
  90. package/modules/release/signing-cli.mjs +56 -0
  91. package/modules/release/signing-io.mjs +61 -0
  92. package/modules/release/signing-key.mjs +22 -0
  93. package/modules/release/statement.mjs +80 -0
  94. package/modules/release/vectors/generate.mjs +381 -0
  95. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  96. package/modules/release/verify.mjs +27 -0
  97. package/onboarding/commands.mjs +3 -1
  98. package/onboarding/production-assets.mjs +23 -0
  99. package/onboarding/production-runtime.mjs +8 -4
  100. package/package.json +2 -1
  101. package/scripts/publish-check/README.md +27 -17
  102. package/scripts/publish-check/content_identities.py +14 -233
  103. package/scripts/publish-check/git_history.py +2 -2
  104. package/scripts/publish-check/gitleaks.toml +1 -1
  105. package/scripts/publish-check/history_scan.py +6 -4
  106. package/scripts/publish-check/hygiene_config.py +3 -1
  107. package/scripts/publish-check/public_hygiene.py +29 -5
  108. package/scripts/publish-check/publication-config.json +9 -1
  109. package/scripts/publish-check/source_metadata.py +62 -0
  110. package/scripts/publish-check/source_shapes.py +91 -0
  111. package/scripts/publish-check/tree_scan.py +2 -2
  112. package/scripts/publish-check/source_hashes.mjs +0 -6
package/README.md CHANGED
@@ -22,8 +22,11 @@ refuses archives with absolute paths, `..` entries, links or devices, then runs
22
22
  the package's own `rer cli install`. A failed or refused download leaves the
23
23
  version you had selected.
24
24
 
25
- No installer edits shell profiles. When `~/.local/bin` is not on your PATH, the
26
- install prints the exact line to add for your shell. The launcher runs `node` from
25
+ No installer edits shell profiles. The canonical launcher is `~/.local/bin/rer`.
26
+ When that directory is absent from PATH, installation also links it into an
27
+ existing writable, user-owned PATH directory if available, preserving foreign
28
+ commands. Otherwise it prints one activation line to run in the current shell:
29
+ `export PATH="$HOME/.local/bin:$PATH"` (with fish syntax for fish). The launcher runs `node` from
27
30
  PATH, so a Node upgrade does not break it. A re-run with the same build changes
28
31
  nothing; a different build is installed beside the current one, the previous one
29
32
  is kept and older ones are removed. It refuses to replace a `~/.local/bin/rer`
@@ -247,7 +250,7 @@ line that still names it. A later refresh is a version bump:
247
250
  | `@runeditrun/rer/server-resources` | managed server resource readings (runtime journeys) | `scripts/base-cli/cli/server-resources.mjs` |
248
251
  | `@runeditrun/rer/operations` | OPERATIONS describe and check | `scripts/base-cli/operations/operations.mjs` |
249
252
  | `@runeditrun/rer/modules/portability`, `/catalogue`, `/cli`, `/cli-options`, `/files`, `/format`, `/http`, `/import`, `/journal`, `/legacy-safety`, `/migration-history`, `/storage` | portability module | `scripts/base-cli/modules/portability/*.mjs` |
250
- | `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source` | release export and publication scan | `scripts/base-cli/modules/release/*.mjs` |
253
+ | `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source`, `/signature` | release export, publication scan and shared signature verifier | `scripts/base-cli/modules/release/*.mjs` |
251
254
  | `@runeditrun/rer/modules/deploy-kit/owner`, `/owner-cli`, `/prepare`, `/provider` | deploy kit | `scripts/base-cli/modules/deploy-kit/*.mjs` |
252
255
  | `@runeditrun/rer/modules/operator-auth`, `/dev`, `/dev/badge`, `/dev-admission`, `/accounts/native`, `/identity/email-link` | operator auth | `scripts/base-cli/modules/operator-auth/index.mjs`, `dev/index.mjs`, `dev/badge.mjs`, `dev-admission.mjs`, `accounts/native.mjs`, `identity/email-link/index.mjs` |
253
256
  | `@runeditrun/rer/modules/erasure/fence` | erasure fence statements | `scripts/base-cli/modules/erasure/fence.mjs` |
@@ -260,9 +263,17 @@ In a checkout, every verb runs that checkout's own pinned `rer`; `rer VERB --hel
260
263
  there lists its flags:
261
264
 
262
265
  `install`, `setup`, `dev`, `preview`, `build`, `verify`, `reset --yes`, `deploy`,
263
- `export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release`.
266
+ `export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release`,
267
+ `release sign`, `release receipt` and `release keygen`.
264
268
 
265
269
  Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
266
270
  `rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
267
271
 
268
272
  Licence: MIT.
273
+
274
+ First-party commands acquire the exact `packageManager` pnpm pin when PATH lacks
275
+ it, verify its registry sha512 integrity and cache it under `$HOME/.rer/pnpm`.
276
+ No global package manager installation or sudo is needed. Independent projects
277
+ continue to run their declared commands with their own tools. Server and job
278
+ state default to `$HOME/.rer/servers` and `$HOME/.rer/jobs`; supplied
279
+ `RER_SERVERS_DIR` and `RER_JOBS_DIR` take precedence and are passed to older pins.
package/cli/README.md CHANGED
@@ -131,8 +131,10 @@ Existing seeds receive this policy by refreshing `scripts/base-cli/` with
131
131
  onboarding copy). For production, adopt the persistent root-head stylesheet,
132
132
  primary preload and matched fallback described in `export/UI-CONVENTIONS.md`.
133
133
 
134
- Both are managed foreground servers; their first invocation stays active while
135
- serving. A compatible repeated invocation verifies the registered application,
134
+ Both are managed servers. `--detach` returns after readiness, leaving the server
135
+ running; `--foreground` keeps the invocation active while serving. Preview
136
+ refreshes changed built assets and their ETags; source edits need a restart and
137
+ rebuild. A compatible repeated invocation verifies the registered application,
136
138
  prints its safe ready/outbox URLs and exits without a new build or sign-in.
137
139
  Explicit conflicting host, port or timeout, or the other mode, refuses with
138
140
  exit 4 and the correct stop command. `--stop` is idempotent;
package/cli/auth-copy.mjs CHANGED
@@ -7,16 +7,17 @@ import { contained } from './project.mjs';
7
7
  // bytes of the Base revision it pinned. A seed records its copies in
8
8
  // base-copies.json (edition authoring) or composition.lock.json /
9
9
  // foundation.lock.json (composed foundations); each records the SHA-256 of the
10
- // Base bytes it copied. Two groups are checked on their own: the application,
11
- // runtime and schema copies (one revision pins them all, since they are coupled)
12
- // and the CLI's bundled tooling under scripts/base-cli (one revision of its
13
- // own, which a CLI refresh moves without touching the application's auth). Each
14
- // drifted, missing or unrecorded file in either group is named. A seed that
15
- // imports Base in place has no copy.
10
+ // Base bytes it copied. Application runtime and schema sources share one pin;
11
+ // the CLI's bundled tooling has its own. Numbered SQL under migrations keeps
12
+ // its historical revision independently: refreshing runtime must not rewrite
13
+ // SQL a database may already have applied. Every copy still needs a valid pin
14
+ // and exact bytes. A seed that imports Base in place has no copy.
16
15
  const ORIGIN = 'modules/operator-auth';
17
16
  const CLI_ROOT = 'scripts/base-cli';
18
17
  const within = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
19
- const groupOf = path => typeof path === 'string' && within(path, CLI_ROOT) ? 'cli' : 'application';
18
+ const groupOf = path => typeof path === 'string' && within(path, CLI_ROOT) ? 'cli'
19
+ : /(?:^|\/)migrations\/(?:[^/]+\/)*\d+[^/]*\.sql$/.test(path) ? 'migration' : 'application';
20
+ const pinned = revision => typeof revision === 'string' && /^[a-f0-9]{40}$/.test(revision);
20
21
  const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
21
22
  async function json(root, path) { try { return JSON.parse(await readFile(await contained(root, path), 'utf8')); } catch { return null; } }
22
23
 
@@ -30,22 +31,24 @@ async function walk(root, path) {
30
31
  return files;
31
32
  }
32
33
 
33
- // [{ group, path, revision, files: { local path: sha256 } }] for every recorded auth copy.
34
+ // [{ path, revision, files: { local path: sha256 } }] for every recorded auth copy.
34
35
  async function recordedCopies(root) {
35
36
  const copies = [];
36
37
  const record = await json(root, 'base-copies.json');
37
38
  for (const copy of record?.copies ?? []) {
38
- if (typeof copy?.originPath === 'string' && within(copy.originPath, ORIGIN) && copy.files && typeof copy.files === 'object') copies.push({ path: copy.path, revision: copy.revision, files: copy.files, directory: copy.originPath === ORIGIN, group: groupOf(copy.path) });
39
+ if (typeof copy?.originPath !== 'string' || !within(copy.originPath, ORIGIN)) continue;
40
+ const files = copy.files && typeof copy.files === 'object' && !Array.isArray(copy.files) ? copy.files : {};
41
+ copies.push({ path: copy.path, revision: copy.revision, files, directory: copy.originPath === ORIGIN || Object.keys(files).some(path => path !== copy.path && within(path, copy.path)) });
39
42
  }
40
43
  for (const name of ['composition.lock.json', 'foundation.lock.json']) {
41
44
  const lock = await json(root, name);
42
45
  if (!lock?.files) continue;
43
46
  const revision = lock.repositories?.base?.commit ?? lock.base?.commit;
44
- for (const group of ['application', 'cli']) {
45
- const entries = Object.entries(lock.files).filter(([path, file]) => groupOf(path) === group && typeof file?.source === 'string' && within(file.source, ORIGIN));
46
- const files = Object.fromEntries(entries.filter(([, file]) => typeof file.sha256 === 'string').map(([path, file]) => [path, file.sha256]));
47
+ for (const cli of [false, true]) {
48
+ const entries = Object.entries(lock.files).filter(([path, file]) => (groupOf(path) === 'cli') === cli && typeof file?.source === 'string' && within(file.source, ORIGIN));
49
+ const files = Object.fromEntries(entries.map(([path, file]) => [path, file.sha256]));
47
50
  const directories = [...new Set(entries.filter(([, file]) => !file.source.endsWith('.sql')).map(([path, file]) => path.slice(0, path.length - (file.source.length - ORIGIN.length))))];
48
- if (Object.keys(files).length) copies.push({ path: directories[0] ?? null, revision, files, directory: directories.length === 1, group });
51
+ if (Object.keys(files).length) copies.push({ path: directories[0] ?? null, revision, files, directory: directories.length === 1 });
49
52
  }
50
53
  }
51
54
  return copies;
@@ -55,18 +58,22 @@ export async function authCopyClauses(root) {
55
58
  const copies = await recordedCopies(root);
56
59
  if (!copies.length) return [];
57
60
  const clauses = [];
58
- for (const [group, id, scope] of [['application', 'SC-05.auth-copy.pin', 'the application, runtime and schema copies'], ['cli', 'SC-05.auth-copy.cli-pin', `the CLI's bundled copy under ${CLI_ROOT}`]]) {
59
- const grouped = copies.filter(copy => copy.group === group);
61
+ for (const [group, id, scope] of [['application', 'SC-05.auth-copy.pin', 'the application runtime and schema sources'], ['cli', 'SC-05.auth-copy.cli-pin', `the CLI's bundled copy under ${CLI_ROOT}`]]) {
62
+ const grouped = copies.filter(copy => Object.keys(copy.files).some(path => groupOf(path) === group));
60
63
  if (!grouped.length) continue;
61
64
  const revisions = [...new Set(grouped.map(copy => copy.revision))];
62
- clauses.push([id, revisions.length === 1 && /^[a-f0-9]{40}$/.test(revisions[0] ?? ''), `Copy ${scope} of operator-auth from one pinned Base revision; the seed records ${revisions.join(', ') || 'none'}.`]);
65
+ clauses.push([id, revisions.length === 1 && pinned(revisions[0]), `Copy ${scope} of operator-auth from one pinned Base revision; the seed records ${revisions.join(', ') || 'none'}.`]);
63
66
  }
64
67
  for (const copy of copies) {
68
+ if (!Object.keys(copy.files).length) clauses.push([`SC-05.auth-copy.${copy.path}`, false, `${copy.path} has no recorded bytes; record its copied files and their SHA-256 digests.`]);
65
69
  for (const [path, expected] of Object.entries(copy.files)) {
70
+ if (groupOf(path) === 'migration') clauses.push([`SC-05.auth-copy.migration-pin.${path}`, pinned(copy.revision), `${path} must retain a valid pinned Base revision for its historical SQL; the seed records ${copy.revision ?? 'none'}.`]);
66
71
  let actual = null; try { actual = sha256(await readFile(await contained(root, path))); } catch {}
67
- clauses.push([`SC-05.auth-copy.${path}`, actual === expected, actual === null
72
+ clauses.push([`SC-05.auth-copy.${path}`, actual !== null && actual === expected, actual === null
68
73
  ? `${path} is missing; restore it from Base ${copy.revision}.`
69
- : `${path} differs from Base ${copy.revision}; restore the copied bytes or record a new pin with refresh-base.`]);
74
+ : groupOf(path) === 'migration'
75
+ ? `${path} differs from Base ${copy.revision}; restore its historical bytes and put schema changes in a new migration.`
76
+ : `${path} differs from Base ${copy.revision}; restore the copied bytes or record a new pin with refresh-base.`]);
70
77
  }
71
78
  if (copy.directory && copy.path) {
72
79
  for (const path of await walk(root, copy.path)) {
package/cli/bounded.mjs CHANGED
@@ -16,7 +16,7 @@ export async function boundedCommand(project,options,argv,out){
16
16
  const policy=await commandPolicy();
17
17
  const childArgs=[...argv];const cwdIndex=childArgs.indexOf('--cwd');if(cwdIndex>=0&&(childArgs.indexOf('--')<0||cwdIndex<childArgs.indexOf('--')))childArgs[cwdIndex+1]=project.root;
18
18
  const directory=await mkdtemp(join(tmpdir(),'rer-bounded-'));
19
- let store,result,finalEvent,errorEvent,buffer='';
19
+ let store,result,finalEvent,errorEvent,buffer='';const unclosed=[];
20
20
  const cancellation=new AbortController();
21
21
  const handlers=new Map(['SIGINT','SIGTERM'].map(name=>[name,()=>cancellation.abort(name)]));
22
22
  for(const [name,handler] of handlers)process.on(name,handler);
@@ -47,11 +47,18 @@ export async function boundedCommand(project,options,argv,out){
47
47
  const held=await recoverInterrupted(project.root);await held?.release();
48
48
  }
49
49
  }
50
+ }).catch(error=>{
51
+ // The workflow already reported its outcome; only stopping its process group
52
+ // failed. Keep that outcome, and say so, instead of losing it.
53
+ if(!finalEvent||cancellation.signal.aborted)throw error;
54
+ unclosed.push(error);return {code:finalEvent.exitCode};
50
55
  });
51
56
  }finally{
52
- try{await store?.closeCommands();await policy.close();await rm(directory,{recursive:true,force:true});}
53
- finally{for(const [name,handler] of handlers)process.off(name,handler);}
57
+ // Every closer runs: a policy socket left listening keeps this process alive.
58
+ for(const close of [()=>store?.closeCommands(),()=>policy.close(),()=>rm(directory,{recursive:true,force:true})]){try{await close();}catch(error){unclosed.push(error);}}
59
+ for(const [name,handler] of handlers)process.off(name,handler);
54
60
  }
61
+ if(unclosed.length)out.progress(`Local cleanup could not confirm that every helper process stopped (${unclosed.map(error=>store?.redact?.(String(error?.message))??'unknown cause').join('; ')}). The ${options.verb} itself finished.\n`);
55
62
  const termination=cancellation.signal.aborted?cancellation.signal.reason:undefined;
56
63
  if(!result&&!termination)return null;
57
64
  const exitCode=termination?(termination==='SIGINT'?130:143):result.code;
@@ -61,7 +68,9 @@ export async function boundedCommand(project,options,argv,out){
61
68
  }else if(finalEvent&&finalEvent.exitCode===exitCode){
62
69
  if(errorEvent)out.error(new RerError(errorEvent.code,store.redact(errorEvent.message),exitCode));
63
70
  const {version,verb,event,ok,exitCode:childExit,...details}=finalEvent;
64
- out.result(exitCode===0,exitCode,JSON.parse(store.redact(JSON.stringify(details))));
71
+ // Redaction can cut through a value that matches a credential; the outcome stands without its details.
72
+ let shown;try{shown=JSON.parse(store.redact(JSON.stringify(details)));}catch{shown={};}
73
+ out.result(exitCode===0,exitCode,shown);
65
74
  }else{
66
75
  const error=new RerError('command_failed',result.supervisorEnded?store.redact(result.error):'Bounded command ended before its result; owned children and private files were recovered.',exitCode||1);
67
76
  out.error(error);out.result(false,error.exitCode);return {exitCode:error.exitCode};
package/cli/contract.mjs CHANGED
@@ -6,12 +6,12 @@ export const commonFlags = ['--help', '--cwd', '--non-interactive', '--json'];
6
6
  const definitions = {
7
7
  install: [[], 'Frozen dependency installation; no setup or global tool changes.'],
8
8
  setup: [['--owner-email', '--owner-name', '--port', '--host', '--answers', '--door'], 'Build, migrate, prepare private settings and first owner; stop the temporary app. --door sets a fresh checkout up at its front-door origin http://NAME.localhost:PORT (after rer door enable).'],
9
- dev: [['--port', '--host', '--idle-timeout', '--stop', '--as', '--login', '--models', '--models-service', '--door', '--direct'], 'Foreground development with assets, HMR and the declared scheduled triggers run locally on their schedule. With devIdentity it opens signed in as the owner (--as PERSONA picks another start, --login uses the outbox sign-in instead); otherwise ordinary owner sign-in through the outbox. --door moves this checkout to its front-door origin http://NAME.localhost:PORT, keeping its data; --direct moves it back; --port and --host serve directly.'],
10
- preview: [['--port', '--host', '--idle-timeout', '--stop'], 'Foreground fixed production output; reuse intact build, preserve owner/state, idle timeout and explicit stop.'],
9
+ dev: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground', '--as', '--login', '--models', '--models-service', '--door', '--direct'], 'Development with assets, HMR and the declared scheduled triggers run locally on their schedule. --detach returns once ready; --foreground keeps this command serving. With devIdentity it opens signed in as the owner (--as PERSONA picks another start, --login uses the outbox sign-in instead); otherwise ordinary owner sign-in through the outbox. --door moves this checkout to its front-door origin http://NAME.localhost:PORT, keeping its data; --direct moves it back; --port and --host serve directly.'],
10
+ preview: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground'], 'Production output; --detach returns once ready, --foreground keeps this command serving. Reuse intact build, refresh changed assets, preserve owner/state, idle timeout and explicit stop.'],
11
11
  build: [['--site'], 'Production build with private local settings excluded and restored. --site instead writes the static run site (homepage, /run, docs) to its declared folder and prints that folder.'],
12
12
  verify: [[], 'Run the complete internal verification target.'],
13
13
  reset: [['--yes'], 'Confirmed removal of the declared local inventory only.'],
14
- deploy: [['--environment', '--dry-run', '--site', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port'], 'Owner-account deployment and health; dry run performs local validation/build only. --site deploys the run site instead: an assets-only Worker on its custom domain with www redirected; with --dry-run it builds the site and prints the plan with no provider request.'],
14
+ deploy: [['--environment', '--dry-run', '--site', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port', '--remove', '--yes', '--set-secret', '--unset-secret'], 'Owner-account deployment and health; dry run performs local validation/build only. Declared secrets come from the environment or --env-file (an owned 0600 KEY=VALUE file); a new Worker gets the local setup owner and fresh generated secrets when they are not given. After a healthy deploy rer claims the setup owner on the hosted app with the one-time setup token, then removes the token. --set-secret NAME (value from the environment or --env-file) and --unset-secret NAME change one secret of the deployed Worker without a build and record its new version. --remove --yes deletes what this environment\'s deploy record says rer created (Worker, D1, R2, KV, queues), reads back that each is gone, and touches nothing else. --site deploys the run site instead: an assets-only Worker on its custom domain with www redirected; with --dry-run it builds the site and prints the plan with no provider request.'],
15
15
  export: [['--target', '--output', '--origin', '--session-file'], 'Complete supported installation archive with integrity and exclusions.'],
16
16
  import: [['--target', '--input', '--journal', '--origin', '--session-file', '--yes', '--legacy-v1', '--acknowledge-unverified-metadata', '--output'], 'Confirmed atomic restore into an empty destination; --journal LATER_ARCHIVE also fences rows that installation erased since. Backup-only local v1 recovery requires --legacy-v1 --acknowledge-unverified-metadata and a new --output v2 archive; --journal requires normal v2 import.'],
17
17
  outbox: [['--port', '--host', '--owner-email', '--open'], 'Credential-free local outbox URL; optional ordinary sign-in request and explicit browser opening.'],
@@ -0,0 +1,33 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { shared } from './shared.mjs';
3
+ import { invalid } from './errors.mjs';
4
+ import { readDeployEnvFile } from '../modules/deploy-kit/env-file.mjs';
5
+
6
+ // The values a Workers deploy reads for its declared secrets: this process's
7
+ // environment, then the --env-file (which wins), then what rer supplies itself:
8
+ // the local setup owner's email, and a fresh value for each secret the setup
9
+ // manifest generates (a session secret, the one-time setup token). A value only
10
+ // reaches a Worker that lacks that secret; an existing one is kept unchanged.
11
+ export async function deployEnvironment({ root, options, declaration, setupManifest, settings, localOnlyKeys = [] }) {
12
+ const env = { ...process.env }, supplied = [];
13
+ if (options.envFile) {
14
+ let values;
15
+ try { values = await readDeployEnvFile(root, options.envFile, localOnlyKeys); }
16
+ catch (error) { throw invalid(`The --env-file ${options.envFile} could not be used: ${error.code === 'ENOENT' ? 'no such file' : error.message}.`); }
17
+ Object.assign(env, values);
18
+ }
19
+ const secrets = Array.isArray(declaration.secrets) ? declaration.secrets : [];
20
+ const absent = item => item && !env[item.environment]?.trim();
21
+ const emailField = setupManifest?.steps?.find(step => step.type === 'owner-claim')?.emailField;
22
+ const email = secrets.find(item => item?.binding === emailField);
23
+ if (absent(email) && settings) {
24
+ const { readSettings } = await shared('adapter-settings.mjs');
25
+ const owner = (await readSettings(root, settings)).values[emailField];
26
+ if (typeof owner === 'string' && owner.includes('@')) { env[email.environment] = owner; supplied.push(email.binding); }
27
+ }
28
+ for (const field of setupManifest?.fields ?? []) {
29
+ const item = field.kind === 'secret' && field.generate ? secrets.find(entry => entry?.binding === field.name) : undefined;
30
+ if (absent(item)) { env[item.environment] = randomBytes(32).toString(field.generate); supplied.push(item.binding); }
31
+ }
32
+ return { env, supplied };
33
+ }
@@ -0,0 +1,61 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ // After a healthy deploy, the hosted app's first owner is the setup owner: rer
4
+ // claims it with the one-time setup token through the seed's standard owner step
5
+ // (Base operator-auth POST /auth/bootstrap), then removes that token from the
6
+ // Worker and records the new Worker version. A Worker still holding a token rer
7
+ // did not upload this run gets a fresh one first, unless the person supplied the
8
+ // token (environment or --env-file): that value is the deployed token and is used. A seed with its own owner step
9
+ // function, an identity-provider claim, or no declared token secret is left to it.
10
+ // `beforeClaim(token)` runs while the token is live (dependency approvals need it).
11
+ export async function claimHostedOwner({ hook, setupManifest, declaration, deployed, env, supplied = [], secret, addSecret, beforeClaim, fetch = globalThis.fetch, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), boundMs = 90000 }) {
12
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook) || !deployed.origin) return undefined;
13
+ const claim = setupManifest?.steps?.find(step => step.type === 'owner-claim');
14
+ if (claim?.mode === 'idp') return undefined;
15
+ const tokenKey = hook.tokenKey ?? claim?.tokenField ?? 'SETUP_TOKEN';
16
+ const item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === tokenKey);
17
+ if (!item || deployed.retiredSecrets?.includes(tokenKey)) return undefined;
18
+ const given = env[item.environment]?.trim() && !supplied.includes(tokenKey);
19
+ let token = deployed.sentSecrets?.includes(tokenKey) || given ? env[item.environment] : undefined;
20
+ if (!token) {
21
+ token = randomBytes(32).toString('hex'); addSecret(token);
22
+ const { versionId } = await secret(['--set', tokenKey], { ...env, [item.environment]: token });
23
+ // A secret change is a new Worker version; claim once the app answers from it.
24
+ await servingVersion({ origin: deployed.origin, versionId, fetch, sleep, boundMs });
25
+ }
26
+ await beforeClaim?.(token);
27
+ const displayName = (typeof hook.displayName === 'function' ? hook.displayName({}) : undefined) || 'Owner';
28
+ let response, code;
29
+ // Edge locations can briefly answer from the previous version, which lacks this token.
30
+ for (let attempt = 1; ; attempt++) {
31
+ try {
32
+ response = await fetch(new URL(hook.path ?? '/auth/bootstrap', deployed.origin), { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(30000),
33
+ headers: { Origin: deployed.origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, displayName, returnTo: hook.returnTo ?? '/' }) });
34
+ } catch { throw ownerFailure('the hosted application did not answer'); }
35
+ code = 'no code';
36
+ if (response.status !== 303) { try { const body = await response.json(); if (typeof body?.error === 'string' && /^[a-z_]{1,64}$/.test(body.error)) code = body.error; } catch {} }
37
+ else await response.body?.cancel();
38
+ if (response.status !== 403 || code !== 'invalid_setup_token' || attempt === 3) break;
39
+ await sleep(5000);
40
+ }
41
+ const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
42
+ if (!outcome) throw ownerFailure(`HTTP ${response.status} (${code})${code === 'invalid_setup_token' && given ? `; the ${item.environment} you supplied is not the deployed setup token: leave it unset and rer issues a fresh one` : ''}`);
43
+ await secret(['--unset', tokenKey], env);
44
+ return outcome;
45
+ }
46
+
47
+ async function servingVersion({ origin, versionId, fetch, sleep, boundMs }) {
48
+ const deadline = Date.now() + boundMs;
49
+ while (true) {
50
+ try {
51
+ const response = await fetch(new URL('/health', origin), { redirect: 'manual', signal: AbortSignal.timeout(10000), headers: { 'Cache-Control': 'no-cache', Accept: 'application/json' } });
52
+ if ((await response.json().catch(() => null))?.deployment?.versionId === versionId) return;
53
+ } catch {}
54
+ if (Date.now() >= deadline) throw ownerFailure(`the hosted app did not answer from the Worker version holding the new setup token within ${Math.round(boundMs / 1000)} seconds`);
55
+ await sleep(2000);
56
+ }
57
+ }
58
+
59
+ function ownerFailure(cause) {
60
+ const error = new Error('owner-claim-failed'); error.cause = cause; return error;
61
+ }
package/cli/deploy.mjs CHANGED
@@ -1,22 +1,33 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
2
+ import { deployDiagnostic } from '../modules/deploy-kit/diagnostics.mjs';
3
+ import { shared } from './shared.mjs';
3
4
  import { contained } from './project.mjs';
4
- import { missing, invalid, refuse } from './errors.mjs';
5
+ import { missing, invalid, refuse, RerError } from './errors.mjs';
5
6
  import { ownerPlan, ownerWorkerName, localOnlyBinding } from '../modules/deploy-kit/owner.mjs';
6
7
  import { packageBuild } from '../modules/deploy-kit/prepare.mjs';
7
8
  import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
9
+ import { deployEnvironment } from './deploy-inputs.mjs';
10
+ import { claimHostedOwner } from './deploy-owner.mjs';
8
11
 
9
12
  export function ownerDeploy(declarationPath, lifecycle) {
10
13
  // rer verify serves the build with this declaration's vars and secrets.
11
- return Object.assign(async ({ root, options, store, signal, operationToken, localOnlyKeys = [], checkBuild = async () => {} }) => {
14
+ return Object.assign(async ({ root, options, store, signal, operationToken, localOnlyKeys = [], checkBuild = async () => {}, setupManifest, linkDependencies }) => {
12
15
  try { await refuseLocalModels(root); } catch { throw refuse('Deploy refuses the local-only key file .rer/local/models.json. Remove live-dev credentials before deployment.'); }
13
16
  const path = await contained(root, declarationPath);
14
17
  let declaration; try { declaration = JSON.parse(await readFile(path, 'utf8')); } catch { throw invalid('Malformed owner-account deploy declaration.'); }
15
18
  const local = localOnlyBinding(declaration, localOnlyKeys);
16
19
  if (local) throw refuse(`${declarationPath} names the local-only key ${local} as a deploy binding or secret. Remove it; local dev keys never reach a deployment.`);
17
20
  const environment = options.environment ?? 'production';
18
- await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
19
- await checkBuild();
21
+ const again = `rer deploy --environment ${environment}`;
22
+ const change = [options.remove && '--remove', options.setSecret && '--set-secret', options.unsetSecret && '--unset-secret'].filter(Boolean);
23
+ // The command that finishes what this run started: the same verb, never a plain deploy in its place.
24
+ const retry = options.remove ? `${again} --remove --yes` : options.setSecret ? `${again} --set-secret ${options.setSecret}` : options.unsetSecret ? `${again} --unset-secret ${options.unsetSecret}` : again;
25
+ if (change.length > 1 || change.length && options.dryRun) throw invalid(`Use ${change.join(', ')}${options.dryRun ? ' and --dry-run' : ''} on separate runs.`);
26
+ if (options.remove && !options.yes) throw invalid(`${again} --remove deletes this environment's Worker and every D1, R2, KV and queue rer created for it, with their data. Add --yes to confirm.`);
27
+ if (!change.length) {
28
+ await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
29
+ await checkBuild();
30
+ }
20
31
  if (options.dryRun) {
21
32
  // Locally validate the real declaration/artifact without ownerTarget's
22
33
  // account lookup. This synthetic target is validation data, not a provider.
@@ -28,10 +39,85 @@ export function ownerDeploy(declarationPath, lifecycle) {
28
39
  }
29
40
  const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
30
41
  if (names.some(name => !process.env[name]?.trim())) throw missing('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required. See the deploy-kit owner-account instructions.');
42
+ const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: lifecycle.settings, localOnlyKeys });
31
43
  const { main } = await import('../modules/deploy-kit/owner-cli.mjs');
32
- const argv = ['deploy', '--declaration', declarationPath, '--environment', environment];
33
- const result = await main(argv, process.env, { cwd: root, localOnlyKeys, notice: line => store.report(line) });
34
- await main(['health', ...argv.slice(1)], process.env, { cwd: root, localOnlyKeys });
35
- return { deployed: true, origin: result.origin };
44
+ const { createRedaction, credentialValues } = await shared('redaction.mjs');
45
+ const redaction = createRedaction();
46
+ redaction.addSecrets([...credentialValues(env), env.CLOUDFLARE_ACCOUNT_ID, ...(declaration.secrets ?? []).map(item => env[item.environment]).filter(Boolean)]);
47
+ const claim = setupManifest?.steps?.find(item => item.type === 'owner-claim');
48
+ const declared = binding => (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(item => item?.binding === binding);
49
+ const owner = (command, extra = [], values = env, more = {}) => main([command, '--declaration', declarationPath, '--environment', environment, ...extra], values, { cwd: root, localOnlyKeys, ...more });
50
+ // Keep confirmed creation events across deploy and the independent health call.
51
+ const created = [];
52
+ let step = 'deploy', deployed;
53
+ try {
54
+ if (options.remove) {
55
+ step = 'remove';
56
+ const result = await owner('remove');
57
+ store.report(`Removed ${result.removed.join(', ') || 'nothing (already gone)'}; read back: none of them remains.`);
58
+ return { removed: result.removed, deployed: false };
59
+ }
60
+ if (change.length) {
61
+ step = 'secrets';
62
+ const binding = options.setSecret ?? options.unsetSecret;
63
+ await owner('secret', [options.setSecret ? '--set' : '--unset', binding]);
64
+ store.report(`${options.setSecret ? `Set ${binding}` : `Removed ${binding}`} on this environment's Worker and recorded its new version; the next ${again} updates it.`);
65
+ return { binding, present: Boolean(options.setSecret), deployed: false };
66
+ }
67
+ deployed = await owner('deploy', [], env, { notice: line => store.report(line), onCreated: resource => created.push(redaction.redact(resource)) });
68
+ const sent = supplied.filter(name => deployed.sentSecrets.includes(name));
69
+ if (sent.length) store.report(`rer supplied ${sent.join(', ')} from the local setup owner and fresh generated values.`);
70
+ step = 'health';
71
+ await owner('health');
72
+ step = 'claim owner';
73
+ const linked = token => linkDependencies?.(deployed.origin, token);
74
+ const claimed = await claimHostedOwner({ hook: lifecycle.hooks?.owner, setupManifest, declaration, deployed, env, supplied, addSecret: value => redaction.addSecrets([value]),
75
+ secret: (extra, values) => owner('secret', extra, values), beforeClaim: linked });
76
+ // A seed that claims its owner its own way links with the token it was given.
77
+ if (claimed === undefined) await linked(env[declared(claim?.tokenField ?? 'SETUP_TOKEN')?.environment]);
78
+ // The app claims the OWNER_EMAIL the Worker holds: the local one only when this run uploaded it.
79
+ const email = declared(claim?.emailField);
80
+ const named = !email ? '' : deployed.sentSecrets.includes(email.binding) && env[email.environment] ? ` (${env[email.environment]})` : ` (the ${email.binding} already set on the Worker)`;
81
+ if (claimed) store.report(`${claimed === 'claimed' ? 'Claimed the hosted owner' : 'The hosted owner was already claimed and was kept'}${named}; the one-time setup token was used and removed. Sign in by email at ${deployed.origin}.`);
82
+ return { deployed: true, origin: deployed.origin, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
83
+ } catch (error) {
84
+ if (error instanceof RerError) throw error;
85
+ const { commandFailure } = await shared('failures.mjs');
86
+ throw commandFailure(failureText({ error, step, created, again, retry, secretChange: Boolean(change.length && !options.remove), deployed, redact: redaction.redact }));
87
+ }
36
88
  }, { declaration: declarationPath });
37
89
  }
90
+
91
+ // The failing step, its redacted cause, what this run created and the command that continues.
92
+ function failureText({ error, step, created, again, retry, secretChange, deployed, redact }) {
93
+ const diagnostic = deployDiagnostic(error, redact);
94
+ const code = diagnostic?.code ?? (/^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? redact(error.message) : 'deploy-failed');
95
+ const at = step === 'deploy' ? diagnostic?.step ?? 'prepare deploy' : step;
96
+ const input = /^missing-secret-([A-Z][A-Z0-9_]*)$/.exec(code)?.[1];
97
+ if (input) return `Deploy needs ${input}, which the deploy declaration lists as a secret and the Worker does not hold it. Set ${input} in the environment or in an --env-file file (an owned 0600 file of KEY=VALUE lines), then run the same command again. Nothing was uploaded.${created.length ? ` Resources created this attempt: ${created.join(', ')}.` : ''}`;
98
+ if (code === 'remove-incomplete') return `Removal stopped with ${error.remaining.join(', ')} still present${error.failed.length ? ` (${error.failed.map(item => `${item.resource}: ${item.code}`).join('; ')})` : ''}. Removed: ${error.removed.join(', ') || 'nothing'}. An R2 bucket must be empty before it can be deleted. Run ${again} --remove --yes again to finish.`;
99
+ if (code === 'nothing-to-remove') return `This checkout has no rer deployment record for this environment, so --remove deletes nothing. Run it from the checkout that deployed it.`;
100
+ if (code === 'owner-claim-failed') return `The application is deployed at ${deployed?.origin}, but claiming its owner failed: ${redact(String(error.cause))}. The setup token is still on the Worker; run ${again} again to retry the claim.`;
101
+ const resource = diagnostic?.resource ?? (error.resource ? redact(String(error.resource)) : undefined);
102
+ const lines = [`Deploy failed at ${at}: ${code}${resource ? ` (${resource})` : ''}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.`];
103
+ if (diagnostic?.output && diagnostic.output !== '(no provider output)') lines.push(`First redacted output lines:\n${diagnostic.output}`);
104
+ lines.push(`Resources created this attempt: ${created.join(', ') || 'none'}.`);
105
+ lines.push(recovery({ code, at, resource, again, retry, secretChange }));
106
+ return lines.join('\n');
107
+ }
108
+
109
+ function recovery({ code, at, resource, again, retry, secretChange }) {
110
+ const remove = `${again} --remove --yes`, removing = retry === remove;
111
+ if (code === 'owned-resource-replaced' || code === 'resource-name-collision') return `rer refuses to change a resource it did not create or that was replaced after it did; nothing was changed. Choose another --environment name, or remove the other resource yourself.`;
112
+ // A lost create answer: the resource may exist, and rer cannot prove it made it.
113
+ if (resource && (code === 'uncertain-resource-create' || code === 'provider-transport-failed' && at.startsWith('create '))) {
114
+ if (resource.startsWith('worker:')) return `Possibly created: ${resource}. The answer to its first upload was lost and the live Worker does not match the build rer sent, so rer cannot prove it is its own. Deploy the same commit again to recover it, or inspect it in the Cloudflare dashboard: if it is the Worker from the interrupted run and its Durable Object data is disposable, delete it, then run ${retry} again.`;
115
+ return `Possibly created: ${resource}. The provider's answer to its create was lost, so rer cannot prove it made it. Inspect it in the Cloudflare dashboard: if it exists and is the empty one from the interrupted run, delete it, then run ${retry} again.${removing || resource.startsWith('queues:') ? '' : ` Or run ${remove}: it deletes it once it confirms it is empty, together with everything else this environment created.`}`;
116
+ }
117
+ if (code === 'uncertain-resource-not-empty') return `Possibly created: ${resource}, but it holds data, so it may not be the one rer's interrupted run made and rer will not delete it. Inspect it in the Cloudflare dashboard; delete it yourself only if its data is disposable, then run ${retry} again.`;
118
+ if (code === 'uncertain-resource-unverifiable') return `Possibly created: ${resource}. rer cannot read whether a queue holds messages, so it will not delete it. Inspect it in the Cloudflare dashboard: if it is the empty one from the interrupted run, delete it, then run ${retry} again.`;
119
+ if (code === 'changed-or-uncertain-migration-history') return `${resource ?? 'A D1 database'} holds a migration rer started but could not confirm, or one changed since it was applied; rer never retries or guesses a migration. Inspect it in the Cloudflare dashboard: if it is the empty one from this interrupted first deploy, delete it with ${remove} (that deletes everything this environment created), then run ${again} again. If it holds data, resolve the interrupted migration by hand first (deploy-kit README, Migration safety).`;
120
+ if (removing) return `Fix the cause, then run ${retry} again.`;
121
+ if (secretChange) return `Fix the cause, then run ${retry} again; a plain ${again} does not make this change.`;
122
+ return `Fix the cause, then run ${again} again: it continues with what was created. To delete what this environment created instead: ${remove}.`;
123
+ }
@@ -0,0 +1,51 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { open, readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { shared } from './shared.mjs';
8
+ import { ready } from './managed-server.mjs';
9
+ import { RerError } from './errors.mjs';
10
+
11
+ // The actual dispatcher owns the server, registry and shutdown. Only its
12
+ // lifetime changes: its parent follows private JSON events until readiness,
13
+ // then leaves that process serving. A failed/interrupted start stops its child.
14
+ export async function detachedServer(options, argv, out) {
15
+ const {privateDirectory} = await shared('private-files.mjs');
16
+ const directory = await privateDirectory(options.cwd,['.rer','local']);
17
+ const log = join(directory,`${options.verb}-launch-${randomUUID()}.log`), handle = await open(log,'wx',0o600);
18
+ const args = argv.filter(arg => !['--detach','--foreground','--json'].includes(arg));
19
+ let child, exit, spawnError, keep = false;
20
+ const stop = () => child?.kill('SIGTERM');
21
+ for (const signal of ['SIGINT','SIGTERM']) process.once(signal,stop);
22
+ try {
23
+ child = spawn(process.execPath,[fileURLToPath(new URL('../bin/rer.mjs',import.meta.url)),...args,'--foreground','--json'], {cwd:options.cwd, env:process.env, detached:true, stdio:['ignore',handle.fd,handle.fd]});
24
+ child.once('error',error => {spawnError=error;exit=1;});
25
+ child.once('exit',(code,signal) => {exit=code ?? (signal ? 128 : 1);});
26
+ await handle.close();
27
+ let offset=0, pending='', failure;
28
+ for (;;) {
29
+ const bytes = await readFile(log), text = bytes.subarray(offset).toString('utf8'); offset=bytes.length;
30
+ pending += text;
31
+ const lines=pending.split('\n');pending=lines.pop();
32
+ for (const line of lines) {
33
+ let event; try { event=JSON.parse(line); } catch { continue; }
34
+ if (event.event==='error') failure=event;
35
+ if (event.event==='ready') {
36
+ keep=true;child.unref();
37
+ const dev = event.openUrl ? {openUrl:event.openUrl,identity:options.verb === 'dev' && !options.login,signedInAs:event.signedInAs,ownerLeft:options.verb === 'dev' && !options.login && event.signedInAs === null} : undefined;
38
+ ready(out,event.url,dev,options.json,Boolean(event.signInUrl),event.siteUrl);
39
+ return 0;
40
+ }
41
+ if (event.event==='waiting' || event.event==='warning') out.event(event.event,event);
42
+ }
43
+ if (exit!==undefined) throw new RerError(failure?.code ?? 'command_failed',failure?.message ?? `The ${options.verb} server stopped before readiness (${spawnError?.code ?? exit}); inspect ${log}.`,failure?.exitCode ?? (exit || 1));
44
+ await delay(200);
45
+ }
46
+ } finally {
47
+ await handle.close().catch(() => {});
48
+ for (const signal of ['SIGINT','SIGTERM']) process.off(signal,stop);
49
+ if (!keep) stop();
50
+ }
51
+ }
package/cli/door.mjs CHANGED
@@ -36,7 +36,7 @@ export const doorChallengeHeader = 'x-rer-door-challenge';
36
36
  export const doorIdentityPath = '/__rer/door/identity';
37
37
  export const doorDefaults = Object.freeze({ serverLimit: 10, idleSeconds: 43200 });
38
38
 
39
- export const doorDirectory = (env = process.env) => resolve(env.RER_DOOR_DIR ?? join(userInfo().homedir, '.rer/door'));
39
+ export const doorDirectory = (env = process.env) => resolve(env.RER_DOOR_DIR ?? join(env.HOME || userInfo().homedir, '.rer/door'));
40
40
  export const doorOrigin = (name, port) => `http://${name}.localhost:${port}`;
41
41
  export const doorHost = (name, port) => `${name}.localhost:${port}`;
42
42
 
@@ -1,4 +1,4 @@
1
- import { readFile, readdir } from 'node:fs/promises';
1
+ import { readFile, readdir, realpath } from 'node:fs/promises';
2
2
  import { createRequire } from 'node:module';
3
3
  import { createHash, randomBytes } from 'node:crypto';
4
4
  import { join, resolve, extname } from 'node:path';
@@ -114,7 +114,7 @@ export async function checkHostedOutput(root, options) {
114
114
  } finally { await hosted.close(); }
115
115
  }
116
116
 
117
- if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
117
+ if (process.argv[1] && await realpath(new URL(import.meta.url)) === await realpath(process.argv[1])) {
118
118
  try { console.log(JSON.stringify(await checkHostedOutput(resolve(process.argv[2] ?? '.'), { declaration: process.argv[3] }))); }
119
119
  catch (error) { console.error(error.message); process.exitCode = 1; }
120
120
  }
@@ -7,7 +7,7 @@
7
7
  // is replaced, an intro replays, a frame loses a section, recovery is noisy or
8
8
  // uncounted, or a hydrated page's document lacks Cache-Control: no-transform.
9
9
  import { resolve } from 'node:path';
10
- import { pathToFileURL } from 'node:url';
10
+ import { realpath } from 'node:fs/promises';
11
11
  import { RerError } from '../errors.mjs';
12
12
  import { hostedServer, launchChromium, originServer, pool, runLoad } from './browser.mjs';
13
13
  import { cloudflareRewrite, describeCase, planCases, selectPages } from './cases.mjs';
@@ -90,7 +90,7 @@ async function declaredPages(root) {
90
90
  catch { return {}; }
91
91
  }
92
92
 
93
- if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
93
+ if (process.argv[1] && await realpath(new URL(import.meta.url)) === await realpath(process.argv[1])) {
94
94
  const args = process.argv.slice(2), flag = name => { const at = args.indexOf(name); return at < 0 ? undefined : args.splice(at, 2)[1]; };
95
95
  const origin = flag('--origin'), pages = flag('--pages')?.split(',').filter(Boolean), details = args.includes('--details');
96
96
  const root = resolve(args.find(arg => !arg.startsWith('--')) ?? '.'), declared = await declaredPages(root);